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>
102 lines
4.0 KiB
TypeScript
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 });
|
|
}
|
|
}
|