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
@@ -0,0 +1,75 @@
// Общие стили экранов туториалов (список, добавление, редактирование)
@use '@angular/material' as mat;
.page {
max-width: 960px;
margin: 0 auto;
}
.page.narrow {
max-width: 560px;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}
.page-title {
margin: 0;
font: var(--mat-sys-headline-small);
color: var(--mat-sys-on-surface);
}
a,
a:hover {
text-decoration: none;
}
.status {
display: inline-block;
padding: 2px 10px;
border-radius: 8px;
font: var(--mat-sys-label-medium);
background-color: var(--mat-sys-surface-container-highest);
color: var(--mat-sys-on-surface-variant);
}
.status.published {
background-color: var(--mat-sys-tertiary-container);
color: var(--mat-sys-on-tertiary-container);
}
.empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 48px 16px;
text-align: center;
color: var(--mat-sys-on-surface-variant);
mat-icon {
width: 48px;
height: 48px;
font-size: 48px;
opacity: 0.6;
}
p {
margin: 0;
}
}
.danger {
@include mat.button-overrides((
text-label-text-color: var(--mat-sys-error),
));
}
.full-width {
width: 100%;
}
@@ -0,0 +1,51 @@
// Общие стили пользователя: аватар с инициалами и чипы ролей (админка, профиль)
.avatar {
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
width: 36px;
height: 36px;
border-radius: 50%;
font: var(--mat-sys-title-small);
text-transform: uppercase;
background-color: var(--mat-sys-primary-container);
color: var(--mat-sys-on-primary-container);
}
.avatar.large {
width: 88px;
height: 88px;
font: var(--mat-sys-headline-medium);
}
.roles {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.role {
display: inline-block;
padding: 2px 10px;
border-radius: 8px;
font: var(--mat-sys-label-medium);
background-color: var(--mat-sys-surface-container-highest);
color: var(--mat-sys-on-surface-variant);
}
.role.ROLE_ADMIN {
background-color: var(--mat-sys-error-container);
color: var(--mat-sys-on-error-container);
}
.role.ROLE_MODERATOR {
background-color: var(--mat-sys-tertiary-container);
color: var(--mat-sys-on-tertiary-container);
}
.role.ROLE_USER {
background-color: var(--mat-sys-secondary-container);
color: var(--mat-sys-on-secondary-container);
}
@@ -1,35 +1,49 @@
<div>
<div class="submit-form">
<div *ngIf="!submitted">
<div class="form-group">
<label for="title">Title</label>
<input
type="text"
class="form-control"
id="title"
required
[(ngModel)]="tutorial.title"
name="title"
/>
</div>
<div class="form-group">
<label for="description">Description</label>
<input
class="form-control"
id="description"
required
[(ngModel)]="tutorial.description"
name="description"
/>
</div>
<button (click)="saveTutorial()" class="btn btn-success">Submit</button>
</div>
<div *ngIf="submitted">
<h4>Tutorial was submitted successfully!</h4>
<button class="btn btn-success" (click)="newTutorial()">Add</button>
</div>
<div class="page narrow">
<div class="page-header">
<h1 class="page-title">New tutorial</h1>
<a mat-button routerLink="/tutorials">
<mat-icon>arrow_back</mat-icon>
Back to list
</a>
</div>
</div>
<mat-card appearance="outlined">
@if (!submitted) {
<form #tutorialForm="ngForm" (ngSubmit)="saveTutorial()">
<mat-card-content>
<mat-form-field appearance="outline" class="full-width">
<mat-label>Title</mat-label>
<input matInput name="title" required maxlength="255" [(ngModel)]="tutorial.title" />
<mat-error>Title is required</mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="full-width">
<mat-label>Description</mat-label>
<textarea matInput name="description" rows="4" maxlength="255" #description
[(ngModel)]="tutorial.description"></textarea>
<mat-hint align="end">{{ description.value.length }} / 255</mat-hint>
</mat-form-field>
</mat-card-content>
<mat-card-actions align="end">
<button mat-flat-button type="submit" [disabled]="tutorialForm.invalid || saving">
<mat-icon>add</mat-icon>
Create
</button>
</mat-card-actions>
</form>
} @else {
<div class="empty success">
<mat-icon>check_circle</mat-icon>
<p>Tutorial was created successfully!</p>
<div class="success-actions">
<a mat-stroked-button routerLink="/tutorials">Go to list</a>
<button mat-flat-button (click)="newTutorial()">
<mat-icon>add</mat-icon>
Add another
</button>
</div>
</div>
}
</mat-card>
</div>
@@ -1,4 +1,20 @@
.submit-form {
max-width: 400px;
margin: auto;
}
@use '../tutorial-shared';
mat-card-content {
padding-top: 16px;
}
mat-card-actions {
padding: 8px 16px 16px;
}
.success mat-icon {
color: var(--mat-sys-primary);
opacity: 1;
}
.success-actions {
display: flex;
gap: 8px;
margin-top: 8px;
}
@@ -1,26 +1,24 @@
import { Component, OnInit } from '@angular/core';
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { httpErrorMessage } from 'src/app/helpers/http-error';
import { Tutorial } from 'src/app/models/tutorial.model';
import { TutorialService } from 'src/app/services/tutorial.service';
@Component({
selector: 'app-add-tutorial',
templateUrl: './add-tutorial.component.html',
styleUrls: ['./add-tutorial.component.scss'],
standalone: false
selector: 'app-add-tutorial',
templateUrl: './add-tutorial.component.html',
styleUrls: ['./add-tutorial.component.scss'],
changeDetection: ChangeDetectionStrategy.Eager,
standalone: false
})
export class AddTutorialComponent implements OnInit {
export class AddTutorialComponent {
private readonly tutorialService = inject(TutorialService);
private readonly snackBar = inject(MatSnackBar);
tutorial: Tutorial = {
title: '',
description: '',
published: false
};
tutorial: Tutorial = this.emptyTutorial();
submitted = false;
constructor(private tutorialService: TutorialService) { }
ngOnInit(): void {
}
saving = false;
saveTutorial(): void {
const data = {
@@ -28,23 +26,25 @@ export class AddTutorialComponent implements OnInit {
description: this.tutorial.description
};
this.tutorialService.create(data)
.subscribe(
response => {
console.log(response);
this.submitted = true;
},
error => {
console.log(error);
});
this.saving = true;
this.tutorialService.create(data).subscribe({
next: () => {
this.saving = false;
this.submitted = true;
},
error: err => {
this.saving = false;
this.snackBar.open(httpErrorMessage(err), 'OK', { duration: 5000 });
}
});
}
newTutorial(): void {
this.submitted = false;
this.tutorial = {
title: '',
description: '',
published: false
};
this.tutorial = this.emptyTutorial();
}
}
private emptyTutorial(): Tutorial {
return { title: '', description: '', published: false };
}
}
@@ -1,51 +1,70 @@
<div class="col-md-6">
<h4>Users List</h4>
<ul class="list-group">
<li
class="list-group-item"
*ngFor="let row of rowData; let i = index"
<div class="page">
<div class="page-header">
<h1 class="page-title">Users</h1>
</div>
>
{{ row.username }}
<mat-form-field appearance="outline" class="search" subscriptSizing="dynamic">
<mat-label>Search by name, email or role</mat-label>
<mat-icon matPrefix>search</mat-icon>
<input matInput #search (input)="applyFilter(search.value)" />
@if (search.value) {
<button matIconButton matSuffix aria-label="Clear search" (click)="search.value = ''; applyFilter('')">
<mat-icon>close</mat-icon>
</button>
}
</mat-form-field>
{{ row.email}}
{{ row.password}}
<mat-card appearance="outlined" class="table-card">
<table mat-table [dataSource]="dataSource" matSort matSortActive="username" matSortDirection="asc">
<ng-container matColumnDef="username">
<th mat-header-cell *matHeaderCellDef mat-sort-header>User</th>
<td mat-cell *matCellDef="let user">
<div class="user-cell">
<span class="avatar">{{ initials(user) }}</span>
<span class="username">{{ user.username }}</span>
@if (user.id === currentUserId) {
<span class="you">you</span>
}
</div>
</td>
</ng-container>
<form>
<mat-form-field class="example-chip-list">
<mat-chip-grid #chipGrid aria-label="Role selection">
@for (role of row.roles; track $index) {
<mat-chip-row (removed)="remove(role)">
{{role.name}}
<button matChipRemove [attr.aria-label]="'remove ' + role.name">
<mat-icon>cancel</mat-icon>
</button>
</mat-chip-row>
}
</mat-chip-grid>
<input
name="currentFruit"
placeholder="Add role..."
#fruitInput
[(ngModel)]="currentRole"
[matChipInputFor]="chipGrid"
[matAutocomplete]="auto"
[matChipInputSeparatorKeyCodes]="separatorKeysCodes"
[formControl]="myControl"
(matChipInputTokenEnd)="add($event)"
(input)="change($event,filteredRoles(row.roles))"
/>
<mat-autocomplete [formControl]="ac" autoActiveFirstOption #auto="matAutocomplete" (optionSelected)="selected(row.roles,$event); ">
@for (role of filteredRoles(row.roles); track role) {
<mat-option [value]="role">{{role.name}}</mat-option>
}
</mat-autocomplete>
<ng-container matColumnDef="email">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Email</th>
<td mat-cell *matCellDef="let user" class="email">{{ user.email }}</td>
</ng-container>
</mat-form-field>
</form>
</li>
<ng-container matColumnDef="roles">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Roles</th>
<td mat-cell *matCellDef="let user">
<div class="roles">
@for (role of user.roles; track role.id) {
<span class="role {{ role.name }}">{{ roleLabel(role.name) }}</span>
}
</div>
</td>
</ng-container>
</ul>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *matCellDef="let user" class="actions">
<button matIconButton matTooltip="Edit roles" aria-label="Edit roles" (click)="editRoles(user)">
<mat-icon>manage_accounts</mat-icon>
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
<tr class="mat-mdc-row" *matNoDataRow>
<td class="mat-mdc-cell no-data" [attr.colspan]="displayedColumns.length">
{{ search.value ? 'No users match "' + search.value + '"' : 'No users yet' }}
</td>
</tr>
</table>
<mat-paginator [pageSizeOptions]="[10, 25, 50]" showFirstLastButtons aria-label="Select page of users">
</mat-paginator>
</mat-card>
</div>
@@ -1,3 +1,55 @@
.example-chip-list {
@use '../tutorial-shared';
@use '../user-shared';
.search {
width: 100%;
margin-bottom: 16px;
}
.table-card {
overflow: hidden;
}
table {
width: 100%;
}
.user-cell {
display: flex;
align-items: center;
gap: 12px;
}
.username {
font: var(--mat-sys-title-small);
}
.you {
padding: 0 6px;
border: 1px solid var(--mat-sys-outline-variant);
border-radius: 6px;
font: var(--mat-sys-label-small);
color: var(--mat-sys-on-surface-variant);
}
.email {
color: var(--mat-sys-on-surface-variant);
}
.actions {
width: 56px;
text-align: right;
}
.no-data {
padding: 32px 16px;
text-align: center;
color: var(--mat-sys-on-surface-variant);
}
// На узких экранах email не помещается — прячем колонку
@media (max-width: 600px) {
.mat-column-email {
display: none;
}
}
@@ -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 });
}
}
@@ -1 +0,0 @@
<p>board-moderator works!</p>
@@ -1,25 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { BoardModeratorComponent } from './board-moderator.component';
describe('BoardModeratorComponent', () => {
let component: BoardModeratorComponent;
let fixture: ComponentFixture<BoardModeratorComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ BoardModeratorComponent ]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(BoardModeratorComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -1,16 +0,0 @@
import { Component, OnInit } from '@angular/core';
@Component({
selector: 'app-board-moderator',
templateUrl: './board-moderator.component.html',
styleUrls: ['./board-moderator.component.scss'],
standalone: false
})
export class BoardModeratorComponent implements OnInit {
constructor() { }
ngOnInit(): void {
}
}
@@ -1 +0,0 @@
<p>board-user works!</p>
@@ -1,25 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { BoardUserComponent } from './board-user.component';
describe('BoardUserComponent', () => {
let component: BoardUserComponent;
let fixture: ComponentFixture<BoardUserComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ BoardUserComponent ]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(BoardUserComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -1,16 +0,0 @@
import { Component, OnInit } from '@angular/core';
@Component({
selector: 'app-board-user',
templateUrl: './board-user.component.html',
styleUrls: ['./board-user.component.scss'],
standalone: false
})
export class BoardUserComponent implements OnInit {
constructor() { }
ngOnInit(): void {
}
}
@@ -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');
}
});
}
}
@@ -1,5 +1,38 @@
<div class="container">
<header class="jumbotron">
<p>{{ content }}</p>
</header>
</div>
<div class="page">
@if (!isLoggedIn) {
<div class="demo-banner" role="note">
<mat-icon>info</mat-icon>
<div>
<strong>This is a demo project.</strong>
Log in as <code>Admin</code> with password <code>pwd123</code> to try editing tutorials and the admin page.
</div>
</div>
}
<div class="page-header">
<h1 class="page-title">Published tutorials</h1>
</div>
@if (tutorials.length) {
<div class="grid">
@for (tutorial of tutorials; track tutorial.id) {
<mat-card appearance="outlined" class="tutorial-card">
<mat-card-header>
<mat-icon mat-card-avatar class="card-icon">article</mat-icon>
<mat-card-title>{{ tutorial.title }}</mat-card-title>
</mat-card-header>
<mat-card-content>
<p class="description">{{ tutorial.description || 'No description' }}</p>
</mat-card-content>
</mat-card>
}
</div>
} @else if (loaded) {
<mat-card appearance="outlined">
<div class="empty">
<mat-icon>menu_book</mat-icon>
<p>No published tutorials yet</p>
</div>
</mat-card>
}
</div>
@@ -0,0 +1,49 @@
@use '../tutorial-shared';
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
.tutorial-card {
height: 100%;
}
.card-icon {
display: flex;
align-items: center;
justify-content: center;
background-color: var(--mat-sys-primary-container);
color: var(--mat-sys-on-primary-container);
}
mat-card-content {
padding-top: 12px;
}
.description {
margin: 0;
white-space: pre-line;
color: var(--mat-sys-on-surface-variant);
}
.demo-banner {
display: flex;
align-items: flex-start;
gap: 12px;
margin-bottom: 24px;
padding: 16px;
border-radius: 12px;
font: var(--mat-sys-body-medium);
background-color: var(--mat-sys-secondary-container);
color: var(--mat-sys-on-secondary-container);
code {
padding: 1px 6px;
border-radius: 4px;
font-weight: 500;
background-color: var(--mat-sys-surface);
color: var(--mat-sys-primary);
}
}
@@ -1,25 +1,38 @@
import { Component, OnInit } from '@angular/core';
import { UserService } from 'src/app/services/user.service';
import { ChangeDetectionStrategy, Component, inject, OnInit } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { httpErrorMessage } from 'src/app/helpers/http-error';
import { Tutorial } from 'src/app/models/tutorial.model';
import { TokenStorageService } from 'src/app/services/token-storage.service';
import { TutorialService } from 'src/app/services/tutorial.service';
@Component({
selector: 'app-home',
templateUrl: './home.component.html',
styleUrls: ['./home.component.scss'],
standalone: false
selector: 'app-home',
templateUrl: './home.component.html',
styleUrls: ['./home.component.scss'],
changeDetection: ChangeDetectionStrategy.Eager,
standalone: false
})
export class HomeComponent implements OnInit {
content?: string;
private readonly tutorialService = inject(TutorialService);
private readonly snackBar = inject(MatSnackBar);
constructor(private userService: UserService) { }
readonly isLoggedIn = inject(TokenStorageService).isLoggedIn();
tutorials: Tutorial[] = [];
loaded = false;
ngOnInit(): void {
// this.userService.getPublicContent().subscribe(
// (data : any) => {
// this.content = data;
// },
// (err : any) => {
// this.content = JSON.parse(err.error).message;
// }
// );
this.tutorialService.getPublished().subscribe({
// На пустой результат сервер отвечает 204 без тела
next: data => {
this.tutorials = data ?? [];
this.loaded = true;
},
error: err => {
this.loaded = true;
this.snackBar.open(httpErrorMessage(err), 'OK', { duration: 5000 });
}
});
}
}
}
@@ -1,75 +0,0 @@
<div class="col-md-12">
<div class="card card-container">
<img
id="profile-img"
src="//ssl.gstatic.com/accounts/ui/avatar_2x.png"
class="profile-img-card"
/>
<form
*ngIf="!isLoggedIn"
name="form"
(ngSubmit)="f.form.valid && onSubmit()"
#f="ngForm"
novalidate
>
<div class="form-group">
<label for="username">Username</label>
<input
type="text"
class="form-control"
id="username"
[(ngModel)]="form.username"
required
#username="ngModel"
/>
<div
class="alert alert-danger"
role="alert"
*ngIf="username.errors && f.submitted"
>
Username is required!
</div>
</div>
<div class="form-group">
<label for="password">Password</label>
<input
type="password"
class="form-control"
id="password"
[(ngModel)]="form.password"
required
minlength="6"
#password="ngModel"
/>
<div
class="alert alert-danger"
role="alert"
*ngIf="password.errors && f.submitted"
>
<div *ngIf="password.errors.required">Password is required</div>
<div *ngIf="password.errors.minlength">
Password must be at least 6 characters
</div>
</div>
</div>
<div class="form-group">
<button class="btn btn-primary btn-block">
Login
</button>
</div>
<div class="form-group">
<div
class="alert alert-danger"
role="alert"
*ngIf="f.submitted && isLoginFailed"
>
Login failed: {{ errorMessage }}
</div>
</div>
</form>
<div class="alert alert-success" *ngIf="isLoggedIn">
Logged in as {{ roles }}.
</div>
</div>
</div>
@@ -1,25 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { LoginComponent } from './login.component';
describe('LoginComponent', () => {
let component: LoginComponent;
let fixture: ComponentFixture<LoginComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ LoginComponent ]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(LoginComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -1,59 +0,0 @@
import { Component, OnInit } from '@angular/core';
import { AuthService } from 'src/app/services/auth.service';
import { TokenStorageService } from 'src/app/services/token-storage.service';
import {FormsModule} from "@angular/forms";
import {NgIf} from "@angular/common";
@Component({
selector: 'app-login',
templateUrl: './login.component.html',
imports: [
FormsModule,
NgIf
],
styleUrls: ['./login.component.scss']
})
export class LoginComponent implements OnInit {
form: any = {
username: null,
password: null
};
isLoggedIn = false;
isLoginFailed = false;
errorMessage = '';
roles: string[] = [];
constructor(private authService: AuthService, private tokenStorage: TokenStorageService) { }
ngOnInit(): void {
if (this.tokenStorage.getToken()) {
this.isLoggedIn = true;
this.roles = this.tokenStorage.getUser().roles;
}
}
onSubmit(): void {
const { username, password } = this.form;
this.authService.login(username, password).subscribe(
data => {
this.tokenStorage.saveToken(data.accessToken);
this.tokenStorage.saveUser(data);
this.isLoginFailed = false;
this.isLoggedIn = true;
this.roles = this.tokenStorage.getUser().roles;
this.reloadPage();
},
err => {
this.errorMessage = err.error.message;
this.isLoginFailed = true;
}
);
}
reloadPage(): void {
window.location.reload();
}
}
@@ -1,26 +1,28 @@
<div class="container" *ngIf="currentUser; else loggedOut">
<header class="jumbotron">
<h3>
<strong>{{ currentUser.username }}</strong> Profile
</h3>
</header>
<p>
<strong>Token:</strong>
{{ currentUser.accessToken.substring(0, 20) }} ...
{{ currentUser.accessToken.substr(currentUser.accessToken.length - 20) }}
</p>
<p>
<strong>Email:</strong>
{{ currentUser.email }}
</p>
<strong>Roles:</strong>
<ul>
<li *ngFor="let role of currentUser.roles">
{{ role }}
</li>
</ul>
</div>
<ng-template #loggedOut>
Please login.
</ng-template>
<div class="page narrow">
<mat-card appearance="outlined" class="profile-card">
<div class="hero">
<span class="avatar large">{{ user.username?.slice(0, 2) }}</span>
<h1 class="name">{{ user.username }}</h1>
<div class="roles">
@for (role of roles; track role) {
<span class="role {{ role }}">{{ roleLabel(role) }}</span>
}
</div>
</div>
<mat-divider></mat-divider>
<mat-list>
<mat-list-item>
<mat-icon matListItemIcon>person</mat-icon>
<span matListItemTitle>Username</span>
<span matListItemLine>{{ user.username }}</span>
</mat-list-item>
<mat-list-item>
<mat-icon matListItemIcon>mail</mat-icon>
<span matListItemTitle>Email</span>
<span matListItemLine>{{ user.email }}</span>
</mat-list-item>
</mat-list>
</mat-card>
</div>
@@ -0,0 +1,20 @@
@use '../tutorial-shared';
@use '../user-shared';
.hero {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 32px 16px 24px;
}
.name {
margin: 0;
font: var(--mat-sys-headline-small);
color: var(--mat-sys-on-surface);
}
.roles {
justify-content: center;
}
@@ -1,18 +1,17 @@
import { Component, OnInit } from '@angular/core';
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { roleLabel } from 'src/app/models/role';
import { TokenStorageService } from 'src/app/services/token-storage.service';
@Component({
selector: 'app-profile',
templateUrl: './profile.component.html',
styleUrls: ['./profile.component.scss'],
standalone: false
selector: 'app-profile',
templateUrl: './profile.component.html',
styleUrls: ['./profile.component.scss'],
changeDetection: ChangeDetectionStrategy.Eager,
standalone: false
})
export class ProfileComponent implements OnInit {
currentUser: any;
constructor(private token: TokenStorageService) { }
ngOnInit(): void {
this.currentUser = this.token.getUser();
}
}
export class ProfileComponent {
readonly user = inject(TokenStorageService).getUser();
readonly roles: string[] = this.user.roles ?? [];
readonly roleLabel = roleLabel;
}
@@ -1,89 +1,75 @@
<div class="col-md-12">
<div id="register">
<img id="foto"
id="profile-img"
src="//ssl.gstatic.com/accounts/ui/avatar_2x.png"
class="profile-img-card"
/>
<form
*ngIf="!isSuccessful"
name="form"
(ngSubmit)="f.form.valid && onSubmit()"
#f="ngForm"
novalidate
>
<div class="form-group">
<label for="username">Username</label>
<input
type="text"
class="form-control"
id="username"
[(ngModel)]="form.username"
[ngModelOptions]="{standalone: true}"
required
minlength="3"
maxlength="20"
#username="ngModel"
/>
<div class="alert-danger" *ngIf="username.errors && f.submitted">
<div *ngIf="username.errors.required">Username is required</div>
<div *ngIf="username.errors.minlength">
Username must be at least 3 characters
</div>
<div *ngIf="username.errors.maxlength">
Username must be at most 20 characters
</div>
</div>
</div>
<div class="form-group">
<label for="email">Email</label>
<input
type="email"
class="form-control"
id="email"
[(ngModel)]="form.email"
[ngModelOptions]="{standalone: true}"
required
email
#email="ngModel"
/>
<div class="alert-danger" *ngIf="email.errors && f.submitted">
<div *ngIf="email.errors.required">Email is required</div>
<div *ngIf="email.errors.email">
Email must be a valid email address
</div>
</div>
</div>
<div class="form-group">
<label for="password">Password</label>
<input
type="password"
class="form-control"
id="password"
[(ngModel)]="form.password"
[ngModelOptions]="{standalone: true}"
required
minlength="6"
#password="ngModel"
/>
<div class="alert-danger" *ngIf="password.errors && f.submitted">
<div *ngIf="password.errors.required">Password is required</div>
<div *ngIf="password.errors.minlength">
Password must be at least 6 characters
</div>
</div>
</div>
<div class="form-group">
<button class="btn btn-primary btn-block">Sign Up</button>
</div>
<div class="alert alert-warning" *ngIf="f.submitted && isSignUpFailed">
Signup failed!<br />{{ errorMessage }}
</div>
</form>
<div class="alert alert-success" *ngIf="isSuccessful">
Your registration is successful!
<div class="page auth">
<mat-card appearance="outlined">
@if (!registeredOnly) {
<div class="heading">
<span class="heading-icon"><mat-icon>person_add</mat-icon></span>
<h1 class="page-title">Create account</h1>
<p class="subtitle">Sign up to add and edit tutorials</p>
</div>
</div>
</div>
<form #f="ngForm" (ngSubmit)="f.valid && onSubmit()">
<mat-card-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 minlength="3" maxlength="20"
[(ngModel)]="form.username" #username="ngModel" />
@if (username.hasError('required')) {
<mat-error>Username is required</mat-error>
} @else if (username.hasError('minlength')) {
<mat-error>Username must be at least 3 characters</mat-error>
}
<mat-hint align="end">{{ form.username.length }} / 20</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline" class="full-width">
<mat-label>Email</mat-label>
<mat-icon matPrefix>mail</mat-icon>
<input matInput type="email" name="email" autocomplete="email" required email maxlength="50"
[(ngModel)]="form.email" #email="ngModel" />
@if (email.hasError('required')) {
<mat-error>Email is required</mat-error>
} @else if (email.hasError('email')) {
<mat-error>Enter a valid email address</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="new-password" required minlength="6" maxlength="40"
[type]="hidePassword ? 'password' : 'text'" [(ngModel)]="form.password" #password="ngModel" />
<button matIconButton matSuffix type="button" (click)="hidePassword = !hidePassword"
[attr.aria-label]="hidePassword ? 'Show password' : 'Hide password'">
<mat-icon>{{ hidePassword ? 'visibility' : 'visibility_off' }}</mat-icon>
</button>
@if (password.hasError('required')) {
<mat-error>Password is required</mat-error>
} @else if (password.hasError('minlength')) {
<mat-error>Password must be at least 6 characters</mat-error>
}
</mat-form-field>
@if (errorMessage) {
<div class="error-box" role="alert">
<mat-icon>error</mat-icon>
<span>{{ errorMessage }}</span>
</div>
}
</mat-card-content>
<mat-card-actions class="form-actions">
<button mat-flat-button type="submit" class="full-width" [disabled]="saving">
{{ saving ? 'Creating account…' : 'Sign up' }}
</button>
</mat-card-actions>
</form>
} @else {
<div class="empty success">
<mat-icon>check_circle</mat-icon>
<p>Your account was created. Please log in.</p>
<button mat-flat-button (click)="openLogin()">Log in</button>
</div>
}
</mat-card>
</div>
@@ -1 +1,60 @@
@use '../tutorial-shared';
.page.auth {
max-width: 420px;
margin-top: 24px;
}
.heading {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 24px 16px 8px;
text-align: center;
}
.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);
}
.subtitle {
margin: 0;
font: var(--mat-sys-body-medium);
color: var(--mat-sys-on-surface-variant);
}
mat-card-content {
padding-top: 16px;
}
mat-form-field {
margin-bottom: 4px;
}
.error-box {
display: flex;
align-items: center;
gap: 8px;
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);
}
.form-actions {
padding: 8px 16px 24px;
}
.success mat-icon {
color: var(--mat-sys-primary);
opacity: 1;
}
@@ -1,40 +1,64 @@
import { Component, OnInit } from '@angular/core';
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { Router } from '@angular/router';
import { MatDialog } from '@angular/material/dialog';
import { AuthService } from 'src/app/services/auth.service';
import { TokenStorageService } from 'src/app/services/token-storage.service';
import { DialogComponent } from '../dialog/dialog.component';
@Component({
selector: 'app-register',
templateUrl: './register.component.html',
styleUrls: ['./register.component.scss'],
standalone: false
selector: 'app-register',
templateUrl: './register.component.html',
styleUrls: ['./register.component.scss'],
changeDetection: ChangeDetectionStrategy.Eager,
standalone: false
})
export class RegisterComponent implements OnInit {
form: any = {
username: null,
email: null,
password: null
export class RegisterComponent {
private readonly authService = inject(AuthService);
private readonly tokenStorage = inject(TokenStorageService);
private readonly router = inject(Router);
private readonly dialog = inject(MatDialog);
form = {
username: '',
email: '',
password: ''
};
isSuccessful = false;
isSignUpFailed = false;
hidePassword = true;
saving = false;
errorMessage = '';
constructor(private authService: AuthService) { }
ngOnInit(): void {
}
// Аккаунт создан, но автоматический вход не удался — предлагаем войти вручную
registeredOnly = false;
onSubmit(): void {
const { username, email, password } = this.form;
this.saving = true;
this.errorMessage = '';
this.authService.register(username, email, password).subscribe(
data => {
console.log(data);
this.isSuccessful = true;
this.isSignUpFailed = false;
},
err => {
this.errorMessage = err.error.message;
this.isSignUpFailed = true;
this.authService.register(username, email, password).subscribe({
next: () => this.signIn(username, password),
error: err => {
this.saving = false;
this.errorMessage = err.error?.message ?? 'Registration failed, please try again';
}
);
});
}
}
openLogin(): void {
this.dialog.open(DialogComponent, { width: '400px', maxWidth: '95vw' });
}
private signIn(username: string, password: string): void {
this.authService.login(username, password).subscribe({
next: user => {
this.tokenStorage.saveUser(user);
// Перезагрузка нужна, чтобы шапка подхватила вошедшего пользователя
this.router.navigate(['/home']).then(() => window.location.reload());
},
error: () => {
this.saving = false;
this.registeredOnly = true;
}
});
}
}
@@ -0,0 +1,19 @@
<h2 mat-dialog-title>Roles of {{ data.user.username }}</h2>
<mat-dialog-content>
<div class="options">
@for (role of data.allRoles; track role.id) {
<mat-checkbox [checked]="isSelected(role.name!)" (change)="toggle(role.name!, $event.checked)">
{{ roleLabel(role.name) }}
</mat-checkbox>
}
</div>
@if (!result.length) {
<p class="hint">Select at least one role</p>
}
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button mat-dialog-close>Cancel</button>
<button mat-flat-button [mat-dialog-close]="result" [disabled]="!result.length">Save</button>
</mat-dialog-actions>
@@ -0,0 +1,11 @@
.options {
display: flex;
flex-direction: column;
gap: 4px;
}
.hint {
margin: 8px 0 0;
font: var(--mat-sys-body-small);
color: var(--mat-sys-error);
}
@@ -0,0 +1,37 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
import { Role, roleLabel } from 'src/app/models/role';
import { User } from 'src/app/models/user.model';
export interface RolesDialogData {
user: User;
allRoles: Role[];
}
// Диалог выбора ролей пользователя; возвращает массив имён ролей или undefined при отмене
@Component({
selector: 'app-roles-dialog',
templateUrl: './roles-dialog.component.html',
styleUrl: './roles-dialog.component.scss',
imports: [MatDialogModule, MatButtonModule, MatCheckboxModule],
changeDetection: ChangeDetectionStrategy.Eager
})
export class RolesDialogComponent {
readonly data = inject<RolesDialogData>(MAT_DIALOG_DATA);
readonly roleLabel = roleLabel;
result: string[] = (this.data.user.roles ?? []).map(role => role.name!);
isSelected(name: string): boolean {
return this.result.includes(name);
}
toggle(name: string, checked: boolean): void {
this.result = checked
? [...this.result, name]
: this.result.filter(selected => selected !== name);
}
}
@@ -1,65 +1,61 @@
<div>
<div *ngIf="currentTutorial.id" class="edit-form">
<h4>Tutorial</h4>
<form>
<div class="form-group">
<label for="title">Title</label>
<input
type="text"
class="form-control"
id="title"
[(ngModel)]="currentTutorial.title"
name="title"
/>
</div>
<div class="form-group">
<label for="description">Description</label>
<input
type="text"
class="form-control"
id="description"
[(ngModel)]="currentTutorial.description"
name="description"
/>
</div>
<div class="form-group">
<label><strong>Status:</strong></label>
{{ currentTutorial.published ? "Published" : "Pending" }}
</div>
</form>
<button
class="badge badge-primary mr-2"
*ngIf="currentTutorial.published"
(click)="updatePublished(false)"
>
UnPublish
</button>
<button
*ngIf="!currentTutorial.published"
class="badge badge-primary mr-2"
(click)="updatePublished(true)"
>
Publish
</button>
<button class="badge badge-danger mr-2" (click)="deleteTutorial()">
Delete
</button>
<button
type="submit"
class="badge badge-success mb-2"
(click)="updateTutorial()"
>
Update
</button>
<p>{{ message }}</p>
<div class="page narrow">
<div class="page-header">
<h1 class="page-title">Edit tutorial</h1>
<a mat-button routerLink="/tutorials">
<mat-icon>arrow_back</mat-icon>
Back to list
</a>
</div>
<div *ngIf="!currentTutorial.id">
<br />
<p>Cannot access this Tutorial...</p>
</div>
</div>
@if (currentTutorial.id) {
<mat-card appearance="outlined">
<form #tutorialForm="ngForm" (ngSubmit)="updateTutorial()">
<mat-card-header>
<mat-card-subtitle>
<span class="status" [class.published]="currentTutorial.published">
{{ currentTutorial.published ? 'Published' : 'Pending' }}
</span>
</mat-card-subtitle>
</mat-card-header>
<mat-card-content>
<mat-form-field appearance="outline" class="full-width">
<mat-label>Title</mat-label>
<input matInput name="title" required maxlength="255" [(ngModel)]="currentTutorial.title" />
<mat-error>Title is required</mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="full-width">
<mat-label>Description</mat-label>
<textarea matInput name="description" rows="4" maxlength="255" #description
[(ngModel)]="currentTutorial.description"></textarea>
<mat-hint align="end">{{ description.value.length }} / 255</mat-hint>
</mat-form-field>
</mat-card-content>
<mat-card-actions class="actions">
<button mat-button type="button" class="danger" (click)="deleteTutorial()">
<mat-icon>delete</mat-icon>
Delete
</button>
<span class="spacer"></span>
<button mat-stroked-button type="button" (click)="updatePublished(!currentTutorial.published)">
<mat-icon>{{ currentTutorial.published ? 'unpublished' : 'publish' }}</mat-icon>
{{ currentTutorial.published ? 'Unpublish' : 'Publish' }}
</button>
<button mat-flat-button type="submit" [disabled]="tutorialForm.invalid">
<mat-icon>save</mat-icon>
Save
</button>
</mat-card-actions>
</form>
</mat-card>
} @else if (notFound) {
<mat-card appearance="outlined">
<div class="empty">
<mat-icon>search_off</mat-icon>
<p>This tutorial could not be loaded</p>
</div>
</mat-card>
}
</div>
@@ -1,4 +1,14 @@
.edit-form {
max-width: 400px;
margin: auto;
}
@use '../tutorial-shared';
mat-card-content {
padding-top: 16px;
}
.actions {
gap: 8px;
padding: 8px 16px 16px;
}
.spacer {
flex: 1 1 auto;
}
@@ -1,89 +1,78 @@
import { Component, OnInit } from '@angular/core';
import { TutorialService } from 'src/app/services/tutorial.service';
import { ChangeDetectionStrategy, Component, inject, OnInit } from '@angular/core';
import { HttpErrorResponse } from '@angular/common/http';
import { ActivatedRoute, Router } from '@angular/router';
import { MatSnackBar } from '@angular/material/snack-bar';
import { httpErrorMessage } from 'src/app/helpers/http-error';
import { Tutorial } from 'src/app/models/tutorial.model';
import { TutorialService } from 'src/app/services/tutorial.service';
@Component({
selector: 'app-tutorial-details',
templateUrl: './tutorial-details.component.html',
styleUrls: ['./tutorial-details.component.scss'],
standalone: false
selector: 'app-tutorial-details',
templateUrl: './tutorial-details.component.html',
styleUrls: ['./tutorial-details.component.scss'],
changeDetection: ChangeDetectionStrategy.Eager,
standalone: false
})
export class TutorialDetailsComponent implements OnInit {
private readonly tutorialService = inject(TutorialService);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly snackBar = inject(MatSnackBar);
currentTutorial: Tutorial = {
title: '',
description: '',
published: false
};
message = '';
constructor(
private tutorialService: TutorialService,
private route: ActivatedRoute,
private router: Router) { }
notFound = false;
ngOnInit(): void {
this.message = '';
this.getTutorial(this.route.snapshot.params.id);
this.tutorialService.get(this.route.snapshot.params['id']).subscribe({
next: data => this.currentTutorial = data,
error: err => {
this.notFound = true;
this.showError(err);
}
});
}
getTutorial(id: string): void {
this.tutorialService.get(id)
.subscribe(
data => {
this.currentTutorial = data;
console.log(data);
},
error => {
console.log(error);
});
}
updatePublished(status: boolean): void {
const data = {
title: this.currentTutorial.title,
description: this.currentTutorial.description,
published: status
};
this.message = '';
this.tutorialService.update(this.currentTutorial.id, data)
.subscribe(
response => {
this.currentTutorial.published = status;
console.log(response);
this.message = response.message ? response.message : 'The status was updated successfully!';
},
error => {
console.log(error);
});
updatePublished(published: boolean): void {
const data = { ...this.currentTutorial, published };
this.tutorialService.update(this.currentTutorial.id, data).subscribe({
next: () => {
this.currentTutorial.published = published;
this.notify(published ? 'Tutorial was published' : 'Tutorial was unpublished');
},
error: err => this.showError(err)
});
}
updateTutorial(): void {
this.message = '';
this.tutorialService.update(this.currentTutorial.id, this.currentTutorial)
.subscribe(
response => {
console.log(response);
this.message = response.message ? response.message : 'This tutorial was updated successfully!';
},
error => {
console.log(error);
});
this.tutorialService.update(this.currentTutorial.id, this.currentTutorial).subscribe({
next: () => this.notify('Tutorial was saved'),
error: err => this.showError(err)
});
}
deleteTutorial(): void {
this.tutorialService.delete(this.currentTutorial.id)
.subscribe(
response => {
console.log(response);
this.router.navigate(['/tutorials']);
},
error => {
console.log(error);
});
if (!confirm(`Delete "${this.currentTutorial.title}"?`)) {
return;
}
this.tutorialService.delete(this.currentTutorial.id).subscribe({
next: () => {
this.notify('Tutorial was deleted');
this.router.navigate(['/tutorials']);
},
error: err => this.showError(err)
});
}
}
private notify(message: string): void {
this.snackBar.open(message, 'OK', { duration: 3000 });
}
private showError(err: HttpErrorResponse): void {
this.snackBar.open(httpErrorMessage(err), 'OK', { duration: 5000 });
}
}
@@ -1,63 +1,81 @@
<div class="list row">
<div class="col-md-8">
<div class="input-group mb-3">
<input
type="text"
class="form-control"
placeholder="Search by title"
[(ngModel)]="title"
/>
<div class="input-group-append">
<button
class="btn btn-outline-secondary"
type="button"
(click)="searchTitle()"
>
Search
</button>
</div>
</div>
</div>
<div class="col-md-6">
<h4>Tutorials List</h4>
<ul class="list-group">
<li
class="list-group-item"
*ngFor="let tutorial of tutorials; let i = index"
[class.active]="i == currentIndex"
(click)="setActiveTutorial(tutorial, i)"
>
{{ tutorial.title }}
</li>
</ul>
<button class="m-3 btn btn-sm btn-danger" (click)="removeAllTutorials()">
Remove All
</button>
</div>
<div class="col-md-6">
<div *ngIf="currentTutorial.id">
<h4>Tutorial</h4>
<div>
<label><strong>Title:</strong></label> {{ currentTutorial.title }}
</div>
<div>
<label><strong>Description:</strong></label>
{{ currentTutorial.description }}
</div>
<div>
<label><strong>Status:</strong></label>
{{ currentTutorial.published ? "Published" : "Pending" }}
</div>
<a class="badge badge-warning" routerLink="/tutorials/{{ currentTutorial.id }}">
Edit
<div class="page">
<div class="page-header">
<h1 class="page-title">Tutorials</h1>
@if (isLoggedIn) {
<a mat-flat-button routerLink="/add">
<mat-icon>add</mat-icon>
New tutorial
</a>
</div>
<div *ngIf="!currentTutorial">
<br />
<p>Please click on a Tutorial...</p>
</div>
}
</div>
</div>
<mat-form-field appearance="outline" class="search" subscriptSizing="dynamic">
<mat-label>Search by title</mat-label>
<mat-icon matPrefix>search</mat-icon>
<input matInput [(ngModel)]="title" (keyup.enter)="searchTitle()" />
@if (title) {
<button matIconButton matSuffix aria-label="Clear search" (click)="clearSearch()">
<mat-icon>close</mat-icon>
</button>
}
</mat-form-field>
<div class="layout">
<mat-card appearance="outlined" class="list-card">
@if (tutorials.length) {
<mat-action-list>
@for (tutorial of tutorials; track tutorial.id; let i = $index) {
<button mat-list-item [activated]="i === currentIndex" (click)="setActiveTutorial(tutorial, i)">
<mat-icon matListItemIcon>{{ tutorial.published ? 'task' : 'draft' }}</mat-icon>
<span matListItemTitle>{{ tutorial.title }}</span>
<span matListItemLine>{{ tutorial.published ? 'Published' : 'Pending' }}</span>
</button>
}
</mat-action-list>
} @else {
<div class="empty">
<mat-icon>menu_book</mat-icon>
<p>{{ title ? 'Nothing found' : 'No tutorials yet' }}</p>
</div>
}
@if (isAdmin && tutorials.length) {
<mat-card-actions align="end">
<button mat-button class="danger" (click)="removeAllTutorials()">
<mat-icon>delete_sweep</mat-icon>
Remove all
</button>
</mat-card-actions>
}
</mat-card>
<mat-card appearance="outlined" class="details-card">
@if (currentTutorial.id) {
<mat-card-header>
<mat-card-title>{{ currentTutorial.title }}</mat-card-title>
<mat-card-subtitle>
<span class="status" [class.published]="currentTutorial.published">
{{ currentTutorial.published ? 'Published' : 'Pending' }}
</span>
</mat-card-subtitle>
</mat-card-header>
<mat-card-content>
<p class="description">{{ currentTutorial.description || 'No description' }}</p>
</mat-card-content>
@if (isLoggedIn) {
<mat-card-actions align="end">
<a mat-stroked-button [routerLink]="['/tutorials', currentTutorial.id]">
<mat-icon>edit</mat-icon>
Edit
</a>
</mat-card-actions>
}
} @else {
<div class="empty">
<mat-icon>touch_app</mat-icon>
<p>Select a tutorial to see the details</p>
</div>
}
</mat-card>
</div>
</div>
@@ -1,5 +1,22 @@
.list {
text-align: left;
max-width: 750px;
margin: auto;
}
@use '../tutorial-shared';
.search {
width: 100%;
margin-bottom: 16px;
}
.layout {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 16px;
align-items: start;
@media (max-width: 768px) {
grid-template-columns: 1fr;
}
}
.description {
white-space: pre-line;
color: var(--mat-sys-on-surface-variant);
}
@@ -1,73 +1,84 @@
import { Component, OnInit } from '@angular/core';
import { ChangeDetectionStrategy, Component, inject, OnInit } from '@angular/core';
import { HttpErrorResponse } from '@angular/common/http';
import { MatSnackBar } from '@angular/material/snack-bar';
import { httpErrorMessage } from 'src/app/helpers/http-error';
import { Tutorial } from 'src/app/models/tutorial.model';
import { TokenStorageService } from 'src/app/services/token-storage.service';
import { TutorialService } from 'src/app/services/tutorial.service';
@Component({
selector: 'app-tutorials-list',
templateUrl: './tutorials-list.component.html',
styleUrls: ['./tutorials-list.component.scss'],
standalone: false
selector: 'app-tutorials-list',
templateUrl: './tutorials-list.component.html',
styleUrls: ['./tutorials-list.component.scss'],
changeDetection: ChangeDetectionStrategy.Eager,
standalone: false
})
export class TutorialsListComponent implements OnInit {
private readonly tutorialService = inject(TutorialService);
private readonly tokenStorage = inject(TokenStorageService);
private readonly snackBar = inject(MatSnackBar);
tutorials?: Tutorial[];
tutorials: Tutorial[] = [];
currentTutorial: Tutorial = {};
currentIndex = -1;
title = '';
constructor(private tutorialService: TutorialService) { }
isAdmin = false;
isLoggedIn = false;
ngOnInit(): void {
this.isLoggedIn = this.tokenStorage.isLoggedIn();
this.isAdmin = (this.tokenStorage.getUser().roles ?? []).includes('ROLE_ADMIN');
this.retrieveTutorials();
}
retrieveTutorials(): void {
this.tutorialService.getAll()
.subscribe(
data => {
this.tutorials = data;
console.log(data);
},
error => {
console.log(error);
});
}
refreshList(): void {
this.retrieveTutorials();
this.currentTutorial = {};
this.currentIndex = -1;
}
setActiveTutorial(tutorial: Tutorial, index: number): void {
this.currentTutorial = tutorial;
this.currentIndex = index;
}
removeAllTutorials(): void {
this.tutorialService.deleteAll()
.subscribe(
response => {
console.log(response);
this.refreshList();
},
error => {
console.log(error);
});
searchTitle(): void {
this.resetSelection();
const request = this.title ? this.tutorialService.findByTitle(this.title) : this.tutorialService.getAll();
request.subscribe({
// На пустой результат сервер отвечает 204 без тела
next: data => this.tutorials = data ?? [],
error: err => this.showError(err)
});
}
searchTitle(): void {
clearSearch(): void {
this.title = '';
this.searchTitle();
}
removeAllTutorials(): void {
if (!confirm('Delete all tutorials? This cannot be undone.')) {
return;
}
this.tutorialService.deleteAll().subscribe({
next: () => {
this.snackBar.open('All tutorials were deleted', 'OK', { duration: 3000 });
this.retrieveTutorials();
},
error: err => this.showError(err)
});
}
private retrieveTutorials(): void {
this.resetSelection();
this.tutorialService.getAll().subscribe({
next: data => this.tutorials = data ?? [],
error: err => this.showError(err)
});
}
private resetSelection(): void {
this.currentTutorial = {};
this.currentIndex = -1;
this.tutorialService.findByTitle(this.title)
.subscribe(
data => {
this.tutorials = data;
console.log(data);
},
error => {
console.log(error);
});
}
}
private showError(err: HttpErrorResponse): void {
this.snackBar.open(httpErrorMessage(err), 'OK', { duration: 5000 });
}
}