This commit is contained in:
longdayi 2024-09-09 18:48:07 +08:00
parent baa376d436
commit b8c73030d8
34 changed files with 1056 additions and 194 deletions

3
.gitignore vendored
View File

@ -230,3 +230,6 @@ $RECYCLE.BIN/
*~
docker-compose.yml
.env
packages/common/prisma/migrations
volumes

View File

@ -26,6 +26,7 @@
"@nestjs/core": "^10.0.0",
"@nestjs/jwt": "^10.2.0",
"@nestjs/platform-express": "^10.0.0",
"@nestjs/platform-socket.io": "^10.4.1",
"@nestjs/schedule": "^4.1.0",
"@nestjs/websockets": "^10.3.10",
"@nicestack/common": "workspace:^",

View File

@ -8,9 +8,10 @@ import { QueueModule } from './queue/queue.module';
import { TransformModule } from './transform/transform.module';
import { AuthModule } from './auth/auth.module';
import { ScheduleModule } from '@nestjs/schedule';
import { ConfigService } from '@nestjs/config';
@Module({
imports: [ScheduleModule.forRoot(), TrpcModule, RedisModule, QueueModule, TransformModule, AuthModule],
providers: [RedisService, SocketGateway],
providers: [RedisService, SocketGateway, ConfigService],
})
export class AppModule { }

View File

@ -0,0 +1,41 @@
import { Controller, Post, Body, UseGuards, Get, Req } from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthSchema, JwtPayload } from '@nicestack/common';
import { z } from 'zod';
import { AuthGuard } from './auth.guard';
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) { }
@Get("user-profile")
async getUserProfile(@Req() request: Request) {
const user: JwtPayload = (request as any).user
return this.authService.getUserProfile(user)
}
@Post('login')
async login(@Body() body: z.infer<typeof AuthSchema.signInRequset>) {
return this.authService.signIn(body);
}
@Post('signup')
async signup(@Body() body: z.infer<typeof AuthSchema.signUpRequest>) {
return this.authService.signUp(body);
}
@UseGuards(AuthGuard) // Protecting the refreshToken endpoint with AuthGuard
@Post('refresh-token')
async refreshToken(@Body() body: z.infer<typeof AuthSchema.refreshTokenRequest>) {
return this.authService.refreshToken(body);
}
@UseGuards(AuthGuard) // Protecting the logout endpoint with AuthGuard
@Post('logout')
async logout(@Body() body: z.infer<typeof AuthSchema.logoutRequest>) {
return this.authService.logout(body);
}
@UseGuards(AuthGuard) // Protecting the changePassword endpoint with AuthGuard
@Post('change-password')
async changePassword(@Body() body: z.infer<typeof AuthSchema.changePassword>) {
return this.authService.changePassword(body);
}
}

View File

@ -7,6 +7,7 @@ import {
import { JwtService } from '@nestjs/jwt';
import { env } from '@server/env';
import { Request } from 'express';
import { JwtPayload } from '@nicestack/common';
@Injectable()
export class AuthGuard implements CanActivate {
@ -18,7 +19,7 @@ export class AuthGuard implements CanActivate {
throw new UnauthorizedException();
}
try {
const payload = await this.jwtService.verifyAsync(
const payload: JwtPayload = await this.jwtService.verifyAsync(
token,
{
secret: env.JWT_SECRET

View File

@ -2,13 +2,18 @@ import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { JwtModule } from '@nestjs/jwt';
import { env } from '@server/env';
import { AuthController } from './auth.controller';
import { StaffService } from '@server/models/staff/staff.service';
import { RoleMapService } from '@server/rbac/rolemap.service';
import { DepartmentService } from '@server/models/department/department.service';
@Module({
providers: [AuthService],
providers: [AuthService, StaffService, RoleMapService,DepartmentService],
imports: [JwtModule.register({
global: true,
secret: env.JWT_SECRET,
signOptions: { expiresIn: '60s' },
}),]
}),],
controllers: [AuthController]
})
export class AuthModule { }

View File

@ -1,25 +0,0 @@
import { Injectable } from '@nestjs/common';
import { TrpcService } from '@server/trpc/trpc.service';
import { AuthService } from './auth.service';
import { AuthSchema } from '@nicestack/common';
@Injectable()
export class AuthRouter {
constructor(private readonly trpc: TrpcService, private readonly authService: AuthService) { }
router = this.trpc.router({
login: this.trpc.procedure.input(AuthSchema.signInRequset).mutation(({ input }) => {
return this.authService.signIn(input);
}),
signup: this.trpc.procedure.input(AuthSchema.signUpRequest).mutation(({ input }) => {
return this.authService.signUp(input);
}),
refreshToken: this.trpc.procedure.input(AuthSchema.refreshTokenRequest).mutation(({ input }) => {
return this.authService.refreshToken(input);
}),
logout: this.trpc.protectProcedure.input(AuthSchema.logoutRequest).mutation(({ input }) => {
return this.authService.logout(input);
}),
changePassword: this.trpc.protectProcedure.input(AuthSchema.changePassword).mutation(({ input }) => {
return this.authService.changePassword(input);
}),
});
}

View File

@ -1,120 +1,157 @@
import {
Injectable,
UnauthorizedException,
BadRequestException,
NotFoundException,
Injectable,
UnauthorizedException,
BadRequestException,
NotFoundException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt';
import { AuthSchema, db, z } from '@nicestack/common';
import { StaffService } from '@server/models/staff/staff.service';
import { JwtPayload } from '@nicestack/common';
import { RoleMapService } from '@server/rbac/rolemap.service';
@Injectable()
export class AuthService {
constructor(
private readonly jwtService: JwtService,
private readonly staffService: StaffService,
) { }
async signIn(data: z.infer<typeof AuthSchema.signInRequset>) {
const { username, password } = data;
const staff = await db.staff.findUnique({ where: { username } });
if (!staff) {
throw new UnauthorizedException('Invalid username or password');
}
const isPasswordMatch = await bcrypt.compare(password, staff.password);
constructor(
private readonly jwtService: JwtService,
private readonly staffService: StaffService,
private readonly roleMapService: RoleMapService
) { }
if (!isPasswordMatch) {
throw new UnauthorizedException('Invalid username or password');
}
const payload = { sub: staff.id, username: staff.username };
const accessToken = await this.jwtService.signAsync(payload, { expiresIn: '1h' });
const refreshToken = await this.generateRefreshToken(staff.id);
// Store the refresh token in the database
await db.refreshToken.create({
data: {
staffId: staff.id,
token: refreshToken,
},
});
return {
access_token: accessToken,
refresh_token: refreshToken,
};
async signIn(data: z.infer<typeof AuthSchema.signInRequset>) {
const { username, password } = data;
const staff = await db.staff.findUnique({ where: { username } });
if (!staff) {
throw new UnauthorizedException('Invalid username or password');
}
async generateRefreshToken(userId: string): Promise<string> {
const payload = { sub: userId };
return this.jwtService.signAsync(payload, { expiresIn: '7d' }); // Set an appropriate expiration
const isPasswordMatch = await bcrypt.compare(password, staff.password);
if (!isPasswordMatch) {
throw new UnauthorizedException('Invalid username or password');
}
async refreshToken(data: z.infer<typeof AuthSchema.refreshTokenRequest>) {
const { refreshToken } = data;
let payload;
try {
payload = this.jwtService.verify(refreshToken);
} catch (error) {
throw new UnauthorizedException('Invalid refresh token');
}
const payload: JwtPayload = { sub: staff.id, username: staff.username };
const accessToken = await this.jwtService.signAsync(payload, { expiresIn: '1h' });
const refreshToken = await this.generateRefreshToken(staff.id);
const storedToken = await db.refreshToken.findUnique({ where: { token: refreshToken } });
// Calculate expiration dates
const accessTokenExpiresAt = new Date();
accessTokenExpiresAt.setHours(accessTokenExpiresAt.getHours() + 1);
if (!storedToken) {
throw new UnauthorizedException('Invalid refresh token');
}
const refreshTokenExpiresAt = new Date();
refreshTokenExpiresAt.setDate(refreshTokenExpiresAt.getDate() + 7);
const user = await db.staff.findUnique({ where: { id: payload.sub } });
if (!user) {
throw new UnauthorizedException('Invalid refresh token');
}
// Store the refresh token in the database
await db.refreshToken.create({
data: {
staffId: staff.id,
token: refreshToken,
},
});
const newAccessToken = await this.jwtService.signAsync(
{ sub: user.id, username: user.username },
{ expiresIn: '1h' },
);
return {
access_token: accessToken,
access_token_expires_at: accessTokenExpiresAt,
refresh_token: refreshToken,
refresh_token_expires_at: refreshTokenExpiresAt,
};
}
return {
access_token: newAccessToken,
};
async generateRefreshToken(userId: string): Promise<string> {
const payload = { sub: userId };
return this.jwtService.signAsync(payload, { expiresIn: '7d' }); // Set an appropriate expiration
}
async refreshToken(data: z.infer<typeof AuthSchema.refreshTokenRequest>) {
const { refreshToken } = data;
let payload: JwtPayload;
try {
payload = this.jwtService.verify(refreshToken);
} catch (error) {
throw new UnauthorizedException('Invalid refresh token');
}
async signUp(data: z.infer<typeof AuthSchema.signUpRequest>) {
const { username, password } = data;
const existingUser = await db.staff.findUnique({ where: { username } });
if (existingUser) {
throw new BadRequestException('Username is already taken');
}
const hashedPassword = await bcrypt.hash(password, 10);
const staff = await this.staffService.create({
username,
password: hashedPassword,
});
return staff
const storedToken = await db.refreshToken.findUnique({ where: { token: refreshToken } });
if (!storedToken) {
throw new UnauthorizedException('Invalid refresh token');
}
async logout(data: z.infer<typeof AuthSchema.logoutRequest>) {
const { refreshToken } = data;
await db.refreshToken.deleteMany({ where: { token: refreshToken } });
return { message: 'Logout successful' };
const user = await db.staff.findUnique({ where: { id: payload.sub } });
if (!user) {
throw new UnauthorizedException('Invalid refresh token');
}
async changePassword(data: z.infer<typeof AuthSchema.changePassword>) {
const { oldPassword, newPassword, username } = data;
const user = await db.staff.findUnique({ where: { username } });
if (!user) {
throw new NotFoundException('User not found');
}
const isPasswordMatch = await bcrypt.compare(oldPassword, user.password);
if (!isPasswordMatch) {
throw new UnauthorizedException('Old password is incorrect');
}
const hashedNewPassword = await bcrypt.hash(newPassword, 10);
await this.staffService.update({ id: user.id, password: hashedNewPassword });
const newAccessToken = await this.jwtService.signAsync(
{ sub: user.id, username: user.username },
{ expiresIn: '1h' },
);
return { message: 'Password successfully changed' };
// Calculate new expiration date
const accessTokenExpiresAt = new Date();
accessTokenExpiresAt.setHours(accessTokenExpiresAt.getHours() + 1);
return {
access_token: newAccessToken,
access_token_expires_at: accessTokenExpiresAt,
};
}
async signUp(data: z.infer<typeof AuthSchema.signUpRequest>) {
const { username, password } = data;
const existingUser = await db.staff.findUnique({ where: { username } });
if (existingUser) {
throw new BadRequestException('Username is already taken');
}
const hashedPassword = await bcrypt.hash(password, 10);
const staff = await this.staffService.create({
username,
password: hashedPassword,
});
return staff;
}
async logout(data: z.infer<typeof AuthSchema.logoutRequest>) {
const { refreshToken } = data;
await db.refreshToken.deleteMany({ where: { token: refreshToken } });
return { message: 'Logout successful' };
}
async changePassword(data: z.infer<typeof AuthSchema.changePassword>) {
const { oldPassword, newPassword, username } = data;
const user = await db.staff.findUnique({ where: { username } });
if (!user) {
throw new NotFoundException('User not found');
}
const isPasswordMatch = await bcrypt.compare(oldPassword, user.password);
if (!isPasswordMatch) {
throw new UnauthorizedException('Old password is incorrect');
}
const hashedNewPassword = await bcrypt.hash(newPassword, 10);
await this.staffService.update({ id: user.id, password: hashedNewPassword });
return { message: 'Password successfully changed' };
}
async getUserProfile(data: JwtPayload) {
const { sub } = data
const staff = await db.staff.findUnique({
where: { id: sub }, include: {
department: true,
domain: true
}
})
const staffPerms = await this.roleMapService.getPermsForObject({
domainId: staff.domainId,
staffId: staff.id,
deptId: staff.deptId,
});
return { ...staff, permissions: staffPerms }
}
}

View File

@ -1,4 +1,11 @@
import { Module } from '@nestjs/common';
import { StaffRouter } from './staff.router';
import { StaffService } from './staff.service';
import { TrpcService } from '@server/trpc/trpc.service';
import { DepartmentService } from '../department/department.service';
@Module({})
export class StaffModule {}
@Module({
providers: [StaffRouter, StaffService, TrpcService, DepartmentService],
exports: [StaffRouter, StaffService]
})
export class StaffModule { }

View File

@ -8,7 +8,7 @@ export class StaffRouter {
constructor(
private readonly trpc: TrpcService,
private readonly staffService: StaffService,
) {}
) { }
router = this.trpc.router({
create: this.trpc.procedure
@ -43,11 +43,6 @@ export class StaffRouter {
.input(StaffSchema.findMany) // Assuming StaffSchema.findMany is the Zod schema for finding staffs by keyword
.query(async ({ input }) => {
return await this.staffService.findMany(input);
}),
findUnique: this.trpc.procedure
.input(StaffSchema.findUnique)
.query(async ({ input }) => {
return await this.staffService.findUnique(input);
}),
})
});
}

View File

@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { RoleMapService } from './rolemap.service';
import { RoleRouter } from './role.router';
import { TrpcService } from '@server/trpc/trpc.service';
import { RoleService } from './role.service';
import { RoleMapRouter } from './rolemap.router';
import { DepartmentModule } from '@server/models/department/department.module';
@Module({
imports: [DepartmentModule],
providers: [RoleMapService, RoleRouter, TrpcService, RoleService, RoleMapRouter],
exports: [RoleRouter, RoleService, RoleMapService, RoleMapRouter]
})
export class RoleMapModule { }

View File

@ -0,0 +1,37 @@
import { Injectable } from "@nestjs/common";
import { TrpcService } from "@server/trpc/trpc.service";
import { RoleService } from "./role.service";
import { z, RoleSchema } from "@nicestack/common";
@Injectable()
export class RoleRouter {
constructor(
private readonly trpc: TrpcService,
private readonly roleService: RoleService
) { }
router = this.trpc.router({
create: this.trpc.protectProcedure.input(RoleSchema.create).mutation(async ({ ctx, input }) => {
const { staff } = ctx;
return await this.roleService.create(input);
}),
batchDelete: this.trpc.protectProcedure.input(RoleSchema.batchDelete).mutation(async ({ input }) => {
return await this.roleService.batchDelete(input);
}),
update: this.trpc.protectProcedure.input(RoleSchema.update).mutation(async ({ ctx, input }) => {
const { staff } = ctx;
return await this.roleService.update(input);
}),
paginate: this.trpc.protectProcedure.input(RoleSchema.paginate).query(async ({ ctx, input }) => {
const { staff } = ctx;
return await this.roleService.paginate(input);
}),
findMany: this.trpc.procedure
.input(RoleSchema.findMany) // Assuming StaffSchema.findMany is the Zod schema for finding staffs by keyword
.query(async ({ input }) => {
return await this.roleService.findMany(input);
})
}
)
}

View File

@ -0,0 +1,134 @@
import { Injectable } from '@nestjs/common';
import { db, z, RoleSchema, ObjectType, Role, RoleMap } from "@nicestack/common";
import { DepartmentService } from '@server/models/department/department.service';
import { TRPCError } from '@trpc/server';
@Injectable()
export class RoleService {
constructor(
private readonly departmentService: DepartmentService
) { }
/**
*
* @param data
* @returns
*/
async create(data: z.infer<typeof RoleSchema.create>) {
// 开启事务
return await db.$transaction(async (prisma) => {
// 创建角色
return await prisma.role.create({ data });
});
}
/**
*
* @param data
* @returns
*/
async update(data: z.infer<typeof RoleSchema.update>) {
const { id, ...others } = data;
// 开启事务
return await db.$transaction(async (prisma) => {
// 更新角色
const updatedRole = await prisma.role.update({
where: { id },
data: { ...others }
});
return updatedRole;
});
}
/**
*
* @param data ID列表的数据
* @returns
* @throws ID
*/
async batchDelete(data: z.infer<typeof RoleSchema.batchDelete>) {
const { ids } = data;
if (!ids || ids.length === 0) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'No IDs provided for deletion.'
});
}
// 开启事务
return await db.$transaction(async (prisma) => {
const deletedRoles = await prisma.role.updateMany({
where: {
id: { in: ids }
},
data: { deletedAt: new Date() }
});
await prisma.roleMap.deleteMany({
where: {
roleId: {
in: ids
}
}
});
if (!deletedRoles.count) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'No roles were found with the provided IDs.'
});
}
return { success: true, count: deletedRoles.count };
});
}
/**
*
* @param data
* @returns
*/
async paginate(data: z.infer<typeof RoleSchema.paginate>) {
const { page, pageSize } = data;
const [items, totalCount] = await Promise.all([
db.role.findMany({
skip: (page - 1) * pageSize,
take: pageSize,
orderBy: { name: "asc" },
where: { deletedAt: null },
include: {
roleMaps: true,
}
}),
db.role.count({ where: { deletedAt: null } }),
]);
const result = { items, totalCount };
return result;
}
/**
*
* @param data
* @returns
*/
async findMany(data: z.infer<typeof RoleSchema.findMany>) {
const { keyword } = data
return await db.role.findMany({
where: {
deletedAt: null,
OR: [
{
name: {
contains: keyword
}
}
]
},
orderBy: { name: "asc" },
take: 10
})
}
}

View File

@ -0,0 +1,59 @@
import { Injectable } from '@nestjs/common';
import { TrpcService } from '@server/trpc/trpc.service';
import { RoleMapSchema } from '@nicestack/common';
import { RoleMapService } from './rolemap.service';
@Injectable()
export class RoleMapRouter {
constructor(
private readonly trpc: TrpcService,
private readonly roleMapService: RoleMapService,
) {}
router = this.trpc.router({
deleteAllRolesForObject: this.trpc.protectProcedure
.input(RoleMapSchema.deleteWithObject)
.mutation(({ input }) =>
this.roleMapService.deleteAllRolesForObject(input),
),
setRoleForObject: this.trpc.protectProcedure
.input(RoleMapSchema.create)
.mutation(({ input }) => this.roleMapService.setRoleForObject(input)),
createManyObjects: this.trpc.protectProcedure
.input(RoleMapSchema.createManyObjects)
.mutation(({ input }) => this.roleMapService.createManyObjects(input)),
setRolesForObject: this.trpc.protectProcedure
.input(RoleMapSchema.createManyRoles)
.mutation(({ input }) => this.roleMapService.setRolesForObject(input)),
getPermsForObject: this.trpc.procedure
.input(RoleMapSchema.getPermsForObject)
.query(({ input }) => this.roleMapService.getPermsForObject(input)),
batchDelete: this.trpc.protectProcedure
.input(RoleMapSchema.batchDelete) // Assuming RoleMapSchema.batchDelete is the Zod schema for batch deleting staff
.mutation(async ({ input }) => {
return await this.roleMapService.batchDelete(input);
}),
paginate: this.trpc.procedure
.input(RoleMapSchema.paginate) // Define the input schema for pagination
.query(async ({ input }) => {
return await this.roleMapService.paginate(input);
}),
update: this.trpc.protectProcedure
.input(RoleMapSchema.update)
.mutation(async ({ ctx, input }) => {
const { staff } = ctx;
return await this.roleMapService.update(input);
}),
getRoleMapDetail: this.trpc.procedure
.input(RoleMapSchema.getRoleMapDetail)
.query(async ({ input }) => {
return await this.roleMapService.getRoleMapDetail(input);
}),
});
}

View File

@ -0,0 +1,215 @@
import { Injectable } from '@nestjs/common';
import { db, z, RoleMapSchema, ObjectType } from '@nicestack/common';
import { DepartmentService } from '@server/models/department/department.service';
import { TRPCError } from '@trpc/server';
@Injectable()
export class RoleMapService {
constructor(private readonly departmentService: DepartmentService) { }
/**
*
* @param data ID的数据
* @returns
*/
async deleteAllRolesForObject(
data: z.infer<typeof RoleMapSchema.deleteWithObject>,
) {
const { objectId } = data;
return await db.roleMap.deleteMany({
where: {
objectId,
},
});
}
/**
*
* @param data
* @returns
*/
async setRoleForObject(data: z.infer<typeof RoleMapSchema.create>) {
return await db.roleMap.create({
data,
});
}
/**
*
* @param data
* @returns
*/
async createManyObjects(
data: z.infer<typeof RoleMapSchema.createManyObjects>,
) {
const { domainId, roleId, objectIds, objectType } = data;
const roleMaps = objectIds.map((id) => ({
domainId,
objectId: id,
roleId,
objectType,
}));
// 开启事务
return await db.$transaction(async (prisma) => {
// 首先,删除现有的角色映射
await prisma.roleMap.deleteMany({
where: {
domainId,
roleId,
objectType,
},
});
// 然后,创建新的角色映射
return await prisma.roleMap.createMany({
data: roleMaps,
});
});
}
/**
*
* @param data
* @returns
*/
async setRolesForObject(data: z.infer<typeof RoleMapSchema.createManyRoles>) {
const { domainId, objectId, roleIds, objectType } = data;
const roleMaps = roleIds.map((id) => ({
domainId,
objectId,
roleId: id,
objectType,
}));
return await db.roleMap.createMany({ data: roleMaps });
}
/**
*
* @param data IDID和对象ID的数据
* @returns
*/
async getPermsForObject(
data: z.infer<typeof RoleMapSchema.getPermsForObject>,
) {
const { domainId, deptId, staffId } = data;
let ancestorDeptIds = [];
if (deptId) {
ancestorDeptIds =
await this.departmentService.getAllParentDeptIds(deptId);
}
const userRoles = await db.roleMap.findMany({
where: {
AND: {
domainId,
OR: [
{
objectId: staffId,
objectType: ObjectType.STAFF
},
(deptId ? {
objectId: { in: [deptId, ...ancestorDeptIds] },
objectType: ObjectType.DEPARTMENT,
} : {}),
],
},
},
include: { role: true },
});
return userRoles.flatMap((userRole) => userRole.role.permissions);
}
/**
*
* @param data ID列表的数据
* @returns
* @throws ID
*/
async batchDelete(data: z.infer<typeof RoleMapSchema.batchDelete>) {
const { ids } = data;
if (!ids || ids.length === 0) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'No IDs provided for deletion.',
});
}
const deletedRoleMaps = await db.roleMap.deleteMany({
where: { id: { in: ids } },
});
if (!deletedRoleMaps.count) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'No taxonomies were found with the provided IDs.',
});
}
return { success: true, count: deletedRoleMaps.count };
}
/**
*
* @param data
* @returns
*/
async paginate(data: z.infer<typeof RoleMapSchema.paginate>) {
const { page, pageSize, domainId, roleId } = data;
const [items, totalCount] = await Promise.all([
db.roleMap.findMany({
skip: (page - 1) * pageSize,
take: pageSize,
where: { domainId, roleId },
}),
db.roleMap.count({
where: { domainId, roleId },
}),
]);
// const processedItems = await Promise.all(items.map(item => this.genRoleMapDto(item)));
return { items, totalCount };
}
/**
*
* @param data
* @returns
*/
async update(data: z.infer<typeof RoleMapSchema.update>) {
const { id, ...others } = data;
// 开启事务
return await db.$transaction(async (prisma) => {
// 更新角色映射
const updatedRoleMap = await prisma.roleMap.update({
where: { id },
data: { ...others },
});
return updatedRoleMap;
});
}
/**
*
* @param data ID和域ID的数据
* @returns ID和员工ID列表
*/
async getRoleMapDetail(data: z.infer<typeof RoleMapSchema.getRoleMapDetail>) {
const { roleId, domainId } = data;
const res = await db.roleMap.findMany({ where: { roleId, domainId } });
const deptIds = res
.filter((item) => item.objectType === ObjectType.DEPARTMENT)
.map((item) => item.objectId);
const staffIds = res
.filter((item) => item.objectType === ObjectType.STAFF)
.map((item) => item.objectId);
return { deptIds, staffIds };
}
}

View File

@ -1,9 +1,10 @@
import { Module } from '@nestjs/common';
import { TransformController } from './transform.controller';
import { TransformService } from './transform.service';
import { TransformRouter } from './transform.router';
import { TrpcService } from '@server/trpc/trpc.service';
@Module({
controllers: [TransformController],
providers: [TransformService]
providers: [TransformService, TransformRouter, TrpcService],
exports: [TransformRouter]
})
export class TransformModule {}
export class TransformModule { }

View File

@ -1,13 +1,17 @@
import { Module } from '@nestjs/common';
import { TrpcService } from './trpc.service';
import { TrpcRouter } from './trpc.router';
import { HelloService } from '@server/hello/hello.service';
import { HelloRouter } from '@server/hello/hello.router';
import { DepartmentRouter } from '@server/models/department/department.router';
import { TransformRouter } from '@server/transform/transform.router';
import { StaffRouter } from '@server/models/staff/staff.router';
import { StaffModule } from '../models/staff/staff.module';
import { DepartmentModule } from '@server/models/department/department.module';
import { TransformModule } from '@server/transform/transform.module';
@Module({
imports: [],
imports: [StaffModule, DepartmentModule, TransformModule],
controllers: [],
providers: [TrpcService, TrpcRouter, HelloRouter, HelloService],
providers: [TrpcService, TrpcRouter],
})
export class TrpcModule { }

View File

@ -1,5 +1,4 @@
import { INestApplication, Injectable } from '@nestjs/common';
import { AuthRouter } from '@server/auth/auth.router';
import { DepartmentRouter } from '@server/models/department/department.router';
import { StaffRouter } from '@server/models/staff/staff.router';
import { TrpcService } from '@server/trpc/trpc.service';
@ -8,13 +7,11 @@ import { TransformRouter } from '../transform/transform.router';
@Injectable()
export class TrpcRouter {
constructor(private readonly trpc: TrpcService,
private readonly auth: AuthRouter,
private readonly staff: StaffRouter,
private readonly department: DepartmentRouter,
private readonly transform: TransformRouter
) { }
appRouter = this.trpc.router({
auth: this.auth.router,
staff: this.staff.router,
department: this.department.router,
transform: this.transform.router

View File

@ -3,7 +3,7 @@ import { initTRPC, TRPCError } from '@trpc/server';
import superjson from 'superjson-cjs';
import * as trpcExpress from '@trpc/server/adapters/express';
import { env } from '@server/env';
import { db, Staff, TokenPayload } from "@nicestack/common"
import { db, Staff, JwtPayload } from "@nicestack/common"
import { JwtService } from '@nestjs/jwt';
type Context = Awaited<ReturnType<TrpcService['createContext']>>;
@ -15,15 +15,15 @@ export class TrpcService {
res,
}: trpcExpress.CreateExpressContextOptions) {
const token = req.headers.authorization?.split(' ')[1];
let tokenData: TokenPayload | undefined = undefined;
let tokenData: JwtPayload | undefined = undefined;
let staff: Staff | undefined = undefined;
if (token) {
try {
// Verify JWT token and extract tokenData
tokenData = await this.jwtService.verifyAsync(token, { secret: env.JWT_SECRET }) as TokenPayload;
tokenData = await this.jwtService.verifyAsync(token, { secret: env.JWT_SECRET }) as JwtPayload;
if (tokenData) {
// Fetch staff details from the database using tokenData.id
staff = await db.staff.findUnique({ where: { id: tokenData.id } });
staff = await db.staff.findUnique({ where: { id: tokenData.sub } });
if (!staff) {
throw new TRPCError({ code: 'UNAUTHORIZED', message: "User not found" });
}

View File

@ -1,13 +1,22 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
<script>
window.env = {
VITE_APP_TUS_URL: "$VITE_APP_TUS_URL",
VITE_APP_API_URL: "$VITE_APP_API_URL"
};
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@ -10,22 +10,23 @@
"preview": "vite preview"
},
"dependencies": {
"@tanstack/react-form": "^0.26.3",
"@tanstack/react-query": "^5.50.1",
"@nicestack/common": "workspace:^",
"@tanstack/query-async-storage-persister": "^5.51.9",
"@tanstack/react-query": "^5.51.1",
"@tanstack/react-query-persist-client": "^5.51.9",
"@tanstack/react-virtual": "^3.8.3",
"@tanstack/zod-form-adapter": "^0.26.3",
"@trpc/client": "11.0.0-rc.456",
"@trpc/react-query": "11.0.0-rc.456",
"@trpc/server": "11.0.0-rc.456",
"axios": "^1.7.3",
"idb-keyval": "^6.2.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.24.1",
"superjson": "^2.2.1",
"zod": "^3.23.8",
"idb-keyval": "^6.2.1",
"@nicestack/common": "workspace:^"
"zustand": "^4.5.5"
},
"devDependencies": {
"@types/react": "^18.3.3",

View File

@ -4,12 +4,15 @@ import {
} from "react-router-dom";
import QueryProvider from './providers/query-provider'
import { router } from './routes';
import { AuthProvider } from './providers/auth-provider';
function App() {
return (
<QueryProvider>
<RouterProvider router={router}></RouterProvider>
</QueryProvider>
<AuthProvider>
<QueryProvider>
<RouterProvider router={router}></RouterProvider>
</QueryProvider>
</AuthProvider>
)
}

View File

@ -0,0 +1,3 @@
export default function LoginPage() {
return 'LoginPage'
}

View File

@ -1,4 +1,3 @@
export default function MainPage() {
return <>hello,world</>
}

View File

@ -0,0 +1,29 @@
import { useAuth } from '@web/src/providers/auth-provider';
import { RolePerms } from '@nicestack/common';
import { ReactNode } from 'react';
import { Navigate } from "react-router-dom";
// Define a type for the props that the HOC will accept.
interface WithAuthProps {
permissions?: RolePerms[];
}
// Create the HOC function.
export default function WithAuth({ options = {}, children }: { children: ReactNode, options?: WithAuthProps }) {
const { isAuthenticated, user, isLoading } = useAuth();
if (isLoading) {
return <div>Loading...</div>;
}
// If the user is not authenticated, redirect them to the login page.
if (!isAuthenticated) {
return <Navigate to={'/login'}></Navigate>
}
if (options.permissions && user) {
const hasPermissions = options.permissions.every(permission => user.permissions.includes(permission));
if (!hasPermissions) {
return <div>You do not have the required permissions to view this page.</div>;
}
}
// Return a new functional component.
return children
}

13
apps/web/src/env.ts Normal file
View File

@ -0,0 +1,13 @@
export const env: {
TUS_URL: string;
API_URL: string;
} = {
TUS_URL:
import.meta.env.PROD
? (window as any).env.VITE_APP_TUS_URL
: import.meta.env.VITE_APP_TUS_URL,
API_URL:
import.meta.env.PROD
? (window as any).env.VITE_APP_API_URL
: import.meta.env.VITE_APP_API_URL,
};

View File

@ -0,0 +1,159 @@
import React, { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react';
import apiClient from '../utils/axios-client';
import { UserProfile } from '@nicestack/common';
interface AuthContextProps {
accessToken: string | null;
refreshToken: string | null;
isAuthenticated: boolean;
isLoading: boolean;
user: any;
login: (username: string, password: string) => Promise<void>;
logout: () => Promise<void>;
refreshAccessToken: () => Promise<void>;
initializeAuth: () => void;
startTokenRefreshInterval: () => void;
fetchUserProfile: () => Promise<void>;
}
const AuthContext = createContext<AuthContextProps | undefined>(undefined);
export const useAuth = (): AuthContextProps => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};
interface AuthProviderProps {
children: ReactNode;
}
export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
const [accessToken, setAccessToken] = useState<string | null>(localStorage.getItem('access_token'));
const [refreshToken, setRefreshToken] = useState<string | null>(localStorage.getItem('refresh_token'));
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(!!localStorage.getItem('access_token'));
const [isLoading, setIsLoading] = useState<boolean>(false);
const [intervalId, setIntervalId] = useState<NodeJS.Timeout | null>(null);
const [user, setUser] = useState<UserProfile | null>(null);
const initializeAuth = useCallback(() => {
const storedAccessToken = localStorage.getItem('access_token');
const storedRefreshToken = localStorage.getItem('refresh_token');
setAccessToken(storedAccessToken);
setRefreshToken(storedRefreshToken);
setIsAuthenticated(!!storedAccessToken);
if (storedRefreshToken) {
startTokenRefreshInterval();
}
if (storedAccessToken) {
fetchUserProfile();
}
}, []);
const refreshAccessToken = useCallback(async () => {
if (!refreshToken) return;
try {
setIsLoading(true);
const response = await apiClient.post(`/auth/refresh-token`, { refreshToken });
const { access_token, access_token_expires_at } = response.data;
localStorage.setItem('access_token', access_token);
localStorage.setItem('access_token_expires_at', access_token_expires_at);
setAccessToken(access_token);
setIsAuthenticated(true);
fetchUserProfile();
} catch (err) {
console.error("Token refresh failed", err);
logout();
} finally {
setIsLoading(false);
}
}, [refreshToken]);
const startTokenRefreshInterval = useCallback(async () => {
if (intervalId) {
clearInterval(intervalId);
}
await refreshAccessToken();
const newIntervalId = setInterval(refreshAccessToken, 10 * 60 * 1000); // 10 minutes
setIntervalId(newIntervalId);
}, [intervalId, refreshAccessToken]);
const login = async (username: string, password: string): Promise<void> => {
try {
setIsLoading(true);
const response = await apiClient.post(`/auth/login`, { username, password });
const { access_token, refresh_token, access_token_expires_at, refresh_token_expires_at } = response.data;
localStorage.setItem('access_token', access_token);
localStorage.setItem('refresh_token', refresh_token);
localStorage.setItem('access_token_expires_at', access_token_expires_at);
localStorage.setItem('refresh_token_expires_at', refresh_token_expires_at);
setAccessToken(access_token);
setRefreshToken(refresh_token);
setIsAuthenticated(true);
startTokenRefreshInterval();
fetchUserProfile();
} catch (err) {
console.error("Login failed", err);
throw new Error("Login failed");
} finally {
setIsLoading(false);
}
};
const logout = async (): Promise<void> => {
try {
setIsLoading(true);
const storedRefreshToken = localStorage.getItem('refresh_token');
await apiClient.post(`/auth/logout`, { refreshToken: storedRefreshToken });
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
localStorage.removeItem('access_token_expires_at');
localStorage.removeItem('refresh_token_expires_at');
setAccessToken(null);
setRefreshToken(null);
setIsAuthenticated(false);
setUser(null);
if (intervalId) {
clearInterval(intervalId);
setIntervalId(null);
}
} catch (err) {
console.error("Logout failed", err);
throw new Error("Logout failed");
} finally {
setIsLoading(false);
}
};
const fetchUserProfile = useCallback(async () => {
try {
const response = await apiClient.get(`/auth/user-profile`);
setUser(response.data);
} catch (err) {
console.error("Fetching user profile failed", err);
}
}, []);
useEffect(() => {
initializeAuth();
}, [initializeAuth]);
const value: AuthContextProps = {
accessToken,
refreshToken,
isAuthenticated,
isLoading,
user,
login,
logout,
refreshAccessToken,
initializeAuth,
startTokenRefreshInterval,
fetchUserProfile
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};

View File

@ -1,25 +1,23 @@
import { QueryClient } from '@tanstack/react-query';
import { unstable_httpBatchStreamLink, loggerLink } from '@trpc/client';
import React, { useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { api } from '../utils/trpc';
import superjson from 'superjson';
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client'
import { createIDBPersister } from '../utils/idb';
import { env } from '../env';
import { useAuth } from './auth-provider';
export default function QueryProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient());
const [trpcClient] = useState(() =>
const { accessToken } = useAuth()
const trpcClient = useMemo(() =>
api.createClient({
links: [
unstable_httpBatchStreamLink({
url: 'http://localhost:3000/trpc',
// You can pass any HTTP headers you wish here
async headers() {
return {
// authorization: getAuthCookie(),
};
},
url: `${env.API_URL}/trpc`,
headers: async () => ({
...(accessToken ? { 'Authorization': `Bearer ${accessToken}` } : {}),
}),
transformer: superjson
}),
loggerLink({
@ -29,7 +27,7 @@ export default function QueryProvider({ children }: { children: React.ReactNode
(opts.direction === 'down' && opts.result instanceof Error),
}),
],
}),
}), [accessToken]
);
return (
<api.Provider client={trpcClient} queryClient={queryClient}>

View File

@ -4,6 +4,8 @@ import {
import MainPage from "../app/main/page";
import ErrorPage from "../app/error";
import LayoutPage from "../app/layout";
import LoginPage from "../app/login";
import WithAuth from "../components/auth/with-auth";
export const router = createBrowserRouter([
{
@ -13,8 +15,13 @@ export const router = createBrowserRouter([
children: [
{
index: true,
element: <MainPage></MainPage>
element: <WithAuth><MainPage></MainPage></WithAuth>
}
]
],
},
{
path: '/login',
element: <LoginPage></LoginPage>
}
]);

View File

@ -0,0 +1,20 @@
import axios from 'axios';
import { env } from '../env';
const BASE_URL = env.API_URL; // Replace with your backend URL
const apiClient = axios.create({
baseURL: BASE_URL,
withCredentials: true,
});
// Add a request interceptor to attach the access token
apiClient.interceptors.request.use(
(config) => {
const accessToken = localStorage.getItem('access_token');
if (accessToken) {
config.headers['Authorization'] = `Bearer ${accessToken}`;
}
return config;
},
(error) => Promise.reject(error)
);
export default apiClient;

View File

@ -71,18 +71,17 @@ model TermAncestry {
}
model Comment {
id String @id @default(uuid())
style String
link String?
title String?
content String
attachments String[] @default([])
createdAt DateTime? @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
createdBy String?
createdStaff Staff? @relation(fields: [createdBy], references: [id])
id String @id @default(uuid())
style String
link String?
title String?
content String
attachments String[] @default([])
createdAt DateTime? @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
createdBy String?
createdStaff Staff? @relation(fields: [createdBy], references: [id])
@@map("comments")
}

View File

@ -9,6 +9,8 @@ export enum RelationType {
READED = "READED",
MESSAGE = "MESSAGE",
}
export enum RolePerms {
// Create Permissions 创建权限
CREATE_ALERT = "CREATE_ALERT", // 创建警报

View File

@ -1,4 +1,5 @@
import { z } from "zod"
import { ObjectType } from "./enum";
export const AuthSchema = {
signInRequset: z.object({
username: z.string(),
@ -18,7 +19,7 @@ export const AuthSchema = {
}),
logoutRequest: z.object({
refreshToken: z.string(),
}),
})
};
export const StaffSchema = {
create: z.object({
@ -90,3 +91,73 @@ export const DepartmentSchema = {
ids: z.array(z.string()).nullish(),
}),
};
export const RoleMapSchema = {
create: z.object({
objectId: z.string(),
roleId: z.string(),
domainId: z.string(),
objectType: z.nativeEnum(ObjectType),
}),
update: z.object({
id: z.string(),
objectId: z.string().nullish(),
roleId: z.string().nullish(),
domainId: z.string().nullish(),
objectType: z.nativeEnum(ObjectType).nullish(),
}),
createManyRoles: z.object({
objectId: z.string(),
roleIds: z.array(z.string()),
domainId: z.string(),
objectType: z.nativeEnum(ObjectType),
}),
createManyObjects: z.object({
objectIds: z.array(z.string()),
roleId: z.string(),
domainId: z.string().nullish(),
objectType: z.nativeEnum(ObjectType),
}),
batchDelete: z.object({
ids: z.array(z.string()),
}),
paginate: z.object({
page: z.number().min(1),
pageSize: z.number().min(1),
domainId: z.string().nullish(),
roleId: z.string().nullish(),
}),
deleteWithObject: z.object({
objectId: z.string(),
}),
getRoleMapDetail: z.object({
roleId: z.string(),
domainId: z.string().nullish(),
}),
getPermsForObject: z.object({
domainId: z.string(),
staffId: z.string(),
deptId: z.string(),
}),
};
export const RoleSchema = {
create: z.object({
name: z.string(),
permissions: z.array(z.string()).nullish(),
}),
update: z.object({
id: z.string(),
name: z.string().nullish(),
permissions: z.array(z.string()).nullish(),
}),
batchDelete: z.object({
ids: z.array(z.string()),
}),
paginate: z.object({
page: z.number().nullish(),
pageSize: z.number().nullish(),
}),
findMany: z.object({
keyword: z.string().nullish(),
}),
};

View File

@ -13,7 +13,29 @@ export type StaffDto = Staff & {
domain?: Department;
department?: Department;
};
export interface TokenPayload {
id: string;
export type UserProfile = Staff & {
permissions: string[];
department?: Department;
domain?: Department;
}
export interface JwtPayload {
sub: string;
username: string;
}
export interface GenPerms {
instruction?: boolean;
createProgress?: boolean;
requestCancel?: boolean;
acceptCancel?: boolean;
conclude?: boolean;
createRisk?: boolean;
editIndicator?: boolean;
editMethod?: boolean;
editOrg?: boolean;
edit?: boolean;
delete?: boolean;
read?: boolean;
}