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