diff --git a/.tmuxinator/dev.yml b/.tmuxinator/dev.yml index f2059bd3..ee346a0c 100644 --- a/.tmuxinator/dev.yml +++ b/.tmuxinator/dev.yml @@ -32,3 +32,9 @@ windows: - docker: - echo "Docker Services (Ctrl+a 4 to focus)" - docker compose up + - dashboard: + root: <%= ENV["PWD"] %>/dashboard + panes: + - dashboard: + - echo "Dashboard (Ctrl+a 5 to focus, Ctrl+a r to restart)" + - pnpm run start diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 9c4ac667..aeae32bd 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -20,6 +20,8 @@ import { MailModule } from './mail/mail.module'; import { GitHubModule } from './github/github.module'; import { AppConfigService } from './config/config.service'; import { getDatabaseConfig } from './database.config'; +import { DashboardModule } from './dashboard/dashboard.module'; +import { InterceptorModule } from './interceptor/interceptor.module'; @Module({ imports: [ @@ -56,6 +58,8 @@ import { getDatabaseConfig } from './database.config'; MailModule, TypeOrmModule.forFeature([User]), GitHubModule, + DashboardModule, + InterceptorModule, ], providers: [ AppResolver, diff --git a/backend/src/auth/auth.module.ts b/backend/src/auth/auth.module.ts index 1c37afdd..8cc95ff6 100644 --- a/backend/src/auth/auth.module.ts +++ b/backend/src/auth/auth.module.ts @@ -13,6 +13,10 @@ import { MailModule } from 'src/mail/mail.module'; import { GoogleStrategy } from './oauth/GoogleStrategy'; import { GoogleController } from './google.controller'; import { AppConfigModule } from 'src/config/config.module'; +import { RoleResolver } from './role/role.resolver'; +import { MenuResolver } from './menu/menu.resolver'; +import { RoleService } from './role/role.service'; +import { MenuService } from './menu/menu.service'; @Module({ imports: [ @@ -29,8 +33,16 @@ import { AppConfigModule } from 'src/config/config.module'; JwtCacheModule, MailModule, ], + providers: [ + AuthService, + AuthResolver, + RoleResolver, + MenuResolver, + RoleService, + MenuService, + GoogleStrategy, + ], + exports: [AuthService, RoleService, MenuService, JwtModule], controllers: [GoogleController], - providers: [AuthService, AuthResolver, GoogleStrategy], - exports: [AuthService, JwtModule], }) export class AuthModule {} diff --git a/backend/src/auth/menu/dto/create-menu.input.ts b/backend/src/auth/menu/dto/create-menu.input.ts new file mode 100644 index 00000000..e6e5ffc1 --- /dev/null +++ b/backend/src/auth/menu/dto/create-menu.input.ts @@ -0,0 +1,25 @@ +import { Field, InputType } from '@nestjs/graphql'; +import { IsString, IsNotEmpty, IsOptional, IsArray } from 'class-validator'; + +@InputType() +export class CreateMenuInput { + @Field() + @IsString() + @IsNotEmpty() + name: string; + + @Field() + @IsString() + @IsNotEmpty() + path: string; + + @Field() + @IsString() + @IsNotEmpty() + permission: string; + + @Field(() => [String], { nullable: true }) + @IsArray() + @IsOptional() + roleIds?: string[]; +} diff --git a/backend/src/auth/menu/dto/update-menu.input.ts b/backend/src/auth/menu/dto/update-menu.input.ts new file mode 100644 index 00000000..f53c6d97 --- /dev/null +++ b/backend/src/auth/menu/dto/update-menu.input.ts @@ -0,0 +1,32 @@ +import { Field, ID, InputType } from '@nestjs/graphql'; +import { IsString, IsOptional, IsArray } from 'class-validator'; + +@InputType() +export class UpdateMenuInput { + @Field(() => ID) + id: string; + + @Field({ nullable: true }) + @IsString() + @IsOptional() + name?: string; + + @Field({ nullable: true }) + @IsString() + @IsOptional() + path?: string; + + @Field({ nullable: true }) + @IsString() + @IsOptional() + permission?: string; + + @Field({ nullable: true }) + @IsOptional() + isActive?: boolean; + + @Field(() => [String], { nullable: true }) + @IsArray() + @IsOptional() + roleIds?: string[]; +} diff --git a/backend/src/auth/menu/menu.model.ts b/backend/src/auth/menu/menu.model.ts index 6b2f39c6..9039ebe8 100644 --- a/backend/src/auth/menu/menu.model.ts +++ b/backend/src/auth/menu/menu.model.ts @@ -1,13 +1,19 @@ import { ObjectType, Field, ID } from '@nestjs/graphql'; -import { SystemBaseModel } from 'src/system-base-model/system-base.model'; -import { Entity, PrimaryGeneratedColumn, Column, ManyToMany } from 'typeorm'; +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToMany, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; import { Role } from '../role/role.model'; @Entity() @ObjectType() -export class Menu extends SystemBaseModel { +export class Menu { @Field(() => ID) - @PrimaryGeneratedColumn() + @PrimaryGeneratedColumn('uuid') id: string; @Field() @@ -22,6 +28,27 @@ export class Menu extends SystemBaseModel { @Column() permission: string; + @Field(() => String, { nullable: true }) + @Column({ nullable: true }) + description?: string; + + @Field() + @Column({ default: true }) + isActive: boolean; + + @Field() + @Column({ default: false }) + isDeleted: boolean; + + @Field() + @CreateDateColumn() + createdAt: Date; + + @Field() + @UpdateDateColumn() + updatedAt: Date; + + @Field(() => [Role], { nullable: true }) @ManyToMany(() => Role, (role) => role.menus) roles: Role[]; } diff --git a/backend/src/auth/menu/menu.resolver.ts b/backend/src/auth/menu/menu.resolver.ts new file mode 100644 index 00000000..f20fd62a --- /dev/null +++ b/backend/src/auth/menu/menu.resolver.ts @@ -0,0 +1,65 @@ +import { Resolver, Query, Mutation, Args, ID } from '@nestjs/graphql'; +import { UseGuards } from '@nestjs/common'; +import { MenuService } from './menu.service'; +import { Menu } from './menu.model'; +import { CreateMenuInput } from './dto/create-menu.input'; +import { UpdateMenuInput } from './dto/update-menu.input'; +import { RequireAuth } from '../../decorator/auth.decorator'; +import { JWTAuthGuard } from 'src/guard/jwt-auth.guard'; + +@UseGuards(JWTAuthGuard) +@Resolver(() => Menu) +export class MenuResolver { + constructor(private readonly menuService: MenuService) {} + + @Query(() => [Menu]) + @RequireAuth({ + roles: ['Admin'], + menuPath: '/menu/list', + }) + async menus(): Promise { + return this.menuService.findAll(); + } + + @Query(() => Menu) + @RequireAuth({ + roles: ['Admin'], + menuPath: '/menu/detail', + }) + async menu(@Args('id', { type: () => ID }) id: string): Promise { + return this.menuService.findOne(id); + } + + @Mutation(() => Menu) + @RequireAuth({ + roles: ['Admin'], + menuPath: '/menu/create', + }) + async createMenu( + @Args('createMenuInput') createMenuInput: CreateMenuInput, + ): Promise { + return this.menuService.create(createMenuInput); + } + + @Mutation(() => Menu) + @RequireAuth({ + roles: ['Admin'], + menuPath: '/menu/update', + }) + async updateMenu( + @Args('updateMenuInput') updateMenuInput: UpdateMenuInput, + ): Promise { + return this.menuService.update(updateMenuInput.id, updateMenuInput); + } + + @Mutation(() => Boolean) + @RequireAuth({ + roles: ['Admin'], + menuPath: '/menu/delete', + }) + async removeMenu( + @Args('id', { type: () => ID }) id: string, + ): Promise { + return this.menuService.remove(id); + } +} diff --git a/backend/src/auth/menu/menu.service.ts b/backend/src/auth/menu/menu.service.ts new file mode 100644 index 00000000..e6275b6e --- /dev/null +++ b/backend/src/auth/menu/menu.service.ts @@ -0,0 +1,69 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Menu } from './menu.model'; +import { CreateMenuInput } from './dto/create-menu.input'; +import { UpdateMenuInput } from './dto/update-menu.input'; + +@Injectable() +export class MenuService { + constructor( + @InjectRepository(Menu) + private readonly menuRepository: Repository, + ) {} + + async create(createMenuInput: CreateMenuInput): Promise { + const menu = this.menuRepository.create(createMenuInput); + return this.menuRepository.save(menu); + } + + async findAll(): Promise { + return this.menuRepository.find({ + relations: ['roles'], + }); + } + + async findOne(id: string): Promise { + const menu = await this.menuRepository.findOne({ + where: { id }, + relations: ['roles'], + }); + + if (!menu) { + throw new NotFoundException(`Menu with ID "${id}" not found`); + } + + return menu; + } + + async update(id: string, updateMenuInput: UpdateMenuInput): Promise { + const menu = await this.findOne(id); + Object.assign(menu, updateMenuInput); + return this.menuRepository.save(menu); + } + + async remove(id: string): Promise { + const result = await this.menuRepository.delete(id); + return result.affected > 0; + } + + async findByPermission(permission: string): Promise { + return this.menuRepository.find({ + where: { permission }, + relations: ['roles'], + }); + } + + async findByPath(path: string): Promise { + const menu = await this.menuRepository.findOne({ + where: { path }, + relations: ['roles'], + }); + + if (!menu) { + throw new NotFoundException(`Menu with path "${path}" not found`); + } + + return menu; + } +} diff --git a/backend/src/auth/role/dto/create-role.input.ts b/backend/src/auth/role/dto/create-role.input.ts new file mode 100644 index 00000000..b8bf0207 --- /dev/null +++ b/backend/src/auth/role/dto/create-role.input.ts @@ -0,0 +1,19 @@ +import { Field, InputType } from '@nestjs/graphql'; +import { IsString, IsArray, IsOptional } from 'class-validator'; + +@InputType() +export class CreateRoleInput { + @Field() + @IsString() + name: string; + + @Field({ nullable: true }) + @IsString() + @IsOptional() + description?: string; + + @Field(() => [String], { nullable: true }) + @IsArray() + @IsOptional() + menuIds?: string[]; +} diff --git a/backend/src/auth/role/dto/update-role.input.ts b/backend/src/auth/role/dto/update-role.input.ts new file mode 100644 index 00000000..d2fefca5 --- /dev/null +++ b/backend/src/auth/role/dto/update-role.input.ts @@ -0,0 +1,27 @@ +import { Field, ID, InputType } from '@nestjs/graphql'; +import { IsString, IsArray, IsOptional } from 'class-validator'; + +@InputType() +export class UpdateRoleInput { + @Field(() => ID) + id: string; + + @Field({ nullable: true }) + @IsString() + @IsOptional() + name?: string; + + @Field({ nullable: true }) + @IsString() + @IsOptional() + description?: string; + + @Field({ nullable: true }) + @IsOptional() + isActive?: boolean; + + @Field(() => [String], { nullable: true }) + @IsArray() + @IsOptional() + menuIds?: string[]; +} diff --git a/backend/src/auth/role/role.model.ts b/backend/src/auth/role/role.model.ts index c32ba7c3..b7e29374 100644 --- a/backend/src/auth/role/role.model.ts +++ b/backend/src/auth/role/role.model.ts @@ -6,10 +6,12 @@ import { Column, ManyToMany, JoinTable, + CreateDateColumn, + UpdateDateColumn, } from 'typeorm'; import { Menu } from '../menu/menu.model'; -@ObjectType() +@ObjectType('SystemRole') @Entity() export class Role { @Field(() => ID) @@ -24,8 +26,24 @@ export class Role { @Column({ nullable: true }) description?: string; + @Field() + @Column({ default: true }) + isActive: boolean; + + @Field() + @Column({ default: false }) + isDeleted: boolean; + + @Field() + @CreateDateColumn() + createdAt: Date; + + @Field() + @UpdateDateColumn() + updatedAt: Date; + @Field(() => [Menu], { nullable: true }) - @ManyToMany(() => Menu, { eager: true }) + @ManyToMany(() => Menu, (menu) => menu.roles, { cascade: true }) @JoinTable({ name: 'role_menus', joinColumn: { @@ -39,17 +57,7 @@ export class Role { }) menus?: Menu[]; + @Field(() => [User], { nullable: true }) @ManyToMany(() => User, (user) => user.roles) - @JoinTable({ - name: 'user_roles', - joinColumn: { - name: 'role_id', - referencedColumnName: 'id', - }, - inverseJoinColumn: { - name: 'user_id', - referencedColumnName: 'id', - }, - }) users?: User[]; } diff --git a/backend/src/auth/role/role.resolver.ts b/backend/src/auth/role/role.resolver.ts new file mode 100644 index 00000000..8f0ac101 --- /dev/null +++ b/backend/src/auth/role/role.resolver.ts @@ -0,0 +1,65 @@ +import { Resolver, Query, Mutation, Args, ID } from '@nestjs/graphql'; +import { RoleService } from './role.service'; +import { Role } from './role.model'; +import { CreateRoleInput } from './dto/create-role.input'; +import { UpdateRoleInput } from './dto/update-role.input'; +import { RequireAuth } from '../../decorator/auth.decorator'; +import { UseGuards } from '@nestjs/common'; +import { JWTAuthGuard } from 'src/guard/jwt-auth.guard'; + +@UseGuards(JWTAuthGuard) +@Resolver(() => Role) +export class RoleResolver { + constructor(private readonly roleService: RoleService) {} + + @Query(() => [Role]) + @RequireAuth({ + roles: ['Admin'], + menuPath: '/role/list', + }) + async roles(): Promise { + return this.roleService.findAll(); + } + + @Query(() => Role) + @RequireAuth({ + roles: ['Admin'], + menuPath: '/role/detail', + }) + async role(@Args('id', { type: () => ID }) id: string): Promise { + return this.roleService.findOne(id); + } + + @Mutation(() => Role) + @RequireAuth({ + roles: ['Admin'], + menuPath: '/role/create', + }) + async createRole( + @Args('createRoleInput') createRoleInput: CreateRoleInput, + ): Promise { + return this.roleService.create(createRoleInput); + } + + @Mutation(() => Role) + @RequireAuth({ + roles: ['Admin'], + menuPath: '/role/update', + }) + async updateRole( + @Args('updateRoleInput') updateRoleInput: UpdateRoleInput, + ): Promise { + return this.roleService.update(updateRoleInput.id, updateRoleInput); + } + + @Mutation(() => Boolean) + @RequireAuth({ + roles: ['Admin'], + menuPath: '/role/delete', + }) + async removeRole( + @Args('id', { type: () => ID }) id: string, + ): Promise { + return this.roleService.remove(id); + } +} diff --git a/backend/src/auth/role/role.service.ts b/backend/src/auth/role/role.service.ts new file mode 100644 index 00000000..8ac38cd6 --- /dev/null +++ b/backend/src/auth/role/role.service.ts @@ -0,0 +1,80 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Role } from './role.model'; +import { Menu } from '../menu/menu.model'; +import { CreateRoleInput } from './dto/create-role.input'; +import { UpdateRoleInput } from './dto/update-role.input'; + +@Injectable() +export class RoleService { + constructor( + @InjectRepository(Role) + private readonly roleRepository: Repository, + @InjectRepository(Menu) + private readonly menuRepository: Repository, + ) {} + + async create(createRoleInput: CreateRoleInput): Promise { + const { menuIds, ...roleData } = createRoleInput; + const role = this.roleRepository.create(roleData); + + if (menuIds?.length) { + const menus = await this.menuRepository.findByIds(menuIds); + role.menus = menus; + } + + return this.roleRepository.save(role); + } + + async findAll(): Promise { + const roles = await this.roleRepository + .createQueryBuilder('role') + .leftJoinAndSelect('role.menus', 'menu') + .getMany(); + + return roles; + } + + async findOne(id: string): Promise { + const role = await this.roleRepository + .createQueryBuilder('role') + .leftJoinAndSelect('role.menus', 'menu') + .where('role.id = :id', { id }) + .getOne(); + + if (!role) { + throw new NotFoundException(`Role with ID "${id}" not found`); + } + + return role; + } + + async update(id: string, updateRoleInput: UpdateRoleInput): Promise { + const role = await this.findOne(id); + const { menuIds, ...roleData } = updateRoleInput; + + if (menuIds !== undefined) { + const menus = menuIds.length + ? await this.menuRepository.findByIds(menuIds) + : []; + role.menus = menus; + } + + Object.assign(role, roleData); + return this.roleRepository.save(role); + } + + async remove(id: string): Promise { + const result = await this.roleRepository.delete(id); + return result.affected > 0; + } + + async findByName(name: string): Promise { + return this.roleRepository + .createQueryBuilder('role') + .leftJoinAndSelect('role.menus', 'menu') + .where('role.name = :name', { name }) + .getOne(); + } +} diff --git a/backend/src/dashboard/dashboard-stat.model.ts b/backend/src/dashboard/dashboard-stat.model.ts new file mode 100644 index 00000000..cca80c7a --- /dev/null +++ b/backend/src/dashboard/dashboard-stat.model.ts @@ -0,0 +1,28 @@ +import { ObjectType, Field, Int } from '@nestjs/graphql'; + +@ObjectType() +export class DashboardStats { + @Field(() => Int) + totalUsers: number; + + @Field(() => Int) + activeUsers: number; + + @Field(() => Int) + totalChats: number; + + @Field(() => Int) + activeChats: number; + + @Field(() => Int) + totalProjects: number; + + @Field(() => Int) + activeProjects: number; + + @Field(() => Int) + totalRoles: number; + + @Field(() => Int) + totalMenus: number; +} diff --git a/backend/src/dashboard/dashboard.module.ts b/backend/src/dashboard/dashboard.module.ts new file mode 100644 index 00000000..05fa517f --- /dev/null +++ b/backend/src/dashboard/dashboard.module.ts @@ -0,0 +1,45 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { DashboardResolver } from './dashboard.resolver'; +import { DashboardService } from './dashboard.service'; + +// Models +import { User } from '../user/user.model'; +import { Role } from '../auth/role/role.model'; +import { Chat } from '../chat/chat.model'; +import { Project } from '../project/project.model'; +import { ProjectPackages } from '../project/project-packages.model'; + +// Related modules +import { UserModule } from '../user/user.module'; +import { AuthModule } from '../auth/auth.module'; +import { ChatModule } from '../chat/chat.module'; +import { ProjectModule } from '../project/project.module'; +import { JwtCacheModule } from '../jwt-cache/jwt-cache.module'; +import { Menu } from 'src/auth/menu/menu.model'; +import { InterceptorModule } from 'src/interceptor/interceptor.module'; +import { TelemetryLogService } from 'src/interceptor/telemetry-log.service'; +import { TelemetryLog } from 'src/interceptor/telemetry-log.model'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + User, + Role, + Chat, + Project, + ProjectPackages, + Menu, + TelemetryLog, + ]), + UserModule, + AuthModule, + ChatModule, + ProjectModule, + JwtCacheModule, + InterceptorModule, + ], + providers: [DashboardResolver, DashboardService, TelemetryLogService], + exports: [DashboardService], +}) +export class DashboardModule {} diff --git a/backend/src/dashboard/dashboard.resolver.ts b/backend/src/dashboard/dashboard.resolver.ts new file mode 100644 index 00000000..62df49ac --- /dev/null +++ b/backend/src/dashboard/dashboard.resolver.ts @@ -0,0 +1,236 @@ +import { Args, Mutation, Query, Resolver, ID } from '@nestjs/graphql'; +import { UseGuards } from '@nestjs/common'; +import { DashboardService } from './dashboard.service'; +import { User } from '../user/user.model'; +import { Chat } from '../chat/chat.model'; +import { Project } from '../project/project.model'; +import { TelemetryLog } from '../interceptor/telemetry-log.model'; +import { + CreateUserInput, + UpdateUserInput, + UserFilterInput, +} from './dto/user-input'; +import { + ChatFilterInput, + CreateChatInput, + UpdateChatInput, +} from './dto/chat-input'; +import { ProjectFilterInput, UpdateProjectInput } from './dto/project-input'; +import { TelemetryLogFilterInput } from './dto/telemetry-log-input'; +import { RequireRoles } from '../decorator/auth.decorator'; +import { JWTAuthGuard } from '../guard/jwt-auth.guard'; +import { CreateRoleInput } from 'src/auth/role/dto/create-role.input'; +import { UpdateRoleInput } from 'src/auth/role/dto/update-role.input'; +import { Role } from 'src/auth/role/role.model'; +import { GetUserIdFromToken } from 'src/decorator/get-auth-token.decorator'; +import { CreateProjectInput } from 'src/project/dto/project.input'; +import { DashboardStats } from './dashboard-stat.model'; +import { TelemetryLogService } from 'src/interceptor/telemetry-log.service'; + +@Resolver() +@UseGuards(JWTAuthGuard) +export class DashboardResolver { + constructor( + private readonly dashboardService: DashboardService, + private readonly telemetryLogService: TelemetryLogService, + ) {} + + @RequireRoles('Admin') + @Query(() => [User]) + async dashboardUsers( + @Args('filter', { nullable: true }) filter?: UserFilterInput, + ): Promise { + return await this.dashboardService.findUsers(filter); + } + + @RequireRoles('Admin') + @Query(() => User) + async dashboardUser( + @Args('id', { type: () => ID }) id: string, + ): Promise { + return this.dashboardService.findUserById(id); + } + + @RequireRoles('Admin') + @Mutation(() => User) + async createDashboardUser( + @Args('input') input: CreateUserInput, + ): Promise { + return this.dashboardService.createUser(input); + } + + @RequireRoles('Admin') + @Mutation(() => User) + async updateDashboardUser( + @Args('id', { type: () => ID }) id: string, + @Args('input') input: UpdateUserInput, + ): Promise { + return this.dashboardService.updateUser(id, input); + } + + @RequireRoles('Admin') + @Mutation(() => Boolean) + async deleteDashboardUser( + @Args('id', { type: () => ID }) id: string, + ): Promise { + return this.dashboardService.deleteUser(id); + } + + // Chat Management + @RequireRoles('Admin') + @Query(() => [Chat]) + async dashboardChats( + @Args('filter', { nullable: true }) filter?: ChatFilterInput, + ): Promise { + return this.dashboardService.findChats(filter); + } + + @RequireRoles('Admin') + @Query(() => Chat) + async dashboardChat( + @Args('id', { type: () => ID }) id: string, + ): Promise { + return this.dashboardService.findChatById(id); + } + + @RequireRoles('Admin') + @Mutation(() => Chat) + async createDashboardChat( + @GetUserIdFromToken() userId: string, + @Args('input') input: CreateChatInput, + ): Promise { + return this.dashboardService.createChat(input, userId); + } + + @RequireRoles('Admin') + @Mutation(() => Chat) + async updateDashboardChat( + @Args('id', { type: () => ID }) id: string, + @Args('input') input: UpdateChatInput, + ): Promise { + return this.dashboardService.updateChat(id, input); + } + + @RequireRoles('Admin') + @Mutation(() => Boolean) + async deleteDashboardChat( + @Args('id', { type: () => ID }) id: string, + ): Promise { + return this.dashboardService.deleteChat(id); + } + + // Project Management + @RequireRoles('Admin') + @Query(() => [Project]) + async dashboardProjects( + @Args('filter', { nullable: true }) filter?: ProjectFilterInput, + ): Promise { + return this.dashboardService.findProjects(filter); + } + + @RequireRoles('Admin') + @Query(() => Project) + async dashboardProject( + @Args('id', { type: () => ID }) id: string, + ): Promise { + return this.dashboardService.findProjectById(id); + } + + @RequireRoles('Admin') + @Mutation(() => Chat) + async createDashboardProject( + @GetUserIdFromToken() userId: string, + @Args('input') input: CreateProjectInput, + ): Promise { + return await this.dashboardService.createProject(input, userId); + } + + @RequireRoles('Admin') + @Mutation(() => Project) + async updateDashboardProject( + @Args('id', { type: () => ID }) id: string, + @Args('input') input: UpdateProjectInput, + ): Promise { + return this.dashboardService.updateProject(id, input); + } + + @RequireRoles('Admin') + @Mutation(() => Boolean) + async deleteDashboardProject( + @Args('id', { type: () => ID }) id: string, + ): Promise { + return this.dashboardService.deleteProject(id); + } + + // Role Management + @RequireRoles('Admin') + @Query(() => [Role]) + async dashboardRoles(): Promise { + return this.dashboardService.findRoles(); + } + + @RequireRoles('Admin') + @Query(() => Role) + async dashboardRole( + @Args('id', { type: () => ID }) id: string, + ): Promise { + return this.dashboardService.findRoleById(id); + } + + @RequireRoles('Admin') + @Mutation(() => Role) + async createDashboardRole( + @Args('input') input: CreateRoleInput, + ): Promise { + return this.dashboardService.createRole(input); + } + + @RequireRoles('Admin') + @Mutation(() => Role) + async updateDashboardRole( + @Args('id', { type: () => ID }) id: string, + @Args('input') input: UpdateRoleInput, + ): Promise { + return this.dashboardService.updateRole(id, input); + } + + @RequireRoles('Admin') + @Mutation(() => Boolean) + async deleteDashboardRole( + @Args('id', { type: () => ID }) id: string, + ): Promise { + return this.dashboardService.deleteRole(id); + } + + // Dashboard Stats + @RequireRoles('Admin') + @Query(() => DashboardStats) + async dashboardStats(): Promise { + return this.dashboardService.getDashboardStats(); + } + + // Telemetry Log Management + @RequireRoles('Admin') + @Query(() => [TelemetryLog]) + async dashboardTelemetryLogs( + @Args('filter', { nullable: true }) filter?: TelemetryLogFilterInput, + ): Promise { + return this.telemetryLogService.findFiltered(filter); + } + + @RequireRoles('Admin') + @Query(() => TelemetryLog) + async dashboardTelemetryLog( + @Args('id', { type: () => ID }) id: number, + ): Promise { + return this.telemetryLogService.findById(id); + } + + @RequireRoles('Admin') + @Query(() => Number) + async dashboardTelemetryLogsCount( + @Args('filter', { nullable: true }) filter?: TelemetryLogFilterInput, + ): Promise { + return this.telemetryLogService.countTelemetryLogs(filter); + } +} diff --git a/backend/src/dashboard/dashboard.service.ts b/backend/src/dashboard/dashboard.service.ts new file mode 100644 index 00000000..052cb70b --- /dev/null +++ b/backend/src/dashboard/dashboard.service.ts @@ -0,0 +1,383 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Between, Repository } from 'typeorm'; +import { User } from '../user/user.model'; +import { Chat } from '../chat/chat.model'; +import { Project } from '../project/project.model'; +import { Role } from '../auth/role/role.model'; +import { ProjectPackages } from '../project/project-packages.model'; +import { hash } from 'bcrypt'; + +import { + CreateUserInput, + UpdateUserInput, + UserFilterInput, +} from './dto/user-input'; + +import { + ChatFilterInput, + CreateChatInput, + UpdateChatInput, +} from './dto/chat-input'; + +import { ProjectFilterInput, UpdateProjectInput } from './dto/project-input'; +import { CreateRoleInput } from 'src/auth/role/dto/create-role.input'; +import { UpdateRoleInput } from 'src/auth/role/dto/update-role.input'; +import { Menu } from 'src/auth/menu/menu.model'; +import { ProjectService } from 'src/project/project.service'; +import { CreateProjectInput } from 'src/project/dto/project.input'; +import { DashboardStats } from './dashboard-stat.model'; +import { TelemetryLog } from 'src/interceptor/telemetry-log.model'; + +@Injectable() +export class DashboardService { + constructor( + @InjectRepository(User) + private readonly userRepository: Repository, + @InjectRepository(Menu) + private readonly menuRepository: Repository, + @InjectRepository(Role) + private readonly roleRepository: Repository, + @InjectRepository(Chat) + private readonly chatRepository: Repository, + @InjectRepository(Project) + private readonly projectRepository: Repository, + @InjectRepository(ProjectPackages) + private readonly packageRepository: Repository, + @InjectRepository(TelemetryLog) + private readonly telemetryLogRepository: Repository, + private readonly projectService: ProjectService, + ) {} + + // User Management + async findUsers(filter?: UserFilterInput): Promise { + const query = this.userRepository + .createQueryBuilder('user') + .leftJoinAndSelect('user.roles', 'role'); + if (filter?.search) { + query.where('(user.username LIKE :search OR user.email LIKE :search)', { + search: `%${filter.search}%`, + }); + } + + if (filter?.isActive !== undefined) { + query.andWhere('user.isActive = :isActive', { + isActive: filter.isActive, + }); + } + console.log(await query.getMany()); + return await query.getMany(); + } + + async findUserById(id: string): Promise { + const user = await this.userRepository.findOne({ where: { id } }); + if (!user) { + throw new NotFoundException(`User with ID ${id} not found`); + } + return user; + } + + async findUserByEmailWithRoles(email: string): Promise { + return this.userRepository.findOne({ + where: { email }, + relations: ['roles'], + }); + } + + async createUser(input: CreateUserInput): Promise { + const hashedPassword = await hash(input.password, 10); + + let roles = []; + if (input.roleIds && input.roleIds.length > 0) { + roles = await this.roleRepository.findByIds(input.roleIds); + } + + const user = this.userRepository.create({ + ...input, + password: hashedPassword, + roles, + }); + console.log('user', user); + return this.userRepository.save(user); + } + + async updateUser(id: string, input: UpdateUserInput): Promise { + const user = await this.findUserById(id); + console.log('user before update', user); + + if (input.password) { + input.password = await hash(input.password, 10); + } + + if (typeof input.roleIds !== 'undefined') { + const roles = + input.roleIds.length > 0 + ? await this.roleRepository.findByIds(input.roleIds) + : []; + (input as any).roles = roles; + } + + Object.assign(user, input); + return this.userRepository.save(user); + } + + async deleteUser(id: string): Promise { + const result = await this.userRepository.delete(id); + return result.affected > 0; + } + + // Chat Management + async findChats(filter?: ChatFilterInput): Promise { + const query = this.chatRepository + .createQueryBuilder('chat') + .leftJoinAndSelect('chat.user', 'user') + .leftJoinAndSelect('chat.project', 'project'); + + if (filter?.search) { + query.where('chat.title LIKE :search', { search: `%${filter.search}%` }); + } + + if (filter?.userId) { + query.andWhere('chat.userId = :userId', { userId: filter.userId }); + } + + if (filter?.projectId) { + query.andWhere('chat.projectId = :projectId', { + projectId: filter.projectId, + }); + } + + if (filter?.isActive !== undefined) { + query.andWhere('chat.isActive = :isActive', { + isActive: filter.isActive, + }); + } + + if (filter?.isDeleted !== undefined) { + query.andWhere('chat.isDeleted = :isDeleted', { + isDeleted: filter.isDeleted, + }); + } + + return query.getMany(); + } + + async findChatById(id: string): Promise { + const chat = await this.chatRepository.findOne({ + where: { id }, + relations: ['user', 'project'], + }); + if (!chat) { + throw new NotFoundException(`Chat with ID ${id} not found`); + } + return chat; + } + + async createChat(input: CreateChatInput, userId: string): Promise { + const user = await this.userRepository.findOne({ + where: { id: userId }, + }); + const project = await this.projectRepository.findOne({ + where: { id: input.projectId }, + relations: ['user'], + }); + const inputChat = { + ...input, + userId, + user, + project, + } as Chat; + const chat = this.chatRepository.create(inputChat); + return this.chatRepository.save(chat); + } + + async updateChat(id: string, input: UpdateChatInput): Promise { + const chat = await this.findChatById(id); + Object.assign(chat, input); + return this.chatRepository.save(chat); + } + + async deleteChat(id: string): Promise { + const chat = await this.findChatById(id); + chat.isDeleted = true; + chat.isActive = false; + await this.chatRepository.save(chat); + return true; + } + + // Project Management + async findProjects(filter?: ProjectFilterInput): Promise { + const query = this.projectRepository + .createQueryBuilder('project') + .leftJoinAndSelect('project.user', 'user') + .leftJoinAndSelect('project.projectPackages', 'packages') + .leftJoinAndSelect('project.chats', 'chats'); + + if (filter?.search) { + query.where('project.projectName LIKE :search', { + search: `%${filter.search}%`, + }); + } + + if (filter?.userId) { + query.andWhere('project.userId = :userId', { userId: filter.userId }); + } + + if (filter?.isPublic !== undefined) { + query.andWhere('project.isPublic = :isPublic', { + isPublic: filter.isPublic, + }); + } + + if (filter?.isActive !== undefined) { + query.andWhere('project.isActive = :isActive', { + isActive: filter.isActive, + }); + } + + if (filter?.isDeleted !== undefined) { + query.andWhere('project.isDeleted = :isDeleted', { + isDeleted: filter.isDeleted, + }); + } + + if (filter?.createdAfter || filter?.createdBefore) { + query.andWhere({ + createdAt: Between( + filter.createdAfter || new Date(0), + filter.createdBefore || new Date(), + ), + }); + } + + return query.getMany(); + } + + async findProjectById(id: string): Promise { + const project = await this.projectRepository.findOne({ + where: { id }, + relations: ['user', 'projectPackages', 'chats'], + }); + if (!project) { + throw new NotFoundException(`Project with ID ${id} not found`); + } + return project; + } + + async createProject( + input: CreateProjectInput, + userId: string, + ): Promise { + return await this.projectService.createProject(input, userId); + } + + async updateProject(id: string, input: UpdateProjectInput): Promise { + const project = await this.findProjectById(id); + + if (input.packageIds !== undefined) { + project.projectPackages = input.packageIds.length + ? await this.packageRepository.findByIds(input.packageIds) + : []; + } + + Object.assign(project, input); + return this.projectRepository.save(project); + } + + async deleteProject(id: string): Promise { + const project = await this.findProjectById(id); + project.isDeleted = true; + project.isActive = false; + await this.projectRepository.save(project); + return true; + } + + async findRoles(): Promise { + return this.roleRepository.find({ + where: { isDeleted: false }, + relations: ['menus', 'users'], + }); + } + + async findRoleById(id: string): Promise { + const role = await this.roleRepository.findOne({ + where: { id }, + relations: ['menus', 'users'], + }); + if (!role) { + throw new NotFoundException(`Role with ID ${id} not found`); + } + return role; + } + + async createRole(input: CreateRoleInput): Promise { + const role = this.roleRepository.create(input); + + if (input.menuIds?.length) { + const menus = await this.menuRepository.findByIds(input.menuIds); + role.menus = menus; + } + + return this.roleRepository.save(role); + } + + async updateRole(id: string, input: UpdateRoleInput): Promise { + const role = await this.findRoleById(id); + + if (input.menuIds !== undefined) { + const menus = input.menuIds.length + ? await this.menuRepository.findByIds(input.menuIds) + : []; + role.menus = menus; + } + + Object.assign(role, input); + return this.roleRepository.save(role); + } + + async deleteRole(id: string): Promise { + const role = await this.findRoleById(id); + role.isDeleted = true; + role.isActive = false; + await this.roleRepository.save(role); + return true; + } + + async getDashboardStats(): Promise { + const totalUsers = await this.userRepository.count({ + where: { isDeleted: false }, + }); + const activeUsers = await this.userRepository.count({ + where: { isActive: true, isDeleted: false }, + }); + const totalChats = await this.chatRepository.count({ + where: { isDeleted: false }, + }); + const activeChats = await this.chatRepository.count({ + where: { isActive: true, isDeleted: false }, + }); + const totalProjects = await this.projectRepository.count({ + where: { isDeleted: false }, + }); + const activeProjects = await this.projectRepository.count({ + where: { isActive: true, isDeleted: false }, + }); + const totalRoles = await this.roleRepository.count({ + where: { isDeleted: false }, + }); + const totalMenus = await this.menuRepository.count({ + where: { isDeleted: false }, + }); + + return { + totalUsers, + activeUsers, + totalChats, + activeChats, + totalProjects, + activeProjects, + totalRoles, + totalMenus, + }; + } +} diff --git a/backend/src/dashboard/dto/chat-input.ts b/backend/src/dashboard/dto/chat-input.ts new file mode 100644 index 00000000..3d889a7b --- /dev/null +++ b/backend/src/dashboard/dto/chat-input.ts @@ -0,0 +1,37 @@ +import { Field, InputType, ID } from '@nestjs/graphql'; + +@InputType() +export class ChatFilterInput { + @Field({ nullable: true }) + search?: string; + + @Field({ nullable: true }) + userId?: string; + + @Field({ nullable: true }) + projectId?: string; + + @Field({ nullable: true }) + isActive?: boolean; + + @Field({ nullable: true }) + isDeleted?: boolean; +} + +@InputType() +export class CreateChatInput { + @Field() + title: string; + + @Field(() => ID, { nullable: true }) + projectId?: string; +} + +@InputType() +export class UpdateChatInput { + @Field({ nullable: true }) + title?: string; + + @Field({ nullable: true }) + isActive?: boolean; +} diff --git a/backend/src/dashboard/dto/project-input.ts b/backend/src/dashboard/dto/project-input.ts new file mode 100644 index 00000000..f5fd3d01 --- /dev/null +++ b/backend/src/dashboard/dto/project-input.ts @@ -0,0 +1,42 @@ +import { Field, InputType, ID } from '@nestjs/graphql'; + +@InputType() +export class ProjectFilterInput { + @Field({ nullable: true }) + search?: string; + + @Field({ nullable: true }) + userId?: string; + + @Field({ nullable: true }) + isPublic?: boolean; + + @Field({ nullable: true }) + isActive?: boolean; + + @Field({ nullable: true }) + isDeleted?: boolean; + + @Field(() => Date, { nullable: true }) + createdAfter?: Date; + + @Field(() => Date, { nullable: true }) + createdBefore?: Date; +} +@InputType() +export class UpdateProjectInput { + @Field({ nullable: true }) + projectName?: string; + + @Field({ nullable: true }) + projectPath?: string; + + @Field({ nullable: true }) + isPublic?: boolean; + + @Field({ nullable: true }) + isActive?: boolean; + + @Field(() => [ID], { nullable: true }) + packageIds?: string[]; +} diff --git a/backend/src/dashboard/dto/telemetry-log-input.ts b/backend/src/dashboard/dto/telemetry-log-input.ts new file mode 100644 index 00000000..18f09f16 --- /dev/null +++ b/backend/src/dashboard/dto/telemetry-log-input.ts @@ -0,0 +1,31 @@ +import { Field, InputType } from '@nestjs/graphql'; + +@InputType() +export class TelemetryLogFilterInput { + @Field(() => Date, { nullable: true }) + startDate?: Date; + + @Field(() => Date, { nullable: true }) + endDate?: Date; + + @Field({ nullable: true }) + requestMethod?: string; + + @Field({ nullable: true }) + endpoint?: string; + + @Field({ nullable: true }) + email?: string; + + @Field({ nullable: true }) + handler?: string; + + @Field(() => Number, { nullable: true }) + minTimeConsumed?: number; + + @Field(() => Number, { nullable: true }) + maxTimeConsumed?: number; + + @Field({ nullable: true }) + search?: string; +} diff --git a/backend/src/dashboard/dto/user-input.ts b/backend/src/dashboard/dto/user-input.ts new file mode 100644 index 00000000..f17bfb64 --- /dev/null +++ b/backend/src/dashboard/dto/user-input.ts @@ -0,0 +1,69 @@ +import { Field, InputType } from '@nestjs/graphql'; +import { + IsArray, + IsEmail, + IsOptional, + IsString, + MinLength, +} from 'class-validator'; + +@InputType() +export class CreateUserInput { + @Field() + @IsString() + @MinLength(2) + username: string; + + @Field() + @IsEmail() + email: string; + + @Field() + @IsString() + @MinLength(6) + password: string; + + @Field(() => [String], { defaultValue: [] }) + @IsArray() + @IsOptional() + roleIds?: string[]; +} +@InputType() +export class UpdateUserInput { + @Field({ nullable: true }) + @IsString() + @MinLength(2) + @IsOptional() + username?: string; + + @Field({ nullable: true }) + @IsEmail() + @IsOptional() + email?: string; + + @Field({ nullable: true }) + @IsOptional() + isActive?: boolean; + + @Field({ nullable: true }) + @IsString() + @MinLength(6) + @IsOptional() + password?: string; + + @Field(() => [String], { nullable: true }) + @IsOptional() + roleIds?: string[]; +} + +@InputType() +export class UserFilterInput { + @Field({ nullable: true }) + @IsString() + @IsOptional() + search?: string; + + @Field(() => Boolean, { nullable: true }) + @IsOptional() + isActive?: boolean; +} diff --git a/backend/src/decorator/auth.decorator.ts b/backend/src/decorator/auth.decorator.ts index 724ef12b..944f360d 100644 --- a/backend/src/decorator/auth.decorator.ts +++ b/backend/src/decorator/auth.decorator.ts @@ -13,7 +13,7 @@ import { applyDecorators, UseGuards } from '@nestjs/common'; import { Roles } from './roles.decorator'; import { Menu } from './menu.decorator'; -import { RolesGuard } from 'src/interceptor/roles.guard'; +import { RolesGuard } from 'src/guard/roles.guard'; import { MenuGuard } from 'src/guard/menu.guard'; export function Auth() { diff --git a/backend/src/guard/jwt-auth.guard.ts b/backend/src/guard/jwt-auth.guard.ts index 40324515..bc3416df 100644 --- a/backend/src/guard/jwt-auth.guard.ts +++ b/backend/src/guard/jwt-auth.guard.ts @@ -56,7 +56,10 @@ export class JWTAuthGuard implements CanActivate { request.user = payload; this.logger.debug('User successfully authenticated'); - + if (contextType === ('graphql' as ContextType)) { + const gqlContext = GqlExecutionContext.create(context); + gqlContext.getContext().user = payload; + } return true; } catch (error) { this.logger.error(`Authentication failed: ${error.message}`); diff --git a/backend/src/guard/menu.guard.ts b/backend/src/guard/menu.guard.ts index 48db5c44..c2e92f74 100644 --- a/backend/src/guard/menu.guard.ts +++ b/backend/src/guard/menu.guard.ts @@ -11,6 +11,7 @@ import { InjectRepository } from '@nestjs/typeorm'; import { MENU_PATH_KEY } from 'src/decorator/menu.decorator'; import { User } from 'src/user/user.model'; import { Repository } from 'typeorm'; + @Injectable() export class MenuGuard implements CanActivate { private readonly logger = new Logger(MenuGuard.name); @@ -34,7 +35,7 @@ export class MenuGuard implements CanActivate { const gqlContext = GqlExecutionContext.create(context); const { req } = gqlContext.getContext(); - if (!req.user?.id) { + if (!req.user?.userId) { throw new UnauthorizedException('User is not authenticated'); } @@ -48,6 +49,18 @@ export class MenuGuard implements CanActivate { throw new UnauthorizedException('User not found'); } + // Check if user has any role with the wildcard '*' permission + const hasWildcardAccess = user.roles.some((role) => + role.menus?.some( + (menu) => menu.path === '*' || menu.permission === '*', + ), + ); + + if (hasWildcardAccess) { + return true; + } + + // Check specific menu permissions const hasMenuAccess = user.roles.some((role) => role.menus?.some((menu) => menu.path === requiredPath), ); diff --git a/backend/src/interceptor/roles.guard.ts b/backend/src/guard/roles.guard.ts similarity index 96% rename from backend/src/interceptor/roles.guard.ts rename to backend/src/guard/roles.guard.ts index 5400e43d..764091f9 100644 --- a/backend/src/interceptor/roles.guard.ts +++ b/backend/src/guard/roles.guard.ts @@ -35,7 +35,8 @@ export class RolesGuard implements CanActivate { const gqlContext = GqlExecutionContext.create(context); const { req } = gqlContext.getContext(); - if (!req.user?.id) { + if (!req.user?.userId) { + this.logger.warn(req.user); throw new UnauthorizedException('User is not authenticated'); } diff --git a/backend/src/init/init-menus.service.ts b/backend/src/init/init-menus.service.ts new file mode 100644 index 00000000..df97d0c5 --- /dev/null +++ b/backend/src/init/init-menus.service.ts @@ -0,0 +1,185 @@ +import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Menu } from '../auth/menu/menu.model'; +import { Role } from '../auth/role/role.model'; +import { DefaultRoles } from '../common/enums/role.enum'; + +const DEFAULT_MENUS = [ + // Admin catch-all menu + { + name: 'All', + path: '*', + permission: '*', + description: 'Full system access', + isActive: true, + }, + // Role management menus + { + name: 'Role List', + path: '/role/list', + permission: 'role.list', + description: 'View all roles', + isActive: true, + }, + { + name: 'Role Detail', + path: '/role/detail', + permission: 'role.detail', + description: 'View role details', + isActive: true, + }, + { + name: 'Create Role', + path: '/role/create', + permission: 'role.create', + description: 'Create new roles', + isActive: true, + }, + { + name: 'Update Role', + path: '/role/update', + permission: 'role.update', + description: 'Update existing roles', + isActive: true, + }, + { + name: 'Delete Role', + path: '/role/delete', + permission: 'role.delete', + description: 'Delete roles', + isActive: true, + }, + + // Menu management menus + { + name: 'Menu List', + path: '/menu/list', + permission: 'menu.list', + description: 'View all menus', + isActive: true, + }, + { + name: 'Menu Detail', + path: '/menu/detail', + permission: 'menu.detail', + description: 'View menu details', + isActive: true, + }, + { + name: 'Create Menu', + path: '/menu/create', + permission: 'menu.create', + description: 'Create new menus', + isActive: true, + }, + { + name: 'Update Menu', + path: '/menu/update', + permission: 'menu.update', + description: 'Update existing menus', + isActive: true, + }, + { + name: 'Delete Menu', + path: '/menu/delete', + permission: 'menu.delete', + description: 'Delete menus', + isActive: true, + }, +]; + +@Injectable() +export class InitMenusService implements OnApplicationBootstrap { + private readonly logger = new Logger(InitMenusService.name); + + constructor( + @InjectRepository(Menu) + private menuRepository: Repository, + @InjectRepository(Role) + private roleRepository: Repository, + ) {} + + async onApplicationBootstrap() { + await this.initializeDefaultMenus(); + } + + private async initializeDefaultMenus() { + this.logger.log('Checking and initializing default menus...'); + + // First, ensure all menus exist + await this.createOrUpdateMenus(); + + // Then, associate them with the admin role + await this.associateMenusWithAdminRole(); + + this.logger.log('Default menus initialization completed'); + } + + private async createOrUpdateMenus(): Promise { + for (const menuData of DEFAULT_MENUS) { + let menu = await this.menuRepository.findOne({ + where: { path: menuData.path }, + }); + + if (!menu) { + menu = await this.createMenu(menuData); + } else { + // Update existing menu + Object.assign(menu, menuData); + menu = await this.menuRepository.save(menu); + } + } + } + + private async createMenu(menuData: { + name: string; + path: string; + permission: string; + description: string; + isActive: boolean; + }): Promise { + try { + const menu = this.menuRepository.create(menuData); + const savedMenu = await this.menuRepository.save(menu); + this.logger.log(`Created menu: ${menu.path}`); + return savedMenu; + } catch (error) { + this.logger.error( + `Failed to create menu ${menuData.path}:`, + error.message, + ); + throw error; + } + } + + private async associateMenusWithAdminRole() { + try { + let adminRole = await this.roleRepository.findOne({ + where: { name: DefaultRoles.ADMIN }, + relations: ['menus'], + }); + + if (!adminRole) { + this.logger.error( + 'Admin role not found. Please ensure roles are initialized first.', + ); + return; + } + + const menu = await this.menuRepository.find({ + where: { path: '*' }, + }); + + adminRole.menus = menu; + adminRole = await this.roleRepository.save(adminRole); + this.logger.log(`Associated ${menu.length} menus with admin role`); + } catch (error) { + this.logger.error( + 'Failed to associate menus with admin role:', + error.message, + ); + throw error; + } + } +} diff --git a/backend/src/init/init-roles.service.ts b/backend/src/init/init-roles.service.ts index 785523ec..09d19cb6 100644 --- a/backend/src/init/init-roles.service.ts +++ b/backend/src/init/init-roles.service.ts @@ -20,34 +20,44 @@ export class InitRolesService implements OnApplicationBootstrap { private async initializeDefaultRoles() { this.logger.log('Checking and initializing default roles...'); - const defaultRoles = Object.values(DefaultRoles); - - for (const roleName of defaultRoles) { - const existingRole = await this.roleRepository.findOne({ - where: { name: roleName }, + try { + // Create Admin role + await this.ensureRoleExists({ + name: DefaultRoles.ADMIN, + description: 'Administrator with full system access', }); - if (!existingRole) { - await this.createDefaultRole(roleName); - } + this.logger.log('Default roles initialization completed'); + } catch (error) { + this.logger.error('Failed to initialize roles:', error.message); + throw error; } - - this.logger.log('Default roles initialization completed'); } - private async createDefaultRole(roleName: string) { + private async ensureRoleExists(roleData: { + name: string; + description: string; + }) { try { - const newRole = this.roleRepository.create({ - name: roleName, - description: `Default ${roleName} role`, - menus: [], + let role = await this.roleRepository.findOne({ + where: { name: roleData.name }, }); - await this.roleRepository.save(newRole); - this.logger.log(`Created default role: ${roleName}`); + if (!role) { + role = this.roleRepository.create(roleData); + await this.roleRepository.save(role); + this.logger.log(`Created ${roleData.name} role`); + } else { + // Update description if needed + if (role.description !== roleData.description) { + role.description = roleData.description; + await this.roleRepository.save(role); + this.logger.log(`Updated ${roleData.name} role description`); + } + } } catch (error) { this.logger.error( - `Failed to create default role ${roleName}:`, + `Failed to ensure role ${roleData.name} exists:`, error.message, ); throw error; diff --git a/backend/src/init/init-user.service.ts b/backend/src/init/init-user.service.ts new file mode 100644 index 00000000..419ad3a1 --- /dev/null +++ b/backend/src/init/init-user.service.ts @@ -0,0 +1,66 @@ +import { Injectable, OnModuleInit } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { User } from '../user/user.model'; +import { Role } from '../auth/role/role.model'; +import * as bcrypt from 'bcrypt'; + +@Injectable() +export class UserInitService implements OnModuleInit { + constructor( + @InjectRepository(User) + private userRepository: Repository, + @InjectRepository(Role) + private roleRepository: Repository, + ) {} + + async onModuleInit() { + await this.initializeAdminRole(); + await this.initializeAdminUser(); + } + + private async initializeAdminRole(): Promise { + let adminRole = await this.roleRepository.findOne({ + where: { name: 'Admin' }, + }); + + if (!adminRole) { + await this.roleRepository.insert({ + name: 'Admin', + description: 'Administrator role with full access', + }); + + // 重新读取一次,确保返回的 role 是“有 id”的 managed entity + adminRole = await this.roleRepository.findOne({ + where: { name: 'Admin' }, + }); + } + + return adminRole!; + } + + private async initializeAdminUser(): Promise { + const existingAdmin = await this.userRepository.findOne({ + where: { email: 'admin@codefox.com' }, + relations: ['roles'], + }); + + if (existingAdmin) { + return; + } + + const adminRole = await this.initializeAdminRole(); + const hashedPassword = await bcrypt.hash('admin123', 10); + + const adminUser = this.userRepository.create({ + username: 'admin', + email: 'admin@codefox.com', + password: hashedPassword, + isEmailConfirmed: true, + roles: [adminRole], + }); + + await this.userRepository.save(adminUser); + console.log(adminUser); + } +} diff --git a/backend/src/init/init.module.ts b/backend/src/init/init.module.ts index 3f9e1316..2089a58f 100644 --- a/backend/src/init/init.module.ts +++ b/backend/src/init/init.module.ts @@ -1,13 +1,19 @@ -// This module is use for init operation like init roles, init permissions, init users, etc. -// This module is imported in app.module.ts -// @Author: Jackson Chen import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Role } from '../auth/role/role.model'; +import { Menu } from '../auth/menu/menu.model'; import { InitRolesService } from './init-roles.service'; +import { InitMenusService } from './init-menus.service'; +import { UserInitService } from './init-user.service'; +import { User } from 'src/user/user.model'; @Module({ - imports: [TypeOrmModule.forFeature([Role])], - providers: [InitRolesService], + imports: [TypeOrmModule.forFeature([Role, Menu, User])], + providers: [ + UserInitService, + InitRolesService, + // Add InitMenusService after InitRolesService to ensure roles are created first + InitMenusService, + ], }) export class InitModule {} diff --git a/backend/src/interceptor/LoggingInterceptor.ts b/backend/src/interceptor/LoggingInterceptor.ts index bbd55dd9..59f7d1ac 100644 --- a/backend/src/interceptor/LoggingInterceptor.ts +++ b/backend/src/interceptor/LoggingInterceptor.ts @@ -7,18 +7,33 @@ import { ContextType, } from '@nestjs/common'; import { Observable } from 'rxjs'; +import { tap } from 'rxjs/operators'; import { GqlExecutionContext } from '@nestjs/graphql'; +import { TelemetryLogService } from './telemetry-log.service'; +import { InjectRepository } from '@nestjs/typeorm'; +import { User } from 'src/user/user.model'; +import { Repository } from 'typeorm'; @Injectable() export class LoggingInterceptor implements NestInterceptor { private readonly logger = new Logger('RequestLogger'); + private startTime: number; - intercept(context: ExecutionContext, next: CallHandler): Observable { + constructor( + private telemetryLogService: TelemetryLogService, + @InjectRepository(User) + private readonly userRepository: Repository, + ) {} + + async intercept( + context: ExecutionContext, + next: CallHandler, + ): Promise> { const contextType = context.getType(); this.logger.debug(`Intercepting request, Context Type: ${contextType}`); if (contextType === ('graphql' as ContextType)) { - return this.handleGraphQLRequest(context, next); + return await this.handleGraphQLRequest(context, next); } else if (contextType === 'http') { return this.handleRestRequest(context, next); } else { @@ -27,12 +42,18 @@ export class LoggingInterceptor implements NestInterceptor { } } - private handleGraphQLRequest( + private async handleGraphQLRequest( context: ExecutionContext, next: CallHandler, - ): Observable { + ): Promise> { const ctx = GqlExecutionContext.create(context); const info = ctx.getInfo(); + const userId = ctx.getContext().req.user?.userId; + const user = await this.userRepository.findOne({ + where: { id: userId }, + select: { email: true }, + }); + const email = user?.email; if (!info) { this.logger.warn( 'GraphQL request detected, but ctx.getInfo() is undefined.', @@ -42,6 +63,8 @@ export class LoggingInterceptor implements NestInterceptor { const { operation, fieldName } = info; let variables = ''; + const startTime = Date.now(); + const request = ctx.getContext().req; try { variables = JSON.stringify(ctx.getContext()?.req?.body?.variables ?? {}); @@ -50,12 +73,29 @@ export class LoggingInterceptor implements NestInterceptor { } this.logger.log( - `[GraphQL] ${operation.operation.toUpperCase()} \x1B[33m${fieldName}\x1B[39m${ + `[GraphQL] ${operation.operation.toUpperCase()} [33m${fieldName}[39m${ variables ? ` Variables: ${variables}` : '' }`, ); - return next.handle(); + return next.handle().pipe( + tap({ + next: (value) => { + const timeConsumed = Date.now() - startTime; + this.telemetryLogService.create({ + timestamp: new Date(), + requestMethod: operation.operation.toUpperCase(), + endpoint: fieldName, + input: variables, + output: JSON.stringify(value), + timeConsumed, + userId, + email, + handler: 'GraphQL', + }); + }, + }), + ); } private handleRestRequest( @@ -64,13 +104,30 @@ export class LoggingInterceptor implements NestInterceptor { ): Observable { const httpContext = context.switchToHttp(); const request = httpContext.getRequest(); + const startTime = Date.now(); - const { method, url, body } = request; + const { method, url, body, user } = request; this.logger.log( `[REST] ${method.toUpperCase()} ${url} Body: ${JSON.stringify(body)}`, ); - return next.handle(); + return next.handle().pipe( + tap({ + next: (value) => { + const timeConsumed = Date.now() - startTime; + this.telemetryLogService.create({ + timestamp: new Date(), + requestMethod: method, + endpoint: url, + input: JSON.stringify(body), + output: JSON.stringify(value), + timeConsumed, + userId: user?.id, + handler: 'REST', + }); + }, + }), + ); } } diff --git a/backend/src/interceptor/interceptor.module.ts b/backend/src/interceptor/interceptor.module.ts new file mode 100644 index 00000000..7ea05c84 --- /dev/null +++ b/backend/src/interceptor/interceptor.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { TelemetryLog } from './telemetry-log.model'; +import { TelemetryLogService } from './telemetry-log.service'; +import { LoggingInterceptor } from './LoggingInterceptor'; +import { User } from 'src/user/user.model'; + +@Module({ + imports: [TypeOrmModule.forFeature([TelemetryLog, User])], + providers: [TelemetryLogService, LoggingInterceptor], + exports: [TelemetryLogService, LoggingInterceptor], +}) +export class InterceptorModule {} diff --git a/backend/src/interceptor/telemetry-log.model.ts b/backend/src/interceptor/telemetry-log.model.ts new file mode 100644 index 00000000..9ea70c33 --- /dev/null +++ b/backend/src/interceptor/telemetry-log.model.ts @@ -0,0 +1,54 @@ +import { Field, ID, ObjectType } from '@nestjs/graphql'; +import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; + +@Entity() +@ObjectType() +export class TelemetryLog { + @PrimaryGeneratedColumn() + @Field(() => ID) + id: number; + + @Column({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' }) + @Field(() => Date) + timestamp: Date; + + @Column() + @Field() + requestMethod: string; + + @Column('text') + @Field() + endpoint: string; + + @Column('text', { nullable: true }) + @Field({ nullable: true }) + input: string; + + @Column('text', { nullable: true }) + @Field({ nullable: true }) + output: string; + + @Column({ nullable: true }) + @Field({ nullable: true }) + inputToken: string; + + @Column({ nullable: true }) + @Field({ nullable: true }) + outputToken: string; + + @Column('int') + @Field() + timeConsumed: number; + + @Column({ nullable: true }) + @Field({ nullable: true }) + userId: string; + + @Column({ nullable: true }) + @Field({ nullable: true }) + email: string; + + @Column({ nullable: true }) + @Field({ nullable: true }) + handler: string; +} diff --git a/backend/src/interceptor/telemetry-log.service.ts b/backend/src/interceptor/telemetry-log.service.ts new file mode 100644 index 00000000..7d6f8649 --- /dev/null +++ b/backend/src/interceptor/telemetry-log.service.ts @@ -0,0 +1,129 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { TelemetryLog } from './telemetry-log.model'; +import { TelemetryLogFilterInput } from 'src/dashboard/dto/telemetry-log-input'; + +@Injectable() +export class TelemetryLogService { + constructor( + @InjectRepository(TelemetryLog) + private telemetryLogRepository: Repository, + ) {} + + async create(data: Partial): Promise { + const telemetryLog = this.telemetryLogRepository.create(data); + return await this.telemetryLogRepository.save(telemetryLog); + } + + async findAll(): Promise { + return await this.telemetryLogRepository.find({ + order: { timestamp: 'DESC' }, + take: 100, + }); + } + + async findById(id: number): Promise { + return await this.telemetryLogRepository.findOne({ where: { id } }); + } + + async findFiltered(filter: TelemetryLogFilterInput): Promise { + const query = this.telemetryLogRepository.createQueryBuilder('log'); + + if (filter.startDate) { + query.andWhere('log.timestamp >= :startDate', { + startDate: filter.startDate, + }); + } + if (filter.endDate) { + query.andWhere('log.timestamp <= :endDate', { endDate: filter.endDate }); + } + if (filter.requestMethod) { + query.andWhere('log.requestMethod = :requestMethod', { + requestMethod: filter.requestMethod, + }); + } + if (filter.endpoint) { + query.andWhere('log.endpoint LIKE :endpoint', { + endpoint: `%${filter.endpoint}%`, + }); + } + if (filter.handler) { + query.andWhere('log.handler LIKE :handler', { + handler: `%${filter.handler}%`, + }); + } + if (filter.minTimeConsumed !== undefined) { + query.andWhere('log.timeConsumed >= :minTimeConsumed', { + minTimeConsumed: filter.minTimeConsumed, + }); + } + if (filter.maxTimeConsumed !== undefined) { + query.andWhere('log.timeConsumed <= :maxTimeConsumed', { + maxTimeConsumed: filter.maxTimeConsumed, + }); + } + if (filter.search) { + query.andWhere( + '(log.endpoint LIKE :search OR log.requestMethod LIKE :search)', + { search: `%${filter.search}%` }, + ); + } + + query.orderBy('log.timestamp', 'DESC'); + + return await query.getMany(); + } + + async countTelemetryLogs(filter?: TelemetryLogFilterInput): Promise { + const query = this.telemetryLogRepository.createQueryBuilder('log'); + + if (filter) { + if (filter.startDate) { + query.andWhere('log.timestamp >= :startDate', { + startDate: filter.startDate, + }); + } + if (filter.endDate) { + query.andWhere('log.timestamp <= :endDate', { + endDate: filter.endDate, + }); + } + if (filter.requestMethod) { + query.andWhere('log.requestMethod = :requestMethod', { + requestMethod: filter.requestMethod, + }); + } + if (filter.endpoint) { + query.andWhere('log.endpoint LIKE :endpoint', { + endpoint: `%${filter.endpoint}%`, + }); + } + if (filter.email) { + query.andWhere('log.email = :email', { email: filter.email }); + } + if (filter.handler) { + query.andWhere('log.handler LIKE :handler', { + handler: `%${filter.handler}%`, + }); + } + if (filter.minTimeConsumed !== undefined) { + query.andWhere('log.timeConsumed >= :minTimeConsumed', { + minTimeConsumed: filter.minTimeConsumed, + }); + } + if (filter.maxTimeConsumed !== undefined) { + query.andWhere('log.timeConsumed <= :maxTimeConsumed', { + maxTimeConsumed: filter.maxTimeConsumed, + }); + } + if (filter.search) { + query.andWhere( + '(log.endpoint LIKE :search OR log.requestMethod LIKE :search)', + { search: `%${filter.search}%` }, + ); + } + } + return await query.getCount(); + } +} diff --git a/backend/src/project/dto/project.input.ts b/backend/src/project/dto/project.input.ts index 4dc9c0be..bf911030 100644 --- a/backend/src/project/dto/project.input.ts +++ b/backend/src/project/dto/project.input.ts @@ -44,7 +44,7 @@ export class CreateProjectInput { @Field() description: string; - @Field(() => [ProjectPackage]) + @Field(() => [ProjectPackage], { nullable: true }) packages: ProjectPackage[]; @Field(() => String, { nullable: true }) diff --git a/backend/src/user/user.model.ts b/backend/src/user/user.model.ts index 8267f03e..4c9a6eb1 100644 --- a/backend/src/user/user.model.ts +++ b/backend/src/user/user.model.ts @@ -64,7 +64,8 @@ export class User extends SystemBaseModel { }) projects: Project[]; - @ManyToMany(() => Role) + @Field(() => [Role]) + @ManyToMany(() => Role, (role) => role.users) @JoinTable({ name: 'user_roles', joinColumn: { @@ -78,9 +79,6 @@ export class User extends SystemBaseModel { }) roles: Role[]; - /** - * The GitHub App installation ID for this user (if they have installed the app). - */ @Field({ nullable: true }) @Column({ nullable: true }) githubInstallationId?: string; @@ -88,17 +86,6 @@ export class User extends SystemBaseModel { @Column({ nullable: true }) githubAccessToken?: string; - /** - * This field is maintained for API compatibility but is no longer actively used. - * With the new design, a user's "subscribed projects" are just their own projects - * that have a forkedFromId (meaning they are copies of other projects). - * - * Important: Subscribed projects are full copies that users can freely modify. - * This is a key feature - allowing users to subscribe to a project and then - * customize it to their needs while keeping a reference to the original. - * - * Get a user's subscribed projects by querying their projects where forkedFromId is not null. - */ @Field(() => [Project], { nullable: true, deprecationReason: 'Use projects with forkedFromId instead', diff --git a/backend/src/user/user.module.ts b/backend/src/user/user.module.ts index 296675bd..3c54ee6b 100644 --- a/backend/src/user/user.module.ts +++ b/backend/src/user/user.module.ts @@ -9,10 +9,11 @@ import { AuthModule } from 'src/auth/auth.module'; import { MailModule } from 'src/mail/mail.module'; import { UploadModule } from 'src/upload/upload.module'; import { GitHubModule } from 'src/github/github.module'; +import { Role } from 'src/auth/role/role.model'; @Module({ imports: [ - TypeOrmModule.forFeature([User]), + TypeOrmModule.forFeature([User, Role]), JwtModule, AuthModule, MailModule, diff --git a/dashboard/.eslintignore b/dashboard/.eslintignore new file mode 100644 index 00000000..f6fb74c5 --- /dev/null +++ b/dashboard/.eslintignore @@ -0,0 +1,4 @@ +# .estlintignore file +dist +build +node_modules/ \ No newline at end of file diff --git a/dashboard/.eslintrc.js b/dashboard/.eslintrc.js new file mode 100644 index 00000000..10cfc5e8 --- /dev/null +++ b/dashboard/.eslintrc.js @@ -0,0 +1,68 @@ +/* eslint-disable no-undef */ +module.exports = { + root: true, + parser: '@typescript-eslint/parser', + parserOptions: { + ecmaFeatures: { + jsx: true + }, + ecmaVersion: 2021, + sourceType: 'module' + }, + env: { + browser: true, + es2021: true + }, + extends: [ + 'plugin:react/recommended', + 'airbnb-typescript', + 'plugin:react/jsx-runtime', + 'plugin:prettier/recommended' + ], + plugins: ['@typescript-eslint', 'react'], + settings: { + react: { + version: 'detect' + } + }, + rules: { + 'prettier/prettier': 'off', + 'react/jsx-filename-extension': 'off', + 'import/no-unresolved': 'off', + 'import/extensions': 'off', + 'react/display-name': 'off', + '@typescript-eslint/comma-dangle': 'off', + 'import/prefer-default-export': 'off', + 'jsx-a11y/anchor-is-valid': 'off', + 'comma-dangle': 'off', + 'max-len': 'off', + 'no-console': 'off', + 'no-param-reassign': 'off', + 'no-plusplus': 'off', + 'no-return-assign': 'off', + 'object-curly-newline': 'off', + 'react/jsx-props-no-spreading': 'off', + 'react/react-in-jsx-scope': 'off', + 'react/require-default-props': 'off', + 'typescript-eslint/no-unused-vars': 'off', + 'import/no-extraneous-dependencies': 'off', + 'react/no-unescaped-entities': 'off', + 'react/forbid-prop-types': 'off', + 'react/jsx-max-props-per-line': [ + 1, + { + maximum: 2, + when: 'multiline' + } + ], + indent: 'off', + '@typescript-eslint/indent': [0], + 'no-use-before-define': 'off', + '@typescript-eslint/no-use-before-define': ['off'], + '@typescript-eslint/no-unused-vars': ['off'], + '@typescript-eslint/no-shadow': ['off'], + '@typescript-eslint/dot-notation': ['off'], + 'react/prop-types': ['off'], + '@typescript-eslint/naming-convention': ['off'] + } +}; diff --git a/dashboard/.gitignore b/dashboard/.gitignore new file mode 100644 index 00000000..c2b6bad9 --- /dev/null +++ b/dashboard/.gitignore @@ -0,0 +1,26 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# production +/build + +# misc +.DS_Store +/.env +/.env.local +/.env.development.local +/.env.test.local +/.env.production.local +.idea +features.html + +npm-debug.log* +yarn-debug.log* +yarn-error.log* diff --git a/dashboard/.prettierrc b/dashboard/.prettierrc new file mode 100644 index 00000000..abd07b62 --- /dev/null +++ b/dashboard/.prettierrc @@ -0,0 +1,9 @@ +{ + "bracketSpacing": true, + "printWidth": 80, + "singleQuote": true, + "trailingComma": "none", + "tabWidth": 2, + "useTabs": false, + "bracketSameLine": false +} diff --git a/dashboard/LICENSE b/dashboard/LICENSE new file mode 100644 index 00000000..5bff574e --- /dev/null +++ b/dashboard/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 BloomUI + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/dashboard/README.md b/dashboard/README.md new file mode 100644 index 00000000..1ad2bdca --- /dev/null +++ b/dashboard/README.md @@ -0,0 +1,64 @@ +

+ + Tokyo Free White Typescript React Admin Dashboard + +

+

+ Tokyo Free White Typescript React Admin Dashboard +
+ + + +

+
+ +![version](https://img.shields.io/badge/version-2.0.0-blue.svg) +![license](https://img.shields.io/badge/license-MIT-blue.svg) + + + +
+ +

Free React Typescript Admin Dashboard Template built with Material-UI

+ +

+ This free and open source admin dashboard template is built for React and it’s bootstrapped from Facebook’s create-react-app. All NPM dependencies are up to date and it contains multiple fully customized components based on the popular frontend components framework, Material-UI. +

+

+Tokyo Free White Typescript Dashboard features a nice classic light & clean design and color scheme. +

+

+You can customize the color scheme and style by editing a single variables files. This Typescript admin dashboard doesn’t use SCSS stylesheets but the more modern approach with styled-components. +

+

+We’ve included a few page examples for most used user flows that will give you a solid base for getting started with your new project’s development. With very light modifications you can even integrate Tokyo Free White Typescript Dashboard into existing projects giving them a much deserved makeover. +

+ +--- + +

Updrade to PRO

+ +

If you're looking for more features like translations, complex user flows, redux examples and more, we recommend taking a look at the premium version (Tokyo White Typescript Dashboard) on bloomui.com

+ +--- + +

+ Quick Start +

+
    +
  1. Make sure you have the latest stable versions for Node.js and NPM installed
  2. +
  3. Clone repository: git clone https://github.com/bloomui/tokyo-free-white-react-admin-dashboard.git
  4. +
  5. Install dependencies: Run npm install inside the project folder
  6. +
  7. Start dev server: After the install finishes, run npm run start. A browser window will open on http://localhost:3000 where you''ll see the live preview
  8. +
+ +--- + +

+ Technical Support +

+

+ You can open a support ticket by sending an email here: + support@bloomui.freshdesk.com + +

diff --git a/dashboard/apollo.config.js b/dashboard/apollo.config.js new file mode 100644 index 00000000..a645577c --- /dev/null +++ b/dashboard/apollo.config.js @@ -0,0 +1,11 @@ +/* eslint-env node */ +module.exports = { + client: { + service: { + name: 'codefox-backend1', + localSchemaFile: './src/graphql/schema.gql' + }, + includes: ['./src/**/*.{js,ts,tsx}'], + excludes: ['**/__tests__/**'] + } +}; diff --git a/dashboard/codegen.ts b/dashboard/codegen.ts new file mode 100644 index 00000000..73299f0f --- /dev/null +++ b/dashboard/codegen.ts @@ -0,0 +1,39 @@ +import { CodegenConfig } from '@graphql-codegen/cli'; + +const config: CodegenConfig = { + schema: './src/graphql/schema.gql', + ignoreNoDocuments: true, + generates: { + 'src/graphql/type.tsx': { + plugins: [ + 'typescript', + 'typescript-operations', + 'typescript-resolvers', + 'typescript-react-apollo' + ], + config: { + withHooks: true, + useIndexSignature: true, + enumsAsTypes: true, + constEnums: true, + skipTypename: false, + dedupeOperationSuffix: true, + nonOptionalTypename: true, + preResolveTypes: true, + namingConvention: { + enumValues: 'keep' + }, + scalars: { + Date: 'Date' + } + } + } + }, + hooks: { + afterOneFileWrite: ['prettier --write'], + afterAllFileWrite: ['echo "✨ GraphQL types generated successfully"'] + }, + watch: ['./src/graphql/schema.gql'] +}; + +export default config; diff --git a/dashboard/package.json b/dashboard/package.json new file mode 100644 index 00000000..92290c0f --- /dev/null +++ b/dashboard/package.json @@ -0,0 +1,88 @@ +{ + "name": "tokyo-free-white-react-admin-dashboard", + "version": "2.0.0", + "title": "Tokyo Free White React Typescript Admin Dashboard", + "description": "This free and open source admin dashboard template is built for React and it’s bootstrapped from Facebook’s create-react-app. All NPM dependencies are up to date and it contains multiple fully customized components based on the popular frontend components framework, Material-UI.", + "author": { + "name": "BloomUI.com", + "url": "https://bloomui.com" + }, + "private": false, + "dependencies": { + "@apollo/client": "^3.13.5", + "@emotion/react": "11.9.0", + "@emotion/styled": "11.8.1", + "@mui/icons-material": "5.8.2", + "@mui/lab": "5.0.0-alpha.84", + "@mui/material": "5.8.2", + "@mui/styles": "5.8.0", + "@types/react": "17.0.40", + "@types/react-dom": "17.0.13", + "apexcharts": "3.35.3", + "apollo-upload-client": "^18.0.0", + "clsx": "1.1.1", + "csstype": "^3.1.3", + "date-fns": "2.28.0", + "eslint-config-next": "14.2.13", + "graphql": "^16.9.0", + "graphql-ws": "^5.16.0", + "history": "5.3.0", + "next": "^14.2.13", + "nprogress": "0.2.0", + "numeral": "2.0.6", + "pino": "^9.6.0", + "prop-types": "15.8.1", + "react": "17.0.2", + "react-apexcharts": "1.4.0", + "react-custom-scrollbars-2": "4.4.0", + "react-dom": "17.0.2", + "react-helmet-async": "1.3.0", + "react-router": "6.3.0", + "react-router-dom": "6.3.0", + "react-scripts": "5.0.1", + "sonner": "^1.5.0", + "stylis": "4.1.1", + "stylis-plugin-rtl": "2.1.1", + "tailwind-merge": "^2.5.2", + "typescript": "4.7.3", + "web-vitals": "2.1.4" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "eject": "react-scripts eject", + "lint": "eslint .", + "lint:fix": "eslint --fix", + "format": "prettier --write \"./**/*.{ts,tsx,js,jsx,json}\" --config ./.prettierrc" + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "devDependencies": { + "@graphql-codegen/cli": "^5.0.3", + "@graphql-codegen/typescript": "^4.1.0", + "@graphql-codegen/typescript-operations": "^4.3.0", + "@graphql-codegen/typescript-react-apollo": "^4.3.2", + "@graphql-codegen/typescript-resolvers": "^4.3.0", + "@typescript-eslint/eslint-plugin": "5.27.0", + "@typescript-eslint/parser": "5.27.0", + "eslint": "8.17.0", + "eslint-config-airbnb-typescript": "17.0.0", + "eslint-config-prettier": "8.5.0", + "eslint-plugin-import": "2.26.0", + "eslint-plugin-jsx-a11y": "6.5.1", + "eslint-plugin-prettier": "4.0.0", + "eslint-plugin-react": "7.30.0", + "eslint-plugin-react-hooks": "4.5.0", + "prettier": "2.6.2" + } +} diff --git a/dashboard/public/_redirects b/dashboard/public/_redirects new file mode 100644 index 00000000..50a46335 --- /dev/null +++ b/dashboard/public/_redirects @@ -0,0 +1 @@ +/* /index.html 200 \ No newline at end of file diff --git a/dashboard/public/favicon.ico b/dashboard/public/favicon.ico new file mode 100644 index 00000000..395b8e9c Binary files /dev/null and b/dashboard/public/favicon.ico differ diff --git a/dashboard/public/icon-192x192.png b/dashboard/public/icon-192x192.png new file mode 100644 index 00000000..33234458 Binary files /dev/null and b/dashboard/public/icon-192x192.png differ diff --git a/dashboard/public/icon-256x256.png b/dashboard/public/icon-256x256.png new file mode 100644 index 00000000..3986cfe8 Binary files /dev/null and b/dashboard/public/icon-256x256.png differ diff --git a/dashboard/public/icon-384x384.png b/dashboard/public/icon-384x384.png new file mode 100644 index 00000000..2ca70cc7 Binary files /dev/null and b/dashboard/public/icon-384x384.png differ diff --git a/dashboard/public/icon-512x512.png b/dashboard/public/icon-512x512.png new file mode 100644 index 00000000..9eed46f2 Binary files /dev/null and b/dashboard/public/icon-512x512.png differ diff --git a/dashboard/public/index.html b/dashboard/public/index.html new file mode 100644 index 00000000..feb040e7 --- /dev/null +++ b/dashboard/public/index.html @@ -0,0 +1,31 @@ + + + + + + + + + + Tokyo Free White React Typescript Admin Dashboard + + + + + + +
+ + diff --git a/dashboard/public/manifest.json b/dashboard/public/manifest.json new file mode 100644 index 00000000..36f5e458 --- /dev/null +++ b/dashboard/public/manifest.json @@ -0,0 +1,30 @@ +{ + "theme_color": "#1975ff", + "background_color": "#f2f5f9", + "display": "standalone", + "start_url": ".", + "short_name": "Tokyo Free White", + "name": "Tokyo Free White React Typescript Admin Dashboard", + "icons": [ + { + "src": "/icon-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/icon-256x256.png", + "sizes": "256x256", + "type": "image/png" + }, + { + "src": "/icon-384x384.png", + "sizes": "384x384", + "type": "image/png" + }, + { + "src": "/icon-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ] +} diff --git a/dashboard/public/robots.txt b/dashboard/public/robots.txt new file mode 100644 index 00000000..e9e57dc4 --- /dev/null +++ b/dashboard/public/robots.txt @@ -0,0 +1,3 @@ +# https://www.robotstxt.org/robotstxt.html +User-agent: * +Disallow: diff --git a/dashboard/public/static/images/avatars/1.jpg b/dashboard/public/static/images/avatars/1.jpg new file mode 100644 index 00000000..abee8de3 Binary files /dev/null and b/dashboard/public/static/images/avatars/1.jpg differ diff --git a/dashboard/public/static/images/avatars/2.jpg b/dashboard/public/static/images/avatars/2.jpg new file mode 100644 index 00000000..e6fac5f5 Binary files /dev/null and b/dashboard/public/static/images/avatars/2.jpg differ diff --git a/dashboard/public/static/images/avatars/3.jpg b/dashboard/public/static/images/avatars/3.jpg new file mode 100644 index 00000000..f2e99c55 Binary files /dev/null and b/dashboard/public/static/images/avatars/3.jpg differ diff --git a/dashboard/public/static/images/avatars/4.jpg b/dashboard/public/static/images/avatars/4.jpg new file mode 100644 index 00000000..7c62d172 Binary files /dev/null and b/dashboard/public/static/images/avatars/4.jpg differ diff --git a/dashboard/public/static/images/avatars/5.jpg b/dashboard/public/static/images/avatars/5.jpg new file mode 100644 index 00000000..18204383 Binary files /dev/null and b/dashboard/public/static/images/avatars/5.jpg differ diff --git a/dashboard/public/static/images/logo/google.svg b/dashboard/public/static/images/logo/google.svg new file mode 100644 index 00000000..9ec3b128 --- /dev/null +++ b/dashboard/public/static/images/logo/google.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/dashboard/public/static/images/logo/material-ui.svg b/dashboard/public/static/images/logo/material-ui.svg new file mode 100644 index 00000000..9c2dba7e --- /dev/null +++ b/dashboard/public/static/images/logo/material-ui.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashboard/public/static/images/logo/typescript.svg b/dashboard/public/static/images/logo/typescript.svg new file mode 100644 index 00000000..339da0b6 --- /dev/null +++ b/dashboard/public/static/images/logo/typescript.svg @@ -0,0 +1,6 @@ + + +TypeScript logo + + + diff --git a/dashboard/public/static/images/overview/tokyo-logo.png b/dashboard/public/static/images/overview/tokyo-logo.png new file mode 100644 index 00000000..7894a041 Binary files /dev/null and b/dashboard/public/static/images/overview/tokyo-logo.png differ diff --git a/dashboard/public/static/images/placeholders/covers/1.jpg b/dashboard/public/static/images/placeholders/covers/1.jpg new file mode 100644 index 00000000..534c8c25 Binary files /dev/null and b/dashboard/public/static/images/placeholders/covers/1.jpg differ diff --git a/dashboard/public/static/images/placeholders/covers/5.jpg b/dashboard/public/static/images/placeholders/covers/5.jpg new file mode 100644 index 00000000..76d7a107 Binary files /dev/null and b/dashboard/public/static/images/placeholders/covers/5.jpg differ diff --git a/dashboard/public/static/images/placeholders/covers/6.jpg b/dashboard/public/static/images/placeholders/covers/6.jpg new file mode 100644 index 00000000..4c89127d Binary files /dev/null and b/dashboard/public/static/images/placeholders/covers/6.jpg differ diff --git a/dashboard/public/static/images/placeholders/illustrations/1.svg b/dashboard/public/static/images/placeholders/illustrations/1.svg new file mode 100644 index 00000000..683471ed --- /dev/null +++ b/dashboard/public/static/images/placeholders/illustrations/1.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/dashboard/public/static/images/placeholders/illustrations/2.png b/dashboard/public/static/images/placeholders/illustrations/2.png new file mode 100644 index 00000000..2a9412a9 Binary files /dev/null and b/dashboard/public/static/images/placeholders/illustrations/2.png differ diff --git a/dashboard/public/static/images/placeholders/illustrations/3.svg b/dashboard/public/static/images/placeholders/illustrations/3.svg new file mode 100644 index 00000000..fa42a637 --- /dev/null +++ b/dashboard/public/static/images/placeholders/illustrations/3.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/dashboard/public/static/images/placeholders/illustrations/4.svg b/dashboard/public/static/images/placeholders/illustrations/4.svg new file mode 100644 index 00000000..9dea2466 --- /dev/null +++ b/dashboard/public/static/images/placeholders/illustrations/4.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/dashboard/public/static/images/placeholders/illustrations/5.svg b/dashboard/public/static/images/placeholders/illustrations/5.svg new file mode 100644 index 00000000..71a9b64f --- /dev/null +++ b/dashboard/public/static/images/placeholders/illustrations/5.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/dashboard/public/static/images/placeholders/illustrations/6.png b/dashboard/public/static/images/placeholders/illustrations/6.png new file mode 100644 index 00000000..570aa844 Binary files /dev/null and b/dashboard/public/static/images/placeholders/illustrations/6.png differ diff --git a/dashboard/public/static/images/placeholders/logo/bitcoin.png b/dashboard/public/static/images/placeholders/logo/bitcoin.png new file mode 100644 index 00000000..a95277f7 Binary files /dev/null and b/dashboard/public/static/images/placeholders/logo/bitcoin.png differ diff --git a/dashboard/public/static/images/placeholders/logo/cardano.png b/dashboard/public/static/images/placeholders/logo/cardano.png new file mode 100644 index 00000000..3e78fc48 Binary files /dev/null and b/dashboard/public/static/images/placeholders/logo/cardano.png differ diff --git a/dashboard/public/static/images/placeholders/logo/ethereum.png b/dashboard/public/static/images/placeholders/logo/ethereum.png new file mode 100644 index 00000000..4e54bb48 Binary files /dev/null and b/dashboard/public/static/images/placeholders/logo/ethereum.png differ diff --git a/dashboard/public/static/images/placeholders/logo/google-logo.jpg b/dashboard/public/static/images/placeholders/logo/google-logo.jpg new file mode 100644 index 00000000..b5a4ba1e Binary files /dev/null and b/dashboard/public/static/images/placeholders/logo/google-logo.jpg differ diff --git a/dashboard/public/static/images/placeholders/logo/mastercard.png b/dashboard/public/static/images/placeholders/logo/mastercard.png new file mode 100644 index 00000000..087b3517 Binary files /dev/null and b/dashboard/public/static/images/placeholders/logo/mastercard.png differ diff --git a/dashboard/public/static/images/placeholders/logo/ripple.png b/dashboard/public/static/images/placeholders/logo/ripple.png new file mode 100644 index 00000000..a98f7306 Binary files /dev/null and b/dashboard/public/static/images/placeholders/logo/ripple.png differ diff --git a/dashboard/public/static/images/placeholders/logo/visa.png b/dashboard/public/static/images/placeholders/logo/visa.png new file mode 100644 index 00000000..7bbc3e03 Binary files /dev/null and b/dashboard/public/static/images/placeholders/logo/visa.png differ diff --git a/dashboard/public/static/images/status/404.svg b/dashboard/public/static/images/status/404.svg new file mode 100644 index 00000000..9c4d4f61 --- /dev/null +++ b/dashboard/public/static/images/status/404.svg @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dashboard/public/static/images/status/500.svg b/dashboard/public/static/images/status/500.svg new file mode 100644 index 00000000..83bf66e6 --- /dev/null +++ b/dashboard/public/static/images/status/500.svg @@ -0,0 +1,110 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dashboard/public/static/images/status/coming-soon.svg b/dashboard/public/static/images/status/coming-soon.svg new file mode 100644 index 00000000..beae8c7a --- /dev/null +++ b/dashboard/public/static/images/status/coming-soon.svg @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dashboard/public/static/images/status/maintenance.svg b/dashboard/public/static/images/status/maintenance.svg new file mode 100644 index 00000000..73822d70 --- /dev/null +++ b/dashboard/public/static/images/status/maintenance.svg @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx new file mode 100644 index 00000000..cdf02a93 --- /dev/null +++ b/dashboard/src/App.tsx @@ -0,0 +1,41 @@ +import { useRoutes, useLocation } from 'react-router-dom'; +import router from 'src/router'; + +import AdapterDateFns from '@mui/lab/AdapterDateFns'; +import LocalizationProvider from '@mui/lab/LocalizationProvider'; + +import { CssBaseline } from '@mui/material'; +import ThemeProvider from './theme/ThemeProvider'; +import { ApolloProvider } from '@apollo/client'; +import { AuthProvider } from './contexts/AuthContext'; +import { ProtectedRoute } from './components/ProtectedRoute'; +import client from './lib/client'; + +function Routes() { + const content = useRoutes(router); + const location = useLocation(); + const isLoginPage = location.pathname === '/'; + + if (isLoginPage) { + return content; + } + + return {content}; +} + +function App() { + return ( + + + + + + + + + + + ); +} + +export default App; diff --git a/dashboard/src/components/Footer/index.tsx b/dashboard/src/components/Footer/index.tsx new file mode 100644 index 00000000..41d2b7f7 --- /dev/null +++ b/dashboard/src/components/Footer/index.tsx @@ -0,0 +1,44 @@ +import { Box, Container, Link, Typography, styled } from '@mui/material'; + +const FooterWrapper = styled(Container)( + ({ theme }) => ` + margin-top: ${theme.spacing(4)}; +` +); + +function Footer() { + return ( + + + + + © 2022 - Tokyo Free White React Typescript Admin Dashboard + + + + Crafted by{' '} + + BloomUI.com + + + + + ); +} + +export default Footer; diff --git a/dashboard/src/components/Label/index.tsx b/dashboard/src/components/Label/index.tsx new file mode 100644 index 00000000..adc5d368 --- /dev/null +++ b/dashboard/src/components/Label/index.tsx @@ -0,0 +1,95 @@ +import { FC, ReactNode } from 'react'; +import PropTypes from 'prop-types'; +import { styled } from '@mui/material/styles'; + +interface LabelProps { + className?: string; + color?: + | 'primary' + | 'black' + | 'secondary' + | 'error' + | 'warning' + | 'success' + | 'info'; + children?: ReactNode; +} + +const LabelWrapper = styled('span')( + ({ theme }) => ` + background-color: ${theme.colors.alpha.black[5]}; + padding: ${theme.spacing(0.5, 1)}; + font-size: ${theme.typography.pxToRem(13)}; + border-radius: ${theme.general.borderRadius}; + display: inline-flex; + align-items: center; + justify-content: center; + max-height: ${theme.spacing(3)}; + + &.MuiLabel { + &-primary { + background-color: ${theme.colors.primary.lighter}; + color: ${theme.palette.primary.main} + } + + &-black { + background-color: ${theme.colors.alpha.black[100]}; + color: ${theme.colors.alpha.white[100]}; + } + + &-secondary { + background-color: ${theme.colors.secondary.lighter}; + color: ${theme.palette.secondary.main} + } + + &-success { + background-color: ${theme.colors.success.lighter}; + color: ${theme.palette.success.main} + } + + &-warning { + background-color: ${theme.colors.warning.lighter}; + color: ${theme.palette.warning.main} + } + + &-error { + background-color: ${theme.colors.error.lighter}; + color: ${theme.palette.error.main} + } + + &-info { + background-color: ${theme.colors.info.lighter}; + color: ${theme.palette.info.main} + } + } +` +); + +const Label: FC = ({ + className, + color = 'secondary', + children, + ...rest +}) => { + return ( + + {children} + + ); +}; + +Label.propTypes = { + children: PropTypes.node, + className: PropTypes.string, + color: PropTypes.oneOf([ + 'primary', + 'black', + 'secondary', + 'error', + 'warning', + 'success', + 'info' + ]) +}; + +export default Label; diff --git a/dashboard/src/components/Logo/index.tsx b/dashboard/src/components/Logo/index.tsx new file mode 100644 index 00000000..d7873e27 --- /dev/null +++ b/dashboard/src/components/Logo/index.tsx @@ -0,0 +1,124 @@ +import { Box, styled, Tooltip } from '@mui/material'; +import { Link } from 'react-router-dom'; + +const LogoWrapper = styled(Link)( + ({ theme }) => ` + color: ${theme.palette.text.primary}; + padding: ${theme.spacing(0, 1, 0, 0)}; + display: flex; + text-decoration: none; + font-weight: ${theme.typography.fontWeightBold}; +` +); + +const LogoSignWrapper = styled(Box)( + () => ` + width: 52px; + height: 38px; + margin-top: 4px; + transform: scale(.8); +` +); + +const LogoSign = styled(Box)( + ({ theme }) => ` + background: ${theme.general.reactFrameworkColor}; + width: 18px; + height: 18px; + border-radius: ${theme.general.borderRadiusSm}; + position: relative; + transform: rotate(45deg); + top: 3px; + left: 17px; + + &:after, + &:before { + content: ""; + display: block; + width: 18px; + height: 18px; + position: absolute; + top: -1px; + right: -20px; + transform: rotate(0deg); + border-radius: ${theme.general.borderRadiusSm}; + } + + &:before { + background: ${theme.palette.primary.main}; + right: auto; + left: 0; + top: 20px; + } + + &:after { + background: ${theme.palette.secondary.main}; + } +` +); + +const LogoSignInner = styled(Box)( + ({ theme }) => ` + width: 16px; + height: 16px; + position: absolute; + top: 12px; + left: 12px; + z-index: 5; + border-radius: ${theme.general.borderRadiusSm}; + background: ${theme.header.background}; +` +); + +const LogoTextWrapper = styled(Box)( + ({ theme }) => ` + padding-left: ${theme.spacing(1)}; +` +); + +const VersionBadge = styled(Box)( + ({ theme }) => ` + background: ${theme.palette.success.main}; + color: ${theme.palette.success.contrastText}; + padding: ${theme.spacing(0.4, 1)}; + border-radius: ${theme.general.borderRadiusSm}; + text-align: center; + display: inline-block; + line-height: 1; + font-size: ${theme.typography.pxToRem(11)}; +` +); + +const LogoText = styled(Box)( + ({ theme }) => ` + font-size: ${theme.typography.pxToRem(15)}; + font-weight: ${theme.typography.fontWeightBold}; +` +); + +function Logo() { + return ( + + + + + + + + + + 3.1 + + Tokyo Free White + + + + ); +} + +export default Logo; diff --git a/dashboard/src/components/LogoSign/index.tsx b/dashboard/src/components/LogoSign/index.tsx new file mode 100644 index 00000000..ef0d4b13 --- /dev/null +++ b/dashboard/src/components/LogoSign/index.tsx @@ -0,0 +1,129 @@ +import { + Box, + Tooltip, + Badge, + TooltipProps, + tooltipClasses, + styled, + useTheme +} from '@mui/material'; +import { Link } from 'react-router-dom'; + +const LogoWrapper = styled(Link)( + ({ theme }) => ` + color: ${theme.palette.text.primary}; + display: flex; + text-decoration: none; + width: 53px; + margin: 0 auto; + font-weight: ${theme.typography.fontWeightBold}; +` +); + +const LogoSignWrapper = styled(Box)( + () => ` + width: 52px; + height: 38px; +` +); + +const LogoSign = styled(Box)( + ({ theme }) => ` + background: ${theme.general.reactFrameworkColor}; + width: 18px; + height: 18px; + border-radius: ${theme.general.borderRadiusSm}; + position: relative; + transform: rotate(45deg); + top: 3px; + left: 17px; + + &:after, + &:before { + content: ""; + display: block; + width: 18px; + height: 18px; + position: absolute; + top: -1px; + right: -20px; + transform: rotate(0deg); + border-radius: ${theme.general.borderRadiusSm}; + } + + &:before { + background: ${theme.palette.primary.main}; + right: auto; + left: 0; + top: 20px; + } + + &:after { + background: ${theme.palette.secondary.main}; + } +` +); + +const LogoSignInner = styled(Box)( + ({ theme }) => ` + width: 16px; + height: 16px; + position: absolute; + top: 12px; + left: 12px; + z-index: 5; + border-radius: ${theme.general.borderRadiusSm}; + background: ${theme.header.background}; +` +); + +const TooltipWrapper = styled(({ className, ...props }: TooltipProps) => ( + +))(({ theme }) => ({ + [`& .${tooltipClasses.tooltip}`]: { + backgroundColor: theme.colors.alpha.trueWhite[100], + color: theme.palette.getContrastText(theme.colors.alpha.trueWhite[100]), + fontSize: theme.typography.pxToRem(12), + fontWeight: 'bold', + borderRadius: theme.general.borderRadiusSm, + boxShadow: + '0 .2rem .8rem rgba(7,9,25,.18), 0 .08rem .15rem rgba(7,9,25,.15)' + }, + [`& .${tooltipClasses.arrow}`]: { + color: theme.colors.alpha.trueWhite[100] + } +})); + +function Logo() { + const theme = useTheme(); + + return ( + + + + + + + + + + + + ); +} + +export default Logo; diff --git a/dashboard/src/components/PageTitle/index.tsx b/dashboard/src/components/PageTitle/index.tsx new file mode 100644 index 00000000..612a02ab --- /dev/null +++ b/dashboard/src/components/PageTitle/index.tsx @@ -0,0 +1,53 @@ +import { FC } from 'react'; +import PropTypes from 'prop-types'; +import AddTwoToneIcon from '@mui/icons-material/AddTwoTone'; +import { Typography, Button, Grid } from '@mui/material'; + +interface PageTitleProps { + heading?: string; + subHeading?: string; + docs?: string; +} + +const PageTitle: FC = ({ + heading = '', + subHeading = '', + docs = '', + ...rest +}) => { + return ( + + + + {heading} + + {subHeading} + + + + + + ); +}; + +PageTitle.propTypes = { + heading: PropTypes.string, + subHeading: PropTypes.string, + docs: PropTypes.string +}; + +export default PageTitle; diff --git a/dashboard/src/components/PageTitleWrapper/index.tsx b/dashboard/src/components/PageTitleWrapper/index.tsx new file mode 100644 index 00000000..cbbb59cf --- /dev/null +++ b/dashboard/src/components/PageTitleWrapper/index.tsx @@ -0,0 +1,27 @@ +import { FC, ReactNode } from 'react'; +import PropTypes from 'prop-types'; +import { Box, Container, styled } from '@mui/material'; + +const PageTitle = styled(Box)( + ({ theme }) => ` + padding: ${theme.spacing(4)}; +` +); + +interface PageTitleWrapperProps { + children?: ReactNode; +} + +const PageTitleWrapper: FC = ({ children }) => { + return ( + + {children} + + ); +}; + +PageTitleWrapper.propTypes = { + children: PropTypes.node.isRequired +}; + +export default PageTitleWrapper; diff --git a/dashboard/src/components/ProtectedRoute/index.tsx b/dashboard/src/components/ProtectedRoute/index.tsx new file mode 100644 index 00000000..06f9a324 --- /dev/null +++ b/dashboard/src/components/ProtectedRoute/index.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { Navigate } from 'react-router-dom'; +import { useAuth } from '../../contexts/AuthContext'; + +interface ProtectedRouteProps { + children: React.ReactNode; +} + +export function ProtectedRoute({ children }: ProtectedRouteProps) { + const { isAuthorized, isLoading } = useAuth(); + + if (isLoading) { + return
Loading...
; + } + + if (!isAuthorized) { + return ; + } + + return <>{children}; +} diff --git a/dashboard/src/components/Scrollbar/index.tsx b/dashboard/src/components/Scrollbar/index.tsx new file mode 100644 index 00000000..22c734a4 --- /dev/null +++ b/dashboard/src/components/Scrollbar/index.tsx @@ -0,0 +1,46 @@ +import { FC, ReactNode } from 'react'; +import PropTypes from 'prop-types'; +import { Scrollbars } from 'react-custom-scrollbars-2'; + +import { Box, useTheme } from '@mui/material'; + +interface ScrollbarProps { + className?: string; + children?: ReactNode; +} + +const Scrollbar: FC = ({ className, children, ...rest }) => { + const theme = useTheme(); + + return ( + { + return ( + + ); + }} + {...rest} + > + {children} + + ); +}; + +Scrollbar.propTypes = { + children: PropTypes.node, + className: PropTypes.string +}; + +export default Scrollbar; diff --git a/dashboard/src/components/SuspenseLoader/index.tsx b/dashboard/src/components/SuspenseLoader/index.tsx new file mode 100644 index 00000000..6e4c0c4e --- /dev/null +++ b/dashboard/src/components/SuspenseLoader/index.tsx @@ -0,0 +1,32 @@ +import { useEffect } from 'react'; +import NProgress from 'nprogress'; +import { Box, CircularProgress } from '@mui/material'; + +function SuspenseLoader() { + useEffect(() => { + NProgress.start(); + + return () => { + NProgress.done(); + }; + }, []); + + return ( + + + + ); +} + +export default SuspenseLoader; diff --git a/dashboard/src/components/Text/index.tsx b/dashboard/src/components/Text/index.tsx new file mode 100644 index 00000000..317de2ea --- /dev/null +++ b/dashboard/src/components/Text/index.tsx @@ -0,0 +1,93 @@ +import { FC, ReactNode } from 'react'; +import PropTypes from 'prop-types'; +import { styled } from '@mui/material/styles'; +import clsx from 'clsx'; + +interface TextProps { + className?: string; + color?: + | 'primary' + | 'secondary' + | 'error' + | 'warning' + | 'success' + | 'info' + | 'black'; + flex?: boolean; + children?: ReactNode; +} + +const TextWrapper = styled('span')( + ({ theme }) => ` + display: inline-block; + align-items: center; + + &.flexItem { + display: inline-flex; + } + + &.MuiText { + + &-black { + color: ${theme.palette.common.black} + } + + &-primary { + color: ${theme.palette.primary.main} + } + + &-secondary { + color: ${theme.palette.secondary.main} + } + + &-success { + color: ${theme.palette.success.main} + } + + &-warning { + color: ${theme.palette.warning.main} + } + + &-error { + color: ${theme.palette.error.main} + } + + &-info { + color: ${theme.palette.info.main} + } + } +` +); + +const Text: FC = ({ + className, + color = 'secondary', + flex, + children, + ...rest +}) => { + return ( + + {children} + + ); +}; + +Text.propTypes = { + children: PropTypes.node, + className: PropTypes.string, + color: PropTypes.oneOf([ + 'primary', + 'secondary', + 'error', + 'warning', + 'success', + 'info', + 'black' + ]) +}; + +export default Text; diff --git a/dashboard/src/content/applications/Messenger/BottomBarContent.tsx b/dashboard/src/content/applications/Messenger/BottomBarContent.tsx new file mode 100644 index 00000000..7f734ba2 --- /dev/null +++ b/dashboard/src/content/applications/Messenger/BottomBarContent.tsx @@ -0,0 +1,80 @@ +import { + Avatar, + Tooltip, + IconButton, + Box, + Button, + styled, + InputBase, + useTheme +} from '@mui/material'; +import AttachFileTwoToneIcon from '@mui/icons-material/AttachFileTwoTone'; +import SendTwoToneIcon from '@mui/icons-material/SendTwoTone'; + +const MessageInputWrapper = styled(InputBase)( + ({ theme }) => ` + font-size: ${theme.typography.pxToRem(18)}; + padding: ${theme.spacing(1)}; + width: 100%; +` +); + +const Input = styled('input')({ + display: 'none' +}); + +function BottomBarContent() { + const theme = useTheme(); + + const user = { + name: 'Catherine Pike', + avatar: '/static/images/avatars/1.jpg' + }; + + return ( + + + + + + + + + 😀 + + + + + + + + + + ); +} + +export default BottomBarContent; diff --git a/dashboard/src/content/applications/Messenger/ChatContent.tsx b/dashboard/src/content/applications/Messenger/ChatContent.tsx new file mode 100644 index 00000000..f4f67dc9 --- /dev/null +++ b/dashboard/src/content/applications/Messenger/ChatContent.tsx @@ -0,0 +1,314 @@ +import { Box, Avatar, Typography, Card, styled, Divider } from '@mui/material'; + +import { + formatDistance, + format, + subDays, + subHours, + subMinutes +} from 'date-fns'; +import ScheduleTwoToneIcon from '@mui/icons-material/ScheduleTwoTone'; + +const DividerWrapper = styled(Divider)( + ({ theme }) => ` + .MuiDivider-wrapper { + border-radius: ${theme.general.borderRadiusSm}; + text-transform: none; + background: ${theme.palette.background.default}; + font-size: ${theme.typography.pxToRem(13)}; + color: ${theme.colors.alpha.black[50]}; + } +` +); + +const CardWrapperPrimary = styled(Card)( + ({ theme }) => ` + background: ${theme.colors.primary.main}; + color: ${theme.palette.primary.contrastText}; + padding: ${theme.spacing(2)}; + border-radius: ${theme.general.borderRadiusXl}; + border-top-right-radius: ${theme.general.borderRadius}; + max-width: 380px; + display: inline-flex; +` +); + +const CardWrapperSecondary = styled(Card)( + ({ theme }) => ` + background: ${theme.colors.alpha.black[10]}; + color: ${theme.colors.alpha.black[100]}; + padding: ${theme.spacing(2)}; + border-radius: ${theme.general.borderRadiusXl}; + border-top-left-radius: ${theme.general.borderRadius}; + max-width: 380px; + display: inline-flex; +` +); + +function ChatContent() { + const user = { + name: 'Catherine Pike', + avatar: '/static/images/avatars/1.jpg' + }; + + return ( + + + {format(subDays(new Date(), 3), 'MMMM dd yyyy')} + + + + + + + Hi. Can you send me the missing invoices asap? + + + + {formatDistance(subHours(new Date(), 115), new Date(), { + addSuffix: true + })} + + + + + + + + Yes, I'll email them right now. I'll let you know once the remaining + invoices are done. + + + + {formatDistance(subHours(new Date(), 125), new Date(), { + addSuffix: true + })} + + + + + + {format(subDays(new Date(), 5), 'MMMM dd yyyy')} + + + + + Hey! Are you there? + + Heeeelloooo???? + + + + {formatDistance(subHours(new Date(), 60), new Date(), { + addSuffix: true + })} + + + + + Today + + + + Hey there! + + How are you? Is it ok if I call you? + + + + {formatDistance(subMinutes(new Date(), 6), new Date(), { + addSuffix: true + })} + + + + + + + Hello, I just got my Amazon order shipped and I’m very happy about + that. + + + Can you confirm? + + + + {formatDistance(subMinutes(new Date(), 8), new Date(), { + addSuffix: true + })} + + + + + + ); +} + +export default ChatContent; diff --git a/dashboard/src/content/applications/Messenger/SidebarContent.tsx b/dashboard/src/content/applications/Messenger/SidebarContent.tsx new file mode 100644 index 00000000..7e8a46e5 --- /dev/null +++ b/dashboard/src/content/applications/Messenger/SidebarContent.tsx @@ -0,0 +1,529 @@ +import { useState, ChangeEvent } from 'react'; +import { + Box, + Typography, + FormControlLabel, + Switch, + Tabs, + Tab, + TextField, + IconButton, + InputAdornment, + Avatar, + List, + Button, + Tooltip, + Divider, + AvatarGroup, + ListItemButton, + ListItemAvatar, + ListItemText, + lighten, + styled +} from '@mui/material'; +import { formatDistance, subMinutes, subHours } from 'date-fns'; +import SettingsTwoToneIcon from '@mui/icons-material/SettingsTwoTone'; +import SearchTwoToneIcon from '@mui/icons-material/SearchTwoTone'; +import Label from 'src/components/Label'; +import CheckTwoToneIcon from '@mui/icons-material/CheckTwoTone'; +import AlarmTwoToneIcon from '@mui/icons-material/AlarmTwoTone'; +import { Link as RouterLink } from 'react-router-dom'; + +const AvatarSuccess = styled(Avatar)( + ({ theme }) => ` + background-color: ${theme.colors.success.lighter}; + color: ${theme.colors.success.main}; + width: ${theme.spacing(8)}; + height: ${theme.spacing(8)}; + margin-left: auto; + margin-right: auto; + ` +); + +const MeetingBox = styled(Box)( + ({ theme }) => ` + background-color: ${lighten(theme.colors.alpha.black[10], 0.5)}; + margin: ${theme.spacing(2)} 0; + border-radius: ${theme.general.borderRadius}; + padding: ${theme.spacing(2)}; + ` +); + +const RootWrapper = styled(Box)( + ({ theme }) => ` + padding: ${theme.spacing(2.5)}; + ` +); + +const ListItemWrapper = styled(ListItemButton)( + ({ theme }) => ` + &.MuiButtonBase-root { + margin: ${theme.spacing(1)} 0; + } + ` +); + +const TabsContainerWrapper = styled(Box)( + ({ theme }) => ` + .MuiTabs-indicator { + min-height: 4px; + height: 4px; + box-shadow: none; + border: 0; + } + + .MuiTab-root { + &.MuiButtonBase-root { + padding: 0; + margin-right: ${theme.spacing(3)}; + font-size: ${theme.typography.pxToRem(16)}; + color: ${theme.colors.alpha.black[50]}; + + .MuiTouchRipple-root { + display: none; + } + } + + &.Mui-selected:hover, + &.Mui-selected { + color: ${theme.colors.alpha.black[100]}; + } + } + ` +); + +function SidebarContent() { + const user = { + name: 'Catherine Pike', + avatar: '/static/images/avatars/1.jpg', + jobtitle: 'Software Developer' + }; + + const [state, setState] = useState({ + invisible: true + }); + + const handleChange = (event) => { + setState({ + ...state, + [event.target.name]: event.target.checked + }); + }; + + const [currentTab, setCurrentTab] = useState('all'); + + const tabs = [ + { value: 'all', label: 'All' }, + { value: 'unread', label: 'Unread' }, + { value: 'archived', label: 'Archived' } + ]; + + const handleTabsChange = (_event: ChangeEvent<{}>, value: string): void => { + setCurrentTab(value); + }; + + return ( + + + + + + + + {user.name} + + + {user.jobtitle} + + + + + + + + + } + label="Invisible" + /> + + + + + + + ) + }} + placeholder="Search..." + /> + + + Chats + + + + + {tabs.map((tab) => ( + + ))} + + + + + {currentTab === 'all' && ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + )} + {currentTab === 'unread' && ( + + + + + + + + + + + + + + + + + )} + {currentTab === 'archived' && ( + + + + + + + Hurray! There are no archived chats! + + + + )} + + + + Meetings + + + + + Daily Design Meeting + + + + + + 10:00 - 11:30 + + + {formatDistance(subMinutes(new Date(), 12), new Date(), { + addSuffix: true + })} + + + + + + + + + + + + + + + + + + + + + + Investors Council Meeting + + + + + + 14:30 - 16:15 + + + {formatDistance(subHours(new Date(), 4), new Date(), { + addSuffix: true + })} + + + + + + + + + + + + + + + + + + ); +} + +export default SidebarContent; diff --git a/dashboard/src/content/applications/Messenger/TopBarContent.tsx b/dashboard/src/content/applications/Messenger/TopBarContent.tsx new file mode 100644 index 00000000..9b60ee6e --- /dev/null +++ b/dashboard/src/content/applications/Messenger/TopBarContent.tsx @@ -0,0 +1,343 @@ +import { useState, SyntheticEvent } from 'react'; +import { + Box, + IconButton, + Tooltip, + Avatar, + Accordion, + AccordionSummary, + AccordionDetails, + Drawer, + Divider, + Typography, + List, + ListItem, + ListItemText, + ListItemIcon, + styled, + useTheme +} from '@mui/material'; +import { formatDistance, subMinutes } from 'date-fns'; +import CallTwoToneIcon from '@mui/icons-material/CallTwoTone'; +import VideoCameraFrontTwoToneIcon from '@mui/icons-material/VideoCameraFrontTwoTone'; +import InfoTwoToneIcon from '@mui/icons-material/InfoTwoTone'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import SearchTwoToneIcon from '@mui/icons-material/SearchTwoTone'; +import ColorLensTwoToneIcon from '@mui/icons-material/ColorLensTwoTone'; +import NotificationsOffTwoToneIcon from '@mui/icons-material/NotificationsOffTwoTone'; +import EmojiEmotionsTwoToneIcon from '@mui/icons-material/EmojiEmotionsTwoTone'; +import CancelTwoToneIcon from '@mui/icons-material/CancelTwoTone'; +import BlockTwoToneIcon from '@mui/icons-material/BlockTwoTone'; +import WarningTwoToneIcon from '@mui/icons-material/WarningTwoTone'; +import DescriptionTwoToneIcon from '@mui/icons-material/DescriptionTwoTone'; + +const RootWrapper = styled(Box)( + ({ theme }) => ` + @media (min-width: ${theme.breakpoints.values.md}px) { + display: flex; + align-items: center; + justify-content: space-between; + } +` +); + +const ListItemIconWrapper = styled(ListItemIcon)( + ({ theme }) => ` + min-width: 36px; + color: ${theme.colors.primary.light}; +` +); + +const AccordionSummaryWrapper = styled(AccordionSummary)( + ({ theme }) => ` + &.Mui-expanded { + min-height: 48px; + } + + .MuiAccordionSummary-content.Mui-expanded { + margin: 12px 0; + } + + .MuiSvgIcon-root { + transition: ${theme.transitions.create(['color'])}; + } + + &.MuiButtonBase-root { + + margin-bottom: ${theme.spacing(0.5)}; + + &:last-child { + margin-bottom: 0; + } + + &.Mui-expanded, + &:hover { + background: ${theme.colors.alpha.black[10]}; + + .MuiSvgIcon-root { + color: ${theme.colors.primary.main}; + } + } + } +` +); + +function TopBarContent() { + const theme = useTheme(); + + const [mobileOpen, setMobileOpen] = useState(false); + + const handleDrawerToggle = () => { + setMobileOpen(!mobileOpen); + }; + + const [expanded, setExpanded] = useState('section1'); + + const handleChange = + (section: string) => (_event: SyntheticEvent, isExpanded: boolean) => { + setExpanded(isExpanded ? section : false); + }; + + return ( + <> + + + + + Zain Baptista + + {formatDistance(subMinutes(new Date(), 8), new Date(), { + addSuffix: true + })} + + + + + + + + + + + + + + + + + + + + + + + + + + Zain Baptista + + Active{' '} + {formatDistance(subMinutes(new Date(), 7), new Date(), { + addSuffix: true + })} + + + + + + }> + Customize Chat + + + + + + + + + + + + + + + + + + + + + + + + + + }> + Privacy & Support + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + }> + Shared Files + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export default TopBarContent; diff --git a/dashboard/src/content/applications/Messenger/index.tsx b/dashboard/src/content/applications/Messenger/index.tsx new file mode 100644 index 00000000..ac0a7e02 --- /dev/null +++ b/dashboard/src/content/applications/Messenger/index.tsx @@ -0,0 +1,144 @@ +import { useState } from 'react'; + +import { Helmet } from 'react-helmet-async'; + +import TopBarContent from './TopBarContent'; +import BottomBarContent from './BottomBarContent'; +import SidebarContent from './SidebarContent'; +import ChatContent from './ChatContent'; +import MenuTwoToneIcon from '@mui/icons-material/MenuTwoTone'; + +import Scrollbar from 'src/components/Scrollbar'; + +import { + Box, + styled, + Divider, + Drawer, + IconButton, + useTheme +} from '@mui/material'; + +const RootWrapper = styled(Box)( + ({ theme }) => ` + height: calc(100vh - ${theme.header.height}); + display: flex; +` +); + +const Sidebar = styled(Box)( + ({ theme }) => ` + width: 300px; + background: ${theme.colors.alpha.white[100]}; + border-right: ${theme.colors.alpha.black[10]} solid 1px; +` +); + +const ChatWindow = styled(Box)( + () => ` + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + flex: 1; +` +); + +const ChatTopBar = styled(Box)( + ({ theme }) => ` + background: ${theme.colors.alpha.white[100]}; + border-bottom: ${theme.colors.alpha.black[10]} solid 1px; + padding: ${theme.spacing(2)}; + align-items: center; +` +); + +const IconButtonToggle = styled(IconButton)( + ({ theme }) => ` + width: ${theme.spacing(4)}; + height: ${theme.spacing(4)}; + background: ${theme.colors.alpha.white[100]}; +` +); + +const DrawerWrapperMobile = styled(Drawer)( + () => ` + width: 340px; + flex-shrink: 0; + + & > .MuiPaper-root { + width: 340px; + z-index: 3; + } +` +); + +function ApplicationsMessenger() { + const theme = useTheme(); + const [mobileOpen, setMobileOpen] = useState(false); + + const handleDrawerToggle = () => { + setMobileOpen(!mobileOpen); + }; + + return ( + <> + + Messenger - Applications + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export default ApplicationsMessenger; diff --git a/dashboard/src/content/applications/Transactions/BulkActions.tsx b/dashboard/src/content/applications/Transactions/BulkActions.tsx new file mode 100644 index 00000000..d0c30481 --- /dev/null +++ b/dashboard/src/content/applications/Transactions/BulkActions.tsx @@ -0,0 +1,93 @@ +import { useState, useRef } from 'react'; + +import { + Box, + Menu, + IconButton, + Button, + ListItemText, + ListItem, + List, + Typography +} from '@mui/material'; +import { styled } from '@mui/material/styles'; + +import DeleteTwoToneIcon from '@mui/icons-material/DeleteTwoTone'; +import MoreVertTwoToneIcon from '@mui/icons-material/MoreVertTwoTone'; + +const ButtonError = styled(Button)( + ({ theme }) => ` + background: ${theme.colors.error.main}; + color: ${theme.palette.error.contrastText}; + + &:hover { + background: ${theme.colors.error.dark}; + } + ` +); + +function BulkActions() { + const [onMenuOpen, menuOpen] = useState(false); + const moreRef = useRef(null); + + const openMenu = (): void => { + menuOpen(true); + }; + + const closeMenu = (): void => { + menuOpen(false); + }; + + return ( + <> + + + + Bulk actions: + + } + variant="contained" + > + Delete + + + + + + + + + + + + + + + + + + + ); +} + +export default BulkActions; diff --git a/dashboard/src/content/applications/Transactions/PageHeader.tsx b/dashboard/src/content/applications/Transactions/PageHeader.tsx new file mode 100644 index 00000000..1b15d073 --- /dev/null +++ b/dashboard/src/content/applications/Transactions/PageHeader.tsx @@ -0,0 +1,33 @@ +import { Typography, Button, Grid } from '@mui/material'; + +import AddTwoToneIcon from '@mui/icons-material/AddTwoTone'; + +function PageHeader() { + const user = { + name: 'Catherine Pike', + avatar: '/static/images/avatars/1.jpg' + }; + return ( + + + + Transactions + + + {user.name}, these are your recent transactions + + + + + + + ); +} + +export default PageHeader; diff --git a/dashboard/src/content/applications/Transactions/RecentOrders.tsx b/dashboard/src/content/applications/Transactions/RecentOrders.tsx new file mode 100644 index 00000000..04be4420 --- /dev/null +++ b/dashboard/src/content/applications/Transactions/RecentOrders.tsx @@ -0,0 +1,147 @@ +import { Card } from '@mui/material'; +import { CryptoOrder } from 'src/models/crypto_order'; +import RecentOrdersTable from './RecentOrdersTable'; +import { subDays } from 'date-fns'; + +function RecentOrders() { + const cryptoOrders: CryptoOrder[] = [ + { + id: '1', + orderDetails: 'Fiat Deposit', + orderDate: new Date().getTime(), + status: 'completed', + orderID: 'VUVX709ET7BY', + sourceName: 'Bank Account', + sourceDesc: '*** 1111', + amountCrypto: 34.4565, + amount: 56787, + cryptoCurrency: 'ETH', + currency: '$' + }, + { + id: '2', + orderDetails: 'Fiat Deposit', + orderDate: subDays(new Date(), 1).getTime(), + status: 'completed', + orderID: '23M3UOG65G8K', + sourceName: 'Bank Account', + sourceDesc: '*** 1111', + amountCrypto: 6.58454334, + amount: 8734587, + cryptoCurrency: 'BTC', + currency: '$' + }, + { + id: '3', + orderDetails: 'Fiat Deposit', + orderDate: subDays(new Date(), 5).getTime(), + status: 'failed', + orderID: 'F6JHK65MS818', + sourceName: 'Bank Account', + sourceDesc: '*** 1111', + amountCrypto: 6.58454334, + amount: 8734587, + cryptoCurrency: 'BTC', + currency: '$' + }, + { + id: '4', + orderDetails: 'Fiat Deposit', + orderDate: subDays(new Date(), 55).getTime(), + status: 'completed', + orderID: 'QJFAI7N84LGM', + sourceName: 'Bank Account', + sourceDesc: '*** 1111', + amountCrypto: 6.58454334, + amount: 8734587, + cryptoCurrency: 'BTC', + currency: '$' + }, + { + id: '5', + orderDetails: 'Fiat Deposit', + orderDate: subDays(new Date(), 56).getTime(), + status: 'pending', + orderID: 'BO5KFSYGC0YW', + sourceName: 'Bank Account', + sourceDesc: '*** 1111', + amountCrypto: 6.58454334, + amount: 8734587, + cryptoCurrency: 'BTC', + currency: '$' + }, + { + id: '6', + orderDetails: 'Fiat Deposit', + orderDate: subDays(new Date(), 33).getTime(), + status: 'completed', + orderID: '6RS606CBMKVQ', + sourceName: 'Bank Account', + sourceDesc: '*** 1111', + amountCrypto: 6.58454334, + amount: 8734587, + cryptoCurrency: 'BTC', + currency: '$' + }, + { + id: '7', + orderDetails: 'Fiat Deposit', + orderDate: new Date().getTime(), + status: 'pending', + orderID: '479KUYHOBMJS', + sourceName: 'Bank Account', + sourceDesc: '*** 1212', + amountCrypto: 2.346546, + amount: 234234, + cryptoCurrency: 'BTC', + currency: '$' + }, + { + id: '8', + orderDetails: 'Paypal Withdraw', + orderDate: subDays(new Date(), 22).getTime(), + status: 'completed', + orderID: 'W67CFZNT71KR', + sourceName: 'Paypal Account', + sourceDesc: '*** 1111', + amountCrypto: 3.345456, + amount: 34544, + cryptoCurrency: 'BTC', + currency: '$' + }, + { + id: '9', + orderDetails: 'Fiat Deposit', + orderDate: subDays(new Date(), 11).getTime(), + status: 'completed', + orderID: '63GJ5DJFKS4H', + sourceName: 'Bank Account', + sourceDesc: '*** 2222', + amountCrypto: 1.4389567945, + amount: 123843, + cryptoCurrency: 'BTC', + currency: '$' + }, + { + id: '10', + orderDetails: 'Wallet Transfer', + orderDate: subDays(new Date(), 123).getTime(), + status: 'failed', + orderID: '17KRZHY8T05M', + sourceName: 'Wallet Transfer', + sourceDesc: "John's Cardano Wallet", + amountCrypto: 765.5695, + amount: 7567, + cryptoCurrency: 'ADA', + currency: '$' + } + ]; + + return ( + + + + ); +} + +export default RecentOrders; diff --git a/dashboard/src/content/applications/Transactions/RecentOrdersTable.tsx b/dashboard/src/content/applications/Transactions/RecentOrdersTable.tsx new file mode 100644 index 00000000..65717722 --- /dev/null +++ b/dashboard/src/content/applications/Transactions/RecentOrdersTable.tsx @@ -0,0 +1,366 @@ +import { FC, ChangeEvent, useState } from 'react'; +import { format } from 'date-fns'; +import numeral from 'numeral'; +import PropTypes from 'prop-types'; +import { + Tooltip, + Divider, + Box, + FormControl, + InputLabel, + Card, + Checkbox, + IconButton, + Table, + TableBody, + TableCell, + TableHead, + TablePagination, + TableRow, + TableContainer, + Select, + MenuItem, + Typography, + useTheme, + CardHeader +} from '@mui/material'; + +import Label from 'src/components/Label'; +import { CryptoOrder, CryptoOrderStatus } from 'src/models/crypto_order'; +import EditTwoToneIcon from '@mui/icons-material/EditTwoTone'; +import DeleteTwoToneIcon from '@mui/icons-material/DeleteTwoTone'; +import BulkActions from './BulkActions'; + +interface RecentOrdersTableProps { + className?: string; + cryptoOrders: CryptoOrder[]; +} + +interface Filters { + status?: CryptoOrderStatus; +} + +const getStatusLabel = (cryptoOrderStatus: CryptoOrderStatus): JSX.Element => { + const map = { + failed: { + text: 'Failed', + color: 'error' + }, + completed: { + text: 'Completed', + color: 'success' + }, + pending: { + text: 'Pending', + color: 'warning' + } + }; + + const { text, color }: any = map[cryptoOrderStatus]; + + return ; +}; + +const applyFilters = ( + cryptoOrders: CryptoOrder[], + filters: Filters +): CryptoOrder[] => { + return cryptoOrders.filter((cryptoOrder) => { + let matches = true; + + if (filters.status && cryptoOrder.status !== filters.status) { + matches = false; + } + + return matches; + }); +}; + +const applyPagination = ( + cryptoOrders: CryptoOrder[], + page: number, + limit: number +): CryptoOrder[] => { + return cryptoOrders.slice(page * limit, page * limit + limit); +}; + +const RecentOrdersTable: FC = ({ cryptoOrders }) => { + const [selectedCryptoOrders, setSelectedCryptoOrders] = useState( + [] + ); + const selectedBulkActions = selectedCryptoOrders.length > 0; + const [page, setPage] = useState(0); + const [limit, setLimit] = useState(5); + const [filters, setFilters] = useState({ + status: null + }); + + const statusOptions = [ + { + id: 'all', + name: 'All' + }, + { + id: 'completed', + name: 'Completed' + }, + { + id: 'pending', + name: 'Pending' + }, + { + id: 'failed', + name: 'Failed' + } + ]; + + const handleStatusChange = (e: ChangeEvent): void => { + let value = null; + + if (e.target.value !== 'all') { + value = e.target.value; + } + + setFilters((prevFilters) => ({ + ...prevFilters, + status: value + })); + }; + + const handleSelectAllCryptoOrders = ( + event: ChangeEvent + ): void => { + setSelectedCryptoOrders( + event.target.checked + ? cryptoOrders.map((cryptoOrder) => cryptoOrder.id) + : [] + ); + }; + + const handleSelectOneCryptoOrder = ( + event: ChangeEvent, + cryptoOrderId: string + ): void => { + if (!selectedCryptoOrders.includes(cryptoOrderId)) { + setSelectedCryptoOrders((prevSelected) => [ + ...prevSelected, + cryptoOrderId + ]); + } else { + setSelectedCryptoOrders((prevSelected) => + prevSelected.filter((id) => id !== cryptoOrderId) + ); + } + }; + + const handlePageChange = (event: any, newPage: number): void => { + setPage(newPage); + }; + + const handleLimitChange = (event: ChangeEvent): void => { + setLimit(parseInt(event.target.value)); + }; + + const filteredCryptoOrders = applyFilters(cryptoOrders, filters); + const paginatedCryptoOrders = applyPagination( + filteredCryptoOrders, + page, + limit + ); + const selectedSomeCryptoOrders = + selectedCryptoOrders.length > 0 && + selectedCryptoOrders.length < cryptoOrders.length; + const selectedAllCryptoOrders = + selectedCryptoOrders.length === cryptoOrders.length; + const theme = useTheme(); + + return ( + + {selectedBulkActions && ( + + + + )} + {!selectedBulkActions && ( + + + Status + + + + } + title="Recent Orders" + /> + )} + + + + + + + + + Order Details + Order ID + Source + Amount + Status + Actions + + + + {paginatedCryptoOrders.map((cryptoOrder) => { + const isCryptoOrderSelected = selectedCryptoOrders.includes( + cryptoOrder.id + ); + return ( + + + ) => + handleSelectOneCryptoOrder(event, cryptoOrder.id) + } + value={isCryptoOrderSelected} + /> + + + + {cryptoOrder.orderDetails} + + + {format(cryptoOrder.orderDate, 'MMMM dd yyyy')} + + + + + {cryptoOrder.orderID} + + + + + {cryptoOrder.sourceName} + + + {cryptoOrder.sourceDesc} + + + + + {cryptoOrder.amountCrypto} + {cryptoOrder.cryptoCurrency} + + + {numeral(cryptoOrder.amount).format( + `${cryptoOrder.currency}0,0.00` + )} + + + + {getStatusLabel(cryptoOrder.status)} + + + + + + + + + + + + + + + ); + })} + +
+
+ + + +
+ ); +}; + +RecentOrdersTable.propTypes = { + cryptoOrders: PropTypes.array.isRequired +}; + +RecentOrdersTable.defaultProps = { + cryptoOrders: [] +}; + +export default RecentOrdersTable; diff --git a/dashboard/src/content/applications/Transactions/index.tsx b/dashboard/src/content/applications/Transactions/index.tsx new file mode 100644 index 00000000..a2172b0e --- /dev/null +++ b/dashboard/src/content/applications/Transactions/index.tsx @@ -0,0 +1,36 @@ +import { Helmet } from 'react-helmet-async'; +import PageHeader from './PageHeader'; +import PageTitleWrapper from 'src/components/PageTitleWrapper'; +import { Grid, Container } from '@mui/material'; +import Footer from 'src/components/Footer'; + +import RecentOrders from './RecentOrders'; + +function ApplicationsTransactions() { + return ( + <> + + Transactions - Applications + + + + + + + + + + + +