berechtigungen

This commit is contained in:
Bastian Wagner
2026-08-03 10:16:42 +02:00
parent 6eeaa3a624
commit 27ab505aba
50 changed files with 1443 additions and 217 deletions

View File

@@ -16,7 +16,6 @@ import { DataSource } from 'typeorm';
import { PlayersModule } from './players/players.module'; import { PlayersModule } from './players/players.module';
import { TeamsModule } from './teams/teams.module'; import { TeamsModule } from './teams/teams.module';
import { TransactionsModule } from './transactions/transactions.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 { TeamWalletTransactionsModule } from './team-wallet-transactions/team-wallet-transactions.module';
import { ServeStaticModule } from '@nestjs/serve-static'; import { ServeStaticModule } from '@nestjs/serve-static';
import { join } from 'path'; import { join } from 'path';
@@ -52,7 +51,6 @@ import { PenaltyModule } from './penalty/penalty.module';
PlayersModule, PlayersModule,
TeamsModule, TeamsModule,
TransactionsModule, TransactionsModule,
TeamSettingsModule,
TeamWalletTransactionsModule, TeamWalletTransactionsModule,
LoggingModule, LoggingModule,
TranslateModule, TranslateModule,

View File

@@ -108,10 +108,11 @@ export class AuthController {
@Post('invite') @Post('invite')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public getInvite( public getInvite(
@Request() request,
@Body() @Body()
invite: any, invite: any,
) { ) {
return this.service.createTeamInvite(invite); return this.service.createTeamInvite(invite, Number(request.user.id));
} }
@ApiOperation({ @ApiOperation({

View File

@@ -12,6 +12,7 @@ import { MailModule } from 'src/mail/mail.module';
import { IsExist } from 'src/utils/validators/is-exists.validator'; import { IsExist } from 'src/utils/validators/is-exists.validator';
import { IsNotExist } from 'src/utils/validators/is-not-exists.validator'; import { IsNotExist } from 'src/utils/validators/is-not-exists.validator';
import { LoggingModule } from 'src/database/logging/logging.module'; import { LoggingModule } from 'src/database/logging/logging.module';
import { TeamsModule } from 'src/teams/teams.module';
@Module({ @Module({
imports: [ imports: [
@@ -19,6 +20,7 @@ import { LoggingModule } from 'src/database/logging/logging.module';
ForgotModule, ForgotModule,
PassportModule, PassportModule,
MailModule, MailModule,
TeamsModule,
JwtModule.registerAsync({ JwtModule.registerAsync({
imports: [ConfigModule], imports: [ConfigModule],
inject: [ConfigService], inject: [ConfigService],

View File

@@ -51,6 +51,7 @@ describe('AuthService inactive-user enforcement and safe logging', () => {
mailService, mailService,
logger, logger,
dataSource, dataSource,
{ assertAtLeast: jest.fn() } as any,
); );
}); });

View File

@@ -25,6 +25,8 @@ import { MailService } from 'src/mail/mail.service';
import { CreateInviteDTO } from './dto/create-invite.dto'; import { CreateInviteDTO } from './dto/create-invite.dto';
import { LoggingService } from 'src/database/logging/logging.service'; import { LoggingService } from 'src/database/logging/logging.service';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { TeamAccessService } from 'src/teams/team-access.service';
import { TeamRolesEnum } from 'src/team-roles/team-roles.enum';
@Injectable() @Injectable()
export class AuthService { export class AuthService {
@@ -35,6 +37,7 @@ export class AuthService {
private mailService: MailService, private mailService: MailService,
private logger: LoggingService, private logger: LoggingService,
private dataSource: DataSource, private dataSource: DataSource,
private teamAccess: TeamAccessService,
) {} ) {}
async validateLogin( 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, { const token = await this.jwtService.sign(object, {
expiresIn: '30d', expiresIn: '30d',
}); });

View File

@@ -25,6 +25,7 @@ export type LOGEVENT =
| 'penalty_catalog_create' | 'penalty_catalog_create'
| 'penalty_catalog_update' | 'penalty_catalog_update'
| 'penalty_catalog_delete' | 'penalty_catalog_delete'
| 'team_create'; | 'team_create'
| 'team_permissions_update';
export type LOGLEVEL = 'FATAL' | 'ERROR' | 'WARN' | 'INFO' | 'DEBUG' | 'TRACE'; export type LOGLEVEL = 'FATAL' | 'ERROR' | 'WARN' | 'INFO' | 'DEBUG' | 'TRACE';

View File

@@ -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<void> {
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<void> {
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'
)`,
);
}
}

View File

@@ -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');
});
});

View File

@@ -1,4 +1,5 @@
import { ConflictException, NotFoundException } from '@nestjs/common'; import { ConflictException, NotFoundException } from '@nestjs/common';
import { TeamRolesEnum } from '../team-roles/team-roles.enum';
import { PenaltyService } from './penalty.service'; import { PenaltyService } from './penalty.service';
describe('PenaltyService catalog management', () => { describe('PenaltyService catalog management', () => {
@@ -28,7 +29,7 @@ describe('PenaltyService catalog management', () => {
), ),
}; };
const dataSource = { transaction: jest.fn((work) => work(manager)) }; 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() }; const logger = { info: jest.fn() };
let service: PenaltyService; let service: PenaltyService;
@@ -127,7 +128,13 @@ describe('PenaltyService catalog management', () => {
}); });
expect(teamQuery.setLock).toHaveBeenCalledWith('pessimistic_write'); 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( expect(logger.info).toHaveBeenCalledWith(
{ {
event: 'penalty_catalog_create', event: 'penalty_catalog_create',

View File

@@ -5,6 +5,7 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { LoggingService } from 'src/database/logging/logging.service'; 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 { Team } from 'src/teams/entities/team.entity';
import { TeamAccessService } from 'src/teams/team-access.service'; import { TeamAccessService } from 'src/teams/team-access.service';
import { DataSource, EntityManager, Repository } from 'typeorm'; import { DataSource, EntityManager, Repository } from 'typeorm';
@@ -55,7 +56,13 @@ export class PenaltyService {
): Promise<PenaltyResponseDTO> { ): Promise<PenaltyResponseDTO> {
return this.dataSource.transaction(async (manager) => { return this.dataSource.transaction(async (manager) => {
const team = await this.lockTeam(manager, dto.teamId); 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 repository = manager.getRepository(PenaltyEntity);
const description = dto.description.trim(); const description = dto.description.trim();
await this.assertUniqueDescription(repository, team.id, description); await this.assertUniqueDescription(repository, team.id, description);
@@ -82,7 +89,13 @@ export class PenaltyService {
const owner = await this.findOwner(penaltyId); const owner = await this.findOwner(penaltyId);
return this.dataSource.transaction(async (manager) => { return this.dataSource.transaction(async (manager) => {
const team = await this.lockTeam(manager, owner.team.id); 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 repository = manager.getRepository(PenaltyEntity);
const penalty = await this.findTransactionalPenalty( const penalty = await this.findTransactionalPenalty(
repository, repository,
@@ -115,7 +128,13 @@ export class PenaltyService {
const owner = await this.findOwner(penaltyId); const owner = await this.findOwner(penaltyId);
await this.dataSource.transaction(async (manager) => { await this.dataSource.transaction(async (manager) => {
const team = await this.lockTeam(manager, owner.team.id); 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 repository = manager.getRepository(PenaltyEntity);
const penalty = await this.findTransactionalPenalty( const penalty = await this.findTransactionalPenalty(
repository, repository,

View File

@@ -1,4 +0,0 @@
import { SetMetadata } from '@nestjs/common';
export const TeamRoles = (...teamRoles: number[]) =>
SetMetadata('teamRoles', teamRoles);

View File

@@ -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<number[]>('teamRoles', [
context.getClass(),
context.getHandler(),
]);
if (!roles.length) {
return true;
}
const request = context.switchToHttp().getRequest();
return roles.includes(request.user?.teamRoles?.id);
}
}

View File

@@ -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<TeamSettingType, ''>,
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,
};

View File

@@ -1,7 +0,0 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
@Module({
imports: [TypeOrmModule.forFeature([TeamSettingsModule])],
})
export class TeamSettingsModule {}

View File

@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { LoggingModule } from 'src/database/logging/logging.module'; import { LoggingModule } from 'src/database/logging/logging.module';
import { Team } from 'src/teams/entities/team.entity'; import { Team } from 'src/teams/entities/team.entity';
import { TeamsModule } from 'src/teams/teams.module';
import { User } from 'src/users/entities/user.entity'; import { User } from 'src/users/entities/user.entity';
import { TeamWalletTransactionType } from './entities/team-wallet-transaction-type.entity'; import { TeamWalletTransactionType } from './entities/team-wallet-transaction-type.entity';
import { TeamWalletTransaction } from './entities/team-wallet-transaction.entity'; import { TeamWalletTransaction } from './entities/team-wallet-transaction.entity';
@@ -17,6 +18,7 @@ import { TeamWalletTransactionsService } from './team-wallet-transactions.servic
Team, Team,
]), ]),
LoggingModule, LoggingModule,
TeamsModule,
], ],
controllers: [TeamWalletTransactionsController], controllers: [TeamWalletTransactionsController],
providers: [TeamWalletTransactionsService], providers: [TeamWalletTransactionsService],

View File

@@ -1,6 +1,9 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { LoggingService } from 'src/database/logging/logging.service'; 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 { Team } from 'src/teams/entities/team.entity';
import { User } from 'src/users/entities/user.entity'; import { User } from 'src/users/entities/user.entity';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
@@ -21,6 +24,7 @@ export class TeamWalletTransactionsService {
@InjectRepository(Team) @InjectRepository(Team)
private teamRepository: Repository<Team>, private teamRepository: Repository<Team>,
private logger: LoggingService, private logger: LoggingService,
private access: TeamAccessService,
) {} ) {}
async create(data: CreateTeamWalletTransactionDto, userId: string) { async create(data: CreateTeamWalletTransactionDto, userId: string) {
@@ -28,32 +32,20 @@ export class TeamWalletTransactionsService {
where: { where: {
id: Number(userId), id: Number(userId),
}, },
relations: ['players', 'players.team', 'players.team.settings'],
}); });
const teamPlayer = creatingUser.players.find( if (creatingUser.role.id != RoleEnum.admin) {
(p) => await this.access.assertAtLeast(
p.team.id == data.teamId && Number(userId),
p.teamRole.id >= data.teamId,
Number( 'transaction_create_min_role',
p.team.settings.find((s) => s.key == 'transaction_create_min_role')[ TeamRolesEnum.scnd_treasurer,
'value' );
],
),
);
if (creatingUser.role.id != 1 && !teamPlayer) {
return;
} }
let team: Team; const team = await this.teamRepository.findOneByOrFail({
if (teamPlayer && teamPlayer.team) { id: data.teamId,
team = teamPlayer.team; });
} else {
team = await this.teamRepository.findOneByOrFail({
id: data.teamId,
});
}
const transactionType = const transactionType =
await this.teamWalletTransactionTypeRepository.findOne({ await this.teamWalletTransactionTypeRepository.findOne({

View File

@@ -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;
}

View File

@@ -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'
| '';

View File

@@ -1,4 +1,5 @@
import { NotFoundException } from '@nestjs/common'; import { NotFoundException } from '@nestjs/common';
import { TeamRolesEnum } from '../team-roles/team-roles.enum';
import { PublicTeamAccessService } from './public-team-access.service'; import { PublicTeamAccessService } from './public-team-access.service';
describe('PublicTeamAccessService', () => { describe('PublicTeamAccessService', () => {
@@ -10,7 +11,7 @@ describe('PublicTeamAccessService', () => {
const playerRepository = { find: jest.fn(), findOne: jest.fn() }; const playerRepository = { find: jest.fn(), findOne: jest.fn() };
const transactionRepository = { find: jest.fn() }; const transactionRepository = { find: jest.fn() };
const penaltyRepository = { 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; let service: PublicTeamAccessService;
const managedTeam = { const managedTeam = {
@@ -48,7 +49,12 @@ describe('PublicTeamAccessService', () => {
const status = await service.setEnabled(4, 7, true); 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.enabled).toBe(true);
expect(status.token).toMatch(/^[a-f0-9]{64}$/); expect(status.token).toMatch(/^[a-f0-9]{64}$/);
expect(teamRepository.save).toHaveBeenCalledWith(team); expect(teamRepository.save).toHaveBeenCalledWith(team);

View File

@@ -3,6 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { randomBytes } from 'crypto'; import { randomBytes } from 'crypto';
import { PenaltyEntity } from '../penalty/entities/penalty.entity'; import { PenaltyEntity } from '../penalty/entities/penalty.entity';
import { Player } from '../players/entities/player.entity'; import { Player } from '../players/entities/player.entity';
import { TeamRolesEnum } from '../team-roles/team-roles.enum';
import { Transaction } from '../transactions/entitites/transaction.entity'; import { Transaction } from '../transactions/entitites/transaction.entity';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { import {
@@ -42,7 +43,12 @@ export class PublicTeamAccessService {
teamId: number, teamId: number,
enabled: boolean, enabled: boolean,
): Promise<PublicAccessStatusDto> { ): Promise<PublicAccessStatusDto> {
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); const team = await this.loadManagedTeam(teamId);
if (enabled && !team.publicAccessToken) { if (enabled && !team.publicAccessToken) {
team.publicAccessToken = this.createToken(); team.publicAccessToken = this.createToken();
@@ -53,7 +59,12 @@ export class PublicTeamAccessService {
} }
async rotate(userId: number, teamId: number): Promise<PublicAccessStatusDto> { async rotate(userId: number, teamId: number): Promise<PublicAccessStatusDto> {
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); const team = await this.loadManagedTeam(teamId);
team.publicAccessToken = this.createToken(); team.publicAccessToken = this.createToken();
await this.teamRepository.save(team); await this.teamRepository.save(team);

View File

@@ -1,10 +1,12 @@
import { ForbiddenException } from '@nestjs/common'; import { ForbiddenException } from '@nestjs/common';
import { RoleEnum } from '../roles/roles.enum'; import { RoleEnum } from '../roles/roles.enum';
import { TeamRolesEnum } from '../team-roles/team-roles.enum';
import { TeamAccessService } from './team-access.service'; import { TeamAccessService } from './team-access.service';
describe('TeamAccessService', () => { describe('TeamAccessService', () => {
const userRepository = { findOne: jest.fn() }; const userRepository = { findOne: jest.fn() };
const playerRepository = { find: jest.fn() }; const playerRepository = { find: jest.fn() };
const teamSettingRepository = { findOne: jest.fn() };
let service: TeamAccessService; let service: TeamAccessService;
beforeEach(() => { beforeEach(() => {
@@ -12,6 +14,7 @@ describe('TeamAccessService', () => {
service = new TeamAccessService( service = new TeamAccessService(
userRepository as any, userRepository as any,
playerRepository as any, playerRepository as any,
teamSettingRepository as any,
); );
}); });
@@ -105,4 +108,104 @@ describe('TeamAccessService', () => {
ForbiddenException, 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();
});
});
}); });

View File

@@ -2,6 +2,9 @@ import { ForbiddenException, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Player } from '../players/entities/player.entity'; import { Player } from '../players/entities/player.entity';
import { RoleEnum } from '../roles/roles.enum'; 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 { User } from '../users/entities/user.entity';
import { EntityManager, Repository } from 'typeorm'; import { EntityManager, Repository } from 'typeorm';
@@ -12,6 +15,8 @@ export class TeamAccessService {
private readonly userRepository: Repository<User>, private readonly userRepository: Repository<User>,
@InjectRepository(Player) @InjectRepository(Player)
private readonly playerRepository: Repository<Player>, private readonly playerRepository: Repository<Player>,
@InjectRepository(TeamSetting)
private readonly teamSettingRepository: Repository<TeamSetting>,
) {} ) {}
async assertMember( async assertMember(
@@ -30,10 +35,42 @@ export class TeamAccessService {
await this.assertMinimumRole(userId, teamId, 3, manager); await this.assertMinimumRole(userId, teamId, 3, manager);
} }
async assertAtLeast(
userId: number,
teamId: number,
settingKey: Exclude<TeamSettingType, ''>,
fallbackMinRole: TeamRolesEnum,
manager?: EntityManager,
): Promise<void> {
await this.assertMinimumRole(
userId,
teamId,
() => this.resolveMinimumRole(teamId, settingKey, fallbackMinRole, manager),
manager,
);
}
private async resolveMinimumRole(
teamId: number,
settingKey: Exclude<TeamSettingType, ''>,
fallbackMinRole: TeamRolesEnum,
manager?: EntityManager,
): Promise<number> {
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( private async assertMinimumRole(
userId: number, userId: number,
teamId: number, teamId: number,
minimumRole: number, minimumRole: number | (() => Promise<number>),
manager?: EntityManager, manager?: EntityManager,
): Promise<void> { ): Promise<void> {
const userRepository = manager?.getRepository(User) ?? this.userRepository; const userRepository = manager?.getRepository(User) ?? this.userRepository;
@@ -42,6 +79,9 @@ export class TeamAccessService {
const user = await userRepository.findOne({ where: { id: userId } }); const user = await userRepository.findOne({ where: { id: userId } });
if (user?.role?.id === RoleEnum.admin) return; if (user?.role?.id === RoleEnum.admin) return;
const resolvedMinimumRole =
typeof minimumRole === 'function' ? await minimumRole() : minimumRole;
const players = await playerRepository.find({ const players = await playerRepository.find({
where: { user: { id: userId }, team: { id: teamId } }, where: { user: { id: userId }, team: { id: teamId } },
}); });
@@ -52,7 +92,7 @@ export class TeamAccessService {
0, 0,
); );
if (highestActiveRole < minimumRole) { if (highestActiveRole < resolvedMinimumRole) {
throw new ForbiddenException('Keine Berechtigung für dieses Team.'); throw new ForbiddenException('Keine Berechtigung für dieses Team.');
} }
} }

View File

@@ -43,12 +43,12 @@ describe('TeamMembersService', () => {
}; };
dataSource = { transaction: jest.fn((work) => work(manager)) }; dataSource = { transaction: jest.fn((work) => work(manager)) };
logger = { info: jest.fn() }; logger = { info: jest.fn() };
access = { assertManager: jest.fn(() => Promise.resolve()) }; access = { assertAtLeast: jest.fn(() => Promise.resolve()) };
service = new TeamMembersService(dataSource, logger, access as any); service = new TeamMembersService(dataSource, logger, access as any);
}); });
it('checks the team-manager permission before touching the database', async () => { 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( await expect(service.setActive(5, teamId, player.id, false)).rejects.toThrow(
'forbidden', 'forbidden',
); );

View File

@@ -26,7 +26,12 @@ export class TeamMembersService {
playerId: number, playerId: number,
active: boolean, active: boolean,
): Promise<Player> { ): Promise<Player> {
await this.access.assertManager(actorUserId, teamId); await this.access.assertAtLeast(
actorUserId,
teamId,
'member_manage_min_role',
TeamRolesEnum.captain,
);
return this.dataSource.transaction(async (manager) => { return this.dataSource.transaction(async (manager) => {
const activeTreasurers = await this.lockActiveTreasurers(manager, teamId); const activeTreasurers = await this.lockActiveTreasurers(manager, teamId);
@@ -71,7 +76,12 @@ export class TeamMembersService {
playerId: number, playerId: number,
teamRoleId: TeamRolesEnum, teamRoleId: TeamRolesEnum,
): Promise<Player> { ): Promise<Player> {
await this.access.assertManager(actorUserId, teamId); await this.access.assertAtLeast(
actorUserId,
teamId,
'member_manage_min_role',
TeamRolesEnum.captain,
);
return this.dataSource.transaction(async (manager) => { return this.dataSource.transaction(async (manager) => {
const activeTreasurers = await this.lockActiveTreasurers(manager, teamId); const activeTreasurers = await this.lockActiveTreasurers(manager, teamId);

View File

@@ -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<string, string> = {}) => ({
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);
});
});

View File

@@ -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<TeamSettingType, ''>
> = {
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<Team>,
@InjectRepository(TeamSetting)
private readonly teamSettingRepository: Repository<TeamSetting>,
private readonly access: TeamAccessService,
private readonly logger: LoggingService,
) {}
async getPermissions(
userId: number,
teamId: number,
): Promise<TeamPermissionsDto> {
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<TeamPermissionsDto> {
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<Team> {
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;
}
}

View File

@@ -23,7 +23,9 @@ import { PublicTeamAccessService } from './public-team-access.service';
import { UpdatePublicAccessDto } from './dto/public-access.dto'; import { UpdatePublicAccessDto } from './dto/public-access.dto';
import { UpdatePlayerProfileDto } from './dto/update-player-profile.dto'; import { UpdatePlayerProfileDto } from './dto/update-player-profile.dto';
import { PlayerActiveDto, PlayerTeamRoleDto } from './dto/player-management.dto'; import { PlayerActiveDto, PlayerTeamRoleDto } from './dto/player-management.dto';
import { UpdateTeamPermissionsDto } from './dto/team-permissions.dto';
import { TeamMembersService } from './team-members.service'; import { TeamMembersService } from './team-members.service';
import { TeamPermissionsService } from './team-permissions.service';
@ApiTags('Teams') @ApiTags('Teams')
@Controller({ @Controller({
@@ -35,6 +37,7 @@ export class TeamsController {
private service: TeamsService, private service: TeamsService,
private publicAccess: PublicTeamAccessService, private publicAccess: PublicTeamAccessService,
private teamMembers: TeamMembersService, private teamMembers: TeamMembersService,
private teamPermissions: TeamPermissionsService,
) {} ) {}
@ApiBearerAuth() @ApiBearerAuth()
@@ -62,6 +65,34 @@ export class TeamsController {
return this.publicAccess.rotate(Number(req.user.id), id); 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() @ApiBearerAuth()
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
@Get(':id/players/:playerId/transactions') @Get(':id/players/:playerId/transactions')
@@ -82,8 +113,8 @@ export class TeamsController {
@UseGuards(AuthGuard('jwt'), RolesGuard) @UseGuards(AuthGuard('jwt'), RolesGuard)
@Get(':id/overview') @Get(':id/overview')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
findOne(@Param('id') id: string) { findOne(@Req() req, @Param('id') id: string) {
return this.service.getOverview(id); return this.service.getOverview(id, req.user?.id);
} }
@ApiBearerAuth() @ApiBearerAuth()
@@ -121,8 +152,12 @@ export class TeamsController {
@UseGuards(AuthGuard('jwt'), RolesGuard) @UseGuards(AuthGuard('jwt'), RolesGuard)
@Post(':id/players') @Post(':id/players')
@HttpCode(HttpStatus.CREATED) @HttpCode(HttpStatus.CREATED)
createPlayer(@Param('id') id: string, @Body() playerDto: any) { createPlayer(
return this.service.createNewPlayer(id, playerDto); @Req() req,
@Param('id') id: string,
@Body() playerDto: any,
) {
return this.service.createNewPlayer(id, playerDto, req.user?.id);
} }
@Roles([RoleEnum.user, RoleEnum.admin]) @Roles([RoleEnum.user, RoleEnum.admin])
@@ -133,8 +168,12 @@ export class TeamsController {
}) })
@Put(':id/players') @Put(':id/players')
@HttpCode(HttpStatus.CREATED) @HttpCode(HttpStatus.CREATED)
updatePlayer(@Body() playerDto: UpdatePlayerProfileDto) { updatePlayer(
return this.service.updatePlayer(playerDto); @Req() req,
@Param('id') id: string,
@Body() playerDto: UpdatePlayerProfileDto,
) {
return this.service.updatePlayer(id, playerDto, req.user?.id);
} }
@ApiOperation({ @ApiOperation({

View File

@@ -16,6 +16,7 @@ import { PublicTeamAccessService } from './public-team-access.service';
import { PublicTeamsController } from './public-teams.controller'; import { PublicTeamsController } from './public-teams.controller';
import { PenaltyEntity } from '../penalty/entities/penalty.entity'; import { PenaltyEntity } from '../penalty/entities/penalty.entity';
import { TeamMembersService } from './team-members.service'; import { TeamMembersService } from './team-members.service';
import { TeamPermissionsService } from './team-permissions.service';
@Module({ @Module({
imports: [ imports: [
@@ -38,6 +39,7 @@ import { TeamMembersService } from './team-members.service';
TeamAccessService, TeamAccessService,
PublicTeamAccessService, PublicTeamAccessService,
TeamMembersService, TeamMembersService,
TeamPermissionsService,
], ],
exports: [TeamAccessService], exports: [TeamAccessService],
}) })

View File

@@ -5,6 +5,7 @@ import { Player } from 'src/players/entities/player.entity';
import { TeamRole } from 'src/team-roles/entities/team-roles.entity'; import { TeamRole } from 'src/team-roles/entities/team-roles.entity';
import { CreateTeamSettingDTO } from 'src/team-settings/dto/create-team-setting.dto'; import { CreateTeamSettingDTO } from 'src/team-settings/dto/create-team-setting.dto';
import { TeamSetting } from 'src/team-settings/entities/team-setting.entity'; 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 { TeamWalletTransaction } from 'src/team-wallet-transactions/entities/team-wallet-transaction.entity';
import { Transaction } from 'src/transactions/entitites/transaction.entity'; import { Transaction } from 'src/transactions/entitites/transaction.entity';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
@@ -32,9 +33,11 @@ export class TeamsService {
private access: TeamAccessService, private access: TeamAccessService,
) {} ) {}
async getOverview(teamId: string) { async getOverview(teamId: string, actorUserId: string) {
const id = Number(teamId); const id = Number(teamId);
await this.access.assertMember(Number(actorUserId), id);
const team = await this.repository.findOneOrFail({ const team = await this.repository.findOneOrFail({
where: { id }, where: { id },
relations: ['players'], relations: ['players'],
@@ -104,7 +107,10 @@ export class TeamsService {
async createNewPlayer( async createNewPlayer(
id: string, id: string,
player: { firstName: string; lastName: string; teamRole: any }, player: { firstName: string; lastName: string; teamRole: any },
actorUserId: string,
) { ) {
await this.access.assertManager(Number(actorUserId), Number(id));
if (typeof player.teamRole == 'string') { if (typeof player.teamRole == 'string') {
player.teamRole = await this.rolesRepository.findOneBy({ player.teamRole = await this.rolesRepository.findOneBy({
name: player.teamRole, name: player.teamRole,
@@ -156,24 +162,26 @@ export class TeamsService {
return team; return team;
} }
private generateBasicTeamSettings(team: Team): Promise<void> { private async generateBasicTeamSettings(team: Team): Promise<void> {
return new Promise<void>(async (resolve) => { const settings: CreateTeamSettingDTO[] = Object.entries(
const s: CreateTeamSettingDTO = { TEAM_SETTING_DEFAULTS,
key: 'transaction_create_min_role', ).map(([key, value]) => ({
value: '2', key: key as CreateTeamSettingDTO['key'],
team, value: String(value),
}; team,
}));
const setting = this.settingsRepository.create(s); await this.settingsRepository.save(
await this.settingsRepository.save(setting); settings.map((s) => this.settingsRepository.create(s)),
resolve(); );
});
} }
async getTeamTransactions(teamId: string | number, userId: string | number) { async getTeamTransactions(teamId: string | number, userId: string | number) {
const start = new Date(); const start = new Date();
teamId = Number(teamId); teamId = Number(teamId);
await this.access.assertMember(Number(userId), teamId);
const team = await this.repository.findOneOrFail({ const team = await this.repository.findOneOrFail({
where: { where: {
id: teamId, id: teamId,
@@ -343,13 +351,24 @@ export class TeamsService {
return Math.round(value * 100) / 100; return Math.round(value * 100) / 100;
} }
async updatePlayer(playerDTO: UpdatePlayerProfileDto) { async updatePlayer(
teamId: string,
playerDTO: UpdatePlayerProfileDto,
actorUserId: string,
) {
const player = await this.playerRepository.findOneOrFail({ const player = await this.playerRepository.findOneOrFail({
where: { where: {
id: playerDTO.id, 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.firstName = playerDTO.firstName;
player.lastName = playerDTO.lastName; player.lastName = playerDTO.lastName;

View File

@@ -6,19 +6,14 @@ import { TransactionsService } from './transactions.service';
import { TransactionsController } from './transactions.controller'; import { TransactionsController } from './transactions.controller';
import { Player } from 'src/players/entities/player.entity'; import { Player } from 'src/players/entities/player.entity';
import { User } from 'src/users/entities/user.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 { LoggingModule } from 'src/database/logging/logging.module';
import { TeamsModule } from 'src/teams/teams.module';
@Module({ @Module({
imports: [ imports: [
TypeOrmModule.forFeature([ TypeOrmModule.forFeature([TransactionType, Transaction, Player, User]),
TransactionType,
Transaction,
Player,
User,
TeamSetting,
]),
LoggingModule, LoggingModule,
TeamsModule,
], ],
providers: [TransactionsService], providers: [TransactionsService],
controllers: [TransactionsController], controllers: [TransactionsController],

View File

@@ -1,6 +1,5 @@
import { import {
BadRequestException, BadRequestException,
ForbiddenException,
Injectable, Injectable,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
@@ -8,6 +7,8 @@ import { InjectRepository } from '@nestjs/typeorm';
import { LoggingService } from 'src/database/logging/logging.service'; import { LoggingService } from 'src/database/logging/logging.service';
import { Player } from 'src/players/entities/player.entity'; import { Player } from 'src/players/entities/player.entity';
import { RoleEnum } from 'src/roles/roles.enum'; 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 { User } from 'src/users/entities/user.entity';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { CreateTransactionDto } from './dto/create-transaction.dto'; import { CreateTransactionDto } from './dto/create-transaction.dto';
@@ -27,6 +28,7 @@ export class TransactionsService {
@InjectRepository(User) @InjectRepository(User)
private usersRepository: Repository<User>, private usersRepository: Repository<User>,
private logger: LoggingService, private logger: LoggingService,
private access: TeamAccessService,
) {} ) {}
async createTransactions(data: CreateTransactionDto[], userId: string) { async createTransactions(data: CreateTransactionDto[], userId: string) {
@@ -40,50 +42,35 @@ export class TransactionsService {
async create(data: CreateTransactionDto, userId: string) { async create(data: CreateTransactionDto, userId: string) {
const player = await this.playersRepository.findOne({ const player = await this.playersRepository.findOne({
where: { id: data.playerId }, where: { id: data.playerId },
relations: ['team'],
}); });
const creatingUser = await this.usersRepository.findOne({ const creatingUser = await this.usersRepository.findOne({
where: { where: {
id: Number(userId), id: Number(userId),
}, },
relations: ['players'],
}); });
const transactionType = await this.transactionTypesRepository.findOne({ const transactionType = await this.transactionTypesRepository.findOne({
where: { id: TransactionTypeEnum[TransactionTypeEnum[data.type]] }, 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 (creatingUser.role.id != RoleEnum.admin) {
if ( await this.access.assertAtLeast(
!player || Number(userId),
!transactionType || player.team.id,
!creatingUser || 'transaction_create_min_role',
!creatingUser.players || TeamRolesEnum.scnd_treasurer,
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'],
),
); );
if (!teamPlayer) {
return;
}
} }
const transaction = this.transactionsRepository.create({ const transaction = this.transactionsRepository.create({
@@ -131,26 +118,15 @@ export class TransactionsService {
const creatingUser = await this.usersRepository.findOne({ const creatingUser = await this.usersRepository.findOne({
where: { id: Number(userId) }, where: { id: Number(userId) },
relations: ['players'],
}); });
if (creatingUser.role.id != RoleEnum.admin) { if (creatingUser.role.id != RoleEnum.admin) {
const teamPlayer = creatingUser.players?.find( await this.access.assertAtLeast(
(p) => Number(userId),
p.team.id == original.player.team.id && original.player.team.id,
p.teamRole.id >= 'transaction_reverse_min_role',
Number( TeamRolesEnum.scnd_treasurer,
original.player.team.settings.find(
(s) => s.key == 'transaction_create_min_role',
)?.['value'] ?? 0,
),
); );
if (!teamPlayer) {
throw new ForbiddenException(
'Keine Berechtigung, diese Buchung zu stornieren',
);
}
} }
const originalAmount = Math.abs(Number(original.amount)); const originalAmount = Math.abs(Number(original.amount));

View File

@@ -101,6 +101,11 @@ export const routes: Routes = [
loadComponent: () => loadComponent: () =>
import('./features/team/more/public-access/public-access').then((m) => m.PublicAccess), 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', path: 'more/guide',
loadComponent: () => loadComponent: () =>

View File

@@ -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 });
});
});

View File

@@ -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<TeamPermissions> {
return this.http.get<TeamPermissions>(`${environment.apiUrl}teams/${teamId}/permissions`);
}
updatePermissions(
teamId: number,
changes: UpdateTeamPermissions,
): Observable<TeamPermissions> {
return this.http.patch<TeamPermissions>(
`${environment.apiUrl}teams/${teamId}/permissions`,
changes,
);
}
}

View File

@@ -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);
});
});

View File

@@ -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<TeamPermissionAction, string> = {
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<TeamPermissionAction, number> = {
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];
}
}

View File

@@ -13,7 +13,7 @@ import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSelectModule } from '@angular/material/select'; import { MatSelectModule } from '@angular/material/select';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; 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 { PenaltyApi } from '../../../core/team/penalty-api';
import { TeamStore } from '../../../core/team/team-store'; import { TeamStore } from '../../../core/team/team-store';
import { TransactionsApi } from '../../../core/team/transactions-api'; import { TransactionsApi } from '../../../core/team/transactions-api';
@@ -55,10 +55,10 @@ const HIGH_AMOUNT_CONFIRM_THRESHOLD = 300;
styleUrl: './cashbox.scss', styleUrl: './cashbox.scss',
}) })
export class Cashbox { export class Cashbox {
private readonly authStore = inject(AuthStore);
private readonly dialog = inject(MatDialog); private readonly dialog = inject(MatDialog);
private readonly formBuilder = inject(FormBuilder); private readonly formBuilder = inject(FormBuilder);
private readonly penaltyApi = inject(PenaltyApi); private readonly penaltyApi = inject(PenaltyApi);
private readonly permissions = inject(TeamPermissionsService);
private readonly route = inject(ActivatedRoute); private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly snackBar = inject(MatSnackBar); private readonly snackBar = inject(MatSnackBar);
@@ -85,15 +85,9 @@ export class Cashbox {
{ id: 14, label: 'Ausgabe' }, { id: 14, label: 'Ausgabe' },
]; ];
protected readonly canBook = computed(() => { protected readonly canBook = computed(() =>
const user = this.authStore.currentUser(); this.permissions.canDo(this.team(), 'transactionCreate'),
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 activePlayers = computed(() => protected readonly activePlayers = computed(() =>
(this.team()?.players ?? []).filter((player) => player.active), (this.team()?.players ?? []).filter((player) => player.active),
@@ -206,7 +200,7 @@ export class Cashbox {
protected canReverse(activity: TeamActivity): boolean { protected canReverse(activity: TeamActivity): boolean {
return ( return (
this.canBook() && this.permissions.canDo(this.team(), 'transactionReverse') &&
!activity.isTeamWalletTransaction && !activity.isTeamWalletTransaction &&
!activity.note?.startsWith('Stornierung von Buchung #') !activity.note?.startsWith('Stornierung von Buchung #')
); );

View File

@@ -9,7 +9,7 @@ import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select'; 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 { TeamStore } from '../../../core/team/team-store';
import { TeamsApi } from '../../../core/team/teams-api'; import { TeamsApi } from '../../../core/team/teams-api';
import { ContextHelp } from '../../../shared/context-help/context-help'; import { ContextHelp } from '../../../shared/context-help/context-help';
@@ -35,8 +35,8 @@ registerLocaleData(localeDe);
styleUrl: './members.scss', styleUrl: './members.scss',
}) })
export class Members { export class Members {
private readonly authStore = inject(AuthStore);
private readonly formBuilder = inject(FormBuilder); private readonly formBuilder = inject(FormBuilder);
private readonly permissions = inject(TeamPermissionsService);
private readonly teamsApi = inject(TeamsApi); private readonly teamsApi = inject(TeamsApi);
private readonly teamStore = inject(TeamStore); private readonly teamStore = inject(TeamStore);
protected readonly team = this.teamStore.team; protected readonly team = this.teamStore.team;
@@ -50,15 +50,9 @@ export class Members {
teamRole: [1, Validators.required], teamRole: [1, Validators.required],
}); });
protected readonly canManage = computed(() => { protected readonly canManage = computed(() =>
const user = this.authStore.currentUser(); this.permissions.canDo(this.team(), 'memberManage'),
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 players = computed(() => { protected readonly players = computed(() => {
const query = this.search().trim().toLocaleLowerCase('de'); const query = this.search().trim().toLocaleLowerCase('de');

View File

@@ -9,7 +9,7 @@ import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select'; import { MatSelectModule } from '@angular/material/select';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { AuthApi } from '../../../../core/auth/auth-api'; 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 { TeamStore } from '../../../../core/team/team-store';
import { ContextHelp } from '../../../../shared/context-help/context-help'; import { ContextHelp } from '../../../../shared/context-help/context-help';
@@ -32,8 +32,8 @@ import { ContextHelp } from '../../../../shared/context-help/context-help';
}) })
export class Invite { export class Invite {
private readonly authApi = inject(AuthApi); private readonly authApi = inject(AuthApi);
private readonly authStore = inject(AuthStore);
private readonly formBuilder = inject(FormBuilder); private readonly formBuilder = inject(FormBuilder);
private readonly permissions = inject(TeamPermissionsService);
private readonly snackBar = inject(MatSnackBar); private readonly snackBar = inject(MatSnackBar);
private readonly teamStore = inject(TeamStore); private readonly teamStore = inject(TeamStore);
protected readonly team = this.teamStore.team; protected readonly team = this.teamStore.team;
@@ -45,15 +45,9 @@ export class Invite {
protected readonly availablePlayers = computed(() => protected readonly availablePlayers = computed(() =>
(this.team()?.players ?? []).filter((player) => player.active && !player.user), (this.team()?.players ?? []).filter((player) => player.active && !player.user),
); );
protected readonly canInvite = computed(() => { protected readonly canInvite = computed(() =>
const user = this.authStore.currentUser(); this.permissions.canDo(this.team(), 'invite'),
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 generateLink(): void { protected generateLink(): void {
const team = this.team(); const team = this.team();

View File

@@ -54,6 +54,17 @@
<mat-icon>chevron_right</mat-icon></mat-card <mat-icon>chevron_right</mat-icon></mat-card
></a ></a
> >
@if (canManagePermissions()) {
<a routerLink="permissions"
><mat-card
><mat-icon>admin_panel_settings</mat-icon>
<div>
<strong>Berechtigungen</strong><span>Festlegen, wer was im Team darf</span>
</div>
<mat-icon>chevron_right</mat-icon></mat-card
></a
>
}
</section> </section>
<mat-card class="account-card"> <mat-card class="account-card">

View File

@@ -3,6 +3,7 @@ import { TestBed } from '@angular/core/testing';
import { provideRouter, Router } from '@angular/router'; import { provideRouter, Router } from '@angular/router';
import { AuthStore } from '../../../core/auth/auth-store'; import { AuthStore } from '../../../core/auth/auth-store';
import { HelpAccessService } from '../../../core/help/help-access'; import { HelpAccessService } from '../../../core/help/help-access';
import { TeamStore } from '../../../core/team/team-store';
import { More } from './more'; import { More } from './more';
@Component({ template: '' }) @Component({ template: '' })
@@ -23,6 +24,7 @@ describe('More', () => {
}, },
}, },
{ provide: HelpAccessService, useValue: { canOpenGuide: signal(true) } }, { provide: HelpAccessService, useValue: { canOpenGuide: signal(true) } },
{ provide: TeamStore, useValue: { team: signal(null) } },
], ],
}).compileComponents(); }).compileComponents();
const fixture = TestBed.createComponent(More); const fixture = TestBed.createComponent(More);
@@ -56,6 +58,7 @@ describe('More', () => {
}, },
}, },
{ provide: HelpAccessService, useValue: { canOpenGuide: signal(false) } }, { provide: HelpAccessService, useValue: { canOpenGuide: signal(false) } },
{ provide: TeamStore, useValue: { team: signal(null) } },
], ],
}).compileComponents(); }).compileComponents();
const fixture = TestBed.createComponent(More); const fixture = TestBed.createComponent(More);
@@ -63,4 +66,49 @@ describe('More', () => {
expect(fixture.nativeElement.textContent).not.toContain('Anleitung für Verantwortliche'); expect(fixture.nativeElement.textContent).not.toContain('Anleitung für Verantwortliche');
}); });
it('shows the permissions link only to a team manager', async () => {
const managerTeam = {
id: 5,
name: 'Team A',
alias: 'a',
balance: 0,
players: [
{
id: 1,
firstName: 'Alex',
lastName: 'Muster',
balance: 0,
active: true,
teamRole: { id: 3 },
user: { id: 42 },
},
],
};
await TestBed.configureTestingModule({
imports: [More],
providers: [
provideRouter([]),
{
provide: AuthStore,
useValue: {
currentUser: signal({
id: 42,
firstName: 'Alex',
lastName: 'Muster',
email: 'a@b.de',
role: { id: 2 },
}),
clearSession: vi.fn(),
},
},
{ provide: HelpAccessService, useValue: { canOpenGuide: signal(false) } },
{ provide: TeamStore, useValue: { team: signal(managerTeam) } },
],
}).compileComponents();
const fixture = TestBed.createComponent(More);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('Berechtigungen');
});
}); });

View File

@@ -1,10 +1,12 @@
import { Component, inject } from '@angular/core'; import { Component, computed, inject } from '@angular/core';
import { Router, RouterLink } from '@angular/router'; import { Router, RouterLink } from '@angular/router';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
import { AuthStore } from '../../../core/auth/auth-store'; import { AuthStore } from '../../../core/auth/auth-store';
import { HelpAccessService } from '../../../core/help/help-access'; import { HelpAccessService } from '../../../core/help/help-access';
import { TeamPermissionsService } from '../../../core/team/team-permissions';
import { TeamStore } from '../../../core/team/team-store';
@Component({ @Component({
selector: 'app-more', selector: 'app-more',
@@ -15,9 +17,14 @@ import { HelpAccessService } from '../../../core/help/help-access';
export class More { export class More {
private readonly authStore = inject(AuthStore); private readonly authStore = inject(AuthStore);
private readonly helpAccess = inject(HelpAccessService); private readonly helpAccess = inject(HelpAccessService);
private readonly permissions = inject(TeamPermissionsService);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly teamStore = inject(TeamStore);
protected readonly user = this.authStore.currentUser; protected readonly user = this.authStore.currentUser;
protected readonly canOpenGuide = this.helpAccess.canOpenGuide; protected readonly canOpenGuide = this.helpAccess.canOpenGuide;
protected readonly canManagePermissions = computed(() =>
this.permissions.canDo(this.teamStore.team(), 'memberManage'),
);
protected logout(): void { protected logout(): void {
this.authStore.clearSession(); this.authStore.clearSession();

View File

@@ -13,7 +13,7 @@ import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { Router, RouterLink } from '@angular/router'; import { Router, RouterLink } from '@angular/router';
import { EMPTY, Observable, catchError, finalize, switchMap, take, tap } from 'rxjs'; import { EMPTY, Observable, catchError, finalize, switchMap, take, tap } from 'rxjs';
import { AuthStore } from '../../../../core/auth/auth-store'; import { TeamPermissionsService } from '../../../../core/team/team-permissions';
import { PenaltyApi } from '../../../../core/team/penalty-api'; import { PenaltyApi } from '../../../../core/team/penalty-api';
import { TeamStore } from '../../../../core/team/team-store'; import { TeamStore } from '../../../../core/team/team-store';
import { Penalty } from '../../../../models/penalty.model'; import { Penalty } from '../../../../models/penalty.model';
@@ -41,11 +41,11 @@ registerLocaleData(localeDe);
styleUrl: './penalties.scss', styleUrl: './penalties.scss',
}) })
export class Penalties { export class Penalties {
private readonly authStore = inject(AuthStore);
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
private readonly dialog = inject(MatDialog); private readonly dialog = inject(MatDialog);
private readonly formBuilder = inject(FormBuilder); private readonly formBuilder = inject(FormBuilder);
private readonly penaltyApi = inject(PenaltyApi); private readonly penaltyApi = inject(PenaltyApi);
private readonly permissions = inject(TeamPermissionsService);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly teamStore = inject(TeamStore); private readonly teamStore = inject(TeamStore);
private loadedTeamId: number | null = null; private loadedTeamId: number | null = null;
@@ -86,26 +86,12 @@ export class Penalties {
], ],
], ],
}); });
protected readonly canManage = computed(() => { protected readonly canManage = computed(() =>
const user = this.authStore.currentUser(); this.permissions.canDo(this.team(), 'penaltyManage'),
if (user?.role?.id === 1) return true; );
return ( protected readonly canBook = computed(() =>
this.team()?.players?.some( this.permissions.canDo(this.team(), 'transactionCreate'),
(player) => );
player.active && player.user?.id === user?.id && (player.teamRole?.id ?? 0) >= 3,
) ?? false
);
});
protected readonly canBook = computed(() => {
const user = this.authStore.currentUser();
if (user?.role?.id === 1) return true;
return (
this.team()?.players?.some(
(player) =>
player.active && player.user?.id === user?.id && (player.teamRole?.id ?? 0) >= 2,
) ?? false
);
});
protected readonly filteredPenalties = computed(() => { protected readonly filteredPenalties = computed(() => {
const query = this.search().trim().toLocaleLowerCase('de'); const query = this.search().trim().toLocaleLowerCase('de');
return this.penalties().filter((penalty) => return this.penalties().filter((penalty) =>

View File

@@ -0,0 +1,50 @@
<a mat-button routerLink="../"><mat-icon>arrow_back</mat-icon>Mehr</a>
<header>
<p class="eyebrow">Team</p>
<h1>Berechtigungen</h1>
<p>Lege pro Aktion fest, ab welcher Rolle Mitglieder sie ausführen dürfen.</p>
</header>
<app-context-help
title="Berechtigungen bewusst einstellen"
[hints]="[
'Änderungen wirken sofort für alle Mitglieder des Teams.',
'Globale Administratoren dürfen unabhängig von dieser Einstellung immer alles.',
]"
sectionId="roles"
/>
@if (loading()) {
<div class="state"><mat-spinner diameter="38" /><span>Berechtigungen werden geladen …</span></div>
} @else if (loadFailed()) {
<div class="state">
<mat-icon>cloud_off</mat-icon><strong>Berechtigungen konnten nicht geladen werden.</strong>
<button mat-stroked-button type="button" (click)="retry()">Erneut versuchen</button>
</div>
} @else if (values(); as current) {
@if (!canManage()) {
<mat-card class="hint-card">
<mat-icon>lock</mat-icon>
<span>Nur Kapitän, Kassenwart oder Trainer können Berechtigungen ändern.</span>
</mat-card>
}
<div class="permission-list">
@for (field of fields; track field.key) {
<mat-card class="permission-row">
<span class="permission-row__label">{{ field.label }}</span>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Mindestrolle</mat-label>
<mat-select
[value]="current[field.key]"
[disabled]="!canManage() || saving()"
(selectionChange)="changeRole(field.key, $event.value)"
>
@for (option of roleOptions; track option.id) {
<mat-option [value]="option.id">{{ option.label }}</mat-option>
}
</mat-select>
</mat-form-field>
</mat-card>
}
</div>
}

View File

@@ -0,0 +1,86 @@
:host {
display: block;
max-width: 720px;
margin: 0 auto;
padding: 28px;
}
header {
margin: 18px 0 28px;
}
h1,
p {
margin-top: 0;
}
h1 {
margin-bottom: 8px;
font-size: clamp(2rem, 4vw, 3rem);
line-height: clamp(2rem, 4vw, 3rem);
}
.eyebrow {
margin-bottom: 6px;
color: var(--mat-sys-primary);
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.state {
min-height: 220px;
display: grid;
place-items: center;
align-content: center;
gap: 14px;
text-align: center;
}
.hint-card {
display: flex;
align-items: center;
gap: 14px;
padding: 16px 20px;
border-radius: 18px;
margin-bottom: 18px;
color: var(--mat-sys-on-surface-variant);
}
.permission-list {
display: grid;
gap: 12px;
}
.permission-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 16px 20px;
border-radius: 18px;
}
.permission-row__label {
font-weight: 500;
}
.permission-row mat-form-field {
width: 200px;
}
@media (max-width: 700px) {
:host {
padding: 20px 16px;
}
.permission-row {
flex-direction: column;
align-items: stretch;
}
.permission-row mat-form-field {
width: 100%;
}
}

View File

@@ -0,0 +1,117 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { of, throwError } from 'rxjs';
import { provideRouter } from '@angular/router';
import { AuthStore } from '../../../../core/auth/auth-store';
import { TeamPermissionsApi } from '../../../../core/team/team-permissions-api';
import { TeamStore } from '../../../../core/team/team-store';
import { Permissions } from './permissions';
describe('Permissions', () => {
const permissions = {
transactionCreateMinRole: 2,
transactionReverseMinRole: 2,
inviteMinRole: 3,
memberManageMinRole: 3,
penaltyManageMinRole: 3,
publicAccessManageMinRole: 3,
};
const managerTeam = {
id: 5,
name: 'Team A',
alias: 'a',
balance: 0,
players: [
{
id: 1,
firstName: 'Alex',
lastName: 'Muster',
balance: 0,
active: true,
teamRole: { id: 3 },
user: { id: 42 },
},
],
};
async function setup(team = managerTeam) {
const getPermissions = vi.fn(() => of(permissions));
const updatePermissions = vi.fn(() => of({ ...permissions, inviteMinRole: 4 }));
const refreshTeam = vi.fn();
await TestBed.configureTestingModule({
imports: [Permissions],
providers: [
provideRouter([]),
{
provide: AuthStore,
useValue: {
currentUser: signal({ id: 42, email: null, firstName: null, lastName: null, role: { id: 2 } }),
},
},
{ provide: TeamStore, useValue: { team: signal(team), refreshTeam } },
{ provide: TeamPermissionsApi, useValue: { getPermissions, updatePermissions } },
],
}).compileComponents();
const fixture = TestBed.createComponent(Permissions);
fixture.detectChanges();
return { fixture, component: fixture.componentInstance, getPermissions, updatePermissions, refreshTeam };
}
it('loads and displays the configured minimum roles for a manager', async () => {
const { fixture, component, getPermissions } = await setup();
expect(getPermissions).toHaveBeenCalledWith(5);
expect(fixture.nativeElement.textContent).toContain('Mitglieder einladen');
expect(component['canManage']()).toBe(true);
});
it('saves a changed minimum role and refreshes the team', async () => {
const { component, updatePermissions, refreshTeam } = await setup();
component['changeRole']('inviteMinRole', 4);
expect(updatePermissions).toHaveBeenCalledWith(5, { inviteMinRole: 4 });
expect(refreshTeam).toHaveBeenCalled();
});
it('ignores changes from a non-manager', async () => {
const playerTeam = {
...managerTeam,
players: [{ ...managerTeam.players[0], teamRole: { id: 1 } }],
};
const { component, updatePermissions } = await setup(playerTeam);
component['changeRole']('inviteMinRole', 4);
expect(updatePermissions).not.toHaveBeenCalled();
});
it('shows an error state when loading fails', async () => {
const refreshTeam = vi.fn();
await TestBed.configureTestingModule({
imports: [Permissions],
providers: [
provideRouter([]),
{
provide: AuthStore,
useValue: {
currentUser: signal({ id: 42, email: null, firstName: null, lastName: null, role: { id: 2 } }),
},
},
{ provide: TeamStore, useValue: { team: signal(managerTeam), refreshTeam } },
{
provide: TeamPermissionsApi,
useValue: {
getPermissions: vi.fn(() => throwError(() => new Error('fail'))),
updatePermissions: vi.fn(),
},
},
],
}).compileComponents();
const fixture = TestBed.createComponent(Permissions);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('konnten nicht geladen werden');
});
});

View File

@@ -0,0 +1,124 @@
import { Component, computed, effect, inject, signal } from '@angular/core';
import { RouterLink } from '@angular/router';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSelectModule } from '@angular/material/select';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { TeamPermissionsApi } from '../../../../core/team/team-permissions-api';
import { TeamPermissionsService } from '../../../../core/team/team-permissions';
import { TeamStore } from '../../../../core/team/team-store';
import { TeamPermissions } from '../../../../models/team-permissions.model';
import { ContextHelp } from '../../../../shared/context-help/context-help';
interface PermissionField {
key: keyof TeamPermissions;
label: string;
}
const FIELDS: PermissionField[] = [
{ key: 'transactionCreateMinRole', label: 'Buchung erfassen' },
{ key: 'transactionReverseMinRole', label: 'Buchung stornieren' },
{ key: 'inviteMinRole', label: 'Mitglieder einladen' },
{ key: 'memberManageMinRole', label: 'Mitglieder verwalten' },
{ key: 'penaltyManageMinRole', label: 'Strafenkatalog verwalten' },
{ key: 'publicAccessManageMinRole', label: 'Öffentliche Freigabe verwalten' },
];
const ROLE_OPTIONS = [
{ id: 1, label: 'Spieler' },
{ id: 2, label: '2. Kassenwart' },
{ id: 3, label: 'Kapitän' },
{ id: 4, label: 'Kassenwart' },
{ id: 5, label: 'Trainer' },
];
@Component({
selector: 'app-permissions',
imports: [
RouterLink,
MatButtonModule,
MatCardModule,
MatFormFieldModule,
MatIconModule,
MatProgressSpinnerModule,
MatSelectModule,
MatSnackBarModule,
ContextHelp,
],
templateUrl: './permissions.html',
styleUrl: './permissions.scss',
})
export class Permissions {
private readonly api = inject(TeamPermissionsApi);
private readonly permissions = inject(TeamPermissionsService);
private readonly snackBar = inject(MatSnackBar);
private readonly teamStore = inject(TeamStore);
private loadedTeamId: number | null = null;
protected readonly team = this.teamStore.team;
protected readonly fields = FIELDS;
protected readonly roleOptions = ROLE_OPTIONS;
protected readonly loading = signal(true);
protected readonly saving = signal(false);
protected readonly loadFailed = signal(false);
protected readonly values = signal<TeamPermissions | null>(null);
protected readonly canManage = computed(() =>
this.permissions.canDo(this.team(), 'memberManage'),
);
constructor() {
effect(() => {
const teamId = this.team()?.id;
if (teamId && teamId !== this.loadedTeamId) {
this.loadedTeamId = teamId;
this.load(teamId);
}
});
}
protected changeRole(field: keyof TeamPermissions, roleId: number): void {
const teamId = this.team()?.id;
const current = this.values();
if (!this.canManage() || !teamId || !current || this.saving() || current[field] === roleId) {
return;
}
this.saving.set(true);
this.api.updatePermissions(teamId, { [field]: roleId }).subscribe({
next: (updated) => {
this.saving.set(false);
this.values.set(updated);
this.teamStore.refreshTeam();
this.snackBar.open('Berechtigung wurde gespeichert.', undefined, { duration: 4000 });
},
error: () => {
this.saving.set(false);
this.snackBar.open('Berechtigung konnte nicht gespeichert werden.', undefined, {
duration: 5000,
});
},
});
}
protected retry(): void {
const teamId = this.team()?.id;
if (teamId) this.load(teamId);
}
private load(teamId: number): void {
this.loading.set(true);
this.loadFailed.set(false);
this.api.getPermissions(teamId).subscribe({
next: (values) => {
this.loading.set(false);
this.values.set(values);
},
error: () => {
this.loading.set(false);
this.loadFailed.set(true);
},
});
}
}

View File

@@ -9,8 +9,8 @@ import { MatFormFieldModule } from '@angular/material/form-field';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSnackBar } from '@angular/material/snack-bar'; import { MatSnackBar } from '@angular/material/snack-bar';
import { toString as qrToString } from 'qrcode'; import { toString as qrToString } from 'qrcode';
import { AuthStore } from '../../../../core/auth/auth-store';
import { PublicAccessApi } from '../../../../core/team/public-access-api'; import { PublicAccessApi } from '../../../../core/team/public-access-api';
import { TeamPermissionsService } from '../../../../core/team/team-permissions';
import { TeamStore } from '../../../../core/team/team-store'; import { TeamStore } from '../../../../core/team/team-store';
import { PublicAccessStatus } from '../../../../models/public-access.model'; import { PublicAccessStatus } from '../../../../models/public-access.model';
import { ConfirmDialog, ConfirmDialogData } from '../../../../shared/confirm-dialog/confirm-dialog'; import { ConfirmDialog, ConfirmDialogData } from '../../../../shared/confirm-dialog/confirm-dialog';
@@ -33,8 +33,8 @@ import { ContextHelp } from '../../../../shared/context-help/context-help';
}) })
export class PublicAccess { export class PublicAccess {
private readonly api = inject(PublicAccessApi); private readonly api = inject(PublicAccessApi);
private readonly authStore = inject(AuthStore);
private readonly dialog = inject(MatDialog); private readonly dialog = inject(MatDialog);
private readonly permissions = inject(TeamPermissionsService);
private readonly snackBar = inject(MatSnackBar); private readonly snackBar = inject(MatSnackBar);
private readonly teamStore = inject(TeamStore); private readonly teamStore = inject(TeamStore);
private loadedTeamId: number | null = null; private loadedTeamId: number | null = null;
@@ -49,16 +49,9 @@ export class PublicAccess {
const status = this.status(); const status = this.status();
return status?.enabled && status.token ? `${location.origin}/t/${status.token}` : ''; return status?.enabled && status.token ? `${location.origin}/t/${status.token}` : '';
}); });
protected readonly canManage = computed(() => { protected readonly canManage = computed(() =>
const user = this.authStore.currentUser(); this.permissions.canDo(this.team(), 'publicAccessManage'),
if (user?.role?.id === 1) return true; );
return (
this.team()?.players?.some(
(player) =>
player.active && player.user?.id === user?.id && (player.teamRole?.id ?? 0) >= 3,
) ?? false
);
});
constructor() { constructor() {
effect(() => { effect(() => {

View File

@@ -0,0 +1,18 @@
export type TeamPermissionAction =
| 'transactionCreate'
| 'transactionReverse'
| 'invite'
| 'memberManage'
| 'penaltyManage'
| 'publicAccessManage';
export interface TeamPermissions {
transactionCreateMinRole: number;
transactionReverseMinRole: number;
inviteMinRole: number;
memberManageMinRole: number;
penaltyManageMinRole: number;
publicAccessManageMinRole: number;
}
export type UpdateTeamPermissions = Partial<TeamPermissions>;

View File

@@ -1,5 +1,10 @@
import { Player } from './player.model'; import { Player } from './player.model';
export interface TeamSetting {
key: string;
value: string;
}
export interface Team { export interface Team {
id: number; id: number;
name: string; name: string;
@@ -7,4 +12,5 @@ export interface Team {
balance: number; balance: number;
outstanding?: number; outstanding?: number;
players?: Player[]; players?: Player[];
settings?: TeamSetting[];
} }