berechtigungen
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ describe('AuthService inactive-user enforcement and safe logging', () => {
|
|||||||
mailService,
|
mailService,
|
||||||
logger,
|
logger,
|
||||||
dataSource,
|
dataSource,
|
||||||
|
{ assertAtLeast: jest.fn() } as any,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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'
|
||||||
|
)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
import { SetMetadata } from '@nestjs/common';
|
|
||||||
|
|
||||||
export const TeamRoles = (...teamRoles: number[]) =>
|
|
||||||
SetMetadata('teamRoles', teamRoles);
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import { Module } from '@nestjs/common';
|
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
imports: [TypeOrmModule.forFeature([TeamSettingsModule])],
|
|
||||||
})
|
|
||||||
export class TeamSettingsModule {}
|
|
||||||
@@ -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],
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
51
myteamwallet_backend/src/teams/dto/team-permissions.dto.ts
Normal file
51
myteamwallet_backend/src/teams/dto/team-permissions.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
|
| '';
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
108
myteamwallet_backend/src/teams/team-permissions.service.spec.ts
Normal file
108
myteamwallet_backend/src/teams/team-permissions.service.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
104
myteamwallet_backend/src/teams/team-permissions.service.ts
Normal file
104
myteamwallet_backend/src/teams/team-permissions.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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({
|
||||||
|
|||||||
@@ -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],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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: () =>
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 #')
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
}
|
||||||
@@ -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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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>;
|
||||||
@@ -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[];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user