diff --git a/client/src/components/accounts/index.vue b/client/src/components/accounts/index.vue new file mode 100644 index 000000000..952e32735 --- /dev/null +++ b/client/src/components/accounts/index.vue @@ -0,0 +1,46 @@ + + + diff --git a/client/src/components/accounts/roles.vue b/client/src/components/accounts/roles.vue new file mode 100644 index 000000000..5dbc359b2 --- /dev/null +++ b/client/src/components/accounts/roles.vue @@ -0,0 +1,287 @@ + + + diff --git a/client/src/components/accounts/teams.vue b/client/src/components/accounts/teams.vue new file mode 100644 index 000000000..34594b579 --- /dev/null +++ b/client/src/components/accounts/teams.vue @@ -0,0 +1,221 @@ + + + diff --git a/client/src/components/accounts/tokens.vue b/client/src/components/accounts/tokens.vue new file mode 100644 index 000000000..96ead1910 --- /dev/null +++ b/client/src/components/accounts/tokens.vue @@ -0,0 +1,163 @@ + + + diff --git a/client/src/components/accounts/users.vue b/client/src/components/accounts/users.vue new file mode 100644 index 000000000..0ad96f720 --- /dev/null +++ b/client/src/components/accounts/users.vue @@ -0,0 +1,467 @@ + + + \ No newline at end of file diff --git a/client/src/components/profile/index.vue b/client/src/components/profile/index.vue new file mode 100644 index 000000000..1df87ac27 --- /dev/null +++ b/client/src/components/profile/index.vue @@ -0,0 +1,216 @@ + + + diff --git a/client/src/components/users/groups.vue b/client/src/components/users/groups.vue new file mode 100644 index 000000000..e69de29bb diff --git a/client/src/components/users/index.vue b/client/src/components/users/index.vue new file mode 100644 index 000000000..e69de29bb diff --git a/client/src/layouts/default/NavDrawer.vue b/client/src/layouts/default/NavDrawer.vue index f2c85234e..e66470ab1 100644 --- a/client/src/layouts/default/NavDrawer.vue +++ b/client/src/layouts/default/NavDrawer.vue @@ -5,11 +5,30 @@ permanent rail > + + + + + + + + + + title="Pipelines"> + + diff --git a/client/src/views/Profile.vue b/client/src/views/Profile.vue new file mode 100644 index 000000000..0a6fc12f3 --- /dev/null +++ b/client/src/views/Profile.vue @@ -0,0 +1,7 @@ + + + diff --git a/client/tsconfig.json b/client/tsconfig.json index aa6f0fdde..e26f4f7e8 100644 --- a/client/tsconfig.json +++ b/client/tsconfig.json @@ -19,7 +19,7 @@ ] } }, - "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"], + "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx"], "references": [{ "path": "./tsconfig.node.json" }], "exclude": ["node_modules"] } diff --git a/server/prisma/migrations/20250620120259_init/migration.sql b/server/prisma/migrations/20250622123918_init/migration.sql similarity index 99% rename from server/prisma/migrations/20250620120259_init/migration.sql rename to server/prisma/migrations/20250622123918_init/migration.sql index a2db482e5..01071c0e9 100644 --- a/server/prisma/migrations/20250620120259_init/migration.sql +++ b/server/prisma/migrations/20250622123918_init/migration.sql @@ -22,8 +22,6 @@ CREATE TABLE "User" ( "username" TEXT NOT NULL, "firstName" TEXT, "lastName" TEXT, - "company" TEXT, - "location" TEXT, "email" TEXT NOT NULL, "emailVerified" DATETIME, "password" TEXT NOT NULL, diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 5fa9fa5fc..59fedb76d 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -121,6 +121,7 @@ model Role { model Token { id String @id @default(cuid()) + name String userId String user User @relation(fields: [userId], references: [id]) token String @unique diff --git a/server/src/app.module.ts b/server/src/app.module.ts index e286e8d4f..495a386e2 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -21,6 +21,9 @@ import { TemplatesController } from './templates/templates.controller'; import { TemplatesService } from './templates/templates.service'; import { StatusModule } from './status/status.module'; import { DatabaseModule } from './database/database.module'; +import { GroupModule } from './groups/groups.module'; +import { RolesModule } from './roles/roles.module'; +import { TokenModule } from './token/token.module'; @Module({ imports: [ @@ -43,6 +46,9 @@ import { DatabaseModule } from './database/database.module'; SecurityModule, StatusModule, DatabaseModule, + GroupModule, + RolesModule, + TokenModule, ], controllers: [AppController, TemplatesController], providers: [AppService, TemplatesService], diff --git a/server/src/config/config.service.ts b/server/src/config/config.service.ts index b5431f65d..06ba0b4b0 100644 --- a/server/src/config/config.service.ts +++ b/server/src/config/config.service.ts @@ -224,6 +224,10 @@ export class ConfigService { private async readConfigFromKubernetes(): Promise { const namespace = process.env.KUBERO_NAMESPACE || 'kubero'; const kuberoes = await this.kubectl.getKuberoConfig(namespace); + if (!kuberoes || !kuberoes.spec) { + this.logger.error('Kubero config not found in Kubernetes'); + throw new Error('Kubero config not found'); + } return kuberoes.spec; } diff --git a/server/src/database/database.service.ts b/server/src/database/database.service.ts index c591d5e5f..6856f1e27 100644 --- a/server/src/database/database.service.ts +++ b/server/src/database/database.service.ts @@ -21,8 +21,12 @@ export class DatabaseService { this.runMigrations() .then(() => { // create user after migrations - this.createAdminUser() - this.migrateLegeacyUsers() + this.seedDefaultData() + .then(() => { + this.createSystemUser() + this.createAdminUser() + this.migrateLegeacyUsers() + }) }) .catch((error) => { this.logger.error('Error during database migrations.', error); @@ -55,31 +59,47 @@ export class DatabaseService { execSync('npx prisma migrate deploy', { stdio: 'inherit' }); //execSync('npx prisma migrate deploy', {}); this.logger.log('Prisma migrations completed.'); - await prisma.$executeRaw` - INSERT INTO "User" ( - "id", - "email", - "username", - "password", - "isActive", - createdAt, - updatedAt - ) VALUES ( - "1", - 'system@kubero.dev', - 'system', - '', - false, - CURRENT_TIMESTAMP, - CURRENT_TIMESTAMP - ) ON CONFLICT DO NOTHING;` - await prisma.$disconnect(); + //await prisma.$disconnect(); } catch (err) { this.logger.error('Prisma migration failed', err); process.exit(1); } } + private async createSystemUser() { + const prisma = new PrismaClient(); + + // Check if the system user already exists + const existingUser = await prisma.user.findUnique({ + where: { id: '1' }, + }); + if (existingUser) { + this.logger.log('System user already exists. Skipping creation.'); + return; + } + + const role = process.env.KUBERO_SYSTEM_USER_ROLE || 'guest'; + const userGroups = ['everyone']; + try { + await prisma.user.create({ + data: { + id: '1', + username: 'system', + email: 'system@kubero.dev', + password: '', // No password for system user + isActive: false, + role: { connect: { name: role } }, + userGroups: userGroups && Array.isArray(userGroups) ? { + connect: userGroups.map((g: any) => ({ name: g })), + } : undefined + }, + }); + this.logger.log('System user created successfully.'); + } catch (error) { + this.logger.error('Failed to create system user.', error); + } + } + private async createAdminUser() { const prisma = new PrismaClient(); @@ -94,6 +114,8 @@ export class DatabaseService { const adminUser = process.env.KUBERO_ADMIN_USERNAME || 'admin'; const adminEmail = process.env.KUBERO_ADMIN_EMAIL || 'admin@kubero.dev'; + const role = process.env.KUBERO_SYSTEM_USER_ROLE || 'admin'; + const userGroups = ['everyone']; try { @@ -113,6 +135,10 @@ export class DatabaseService { email: adminEmail, password: passwordHash, isActive: true, + role: { connect: { name: role } }, + userGroups: userGroups && Array.isArray(userGroups) ? { + connect: userGroups.map((g: any) => ({ name: g })), + } : undefined, createdAt: new Date(), updatedAt: new Date(), }, @@ -121,7 +147,6 @@ export class DatabaseService { } catch (error) { Logger.error('Failed to create admin user.', error); } - //await prisma.$disconnect(); } private async migrateLegeacyUsers() { @@ -162,7 +187,8 @@ export class DatabaseService { } const userID = crypto.randomUUID(); - + const role = process.env.KUBERO_DEFAULT_USER_ROLE || 'guest'; + const userGroups = ['everyone']; try { await prisma.user.create({ data: { @@ -171,6 +197,10 @@ export class DatabaseService { email: user.username + '@kubero.dev', password: password, isActive: true, + role: { connect: { name: role } }, + userGroups: userGroups && Array.isArray(userGroups) ? { + connect: userGroups.map((g: any) => ({ name: g })), + } : undefined, }, }); this.logger.log(`Migrated user ${user.username} successfully.`); @@ -181,6 +211,90 @@ export class DatabaseService { }; this.logger.log('Legacy users migrated successfully.'); - //await prisma.$disconnect(); + } + + private async seedDefaultData() { + + // Ensure the 'admin' role exists with permissions + this.prisma.role.upsert({ + where: { name: 'admin' }, + update: {}, + create: { + name: 'admin', + description: 'Administrator role with full access', + permissions: { + create: [ + { action: 'write', resource: 'user' }, + { action: 'write', resource: 'pipeline' }, + { action: 'write', resource: 'app' }, + { action: 'write', resource: 'settings' }, + { action: 'write', resource: 'templates' }, + ], + }, + }, + }) + .then(() => { + this.logger.log('Role "admin" seeded successfully.'); + }) + + // Ensure the 'member' role exists with limited permissions + this.prisma.role.upsert({ + where: { name: 'member' }, + update: {}, + create: { + name: 'member', + description: 'Member role with limited access', + permissions: { + create: [ + { action: 'read', resource: 'user' }, + { action: 'write', resource: 'pipeline' }, + { action: 'write', resource: 'app' }, + { action: 'write', resource: 'templates' }, + ], + }, + }, + }) + .then(() => { + this.logger.log('Role "member" seeded successfully.'); + }) + + // Ensure the 'guest' role exists with minimal permissions + this.prisma.role.upsert({ + where: { name: 'guest' }, + update: {}, + create: { + name: 'guest', + description: 'Guest role with minimal access', + permissions: { + create: [ + { action: 'read', resource: 'app' }, + { action: 'read', resource: 'pipeline' }, + { action: 'read', resource: 'templates' }, + ], + }, + }, + }) + .then(() => { + this.logger.log('Role "guest" seeded successfully.'); + }) + + // Ensure the 'everyone' user group exists + const existingGroup = await this.prisma.userGroup.findUnique({ + where: { name: 'everyone' }, + }); + + if (!existingGroup) { + await this.prisma.userGroup.create({ + data: { + name: 'everyone', + description: 'Standard group for all users', + }, + }); + this.logger.log('UserGroup "everyone" created successfully.'); + } else { + this.logger.log('UserGroup "everyone" already exists. Skipping creation.'); + } + + this.logger.log('Default data seeded successfully.'); } } diff --git a/server/src/groups/groups.controller.spec.ts b/server/src/groups/groups.controller.spec.ts new file mode 100644 index 000000000..3ecbcac07 --- /dev/null +++ b/server/src/groups/groups.controller.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { GroupsController } from './groups.controller'; + +describe('GroupController', () => { + let controller: GroupsController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [GroupsController], + }).compile(); + + controller = module.get(GroupsController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/server/src/groups/groups.controller.ts b/server/src/groups/groups.controller.ts new file mode 100644 index 000000000..3adf6e9b4 --- /dev/null +++ b/server/src/groups/groups.controller.ts @@ -0,0 +1,110 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + Put, + UseGuards, + +} from '@nestjs/common'; +import { + ApiBearerAuth, + ApiForbiddenResponse, + ApiOkResponse, + ApiOperation, +} from '@nestjs/swagger'; +import { JwtAuthGuard } from '../auth/strategies/jwt.guard'; +import { OKDTO } from '../common/dto/ok.dto'; +import { GroupsService } from './groups.service'; + +@Controller({ path: 'api/groups', version: '1' }) +export class GroupsController { + + constructor(private groupsService: GroupsService) {} + + @Get('/') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth('bearerAuth') + @ApiForbiddenResponse({ + description: 'Error: Unauthorized', + type: OKDTO, + isArray: false, + }) + @ApiOkResponse({ + description: 'A List of Groups', + type: OKDTO, + isArray: true, + }) + @ApiOperation({ summary: 'Get all Groups' }) + async getGroups() { + return this.groupsService.findAll(); + } + + @Post('/') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth('bearerAuth') + @ApiForbiddenResponse({ + description: 'Error: Unauthorized', + type: OKDTO, + isArray: false, + }) + @ApiOkResponse({ + description: 'Group created successfully', + type: OKDTO, + isArray: false, + }) + @ApiOperation({ summary: 'Create a new Group' }) + async createGroup(@Body() groupData: any) { + if (!groupData || !groupData.name || !groupData.description) { + throw new Error('Invalid group data provided'); + } + return this.groupsService.create(groupData.name, groupData.description); + } + + @Delete('/:id') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth('bearerAuth') + @ApiForbiddenResponse({ + description: 'Error: Unauthorized', + type: OKDTO, + isArray: false, + }) + @ApiOkResponse({ + description: 'Group deleted successfully', + type: OKDTO, + isArray: false, + }) + @ApiOperation({ summary: 'Delete a Group by ID' }) + async deleteGroup(@Param('id') id: string) { + if (!id) { + throw new Error('Group ID is required'); + } + return this.groupsService.delete(id); + } + + @Put('/:id') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth('bearerAuth') + @ApiForbiddenResponse({ + description: 'Error: Unauthorized', + type: OKDTO, + isArray: false, + }) + @ApiOkResponse({ + description: 'Group updated successfully', + type: OKDTO, + isArray: false, + }) + @ApiOperation({ summary: 'Update a Group by ID' }) + async updateGroup( + @Param('id') id: string, + @Body() groupData: any, + ) { + if (!id || !groupData || !groupData.name || !groupData.description) { + throw new Error('Invalid group data provided'); + } + return this.groupsService.update(id, groupData); + } +} diff --git a/server/src/groups/groups.module.ts b/server/src/groups/groups.module.ts new file mode 100644 index 000000000..7fab50801 --- /dev/null +++ b/server/src/groups/groups.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { GroupsService } from './groups.service'; +import { GroupsController } from './groups.controller'; + +@Module({ + providers: [GroupsService], + exports: [GroupsService], + controllers: [GroupsController] +}) +export class GroupModule {} diff --git a/server/src/groups/groups.service.spec.ts b/server/src/groups/groups.service.spec.ts new file mode 100644 index 000000000..f2b97e062 --- /dev/null +++ b/server/src/groups/groups.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { GroupsService } from './groups.service'; + +describe('GroupService', () => { + let service: GroupsService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [GroupsService], + }).compile(); + + service = module.get(GroupsService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/server/src/groups/groups.service.ts b/server/src/groups/groups.service.ts new file mode 100644 index 000000000..3dbd9e8f1 --- /dev/null +++ b/server/src/groups/groups.service.ts @@ -0,0 +1,50 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { PrismaClient, User as PrismaUser } from '@prisma/client'; + +@Injectable() +export class GroupsService { + private readonly prisma = new PrismaClient(); + private logger = new Logger(GroupsService.name); + + constructor() {} + async findAll(): Promise { + return this.prisma.userGroup.findMany({ + select: { + id: true, + name: true, + description: true, + createdAt: true, + updatedAt: true, + }, + }); + } + + async create(name: string, description: string): Promise { + const groupData = { + name, + description, + }; + return this.prisma.userGroup.create({ + data: groupData, + }); + } + + async findById(id: string): Promise { + return this.prisma.userGroup.findUnique({ + where: { id }, + }); + } + + async update(id: string, groupData: any): Promise { + return this.prisma.userGroup.update({ + where: { id }, + data: groupData, + }); + } + + async delete(id: string): Promise { + return this.prisma.userGroup.delete({ + where: { id }, + }); + } +} diff --git a/server/src/kubernetes/kubernetes.service.ts b/server/src/kubernetes/kubernetes.service.ts index 8f0b7b729..d127a9dc0 100644 --- a/server/src/kubernetes/kubernetes.service.ts +++ b/server/src/kubernetes/kubernetes.service.ts @@ -1186,8 +1186,8 @@ export class KubernetesService { ); //console.log(config.body); return config.body as any; - } catch (_error) { - //this.logger.debug(error); + } catch (error) { + this.logger.debug(error); this.logger.debug('getKuberoConfig: error getting config'); } } diff --git a/server/src/main.ts b/server/src/main.ts index 65bf6f3d6..63f29aa13 100644 --- a/server/src/main.ts +++ b/server/src/main.ts @@ -90,7 +90,21 @@ async function bootstrap() { .build(); const documentFactory = () => SwaggerModule.createDocument(app, config); - SwaggerModule.setup('api/docs', app, documentFactory); + SwaggerModule.setup( + 'api/docs', + app, + documentFactory, + { + customSiteTitle: 'Kubero API Documentation', + //customfavIcon: '/favicon.ico', + swaggerOptions: { + tagsSorter: 'alpha', + persistAuthorization: true, + displayRequestDuration: true, + //docExpansion: 'none', // 'none' to collapse all sections by default + }, + }, + ); await app.listen(process.env.PORT ?? 2000); // Use port 2000 for compatibility with kubero v2 diff --git a/server/src/roles/roles.controller.spec.ts b/server/src/roles/roles.controller.spec.ts new file mode 100644 index 000000000..79260d6da --- /dev/null +++ b/server/src/roles/roles.controller.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { RolesController } from './roles.controller'; + +describe('RolesController', () => { + let controller: RolesController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [RolesController], + }).compile(); + + controller = module.get(RolesController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/server/src/roles/roles.controller.ts b/server/src/roles/roles.controller.ts new file mode 100644 index 000000000..1eae98144 --- /dev/null +++ b/server/src/roles/roles.controller.ts @@ -0,0 +1,45 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + Put, + UseGuards, + +} from '@nestjs/common'; +import { + ApiBearerAuth, + ApiForbiddenResponse, + ApiOkResponse, + ApiOperation, +} from '@nestjs/swagger'; +import { JwtAuthGuard } from '../auth/strategies/jwt.guard'; +import { OKDTO } from '../common/dto/ok.dto'; +import { RolesService } from './roles.service'; + + +@Controller({ path: 'api/roles', version: '1' }) +export class RolesController { + + constructor(private rolesService: RolesService) {} + + @Get('/') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth('bearerAuth') + @ApiForbiddenResponse({ + description: 'Error: Unauthorized', + type: OKDTO, + isArray: false, + }) + @ApiOkResponse({ + description: 'A List of Roles', + type: OKDTO, + isArray: true, + }) + @ApiOperation({ summary: 'Get all Roles' }) + async getRoles() { + return this.rolesService.findAll(); + } +} diff --git a/server/src/roles/roles.module.ts b/server/src/roles/roles.module.ts new file mode 100644 index 000000000..843f678b5 --- /dev/null +++ b/server/src/roles/roles.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { RolesController } from './roles.controller'; +import { RolesService } from './roles.service'; + +@Module({ + controllers: [RolesController], + providers: [RolesService] +}) +export class RolesModule {} diff --git a/server/src/roles/roles.service.spec.ts b/server/src/roles/roles.service.spec.ts new file mode 100644 index 000000000..058d35f3f --- /dev/null +++ b/server/src/roles/roles.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { RolesService } from './roles.service'; + +describe('RolesService', () => { + let service: RolesService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [RolesService], + }).compile(); + + service = module.get(RolesService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/server/src/roles/roles.service.ts b/server/src/roles/roles.service.ts new file mode 100644 index 000000000..67920a3ac --- /dev/null +++ b/server/src/roles/roles.service.ts @@ -0,0 +1,32 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { PrismaClient, User as PrismaUser } from '@prisma/client'; + +@Injectable() +export class RolesService { + + private readonly prisma = new PrismaClient(); + private logger = new Logger(RolesService.name); + + constructor() {} + + async findAll(): Promise { + return this.prisma.role.findMany({ + select: { + id: true, + name: true, + description: true, + createdAt: true, + updatedAt: true, + permissions: { + select: { + id: true, + resource: true, + action: true, + }, + }, + }, + }); + } + + +} diff --git a/server/src/token/token.controller.spec.ts b/server/src/token/token.controller.spec.ts new file mode 100644 index 000000000..823fa44c4 --- /dev/null +++ b/server/src/token/token.controller.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { TokenController } from './token.controller'; + +describe('TokenController', () => { + let controller: TokenController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [TokenController], + }).compile(); + + controller = module.get(TokenController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/server/src/token/token.controller.ts b/server/src/token/token.controller.ts new file mode 100644 index 000000000..aeb14cad9 --- /dev/null +++ b/server/src/token/token.controller.ts @@ -0,0 +1,162 @@ +import { + Body, + Controller, + Delete, + Get, + HttpException, + HttpStatus, + Param, + Post, + Put, + UseGuards, + Request, +} from '@nestjs/common'; +import { + ApiBearerAuth, + ApiForbiddenResponse, + ApiOkResponse, + ApiOperation, +} from '@nestjs/swagger'; +import { JwtAuthGuard } from '../auth/strategies/jwt.guard'; +import { OKDTO } from '../common/dto/ok.dto'; +import { TokenService } from './token.service'; + +@Controller({ path: 'api/tokens', version: '1' }) +export class TokenController { + + constructor(private tokenService: TokenService) {} + + @Get('/') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth('bearerAuth') + @ApiForbiddenResponse({ + description: 'Error: Unauthorized', + type: OKDTO, + isArray: false, + }) + @ApiOkResponse({ + description: 'A List of Tokens', + type: OKDTO, + isArray: true, + }) + @ApiOperation({ summary: 'Get all Tokens' }) + async getTokens() { + return this.tokenService.findAll(); + } +/* + @Post('/user/:userId') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth('bearerAuth') + @ApiForbiddenResponse({ + description: 'Error: Unauthorized', + type: OKDTO, + isArray: false, + }) + @ApiOkResponse({ + description: 'Token created successfully', + type: OKDTO, + isArray: false, + }) + @ApiOperation({ summary: 'Create a new Token for a User' }) + async createToken(@Param('userId') userId: string, @Body() tokenData: any) { + if (!tokenData || !tokenData.token || !tokenData.expiresAt || !userId) { + throw new HttpException('Invalid token data', HttpStatus.BAD_REQUEST); + } + return this.tokenService.create(tokenData, userId); + } +*/ + @Post('') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth('bearerAuth') + @ApiForbiddenResponse({ + description: 'Error: Unauthorized', + type: OKDTO, + isArray: false, + }) + @ApiOkResponse({ + description: 'Token created successfully', + type: OKDTO, + isArray: false, + }) + @ApiOperation({ summary: 'Create a new Token for the current logged in user' }) + async createToken(@Body() tokenData: any, @Request() req: any,) { + if (!tokenData || !tokenData.token || !tokenData.expiresAt || !req.user.userId) { + throw new HttpException('Invalid token data', HttpStatus.BAD_REQUEST); + } + const userId = req.user.userId; + + return this.tokenService.create(tokenData, userId); + } + + @Delete('/:id') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth('bearerAuth') + @ApiForbiddenResponse({ + description: 'Error: Unauthorized', + type: OKDTO, + isArray: false, + }) + @ApiOkResponse({ + description: 'Token deleted successfully', + type: OKDTO, + isArray: false, + }) + @ApiOperation({ summary: 'Delete a Token' }) + async deleteToken(@Param('id') id: string) { + if (!id) { + throw new HttpException('Token ID is required', HttpStatus.BAD_REQUEST); + } + return this.tokenService.delete(id); + } + + @Get('/my') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth('bearerAuth') + @ApiForbiddenResponse({ + description: 'Error: Unauthorized', + type: OKDTO, + isArray: false, + }) + @ApiOkResponse({ + description: 'A List of Tokens for the current user', + type: OKDTO, + isArray: true, + }) + @ApiOperation({ summary: 'Get all Tokens for the current user' }) + async getMyTokens(@Request() req: any) { + const userId = req.user.userId; + if (!userId) { + throw new HttpException('User ID is required', HttpStatus.BAD_REQUEST); + } + return this.tokenService.findAll().then(tokens => + tokens.filter(token => token.user.id === userId) + ); + } + + @Delete('/my/:id') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth('bearerAuth') + @ApiForbiddenResponse({ + description: 'Error: Unauthorized', + type: OKDTO, + isArray: false, + }) + @ApiOkResponse({ + description: 'Token deleted successfully for the current user', + type: OKDTO, + isArray: false, + }) + @ApiOperation({ summary: 'Delete a Token for the current user' }) + async deleteMyToken(@Param('id') id: string, @Request() req: any) { + const userId = req.user.userId; + if (!id || !userId) { + throw new HttpException('Token ID and User ID are required', HttpStatus.BAD_REQUEST); + } + return this.tokenService.delete(id).then(() => { + this.tokenService.findAll().then(tokens => + tokens.filter(token => token.user.id === userId) + ); + }); + } + +} diff --git a/server/src/token/token.module.ts b/server/src/token/token.module.ts new file mode 100644 index 000000000..0d1c5843c --- /dev/null +++ b/server/src/token/token.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { TokenService } from './token.service'; +import { TokenController } from './token.controller'; + +@Module({ + providers: [TokenService], + controllers: [TokenController] +}) +export class TokenModule {} diff --git a/server/src/token/token.service.spec.ts b/server/src/token/token.service.spec.ts new file mode 100644 index 000000000..a5f5d655a --- /dev/null +++ b/server/src/token/token.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { TokenService } from './token.service'; + +describe('TokenService', () => { + let service: TokenService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [TokenService], + }).compile(); + + service = module.get(TokenService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/server/src/token/token.service.ts b/server/src/token/token.service.ts new file mode 100644 index 000000000..66d3c3bc3 --- /dev/null +++ b/server/src/token/token.service.ts @@ -0,0 +1,65 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { PrismaClient, User as PrismaUser } from '@prisma/client'; + +@Injectable() +export class TokenService { + private readonly prisma = new PrismaClient(); + private logger = new Logger(TokenService.name); + + constructor() {} + async findAll(): Promise { + + return this.prisma.token.findMany({ + select: { + id: true, + token: true, + createdAt: true, + expiresAt: true, + user: { + select: { + id: true, + username: true, + email: true, + firstName: true, + lastName: true, + }, + }, + }, + }); + } + + async create(tokenData: any, userId: string): Promise { + const { token, expiresAt } = tokenData; + if (!token || !expiresAt || !userId) { + throw new Error('Invalid token data'); + } + const newToken = { + token, + expiresAt: new Date(expiresAt), + user: { + connect: { id: userId }, + }, + }; + return this.prisma.token.create({ + data: newToken, + include: { + user: { + select: { + id: true, + username: true, + email: true, + firstName: true, + lastName: true, + }, + }, + }, + }); + } + + async delete(id: string): Promise { + return this.prisma.token.delete({ + where: { id }, + }); + } + +} diff --git a/server/src/users/users.controller.ts b/server/src/users/users.controller.ts index d763edb73..4f68bcd53 100644 --- a/server/src/users/users.controller.ts +++ b/server/src/users/users.controller.ts @@ -1,8 +1,18 @@ import { + Body, Controller, + Delete, Get, + HttpException, + HttpStatus, Param, + Post, + Put, + Request, UseGuards, + UploadedFile, + UseInterceptors, + Response, } from '@nestjs/common'; import { @@ -13,8 +23,10 @@ import { } from '@nestjs/swagger'; import { JwtAuthGuard } from '../auth/strategies/jwt.guard'; import { OKDTO } from '../common/dto/ok.dto'; -import { UsersService } from './users.service'; +import { User, UsersService } from './users.service'; import { GetAllUsersDTO } from './dto/users.dto'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { Response as ResType } from 'express'; @Controller({ path: 'api/users', version: '1' }) export class UsersController { @@ -57,6 +69,7 @@ export class UsersController { (this.usersService as any).request.user.username, ); } + @Get('/username/:username') @UseGuards(JwtAuthGuard) @ApiBearerAuth('bearerAuth') @@ -74,6 +87,7 @@ export class UsersController { async getUserByUsername(@Param('username') username: string) { return this.usersService.findByUsername(username); } + @Get('/id/:id') @UseGuards(JwtAuthGuard) @ApiBearerAuth('bearerAuth') @@ -91,6 +105,7 @@ export class UsersController { async getUserById(@Param('id') id: string) { return this.usersService.findById(id); } + @Get('/count') @UseGuards(JwtAuthGuard) @ApiBearerAuth('bearerAuth') @@ -108,4 +123,195 @@ export class UsersController { async getUserCount() { return this.usersService.count(); } + + @Put('/:id') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth('bearerAuth') + @ApiForbiddenResponse({ + description: 'Error: Unauthorized', + type: OKDTO, + isArray: false, + }) + @ApiOkResponse({ + description: 'Update User by ID', + type: GetAllUsersDTO, + isArray: false, + }) + @ApiOperation({ summary: 'Update User by ID' }) + async updateUser( + @Param('id') id: string, + @Body() body: Partial, + ) { + return this.usersService.update(id, body); + } + + @Delete('/:id') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth('bearerAuth') + @ApiForbiddenResponse({ + description: 'Error: Unauthorized', + type: OKDTO, + isArray: false, + }) + @ApiOkResponse({ + description: 'Delete User by ID', + type: OKDTO, + isArray: false, + }) + @ApiOperation({ summary: 'Delete User by ID' }) + async deleteUser(@Param('id') id: string) { + return this.usersService.delete(id); + } + + @Put('/:id/password/') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth('bearerAuth') + @ApiForbiddenResponse({ + description: 'Error: Unauthorized', + type: OKDTO, + isArray: false, + }) + @ApiOkResponse({ + description: 'Update User password by ID', + type: GetAllUsersDTO, + isArray: false, + }) + @ApiOperation({ summary: 'Update User password by ID' }) + async updateUserPassword( + @Param('id') id: string, + @Body() body: Partial, + ) { + if (!body.password || typeof body.password !== 'string' || body.password.length === 0) { + throw new HttpException('Invalid password provided', HttpStatus.BAD_REQUEST); + } + return this.usersService.updatePassword(id, body.password); + } + + @Put('/update-my-password') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth('bearerAuth') + @ApiForbiddenResponse({ + description: 'Error: Unauthorized', + type: OKDTO, + isArray: false, + }) + @ApiOkResponse({ + description: 'Update current User password', + type: GetAllUsersDTO, + isArray: false, + }) + @ApiOperation({ summary: 'Update current User password' }) + async updateMyPassword( + @Param('password') password: string, + ) { + const user = (this.usersService as any).request.user; + return this.usersService.updatePassword(user.id, password); + } + + @Post('/') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth('bearerAuth') + @ApiForbiddenResponse({ + description: 'Error: Unauthorized', + type: OKDTO, + isArray: false, + }) + @ApiOkResponse({ + description: 'Create a new User', + type: GetAllUsersDTO, + isArray: false, + }) + @ApiOperation({ summary: 'Create a new User' }) + async createUser( + @Body() body: User, + ) { + try { + return this.usersService.create(body); + } catch (error) { + throw new HttpException(`Error creating user: ${error.message}`, HttpStatus.BAD_REQUEST); + } + } + + @Get('/profile') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth('bearerAuth') + @ApiForbiddenResponse({ + description: 'Error: Unauthorized', + type: OKDTO, + isArray: false, + }) + @ApiOkResponse({ + description: 'Get current User profile', + type: GetAllUsersDTO, + isArray: false, + }) + @ApiOperation({ summary: 'Get current User profile' }) + async getProfile(@Request() req: any) { + const user = req.user; + return this.usersService.findById(user.userId); + } + + @Post('/profile/avatar') + @UseGuards(JwtAuthGuard) + @UseInterceptors(FileInterceptor('avatar')) + @ApiBearerAuth('bearerAuth') + @ApiForbiddenResponse({ + description: 'Error: Unauthorized', + type: OKDTO, + isArray: false, + }) + @ApiOkResponse({ + description: 'Update current User avatar', + type: GetAllUsersDTO, + isArray: false, + }) + @ApiOperation({ summary: 'Update current User avatar' }) + async updateProfileAvatar( + @Request() req: any, + @UploadedFile() file: any, + ) { + const user = req.user; + if (!file) { + throw new HttpException('No avatar file uploaded', HttpStatus.BAD_REQUEST); + } + if (file.size > 102400) { // 100KB + throw new HttpException('Avatar image too large (max 100KB)', HttpStatus.BAD_REQUEST); + } + return this.usersService.updateAvatar(user.userId, file); + } +/* + @Get('/profile/avatar') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth('bearerAuth') + @ApiForbiddenResponse({ + description: 'Error: Unauthorized', + type: OKDTO, + isArray: false, + }) + @ApiOkResponse({ + description: 'Get current User avatar', + type: GetAllUsersDTO, + isArray: false, + }) + @ApiOperation({ summary: 'Get current User avatar' }) + async getProfileAvatar(@Request() req: any, @Response() res: ResType) { + const user = req.user; + const avatarImage = await this.usersService.getAvatar(user.userId); + + if (!avatarImage) { + throw new HttpException('No avatar image found', HttpStatus.NOT_FOUND); + } + + // Parse data URL: data:[][;base64], + const matches = avatarImage.match(/^data:(.+);base64,(.+)$/); + if (!matches || matches.length !== 3) { + throw new HttpException('Invalid avatar image format', HttpStatus.INTERNAL_SERVER_ERROR); + } + const contentType = matches[1]; + const imageBuffer = Buffer.from(matches[2], 'base64'); + res.setHeader('Content-Type', contentType); + res.setHeader('Content-Length', imageBuffer.length); + return res.end(imageBuffer); + } +*/ } diff --git a/server/src/users/users.interface.ts b/server/src/users/users.interface.ts index 79d90df3a..cb4fe5025 100644 --- a/server/src/users/users.interface.ts +++ b/server/src/users/users.interface.ts @@ -4,7 +4,7 @@ export interface User { name?: string; email: string; emailVerified?: Date; - password: string; + password?: string; twoFaSecret?: string; twoFaEnabled: boolean; image?: string; diff --git a/server/src/users/users.service.ts b/server/src/users/users.service.ts index 6a0096f63..2908090aa 100644 --- a/server/src/users/users.service.ts +++ b/server/src/users/users.service.ts @@ -2,10 +2,12 @@ import { Injectable, Logger } from '@nestjs/common'; import { PrismaClient, User as PrismaUser } from '@prisma/client'; import * as dotenv from 'dotenv'; dotenv.config(); -import * as crypto from 'crypto'; +//import * as crypto from 'crypto'; +import * as bcrypt from 'bcrypt'; // This should be a real class/interface representing a user entity export type User = any; +export type PartialPrismaUser = Partial; // Partial type for Prisma User to remove sensitive fields @Injectable() export class UsersService { @@ -18,47 +20,178 @@ export class UsersService { return this.prisma.user.findUnique({ where: { username } }); } - async findById(userId: string): Promise { - return this.prisma.user.findUnique({ where: { id: userId } }); + async findById(userId: string): Promise { + return this.prisma.user.findUnique({ + where: { + id: userId + }, + select: { + id: true, + username: true, + email: true, + firstName: true, + lastName: true, + createdAt: true, + updatedAt: true, + isActive: true, + lastLogin: true, + lastIp: true, + provider: true, + providerId: true, + providerData: true, + image: true, + role: { + select: { + id: true, + name: true, + description: true + } + }, + userGroups: { + select: { + id: true, + name: true, + description: true + } + }, + } + }); } - async findAll(): Promise { - return this.prisma.user.findMany(); + async findAll(): Promise { + return this.prisma.user.findMany({ + select: { + id: true, + username: true, + email: true, + firstName: true, + lastName: true, + createdAt: true, + updatedAt: true, + isActive: true, + lastLogin: true, + lastIp: true, + provider: true, + providerId: true, + providerData: true, + image: true, + role: { + select: { + id: true, + name: true, + description: true, + } + }, + userGroups: { + select: { + id: true, + name: true, + description: true + } + }, + tokens: { + select: { + id: true, + token: true, + createdAt: true, + expiresAt: true, + } + }, + }, + }); } async findByUsername(username: string): Promise { return this.prisma.user.findUnique({ where: { username } }); } - async create(user: Partial): Promise { - // Remove null values to match Prisma's expectations - const cleanedData = Object.fromEntries( - Object.entries(user).filter(([_, value]) => value !== null) - ); - return this.prisma.user.create({ - data: cleanedData as PrismaUser + async create(user: any): Promise { + this.logger.debug('Creating user with data:', user); + const { + role, + userGroups, + tokens, + ...cleanedData + } = user; + + + if (cleanedData.password && typeof cleanedData.password === 'string' && cleanedData.password.length > 0) { + cleanedData.password = bcrypt.hashSync(cleanedData.password, 10); + } else { + // If no password is provided, throw an error or handle accordingly + this.logger.warn('Password is required for user creation.'); + throw new Error('Password is required for user creation.'); + } + return this.prisma.user.create({ + data: { + ...cleanedData, + role: role && role ? { connect: { id: role } } : undefined, + userGroups: userGroups && Array.isArray(userGroups) ? { + connect: userGroups.map((g: any) => ({ id: g })), + } : undefined + }, }); } - async update(userId: string, user: Partial): Promise { + async update(userId: string, user: any): Promise { + + let { + id, + createdAt, + updatedAt, + role, + userGroups, + tokens, + password, + ...data + } = user; + + // fix relations + if (role && typeof role === 'string' ) { + data.role = { connect: { id: role } }; + } + if (userGroups && Array.isArray(userGroups)) { + data.userGroups = { + set: [], + connect: userGroups.map((g: any) => ({ id: g.id || g })), + }; + } + + if (Object.keys(data).length === 0) { + this.logger.warn(`No valid fields provided for update on user with ID ${userId}.`); + return undefined; + } + try { return await this.prisma.user.update({ + omit: { + password: true + }, where: { id: userId }, - data: user, + data, }); } catch (error) { - this.logger.warn(`User with ID ${userId} not found for update.`); + this.logger.warn(`User with ID ${userId} not updated.`); + this.logger.debug(error); return undefined; } } async updatePassword(userId: string, newPassword: string): Promise { + if (!newPassword || typeof newPassword !== 'string' || newPassword.length === 0) { + this.logger.warn('No valid new password provided for password update.'); + return undefined; + } try { - return await this.prisma.user.update({ + const hashedPassword = await bcrypt.hash(newPassword, 10); + const user = await this.prisma.user.update({ where: { id: userId }, - data: { password: newPassword }, + data: { password: hashedPassword }, }); + this.logger.debug(`Password updated for user with ID ${userId}.`); + return user; } catch (error) { + this.logger.debug(`Error updating password for user with ID ${userId}:`, error); this.logger.warn(`User with ID ${userId} not found for password update.`); return undefined; } @@ -80,4 +213,71 @@ export class UsersService { const user = await this.prisma.user.findUnique({ where: { username } }); return !!user; } + + async listUsersByGroup(groupId: string): Promise { + return this.prisma.user.findMany({ + where: { + userGroups: { + some: { + id: groupId, + }, + }, + } + }); + } + /* + async generatePasswordHash(password: string): Promise { + const salt = crypto.randomBytes(16).toString('hex'); + const hash = crypto.pbkdf2Sync(password, salt, 1000, 64, 'sha512').toString('hex'); + return `${salt}:${hash}`; + } + async verifyPassword(password: string, hash: string): Promise { + const [salt, key] = hash.split(':'); + const hashVerify = crypto.pbkdf2Sync(password, salt, 1000, 64, 'sha512').toString('hex'); + return key === hashVerify; + } + async getUserByEmail(email: string): Promise { + return this.prisma.user.findUnique({ where: { email } }); + } + */ + + async findAllRoles(): Promise { + return this.prisma.role.findMany({ + select: { + id: true, + name: true, + description: true, + createdAt: true, + updatedAt: true, + }, + }); + } + + async updateAvatar(userId: string, avatarFile: any): Promise { + if (!avatarFile || !avatarFile.buffer) { + this.logger.warn('No avatar file buffer provided.'); + return undefined; + } + // Store as base64 string in DB (for demo; in production, store in object storage or filesystem) + const base64Image = `data:${avatarFile.mimetype};base64,${avatarFile.buffer.toString('base64')}`; + try { + return await this.prisma.user.update({ + where: { id: userId }, + data: { image: base64Image }, + }); + } catch (error) { + this.logger.warn(`User with ID ${userId} not found for avatar update.`); + this.logger.debug(error); + return undefined; + } + } + + async getAvatar(userId: string): Promise { + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + select: { image: true }, + }); + return user ? user.image : null; + } + }