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 docker-compose.yml
.env
packages/common/prisma/migrations
volumes

View File

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

View File

@ -8,9 +8,10 @@ import { QueueModule } from './queue/queue.module';
import { TransformModule } from './transform/transform.module'; import { TransformModule } from './transform/transform.module';
import { AuthModule } from './auth/auth.module'; import { AuthModule } from './auth/auth.module';
import { ScheduleModule } from '@nestjs/schedule'; import { ScheduleModule } from '@nestjs/schedule';
import { ConfigService } from '@nestjs/config';
@Module({ @Module({
imports: [ScheduleModule.forRoot(), TrpcModule, RedisModule, QueueModule, TransformModule, AuthModule], imports: [ScheduleModule.forRoot(), TrpcModule, RedisModule, QueueModule, TransformModule, AuthModule],
providers: [RedisService, SocketGateway], providers: [RedisService, SocketGateway, ConfigService],
}) })
export class AppModule { } 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 { JwtService } from '@nestjs/jwt';
import { env } from '@server/env'; import { env } from '@server/env';
import { Request } from 'express'; import { Request } from 'express';
import { JwtPayload } from '@nicestack/common';
@Injectable() @Injectable()
export class AuthGuard implements CanActivate { export class AuthGuard implements CanActivate {
@ -18,7 +19,7 @@ export class AuthGuard implements CanActivate {
throw new UnauthorizedException(); throw new UnauthorizedException();
} }
try { try {
const payload = await this.jwtService.verifyAsync( const payload: JwtPayload = await this.jwtService.verifyAsync(
token, token,
{ {
secret: env.JWT_SECRET secret: env.JWT_SECRET

View File

@ -2,13 +2,18 @@ import { Module } from '@nestjs/common';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { JwtModule } from '@nestjs/jwt'; import { JwtModule } from '@nestjs/jwt';
import { env } from '@server/env'; 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({ @Module({
providers: [AuthService], providers: [AuthService, StaffService, RoleMapService,DepartmentService],
imports: [JwtModule.register({ imports: [JwtModule.register({
global: true, global: true,
secret: env.JWT_SECRET, secret: env.JWT_SECRET,
signOptions: { expiresIn: '60s' }, signOptions: { expiresIn: '60s' },
}),] }),],
controllers: [AuthController]
}) })
export class AuthModule { } 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

@ -8,29 +8,40 @@ import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt'; import * as bcrypt from 'bcrypt';
import { AuthSchema, db, z } from '@nicestack/common'; import { AuthSchema, db, z } from '@nicestack/common';
import { StaffService } from '@server/models/staff/staff.service'; import { StaffService } from '@server/models/staff/staff.service';
import { JwtPayload } from '@nicestack/common';
import { RoleMapService } from '@server/rbac/rolemap.service';
@Injectable() @Injectable()
export class AuthService { export class AuthService {
constructor( constructor(
private readonly jwtService: JwtService, private readonly jwtService: JwtService,
private readonly staffService: StaffService, private readonly staffService: StaffService,
private readonly roleMapService: RoleMapService
) { } ) { }
async signIn(data: z.infer<typeof AuthSchema.signInRequset>) { async signIn(data: z.infer<typeof AuthSchema.signInRequset>) {
const { username, password } = data; const { username, password } = data;
const staff = await db.staff.findUnique({ where: { username } }); const staff = await db.staff.findUnique({ where: { username } });
if (!staff) { if (!staff) {
throw new UnauthorizedException('Invalid username or password'); throw new UnauthorizedException('Invalid username or password');
} }
const isPasswordMatch = await bcrypt.compare(password, staff.password);
const isPasswordMatch = await bcrypt.compare(password, staff.password);
if (!isPasswordMatch) { if (!isPasswordMatch) {
throw new UnauthorizedException('Invalid username or password'); throw new UnauthorizedException('Invalid username or password');
} }
const payload = { sub: staff.id, username: staff.username }; const payload: JwtPayload = { sub: staff.id, username: staff.username };
const accessToken = await this.jwtService.signAsync(payload, { expiresIn: '1h' }); const accessToken = await this.jwtService.signAsync(payload, { expiresIn: '1h' });
const refreshToken = await this.generateRefreshToken(staff.id); const refreshToken = await this.generateRefreshToken(staff.id);
// Calculate expiration dates
const accessTokenExpiresAt = new Date();
accessTokenExpiresAt.setHours(accessTokenExpiresAt.getHours() + 1);
const refreshTokenExpiresAt = new Date();
refreshTokenExpiresAt.setDate(refreshTokenExpiresAt.getDate() + 7);
// Store the refresh token in the database // Store the refresh token in the database
await db.refreshToken.create({ await db.refreshToken.create({
data: { data: {
@ -38,9 +49,12 @@ export class AuthService {
token: refreshToken, token: refreshToken,
}, },
}); });
return { return {
access_token: accessToken, access_token: accessToken,
access_token_expires_at: accessTokenExpiresAt,
refresh_token: refreshToken, refresh_token: refreshToken,
refresh_token_expires_at: refreshTokenExpiresAt,
}; };
} }
@ -51,7 +65,8 @@ export class AuthService {
async refreshToken(data: z.infer<typeof AuthSchema.refreshTokenRequest>) { async refreshToken(data: z.infer<typeof AuthSchema.refreshTokenRequest>) {
const { refreshToken } = data; const { refreshToken } = data;
let payload;
let payload: JwtPayload;
try { try {
payload = this.jwtService.verify(refreshToken); payload = this.jwtService.verify(refreshToken);
} catch (error) { } catch (error) {
@ -59,7 +74,6 @@ export class AuthService {
} }
const storedToken = await db.refreshToken.findUnique({ where: { token: refreshToken } }); const storedToken = await db.refreshToken.findUnique({ where: { token: refreshToken } });
if (!storedToken) { if (!storedToken) {
throw new UnauthorizedException('Invalid refresh token'); throw new UnauthorizedException('Invalid refresh token');
} }
@ -74,8 +88,13 @@ export class AuthService {
{ expiresIn: '1h' }, { expiresIn: '1h' },
); );
// Calculate new expiration date
const accessTokenExpiresAt = new Date();
accessTokenExpiresAt.setHours(accessTokenExpiresAt.getHours() + 1);
return { return {
access_token: newAccessToken, access_token: newAccessToken,
access_token_expires_at: accessTokenExpiresAt,
}; };
} }
@ -93,7 +112,7 @@ export class AuthService {
password: hashedPassword, password: hashedPassword,
}); });
return staff return staff;
} }
async logout(data: z.infer<typeof AuthSchema.logoutRequest>) { async logout(data: z.infer<typeof AuthSchema.logoutRequest>) {
@ -105,16 +124,34 @@ export class AuthService {
async changePassword(data: z.infer<typeof AuthSchema.changePassword>) { async changePassword(data: z.infer<typeof AuthSchema.changePassword>) {
const { oldPassword, newPassword, username } = data; const { oldPassword, newPassword, username } = data;
const user = await db.staff.findUnique({ where: { username } }); const user = await db.staff.findUnique({ where: { username } });
if (!user) { if (!user) {
throw new NotFoundException('User not found'); throw new NotFoundException('User not found');
} }
const isPasswordMatch = await bcrypt.compare(oldPassword, user.password); const isPasswordMatch = await bcrypt.compare(oldPassword, user.password);
if (!isPasswordMatch) { if (!isPasswordMatch) {
throw new UnauthorizedException('Old password is incorrect'); throw new UnauthorizedException('Old password is incorrect');
} }
const hashedNewPassword = await bcrypt.hash(newPassword, 10); const hashedNewPassword = await bcrypt.hash(newPassword, 10);
await this.staffService.update({ id: user.id, password: hashedNewPassword }); await this.staffService.update({ id: user.id, password: hashedNewPassword });
return { message: 'Password successfully changed' }; 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 { 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({}) @Module({
providers: [StaffRouter, StaffService, TrpcService, DepartmentService],
exports: [StaffRouter, StaffService]
})
export class StaffModule { } export class StaffModule { }

View File

@ -43,11 +43,6 @@ export class StaffRouter {
.input(StaffSchema.findMany) // Assuming StaffSchema.findMany is the Zod schema for finding staffs by keyword .input(StaffSchema.findMany) // Assuming StaffSchema.findMany is the Zod schema for finding staffs by keyword
.query(async ({ input }) => { .query(async ({ input }) => {
return await this.staffService.findMany(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 { Module } from '@nestjs/common';
import { TransformController } from './transform.controller';
import { TransformService } from './transform.service'; import { TransformService } from './transform.service';
import { TransformRouter } from './transform.router';
import { TrpcService } from '@server/trpc/trpc.service';
@Module({ @Module({
controllers: [TransformController], providers: [TransformService, TransformRouter, TrpcService],
providers: [TransformService] exports: [TransformRouter]
}) })
export class TransformModule { } export class TransformModule { }

View File

@ -1,13 +1,17 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TrpcService } from './trpc.service'; import { TrpcService } from './trpc.service';
import { TrpcRouter } from './trpc.router'; import { TrpcRouter } from './trpc.router';
import { HelloService } from '@server/hello/hello.service'; import { DepartmentRouter } from '@server/models/department/department.router';
import { HelloRouter } from '@server/hello/hello.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({ @Module({
imports: [], imports: [StaffModule, DepartmentModule, TransformModule],
controllers: [], controllers: [],
providers: [TrpcService, TrpcRouter, HelloRouter, HelloService], providers: [TrpcService, TrpcRouter],
}) })
export class TrpcModule { } export class TrpcModule { }

View File

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

View File

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

View File

@ -1,13 +1,22 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title> <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> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
</html> </html>

View File

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

View File

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

View File

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

View File

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

View File

@ -4,6 +4,8 @@ import {
import MainPage from "../app/main/page"; import MainPage from "../app/main/page";
import ErrorPage from "../app/error"; import ErrorPage from "../app/error";
import LayoutPage from "../app/layout"; import LayoutPage from "../app/layout";
import LoginPage from "../app/login";
import WithAuth from "../components/auth/with-auth";
export const router = createBrowserRouter([ export const router = createBrowserRouter([
{ {
@ -13,8 +15,13 @@ export const router = createBrowserRouter([
children: [ children: [
{ {
index: true, 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

@ -81,7 +81,6 @@ model Comment {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
deletedAt DateTime? deletedAt DateTime?
createdBy String? createdBy String?
createdStaff Staff? @relation(fields: [createdBy], references: [id]) createdStaff Staff? @relation(fields: [createdBy], references: [id])
@@map("comments") @@map("comments")

View File

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

View File

@ -1,4 +1,5 @@
import { z } from "zod" import { z } from "zod"
import { ObjectType } from "./enum";
export const AuthSchema = { export const AuthSchema = {
signInRequset: z.object({ signInRequset: z.object({
username: z.string(), username: z.string(),
@ -18,7 +19,7 @@ export const AuthSchema = {
}), }),
logoutRequest: z.object({ logoutRequest: z.object({
refreshToken: z.string(), refreshToken: z.string(),
}), })
}; };
export const StaffSchema = { export const StaffSchema = {
create: z.object({ create: z.object({
@ -90,3 +91,73 @@ export const DepartmentSchema = {
ids: z.array(z.string()).nullish(), 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; domain?: Department;
department?: Department; department?: Department;
}; };
export interface TokenPayload { export type UserProfile = Staff & {
id: string; permissions: string[];
department?: Department;
domain?: Department;
}
export interface JwtPayload {
sub: string;
username: 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;
}