This commit is contained in:
longdayi 2025-01-26 11:37:57 +08:00
commit 99d39ffe5b
7 changed files with 456 additions and 403 deletions

View File

@ -4,14 +4,9 @@ import {
BadRequestException, BadRequestException,
Logger, Logger,
InternalServerErrorException, InternalServerErrorException,
} from '@nestjs/common'; } from '@nestjs/common';
import { StaffService } from '../models/staff/staff.service'; import { StaffService } from '../models/staff/staff.service';
import { import { db, AuthSchema, JwtPayload } from '@nice/common';
db,
AuthSchema,
JwtPayload,
} from '@nice/common';
import * as argon2 from 'argon2'; import * as argon2 from 'argon2';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { redis } from '@server/utils/redis/redis.service'; import { redis } from '@server/utils/redis/redis.service';
@ -24,14 +19,12 @@ import { TusService } from '@server/upload/tus.service';
import { extractFileIdFromNginxUrl } from '@server/upload/utils'; import { extractFileIdFromNginxUrl } from '@server/upload/utils';
@Injectable() @Injectable()
export class AuthService { export class AuthService {
private logger = new Logger(AuthService.name) private logger = new Logger(AuthService.name);
constructor( constructor(
private readonly staffService: StaffService, private readonly staffService: StaffService,
private readonly jwtService: JwtService, private readonly jwtService: JwtService,
private readonly sessionService: SessionService private readonly sessionService: SessionService,
) { ) {}
}
async validateFileRequest(params: FileRequest): Promise<FileAuthResult> { async validateFileRequest(params: FileRequest): Promise<FileAuthResult> {
try { try {
// 基础参数验证 // 基础参数验证
@ -39,27 +32,32 @@ export class AuthService {
return { isValid: false, error: FileValidationErrorType.INVALID_URI }; return { isValid: false, error: FileValidationErrorType.INVALID_URI };
} }
const fileId = extractFileIdFromNginxUrl(params.originalUri); const fileId = extractFileIdFromNginxUrl(params.originalUri);
console.log(params.originalUri, fileId) console.log(params.originalUri, fileId);
const resource = await db.resource.findFirst({ where: { fileId } }); const resource = await db.resource.findFirst({ where: { fileId } });
// 资源验证 // 资源验证
if (!resource) { if (!resource) {
return { isValid: false, error: FileValidationErrorType.RESOURCE_NOT_FOUND }; return {
isValid: false,
error: FileValidationErrorType.RESOURCE_NOT_FOUND,
};
} }
// 处理公开资源 // 处理公开资源
if (resource.isPublic) { if (resource.isPublic) {
return { return {
isValid: true, isValid: true,
resourceType: resource.type || 'unknown' resourceType: resource.type || 'unknown',
}; };
} }
// 处理私有资源 // 处理私有资源
const token = extractTokenFromAuthorization(params.authorization); const token = extractTokenFromAuthorization(params.authorization);
if (!token) { if (!token) {
return { isValid: false, error: FileValidationErrorType.AUTHORIZATION_REQUIRED }; return {
isValid: false,
error: FileValidationErrorType.AUTHORIZATION_REQUIRED,
};
} }
const payload: JwtPayload = await this.jwtService.verify(token) const payload: JwtPayload = await this.jwtService.verify(token);
if (!payload.sub) { if (!payload.sub) {
return { isValid: false, error: FileValidationErrorType.INVALID_TOKEN }; return { isValid: false, error: FileValidationErrorType.INVALID_TOKEN };
} }
@ -67,9 +65,8 @@ export class AuthService {
return { return {
isValid: true, isValid: true,
userId: payload.sub, userId: payload.sub,
resourceType: resource.type || 'unknown' resourceType: resource.type || 'unknown',
}; };
} catch (error) { } catch (error) {
this.logger.error('File validation error:', error); this.logger.error('File validation error:', error);
return { isValid: false, error: FileValidationErrorType.UNKNOWN_ERROR }; return { isValid: false, error: FileValidationErrorType.UNKNOWN_ERROR };
@ -93,7 +90,9 @@ export class AuthService {
return { accessToken, refreshToken }; return { accessToken, refreshToken };
} }
async signIn(data: z.infer<typeof AuthSchema.signInRequset>): Promise<SessionInfo> { async signIn(
data: z.infer<typeof AuthSchema.signInRequset>,
): Promise<SessionInfo> {
const { username, password, phoneNumber } = data; const { username, password, phoneNumber } = data;
let staff = await db.staff.findFirst({ let staff = await db.staff.findFirst({
@ -113,7 +112,8 @@ export class AuthService {
if (!staff.enabled) { if (!staff.enabled) {
throw new UnauthorizedException('帐号已禁用'); throw new UnauthorizedException('帐号已禁用');
} }
const isPasswordMatch = phoneNumber || await argon2.verify(staff.password, password); const isPasswordMatch =
phoneNumber || (await argon2.verify(staff.password, password));
if (!isPasswordMatch) { if (!isPasswordMatch) {
throw new UnauthorizedException('帐号或密码错误'); throw new UnauthorizedException('帐号或密码错误');
} }
@ -138,12 +138,20 @@ export class AuthService {
} }
} }
async signUp(data: z.infer<typeof AuthSchema.signUpRequest>) { async signUp(data: z.infer<typeof AuthSchema.signUpRequest>) {
const { username, phoneNumber, officerId } = data; const {
username,
phoneNumber,
password,
officerId,
showname,
photoUrl,
deptId,
...others
} = data;
const existingUser = await db.staff.findFirst({ const existingUser = await db.staff.findFirst({
where: { where: {
OR: [{ username }, { officerId }, { phoneNumber }], OR: [{ username }, { officerId }, { phoneNumber }],
deletedAt: null deletedAt: null,
}, },
}); });
@ -153,9 +161,22 @@ export class AuthService {
return this.staffService.create({ return this.staffService.create({
data: { data: {
...data, username,
domainId: data.deptId, phoneNumber,
} password,
officerId,
showname,
department: {
connect: { id: deptId },
},
domain: {
connect: { id: deptId },
},
// domainId: data.deptId,
meta: {
photoUrl,
},
},
}); });
} }
async refreshToken(data: z.infer<typeof AuthSchema.refreshTokenRequest>) { async refreshToken(data: z.infer<typeof AuthSchema.refreshTokenRequest>) {
@ -168,12 +189,17 @@ export class AuthService {
throw new UnauthorizedException('用户会话已过期'); throw new UnauthorizedException('用户会话已过期');
} }
const session = await this.sessionService.getSession(payload.sub, sessionId); const session = await this.sessionService.getSession(
payload.sub,
sessionId,
);
if (!session || session.refresh_token !== refreshToken) { if (!session || session.refresh_token !== refreshToken) {
throw new UnauthorizedException('用户会话已过期'); throw new UnauthorizedException('用户会话已过期');
} }
const user = await db.staff.findUnique({ where: { id: payload.sub, deletedAt: null } }); const user = await db.staff.findUnique({
where: { id: payload.sub, deletedAt: null },
});
if (!user) { if (!user) {
throw new UnauthorizedException('用户不存在'); throw new UnauthorizedException('用户不存在');
} }
@ -186,14 +212,17 @@ export class AuthService {
const updatedSession = { const updatedSession = {
...session, ...session,
access_token: accessToken, access_token: accessToken,
access_token_expires_at: Date.now() + tokenConfig.accessToken.expirationMs, access_token_expires_at:
Date.now() + tokenConfig.accessToken.expirationMs,
}; };
await this.sessionService.saveSession( await this.sessionService.saveSession(
payload.sub, payload.sub,
updatedSession, updatedSession,
tokenConfig.accessToken.expirationTTL, tokenConfig.accessToken.expirationTTL,
); );
await redis.del(UserProfileService.instance.getProfileCacheKey(payload.sub)); await redis.del(
UserProfileService.instance.getProfileCacheKey(payload.sub),
);
return { return {
access_token: accessToken, access_token: accessToken,
access_token_expires_at: updatedSession.access_token_expires_at, access_token_expires_at: updatedSession.access_token_expires_at,
@ -212,7 +241,7 @@ export class AuthService {
where: { id: user?.id }, where: { id: user?.id },
data: { data: {
password: newPassword, password: newPassword,
} },
}); });
return { message: '密码已修改' }; return { message: '密码已修改' };
@ -232,5 +261,4 @@ export class AuthService {
return { message: '注销成功' }; return { message: '注销成功' };
} }
} }

View File

@ -15,13 +15,13 @@ import { z } from 'zod';
import { isFieldCondition } from '../base/sql-builder'; import { isFieldCondition } from '../base/sql-builder';
@Injectable() @Injectable()
export class StaffRowService extends RowCacheService { export class StaffRowService extends RowCacheService {
constructor( constructor(private readonly departmentService: DepartmentService) {
private readonly departmentService: DepartmentService,
) {
super(ObjectType.STAFF, false); super(ObjectType.STAFF, false);
} }
createUnGroupingRowSelect(request?: RowModelRequest): string[] { createUnGroupingRowSelect(request?: RowModelRequest): string[] {
const result = super.createUnGroupingRowSelect(request).concat([ const result = super
.createUnGroupingRowSelect(request)
.concat([
`${this.tableName}.id AS id`, `${this.tableName}.id AS id`,
`${this.tableName}.username AS username`, `${this.tableName}.username AS username`,
`${this.tableName}.showname AS showname`, `${this.tableName}.showname AS showname`,
@ -33,7 +33,7 @@ export class StaffRowService extends RowCacheService {
'dept.name AS dept_name', 'dept.name AS dept_name',
'domain.name AS domain_name', 'domain.name AS domain_name',
]); ]);
return result return result;
} }
createJoinSql(request?: RowModelRequest): string[] { createJoinSql(request?: RowModelRequest): string[] {
return [ return [
@ -94,17 +94,13 @@ export class StaffRowService extends RowCacheService {
const deptId = data?.deptId; const deptId = data?.deptId;
const isFromSameDept = staff.deptIds?.includes(deptId); const isFromSameDept = staff.deptIds?.includes(deptId);
const domainChildDeptIds = await this.departmentService.getDescendantIds( const domainChildDeptIds = await this.departmentService.getDescendantIds(
staff.domainId, true staff.domainId,
); true,
const belongsToDomain = domainChildDeptIds.includes(
deptId,
); );
const belongsToDomain = domainChildDeptIds.includes(deptId);
return { isFromSameDept, belongsToDomain }; return { isFromSameDept, belongsToDomain };
} }
protected async setResPermissions( protected async setResPermissions(data: Staff, staff: UserProfile) {
data: Staff,
staff: UserProfile,
) {
const permissions: ResPerm = {}; const permissions: ResPerm = {};
const { isFromSameDept, belongsToDomain } = await this.getPermissionContext( const { isFromSameDept, belongsToDomain } = await this.getPermissionContext(
data.id, data.id,
@ -131,5 +127,4 @@ export class StaffRowService extends RowCacheService {
}); });
return { ...data, perm: permissions }; return { ...data, perm: permissions };
} }
} }

View File

@ -1,3 +1,4 @@
import AvatarUploader from "@web/src/components/common/uploader/AvatarUploader";
import DepartmentSelect from "@web/src/components/models/department/department-select"; import DepartmentSelect from "@web/src/components/models/department/department-select";
import { Form, Input, Button, Select } from "antd"; import { Form, Input, Button, Select } from "antd";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
@ -23,48 +24,52 @@ export const RegisterForm = ({ onSubmit, isLoading }: RegisterFormProps) => {
<motion.div <motion.div
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}>
>
<Form <Form
form={form} form={form}
layout="vertical" layout="vertical"
onFinish={onSubmit} onFinish={onSubmit}
scrollToFirstError scrollToFirstError>
> <div className=" flex items-center gap-4 mb-2">
<Form.Item <div>
name="deptId" <Form.Item name="photoUrl" label="头像" noStyle>
label="部门" <AvatarUploader
rules={[ placeholder="点击上传头像"
{ required: true, message: "请选择部门" } style={{
]} height: 120,
> width: 100,
<DepartmentSelect></DepartmentSelect> }}></AvatarUploader>
</Form.Item> </Form.Item>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 gap-2 flex-1">
<Form.Item <Form.Item
name="username" name="username"
label="用户名" label="用户名"
noStyle
rules={[ rules={[
{ required: true, message: "请输入用户名" }, { required: true, message: "请输入用户名" },
{ min: 2, message: "用户名至少需要2个字符" } { min: 2, message: "用户名至少需要2个字符" },
]} ]}>
>
<Input placeholder="用户名" /> <Input placeholder="用户名" />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
name="showname" name="showname"
label="姓名" label="姓名"
noStyle
rules={[ rules={[
{ required: true, message: "请输入姓名" }, { required: true, message: "请输入姓名" },
{ min: 2, message: "姓名至少需要2个字符" } { min: 2, message: "姓名至少需要2个字符" },
]} ]}>
>
<Input placeholder="姓名" /> <Input placeholder="姓名" />
</Form.Item> </Form.Item>
<Form.Item
name="deptId"
noStyle
label="部门"
rules={[{ required: true, message: "请选择部门" }]}>
<DepartmentSelect></DepartmentSelect>
</Form.Item>
</div>
</div> </div>
<Form.Item <Form.Item
@ -74,10 +79,9 @@ export const RegisterForm = ({ onSubmit, isLoading }: RegisterFormProps) => {
{ required: true, message: "请输入证件号" }, { required: true, message: "请输入证件号" },
{ {
pattern: /^\d{5,12}$/, pattern: /^\d{5,12}$/,
message: "请输入有效的证件号5-12位数字" message: "请输入有效的证件号5-12位数字",
} },
]} ]}>
>
<Input placeholder="证件号" /> <Input placeholder="证件号" />
</Form.Item> </Form.Item>
@ -88,30 +92,34 @@ export const RegisterForm = ({ onSubmit, isLoading }: RegisterFormProps) => {
{ required: true, message: "请输入密码" }, { required: true, message: "请输入密码" },
{ min: 8, message: "密码至少需要8个字符" }, { min: 8, message: "密码至少需要8个字符" },
{ {
pattern: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/, pattern:
message: "密码必须包含大小写字母、数字和特殊字符" /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/,
} message: "密码必须包含大小写字母、数字和特殊字符",
]} },
> ]}>
<Input.Password placeholder="密码" /> <Input.Password placeholder="密码" />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
name="repeatPass" name="repeatPass"
label="确认密码" label="确认密码"
dependencies={['password']} dependencies={["password"]}
rules={[ rules={[
{ required: true, message: "请确认密码" }, { required: true, message: "请确认密码" },
({ getFieldValue }) => ({ ({ getFieldValue }) => ({
validator(_, value) { validator(_, value) {
if (!value || getFieldValue('password') === value) { if (
!value ||
getFieldValue("password") === value
) {
return Promise.resolve(); return Promise.resolve();
} }
return Promise.reject(new Error('两次输入的密码不一致')); return Promise.reject(
new Error("两次输入的密码不一致")
);
}, },
}), }),
]} ]}>
>
<Input.Password placeholder="确认密码" /> <Input.Password placeholder="确认密码" />
</Form.Item> </Form.Item>
@ -120,8 +128,7 @@ export const RegisterForm = ({ onSubmit, isLoading }: RegisterFormProps) => {
type="primary" type="primary"
htmlType="submit" htmlType="submit"
loading={isLoading} loading={isLoading}
className="w-full h-10 rounded-lg" className="w-full h-10 rounded-lg">
>
{isLoading ? "正在注册..." : "注册"} {isLoading ? "正在注册..." : "注册"}
</Button> </Button>
</Form.Item> </Form.Item>

View File

@ -1,7 +1,7 @@
import { useClickOutside } from "@web/src/hooks/useClickOutside"; import { useClickOutside } from "@web/src/hooks/useClickOutside";
import { useAuth } from "@web/src/providers/auth-provider"; import { useAuth } from "@web/src/providers/auth-provider";
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
import { useState, useRef, useCallback, useMemo } from "react"; import React,{ useState, useRef, useCallback, useMemo, createContext } from "react";
import { Avatar } from "../../common/element/Avatar"; import { Avatar } from "../../common/element/Avatar";
import { import {
UserOutlined, UserOutlined,
@ -9,9 +9,10 @@ import {
QuestionCircleOutlined, QuestionCircleOutlined,
LogoutOutlined, LogoutOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import { Spin } from "antd"; import { FormInstance, Spin } from "antd";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { MenuItemType } from "./types"; import { MenuItemType } from "./types";
import { RolePerms } from "@nice/common";
const menuVariants = { const menuVariants = {
hidden: { opacity: 0, scale: 0.95, y: -10 }, hidden: { opacity: 0, scale: 0.95, y: -10 },
visible: { visible: {
@ -34,25 +35,52 @@ const menuVariants = {
}, },
}; };
export const UserEditorContext = createContext<{
domainId: string;
modalOpen: boolean;
setDomainId: React.Dispatch<React.SetStateAction<string>>;
setModalOpen: React.Dispatch<React.SetStateAction<boolean>>;
editId: string;
setEditId: React.Dispatch<React.SetStateAction<string>>;
form: FormInstance<any>;
formLoading: boolean;
setFormLoading: React.Dispatch<React.SetStateAction<boolean>>;
canManageAnyStaff: boolean;
}>({
domainId: undefined,
modalOpen: false,
setDomainId: undefined,
setModalOpen: undefined,
editId: undefined,
setEditId: undefined,
form: undefined,
formLoading: undefined,
setFormLoading: undefined,
canManageAnyStaff: false,
});
export function UserMenu() { export function UserMenu() {
const [showMenu, setShowMenu] = useState(false); const [showMenu, setShowMenu] = useState(false);
const menuRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null);
const { user, logout, isLoading } = useAuth(); const { user, logout, isLoading, hasSomePermissions } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
useClickOutside(menuRef, () => setShowMenu(false)); useClickOutside(menuRef, () => setShowMenu(false));
const [modalOpen, setModalOpen] = useState<boolean>(false);
const toggleMenu = useCallback(() => { const toggleMenu = useCallback(() => {
setShowMenu((prev) => !prev); setShowMenu((prev) => !prev);
}, []); }, []);
const canManageAnyStaff = useMemo(() => {
return hasSomePermissions(RolePerms.MANAGE_ANY_STAFF);
}, [user]);
const menuItems: MenuItemType[] = useMemo( const menuItems: MenuItemType[] = useMemo(
() => [ () =>
[
{ {
icon: <UserOutlined className="text-lg" />, icon: <UserOutlined className="text-lg" />,
label: "个人信息", label: "个人信息",
action: () => {}, action: () => {},
}, },
{ canManageAnyStaff && {
icon: <SettingOutlined className="text-lg" />, icon: <SettingOutlined className="text-lg" />,
label: "设置", label: "设置",
action: () => { action: () => {
@ -69,7 +97,7 @@ export function UserMenu() {
label: "注销", label: "注销",
action: () => logout(), action: () => logout(),
}, },
], ].filter(Boolean),
[logout] [logout]
); );
@ -87,6 +115,7 @@ export function UserMenu() {
} }
return ( return (
<div ref={menuRef} className="relative"> <div ref={menuRef} className="relative">
<motion.button <motion.button
aria-label="用户菜单" aria-label="用户菜单"
@ -209,5 +238,6 @@ export function UserMenu() {
)} )}
</AnimatePresence> </AnimatePresence>
</div> </div>
); );
} }

View File

@ -124,11 +124,7 @@ export default function StaffForm() {
onFinish={handleFinish}> onFinish={handleFinish}>
<div className=" flex items-center gap-4 mb-2"> <div className=" flex items-center gap-4 mb-2">
<div> <div>
<Form.Item <Form.Item name={"photoUrl"} label="头像" noStyle>
name={"photoUrl"}
label="头像"
noStyle
rules={[{ required: true }]}>
<AvatarUploader <AvatarUploader
style={{ style={{
width: "100px", width: "100px",

View File

@ -8,6 +8,7 @@ export const AuthSchema = {
officerId: z.string().nullish(), officerId: z.string().nullish(),
showname: z.string().nullish(), showname: z.string().nullish(),
phoneNumber: z.string().nullish(), phoneNumber: z.string().nullish(),
photoUrl: z.string().nullish(),
}), }),
refreshTokenRequest: z.object({ refreshTokenRequest: z.object({
refreshToken: z.string(), refreshToken: z.string(),
@ -66,7 +67,7 @@ export const RowRequestSchema = z.object({
groupKeys: z.array(z.any()), groupKeys: z.array(z.any()),
filterModel: z.any().nullish(), filterModel: z.any().nullish(),
sortModel: z.array(SortModel).nullish(), sortModel: z.array(SortModel).nullish(),
includeDeleted: z.boolean().nullish() includeDeleted: z.boolean().nullish(),
}); });
export const StaffMethodSchema = { export const StaffMethodSchema = {
create: z.object({ create: z.object({
@ -170,7 +171,6 @@ export const DepartmentMethodSchema = {
}; };
export const TransformMethodSchema = { export const TransformMethodSchema = {
importStaffs: z.object({ importStaffs: z.object({
base64: z.string(), base64: z.string(),
domainId: z.string().nullish(), domainId: z.string().nullish(),
@ -186,7 +186,6 @@ export const TransformMethodSchema = {
domainId: z.string().nullish(), domainId: z.string().nullish(),
parentId: z.string().nullish(), parentId: z.string().nullish(),
}), }),
}; };
export const TermMethodSchema = { export const TermMethodSchema = {
getRows: RowRequestSchema.extend({ getRows: RowRequestSchema.extend({
@ -390,5 +389,3 @@ export const BaseCursorSchema = z.object({
createEndDate: z.date().nullish(), createEndDate: z.date().nullish(),
deptId: z.string().nullish(), deptId: z.string().nullish(),
}); });