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:
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user