Merge branch 'feature/admin-user-management'
This commit is contained in:
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,
|
Post,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
Patch,
|
Patch,
|
||||||
Delete,
|
|
||||||
UseInterceptors,
|
UseInterceptors,
|
||||||
ClassSerializerInterceptor,
|
ClassSerializerInterceptor,
|
||||||
SerializeOptions,
|
SerializeOptions,
|
||||||
@@ -27,6 +26,7 @@ import {
|
|||||||
ApiOkResponse,
|
ApiOkResponse,
|
||||||
} from '@nestjs/swagger';
|
} from '@nestjs/swagger';
|
||||||
import { CreateInviteDTO } from './dto/create-invite.dto';
|
import { CreateInviteDTO } from './dto/create-invite.dto';
|
||||||
|
import { AuthRegisterLoginDto } from './dto/auth-register-login.dto';
|
||||||
|
|
||||||
@ApiTags('Auth')
|
@ApiTags('Auth')
|
||||||
@Controller({
|
@Controller({
|
||||||
@@ -51,7 +51,7 @@ export class AuthController {
|
|||||||
|
|
||||||
@Post('email/register')
|
@Post('email/register')
|
||||||
@HttpCode(HttpStatus.CREATED)
|
@HttpCode(HttpStatus.CREATED)
|
||||||
async register(@Body() createUserDto: any) {
|
async register(@Body() createUserDto: AuthRegisterLoginDto) {
|
||||||
return this.service.register(createUserDto);
|
return this.service.register(createUserDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,7 +81,7 @@ export class AuthController {
|
|||||||
groups: ['exposeProvider'],
|
groups: ['exposeProvider'],
|
||||||
})
|
})
|
||||||
@Get('me')
|
@Get('me')
|
||||||
// @UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
public me(@Request() request: Request) {
|
public me(@Request() request: Request) {
|
||||||
return this.service.me(request.headers['authorization']);
|
return this.service.me(request.headers['authorization']);
|
||||||
@@ -95,14 +95,6 @@ export class AuthController {
|
|||||||
return this.service.update(request.user, userDto);
|
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({
|
@ApiOperation({
|
||||||
summary: 'Erstellt Registrierungstoken',
|
summary: 'Erstellt Registrierungstoken',
|
||||||
description:
|
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 { JwtService } from '@nestjs/jwt';
|
||||||
import { User } from '../users/entities/user.entity';
|
import { User } from '../users/entities/user.entity';
|
||||||
import * as bcrypt from 'bcryptjs';
|
import * as bcrypt from 'bcryptjs';
|
||||||
@@ -19,6 +25,7 @@ import { ForgotService } from 'src/forgot/forgot.service';
|
|||||||
import { MailService } from 'src/mail/mail.service';
|
import { MailService } from 'src/mail/mail.service';
|
||||||
import { CreateInviteDTO } from './dto/create-invite.dto';
|
import { CreateInviteDTO } from './dto/create-invite.dto';
|
||||||
import { LoggingService } from 'src/database/logging/logging.service';
|
import { LoggingService } from 'src/database/logging/logging.service';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
@@ -28,6 +35,7 @@ export class AuthService {
|
|||||||
private forgotService: ForgotService,
|
private forgotService: ForgotService,
|
||||||
private mailService: MailService,
|
private mailService: MailService,
|
||||||
private logger: LoggingService,
|
private logger: LoggingService,
|
||||||
|
private dataSource: DataSource,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async validateLogin(
|
async validateLogin(
|
||||||
@@ -40,7 +48,7 @@ export class AuthService {
|
|||||||
if (!user) {
|
if (!user) {
|
||||||
await this.logger.info({
|
await this.logger.info({
|
||||||
event: 'user_login_fail',
|
event: 'user_login_fail',
|
||||||
details: `mail not found: ${loginDto.email}`,
|
details: 'reason=user_not_found',
|
||||||
userId: -1,
|
userId: -1,
|
||||||
});
|
});
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
@@ -54,6 +62,8 @@ export class AuthService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.assertActiveUser(user);
|
||||||
|
|
||||||
if (user.provider !== AuthProvidersEnum.email) {
|
if (user.provider !== AuthProvidersEnum.email) {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
{
|
{
|
||||||
@@ -79,7 +89,7 @@ export class AuthService {
|
|||||||
|
|
||||||
await this.logger.info({
|
await this.logger.info({
|
||||||
event: 'user_login_success',
|
event: 'user_login_success',
|
||||||
details: `logged in: ${loginDto.email}`,
|
details: `userId=${user.id}`,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -87,7 +97,7 @@ export class AuthService {
|
|||||||
} else {
|
} else {
|
||||||
await this.logger.info({
|
await this.logger.info({
|
||||||
event: 'user_login_fail',
|
event: 'user_login_fail',
|
||||||
details: `incorrect password for user: ${loginDto.email}`,
|
details: `userId=${user.id} reason=incorrect_password`,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -107,57 +117,68 @@ export class AuthService {
|
|||||||
authProvider: string,
|
authProvider: string,
|
||||||
socialData: SocialInterface,
|
socialData: SocialInterface,
|
||||||
): Promise<{ token: string; user: User }> {
|
): Promise<{ token: string; user: User }> {
|
||||||
let user: User;
|
|
||||||
const socialEmail = socialData.email?.toLowerCase();
|
const socialEmail = socialData.email?.toLowerCase();
|
||||||
|
return this.dataSource.transaction(async (manager) => {
|
||||||
const userByEmail = await this.usersService.findOne({
|
const repository = manager.getRepository(User);
|
||||||
email: socialEmail,
|
const candidateQuery = repository
|
||||||
|
.createQueryBuilder('socialCandidate')
|
||||||
|
.where(
|
||||||
|
'socialCandidate.socialId = :socialId AND socialCandidate.provider = :authProvider',
|
||||||
|
{ socialId: socialData.id, authProvider },
|
||||||
|
);
|
||||||
|
if (socialEmail) {
|
||||||
|
candidateQuery.orWhere('socialCandidate.email = :socialEmail', {
|
||||||
|
socialEmail,
|
||||||
});
|
});
|
||||||
|
|
||||||
user = await this.usersService.findOne({
|
|
||||||
socialId: socialData.id,
|
|
||||||
provider: authProvider,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (user) {
|
|
||||||
if (socialEmail && !userByEmail) {
|
|
||||||
user.email = socialEmail;
|
|
||||||
}
|
}
|
||||||
await this.usersService.update(user.id, user);
|
const candidates = await candidateQuery
|
||||||
} else if (userByEmail) {
|
.setLock('pessimistic_write', undefined, ['socialCandidate'])
|
||||||
user = userByEmail;
|
.orderBy('socialCandidate.id', 'ASC')
|
||||||
} else {
|
.getMany();
|
||||||
const role = plainToClass(Role, {
|
const socialUser = candidates.find(
|
||||||
id: RoleEnum.user,
|
(candidate) =>
|
||||||
});
|
candidate.socialId === socialData.id &&
|
||||||
const status = plainToClass(Status, {
|
candidate.provider === authProvider,
|
||||||
id: StatusEnum.active,
|
);
|
||||||
});
|
const emailUser = socialEmail
|
||||||
|
? candidates.find((candidate) => candidate.email === socialEmail)
|
||||||
user = await this.usersService.create({
|
: undefined;
|
||||||
|
let user = socialUser ?? emailUser;
|
||||||
|
if (!user) {
|
||||||
|
user = await repository.save(
|
||||||
|
repository.create({
|
||||||
email: socialEmail,
|
email: socialEmail,
|
||||||
firstName: socialData.firstName,
|
firstName: socialData.firstName,
|
||||||
lastName: socialData.lastName,
|
lastName: socialData.lastName,
|
||||||
socialId: socialData.id,
|
socialId: socialData.id,
|
||||||
provider: authProvider,
|
provider: authProvider,
|
||||||
role,
|
role: { id: RoleEnum.user } as Role,
|
||||||
status,
|
status: { id: StatusEnum.active } as Status,
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
user = await this.usersService.findOne({
|
} else if (
|
||||||
id: user.id,
|
socialUser &&
|
||||||
});
|
socialEmail &&
|
||||||
|
!emailUser &&
|
||||||
|
socialUser.email !== socialEmail
|
||||||
|
) {
|
||||||
|
await repository.update(socialUser.id, { email: socialEmail });
|
||||||
}
|
}
|
||||||
|
const currentUser = await repository
|
||||||
const jwtToken = await this.jwtService.sign({
|
.createQueryBuilder('currentSocialUser')
|
||||||
id: user.id,
|
.leftJoinAndSelect('currentSocialUser.role', 'role')
|
||||||
role: user.role,
|
.leftJoinAndSelect('currentSocialUser.status', 'status')
|
||||||
|
.where('currentSocialUser.id = :userId', { userId: user.id })
|
||||||
|
.setLock('pessimistic_write', undefined, ['currentSocialUser'])
|
||||||
|
.getOne();
|
||||||
|
if (!currentUser) throw new UnauthorizedException();
|
||||||
|
await this.assertActiveUser(currentUser);
|
||||||
|
const token = await this.jwtService.sign({
|
||||||
|
id: currentUser.id,
|
||||||
|
role: currentUser.role,
|
||||||
|
});
|
||||||
|
return { token, user: currentUser };
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
|
||||||
token: jwtToken,
|
|
||||||
user,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async register(dto: AuthRegisterLoginDto): Promise<void> {
|
async register(dto: AuthRegisterLoginDto): Promise<void> {
|
||||||
@@ -167,8 +188,10 @@ export class AuthService {
|
|||||||
.digest('hex');
|
.digest('hex');
|
||||||
|
|
||||||
const user = await this.usersService.create({
|
const user = await this.usersService.create({
|
||||||
...dto,
|
|
||||||
email: dto.email,
|
email: dto.email,
|
||||||
|
password: dto.password,
|
||||||
|
firstName: dto.firstName,
|
||||||
|
lastName: dto.lastName,
|
||||||
role: {
|
role: {
|
||||||
id: RoleEnum.user,
|
id: RoleEnum.user,
|
||||||
} as Role,
|
} as Role,
|
||||||
@@ -178,13 +201,9 @@ export class AuthService {
|
|||||||
hash,
|
hash,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (user && dto.linkPlayerId != null) {
|
|
||||||
await this.usersService.linkPlayerToUserId(user, dto.linkPlayerId);
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.logger.info({
|
await this.logger.info({
|
||||||
event: 'user_create',
|
event: 'user_create',
|
||||||
details: `user created with mail: ${dto.email}`,
|
details: `userId=${user.id}`,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -198,10 +217,13 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async confirmEmail(hash: string): Promise<void> {
|
async confirmEmail(hash: string): Promise<void> {
|
||||||
const user = await this.usersService.findOne({
|
await this.dataSource.transaction(async (manager) => {
|
||||||
hash,
|
const repository = manager.getRepository(User);
|
||||||
});
|
const user = await repository
|
||||||
|
.createQueryBuilder('user')
|
||||||
|
.where('user.hash = :hash', { hash })
|
||||||
|
.setLock('pessimistic_write', undefined, ['user'])
|
||||||
|
.getOne();
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
{
|
{
|
||||||
@@ -211,12 +233,10 @@ export class AuthService {
|
|||||||
HttpStatus.NOT_FOUND,
|
HttpStatus.NOT_FOUND,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
user.hash = null;
|
user.hash = null;
|
||||||
user.status = plainToClass(Status, {
|
user.status = plainToClass(Status, { id: StatusEnum.active });
|
||||||
id: StatusEnum.active,
|
await repository.save(user);
|
||||||
});
|
});
|
||||||
await user.save();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async forgotPassword(email: string): Promise<void> {
|
async forgotPassword(email: string): Promise<void> {
|
||||||
@@ -281,41 +301,32 @@ export class AuthService {
|
|||||||
|
|
||||||
async me(token: string): Promise<User> {
|
async me(token: string): Promise<User> {
|
||||||
token = token.replace('Bearer ', '');
|
token = token.replace('Bearer ', '');
|
||||||
let role: any;
|
let payload: any;
|
||||||
|
let refreshToken = false;
|
||||||
try {
|
try {
|
||||||
role = this.jwtService.verify(token);
|
payload = this.jwtService.verify(token);
|
||||||
|
} catch {
|
||||||
const u = await this.usersService.findOne({
|
payload = this.jwtService.decode(token);
|
||||||
id: role.id,
|
refreshToken = true;
|
||||||
});
|
}
|
||||||
|
if (!payload?.id) throw new UnauthorizedException();
|
||||||
await this.logger.debug({
|
const user = await this.usersService.findOne({ id: payload.id });
|
||||||
event: 'user_token_verification_success',
|
if (!user) throw new UnauthorizedException();
|
||||||
details: `Email: ${u.email}`,
|
await this.assertActiveUser(user);
|
||||||
userId: u.id,
|
if (refreshToken) {
|
||||||
});
|
|
||||||
|
|
||||||
return u;
|
|
||||||
} catch (error) {
|
|
||||||
const role = this.jwtService.decode(token);
|
|
||||||
|
|
||||||
const user = await this.usersService.findOne({
|
|
||||||
id: (role as any).id,
|
|
||||||
});
|
|
||||||
const t = await this.jwtService.sign({
|
const t = await this.jwtService.sign({
|
||||||
id: user.id,
|
id: user.id,
|
||||||
role: user.role,
|
role: user.role,
|
||||||
});
|
});
|
||||||
|
|
||||||
user['token'] = t;
|
user['token'] = t;
|
||||||
|
}
|
||||||
await this.logger.debug({
|
await this.logger.debug({
|
||||||
event: 'user_token_verification_success',
|
event: 'user_token_verification_success',
|
||||||
details: `Email: ${user.email}`,
|
details: `userId=${user.id}`,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
});
|
});
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
async update(user: User, userDto: AuthUpdateDto): Promise<User> {
|
async update(user: User, userDto: AuthUpdateDto): Promise<User> {
|
||||||
if (userDto.password) {
|
if (userDto.password) {
|
||||||
@@ -360,10 +371,6 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async softDelete(user: User): Promise<void> {
|
|
||||||
await this.usersService.softDelete(user.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
async createTeamInvite(object: CreateInviteDTO) {
|
async createTeamInvite(object: CreateInviteDTO) {
|
||||||
const token = await this.jwtService.sign(object, {
|
const token = await this.jwtService.sign(object, {
|
||||||
expiresIn: '30d',
|
expiresIn: '30d',
|
||||||
@@ -394,7 +401,7 @@ export class AuthService {
|
|||||||
} catch {
|
} catch {
|
||||||
await this.logger.info({
|
await this.logger.info({
|
||||||
event: 'user_invite_link_validate_fail',
|
event: 'user_invite_link_validate_fail',
|
||||||
details: `validation failed for token ${token}`,
|
details: 'invitation validation failed',
|
||||||
userId: 0,
|
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' })
|
@ApiProperty({ example: 'Doe' })
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
lastName: string;
|
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 { PassportStrategy } from '@nestjs/passport';
|
||||||
import { User } from '../../users/entities/user.entity';
|
import { User } from '../../users/entities/user.entity';
|
||||||
import { ConfigService } from '@nestjs/config';
|
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 };
|
type JwtPayload = Pick<User, 'id' | 'role'> & { iat: number; exp: number };
|
||||||
|
|
||||||
@@ -12,6 +14,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
|||||||
constructor(
|
constructor(
|
||||||
private jwtService: JwtService,
|
private jwtService: JwtService,
|
||||||
private configService: ConfigService,
|
private configService: ConfigService,
|
||||||
|
private usersService: UsersService,
|
||||||
) {
|
) {
|
||||||
super({
|
super({
|
||||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
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) {
|
if (!payload.id) {
|
||||||
throw new UnauthorizedException();
|
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';
|
import { LoggingService } from './logging.service';
|
||||||
|
|
||||||
describe('LoggingService', () => {
|
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 () => {
|
await service.info(event, manager);
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
|
||||||
providers: [LoggingService],
|
|
||||||
}).compile();
|
|
||||||
|
|
||||||
service = module.get<LoggingService>(LoggingService);
|
expect(transactionRepository.save).toHaveBeenCalledWith({
|
||||||
|
...event,
|
||||||
|
level: 'INFO',
|
||||||
});
|
});
|
||||||
|
expect(defaultRepository.save).not.toHaveBeenCalled();
|
||||||
it('should be defined', () => {
|
|
||||||
expect(service).toBeDefined();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { EntityManager, Repository } from 'typeorm';
|
||||||
import { CreateLogDTO } from './dto/create-log.dto';
|
import { CreateLogDTO } from './dto/create-log.dto';
|
||||||
import { LogEntry } from './entities/log-entry.entity';
|
import { LogEntry } from './entities/log-entry.entity';
|
||||||
import { LOGEVENT } from './model/logging-event.type';
|
import { LOGEVENT } from './model/logging-event.type';
|
||||||
@@ -23,7 +23,8 @@ export class LoggingService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async info({
|
async info(
|
||||||
|
{
|
||||||
event,
|
event,
|
||||||
details,
|
details,
|
||||||
userId,
|
userId,
|
||||||
@@ -31,14 +32,16 @@ export class LoggingService {
|
|||||||
event: LOGEVENT;
|
event: LOGEVENT;
|
||||||
details: string;
|
details: string;
|
||||||
userId: number;
|
userId: number;
|
||||||
}) {
|
},
|
||||||
|
manager?: EntityManager,
|
||||||
|
) {
|
||||||
const e: CreateLogDTO = {
|
const e: CreateLogDTO = {
|
||||||
event,
|
event,
|
||||||
details,
|
details,
|
||||||
userId,
|
userId,
|
||||||
level: 'INFO',
|
level: 'INFO',
|
||||||
};
|
};
|
||||||
await this.repository.save(e);
|
await (manager?.getRepository(LogEntry) ?? this.repository).save(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
async error({
|
async error({
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ export type LOGEVENT =
|
|||||||
| 'transaction_create_fail'
|
| 'transaction_create_fail'
|
||||||
| 'transaction_reverse'
|
| 'transaction_reverse'
|
||||||
| 'player_creation'
|
| 'player_creation'
|
||||||
|
| 'admin_user_profile_update'
|
||||||
|
| 'admin_user_role_update'
|
||||||
|
| 'admin_user_status_update'
|
||||||
|
| 'admin_player_assign'
|
||||||
|
| 'admin_player_unlink'
|
||||||
| 'team_create';
|
| 'team_create';
|
||||||
|
|
||||||
export type LOGLEVEL = 'FATAL' | 'ERROR' | 'WARN' | 'INFO' | 'DEBUG' | 'TRACE';
|
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,
|
BeforeInsert,
|
||||||
Column,
|
Column,
|
||||||
Entity,
|
Entity,
|
||||||
|
Index,
|
||||||
ManyToOne,
|
ManyToOne,
|
||||||
OneToMany,
|
OneToMany,
|
||||||
PrimaryGeneratedColumn,
|
PrimaryGeneratedColumn,
|
||||||
|
RelationId,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { EntityHelper } from 'src/utils/entity-helper';
|
import { EntityHelper } from 'src/utils/entity-helper';
|
||||||
import { TeamRole } from 'src/team-roles/entities/team-roles.entity';
|
import { TeamRole } from 'src/team-roles/entities/team-roles.entity';
|
||||||
@@ -29,6 +31,7 @@ export class Player extends EntityHelper {
|
|||||||
})
|
})
|
||||||
teamRole?: TeamRole | null;
|
teamRole?: TeamRole | null;
|
||||||
|
|
||||||
|
@Index('IDX_player_team_id')
|
||||||
@ManyToOne(() => Team, {
|
@ManyToOne(() => Team, {
|
||||||
eager: true,
|
eager: true,
|
||||||
})
|
})
|
||||||
@@ -37,11 +40,15 @@ export class Player extends EntityHelper {
|
|||||||
@Column({ type: 'decimal', precision: 10, scale: 2, default: 0 })
|
@Column({ type: 'decimal', precision: 10, scale: 2, default: 0 })
|
||||||
balance: number;
|
balance: number;
|
||||||
|
|
||||||
|
@Index('IDX_player_user_id')
|
||||||
@ManyToOne(() => User, (user) => user.players, {
|
@ManyToOne(() => User, (user) => user.players, {
|
||||||
eager: true,
|
eager: true,
|
||||||
})
|
})
|
||||||
user?: User | null;
|
user?: User | null;
|
||||||
|
|
||||||
|
@RelationId((player: Player) => player.user)
|
||||||
|
userId?: number | null;
|
||||||
|
|
||||||
@OneToMany(() => Transaction, (transaction) => transaction.player)
|
@OneToMany(() => Transaction, (transaction) => transaction.player)
|
||||||
transactions: Transaction[];
|
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 { Transform } from 'class-transformer';
|
||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { Role } from '../../roles/entities/role.entity';
|
import { Role } from '../../roles/entities/role.entity';
|
||||||
import {
|
import { IsEmail, IsNotEmpty, MinLength, Validate } from 'class-validator';
|
||||||
IsEmail,
|
|
||||||
IsNotEmpty,
|
|
||||||
IsOptional,
|
|
||||||
MinLength,
|
|
||||||
Validate,
|
|
||||||
} from 'class-validator';
|
|
||||||
import { Status } from '../../statuses/entities/status.entity';
|
import { Status } from '../../statuses/entities/status.entity';
|
||||||
import { IsNotExist } from '../../utils/validators/is-not-exists.validator';
|
import { IsNotExist } from '../../utils/validators/is-not-exists.validator';
|
||||||
import { IsExist } from '../../utils/validators/is-exists.validator';
|
import { IsExist } from '../../utils/validators/is-exists.validator';
|
||||||
@@ -51,6 +45,4 @@ export class CreateUserDto {
|
|||||||
status?: Status;
|
status?: Status;
|
||||||
|
|
||||||
hash?: string | null;
|
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 {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
Post,
|
|
||||||
Body,
|
|
||||||
Patch,
|
|
||||||
Param,
|
Param,
|
||||||
Delete,
|
|
||||||
UseGuards,
|
UseGuards,
|
||||||
Query,
|
Query,
|
||||||
DefaultValuePipe,
|
|
||||||
ParseIntPipe,
|
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
HttpCode,
|
HttpCode,
|
||||||
|
Request,
|
||||||
|
ForbiddenException,
|
||||||
|
ParseIntPipe,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { UsersService } from './users.service';
|
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 { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||||
import { Roles } from 'src/roles/roles.decorator';
|
import { Roles } from 'src/roles/roles.decorator';
|
||||||
import { RoleEnum } from 'src/roles/roles.enum';
|
import { RoleEnum } from 'src/roles/roles.enum';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { RolesGuard } from 'src/roles/roles.guard';
|
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()
|
@ApiBearerAuth()
|
||||||
@UseGuards(AuthGuard('jwt'), RolesGuard)
|
@UseGuards(AuthGuard('jwt'), RolesGuard)
|
||||||
@@ -33,57 +29,26 @@ import { infinityPagination } from 'src/utils/infinity-pagination';
|
|||||||
export class UsersController {
|
export class UsersController {
|
||||||
constructor(private readonly usersService: UsersService) {}
|
constructor(private readonly usersService: UsersService) {}
|
||||||
|
|
||||||
@Roles([RoleEnum.admin])
|
@Roles([RoleEnum.user, RoleEnum.admin])
|
||||||
@Post()
|
@Get('directory')
|
||||||
@HttpCode(HttpStatus.CREATED)
|
|
||||||
create(@Body() createProfileDto: CreateUserDto) {
|
|
||||||
return this.usersService.create(createProfileDto);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Roles([RoleEnum.admin])
|
|
||||||
@Get()
|
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
async findAll(
|
findDirectory(
|
||||||
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
|
@Request() request: { user: Pick<User, 'id' | 'role'> },
|
||||||
@Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number,
|
@Query() query: UserDirectoryQueryDto,
|
||||||
) {
|
) {
|
||||||
if (limit > 50) {
|
return this.usersService.findDirectory(request.user, query);
|
||||||
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 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Roles([RoleEnum.user, RoleEnum.admin])
|
@Roles([RoleEnum.user, RoleEnum.admin])
|
||||||
@Get(':id/teams')
|
@Get(':id/teams')
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
findTeamsOfPlayer(@Param('id') id: string) {
|
findTeamsOfPlayer(
|
||||||
return this.usersService.findTeams({ id: +id });
|
@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);
|
||||||
@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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,11 +7,14 @@ import { IsExist } from 'src/utils/validators/is-exists.validator';
|
|||||||
import { IsNotExist } from 'src/utils/validators/is-not-exists.validator';
|
import { IsNotExist } from 'src/utils/validators/is-not-exists.validator';
|
||||||
import { Team } from 'src/teams/entities/team.entity';
|
import { Team } from 'src/teams/entities/team.entity';
|
||||||
import { Player } from 'src/players/entities/player.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({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([User, Team, Player])],
|
imports: [TypeOrmModule.forFeature([User, Team, Player]), LoggingModule],
|
||||||
controllers: [UsersController],
|
controllers: [UsersController, AdminUsersController],
|
||||||
providers: [IsExist, IsNotExist, UsersService],
|
providers: [IsExist, IsNotExist, UsersService, AdminUsersService],
|
||||||
exports: [UsersService],
|
exports: [UsersService],
|
||||||
})
|
})
|
||||||
export class UsersModule {}
|
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 { Player } from 'src/players/entities/player.entity';
|
||||||
import { EntityCondition } from 'src/utils/types/entity-condition.type';
|
import { EntityCondition } from 'src/utils/types/entity-condition.type';
|
||||||
import { IPaginationOptions } from 'src/utils/types/pagination-options';
|
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 { CreateUserDto } from './dto/create-user.dto';
|
||||||
import { UpdateUserDto } from './dto/update-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 { User } from './entities/user.entity';
|
||||||
|
import { UserTeamPlayerDto } from './dto/user-team-response.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UsersService {
|
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>) {
|
findOne(fields: EntityCondition<User>) {
|
||||||
return this.usersRepository.findOne({
|
return this.usersRepository.findOne({
|
||||||
where: fields,
|
where: fields,
|
||||||
@@ -49,32 +114,232 @@ export class UsersService {
|
|||||||
await this.usersRepository.softDelete(id);
|
await this.usersRepository.softDelete(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
async findTeams(fields: EntityCondition<User>) {
|
async findMyTeams(userId: number): Promise<UserTeamPlayerDto[]> {
|
||||||
const user = await this.findOne(fields);
|
const rows = await this.playersRepository
|
||||||
if (!user) {
|
.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 [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const players = await this.playersRepository.find({
|
const query = this.playersRepository
|
||||||
where: {
|
.createQueryBuilder('player')
|
||||||
user: {
|
.innerJoin('player.team', 'team')
|
||||||
id: user.id,
|
.leftJoin('player.teamRole', 'teamRole')
|
||||||
},
|
.select([
|
||||||
},
|
'player.id AS player_id',
|
||||||
relations: ['team'],
|
'player.userId AS user_id',
|
||||||
});
|
'player.firstName AS first_name',
|
||||||
return players;
|
'player.lastName AS last_name',
|
||||||
|
'player.active AS active',
|
||||||
|
'team.id AS team_id',
|
||||||
|
'team.name AS team_name',
|
||||||
|
'team.alias AS team_alias',
|
||||||
|
'teamRole.id AS team_role_id',
|
||||||
|
'teamRole.name AS team_role_name',
|
||||||
|
])
|
||||||
|
.where('player.userId IN (:...userIds)', { userIds })
|
||||||
|
.orderBy('player.id', 'ASC');
|
||||||
|
|
||||||
|
if (!isAdmin && sharedTeamsQuery) {
|
||||||
|
query
|
||||||
|
.andWhere(`player.teamId IN (${sharedTeamsQuery.getQuery()})`)
|
||||||
|
.setParameters(sharedTeamsQuery.getParameters());
|
||||||
}
|
}
|
||||||
|
|
||||||
async linkPlayerToUserId(user: User, playerId: number): Promise<boolean> {
|
return query.getRawMany<DirectoryAssignmentRow>();
|
||||||
return new Promise<boolean>(async (resolve) => {
|
}
|
||||||
const player = await this.playersRepository.findOneByOrFail({
|
|
||||||
id: playerId,
|
|
||||||
});
|
|
||||||
|
|
||||||
player.user = user;
|
private mapDirectoryUser(
|
||||||
await this.playersRepository.save(player);
|
user: DirectoryUserRow,
|
||||||
return resolve(true);
|
assignments: DirectoryAssignmentRow[],
|
||||||
});
|
includeAdminFields: boolean,
|
||||||
|
): UserDirectorySummaryDto | AdminUserDirectorySummaryDto {
|
||||||
|
const summary: UserDirectorySummaryDto = {
|
||||||
|
id: Number(user.user_id),
|
||||||
|
firstName: user.first_name,
|
||||||
|
lastName: user.last_name,
|
||||||
|
status: this.mapDirectoryReference(user.status_id, user.status_name),
|
||||||
|
assignments: assignments.map((assignment) =>
|
||||||
|
this.mapDirectoryAssignment(assignment),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!includeAdminFields) {
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...summary,
|
||||||
|
email: user.email,
|
||||||
|
role: this.mapDirectoryReference(user.role_id, user.role_name),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapDirectoryAssignment(
|
||||||
|
player: DirectoryAssignmentRow,
|
||||||
|
): UserDirectoryAssignmentDto {
|
||||||
|
return {
|
||||||
|
id: Number(player.player_id),
|
||||||
|
firstName: player.first_name,
|
||||||
|
lastName: player.last_name,
|
||||||
|
active: this.asBoolean(player.active),
|
||||||
|
team: {
|
||||||
|
id: Number(player.team_id),
|
||||||
|
name: player.team_name,
|
||||||
|
alias: player.team_alias,
|
||||||
|
},
|
||||||
|
teamRole: this.mapDirectoryReference(
|
||||||
|
player.team_role_id,
|
||||||
|
player.team_role_name,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private asBoolean(value: boolean | number | string): boolean {
|
||||||
|
return value === true || value === 1 || value === '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapDirectoryReference(
|
||||||
|
id: number | string | null,
|
||||||
|
name: string | null,
|
||||||
|
): UserDirectoryReferenceDto | null {
|
||||||
|
if (id == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: Number(id),
|
||||||
|
name: name ?? undefined,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DirectoryUserRow {
|
||||||
|
user_id: number | string;
|
||||||
|
first_name: string | null;
|
||||||
|
last_name: string | null;
|
||||||
|
status_id: number | string | null;
|
||||||
|
status_name: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
role_id?: number | string | null;
|
||||||
|
role_name?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DirectoryAssignmentRow {
|
||||||
|
player_id: number | string;
|
||||||
|
user_id: number | string;
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
active: boolean | number | string;
|
||||||
|
team_id: number | string;
|
||||||
|
team_name: string;
|
||||||
|
team_alias: string;
|
||||||
|
team_role_id: number | string | null;
|
||||||
|
team_role_name: string | null;
|
||||||
|
}
|
||||||
|
|||||||
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');
|
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 () => {
|
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' });
|
authStore.setSession('token', { id: 1, email: 'a@b.de', firstName: 'A', lastName: 'B' });
|
||||||
await router.navigateByUrl('/');
|
await router.navigateByUrl('/');
|
||||||
|
|||||||
@@ -38,6 +38,11 @@ export const routes: Routes = [
|
|||||||
canActivate: [authGuard],
|
canActivate: [authGuard],
|
||||||
loadComponent: () => import('./features/team-select/team-select').then((m) => m.TeamSelect),
|
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',
|
path: 't/:token/:playerId',
|
||||||
loadComponent: () => import('./features/public-team/public-player').then((m) => m.PublicPlayer),
|
loadComponent: () => import('./features/public-team/public-player').then((m) => m.PublicPlayer),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
import { provideHttpClient } from '@angular/common/http';
|
import { provideHttpClient } from '@angular/common/http';
|
||||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
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 { environment } from '../../../environments/environment';
|
||||||
import { User } from '../../models/user.model';
|
import { User } from '../../models/user.model';
|
||||||
|
|
||||||
@@ -59,8 +59,8 @@ describe('AuthApi', () => {
|
|||||||
request.flush(invitation);
|
request.flush(invitation);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('registers and links an invited player', () => {
|
it('registers with only the supported account fields', () => {
|
||||||
const registration = {
|
const registrationWithLegacyField = {
|
||||||
email: 'alex@example.de',
|
email: 'alex@example.de',
|
||||||
password: 'secret1',
|
password: 'secret1',
|
||||||
firstName: 'Alex',
|
firstName: 'Alex',
|
||||||
@@ -68,11 +68,16 @@ describe('AuthApi', () => {
|
|||||||
linkPlayerId: 7,
|
linkPlayerId: 7,
|
||||||
};
|
};
|
||||||
|
|
||||||
service.register(registration).subscribe();
|
service.register(registrationWithLegacyField as RegistrationRequest).subscribe();
|
||||||
|
|
||||||
const request = httpMock.expectOne(`${environment.apiUrl}auth/email/register`);
|
const request = httpMock.expectOne(`${environment.apiUrl}auth/email/register`);
|
||||||
expect(request.request.method).toBe('POST');
|
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);
|
request.flush(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ export interface RegistrationRequest {
|
|||||||
password: string;
|
password: string;
|
||||||
firstName: string;
|
firstName: string;
|
||||||
lastName: string;
|
lastName: string;
|
||||||
linkPlayerId: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateInviteRequest extends InviteDetails {}
|
export interface CreateInviteRequest extends InviteDetails {}
|
||||||
@@ -53,7 +52,13 @@ export class AuthApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
register(request: RegistrationRequest): Observable<void> {
|
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> {
|
forgotPassword(email: string): Observable<void> {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
import { AuthStore } from './auth-store';
|
import { AuthStore } from './auth-store';
|
||||||
|
import { RoleId } from '../../models/role.model';
|
||||||
|
|
||||||
describe('AuthStore', () => {
|
describe('AuthStore', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -76,4 +77,34 @@ describe('AuthStore', () => {
|
|||||||
expect(localStorage.getItem('tw_token')).toBe('jwt-token');
|
expect(localStorage.getItem('tw_token')).toBe('jwt-token');
|
||||||
expect(JSON.parse(localStorage.getItem('tw_user')!)).toEqual(updated);
|
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 { Injectable, computed, signal } from '@angular/core';
|
||||||
|
import { RoleId } from '../../models/role.model';
|
||||||
import { User } from '../../models/user.model';
|
import { User } from '../../models/user.model';
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
@@ -12,6 +13,11 @@ export class AuthStore {
|
|||||||
readonly token = this.tokenSignal.asReadonly();
|
readonly token = this.tokenSignal.asReadonly();
|
||||||
readonly currentUser = this.userSignal.asReadonly();
|
readonly currentUser = this.userSignal.asReadonly();
|
||||||
readonly isLoggedIn = computed(() => this.tokenSignal() !== null);
|
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 {
|
setSession(token: string, user: User): void {
|
||||||
localStorage.setItem(AuthStore.TOKEN_KEY, token);
|
localStorage.setItem(AuthStore.TOKEN_KEY, token);
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { MatToolbarModule } from '@angular/material/toolbar';
|
|||||||
import { AuthStore } from '../../auth/auth-store';
|
import { AuthStore } from '../../auth/auth-store';
|
||||||
import { MyTeamsStore } from '../../team/my-teams-store';
|
import { MyTeamsStore } from '../../team/my-teams-store';
|
||||||
import { TeamStore } from '../../team/team-store';
|
import { TeamStore } from '../../team/team-store';
|
||||||
import { Team } from '../../../models/team.model';
|
import { UserTeamReference } from '../../../models/user-directory.model';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-shell',
|
selector: 'app-shell',
|
||||||
@@ -41,9 +41,9 @@ export class Shell {
|
|||||||
|
|
||||||
protected readonly myTeams = computed(() => {
|
protected readonly myTeams = computed(() => {
|
||||||
const seen = new Set<number>();
|
const seen = new Set<number>();
|
||||||
const teams: Team[] = [];
|
const teams: UserTeamReference[] = [];
|
||||||
for (const player of this.myTeamsStore.players()) {
|
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);
|
seen.add(player.team.id);
|
||||||
teams.push(player.team);
|
teams.push(player.team);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,19 +3,16 @@ import { provideHttpClient } from '@angular/common/http';
|
|||||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||||
import { MyTeamsStore } from './my-teams-store';
|
import { MyTeamsStore } from './my-teams-store';
|
||||||
import { environment } from '../../../environments/environment';
|
import { environment } from '../../../environments/environment';
|
||||||
import { Player } from '../../models/player.model';
|
|
||||||
|
|
||||||
describe('MyTeamsStore', () => {
|
describe('MyTeamsStore', () => {
|
||||||
let store: MyTeamsStore;
|
let store: MyTeamsStore;
|
||||||
let httpMock: HttpTestingController;
|
let httpMock: HttpTestingController;
|
||||||
|
|
||||||
const player: Player = {
|
const player = {
|
||||||
id: 1,
|
id: 1,
|
||||||
firstName: 'A',
|
firstName: 'A',
|
||||||
lastName: 'B',
|
lastName: 'B',
|
||||||
balance: 0,
|
team: { id: 5, name: 'Team A' },
|
||||||
active: true,
|
|
||||||
team: { id: 5, name: 'Team A', alias: 'team-a', balance: 0 },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { Injectable, inject, signal } from '@angular/core';
|
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';
|
import { TeamsApi } from './teams-api';
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class MyTeamsStore {
|
export class MyTeamsStore {
|
||||||
private readonly teamsApi = inject(TeamsApi);
|
private readonly teamsApi = inject(TeamsApi);
|
||||||
|
|
||||||
private readonly playersSignal = signal<Player[]>([]);
|
private readonly playersSignal = signal<UserTeamMembership[]>([]);
|
||||||
private readonly loadingSignal = signal(false);
|
private readonly loadingSignal = signal(false);
|
||||||
private readonly loadedForUserId = signal<number | null>(null);
|
private readonly loadedForUserId = signal<number | null>(null);
|
||||||
|
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ describe('TeamsApi', () => {
|
|||||||
httpMock.verify();
|
httpMock.verify();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('fetches the players/teams belonging to a user', () => {
|
it('fetches the reduced bootstrap memberships belonging to a user', () => {
|
||||||
const players: Player[] = [{ id: 1, firstName: 'A', lastName: 'B', balance: 0, active: true }];
|
const players = [{ id: 1, firstName: 'A', lastName: 'B', team: { id: 5, name: 'Team A' } }];
|
||||||
|
|
||||||
service.loadMyTeams(42).subscribe((response) => {
|
service.loadMyTeams(42).subscribe((response) => {
|
||||||
expect(response).toEqual(players);
|
expect(response).toEqual(players);
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ import { HttpClient } from '@angular/common/http';
|
|||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { environment } from '../../../environments/environment';
|
import { environment } from '../../../environments/environment';
|
||||||
import { Player } from '../../models/player.model';
|
|
||||||
import { Team } from '../../models/team.model';
|
import { Team } from '../../models/team.model';
|
||||||
import { PlayerTransaction } from '../../models/transaction.model';
|
import { PlayerTransaction } from '../../models/transaction.model';
|
||||||
|
import { UserTeamMembership } from '../../models/user-directory.model';
|
||||||
|
import { Player } from '../../models/player.model';
|
||||||
|
|
||||||
export interface CreatePlayerRequest {
|
export interface CreatePlayerRequest {
|
||||||
firstName: string;
|
firstName: string;
|
||||||
@@ -16,8 +17,8 @@ export interface CreatePlayerRequest {
|
|||||||
export class TeamsApi {
|
export class TeamsApi {
|
||||||
private readonly http = inject(HttpClient);
|
private readonly http = inject(HttpClient);
|
||||||
|
|
||||||
loadMyTeams(userId: number): Observable<Player[]> {
|
loadMyTeams(userId: number): Observable<UserTeamMembership[]> {
|
||||||
return this.http.get<Player[]>(`${environment.apiUrl}users/${userId}/teams`);
|
return this.http.get<UserTeamMembership[]>(`${environment.apiUrl}users/${userId}/teams`);
|
||||||
}
|
}
|
||||||
|
|
||||||
loadTeamOverview(teamId: number): Observable<Team> {
|
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());
|
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 fixture = TestBed.createComponent(Register);
|
||||||
const navigateSpy = vi.spyOn(router, 'navigate');
|
const navigateSpy = vi.spyOn(router, 'navigate');
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
@@ -50,7 +50,12 @@ describe('Register', () => {
|
|||||||
fixture.componentInstance['onSubmit']();
|
fixture.componentInstance['onSubmit']();
|
||||||
|
|
||||||
const request = httpMock.expectOne(`${environment.apiUrl}auth/email/register`);
|
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);
|
request.flush(null);
|
||||||
|
|
||||||
expect(navigateSpy).toHaveBeenCalledWith(['/auth/login'], { replaceUrl: true });
|
expect(navigateSpy).toHaveBeenCalledWith(['/auth/login'], { replaceUrl: true });
|
||||||
|
|||||||
@@ -78,7 +78,6 @@ export class Register {
|
|||||||
password: value.password,
|
password: value.password,
|
||||||
firstName: value.firstName,
|
firstName: value.firstName,
|
||||||
lastName: value.lastName,
|
lastName: value.lastName,
|
||||||
linkPlayerId: invitation.playerId,
|
|
||||||
})
|
})
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
|
|||||||
@@ -11,8 +11,8 @@
|
|||||||
<h1>Team auswählen</h1>
|
<h1>Team auswählen</h1>
|
||||||
<mat-nav-list>
|
<mat-nav-list>
|
||||||
@for (player of players(); track player.id) {
|
@for (player of players(); track player.id) {
|
||||||
<a mat-list-item [routerLink]="['/team', player.team?.id, 'overview']">
|
<a mat-list-item [routerLink]="['/team', player.team.id, 'overview']">
|
||||||
<span matListItemTitle>{{ player.team?.name }}</span>
|
<span matListItemTitle>{{ player.team.name }}</span>
|
||||||
<span matListItemLine>{{ player.firstName }} {{ player.lastName }}</span>
|
<span matListItemLine>{{ player.firstName }} {{ player.lastName }}</span>
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,13 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="link-grid">
|
<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"
|
<a routerLink="penalties"
|
||||||
><mat-card
|
><mat-card
|
||||||
><mat-icon>gavel</mat-icon>
|
><mat-icon>gavel</mat-icon>
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ describe('More', () => {
|
|||||||
expect(fixture.nativeElement.textContent).toContain('Profil');
|
expect(fixture.nativeElement.textContent).toContain('Profil');
|
||||||
expect(fixture.nativeElement.textContent).toContain('Öffentliche Freigabe');
|
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']();
|
fixture.componentInstance['logout']();
|
||||||
await fixture.whenStable();
|
await fixture.whenStable();
|
||||||
expect(clearSession).toHaveBeenCalled();
|
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