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

View File

@ -15,13 +15,13 @@ import { z } from 'zod';
import { isFieldCondition } from '../base/sql-builder';
@Injectable()
export class StaffRowService extends RowCacheService {
constructor(
private readonly departmentService: DepartmentService,
) {
constructor(private readonly departmentService: DepartmentService) {
super(ObjectType.STAFF, false);
}
createUnGroupingRowSelect(request?: RowModelRequest): string[] {
const result = super.createUnGroupingRowSelect(request).concat([
const result = super
.createUnGroupingRowSelect(request)
.concat([
`${this.tableName}.id AS id`,
`${this.tableName}.username AS username`,
`${this.tableName}.showname AS showname`,
@ -33,7 +33,7 @@ export class StaffRowService extends RowCacheService {
'dept.name AS dept_name',
'domain.name AS domain_name',
]);
return result
return result;
}
createJoinSql(request?: RowModelRequest): string[] {
return [
@ -94,17 +94,13 @@ export class StaffRowService extends RowCacheService {
const deptId = data?.deptId;
const isFromSameDept = staff.deptIds?.includes(deptId);
const domainChildDeptIds = await this.departmentService.getDescendantIds(
staff.domainId, true
);
const belongsToDomain = domainChildDeptIds.includes(
deptId,
staff.domainId,
true,
);
const belongsToDomain = domainChildDeptIds.includes(deptId);
return { isFromSameDept, belongsToDomain };
}
protected async setResPermissions(
data: Staff,
staff: UserProfile,
) {
protected async setResPermissions(data: Staff, staff: UserProfile) {
const permissions: ResPerm = {};
const { isFromSameDept, belongsToDomain } = await this.getPermissionContext(
data.id,
@ -131,5 +127,4 @@ export class StaffRowService extends RowCacheService {
});
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 { Form, Input, Button, Select } from "antd";
import { motion } from "framer-motion";
@ -23,48 +24,52 @@ export const RegisterForm = ({ onSubmit, isLoading }: RegisterFormProps) => {
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
exit={{ opacity: 0 }}>
<Form
form={form}
layout="vertical"
onFinish={onSubmit}
scrollToFirstError
>
<Form.Item
name="deptId"
label="部门"
rules={[
{ required: true, message: "请选择部门" }
]}
>
<DepartmentSelect></DepartmentSelect>
scrollToFirstError>
<div className=" flex items-center gap-4 mb-2">
<div>
<Form.Item name="photoUrl" label="头像" noStyle>
<AvatarUploader
placeholder="点击上传头像"
style={{
height: 120,
width: 100,
}}></AvatarUploader>
</Form.Item>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
</div>
<div className="grid grid-cols-1 gap-2 flex-1">
<Form.Item
name="username"
label="用户名"
noStyle
rules={[
{ required: true, message: "请输入用户名" },
{ min: 2, message: "用户名至少需要2个字符" }
]}
>
{ min: 2, message: "用户名至少需要2个字符" },
]}>
<Input placeholder="用户名" />
</Form.Item>
<Form.Item
name="showname"
label="姓名"
noStyle
rules={[
{ required: true, message: "请输入姓名" },
{ min: 2, message: "姓名至少需要2个字符" }
]}
>
{ min: 2, message: "姓名至少需要2个字符" },
]}>
<Input placeholder="姓名" />
</Form.Item>
<Form.Item
name="deptId"
noStyle
label="部门"
rules={[{ required: true, message: "请选择部门" }]}>
<DepartmentSelect></DepartmentSelect>
</Form.Item>
</div>
</div>
<Form.Item
@ -74,10 +79,9 @@ export const RegisterForm = ({ onSubmit, isLoading }: RegisterFormProps) => {
{ required: true, message: "请输入证件号" },
{
pattern: /^\d{5,12}$/,
message: "请输入有效的证件号5-12位数字"
}
]}
>
message: "请输入有效的证件号5-12位数字",
},
]}>
<Input placeholder="证件号" />
</Form.Item>
@ -88,30 +92,34 @@ export const RegisterForm = ({ onSubmit, isLoading }: RegisterFormProps) => {
{ required: true, message: "请输入密码" },
{ min: 8, message: "密码至少需要8个字符" },
{
pattern: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/,
message: "密码必须包含大小写字母、数字和特殊字符"
}
]}
>
pattern:
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/,
message: "密码必须包含大小写字母、数字和特殊字符",
},
]}>
<Input.Password placeholder="密码" />
</Form.Item>
<Form.Item
name="repeatPass"
label="确认密码"
dependencies={['password']}
dependencies={["password"]}
rules={[
{ required: true, message: "请确认密码" },
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue('password') === value) {
if (
!value ||
getFieldValue("password") === value
) {
return Promise.resolve();
}
return Promise.reject(new Error('两次输入的密码不一致'));
return Promise.reject(
new Error("两次输入的密码不一致")
);
},
}),
]}
>
]}>
<Input.Password placeholder="确认密码" />
</Form.Item>
@ -120,8 +128,7 @@ export const RegisterForm = ({ onSubmit, isLoading }: RegisterFormProps) => {
type="primary"
htmlType="submit"
loading={isLoading}
className="w-full h-10 rounded-lg"
>
className="w-full h-10 rounded-lg">
{isLoading ? "正在注册..." : "注册"}
</Button>
</Form.Item>

View File

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

View File

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

View File

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