diff --git a/backend/src/connections/connections.module.ts b/backend/src/connections/connections.module.ts new file mode 100644 index 0000000..aa04451 --- /dev/null +++ b/backend/src/connections/connections.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { HostsService } from './hosts.service'; +import { HostsController } from './hosts.controller'; +import { GroupsService } from './groups.service'; +import { GroupsController } from './groups.controller'; + +@Module({ + providers: [HostsService, GroupsService], + controllers: [HostsController, GroupsController], + exports: [HostsService], +}) +export class ConnectionsModule {} diff --git a/backend/src/connections/dto/create-group.dto.ts b/backend/src/connections/dto/create-group.dto.ts new file mode 100644 index 0000000..90deb2a --- /dev/null +++ b/backend/src/connections/dto/create-group.dto.ts @@ -0,0 +1,14 @@ +import { IsString, IsInt, IsOptional } from 'class-validator'; + +export class CreateGroupDto { + @IsString() + name: string; + + @IsInt() + @IsOptional() + parentId?: number; + + @IsInt() + @IsOptional() + sortOrder?: number; +} diff --git a/backend/src/connections/dto/create-host.dto.ts b/backend/src/connections/dto/create-host.dto.ts new file mode 100644 index 0000000..e23b5d1 --- /dev/null +++ b/backend/src/connections/dto/create-host.dto.ts @@ -0,0 +1,41 @@ +import { IsString, IsInt, IsOptional, IsEnum, IsArray, Min, Max } from 'class-validator'; +import { Protocol } from '@prisma/client'; + +export class CreateHostDto { + @IsString() + name: string; + + @IsString() + hostname: string; + + @IsInt() + @Min(1) + @Max(65535) + @IsOptional() + port?: number; + + @IsEnum(Protocol) + @IsOptional() + protocol?: Protocol; + + @IsInt() + @IsOptional() + groupId?: number; + + @IsInt() + @IsOptional() + credentialId?: number; + + @IsArray() + @IsString({ each: true }) + @IsOptional() + tags?: string[]; + + @IsString() + @IsOptional() + notes?: string; + + @IsString() + @IsOptional() + color?: string; +} diff --git a/backend/src/connections/dto/update-group.dto.ts b/backend/src/connections/dto/update-group.dto.ts new file mode 100644 index 0000000..ddaa1b0 --- /dev/null +++ b/backend/src/connections/dto/update-group.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateGroupDto } from './create-group.dto'; + +export class UpdateGroupDto extends PartialType(CreateGroupDto) {} diff --git a/backend/src/connections/dto/update-host.dto.ts b/backend/src/connections/dto/update-host.dto.ts new file mode 100644 index 0000000..ce1e597 --- /dev/null +++ b/backend/src/connections/dto/update-host.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateHostDto } from './create-host.dto'; + +export class UpdateHostDto extends PartialType(CreateHostDto) {} diff --git a/backend/src/connections/groups.controller.ts b/backend/src/connections/groups.controller.ts new file mode 100644 index 0000000..71373ea --- /dev/null +++ b/backend/src/connections/groups.controller.ts @@ -0,0 +1,41 @@ +import { Controller, Get, Post, Put, Delete, Param, Body, UseGuards, ParseIntPipe } from '@nestjs/common'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { GroupsService } from './groups.service'; +import { CreateGroupDto } from './dto/create-group.dto'; +import { UpdateGroupDto } from './dto/update-group.dto'; + +@UseGuards(JwtAuthGuard) +@Controller('groups') +export class GroupsController { + constructor(private groups: GroupsService) {} + + @Get() + findAll() { + return this.groups.findAll(); + } + + @Get('tree') + findTree() { + return this.groups.findTree(); + } + + @Get(':id') + findOne(@Param('id', ParseIntPipe) id: number) { + return this.groups.findOne(id); + } + + @Post() + create(@Body() dto: CreateGroupDto) { + return this.groups.create(dto); + } + + @Put(':id') + update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateGroupDto) { + return this.groups.update(id, dto); + } + + @Delete(':id') + remove(@Param('id', ParseIntPipe) id: number) { + return this.groups.remove(id); + } +} diff --git a/backend/src/connections/groups.service.ts b/backend/src/connections/groups.service.ts new file mode 100644 index 0000000..458205f --- /dev/null +++ b/backend/src/connections/groups.service.ts @@ -0,0 +1,60 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { CreateGroupDto } from './dto/create-group.dto'; +import { UpdateGroupDto } from './dto/update-group.dto'; + +@Injectable() +export class GroupsService { + constructor(private prisma: PrismaService) {} + + findAll() { + return this.prisma.hostGroup.findMany({ + include: { children: true, hosts: { select: { id: true, name: true, protocol: true } } }, + orderBy: { sortOrder: 'asc' }, + }); + } + + findTree() { + return this.prisma.hostGroup.findMany({ + where: { parentId: null }, + include: { + hosts: { orderBy: { sortOrder: 'asc' } }, + children: { + include: { + hosts: { orderBy: { sortOrder: 'asc' } }, + children: { + include: { + hosts: { orderBy: { sortOrder: 'asc' } }, + }, + }, + }, + orderBy: { sortOrder: 'asc' }, + }, + }, + orderBy: { sortOrder: 'asc' }, + }); + } + + async findOne(id: number) { + const group = await this.prisma.hostGroup.findUnique({ + where: { id }, + include: { hosts: true, children: true }, + }); + if (!group) throw new NotFoundException(`Group ${id} not found`); + return group; + } + + create(dto: CreateGroupDto) { + return this.prisma.hostGroup.create({ data: dto }); + } + + async update(id: number, dto: UpdateGroupDto) { + await this.findOne(id); + return this.prisma.hostGroup.update({ where: { id }, data: dto }); + } + + async remove(id: number) { + await this.findOne(id); + return this.prisma.hostGroup.delete({ where: { id } }); + } +} diff --git a/backend/src/connections/hosts.controller.ts b/backend/src/connections/hosts.controller.ts new file mode 100644 index 0000000..88057f7 --- /dev/null +++ b/backend/src/connections/hosts.controller.ts @@ -0,0 +1,41 @@ +import { Controller, Get, Post, Put, Delete, Param, Body, Query, UseGuards, ParseIntPipe } from '@nestjs/common'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { HostsService } from './hosts.service'; +import { CreateHostDto } from './dto/create-host.dto'; +import { UpdateHostDto } from './dto/update-host.dto'; + +@UseGuards(JwtAuthGuard) +@Controller('hosts') +export class HostsController { + constructor(private hosts: HostsService) {} + + @Get() + findAll(@Query('search') search?: string) { + return this.hosts.findAll(search); + } + + @Get(':id') + findOne(@Param('id', ParseIntPipe) id: number) { + return this.hosts.findOne(id); + } + + @Post() + create(@Body() dto: CreateHostDto) { + return this.hosts.create(dto); + } + + @Put(':id') + update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateHostDto) { + return this.hosts.update(id, dto); + } + + @Delete(':id') + remove(@Param('id', ParseIntPipe) id: number) { + return this.hosts.remove(id); + } + + @Post('reorder') + reorder(@Body() body: { ids: number[] }) { + return this.hosts.reorder(body.ids); + } +} diff --git a/backend/src/connections/hosts.service.ts b/backend/src/connections/hosts.service.ts new file mode 100644 index 0000000..a807af6 --- /dev/null +++ b/backend/src/connections/hosts.service.ts @@ -0,0 +1,76 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { CreateHostDto } from './dto/create-host.dto'; +import { UpdateHostDto } from './dto/update-host.dto'; + +@Injectable() +export class HostsService { + constructor(private prisma: PrismaService) {} + + findAll(search?: string) { + const where = search + ? { + OR: [ + { name: { contains: search, mode: 'insensitive' as const } }, + { hostname: { contains: search, mode: 'insensitive' as const } }, + { tags: { has: search } }, + ], + } + : {}; + return this.prisma.host.findMany({ + where, + include: { group: true, credential: { select: { id: true, name: true, type: true } } }, + orderBy: [{ lastConnectedAt: { sort: 'desc', nulls: 'last' } }, { sortOrder: 'asc' }], + }); + } + + async findOne(id: number) { + const host = await this.prisma.host.findUnique({ + where: { id }, + include: { group: true, credential: true }, + }); + if (!host) throw new NotFoundException(`Host ${id} not found`); + return host; + } + + create(dto: CreateHostDto) { + return this.prisma.host.create({ + data: { + name: dto.name, + hostname: dto.hostname, + port: dto.port ?? (dto.protocol === 'rdp' ? 3389 : 22), + protocol: dto.protocol ?? 'ssh', + groupId: dto.groupId, + credentialId: dto.credentialId, + tags: dto.tags ?? [], + notes: dto.notes, + color: dto.color, + }, + include: { group: true }, + }); + } + + async update(id: number, dto: UpdateHostDto) { + await this.findOne(id); // throws if not found + return this.prisma.host.update({ where: { id }, data: dto }); + } + + async remove(id: number) { + await this.findOne(id); + return this.prisma.host.delete({ where: { id } }); + } + + async touchLastConnected(id: number) { + return this.prisma.host.update({ + where: { id }, + data: { lastConnectedAt: new Date() }, + }); + } + + async reorder(ids: number[]) { + const updates = ids.map((id, index) => + this.prisma.host.update({ where: { id }, data: { sortOrder: index } }), + ); + return this.prisma.$transaction(updates); + } +}