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>
This commit is contained in:
2026-10-17 19:33:38 +02:00
co-authored by Claude Opus 5.5
parent 1d38a95f48
commit 514e1fd0c7
118 changed files with 9134 additions and 79218 deletions
@@ -1,124 +1,101 @@
import {Component, computed, model, OnInit} from '@angular/core';
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 {RolesService} from "src/app/services/roles.service";
import { Role } from 'src/app/models/role';
import {User} from "../../models/user.model";
import {MatChipInputEvent} from "@angular/material/chips";
import {MatAutocompleteSelectedEvent} from "@angular/material/autocomplete";
import {COMMA, ENTER} from "@angular/cdk/keycodes";
import {AsyncPipe} from "@angular/common";
import {FormControl} from "@angular/forms";
import {Observable, startWith} from "rxjs";
import {map} from "rxjs/operators";
import { RolesDialogComponent, RolesDialogData } from '../roles-dialog/roles-dialog.component';
@Component({
selector: 'app-board-admin',
templateUrl: './board-admin.component.html',
styleUrls: ['./board-admin.component.scss'],
standalone: false,
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 gridApi: any;
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 separatorKeysCodes: number[] = [ENTER, COMMA];
rowData?: User[];
allRoles: any;
readonly displayedColumns = ['username', 'email', 'roles', 'actions'];
readonly dataSource = new MatTableDataSource<User>([]);
readonly roleLabel = roleLabel;
readonly currentUserId = this.tokenStorage.getUser().id;
readonly currentRole = model('');
private allRoles: Role[] = [];
protected myControl = new FormControl('');
protected ac = new FormControl('');
@ViewChild(MatSort) set sort(sort: MatSort) {
this.dataSource.sort = sort;
}
constructor(private userService: UserService, private roleService: RolesService ) {
this.getAllRoles();
}
@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.myControl.valueChanges.pipe(
// startWith(''),
// map(value => this._filter(value || '')),
//);
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)
});
}
this.userService.getAdminBoard().subscribe(
(data : any) => {
this.rowData = data;
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 });
},
(err : any)=> {
this.rowData = JSON.parse(err.error).message;
}
);
error: err => this.showError(err)
});
}
filteredRoles(roles : Role[]):any {
return this.allRoles.filter(
(r:Role) => !roles.some((item) => item.id === r.id),
);
}
// private _filter(value: string): string[] {
// const filterValue = value.toLowerCase();
// this.ac.
// return this.options.filter(option => option.toLowerCase().includes(filterValue));
//}
getAllRoles():any{
this.roleService.getAllRoles().subscribe(
(data : any) => {
this.allRoles = data;
},
(err : any)=> {
this.allRoles = JSON.parse(err.error).message;
}
);
}
remove(role: string): void {
// this.fruits.update(fruits => {
// const index = fruits.indexOf(fruit);
// if (index < 0) {
// return fruits;
// }
// fruits.splice(index, 1);
// this.announcer.announce(`Removed ${fruit}`);
// return [...fruits];
}
add(event: MatChipInputEvent): void {
const value = (event.value || '').trim();
// Add our fruit
if (value) {
// this.fruits.update(fruits => [...fruits, value]);
}
// Clear the input value
this.currentRole.set('');
}
selected(roles : Role[], event: MatAutocompleteSelectedEvent): void {
roles.push(event.option.value);
this.currentRole.set('');
event.option.deselect();
}
change($event: Event, roles: Role[]) {
roles.filter(
(r:Role) => !roles.some((item) => item.name?.toLowerCase() === r.id),
)
private showError(err: HttpErrorResponse): void {
this.snackBar.open(httpErrorMessage(err), 'OK', { duration: 5000 });
}
}