Compare commits

...

16 Commits

Author SHA1 Message Date
Bastian Wagner
e3bff40181 Merge branch 'feature/admin-user-management' 2026-08-01 11:50:27 +02:00
Bastian Wagner
945369796e fix: refresh admin self profile state 2026-08-01 11:45:03 +02:00
Bastian Wagner
431eba86e7 fix: align admin profile request validation 2026-08-01 11:20:47 +02:00
Bastian Wagner
d962200b79 test: cover assignment failure recovery 2026-08-01 10:52:17 +02:00
Bastian Wagner
9eec6268d0 fix: lock directory during assignment changes 2026-08-01 10:44:17 +02:00
Bastian Wagner
9dc9dc3dcf fix: stabilize admin user UI state 2026-08-01 10:32:51 +02:00
Bastian Wagner
3ae0fd2000 feat: build admin user management UI 2026-08-01 10:01:25 +02:00
Bastian Wagner
d0ec000bff fix: harden admin frontend contracts 2026-08-01 09:26:14 +02:00
Bastian Wagner
484c7473fb feat: add admin user frontend contract 2026-08-01 09:17:57 +02:00
Bastian Wagner
78761b570b chore: untrack SDD reports 2026-08-01 00:21:26 +02:00
Bastian Wagner
bec1826bfa fix: close admin user security gaps 2026-08-01 00:19:49 +02:00
Bastian Wagner
e6acfdcac7 feat: secure admin user management 2026-07-31 23:37:54 +02:00
Bastian Wagner
4105460400 test: guard inactive directory membership 2026-07-31 22:36:18 +02:00
Bastian Wagner
d738b49cbf fix: query user directory safely 2026-07-31 22:29:15 +02:00
Bastian Wagner
c382234746 feat: add safe user directory query 2026-07-31 22:12:53 +02:00
Bastian Wagner
bcb4207233 docs: add admin user management plan 2026-07-31 21:58:47 +02:00
57 changed files with 4897 additions and 260 deletions

View File

@@ -0,0 +1,56 @@
# Admin User Management Implementation Plan
## Goal
Add a shared user directory and a secure global-admin management surface spanning the NestJS backend and the modern Angular frontend. The legacy frontend remains untouched.
## Global Constraints
- Reuse the existing `User.status`, `User.role`, and `Player.user` relations; do not add a new assignment table.
- Only global `RoleEnum.admin` users may mutate users or player assignments. Team roles are read-only.
- Non-admin directory responses contain only users sharing at least one team with the requester, and only assignments from those shared teams. They never contain email or unrelated-team information.
- Admin directory responses may contain email, global role, status, and all assignments, but never password, hash, social ID, or authentication secrets.
- Admins may edit first name, last name, global role, status, and player assignments. They may not edit email/password, create users, delete users, or edit legacy frontend code.
- Deactivation is immediate for login and already-issued JWTs, preserves assignments, and must not allow self-deactivation or loss of the last active admin. The same last-admin and self-protection applies to role demotion.
- Admin mutation endpoints use narrow DTOs and server-side authorization. The existing generic user PATCH is not used by the modern frontend.
- Follow strict TDD: add focused failing tests first, confirm the expected failure, then add minimal production code and refactor only while green.
## Task 1: Backend directory contract and query
- Add explicit directory/admin summary DTOs and pagination/search inputs.
- Implement a user-directory service query that deduplicates users, scopes non-admin results to shared teams, filters their visible assignments to those teams, and returns all users/assignments plus email and role for admins.
- Add focused service tests covering cross-team isolation, email/secret redaction, inactive visibility, admin visibility, deduplication, search, and pagination.
- Keep response mapping explicit rather than serializing entities.
## Task 2: Backend admin mutations and authentication enforcement
- Add global-admin-only endpoints for profile changes, role changes, status changes, player search, assignment, reassignment, and unlinking.
- Use transactions/locking for last-admin protection and assignment changes; reject self-deactivation/self-demotion and loss of the last active admin.
- Ensure password/social login rejects inactive users and JWT validation reloads the current user, rejecting inactive/deleted users and returning the current database role.
- Extend audit event types and record admin actor, target, and action without secrets.
- Add a migration/index metadata for the player foreign keys used by directory queries.
- Add focused controller/service/auth tests for authorization, narrow DTO behavior, status/session enforcement, role safeguards, and assignment conflict behavior.
## Task 3: Modern frontend API, models, routing, and authorization state
- Add typed directory/admin API clients matching the backend contract and never call the generic user PATCH.
- Add typed view models for safe directory records, admin details, assignments, filters, and mutation requests.
- Add the protected `/users` route and a visible entry in the existing More screen for every authenticated role.
- Expose the current global role through existing auth state and treat it only as a presentation hint; backend authorization remains authoritative.
- Add focused tests for API URLs/payloads, route protection, role derivation, and navigation visibility.
## Task 4: Modern frontend user directory and admin interactions
- Implement a responsive user-directory page in the existing modern frontend design system, using its spacing, typography, colors, controls, and list patterns as the accepted visual reference.
- Provide search, pagination, status, and team/player assignment display with loading, empty, and error states.
- Hide email, role, unrelated-team information, and every mutation control from non-admins.
- For admins, add profile/role editing, activation/deactivation confirmation, player search, assignment, unlink confirmation, and explicit reassignment confirmation naming the current and target users.
- Do not optimistically update security-sensitive state; reload affected data after successful mutations and surface `403`/safeguard errors clearly.
- Add component tests for non-admin/admin rendering, confirmations, successful refresh, error behavior, search, and pagination.
## Task 5: Integration verification and documentation
- Run all focused backend tests and the backend build; document unrelated pre-existing full-suite failures separately.
- Run the complete modern frontend test suite and a production/container build that does not require external font inlining.
- Run the backend/frontend locally and verify the directory and primary admin workflow at desktop and mobile widths using the available browser tooling or Playwright fallback.
- Confirm the legacy frontend has no changes and review the complete branch diff for data leakage, authorization bypasses, concurrency errors, and visual regressions.

View File

@@ -0,0 +1,30 @@
import { GUARDS_METADATA } from '@nestjs/common/constants';
import { AuthController } from './auth.controller';
import { AuthRegisterLoginDto } from './dto/auth-register-login.dto';
describe('AuthController session enforcement', () => {
it('protects GET auth/me with JWT validation', () => {
const guards = Reflect.getMetadata(
GUARDS_METADATA,
AuthController.prototype.me,
);
expect(guards).toBeDefined();
expect(guards).toHaveLength(1);
});
it('does not expose self-deletion that can race with an admin promotion', () => {
expect(AuthController.prototype).not.toHaveProperty('delete');
});
it('uses the narrow validated registration DTO instead of an untyped body', () => {
const parameterTypes = Reflect.getMetadata(
'design:paramtypes',
AuthController.prototype,
'register',
);
expect(parameterTypes[0]).toBe(AuthRegisterLoginDto);
expect(AuthRegisterLoginDto.prototype).not.toHaveProperty('linkPlayerId');
});
});

View File

@@ -8,7 +8,6 @@ import {
Post,
UseGuards,
Patch,
Delete,
UseInterceptors,
ClassSerializerInterceptor,
SerializeOptions,
@@ -27,6 +26,7 @@ import {
ApiOkResponse,
} from '@nestjs/swagger';
import { CreateInviteDTO } from './dto/create-invite.dto';
import { AuthRegisterLoginDto } from './dto/auth-register-login.dto';
@ApiTags('Auth')
@Controller({
@@ -51,7 +51,7 @@ export class AuthController {
@Post('email/register')
@HttpCode(HttpStatus.CREATED)
async register(@Body() createUserDto: any) {
async register(@Body() createUserDto: AuthRegisterLoginDto) {
return this.service.register(createUserDto);
}
@@ -81,7 +81,7 @@ export class AuthController {
groups: ['exposeProvider'],
})
@Get('me')
// @UseGuards(AuthGuard('jwt'))
@UseGuards(AuthGuard('jwt'))
@HttpCode(HttpStatus.OK)
public me(@Request() request: Request) {
return this.service.me(request.headers['authorization']);
@@ -95,14 +95,6 @@ export class AuthController {
return this.service.update(request.user, userDto);
}
@ApiBearerAuth()
@Delete('me')
@UseGuards(AuthGuard('jwt'))
@HttpCode(HttpStatus.OK)
public async delete(@Request() request) {
return this.service.softDelete(request.user);
}
@ApiOperation({
summary: 'Erstellt Registrierungstoken',
description:

View File

@@ -0,0 +1,291 @@
import { ForbiddenException } from '@nestjs/common';
import { AuthProvidersEnum } from './auth-providers.enum';
import { AuthService } from './auth.service';
import { RoleEnum } from '../roles/roles.enum';
import { StatusEnum } from '../statuses/statuses.enum';
describe('AuthService inactive-user enforcement and safe logging', () => {
let jwtService: any;
let usersService: any;
let logger: any;
let dataSource: any;
let confirmationUser: any;
let lockedUserQuery: any;
let userRepository: any;
let service: AuthService;
let mailService: any;
beforeEach(() => {
jwtService = {
sign: jest.fn(() => 'signed-token'),
verify: jest.fn(),
decode: jest.fn(),
};
usersService = {
findOne: jest.fn(),
update: jest.fn(),
create: jest.fn(),
linkPlayerToUserId: jest.fn(),
};
logger = { info: jest.fn(), debug: jest.fn() };
mailService = { userSignUp: jest.fn() };
confirmationUser = user(StatusEnum.inactive);
confirmationUser.hash = 'confirmation-hash';
lockedUserQuery = {
where: jest.fn().mockReturnThis(),
setLock: jest.fn().mockReturnThis(),
getOne: jest.fn(() => confirmationUser),
};
userRepository = {
createQueryBuilder: jest.fn(() => lockedUserQuery),
save: jest.fn((value) => Promise.resolve(value)),
};
const manager = { getRepository: jest.fn(() => userRepository) };
dataSource = {
transaction: jest.fn((work) => work(manager)),
};
service = new AuthService(
jwtService,
usersService,
{} as any,
mailService,
logger,
dataSource,
);
});
it('rejects password login for an inactive user before issuing a token', async () => {
usersService.findOne.mockResolvedValue(user(StatusEnum.inactive));
await expect(
service.validateLogin({
email: 'inactive@example.com',
password: 'password',
}),
).rejects.toBeInstanceOf(ForbiddenException);
expect(jwtService.sign).not.toHaveBeenCalled();
expect(logger.info).toHaveBeenCalledWith({
event: 'user_login_fail',
details: 'userId=2 reason=inactive',
userId: 2,
});
});
it('rejects social login when the existing account is inactive', async () => {
const inactive = socialUser(
AuthProvidersEnum.google,
RoleEnum.user,
StatusEnum.inactive,
);
configureSocialQueries([inactive], inactive);
await expect(
service.validateSocialLogin(AuthProvidersEnum.google, {
id: 'social-id',
email: inactive.email,
firstName: 'Inactive',
lastName: 'User',
}),
).rejects.toBeInstanceOf(ForbiddenException);
expect(usersService.update).not.toHaveBeenCalled();
expect(jwtService.sign).not.toHaveBeenCalled();
});
it('reloads and rejects a concurrently deactivated social user instead of signing stale state', async () => {
const stale = socialUser(
AuthProvidersEnum.google,
RoleEnum.admin,
StatusEnum.active,
);
const current = socialUser(
AuthProvidersEnum.google,
RoleEnum.user,
StatusEnum.inactive,
);
configureSocialQueries([stale], current);
await expect(
service.validateSocialLogin(AuthProvidersEnum.google, {
id: stale.socialId,
email: 'updated@example.com',
}),
).rejects.toBeInstanceOf(ForbiddenException);
expect(dataSource.transaction).toHaveBeenCalledTimes(1);
expect(userRepository.save).not.toHaveBeenCalledWith(stale);
expect(usersService.update).not.toHaveBeenCalled();
expect(jwtService.sign).not.toHaveBeenCalled();
});
it.each([
AuthProvidersEnum.facebook,
AuthProvidersEnum.google,
AuthProvidersEnum.twitter,
AuthProvidersEnum.apple,
])(
'locks and reloads an existing %s user, narrowly updates email, and signs the current role',
async (provider) => {
const stale = socialUser(provider, RoleEnum.admin, StatusEnum.active);
const current = socialUser(provider, RoleEnum.user, StatusEnum.active);
configureSocialQueries([stale], current);
const result = await service.validateSocialLogin(provider, {
id: stale.socialId,
email: 'updated@example.com',
});
expect(userRepository.update).toHaveBeenCalledWith(stale.id, {
email: 'updated@example.com',
});
expect(userRepository.save).not.toHaveBeenCalledWith(stale);
expect(jwtService.sign).toHaveBeenCalledWith({
id: current.id,
role: current.role,
});
expect(result.user).toBe(current);
},
);
it('never includes an email in an unknown-user login audit event', async () => {
usersService.findOne.mockResolvedValue(undefined);
await expect(
service.validateLogin({
email: 'secret@example.com',
password: 'secret-password',
}),
).rejects.toBeDefined();
expect(logger.info).toHaveBeenCalledWith({
event: 'user_login_fail',
details: 'reason=user_not_found',
userId: -1,
});
expect(JSON.stringify(logger.info.mock.calls)).not.toContain(
'secret@example.com',
);
expect(JSON.stringify(logger.info.mock.calls)).not.toContain(
'secret-password',
);
});
it('never includes a rejected invite token in logging details', async () => {
jwtService.verify.mockImplementation(() => {
throw new Error('invalid');
});
await expect(
service.getTeamFromInvite('secret-token'),
).rejects.toBeDefined();
expect(logger.info).toHaveBeenCalledWith({
event: 'user_invite_link_validate_fail',
details: 'invitation validation failed',
userId: 0,
});
expect(JSON.stringify(logger.info.mock.calls)).not.toContain(
'secret-token',
);
});
it('does not refresh an inactive user through the me endpoint flow', async () => {
jwtService.verify.mockReturnValue({ id: 2 });
usersService.findOne.mockResolvedValue(user(StatusEnum.inactive));
await expect(service.me('Bearer existing-token')).rejects.toBeInstanceOf(
ForbiddenException,
);
expect(jwtService.sign).not.toHaveBeenCalled();
});
it('serializes email confirmation on the user row and consumes the hash', async () => {
await service.confirmEmail('confirmation-hash');
expect(dataSource.transaction).toHaveBeenCalledTimes(1);
expect(lockedUserQuery.setLock).toHaveBeenCalledWith(
'pessimistic_write',
undefined,
['user'],
);
expect(confirmationUser.status).toEqual({ id: StatusEnum.active });
expect(confirmationUser.hash).toBeNull();
expect(userRepository.save).toHaveBeenCalledWith(confirmationUser);
});
it('ignores a public registration player id and never mutates player ownership', async () => {
usersService.create.mockResolvedValue({
id: 8,
email: 'new@example.com',
});
await service.register({
email: 'new@example.com',
password: 'password',
firstName: 'New',
lastName: 'User',
linkPlayerId: 101,
} as any);
expect(usersService.create.mock.calls[0][0]).not.toHaveProperty(
'linkPlayerId',
);
expect(usersService.linkPlayerToUserId).not.toHaveBeenCalled();
});
function user(statusId: StatusEnum) {
return {
id: 2,
email: 'inactive@example.com',
password: 'password-hash',
provider: AuthProvidersEnum.email,
role: { id: RoleEnum.user, name: 'User' },
status: {
id: statusId,
name: statusId === StatusEnum.active ? 'Active' : 'Inactive',
},
};
}
function socialUser(
provider: AuthProvidersEnum,
roleId: RoleEnum,
statusId: StatusEnum,
) {
return {
...user(statusId),
email: 'old@example.com',
socialId: `${provider}-id`,
provider,
role: { id: roleId, name: roleId === RoleEnum.admin ? 'Admin' : 'User' },
hash: 'stale-hash',
};
}
function configureSocialQueries(candidates: any[], current: any) {
const candidateQuery = chain({ getMany: jest.fn(() => candidates) });
const reloadQuery = chain({ getOne: jest.fn(() => current) });
userRepository.createQueryBuilder = jest.fn((alias: string) =>
alias === 'socialCandidate' ? candidateQuery : reloadQuery,
);
userRepository.update = jest.fn();
}
function chain(overrides: Record<string, jest.Mock>) {
const query: Record<string, jest.Mock> = {};
[
'leftJoinAndSelect',
'where',
'orWhere',
'andWhere',
'setParameter',
'setParameters',
'setLock',
'orderBy',
].forEach((method) => {
query[method] = jest.fn(() => query);
});
return Object.assign(query, overrides);
}
});

View File

@@ -1,4 +1,10 @@
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import {
ForbiddenException,
HttpException,
HttpStatus,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { User } from '../users/entities/user.entity';
import * as bcrypt from 'bcryptjs';
@@ -19,6 +25,7 @@ import { ForgotService } from 'src/forgot/forgot.service';
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';
@Injectable()
export class AuthService {
@@ -28,6 +35,7 @@ export class AuthService {
private forgotService: ForgotService,
private mailService: MailService,
private logger: LoggingService,
private dataSource: DataSource,
) {}
async validateLogin(
@@ -40,7 +48,7 @@ export class AuthService {
if (!user) {
await this.logger.info({
event: 'user_login_fail',
details: `mail not found: ${loginDto.email}`,
details: 'reason=user_not_found',
userId: -1,
});
throw new HttpException(
@@ -54,6 +62,8 @@ export class AuthService {
);
}
await this.assertActiveUser(user);
if (user.provider !== AuthProvidersEnum.email) {
throw new HttpException(
{
@@ -79,7 +89,7 @@ export class AuthService {
await this.logger.info({
event: 'user_login_success',
details: `logged in: ${loginDto.email}`,
details: `userId=${user.id}`,
userId: user.id,
});
@@ -87,7 +97,7 @@ export class AuthService {
} else {
await this.logger.info({
event: 'user_login_fail',
details: `incorrect password for user: ${loginDto.email}`,
details: `userId=${user.id} reason=incorrect_password`,
userId: user.id,
});
@@ -107,57 +117,68 @@ export class AuthService {
authProvider: string,
socialData: SocialInterface,
): Promise<{ token: string; user: User }> {
let user: User;
const socialEmail = socialData.email?.toLowerCase();
const userByEmail = await this.usersService.findOne({
email: socialEmail,
return this.dataSource.transaction(async (manager) => {
const repository = manager.getRepository(User);
const candidateQuery = repository
.createQueryBuilder('socialCandidate')
.where(
'socialCandidate.socialId = :socialId AND socialCandidate.provider = :authProvider',
{ socialId: socialData.id, authProvider },
);
if (socialEmail) {
candidateQuery.orWhere('socialCandidate.email = :socialEmail', {
socialEmail,
});
user = await this.usersService.findOne({
socialId: socialData.id,
provider: authProvider,
});
if (user) {
if (socialEmail && !userByEmail) {
user.email = socialEmail;
}
await this.usersService.update(user.id, user);
} else if (userByEmail) {
user = userByEmail;
} else {
const role = plainToClass(Role, {
id: RoleEnum.user,
});
const status = plainToClass(Status, {
id: StatusEnum.active,
});
user = await this.usersService.create({
const candidates = await candidateQuery
.setLock('pessimistic_write', undefined, ['socialCandidate'])
.orderBy('socialCandidate.id', 'ASC')
.getMany();
const socialUser = candidates.find(
(candidate) =>
candidate.socialId === socialData.id &&
candidate.provider === authProvider,
);
const emailUser = socialEmail
? candidates.find((candidate) => candidate.email === socialEmail)
: undefined;
let user = socialUser ?? emailUser;
if (!user) {
user = await repository.save(
repository.create({
email: socialEmail,
firstName: socialData.firstName,
lastName: socialData.lastName,
socialId: socialData.id,
provider: authProvider,
role,
status,
});
user = await this.usersService.findOne({
id: user.id,
});
role: { id: RoleEnum.user } as Role,
status: { id: StatusEnum.active } as Status,
}),
);
} else if (
socialUser &&
socialEmail &&
!emailUser &&
socialUser.email !== socialEmail
) {
await repository.update(socialUser.id, { email: socialEmail });
}
const jwtToken = await this.jwtService.sign({
id: user.id,
role: user.role,
const currentUser = await repository
.createQueryBuilder('currentSocialUser')
.leftJoinAndSelect('currentSocialUser.role', 'role')
.leftJoinAndSelect('currentSocialUser.status', 'status')
.where('currentSocialUser.id = :userId', { userId: user.id })
.setLock('pessimistic_write', undefined, ['currentSocialUser'])
.getOne();
if (!currentUser) throw new UnauthorizedException();
await this.assertActiveUser(currentUser);
const token = await this.jwtService.sign({
id: currentUser.id,
role: currentUser.role,
});
return { token, user: currentUser };
});
return {
token: jwtToken,
user,
};
}
async register(dto: AuthRegisterLoginDto): Promise<void> {
@@ -167,8 +188,10 @@ export class AuthService {
.digest('hex');
const user = await this.usersService.create({
...dto,
email: dto.email,
password: dto.password,
firstName: dto.firstName,
lastName: dto.lastName,
role: {
id: RoleEnum.user,
} as Role,
@@ -178,13 +201,9 @@ export class AuthService {
hash,
});
if (user && dto.linkPlayerId != null) {
await this.usersService.linkPlayerToUserId(user, dto.linkPlayerId);
}
await this.logger.info({
event: 'user_create',
details: `user created with mail: ${dto.email}`,
details: `userId=${user.id}`,
userId: user.id,
});
@@ -198,10 +217,13 @@ export class AuthService {
}
async confirmEmail(hash: string): Promise<void> {
const user = await this.usersService.findOne({
hash,
});
await this.dataSource.transaction(async (manager) => {
const repository = manager.getRepository(User);
const user = await repository
.createQueryBuilder('user')
.where('user.hash = :hash', { hash })
.setLock('pessimistic_write', undefined, ['user'])
.getOne();
if (!user) {
throw new HttpException(
{
@@ -211,12 +233,10 @@ export class AuthService {
HttpStatus.NOT_FOUND,
);
}
user.hash = null;
user.status = plainToClass(Status, {
id: StatusEnum.active,
user.status = plainToClass(Status, { id: StatusEnum.active });
await repository.save(user);
});
await user.save();
}
async forgotPassword(email: string): Promise<void> {
@@ -281,41 +301,32 @@ export class AuthService {
async me(token: string): Promise<User> {
token = token.replace('Bearer ', '');
let role: any;
let payload: any;
let refreshToken = false;
try {
role = this.jwtService.verify(token);
const u = await this.usersService.findOne({
id: role.id,
});
await this.logger.debug({
event: 'user_token_verification_success',
details: `Email: ${u.email}`,
userId: u.id,
});
return u;
} catch (error) {
const role = this.jwtService.decode(token);
const user = await this.usersService.findOne({
id: (role as any).id,
});
payload = this.jwtService.verify(token);
} catch {
payload = this.jwtService.decode(token);
refreshToken = true;
}
if (!payload?.id) throw new UnauthorizedException();
const user = await this.usersService.findOne({ id: payload.id });
if (!user) throw new UnauthorizedException();
await this.assertActiveUser(user);
if (refreshToken) {
const t = await this.jwtService.sign({
id: user.id,
role: user.role,
});
user['token'] = t;
}
await this.logger.debug({
event: 'user_token_verification_success',
details: `Email: ${user.email}`,
details: `userId=${user.id}`,
userId: user.id,
});
return user;
}
}
async update(user: User, userDto: AuthUpdateDto): Promise<User> {
if (userDto.password) {
@@ -360,10 +371,6 @@ export class AuthService {
});
}
async softDelete(user: User): Promise<void> {
await this.usersService.softDelete(user.id);
}
async createTeamInvite(object: CreateInviteDTO) {
const token = await this.jwtService.sign(object, {
expiresIn: '30d',
@@ -394,7 +401,7 @@ export class AuthService {
} catch {
await this.logger.info({
event: 'user_invite_link_validate_fail',
details: `validation failed for token ${token}`,
details: 'invitation validation failed',
userId: 0,
});
@@ -404,4 +411,14 @@ export class AuthService {
);
}
}
private async assertActiveUser(user: User): Promise<void> {
if (user.status?.id === StatusEnum.active) return;
await this.logger.info({
event: 'user_login_fail',
details: `userId=${user.id} reason=inactive`,
userId: user.id,
});
throw new ForbiddenException('User account is inactive');
}
}

View File

@@ -23,7 +23,4 @@ export class AuthRegisterLoginDto {
@ApiProperty({ example: 'Doe' })
@IsNotEmpty()
lastName: string;
@ApiProperty({ example: 27 })
linkPlayerId: number | null;
}

View File

@@ -0,0 +1,67 @@
import { UnauthorizedException } from '@nestjs/common';
import { RoleEnum } from '../../roles/roles.enum';
import { StatusEnum } from '../../statuses/statuses.enum';
import { JwtStrategy } from './jwt.strategy';
describe('JwtStrategy', () => {
const jwtService = {} as any;
const configService = { get: jest.fn(() => 'secret') } as any;
let usersService: any;
let strategy: JwtStrategy;
beforeEach(() => {
usersService = { findOne: jest.fn() };
strategy = new JwtStrategy(jwtService, configService, usersService);
});
it('reloads the current database user and replaces a stale token role', async () => {
usersService.findOne.mockResolvedValue({
id: 2,
role: { id: RoleEnum.user, name: 'User' },
status: { id: StatusEnum.active, name: 'Active' },
password: 'must-not-be-exposed',
});
const result = await strategy.validate({
id: 2,
role: { id: RoleEnum.admin },
iat: 1,
exp: 2,
} as any);
expect(usersService.findOne).toHaveBeenCalledWith({ id: 2 });
expect(result).toEqual({
id: 2,
role: { id: RoleEnum.user, name: 'User' },
status: { id: StatusEnum.active, name: 'Active' },
});
expect(result).not.toHaveProperty('password');
});
it('rejects a missing or soft-deleted database user', async () => {
usersService.findOne.mockResolvedValue(undefined);
await expect(
strategy.validate({ id: 2, iat: 1, exp: 2 } as any),
).rejects.toBeInstanceOf(UnauthorizedException);
});
it('rejects an inactive current database user', async () => {
usersService.findOne.mockResolvedValue({
id: 2,
role: { id: RoleEnum.user },
status: { id: StatusEnum.inactive },
});
await expect(
strategy.validate({ id: 2, iat: 1, exp: 2 } as any),
).rejects.toBeInstanceOf(UnauthorizedException);
});
it('rejects a payload without an id without querying the database', async () => {
await expect(
strategy.validate({ iat: 1, exp: 2 } as any),
).rejects.toBeInstanceOf(UnauthorizedException);
expect(usersService.findOne).not.toHaveBeenCalled();
});
});

View File

@@ -4,6 +4,8 @@ import { JwtService } from '@nestjs/jwt';
import { PassportStrategy } from '@nestjs/passport';
import { User } from '../../users/entities/user.entity';
import { ConfigService } from '@nestjs/config';
import { UsersService } from '../../users/users.service';
import { StatusEnum } from '../../statuses/statuses.enum';
type JwtPayload = Pick<User, 'id' | 'role'> & { iat: number; exp: number };
@@ -12,6 +14,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(
private jwtService: JwtService,
private configService: ConfigService,
private usersService: UsersService,
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
@@ -19,10 +22,18 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
});
}
public validate(payload: JwtPayload) {
public async validate(payload: JwtPayload) {
if (!payload.id) {
throw new UnauthorizedException();
}
return payload;
const user = await this.usersService.findOne({ id: payload.id });
if (!user || user.status?.id !== StatusEnum.active) {
throw new UnauthorizedException();
}
return {
id: user.id,
role: user.role ?? null,
status: user.status ?? null,
};
}
}

View File

@@ -1,18 +1,25 @@
import { Test, TestingModule } from '@nestjs/testing';
import { LoggingService } from './logging.service';
describe('LoggingService', () => {
let service: LoggingService;
it('can persist an event through the caller transaction manager', async () => {
const defaultRepository = { save: jest.fn() };
const transactionRepository = { save: jest.fn() };
const manager = {
getRepository: jest.fn(() => transactionRepository),
} as any;
const service = new LoggingService(defaultRepository as any);
const event = {
event: 'admin_user_profile_update' as const,
details: 'targetUserId=2',
userId: 1,
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [LoggingService],
}).compile();
await service.info(event, manager);
service = module.get<LoggingService>(LoggingService);
expect(transactionRepository.save).toHaveBeenCalledWith({
...event,
level: 'INFO',
});
it('should be defined', () => {
expect(service).toBeDefined();
expect(defaultRepository.save).not.toHaveBeenCalled();
});
});

View File

@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { EntityManager, Repository } from 'typeorm';
import { CreateLogDTO } from './dto/create-log.dto';
import { LogEntry } from './entities/log-entry.entity';
import { LOGEVENT } from './model/logging-event.type';
@@ -23,7 +23,8 @@ export class LoggingService {
});
}
async info({
async info(
{
event,
details,
userId,
@@ -31,14 +32,16 @@ export class LoggingService {
event: LOGEVENT;
details: string;
userId: number;
}) {
},
manager?: EntityManager,
) {
const e: CreateLogDTO = {
event,
details,
userId,
level: 'INFO',
};
await this.repository.save(e);
await (manager?.getRepository(LogEntry) ?? this.repository).save(e);
}
async error({

View File

@@ -15,6 +15,11 @@ export type LOGEVENT =
| 'transaction_create_fail'
| 'transaction_reverse'
| 'player_creation'
| 'admin_user_profile_update'
| 'admin_user_role_update'
| 'admin_user_status_update'
| 'admin_player_assign'
| 'admin_player_unlink'
| 'team_create';
export type LOGLEVEL = 'FATAL' | 'ERROR' | 'WARN' | 'INFO' | 'DEBUG' | 'TRACE';

View File

@@ -0,0 +1,19 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddPlayerLookupIndexes1785517200000 implements MigrationInterface {
name = 'AddPlayerLookupIndexes1785517200000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
'CREATE INDEX "IDX_player_team_id" ON "player" ("teamId")',
);
await queryRunner.query(
'CREATE INDEX "IDX_player_user_id" ON "player" ("userId")',
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('DROP INDEX "IDX_player_user_id"');
await queryRunner.query('DROP INDEX "IDX_player_team_id"');
}
}

View File

@@ -0,0 +1,33 @@
import { getMetadataArgsStorage } from 'typeorm';
import { Player } from '../../players/entities/player.entity';
import { AddPlayerLookupIndexes1785517200000 } from './1785517200000-AddPlayerLookupIndexes';
describe('AddPlayerLookupIndexes1785517200000', () => {
it('adds reversible indexes for team and user foreign-key lookups', async () => {
const queryRunner = { query: jest.fn() } as any;
const migration = new AddPlayerLookupIndexes1785517200000();
await migration.up(queryRunner);
expect(queryRunner.query.mock.calls.map(([sql]) => sql)).toEqual([
'CREATE INDEX "IDX_player_team_id" ON "player" ("teamId")',
'CREATE INDEX "IDX_player_user_id" ON "player" ("userId")',
]);
queryRunner.query.mockClear();
await migration.down(queryRunner);
expect(queryRunner.query.mock.calls.map(([sql]) => sql)).toEqual([
'DROP INDEX "IDX_player_user_id"',
'DROP INDEX "IDX_player_team_id"',
]);
});
it('keeps entity index metadata aligned with the migration', () => {
const playerIndexes = getMetadataArgsStorage()
.indices.filter((index) => index.target === Player)
.map((index) => index.name);
expect(playerIndexes).toEqual(
expect.arrayContaining(['IDX_player_team_id', 'IDX_player_user_id']),
);
});
});

View File

@@ -3,9 +3,11 @@ import {
BeforeInsert,
Column,
Entity,
Index,
ManyToOne,
OneToMany,
PrimaryGeneratedColumn,
RelationId,
} from 'typeorm';
import { EntityHelper } from 'src/utils/entity-helper';
import { TeamRole } from 'src/team-roles/entities/team-roles.entity';
@@ -29,6 +31,7 @@ export class Player extends EntityHelper {
})
teamRole?: TeamRole | null;
@Index('IDX_player_team_id')
@ManyToOne(() => Team, {
eager: true,
})
@@ -37,11 +40,15 @@ export class Player extends EntityHelper {
@Column({ type: 'decimal', precision: 10, scale: 2, default: 0 })
balance: number;
@Index('IDX_player_user_id')
@ManyToOne(() => User, (user) => user.players, {
eager: true,
})
user?: User | null;
@RelationId((player: Player) => player.user)
userId?: number | null;
@OneToMany(() => Transaction, (transaction) => transaction.player)
transactions: Transaction[];

View File

@@ -0,0 +1,145 @@
import { GUARDS_METADATA, PATH_METADATA } from '@nestjs/common/constants';
import { validate } from 'class-validator';
import { plainToInstance } from 'class-transformer';
import { RoleEnum } from '../roles/roles.enum';
import { RolesGuard } from '../roles/roles.guard';
import { AdminUsersController } from './admin-users.controller';
import {
AdminPlayerQueryDto,
AdminUserProfileDto,
AdminUserRoleDto,
AdminUserStatusDto,
} from './dto/admin-user.dto';
describe('AdminUsersController', () => {
const service = {
updateProfile: jest.fn(),
updateRole: jest.fn(),
updateStatus: jest.fn(),
findPlayers: jest.fn(),
assignPlayer: jest.fn(),
unlinkPlayer: jest.fn(),
};
const controller = new AdminUsersController(service as any);
const actor = { user: { id: 7, role: { id: RoleEnum.admin } } };
beforeEach(() => jest.clearAllMocks());
it('uses a separate versioned admin/users controller guarded by the global admin role', () => {
expect(Reflect.getMetadata(PATH_METADATA, AdminUsersController)).toBe(
'admin/users',
);
expect(Reflect.getMetadata('roles', AdminUsersController)).toEqual([
RoleEnum.admin,
]);
expect(
Reflect.getMetadata(GUARDS_METADATA, AdminUsersController),
).toContain(RolesGuard);
});
it('passes the authenticated actor and target to profile, role, and status mutations', async () => {
await controller.updateProfile(actor as any, 2, {
firstName: 'New',
lastName: 'Name',
});
await controller.updateRole(actor as any, 2, { role: RoleEnum.user });
await controller.updateStatus(actor as any, 2, {
status: 2,
});
expect(service.updateProfile).toHaveBeenCalledWith(7, 2, {
firstName: 'New',
lastName: 'Name',
});
expect(service.updateRole).toHaveBeenCalledWith(7, 2, RoleEnum.user);
expect(service.updateStatus).toHaveBeenCalledWith(7, 2, 2);
});
it('passes player search and atomic assignment operations to the service', async () => {
const query = { assignment: 'unassigned' as const, page: 2, limit: 10 };
await controller.findPlayers(query);
await controller.assignPlayer(actor as any, 3, 11);
await controller.unlinkPlayer(actor as any, 3, 11);
expect(service.findPlayers).toHaveBeenCalledWith(query);
expect(service.assignPlayer).toHaveBeenCalledWith(7, 3, 11);
expect(service.unlinkPlayer).toHaveBeenCalledWith(7, 3, 11);
});
});
describe('admin user DTOs', () => {
it('accepts omitted and string profile names but rejects explicit nulls', async () => {
const omitted = plainToInstance(AdminUserProfileDto, {});
const names = plainToInstance(AdminUserProfileDto, {
firstName: 'Ada',
lastName: 'Admin',
});
const nulls = plainToInstance(AdminUserProfileDto, {
firstName: null,
lastName: null,
});
expect(await validate(omitted)).toEqual([]);
expect(await validate(names)).toEqual([]);
expect(await validate(nulls)).not.toEqual([]);
});
it('accepts only profile names in the profile DTO', async () => {
const dto = plainToInstance(AdminUserProfileDto, {
firstName: 'Ada',
lastName: 'Admin',
email: 'must-not-pass@example.com',
role: RoleEnum.user,
status: 2,
});
expect(await validate(dto, { whitelist: true })).toEqual([]);
expect(dto).toEqual({ firstName: 'Ada', lastName: 'Admin' });
});
it('rejects role and status ids outside their enums', async () => {
const role = plainToInstance(AdminUserRoleDto, { role: 999 });
const status = plainToInstance(AdminUserStatusDto, { status: 999 });
expect(await validate(role)).not.toEqual([]);
expect(await validate(status)).not.toEqual([]);
});
it('rejects numeric-enum reverse-map names instead of sending strings to integer foreign keys', async () => {
const role = plainToInstance(AdminUserRoleDto, { role: 'admin' });
const status = plainToInstance(AdminUserStatusDto, { status: 'inactive' });
expect(await validate(role)).not.toEqual([]);
expect(await validate(status)).not.toEqual([]);
});
it.each([true, '1'])(
'rejects non-number role and status bodies: %p',
async (value) => {
const role = plainToInstance(AdminUserRoleDto, { role: value });
const status = plainToInstance(AdminUserStatusDto, { status: value });
expect(await validate(role)).not.toEqual([]);
expect(await validate(status)).not.toEqual([]);
},
);
it('validates player assignment filters and pagination bounds', async () => {
const valid = plainToInstance(AdminPlayerQueryDto, {
assignment: 'assigned',
teamId: '4',
page: '2',
limit: '50',
});
const invalid = plainToInstance(AdminPlayerQueryDto, {
assignment: 'someone',
page: 0,
limit: 51,
});
expect(await validate(valid)).toEqual([]);
expect(valid).toMatchObject({ teamId: 4, page: 2, limit: 50 });
expect(await validate(invalid)).not.toEqual([]);
});
});

View File

@@ -0,0 +1,102 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseIntPipe,
Patch,
Put,
Query,
Request,
UseGuards,
} from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { AuthGuard } from '@nestjs/passport';
import { Roles } from '../roles/roles.decorator';
import { RoleEnum } from '../roles/roles.enum';
import { RolesGuard } from '../roles/roles.guard';
import { AdminUsersService } from './admin-users.service';
import {
AdminPlayerQueryDto,
AdminUserProfileDto,
AdminUserRoleDto,
AdminUserStatusDto,
} from './dto/admin-user.dto';
type AdminRequest = { user: { id: number; role: { id: RoleEnum } } };
@ApiBearerAuth()
@ApiTags('Admin users')
@UseGuards(AuthGuard('jwt'), RolesGuard)
@Roles([RoleEnum.admin])
@Controller({ path: 'admin/users', version: '1' })
export class AdminUsersController {
constructor(private readonly adminUsersService: AdminUsersService) {}
@Patch(':id/profile')
@HttpCode(HttpStatus.OK)
updateProfile(
@Request() request: AdminRequest,
@Param('id', ParseIntPipe) id: number,
@Body() dto: AdminUserProfileDto,
) {
return this.adminUsersService.updateProfile(request.user.id, id, dto);
}
@Patch(':id/role')
@HttpCode(HttpStatus.OK)
updateRole(
@Request() request: AdminRequest,
@Param('id', ParseIntPipe) id: number,
@Body() dto: AdminUserRoleDto,
) {
return this.adminUsersService.updateRole(request.user.id, id, dto.role);
}
@Patch(':id/status')
@HttpCode(HttpStatus.OK)
updateStatus(
@Request() request: AdminRequest,
@Param('id', ParseIntPipe) id: number,
@Body() dto: AdminUserStatusDto,
) {
return this.adminUsersService.updateStatus(request.user.id, id, dto.status);
}
@Get('players')
@HttpCode(HttpStatus.OK)
findPlayers(@Query() query: AdminPlayerQueryDto) {
return this.adminUsersService.findPlayers(query);
}
@Put(':userId/players/:playerId')
@HttpCode(HttpStatus.OK)
assignPlayer(
@Request() request: AdminRequest,
@Param('userId', ParseIntPipe) userId: number,
@Param('playerId', ParseIntPipe) playerId: number,
) {
return this.adminUsersService.assignPlayer(
request.user.id,
userId,
playerId,
);
}
@Delete(':userId/players/:playerId')
@HttpCode(HttpStatus.OK)
unlinkPlayer(
@Request() request: AdminRequest,
@Param('userId', ParseIntPipe) userId: number,
@Param('playerId', ParseIntPipe) playerId: number,
) {
return this.adminUsersService.unlinkPlayer(
request.user.id,
userId,
playerId,
);
}
}

View File

@@ -0,0 +1,173 @@
import {
INestApplication,
UnauthorizedException,
ValidationPipe,
VersioningType,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { Test } from '@nestjs/testing';
import * as request from 'supertest';
import { useContainer } from 'class-validator';
import { DataSource } from 'typeorm';
import { AuthController } from '../auth/auth.controller';
import { AuthService } from '../auth/auth.service';
import { RoleEnum } from '../roles/roles.enum';
import { RolesGuard } from '../roles/roles.guard';
import validationOptions from '../utils/validation-options';
import { IsNotExist } from '../utils/validators/is-not-exists.validator';
import { AdminUsersController } from './admin-users.controller';
import { AdminUsersService } from './admin-users.service';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
describe('admin user HTTP security boundary', () => {
let app: INestApplication;
const safeUser = {
id: 2,
firstName: 'Target',
lastName: 'User',
email: 'target@example.com',
role: { id: RoleEnum.user, name: 'User' },
status: { id: 1, name: 'Active' },
assignments: [],
};
const adminService = {
updateProfile: jest.fn(() => safeUser),
updateRole: jest.fn(() => safeUser),
updateStatus: jest.fn(() => safeUser),
findPlayers: jest.fn(),
assignPlayer: jest.fn(),
unlinkPlayer: jest.fn(),
};
const usersService = {
findDirectory: jest.fn(),
findMyTeams: jest.fn(() => [
{
id: 11,
firstName: 'Pat',
lastName: 'Player',
team: { id: 4, name: 'Alpha' },
},
]),
};
const authService = { register: jest.fn() };
beforeAll(async () => {
const module = await Test.createTestingModule({
controllers: [AdminUsersController, UsersController, AuthController],
providers: [
Reflector,
RolesGuard,
IsNotExist,
{
provide: DataSource,
useValue: {
getRepository: () => ({ findOne: jest.fn(() => undefined) }),
},
},
{ provide: AdminUsersService, useValue: adminService },
{ provide: UsersService, useValue: usersService },
{ provide: AuthService, useValue: authService },
],
})
.overrideGuard(AuthGuard('jwt'))
.useValue({
canActivate(context) {
const httpRequest = context.switchToHttp().getRequest();
const token = httpRequest.headers.authorization;
if (token === 'Bearer admin') {
httpRequest.user = { id: 1, role: { id: RoleEnum.admin } };
return true;
}
if (token === 'Bearer user') {
httpRequest.user = { id: 7, role: { id: RoleEnum.user } };
return true;
}
throw new UnauthorizedException();
},
})
.compile();
app = module.createNestApplication();
useContainer(app, { fallbackOnErrors: true });
app.setGlobalPrefix('api');
app.enableVersioning({ type: VersioningType.URI });
app.useGlobalPipes(new ValidationPipe(validationOptions));
await app.init();
});
afterAll(() => app.close());
beforeEach(() => jest.clearAllMocks());
it('enforces JWT and current global admin role on an admin mutation', async () => {
await request(app.getHttpServer())
.patch('/api/v1/admin/users/2/role')
.send({ role: RoleEnum.user })
.expect(401);
await request(app.getHttpServer())
.patch('/api/v1/admin/users/2/role')
.set('Authorization', 'Bearer user')
.send({ role: RoleEnum.user })
.expect(403);
await request(app.getHttpServer())
.patch('/api/v1/admin/users/2/role')
.set('Authorization', 'Bearer admin')
.send({ role: RoleEnum.user })
.expect(200, safeUser);
});
it.each([true, '1'])(
'rejects non-numeric role JSON at the HTTP validation boundary: %p',
async (role) => {
await request(app.getHttpServer())
.patch('/api/v1/admin/users/2/role')
.set('Authorization', 'Bearer admin')
.send({ role })
.expect(422);
expect(adminService.updateRole).not.toHaveBeenCalled();
},
);
it('strips public player assignment input from registration', async () => {
await request(app.getHttpServer())
.post('/api/v1/auth/email/register')
.send({
email: 'new@example.com',
password: 'password',
firstName: 'New',
lastName: 'User',
linkPlayerId: 101,
})
.expect(201);
expect(authService.register).toHaveBeenCalledWith({
email: 'new@example.com',
password: 'password',
firstName: 'New',
lastName: 'User',
});
});
it('restricts team bootstrap to self and serializes only the safe projection', async () => {
await request(app.getHttpServer())
.get('/api/v1/users/8/teams')
.set('Authorization', 'Bearer user')
.expect(403);
const response = await request(app.getHttpServer())
.get('/api/v1/users/7/teams')
.set('Authorization', 'Bearer user')
.expect(200);
expect(response.body).toEqual([
{
id: 11,
firstName: 'Pat',
lastName: 'Player',
team: { id: 4, name: 'Alpha' },
},
]);
expect(JSON.stringify(response.body)).not.toMatch(
/provider|socialId|password|hash|balance|createdAt|updatedAt/i,
);
});
});

View File

@@ -0,0 +1,330 @@
import { ConflictException, ForbiddenException } from '@nestjs/common';
import { RoleEnum } from '../roles/roles.enum';
import { StatusEnum } from '../statuses/statuses.enum';
import { AdminUsersService } from './admin-users.service';
describe('AdminUsersService', () => {
const team = { id: 10, name: 'Alpha', alias: 'alpha' };
const teamRole = { id: 1, name: 'Player' };
const actorId = 1;
let target: any;
let activeAdmins: any[];
let player: any;
let playerRows: any[];
let playerTotal: number;
let lockQuery: any;
let lockedUserQuery: any;
let playerQuery: any;
let lockedPlayerQuery: any;
let userRepository: any;
let playerRepository: any;
let manager: any;
let dataSource: any;
let logger: any;
let service: AdminUsersService;
beforeEach(() => {
target = user(2, RoleEnum.user, StatusEnum.active);
activeAdmins = [user(actorId, RoleEnum.admin, StatusEnum.active)];
player = assignment(101, null);
playerRows = [];
playerTotal = 0;
lockQuery = chain({ getMany: jest.fn(() => activeAdmins) });
lockedUserQuery = chain({ getOne: jest.fn(() => target) });
playerQuery = chain({
getRawMany: jest.fn(() => playerRows),
getCount: jest.fn(() => playerTotal),
});
lockedPlayerQuery = chain({ getOne: jest.fn(() => player) });
userRepository = {
createQueryBuilder: jest.fn((alias: string) =>
alias === 'lockedUser' ? lockedUserQuery : lockQuery,
),
findOne: jest.fn(() => target),
save: jest.fn((value) => Promise.resolve(value)),
};
playerRepository = {
createQueryBuilder: jest.fn((alias: string) =>
alias === 'lockedPlayer' ? lockedPlayerQuery : playerQuery,
),
findOne: jest.fn(() => player),
save: jest.fn((value) => Promise.resolve(value)),
};
manager = {
getRepository: jest.fn((entity) =>
entity.name === 'User' ? userRepository : playerRepository,
),
};
dataSource = {
manager,
transaction: jest.fn((work) => work(manager)),
};
logger = { info: jest.fn() };
service = new AdminUsersService(dataSource, logger);
});
it('updates only names and returns an explicit Task 1-compatible safe admin summary', async () => {
target.players = [assignment(101, target)];
const result = await service.updateProfile(actorId, target.id, {
firstName: 'New',
lastName: 'Name',
});
expect(userRepository.save).toHaveBeenCalledWith(
expect.objectContaining({ firstName: 'New', lastName: 'Name' }),
);
expect(result).toEqual({
id: 2,
firstName: 'New',
lastName: 'Name',
email: 'user-2@example.com',
role: { id: RoleEnum.user, name: 'User' },
status: { id: StatusEnum.active, name: 'Active' },
assignments: [playerSummary(101)],
});
expect(result).not.toHaveProperty('password');
expect(result).not.toHaveProperty('hash');
expect(result).not.toHaveProperty('socialId');
expect(logger.info).toHaveBeenCalledWith(
{
event: 'admin_user_profile_update',
details: 'targetUserId=2',
userId: actorId,
},
manager,
);
expect(lockedUserQuery.setLock).toHaveBeenCalledWith(
'pessimistic_write',
undefined,
['lockedUser'],
);
expect(lockedUserQuery.leftJoinAndSelect).toHaveBeenCalledTimes(2);
});
it('rejects self-demotion and self-deactivation before saving', async () => {
target = user(actorId, RoleEnum.admin, StatusEnum.active);
await expect(
service.updateRole(actorId, actorId, RoleEnum.user),
).rejects.toBeInstanceOf(ForbiddenException);
await expect(
service.updateStatus(actorId, actorId, StatusEnum.inactive),
).rejects.toBeInstanceOf(ForbiddenException);
expect(userRepository.save).not.toHaveBeenCalled();
});
it('locks the active-admin set and rejects loss of the last active admin', async () => {
target = user(2, RoleEnum.admin, StatusEnum.active);
activeAdmins = [target];
await expect(
service.updateRole(actorId, target.id, RoleEnum.user),
).rejects.toBeInstanceOf(ConflictException);
expect(lockQuery.setLock).toHaveBeenCalledWith(
'pessimistic_write',
undefined,
['user'],
);
expect(dataSource.transaction).toHaveBeenCalledTimes(1);
expect(userRepository.save).not.toHaveBeenCalled();
});
it('allows a locked role change when another active admin remains', async () => {
target = user(2, RoleEnum.admin, StatusEnum.active);
activeAdmins = [target, user(3, RoleEnum.admin, StatusEnum.active)];
const result = await service.updateRole(actorId, target.id, RoleEnum.user);
expect(target.role).toEqual({ id: RoleEnum.user });
expect(result.role).toEqual({ id: RoleEnum.user, name: undefined });
expect(logger.info).toHaveBeenCalledWith(
{
event: 'admin_user_role_update',
details: 'targetUserId=2 roleId=2',
userId: actorId,
},
manager,
);
});
it('deactivates under the same lock without changing player assignments', async () => {
target = user(2, RoleEnum.user, StatusEnum.active);
target.players = [assignment(101, target)];
const result = await service.updateStatus(
actorId,
target.id,
StatusEnum.inactive,
);
expect(target.status).toEqual({ id: StatusEnum.inactive });
expect(target.hash).toBeNull();
expect(result.assignments).toEqual([playerSummary(101)]);
expect(playerRepository.save).not.toHaveBeenCalled();
});
it('returns an explicit paginated player projection with safe current-user summaries', async () => {
playerRows = [
{
player_id: 101,
first_name: 'Pat',
last_name: 'Player',
active: 1,
team_id: 10,
team_name: 'Alpha',
team_alias: 'alpha',
user_id: 2,
user_first_name: 'Target',
user_last_name: 'User',
status_id: StatusEnum.active,
status_name: 'Active',
},
];
playerTotal = 1;
const result = await service.findPlayers({
search: 'pat',
teamId: 10,
assignment: 'assigned',
page: 1,
limit: 20,
});
expect(result).toEqual({
data: [
{
id: 101,
firstName: 'Pat',
lastName: 'Player',
active: true,
team,
currentUser: {
id: 2,
firstName: 'Target',
lastName: 'User',
status: { id: StatusEnum.active, name: 'Active' },
},
},
],
page: 1,
limit: 20,
total: 1,
hasNextPage: false,
});
expect(playerQuery.andWhere).toHaveBeenCalledWith(
'player.teamId = :teamId',
{
teamId: 10,
},
);
expect(playerQuery.andWhere).toHaveBeenCalledWith(
'player.userId IS NOT NULL',
);
});
it('atomically reassigns a locked player and returns the refreshed target summary', async () => {
const previous = user(9, RoleEnum.user, StatusEnum.active);
player = assignment(101, previous);
target.players = [player];
const result = await service.assignPlayer(actorId, target.id, player.id);
expect(dataSource.transaction).toHaveBeenCalledTimes(1);
expect(lockedPlayerQuery.setLock).toHaveBeenCalledWith('pessimistic_write');
expect(player.user).toBe(target);
expect(playerRepository.save).toHaveBeenCalledWith(player);
expect(result.id).toBe(target.id);
expect(logger.info).toHaveBeenCalledWith(
{
event: 'admin_player_assign',
details: 'targetUserId=2 playerId=101 previousUserId=9',
userId: actorId,
},
manager,
);
});
it('unlinks only when the locked player is currently linked to the target user', async () => {
player = assignment(101, user(9, RoleEnum.user, StatusEnum.active));
await expect(
service.unlinkPlayer(actorId, target.id, player.id),
).rejects.toBeInstanceOf(ConflictException);
expect(playerRepository.save).not.toHaveBeenCalled();
player.user = target;
player.userId = target.id;
await service.unlinkPlayer(actorId, target.id, player.id);
expect(player.user).toBeNull();
expect(playerRepository.save).toHaveBeenCalledWith(player);
});
function user(id: number, roleId: RoleEnum, statusId: StatusEnum) {
return {
id,
firstName: id === 2 ? 'Target' : 'Admin',
lastName: id === 2 ? 'User' : String(id),
email: `user-${id}@example.com`,
password: `password-${id}`,
hash: `hash-${id}`,
socialId: `social-${id}`,
role: {
id: roleId,
name: roleId === RoleEnum.admin ? 'Admin' : 'User',
},
status: {
id: statusId,
name: statusId === StatusEnum.active ? 'Active' : 'Inactive',
},
players: [],
};
}
function assignment(id: number, assignedUser: any) {
return {
id,
firstName: 'Pat',
lastName: 'Player',
active: true,
team,
teamRole,
user: assignedUser,
userId: assignedUser?.id ?? null,
};
}
function playerSummary(id: number) {
return {
id,
firstName: 'Pat',
lastName: 'Player',
active: true,
team,
teamRole,
};
}
function chain(overrides: Record<string, jest.Mock>) {
const query: Record<string, jest.Mock> = {};
[
'innerJoin',
'innerJoinAndSelect',
'leftJoinAndSelect',
'leftJoin',
'select',
'where',
'andWhere',
'setParameter',
'setParameters',
'setLock',
'orderBy',
'offset',
'limit',
].forEach((method) => {
query[method] = jest.fn(() => query);
});
return Object.assign(query, overrides);
}
});

View File

@@ -0,0 +1,381 @@
import {
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Brackets, DataSource, EntityManager, Repository } from 'typeorm';
import { LoggingService } from '../database/logging/logging.service';
import { Player } from '../players/entities/player.entity';
import { Role } from '../roles/entities/role.entity';
import { RoleEnum } from '../roles/roles.enum';
import { Status } from '../statuses/entities/status.entity';
import { StatusEnum } from '../statuses/statuses.enum';
import {
AdminPlayerPageDto,
AdminPlayerSummaryDto,
} from './dto/admin-player-response.dto';
import { AdminPlayerQueryDto, AdminUserProfileDto } from './dto/admin-user.dto';
import { AdminUserDirectorySummaryDto } from './dto/user-directory-response.dto';
import { User } from './entities/user.entity';
type PlayerSearchRow = {
player_id: number | string;
first_name: string;
last_name: string;
active: boolean;
team_id: number | string;
team_name: string;
team_alias: string;
user_id: number | string | null;
user_first_name: string | null;
user_last_name: string | null;
status_id: number | string | null;
status_name: string | null;
};
@Injectable()
export class AdminUsersService {
constructor(
private readonly dataSource: DataSource,
private readonly logger: LoggingService,
) {}
async updateProfile(
actorId: number,
targetUserId: number,
dto: AdminUserProfileDto,
): Promise<AdminUserDirectorySummaryDto> {
const summary = await this.dataSource.transaction(async (manager) => {
const repository = manager.getRepository(User);
const target = await this.findLockedUser(repository, targetUserId);
if (dto.firstName !== undefined) target.firstName = dto.firstName;
if (dto.lastName !== undefined) target.lastName = dto.lastName;
await repository.save(target);
const summary = await this.findAdminSummary(manager, targetUserId);
await this.log(
manager,
'admin_user_profile_update',
actorId,
`targetUserId=${targetUserId}`,
);
return summary;
});
return summary;
}
async updateRole(
actorId: number,
targetUserId: number,
roleId: RoleEnum,
): Promise<AdminUserDirectorySummaryDto> {
const summary = await this.dataSource.transaction(async (manager) => {
const activeAdmins = await this.lockActiveAdmins(manager);
const repository = manager.getRepository(User);
const target = await this.findLockedUser(repository, targetUserId);
const isDemotion =
target.role?.id === RoleEnum.admin && roleId !== RoleEnum.admin;
if (actorId === targetUserId && isDemotion) {
throw new ForbiddenException('Administrators cannot demote themselves');
}
if (
isDemotion &&
target.status?.id === StatusEnum.active &&
activeAdmins.length <= 1
) {
throw new ConflictException('At least one active admin must remain');
}
target.role = { id: roleId } as Role;
await repository.save(target);
const summary = await this.findAdminSummary(manager, targetUserId);
await this.log(
manager,
'admin_user_role_update',
actorId,
`targetUserId=${targetUserId} roleId=${roleId}`,
);
return summary;
});
return summary;
}
async updateStatus(
actorId: number,
targetUserId: number,
statusId: StatusEnum,
): Promise<AdminUserDirectorySummaryDto> {
const summary = await this.dataSource.transaction(async (manager) => {
const activeAdmins = await this.lockActiveAdmins(manager);
const repository = manager.getRepository(User);
const target = await this.findLockedUser(repository, targetUserId);
const isDeactivation =
target.status?.id === StatusEnum.active &&
statusId === StatusEnum.inactive;
if (actorId === targetUserId && isDeactivation) {
throw new ForbiddenException(
'Administrators cannot deactivate themselves',
);
}
if (
isDeactivation &&
target.role?.id === RoleEnum.admin &&
activeAdmins.length <= 1
) {
throw new ConflictException('At least one active admin must remain');
}
target.status = { id: statusId } as Status;
if (statusId === StatusEnum.inactive) target.hash = null;
await repository.save(target);
const summary = await this.findAdminSummary(manager, targetUserId);
await this.log(
manager,
'admin_user_status_update',
actorId,
`targetUserId=${targetUserId} statusId=${statusId}`,
);
return summary;
});
return summary;
}
async findPlayers(query: AdminPlayerQueryDto): Promise<AdminPlayerPageDto> {
const page = query.page ?? 1;
const limit = query.limit ?? 20;
const repository = this.dataSource.manager.getRepository(Player);
const builder = repository
.createQueryBuilder('player')
.innerJoin('player.team', 'team')
.leftJoin('player.user', 'currentUser')
.leftJoin('currentUser.status', 'currentStatus');
const term = query.search?.trim().toLocaleLowerCase();
if (term) {
builder
.andWhere(
new Brackets((where) =>
where
.where('LOWER(player.firstName) LIKE :playerSearch')
.orWhere('LOWER(player.lastName) LIKE :playerSearch')
.orWhere('LOWER(team.name) LIKE :playerSearch'),
),
)
.setParameter('playerSearch', `%${term}%`);
}
if (query.teamId !== undefined) {
builder.andWhere('player.teamId = :teamId', { teamId: query.teamId });
}
if (query.assignment === 'assigned') {
builder.andWhere('player.userId IS NOT NULL');
} else if (query.assignment === 'unassigned') {
builder.andWhere('player.userId IS NULL');
}
const total = await builder.getCount();
const rows = await builder
.select([
'player.id AS player_id',
'player.firstName AS first_name',
'player.lastName AS last_name',
'player.active AS active',
'team.id AS team_id',
'team.name AS team_name',
'team.alias AS team_alias',
'currentUser.id AS user_id',
'currentUser.firstName AS user_first_name',
'currentUser.lastName AS user_last_name',
'currentStatus.id AS status_id',
'currentStatus.name AS status_name',
])
.orderBy('player.id', 'ASC')
.offset((page - 1) * limit)
.limit(limit)
.getRawMany<PlayerSearchRow>();
return {
data: rows.map((row) => this.mapPlayerSearchRow(row)),
page,
limit,
total,
hasNextPage: page * limit < total,
};
}
async assignPlayer(
actorId: number,
targetUserId: number,
playerId: number,
): Promise<AdminUserDirectorySummaryDto> {
let previousUserId: number | null = null;
const summary = await this.dataSource.transaction(async (manager) => {
const userRepository = manager.getRepository(User);
const playerRepository = manager.getRepository(Player);
const target = await userRepository.findOne({
where: { id: targetUserId },
});
if (!target) throw new NotFoundException('User not found');
const lockedPlayer = await this.findLockedPlayer(
playerRepository,
playerId,
);
previousUserId = lockedPlayer.userId ?? null;
lockedPlayer.user = target;
await playerRepository.save(lockedPlayer);
const summary = await this.findAdminSummary(manager, targetUserId);
await this.log(
manager,
'admin_player_assign',
actorId,
`targetUserId=${targetUserId} playerId=${playerId} previousUserId=${
previousUserId ?? 'none'
}`,
);
return summary;
});
return summary;
}
async unlinkPlayer(
actorId: number,
targetUserId: number,
playerId: number,
): Promise<AdminUserDirectorySummaryDto> {
const summary = await this.dataSource.transaction(async (manager) => {
const playerRepository = manager.getRepository(Player);
const lockedPlayer = await this.findLockedPlayer(
playerRepository,
playerId,
);
if (lockedPlayer.userId !== targetUserId) {
throw new ConflictException('Player is not assigned to this user');
}
lockedPlayer.user = null;
await playerRepository.save(lockedPlayer);
const summary = await this.findAdminSummary(manager, targetUserId);
await this.log(
manager,
'admin_player_unlink',
actorId,
`targetUserId=${targetUserId} playerId=${playerId}`,
);
return summary;
});
return summary;
}
private lockActiveAdmins(manager: EntityManager): Promise<User[]> {
return manager
.getRepository(User)
.createQueryBuilder('user')
.innerJoinAndSelect('user.role', 'role')
.innerJoinAndSelect('user.status', 'status')
.where('role.id = :adminRole', { adminRole: RoleEnum.admin })
.andWhere('status.id = :activeStatus', {
activeStatus: StatusEnum.active,
})
.setLock('pessimistic_write', undefined, ['user'])
.orderBy('user.id', 'ASC')
.getMany();
}
private async findLockedUser(
repository: Repository<User>,
userId: number,
): Promise<User> {
const user = await repository
.createQueryBuilder('lockedUser')
.leftJoinAndSelect('lockedUser.role', 'role')
.leftJoinAndSelect('lockedUser.status', 'status')
.where('lockedUser.id = :userId', { userId })
.setLock('pessimistic_write', undefined, ['lockedUser'])
.getOne();
if (!user) throw new NotFoundException('User not found');
return user;
}
private async findLockedPlayer(
repository: Repository<Player>,
playerId: number,
): Promise<Player> {
const player = await repository
.createQueryBuilder('lockedPlayer')
.where('lockedPlayer.id = :playerId', { playerId })
.setLock('pessimistic_write')
.getOne();
if (!player) throw new NotFoundException('Player not found');
return player;
}
private async findAdminSummary(
manager: EntityManager,
userId: number,
): Promise<AdminUserDirectorySummaryDto> {
const user = await manager.getRepository(User).findOne({
where: { id: userId },
relations: {
role: true,
status: true,
players: { team: true, teamRole: true },
},
});
if (!user) throw new NotFoundException('User not found');
return {
id: user.id,
firstName: user.firstName,
lastName: user.lastName,
email: user.email,
role: user.role ? { id: user.role.id, name: user.role.name } : null,
status: user.status
? { id: user.status.id, name: user.status.name }
: null,
assignments: (user.players ?? []).map((player) => ({
id: player.id,
firstName: player.firstName,
lastName: player.lastName,
active: player.active,
team: {
id: player.team.id,
name: player.team.name,
alias: player.team.alias,
},
teamRole: player.teamRole
? { id: player.teamRole.id, name: player.teamRole.name }
: null,
})),
};
}
private mapPlayerSearchRow(row: PlayerSearchRow): AdminPlayerSummaryDto {
return {
id: Number(row.player_id),
firstName: row.first_name,
lastName: row.last_name,
active: Boolean(row.active),
team: {
id: Number(row.team_id),
name: row.team_name,
alias: row.team_alias,
},
currentUser:
row.user_id === null
? null
: {
id: Number(row.user_id),
firstName: row.user_first_name,
lastName: row.user_last_name,
status:
row.status_id === null
? null
: {
id: Number(row.status_id),
name: row.status_name ?? undefined,
},
},
};
}
private log(
manager: EntityManager,
event: Parameters<LoggingService['info']>[0]['event'],
userId: number,
details: string,
) {
return this.logger.info({ event, userId, details }, manager);
}
}

View File

@@ -0,0 +1,31 @@
import { UserDirectoryReferenceDto } from './user-directory-response.dto';
export class AdminPlayerTeamDto {
id: number;
name: string;
alias: string;
}
export class AdminPlayerCurrentUserDto {
id: number;
firstName: string | null;
lastName: string | null;
status: UserDirectoryReferenceDto | null;
}
export class AdminPlayerSummaryDto {
id: number;
firstName: string;
lastName: string;
active: boolean;
team: AdminPlayerTeamDto;
currentUser: AdminPlayerCurrentUserDto | null;
}
export class AdminPlayerPageDto {
data: AdminPlayerSummaryDto[];
page: number;
limit: number;
total: number;
hasNextPage: boolean;
}

View File

@@ -0,0 +1,68 @@
import { Type } from 'class-transformer';
import {
IsIn,
IsInt,
IsOptional,
IsString,
Max,
MaxLength,
Min,
ValidateIf,
} from 'class-validator';
import { RoleEnum } from '../../roles/roles.enum';
import { StatusEnum } from '../../statuses/statuses.enum';
export class AdminUserProfileDto {
@ValidateIf((_, value) => value !== undefined)
@IsString()
@MaxLength(255)
firstName?: string;
@ValidateIf((_, value) => value !== undefined)
@IsString()
@MaxLength(255)
lastName?: string;
}
export class AdminUserRoleDto {
@IsInt()
@IsIn([RoleEnum.admin, RoleEnum.user])
role: RoleEnum;
}
export class AdminUserStatusDto {
@IsInt()
@IsIn([StatusEnum.active, StatusEnum.inactive])
status: StatusEnum;
}
export type AdminPlayerAssignmentFilter = 'all' | 'assigned' | 'unassigned';
export class AdminPlayerQueryDto {
@IsOptional()
@IsString()
search?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
teamId?: number;
@IsOptional()
@IsIn(['all', 'assigned', 'unassigned'])
assignment: AdminPlayerAssignmentFilter = 'all';
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(50)
limit = 20;
}

View File

@@ -1,13 +1,7 @@
import { Transform } from 'class-transformer';
import { ApiProperty } from '@nestjs/swagger';
import { Role } from '../../roles/entities/role.entity';
import {
IsEmail,
IsNotEmpty,
IsOptional,
MinLength,
Validate,
} from 'class-validator';
import { IsEmail, IsNotEmpty, MinLength, Validate } from 'class-validator';
import { Status } from '../../statuses/entities/status.entity';
import { IsNotExist } from '../../utils/validators/is-not-exists.validator';
import { IsExist } from '../../utils/validators/is-exists.validator';
@@ -51,6 +45,4 @@ export class CreateUserDto {
status?: Status;
hash?: string | null;
linkPlayerId?: number | null;
}

View File

@@ -0,0 +1,21 @@
import { Type } from 'class-transformer';
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
export class UserDirectoryQueryDto {
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(50)
limit = 20;
@IsOptional()
@IsString()
search?: string;
}

View File

@@ -0,0 +1,40 @@
export class UserDirectoryReferenceDto {
id: number;
name?: string;
}
export class UserDirectoryTeamDto {
id: number;
name: string;
alias: string;
}
export class UserDirectoryAssignmentDto {
id: number;
firstName: string;
lastName: string;
active: boolean;
team: UserDirectoryTeamDto;
teamRole: UserDirectoryReferenceDto | null;
}
export class UserDirectorySummaryDto {
id: number;
firstName: string | null;
lastName: string | null;
status: UserDirectoryReferenceDto | null;
assignments: UserDirectoryAssignmentDto[];
}
export class AdminUserDirectorySummaryDto extends UserDirectorySummaryDto {
email: string | null;
role: UserDirectoryReferenceDto | null;
}
export class UserDirectoryPageDto {
data: Array<UserDirectorySummaryDto | AdminUserDirectorySummaryDto>;
page: number;
limit: number;
total: number;
hasNextPage: boolean;
}

View File

@@ -0,0 +1,11 @@
export class UserTeamReferenceDto {
id: number;
name: string;
}
export class UserTeamPlayerDto {
id: number;
firstName: string;
lastName: string;
team: UserTeamReferenceDto;
}

View File

@@ -0,0 +1,25 @@
import { UsersController } from './users.controller';
import { ForbiddenException } from '@nestjs/common';
describe('UsersController admin mutation isolation', () => {
it('does not expose generic create, update, or delete handlers that bypass safeguards', () => {
expect(UsersController.prototype).not.toHaveProperty('create');
expect(UsersController.prototype).not.toHaveProperty('update');
expect(UsersController.prototype).not.toHaveProperty('remove');
expect(UsersController.prototype).not.toHaveProperty('findAll');
expect(UsersController.prototype).not.toHaveProperty('findOne');
});
it('restricts team bootstrap to the authenticated user id', async () => {
const service = { findMyTeams: jest.fn(() => []) };
const controller = new UsersController(service as any);
const request = { user: { id: 7, role: { id: 2 } } };
expect(() => (controller as any).findTeamsOfPlayer(request, 8)).toThrow(
ForbiddenException,
);
await (controller as any).findTeamsOfPlayer(request, 7);
expect(service.findMyTeams).toHaveBeenCalledWith(7);
});
});

View File

@@ -1,27 +1,23 @@
import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
UseGuards,
Query,
DefaultValuePipe,
ParseIntPipe,
HttpStatus,
HttpCode,
Request,
ForbiddenException,
ParseIntPipe,
} from '@nestjs/common';
import { UsersService } from './users.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Roles } from 'src/roles/roles.decorator';
import { RoleEnum } from 'src/roles/roles.enum';
import { AuthGuard } from '@nestjs/passport';
import { RolesGuard } from 'src/roles/roles.guard';
import { infinityPagination } from 'src/utils/infinity-pagination';
import { UserDirectoryQueryDto } from './dto/user-directory-query.dto';
import { User } from './entities/user.entity';
@ApiBearerAuth()
@UseGuards(AuthGuard('jwt'), RolesGuard)
@@ -33,57 +29,26 @@ import { infinityPagination } from 'src/utils/infinity-pagination';
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Roles([RoleEnum.admin])
@Post()
@HttpCode(HttpStatus.CREATED)
create(@Body() createProfileDto: CreateUserDto) {
return this.usersService.create(createProfileDto);
}
@Roles([RoleEnum.admin])
@Get()
@Roles([RoleEnum.user, RoleEnum.admin])
@Get('directory')
@HttpCode(HttpStatus.OK)
async findAll(
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
@Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number,
findDirectory(
@Request() request: { user: Pick<User, 'id' | 'role'> },
@Query() query: UserDirectoryQueryDto,
) {
if (limit > 50) {
limit = 50;
}
return infinityPagination(
await this.usersService.findManyWithPagination({
page,
limit,
}),
{ page, limit },
);
}
@Roles([RoleEnum.admin])
@Get(':id')
@HttpCode(HttpStatus.OK)
findOne(@Param('id') id: string) {
return this.usersService.findOne({ id: +id });
return this.usersService.findDirectory(request.user, query);
}
@Roles([RoleEnum.user, RoleEnum.admin])
@Get(':id/teams')
@HttpCode(HttpStatus.OK)
findTeamsOfPlayer(@Param('id') id: string) {
return this.usersService.findTeams({ id: +id });
findTeamsOfPlayer(
@Request() request: { user: Pick<User, 'id' | 'role'> },
@Param('id', ParseIntPipe) id: number,
) {
if (request.user.id !== id) {
throw new ForbiddenException('Users may only load their own teams');
}
@Roles([RoleEnum.admin])
@Patch(':id')
@HttpCode(HttpStatus.OK)
update(@Param('id') id: number, @Body() updateProfileDto: UpdateUserDto) {
return this.usersService.update(id, updateProfileDto);
}
@Roles([RoleEnum.admin])
@Delete(':id')
remove(@Param('id') id: number) {
return this.usersService.softDelete(id);
return this.usersService.findMyTeams(id);
}
}

View File

@@ -7,11 +7,14 @@ import { IsExist } from 'src/utils/validators/is-exists.validator';
import { IsNotExist } from 'src/utils/validators/is-not-exists.validator';
import { Team } from 'src/teams/entities/team.entity';
import { Player } from 'src/players/entities/player.entity';
import { LoggingModule } from 'src/database/logging/logging.module';
import { AdminUsersController } from './admin-users.controller';
import { AdminUsersService } from './admin-users.service';
@Module({
imports: [TypeOrmModule.forFeature([User, Team, Player])],
controllers: [UsersController],
providers: [IsExist, IsNotExist, UsersService],
imports: [TypeOrmModule.forFeature([User, Team, Player]), LoggingModule],
controllers: [UsersController, AdminUsersController],
providers: [IsExist, IsNotExist, UsersService, AdminUsersService],
exports: [UsersService],
})
export class UsersModule {}

View File

@@ -0,0 +1,390 @@
import { RoleEnum } from '../roles/roles.enum';
import { StatusEnum } from '../statuses/statuses.enum';
import { UsersService } from './users.service';
describe('UsersService directory', () => {
const teamA = { id: 10, name: 'Alpha', alias: 'alpha' };
const teamB = { id: 20, name: 'Bravo', alias: 'bravo' };
const playerRole = { id: 1, name: 'Player' };
const users = [
user(1, 'Riley', 'Reader', 'reader@example.com'),
user(2, 'Emma', 'Shared', 'emma@example.com'),
user(3, 'Iva', 'Inactive', 'iva@example.com', StatusEnum.inactive),
user(4, 'Otis', 'Outside', 'otis@example.com'),
user(5, 'Morgan', 'Multiple', 'morgan@example.com'),
user(
6,
'Ada',
'Admin',
'admin@example.com',
StatusEnum.active,
RoleEnum.admin,
),
];
const players = [
assignment(101, users[0], teamA),
assignment(201, users[1], teamA),
assignment(301, users[2], teamA, false),
assignment(401, users[3], teamB),
assignment(501, users[4], teamA),
assignment(502, users[4], teamB),
];
let userRows: ReturnType<typeof directoryUserRow>[];
let assignmentRows: ReturnType<typeof directoryAssignmentRow>[];
let total: number;
let rejectActiveSharedTeamPredicate: boolean;
const usersRepository = {
find: jest.fn(() => {
throw new Error('directory queries must use a safe database projection');
}),
createQueryBuilder: jest.fn(() => createDirectoryUsersQuery()),
};
const playersRepository = {
find: jest.fn(() => {
throw new Error('directory queries must use a safe database projection');
}),
createQueryBuilder: jest.fn((alias: string) =>
alias === 'requesterPlayer'
? createSharedTeamsQuery()
: createDirectoryAssignmentsQuery(),
),
};
let service: UsersService;
beforeEach(() => {
jest.clearAllMocks();
rejectActiveSharedTeamPredicate = false;
setDirectoryResult(
[users[0], users[1], users[2], users[4]],
[players[0], players[1], players[2], players[4]],
);
service = new UsersService(
usersRepository as any,
playersRepository as any,
);
});
it('hides users and assignments from teams that the requester does not share', async () => {
const result = await directoryFor(users[0]);
expect(result.data.map((entry) => entry.id)).toEqual([1, 2, 3, 5]);
expect(result.data.find((entry) => entry.id === 4)).toBeUndefined();
expect(result.data.find((entry) => entry.id === 5).assignments).toEqual([
assignmentSummary(501, 'Morgan', 'Multiple', true, teamA),
]);
});
it('redacts email and authentication secrets for a non-admin requester', async () => {
const result = await directoryFor(users[0]);
const entry = result.data.find((candidate) => candidate.id === 2);
expect(entry).toEqual({
id: 2,
firstName: 'Emma',
lastName: 'Shared',
status: { id: StatusEnum.active, name: 'Active' },
assignments: [assignmentSummary(201, 'Emma', 'Shared', true, teamA)],
});
expect(entry).not.toHaveProperty('email');
expect(entry).not.toHaveProperty('password');
expect(entry).not.toHaveProperty('hash');
expect(entry).not.toHaveProperty('socialId');
});
it('keeps inactive users and inactive assignments visible in shared teams', async () => {
const result = await directoryFor(users[0]);
expect(result.data.find((entry) => entry.id === 3)).toEqual({
id: 3,
firstName: 'Iva',
lastName: 'Inactive',
status: { id: StatusEnum.inactive, name: 'Inactive' },
assignments: [assignmentSummary(301, 'Iva', 'Inactive', false, teamA)],
});
});
it('treats an inactive requester assignment as a shared team membership', async () => {
rejectActiveSharedTeamPredicate = true;
setDirectoryResult(
[users[0], users[1]],
[
assignment(101, users[0], teamA, false),
assignment(201, users[1], teamA),
],
);
const result = await directoryFor(users[0]);
expect(result.data.map((entry) => entry.id)).toEqual([1, 2]);
});
it('does not hydrate whole user entities for the directory', async () => {
const result = await directoryFor(users[0]);
expect(
result.data.every(
(entry) =>
!('password' in entry || 'hash' in entry || 'socialId' in entry),
),
).toBe(true);
});
it('returns every user and assignment with email and role for an admin requester', async () => {
setDirectoryResult(users, players, true);
const result = await directoryFor(users[5]);
const multiple = result.data.find((entry) => entry.id === 5);
const outsider = result.data.find((entry) => entry.id === 4);
expect(result.data).toHaveLength(6);
expect(outsider).toMatchObject({
email: 'otis@example.com',
role: { id: RoleEnum.user, name: 'User' },
});
expect(multiple.assignments).toEqual([
assignmentSummary(501, 'Morgan', 'Multiple', true, teamA),
assignmentSummary(502, 'Morgan', 'Multiple', true, teamB),
]);
expect(multiple).not.toHaveProperty('password');
expect(multiple).not.toHaveProperty('hash');
expect(multiple).not.toHaveProperty('socialId');
});
it('deduplicates a user with assignments in more than one shared team before pagination', async () => {
setDirectoryResult(
[users[2], users[4]],
[players[2], players[4]],
false,
4,
);
const result = await directoryFor(users[0], { page: 2, limit: 2 });
expect(result.data.map((entry) => entry.id)).toEqual([3, 5]);
expect(result.total).toBe(4);
expect(result.hasNextPage).toBe(false);
});
it('searches visible names case-insensitively without exposing outside-team users', async () => {
setDirectoryResult([users[4]], [players[4]]);
const matched = await directoryFor(users[0], { search: 'mOrGaN' });
setDirectoryResult([], []);
const hidden = await directoryFor(users[0], { search: 'outside' });
expect(matched.data.map((entry) => entry.id)).toEqual([5]);
expect(hidden.data).toEqual([]);
});
it('paginates the deduplicated, filtered directory and reports the next page', async () => {
setDirectoryResult(
[users[0], users[1]],
[players[0], players[1]],
false,
4,
);
const result = await directoryFor(users[0], { page: 1, limit: 2 });
expect(result).toMatchObject({
page: 1,
limit: 2,
total: 4,
hasNextPage: true,
});
expect(result.data.map((entry) => entry.id)).toEqual([1, 2]);
});
function directoryFor(
requester: typeof users[number],
query: { page?: number; limit?: number; search?: string } = {},
) {
return (service as any).findDirectory(requester, {
page: 1,
limit: 20,
...query,
});
}
function user(
id: number,
firstName: string,
lastName: string,
email: string,
statusId = StatusEnum.active,
roleId = RoleEnum.user,
) {
return {
id,
firstName,
lastName,
email,
password: `password-${id}`,
hash: `hash-${id}`,
socialId: `social-${id}`,
provider: 'email',
previousPassword: `previous-password-${id}`,
status: {
id: statusId,
name: statusId === StatusEnum.active ? 'Active' : 'Inactive',
},
role: {
id: roleId,
name: roleId === RoleEnum.admin ? 'Admin' : 'User',
},
};
}
function assignment(
id: number,
playerUser: typeof users[number],
team: typeof teamA,
active = true,
) {
return {
id,
firstName: playerUser.firstName,
lastName: playerUser.lastName,
active,
user: playerUser,
team,
teamRole: playerRole,
};
}
function assignmentSummary(
id: number,
firstName: string,
lastName: string,
active: boolean,
team: typeof teamA,
) {
return {
id,
firstName,
lastName,
active,
team,
teamRole: playerRole,
};
}
function setDirectoryResult(
directoryUsers: typeof users,
directoryPlayers: typeof players,
isAdmin = false,
directoryTotal = directoryUsers.length,
) {
userRows = directoryUsers.map((directoryUser) =>
directoryUserRow(directoryUser, isAdmin),
);
assignmentRows = directoryPlayers.map(directoryAssignmentRow);
total = directoryTotal;
}
function directoryUserRow(
directoryUser: typeof users[number],
isAdmin = false,
) {
return {
user_id: directoryUser.id,
first_name: directoryUser.firstName,
last_name: directoryUser.lastName,
status_id: directoryUser.status.id,
status_name: directoryUser.status.name,
...(isAdmin
? {
email: directoryUser.email,
role_id: directoryUser.role.id,
role_name: directoryUser.role.name,
}
: {}),
};
}
function directoryAssignmentRow(directoryPlayer: typeof players[number]) {
return {
player_id: directoryPlayer.id,
user_id: directoryPlayer.user.id,
first_name: directoryPlayer.firstName,
last_name: directoryPlayer.lastName,
active: directoryPlayer.active,
team_id: directoryPlayer.team.id,
team_name: directoryPlayer.team.name,
team_alias: directoryPlayer.team.alias,
team_role_id: directoryPlayer.teamRole.id,
team_role_name: directoryPlayer.teamRole.name,
};
}
function createDirectoryUsersQuery() {
const query: any = {
leftJoin: () => query,
innerJoin: () => query,
setParameters: () => query,
setParameter: () => query,
andWhere: () => query,
distinct: () => query,
orderBy: () => query,
offset: () => query,
limit: () => query,
select: (selection: string | string[]) => {
const selected = Array.isArray(selection) ? selection : [selection];
if (selected.join(' ').match(/password|hash|socialId|provider/i)) {
throw new Error(
'directory projection contains an authentication field',
);
}
return query;
},
clone: () => createDirectoryCountQuery(),
getRawMany: () => Promise.resolve(userRows),
};
return query;
}
function createDirectoryCountQuery() {
const query: any = {
select: () => query,
getRawOne: () => Promise.resolve({ total: String(total) }),
};
return query;
}
function createSharedTeamsQuery() {
const query: any = {
select: () => query,
where: (predicate: string) => {
if (
rejectActiveSharedTeamPredicate &&
/requesterPlayer\.active/i.test(predicate)
) {
throw new Error(
'shared-team membership must not filter inactive requester assignments',
);
}
return query;
},
getQuery: () =>
'SELECT requesterPlayer.teamId FROM player requesterPlayer',
getParameters: () => ({ requesterId: users[0].id }),
};
return query;
}
function createDirectoryAssignmentsQuery() {
const query: any = {
innerJoin: () => query,
leftJoin: () => query,
select: () => query,
where: () => query,
andWhere: () => query,
setParameters: () => query,
orderBy: () => query,
getRawMany: () => Promise.resolve(assignmentRows),
};
return query;
}
});

View File

@@ -3,10 +3,20 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Player } from 'src/players/entities/player.entity';
import { EntityCondition } from 'src/utils/types/entity-condition.type';
import { IPaginationOptions } from 'src/utils/types/pagination-options';
import { Repository } from 'typeorm';
import { Brackets, Repository, SelectQueryBuilder } from 'typeorm';
import { RoleEnum } from '../roles/roles.enum';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { UserDirectoryQueryDto } from './dto/user-directory-query.dto';
import {
AdminUserDirectorySummaryDto,
UserDirectoryAssignmentDto,
UserDirectoryPageDto,
UserDirectoryReferenceDto,
UserDirectorySummaryDto,
} from './dto/user-directory-response.dto';
import { User } from './entities/user.entity';
import { UserTeamPlayerDto } from './dto/user-team-response.dto';
@Injectable()
export class UsersService {
@@ -30,6 +40,61 @@ export class UsersService {
});
}
async findDirectory(
requester: Pick<User, 'id' | 'role'>,
query: UserDirectoryQueryDto,
): Promise<UserDirectoryPageDto> {
const isAdmin = requester.role?.id === RoleEnum.admin;
const page = query.page ?? 1;
const limit = query.limit ?? 20;
const sharedTeamsQuery = isAdmin
? undefined
: this.createSharedTeamsQuery(requester.id);
const directoryQuery = this.createDirectoryUsersQuery(
isAdmin,
sharedTeamsQuery,
query.search,
);
const totalResult = await directoryQuery
.clone()
.select('COUNT(DISTINCT user.id)', 'total')
.getRawOne<{ total: string }>();
const userRows = await directoryQuery
.select(this.directoryUserSelects(isAdmin))
.distinct(true)
.orderBy('user.id', 'ASC')
.offset((page - 1) * limit)
.limit(limit)
.getRawMany<DirectoryUserRow>();
const assignments = await this.findDirectoryAssignments(
userRows.map((user) => Number(user.user_id)),
isAdmin,
sharedTeamsQuery,
);
const assignmentsByUserId = assignments.reduce((byUserId, assignment) => {
const userAssignments = byUserId.get(Number(assignment.user_id)) ?? [];
userAssignments.push(assignment);
byUserId.set(Number(assignment.user_id), userAssignments);
return byUserId;
}, new Map<number, DirectoryAssignmentRow[]>());
const total = Number(totalResult?.total ?? 0);
const data = userRows.map((user) =>
this.mapDirectoryUser(
user,
assignmentsByUserId.get(Number(user.user_id)) ?? [],
isAdmin,
),
);
return {
data,
page,
limit,
total,
hasNextPage: page * limit < total,
};
}
findOne(fields: EntityCondition<User>) {
return this.usersRepository.findOne({
where: fields,
@@ -49,32 +114,232 @@ export class UsersService {
await this.usersRepository.softDelete(id);
}
async findTeams(fields: EntityCondition<User>) {
const user = await this.findOne(fields);
if (!user) {
async findMyTeams(userId: number): Promise<UserTeamPlayerDto[]> {
const rows = await this.playersRepository
.createQueryBuilder('player')
.innerJoin('player.team', 'team')
.select([
'player.id AS player_id',
'player.firstName AS first_name',
'player.lastName AS last_name',
'team.id AS team_id',
'team.name AS team_name',
])
.where('player.userId = :userId', { userId })
.orderBy('player.id', 'ASC')
.getRawMany<{
player_id: number | string;
first_name: string;
last_name: string;
team_id: number | string;
team_name: string;
}>();
return rows.map((row) => ({
id: Number(row.player_id),
firstName: row.first_name,
lastName: row.last_name,
team: { id: Number(row.team_id), name: row.team_name },
}));
}
private createSharedTeamsQuery(requesterId: number) {
return this.playersRepository
.createQueryBuilder('requesterPlayer')
.select('requesterPlayer.teamId')
.where('requesterPlayer.userId = :requesterId', { requesterId });
}
private createDirectoryUsersQuery(
isAdmin: boolean,
sharedTeamsQuery: SelectQueryBuilder<Player> | undefined,
search: string | undefined,
) {
const query = this.usersRepository
.createQueryBuilder('user')
.leftJoin('user.status', 'status')
.leftJoin('user.role', 'role');
if (!isAdmin && sharedTeamsQuery) {
query
.innerJoin(
'user.players',
'visibleAssignment',
`visibleAssignment.teamId IN (${sharedTeamsQuery.getQuery()})`,
)
.setParameters(sharedTeamsQuery.getParameters());
}
const term = search?.trim().toLocaleLowerCase();
if (!term) {
return query;
}
const assignmentAlias = isAdmin ? 'searchAssignment' : 'visibleAssignment';
if (isAdmin) {
query.leftJoin('user.players', assignmentAlias);
}
const searchColumns = [
'user.firstName',
'user.lastName',
`${assignmentAlias}.firstName`,
`${assignmentAlias}.lastName`,
...(isAdmin ? ['user.email'] : []),
];
query.andWhere(
new Brackets((where) => {
searchColumns.forEach((column, index) => {
const expression = `LOWER(${column}) LIKE :directorySearch`;
if (index === 0) {
where.where(expression);
} else {
where.orWhere(expression);
}
});
}),
);
return query.setParameter('directorySearch', `%${term}%`);
}
private directoryUserSelects(isAdmin: boolean): string[] {
return [
'user.id AS user_id',
'user.firstName AS first_name',
'user.lastName AS last_name',
'status.id AS status_id',
'status.name AS status_name',
...(isAdmin
? [
'user.email AS email',
'role.id AS role_id',
'role.name AS role_name',
]
: []),
];
}
private async findDirectoryAssignments(
userIds: number[],
isAdmin: boolean,
sharedTeamsQuery: SelectQueryBuilder<Player> | undefined,
): Promise<DirectoryAssignmentRow[]> {
if (userIds.length === 0) {
return [];
}
const players = await this.playersRepository.find({
where: {
user: {
id: user.id,
},
},
relations: ['team'],
});
return players;
const query = this.playersRepository
.createQueryBuilder('player')
.innerJoin('player.team', 'team')
.leftJoin('player.teamRole', 'teamRole')
.select([
'player.id AS player_id',
'player.userId AS user_id',
'player.firstName AS first_name',
'player.lastName AS last_name',
'player.active AS active',
'team.id AS team_id',
'team.name AS team_name',
'team.alias AS team_alias',
'teamRole.id AS team_role_id',
'teamRole.name AS team_role_name',
])
.where('player.userId IN (:...userIds)', { userIds })
.orderBy('player.id', 'ASC');
if (!isAdmin && sharedTeamsQuery) {
query
.andWhere(`player.teamId IN (${sharedTeamsQuery.getQuery()})`)
.setParameters(sharedTeamsQuery.getParameters());
}
async linkPlayerToUserId(user: User, playerId: number): Promise<boolean> {
return new Promise<boolean>(async (resolve) => {
const player = await this.playersRepository.findOneByOrFail({
id: playerId,
});
return query.getRawMany<DirectoryAssignmentRow>();
}
player.user = user;
await this.playersRepository.save(player);
return resolve(true);
});
private mapDirectoryUser(
user: DirectoryUserRow,
assignments: DirectoryAssignmentRow[],
includeAdminFields: boolean,
): UserDirectorySummaryDto | AdminUserDirectorySummaryDto {
const summary: UserDirectorySummaryDto = {
id: Number(user.user_id),
firstName: user.first_name,
lastName: user.last_name,
status: this.mapDirectoryReference(user.status_id, user.status_name),
assignments: assignments.map((assignment) =>
this.mapDirectoryAssignment(assignment),
),
};
if (!includeAdminFields) {
return summary;
}
return {
...summary,
email: user.email,
role: this.mapDirectoryReference(user.role_id, user.role_name),
};
}
private mapDirectoryAssignment(
player: DirectoryAssignmentRow,
): UserDirectoryAssignmentDto {
return {
id: Number(player.player_id),
firstName: player.first_name,
lastName: player.last_name,
active: this.asBoolean(player.active),
team: {
id: Number(player.team_id),
name: player.team_name,
alias: player.team_alias,
},
teamRole: this.mapDirectoryReference(
player.team_role_id,
player.team_role_name,
),
};
}
private asBoolean(value: boolean | number | string): boolean {
return value === true || value === 1 || value === '1';
}
private mapDirectoryReference(
id: number | string | null,
name: string | null,
): UserDirectoryReferenceDto | null {
if (id == null) {
return null;
}
return {
id: Number(id),
name: name ?? undefined,
};
}
}
interface DirectoryUserRow {
user_id: number | string;
first_name: string | null;
last_name: string | null;
status_id: number | string | null;
status_name: string | null;
email?: string | null;
role_id?: number | string | null;
role_name?: string | null;
}
interface DirectoryAssignmentRow {
player_id: number | string;
user_id: number | string;
first_name: string;
last_name: string;
active: boolean | number | string;
team_id: number | string;
team_name: string;
team_alias: string;
team_role_id: number | string | null;
team_role_name: string | null;
}

View File

@@ -0,0 +1,49 @@
import { UsersService } from './users.service';
describe('UsersService safe current-user teams', () => {
it('returns only the player and team fields required by team bootstrap', async () => {
const rows = [
{
player_id: 11,
first_name: 'Pat',
last_name: 'Player',
team_id: 4,
team_name: 'Alpha',
user_id: 7,
provider: 'google',
social_id: 'must-not-leak',
balance: '100.00',
},
];
const query = chain({ getRawMany: jest.fn(() => rows) });
const playersRepository = {
createQueryBuilder: jest.fn(() => query),
};
const service = new UsersService({} as any, playersRepository as any);
const result = await service.findMyTeams(7);
expect(result).toEqual([
{
id: 11,
firstName: 'Pat',
lastName: 'Player',
team: { id: 4, name: 'Alpha' },
},
]);
expect(JSON.stringify(result)).not.toMatch(
/user|provider|social|role|status|password|hash|balance|createdAt|updatedAt/i,
);
expect(query.where).toHaveBeenCalledWith('player.userId = :userId', {
userId: 7,
});
});
function chain(overrides: Record<string, jest.Mock>) {
const query: Record<string, jest.Mock> = {};
['innerJoin', 'select', 'where', 'orderBy'].forEach((method) => {
query[method] = jest.fn(() => query);
});
return Object.assign(query, overrides);
}
});

View File

@@ -28,6 +28,17 @@ describe('app routing', () => {
expect(router.url).toBe('/auth/login');
});
it('redirects the protected users route to login when logged out', async () => {
await router.navigateByUrl('/users');
expect(router.url).toBe('/auth/login');
});
it('allows an authenticated user to open the users route', async () => {
authStore.setSession('token', { id: 1, email: 'a@b.de', firstName: 'A', lastName: 'B' });
await router.navigateByUrl('/users');
expect(router.url).toBe('/users');
});
it('redirects the root path to team-select when logged in', async () => {
authStore.setSession('token', { id: 1, email: 'a@b.de', firstName: 'A', lastName: 'B' });
await router.navigateByUrl('/');

View File

@@ -38,6 +38,11 @@ export const routes: Routes = [
canActivate: [authGuard],
loadComponent: () => import('./features/team-select/team-select').then((m) => m.TeamSelect),
},
{
path: 'users',
canActivate: [authGuard],
loadComponent: () => import('./features/users/users').then((m) => m.Users),
},
{
path: 't/:token/:playerId',
loadComponent: () => import('./features/public-team/public-player').then((m) => m.PublicPlayer),

View File

@@ -1,7 +1,7 @@
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { AuthApi } from './auth-api';
import { AuthApi, RegistrationRequest } from './auth-api';
import { environment } from '../../../environments/environment';
import { User } from '../../models/user.model';
@@ -59,8 +59,8 @@ describe('AuthApi', () => {
request.flush(invitation);
});
it('registers and links an invited player', () => {
const registration = {
it('registers with only the supported account fields', () => {
const registrationWithLegacyField = {
email: 'alex@example.de',
password: 'secret1',
firstName: 'Alex',
@@ -68,11 +68,16 @@ describe('AuthApi', () => {
linkPlayerId: 7,
};
service.register(registration).subscribe();
service.register(registrationWithLegacyField as RegistrationRequest).subscribe();
const request = httpMock.expectOne(`${environment.apiUrl}auth/email/register`);
expect(request.request.method).toBe('POST');
expect(request.request.body).toEqual(registration);
expect(request.request.body).toEqual({
email: 'alex@example.de',
password: 'secret1',
firstName: 'Alex',
lastName: 'Muster',
});
request.flush(null);
});

View File

@@ -23,7 +23,6 @@ export interface RegistrationRequest {
password: string;
firstName: string;
lastName: string;
linkPlayerId: number;
}
export interface CreateInviteRequest extends InviteDetails {}
@@ -53,7 +52,13 @@ export class AuthApi {
}
register(request: RegistrationRequest): Observable<void> {
return this.http.post<void>(`${this.baseUrl}/email/register`, request);
const { email, password, firstName, lastName } = request;
return this.http.post<void>(`${this.baseUrl}/email/register`, {
email,
password,
firstName,
lastName,
});
}
forgotPassword(email: string): Observable<void> {

View File

@@ -1,5 +1,6 @@
import { TestBed } from '@angular/core/testing';
import { AuthStore } from './auth-store';
import { RoleId } from '../../models/role.model';
describe('AuthStore', () => {
beforeEach(() => {
@@ -76,4 +77,34 @@ describe('AuthStore', () => {
expect(localStorage.getItem('tw_token')).toBe('jwt-token');
expect(JSON.parse(localStorage.getItem('tw_user')!)).toEqual(updated);
});
it('derives the global admin presentation hint from the current user role', () => {
TestBed.configureTestingModule({});
const store = TestBed.inject(AuthStore);
store.setSession('jwt-token', {
id: 1,
email: 'admin@example.de',
firstName: 'Ada',
lastName: 'Admin',
role: { id: RoleId.Admin, name: 'admin' },
});
expect(store.currentGlobalRole()).toBe(RoleId.Admin);
expect(store.isGlobalAdmin()).toBe(true);
});
it('does not treat a signed-in standard user as a global admin', () => {
TestBed.configureTestingModule({});
const store = TestBed.inject(AuthStore);
store.setSession('jwt-token', {
id: 2,
email: 'user@example.de',
firstName: 'Ute',
lastName: 'User',
role: { id: RoleId.User, name: 'user' },
});
expect(store.currentGlobalRole()).toBe(RoleId.User);
expect(store.isGlobalAdmin()).toBe(false);
});
});

View File

@@ -1,4 +1,5 @@
import { Injectable, computed, signal } from '@angular/core';
import { RoleId } from '../../models/role.model';
import { User } from '../../models/user.model';
@Injectable({ providedIn: 'root' })
@@ -12,6 +13,11 @@ export class AuthStore {
readonly token = this.tokenSignal.asReadonly();
readonly currentUser = this.userSignal.asReadonly();
readonly isLoggedIn = computed(() => this.tokenSignal() !== null);
readonly currentGlobalRole = computed<RoleId | null>(() => {
const roleId = this.userSignal()?.role?.id;
return roleId === RoleId.Admin || roleId === RoleId.User ? roleId : null;
});
readonly isGlobalAdmin = computed(() => this.currentGlobalRole() === RoleId.Admin);
setSession(token: string, user: User): void {
localStorage.setItem(AuthStore.TOKEN_KEY, token);

View File

@@ -14,7 +14,7 @@ import { MatToolbarModule } from '@angular/material/toolbar';
import { AuthStore } from '../../auth/auth-store';
import { MyTeamsStore } from '../../team/my-teams-store';
import { TeamStore } from '../../team/team-store';
import { Team } from '../../../models/team.model';
import { UserTeamReference } from '../../../models/user-directory.model';
@Component({
selector: 'app-shell',
@@ -41,9 +41,9 @@ export class Shell {
protected readonly myTeams = computed(() => {
const seen = new Set<number>();
const teams: Team[] = [];
const teams: UserTeamReference[] = [];
for (const player of this.myTeamsStore.players()) {
if (player.team && !seen.has(player.team.id)) {
if (!seen.has(player.team.id)) {
seen.add(player.team.id);
teams.push(player.team);
}

View File

@@ -3,19 +3,16 @@ import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { MyTeamsStore } from './my-teams-store';
import { environment } from '../../../environments/environment';
import { Player } from '../../models/player.model';
describe('MyTeamsStore', () => {
let store: MyTeamsStore;
let httpMock: HttpTestingController;
const player: Player = {
const player = {
id: 1,
firstName: 'A',
lastName: 'B',
balance: 0,
active: true,
team: { id: 5, name: 'Team A', alias: 'team-a', balance: 0 },
team: { id: 5, name: 'Team A' },
};
beforeEach(() => {

View File

@@ -1,12 +1,12 @@
import { Injectable, inject, signal } from '@angular/core';
import { Player } from '../../models/player.model';
import { UserTeamMembership } from '../../models/user-directory.model';
import { TeamsApi } from './teams-api';
@Injectable({ providedIn: 'root' })
export class MyTeamsStore {
private readonly teamsApi = inject(TeamsApi);
private readonly playersSignal = signal<Player[]>([]);
private readonly playersSignal = signal<UserTeamMembership[]>([]);
private readonly loadingSignal = signal(false);
private readonly loadedForUserId = signal<number | null>(null);

View File

@@ -22,8 +22,8 @@ describe('TeamsApi', () => {
httpMock.verify();
});
it('fetches the players/teams belonging to a user', () => {
const players: Player[] = [{ id: 1, firstName: 'A', lastName: 'B', balance: 0, active: true }];
it('fetches the reduced bootstrap memberships belonging to a user', () => {
const players = [{ id: 1, firstName: 'A', lastName: 'B', team: { id: 5, name: 'Team A' } }];
service.loadMyTeams(42).subscribe((response) => {
expect(response).toEqual(players);

View File

@@ -2,9 +2,10 @@ import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from '../../../environments/environment';
import { Player } from '../../models/player.model';
import { Team } from '../../models/team.model';
import { PlayerTransaction } from '../../models/transaction.model';
import { UserTeamMembership } from '../../models/user-directory.model';
import { Player } from '../../models/player.model';
export interface CreatePlayerRequest {
firstName: string;
@@ -16,8 +17,8 @@ export interface CreatePlayerRequest {
export class TeamsApi {
private readonly http = inject(HttpClient);
loadMyTeams(userId: number): Observable<Player[]> {
return this.http.get<Player[]>(`${environment.apiUrl}users/${userId}/teams`);
loadMyTeams(userId: number): Observable<UserTeamMembership[]> {
return this.http.get<UserTeamMembership[]>(`${environment.apiUrl}users/${userId}/teams`);
}
loadTeamOverview(teamId: number): Observable<Team> {

View File

@@ -0,0 +1,52 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from '../../../environments/environment';
import {
AdminPlayerFilters,
AdminPlayerPage,
AdminUserDirectorySummary,
AdminUserProfileRequest,
AdminUserRoleRequest,
AdminUserStatusRequest,
} from '../../models/user-directory.model';
@Injectable({ providedIn: 'root' })
export class AdminUsersApi {
private readonly http = inject(HttpClient);
private readonly baseUrl = `${environment.apiUrl}admin/users`;
updateProfile(userId: number, request: AdminUserProfileRequest): Observable<AdminUserDirectorySummary> {
return this.http.patch<AdminUserDirectorySummary>(`${this.baseUrl}/${userId}/profile`, request);
}
updateRole(userId: number, request: AdminUserRoleRequest): Observable<AdminUserDirectorySummary> {
return this.http.patch<AdminUserDirectorySummary>(`${this.baseUrl}/${userId}/role`, request);
}
updateStatus(userId: number, request: AdminUserStatusRequest): Observable<AdminUserDirectorySummary> {
return this.http.patch<AdminUserDirectorySummary>(`${this.baseUrl}/${userId}/status`, request);
}
loadPlayers(filters: AdminPlayerFilters = {}): Observable<AdminPlayerPage> {
return this.http.get<AdminPlayerPage>(`${this.baseUrl}/players`, { params: this.toParams(filters) });
}
assignPlayer(userId: number, playerId: number): Observable<AdminUserDirectorySummary> {
return this.http.put<AdminUserDirectorySummary>(`${this.baseUrl}/${userId}/players/${playerId}`, null);
}
unlinkPlayer(userId: number, playerId: number): Observable<AdminUserDirectorySummary> {
return this.http.delete<AdminUserDirectorySummary>(`${this.baseUrl}/${userId}/players/${playerId}`);
}
private toParams(filters: AdminPlayerFilters): HttpParams {
let params = new HttpParams();
if (filters.search !== undefined) params = params.set('search', filters.search);
if (filters.teamId !== undefined) params = params.set('teamId', filters.teamId);
if (filters.assignment !== undefined) params = params.set('assignment', filters.assignment);
if (filters.page !== undefined) params = params.set('page', filters.page);
if (filters.limit !== undefined) params = params.set('limit', filters.limit);
return params;
}
}

View File

@@ -0,0 +1,106 @@
import { provideHttpClient } from '@angular/common/http';
import { TestBed } from '@angular/core/testing';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { environment } from '../../../environments/environment';
import { AdminUsersApi } from './admin-users-api';
import { AdminUserProfileRequest } from '../../models/user-directory.model';
import { UsersApi } from './users-api';
describe('UsersApi', () => {
let service: UsersApi;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] });
service = TestBed.inject(UsersApi);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => httpMock.verify());
it('loads the safe user directory with supplied pagination and search filters', () => {
service.loadDirectory({ page: 2, limit: 10, search: 'Alex Muster' }).subscribe();
const request = httpMock.expectOne(
`${environment.apiUrl}users/directory?page=2&limit=10&search=Alex%20Muster`,
);
expect(request.request.method).toBe('GET');
request.flush({ data: [], page: 2, limit: 10, total: 0, hasNextPage: false });
});
});
describe('AdminUsersApi', () => {
let service: AdminUsersApi;
let httpMock: HttpTestingController;
it('accepts profile requests with omitted or string names only', () => {
const omitted: AdminUserProfileRequest = {};
const names: AdminUserProfileRequest = {
firstName: 'Ada',
lastName: 'Admin',
};
// @ts-expect-error Profile names cannot be explicitly cleared to null.
const nullName: AdminUserProfileRequest = { firstName: null };
expect(omitted).toEqual({});
expect(names).toEqual({ firstName: 'Ada', lastName: 'Admin' });
expect(nullName).toEqual({ firstName: null });
});
beforeEach(() => {
TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] });
service = TestBed.inject(AdminUsersApi);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => httpMock.verify());
it('uses the dedicated admin profile endpoint and payload', () => {
service.updateProfile(7, { firstName: 'Alex' }).subscribe();
const request = httpMock.expectOne(`${environment.apiUrl}admin/users/7/profile`);
expect(request.request.method).toBe('PATCH');
expect(request.request.body).toEqual({ firstName: 'Alex' });
request.flush({});
});
it('uses the dedicated admin role endpoint and payload', () => {
service.updateRole(7, { role: 1 }).subscribe();
const request = httpMock.expectOne(`${environment.apiUrl}admin/users/7/role`);
expect(request.request.method).toBe('PATCH');
expect(request.request.body).toEqual({ role: 1 });
request.flush({});
});
it('uses the dedicated admin status endpoint and payload', () => {
service.updateStatus(7, { status: 2 }).subscribe();
const request = httpMock.expectOne(`${environment.apiUrl}admin/users/7/status`);
expect(request.request.method).toBe('PATCH');
expect(request.request.body).toEqual({ status: 2 });
request.flush({});
});
it('loads players with the admin filters', () => {
service.loadPlayers({ search: 'Alex', teamId: 5, assignment: 'unassigned', page: 2, limit: 10 }).subscribe();
const request = httpMock.expectOne(
`${environment.apiUrl}admin/users/players?search=Alex&teamId=5&assignment=unassigned&page=2&limit=10`,
);
expect(request.request.method).toBe('GET');
request.flush({ data: [], page: 2, limit: 10, total: 0, hasNextPage: false });
});
it('assigns a player through the dedicated admin endpoint', () => {
service.assignPlayer(7, 9).subscribe();
const request = httpMock.expectOne(`${environment.apiUrl}admin/users/7/players/9`);
expect(request.request.method).toBe('PUT');
expect(request.request.body).toBeNull();
request.flush({});
});
it('unlinks a player through the dedicated admin endpoint', () => {
service.unlinkPlayer(7, 9).subscribe();
const request = httpMock.expectOne(`${environment.apiUrl}admin/users/7/players/9`);
expect(request.request.method).toBe('DELETE');
request.flush({});
});
});

View File

@@ -0,0 +1,25 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from '../../../environments/environment';
import { UserDirectoryFilters, UserDirectoryPage } from '../../models/user-directory.model';
@Injectable({ providedIn: 'root' })
export class UsersApi {
private readonly http = inject(HttpClient);
private readonly baseUrl = `${environment.apiUrl}users`;
loadDirectory(filters: UserDirectoryFilters = {}): Observable<UserDirectoryPage> {
return this.http.get<UserDirectoryPage>(`${this.baseUrl}/directory`, {
params: this.toParams(filters),
});
}
private toParams(filters: UserDirectoryFilters): HttpParams {
let params = new HttpParams();
if (filters.page !== undefined) params = params.set('page', filters.page);
if (filters.limit !== undefined) params = params.set('limit', filters.limit);
if (filters.search !== undefined) params = params.set('search', filters.search);
return params;
}
}

View File

@@ -28,7 +28,7 @@ describe('Register', () => {
afterEach(() => httpMock.verify());
it('verifies the invitation and links its player during registration', () => {
it('verifies the invitation and registers without linking its player', () => {
const fixture = TestBed.createComponent(Register);
const navigateSpy = vi.spyOn(router, 'navigate');
fixture.detectChanges();
@@ -50,7 +50,12 @@ describe('Register', () => {
fixture.componentInstance['onSubmit']();
const request = httpMock.expectOne(`${environment.apiUrl}auth/email/register`);
expect(request.request.body.linkPlayerId).toBe(7);
expect(request.request.body).toEqual({
email: 'alex@example.de',
password: 'secret1',
firstName: 'Alex',
lastName: 'Muster',
});
request.flush(null);
expect(navigateSpy).toHaveBeenCalledWith(['/auth/login'], { replaceUrl: true });

View File

@@ -78,7 +78,6 @@ export class Register {
password: value.password,
firstName: value.firstName,
lastName: value.lastName,
linkPlayerId: invitation.playerId,
})
.subscribe({
next: () => {

View File

@@ -11,8 +11,8 @@
<h1>Team auswählen</h1>
<mat-nav-list>
@for (player of players(); track player.id) {
<a mat-list-item [routerLink]="['/team', player.team?.id, 'overview']">
<span matListItemTitle>{{ player.team?.name }}</span>
<a mat-list-item [routerLink]="['/team', player.team.id, 'overview']">
<span matListItemTitle>{{ player.team.name }}</span>
<span matListItemLine>{{ player.firstName }} {{ player.lastName }}</span>
</a>
}

View File

@@ -5,6 +5,13 @@
</header>
<section class="link-grid">
<a routerLink="/users"
><mat-card
><mat-icon>group</mat-icon>
<div><strong>Benutzer</strong><span>Benutzerverzeichnis öffnen</span></div>
<mat-icon>chevron_right</mat-icon></mat-card
></a
>
<a routerLink="penalties"
><mat-card
><mat-icon>gavel</mat-icon>

View File

@@ -31,6 +31,9 @@ describe('More', () => {
expect(fixture.nativeElement.textContent).toContain('Profil');
expect(fixture.nativeElement.textContent).toContain('Öffentliche Freigabe');
expect(fixture.nativeElement.textContent).toContain('Benutzer');
expect(fixture.nativeElement.querySelector('a[href="/users"]')).not.toBeNull();
fixture.componentInstance['logout']();
await fixture.whenStable();
expect(clearSession).toHaveBeenCalled();

View File

@@ -0,0 +1,325 @@
import { HttpErrorResponse } from '@angular/common/http';
import { Component, DestroyRef, EventEmitter, Input, OnChanges, Output, SimpleChanges, inject, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { EMPTY, Subject, catchError, filter, finalize, switchMap, take, tap } from 'rxjs';
import { AdminUsersApi } from '../../core/users/admin-users-api';
import {
AdminPlayerPage,
AdminPlayerSummary,
AdminUserDirectorySummary,
} from '../../models/user-directory.model';
import { ConfirmDialog } from '../../shared/confirm-dialog/confirm-dialog';
@Component({
selector: 'app-player-assignments',
imports: [
MatButtonModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
MatProgressSpinnerModule,
],
template: `
<section
class="assignments"
aria-label="Spielerzuordnungen verwalten"
[attr.aria-busy]="pendingPlayerId() !== null ? 'true' : null"
>
<div class="assignments__heading">
<div>
<h3>Spielerzuordnungen</h3>
<p>Spieler teamübergreifend suchen und sicher verknüpfen.</p>
</div>
<button mat-button type="button" [disabled]="pendingPlayerId() !== null" (click)="close()">Schließen</button>
</div>
<form class="assignments__search" (submit)="submitSearch(); $event.preventDefault()" role="search">
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Spieler suchen</mat-label>
<mat-icon matPrefix>search</mat-icon>
<input
matInput
type="search"
name="playerSearch"
[value]="searchDraft"
[disabled]="pendingPlayerId() !== null"
(input)="searchDraft = $any($event.target).value"
/>
</mat-form-field>
<button mat-stroked-button type="submit" [disabled]="pendingPlayerId() !== null">Suchen</button>
</form>
@if (loadError()) {
<div class="assignments__error" role="alert">
<span>{{ loadError() }}</span>
<button mat-button type="button" (click)="loadPlayers()">Erneut versuchen</button>
</div>
}
@if (mutationError()) {
<div class="assignments__error" role="alert">{{ mutationError() }}</div>
}
@if (loading()) {
<div class="assignments__state" aria-live="polite">
<mat-spinner diameter="30" />
<span>Spieler werden geladen …</span>
</div>
} @else if (!loadError() && players()?.data?.length === 0) {
<div class="assignments__state"><span>Keine Spieler gefunden.</span></div>
} @else if (players()) {
<div class="player-list">
@for (player of players()!.data; track player.id) {
<div class="player-row" [class.player-row--inactive]="!player.active">
<div class="player-row__copy">
<strong>{{ player.firstName }} {{ player.lastName }}</strong>
<span>{{ player.team.name }} · {{ player.active ? 'Aktiv' : 'Inaktiv' }}</span>
@if (player.currentUser) {
<span>Aktuell: {{ userName(player.currentUser) }}</span>
} @else {
<span>Nicht verknüpft</span>
}
</div>
@if (player.currentUser?.id === user.id) {
<button
mat-stroked-button
type="button"
[disabled]="pendingPlayerId() !== null"
(click)="confirmUnlink(player)"
>
{{ pendingPlayerId() === player.id ? 'Wird gelöst …' : 'Verknüpfung lösen' }}
</button>
} @else if (player.currentUser) {
<button
mat-stroked-button
type="button"
[disabled]="pendingPlayerId() !== null"
(click)="confirmReassign(player)"
>
{{ pendingPlayerId() === player.id ? 'Wird neu zugeordnet …' : 'Neu zuordnen' }}
</button>
} @else {
<button
mat-flat-button
type="button"
[disabled]="pendingPlayerId() !== null"
(click)="assign(player)"
>
{{ pendingPlayerId() === player.id ? 'Wird zugeordnet …' : 'Zuordnen' }}
</button>
}
</div>
}
</div>
<nav class="assignments__paging" aria-label="Spielerseiten">
<button mat-button type="button" [disabled]="players()!.page <= 1 || loading() || pendingPlayerId() !== null" (click)="previousPage()">
<mat-icon>chevron_left</mat-icon>Zurück
</button>
<span>Seite {{ players()!.page }}</span>
<button mat-button type="button" [disabled]="!players()!.hasNextPage || loading() || pendingPlayerId() !== null" (click)="nextPage()">
Weiter<mat-icon>chevron_right</mat-icon>
</button>
</nav>
}
</section>
`,
styles: `
.assignments { padding: 18px; border: 1px solid var(--mat-sys-outline-variant); border-radius: 18px; }
.assignments__heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
h3, p { margin: 0; }
.assignments__heading p { margin-top: 4px; color: var(--mat-sys-on-surface-variant); }
.assignments__search { display: flex; align-items: center; gap: 10px; margin: 18px 0; }
.assignments__search mat-form-field { flex: 1; }
.player-list { display: grid; }
.player-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 14px 0; border-bottom: 1px solid var(--mat-sys-outline-variant); }
.player-row--inactive { opacity: .68; }
.player-row__copy { display: grid; gap: 2px; min-width: 0; }
.player-row__copy span { color: var(--mat-sys-on-surface-variant); }
.assignments__state { min-height: 120px; display: grid; place-content: center; color: var(--mat-sys-on-surface-variant); }
.assignments__error { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 12px; padding: 12px; border-radius: 12px; color: var(--mat-sys-on-error-container); background: var(--mat-sys-error-container); }
.assignments__paging { display: flex; align-items: center; justify-content: space-between; margin-top: 14px; }
@media (max-width: 600px) {
.assignments { padding: 16px; }
.assignments__heading, .assignments__search, .player-row { align-items: stretch; flex-direction: column; }
.player-row button { width: 100%; }
}
`,
})
export class PlayerAssignments implements OnChanges {
private readonly adminUsersApi = inject(AdminUsersApi);
private readonly dialog = inject(MatDialog);
private readonly destroyRef = inject(DestroyRef);
private readonly playerRequests = new Subject<void>();
@Input({ required: true }) user!: AdminUserDirectorySummary;
@Output() readonly directoryChanged = new EventEmitter<void>();
@Output() readonly closed = new EventEmitter<void>();
@Output() readonly busyChange = new EventEmitter<boolean>();
protected readonly players = signal<AdminPlayerPage | null>(null);
protected readonly loading = signal(false);
protected readonly loadError = signal<string | null>(null);
protected readonly mutationError = signal<string | null>(null);
protected readonly pendingPlayerId = signal<number | null>(null);
protected searchDraft = '';
private search = '';
private page = 1;
private readonly limit = 20;
constructor() {
this.playerRequests
.pipe(
switchMap(() => {
this.loading.set(true);
this.loadError.set(null);
this.players.set(null);
return this.adminUsersApi
.loadPlayers({
assignment: 'all',
page: this.page,
limit: this.limit,
...(this.search ? { search: this.search } : {}),
})
.pipe(
tap((players) => this.players.set(players)),
catchError((error: HttpErrorResponse) => {
this.loadError.set(this.loadErrorMessage(error));
return EMPTY;
}),
finalize(() => this.loading.set(false)),
);
}),
takeUntilDestroyed(this.destroyRef),
)
.subscribe();
}
ngOnChanges(changes: SimpleChanges): void {
const userChange = changes['user'];
if (!userChange) return;
const previous = userChange.previousValue as AdminUserDirectorySummary | undefined;
const current = userChange.currentValue as AdminUserDirectorySummary;
if (!userChange.firstChange && previous?.id === current.id) return;
this.searchDraft = '';
this.search = '';
this.page = 1;
this.loadPlayers();
}
protected loadPlayers(): void {
this.playerRequests.next();
}
protected submitSearch(): void {
if (this.pendingPlayerId() !== null) return;
const search = this.searchDraft.trim();
if (this.loading() && search === this.search && this.page === 1) return;
this.search = search;
this.page = 1;
this.loadPlayers();
}
protected previousPage(): void {
if (this.page <= 1 || this.loading() || this.pendingPlayerId() !== null) return;
this.page -= 1;
this.loadPlayers();
}
protected nextPage(): void {
if (!this.players()?.hasNextPage || this.loading() || this.pendingPlayerId() !== null) return;
this.page += 1;
this.loadPlayers();
}
protected assign(player: AdminPlayerSummary): void {
this.runAssignment(player);
}
protected close(): void {
if (this.pendingPlayerId() === null) this.closed.emit();
}
protected confirmUnlink(player: AdminPlayerSummary): void {
this.confirm({
title: 'Verknüpfung lösen?',
message: `${player.firstName} ${player.lastName} wird von ${this.targetName()} getrennt.`,
confirmLabel: 'Verknüpfung lösen',
}).subscribe(() => this.runUnlink(player));
}
protected confirmReassign(player: AdminPlayerSummary): void {
this.confirm({
title: 'Spieler neu zuordnen?',
message: `${player.firstName} ${player.lastName} ist aktuell mit ${this.userName(player.currentUser!)} verknüpft und wird ${this.targetName()} zugeordnet.`,
confirmLabel: 'Neu zuordnen',
}).subscribe(() => this.runAssignment(player));
}
protected userName(user: { id: number; firstName: string | null; lastName: string | null }): string {
return [user.firstName, user.lastName].filter(Boolean).join(' ') || `Benutzer ${user.id}`;
}
private targetName(): string {
return this.userName(this.user);
}
private confirm(data: { title: string; message: string; confirmLabel: string }) {
return this.dialog
.open(ConfirmDialog, { data, restoreFocus: true })
.afterClosed()
.pipe(filter(Boolean), take(1), takeUntilDestroyed(this.destroyRef));
}
private runAssignment(player: AdminPlayerSummary): void {
if (this.pendingPlayerId() !== null) return;
this.setPending(player.id);
this.mutationError.set(null);
this.adminUsersApi
.assignPlayer(this.user.id, player.id)
.pipe(finalize(() => this.setPending(null)), takeUntilDestroyed(this.destroyRef))
.subscribe({
next: () => this.refreshAfterMutation(),
error: (error: HttpErrorResponse) => this.mutationError.set(this.mutationErrorMessage(error)),
});
}
private runUnlink(player: AdminPlayerSummary): void {
if (this.pendingPlayerId() !== null) return;
this.setPending(player.id);
this.mutationError.set(null);
this.adminUsersApi
.unlinkPlayer(this.user.id, player.id)
.pipe(finalize(() => this.setPending(null)), takeUntilDestroyed(this.destroyRef))
.subscribe({
next: () => this.refreshAfterMutation(),
error: (error: HttpErrorResponse) => this.mutationError.set(this.mutationErrorMessage(error)),
});
}
private refreshAfterMutation(): void {
this.directoryChanged.emit();
this.loadPlayers();
}
private setPending(playerId: number | null): void {
this.pendingPlayerId.set(playerId);
this.busyChange.emit(playerId !== null);
}
private loadErrorMessage(error: HttpErrorResponse): string {
const detail = typeof error.error?.message === 'string' ? error.error.message : '';
const fallback = error.status === 403 ? 'Keine Berechtigung.' : 'Spieler konnten nicht geladen werden.';
return detail ? `${fallback} ${detail}` : fallback;
}
private mutationErrorMessage(error: HttpErrorResponse): string {
const detail = typeof error.error?.message === 'string' ? error.error.message : '';
const fallback = error.status === 403 ? 'Keine Berechtigung.' : 'Zuordnung konnte nicht geändert werden.';
return detail ? `${fallback} ${detail}` : fallback;
}
}

View File

@@ -0,0 +1,98 @@
import { Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { AdminUserDirectorySummary, AdminUserRoleId } from '../../models/user-directory.model';
export interface UserEditValue {
firstName: string;
lastName: string;
role: AdminUserRoleId;
}
@Component({
selector: 'app-user-edit',
imports: [MatButtonModule, MatFormFieldModule, MatInputModule, MatSelectModule],
template: `
<form class="user-edit" (submit)="submit(); $event.preventDefault()" aria-label="Benutzer bearbeiten">
<div class="user-edit__fields">
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Vorname</mat-label>
<input matInput name="firstName" [value]="firstName" (input)="firstName = $any($event.target).value" required />
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Nachname</mat-label>
<input matInput name="lastName" [value]="lastName" (input)="lastName = $any($event.target).value" required />
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Globale Rolle</mat-label>
<select matNativeControl name="role" [value]="role" (change)="setRole($any($event.target).value)" [disabled]="self">
<option value="1">Administrator</option>
<option value="2">Benutzer</option>
</select>
</mat-form-field>
</div>
@if (self) {
<p class="user-edit__hint">Die eigene Administratorrolle kann hier nicht entzogen werden.</p>
}
<div class="user-edit__actions">
<button mat-button type="button" (click)="cancel.emit()" [disabled]="saving">Abbrechen</button>
<button mat-flat-button type="submit" [disabled]="saving || !firstName.trim() || !lastName.trim()">
{{ saving ? 'Wird gespeichert …' : 'Änderungen speichern' }}
</button>
</div>
</form>
`,
styles: `
.user-edit { padding: 18px; border-radius: 18px; background: var(--mat-sys-surface-container-low); }
.user-edit__fields { display: grid; grid-template-columns: 1fr 1fr 180px; gap: 12px; }
.user-edit__hint { margin: 10px 0 0; color: var(--mat-sys-on-surface-variant); }
.user-edit__actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; }
@media (max-width: 700px) {
.user-edit__fields { grid-template-columns: 1fr; }
.user-edit__actions { align-items: stretch; flex-direction: column-reverse; }
}
`,
})
export class UserEdit implements OnChanges {
@Input({ required: true }) user!: AdminUserDirectorySummary;
@Input() self = false;
@Input() saving = false;
@Output() readonly saved = new EventEmitter<UserEditValue>();
@Output() readonly cancel = new EventEmitter<void>();
protected firstName = '';
protected lastName = '';
protected role: AdminUserRoleId = 2;
ngOnChanges(changes: SimpleChanges): void {
const userChange = changes['user'];
if (!userChange) return;
const previous = userChange.previousValue as AdminUserDirectorySummary | undefined;
const current = userChange.currentValue as AdminUserDirectorySummary;
if (
!userChange.firstChange &&
previous?.id === current.id &&
previous.firstName === current.firstName &&
previous.lastName === current.lastName &&
previous.role?.id === current.role?.id
) {
return;
}
this.firstName = current.firstName ?? '';
this.lastName = current.lastName ?? '';
this.role = current.role?.id === 1 ? 1 : 2;
}
protected submit(): void {
const firstName = this.firstName.trim();
const lastName = this.lastName.trim();
if (!firstName || !lastName || this.saving) return;
this.saved.emit({ firstName, lastName, role: this.self ? 1 : this.role });
}
protected setRole(value: string): void {
this.role = value === '1' ? 1 : 2;
}
}

View File

@@ -0,0 +1,161 @@
<main class="users-page">
<a mat-button routerLink="/" class="back-link"><mat-icon>arrow_back</mat-icon>Zurück</a>
<header class="page-header">
<p class="eyebrow">Organisation</p>
<h1>Benutzer</h1>
<p>Konten und sichtbare Teamzuordnungen im Überblick.</p>
</header>
<form class="directory-search" (submit)="submitSearch(); $event.preventDefault()" role="search">
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Benutzer suchen</mat-label>
<mat-icon matPrefix>search</mat-icon>
<input
matInput
type="search"
name="directorySearch"
[value]="searchDraft()"
[disabled]="assignmentBusyUserId() !== null"
(input)="searchDraft.set($any($event.target).value)"
/>
</mat-form-field>
<button mat-stroked-button type="submit" [disabled]="loading() || assignmentBusyUserId() !== null">Suchen</button>
</form>
@if (mutationError()) {
<div class="message message--error" role="alert">{{ mutationError() }}</div>
}
@if (loading()) {
<div class="page-state" aria-live="polite">
<mat-spinner diameter="36" />
<span>Benutzer werden geladen …</span>
</div>
} @else if (loadError()) {
<div class="page-state page-state--error" role="alert">
<mat-icon>error_outline</mat-icon>
<strong>{{ loadError() }}</strong>
<button mat-stroked-button type="button" (click)="loadDirectory()">Erneut versuchen</button>
</div>
} @else if (directory()?.data?.length === 0) {
<div class="page-state">
<mat-icon>group_off</mat-icon>
<strong>Keine Benutzer gefunden</strong>
<span>Versuche einen anderen Suchbegriff.</span>
</div>
} @else if (directory()) {
<div class="directory" aria-live="polite">
@for (user of directory()!.data; track user.id) {
<article
class="user-row"
[attr.data-user-id]="user.id"
[attr.aria-busy]="pendingUserId() === user.id ? 'true' : null"
[class.user-row--inactive]="user.status?.id === 2"
>
<div class="user-row__summary">
<div class="avatar" aria-hidden="true">{{ initials(user) }}</div>
<div class="user-row__identity">
<div class="user-row__name">
<h2>{{ fullName(user) }}</h2>
<span class="status" [class.status--inactive]="user.status?.id === 2">
{{ statusName(user.status?.id) }}
</span>
</div>
@if (adminDetails(user); as details) {
<span>{{ details.email ?? 'Keine E-Mail' }} · {{ roleName(details.role?.id) }}</span>
}
@if (user.assignments.length === 0) {
<span>Keine Spielerzuordnung sichtbar</span>
} @else {
<div class="assignment-summary">
@for (assignment of user.assignments; track assignment.id) {
<span>
{{ assignment.team.name }} · {{ assignment.firstName }} {{ assignment.lastName }} ·
{{ teamRoleName(assignment.teamRole?.name) }}
@if (!assignment.active) { · Inaktiv }
</span>
}
</div>
}
</div>
</div>
@if (adminDetails(user); as details) {
<div class="user-row__actions">
<button mat-button type="button" [disabled]="assignmentBusyUserId() !== null" (click)="toggleEdit(user.id)">
Bearbeiten
</button>
<button
mat-button
type="button"
[disabled]="assignmentBusyUserId() !== null"
(click)="toggleAssignments(user.id)"
>
Zuordnungen verwalten
</button>
<button
mat-stroked-button
type="button"
[disabled]="isSelf(user) || pendingUserId() !== null"
[attr.title]="isSelf(user) ? 'Das eigene Konto kann nicht deaktiviert werden.' : null"
[attr.aria-describedby]="isSelf(user) ? 'self-status-note-' + user.id : null"
(click)="changeStatus(details)"
>
{{
pendingUserId() === user.id
? 'Status wird geändert …'
: user.status?.id === 2
? 'Aktivieren'
: 'Deaktivieren'
}}
</button>
</div>
@if (isSelf(user)) {
<p class="self-status-note" [id]="'self-status-note-' + user.id">
Das eigene Konto kann nicht deaktiviert werden.
</p>
}
@if (editingUserId() === user.id) {
<app-user-edit
[user]="details"
[self]="isSelf(user)"
[saving]="pendingUserId() === user.id"
(saved)="saveEdit(details, $event)"
(cancel)="editingUserId.set(null)"
/>
}
@if (assignmentUserId() === user.id) {
<app-player-assignments
[user]="details"
(directoryChanged)="assignmentsChanged()"
(closed)="assignmentUserId.set(null)"
(busyChange)="assignmentBusyChanged(user.id, $event)"
/>
}
}
</article>
}
</div>
<nav class="pagination" aria-label="Benutzerseiten">
<button
mat-button
type="button"
[disabled]="directory()!.page <= 1 || loading() || assignmentBusyUserId() !== null"
(click)="previousPage()"
>
<mat-icon>chevron_left</mat-icon>Zurück
</button>
<span>Seite {{ directory()!.page }} · {{ directory()!.total }} Benutzer</span>
<button
mat-button
type="button"
[disabled]="!directory()!.hasNextPage || loading() || assignmentBusyUserId() !== null"
(click)="nextPage()"
>
Weiter<mat-icon>chevron_right</mat-icon>
</button>
</nav>
}
</main>

View File

@@ -0,0 +1,198 @@
:host {
display: block;
min-height: 100dvh;
background: var(--mat-sys-surface);
}
.users-page {
max-width: 960px;
margin: 0 auto;
padding: 24px 28px 40px;
}
.back-link {
margin-left: -12px;
}
.page-header {
margin: 20px 0 26px;
}
h1,
h2,
p {
margin-top: 0;
}
h1 {
margin-bottom: 8px;
font-size: clamp(2rem, 4vw, 3rem);
}
.page-header > p:last-child {
color: var(--mat-sys-on-surface-variant);
}
.eyebrow {
margin-bottom: 6px;
color: var(--mat-sys-primary);
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.directory-search {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 22px;
}
.directory-search mat-form-field {
flex: 1;
}
.directory {
border-top: 1px solid var(--mat-sys-outline-variant);
}
.user-row {
display: grid;
gap: 16px;
padding: 20px 0;
border-bottom: 1px solid var(--mat-sys-outline-variant);
}
.user-row--inactive .avatar {
filter: grayscale(1);
opacity: 0.65;
}
.user-row__summary {
display: grid;
grid-template-columns: 48px minmax(0, 1fr);
gap: 14px;
}
.avatar {
width: 48px;
height: 48px;
display: grid;
place-items: center;
border-radius: 50%;
background: var(--mat-sys-primary-container);
color: var(--mat-sys-on-primary-container);
font-weight: 700;
}
.user-row__identity,
.assignment-summary {
display: grid;
gap: 4px;
min-width: 0;
}
.user-row__identity > span,
.assignment-summary span {
color: var(--mat-sys-on-surface-variant);
}
.user-row__name {
display: flex;
align-items: center;
gap: 10px;
}
.user-row__name h2 {
margin-bottom: 0;
font-size: 1.08rem;
}
.status {
display: inline-flex;
align-items: center;
min-height: 24px;
padding: 0 9px;
border-radius: 999px;
background: var(--mat-sys-primary-container);
color: var(--mat-sys-on-primary-container);
font-size: 0.75rem;
font-weight: 700;
}
.status--inactive {
background: var(--mat-sys-surface-container-high);
color: var(--mat-sys-on-surface-variant);
}
.user-row__actions {
display: flex;
justify-content: flex-end;
flex-wrap: wrap;
gap: 6px;
}
.self-status-note {
margin: -8px 0 0;
color: var(--mat-sys-on-surface-variant);
font-size: 0.8rem;
text-align: right;
}
.page-state {
min-height: 240px;
display: grid;
place-content: center;
justify-items: center;
gap: 10px;
padding: 24px;
color: var(--mat-sys-on-surface-variant);
text-align: center;
}
.page-state--error {
color: var(--mat-sys-error);
}
.message {
margin-bottom: 16px;
padding: 12px 14px;
border-radius: 12px;
}
.message--error {
color: var(--mat-sys-on-error-container);
background: var(--mat-sys-error-container);
}
.pagination {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 18px;
color: var(--mat-sys-on-surface-variant);
}
@media (max-width: 700px) {
.users-page {
padding: 20px 16px 32px;
}
.directory-search,
.user-row__actions {
align-items: stretch;
flex-direction: column;
}
.directory-search button,
.user-row__actions button {
width: 100%;
}
.pagination span {
font-size: 0.8rem;
text-align: center;
}
}

View File

@@ -0,0 +1,701 @@
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { provideHttpClient } from '@angular/common/http';
import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MatDialog } from '@angular/material/dialog';
import { provideRouter } from '@angular/router';
import { Subject } from 'rxjs';
import { environment } from '../../../environments/environment';
import { AuthStore } from '../../core/auth/auth-store';
import { AdminPlayerPage, AdminUserDirectorySummary, UserDirectoryPage } from '../../models/user-directory.model';
import { Users } from './users';
const api = `${environment.apiUrl}users/directory`;
const adminApi = `${environment.apiUrl}admin/users`;
const ada: AdminUserDirectorySummary = {
id: 7,
firstName: 'Ada',
lastName: 'Lovelace',
email: 'ada@example.test',
role: { id: 2, name: 'User' },
status: { id: 1, name: 'Active' },
assignments: [
{
id: 101,
firstName: 'Ada',
lastName: 'Lovelace',
active: true,
team: { id: 5, name: 'First Team', alias: 'first' },
teamRole: { id: 1, name: 'player' },
},
],
};
const admin: AdminUserDirectorySummary = {
id: 1,
firstName: 'Grace',
lastName: 'Admin',
email: 'grace@example.test',
role: { id: 1, name: 'Admin' },
status: { id: 1, name: 'Active' },
assignments: [],
};
function directoryPage(data = [ada], page = 1, total = data.length, hasNextPage = false): UserDirectoryPage {
return { data, page, limit: 20, total, hasNextPage };
}
function playersPage(overrides: Partial<AdminPlayerPage> = {}): AdminPlayerPage {
return {
data: [
{
id: 202,
firstName: 'Linus',
lastName: 'Player',
active: true,
team: { id: 6, name: 'Second Team', alias: 'second' },
currentUser: null,
},
],
page: 1,
limit: 20,
total: 1,
hasNextPage: false,
...overrides,
};
}
describe('Users directory', () => {
let fixture: ComponentFixture<Users>;
let http: HttpTestingController;
let isAdmin: ReturnType<typeof signal<boolean>>;
let currentUser: ReturnType<typeof signal<{ id: number; firstName: string; lastName: string; role: { id: number } }>>;
let updateUser: ReturnType<typeof vi.fn>;
let closeDialog: Subject<boolean>;
let dialog: { open: ReturnType<typeof vi.fn> };
beforeEach(async () => {
isAdmin = signal(false);
currentUser = signal({ id: 99, firstName: 'Nora', lastName: 'Viewer', role: { id: 2 } });
updateUser = vi.fn();
closeDialog = new Subject<boolean>();
dialog = { open: vi.fn(() => ({ afterClosed: () => closeDialog.asObservable() })) };
await TestBed.configureTestingModule({
imports: [Users],
providers: [
provideHttpClient(),
provideHttpClientTesting(),
provideRouter([]),
{ provide: AuthStore, useValue: { isGlobalAdmin: isAdmin, currentUser, updateUser } },
{ provide: MatDialog, useValue: dialog },
],
}).compileComponents();
http = TestBed.inject(HttpTestingController);
});
afterEach(() => http.verify({ ignoreCancelled: true }));
function create(): void {
fixture = TestBed.createComponent(Users);
fixture.detectChanges();
}
function text(): string {
return (fixture.nativeElement as HTMLElement).textContent ?? '';
}
function button(label: string): HTMLButtonElement {
const match = [...(fixture.nativeElement as HTMLElement).querySelectorAll('button')].find((element) =>
element.textContent?.includes(label),
);
if (!match) throw new Error(`Missing button: ${label}`);
return match as HTMLButtonElement;
}
it('renders unknown role and status values explicitly', () => {
create();
flushDirectory();
expect(fixture.componentInstance['statusName']()).toBe('Unbekannt');
expect(fixture.componentInstance['statusName'](99)).toBe('Unbekannt');
expect(fixture.componentInstance['roleName']()).toBe('Unbekannt');
expect(fixture.componentInstance['roleName'](99)).toBe('Unbekannt');
});
it('persists returned names when an admin edits their own profile', () => {
isAdmin.set(true);
currentUser.set({ id: 7, firstName: 'Ada', lastName: 'Lovelace', role: { id: 1 } });
create();
flushDirectory(directoryPage([admin, ada]));
fixture.componentInstance['saveEdit'](ada, {
firstName: 'Augusta',
lastName: 'King',
role: 2,
});
http.expectOne(`${adminApi}/7/profile`).flush({
...ada,
firstName: 'Augusta',
lastName: 'King',
});
http.expectOne(`${api}?page=1&limit=20`).flush(directoryPage());
expect(updateUser).toHaveBeenCalledWith({
id: 7,
firstName: 'Augusta',
lastName: 'King',
role: { id: 1 },
});
});
function childButton(host: HTMLElement, label: string): HTMLButtonElement {
const match = [...host.querySelectorAll('button')].find((element) => element.textContent?.includes(label));
if (!match) throw new Error(`Missing child button: ${label}`);
return match as HTMLButtonElement;
}
function flushDirectory(page = directoryPage()): void {
http.expectOne(`${api}?page=1&limit=20`).flush(page);
fixture.detectChanges();
}
it('loads the first directory page, searches from page one, and pages forward', () => {
create();
flushDirectory(directoryPage([ada], 1, 12, true));
expect(text()).toContain('Ada Lovelace');
expect(text()).toContain('First Team');
expect(text()).toContain('Aktiv');
const search = (fixture.nativeElement as HTMLElement).querySelector<HTMLInputElement>('input[type="search"]')!;
search.value = ' Linus ';
search.dispatchEvent(new Event('input'));
fixture.detectChanges();
search.closest('form')!.dispatchEvent(new Event('submit'));
fixture.detectChanges();
http.expectOne(`${api}?page=1&limit=20&search=Linus`).flush(directoryPage([], 1));
fixture.detectChanges();
search.value = '';
search.dispatchEvent(new Event('input'));
fixture.detectChanges();
search.closest('form')!.dispatchEvent(new Event('submit'));
fixture.detectChanges();
http.expectOne(`${api}?page=1&limit=20`).flush(directoryPage([ada], 1, 12, true));
fixture.detectChanges();
button('Weiter').click();
fixture.detectChanges();
http.expectOne(`${api}?page=2&limit=20`).flush(directoryPage([admin], 2, 12, false));
});
it('cancels an older directory load when a newer search starts', () => {
create();
const older = http.expectOne(`${api}?page=1&limit=20`);
const search = (fixture.nativeElement as HTMLElement).querySelector<HTMLInputElement>('input[type="search"]')!;
search.value = 'Grace';
search.dispatchEvent(new Event('input'));
search.closest('form')!.dispatchEvent(new Event('submit'));
const newer = http.expectOne(`${api}?page=1&limit=20&search=Grace`);
const olderWasCancelled = older.cancelled;
newer.flush(directoryPage([admin]));
if (!olderWasCancelled) older.flush(directoryPage([ada]));
fixture.detectChanges();
expect(olderWasCancelled).toBe(true);
expect(text()).toContain('Grace Admin');
expect(text()).not.toContain('Ada Lovelace');
});
it('redacts admin-only data and controls for a non-admin even if extra fields arrive', () => {
create();
flushDirectory(directoryPage([ada]));
expect(text()).toContain('Ada Lovelace');
expect(text()).not.toContain('ada@example.test');
expect(text()).not.toContain('Globale Rolle');
expect(text()).not.toContain('Bearbeiten');
expect(text()).not.toContain('Zuordnungen verwalten');
});
it('shows admin fields and actions while disabling self-demotion and self-deactivation', () => {
isAdmin.set(true);
currentUser.set({ id: 1, firstName: 'Grace', lastName: 'Admin', role: { id: 1 } });
create();
http.expectOne(`${api}?page=1&limit=20`).flush(directoryPage([admin, ada]));
fixture.detectChanges();
expect(text()).toContain('ada@example.test');
expect(text()).toContain('Administrator');
expect(text()).toContain('Bearbeiten');
expect(text()).toContain('Zuordnungen verwalten');
const selfRow = (fixture.nativeElement as HTMLElement).querySelector<HTMLElement>('[data-user-id="1"]')!;
const selfButtons = [...selfRow.querySelectorAll('button')];
expect(selfButtons.find((item) => item.textContent?.includes('Deaktivieren'))?.disabled).toBe(true);
selfButtons.find((item) => item.textContent?.includes('Bearbeiten'))?.click();
fixture.detectChanges();
expect(selfRow.querySelector<HTMLSelectElement>('select[name="role"]')?.disabled).toBe(true);
});
it('edits a profile and role pessimistically, then reloads the directory', () => {
isAdmin.set(true);
create();
flushDirectory();
button('Bearbeiten').click();
fixture.detectChanges();
const firstName = (fixture.nativeElement as HTMLElement).querySelector<HTMLInputElement>('input[name="firstName"]')!;
const lastName = (fixture.nativeElement as HTMLElement).querySelector<HTMLInputElement>('input[name="lastName"]')!;
const role = (fixture.nativeElement as HTMLElement).querySelector<HTMLSelectElement>('select[name="role"]')!;
firstName.value = 'Augusta';
firstName.dispatchEvent(new Event('input'));
lastName.value = 'King';
lastName.dispatchEvent(new Event('input'));
role.value = '1';
role.dispatchEvent(new Event('change'));
firstName.closest('form')!.dispatchEvent(new Event('submit'));
fixture.detectChanges();
const profile = http.expectOne(`${adminApi}/7/profile`);
expect(profile.request.body).toEqual({ firstName: 'Augusta', lastName: 'King' });
expect(text()).toContain('Wird gespeichert');
profile.flush(ada);
const roleRequest = http.expectOne(`${adminApi}/7/role`);
expect(roleRequest.request.body).toEqual({ role: 1 });
roleRequest.flush(ada);
http.expectOne(`${api}?page=1&limit=20`).flush(directoryPage([{ ...ada, firstName: 'Augusta', lastName: 'King' }]));
fixture.detectChanges();
expect(text()).toContain('Augusta King');
});
it('keeps the editor draft while saving input changes and on an initial profile failure', () => {
isAdmin.set(true);
create();
flushDirectory();
button('Bearbeiten').click();
fixture.detectChanges();
const firstName = (fixture.nativeElement as HTMLElement).querySelector<HTMLInputElement>('input[name="firstName"]')!;
firstName.value = 'Operator draft';
firstName.dispatchEvent(new Event('input'));
firstName.closest('form')!.dispatchEvent(new Event('submit'));
fixture.detectChanges();
expect(firstName.value).toBe('Operator draft');
http.expectOne(`${adminApi}/7/profile`).flush(
{ message: 'Profile rejected' },
{ status: 400, statusText: 'Bad Request' },
);
http.expectNone(`${api}?page=1&limit=20`);
fixture.detectChanges();
expect((fixture.nativeElement as HTMLElement).querySelector<HTMLInputElement>('input[name="firstName"]')!.value).toBe(
'Operator draft',
);
expect(text()).toContain('Profile rejected');
});
it('reloads authoritative directory state when a role change fails after the profile was saved', () => {
isAdmin.set(true);
create();
flushDirectory();
button('Bearbeiten').click();
fixture.detectChanges();
const role = (fixture.nativeElement as HTMLElement).querySelector<HTMLSelectElement>('select[name="role"]')!;
role.value = '1';
role.dispatchEvent(new Event('change'));
role.closest('form')!.dispatchEvent(new Event('submit'));
http.expectOne(`${adminApi}/7/profile`).flush(ada);
http.expectOne(`${adminApi}/7/role`).flush(
{ message: 'At least one active admin must remain' },
{ status: 409, statusText: 'Conflict' },
);
http.expectOne(`${api}?page=1&limit=20`).flush(directoryPage());
fixture.detectChanges();
expect(text()).toContain('At least one active admin must remain');
});
it('confirms status changes before mutating and refreshes after success', () => {
isAdmin.set(true);
create();
flushDirectory();
button('Deaktivieren').click();
expect(dialog.open).toHaveBeenCalled();
expect(dialog.open.mock.calls[0][1].data.message).toContain('Ada Lovelace');
http.expectNone(`${adminApi}/7/status`);
closeDialog.next(true);
const request = http.expectOne(`${adminApi}/7/status`);
expect(request.request.body).toEqual({ status: 2 });
fixture.detectChanges();
expect(text()).toContain('Status wird geändert');
expect((fixture.nativeElement as HTMLElement).querySelector('[data-user-id="7"]')?.getAttribute('aria-busy')).toBe('true');
request.flush(ada);
http.expectOne(`${api}?page=1&limit=20`).flush(directoryPage([{ ...ada, status: { id: 2, name: 'Inactive' } }]));
});
it('searches players from page one after paging and assigns before refreshing both lists', () => {
isAdmin.set(true);
create();
flushDirectory();
button('Zuordnungen verwalten').click();
fixture.detectChanges();
const panel = (fixture.nativeElement as HTMLElement).querySelector<HTMLElement>('app-player-assignments')!;
expect(panel.textContent).toContain('Spieler werden geladen');
http.expectOne(`${adminApi}/players?assignment=all&page=1&limit=20`).flush(playersPage({ hasNextPage: true, total: 21 }));
fixture.detectChanges();
expect(panel.querySelector('nav[aria-label="Spielerseiten"]')).not.toBeNull();
childButton(panel, 'Weiter').click();
http.expectOne(`${adminApi}/players?assignment=all&page=2&limit=20`).flush(playersPage({ page: 2 }));
fixture.detectChanges();
const playerSearch = panel.querySelector<HTMLInputElement>('input[type="search"]')!;
playerSearch.value = 'Linus';
playerSearch.dispatchEvent(new Event('input'));
playerSearch.closest('form')!.dispatchEvent(new Event('submit'));
http.expectOne(`${adminApi}/players?search=Linus&assignment=all&page=1&limit=20`).flush(playersPage());
fixture.detectChanges();
expect(text()).toContain('Linus Player');
expect(text()).toContain('Second Team');
childButton(panel, 'Zuordnen').click();
const assign = http.expectOne(`${adminApi}/7/players/202`);
expect(assign.request.method).toBe('PUT');
assign.flush(ada);
http.expectOne(`${api}?page=1&limit=20`).flush(directoryPage());
http.expectOne(`${adminApi}/players?search=Linus&assignment=all&page=1&limit=20`).flush(playersPage());
});
it('cancels an older player load when a newer player search starts', () => {
isAdmin.set(true);
create();
flushDirectory();
button('Zuordnungen verwalten').click();
fixture.detectChanges();
const older = http.expectOne(`${adminApi}/players?assignment=all&page=1&limit=20`);
const panel = (fixture.nativeElement as HTMLElement).querySelector<HTMLElement>('app-player-assignments')!;
const playerSearch = panel.querySelector<HTMLInputElement>('input[type="search"]')!;
playerSearch.value = 'New';
playerSearch.dispatchEvent(new Event('input'));
playerSearch.closest('form')!.dispatchEvent(new Event('submit'));
const newer = http.expectOne(`${adminApi}/players?search=New&assignment=all&page=1&limit=20`);
const olderWasCancelled = older.cancelled;
newer.flush(playersPage({ data: [{ ...playersPage().data[0], firstName: 'New' }] }));
if (!olderWasCancelled) older.flush(playersPage());
fixture.detectChanges();
expect(olderWasCancelled).toBe(true);
expect(panel.textContent).toContain('New Player');
expect(panel.textContent).not.toContain('Linus Player');
});
it('clears stale player rows on load errors and retries with load-specific copy', () => {
isAdmin.set(true);
create();
flushDirectory();
button('Zuordnungen verwalten').click();
fixture.detectChanges();
http.expectOne(`${adminApi}/players?assignment=all&page=1&limit=20`).flush(playersPage());
fixture.detectChanges();
const panel = (fixture.nativeElement as HTMLElement).querySelector<HTMLElement>('app-player-assignments')!;
expect(panel.textContent).toContain('Linus Player');
const playerSearch = panel.querySelector<HTMLInputElement>('input[type="search"]')!;
playerSearch.value = 'broken';
playerSearch.dispatchEvent(new Event('input'));
playerSearch.closest('form')!.dispatchEvent(new Event('submit'));
http.expectOne(`${adminApi}/players?search=broken&assignment=all&page=1&limit=20`).flush(
{ message: 'Search unavailable' },
{ status: 500, statusText: 'Server Error' },
);
fixture.detectChanges();
expect(panel.textContent).not.toContain('Linus Player');
expect(panel.textContent).toContain('Spieler konnten nicht geladen werden');
expect(panel.textContent).toContain('Search unavailable');
childButton(panel, 'Erneut versuchen').click();
http.expectOne(`${adminApi}/players?search=broken&assignment=all&page=1&limit=20`).flush(playersPage());
});
it('confirms unlinking and reassignment with the affected user names', () => {
isAdmin.set(true);
create();
flushDirectory();
button('Zuordnungen verwalten').click();
fixture.detectChanges();
http.expectOne(`${adminApi}/players?assignment=all&page=1&limit=20`).flush(
playersPage({
data: [
{
id: 101,
firstName: 'Ada',
lastName: 'Lovelace',
active: true,
team: { id: 5, name: 'First Team', alias: 'first' },
currentUser: { id: 7, firstName: 'Ada', lastName: 'Lovelace', status: { id: 1, name: 'Active' } },
},
{
id: 203,
firstName: 'Other',
lastName: 'Player',
active: true,
team: { id: 6, name: 'Second Team', alias: 'second' },
currentUser: { id: 8, firstName: 'Alan', lastName: 'Turing', status: { id: 1, name: 'Active' } },
},
],
total: 2,
}),
);
fixture.detectChanges();
button('Verknüpfung lösen').click();
expect(dialog.open.mock.calls[0][1].data.message).toContain('Ada Lovelace');
closeDialog.next(false);
http.expectNone(`${adminApi}/7/players/101`);
closeDialog = new Subject<boolean>();
dialog.open.mockReturnValue({ afterClosed: () => closeDialog.asObservable() });
button('Neu zuordnen').click();
const message = dialog.open.mock.calls[1][1].data.message;
expect(message).toContain('Alan Turing');
expect(message).toContain('Ada Lovelace');
closeDialog.next(true);
http.expectOne(`${adminApi}/7/players/203`).flush(ada);
http.expectOne(`${api}?page=1&limit=20`).flush(directoryPage());
http.expectOne(`${adminApi}/players?assignment=all&page=1&limit=20`).flush(playersPage());
});
it('keeps an unlink pending until success and then reloads directory and player results', () => {
isAdmin.set(true);
create();
flushDirectory();
button('Zuordnungen verwalten').click();
fixture.detectChanges();
http.expectOne(`${adminApi}/players?assignment=all&page=1&limit=20`).flush(
playersPage({
data: [
{
id: 101,
firstName: 'Ada',
lastName: 'Lovelace',
active: true,
team: { id: 5, name: 'First Team', alias: 'first' },
currentUser: { id: 7, firstName: 'Ada', lastName: 'Lovelace', status: { id: 1, name: 'Active' } },
},
],
}),
);
fixture.detectChanges();
button('Verknüpfung lösen').click();
http.expectNone(`${adminApi}/7/players/101`);
closeDialog.next(true);
fixture.detectChanges();
const unlink = http.expectOne(`${adminApi}/7/players/101`);
expect(unlink.request.method).toBe('DELETE');
expect(text()).toContain('Wird gelöst');
const panel = (fixture.nativeElement as HTMLElement).querySelector<HTMLElement>('app-player-assignments')!;
expect(panel.querySelector('section')?.getAttribute('aria-busy')).toBe('true');
expect(childButton(panel, 'Schließen').disabled).toBe(true);
expect(childButton(panel, 'Suchen').disabled).toBe(true);
const mainToggle = [...(fixture.nativeElement as HTMLElement).querySelectorAll<HTMLButtonElement>('[data-user-id="7"] > .user-row__actions button')].find(
(item) => item.textContent?.includes('Zuordnungen verwalten'),
)!;
expect(mainToggle.disabled).toBe(true);
childButton(panel, 'Schließen').click();
fixture.detectChanges();
expect((fixture.nativeElement as HTMLElement).querySelector('app-player-assignments')).not.toBeNull();
unlink.flush(ada);
http.expectOne(`${api}?page=1&limit=20`).flush(directoryPage());
http.expectOne(`${adminApi}/players?assignment=all&page=1&limit=20`).flush(playersPage());
});
it('blocks directory search and paging while an assignment mutation is pending, then restores them', () => {
isAdmin.set(true);
create();
http.expectOne(`${api}?page=1&limit=20`).flush(directoryPage([ada], 1, 60, true));
fixture.detectChanges();
button('Weiter').click();
http.expectOne(`${api}?page=2&limit=20`).flush(directoryPage([ada], 2, 60, true));
fixture.detectChanges();
button('Zuordnungen verwalten').click();
fixture.detectChanges();
http.expectOne(`${adminApi}/players?assignment=all&page=1&limit=20`).flush(
playersPage({
data: [
{
id: 101,
firstName: 'Ada',
lastName: 'Lovelace',
active: true,
team: { id: 5, name: 'First Team', alias: 'first' },
currentUser: { id: 7, firstName: 'Ada', lastName: 'Lovelace', status: { id: 1, name: 'Active' } },
},
],
}),
);
fixture.detectChanges();
childButton(
(fixture.nativeElement as HTMLElement).querySelector<HTMLElement>('app-player-assignments')!,
'Verknüpfung lösen',
).click();
closeDialog.next(true);
fixture.detectChanges();
const unlink = http.expectOne(`${adminApi}/7/players/101`);
const directoryForm = (fixture.nativeElement as HTMLElement).querySelector<HTMLFormElement>('.directory-search')!;
const directorySearch = directoryForm.querySelector<HTMLInputElement>('input[type="search"]')!;
const directorySearchButton = directoryForm.querySelector<HTMLButtonElement>('button[type="submit"]')!;
const paging = (fixture.nativeElement as HTMLElement).querySelector<HTMLElement>('nav.pagination')!;
const [previous, next] = [...paging.querySelectorAll<HTMLButtonElement>('button')];
const controlsWereBlocked = directorySearch.disabled && directorySearchButton.disabled && previous.disabled && next.disabled;
directorySearch.value = 'Other';
directorySearch.dispatchEvent(new Event('input'));
directoryForm.dispatchEvent(new Event('submit'));
previous.click();
next.click();
fixture.detectChanges();
const unexpectedQueries = http.match((request) => request.url.startsWith(api));
const mutationStayedActive = !unlink.cancelled;
if (controlsWereBlocked && unexpectedQueries.length === 0 && mutationStayedActive) {
unlink.flush(ada);
http.expectOne(`${api}?page=2&limit=20`).flush(directoryPage([ada], 2, 60, true));
http.expectOne(`${adminApi}/players?assignment=all&page=1&limit=20`).flush(playersPage());
fixture.detectChanges();
expect(directorySearch.disabled).toBe(false);
expect(directorySearchButton.disabled).toBe(false);
expect(previous.disabled).toBe(false);
expect(next.disabled).toBe(false);
directoryForm.dispatchEvent(new Event('submit'));
http.expectOne(`${api}?page=1&limit=20&search=Other`).flush(directoryPage([]));
} else {
for (const query of unexpectedQueries) query.flush(directoryPage([ada], 1, 60, true));
fixture.detectChanges();
if (!unlink.cancelled) unlink.flush(ada);
for (const playerLoad of http.match((request) => request.url.startsWith(`${adminApi}/players`))) {
playerLoad.flush(playersPage());
}
}
expect(controlsWereBlocked).toBe(true);
expect(unexpectedQueries).toHaveLength(0);
expect(mutationStayedActive).toBe(true);
});
it('keeps directory controls blocked until a failed assignment mutation clears', () => {
isAdmin.set(true);
create();
http.expectOne(`${api}?page=1&limit=20`).flush(directoryPage([ada], 1, 60, true));
fixture.detectChanges();
button('Weiter').click();
http.expectOne(`${api}?page=2&limit=20`).flush(directoryPage([ada], 2, 60, true));
fixture.detectChanges();
button('Zuordnungen verwalten').click();
fixture.detectChanges();
http.expectOne(`${adminApi}/players?assignment=all&page=1&limit=20`).flush(
playersPage({
data: [
{
id: 101,
firstName: 'Ada',
lastName: 'Lovelace',
active: true,
team: { id: 5, name: 'First Team', alias: 'first' },
currentUser: { id: 7, firstName: 'Ada', lastName: 'Lovelace', status: { id: 1, name: 'Active' } },
},
],
}),
);
fixture.detectChanges();
const panel = (fixture.nativeElement as HTMLElement).querySelector<HTMLElement>('app-player-assignments')!;
panel.querySelector<HTMLButtonElement>('.player-row button')!.click();
closeDialog.next(true);
fixture.detectChanges();
const unlink = http.expectOne(`${adminApi}/7/players/101`);
const directoryForm = (fixture.nativeElement as HTMLElement).querySelector<HTMLFormElement>('.directory-search')!;
const directorySearch = directoryForm.querySelector<HTMLInputElement>('input[type="search"]')!;
const directorySearchButton = directoryForm.querySelector<HTMLButtonElement>('button[type="submit"]')!;
const [previous, next] = [
...(fixture.nativeElement as HTMLElement).querySelectorAll<HTMLButtonElement>('nav.pagination button'),
];
expect(directorySearch.disabled).toBe(true);
expect(directorySearchButton.disabled).toBe(true);
expect(previous.disabled).toBe(true);
expect(next.disabled).toBe(true);
directorySearch.value = 'Other';
directorySearch.dispatchEvent(new Event('input'));
directoryForm.dispatchEvent(new Event('submit'));
previous.click();
next.click();
fixture.detectChanges();
expect(http.match((request) => request.url.startsWith(api))).toHaveLength(0);
expect(unlink.cancelled).toBe(false);
unlink.flush({ message: 'Assignment failed' }, { status: 500, statusText: 'Server Error' });
fixture.detectChanges();
expect(text()).toContain('Zuordnung konnte nicht geändert werden. Assignment failed');
expect(directorySearch.disabled).toBe(false);
expect(directorySearchButton.disabled).toBe(false);
expect(previous.disabled).toBe(false);
expect(next.disabled).toBe(false);
expect(http.match((request) => request.url.startsWith(api))).toHaveLength(0);
directoryForm.dispatchEvent(new Event('submit'));
http.expectOne(`${api}?page=1&limit=20&search=Other`).flush(directoryPage([]));
});
it('shows a persistent explanation for disabled self-deactivation', () => {
isAdmin.set(true);
currentUser.set({ id: 1, firstName: 'Grace', lastName: 'Admin', role: { id: 1 } });
create();
http.expectOne(`${api}?page=1&limit=20`).flush(directoryPage([admin]));
fixture.detectChanges();
const row = (fixture.nativeElement as HTMLElement).querySelector<HTMLElement>('[data-user-id="1"]')!;
const statusButton = [...row.querySelectorAll('button')].find((item) => item.textContent?.includes('Deaktivieren'))!;
const descriptionId = statusButton.getAttribute('aria-describedby');
expect(descriptionId).toBeTruthy();
expect((fixture.nativeElement as HTMLElement).querySelector(`#${descriptionId}`)?.textContent).toContain(
'eigene Konto kann nicht deaktiviert werden',
);
});
it('renders loading, empty, general error, and retry states', () => {
create();
expect((fixture.nativeElement as HTMLElement).querySelector('[role="progressbar"]')).not.toBeNull();
http.expectOne(`${api}?page=1&limit=20`).flush('broken', { status: 500, statusText: 'Server Error' });
fixture.detectChanges();
expect(text()).toContain('Benutzer konnten nicht geladen werden');
button('Erneut versuchen').click();
http.expectOne(`${api}?page=1&limit=20`).flush(directoryPage([]));
fixture.detectChanges();
expect(text()).toContain('Keine Benutzer gefunden');
});
it('surfaces directory and mutation authorization errors clearly', () => {
create();
http.expectOne(`${api}?page=1&limit=20`).flush({ message: 'Forbidden' }, { status: 403, statusText: 'Forbidden' });
fixture.detectChanges();
expect(text()).toContain('Keine Berechtigung');
isAdmin.set(true);
button('Erneut versuchen').click();
http.expectOne(`${api}?page=1&limit=20`).flush(directoryPage());
fixture.detectChanges();
button('Deaktivieren').click();
closeDialog.next(true);
http.expectOne(`${adminApi}/7/status`).flush(
{ message: 'At least one active admin must remain' },
{ status: 403, statusText: 'Forbidden' },
);
fixture.detectChanges();
expect(text()).toContain('Keine Berechtigung');
expect(text()).toContain('At least one active admin must remain');
});
});

View File

@@ -0,0 +1,254 @@
import { HttpErrorResponse } from '@angular/common/http';
import { Component, DestroyRef, inject, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { RouterLink } from '@angular/router';
import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { EMPTY, Subject, catchError, filter, finalize, of, startWith, switchMap, take, tap } from 'rxjs';
import { AuthStore } from '../../core/auth/auth-store';
import { AdminUsersApi } from '../../core/users/admin-users-api';
import { UsersApi } from '../../core/users/users-api';
import {
AdminUserDirectorySummary,
AdminUserStatusId,
UserDirectoryPage,
UserDirectoryRecord,
} from '../../models/user-directory.model';
import { ConfirmDialog } from '../../shared/confirm-dialog/confirm-dialog';
import { PlayerAssignments } from './player-assignments';
import { UserEdit, UserEditValue } from './user-edit';
@Component({
selector: 'app-users',
imports: [
RouterLink,
MatButtonModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
MatProgressSpinnerModule,
PlayerAssignments,
UserEdit,
],
templateUrl: './users.html',
styleUrl: './users.scss',
})
export class Users {
private readonly usersApi = inject(UsersApi);
private readonly adminUsersApi = inject(AdminUsersApi);
private readonly authStore = inject(AuthStore);
private readonly dialog = inject(MatDialog);
private readonly destroyRef = inject(DestroyRef);
private readonly directoryRequests = new Subject<boolean>();
protected readonly isAdmin = this.authStore.isGlobalAdmin;
protected readonly currentUser = this.authStore.currentUser;
protected readonly directory = signal<UserDirectoryPage | null>(null);
protected readonly loading = signal(true);
protected readonly loadError = signal<string | null>(null);
protected readonly mutationError = signal<string | null>(null);
protected readonly searchDraft = signal('');
protected readonly search = signal('');
protected readonly page = signal(1);
protected readonly limit = 20;
protected readonly editingUserId = signal<number | null>(null);
protected readonly assignmentUserId = signal<number | null>(null);
protected readonly assignmentBusyUserId = signal<number | null>(null);
protected readonly pendingUserId = signal<number | null>(null);
constructor() {
this.directoryRequests
.pipe(
startWith(true),
switchMap((showLoading) => {
if (showLoading) this.loading.set(true);
this.loadError.set(null);
const search = this.search();
return this.usersApi
.loadDirectory({ page: this.page(), limit: this.limit, ...(search ? { search } : {}) })
.pipe(
tap((directory) => this.directory.set(directory)),
catchError((error: HttpErrorResponse) => {
this.directory.set(null);
this.loadError.set(this.errorMessage(error, 'Benutzer konnten nicht geladen werden.'));
return EMPTY;
}),
finalize(() => this.loading.set(false)),
);
}),
takeUntilDestroyed(this.destroyRef),
)
.subscribe();
}
protected loadDirectory(showLoading = true): void {
this.directoryRequests.next(showLoading);
}
protected submitSearch(): void {
if (this.assignmentBusyUserId() !== null) return;
const search = this.searchDraft().trim();
if (this.loading() && search === this.search() && this.page() === 1) return;
this.search.set(search);
this.page.set(1);
this.loadDirectory();
}
protected previousPage(): void {
if (this.page() <= 1 || this.loading() || this.assignmentBusyUserId() !== null) return;
this.page.update((value) => value - 1);
this.loadDirectory();
}
protected nextPage(): void {
if (!this.directory()?.hasNextPage || this.loading() || this.assignmentBusyUserId() !== null) return;
this.page.update((value) => value + 1);
this.loadDirectory();
}
protected adminDetails(user: UserDirectoryRecord): AdminUserDirectorySummary | null {
if (!this.isAdmin()) return null;
return 'email' in user && 'role' in user ? user : null;
}
protected fullName(user: UserDirectoryRecord): string {
return [user.firstName, user.lastName].filter(Boolean).join(' ') || `Benutzer ${user.id}`;
}
protected initials(user: UserDirectoryRecord): string {
const value = `${user.firstName?.charAt(0) ?? ''}${user.lastName?.charAt(0) ?? ''}`.trim();
return value || '?';
}
protected statusName(statusId?: number): string {
if (statusId === 1) return 'Aktiv';
if (statusId === 2) return 'Inaktiv';
return 'Unbekannt';
}
protected roleName(roleId?: number): string {
if (roleId === 1) return 'Administrator';
if (roleId === 2) return 'Benutzer';
return 'Unbekannt';
}
protected teamRoleName(name?: string): string {
return (
{
player: 'Spieler',
scnd_treasurer: '2. Kassenwart',
captain: 'Kapitän',
treasurer: 'Kassenwart',
coach: 'Trainer',
}[name ?? ''] ?? 'Spieler'
);
}
protected toggleEdit(userId: number): void {
if (this.assignmentBusyUserId() !== null) return;
this.assignmentUserId.set(null);
this.editingUserId.update((value) => (value === userId ? null : userId));
this.mutationError.set(null);
}
protected toggleAssignments(userId: number): void {
if (this.assignmentBusyUserId() !== null) return;
this.editingUserId.set(null);
this.assignmentUserId.update((value) => (value === userId ? null : userId));
this.mutationError.set(null);
}
protected saveEdit(user: AdminUserDirectorySummary, value: UserEditValue): void {
if (this.pendingUserId() !== null) return;
this.pendingUserId.set(user.id);
this.mutationError.set(null);
const profileRequest = this.adminUsersApi.updateProfile(user.id, {
firstName: value.firstName,
lastName: value.lastName,
});
const roleId = user.role?.id;
let profileSaved = false;
profileRequest
.pipe(
tap((updatedUser) => {
profileSaved = true;
const currentUser = this.currentUser();
if (currentUser?.id === updatedUser.id) {
this.authStore.updateUser({
...currentUser,
firstName: updatedUser.firstName,
lastName: updatedUser.lastName,
});
}
}),
switchMap(() =>
roleId === value.role ? of(user) : this.adminUsersApi.updateRole(user.id, { role: value.role }),
),
finalize(() => this.pendingUserId.set(null)),
takeUntilDestroyed(this.destroyRef),
)
.subscribe({
next: () => {
this.editingUserId.set(null);
this.loadDirectory();
},
error: (error: HttpErrorResponse) => {
this.mutationError.set(this.errorMessage(error, 'Änderung fehlgeschlagen.'));
if (profileSaved) this.loadDirectory();
},
});
}
protected changeStatus(user: AdminUserDirectorySummary): void {
if (this.isSelf(user) || this.pendingUserId() !== null) return;
const isActive = user.status?.id !== 2;
const status: AdminUserStatusId = isActive ? 2 : 1;
this.dialog
.open(ConfirmDialog, {
data: {
title: isActive ? 'Benutzer deaktivieren?' : 'Benutzer aktivieren?',
message: `${this.fullName(user)} wird ${isActive ? 'deaktiviert' : 'aktiviert'}.`,
confirmLabel: isActive ? 'Deaktivieren' : 'Aktivieren',
},
restoreFocus: true,
})
.afterClosed()
.pipe(filter(Boolean), take(1), takeUntilDestroyed(this.destroyRef))
.subscribe(() => this.updateStatus(user.id, status));
}
protected isSelf(user: UserDirectoryRecord): boolean {
return user.id === this.currentUser()?.id;
}
protected assignmentsChanged(): void {
this.loadDirectory(false);
}
protected assignmentBusyChanged(userId: number, busy: boolean): void {
this.assignmentBusyUserId.set(busy ? userId : null);
}
private updateStatus(userId: number, status: AdminUserStatusId): void {
this.pendingUserId.set(userId);
this.mutationError.set(null);
this.adminUsersApi
.updateStatus(userId, { status })
.pipe(finalize(() => this.pendingUserId.set(null)), takeUntilDestroyed(this.destroyRef))
.subscribe({
next: () => this.loadDirectory(),
error: (error: HttpErrorResponse) =>
this.mutationError.set(this.errorMessage(error, 'Status konnte nicht geändert werden.')),
});
}
private errorMessage(error: HttpErrorResponse, fallback: string): string {
const detail = typeof error.error?.message === 'string' ? error.error.message : '';
if (error.status === 403) return `Keine Berechtigung. ${detail}`.trim();
return detail ? `${fallback} ${detail}` : fallback;
}
}

View File

@@ -0,0 +1,116 @@
export interface UserDirectoryReference {
id: number;
name?: string;
}
export interface UserDirectoryTeam {
id: number;
name: string;
alias: string;
}
export interface UserDirectoryAssignment {
id: number;
firstName: string;
lastName: string;
active: boolean;
team: UserDirectoryTeam;
teamRole: UserDirectoryReference | null;
}
export interface UserDirectorySummary {
id: number;
firstName: string | null;
lastName: string | null;
status: UserDirectoryReference | null;
assignments: UserDirectoryAssignment[];
}
export interface AdminUserDirectorySummary extends UserDirectorySummary {
email: string | null;
role: UserDirectoryReference | null;
}
export type UserDirectoryRecord = UserDirectorySummary | AdminUserDirectorySummary;
export interface UserDirectoryPage {
data: UserDirectoryRecord[];
page: number;
limit: number;
total: number;
hasNextPage: boolean;
}
export interface UserDirectoryFilters {
page?: number;
limit?: number;
search?: string;
}
export interface AdminUserProfileRequest {
firstName?: string;
lastName?: string;
}
export type AdminUserRoleId = 1 | 2;
export type AdminUserStatusId = 1 | 2;
export interface AdminUserRoleRequest {
role: AdminUserRoleId;
}
export interface AdminUserStatusRequest {
status: AdminUserStatusId;
}
export type AdminPlayerAssignmentFilter = 'all' | 'assigned' | 'unassigned';
export interface AdminPlayerFilters {
search?: string;
teamId?: number;
assignment?: AdminPlayerAssignmentFilter;
page?: number;
limit?: number;
}
export interface AdminPlayerTeam {
id: number;
name: string;
alias: string;
}
export interface AdminPlayerCurrentUser {
id: number;
firstName: string | null;
lastName: string | null;
status: UserDirectoryReference | null;
}
export interface AdminPlayerSummary {
id: number;
firstName: string;
lastName: string;
active: boolean;
team: AdminPlayerTeam;
currentUser: AdminPlayerCurrentUser | null;
}
export interface AdminPlayerPage {
data: AdminPlayerSummary[];
page: number;
limit: number;
total: number;
hasNextPage: boolean;
}
export interface UserTeamReference {
id: number;
name: string;
}
export interface UserTeamMembership {
id: number;
firstName: string;
lastName: string;
team: UserTeamReference;
}