Files
demo-decoupled/spring-angular-ui/src/app/components/board-admin/board-admin.component.ts
T
Liosha84andClaude Opus 5.5 514e1fd0c7 feat: finish demo — port demo-monolith cleanup, separate UI and API containers
Frontend
- Bring spring-angular-ui back under version control with the Angular 22 +
  Material UI from demo-monolith; stop committing the built static/ folder
- Serve the UI from nginx, which proxies /api to the backend so the JWT
  cookie works on a single origin, and falls back to index.html for routes

Backend
- Add PUT /api/users/{id}/roles (admin only) that keeps at least one admin
  and does not let an admin drop their own admin role
- Tighten security: read tutorials publicly, change them only when logged
  in, users/roles API for admins only; hide password hashes in JSON
- Remove unused Settings and Test endpoints, DTO mappers, commented code
- Flyway V2 aligns the schema with the entities; Hibernate now runs with
  ddl-auto: validate
- JWT lifetime matches the 24h cookie; fix the cookie name typo
- Keep CORS only for calling the API directly (ng serve without proxy)

Docker
- Backend Dockerfile: Gradle build + JRE runtime with layer caching,
  non-root user and healthcheck; add .dockerignore
- UI Dockerfile: Node 24 build + nginx with healthcheck
- docker-compose runs its own Postgres, starts services in health order
  and always rebuilds images

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 19:33:38 +02:00

102 lines
4.0 KiB
TypeScript

import { ChangeDetectionStrategy, Component, inject, OnInit, ViewChild } from '@angular/core';
import { HttpErrorResponse } from '@angular/common/http';
import { MatDialog } from '@angular/material/dialog';
import { MatPaginator } from '@angular/material/paginator';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatSort } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table';
import { httpErrorMessage } from 'src/app/helpers/http-error';
import { Role, roleLabel } from 'src/app/models/role';
import { User } from 'src/app/models/user.model';
import { RolesService } from 'src/app/services/roles.service';
import { TokenStorageService } from 'src/app/services/token-storage.service';
import { UserService } from 'src/app/services/user.service';
import { RolesDialogComponent, RolesDialogData } from '../roles-dialog/roles-dialog.component';
@Component({
selector: 'app-board-admin',
templateUrl: './board-admin.component.html',
styleUrls: ['./board-admin.component.scss'],
changeDetection: ChangeDetectionStrategy.Eager,
standalone: false
})
export class BoardAdminComponent implements OnInit {
private readonly userService = inject(UserService);
private readonly rolesService = inject(RolesService);
private readonly tokenStorage = inject(TokenStorageService);
private readonly dialog = inject(MatDialog);
private readonly snackBar = inject(MatSnackBar);
readonly displayedColumns = ['username', 'email', 'roles', 'actions'];
readonly dataSource = new MatTableDataSource<User>([]);
readonly roleLabel = roleLabel;
readonly currentUserId = this.tokenStorage.getUser().id;
private allRoles: Role[] = [];
@ViewChild(MatSort) set sort(sort: MatSort) {
this.dataSource.sort = sort;
}
@ViewChild(MatPaginator) set paginator(paginator: MatPaginator) {
this.dataSource.paginator = paginator;
}
ngOnInit(): void {
this.dataSource.sortingDataAccessor = (user, column) =>
column === 'roles' ? (user.roles ?? []).length : String((user as any)[column] ?? '').toLowerCase();
this.dataSource.filterPredicate = (user, filter) =>
[user.username, user.email, ...(user.roles ?? []).map(role => roleLabel(role.name))]
.some(value => value?.toLowerCase().includes(filter));
this.userService.getAll().subscribe({
// На пустой результат сервер отвечает 204 без тела
next: users => this.dataSource.data = users ?? [],
error: err => this.showError(err)
});
this.rolesService.getAllRoles().subscribe({
next: roles => this.allRoles = roles ?? [],
error: err => this.showError(err)
});
}
applyFilter(value: string): void {
this.dataSource.filter = value.trim().toLowerCase();
this.dataSource.paginator?.firstPage();
}
initials(user: User): string {
return (user.username ?? '?').slice(0, 2);
}
editRoles(user: User): void {
const data: RolesDialogData = { user, allRoles: this.allRoles };
this.dialog.open(RolesDialogComponent, { width: '360px', data })
.afterClosed()
.subscribe((roles?: string[]) => {
if (roles) {
this.saveRoles(user, roles);
}
});
}
private saveRoles(user: User, roles: string[]): void {
this.userService.updateRoles(user.id, roles).subscribe({
next: updated => {
this.dataSource.data = this.dataSource.data.map(u => u.id === updated.id ? updated : u);
if (updated.id === this.currentUserId) {
// Собственные роли хранятся в сессии — обновляем, чтобы меню и доступы совпадали с сервером
this.tokenStorage.saveUser({ ...this.tokenStorage.getUser(), roles });
}
this.snackBar.open(`Roles of ${updated.username} were updated`, 'OK', { duration: 3000 });
},
error: err => this.showError(err)
});
}
private showError(err: HttpErrorResponse): void {
this.snackBar.open(httpErrorMessage(err), 'OK', { duration: 5000 });
}
}