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,29 +1,51 @@
<h1 mat-dialog-title>Hi </h1>
<div mat-dialog-content>
<p>User name</p>
<mat-form-field class="fillField">
<input matInput [(ngModel)]="data.username">
</mat-form-field>
<p>Password</p>
<mat-form-field class="fillField">
<mat-label>Enter your password</mat-label>
<input matInput
[type]="hide() ? 'password' : 'text'"
[(ngModel)]="data.password"
/>
<button
mat-icon-button
matSuffix
(click)="clickEvent($event)"
[attr.aria-label]="'Hide password'"
[attr.aria-pressed]="hide()"
>
<mat-icon>{{hide() ? 'visibility_off' : 'visibility'}}</mat-icon>
</button>
</mat-form-field>
</div>
<form #f="ngForm" (ngSubmit)="f.valid && signIn()">
<div class="heading">
<span class="heading-icon"><mat-icon>login</mat-icon></span>
<h2 class="title">Sign in</h2>
<p class="subtitle">Welcome back! Enter your details to continue.</p>
</div>
<div mat-dialog-actions>
<button mat-button mat-dialog-close (click)="onNoClick()">No Thanks</button>
<button mat-button [mat-dialog-close]="data" cdkFocusInitial>Ok</button>
</div>
<mat-dialog-content>
<mat-form-field appearance="outline" class="full-width">
<mat-label>Username</mat-label>
<mat-icon matPrefix>person</mat-icon>
<input matInput name="username" autocomplete="username" required cdkFocusInitial [(ngModel)]="username" />
<mat-error>Username is required</mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="full-width">
<mat-label>Password</mat-label>
<mat-icon matPrefix>lock</mat-icon>
<input matInput name="password" autocomplete="current-password" required
[type]="hidePassword() ? 'password' : 'text'" [(ngModel)]="password" />
<button matIconButton matSuffix type="button" (click)="togglePassword($event)"
[attr.aria-label]="hidePassword() ? 'Show password' : 'Hide password'"
[attr.aria-pressed]="!hidePassword()">
<mat-icon>{{ hidePassword() ? 'visibility' : 'visibility_off' }}</mat-icon>
</button>
<mat-error>Password is required</mat-error>
</mat-form-field>
@if (errorMessage()) {
<div class="error-box" role="alert">
<mat-icon>error</mat-icon>
<span>{{ errorMessage() }}</span>
</div>
}
<p class="demo-hint">
<mat-icon>info</mat-icon>
<span>Demo: <code>Admin</code> / <code>pwd123</code></span>
</p>
</mat-dialog-content>
<mat-dialog-actions class="actions">
<button mat-flat-button type="submit" class="full-width" [disabled]="submitting()">
{{ submitting() ? 'Signing in…' : 'Sign in' }}
</button>
<p class="register">
No account yet?
<a routerLink="/register" mat-dialog-close>Create one</a>
</p>
</mat-dialog-actions>
</form>
@@ -1,4 +1,102 @@
.heading {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 24px 24px 8px;
text-align: center;
}
.fillField{
.heading-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
border-radius: 50%;
background-color: var(--mat-sys-primary-container);
color: var(--mat-sys-on-primary-container);
}
.title {
margin: 0;
font: var(--mat-sys-headline-small);
color: var(--mat-sys-on-surface);
}
.subtitle {
margin: 0;
font: var(--mat-sys-body-medium);
color: var(--mat-sys-on-surface-variant);
}
mat-dialog-content {
padding-top: 16px !important;
}
.full-width {
width: 100%;
};
}
mat-form-field {
margin-bottom: 4px;
}
.error-box {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
padding: 12px;
border-radius: 8px;
font: var(--mat-sys-body-medium);
background-color: var(--mat-sys-error-container);
color: var(--mat-sys-on-error-container);
}
.demo-hint {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
font: var(--mat-sys-body-small);
color: var(--mat-sys-on-surface-variant);
mat-icon {
width: 18px;
height: 18px;
font-size: 18px;
}
code {
padding: 1px 6px;
border-radius: 4px;
font-weight: 500;
background-color: var(--mat-sys-surface-container-highest);
color: var(--mat-sys-primary);
}
}
.actions {
flex-direction: column;
align-items: stretch;
gap: 8px;
padding: 8px 24px 24px;
}
.register {
margin: 0;
text-align: center;
font: var(--mat-sys-body-medium);
color: var(--mat-sys-on-surface-variant);
a {
font-weight: 500;
color: var(--mat-sys-primary);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
@@ -1,47 +1,55 @@
import {ChangeDetectionStrategy, Component, Inject, inject, signal} from '@angular/core';
import {MatButtonModule} from "@angular/material/button";
import {
MAT_DIALOG_DATA,
MatDialogActions,
MatDialogClose,
MatDialogContent,
MatDialogRef,
MatDialogTitle
} from "@angular/material/dialog";
import {MatFormField, MatLabel, MatSuffix} from "@angular/material/form-field";
import {MatInput} from "@angular/material/input";
import {FormsModule} from "@angular/forms";
import {MatIcon} from "@angular/material/icon";
import {DialogData} from "../../app.component";
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { RouterLink } from '@angular/router';
import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatIcon } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { AuthService } from 'src/app/services/auth.service';
import { TokenStorageService } from 'src/app/services/token-storage.service';
// Диалог входа: сам выполняет логин и закрывается только при успехе
@Component({
selector: 'app-dialog',
imports: [MatButtonModule, MatLabel, MatDialogActions, MatDialogClose, MatDialogTitle, MatDialogContent, MatFormField, MatInput, FormsModule, MatIcon, MatSuffix],
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './dialog.component.html',
styleUrl: './dialog.component.scss'
selector: 'app-dialog',
imports: [FormsModule, RouterLink, MatButtonModule, MatDialogModule, MatIcon, MatInputModule],
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './dialog.component.html',
styleUrl: './dialog.component.scss'
})
export class DialogComponent {
readonly dialogRef = inject(MatDialogRef<DialogComponent>);
hide = signal(true);
private readonly dialogRef = inject(MatDialogRef<DialogComponent>);
private readonly authService = inject(AuthService);
private readonly tokenStorage = inject(TokenStorageService);
constructor(
@Inject(MAT_DIALOG_DATA) public data:DialogData) {}
username = '';
password = '';
readonly hidePassword = signal(true);
readonly submitting = signal(false);
readonly errorMessage = signal('');
onNoClick() {
this.dialogRef.close();
}
clickEvent(event: MouseEvent) {
this.hide.set(!this.hide());
togglePassword(event: MouseEvent): void {
this.hidePassword.update(hide => !hide);
event.stopPropagation();
}
signIn(): void {
this.submitting.set(true);
this.errorMessage.set('');
this.authService.login(this.username, this.password).subscribe({
next: user => {
this.tokenStorage.saveUser(user);
this.dialogRef.close(true);
// Перезагрузка нужна, чтобы шапка и страницы подхватили вошедшего пользователя
window.location.reload();
},
error: err => {
this.submitting.set(false);
this.errorMessage.set(err.status === 401
? 'Wrong username or password'
: err.error?.message ?? 'Login failed, please try again');
}
});
}
}