berechtigungen
This commit is contained in:
@@ -16,7 +16,6 @@ import { DataSource } from 'typeorm';
|
||||
import { PlayersModule } from './players/players.module';
|
||||
import { TeamsModule } from './teams/teams.module';
|
||||
import { TransactionsModule } from './transactions/transactions.module';
|
||||
import { TeamSettingsModule } from './team-settings/team-settings.module';
|
||||
import { TeamWalletTransactionsModule } from './team-wallet-transactions/team-wallet-transactions.module';
|
||||
import { ServeStaticModule } from '@nestjs/serve-static';
|
||||
import { join } from 'path';
|
||||
@@ -52,7 +51,6 @@ import { PenaltyModule } from './penalty/penalty.module';
|
||||
PlayersModule,
|
||||
TeamsModule,
|
||||
TransactionsModule,
|
||||
TeamSettingsModule,
|
||||
TeamWalletTransactionsModule,
|
||||
LoggingModule,
|
||||
TranslateModule,
|
||||
|
||||
@@ -108,10 +108,11 @@ export class AuthController {
|
||||
@Post('invite')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public getInvite(
|
||||
@Request() request,
|
||||
@Body()
|
||||
invite: any,
|
||||
) {
|
||||
return this.service.createTeamInvite(invite);
|
||||
return this.service.createTeamInvite(invite, Number(request.user.id));
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
|
||||
@@ -12,6 +12,7 @@ import { MailModule } from 'src/mail/mail.module';
|
||||
import { IsExist } from 'src/utils/validators/is-exists.validator';
|
||||
import { IsNotExist } from 'src/utils/validators/is-not-exists.validator';
|
||||
import { LoggingModule } from 'src/database/logging/logging.module';
|
||||
import { TeamsModule } from 'src/teams/teams.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -19,6 +20,7 @@ import { LoggingModule } from 'src/database/logging/logging.module';
|
||||
ForgotModule,
|
||||
PassportModule,
|
||||
MailModule,
|
||||
TeamsModule,
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
|
||||
@@ -51,6 +51,7 @@ describe('AuthService inactive-user enforcement and safe logging', () => {
|
||||
mailService,
|
||||
logger,
|
||||
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 { LoggingService } from 'src/database/logging/logging.service';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { TeamAccessService } from 'src/teams/team-access.service';
|
||||
import { TeamRolesEnum } from 'src/team-roles/team-roles.enum';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
@@ -35,6 +37,7 @@ export class AuthService {
|
||||
private mailService: MailService,
|
||||
private logger: LoggingService,
|
||||
private dataSource: DataSource,
|
||||
private teamAccess: TeamAccessService,
|
||||
) {}
|
||||
|
||||
async validateLogin(
|
||||
@@ -302,7 +305,14 @@ export class AuthService {
|
||||
});
|
||||
}
|
||||
|
||||
async createTeamInvite(object: CreateInviteDTO) {
|
||||
async createTeamInvite(object: CreateInviteDTO, actorUserId: number) {
|
||||
await this.teamAccess.assertAtLeast(
|
||||
actorUserId,
|
||||
object.teamId,
|
||||
'invite_min_role',
|
||||
TeamRolesEnum.captain,
|
||||
);
|
||||
|
||||
const token = await this.jwtService.sign(object, {
|
||||
expiresIn: '30d',
|
||||
});
|
||||
|
||||
@@ -25,6 +25,7 @@ export type LOGEVENT =
|
||||
| 'penalty_catalog_create'
|
||||
| 'penalty_catalog_update'
|
||||
| 'penalty_catalog_delete'
|
||||
| 'team_create';
|
||||
| 'team_create'
|
||||
| 'team_permissions_update';
|
||||
|
||||
export type LOGLEVEL = 'FATAL' | 'ERROR' | 'WARN' | 'INFO' | 'DEBUG' | 'TRACE';
|
||||
|
||||
@@ -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 { TeamRolesEnum } from '../team-roles/team-roles.enum';
|
||||
import { PenaltyService } from './penalty.service';
|
||||
|
||||
describe('PenaltyService catalog management', () => {
|
||||
@@ -28,7 +29,7 @@ describe('PenaltyService catalog management', () => {
|
||||
),
|
||||
};
|
||||
const dataSource = { transaction: jest.fn((work) => work(manager)) };
|
||||
const access = { assertMember: jest.fn(), assertManager: jest.fn() };
|
||||
const access = { assertMember: jest.fn(), assertAtLeast: jest.fn() };
|
||||
const logger = { info: jest.fn() };
|
||||
let service: PenaltyService;
|
||||
|
||||
@@ -127,7 +128,13 @@ describe('PenaltyService catalog management', () => {
|
||||
});
|
||||
|
||||
expect(teamQuery.setLock).toHaveBeenCalledWith('pessimistic_write');
|
||||
expect(access.assertManager).toHaveBeenCalledWith(42, 5, manager);
|
||||
expect(access.assertAtLeast).toHaveBeenCalledWith(
|
||||
42,
|
||||
5,
|
||||
'penalty_manage_min_role',
|
||||
TeamRolesEnum.captain,
|
||||
manager,
|
||||
);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
{
|
||||
event: 'penalty_catalog_create',
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { LoggingService } from 'src/database/logging/logging.service';
|
||||
import { TeamRolesEnum } from 'src/team-roles/team-roles.enum';
|
||||
import { Team } from 'src/teams/entities/team.entity';
|
||||
import { TeamAccessService } from 'src/teams/team-access.service';
|
||||
import { DataSource, EntityManager, Repository } from 'typeorm';
|
||||
@@ -55,7 +56,13 @@ export class PenaltyService {
|
||||
): Promise<PenaltyResponseDTO> {
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const team = await this.lockTeam(manager, dto.teamId);
|
||||
await this.access.assertManager(userId, team.id, manager);
|
||||
await this.access.assertAtLeast(
|
||||
userId,
|
||||
team.id,
|
||||
'penalty_manage_min_role',
|
||||
TeamRolesEnum.captain,
|
||||
manager,
|
||||
);
|
||||
const repository = manager.getRepository(PenaltyEntity);
|
||||
const description = dto.description.trim();
|
||||
await this.assertUniqueDescription(repository, team.id, description);
|
||||
@@ -82,7 +89,13 @@ export class PenaltyService {
|
||||
const owner = await this.findOwner(penaltyId);
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const team = await this.lockTeam(manager, owner.team.id);
|
||||
await this.access.assertManager(userId, team.id, manager);
|
||||
await this.access.assertAtLeast(
|
||||
userId,
|
||||
team.id,
|
||||
'penalty_manage_min_role',
|
||||
TeamRolesEnum.captain,
|
||||
manager,
|
||||
);
|
||||
const repository = manager.getRepository(PenaltyEntity);
|
||||
const penalty = await this.findTransactionalPenalty(
|
||||
repository,
|
||||
@@ -115,7 +128,13 @@ export class PenaltyService {
|
||||
const owner = await this.findOwner(penaltyId);
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const team = await this.lockTeam(manager, owner.team.id);
|
||||
await this.access.assertManager(userId, team.id, manager);
|
||||
await this.access.assertAtLeast(
|
||||
userId,
|
||||
team.id,
|
||||
'penalty_manage_min_role',
|
||||
TeamRolesEnum.captain,
|
||||
manager,
|
||||
);
|
||||
const repository = manager.getRepository(PenaltyEntity);
|
||||
const penalty = await this.findTransactionalPenalty(
|
||||
repository,
|
||||
|
||||
@@ -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 { LoggingModule } from 'src/database/logging/logging.module';
|
||||
import { Team } from 'src/teams/entities/team.entity';
|
||||
import { TeamsModule } from 'src/teams/teams.module';
|
||||
import { User } from 'src/users/entities/user.entity';
|
||||
import { TeamWalletTransactionType } from './entities/team-wallet-transaction-type.entity';
|
||||
import { TeamWalletTransaction } from './entities/team-wallet-transaction.entity';
|
||||
@@ -17,6 +18,7 @@ import { TeamWalletTransactionsService } from './team-wallet-transactions.servic
|
||||
Team,
|
||||
]),
|
||||
LoggingModule,
|
||||
TeamsModule,
|
||||
],
|
||||
controllers: [TeamWalletTransactionsController],
|
||||
providers: [TeamWalletTransactionsService],
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { LoggingService } from 'src/database/logging/logging.service';
|
||||
import { RoleEnum } from 'src/roles/roles.enum';
|
||||
import { TeamRolesEnum } from 'src/team-roles/team-roles.enum';
|
||||
import { TeamAccessService } from 'src/teams/team-access.service';
|
||||
import { Team } from 'src/teams/entities/team.entity';
|
||||
import { User } from 'src/users/entities/user.entity';
|
||||
import { Repository } from 'typeorm';
|
||||
@@ -21,6 +24,7 @@ export class TeamWalletTransactionsService {
|
||||
@InjectRepository(Team)
|
||||
private teamRepository: Repository<Team>,
|
||||
private logger: LoggingService,
|
||||
private access: TeamAccessService,
|
||||
) {}
|
||||
|
||||
async create(data: CreateTeamWalletTransactionDto, userId: string) {
|
||||
@@ -28,32 +32,20 @@ export class TeamWalletTransactionsService {
|
||||
where: {
|
||||
id: Number(userId),
|
||||
},
|
||||
relations: ['players', 'players.team', 'players.team.settings'],
|
||||
});
|
||||
|
||||
const teamPlayer = creatingUser.players.find(
|
||||
(p) =>
|
||||
p.team.id == data.teamId &&
|
||||
p.teamRole.id >=
|
||||
Number(
|
||||
p.team.settings.find((s) => s.key == 'transaction_create_min_role')[
|
||||
'value'
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (creatingUser.role.id != 1 && !teamPlayer) {
|
||||
return;
|
||||
if (creatingUser.role.id != RoleEnum.admin) {
|
||||
await this.access.assertAtLeast(
|
||||
Number(userId),
|
||||
data.teamId,
|
||||
'transaction_create_min_role',
|
||||
TeamRolesEnum.scnd_treasurer,
|
||||
);
|
||||
}
|
||||
|
||||
let team: Team;
|
||||
if (teamPlayer && teamPlayer.team) {
|
||||
team = teamPlayer.team;
|
||||
} else {
|
||||
team = await this.teamRepository.findOneByOrFail({
|
||||
id: data.teamId,
|
||||
});
|
||||
}
|
||||
const team = await this.teamRepository.findOneByOrFail({
|
||||
id: data.teamId,
|
||||
});
|
||||
|
||||
const transactionType =
|
||||
await this.teamWalletTransactionTypeRepository.findOne({
|
||||
|
||||
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 { TeamRolesEnum } from '../team-roles/team-roles.enum';
|
||||
import { PublicTeamAccessService } from './public-team-access.service';
|
||||
|
||||
describe('PublicTeamAccessService', () => {
|
||||
@@ -10,7 +11,7 @@ describe('PublicTeamAccessService', () => {
|
||||
const playerRepository = { find: jest.fn(), findOne: jest.fn() };
|
||||
const transactionRepository = { find: jest.fn() };
|
||||
const penaltyRepository = { find: jest.fn() };
|
||||
const access = { assertMember: jest.fn(), assertManager: jest.fn() };
|
||||
const access = { assertMember: jest.fn(), assertAtLeast: jest.fn() };
|
||||
let service: PublicTeamAccessService;
|
||||
|
||||
const managedTeam = {
|
||||
@@ -48,7 +49,12 @@ describe('PublicTeamAccessService', () => {
|
||||
|
||||
const status = await service.setEnabled(4, 7, true);
|
||||
|
||||
expect(access.assertManager).toHaveBeenCalledWith(4, 7);
|
||||
expect(access.assertAtLeast).toHaveBeenCalledWith(
|
||||
4,
|
||||
7,
|
||||
'public_access_manage_min_role',
|
||||
TeamRolesEnum.captain,
|
||||
);
|
||||
expect(status.enabled).toBe(true);
|
||||
expect(status.token).toMatch(/^[a-f0-9]{64}$/);
|
||||
expect(teamRepository.save).toHaveBeenCalledWith(team);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { PenaltyEntity } from '../penalty/entities/penalty.entity';
|
||||
import { Player } from '../players/entities/player.entity';
|
||||
import { TeamRolesEnum } from '../team-roles/team-roles.enum';
|
||||
import { Transaction } from '../transactions/entitites/transaction.entity';
|
||||
import { Repository } from 'typeorm';
|
||||
import {
|
||||
@@ -42,7 +43,12 @@ export class PublicTeamAccessService {
|
||||
teamId: number,
|
||||
enabled: boolean,
|
||||
): Promise<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);
|
||||
if (enabled && !team.publicAccessToken) {
|
||||
team.publicAccessToken = this.createToken();
|
||||
@@ -53,7 +59,12 @@ export class PublicTeamAccessService {
|
||||
}
|
||||
|
||||
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);
|
||||
team.publicAccessToken = this.createToken();
|
||||
await this.teamRepository.save(team);
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
import { RoleEnum } from '../roles/roles.enum';
|
||||
import { TeamRolesEnum } from '../team-roles/team-roles.enum';
|
||||
import { TeamAccessService } from './team-access.service';
|
||||
|
||||
describe('TeamAccessService', () => {
|
||||
const userRepository = { findOne: jest.fn() };
|
||||
const playerRepository = { find: jest.fn() };
|
||||
const teamSettingRepository = { findOne: jest.fn() };
|
||||
let service: TeamAccessService;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -12,6 +14,7 @@ describe('TeamAccessService', () => {
|
||||
service = new TeamAccessService(
|
||||
userRepository as any,
|
||||
playerRepository as any,
|
||||
teamSettingRepository as any,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -105,4 +108,104 @@ describe('TeamAccessService', () => {
|
||||
ForbiddenException,
|
||||
);
|
||||
});
|
||||
|
||||
describe('assertAtLeast', () => {
|
||||
it('uses the configured team setting instead of the fallback', async () => {
|
||||
userRepository.findOne.mockResolvedValue({
|
||||
id: 2,
|
||||
role: { id: RoleEnum.user },
|
||||
});
|
||||
teamSettingRepository.findOne.mockResolvedValue({ value: '4' });
|
||||
playerRepository.find.mockResolvedValue([
|
||||
{ active: true, teamRole: { id: 3 } },
|
||||
]);
|
||||
|
||||
await expect(
|
||||
service.assertAtLeast(2, 9, 'invite_min_role', TeamRolesEnum.captain),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
it('falls back to the given minimum role when no setting exists', async () => {
|
||||
userRepository.findOne.mockResolvedValue({
|
||||
id: 2,
|
||||
role: { id: RoleEnum.user },
|
||||
});
|
||||
teamSettingRepository.findOne.mockResolvedValue(null);
|
||||
playerRepository.find.mockResolvedValue([
|
||||
{ active: true, teamRole: { id: 3 } },
|
||||
]);
|
||||
|
||||
await expect(
|
||||
service.assertAtLeast(2, 9, 'invite_min_role', TeamRolesEnum.captain),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('falls back to the given minimum role when the stored value is invalid', async () => {
|
||||
userRepository.findOne.mockResolvedValue({
|
||||
id: 2,
|
||||
role: { id: RoleEnum.user },
|
||||
});
|
||||
teamSettingRepository.findOne.mockResolvedValue({ value: 'not-a-role' });
|
||||
playerRepository.find.mockResolvedValue([
|
||||
{ active: true, teamRole: { id: 3 } },
|
||||
]);
|
||||
|
||||
await expect(
|
||||
service.assertAtLeast(2, 9, 'invite_min_role', TeamRolesEnum.captain),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('lets a global admin bypass the check without reading settings', async () => {
|
||||
userRepository.findOne.mockResolvedValue({
|
||||
id: 1,
|
||||
role: { id: RoleEnum.admin },
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.assertAtLeast(1, 9, 'invite_min_role', TeamRolesEnum.captain),
|
||||
).resolves.toBeUndefined();
|
||||
expect(teamSettingRepository.findOne).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reads the team setting from the supplied transaction manager', async () => {
|
||||
const transactionalUserRepository = {
|
||||
findOne: jest.fn().mockResolvedValue({
|
||||
id: 2,
|
||||
role: { id: RoleEnum.user },
|
||||
}),
|
||||
};
|
||||
const transactionalPlayerRepository = {
|
||||
find: jest
|
||||
.fn()
|
||||
.mockResolvedValue([{ active: true, teamRole: { id: 4 } }]),
|
||||
};
|
||||
const transactionalTeamSettingRepository = {
|
||||
findOne: jest.fn().mockResolvedValue({ value: '4' }),
|
||||
};
|
||||
const manager = {
|
||||
getRepository: jest
|
||||
.fn()
|
||||
.mockImplementation((entity) => {
|
||||
if (entity.name === 'TeamSetting') {
|
||||
return transactionalTeamSettingRepository;
|
||||
}
|
||||
if (entity.name === 'User') return transactionalUserRepository;
|
||||
return transactionalPlayerRepository;
|
||||
}),
|
||||
};
|
||||
|
||||
await expect(
|
||||
service.assertAtLeast(
|
||||
2,
|
||||
9,
|
||||
'penalty_manage_min_role',
|
||||
TeamRolesEnum.captain,
|
||||
manager as any,
|
||||
),
|
||||
).resolves.toBeUndefined();
|
||||
expect(teamSettingRepository.findOne).not.toHaveBeenCalled();
|
||||
expect(userRepository.findOne).not.toHaveBeenCalled();
|
||||
expect(playerRepository.find).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,9 @@ import { ForbiddenException, Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Player } from '../players/entities/player.entity';
|
||||
import { RoleEnum } from '../roles/roles.enum';
|
||||
import { TeamRolesEnum } from '../team-roles/team-roles.enum';
|
||||
import { TeamSetting } from '../team-settings/entities/team-setting.entity';
|
||||
import { TeamSettingType } from './model/team-settings-type';
|
||||
import { User } from '../users/entities/user.entity';
|
||||
import { EntityManager, Repository } from 'typeorm';
|
||||
|
||||
@@ -12,6 +15,8 @@ export class TeamAccessService {
|
||||
private readonly userRepository: Repository<User>,
|
||||
@InjectRepository(Player)
|
||||
private readonly playerRepository: Repository<Player>,
|
||||
@InjectRepository(TeamSetting)
|
||||
private readonly teamSettingRepository: Repository<TeamSetting>,
|
||||
) {}
|
||||
|
||||
async assertMember(
|
||||
@@ -30,10 +35,42 @@ export class TeamAccessService {
|
||||
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(
|
||||
userId: number,
|
||||
teamId: number,
|
||||
minimumRole: number,
|
||||
minimumRole: number | (() => Promise<number>),
|
||||
manager?: EntityManager,
|
||||
): Promise<void> {
|
||||
const userRepository = manager?.getRepository(User) ?? this.userRepository;
|
||||
@@ -42,6 +79,9 @@ export class TeamAccessService {
|
||||
const user = await userRepository.findOne({ where: { id: userId } });
|
||||
if (user?.role?.id === RoleEnum.admin) return;
|
||||
|
||||
const resolvedMinimumRole =
|
||||
typeof minimumRole === 'function' ? await minimumRole() : minimumRole;
|
||||
|
||||
const players = await playerRepository.find({
|
||||
where: { user: { id: userId }, team: { id: teamId } },
|
||||
});
|
||||
@@ -52,7 +92,7 @@ export class TeamAccessService {
|
||||
0,
|
||||
);
|
||||
|
||||
if (highestActiveRole < minimumRole) {
|
||||
if (highestActiveRole < resolvedMinimumRole) {
|
||||
throw new ForbiddenException('Keine Berechtigung für dieses Team.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,12 +43,12 @@ describe('TeamMembersService', () => {
|
||||
};
|
||||
dataSource = { transaction: jest.fn((work) => work(manager)) };
|
||||
logger = { info: jest.fn() };
|
||||
access = { assertManager: jest.fn(() => Promise.resolve()) };
|
||||
access = { assertAtLeast: jest.fn(() => Promise.resolve()) };
|
||||
service = new TeamMembersService(dataSource, logger, access as any);
|
||||
});
|
||||
|
||||
it('checks the team-manager permission before touching the database', async () => {
|
||||
access.assertManager.mockRejectedValue(new Error('forbidden'));
|
||||
access.assertAtLeast.mockRejectedValue(new Error('forbidden'));
|
||||
await expect(service.setActive(5, teamId, player.id, false)).rejects.toThrow(
|
||||
'forbidden',
|
||||
);
|
||||
|
||||
@@ -26,7 +26,12 @@ export class TeamMembersService {
|
||||
playerId: number,
|
||||
active: boolean,
|
||||
): 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) => {
|
||||
const activeTreasurers = await this.lockActiveTreasurers(manager, teamId);
|
||||
@@ -71,7 +76,12 @@ export class TeamMembersService {
|
||||
playerId: number,
|
||||
teamRoleId: TeamRolesEnum,
|
||||
): 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) => {
|
||||
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 { UpdatePlayerProfileDto } from './dto/update-player-profile.dto';
|
||||
import { PlayerActiveDto, PlayerTeamRoleDto } from './dto/player-management.dto';
|
||||
import { UpdateTeamPermissionsDto } from './dto/team-permissions.dto';
|
||||
import { TeamMembersService } from './team-members.service';
|
||||
import { TeamPermissionsService } from './team-permissions.service';
|
||||
|
||||
@ApiTags('Teams')
|
||||
@Controller({
|
||||
@@ -35,6 +37,7 @@ export class TeamsController {
|
||||
private service: TeamsService,
|
||||
private publicAccess: PublicTeamAccessService,
|
||||
private teamMembers: TeamMembersService,
|
||||
private teamPermissions: TeamPermissionsService,
|
||||
) {}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@@ -62,6 +65,34 @@ export class TeamsController {
|
||||
return this.publicAccess.rotate(Number(req.user.id), id);
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: 'Team-Berechtigungen abrufen',
|
||||
description:
|
||||
'Gibt die pro Team konfigurierte Mindestrolle je Aktion zurück.',
|
||||
})
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@Get(':id/permissions')
|
||||
getPermissions(@Req() req, @Param('id', ParseIntPipe) id: number) {
|
||||
return this.teamPermissions.getPermissions(Number(req.user.id), id);
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: 'Team-Berechtigungen ändern',
|
||||
description:
|
||||
'Setzt die Mindestrolle je Aktion für dieses Team. Nur Team-Manager (Kapitän aufwärts).',
|
||||
})
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@Patch(':id/permissions')
|
||||
updatePermissions(
|
||||
@Req() req,
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() body: UpdateTeamPermissionsDto,
|
||||
) {
|
||||
return this.teamPermissions.updatePermissions(Number(req.user.id), id, body);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@Get(':id/players/:playerId/transactions')
|
||||
@@ -82,8 +113,8 @@ export class TeamsController {
|
||||
@UseGuards(AuthGuard('jwt'), RolesGuard)
|
||||
@Get(':id/overview')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.service.getOverview(id);
|
||||
findOne(@Req() req, @Param('id') id: string) {
|
||||
return this.service.getOverview(id, req.user?.id);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@@ -121,8 +152,12 @@ export class TeamsController {
|
||||
@UseGuards(AuthGuard('jwt'), RolesGuard)
|
||||
@Post(':id/players')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
createPlayer(@Param('id') id: string, @Body() playerDto: any) {
|
||||
return this.service.createNewPlayer(id, playerDto);
|
||||
createPlayer(
|
||||
@Req() req,
|
||||
@Param('id') id: string,
|
||||
@Body() playerDto: any,
|
||||
) {
|
||||
return this.service.createNewPlayer(id, playerDto, req.user?.id);
|
||||
}
|
||||
|
||||
@Roles([RoleEnum.user, RoleEnum.admin])
|
||||
@@ -133,8 +168,12 @@ export class TeamsController {
|
||||
})
|
||||
@Put(':id/players')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
updatePlayer(@Body() playerDto: UpdatePlayerProfileDto) {
|
||||
return this.service.updatePlayer(playerDto);
|
||||
updatePlayer(
|
||||
@Req() req,
|
||||
@Param('id') id: string,
|
||||
@Body() playerDto: UpdatePlayerProfileDto,
|
||||
) {
|
||||
return this.service.updatePlayer(id, playerDto, req.user?.id);
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
|
||||
@@ -16,6 +16,7 @@ import { PublicTeamAccessService } from './public-team-access.service';
|
||||
import { PublicTeamsController } from './public-teams.controller';
|
||||
import { PenaltyEntity } from '../penalty/entities/penalty.entity';
|
||||
import { TeamMembersService } from './team-members.service';
|
||||
import { TeamPermissionsService } from './team-permissions.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -38,6 +39,7 @@ import { TeamMembersService } from './team-members.service';
|
||||
TeamAccessService,
|
||||
PublicTeamAccessService,
|
||||
TeamMembersService,
|
||||
TeamPermissionsService,
|
||||
],
|
||||
exports: [TeamAccessService],
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Player } from 'src/players/entities/player.entity';
|
||||
import { TeamRole } from 'src/team-roles/entities/team-roles.entity';
|
||||
import { CreateTeamSettingDTO } from 'src/team-settings/dto/create-team-setting.dto';
|
||||
import { TeamSetting } from 'src/team-settings/entities/team-setting.entity';
|
||||
import { TEAM_SETTING_DEFAULTS } from 'src/team-settings/team-setting-defaults';
|
||||
import { TeamWalletTransaction } from 'src/team-wallet-transactions/entities/team-wallet-transaction.entity';
|
||||
import { Transaction } from 'src/transactions/entitites/transaction.entity';
|
||||
import { Repository } from 'typeorm';
|
||||
@@ -32,9 +33,11 @@ export class TeamsService {
|
||||
private access: TeamAccessService,
|
||||
) {}
|
||||
|
||||
async getOverview(teamId: string) {
|
||||
async getOverview(teamId: string, actorUserId: string) {
|
||||
const id = Number(teamId);
|
||||
|
||||
await this.access.assertMember(Number(actorUserId), id);
|
||||
|
||||
const team = await this.repository.findOneOrFail({
|
||||
where: { id },
|
||||
relations: ['players'],
|
||||
@@ -104,7 +107,10 @@ export class TeamsService {
|
||||
async createNewPlayer(
|
||||
id: string,
|
||||
player: { firstName: string; lastName: string; teamRole: any },
|
||||
actorUserId: string,
|
||||
) {
|
||||
await this.access.assertManager(Number(actorUserId), Number(id));
|
||||
|
||||
if (typeof player.teamRole == 'string') {
|
||||
player.teamRole = await this.rolesRepository.findOneBy({
|
||||
name: player.teamRole,
|
||||
@@ -156,24 +162,26 @@ export class TeamsService {
|
||||
return team;
|
||||
}
|
||||
|
||||
private generateBasicTeamSettings(team: Team): Promise<void> {
|
||||
return new Promise<void>(async (resolve) => {
|
||||
const s: CreateTeamSettingDTO = {
|
||||
key: 'transaction_create_min_role',
|
||||
value: '2',
|
||||
team,
|
||||
};
|
||||
private async generateBasicTeamSettings(team: Team): Promise<void> {
|
||||
const settings: CreateTeamSettingDTO[] = Object.entries(
|
||||
TEAM_SETTING_DEFAULTS,
|
||||
).map(([key, value]) => ({
|
||||
key: key as CreateTeamSettingDTO['key'],
|
||||
value: String(value),
|
||||
team,
|
||||
}));
|
||||
|
||||
const setting = this.settingsRepository.create(s);
|
||||
await this.settingsRepository.save(setting);
|
||||
resolve();
|
||||
});
|
||||
await this.settingsRepository.save(
|
||||
settings.map((s) => this.settingsRepository.create(s)),
|
||||
);
|
||||
}
|
||||
|
||||
async getTeamTransactions(teamId: string | number, userId: string | number) {
|
||||
const start = new Date();
|
||||
teamId = Number(teamId);
|
||||
|
||||
await this.access.assertMember(Number(userId), teamId);
|
||||
|
||||
const team = await this.repository.findOneOrFail({
|
||||
where: {
|
||||
id: teamId,
|
||||
@@ -343,13 +351,24 @@ export class TeamsService {
|
||||
return Math.round(value * 100) / 100;
|
||||
}
|
||||
|
||||
async updatePlayer(playerDTO: UpdatePlayerProfileDto) {
|
||||
async updatePlayer(
|
||||
teamId: string,
|
||||
playerDTO: UpdatePlayerProfileDto,
|
||||
actorUserId: string,
|
||||
) {
|
||||
const player = await this.playerRepository.findOneOrFail({
|
||||
where: {
|
||||
id: playerDTO.id,
|
||||
},
|
||||
relations: ['team'],
|
||||
});
|
||||
|
||||
if (player.team?.id !== Number(teamId)) {
|
||||
throw new HttpException('No Player Found', HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
await this.access.assertManager(Number(actorUserId), Number(teamId));
|
||||
|
||||
player.firstName = playerDTO.firstName;
|
||||
player.lastName = playerDTO.lastName;
|
||||
|
||||
|
||||
@@ -6,19 +6,14 @@ import { TransactionsService } from './transactions.service';
|
||||
import { TransactionsController } from './transactions.controller';
|
||||
import { Player } from 'src/players/entities/player.entity';
|
||||
import { User } from 'src/users/entities/user.entity';
|
||||
import { TeamSetting } from 'src/team-settings/entities/team-setting.entity';
|
||||
import { LoggingModule } from 'src/database/logging/logging.module';
|
||||
import { TeamsModule } from 'src/teams/teams.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
TransactionType,
|
||||
Transaction,
|
||||
Player,
|
||||
User,
|
||||
TeamSetting,
|
||||
]),
|
||||
TypeOrmModule.forFeature([TransactionType, Transaction, Player, User]),
|
||||
LoggingModule,
|
||||
TeamsModule,
|
||||
],
|
||||
providers: [TransactionsService],
|
||||
controllers: [TransactionsController],
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
@@ -8,6 +7,8 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { LoggingService } from 'src/database/logging/logging.service';
|
||||
import { Player } from 'src/players/entities/player.entity';
|
||||
import { RoleEnum } from 'src/roles/roles.enum';
|
||||
import { TeamRolesEnum } from 'src/team-roles/team-roles.enum';
|
||||
import { TeamAccessService } from 'src/teams/team-access.service';
|
||||
import { User } from 'src/users/entities/user.entity';
|
||||
import { Repository } from 'typeorm';
|
||||
import { CreateTransactionDto } from './dto/create-transaction.dto';
|
||||
@@ -27,6 +28,7 @@ export class TransactionsService {
|
||||
@InjectRepository(User)
|
||||
private usersRepository: Repository<User>,
|
||||
private logger: LoggingService,
|
||||
private access: TeamAccessService,
|
||||
) {}
|
||||
|
||||
async createTransactions(data: CreateTransactionDto[], userId: string) {
|
||||
@@ -40,50 +42,35 @@ export class TransactionsService {
|
||||
async create(data: CreateTransactionDto, userId: string) {
|
||||
const player = await this.playersRepository.findOne({
|
||||
where: { id: data.playerId },
|
||||
relations: ['team'],
|
||||
});
|
||||
|
||||
const creatingUser = await this.usersRepository.findOne({
|
||||
where: {
|
||||
id: Number(userId),
|
||||
},
|
||||
relations: ['players'],
|
||||
});
|
||||
|
||||
const transactionType = await this.transactionTypesRepository.findOne({
|
||||
where: { id: TransactionTypeEnum[TransactionTypeEnum[data.type]] },
|
||||
});
|
||||
|
||||
if (!player || !transactionType || !creatingUser) {
|
||||
await this.logger.warn({
|
||||
event: 'transaction_create_fail',
|
||||
details: `Player: ${data.playerId}, amount: ${data.amount}, typeEnum: ${data.type}`,
|
||||
userId: Number(userId),
|
||||
});
|
||||
throw new NotFoundException('Spieler oder Buchungstyp nicht gefunden.');
|
||||
}
|
||||
|
||||
if (creatingUser.role.id != RoleEnum.admin) {
|
||||
if (
|
||||
!player ||
|
||||
!transactionType ||
|
||||
!creatingUser ||
|
||||
!creatingUser.players ||
|
||||
creatingUser.players.length == 0
|
||||
) {
|
||||
await this.logger.warn({
|
||||
event: 'transaction_create_fail',
|
||||
details: `Player: ${data.playerId}, amount: ${data.amount}, typeEnum: ${data.type}`,
|
||||
userId: Number(userId),
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const teamPlayer = creatingUser.players.find(
|
||||
(p) =>
|
||||
p.team.id == player.team.id &&
|
||||
p.teamRole.id >=
|
||||
Number(
|
||||
player.team.settings.find(
|
||||
(s) => s.key == 'transaction_create_min_role',
|
||||
)['value'],
|
||||
),
|
||||
await this.access.assertAtLeast(
|
||||
Number(userId),
|
||||
player.team.id,
|
||||
'transaction_create_min_role',
|
||||
TeamRolesEnum.scnd_treasurer,
|
||||
);
|
||||
|
||||
if (!teamPlayer) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const transaction = this.transactionsRepository.create({
|
||||
@@ -131,26 +118,15 @@ export class TransactionsService {
|
||||
|
||||
const creatingUser = await this.usersRepository.findOne({
|
||||
where: { id: Number(userId) },
|
||||
relations: ['players'],
|
||||
});
|
||||
|
||||
if (creatingUser.role.id != RoleEnum.admin) {
|
||||
const teamPlayer = creatingUser.players?.find(
|
||||
(p) =>
|
||||
p.team.id == original.player.team.id &&
|
||||
p.teamRole.id >=
|
||||
Number(
|
||||
original.player.team.settings.find(
|
||||
(s) => s.key == 'transaction_create_min_role',
|
||||
)?.['value'] ?? 0,
|
||||
),
|
||||
await this.access.assertAtLeast(
|
||||
Number(userId),
|
||||
original.player.team.id,
|
||||
'transaction_reverse_min_role',
|
||||
TeamRolesEnum.scnd_treasurer,
|
||||
);
|
||||
|
||||
if (!teamPlayer) {
|
||||
throw new ForbiddenException(
|
||||
'Keine Berechtigung, diese Buchung zu stornieren',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const originalAmount = Math.abs(Number(original.amount));
|
||||
|
||||
@@ -101,6 +101,11 @@ export const routes: Routes = [
|
||||
loadComponent: () =>
|
||||
import('./features/team/more/public-access/public-access').then((m) => m.PublicAccess),
|
||||
},
|
||||
{
|
||||
path: 'more/permissions',
|
||||
loadComponent: () =>
|
||||
import('./features/team/more/permissions/permissions').then((m) => m.Permissions),
|
||||
},
|
||||
{
|
||||
path: 'more/guide',
|
||||
loadComponent: () =>
|
||||
|
||||
@@ -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 { MatSelectModule } from '@angular/material/select';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { AuthStore } from '../../../core/auth/auth-store';
|
||||
import { TeamPermissionsService } from '../../../core/team/team-permissions';
|
||||
import { PenaltyApi } from '../../../core/team/penalty-api';
|
||||
import { TeamStore } from '../../../core/team/team-store';
|
||||
import { TransactionsApi } from '../../../core/team/transactions-api';
|
||||
@@ -55,10 +55,10 @@ const HIGH_AMOUNT_CONFIRM_THRESHOLD = 300;
|
||||
styleUrl: './cashbox.scss',
|
||||
})
|
||||
export class Cashbox {
|
||||
private readonly authStore = inject(AuthStore);
|
||||
private readonly dialog = inject(MatDialog);
|
||||
private readonly formBuilder = inject(FormBuilder);
|
||||
private readonly penaltyApi = inject(PenaltyApi);
|
||||
private readonly permissions = inject(TeamPermissionsService);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
@@ -85,15 +85,9 @@ export class Cashbox {
|
||||
{ id: 14, label: 'Ausgabe' },
|
||||
];
|
||||
|
||||
protected readonly canBook = computed(() => {
|
||||
const user = this.authStore.currentUser();
|
||||
if (user?.role?.id === 1) return true;
|
||||
return (
|
||||
this.team()?.players?.some(
|
||||
(player) => player.user?.id === user?.id && (player.teamRole?.id ?? 0) >= 2,
|
||||
) ?? false
|
||||
);
|
||||
});
|
||||
protected readonly canBook = computed(() =>
|
||||
this.permissions.canDo(this.team(), 'transactionCreate'),
|
||||
);
|
||||
|
||||
protected readonly activePlayers = computed(() =>
|
||||
(this.team()?.players ?? []).filter((player) => player.active),
|
||||
@@ -206,7 +200,7 @@ export class Cashbox {
|
||||
|
||||
protected canReverse(activity: TeamActivity): boolean {
|
||||
return (
|
||||
this.canBook() &&
|
||||
this.permissions.canDo(this.team(), 'transactionReverse') &&
|
||||
!activity.isTeamWalletTransaction &&
|
||||
!activity.note?.startsWith('Stornierung von Buchung #')
|
||||
);
|
||||
|
||||
@@ -9,7 +9,7 @@ import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { AuthStore } from '../../../core/auth/auth-store';
|
||||
import { TeamPermissionsService } from '../../../core/team/team-permissions';
|
||||
import { TeamStore } from '../../../core/team/team-store';
|
||||
import { TeamsApi } from '../../../core/team/teams-api';
|
||||
import { ContextHelp } from '../../../shared/context-help/context-help';
|
||||
@@ -35,8 +35,8 @@ registerLocaleData(localeDe);
|
||||
styleUrl: './members.scss',
|
||||
})
|
||||
export class Members {
|
||||
private readonly authStore = inject(AuthStore);
|
||||
private readonly formBuilder = inject(FormBuilder);
|
||||
private readonly permissions = inject(TeamPermissionsService);
|
||||
private readonly teamsApi = inject(TeamsApi);
|
||||
private readonly teamStore = inject(TeamStore);
|
||||
protected readonly team = this.teamStore.team;
|
||||
@@ -50,15 +50,9 @@ export class Members {
|
||||
teamRole: [1, Validators.required],
|
||||
});
|
||||
|
||||
protected readonly canManage = computed(() => {
|
||||
const user = this.authStore.currentUser();
|
||||
if (user?.role?.id === 1) return true;
|
||||
return (
|
||||
this.team()?.players?.some(
|
||||
(player) => player.user?.id === user?.id && (player.teamRole?.id ?? 0) >= 3,
|
||||
) ?? false
|
||||
);
|
||||
});
|
||||
protected readonly canManage = computed(() =>
|
||||
this.permissions.canDo(this.team(), 'memberManage'),
|
||||
);
|
||||
|
||||
protected readonly players = computed(() => {
|
||||
const query = this.search().trim().toLocaleLowerCase('de');
|
||||
|
||||
@@ -9,7 +9,7 @@ import { MatInputModule } from '@angular/material/input';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { AuthApi } from '../../../../core/auth/auth-api';
|
||||
import { AuthStore } from '../../../../core/auth/auth-store';
|
||||
import { TeamPermissionsService } from '../../../../core/team/team-permissions';
|
||||
import { TeamStore } from '../../../../core/team/team-store';
|
||||
import { ContextHelp } from '../../../../shared/context-help/context-help';
|
||||
|
||||
@@ -32,8 +32,8 @@ import { ContextHelp } from '../../../../shared/context-help/context-help';
|
||||
})
|
||||
export class Invite {
|
||||
private readonly authApi = inject(AuthApi);
|
||||
private readonly authStore = inject(AuthStore);
|
||||
private readonly formBuilder = inject(FormBuilder);
|
||||
private readonly permissions = inject(TeamPermissionsService);
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
private readonly teamStore = inject(TeamStore);
|
||||
protected readonly team = this.teamStore.team;
|
||||
@@ -45,15 +45,9 @@ export class Invite {
|
||||
protected readonly availablePlayers = computed(() =>
|
||||
(this.team()?.players ?? []).filter((player) => player.active && !player.user),
|
||||
);
|
||||
protected readonly canInvite = computed(() => {
|
||||
const user = this.authStore.currentUser();
|
||||
if (user?.role?.id === 1) return true;
|
||||
return (
|
||||
this.team()?.players?.some(
|
||||
(player) => player.user?.id === user?.id && (player.teamRole?.id ?? 0) > 2,
|
||||
) ?? false
|
||||
);
|
||||
});
|
||||
protected readonly canInvite = computed(() =>
|
||||
this.permissions.canDo(this.team(), 'invite'),
|
||||
);
|
||||
|
||||
protected generateLink(): void {
|
||||
const team = this.team();
|
||||
|
||||
@@ -54,6 +54,17 @@
|
||||
<mat-icon>chevron_right</mat-icon></mat-card
|
||||
></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>
|
||||
|
||||
<mat-card class="account-card">
|
||||
|
||||
@@ -3,6 +3,7 @@ import { TestBed } from '@angular/core/testing';
|
||||
import { provideRouter, Router } from '@angular/router';
|
||||
import { AuthStore } from '../../../core/auth/auth-store';
|
||||
import { HelpAccessService } from '../../../core/help/help-access';
|
||||
import { TeamStore } from '../../../core/team/team-store';
|
||||
import { More } from './more';
|
||||
|
||||
@Component({ template: '' })
|
||||
@@ -23,6 +24,7 @@ describe('More', () => {
|
||||
},
|
||||
},
|
||||
{ provide: HelpAccessService, useValue: { canOpenGuide: signal(true) } },
|
||||
{ provide: TeamStore, useValue: { team: signal(null) } },
|
||||
],
|
||||
}).compileComponents();
|
||||
const fixture = TestBed.createComponent(More);
|
||||
@@ -56,6 +58,7 @@ describe('More', () => {
|
||||
},
|
||||
},
|
||||
{ provide: HelpAccessService, useValue: { canOpenGuide: signal(false) } },
|
||||
{ provide: TeamStore, useValue: { team: signal(null) } },
|
||||
],
|
||||
}).compileComponents();
|
||||
const fixture = TestBed.createComponent(More);
|
||||
@@ -63,4 +66,49 @@ describe('More', () => {
|
||||
|
||||
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 { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { AuthStore } from '../../../core/auth/auth-store';
|
||||
import { HelpAccessService } from '../../../core/help/help-access';
|
||||
import { TeamPermissionsService } from '../../../core/team/team-permissions';
|
||||
import { TeamStore } from '../../../core/team/team-store';
|
||||
|
||||
@Component({
|
||||
selector: 'app-more',
|
||||
@@ -15,9 +17,14 @@ import { HelpAccessService } from '../../../core/help/help-access';
|
||||
export class More {
|
||||
private readonly authStore = inject(AuthStore);
|
||||
private readonly helpAccess = inject(HelpAccessService);
|
||||
private readonly permissions = inject(TeamPermissionsService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly teamStore = inject(TeamStore);
|
||||
protected readonly user = this.authStore.currentUser;
|
||||
protected readonly canOpenGuide = this.helpAccess.canOpenGuide;
|
||||
protected readonly canManagePermissions = computed(() =>
|
||||
this.permissions.canDo(this.teamStore.team(), 'memberManage'),
|
||||
);
|
||||
|
||||
protected logout(): void {
|
||||
this.authStore.clearSession();
|
||||
|
||||
@@ -13,7 +13,7 @@ import { MatInputModule } from '@angular/material/input';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
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 { TeamStore } from '../../../../core/team/team-store';
|
||||
import { Penalty } from '../../../../models/penalty.model';
|
||||
@@ -41,11 +41,11 @@ registerLocaleData(localeDe);
|
||||
styleUrl: './penalties.scss',
|
||||
})
|
||||
export class Penalties {
|
||||
private readonly authStore = inject(AuthStore);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly dialog = inject(MatDialog);
|
||||
private readonly formBuilder = inject(FormBuilder);
|
||||
private readonly penaltyApi = inject(PenaltyApi);
|
||||
private readonly permissions = inject(TeamPermissionsService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly teamStore = inject(TeamStore);
|
||||
private loadedTeamId: number | null = null;
|
||||
@@ -86,26 +86,12 @@ export class Penalties {
|
||||
],
|
||||
],
|
||||
});
|
||||
protected readonly canManage = 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) >= 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 canManage = computed(() =>
|
||||
this.permissions.canDo(this.team(), 'penaltyManage'),
|
||||
);
|
||||
protected readonly canBook = computed(() =>
|
||||
this.permissions.canDo(this.team(), 'transactionCreate'),
|
||||
);
|
||||
protected readonly filteredPenalties = computed(() => {
|
||||
const query = this.search().trim().toLocaleLowerCase('de');
|
||||
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 { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { toString as qrToString } from 'qrcode';
|
||||
import { AuthStore } from '../../../../core/auth/auth-store';
|
||||
import { PublicAccessApi } from '../../../../core/team/public-access-api';
|
||||
import { TeamPermissionsService } from '../../../../core/team/team-permissions';
|
||||
import { TeamStore } from '../../../../core/team/team-store';
|
||||
import { PublicAccessStatus } from '../../../../models/public-access.model';
|
||||
import { ConfirmDialog, ConfirmDialogData } from '../../../../shared/confirm-dialog/confirm-dialog';
|
||||
@@ -33,8 +33,8 @@ import { ContextHelp } from '../../../../shared/context-help/context-help';
|
||||
})
|
||||
export class PublicAccess {
|
||||
private readonly api = inject(PublicAccessApi);
|
||||
private readonly authStore = inject(AuthStore);
|
||||
private readonly dialog = inject(MatDialog);
|
||||
private readonly permissions = inject(TeamPermissionsService);
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
private readonly teamStore = inject(TeamStore);
|
||||
private loadedTeamId: number | null = null;
|
||||
@@ -49,16 +49,9 @@ export class PublicAccess {
|
||||
const status = this.status();
|
||||
return status?.enabled && status.token ? `${location.origin}/t/${status.token}` : '';
|
||||
});
|
||||
protected readonly canManage = 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) >= 3,
|
||||
) ?? false
|
||||
);
|
||||
});
|
||||
protected readonly canManage = computed(() =>
|
||||
this.permissions.canDo(this.team(), 'publicAccessManage'),
|
||||
);
|
||||
|
||||
constructor() {
|
||||
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';
|
||||
|
||||
export interface TeamSetting {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface Team {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -7,4 +12,5 @@ export interface Team {
|
||||
balance: number;
|
||||
outstanding?: number;
|
||||
players?: Player[];
|
||||
settings?: TeamSetting[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user