Merge pull request 'Доделать demo-decoupled' (#2) from feature/1-finish-demo into main

This commit was merged in pull request #2.
This commit is contained in:
2026-10-17 19:43:34 +02:00
118 changed files with 9134 additions and 79218 deletions
+22
View File
@@ -0,0 +1,22 @@
# Всё, что не нужно для сборки образа бэкенда: ускоряет передачу контекста
# и не даёт попасть внутрь локальным секретам
.git
.gitignore
.gitattributes
.idea
.vscode
.gradle
build
out
*.iml
.env
.env.*
!.env.example
Dockerfile
docker-compose*.yml
.dockerignore
# Фронт собирается в своём образе (контекст — spring-angular-ui)
spring-angular-ui
+3
View File
@@ -35,3 +35,6 @@ out/
### VS Code ###
.vscode/
### Docker ###
.env
+52
View File
@@ -0,0 +1,52 @@
# syntax=docker/dockerfile:1
# Образ только бэкенда (REST API); фронт собирается отдельно, см. spring-angular-ui/Dockerfile
# 1. Сборка jar
FROM eclipse-temurin:26-jdk-jammy AS build
WORKDIR /app
# Сначала Gradle-обвязка — зависимости скачиваются заново лишь при изменении build.gradle
COPY gradlew settings.gradle build.gradle ./
COPY gradle ./gradle
RUN chmod +x gradlew
RUN --mount=type=cache,target=/root/.gradle \
./gradlew dependencies --no-daemon -q > /dev/null
COPY src ./src
RUN --mount=type=cache,target=/root/.gradle \
./gradlew bootJar -x test --no-daemon \
&& cp "$(ls build/libs/*.jar | grep -v -- '-plain.jar' | head -n 1)" app.jar
# Раскладываем jar по слоям: зависимости меняются редко и кэшируются отдельно от кода
RUN java -Djarmode=tools -jar app.jar extract --layers --launcher --destination extracted
# 2. Запуск
FROM eclipse-temurin:26-jre-jammy
# curl нужен для healthcheck
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
# Непривилегированный пользователь; InitDataService пишет начальные данные в /jambotron_data
RUN groupadd --system app \
&& useradd --system --gid app --home-dir /app app \
&& mkdir -p /jambotron_data \
&& chown app:app /jambotron_data
WORKDIR /app
COPY --from=build --chown=app:app /app/extracted/dependencies/ ./
COPY --from=build --chown=app:app /app/extracted/spring-boot-loader/ ./
COPY --from=build --chown=app:app /app/extracted/snapshot-dependencies/ ./
COPY --from=build --chown=app:app /app/extracted/application/ ./
USER app
EXPOSE 8080
HEALTHCHECK --interval=15s --timeout=5s --start-period=60s --retries=5 \
CMD curl -fs http://localhost:8080/actuator/health || exit 1
# JVM ориентируется на лимит памяти контейнера, а не на память хоста
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75", "org.springframework.boot.loader.launch.JarLauncher"]
+2 -1
View File
@@ -18,6 +18,7 @@ repositories {
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-flyway'
implementation 'org.springframework.boot:spring-boot-starter-security'
@@ -31,7 +32,7 @@ dependencies {
testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
implementation 'jakarta.validation:jakarta.validation-api:3.0.2'
implementation 'org.springframework.boot:spring-boot-starter-validation'
// Безопасность и JWT
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
+43 -13
View File
@@ -1,28 +1,58 @@
services:
demo-decoupled-postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: jambotron_decoupled
POSTGRES_USER: jambotron_admin
POSTGRES_PASSWORD: jambo_secret_pass
healthcheck:
test: ["CMD-SHELL", "pg_isready -U jambotron_admin -d jambotron_decoupled"]
interval: 3s
timeout: 3s
retries: 5
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- demo-decoupled
backend:
build: .
container_name: decoupled_backend
# Собирать образ при каждом `docker compose up`, иначе compose молча запускает старый образ
pull_policy: build
restart: unless-stopped
# Ждём, пока база начнёт принимать подключения, иначе Flyway падает на старте
depends_on:
demo-decoupled-postgres:
condition: service_healthy
ports:
- "8080:8080"
environment:
- SPRING_DATASOURCE_URL=jdbc:postgresql://jambo-postgres:5432/jambotron_decoupled
- SPRING_DATASOURCE_USERNAME=jambotron_admin
- SPRING_DATASOURCE_PASSWORD=jambo_secret_pass
- SPRING_DATASOURCE_DRIVER-CLASS-NAME=org.postgresql.Driver
- SPRING_FLYWAY_URL=jdbc:postgresql://jambo-postgres:5432/jambotron_decoupled?options=-c%20timezone=UTC
- SPRING_FLYWAY_USER=jambotron_admin
- SPRING_FLYWAY_PASSWORD=jambo_secret_pass
SPRING_DATASOURCE_URL: jdbc:postgresql://demo-decoupled-postgres:5432/jambotron_decoupled?options=-c%20timezone=UTC
SPRING_DATASOURCE_USERNAME: jambotron_admin
SPRING_DATASOURCE_PASSWORD: jambo_secret_pass
# В application.yaml Flyway настроен на localhost отдельно от datasource — переопределяем
SPRING_FLYWAY_URL: jdbc:postgresql://demo-decoupled-postgres:5432/jambotron_decoupled?options=-c%20timezone=UTC
SPRING_FLYWAY_USER: jambotron_admin
SPRING_FLYWAY_PASSWORD: jambo_secret_pass
networks:
- jambotron-net
- demo-decoupled
frontend:
build: ./spring-angular-ui
container_name: decoupled_frontend
pull_policy: build
restart: unless-stopped
# nginx проксирует /api на backend — без него UI не работает
depends_on:
backend:
condition: service_healthy
ports:
- "8081:80"
networks:
- jambotron-net
- demo-decoupled
volumes:
postgres_data:
networks:
jambotron-net:
external: true
demo-decoupled:
-11
View File
@@ -1,11 +0,0 @@
FROM eclipse-temurin:26-jdk-alpine AS build
WORKDIR /app
COPY . .
RUN chmod +x gradlew
RUN ./gradlew bootJar -x test --no-daemon
FROM eclipse-temurin:26-jre-alpine
WORKDIR /app
COPY --from=build /app/build/libs/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
+7
View File
@@ -0,0 +1,7 @@
node_modules
.angular
static
dist
Dockerfile
.dockerignore
+6
View File
@@ -43,3 +43,9 @@ testem.log
# System Files
.DS_Store
Thumbs.db
# Angular CLI cache
/.angular/
# Сборка Angular (в decoupled фронт собирается внутри Docker)
/static/
+21 -10
View File
@@ -1,13 +1,24 @@
# Этап сборки Angular
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm install --legacy-peer-deps
COPY . .
RUN npm run build
# syntax=docker/dockerfile:1
# Этап раздачи через Nginx
# 1. Сборка Angular
FROM node:24-alpine AS build
WORKDIR /ui
# Сначала только манифесты — слой с зависимостями пересобирается лишь при их изменении.
# npm install, а не npm ci: сборка не должна падать, если package-lock.json разошёлся с package.json
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm \
npm install --legacy-peer-deps --no-audit --no-fund
COPY . ./
RUN npx ng build
# 2. Раздача статики через nginx; /api проксируется на бэкенд
FROM nginx:alpine
COPY --from=build /app/static/browser /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /ui/static/browser /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
HEALTHCHECK --interval=15s --timeout=5s --start-period=10s --retries=5 \
CMD wget -q --spider http://127.0.0.1/ || exit 1
+29 -1
View File
@@ -109,5 +109,33 @@
}
}
},
"defaultProject": "spring-angular-ui"
"schematics": {
"@schematics/angular:component": {
"type": "component"
},
"@schematics/angular:directive": {
"type": "directive"
},
"@schematics/angular:service": {
"type": "service"
},
"@schematics/angular:guard": {
"typeSeparator": "."
},
"@schematics/angular:interceptor": {
"typeSeparator": "."
},
"@schematics/angular:module": {
"typeSeparator": "."
},
"@schematics/angular:pipe": {
"typeSeparator": "."
},
"@schematics/angular:resolver": {
"typeSeparator": "."
}
},
"cli": {
"analytics": false
}
}
+29
View File
@@ -0,0 +1,29 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# UI ходит в API по относительному пути /api — проксируем на бэкенд,
# так браузер видит один origin и JWT-cookie работает без CORS
location /api/ {
proxy_pass http://backend:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Маршруты Angular (/admin, /tutorials/1 ...) отдаём через index.html
location / {
try_files $uri $uri/ /index.html;
}
# Файлы сборки с хешем в имени можно кэшировать надолго
location ~* \.(?:js|css|ico|png|svg|woff2?)$ {
try_files $uri =404;
expires 1y;
add_header Cache-Control "public, immutable";
}
}
+7113 -7495
View File
File diff suppressed because it is too large Load Diff
+16 -21
View File
@@ -3,7 +3,7 @@
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"start": "ng serve --proxy-config src/proxy.conf.json",
"build": "ng build",
"build_dev": "ng build --configuration development",
"watch": "ng build --watch --configuration development",
@@ -11,38 +11,33 @@
},
"private": true,
"dependencies": {
"@angular/animations": "^19.2.11",
"@angular/cdk": "^19.2.16",
"@angular/common": "^19.2.11",
"@angular/compiler": "^19.2.11",
"@angular/core": "^19.2.11",
"@angular/forms": "^19.2.11",
"@angular/material": "^19.2.16",
"@angular/platform-browser": "^19.2.11",
"@angular/platform-browser-dynamic": "^19.2.11",
"@angular/router": "^19.2.11",
"@ng-bootstrap/ng-bootstrap": "^17.0.0",
"@popperjs/core": "^2.11.8",
"bootstrap": "^5.3.3",
"jquery": "^3.7.1",
"popper.js": "^1.16.1",
"@angular/animations": "^22.2.3",
"@angular/cdk": "^22.2.3",
"@angular/common": "^22.2.3",
"@angular/compiler": "^22.2.3",
"@angular/core": "^22.2.3",
"@angular/forms": "^22.2.3",
"@angular/material": "^22.2.3",
"@angular/platform-browser": "^22.2.3",
"@angular/platform-browser-dynamic": "^22.2.3",
"@angular/router": "^22.2.3",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"zone.js": "~0.15.0"
},
"devDependencies": {
"@angular-devkit/build-angular": "^19.2.12",
"@angular/cli": "^19.2.12",
"@angular/compiler-cli": "~19.2.11",
"@angular-devkit/build-angular": "^22.2.3",
"@angular/cli": "^22.2.3",
"@angular/compiler-cli": "~22.2.3",
"@types/jasmine": "~3.6.0",
"@types/node": "^12.11.1",
"istanbul-lib-instrument": "^6.0.3",
"jasmine-core": "~3.7.0",
"karma": "~6.3.0",
"karma-chrome-launcher": "~3.1.0",
"karma-coverage": "~2.0.3",
"karma-jasmine": "~4.0.0",
"karma-jasmine-html-reporter": "^1.5.0",
"typescript": "~5.8.3",
"webpack": "^5.93.0"
"typescript": "~6.0.3"
}
}
@@ -1,16 +0,0 @@
import { TestBed } from '@angular/core/testing';
import { EventBusService } from './event-bus.service';
describe('EventBusService', () => {
let service: EventBusService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(EventBusService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});
@@ -1,21 +0,0 @@
import { Injectable } from '@angular/core';
import { Subject, Subscription } from 'rxjs';
import { filter, map } from 'rxjs/operators';
import { EventData } from './event.class';
@Injectable({
providedIn: 'root',
})
export class EventBusService {
private subject$ = new Subject<EventData>();
emit(event: EventData) {
this.subject$.next(event);
}
on(eventName: string, action: any): Subscription {
return this.subject$.pipe(
filter((e: EventData) => e.name === eventName),
map((e: EventData) => e["value"])).subscribe(action);
}
}
@@ -1,9 +0,0 @@
export class EventData {
name: string;
value: any;
constructor(name: string, value: any) {
this.name = name;
this.value = value;
}
}
@@ -4,25 +4,20 @@ import { TutorialsListComponent } from './components/tutorials-list/tutorials-li
import { TutorialDetailsComponent } from './components/tutorial-details/tutorial-details.component';
import { AddTutorialComponent } from './components/add-tutorial/add-tutorial.component';
import { BoardAdminComponent } from './components/board-admin/board-admin.component';
import { BoardModeratorComponent } from './components/board-moderator/board-moderator.component';
import { BoardUserComponent } from './components/board-user/board-user.component';
import { ProfileComponent } from './components/profile/profile.component';
import { RegisterComponent } from './components/register/register.component';
import { LoginComponent } from './components/login/login.component';
import { HomeComponent } from './components/home/home.component';
import { adminGuard, authGuard } from './helpers/auth.guard';
const routes: Routes = [
{ path: 'home', component: HomeComponent },
{ path: 'login', component: LoginComponent },
{ path: 'register', component: RegisterComponent },
{ path: 'profile', component: ProfileComponent },
{ path: 'user', component: BoardUserComponent },
{ path: 'mod', component: BoardModeratorComponent },
{ path: 'admin', component: BoardAdminComponent },
{ path: 'profile', component: ProfileComponent, canActivate: [authGuard] },
{ path: 'admin', component: BoardAdminComponent, canActivate: [adminGuard] },
{ path: '', redirectTo: 'home', pathMatch: 'full' },
{ path: 'tutorials', component: TutorialsListComponent },
{ path: 'tutorials/:id', component: TutorialDetailsComponent },
{ path: 'add', component: AddTutorialComponent }
{ path: 'tutorials/:id', component: TutorialDetailsComponent, canActivate: [authGuard] },
{ path: 'add', component: AddTutorialComponent, canActivate: [authGuard] }
];
@NgModule({
+58 -71
View File
@@ -1,79 +1,66 @@
<div>
<nav class="navbar navbar-expand navbar-dark bg-dark">
<a href="#" class="navbar-brand">Spring-AngularTS</a>
<ul class="navbar-nav mr-auto" routerLinkActive="active">
<li class="nav-item">
<a routerLink="tutorials" class="nav-link">Tutorials</a>
</li>
<li class="nav-item">
<a routerLink="add" class="nav-link">Add</a>
</li>
<li class="nav-item" *ngIf="showAdminBoard">
<a href="/admin" class="nav-link" routerLink="admin">Admin Board</a>
</li>
<li class="nav-item" *ngIf="showModeratorBoard">
<a href="/mod" class="nav-link" routerLink="mod">Moderator Board</a>
</li>
<li class="nav-item">
<a href="/user" class="nav-link" *ngIf="isLoggedIn" routerLink="user">User</a>
</li>
</ul>
<ul class="navbar-nav ml-auto" *ngIf="!isLoggedIn">
<li class="nav-item">
<a href="/register" class="nav-link" routerLink="register">Sign Up</a>
</li>
<li class="nav-item">
<a href="/login" class="nav-link" routerLink="login">Login</a>
</li>
</ul>
<ul class="navbar-nav ml-auto" *ngIf="isLoggedIn">
<li class="nav-item">
<a href="/profile" class="nav-link" routerLink="profile">{{ username }}</a>
</li>
<li class="nav-item">
<a href class="nav-link" (click)="logout()">LogOut</a>
</li>
</ul>
<mat-toolbar class="app-toolbar">
<a class="brand" routerLink="/">
<mat-icon>school</mat-icon>
<span>Spring-AngularTS</span>
</a>
<nav class="nav">
<a mat-button routerLink="home" routerLinkActive="active">
<mat-icon>home</mat-icon>
<span class="label">Home</span>
</a>
<a mat-button routerLink="tutorials" routerLinkActive="active">
<mat-icon>menu_book</mat-icon>
<span class="label">Tutorials</span>
</a>
@if (isLoggedIn) {
<a mat-button routerLink="add" routerLinkActive="active">
<mat-icon>add_circle</mat-icon>
<span class="label">Add</span>
</a>
}
@if (showAdminBoard) {
<a mat-button routerLink="admin" routerLinkActive="active">
<mat-icon>admin_panel_settings</mat-icon>
<span class="label">Admin</span>
</a>
}
</nav>
<mat-toolbar>
<button mat-raised-button routerLink="/" href="#">
My App
</button>
<span>My App
</span>
<span class="example-spacer"></span>
<button mat-raised-button *ngIf="!isLoggedIn">
<mat-icon>app_registration</mat-icon>
Register
</button>
<span class="spacer"></span>
<button mat-raised-button (click)="openDialog('100ms', '5ms')" *ngIf="!isLoggedIn">
<mat-icon>login</mat-icon>
Login
</button>
<button mat-raised-button *ngIf="isLoggedIn">
@if (!isLoggedIn) {
<div class="actions">
<a mat-stroked-button routerLink="register">
<mat-icon>app_registration</mat-icon>
<span class="label">Register</span>
</a>
<button mat-flat-button (click)="openDialog()">
<mat-icon>login</mat-icon>
<span class="label">Login</span>
</button>
</div>
} @else {
<button mat-button class="user-button" [matMenuTriggerFor]="userMenu">
<mat-icon>account_circle</mat-icon>
{{ username }}
<span class="label">{{ username }}</span>
<mat-icon iconPositionEnd>expand_more</mat-icon>
</button>
<button mat-raised-button (click)="logout()"*ngIf="isLoggedIn">
<mat-icon>logout</mat-icon>
Logout
</button>
<mat-menu #userMenu="matMenu" xPosition="before">
<a mat-menu-item routerLink="profile">
<mat-icon>person</mat-icon>
<span>Profile</span>
</a>
<mat-divider></mat-divider>
<button mat-menu-item (click)="logout()">
<mat-icon>logout</mat-icon>
<span>Logout</span>
</button>
</mat-menu>
}
</mat-toolbar>
</mat-toolbar>
<div class="container mt-3">
<router-outlet></router-outlet>
</div>
</div>
<main class="container mt-4">
<router-outlet></router-outlet>
</main>
+83 -4
View File
@@ -1,6 +1,85 @@
//#header_panel{
// background-color: #181d1f;
//}
.example-spacer {
@use '@angular/material' as mat;
.app-toolbar {
@include mat.toolbar-overrides((
container-background-color: var(--mat-sys-surface-container),
container-text-color: var(--mat-sys-on-surface),
));
position: sticky;
top: 0;
z-index: 10;
gap: 8px;
padding: 0 16px;
box-shadow: var(--mat-sys-level2);
}
// Bootstrap (CDN) подчёркивает ссылки при наведении — для кнопок это лишнее
a,
a:hover {
text-decoration: none;
}
.brand {
display: flex;
align-items: center;
gap: 8px;
margin-right: 16px;
color: var(--mat-sys-primary);
font: var(--mat-sys-title-large);
white-space: nowrap;
}
.nav {
display: flex;
gap: 4px;
}
.nav a {
@include mat.button-overrides((
text-label-text-color: var(--mat-sys-on-surface-variant),
));
}
.nav a.active {
@include mat.button-overrides((
text-label-text-color: var(--mat-sys-on-secondary-container),
));
background-color: var(--mat-sys-secondary-container);
}
.spacer {
flex: 1 1 auto;
}
.actions {
display: flex;
gap: 8px;
}
// На узких экранах оставляем только иконки
@media (max-width: 768px) {
.brand span,
.label {
display: none;
}
.user-button mat-icon[iconPositionEnd] {
display: none;
}
// Без подписи иконка должна стоять по центру кнопки
.nav a,
.actions > *,
.user-button {
min-width: 48px;
padding: 0 12px;
}
.nav mat-icon,
.actions mat-icon,
.user-button mat-icon {
margin: 0;
}
}
+31 -88
View File
@@ -1,113 +1,56 @@
import {Component, inject, OnInit} from '@angular/core';
import { ChangeDetectionStrategy, Component, inject, OnInit } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { DialogComponent } from './components/dialog/dialog.component';
import { AuthService } from './services/auth.service';
import { TokenStorageService } from './services/token-storage.service';
import {MatDialog} from "@angular/material/dialog";
import {DialogComponent} from "./components/dialog/dialog.component";
import {AuthService} from "./services/auth.service";
import {Subscription} from "rxjs";
import {EventBusService} from "./_shared/event-bus.service";
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.scss'],
standalone: false
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.scss'],
changeDetection: ChangeDetectionStrategy.Eager,
standalone: false
})
export class AppComponent implements OnInit {
readonly dialog = inject(MatDialog);
//private authService = new AuthService(provideHttpClient())
public dialogData : DialogData = {password: "", username: ""};
private readonly dialog = inject(MatDialog);
private readonly authService = inject(AuthService);
private readonly storageService = inject(TokenStorageService);
private roles: string[] = [];
isLoggedIn = false;
showAdminBoard = false;
showModeratorBoard = false;
username?: string;
eventBusSub?: Subscription;
private errorMessage: any;
private isLoginFailed: boolean = false;
constructor(
private storageService: TokenStorageService,
private authService: AuthService,
private eventBusService: EventBusService
) {}
ngOnInit(): void {
this.isLoggedIn = this.storageService.isLoggedIn();
if (this.isLoggedIn) {
const user = this.storageService.getUser();
this.roles = user.roles;
this.showAdminBoard = true;//this.roles.includes('ROLE_ADMIN');
this.showModeratorBoard = this.roles.includes('ROLE_MODERATOR');
this.username = user.username;
if (!this.isLoggedIn) {
return;
}
this.eventBusSub = this.eventBusService.on('logout', () => {
this.logout();
const user = this.storageService.getUser();
const roles: string[] = user.roles ?? [];
this.showAdminBoard = roles.includes('ROLE_ADMIN');
this.username = user.username;
}
openDialog(): void {
// Вход выполняет сам диалог и при успехе перезагружает страницу
this.dialog.open(DialogComponent, {
width: '400px',
maxWidth: '95vw',
enterAnimationDuration: '100ms',
exitAnimationDuration: '5ms'
});
}
logout(): void {
this.authService.logout().subscribe({
next: res => {
console.log(res);
next: () => {
this.storageService.clean();
window.location.reload();
},
error: err => {
console.log(err);
}
error: err => console.error('Logout failed', err)
});
}
openDialog(enterAnimationDuration: string, exitAnimationDuration: string) {
let dialogRef = this.dialog.open(DialogComponent, {
width: '350px',
enterAnimationDuration,
exitAnimationDuration,
data: {username: this.dialogData.username, password: this.dialogData.password}
});
dialogRef.afterClosed().subscribe(result => {
console.log('The dialog was closed');
if(result== null){
return;
}
this.dialogData = result;
this.authService.login(this.dialogData.username, this.dialogData.password).subscribe(
data => {
this.storageService.saveToken(data.accessToken);
this.storageService.saveUser(data);
this.isLoginFailed = false;
this.isLoggedIn = true;
let user = this.storageService.getUser();
this.roles = user.roles;
//this.username = user.username;
this.reloadPage();
},
err => {
this.errorMessage = err.error.message;
this.isLoginFailed = true;
}
);
});
}
reloadPage(): void {
window.location.reload();
}
}
export interface DialogData {
username: string;
password: string;
}
+26 -27
View File
@@ -2,30 +2,32 @@ import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { AppRoutingModule } from './app-routing.module';
import {FormsModule, ReactiveFormsModule} from '@angular/forms';
import {FormsModule} from '@angular/forms';
import { AppComponent } from './app.component';
import { TutorialsListComponent } from './components/tutorials-list/tutorials-list.component';
import { TutorialDetailsComponent } from './components/tutorial-details/tutorial-details.component';
import { AddTutorialComponent } from './components/add-tutorial/add-tutorial.component';
import { LoginComponent } from './components/login/login.component';
import { RegisterComponent } from './components/register/register.component';
import { HomeComponent } from './components/home/home.component';
import { ProfileComponent } from './components/profile/profile.component';
import { BoardAdminComponent } from './components/board-admin/board-admin.component';
import { BoardModeratorComponent } from './components/board-moderator/board-moderator.component';
import { BoardUserComponent } from './components/board-user/board-user.component';
import { authInterceptorProviders } from './helpers/auth.interceptor';
import {provideHttpClient, withInterceptorsFromDi} from "@angular/common/http";
import {provideHttpClient, withXhr} from "@angular/common/http";
import {provideAnimationsAsync} from "@angular/platform-browser/animations/async";
import {MatToolbar} from "@angular/material/toolbar";
import {MatAnchor, MatButton} from "@angular/material/button";
import {MatButtonModule} from "@angular/material/button";
import {MatCardModule} from "@angular/material/card";
import {MatInputModule} from "@angular/material/input";
import {MatListModule} from "@angular/material/list";
import {MatIcon} from "@angular/material/icon";
import {MatChipGrid, MatChipInput, MatChipListbox, MatChipOption, MatChipRow} from "@angular/material/chips";
import {MatAutocomplete, MatAutocompleteTrigger, MatOption} from "@angular/material/autocomplete";
import {MatFormField} from "@angular/material/form-field";
import {MatMenu, MatMenuItem, MatMenuTrigger} from "@angular/material/menu";
import {MatDivider} from "@angular/material/divider";
import {MatPaginatorModule} from "@angular/material/paginator";
import {MatSortModule} from "@angular/material/sort";
import {MatTableModule} from "@angular/material/table";
import {MatTooltipModule} from "@angular/material/tooltip";
@@ -39,9 +41,7 @@ import {MatFormField} from "@angular/material/form-field";
RegisterComponent,
HomeComponent,
ProfileComponent,
BoardAdminComponent,
BoardModeratorComponent,
BoardUserComponent
BoardAdminComponent
],
bootstrap: [AppComponent],
@@ -49,23 +49,22 @@ import {MatFormField} from "@angular/material/form-field";
BrowserModule,
AppRoutingModule,
FormsModule,
LoginComponent,
MatToolbar,
MatButton,
MatButtonModule,
MatCardModule,
MatInputModule,
MatListModule,
MatIcon,
MatAnchor,
MatChipGrid,
MatChipRow,
MatChipInput,
MatAutocompleteTrigger,
MatAutocomplete,
MatOption,
MatFormField,
MatChipListbox,
MatChipOption,
ReactiveFormsModule
MatMenu,
MatMenuItem,
MatMenuTrigger,
MatDivider,
MatTableModule,
MatSortModule,
MatPaginatorModule,
MatTooltipModule
],
providers: [authInterceptorProviders, provideHttpClient(withInterceptorsFromDi()), provideAnimationsAsync()] })
providers: [provideHttpClient(withXhr()), provideAnimationsAsync()] })
export class AppModule { }
@@ -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 });
}
}
@@ -0,0 +1,21 @@
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { MatDialog } from '@angular/material/dialog';
import { DialogComponent } from '../components/dialog/dialog.component';
import { TokenStorageService } from '../services/token-storage.service';
// Пускает на страницу только вошедшего пользователя; гостя отправляет на главную и открывает диалог входа
export const authGuard: CanActivateFn = () => {
if (inject(TokenStorageService).isLoggedIn()) {
return true;
}
inject(MatDialog).open(DialogComponent, { width: '400px', maxWidth: '95vw' });
return inject(Router).createUrlTree(['/home']);
};
// Пускает только администратора, остальных отправляет на главную
export const adminGuard: CanActivateFn = () => {
const roles: string[] = inject(TokenStorageService).getUser().roles ?? [];
return roles.includes('ROLE_ADMIN') || inject(Router).createUrlTree(['/home']);
};
@@ -1,26 +0,0 @@
import { HTTP_INTERCEPTORS, HttpEvent } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { HttpInterceptor, HttpHandler, HttpRequest } from '@angular/common/http';
import { TokenStorageService } from '../services/token-storage.service';
import { Observable } from 'rxjs';
const TOKEN_HEADER_KEY = 'Authorization'; // for Spring Boot back-end
@Injectable()
export class AuthInterceptor implements HttpInterceptor {
constructor(private token: TokenStorageService) { }
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
let authReq = req;
const token = this.token.getToken();
if (token != null) {
authReq = req.clone({ headers: req.headers.set(TOKEN_HEADER_KEY, 'Bearer ' + token) });
}
return next.handle(authReq);
}
}
export const authInterceptorProviders = [
{ provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true }
];
@@ -0,0 +1,15 @@
import { HttpErrorResponse } from '@angular/common/http';
// Понятный пользователю текст ошибки HTTP-запроса
export function httpErrorMessage(err: HttpErrorResponse): string {
switch (err.status) {
case 401:
return 'Please log in to continue';
case 403:
return 'You do not have permission to do this';
case 404:
return err.error?.message ?? 'Not found';
default:
return err.error?.message ?? 'Something went wrong, please try again';
}
}
+14
View File
@@ -2,3 +2,17 @@ export class Role {
id?:any;
name?:string;
}
// Человекочитаемые названия ролей для интерфейса
export function roleLabel(name?: string): string {
switch (name) {
case 'ROLE_ADMIN':
return 'Admin';
case 'ROLE_MODERATOR':
return 'Moderator';
case 'ROLE_USER':
return 'User';
default:
return name ?? '';
}
}
@@ -1,5 +0,0 @@
export class Setting {
id?:any;
name?:string;
value?:boolean;
}
@@ -1,7 +1,8 @@
import { Role } from './role';
export class User {
id?:any;
username?:string;
email?:string;
password?:string;
roles?:any;
roles?:Role[];
}
@@ -2,7 +2,8 @@ import { Injectable } from '@angular/core';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Observable } from 'rxjs';
const AUTH_API = 'http://localhost:8080/api/auth/';
// Используем относительный путь вместо хардкода http://localhost:8080/api/auth/
const AUTH_API = '/api/auth/';
const httpOptions = {
headers: new HttpHeaders({ 'Content-Type': 'application/json' })
@@ -3,7 +3,8 @@ import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { Role } from '../models/role';
const API_URL = 'http://localhost:8080/api/roles';
// Используем относительный путь вместо хардкода http://localhost:8080/api/roles
const API_URL = '/api/roles';
@Injectable({
@@ -1,6 +1,5 @@
import { Injectable } from '@angular/core';
const TOKEN_KEY = 'auth-token';
const USER_KEY = 'auth-user';
@Injectable({
@@ -9,19 +8,6 @@ const USER_KEY = 'auth-user';
export class TokenStorageService {
constructor() { }
signOut(): void {
window.sessionStorage.clear();
}
public saveToken(token: string): void {
window.sessionStorage.removeItem(TOKEN_KEY);
window.sessionStorage.setItem(TOKEN_KEY, token);
}
public getToken(): string | null {
return window.sessionStorage.getItem(TOKEN_KEY);
}
public saveUser(user: any): void {
window.sessionStorage.removeItem(USER_KEY);
window.sessionStorage.setItem(USER_KEY, JSON.stringify(user));
@@ -3,7 +3,8 @@ import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { Tutorial } from '../models/tutorial.model';
const baseUrl = 'http://localhost:8080/api/tutorials';
// Используем относительный путь вместо хардкода http://localhost:8080/api/tutorials
const baseUrl = '/api/tutorials';
@Injectable({
providedIn: 'root'
@@ -36,6 +37,10 @@ export class TutorialService {
return this.http.delete(baseUrl);
}
getPublished(): Observable<Tutorial[]> {
return this.http.get<Tutorial[]>(`${baseUrl}/published`);
}
findByTitle(title: any): Observable<Tutorial[]> {
return this.http.get<Tutorial[]>(`${baseUrl}?title=${title}`);
}
@@ -2,9 +2,9 @@ import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import {User} from "../models/user.model";
import {Tutorial} from "../models/tutorial.model";
const API_URL = 'http://localhost:8080/api/users';
// Используем относительный путь вместо хардкода http://localhost:8080/api/users
const API_URL = '/api/users';
@Injectable({
providedIn: 'root'
@@ -12,20 +12,11 @@ const API_URL = 'http://localhost:8080/api/users';
export class UserService {
constructor(private http: HttpClient) { }
getPublicContent(): Observable<any> {
return this.http.get(API_URL + '/all', { responseType: 'text' });
}
getUserBoard(): Observable<any> {
return this.http.get(API_URL + '/user', { responseType: 'text' });
}
getModeratorBoard(): Observable<any> {
return this.http.get(API_URL + '/mod', { responseType: 'text' });
}
getAdminBoard(): Observable<User[]> {
getAll(): Observable<User[]> {
return this.http.get<User[]>(API_URL);
}
}
updateRoles(id: any, roles: string[]): Observable<User> {
return this.http.put<User>(`${API_URL}/${id}/roles`, { roles });
}
}
+1
View File
@@ -12,6 +12,7 @@
integrity="sha384-Vkoo8x4CGsO3+Hhxv8T/Q5PaXtkKtu6ug5TOeNV6gBiFeWPGFN9MuhOf23Q9Ifjh"
crossorigin="anonymous"
/>
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
</head>
<body>
+2 -2
View File
@@ -1,4 +1,4 @@
import { enableProdMode } from '@angular/core';
import { enableProdMode, provideZoneChangeDetection } from '@angular/core';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { AppModule } from './app/app.module';
@@ -8,5 +8,5 @@ if (environment.production) {
enableProdMode();
}
platformBrowserDynamic().bootstrapModule(AppModule)
platformBrowserDynamic().bootstrapModule(AppModule, { applicationProviders: [provideZoneChangeDetection()], })
.catch(err => console.error(err));
+10
View File
@@ -0,0 +1,10 @@
{
"/api": {
"target": "http://localhost:8080",
"secure": false,
"changeOrigin": true,
"logLevel": "debug",
"timeout": 0,
"ws": true
}
}
@@ -1,7 +0,0 @@
{
"version": 3,
"sources": ["src/app/components/add-tutorial/add-tutorial.component.scss"],
"sourcesContent": [".submit-form {\r\n max-width: 400px;\r\n margin: auto;\r\n}"],
"mappings": ";AAAA,CAAA;AACE,aAAA;AACA,UAAA;;",
"names": []
}
@@ -1,7 +0,0 @@
{
"version": 3,
"sources": ["src/app/app.component.scss"],
"sourcesContent": ["//#header_panel{\r\n// background-color: #181d1f;\r\n//}\r\n.example-spacer {\r\n flex: 1 1 auto;\r\n}\r\n"],
"mappings": ";AAGA,CAAA;AACE,QAAA,EAAA,EAAA;;",
"names": []
}
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
@@ -1,7 +0,0 @@
{
"version": 3,
"sources": ["src/app/components/dialog/dialog.component.scss"],
"sourcesContent": ["\r\n.fillField{\r\n width: 100%;\r\n};\r\n"],
"mappings": ";AACA,CAAA;AACE,SAAA;;",
"names": []
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 948 B

@@ -1,15 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>SpringAngularUi</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css" integrity="sha384-Vkoo8x4CGsO3+Hhxv8T/Q5PaXtkKtu6ug5TOeNV6gBiFeWPGFN9MuhOf23Q9Ifjh" crossorigin="anonymous"/>
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
<link rel="stylesheet" href="styles.css"></head>
<body>
<app-root></app-root>
<link rel="modulepreload" href="chunk-7E6NIDIZ.js"><script src="polyfills.js" type="module"></script><script src="main.js" type="module"></script></body>
</html>
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
-111
View File
@@ -1,111 +0,0 @@
/* src/styles.scss */
.mat-elevation-z0,
.mat-mdc-elevation-specific.mat-elevation-z0 {
box-shadow: var(--mat-app-elevation-shadow-level-0, none);
}
.mat-elevation-z1,
.mat-mdc-elevation-specific.mat-elevation-z1 {
box-shadow: var(--mat-app-elevation-shadow-level-1, none);
}
.mat-elevation-z2,
.mat-mdc-elevation-specific.mat-elevation-z2 {
box-shadow: var(--mat-app-elevation-shadow-level-2, none);
}
.mat-elevation-z3,
.mat-mdc-elevation-specific.mat-elevation-z3 {
box-shadow: var(--mat-app-elevation-shadow-level-3, none);
}
.mat-elevation-z4,
.mat-mdc-elevation-specific.mat-elevation-z4 {
box-shadow: var(--mat-app-elevation-shadow-level-4, none);
}
.mat-elevation-z5,
.mat-mdc-elevation-specific.mat-elevation-z5 {
box-shadow: var(--mat-app-elevation-shadow-level-5, none);
}
.mat-elevation-z6,
.mat-mdc-elevation-specific.mat-elevation-z6 {
box-shadow: var(--mat-app-elevation-shadow-level-6, none);
}
.mat-elevation-z7,
.mat-mdc-elevation-specific.mat-elevation-z7 {
box-shadow: var(--mat-app-elevation-shadow-level-7, none);
}
.mat-elevation-z8,
.mat-mdc-elevation-specific.mat-elevation-z8 {
box-shadow: var(--mat-app-elevation-shadow-level-8, none);
}
.mat-elevation-z9,
.mat-mdc-elevation-specific.mat-elevation-z9 {
box-shadow: var(--mat-app-elevation-shadow-level-9, none);
}
.mat-elevation-z10,
.mat-mdc-elevation-specific.mat-elevation-z10 {
box-shadow: var(--mat-app-elevation-shadow-level-10, none);
}
.mat-elevation-z11,
.mat-mdc-elevation-specific.mat-elevation-z11 {
box-shadow: var(--mat-app-elevation-shadow-level-11, none);
}
.mat-elevation-z12,
.mat-mdc-elevation-specific.mat-elevation-z12 {
box-shadow: var(--mat-app-elevation-shadow-level-12, none);
}
.mat-elevation-z13,
.mat-mdc-elevation-specific.mat-elevation-z13 {
box-shadow: var(--mat-app-elevation-shadow-level-13, none);
}
.mat-elevation-z14,
.mat-mdc-elevation-specific.mat-elevation-z14 {
box-shadow: var(--mat-app-elevation-shadow-level-14, none);
}
.mat-elevation-z15,
.mat-mdc-elevation-specific.mat-elevation-z15 {
box-shadow: var(--mat-app-elevation-shadow-level-15, none);
}
.mat-elevation-z16,
.mat-mdc-elevation-specific.mat-elevation-z16 {
box-shadow: var(--mat-app-elevation-shadow-level-16, none);
}
.mat-elevation-z17,
.mat-mdc-elevation-specific.mat-elevation-z17 {
box-shadow: var(--mat-app-elevation-shadow-level-17, none);
}
.mat-elevation-z18,
.mat-mdc-elevation-specific.mat-elevation-z18 {
box-shadow: var(--mat-app-elevation-shadow-level-18, none);
}
.mat-elevation-z19,
.mat-mdc-elevation-specific.mat-elevation-z19 {
box-shadow: var(--mat-app-elevation-shadow-level-19, none);
}
.mat-elevation-z20,
.mat-mdc-elevation-specific.mat-elevation-z20 {
box-shadow: var(--mat-app-elevation-shadow-level-20, none);
}
.mat-elevation-z21,
.mat-mdc-elevation-specific.mat-elevation-z21 {
box-shadow: var(--mat-app-elevation-shadow-level-21, none);
}
.mat-elevation-z22,
.mat-mdc-elevation-specific.mat-elevation-z22 {
box-shadow: var(--mat-app-elevation-shadow-level-22, none);
}
.mat-elevation-z23,
.mat-mdc-elevation-specific.mat-elevation-z23 {
box-shadow: var(--mat-app-elevation-shadow-level-23, none);
}
.mat-elevation-z24,
.mat-mdc-elevation-specific.mat-elevation-z24 {
box-shadow: var(--mat-app-elevation-shadow-level-24, none);
}
html {
--mat-sys-on-surface: initial;
}
.mat-app-background {
background-color: var(--mat-app-background-color, var(--mat-sys-background, transparent));
color: var(--mat-app-text-color, var(--mat-sys-on-background, inherit));
}
/* angular:styles/global:styles */
/*# sourceMappingURL=styles.css.map */
File diff suppressed because one or more lines are too long
@@ -1,7 +0,0 @@
{
"version": 3,
"sources": ["src/app/components/tutorial-details/tutorial-details.component.scss"],
"sourcesContent": [".edit-form {\r\n max-width: 400px;\r\n margin: auto;\r\n}"],
"mappings": ";AAAA,CAAA;AACE,aAAA;AACA,UAAA;;",
"names": []
}
@@ -1,7 +0,0 @@
{
"version": 3,
"sources": ["src/app/components/tutorials-list/tutorials-list.component.scss"],
"sourcesContent": [".list {\r\n text-align: left;\r\n max-width: 750px;\r\n margin: auto;\r\n}"],
"mappings": ";AAAA,CAAA;AACE,cAAA;AACA,aAAA;AACA,UAAA;;",
"names": []
}
@@ -1,3 +0,0 @@
{
"routes": {}
}
+9 -1
View File
@@ -11,5 +11,13 @@
],
"include": [
"src/**/*.d.ts"
]
],
"angularCompilerOptions": {
"extendedDiagnostics": {
"checks": {
"nullishCoalescingNotNullable": "suppress",
"optionalChainNotNullable": "suppress"
}
}
}
}
+5 -7
View File
@@ -2,7 +2,9 @@
{
"compileOnSave": false,
"compilerOptions": {
"baseUrl": "./",
"paths": {
"src/*": ["./src/*"]
},
"outDir": "./dist/out-tsc",
"forceConsistentCasingInFileNames": true,
"esModuleInterop": true,
@@ -12,14 +14,10 @@
"sourceMap": true,
"declaration": false,
"experimentalDecorators": true,
"moduleResolution": "node",
"moduleResolution": "bundler",
"importHelpers": true,
"target": "ES2022",
"module": "es2022",
"lib": [
"es2018",
"dom"
]
"module": "es2022"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
+9 -1
View File
@@ -14,5 +14,13 @@
"include": [
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
],
"angularCompilerOptions": {
"extendedDiagnostics": {
"checks": {
"nullishCoalescingNotNullable": "suppress",
"optionalChainNotNullable": "suppress"
}
}
}
}
@@ -1,6 +1,5 @@
package com.example.demo_decoupled.DTOs;
import com.example.demo_decoupled.model.ERole;
import com.example.demo_decoupled.model.Role;
import com.fasterxml.jackson.annotation.JsonIgnore;
@@ -34,12 +33,6 @@ public class RoleDto {
this.name = name;
}
// Mapping helpers
public static RoleDto fromEntity(Role role) {
if (role == null) return null;
return new RoleDto(role.getId(), role.getName());
}
public Role toEntity() {
Role role = new Role();
role.setId(this.id);
@@ -1,12 +1,8 @@
package com.example.demo_decoupled.DTOs;
import com.example.demo_decoupled.model.User;
import com.fasterxml.jackson.annotation.JsonIgnore;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
public class UserDto {
@JsonIgnore
@@ -69,35 +65,4 @@ public class UserDto {
public void setPassword(String password) {
this.password = password;
}
// Mapping helpers
public static UserDto fromEntity(User user) {
if (user == null) return null;
return new UserDto(
user.getId(),
user.getUsername(),
user.getEmail(),
user.getPassword(),
user.getRoles() == null ? null :
user.getRoles().stream()
.filter(Objects::nonNull)
.map(RoleDto::fromEntity)
.collect(Collectors.toSet())
);
}
public User toEntity() {
User user = new User();
user.setId(this.id);
user.setUsername(this.username);
user.setEmail(this.email);
user.setPassword(this.password); // Assuming password is already hashed
if (this.roles != null) {
user.setRoles(this.roles.stream()
.filter(Objects::nonNull)
.map(RoleDto::toEntity)
.collect(Collectors.toSet()));
}
return user;
}
}
@@ -27,8 +27,7 @@ import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
//@CrossOrigin(origins = "*", maxAge = 3600)
@CrossOrigin(origins = "http://localhost:4200", maxAge = 3600, allowCredentials="true")
@CrossOrigin(origins = "${app.origin}", maxAge = 3600, allowCredentials="true")
@RestController
@RequestMapping("/api/auth")
public class AuthController {

Some files were not shown because too many files have changed in this diff Show More