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:
@@ -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>
|
||||
-25
@@ -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 +0,0 @@
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+59
-63
@@ -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>
|
||||
|
||||
+14
-4
@@ -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;
|
||||
}
|
||||
|
||||
+55
-66
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user