diff --git a/myteamwallet_backend/src/app.module.ts b/myteamwallet_backend/src/app.module.ts index 63601b4..084d3be 100644 --- a/myteamwallet_backend/src/app.module.ts +++ b/myteamwallet_backend/src/app.module.ts @@ -16,7 +16,6 @@ import { DataSource } from 'typeorm'; import { PlayersModule } from './players/players.module'; import { TeamsModule } from './teams/teams.module'; import { TransactionsModule } from './transactions/transactions.module'; -import { TeamSettingsModule } from './team-settings/team-settings.module'; import { TeamWalletTransactionsModule } from './team-wallet-transactions/team-wallet-transactions.module'; import { ServeStaticModule } from '@nestjs/serve-static'; import { join } from 'path'; @@ -52,7 +51,6 @@ import { PenaltyModule } from './penalty/penalty.module'; PlayersModule, TeamsModule, TransactionsModule, - TeamSettingsModule, TeamWalletTransactionsModule, LoggingModule, TranslateModule, diff --git a/myteamwallet_backend/src/auth/auth.controller.ts b/myteamwallet_backend/src/auth/auth.controller.ts index 27300dc..a67a5eb 100644 --- a/myteamwallet_backend/src/auth/auth.controller.ts +++ b/myteamwallet_backend/src/auth/auth.controller.ts @@ -108,10 +108,11 @@ export class AuthController { @Post('invite') @UseGuards(AuthGuard('jwt')) public getInvite( + @Request() request, @Body() invite: any, ) { - return this.service.createTeamInvite(invite); + return this.service.createTeamInvite(invite, Number(request.user.id)); } @ApiOperation({ diff --git a/myteamwallet_backend/src/auth/auth.module.ts b/myteamwallet_backend/src/auth/auth.module.ts index 6029a36..6502f58 100644 --- a/myteamwallet_backend/src/auth/auth.module.ts +++ b/myteamwallet_backend/src/auth/auth.module.ts @@ -12,6 +12,7 @@ import { MailModule } from 'src/mail/mail.module'; import { IsExist } from 'src/utils/validators/is-exists.validator'; import { IsNotExist } from 'src/utils/validators/is-not-exists.validator'; import { LoggingModule } from 'src/database/logging/logging.module'; +import { TeamsModule } from 'src/teams/teams.module'; @Module({ imports: [ @@ -19,6 +20,7 @@ import { LoggingModule } from 'src/database/logging/logging.module'; ForgotModule, PassportModule, MailModule, + TeamsModule, JwtModule.registerAsync({ imports: [ConfigModule], inject: [ConfigService], diff --git a/myteamwallet_backend/src/auth/auth.service.spec.ts b/myteamwallet_backend/src/auth/auth.service.spec.ts index 67e6e83..cd23f38 100644 --- a/myteamwallet_backend/src/auth/auth.service.spec.ts +++ b/myteamwallet_backend/src/auth/auth.service.spec.ts @@ -51,6 +51,7 @@ describe('AuthService inactive-user enforcement and safe logging', () => { mailService, logger, dataSource, + { assertAtLeast: jest.fn() } as any, ); }); diff --git a/myteamwallet_backend/src/auth/auth.service.ts b/myteamwallet_backend/src/auth/auth.service.ts index 82c592e..4c39b65 100644 --- a/myteamwallet_backend/src/auth/auth.service.ts +++ b/myteamwallet_backend/src/auth/auth.service.ts @@ -25,6 +25,8 @@ import { MailService } from 'src/mail/mail.service'; import { CreateInviteDTO } from './dto/create-invite.dto'; import { LoggingService } from 'src/database/logging/logging.service'; import { DataSource } from 'typeorm'; +import { TeamAccessService } from 'src/teams/team-access.service'; +import { TeamRolesEnum } from 'src/team-roles/team-roles.enum'; @Injectable() export class AuthService { @@ -35,6 +37,7 @@ export class AuthService { private mailService: MailService, private logger: LoggingService, private dataSource: DataSource, + private teamAccess: TeamAccessService, ) {} async validateLogin( @@ -302,7 +305,14 @@ export class AuthService { }); } - async createTeamInvite(object: CreateInviteDTO) { + async createTeamInvite(object: CreateInviteDTO, actorUserId: number) { + await this.teamAccess.assertAtLeast( + actorUserId, + object.teamId, + 'invite_min_role', + TeamRolesEnum.captain, + ); + const token = await this.jwtService.sign(object, { expiresIn: '30d', }); diff --git a/myteamwallet_backend/src/database/logging/model/logging-event.type.ts b/myteamwallet_backend/src/database/logging/model/logging-event.type.ts index 5e8073f..671d433 100644 --- a/myteamwallet_backend/src/database/logging/model/logging-event.type.ts +++ b/myteamwallet_backend/src/database/logging/model/logging-event.type.ts @@ -25,6 +25,7 @@ export type LOGEVENT = | 'penalty_catalog_create' | 'penalty_catalog_update' | 'penalty_catalog_delete' - | 'team_create'; + | 'team_create' + | 'team_permissions_update'; export type LOGLEVEL = 'FATAL' | 'ERROR' | 'WARN' | 'INFO' | 'DEBUG' | 'TRACE'; diff --git a/myteamwallet_backend/src/database/migrations/1785524400000-AddTeamPermissionSettings.ts b/myteamwallet_backend/src/database/migrations/1785524400000-AddTeamPermissionSettings.ts new file mode 100644 index 0000000..0a93a80 --- /dev/null +++ b/myteamwallet_backend/src/database/migrations/1785524400000-AddTeamPermissionSettings.ts @@ -0,0 +1,48 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +const BACKFILLED_KEYS = [ + 'invite_min_role', + 'member_manage_min_role', + 'penalty_manage_min_role', + 'public_access_manage_min_role', +] as const; + +export class AddTeamPermissionSettings1785524400000 + implements MigrationInterface +{ + name = 'AddTeamPermissionSettings1785524400000'; + + public async up(queryRunner: QueryRunner): Promise { + for (const key of BACKFILLED_KEYS) { + await queryRunner.query( + `INSERT INTO "team_setting" ("teamId", "key", "value") + SELECT "id", '${key}', '3' FROM "team" + WHERE NOT EXISTS ( + SELECT 1 FROM "team_setting" ts + WHERE ts."teamId" = "team"."id" AND ts."key" = '${key}' + )`, + ); + } + + await queryRunner.query( + `INSERT INTO "team_setting" ("teamId", "key", "value") + SELECT "teamId", 'transaction_reverse_min_role', "value" FROM "team_setting" + WHERE "key" = 'transaction_create_min_role' + AND "teamId" NOT IN ( + SELECT "teamId" FROM "team_setting" WHERE "key" = 'transaction_reverse_min_role' + )`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DELETE FROM "team_setting" WHERE "key" IN ( + 'transaction_reverse_min_role', + 'invite_min_role', + 'member_manage_min_role', + 'penalty_manage_min_role', + 'public_access_manage_min_role' + )`, + ); + } +} diff --git a/myteamwallet_backend/src/database/migrations/AddTeamPermissionSettings.spec.ts b/myteamwallet_backend/src/database/migrations/AddTeamPermissionSettings.spec.ts new file mode 100644 index 0000000..e9d6054 --- /dev/null +++ b/myteamwallet_backend/src/database/migrations/AddTeamPermissionSettings.spec.ts @@ -0,0 +1,50 @@ +describe('AddTeamPermissionSettings1785524400000', () => { + it('backfills the new min-role settings for existing teams idempotently', async () => { + const migrationModule = require('./1785524400000-AddTeamPermissionSettings'); + const migration = + new migrationModule.AddTeamPermissionSettings1785524400000(); + const queryRunner = { query: jest.fn() } as any; + + await migration.up(queryRunner); + + const calls: string[] = queryRunner.query.mock.calls.map((c: any) => c[0]); + expect(calls).toHaveLength(5); + for (const key of [ + 'invite_min_role', + 'member_manage_min_role', + 'penalty_manage_min_role', + 'public_access_manage_min_role', + ]) { + expect( + calls.some( + (sql) => sql.includes(`'${key}'`) && sql.includes('NOT EXISTS'), + ), + ).toBe(true); + } + expect( + calls.some( + (sql) => + sql.includes("'transaction_reverse_min_role'") && + sql.includes("'transaction_create_min_role'"), + ), + ).toBe(true); + }); + + it('removes the new min-role settings again on down', async () => { + const migrationModule = require('./1785524400000-AddTeamPermissionSettings'); + const migration = + new migrationModule.AddTeamPermissionSettings1785524400000(); + const queryRunner = { query: jest.fn() } as any; + + await migration.down(queryRunner); + + expect(queryRunner.query).toHaveBeenCalledTimes(1); + const sql = queryRunner.query.mock.calls[0][0]; + expect(sql).toContain('DELETE FROM "team_setting"'); + expect(sql).toContain('transaction_reverse_min_role'); + expect(sql).toContain('invite_min_role'); + expect(sql).toContain('member_manage_min_role'); + expect(sql).toContain('penalty_manage_min_role'); + expect(sql).toContain('public_access_manage_min_role'); + }); +}); diff --git a/myteamwallet_backend/src/penalty/penalty.service.spec.ts b/myteamwallet_backend/src/penalty/penalty.service.spec.ts index 65995ac..525e24b 100644 --- a/myteamwallet_backend/src/penalty/penalty.service.spec.ts +++ b/myteamwallet_backend/src/penalty/penalty.service.spec.ts @@ -1,4 +1,5 @@ import { ConflictException, NotFoundException } from '@nestjs/common'; +import { TeamRolesEnum } from '../team-roles/team-roles.enum'; import { PenaltyService } from './penalty.service'; describe('PenaltyService catalog management', () => { @@ -28,7 +29,7 @@ describe('PenaltyService catalog management', () => { ), }; const dataSource = { transaction: jest.fn((work) => work(manager)) }; - const access = { assertMember: jest.fn(), assertManager: jest.fn() }; + const access = { assertMember: jest.fn(), assertAtLeast: jest.fn() }; const logger = { info: jest.fn() }; let service: PenaltyService; @@ -127,7 +128,13 @@ describe('PenaltyService catalog management', () => { }); expect(teamQuery.setLock).toHaveBeenCalledWith('pessimistic_write'); - expect(access.assertManager).toHaveBeenCalledWith(42, 5, manager); + expect(access.assertAtLeast).toHaveBeenCalledWith( + 42, + 5, + 'penalty_manage_min_role', + TeamRolesEnum.captain, + manager, + ); expect(logger.info).toHaveBeenCalledWith( { event: 'penalty_catalog_create', diff --git a/myteamwallet_backend/src/penalty/penalty.service.ts b/myteamwallet_backend/src/penalty/penalty.service.ts index 205e2dc..09f5892 100644 --- a/myteamwallet_backend/src/penalty/penalty.service.ts +++ b/myteamwallet_backend/src/penalty/penalty.service.ts @@ -5,6 +5,7 @@ import { } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { LoggingService } from 'src/database/logging/logging.service'; +import { TeamRolesEnum } from 'src/team-roles/team-roles.enum'; import { Team } from 'src/teams/entities/team.entity'; import { TeamAccessService } from 'src/teams/team-access.service'; import { DataSource, EntityManager, Repository } from 'typeorm'; @@ -55,7 +56,13 @@ export class PenaltyService { ): Promise { return this.dataSource.transaction(async (manager) => { const team = await this.lockTeam(manager, dto.teamId); - await this.access.assertManager(userId, team.id, manager); + await this.access.assertAtLeast( + userId, + team.id, + 'penalty_manage_min_role', + TeamRolesEnum.captain, + manager, + ); const repository = manager.getRepository(PenaltyEntity); const description = dto.description.trim(); await this.assertUniqueDescription(repository, team.id, description); @@ -82,7 +89,13 @@ export class PenaltyService { const owner = await this.findOwner(penaltyId); return this.dataSource.transaction(async (manager) => { const team = await this.lockTeam(manager, owner.team.id); - await this.access.assertManager(userId, team.id, manager); + await this.access.assertAtLeast( + userId, + team.id, + 'penalty_manage_min_role', + TeamRolesEnum.captain, + manager, + ); const repository = manager.getRepository(PenaltyEntity); const penalty = await this.findTransactionalPenalty( repository, @@ -115,7 +128,13 @@ export class PenaltyService { const owner = await this.findOwner(penaltyId); await this.dataSource.transaction(async (manager) => { const team = await this.lockTeam(manager, owner.team.id); - await this.access.assertManager(userId, team.id, manager); + await this.access.assertAtLeast( + userId, + team.id, + 'penalty_manage_min_role', + TeamRolesEnum.captain, + manager, + ); const repository = manager.getRepository(PenaltyEntity); const penalty = await this.findTransactionalPenalty( repository, diff --git a/myteamwallet_backend/src/team-roles/team-roles.decorator.ts b/myteamwallet_backend/src/team-roles/team-roles.decorator.ts deleted file mode 100644 index 784de44..0000000 --- a/myteamwallet_backend/src/team-roles/team-roles.decorator.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { SetMetadata } from '@nestjs/common'; - -export const TeamRoles = (...teamRoles: number[]) => - SetMetadata('teamRoles', teamRoles); diff --git a/myteamwallet_backend/src/team-roles/team-roles.guard.ts b/myteamwallet_backend/src/team-roles/team-roles.guard.ts deleted file mode 100644 index b62a141..0000000 --- a/myteamwallet_backend/src/team-roles/team-roles.guard.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; -import { Reflector } from '@nestjs/core'; - -@Injectable() -export class TeamRolesGuard implements CanActivate { - constructor(private reflector: Reflector) {} - - canActivate(context: ExecutionContext): boolean { - const roles = this.reflector.getAllAndOverride('teamRoles', [ - context.getClass(), - context.getHandler(), - ]); - if (!roles.length) { - return true; - } - const request = context.switchToHttp().getRequest(); - - return roles.includes(request.user?.teamRoles?.id); - } -} diff --git a/myteamwallet_backend/src/team-settings/team-setting-defaults.ts b/myteamwallet_backend/src/team-settings/team-setting-defaults.ts new file mode 100644 index 0000000..26e69bc --- /dev/null +++ b/myteamwallet_backend/src/team-settings/team-setting-defaults.ts @@ -0,0 +1,14 @@ +import { TeamRolesEnum } from '../team-roles/team-roles.enum'; +import { TeamSettingType } from '../teams/model/team-settings-type'; + +export const TEAM_SETTING_DEFAULTS: Record< + Exclude, + TeamRolesEnum +> = { + transaction_create_min_role: TeamRolesEnum.scnd_treasurer, + transaction_reverse_min_role: TeamRolesEnum.scnd_treasurer, + invite_min_role: TeamRolesEnum.captain, + member_manage_min_role: TeamRolesEnum.captain, + penalty_manage_min_role: TeamRolesEnum.captain, + public_access_manage_min_role: TeamRolesEnum.captain, +}; diff --git a/myteamwallet_backend/src/team-settings/team-settings.module.ts b/myteamwallet_backend/src/team-settings/team-settings.module.ts deleted file mode 100644 index fc876b2..0000000 --- a/myteamwallet_backend/src/team-settings/team-settings.module.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; - -@Module({ - imports: [TypeOrmModule.forFeature([TeamSettingsModule])], -}) -export class TeamSettingsModule {} diff --git a/myteamwallet_backend/src/team-wallet-transactions/team-wallet-transactions.module.ts b/myteamwallet_backend/src/team-wallet-transactions/team-wallet-transactions.module.ts index 3f618bc..5869b0e 100644 --- a/myteamwallet_backend/src/team-wallet-transactions/team-wallet-transactions.module.ts +++ b/myteamwallet_backend/src/team-wallet-transactions/team-wallet-transactions.module.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { LoggingModule } from 'src/database/logging/logging.module'; import { Team } from 'src/teams/entities/team.entity'; +import { TeamsModule } from 'src/teams/teams.module'; import { User } from 'src/users/entities/user.entity'; import { TeamWalletTransactionType } from './entities/team-wallet-transaction-type.entity'; import { TeamWalletTransaction } from './entities/team-wallet-transaction.entity'; @@ -17,6 +18,7 @@ import { TeamWalletTransactionsService } from './team-wallet-transactions.servic Team, ]), LoggingModule, + TeamsModule, ], controllers: [TeamWalletTransactionsController], providers: [TeamWalletTransactionsService], diff --git a/myteamwallet_backend/src/team-wallet-transactions/team-wallet-transactions.service.ts b/myteamwallet_backend/src/team-wallet-transactions/team-wallet-transactions.service.ts index 86f449d..3cd863e 100644 --- a/myteamwallet_backend/src/team-wallet-transactions/team-wallet-transactions.service.ts +++ b/myteamwallet_backend/src/team-wallet-transactions/team-wallet-transactions.service.ts @@ -1,6 +1,9 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { LoggingService } from 'src/database/logging/logging.service'; +import { RoleEnum } from 'src/roles/roles.enum'; +import { TeamRolesEnum } from 'src/team-roles/team-roles.enum'; +import { TeamAccessService } from 'src/teams/team-access.service'; import { Team } from 'src/teams/entities/team.entity'; import { User } from 'src/users/entities/user.entity'; import { Repository } from 'typeorm'; @@ -21,6 +24,7 @@ export class TeamWalletTransactionsService { @InjectRepository(Team) private teamRepository: Repository, private logger: LoggingService, + private access: TeamAccessService, ) {} async create(data: CreateTeamWalletTransactionDto, userId: string) { @@ -28,32 +32,20 @@ export class TeamWalletTransactionsService { where: { id: Number(userId), }, - relations: ['players', 'players.team', 'players.team.settings'], }); - const teamPlayer = creatingUser.players.find( - (p) => - p.team.id == data.teamId && - p.teamRole.id >= - Number( - p.team.settings.find((s) => s.key == 'transaction_create_min_role')[ - 'value' - ], - ), - ); - - if (creatingUser.role.id != 1 && !teamPlayer) { - return; + if (creatingUser.role.id != RoleEnum.admin) { + await this.access.assertAtLeast( + Number(userId), + data.teamId, + 'transaction_create_min_role', + TeamRolesEnum.scnd_treasurer, + ); } - let team: Team; - if (teamPlayer && teamPlayer.team) { - team = teamPlayer.team; - } else { - team = await this.teamRepository.findOneByOrFail({ - id: data.teamId, - }); - } + const team = await this.teamRepository.findOneByOrFail({ + id: data.teamId, + }); const transactionType = await this.teamWalletTransactionTypeRepository.findOne({ diff --git a/myteamwallet_backend/src/teams/dto/team-permissions.dto.ts b/myteamwallet_backend/src/teams/dto/team-permissions.dto.ts new file mode 100644 index 0000000..f6f2265 --- /dev/null +++ b/myteamwallet_backend/src/teams/dto/team-permissions.dto.ts @@ -0,0 +1,51 @@ +import { IsIn, IsInt, IsOptional } from 'class-validator'; +import { TeamRolesEnum } from '../../team-roles/team-roles.enum'; + +const VALID_ROLES = [ + TeamRolesEnum.player, + TeamRolesEnum.scnd_treasurer, + TeamRolesEnum.captain, + TeamRolesEnum.treasurer, + TeamRolesEnum.coach, +]; + +export class UpdateTeamPermissionsDto { + @IsOptional() + @IsInt() + @IsIn(VALID_ROLES) + transactionCreateMinRole?: TeamRolesEnum; + + @IsOptional() + @IsInt() + @IsIn(VALID_ROLES) + transactionReverseMinRole?: TeamRolesEnum; + + @IsOptional() + @IsInt() + @IsIn(VALID_ROLES) + inviteMinRole?: TeamRolesEnum; + + @IsOptional() + @IsInt() + @IsIn(VALID_ROLES) + memberManageMinRole?: TeamRolesEnum; + + @IsOptional() + @IsInt() + @IsIn(VALID_ROLES) + penaltyManageMinRole?: TeamRolesEnum; + + @IsOptional() + @IsInt() + @IsIn(VALID_ROLES) + publicAccessManageMinRole?: TeamRolesEnum; +} + +export interface TeamPermissionsDto { + transactionCreateMinRole: number; + transactionReverseMinRole: number; + inviteMinRole: number; + memberManageMinRole: number; + penaltyManageMinRole: number; + publicAccessManageMinRole: number; +} diff --git a/myteamwallet_backend/src/teams/model/team-settings-type.ts b/myteamwallet_backend/src/teams/model/team-settings-type.ts index c85ae3e..25f6abc 100644 --- a/myteamwallet_backend/src/teams/model/team-settings-type.ts +++ b/myteamwallet_backend/src/teams/model/team-settings-type.ts @@ -1 +1,8 @@ -export type TeamSettingType = 'transaction_create_min_role' | ''; +export type TeamSettingType = + | 'transaction_create_min_role' + | 'transaction_reverse_min_role' + | 'invite_min_role' + | 'member_manage_min_role' + | 'penalty_manage_min_role' + | 'public_access_manage_min_role' + | ''; diff --git a/myteamwallet_backend/src/teams/public-team-access.service.spec.ts b/myteamwallet_backend/src/teams/public-team-access.service.spec.ts index a9dbfc6..9d820a8 100644 --- a/myteamwallet_backend/src/teams/public-team-access.service.spec.ts +++ b/myteamwallet_backend/src/teams/public-team-access.service.spec.ts @@ -1,4 +1,5 @@ import { NotFoundException } from '@nestjs/common'; +import { TeamRolesEnum } from '../team-roles/team-roles.enum'; import { PublicTeamAccessService } from './public-team-access.service'; describe('PublicTeamAccessService', () => { @@ -10,7 +11,7 @@ describe('PublicTeamAccessService', () => { const playerRepository = { find: jest.fn(), findOne: jest.fn() }; const transactionRepository = { find: jest.fn() }; const penaltyRepository = { find: jest.fn() }; - const access = { assertMember: jest.fn(), assertManager: jest.fn() }; + const access = { assertMember: jest.fn(), assertAtLeast: jest.fn() }; let service: PublicTeamAccessService; const managedTeam = { @@ -48,7 +49,12 @@ describe('PublicTeamAccessService', () => { const status = await service.setEnabled(4, 7, true); - expect(access.assertManager).toHaveBeenCalledWith(4, 7); + expect(access.assertAtLeast).toHaveBeenCalledWith( + 4, + 7, + 'public_access_manage_min_role', + TeamRolesEnum.captain, + ); expect(status.enabled).toBe(true); expect(status.token).toMatch(/^[a-f0-9]{64}$/); expect(teamRepository.save).toHaveBeenCalledWith(team); diff --git a/myteamwallet_backend/src/teams/public-team-access.service.ts b/myteamwallet_backend/src/teams/public-team-access.service.ts index fa9093d..368e620 100644 --- a/myteamwallet_backend/src/teams/public-team-access.service.ts +++ b/myteamwallet_backend/src/teams/public-team-access.service.ts @@ -3,6 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm'; import { randomBytes } from 'crypto'; import { PenaltyEntity } from '../penalty/entities/penalty.entity'; import { Player } from '../players/entities/player.entity'; +import { TeamRolesEnum } from '../team-roles/team-roles.enum'; import { Transaction } from '../transactions/entitites/transaction.entity'; import { Repository } from 'typeorm'; import { @@ -42,7 +43,12 @@ export class PublicTeamAccessService { teamId: number, enabled: boolean, ): Promise { - await this.access.assertManager(userId, teamId); + await this.access.assertAtLeast( + userId, + teamId, + 'public_access_manage_min_role', + TeamRolesEnum.captain, + ); const team = await this.loadManagedTeam(teamId); if (enabled && !team.publicAccessToken) { team.publicAccessToken = this.createToken(); @@ -53,7 +59,12 @@ export class PublicTeamAccessService { } async rotate(userId: number, teamId: number): Promise { - await this.access.assertManager(userId, teamId); + await this.access.assertAtLeast( + userId, + teamId, + 'public_access_manage_min_role', + TeamRolesEnum.captain, + ); const team = await this.loadManagedTeam(teamId); team.publicAccessToken = this.createToken(); await this.teamRepository.save(team); diff --git a/myteamwallet_backend/src/teams/team-access.service.spec.ts b/myteamwallet_backend/src/teams/team-access.service.spec.ts index b28b422..5600812 100644 --- a/myteamwallet_backend/src/teams/team-access.service.spec.ts +++ b/myteamwallet_backend/src/teams/team-access.service.spec.ts @@ -1,10 +1,12 @@ import { ForbiddenException } from '@nestjs/common'; import { RoleEnum } from '../roles/roles.enum'; +import { TeamRolesEnum } from '../team-roles/team-roles.enum'; import { TeamAccessService } from './team-access.service'; describe('TeamAccessService', () => { const userRepository = { findOne: jest.fn() }; const playerRepository = { find: jest.fn() }; + const teamSettingRepository = { findOne: jest.fn() }; let service: TeamAccessService; beforeEach(() => { @@ -12,6 +14,7 @@ describe('TeamAccessService', () => { service = new TeamAccessService( userRepository as any, playerRepository as any, + teamSettingRepository as any, ); }); @@ -105,4 +108,104 @@ describe('TeamAccessService', () => { ForbiddenException, ); }); + + describe('assertAtLeast', () => { + it('uses the configured team setting instead of the fallback', async () => { + userRepository.findOne.mockResolvedValue({ + id: 2, + role: { id: RoleEnum.user }, + }); + teamSettingRepository.findOne.mockResolvedValue({ value: '4' }); + playerRepository.find.mockResolvedValue([ + { active: true, teamRole: { id: 3 } }, + ]); + + await expect( + service.assertAtLeast(2, 9, 'invite_min_role', TeamRolesEnum.captain), + ).rejects.toBeInstanceOf(ForbiddenException); + }); + + it('falls back to the given minimum role when no setting exists', async () => { + userRepository.findOne.mockResolvedValue({ + id: 2, + role: { id: RoleEnum.user }, + }); + teamSettingRepository.findOne.mockResolvedValue(null); + playerRepository.find.mockResolvedValue([ + { active: true, teamRole: { id: 3 } }, + ]); + + await expect( + service.assertAtLeast(2, 9, 'invite_min_role', TeamRolesEnum.captain), + ).resolves.toBeUndefined(); + }); + + it('falls back to the given minimum role when the stored value is invalid', async () => { + userRepository.findOne.mockResolvedValue({ + id: 2, + role: { id: RoleEnum.user }, + }); + teamSettingRepository.findOne.mockResolvedValue({ value: 'not-a-role' }); + playerRepository.find.mockResolvedValue([ + { active: true, teamRole: { id: 3 } }, + ]); + + await expect( + service.assertAtLeast(2, 9, 'invite_min_role', TeamRolesEnum.captain), + ).resolves.toBeUndefined(); + }); + + it('lets a global admin bypass the check without reading settings', async () => { + userRepository.findOne.mockResolvedValue({ + id: 1, + role: { id: RoleEnum.admin }, + }); + + await expect( + service.assertAtLeast(1, 9, 'invite_min_role', TeamRolesEnum.captain), + ).resolves.toBeUndefined(); + expect(teamSettingRepository.findOne).not.toHaveBeenCalled(); + }); + + it('reads the team setting from the supplied transaction manager', async () => { + const transactionalUserRepository = { + findOne: jest.fn().mockResolvedValue({ + id: 2, + role: { id: RoleEnum.user }, + }), + }; + const transactionalPlayerRepository = { + find: jest + .fn() + .mockResolvedValue([{ active: true, teamRole: { id: 4 } }]), + }; + const transactionalTeamSettingRepository = { + findOne: jest.fn().mockResolvedValue({ value: '4' }), + }; + const manager = { + getRepository: jest + .fn() + .mockImplementation((entity) => { + if (entity.name === 'TeamSetting') { + return transactionalTeamSettingRepository; + } + if (entity.name === 'User') return transactionalUserRepository; + return transactionalPlayerRepository; + }), + }; + + await expect( + service.assertAtLeast( + 2, + 9, + 'penalty_manage_min_role', + TeamRolesEnum.captain, + manager as any, + ), + ).resolves.toBeUndefined(); + expect(teamSettingRepository.findOne).not.toHaveBeenCalled(); + expect(userRepository.findOne).not.toHaveBeenCalled(); + expect(playerRepository.find).not.toHaveBeenCalled(); + }); + }); }); diff --git a/myteamwallet_backend/src/teams/team-access.service.ts b/myteamwallet_backend/src/teams/team-access.service.ts index 01fa092..fa33871 100644 --- a/myteamwallet_backend/src/teams/team-access.service.ts +++ b/myteamwallet_backend/src/teams/team-access.service.ts @@ -2,6 +2,9 @@ import { ForbiddenException, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Player } from '../players/entities/player.entity'; import { RoleEnum } from '../roles/roles.enum'; +import { TeamRolesEnum } from '../team-roles/team-roles.enum'; +import { TeamSetting } from '../team-settings/entities/team-setting.entity'; +import { TeamSettingType } from './model/team-settings-type'; import { User } from '../users/entities/user.entity'; import { EntityManager, Repository } from 'typeorm'; @@ -12,6 +15,8 @@ export class TeamAccessService { private readonly userRepository: Repository, @InjectRepository(Player) private readonly playerRepository: Repository, + @InjectRepository(TeamSetting) + private readonly teamSettingRepository: Repository, ) {} async assertMember( @@ -30,10 +35,42 @@ export class TeamAccessService { await this.assertMinimumRole(userId, teamId, 3, manager); } + async assertAtLeast( + userId: number, + teamId: number, + settingKey: Exclude, + fallbackMinRole: TeamRolesEnum, + manager?: EntityManager, + ): Promise { + await this.assertMinimumRole( + userId, + teamId, + () => this.resolveMinimumRole(teamId, settingKey, fallbackMinRole, manager), + manager, + ); + } + + private async resolveMinimumRole( + teamId: number, + settingKey: Exclude, + fallbackMinRole: TeamRolesEnum, + manager?: EntityManager, + ): Promise { + const teamSettingRepository = + manager?.getRepository(TeamSetting) ?? this.teamSettingRepository; + const setting = await teamSettingRepository.findOne({ + where: { team: { id: teamId }, key: settingKey }, + }); + const parsed = setting ? Number(setting.value) : NaN; + return Number.isInteger(parsed) && parsed >= 1 && parsed <= 5 + ? parsed + : fallbackMinRole; + } + private async assertMinimumRole( userId: number, teamId: number, - minimumRole: number, + minimumRole: number | (() => Promise), manager?: EntityManager, ): Promise { const userRepository = manager?.getRepository(User) ?? this.userRepository; @@ -42,6 +79,9 @@ export class TeamAccessService { const user = await userRepository.findOne({ where: { id: userId } }); if (user?.role?.id === RoleEnum.admin) return; + const resolvedMinimumRole = + typeof minimumRole === 'function' ? await minimumRole() : minimumRole; + const players = await playerRepository.find({ where: { user: { id: userId }, team: { id: teamId } }, }); @@ -52,7 +92,7 @@ export class TeamAccessService { 0, ); - if (highestActiveRole < minimumRole) { + if (highestActiveRole < resolvedMinimumRole) { throw new ForbiddenException('Keine Berechtigung für dieses Team.'); } } diff --git a/myteamwallet_backend/src/teams/team-members.service.spec.ts b/myteamwallet_backend/src/teams/team-members.service.spec.ts index f27ccdb..956614f 100644 --- a/myteamwallet_backend/src/teams/team-members.service.spec.ts +++ b/myteamwallet_backend/src/teams/team-members.service.spec.ts @@ -43,12 +43,12 @@ describe('TeamMembersService', () => { }; dataSource = { transaction: jest.fn((work) => work(manager)) }; logger = { info: jest.fn() }; - access = { assertManager: jest.fn(() => Promise.resolve()) }; + access = { assertAtLeast: jest.fn(() => Promise.resolve()) }; service = new TeamMembersService(dataSource, logger, access as any); }); it('checks the team-manager permission before touching the database', async () => { - access.assertManager.mockRejectedValue(new Error('forbidden')); + access.assertAtLeast.mockRejectedValue(new Error('forbidden')); await expect(service.setActive(5, teamId, player.id, false)).rejects.toThrow( 'forbidden', ); diff --git a/myteamwallet_backend/src/teams/team-members.service.ts b/myteamwallet_backend/src/teams/team-members.service.ts index ce27dfa..b373975 100644 --- a/myteamwallet_backend/src/teams/team-members.service.ts +++ b/myteamwallet_backend/src/teams/team-members.service.ts @@ -26,7 +26,12 @@ export class TeamMembersService { playerId: number, active: boolean, ): Promise { - await this.access.assertManager(actorUserId, teamId); + await this.access.assertAtLeast( + actorUserId, + teamId, + 'member_manage_min_role', + TeamRolesEnum.captain, + ); return this.dataSource.transaction(async (manager) => { const activeTreasurers = await this.lockActiveTreasurers(manager, teamId); @@ -71,7 +76,12 @@ export class TeamMembersService { playerId: number, teamRoleId: TeamRolesEnum, ): Promise { - await this.access.assertManager(actorUserId, teamId); + await this.access.assertAtLeast( + actorUserId, + teamId, + 'member_manage_min_role', + TeamRolesEnum.captain, + ); return this.dataSource.transaction(async (manager) => { const activeTreasurers = await this.lockActiveTreasurers(manager, teamId); diff --git a/myteamwallet_backend/src/teams/team-permissions.service.spec.ts b/myteamwallet_backend/src/teams/team-permissions.service.spec.ts new file mode 100644 index 0000000..45a0868 --- /dev/null +++ b/myteamwallet_backend/src/teams/team-permissions.service.spec.ts @@ -0,0 +1,108 @@ +import { ForbiddenException } from '@nestjs/common'; +import { TeamPermissionsService } from './team-permissions.service'; + +describe('TeamPermissionsService', () => { + const teamRepository = { findOne: jest.fn() }; + const teamSettingRepository = { + save: jest.fn((s) => Promise.resolve(s)), + create: jest.fn((s) => s), + }; + const access = { assertMember: jest.fn(), assertManager: jest.fn() }; + const logger = { info: jest.fn() }; + let service: TeamPermissionsService; + + beforeEach(() => { + jest.resetAllMocks(); + teamSettingRepository.save.mockImplementation((s) => Promise.resolve(s)); + teamSettingRepository.create.mockImplementation((s) => s); + service = new TeamPermissionsService( + teamRepository as any, + teamSettingRepository as any, + access as any, + logger as any, + ); + }); + + const teamWithSettings = (overrides: Record = {}) => ({ + id: 9, + settings: [ + { key: 'transaction_create_min_role', value: overrides.transaction_create_min_role ?? '2' }, + { key: 'transaction_reverse_min_role', value: overrides.transaction_reverse_min_role ?? '2' }, + { key: 'invite_min_role', value: overrides.invite_min_role ?? '3' }, + { key: 'member_manage_min_role', value: overrides.member_manage_min_role ?? '3' }, + { key: 'penalty_manage_min_role', value: overrides.penalty_manage_min_role ?? '3' }, + { key: 'public_access_manage_min_role', value: overrides.public_access_manage_min_role ?? '3' }, + ], + }); + + it('requires at least team membership to read permissions', async () => { + access.assertMember.mockRejectedValue(new ForbiddenException()); + await expect(service.getPermissions(2, 9)).rejects.toBeInstanceOf( + ForbiddenException, + ); + expect(teamRepository.findOne).not.toHaveBeenCalled(); + }); + + it('maps the stored settings to the permissions DTO', async () => { + access.assertMember.mockResolvedValue(undefined); + teamRepository.findOne.mockResolvedValue(teamWithSettings()); + + await expect(service.getPermissions(2, 9)).resolves.toEqual({ + transactionCreateMinRole: 2, + transactionReverseMinRole: 2, + inviteMinRole: 3, + memberManageMinRole: 3, + penaltyManageMinRole: 3, + publicAccessManageMinRole: 3, + }); + }); + + it('falls back to defaults for missing settings', async () => { + access.assertMember.mockResolvedValue(undefined); + teamRepository.findOne.mockResolvedValue({ id: 9, settings: [] }); + + await expect(service.getPermissions(2, 9)).resolves.toEqual({ + transactionCreateMinRole: 2, + transactionReverseMinRole: 2, + inviteMinRole: 3, + memberManageMinRole: 3, + penaltyManageMinRole: 3, + publicAccessManageMinRole: 3, + }); + }); + + it('requires manager rights to update permissions', async () => { + access.assertManager.mockRejectedValue(new ForbiddenException()); + await expect( + service.updatePermissions(2, 9, { inviteMinRole: 4 }), + ).rejects.toBeInstanceOf(ForbiddenException); + expect(teamSettingRepository.save).not.toHaveBeenCalled(); + }); + + it('updates only the fields present in the DTO and leaves the rest untouched', async () => { + access.assertManager.mockResolvedValue(undefined); + const team = teamWithSettings(); + teamRepository.findOne.mockResolvedValue(team); + + await service.updatePermissions(2, 9, { inviteMinRole: 4 }); + + expect(teamSettingRepository.save).toHaveBeenCalledTimes(1); + const saved = teamSettingRepository.save.mock.calls[0][0]; + expect(saved).toMatchObject({ key: 'invite_min_role', value: '4' }); + expect(logger.info).toHaveBeenCalledWith( + expect.objectContaining({ event: 'team_permissions_update', userId: 2 }), + ); + }); + + it('creates a new setting row if none exists yet for that key', async () => { + access.assertManager.mockResolvedValue(undefined); + teamRepository.findOne.mockResolvedValue({ id: 9, settings: [] }); + + await service.updatePermissions(2, 9, { memberManageMinRole: 4 }); + + expect(teamSettingRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ key: 'member_manage_min_role', value: '4' }), + ); + expect(teamSettingRepository.save).toHaveBeenCalledTimes(1); + }); +}); diff --git a/myteamwallet_backend/src/teams/team-permissions.service.ts b/myteamwallet_backend/src/teams/team-permissions.service.ts new file mode 100644 index 0000000..6dc3638 --- /dev/null +++ b/myteamwallet_backend/src/teams/team-permissions.service.ts @@ -0,0 +1,104 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { LoggingService } from '../database/logging/logging.service'; +import { TeamSetting } from '../team-settings/entities/team-setting.entity'; +import { TEAM_SETTING_DEFAULTS } from '../team-settings/team-setting-defaults'; +import { + TeamPermissionsDto, + UpdateTeamPermissionsDto, +} from './dto/team-permissions.dto'; +import { Team } from './entities/team.entity'; +import { TeamSettingType } from './model/team-settings-type'; +import { TeamAccessService } from './team-access.service'; + +type PermissionField = keyof TeamPermissionsDto; + +const FIELD_TO_SETTING_KEY: Record< + PermissionField, + Exclude +> = { + transactionCreateMinRole: 'transaction_create_min_role', + transactionReverseMinRole: 'transaction_reverse_min_role', + inviteMinRole: 'invite_min_role', + memberManageMinRole: 'member_manage_min_role', + penaltyManageMinRole: 'penalty_manage_min_role', + publicAccessManageMinRole: 'public_access_manage_min_role', +}; + +@Injectable() +export class TeamPermissionsService { + constructor( + @InjectRepository(Team) + private readonly teamRepository: Repository, + @InjectRepository(TeamSetting) + private readonly teamSettingRepository: Repository, + private readonly access: TeamAccessService, + private readonly logger: LoggingService, + ) {} + + async getPermissions( + userId: number, + teamId: number, + ): Promise { + await this.access.assertMember(userId, teamId); + const team = await this.loadTeam(teamId); + return this.toDto(team.settings ?? []); + } + + async updatePermissions( + userId: number, + teamId: number, + dto: UpdateTeamPermissionsDto, + ): Promise { + await this.access.assertManager(userId, teamId); + const team = await this.loadTeam(teamId); + + for (const field of Object.keys(FIELD_TO_SETTING_KEY) as PermissionField[]) { + const value = dto[field]; + if (value === undefined) continue; + const key = FIELD_TO_SETTING_KEY[field]; + const existing = (team.settings ?? []).find((s) => s.key === key); + if (existing) { + existing.value = String(value); + await this.teamSettingRepository.save(existing); + } else { + await this.teamSettingRepository.save( + this.teamSettingRepository.create({ team, key, value: String(value) }), + ); + } + } + + await this.logger.info({ + event: 'team_permissions_update', + details: `teamId=${teamId} changed=${Object.keys(dto).join(',')}`, + userId, + }); + + const updatedTeam = await this.loadTeam(teamId); + return this.toDto(updatedTeam.settings ?? []); + } + + private async loadTeam(teamId: number): Promise { + const team = await this.teamRepository.findOne({ + where: { id: teamId }, + relations: ['settings'], + }); + if (!team) throw new NotFoundException('Team nicht gefunden.'); + return team; + } + + private toDto(settings: TeamSetting[]): TeamPermissionsDto { + const result = {} as TeamPermissionsDto; + for (const field of Object.keys(FIELD_TO_SETTING_KEY) as PermissionField[]) { + const key = FIELD_TO_SETTING_KEY[field]; + const setting = settings.find((s) => s.key === key); + const parsed = setting ? Number(setting.value) : NaN; + result[field] = + Number.isInteger(parsed) && parsed >= 1 && parsed <= 5 + ? parsed + : TEAM_SETTING_DEFAULTS[key]; + } + return result; + } +} diff --git a/myteamwallet_backend/src/teams/teams.controller.ts b/myteamwallet_backend/src/teams/teams.controller.ts index 1ac20de..a1b25ae 100644 --- a/myteamwallet_backend/src/teams/teams.controller.ts +++ b/myteamwallet_backend/src/teams/teams.controller.ts @@ -23,7 +23,9 @@ import { PublicTeamAccessService } from './public-team-access.service'; import { UpdatePublicAccessDto } from './dto/public-access.dto'; import { UpdatePlayerProfileDto } from './dto/update-player-profile.dto'; import { PlayerActiveDto, PlayerTeamRoleDto } from './dto/player-management.dto'; +import { UpdateTeamPermissionsDto } from './dto/team-permissions.dto'; import { TeamMembersService } from './team-members.service'; +import { TeamPermissionsService } from './team-permissions.service'; @ApiTags('Teams') @Controller({ @@ -35,6 +37,7 @@ export class TeamsController { private service: TeamsService, private publicAccess: PublicTeamAccessService, private teamMembers: TeamMembersService, + private teamPermissions: TeamPermissionsService, ) {} @ApiBearerAuth() @@ -62,6 +65,34 @@ export class TeamsController { return this.publicAccess.rotate(Number(req.user.id), id); } + @ApiOperation({ + summary: 'Team-Berechtigungen abrufen', + description: + 'Gibt die pro Team konfigurierte Mindestrolle je Aktion zurück.', + }) + @ApiBearerAuth() + @UseGuards(AuthGuard('jwt')) + @Get(':id/permissions') + getPermissions(@Req() req, @Param('id', ParseIntPipe) id: number) { + return this.teamPermissions.getPermissions(Number(req.user.id), id); + } + + @ApiOperation({ + summary: 'Team-Berechtigungen ändern', + description: + 'Setzt die Mindestrolle je Aktion für dieses Team. Nur Team-Manager (Kapitän aufwärts).', + }) + @ApiBearerAuth() + @UseGuards(AuthGuard('jwt')) + @Patch(':id/permissions') + updatePermissions( + @Req() req, + @Param('id', ParseIntPipe) id: number, + @Body() body: UpdateTeamPermissionsDto, + ) { + return this.teamPermissions.updatePermissions(Number(req.user.id), id, body); + } + @ApiBearerAuth() @UseGuards(AuthGuard('jwt')) @Get(':id/players/:playerId/transactions') @@ -82,8 +113,8 @@ export class TeamsController { @UseGuards(AuthGuard('jwt'), RolesGuard) @Get(':id/overview') @HttpCode(HttpStatus.OK) - findOne(@Param('id') id: string) { - return this.service.getOverview(id); + findOne(@Req() req, @Param('id') id: string) { + return this.service.getOverview(id, req.user?.id); } @ApiBearerAuth() @@ -121,8 +152,12 @@ export class TeamsController { @UseGuards(AuthGuard('jwt'), RolesGuard) @Post(':id/players') @HttpCode(HttpStatus.CREATED) - createPlayer(@Param('id') id: string, @Body() playerDto: any) { - return this.service.createNewPlayer(id, playerDto); + createPlayer( + @Req() req, + @Param('id') id: string, + @Body() playerDto: any, + ) { + return this.service.createNewPlayer(id, playerDto, req.user?.id); } @Roles([RoleEnum.user, RoleEnum.admin]) @@ -133,8 +168,12 @@ export class TeamsController { }) @Put(':id/players') @HttpCode(HttpStatus.CREATED) - updatePlayer(@Body() playerDto: UpdatePlayerProfileDto) { - return this.service.updatePlayer(playerDto); + updatePlayer( + @Req() req, + @Param('id') id: string, + @Body() playerDto: UpdatePlayerProfileDto, + ) { + return this.service.updatePlayer(id, playerDto, req.user?.id); } @ApiOperation({ diff --git a/myteamwallet_backend/src/teams/teams.module.ts b/myteamwallet_backend/src/teams/teams.module.ts index b859524..63241c0 100644 --- a/myteamwallet_backend/src/teams/teams.module.ts +++ b/myteamwallet_backend/src/teams/teams.module.ts @@ -16,6 +16,7 @@ import { PublicTeamAccessService } from './public-team-access.service'; import { PublicTeamsController } from './public-teams.controller'; import { PenaltyEntity } from '../penalty/entities/penalty.entity'; import { TeamMembersService } from './team-members.service'; +import { TeamPermissionsService } from './team-permissions.service'; @Module({ imports: [ @@ -38,6 +39,7 @@ import { TeamMembersService } from './team-members.service'; TeamAccessService, PublicTeamAccessService, TeamMembersService, + TeamPermissionsService, ], exports: [TeamAccessService], }) diff --git a/myteamwallet_backend/src/teams/teams.service.ts b/myteamwallet_backend/src/teams/teams.service.ts index 8f1533f..f80a4c1 100644 --- a/myteamwallet_backend/src/teams/teams.service.ts +++ b/myteamwallet_backend/src/teams/teams.service.ts @@ -5,6 +5,7 @@ import { Player } from 'src/players/entities/player.entity'; import { TeamRole } from 'src/team-roles/entities/team-roles.entity'; import { CreateTeamSettingDTO } from 'src/team-settings/dto/create-team-setting.dto'; import { TeamSetting } from 'src/team-settings/entities/team-setting.entity'; +import { TEAM_SETTING_DEFAULTS } from 'src/team-settings/team-setting-defaults'; import { TeamWalletTransaction } from 'src/team-wallet-transactions/entities/team-wallet-transaction.entity'; import { Transaction } from 'src/transactions/entitites/transaction.entity'; import { Repository } from 'typeorm'; @@ -32,9 +33,11 @@ export class TeamsService { private access: TeamAccessService, ) {} - async getOverview(teamId: string) { + async getOverview(teamId: string, actorUserId: string) { const id = Number(teamId); + await this.access.assertMember(Number(actorUserId), id); + const team = await this.repository.findOneOrFail({ where: { id }, relations: ['players'], @@ -104,7 +107,10 @@ export class TeamsService { async createNewPlayer( id: string, player: { firstName: string; lastName: string; teamRole: any }, + actorUserId: string, ) { + await this.access.assertManager(Number(actorUserId), Number(id)); + if (typeof player.teamRole == 'string') { player.teamRole = await this.rolesRepository.findOneBy({ name: player.teamRole, @@ -156,24 +162,26 @@ export class TeamsService { return team; } - private generateBasicTeamSettings(team: Team): Promise { - return new Promise(async (resolve) => { - const s: CreateTeamSettingDTO = { - key: 'transaction_create_min_role', - value: '2', - team, - }; + private async generateBasicTeamSettings(team: Team): Promise { + const settings: CreateTeamSettingDTO[] = Object.entries( + TEAM_SETTING_DEFAULTS, + ).map(([key, value]) => ({ + key: key as CreateTeamSettingDTO['key'], + value: String(value), + team, + })); - const setting = this.settingsRepository.create(s); - await this.settingsRepository.save(setting); - resolve(); - }); + await this.settingsRepository.save( + settings.map((s) => this.settingsRepository.create(s)), + ); } async getTeamTransactions(teamId: string | number, userId: string | number) { const start = new Date(); teamId = Number(teamId); + await this.access.assertMember(Number(userId), teamId); + const team = await this.repository.findOneOrFail({ where: { id: teamId, @@ -343,13 +351,24 @@ export class TeamsService { return Math.round(value * 100) / 100; } - async updatePlayer(playerDTO: UpdatePlayerProfileDto) { + async updatePlayer( + teamId: string, + playerDTO: UpdatePlayerProfileDto, + actorUserId: string, + ) { const player = await this.playerRepository.findOneOrFail({ where: { id: playerDTO.id, }, + relations: ['team'], }); + if (player.team?.id !== Number(teamId)) { + throw new HttpException('No Player Found', HttpStatus.NOT_FOUND); + } + + await this.access.assertManager(Number(actorUserId), Number(teamId)); + player.firstName = playerDTO.firstName; player.lastName = playerDTO.lastName; diff --git a/myteamwallet_backend/src/transactions/transactions.module.ts b/myteamwallet_backend/src/transactions/transactions.module.ts index 7493d07..f4c6890 100644 --- a/myteamwallet_backend/src/transactions/transactions.module.ts +++ b/myteamwallet_backend/src/transactions/transactions.module.ts @@ -6,19 +6,14 @@ import { TransactionsService } from './transactions.service'; import { TransactionsController } from './transactions.controller'; import { Player } from 'src/players/entities/player.entity'; import { User } from 'src/users/entities/user.entity'; -import { TeamSetting } from 'src/team-settings/entities/team-setting.entity'; import { LoggingModule } from 'src/database/logging/logging.module'; +import { TeamsModule } from 'src/teams/teams.module'; @Module({ imports: [ - TypeOrmModule.forFeature([ - TransactionType, - Transaction, - Player, - User, - TeamSetting, - ]), + TypeOrmModule.forFeature([TransactionType, Transaction, Player, User]), LoggingModule, + TeamsModule, ], providers: [TransactionsService], controllers: [TransactionsController], diff --git a/myteamwallet_backend/src/transactions/transactions.service.ts b/myteamwallet_backend/src/transactions/transactions.service.ts index 48b01e6..704c741 100644 --- a/myteamwallet_backend/src/transactions/transactions.service.ts +++ b/myteamwallet_backend/src/transactions/transactions.service.ts @@ -1,6 +1,5 @@ import { BadRequestException, - ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; @@ -8,6 +7,8 @@ import { InjectRepository } from '@nestjs/typeorm'; import { LoggingService } from 'src/database/logging/logging.service'; import { Player } from 'src/players/entities/player.entity'; import { RoleEnum } from 'src/roles/roles.enum'; +import { TeamRolesEnum } from 'src/team-roles/team-roles.enum'; +import { TeamAccessService } from 'src/teams/team-access.service'; import { User } from 'src/users/entities/user.entity'; import { Repository } from 'typeorm'; import { CreateTransactionDto } from './dto/create-transaction.dto'; @@ -27,6 +28,7 @@ export class TransactionsService { @InjectRepository(User) private usersRepository: Repository, private logger: LoggingService, + private access: TeamAccessService, ) {} async createTransactions(data: CreateTransactionDto[], userId: string) { @@ -40,50 +42,35 @@ export class TransactionsService { async create(data: CreateTransactionDto, userId: string) { const player = await this.playersRepository.findOne({ where: { id: data.playerId }, + relations: ['team'], }); const creatingUser = await this.usersRepository.findOne({ where: { id: Number(userId), }, - relations: ['players'], }); const transactionType = await this.transactionTypesRepository.findOne({ where: { id: TransactionTypeEnum[TransactionTypeEnum[data.type]] }, }); + if (!player || !transactionType || !creatingUser) { + await this.logger.warn({ + event: 'transaction_create_fail', + details: `Player: ${data.playerId}, amount: ${data.amount}, typeEnum: ${data.type}`, + userId: Number(userId), + }); + throw new NotFoundException('Spieler oder Buchungstyp nicht gefunden.'); + } + if (creatingUser.role.id != RoleEnum.admin) { - if ( - !player || - !transactionType || - !creatingUser || - !creatingUser.players || - creatingUser.players.length == 0 - ) { - await this.logger.warn({ - event: 'transaction_create_fail', - details: `Player: ${data.playerId}, amount: ${data.amount}, typeEnum: ${data.type}`, - userId: Number(userId), - }); - - return; - } - - const teamPlayer = creatingUser.players.find( - (p) => - p.team.id == player.team.id && - p.teamRole.id >= - Number( - player.team.settings.find( - (s) => s.key == 'transaction_create_min_role', - )['value'], - ), + await this.access.assertAtLeast( + Number(userId), + player.team.id, + 'transaction_create_min_role', + TeamRolesEnum.scnd_treasurer, ); - - if (!teamPlayer) { - return; - } } const transaction = this.transactionsRepository.create({ @@ -131,26 +118,15 @@ export class TransactionsService { const creatingUser = await this.usersRepository.findOne({ where: { id: Number(userId) }, - relations: ['players'], }); if (creatingUser.role.id != RoleEnum.admin) { - const teamPlayer = creatingUser.players?.find( - (p) => - p.team.id == original.player.team.id && - p.teamRole.id >= - Number( - original.player.team.settings.find( - (s) => s.key == 'transaction_create_min_role', - )?.['value'] ?? 0, - ), + await this.access.assertAtLeast( + Number(userId), + original.player.team.id, + 'transaction_reverse_min_role', + TeamRolesEnum.scnd_treasurer, ); - - if (!teamPlayer) { - throw new ForbiddenException( - 'Keine Berechtigung, diese Buchung zu stornieren', - ); - } } const originalAmount = Math.abs(Number(original.amount)); diff --git a/myteamwallet_frontend_modern/src/app/app.routes.ts b/myteamwallet_frontend_modern/src/app/app.routes.ts index 221f238..7eab3cc 100644 --- a/myteamwallet_frontend_modern/src/app/app.routes.ts +++ b/myteamwallet_frontend_modern/src/app/app.routes.ts @@ -101,6 +101,11 @@ export const routes: Routes = [ loadComponent: () => import('./features/team/more/public-access/public-access').then((m) => m.PublicAccess), }, + { + path: 'more/permissions', + loadComponent: () => + import('./features/team/more/permissions/permissions').then((m) => m.Permissions), + }, { path: 'more/guide', loadComponent: () => diff --git a/myteamwallet_frontend_modern/src/app/core/team/team-permissions-api.spec.ts b/myteamwallet_frontend_modern/src/app/core/team/team-permissions-api.spec.ts new file mode 100644 index 0000000..92baa9b --- /dev/null +++ b/myteamwallet_frontend_modern/src/app/core/team/team-permissions-api.spec.ts @@ -0,0 +1,44 @@ +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { environment } from '../../../environments/environment'; +import { TeamPermissionsApi } from './team-permissions-api'; + +describe('TeamPermissionsApi', () => { + let api: TeamPermissionsApi; + let httpMock: HttpTestingController; + + const permissions = { + transactionCreateMinRole: 2, + transactionReverseMinRole: 2, + inviteMinRole: 3, + memberManageMinRole: 3, + penaltyManageMinRole: 3, + publicAccessManageMinRole: 3, + }; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideHttpClient(), provideHttpClientTesting()], + }); + api = TestBed.inject(TeamPermissionsApi); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => httpMock.verify()); + + it('loads the team permissions', () => { + api.getPermissions(5).subscribe(); + const request = httpMock.expectOne(`${environment.apiUrl}teams/5/permissions`); + expect(request.request.method).toBe('GET'); + request.flush(permissions); + }); + + it('updates only the changed permissions', () => { + api.updatePermissions(5, { inviteMinRole: 4 }).subscribe(); + const request = httpMock.expectOne(`${environment.apiUrl}teams/5/permissions`); + expect(request.request.method).toBe('PATCH'); + expect(request.request.body).toEqual({ inviteMinRole: 4 }); + request.flush({ ...permissions, inviteMinRole: 4 }); + }); +}); diff --git a/myteamwallet_frontend_modern/src/app/core/team/team-permissions-api.ts b/myteamwallet_frontend_modern/src/app/core/team/team-permissions-api.ts new file mode 100644 index 0000000..2d44d15 --- /dev/null +++ b/myteamwallet_frontend_modern/src/app/core/team/team-permissions-api.ts @@ -0,0 +1,24 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; +import { Observable } from 'rxjs'; +import { environment } from '../../../environments/environment'; +import { TeamPermissions, UpdateTeamPermissions } from '../../models/team-permissions.model'; + +@Injectable({ providedIn: 'root' }) +export class TeamPermissionsApi { + private readonly http = inject(HttpClient); + + getPermissions(teamId: number): Observable { + return this.http.get(`${environment.apiUrl}teams/${teamId}/permissions`); + } + + updatePermissions( + teamId: number, + changes: UpdateTeamPermissions, + ): Observable { + return this.http.patch( + `${environment.apiUrl}teams/${teamId}/permissions`, + changes, + ); + } +} diff --git a/myteamwallet_frontend_modern/src/app/core/team/team-permissions.spec.ts b/myteamwallet_frontend_modern/src/app/core/team/team-permissions.spec.ts new file mode 100644 index 0000000..2ac37bc --- /dev/null +++ b/myteamwallet_frontend_modern/src/app/core/team/team-permissions.spec.ts @@ -0,0 +1,91 @@ +import { TestBed } from '@angular/core/testing'; +import { RoleId } from '../../models/role.model'; +import { Team } from '../../models/team.model'; +import { AuthStore } from '../auth/auth-store'; +import { TeamPermissionsService } from './team-permissions'; + +describe('TeamPermissionsService', () => { + let service: TeamPermissionsService; + let authStore: AuthStore; + + const team: Team = { + id: 9, + name: 'Team A', + alias: 'a', + balance: 0, + players: [ + { + id: 1, + firstName: 'Cap', + lastName: 'Tain', + balance: 0, + active: true, + user: { id: 42, email: null, firstName: null, lastName: null }, + teamRole: { id: 3 }, + }, + ], + settings: [{ key: 'invite_min_role', value: '4' }], + }; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(TeamPermissionsService); + authStore = TestBed.inject(AuthStore); + }); + + it('denies everything when nobody is logged in', () => { + authStore.clearSession(); + expect(service.canDo(team, 'invite')).toBe(false); + }); + + it('lets a global admin bypass all checks', () => { + authStore.setSession('t', { + id: 99, + email: null, + firstName: null, + lastName: null, + role: { id: RoleId.Admin }, + }); + expect(service.canDo(team, 'invite')).toBe(true); + }); + + it('uses the configured team setting instead of the built-in default', () => { + authStore.setSession('t', { + id: 42, + email: null, + firstName: null, + lastName: null, + role: { id: RoleId.User }, + }); + // captain (3) is below the configured invite_min_role of 4 + expect(service.canDo(team, 'invite')).toBe(false); + // but still allowed for the default-threshold action memberManage (3) + expect(service.canDo(team, 'memberManage')).toBe(true); + }); + + it('falls back to the built-in default when no setting exists for that action', () => { + authStore.setSession('t', { + id: 42, + email: null, + firstName: null, + lastName: null, + role: { id: RoleId.User }, + }); + expect(service.canDo(team, 'transactionCreate')).toBe(true); + }); + + it('ignores inactive players', () => { + authStore.setSession('t', { + id: 42, + email: null, + firstName: null, + lastName: null, + role: { id: RoleId.User }, + }); + const inactiveTeam: Team = { + ...team, + players: [{ ...team.players![0], active: false }], + }; + expect(service.canDo(inactiveTeam, 'memberManage')).toBe(false); + }); +}); diff --git a/myteamwallet_frontend_modern/src/app/core/team/team-permissions.ts b/myteamwallet_frontend_modern/src/app/core/team/team-permissions.ts new file mode 100644 index 0000000..760f0f8 --- /dev/null +++ b/myteamwallet_frontend_modern/src/app/core/team/team-permissions.ts @@ -0,0 +1,49 @@ +import { Injectable, inject } from '@angular/core'; +import { RoleId } from '../../models/role.model'; +import { Team } from '../../models/team.model'; +import { TeamPermissionAction } from '../../models/team-permissions.model'; +import { AuthStore } from '../auth/auth-store'; + +const ACTION_TO_SETTING_KEY: Record = { + transactionCreate: 'transaction_create_min_role', + transactionReverse: 'transaction_reverse_min_role', + invite: 'invite_min_role', + memberManage: 'member_manage_min_role', + penaltyManage: 'penalty_manage_min_role', + publicAccessManage: 'public_access_manage_min_role', +}; + +const DEFAULT_MIN_ROLE: Record = { + transactionCreate: 2, + transactionReverse: 2, + invite: 3, + memberManage: 3, + penaltyManage: 3, + publicAccessManage: 3, +}; + +@Injectable({ providedIn: 'root' }) +export class TeamPermissionsService { + private readonly authStore = inject(AuthStore); + + canDo(team: Team | null | undefined, action: TeamPermissionAction): boolean { + const user = this.authStore.currentUser(); + if (!user) return false; + if (user.role?.id === RoleId.Admin) return true; + + const minRole = this.minRoleFor(team, action); + return ( + team?.players?.some( + (player) => + player.active && player.user?.id === user.id && (player.teamRole?.id ?? 0) >= minRole, + ) ?? false + ); + } + + private minRoleFor(team: Team | null | undefined, action: TeamPermissionAction): number { + const key = ACTION_TO_SETTING_KEY[action]; + const setting = team?.settings?.find((s) => s.key === key); + const parsed = setting ? Number(setting.value) : NaN; + return Number.isInteger(parsed) && parsed >= 1 && parsed <= 5 ? parsed : DEFAULT_MIN_ROLE[action]; + } +} diff --git a/myteamwallet_frontend_modern/src/app/features/team/cashbox/cashbox.ts b/myteamwallet_frontend_modern/src/app/features/team/cashbox/cashbox.ts index 2f1a256..7899365 100644 --- a/myteamwallet_frontend_modern/src/app/features/team/cashbox/cashbox.ts +++ b/myteamwallet_frontend_modern/src/app/features/team/cashbox/cashbox.ts @@ -13,7 +13,7 @@ import { MatInputModule } from '@angular/material/input'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatSelectModule } from '@angular/material/select'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; -import { AuthStore } from '../../../core/auth/auth-store'; +import { TeamPermissionsService } from '../../../core/team/team-permissions'; import { PenaltyApi } from '../../../core/team/penalty-api'; import { TeamStore } from '../../../core/team/team-store'; import { TransactionsApi } from '../../../core/team/transactions-api'; @@ -55,10 +55,10 @@ const HIGH_AMOUNT_CONFIRM_THRESHOLD = 300; styleUrl: './cashbox.scss', }) export class Cashbox { - private readonly authStore = inject(AuthStore); private readonly dialog = inject(MatDialog); private readonly formBuilder = inject(FormBuilder); private readonly penaltyApi = inject(PenaltyApi); + private readonly permissions = inject(TeamPermissionsService); private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); private readonly snackBar = inject(MatSnackBar); @@ -85,15 +85,9 @@ export class Cashbox { { id: 14, label: 'Ausgabe' }, ]; - protected readonly canBook = computed(() => { - const user = this.authStore.currentUser(); - if (user?.role?.id === 1) return true; - return ( - this.team()?.players?.some( - (player) => player.user?.id === user?.id && (player.teamRole?.id ?? 0) >= 2, - ) ?? false - ); - }); + protected readonly canBook = computed(() => + this.permissions.canDo(this.team(), 'transactionCreate'), + ); protected readonly activePlayers = computed(() => (this.team()?.players ?? []).filter((player) => player.active), @@ -206,7 +200,7 @@ export class Cashbox { protected canReverse(activity: TeamActivity): boolean { return ( - this.canBook() && + this.permissions.canDo(this.team(), 'transactionReverse') && !activity.isTeamWalletTransaction && !activity.note?.startsWith('Stornierung von Buchung #') ); diff --git a/myteamwallet_frontend_modern/src/app/features/team/members/members.ts b/myteamwallet_frontend_modern/src/app/features/team/members/members.ts index 0fb1586..c10368e 100644 --- a/myteamwallet_frontend_modern/src/app/features/team/members/members.ts +++ b/myteamwallet_frontend_modern/src/app/features/team/members/members.ts @@ -9,7 +9,7 @@ import { MatFormFieldModule } from '@angular/material/form-field'; import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; import { MatSelectModule } from '@angular/material/select'; -import { AuthStore } from '../../../core/auth/auth-store'; +import { TeamPermissionsService } from '../../../core/team/team-permissions'; import { TeamStore } from '../../../core/team/team-store'; import { TeamsApi } from '../../../core/team/teams-api'; import { ContextHelp } from '../../../shared/context-help/context-help'; @@ -35,8 +35,8 @@ registerLocaleData(localeDe); styleUrl: './members.scss', }) export class Members { - private readonly authStore = inject(AuthStore); private readonly formBuilder = inject(FormBuilder); + private readonly permissions = inject(TeamPermissionsService); private readonly teamsApi = inject(TeamsApi); private readonly teamStore = inject(TeamStore); protected readonly team = this.teamStore.team; @@ -50,15 +50,9 @@ export class Members { teamRole: [1, Validators.required], }); - protected readonly canManage = computed(() => { - const user = this.authStore.currentUser(); - if (user?.role?.id === 1) return true; - return ( - this.team()?.players?.some( - (player) => player.user?.id === user?.id && (player.teamRole?.id ?? 0) >= 3, - ) ?? false - ); - }); + protected readonly canManage = computed(() => + this.permissions.canDo(this.team(), 'memberManage'), + ); protected readonly players = computed(() => { const query = this.search().trim().toLocaleLowerCase('de'); diff --git a/myteamwallet_frontend_modern/src/app/features/team/more/invite/invite.ts b/myteamwallet_frontend_modern/src/app/features/team/more/invite/invite.ts index 22c5676..43aaa8c 100644 --- a/myteamwallet_frontend_modern/src/app/features/team/more/invite/invite.ts +++ b/myteamwallet_frontend_modern/src/app/features/team/more/invite/invite.ts @@ -9,7 +9,7 @@ import { MatInputModule } from '@angular/material/input'; import { MatSelectModule } from '@angular/material/select'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; import { AuthApi } from '../../../../core/auth/auth-api'; -import { AuthStore } from '../../../../core/auth/auth-store'; +import { TeamPermissionsService } from '../../../../core/team/team-permissions'; import { TeamStore } from '../../../../core/team/team-store'; import { ContextHelp } from '../../../../shared/context-help/context-help'; @@ -32,8 +32,8 @@ import { ContextHelp } from '../../../../shared/context-help/context-help'; }) export class Invite { private readonly authApi = inject(AuthApi); - private readonly authStore = inject(AuthStore); private readonly formBuilder = inject(FormBuilder); + private readonly permissions = inject(TeamPermissionsService); private readonly snackBar = inject(MatSnackBar); private readonly teamStore = inject(TeamStore); protected readonly team = this.teamStore.team; @@ -45,15 +45,9 @@ export class Invite { protected readonly availablePlayers = computed(() => (this.team()?.players ?? []).filter((player) => player.active && !player.user), ); - protected readonly canInvite = computed(() => { - const user = this.authStore.currentUser(); - if (user?.role?.id === 1) return true; - return ( - this.team()?.players?.some( - (player) => player.user?.id === user?.id && (player.teamRole?.id ?? 0) > 2, - ) ?? false - ); - }); + protected readonly canInvite = computed(() => + this.permissions.canDo(this.team(), 'invite'), + ); protected generateLink(): void { const team = this.team(); diff --git a/myteamwallet_frontend_modern/src/app/features/team/more/more.html b/myteamwallet_frontend_modern/src/app/features/team/more/more.html index 89c8864..5d1bf8d 100644 --- a/myteamwallet_frontend_modern/src/app/features/team/more/more.html +++ b/myteamwallet_frontend_modern/src/app/features/team/more/more.html @@ -54,6 +54,17 @@ chevron_right + @if (canManagePermissions()) { + admin_panel_settings +
+ BerechtigungenFestlegen, wer was im Team darf +
+ chevron_right
+ }