feat(auth): add role/permission management (backend + frontend)
- RoleService: CRUD, assign_permissions, get_role_permissions - PermissionService: list all tenant permissions - Role handlers: 8 endpoints with RBAC permission checks - Frontend Roles page: table, create/edit modal, permission assignment - Frontend Roles API: full CRUD + permission operations - Routes registered in AuthModule protected_routes
This commit is contained in:
@@ -5,6 +5,7 @@ import zhCN from 'antd/locale/zh_CN';
|
||||
import MainLayout from './layouts/MainLayout';
|
||||
import Login from './pages/Login';
|
||||
import Home from './pages/Home';
|
||||
import Roles from './pages/Roles';
|
||||
import { useAuthStore } from './stores/auth';
|
||||
import { useAppStore } from './stores/app';
|
||||
|
||||
@@ -40,7 +41,7 @@ export default function App() {
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/users" element={<div>用户管理(开发中)</div>} />
|
||||
<Route path="/roles" element={<div>权限管理(开发中)</div>} />
|
||||
<Route path="/roles" element={<Roles />} />
|
||||
<Route path="/settings" element={<div>系统设置(开发中)</div>} />
|
||||
</Routes>
|
||||
</MainLayout>
|
||||
|
||||
73
apps/web/src/api/roles.ts
Normal file
73
apps/web/src/api/roles.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import client from './client';
|
||||
import type { PaginatedResponse } from './users';
|
||||
|
||||
export interface RoleInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
description?: string;
|
||||
is_system: boolean;
|
||||
}
|
||||
|
||||
export interface PermissionInfo {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
resource: string;
|
||||
action: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface CreateRoleRequest {
|
||||
name: string;
|
||||
code: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface UpdateRoleRequest {
|
||||
name?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export async function listRoles(page = 1, pageSize = 20) {
|
||||
const { data } = await client.get<{ success: boolean; data: PaginatedResponse<RoleInfo> }>(
|
||||
'/roles',
|
||||
{ params: { page, page_size: pageSize } },
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function getRole(id: string) {
|
||||
const { data } = await client.get<{ success: boolean; data: RoleInfo }>(`/roles/${id}`);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function createRole(req: CreateRoleRequest) {
|
||||
const { data } = await client.post<{ success: boolean; data: RoleInfo }>('/roles', req);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function updateRole(id: string, req: UpdateRoleRequest) {
|
||||
const { data } = await client.put<{ success: boolean; data: RoleInfo }>(`/roles/${id}`, req);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function deleteRole(id: string) {
|
||||
await client.delete(`/roles/${id}`);
|
||||
}
|
||||
|
||||
export async function assignPermissions(roleId: string, permissionIds: string[]) {
|
||||
await client.post(`/roles/${roleId}/permissions`, { permission_ids: permissionIds });
|
||||
}
|
||||
|
||||
export async function getRolePermissions(roleId: string) {
|
||||
const { data } = await client.get<{ success: boolean; data: PermissionInfo[] }>(
|
||||
`/roles/${roleId}/permissions`,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function listPermissions() {
|
||||
const { data } = await client.get<{ success: boolean; data: PermissionInfo[] }>('/permissions');
|
||||
return data.data;
|
||||
}
|
||||
272
apps/web/src/pages/Roles.tsx
Normal file
272
apps/web/src/pages/Roles.tsx
Normal file
@@ -0,0 +1,272 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
Space,
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
Tag,
|
||||
Popconfirm,
|
||||
Checkbox,
|
||||
message,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
listRoles,
|
||||
createRole,
|
||||
updateRole,
|
||||
deleteRole,
|
||||
assignPermissions,
|
||||
getRolePermissions,
|
||||
listPermissions,
|
||||
type RoleInfo,
|
||||
type PermissionInfo,
|
||||
} from '../api/roles';
|
||||
|
||||
export default function Roles() {
|
||||
const [roles, setRoles] = useState<RoleInfo[]>([]);
|
||||
const [permissions, setPermissions] = useState<PermissionInfo[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const [editRole, setEditRole] = useState<RoleInfo | null>(null);
|
||||
const [permModalOpen, setPermModalOpen] = useState(false);
|
||||
const [selectedRole, setSelectedRole] = useState<RoleInfo | null>(null);
|
||||
const [selectedPermIds, setSelectedPermIds] = useState<string[]>([]);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const fetchRoles = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await listRoles();
|
||||
setRoles(result.data);
|
||||
} catch {
|
||||
message.error('加载角色失败');
|
||||
}
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
const fetchPermissions = useCallback(async () => {
|
||||
try {
|
||||
setPermissions(await listPermissions());
|
||||
} catch {
|
||||
// Permissions may not be seeded yet; silently ignore
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchRoles();
|
||||
fetchPermissions();
|
||||
}, [fetchRoles, fetchPermissions]);
|
||||
|
||||
const handleCreate = async (values: {
|
||||
name: string;
|
||||
code: string;
|
||||
description?: string;
|
||||
}) => {
|
||||
try {
|
||||
if (editRole) {
|
||||
await updateRole(editRole.id, values);
|
||||
message.success('角色更新成功');
|
||||
} else {
|
||||
await createRole(values);
|
||||
message.success('角色创建成功');
|
||||
}
|
||||
setCreateModalOpen(false);
|
||||
setEditRole(null);
|
||||
form.resetFields();
|
||||
fetchRoles();
|
||||
} catch (err: unknown) {
|
||||
const errorMsg =
|
||||
(err as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message || '操作失败';
|
||||
message.error(errorMsg);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deleteRole(id);
|
||||
message.success('角色已删除');
|
||||
fetchRoles();
|
||||
} catch {
|
||||
message.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const openPermModal = async (role: RoleInfo) => {
|
||||
setSelectedRole(role);
|
||||
try {
|
||||
const rolePerms = await getRolePermissions(role.id);
|
||||
setSelectedPermIds(rolePerms.map((p) => p.id));
|
||||
} catch {
|
||||
setSelectedPermIds([]);
|
||||
}
|
||||
setPermModalOpen(true);
|
||||
};
|
||||
|
||||
const savePermissions = async () => {
|
||||
if (!selectedRole) return;
|
||||
try {
|
||||
await assignPermissions(selectedRole.id, selectedPermIds);
|
||||
message.success('权限分配成功');
|
||||
setPermModalOpen(false);
|
||||
} catch {
|
||||
message.error('权限分配失败');
|
||||
}
|
||||
};
|
||||
|
||||
const openEditModal = (role: RoleInfo) => {
|
||||
setEditRole(role);
|
||||
form.setFieldsValue({
|
||||
name: role.name,
|
||||
code: role.code,
|
||||
description: role.description,
|
||||
});
|
||||
setCreateModalOpen(true);
|
||||
};
|
||||
|
||||
const openCreateModal = () => {
|
||||
setEditRole(null);
|
||||
form.resetFields();
|
||||
setCreateModalOpen(true);
|
||||
};
|
||||
|
||||
const closeCreateModal = () => {
|
||||
setCreateModalOpen(false);
|
||||
setEditRole(null);
|
||||
form.resetFields();
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: '名称', dataIndex: 'name', key: 'name' },
|
||||
{ title: '编码', dataIndex: 'code', key: 'code' },
|
||||
{
|
||||
title: '描述',
|
||||
dataIndex: 'description',
|
||||
key: 'description',
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'is_system',
|
||||
key: 'is_system',
|
||||
render: (v: boolean) =>
|
||||
v ? <Tag color="blue">系统</Tag> : <Tag>自定义</Tag>,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
render: (_: unknown, record: RoleInfo) => (
|
||||
<Space>
|
||||
<Button size="small" onClick={() => openPermModal(record)}>
|
||||
权限
|
||||
</Button>
|
||||
{!record.is_system && (
|
||||
<>
|
||||
<Button size="small" onClick={() => openEditModal(record)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="确定删除此角色?"
|
||||
onConfirm={() => handleDelete(record.id)}
|
||||
>
|
||||
<Button size="small" danger>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
// Group permissions by resource for better UX
|
||||
const groupedPermissions = permissions.reduce<Record<string, PermissionInfo[]>>(
|
||||
(acc, p) => {
|
||||
if (!acc[p.resource]) acc[p.resource] = [];
|
||||
acc[p.resource].push(p);
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
角色管理
|
||||
</Typography.Title>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreateModal}>
|
||||
新建角色
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={roles}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{ pageSize: 20 }}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={editRole ? '编辑角色' : '新建角色'}
|
||||
open={createModalOpen}
|
||||
onCancel={closeCreateModal}
|
||||
onOk={() => form.submit()}
|
||||
>
|
||||
<Form form={form} onFinish={handleCreate} layout="vertical">
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="名称"
|
||||
rules={[{ required: true, message: '请输入角色名称' }]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="code"
|
||||
label="编码"
|
||||
rules={[{ required: true, message: '请输入角色编码' }]}
|
||||
>
|
||||
<Input disabled={!!editRole} />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="描述">
|
||||
<Input.TextArea rows={3} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={`权限分配 - ${selectedRole?.name || ''}`}
|
||||
open={permModalOpen}
|
||||
onCancel={() => setPermModalOpen(false)}
|
||||
onOk={savePermissions}
|
||||
width={600}
|
||||
>
|
||||
{Object.entries(groupedPermissions).map(([resource, perms]) => (
|
||||
<div key={resource} style={{ marginBottom: 16 }}>
|
||||
<Typography.Text strong style={{ textTransform: 'capitalize' }}>
|
||||
{resource}
|
||||
</Typography.Text>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Checkbox.Group
|
||||
value={selectedPermIds}
|
||||
onChange={(values) => setSelectedPermIds(values as string[])}
|
||||
options={perms.map((p) => ({ label: p.name, value: p.id }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
pub mod auth_handler;
|
||||
pub mod role_handler;
|
||||
pub mod user_handler;
|
||||
|
||||
217
crates/erp-auth/src/handler/role_handler.rs
Normal file
217
crates/erp-auth/src/handler/role_handler.rs
Normal file
@@ -0,0 +1,217 @@
|
||||
use axum::Extension;
|
||||
use axum::extract::{FromRef, Path, Query, State};
|
||||
use axum::response::Json;
|
||||
use validator::Validate;
|
||||
|
||||
use erp_core::error::AppError;
|
||||
use erp_core::types::{ApiResponse, PaginatedResponse, Pagination, TenantContext};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth_state::AuthState;
|
||||
use crate::dto::{AssignPermissionsReq, CreateRoleReq, PermissionResp, RoleResp, UpdateRoleReq};
|
||||
use crate::middleware::rbac::require_permission;
|
||||
use crate::service::permission_service::PermissionService;
|
||||
use crate::service::role_service::RoleService;
|
||||
|
||||
/// GET /api/v1/roles
|
||||
///
|
||||
/// List roles within the current tenant with pagination.
|
||||
/// Requires the `role.list` permission.
|
||||
pub async fn list_roles<S>(
|
||||
State(state): State<AuthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Query(pagination): Query<Pagination>,
|
||||
) -> Result<Json<ApiResponse<PaginatedResponse<RoleResp>>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "role.list")?;
|
||||
|
||||
let (roles, total) = RoleService::list(ctx.tenant_id, &pagination, &state.db).await?;
|
||||
|
||||
let page = pagination.page.unwrap_or(1);
|
||||
let page_size = pagination.limit();
|
||||
let total_pages = (total + page_size - 1) / page_size;
|
||||
|
||||
Ok(Json(ApiResponse::ok(PaginatedResponse {
|
||||
data: roles,
|
||||
total,
|
||||
page,
|
||||
page_size,
|
||||
total_pages,
|
||||
})))
|
||||
}
|
||||
|
||||
/// POST /api/v1/roles
|
||||
///
|
||||
/// Create a new role within the current tenant.
|
||||
/// Requires the `role.create` permission.
|
||||
pub async fn create_role<S>(
|
||||
State(state): State<AuthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Json(req): Json<CreateRoleReq>,
|
||||
) -> Result<Json<ApiResponse<RoleResp>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "role.create")?;
|
||||
|
||||
req.validate()
|
||||
.map_err(|e| AppError::Validation(e.to_string()))?;
|
||||
|
||||
let role = RoleService::create(
|
||||
ctx.tenant_id,
|
||||
ctx.user_id,
|
||||
&req.name,
|
||||
&req.code,
|
||||
&req.description,
|
||||
&state.db,
|
||||
&state.event_bus,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(role)))
|
||||
}
|
||||
|
||||
/// GET /api/v1/roles/:id
|
||||
///
|
||||
/// Fetch a single role by ID within the current tenant.
|
||||
/// Requires the `role.read` permission.
|
||||
pub async fn get_role<S>(
|
||||
State(state): State<AuthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<RoleResp>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "role.read")?;
|
||||
|
||||
let role = RoleService::get_by_id(id, ctx.tenant_id, &state.db).await?;
|
||||
Ok(Json(ApiResponse::ok(role)))
|
||||
}
|
||||
|
||||
/// PUT /api/v1/roles/:id
|
||||
///
|
||||
/// Update editable role fields (name, description).
|
||||
/// Requires the `role.update` permission.
|
||||
pub async fn update_role<S>(
|
||||
State(state): State<AuthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(req): Json<UpdateRoleReq>,
|
||||
) -> Result<Json<ApiResponse<RoleResp>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "role.update")?;
|
||||
|
||||
let role = RoleService::update(
|
||||
id,
|
||||
ctx.tenant_id,
|
||||
ctx.user_id,
|
||||
&req.name,
|
||||
&req.description,
|
||||
&state.db,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(role)))
|
||||
}
|
||||
|
||||
/// DELETE /api/v1/roles/:id
|
||||
///
|
||||
/// Soft-delete a role by ID within the current tenant.
|
||||
/// System roles cannot be deleted.
|
||||
/// Requires the `role.delete` permission.
|
||||
pub async fn delete_role<S>(
|
||||
State(state): State<AuthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "role.delete")?;
|
||||
|
||||
RoleService::delete(id, ctx.tenant_id, ctx.user_id, &state.db, &state.event_bus).await?;
|
||||
|
||||
Ok(Json(ApiResponse {
|
||||
success: true,
|
||||
data: None,
|
||||
message: Some("角色已删除".to_string()),
|
||||
}))
|
||||
}
|
||||
|
||||
/// POST /api/v1/roles/:id/permissions
|
||||
///
|
||||
/// Replace all permission assignments for a role.
|
||||
/// Requires the `role.update` permission.
|
||||
pub async fn assign_permissions<S>(
|
||||
State(state): State<AuthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(req): Json<AssignPermissionsReq>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "role.update")?;
|
||||
|
||||
RoleService::assign_permissions(
|
||||
id,
|
||||
ctx.tenant_id,
|
||||
ctx.user_id,
|
||||
&req.permission_ids,
|
||||
&state.db,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse {
|
||||
success: true,
|
||||
data: None,
|
||||
message: Some("权限分配成功".to_string()),
|
||||
}))
|
||||
}
|
||||
|
||||
/// GET /api/v1/roles/:id/permissions
|
||||
///
|
||||
/// Fetch all permissions assigned to a role.
|
||||
/// Requires the `role.read` permission.
|
||||
pub async fn get_role_permissions<S>(
|
||||
State(state): State<AuthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<Vec<PermissionResp>>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "role.read")?;
|
||||
|
||||
let perms = RoleService::get_role_permissions(id, ctx.tenant_id, &state.db).await?;
|
||||
Ok(Json(ApiResponse::ok(perms)))
|
||||
}
|
||||
|
||||
/// GET /api/v1/permissions
|
||||
///
|
||||
/// List all permissions within the current tenant.
|
||||
/// Requires the `permission.list` permission.
|
||||
pub async fn list_permissions<S>(
|
||||
State(state): State<AuthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
) -> Result<Json<ApiResponse<Vec<PermissionResp>>>, AppError>
|
||||
where
|
||||
AuthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "permission.list")?;
|
||||
|
||||
let perms = PermissionService::list(ctx.tenant_id, &state.db).await?;
|
||||
Ok(Json(ApiResponse::ok(perms)))
|
||||
}
|
||||
@@ -5,7 +5,7 @@ use erp_core::error::AppResult;
|
||||
use erp_core::events::EventBus;
|
||||
use erp_core::module::ErpModule;
|
||||
|
||||
use crate::handler::{auth_handler, user_handler};
|
||||
use crate::handler::{auth_handler, role_handler, user_handler};
|
||||
|
||||
/// Auth module implementing the `ErpModule` trait.
|
||||
///
|
||||
@@ -53,6 +53,25 @@ impl AuthModule {
|
||||
.put(user_handler::update_user)
|
||||
.delete(user_handler::delete_user),
|
||||
)
|
||||
.route(
|
||||
"/roles",
|
||||
axum::routing::get(role_handler::list_roles).post(role_handler::create_role),
|
||||
)
|
||||
.route(
|
||||
"/roles/{id}",
|
||||
axum::routing::get(role_handler::get_role)
|
||||
.put(role_handler::update_role)
|
||||
.delete(role_handler::delete_role),
|
||||
)
|
||||
.route(
|
||||
"/roles/{id}/permissions",
|
||||
axum::routing::get(role_handler::get_role_permissions)
|
||||
.post(role_handler::assign_permissions),
|
||||
)
|
||||
.route(
|
||||
"/permissions",
|
||||
axum::routing::get(role_handler::list_permissions),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
pub mod auth_service;
|
||||
pub mod password;
|
||||
pub mod permission_service;
|
||||
pub mod role_service;
|
||||
pub mod seed;
|
||||
pub mod token_service;
|
||||
pub mod user_service;
|
||||
|
||||
38
crates/erp-auth/src/service/permission_service.rs
Normal file
38
crates/erp-auth/src/service/permission_service.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::dto::PermissionResp;
|
||||
use crate::entity::permission;
|
||||
use crate::error::AuthResult;
|
||||
|
||||
/// Permission read-only service — list permissions within a tenant.
|
||||
///
|
||||
/// Permissions are seeded by the system and not typically created via API.
|
||||
pub struct PermissionService;
|
||||
|
||||
impl PermissionService {
|
||||
/// List all active permissions within a tenant.
|
||||
pub async fn list(
|
||||
tenant_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> AuthResult<Vec<PermissionResp>> {
|
||||
let perms = permission::Entity::find()
|
||||
.filter(permission::Column::TenantId.eq(tenant_id))
|
||||
.filter(permission::Column::DeletedAt.is_null())
|
||||
.all(db)
|
||||
.await
|
||||
.map_err(|e| crate::error::AuthError::Validation(e.to_string()))?;
|
||||
|
||||
Ok(perms
|
||||
.iter()
|
||||
.map(|p| PermissionResp {
|
||||
id: p.id,
|
||||
code: p.code.clone(),
|
||||
name: p.name.clone(),
|
||||
resource: p.resource.clone(),
|
||||
action: p.action.clone(),
|
||||
description: p.description.clone(),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
321
crates/erp-auth/src/service/role_service.rs
Normal file
321
crates/erp-auth/src/service/role_service.rs
Normal file
@@ -0,0 +1,321 @@
|
||||
use chrono::Utc;
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, Set,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::dto::{PermissionResp, RoleResp};
|
||||
use crate::entity::{permission, role, role_permission};
|
||||
use crate::error::AuthError;
|
||||
use crate::error::AuthResult;
|
||||
use erp_core::events::EventBus;
|
||||
use erp_core::types::Pagination;
|
||||
|
||||
/// Role CRUD service — create, read, update, soft-delete roles within a tenant,
|
||||
/// and manage role-permission assignments.
|
||||
pub struct RoleService;
|
||||
|
||||
impl RoleService {
|
||||
/// List roles within a tenant with pagination.
|
||||
///
|
||||
/// Returns `(roles, total_count)`.
|
||||
pub async fn list(
|
||||
tenant_id: Uuid,
|
||||
pagination: &Pagination,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> AuthResult<(Vec<RoleResp>, u64)> {
|
||||
let paginator = role::Entity::find()
|
||||
.filter(role::Column::TenantId.eq(tenant_id))
|
||||
.filter(role::Column::DeletedAt.is_null())
|
||||
.paginate(db, pagination.limit());
|
||||
|
||||
let total = paginator
|
||||
.num_items()
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
let page_index = pagination.page.unwrap_or(1).saturating_sub(1) as u64;
|
||||
let models = paginator
|
||||
.fetch_page(page_index)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
let resps: Vec<RoleResp> = models
|
||||
.iter()
|
||||
.map(|m| RoleResp {
|
||||
id: m.id,
|
||||
name: m.name.clone(),
|
||||
code: m.code.clone(),
|
||||
description: m.description.clone(),
|
||||
is_system: m.is_system,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok((resps, total))
|
||||
}
|
||||
|
||||
/// Fetch a single role by ID, scoped to the given tenant.
|
||||
pub async fn get_by_id(
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> AuthResult<RoleResp> {
|
||||
let model = role::Entity::find_by_id(id)
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||
.filter(|r| r.tenant_id == tenant_id && r.deleted_at.is_none())
|
||||
.ok_or_else(|| AuthError::Validation("角色不存在".to_string()))?;
|
||||
|
||||
Ok(RoleResp {
|
||||
id: model.id,
|
||||
name: model.name.clone(),
|
||||
code: model.code.clone(),
|
||||
description: model.description.clone(),
|
||||
is_system: model.is_system,
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a new role within the current tenant.
|
||||
///
|
||||
/// Validates code uniqueness, then inserts the record and publishes
|
||||
/// a `role.created` domain event.
|
||||
pub async fn create(
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
name: &str,
|
||||
code: &str,
|
||||
description: &Option<String>,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
event_bus: &EventBus,
|
||||
) -> AuthResult<RoleResp> {
|
||||
// Check code uniqueness within tenant
|
||||
let existing = role::Entity::find()
|
||||
.filter(role::Column::TenantId.eq(tenant_id))
|
||||
.filter(role::Column::Code.eq(code))
|
||||
.filter(role::Column::DeletedAt.is_null())
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
if existing.is_some() {
|
||||
return Err(AuthError::Validation("角色编码已存在".to_string()));
|
||||
}
|
||||
|
||||
let now = Utc::now();
|
||||
let id = Uuid::now_v7();
|
||||
let model = role::ActiveModel {
|
||||
id: Set(id),
|
||||
tenant_id: Set(tenant_id),
|
||||
name: Set(name.to_string()),
|
||||
code: Set(code.to_string()),
|
||||
description: Set(description.clone()),
|
||||
is_system: Set(false),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
created_by: Set(operator_id),
|
||||
updated_by: Set(operator_id),
|
||||
deleted_at: Set(None),
|
||||
version: Set(1),
|
||||
};
|
||||
model
|
||||
.insert(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
event_bus.publish(erp_core::events::DomainEvent::new(
|
||||
"role.created",
|
||||
tenant_id,
|
||||
serde_json::json!({ "role_id": id, "code": code }),
|
||||
));
|
||||
|
||||
Ok(RoleResp {
|
||||
id,
|
||||
name: name.to_string(),
|
||||
code: code.to_string(),
|
||||
description: description.clone(),
|
||||
is_system: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Update editable role fields (name and description).
|
||||
///
|
||||
/// Code and is_system cannot be changed after creation.
|
||||
pub async fn update(
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
name: &Option<String>,
|
||||
description: &Option<String>,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> AuthResult<RoleResp> {
|
||||
let model = role::Entity::find_by_id(id)
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||
.filter(|r| r.tenant_id == tenant_id && r.deleted_at.is_none())
|
||||
.ok_or_else(|| AuthError::Validation("角色不存在".to_string()))?;
|
||||
|
||||
let mut active: role::ActiveModel = model.into();
|
||||
|
||||
if let Some(name) = name {
|
||||
active.name = Set(name.clone());
|
||||
}
|
||||
if let Some(desc) = description {
|
||||
active.description = Set(Some(desc.clone()));
|
||||
}
|
||||
|
||||
active.updated_at = Set(Utc::now());
|
||||
active.updated_by = Set(operator_id);
|
||||
|
||||
let updated = active
|
||||
.update(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
Ok(RoleResp {
|
||||
id: updated.id,
|
||||
name: updated.name.clone(),
|
||||
code: updated.code.clone(),
|
||||
description: updated.description.clone(),
|
||||
is_system: updated.is_system,
|
||||
})
|
||||
}
|
||||
|
||||
/// Soft-delete a role by setting the `deleted_at` timestamp.
|
||||
///
|
||||
/// System roles cannot be deleted.
|
||||
pub async fn delete(
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
event_bus: &EventBus,
|
||||
) -> AuthResult<()> {
|
||||
let model = role::Entity::find_by_id(id)
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||
.filter(|r| r.tenant_id == tenant_id && r.deleted_at.is_none())
|
||||
.ok_or_else(|| AuthError::Validation("角色不存在".to_string()))?;
|
||||
|
||||
if model.is_system {
|
||||
return Err(AuthError::Validation("系统角色不可删除".to_string()));
|
||||
}
|
||||
|
||||
let mut active: role::ActiveModel = model.into();
|
||||
active.deleted_at = Set(Some(Utc::now()));
|
||||
active.updated_at = Set(Utc::now());
|
||||
active.updated_by = Set(operator_id);
|
||||
active
|
||||
.update(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
event_bus.publish(erp_core::events::DomainEvent::new(
|
||||
"role.deleted",
|
||||
tenant_id,
|
||||
serde_json::json!({ "role_id": id }),
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Replace all permission assignments for a role.
|
||||
///
|
||||
/// Soft-deletes existing assignments and creates new ones.
|
||||
pub async fn assign_permissions(
|
||||
role_id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
permission_ids: &[Uuid],
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> AuthResult<()> {
|
||||
// Verify the role exists and belongs to this tenant
|
||||
let _role = role::Entity::find_by_id(role_id)
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||
.filter(|r| r.tenant_id == tenant_id && r.deleted_at.is_none())
|
||||
.ok_or_else(|| AuthError::Validation("角色不存在".to_string()))?;
|
||||
|
||||
// Soft-delete existing role_permission rows
|
||||
let existing = role_permission::Entity::find()
|
||||
.filter(role_permission::Column::RoleId.eq(role_id))
|
||||
.filter(role_permission::Column::TenantId.eq(tenant_id))
|
||||
.filter(role_permission::Column::DeletedAt.is_null())
|
||||
.all(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
let now = Utc::now();
|
||||
for rp in existing {
|
||||
let mut active: role_permission::ActiveModel = rp.into();
|
||||
active.deleted_at = Set(Some(now));
|
||||
active.updated_at = Set(now);
|
||||
active.updated_by = Set(operator_id);
|
||||
active
|
||||
.update(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
}
|
||||
|
||||
// Insert new role_permission rows
|
||||
for perm_id in permission_ids {
|
||||
let rp = role_permission::ActiveModel {
|
||||
role_id: Set(role_id),
|
||||
permission_id: Set(*perm_id),
|
||||
tenant_id: Set(tenant_id),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
created_by: Set(operator_id),
|
||||
updated_by: Set(operator_id),
|
||||
deleted_at: Set(None),
|
||||
version: Set(1),
|
||||
};
|
||||
rp.insert(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fetch all permissions assigned to a role.
|
||||
///
|
||||
/// Resolves through the role_permission join table.
|
||||
pub async fn get_role_permissions(
|
||||
role_id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> AuthResult<Vec<PermissionResp>> {
|
||||
let rp_rows = role_permission::Entity::find()
|
||||
.filter(role_permission::Column::RoleId.eq(role_id))
|
||||
.filter(role_permission::Column::TenantId.eq(tenant_id))
|
||||
.filter(role_permission::Column::DeletedAt.is_null())
|
||||
.all(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
let perm_ids: Vec<Uuid> = rp_rows.iter().map(|rp| rp.permission_id).collect();
|
||||
if perm_ids.is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
let perms = permission::Entity::find()
|
||||
.filter(permission::Column::Id.is_in(perm_ids))
|
||||
.filter(permission::Column::TenantId.eq(tenant_id))
|
||||
.all(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
|
||||
Ok(perms
|
||||
.iter()
|
||||
.map(|p| PermissionResp {
|
||||
id: p.id,
|
||||
code: p.code.clone(),
|
||||
name: p.name.clone(),
|
||||
resource: p.resource.clone(),
|
||||
action: p.action.clone(),
|
||||
description: p.description.clone(),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user