Compare commits
16 Commits
1288f5fc60
...
e3bff40181
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3bff40181 | ||
|
|
945369796e | ||
|
|
431eba86e7 | ||
|
|
d962200b79 | ||
|
|
9eec6268d0 | ||
|
|
9dc9dc3dcf | ||
|
|
3ae0fd2000 | ||
|
|
d0ec000bff | ||
|
|
484c7473fb | ||
|
|
78761b570b | ||
|
|
bec1826bfa | ||
|
|
e6acfdcac7 | ||
|
|
4105460400 | ||
|
|
d738b49cbf | ||
|
|
c382234746 | ||
|
|
bcb4207233 |
56
docs/plans/admin-user-management.md
Normal file
56
docs/plans/admin-user-management.md
Normal 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.
|
||||
30
myteamwallet_backend/src/auth/auth.controller.spec.ts
Normal file
30
myteamwallet_backend/src/auth/auth.controller.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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:
|
||||
|
||||
291
myteamwallet_backend/src/auth/auth.service.spec.ts
Normal file
291
myteamwallet_backend/src/auth/auth.service.spec.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
user = await this.usersService.findOne({
|
||||
socialId: socialData.id,
|
||||
provider: authProvider,
|
||||
});
|
||||
|
||||
if (user) {
|
||||
if (socialEmail && !userByEmail) {
|
||||
user.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,
|
||||
});
|
||||
}
|
||||
await this.usersService.update(user.id, user);
|
||||
} else if (userByEmail) {
|
||||
user = userByEmail;
|
||||
} else {
|
||||
const role = plainToClass(Role, {
|
||||
id: RoleEnum.user,
|
||||
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: { 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 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,
|
||||
});
|
||||
const status = plainToClass(Status, {
|
||||
id: StatusEnum.active,
|
||||
});
|
||||
|
||||
user = await this.usersService.create({
|
||||
email: socialEmail,
|
||||
firstName: socialData.firstName,
|
||||
lastName: socialData.lastName,
|
||||
socialId: socialData.id,
|
||||
provider: authProvider,
|
||||
role,
|
||||
status,
|
||||
});
|
||||
|
||||
user = await this.usersService.findOne({
|
||||
id: user.id,
|
||||
});
|
||||
}
|
||||
|
||||
const jwtToken = await this.jwtService.sign({
|
||||
id: user.id,
|
||||
role: user.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,25 +217,26 @@ 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(
|
||||
{
|
||||
status: HttpStatus.NOT_FOUND,
|
||||
error: `notFound`,
|
||||
},
|
||||
HttpStatus.NOT_FOUND,
|
||||
);
|
||||
}
|
||||
user.hash = null;
|
||||
user.status = plainToClass(Status, { id: StatusEnum.active });
|
||||
await repository.save(user);
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new HttpException(
|
||||
{
|
||||
status: HttpStatus.NOT_FOUND,
|
||||
error: `notFound`,
|
||||
},
|
||||
HttpStatus.NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
user.hash = null;
|
||||
user.status = plainToClass(Status, {
|
||||
id: StatusEnum.active,
|
||||
});
|
||||
await user.save();
|
||||
}
|
||||
|
||||
async forgotPassword(email: string): Promise<void> {
|
||||
@@ -281,40 +301,31 @@ 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}`,
|
||||
userId: user.id,
|
||||
});
|
||||
return user;
|
||||
}
|
||||
await this.logger.debug({
|
||||
event: 'user_token_verification_success',
|
||||
details: `userId=${user.id}`,
|
||||
userId: user.id,
|
||||
});
|
||||
return user;
|
||||
}
|
||||
|
||||
async update(user: User, userDto: AuthUpdateDto): Promise<User> {
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,4 @@ export class AuthRegisterLoginDto {
|
||||
@ApiProperty({ example: 'Doe' })
|
||||
@IsNotEmpty()
|
||||
lastName: string;
|
||||
|
||||
@ApiProperty({ example: 27 })
|
||||
linkPlayerId: number | null;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
expect(transactionRepository.save).toHaveBeenCalledWith({
|
||||
...event,
|
||||
level: 'INFO',
|
||||
});
|
||||
expect(defaultRepository.save).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,22 +23,25 @@ export class LoggingService {
|
||||
});
|
||||
}
|
||||
|
||||
async info({
|
||||
event,
|
||||
details,
|
||||
userId,
|
||||
}: {
|
||||
event: LOGEVENT;
|
||||
details: string;
|
||||
userId: number;
|
||||
}) {
|
||||
async info(
|
||||
{
|
||||
event,
|
||||
details,
|
||||
userId,
|
||||
}: {
|
||||
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({
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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"');
|
||||
}
|
||||
}
|
||||
@@ -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']),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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[];
|
||||
|
||||
|
||||
145
myteamwallet_backend/src/users/admin-users.controller.spec.ts
Normal file
145
myteamwallet_backend/src/users/admin-users.controller.spec.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
102
myteamwallet_backend/src/users/admin-users.controller.ts
Normal file
102
myteamwallet_backend/src/users/admin-users.controller.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
173
myteamwallet_backend/src/users/admin-users.http.spec.ts
Normal file
173
myteamwallet_backend/src/users/admin-users.http.spec.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
330
myteamwallet_backend/src/users/admin-users.service.spec.ts
Normal file
330
myteamwallet_backend/src/users/admin-users.service.spec.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
381
myteamwallet_backend/src/users/admin-users.service.ts
Normal file
381
myteamwallet_backend/src/users/admin-users.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
68
myteamwallet_backend/src/users/dto/admin-user.dto.ts
Normal file
68
myteamwallet_backend/src/users/dto/admin-user.dto.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
11
myteamwallet_backend/src/users/dto/user-team-response.dto.ts
Normal file
11
myteamwallet_backend/src/users/dto/user-team-response.dto.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export class UserTeamReferenceDto {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export class UserTeamPlayerDto {
|
||||
id: number;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
team: UserTeamReferenceDto;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@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);
|
||||
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');
|
||||
}
|
||||
return this.usersService.findMyTeams(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
390
myteamwallet_backend/src/users/users.service.spec.ts
Normal file
390
myteamwallet_backend/src/users/users.service.spec.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
return query.getRawMany<DirectoryAssignmentRow>();
|
||||
}
|
||||
|
||||
async linkPlayerToUserId(user: User, playerId: number): Promise<boolean> {
|
||||
return new Promise<boolean>(async (resolve) => {
|
||||
const player = await this.playersRepository.findOneByOrFail({
|
||||
id: playerId,
|
||||
});
|
||||
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),
|
||||
),
|
||||
};
|
||||
|
||||
player.user = user;
|
||||
await this.playersRepository.save(player);
|
||||
return resolve(true);
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
49
myteamwallet_backend/src/users/users.teams.spec.ts
Normal file
49
myteamwallet_backend/src/users/users.teams.spec.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
@@ -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('/');
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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({});
|
||||
});
|
||||
});
|
||||
25
myteamwallet_frontend_modern/src/app/core/users/users-api.ts
Normal file
25
myteamwallet_frontend_modern/src/app/core/users/users-api.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -78,7 +78,6 @@ export class Register {
|
||||
password: value.password,
|
||||
firstName: value.firstName,
|
||||
lastName: value.lastName,
|
||||
linkPlayerId: invitation.playerId,
|
||||
})
|
||||
.subscribe({
|
||||
next: () => {
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
161
myteamwallet_frontend_modern/src/app/features/users/users.html
Normal file
161
myteamwallet_frontend_modern/src/app/features/users/users.html
Normal 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>
|
||||
198
myteamwallet_frontend_modern/src/app/features/users/users.scss
Normal file
198
myteamwallet_frontend_modern/src/app/features/users/users.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
254
myteamwallet_frontend_modern/src/app/features/users/users.ts
Normal file
254
myteamwallet_frontend_modern/src/app/features/users/users.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user