Доделать demo-monolith #2
@@ -0,0 +1,26 @@
|
|||||||
|
# Всё, что не нужно для сборки образа: ускоряет передачу контекста и не даёт
|
||||||
|
# попасть внутрь Windows-бинарникам из node_modules и локальным секретам
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.gitattributes
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
.gradle
|
||||||
|
build
|
||||||
|
*.iml
|
||||||
|
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
Dockerfile
|
||||||
|
docker-compose*.yml
|
||||||
|
.dockerignore
|
||||||
|
|
||||||
|
spring-angular-ui/node_modules
|
||||||
|
spring-angular-ui/.angular
|
||||||
|
spring-angular-ui/static
|
||||||
|
spring-angular-ui/dist
|
||||||
|
|
||||||
|
# Собранный фронт собирается заново внутри образа
|
||||||
|
src/main/resources/static
|
||||||
@@ -35,3 +35,6 @@ out/
|
|||||||
|
|
||||||
### VS Code ###
|
### VS Code ###
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|
||||||
|
### Docker ###
|
||||||
|
.env
|
||||||
|
|||||||
+54
-25
@@ -1,37 +1,66 @@
|
|||||||
# 1. Этап сборки (Build stage)
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
# 1. Сборка Angular
|
||||||
|
FROM node:24-alpine AS ui
|
||||||
|
WORKDIR /ui
|
||||||
|
|
||||||
|
# Сначала только манифесты — слой с зависимостями пересобирается лишь при их изменении.
|
||||||
|
# npm install, а не npm ci: сборка не должна падать, если package-lock.json разошёлся с package.json
|
||||||
|
COPY spring-angular-ui/package*.json ./
|
||||||
|
RUN --mount=type=cache,target=/root/.npm \
|
||||||
|
npm install --legacy-peer-deps --no-audit --no-fund
|
||||||
|
|
||||||
|
COPY spring-angular-ui/ ./
|
||||||
|
RUN npx ng build
|
||||||
|
|
||||||
|
|
||||||
|
# 2. Сборка jar
|
||||||
FROM eclipse-temurin:26-jdk-jammy AS build
|
FROM eclipse-temurin:26-jdk-jammy AS build
|
||||||
|
|
||||||
# Устанавливаем только Node.js v20 для Angular
|
|
||||||
RUN apt-get update && apt-get install -y curl \
|
|
||||||
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
|
||||||
&& apt-get install -y nodejs \
|
|
||||||
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Копируем всё содержимое проекта
|
# Сначала Gradle-обвязка — зависимости скачиваются заново лишь при изменении build.gradle
|
||||||
COPY . .
|
COPY gradlew settings.gradle build.gradle ./
|
||||||
|
COPY gradle ./gradle
|
||||||
# Даем права на запуск Gradle
|
|
||||||
RUN chmod +x gradlew
|
RUN chmod +x gradlew
|
||||||
|
RUN --mount=type=cache,target=/root/.gradle \
|
||||||
|
./gradlew dependencies --no-daemon -q > /dev/null
|
||||||
|
|
||||||
# Устанавливаем зависимости фронтенда
|
COPY src ./src
|
||||||
RUN cd spring-angular-ui && npm install --legacy-peer-deps
|
# Свежесобранный фронт кладём туда же, куда его копирует задача copyAngularBuild
|
||||||
|
COPY --from=ui /ui/static/browser ./src/main/resources/static
|
||||||
|
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
|
||||||
|
|
||||||
# Запускаем сборку через Gradle
|
# Раскладываем jar по слоям: зависимости меняются редко и кэшируются отдельно от кода
|
||||||
RUN ./gradlew copyAngularBuild --no-daemon --info
|
RUN java -Djarmode=tools -jar app.jar extract --layers --launcher --destination extracted
|
||||||
RUN ./gradlew bootJar -x test --no-daemon
|
|
||||||
|
|
||||||
# 2. Финальный образ (Runtime stage)
|
|
||||||
FROM eclipse-temurin:26-jdk-jammy
|
|
||||||
|
|
||||||
# Здесь оставляем все утилиты управления контейнерами
|
# 3. Запуск
|
||||||
RUN apt-get update && \
|
FROM eclipse-temurin:26-jre-jammy
|
||||||
apt-get install -y docker.io docker-compose-v2 && \
|
|
||||||
apt-get clean && rm -rf /var/lib/apt/lists/*
|
# 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
|
WORKDIR /app
|
||||||
COPY --from=build /app/build/libs/*.jar app.jar
|
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
|
EXPOSE 8080
|
||||||
ENTRYPOINT ["java", "-jar", "-Dspring.profiles.active=prod", "app.jar"]
|
|
||||||
|
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"]
|
||||||
|
|||||||
+1
-1
@@ -57,7 +57,7 @@ dependencies {
|
|||||||
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
|
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
|
||||||
|
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-security'
|
implementation 'org.springframework.boot:spring-boot-starter-security'
|
||||||
implementation 'jakarta.validation:jakarta.validation-api:3.0.2'
|
implementation 'org.springframework.boot:spring-boot-starter-validation'
|
||||||
|
|
||||||
// Безопасность и JWT
|
// Безопасность и JWT
|
||||||
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
|
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
|
||||||
|
|||||||
+15
-13
@@ -1,13 +1,11 @@
|
|||||||
services:
|
services:
|
||||||
demo-monolith-postgres:
|
demo-monolith-postgres:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
restart: always
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: jambotron_monolith
|
POSTGRES_DB: jambotron_monolith
|
||||||
POSTGRES_USER: jambotron_admin
|
POSTGRES_USER: jambotron_admin
|
||||||
POSTGRES_PASSWORD: jambo_secret_pass
|
POSTGRES_PASSWORD: jambo_secret_pass
|
||||||
expose:
|
|
||||||
- "5432"
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U jambotron_admin -d jambotron_monolith"]
|
test: ["CMD-SHELL", "pg_isready -U jambotron_admin -d jambotron_monolith"]
|
||||||
interval: 3s
|
interval: 3s
|
||||||
@@ -20,19 +18,23 @@ services:
|
|||||||
|
|
||||||
app:
|
app:
|
||||||
build: .
|
build: .
|
||||||
restart: always
|
# Собирать образ при каждом `docker compose up`, иначе compose молча запускает старый образ
|
||||||
|
pull_policy: build
|
||||||
|
restart: unless-stopped
|
||||||
|
# Ждём, пока база начнёт принимать подключения, иначе Flyway падает на старте
|
||||||
|
depends_on:
|
||||||
|
demo-monolith-postgres:
|
||||||
|
condition: service_healthy
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
environment:
|
environment:
|
||||||
- SPRING_DATASOURCE_URL=jdbc:postgresql://demo-monolith-postgres:5432/jambotron_monolith
|
SPRING_DATASOURCE_URL: jdbc:postgresql://demo-monolith-postgres:5432/jambotron_monolith
|
||||||
- SPRING_DATASOURCE_USERNAME=jambotron_admin
|
SPRING_DATASOURCE_USERNAME: jambotron_admin
|
||||||
- SPRING_DATASOURCE_PASSWORD=jambo_secret_pass
|
SPRING_DATASOURCE_PASSWORD: jambo_secret_pass
|
||||||
- SPRING_DATASOURCE_DRIVER-CLASS-NAME=org.postgresql.Driver
|
# В application.yaml Flyway настроен на localhost отдельно от datasource — переопределяем
|
||||||
- SPRING_FLYWAY_URL=jdbc:postgresql://demo-monolith-postgres:5432/jambotron_monolith?options=-c%20timezone=UTC
|
SPRING_FLYWAY_URL: jdbc:postgresql://demo-monolith-postgres:5432/jambotron_monolith?options=-c%20timezone=UTC
|
||||||
- SPRING_FLYWAY_USER=jambotron_admin
|
SPRING_FLYWAY_USER: jambotron_admin
|
||||||
- SPRING_FLYWAY_PASSWORD=jambo_secret_pass
|
SPRING_FLYWAY_PASSWORD: jambo_secret_pass
|
||||||
volumes:
|
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
|
||||||
networks:
|
networks:
|
||||||
- demo-monolith
|
- demo-monolith
|
||||||
|
|
||||||
|
|||||||
@@ -43,3 +43,6 @@ testem.log
|
|||||||
# System Files
|
# System Files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
# Angular CLI cache
|
||||||
|
/.angular/
|
||||||
|
|||||||
@@ -108,5 +108,34 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+7042
-7363
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
"start": "ng serve",
|
"start": "ng serve --proxy-config src/proxy.conf.json",
|
||||||
"build": "ng build",
|
"build": "ng build",
|
||||||
"build_dev": "ng build --configuration development",
|
"build_dev": "ng build --configuration development",
|
||||||
"watch": "ng build --watch --configuration development",
|
"watch": "ng build --watch --configuration development",
|
||||||
@@ -11,38 +11,33 @@
|
|||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/animations": "^19.2.11",
|
"@angular/animations": "^22.2.3",
|
||||||
"@angular/cdk": "^19.2.16",
|
"@angular/cdk": "^22.2.3",
|
||||||
"@angular/common": "^19.2.11",
|
"@angular/common": "^22.2.3",
|
||||||
"@angular/compiler": "^19.2.11",
|
"@angular/compiler": "^22.2.3",
|
||||||
"@angular/core": "^19.2.11",
|
"@angular/core": "^22.2.3",
|
||||||
"@angular/forms": "^19.2.11",
|
"@angular/forms": "^22.2.3",
|
||||||
"@angular/material": "^19.2.16",
|
"@angular/material": "^22.2.3",
|
||||||
"@angular/platform-browser": "^19.2.11",
|
"@angular/platform-browser": "^22.2.3",
|
||||||
"@angular/platform-browser-dynamic": "^19.2.11",
|
"@angular/platform-browser-dynamic": "^22.2.3",
|
||||||
"@angular/router": "^19.2.11",
|
"@angular/router": "^22.2.3",
|
||||||
"@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",
|
|
||||||
"rxjs": "~7.8.0",
|
"rxjs": "~7.8.0",
|
||||||
"tslib": "^2.3.0",
|
"tslib": "^2.3.0",
|
||||||
"zone.js": "~0.15.0"
|
"zone.js": "~0.15.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular-devkit/build-angular": "^19.2.12",
|
"@angular-devkit/build-angular": "^22.2.3",
|
||||||
"@angular/cli": "^19.2.12",
|
"@angular/cli": "^22.2.3",
|
||||||
"@angular/compiler-cli": "~19.2.11",
|
"@angular/compiler-cli": "~22.2.3",
|
||||||
"@types/jasmine": "~3.6.0",
|
"@types/jasmine": "~3.6.0",
|
||||||
"@types/node": "^12.11.1",
|
"@types/node": "^12.11.1",
|
||||||
|
"istanbul-lib-instrument": "^6.0.3",
|
||||||
"jasmine-core": "~3.7.0",
|
"jasmine-core": "~3.7.0",
|
||||||
"karma": "~6.3.0",
|
"karma": "~6.3.0",
|
||||||
"karma-chrome-launcher": "~3.1.0",
|
"karma-chrome-launcher": "~3.1.0",
|
||||||
"karma-coverage": "~2.0.3",
|
"karma-coverage": "~2.0.3",
|
||||||
"karma-jasmine": "~4.0.0",
|
"karma-jasmine": "~4.0.0",
|
||||||
"karma-jasmine-html-reporter": "^1.5.0",
|
"karma-jasmine-html-reporter": "^1.5.0",
|
||||||
"typescript": "~5.8.3",
|
"typescript": "~6.0.3"
|
||||||
"webpack": "^5.93.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { TutorialDetailsComponent } from './components/tutorial-details/tutorial-details.component';
|
||||||
import { AddTutorialComponent } from './components/add-tutorial/add-tutorial.component';
|
import { AddTutorialComponent } from './components/add-tutorial/add-tutorial.component';
|
||||||
import { BoardAdminComponent } from './components/board-admin/board-admin.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 { ProfileComponent } from './components/profile/profile.component';
|
||||||
import { RegisterComponent } from './components/register/register.component';
|
import { RegisterComponent } from './components/register/register.component';
|
||||||
import { LoginComponent } from './components/login/login.component';
|
|
||||||
import { HomeComponent } from './components/home/home.component';
|
import { HomeComponent } from './components/home/home.component';
|
||||||
|
import { adminGuard, authGuard } from './helpers/auth.guard';
|
||||||
|
|
||||||
const routes: Routes = [
|
const routes: Routes = [
|
||||||
{ path: 'home', component: HomeComponent },
|
{ path: 'home', component: HomeComponent },
|
||||||
{ path: 'login', component: LoginComponent },
|
|
||||||
{ path: 'register', component: RegisterComponent },
|
{ path: 'register', component: RegisterComponent },
|
||||||
{ path: 'profile', component: ProfileComponent },
|
{ path: 'profile', component: ProfileComponent, canActivate: [authGuard] },
|
||||||
{ path: 'user', component: BoardUserComponent },
|
{ path: 'admin', component: BoardAdminComponent, canActivate: [adminGuard] },
|
||||||
{ path: 'mod', component: BoardModeratorComponent },
|
|
||||||
{ path: 'admin', component: BoardAdminComponent },
|
|
||||||
{ path: '', redirectTo: 'home', pathMatch: 'full' },
|
{ path: '', redirectTo: 'home', pathMatch: 'full' },
|
||||||
{ path: 'tutorials', component: TutorialsListComponent },
|
{ path: 'tutorials', component: TutorialsListComponent },
|
||||||
{ path: 'tutorials/:id', component: TutorialDetailsComponent },
|
{ path: 'tutorials/:id', component: TutorialDetailsComponent, canActivate: [authGuard] },
|
||||||
{ path: 'add', component: AddTutorialComponent }
|
{ path: 'add', component: AddTutorialComponent, canActivate: [authGuard] }
|
||||||
];
|
];
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
|
|||||||
@@ -1,79 +1,66 @@
|
|||||||
<div>
|
<mat-toolbar class="app-toolbar">
|
||||||
<nav class="navbar navbar-expand navbar-dark bg-dark">
|
<a class="brand" routerLink="/">
|
||||||
<a href="#" class="navbar-brand">Spring-AngularTS</a>
|
<mat-icon>school</mat-icon>
|
||||||
|
<span>Spring-AngularTS</span>
|
||||||
<ul class="navbar-nav mr-auto" routerLinkActive="active">
|
</a>
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
|
<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>
|
</nav>
|
||||||
<mat-toolbar>
|
|
||||||
|
|
||||||
<button mat-raised-button routerLink="/" href="#">
|
<span class="spacer"></span>
|
||||||
My App
|
|
||||||
</button>
|
@if (!isLoggedIn) {
|
||||||
<span>My App
|
<div class="actions">
|
||||||
</span>
|
<a mat-stroked-button routerLink="register">
|
||||||
<span class="example-spacer"></span>
|
|
||||||
<button mat-raised-button *ngIf="!isLoggedIn">
|
|
||||||
<mat-icon>app_registration</mat-icon>
|
<mat-icon>app_registration</mat-icon>
|
||||||
Register
|
<span class="label">Register</span>
|
||||||
</button>
|
</a>
|
||||||
|
<button mat-flat-button (click)="openDialog()">
|
||||||
<button mat-raised-button (click)="openDialog('100ms', '5ms')" *ngIf="!isLoggedIn">
|
|
||||||
<mat-icon>login</mat-icon>
|
<mat-icon>login</mat-icon>
|
||||||
Login
|
<span class="label">Login</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button mat-raised-button *ngIf="isLoggedIn">
|
|
||||||
<mat-icon>account_circle</mat-icon>
|
|
||||||
{{ username }}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button mat-raised-button (click)="logout()"*ngIf="isLoggedIn">
|
|
||||||
<mat-icon>logout</mat-icon>
|
|
||||||
Logout
|
|
||||||
</button>
|
|
||||||
|
|
||||||
</mat-toolbar>
|
|
||||||
|
|
||||||
|
|
||||||
<div class="container mt-3">
|
|
||||||
<router-outlet></router-outlet>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
} @else {
|
||||||
|
<button mat-button class="user-button" [matMenuTriggerFor]="userMenu">
|
||||||
|
<mat-icon>account_circle</mat-icon>
|
||||||
|
<span class="label">{{ username }}</span>
|
||||||
|
<mat-icon iconPositionEnd>expand_more</mat-icon>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<main class="container mt-4">
|
||||||
|
<router-outlet></router-outlet>
|
||||||
|
</main>
|
||||||
|
|||||||
@@ -1,6 +1,85 @@
|
|||||||
//#header_panel{
|
@use '@angular/material' as mat;
|
||||||
// background-color: #181d1f;
|
|
||||||
//}
|
.app-toolbar {
|
||||||
.example-spacer {
|
@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;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 { 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({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
templateUrl: './app.component.html',
|
templateUrl: './app.component.html',
|
||||||
styleUrls: ['./app.component.scss'],
|
styleUrls: ['./app.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.Eager,
|
||||||
standalone: false
|
standalone: false
|
||||||
})
|
})
|
||||||
export class AppComponent implements OnInit {
|
export class AppComponent implements OnInit {
|
||||||
readonly dialog = inject(MatDialog);
|
private readonly dialog = inject(MatDialog);
|
||||||
//private authService = new AuthService(provideHttpClient())
|
private readonly authService = inject(AuthService);
|
||||||
public dialogData : DialogData = {password: "", username: ""};
|
private readonly storageService = inject(TokenStorageService);
|
||||||
|
|
||||||
private roles: string[] = [];
|
|
||||||
isLoggedIn = false;
|
isLoggedIn = false;
|
||||||
showAdminBoard = false;
|
showAdminBoard = false;
|
||||||
showModeratorBoard = false;
|
|
||||||
username?: string;
|
username?: string;
|
||||||
|
|
||||||
eventBusSub?: Subscription;
|
|
||||||
private errorMessage: any;
|
|
||||||
private isLoginFailed: boolean = false;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private storageService: TokenStorageService,
|
|
||||||
private authService: AuthService,
|
|
||||||
private eventBusService: EventBusService
|
|
||||||
) {}
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.isLoggedIn = this.storageService.isLoggedIn();
|
this.isLoggedIn = this.storageService.isLoggedIn();
|
||||||
|
if (!this.isLoggedIn) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.isLoggedIn) {
|
|
||||||
const user = this.storageService.getUser();
|
const user = this.storageService.getUser();
|
||||||
this.roles = user.roles;
|
const roles: string[] = user.roles ?? [];
|
||||||
|
|
||||||
this.showAdminBoard = true;//this.roles.includes('ROLE_ADMIN');
|
|
||||||
this.showModeratorBoard = this.roles.includes('ROLE_MODERATOR');
|
|
||||||
|
|
||||||
|
this.showAdminBoard = roles.includes('ROLE_ADMIN');
|
||||||
this.username = user.username;
|
this.username = user.username;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.eventBusSub = this.eventBusService.on('logout', () => {
|
openDialog(): void {
|
||||||
this.logout();
|
// Вход выполняет сам диалог и при успехе перезагружает страницу
|
||||||
|
this.dialog.open(DialogComponent, {
|
||||||
|
width: '400px',
|
||||||
|
maxWidth: '95vw',
|
||||||
|
enterAnimationDuration: '100ms',
|
||||||
|
exitAnimationDuration: '5ms'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
logout(): void {
|
logout(): void {
|
||||||
this.authService.logout().subscribe({
|
this.authService.logout().subscribe({
|
||||||
next: res => {
|
next: () => {
|
||||||
console.log(res);
|
|
||||||
this.storageService.clean();
|
this.storageService.clean();
|
||||||
|
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
},
|
},
|
||||||
error: err => {
|
error: err => console.error('Logout failed', err)
|
||||||
console.log(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;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,30 +2,32 @@ import { NgModule } from '@angular/core';
|
|||||||
import { BrowserModule } from '@angular/platform-browser';
|
import { BrowserModule } from '@angular/platform-browser';
|
||||||
|
|
||||||
import { AppRoutingModule } from './app-routing.module';
|
import { AppRoutingModule } from './app-routing.module';
|
||||||
import {FormsModule, ReactiveFormsModule} from '@angular/forms';
|
import {FormsModule} from '@angular/forms';
|
||||||
|
|
||||||
|
|
||||||
import { AppComponent } from './app.component';
|
import { AppComponent } from './app.component';
|
||||||
import { TutorialsListComponent } from './components/tutorials-list/tutorials-list.component';
|
import { TutorialsListComponent } from './components/tutorials-list/tutorials-list.component';
|
||||||
import { TutorialDetailsComponent } from './components/tutorial-details/tutorial-details.component';
|
import { TutorialDetailsComponent } from './components/tutorial-details/tutorial-details.component';
|
||||||
import { AddTutorialComponent } from './components/add-tutorial/add-tutorial.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 { RegisterComponent } from './components/register/register.component';
|
||||||
import { HomeComponent } from './components/home/home.component';
|
import { HomeComponent } from './components/home/home.component';
|
||||||
import { ProfileComponent } from './components/profile/profile.component';
|
import { ProfileComponent } from './components/profile/profile.component';
|
||||||
import { BoardAdminComponent } from './components/board-admin/board-admin.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, withXhr} from "@angular/common/http";
|
||||||
import {provideHttpClient, withInterceptorsFromDi} from "@angular/common/http";
|
|
||||||
import {provideAnimationsAsync} from "@angular/platform-browser/animations/async";
|
import {provideAnimationsAsync} from "@angular/platform-browser/animations/async";
|
||||||
import {MatToolbar} from "@angular/material/toolbar";
|
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 {MatIcon} from "@angular/material/icon";
|
||||||
import {MatChipGrid, MatChipInput, MatChipListbox, MatChipOption, MatChipRow} from "@angular/material/chips";
|
import {MatMenu, MatMenuItem, MatMenuTrigger} from "@angular/material/menu";
|
||||||
import {MatAutocomplete, MatAutocompleteTrigger, MatOption} from "@angular/material/autocomplete";
|
import {MatDivider} from "@angular/material/divider";
|
||||||
import {MatFormField} from "@angular/material/form-field";
|
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,
|
RegisterComponent,
|
||||||
HomeComponent,
|
HomeComponent,
|
||||||
ProfileComponent,
|
ProfileComponent,
|
||||||
BoardAdminComponent,
|
BoardAdminComponent
|
||||||
BoardModeratorComponent,
|
|
||||||
BoardUserComponent
|
|
||||||
|
|
||||||
],
|
],
|
||||||
bootstrap: [AppComponent],
|
bootstrap: [AppComponent],
|
||||||
@@ -49,23 +49,22 @@ import {MatFormField} from "@angular/material/form-field";
|
|||||||
BrowserModule,
|
BrowserModule,
|
||||||
AppRoutingModule,
|
AppRoutingModule,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
LoginComponent,
|
|
||||||
MatToolbar,
|
MatToolbar,
|
||||||
MatButton,
|
MatButtonModule,
|
||||||
|
MatCardModule,
|
||||||
|
MatInputModule,
|
||||||
|
MatListModule,
|
||||||
MatIcon,
|
MatIcon,
|
||||||
MatAnchor,
|
MatMenu,
|
||||||
MatChipGrid,
|
MatMenuItem,
|
||||||
MatChipRow,
|
MatMenuTrigger,
|
||||||
MatChipInput,
|
MatDivider,
|
||||||
MatAutocompleteTrigger,
|
MatTableModule,
|
||||||
MatAutocomplete,
|
MatSortModule,
|
||||||
MatOption,
|
MatPaginatorModule,
|
||||||
MatFormField,
|
MatTooltipModule
|
||||||
MatChipListbox,
|
|
||||||
MatChipOption,
|
|
||||||
ReactiveFormsModule
|
|
||||||
],
|
],
|
||||||
providers: [authInterceptorProviders, provideHttpClient(withInterceptorsFromDi()), provideAnimationsAsync()] })
|
providers: [provideHttpClient(withXhr()), provideAnimationsAsync()] })
|
||||||
|
|
||||||
|
|
||||||
export class AppModule { }
|
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="page narrow">
|
||||||
<div class="submit-form">
|
<div class="page-header">
|
||||||
<div *ngIf="!submitted">
|
<h1 class="page-title">New tutorial</h1>
|
||||||
<div class="form-group">
|
<a mat-button routerLink="/tutorials">
|
||||||
<label for="title">Title</label>
|
<mat-icon>arrow_back</mat-icon>
|
||||||
<input
|
Back to list
|
||||||
type="text"
|
</a>
|
||||||
class="form-control"
|
|
||||||
id="title"
|
|
||||||
required
|
|
||||||
[(ngModel)]="tutorial.title"
|
|
||||||
name="title"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<mat-card appearance="outlined">
|
||||||
<label for="description">Description</label>
|
@if (!submitted) {
|
||||||
<input
|
<form #tutorialForm="ngForm" (ngSubmit)="saveTutorial()">
|
||||||
class="form-control"
|
<mat-card-content>
|
||||||
id="description"
|
<mat-form-field appearance="outline" class="full-width">
|
||||||
required
|
<mat-label>Title</mat-label>
|
||||||
[(ngModel)]="tutorial.description"
|
<input matInput name="title" required maxlength="255" [(ngModel)]="tutorial.title" />
|
||||||
name="description"
|
<mat-error>Title is required</mat-error>
|
||||||
/>
|
</mat-form-field>
|
||||||
</div>
|
|
||||||
|
|
||||||
<button (click)="saveTutorial()" class="btn btn-success">Submit</button>
|
<mat-form-field appearance="outline" class="full-width">
|
||||||
</div>
|
<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>
|
||||||
|
|
||||||
<div *ngIf="submitted">
|
<mat-card-actions align="end">
|
||||||
<h4>Tutorial was submitted successfully!</h4>
|
<button mat-flat-button type="submit" [disabled]="tutorialForm.invalid || saving">
|
||||||
<button class="btn btn-success" (click)="newTutorial()">Add</button>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
|
</mat-card>
|
||||||
</div>
|
</div>
|
||||||
@@ -1,4 +1,20 @@
|
|||||||
.submit-form {
|
@use '../tutorial-shared';
|
||||||
max-width: 400px;
|
|
||||||
margin: auto;
|
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,4 +1,7 @@
|
|||||||
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 { Tutorial } from 'src/app/models/tutorial.model';
|
||||||
import { TutorialService } from 'src/app/services/tutorial.service';
|
import { TutorialService } from 'src/app/services/tutorial.service';
|
||||||
|
|
||||||
@@ -6,21 +9,16 @@ import { TutorialService } from 'src/app/services/tutorial.service';
|
|||||||
selector: 'app-add-tutorial',
|
selector: 'app-add-tutorial',
|
||||||
templateUrl: './add-tutorial.component.html',
|
templateUrl: './add-tutorial.component.html',
|
||||||
styleUrls: ['./add-tutorial.component.scss'],
|
styleUrls: ['./add-tutorial.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.Eager,
|
||||||
standalone: false
|
standalone: false
|
||||||
})
|
})
|
||||||
export class AddTutorialComponent implements OnInit {
|
export class AddTutorialComponent {
|
||||||
|
private readonly tutorialService = inject(TutorialService);
|
||||||
|
private readonly snackBar = inject(MatSnackBar);
|
||||||
|
|
||||||
tutorial: Tutorial = {
|
tutorial: Tutorial = this.emptyTutorial();
|
||||||
title: '',
|
|
||||||
description: '',
|
|
||||||
published: false
|
|
||||||
};
|
|
||||||
submitted = false;
|
submitted = false;
|
||||||
|
saving = false;
|
||||||
constructor(private tutorialService: TutorialService) { }
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
|
||||||
}
|
|
||||||
|
|
||||||
saveTutorial(): void {
|
saveTutorial(): void {
|
||||||
const data = {
|
const data = {
|
||||||
@@ -28,23 +26,25 @@ export class AddTutorialComponent implements OnInit {
|
|||||||
description: this.tutorial.description
|
description: this.tutorial.description
|
||||||
};
|
};
|
||||||
|
|
||||||
this.tutorialService.create(data)
|
this.saving = true;
|
||||||
.subscribe(
|
this.tutorialService.create(data).subscribe({
|
||||||
response => {
|
next: () => {
|
||||||
console.log(response);
|
this.saving = false;
|
||||||
this.submitted = true;
|
this.submitted = true;
|
||||||
},
|
},
|
||||||
error => {
|
error: err => {
|
||||||
console.log(error);
|
this.saving = false;
|
||||||
|
this.snackBar.open(httpErrorMessage(err), 'OK', { duration: 5000 });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
newTutorial(): void {
|
newTutorial(): void {
|
||||||
this.submitted = false;
|
this.submitted = false;
|
||||||
this.tutorial = {
|
this.tutorial = this.emptyTutorial();
|
||||||
title: '',
|
}
|
||||||
description: '',
|
|
||||||
published: false
|
private emptyTutorial(): Tutorial {
|
||||||
};
|
return { title: '', description: '', published: false };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,51 +1,70 @@
|
|||||||
<div class="col-md-6">
|
<div class="page">
|
||||||
<h4>Users List</h4>
|
<div class="page-header">
|
||||||
<ul class="list-group">
|
<h1 class="page-title">Users</h1>
|
||||||
<li
|
</div>
|
||||||
class="list-group-item"
|
|
||||||
*ngFor="let row of rowData; let i = index"
|
|
||||||
|
|
||||||
>
|
<mat-form-field appearance="outline" class="search" subscriptSizing="dynamic">
|
||||||
{{ row.username }}
|
<mat-label>Search by name, email or role</mat-label>
|
||||||
|
<mat-icon matPrefix>search</mat-icon>
|
||||||
{{ row.email}}
|
<input matInput #search (input)="applyFilter(search.value)" />
|
||||||
{{ row.password}}
|
@if (search.value) {
|
||||||
|
<button matIconButton matSuffix aria-label="Clear search" (click)="search.value = ''; applyFilter('')">
|
||||||
<form>
|
<mat-icon>close</mat-icon>
|
||||||
<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>
|
</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>
|
|
||||||
|
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
</form>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
</ul>
|
<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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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>
|
</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%;
|
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 { UserService } from 'src/app/services/user.service';
|
||||||
|
import { RolesDialogComponent, RolesDialogData } from '../roles-dialog/roles-dialog.component';
|
||||||
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";
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-board-admin',
|
selector: 'app-board-admin',
|
||||||
templateUrl: './board-admin.component.html',
|
templateUrl: './board-admin.component.html',
|
||||||
styleUrls: ['./board-admin.component.scss'],
|
styleUrls: ['./board-admin.component.scss'],
|
||||||
standalone: false,
|
changeDetection: ChangeDetectionStrategy.Eager,
|
||||||
|
standalone: false
|
||||||
})
|
})
|
||||||
export class BoardAdminComponent implements OnInit {
|
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];
|
readonly displayedColumns = ['username', 'email', 'roles', 'actions'];
|
||||||
rowData?: User[];
|
readonly dataSource = new MatTableDataSource<User>([]);
|
||||||
allRoles: any;
|
readonly roleLabel = roleLabel;
|
||||||
|
readonly currentUserId = this.tokenStorage.getUser().id;
|
||||||
|
|
||||||
readonly currentRole = model('');
|
private allRoles: Role[] = [];
|
||||||
|
|
||||||
protected myControl = new FormControl('');
|
@ViewChild(MatSort) set sort(sort: MatSort) {
|
||||||
protected ac = new FormControl('');
|
this.dataSource.sort = sort;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewChild(MatPaginator) set paginator(paginator: MatPaginator) {
|
||||||
constructor(private userService: UserService, private roleService: RolesService ) {
|
this.dataSource.paginator = paginator;
|
||||||
this.getAllRoles();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
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(
|
this.userService.getAll().subscribe({
|
||||||
// startWith(''),
|
// На пустой результат сервер отвечает 204 без тела
|
||||||
// map(value => this._filter(value || '')),
|
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(
|
applyFilter(value: string): void {
|
||||||
(data : any) => {
|
this.dataSource.filter = value.trim().toLowerCase();
|
||||||
this.rowData = data;
|
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)=> {
|
error: err => this.showError(err)
|
||||||
this.rowData = JSON.parse(err.error).message;
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
);
|
private showError(err: HttpErrorResponse): void {
|
||||||
}
|
this.snackBar.open(httpErrorMessage(err), 'OK', { duration: 5000 });
|
||||||
|
|
||||||
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),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
<p>board-moderator works!</p>
|
|
||||||
-25
@@ -1,25 +0,0 @@
|
|||||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
|
||||||
|
|
||||||
import { BoardModeratorComponent } from './board-moderator.component';
|
|
||||||
|
|
||||||
describe('BoardModeratorComponent', () => {
|
|
||||||
let component: BoardModeratorComponent;
|
|
||||||
let fixture: ComponentFixture<BoardModeratorComponent>;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await TestBed.configureTestingModule({
|
|
||||||
declarations: [ BoardModeratorComponent ]
|
|
||||||
})
|
|
||||||
.compileComponents();
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
fixture = TestBed.createComponent(BoardModeratorComponent);
|
|
||||||
component = fixture.componentInstance;
|
|
||||||
fixture.detectChanges();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should create', () => {
|
|
||||||
expect(component).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import { Component, OnInit } from '@angular/core';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-board-moderator',
|
|
||||||
templateUrl: './board-moderator.component.html',
|
|
||||||
styleUrls: ['./board-moderator.component.scss'],
|
|
||||||
standalone: false
|
|
||||||
})
|
|
||||||
export class BoardModeratorComponent implements OnInit {
|
|
||||||
|
|
||||||
constructor() { }
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
<p>board-user works!</p>
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
|
||||||
|
|
||||||
import { BoardUserComponent } from './board-user.component';
|
|
||||||
|
|
||||||
describe('BoardUserComponent', () => {
|
|
||||||
let component: BoardUserComponent;
|
|
||||||
let fixture: ComponentFixture<BoardUserComponent>;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await TestBed.configureTestingModule({
|
|
||||||
declarations: [ BoardUserComponent ]
|
|
||||||
})
|
|
||||||
.compileComponents();
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
fixture = TestBed.createComponent(BoardUserComponent);
|
|
||||||
component = fixture.componentInstance;
|
|
||||||
fixture.detectChanges();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should create', () => {
|
|
||||||
expect(component).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import { Component, OnInit } from '@angular/core';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-board-user',
|
|
||||||
templateUrl: './board-user.component.html',
|
|
||||||
styleUrls: ['./board-user.component.scss'],
|
|
||||||
standalone: false
|
|
||||||
})
|
|
||||||
export class BoardUserComponent implements OnInit {
|
|
||||||
|
|
||||||
constructor() { }
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,29 +1,51 @@
|
|||||||
<h1 mat-dialog-title>Hi </h1>
|
<form #f="ngForm" (ngSubmit)="f.valid && signIn()">
|
||||||
<div mat-dialog-content>
|
<div class="heading">
|
||||||
<p>User name</p>
|
<span class="heading-icon"><mat-icon>login</mat-icon></span>
|
||||||
<mat-form-field class="fillField">
|
<h2 class="title">Sign in</h2>
|
||||||
<input matInput [(ngModel)]="data.username">
|
<p class="subtitle">Welcome back! Enter your details to continue.</p>
|
||||||
</mat-form-field>
|
</div>
|
||||||
<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>
|
|
||||||
|
|
||||||
<div mat-dialog-actions>
|
<mat-dialog-content>
|
||||||
<button mat-button mat-dialog-close (click)="onNoClick()">No Thanks</button>
|
<mat-form-field appearance="outline" class="full-width">
|
||||||
<button mat-button [mat-dialog-close]="data" cdkFocusInitial>Ok</button>
|
<mat-label>Username</mat-label>
|
||||||
</div>
|
<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%;
|
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 { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
|
||||||
import {MatButtonModule} from "@angular/material/button";
|
import { FormsModule } from '@angular/forms';
|
||||||
import {
|
import { RouterLink } from '@angular/router';
|
||||||
MAT_DIALOG_DATA,
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
MatDialogActions,
|
import { MatDialogModule, MatDialogRef } from '@angular/material/dialog';
|
||||||
MatDialogClose,
|
import { MatIcon } from '@angular/material/icon';
|
||||||
MatDialogContent,
|
import { MatInputModule } from '@angular/material/input';
|
||||||
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 { AuthService } from 'src/app/services/auth.service';
|
||||||
|
import { TokenStorageService } from 'src/app/services/token-storage.service';
|
||||||
|
|
||||||
|
// Диалог входа: сам выполняет логин и закрывается только при успехе
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-dialog',
|
selector: 'app-dialog',
|
||||||
imports: [MatButtonModule, MatLabel, MatDialogActions, MatDialogClose, MatDialogTitle, MatDialogContent, MatFormField, MatInput, FormsModule, MatIcon, MatSuffix],
|
imports: [FormsModule, RouterLink, MatButtonModule, MatDialogModule, MatIcon, MatInputModule],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
templateUrl: './dialog.component.html',
|
templateUrl: './dialog.component.html',
|
||||||
styleUrl: './dialog.component.scss'
|
styleUrl: './dialog.component.scss'
|
||||||
})
|
})
|
||||||
export class DialogComponent {
|
export class DialogComponent {
|
||||||
readonly dialogRef = inject(MatDialogRef<DialogComponent>);
|
private readonly dialogRef = inject(MatDialogRef<DialogComponent>);
|
||||||
hide = signal(true);
|
private readonly authService = inject(AuthService);
|
||||||
|
private readonly tokenStorage = inject(TokenStorageService);
|
||||||
|
|
||||||
constructor(
|
username = '';
|
||||||
@Inject(MAT_DIALOG_DATA) public data:DialogData) {}
|
password = '';
|
||||||
|
readonly hidePassword = signal(true);
|
||||||
|
readonly submitting = signal(false);
|
||||||
|
readonly errorMessage = signal('');
|
||||||
|
|
||||||
onNoClick() {
|
togglePassword(event: MouseEvent): void {
|
||||||
this.dialogRef.close();
|
this.hidePassword.update(hide => !hide);
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
clickEvent(event: MouseEvent) {
|
|
||||||
this.hide.set(!this.hide());
|
|
||||||
event.stopPropagation();
|
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">
|
<div class="page">
|
||||||
<header class="jumbotron">
|
@if (!isLoggedIn) {
|
||||||
<p>{{ content }}</p>
|
<div class="demo-banner" role="note">
|
||||||
</header>
|
<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>
|
||||||
|
}
|
||||||
|
|
||||||
|
<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 { ChangeDetectionStrategy, Component, inject, OnInit } from '@angular/core';
|
||||||
import { UserService } from 'src/app/services/user.service';
|
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({
|
@Component({
|
||||||
selector: 'app-home',
|
selector: 'app-home',
|
||||||
templateUrl: './home.component.html',
|
templateUrl: './home.component.html',
|
||||||
styleUrls: ['./home.component.scss'],
|
styleUrls: ['./home.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.Eager,
|
||||||
standalone: false
|
standalone: false
|
||||||
})
|
})
|
||||||
export class HomeComponent implements OnInit {
|
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 {
|
ngOnInit(): void {
|
||||||
// this.userService.getPublicContent().subscribe(
|
this.tutorialService.getPublished().subscribe({
|
||||||
// (data : any) => {
|
// На пустой результат сервер отвечает 204 без тела
|
||||||
// this.content = data;
|
next: data => {
|
||||||
// },
|
this.tutorials = data ?? [];
|
||||||
// (err : any) => {
|
this.loaded = true;
|
||||||
// this.content = JSON.parse(err.error).message;
|
},
|
||||||
// }
|
error: err => {
|
||||||
// );
|
this.loaded = true;
|
||||||
|
this.snackBar.open(httpErrorMessage(err), 'OK', { duration: 5000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
<div class="col-md-12">
|
|
||||||
<div class="card card-container">
|
|
||||||
<img
|
|
||||||
id="profile-img"
|
|
||||||
src="//ssl.gstatic.com/accounts/ui/avatar_2x.png"
|
|
||||||
class="profile-img-card"
|
|
||||||
/>
|
|
||||||
<form
|
|
||||||
*ngIf="!isLoggedIn"
|
|
||||||
name="form"
|
|
||||||
(ngSubmit)="f.form.valid && onSubmit()"
|
|
||||||
#f="ngForm"
|
|
||||||
novalidate
|
|
||||||
>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="username">Username</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
class="form-control"
|
|
||||||
id="username"
|
|
||||||
[(ngModel)]="form.username"
|
|
||||||
required
|
|
||||||
#username="ngModel"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
class="alert alert-danger"
|
|
||||||
role="alert"
|
|
||||||
*ngIf="username.errors && f.submitted"
|
|
||||||
>
|
|
||||||
Username is required!
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="password">Password</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
class="form-control"
|
|
||||||
id="password"
|
|
||||||
[(ngModel)]="form.password"
|
|
||||||
required
|
|
||||||
minlength="6"
|
|
||||||
#password="ngModel"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
class="alert alert-danger"
|
|
||||||
role="alert"
|
|
||||||
*ngIf="password.errors && f.submitted"
|
|
||||||
>
|
|
||||||
<div *ngIf="password.errors.required">Password is required</div>
|
|
||||||
<div *ngIf="password.errors.minlength">
|
|
||||||
Password must be at least 6 characters
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<button class="btn btn-primary btn-block">
|
|
||||||
Login
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<div
|
|
||||||
class="alert alert-danger"
|
|
||||||
role="alert"
|
|
||||||
*ngIf="f.submitted && isLoginFailed"
|
|
||||||
>
|
|
||||||
Login failed: {{ errorMessage }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div class="alert alert-success" *ngIf="isLoggedIn">
|
|
||||||
Logged in as {{ roles }}.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
|
||||||
|
|
||||||
import { LoginComponent } from './login.component';
|
|
||||||
|
|
||||||
describe('LoginComponent', () => {
|
|
||||||
let component: LoginComponent;
|
|
||||||
let fixture: ComponentFixture<LoginComponent>;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await TestBed.configureTestingModule({
|
|
||||||
declarations: [ LoginComponent ]
|
|
||||||
})
|
|
||||||
.compileComponents();
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
fixture = TestBed.createComponent(LoginComponent);
|
|
||||||
component = fixture.componentInstance;
|
|
||||||
fixture.detectChanges();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should create', () => {
|
|
||||||
expect(component).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
import { Component, OnInit } from '@angular/core';
|
|
||||||
import { AuthService } from 'src/app/services/auth.service';
|
|
||||||
import { TokenStorageService } from 'src/app/services/token-storage.service';
|
|
||||||
import {FormsModule} from "@angular/forms";
|
|
||||||
import {NgIf} from "@angular/common";
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-login',
|
|
||||||
templateUrl: './login.component.html',
|
|
||||||
imports: [
|
|
||||||
FormsModule,
|
|
||||||
NgIf
|
|
||||||
],
|
|
||||||
styleUrls: ['./login.component.scss']
|
|
||||||
})
|
|
||||||
export class LoginComponent implements OnInit {
|
|
||||||
|
|
||||||
form: any = {
|
|
||||||
username: null,
|
|
||||||
password: null
|
|
||||||
};
|
|
||||||
isLoggedIn = false;
|
|
||||||
isLoginFailed = false;
|
|
||||||
errorMessage = '';
|
|
||||||
roles: string[] = [];
|
|
||||||
|
|
||||||
constructor(private authService: AuthService, private tokenStorage: TokenStorageService) { }
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
|
||||||
if (this.tokenStorage.getToken()) {
|
|
||||||
this.isLoggedIn = true;
|
|
||||||
this.roles = this.tokenStorage.getUser().roles;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onSubmit(): void {
|
|
||||||
const { username, password } = this.form;
|
|
||||||
|
|
||||||
this.authService.login(username, password).subscribe(
|
|
||||||
data => {
|
|
||||||
this.tokenStorage.saveToken(data.accessToken);
|
|
||||||
this.tokenStorage.saveUser(data);
|
|
||||||
|
|
||||||
this.isLoginFailed = false;
|
|
||||||
this.isLoggedIn = true;
|
|
||||||
this.roles = this.tokenStorage.getUser().roles;
|
|
||||||
this.reloadPage();
|
|
||||||
},
|
|
||||||
err => {
|
|
||||||
this.errorMessage = err.error.message;
|
|
||||||
this.isLoginFailed = true;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
reloadPage(): void {
|
|
||||||
window.location.reload();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,26 +1,28 @@
|
|||||||
<div class="container" *ngIf="currentUser; else loggedOut">
|
<div class="page narrow">
|
||||||
<header class="jumbotron">
|
<mat-card appearance="outlined" class="profile-card">
|
||||||
<h3>
|
<div class="hero">
|
||||||
<strong>{{ currentUser.username }}</strong> Profile
|
<span class="avatar large">{{ user.username?.slice(0, 2) }}</span>
|
||||||
</h3>
|
<h1 class="name">{{ user.username }}</h1>
|
||||||
</header>
|
<div class="roles">
|
||||||
<p>
|
@for (role of roles; track role) {
|
||||||
<strong>Token:</strong>
|
<span class="role {{ role }}">{{ roleLabel(role) }}</span>
|
||||||
{{ currentUser.accessToken.substring(0, 20) }} ...
|
}
|
||||||
{{ currentUser.accessToken.substr(currentUser.accessToken.length - 20) }}
|
</div>
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<strong>Email:</strong>
|
|
||||||
{{ currentUser.email }}
|
|
||||||
</p>
|
|
||||||
<strong>Roles:</strong>
|
|
||||||
<ul>
|
|
||||||
<li *ngFor="let role of currentUser.roles">
|
|
||||||
{{ role }}
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ng-template #loggedOut>
|
<mat-divider></mat-divider>
|
||||||
Please login.
|
|
||||||
</ng-template>
|
<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';
|
import { TokenStorageService } from 'src/app/services/token-storage.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-profile',
|
selector: 'app-profile',
|
||||||
templateUrl: './profile.component.html',
|
templateUrl: './profile.component.html',
|
||||||
styleUrls: ['./profile.component.scss'],
|
styleUrls: ['./profile.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.Eager,
|
||||||
standalone: false
|
standalone: false
|
||||||
})
|
})
|
||||||
export class ProfileComponent implements OnInit {
|
export class ProfileComponent {
|
||||||
currentUser: any;
|
readonly user = inject(TokenStorageService).getUser();
|
||||||
|
readonly roles: string[] = this.user.roles ?? [];
|
||||||
constructor(private token: TokenStorageService) { }
|
readonly roleLabel = roleLabel;
|
||||||
|
|
||||||
ngOnInit(): void {
|
|
||||||
this.currentUser = this.token.getUser();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,89 +1,75 @@
|
|||||||
<div class="col-md-12">
|
<div class="page auth">
|
||||||
<div id="register">
|
<mat-card appearance="outlined">
|
||||||
<img id="foto"
|
@if (!registeredOnly) {
|
||||||
id="profile-img"
|
<div class="heading">
|
||||||
src="//ssl.gstatic.com/accounts/ui/avatar_2x.png"
|
<span class="heading-icon"><mat-icon>person_add</mat-icon></span>
|
||||||
class="profile-img-card"
|
<h1 class="page-title">Create account</h1>
|
||||||
/>
|
<p class="subtitle">Sign up to add and edit tutorials</p>
|
||||||
<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>
|
||||||
|
|
||||||
<div class="alert alert-warning" *ngIf="f.submitted && isSignUpFailed">
|
<form #f="ngForm" (ngSubmit)="f.valid && onSubmit()">
|
||||||
Signup failed!<br />{{ errorMessage }}
|
<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>
|
</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>
|
</form>
|
||||||
|
} @else {
|
||||||
<div class="alert alert-success" *ngIf="isSuccessful">
|
<div class="empty success">
|
||||||
Your registration is successful!
|
<mat-icon>check_circle</mat-icon>
|
||||||
</div>
|
<p>Your account was created. Please log in.</p>
|
||||||
</div>
|
<button mat-flat-button (click)="openLogin()">Log in</button>
|
||||||
</div>
|
</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 { AuthService } from 'src/app/services/auth.service';
|
||||||
|
import { TokenStorageService } from 'src/app/services/token-storage.service';
|
||||||
|
import { DialogComponent } from '../dialog/dialog.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-register',
|
selector: 'app-register',
|
||||||
templateUrl: './register.component.html',
|
templateUrl: './register.component.html',
|
||||||
styleUrls: ['./register.component.scss'],
|
styleUrls: ['./register.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.Eager,
|
||||||
standalone: false
|
standalone: false
|
||||||
})
|
})
|
||||||
export class RegisterComponent implements OnInit {
|
export class RegisterComponent {
|
||||||
form: any = {
|
private readonly authService = inject(AuthService);
|
||||||
username: null,
|
private readonly tokenStorage = inject(TokenStorageService);
|
||||||
email: null,
|
private readonly router = inject(Router);
|
||||||
password: null
|
private readonly dialog = inject(MatDialog);
|
||||||
|
|
||||||
|
form = {
|
||||||
|
username: '',
|
||||||
|
email: '',
|
||||||
|
password: ''
|
||||||
};
|
};
|
||||||
isSuccessful = false;
|
hidePassword = true;
|
||||||
isSignUpFailed = false;
|
saving = false;
|
||||||
errorMessage = '';
|
errorMessage = '';
|
||||||
|
// Аккаунт создан, но автоматический вход не удался — предлагаем войти вручную
|
||||||
constructor(private authService: AuthService) { }
|
registeredOnly = false;
|
||||||
|
|
||||||
ngOnInit(): void {
|
|
||||||
}
|
|
||||||
|
|
||||||
onSubmit(): void {
|
onSubmit(): void {
|
||||||
const { username, email, password } = this.form;
|
const { username, email, password } = this.form;
|
||||||
|
this.saving = true;
|
||||||
|
this.errorMessage = '';
|
||||||
|
|
||||||
this.authService.register(username, email, password).subscribe(
|
this.authService.register(username, email, password).subscribe({
|
||||||
data => {
|
next: () => this.signIn(username, password),
|
||||||
console.log(data);
|
error: err => {
|
||||||
this.isSuccessful = true;
|
this.saving = false;
|
||||||
this.isSignUpFailed = false;
|
this.errorMessage = err.error?.message ?? 'Registration failed, please try again';
|
||||||
},
|
|
||||||
err => {
|
|
||||||
this.errorMessage = err.error.message;
|
|
||||||
this.isSignUpFailed = true;
|
|
||||||
}
|
}
|
||||||
);
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+52
-56
@@ -1,65 +1,61 @@
|
|||||||
<div>
|
<div class="page narrow">
|
||||||
<div *ngIf="currentTutorial.id" class="edit-form">
|
<div class="page-header">
|
||||||
<h4>Tutorial</h4>
|
<h1 class="page-title">Edit tutorial</h1>
|
||||||
<form>
|
<a mat-button routerLink="/tutorials">
|
||||||
<div class="form-group">
|
<mat-icon>arrow_back</mat-icon>
|
||||||
<label for="title">Title</label>
|
Back to list
|
||||||
<input
|
</a>
|
||||||
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>
|
||||||
|
|
||||||
<div class="form-group">
|
@if (currentTutorial.id) {
|
||||||
<label><strong>Status:</strong></label>
|
<mat-card appearance="outlined">
|
||||||
{{ currentTutorial.published ? "Published" : "Pending" }}
|
<form #tutorialForm="ngForm" (ngSubmit)="updateTutorial()">
|
||||||
</div>
|
<mat-card-header>
|
||||||
</form>
|
<mat-card-subtitle>
|
||||||
|
<span class="status" [class.published]="currentTutorial.published">
|
||||||
|
{{ currentTutorial.published ? 'Published' : 'Pending' }}
|
||||||
|
</span>
|
||||||
|
</mat-card-subtitle>
|
||||||
|
</mat-card-header>
|
||||||
|
|
||||||
<button
|
<mat-card-content>
|
||||||
class="badge badge-primary mr-2"
|
<mat-form-field appearance="outline" class="full-width">
|
||||||
*ngIf="currentTutorial.published"
|
<mat-label>Title</mat-label>
|
||||||
(click)="updatePublished(false)"
|
<input matInput name="title" required maxlength="255" [(ngModel)]="currentTutorial.title" />
|
||||||
>
|
<mat-error>Title is required</mat-error>
|
||||||
UnPublish
|
</mat-form-field>
|
||||||
</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()">
|
<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
|
Delete
|
||||||
</button>
|
</button>
|
||||||
|
<span class="spacer"></span>
|
||||||
<button
|
<button mat-stroked-button type="button" (click)="updatePublished(!currentTutorial.published)">
|
||||||
type="submit"
|
<mat-icon>{{ currentTutorial.published ? 'unpublished' : 'publish' }}</mat-icon>
|
||||||
class="badge badge-success mb-2"
|
{{ currentTutorial.published ? 'Unpublish' : 'Publish' }}
|
||||||
(click)="updateTutorial()"
|
|
||||||
>
|
|
||||||
Update
|
|
||||||
</button>
|
</button>
|
||||||
<p>{{ message }}</p>
|
<button mat-flat-button type="submit" [disabled]="tutorialForm.invalid">
|
||||||
</div>
|
<mat-icon>save</mat-icon>
|
||||||
|
Save
|
||||||
<div *ngIf="!currentTutorial.id">
|
</button>
|
||||||
<br />
|
</mat-card-actions>
|
||||||
<p>Cannot access this Tutorial...</p>
|
</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>
|
</div>
|
||||||
|
</mat-card>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
+13
-3
@@ -1,4 +1,14 @@
|
|||||||
.edit-form {
|
@use '../tutorial-shared';
|
||||||
max-width: 400px;
|
|
||||||
margin: auto;
|
mat-card-content {
|
||||||
|
padding-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 16px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spacer {
|
||||||
|
flex: 1 1 auto;
|
||||||
}
|
}
|
||||||
+42
-53
@@ -1,89 +1,78 @@
|
|||||||
import { Component, OnInit } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, inject, OnInit } from '@angular/core';
|
||||||
import { TutorialService } from 'src/app/services/tutorial.service';
|
import { HttpErrorResponse } from '@angular/common/http';
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
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 { Tutorial } from 'src/app/models/tutorial.model';
|
||||||
|
import { TutorialService } from 'src/app/services/tutorial.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-tutorial-details',
|
selector: 'app-tutorial-details',
|
||||||
templateUrl: './tutorial-details.component.html',
|
templateUrl: './tutorial-details.component.html',
|
||||||
styleUrls: ['./tutorial-details.component.scss'],
|
styleUrls: ['./tutorial-details.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.Eager,
|
||||||
standalone: false
|
standalone: false
|
||||||
})
|
})
|
||||||
export class TutorialDetailsComponent implements OnInit {
|
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 = {
|
currentTutorial: Tutorial = {
|
||||||
title: '',
|
title: '',
|
||||||
description: '',
|
description: '',
|
||||||
published: false
|
published: false
|
||||||
};
|
};
|
||||||
message = '';
|
notFound = false;
|
||||||
|
|
||||||
constructor(
|
|
||||||
private tutorialService: TutorialService,
|
|
||||||
private route: ActivatedRoute,
|
|
||||||
private router: Router) { }
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.message = '';
|
this.tutorialService.get(this.route.snapshot.params['id']).subscribe({
|
||||||
this.getTutorial(this.route.snapshot.params.id);
|
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 {
|
updatePublished(published: boolean): void {
|
||||||
const data = {
|
const data = { ...this.currentTutorial, published };
|
||||||
title: this.currentTutorial.title,
|
this.tutorialService.update(this.currentTutorial.id, data).subscribe({
|
||||||
description: this.currentTutorial.description,
|
next: () => {
|
||||||
published: status
|
this.currentTutorial.published = published;
|
||||||
};
|
this.notify(published ? 'Tutorial was published' : 'Tutorial was unpublished');
|
||||||
|
|
||||||
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 => {
|
error: err => this.showError(err)
|
||||||
console.log(error);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
updateTutorial(): void {
|
updateTutorial(): void {
|
||||||
this.message = '';
|
this.tutorialService.update(this.currentTutorial.id, this.currentTutorial).subscribe({
|
||||||
|
next: () => this.notify('Tutorial was saved'),
|
||||||
this.tutorialService.update(this.currentTutorial.id, this.currentTutorial)
|
error: err => this.showError(err)
|
||||||
.subscribe(
|
|
||||||
response => {
|
|
||||||
console.log(response);
|
|
||||||
this.message = response.message ? response.message : 'This tutorial was updated successfully!';
|
|
||||||
},
|
|
||||||
error => {
|
|
||||||
console.log(error);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteTutorial(): void {
|
deleteTutorial(): void {
|
||||||
this.tutorialService.delete(this.currentTutorial.id)
|
if (!confirm(`Delete "${this.currentTutorial.title}"?`)) {
|
||||||
.subscribe(
|
return;
|
||||||
response => {
|
}
|
||||||
console.log(response);
|
this.tutorialService.delete(this.currentTutorial.id).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.notify('Tutorial was deleted');
|
||||||
this.router.navigate(['/tutorials']);
|
this.router.navigate(['/tutorials']);
|
||||||
},
|
},
|
||||||
error => {
|
error: err => this.showError(err)
|
||||||
console.log(error);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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="page">
|
||||||
<div class="col-md-8">
|
<div class="page-header">
|
||||||
<div class="input-group mb-3">
|
<h1 class="page-title">Tutorials</h1>
|
||||||
<input
|
@if (isLoggedIn) {
|
||||||
type="text"
|
<a mat-flat-button routerLink="/add">
|
||||||
class="form-control"
|
<mat-icon>add</mat-icon>
|
||||||
placeholder="Search by title"
|
New tutorial
|
||||||
[(ngModel)]="title"
|
</a>
|
||||||
/>
|
}
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
<a class="badge badge-warning" routerLink="/tutorials/{{ currentTutorial.id }}">
|
<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
|
Edit
|
||||||
</a>
|
</a>
|
||||||
|
</mat-card-actions>
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
<div class="empty">
|
||||||
|
<mat-icon>touch_app</mat-icon>
|
||||||
|
<p>Select a tutorial to see the details</p>
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
<div *ngIf="!currentTutorial">
|
</mat-card>
|
||||||
<br />
|
|
||||||
<p>Please click on a Tutorial...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1,5 +1,22 @@
|
|||||||
.list {
|
@use '../tutorial-shared';
|
||||||
text-align: left;
|
|
||||||
max-width: 750px;
|
.search {
|
||||||
margin: auto;
|
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 { Tutorial } from 'src/app/models/tutorial.model';
|
||||||
|
import { TokenStorageService } from 'src/app/services/token-storage.service';
|
||||||
import { TutorialService } from 'src/app/services/tutorial.service';
|
import { TutorialService } from 'src/app/services/tutorial.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-tutorials-list',
|
selector: 'app-tutorials-list',
|
||||||
templateUrl: './tutorials-list.component.html',
|
templateUrl: './tutorials-list.component.html',
|
||||||
styleUrls: ['./tutorials-list.component.scss'],
|
styleUrls: ['./tutorials-list.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.Eager,
|
||||||
standalone: false
|
standalone: false
|
||||||
})
|
})
|
||||||
export class TutorialsListComponent implements OnInit {
|
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 = {};
|
currentTutorial: Tutorial = {};
|
||||||
currentIndex = -1;
|
currentIndex = -1;
|
||||||
title = '';
|
title = '';
|
||||||
|
isAdmin = false;
|
||||||
constructor(private tutorialService: TutorialService) { }
|
isLoggedIn = false;
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
|
this.isLoggedIn = this.tokenStorage.isLoggedIn();
|
||||||
|
this.isAdmin = (this.tokenStorage.getUser().roles ?? []).includes('ROLE_ADMIN');
|
||||||
this.retrieveTutorials();
|
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 {
|
setActiveTutorial(tutorial: Tutorial, index: number): void {
|
||||||
this.currentTutorial = tutorial;
|
this.currentTutorial = tutorial;
|
||||||
this.currentIndex = index;
|
this.currentIndex = index;
|
||||||
}
|
}
|
||||||
|
|
||||||
removeAllTutorials(): void {
|
searchTitle(): void {
|
||||||
this.tutorialService.deleteAll()
|
this.resetSelection();
|
||||||
.subscribe(
|
const request = this.title ? this.tutorialService.findByTitle(this.title) : this.tutorialService.getAll();
|
||||||
response => {
|
request.subscribe({
|
||||||
console.log(response);
|
// На пустой результат сервер отвечает 204 без тела
|
||||||
this.refreshList();
|
next: data => this.tutorials = data ?? [],
|
||||||
},
|
error: err => this.showError(err)
|
||||||
error => {
|
|
||||||
console.log(error);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
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.currentTutorial = {};
|
||||||
this.currentIndex = -1;
|
this.currentIndex = -1;
|
||||||
|
}
|
||||||
|
|
||||||
this.tutorialService.findByTitle(this.title)
|
private showError(err: HttpErrorResponse): void {
|
||||||
.subscribe(
|
this.snackBar.open(httpErrorMessage(err), 'OK', { duration: 5000 });
|
||||||
data => {
|
|
||||||
this.tutorials = data;
|
|
||||||
console.log(data);
|
|
||||||
},
|
|
||||||
error => {
|
|
||||||
console.log(error);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,3 +2,17 @@ export class Role {
|
|||||||
id?:any;
|
id?:any;
|
||||||
name?:string;
|
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 {
|
export class User {
|
||||||
id?:any;
|
id?:any;
|
||||||
username?:string;
|
username?:string;
|
||||||
email?:string;
|
email?:string;
|
||||||
password?:string;
|
roles?:Role[];
|
||||||
roles?:any;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
|
|
||||||
const TOKEN_KEY = 'auth-token';
|
|
||||||
const USER_KEY = 'auth-user';
|
const USER_KEY = 'auth-user';
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
@@ -9,19 +8,6 @@ const USER_KEY = 'auth-user';
|
|||||||
export class TokenStorageService {
|
export class TokenStorageService {
|
||||||
constructor() { }
|
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 {
|
public saveUser(user: any): void {
|
||||||
window.sessionStorage.removeItem(USER_KEY);
|
window.sessionStorage.removeItem(USER_KEY);
|
||||||
window.sessionStorage.setItem(USER_KEY, JSON.stringify(user));
|
window.sessionStorage.setItem(USER_KEY, JSON.stringify(user));
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ export class TutorialService {
|
|||||||
return this.http.delete(baseUrl);
|
return this.http.delete(baseUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getPublished(): Observable<Tutorial[]> {
|
||||||
|
return this.http.get<Tutorial[]>(`${baseUrl}/published`);
|
||||||
|
}
|
||||||
|
|
||||||
findByTitle(title: any): Observable<Tutorial[]> {
|
findByTitle(title: any): Observable<Tutorial[]> {
|
||||||
return this.http.get<Tutorial[]>(`${baseUrl}?title=${title}`);
|
return this.http.get<Tutorial[]>(`${baseUrl}?title=${title}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { Injectable } from '@angular/core';
|
|||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient } from '@angular/common/http';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import {User} from "../models/user.model";
|
import {User} from "../models/user.model";
|
||||||
import {Tutorial} from "../models/tutorial.model";
|
|
||||||
|
|
||||||
// Используем относительный путь вместо хардкода http://localhost:8080/api/users
|
// Используем относительный путь вместо хардкода http://localhost:8080/api/users
|
||||||
const API_URL = '/api/users';
|
const API_URL = '/api/users';
|
||||||
@@ -13,20 +12,11 @@ const API_URL = '/api/users';
|
|||||||
export class UserService {
|
export class UserService {
|
||||||
constructor(private http: HttpClient) { }
|
constructor(private http: HttpClient) { }
|
||||||
|
|
||||||
getPublicContent(): Observable<any> {
|
getAll(): Observable<User[]> {
|
||||||
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[]> {
|
|
||||||
return this.http.get<User[]>(API_URL);
|
return this.http.get<User[]>(API_URL);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
updateRoles(id: any, roles: string[]): Observable<User> {
|
||||||
|
return this.http.put<User>(`${API_URL}/${id}/roles`, { roles });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
integrity="sha384-Vkoo8x4CGsO3+Hhxv8T/Q5PaXtkKtu6ug5TOeNV6gBiFeWPGFN9MuhOf23Q9Ifjh"
|
integrity="sha384-Vkoo8x4CGsO3+Hhxv8T/Q5PaXtkKtu6ug5TOeNV6gBiFeWPGFN9MuhOf23Q9Ifjh"
|
||||||
crossorigin="anonymous"
|
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">
|
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { enableProdMode } from '@angular/core';
|
import { enableProdMode, provideZoneChangeDetection } from '@angular/core';
|
||||||
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
|
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
|
||||||
|
|
||||||
import { AppModule } from './app/app.module';
|
import { AppModule } from './app/app.module';
|
||||||
@@ -8,5 +8,5 @@ if (environment.production) {
|
|||||||
enableProdMode();
|
enableProdMode();
|
||||||
}
|
}
|
||||||
|
|
||||||
platformBrowserDynamic().bootstrapModule(AppModule)
|
platformBrowserDynamic().bootstrapModule(AppModule, { applicationProviders: [provideZoneChangeDetection()], })
|
||||||
.catch(err => console.error(err));
|
.catch(err => console.error(err));
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"/api": {
|
||||||
|
"target": "http://localhost:8080",
|
||||||
|
"secure": false,
|
||||||
|
"changeOrigin": true,
|
||||||
|
"logLevel": "debug",
|
||||||
|
"timeout": 0,
|
||||||
|
"ws": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ License: "MIT"
|
|||||||
|
|
||||||
The MIT License
|
The MIT License
|
||||||
|
|
||||||
Copyright (c) 2010-2025 Google LLC. https://angular.dev/license
|
Copyright (c) 2010-2026 Google LLC. https://angular.dev/license
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
@@ -31,7 +31,7 @@ License: "MIT"
|
|||||||
|
|
||||||
The MIT License
|
The MIT License
|
||||||
|
|
||||||
Copyright (c) 2010-2025 Google LLC. https://angular.dev/license
|
Copyright (c) 2010-2026 Google LLC. https://angular.dev/license
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
@@ -57,33 +57,7 @@ License: "MIT"
|
|||||||
|
|
||||||
The MIT License
|
The MIT License
|
||||||
|
|
||||||
Copyright (c) 2010-2025 Google LLC. https://angular.dev/license
|
Copyright (c) 2010-2026 Google LLC. https://angular.dev/license
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in
|
|
||||||
all copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
||||||
THE SOFTWARE.
|
|
||||||
|
|
||||||
--------------------------------------------------------------------------------
|
|
||||||
Package: @angular/forms
|
|
||||||
License: "MIT"
|
|
||||||
|
|
||||||
The MIT License
|
|
||||||
|
|
||||||
Copyright (c) 2010-2025 Google LLC. https://angular.dev/license
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
@@ -109,7 +83,7 @@ License: "MIT"
|
|||||||
|
|
||||||
The MIT License
|
The MIT License
|
||||||
|
|
||||||
Copyright (c) 2025 Google LLC.
|
Copyright (c) 2026 Google LLC.
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
@@ -135,7 +109,33 @@ License: "MIT"
|
|||||||
|
|
||||||
The MIT License
|
The MIT License
|
||||||
|
|
||||||
Copyright (c) 2025 Google LLC.
|
Copyright (c) 2026 Google LLC.
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in
|
||||||
|
all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
THE SOFTWARE.
|
||||||
|
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
Package: @angular/forms
|
||||||
|
License: "MIT"
|
||||||
|
|
||||||
|
The MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2010-2026 Google LLC. https://angular.dev/license
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
@@ -161,7 +161,7 @@ License: "MIT"
|
|||||||
|
|
||||||
The MIT License
|
The MIT License
|
||||||
|
|
||||||
Copyright (c) 2010-2025 Google LLC. https://angular.dev/license
|
Copyright (c) 2010-2026 Google LLC. https://angular.dev/license
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
@@ -187,7 +187,7 @@ License: "MIT"
|
|||||||
|
|
||||||
The MIT License
|
The MIT License
|
||||||
|
|
||||||
Copyright (c) 2010-2025 Google LLC. https://angular.dev/license
|
Copyright (c) 2010-2026 Google LLC. https://angular.dev/license
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|||||||
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 one or more lines are too long
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 one or more lines are too long
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 one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"routes": {}
|
|
||||||
}
|
|
||||||
@@ -11,5 +11,13 @@
|
|||||||
],
|
],
|
||||||
"include": [
|
"include": [
|
||||||
"src/**/*.d.ts"
|
"src/**/*.d.ts"
|
||||||
]
|
],
|
||||||
|
"angularCompilerOptions": {
|
||||||
|
"extendedDiagnostics": {
|
||||||
|
"checks": {
|
||||||
|
"nullishCoalescingNotNullable": "suppress",
|
||||||
|
"optionalChainNotNullable": "suppress"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
{
|
{
|
||||||
"compileOnSave": false,
|
"compileOnSave": false,
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"baseUrl": "./",
|
"paths": {
|
||||||
|
"src/*": ["./src/*"]
|
||||||
|
},
|
||||||
"outDir": "./dist/out-tsc",
|
"outDir": "./dist/out-tsc",
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
@@ -12,14 +14,10 @@
|
|||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"declaration": false,
|
"declaration": false,
|
||||||
"experimentalDecorators": true,
|
"experimentalDecorators": true,
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "bundler",
|
||||||
"importHelpers": true,
|
"importHelpers": true,
|
||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
"module": "es2022",
|
"module": "es2022"
|
||||||
"lib": [
|
|
||||||
"es2018",
|
|
||||||
"dom"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"angularCompilerOptions": {
|
"angularCompilerOptions": {
|
||||||
"enableI18nLegacyMessageIdFormat": false,
|
"enableI18nLegacyMessageIdFormat": false,
|
||||||
|
|||||||
@@ -14,5 +14,13 @@
|
|||||||
"include": [
|
"include": [
|
||||||
"src/**/*.spec.ts",
|
"src/**/*.spec.ts",
|
||||||
"src/**/*.d.ts"
|
"src/**/*.d.ts"
|
||||||
]
|
],
|
||||||
|
"angularCompilerOptions": {
|
||||||
|
"extendedDiagnostics": {
|
||||||
|
"checks": {
|
||||||
|
"nullishCoalescingNotNullable": "suppress",
|
||||||
|
"optionalChainNotNullable": "suppress"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,12 +33,6 @@ public class RoleDto {
|
|||||||
this.name = name;
|
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() {
|
public Role toEntity() {
|
||||||
Role role = new Role();
|
Role role = new Role();
|
||||||
role.setId(this.id);
|
role.setId(this.id);
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
package com.example.demo_monolith.DTOs;
|
package com.example.demo_monolith.DTOs;
|
||||||
|
|
||||||
import com.example.demo_monolith.model.User;
|
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||||
|
|
||||||
import java.util.Objects;
|
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
public class UserDto {
|
public class UserDto {
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
@@ -68,35 +65,4 @@ public class UserDto {
|
|||||||
public void setPassword(String password) {
|
public void setPassword(String password) {
|
||||||
this.password = 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.Set;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
//@CrossOrigin(origins = "*", maxAge = 3600)
|
@CrossOrigin(origins = "${app.origin}", maxAge = 3600, allowCredentials="true")
|
||||||
@CrossOrigin(origins = "http://localhost:4200", maxAge = 3600, allowCredentials="true")
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/auth")
|
@RequestMapping("/api/auth")
|
||||||
public class AuthController {
|
public class AuthController {
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ public class RolesController {
|
|||||||
try {
|
try {
|
||||||
List<Role> role = new ArrayList<Role>();
|
List<Role> role = new ArrayList<Role>();
|
||||||
role = roleRepository.findAll();
|
role = roleRepository.findAll();
|
||||||
//userRepository.findAll().forEach(users::add);
|
|
||||||
|
|
||||||
|
|
||||||
if (role.isEmpty()) {
|
if (role.isEmpty()) {
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
package com.example.demo_monolith.controllers;
|
|
||||||
|
|
||||||
|
|
||||||
import com.example.demo_monolith.model.Setting;
|
|
||||||
import com.example.demo_monolith.repository.SettingsRepository;
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
|
||||||
import org.springframework.http.HttpStatus;
|
|
||||||
import org.springframework.http.ResponseEntity;
|
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
@RestController
|
|
||||||
@RequestMapping("/api")
|
|
||||||
public class SettingsController {
|
|
||||||
@Autowired
|
|
||||||
private SettingsRepository settingsRepository;
|
|
||||||
|
|
||||||
@GetMapping("/settings")
|
|
||||||
public ResponseEntity<List<Setting>> getSettings(@RequestParam(required = false) String title) {
|
|
||||||
try {
|
|
||||||
List<Setting> settings = settingsRepository.findAll();
|
|
||||||
//userRepository.findAll().forEach(users::add);
|
|
||||||
|
|
||||||
|
|
||||||
if (settings.isEmpty()) {
|
|
||||||
return new ResponseEntity<>(HttpStatus.NO_CONTENT);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new ResponseEntity<>(settings, HttpStatus.OK);
|
|
||||||
} catch (Exception e) {
|
|
||||||
return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
package com.example.demo_monolith.controllers;
|
|
||||||
|
|
||||||
import org.springframework.security.access.prepost.PreAuthorize;
|
|
||||||
import org.springframework.web.bind.annotation.CrossOrigin;
|
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
|
||||||
|
|
||||||
@CrossOrigin(origins = "*", maxAge = 3600)
|
|
||||||
@RestController
|
|
||||||
@RequestMapping("/api/test")
|
|
||||||
public class TestController {
|
|
||||||
@GetMapping("/all")
|
|
||||||
public String allAccess() {
|
|
||||||
return "Public Content.";
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/user")
|
|
||||||
@PreAuthorize("hasRole('USER') or hasRole('MODERATOR') or hasRole('ADMIN')")
|
|
||||||
public String userAccess() {
|
|
||||||
return "User Content.";
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/mod")
|
|
||||||
@PreAuthorize("hasRole('MODERATOR')")
|
|
||||||
public String moderatorAccess() {
|
|
||||||
return "Moderator Board.";
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/admin")
|
|
||||||
@PreAuthorize("hasRole('ADMIN')")
|
|
||||||
public String adminAccess() {
|
|
||||||
return "Admin Board.";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -55,8 +55,11 @@ public class TutorialController {
|
|||||||
@PostMapping("/tutorials")
|
@PostMapping("/tutorials")
|
||||||
public ResponseEntity<Tutorial> createTutorial(@RequestBody Tutorial tutorial) {
|
public ResponseEntity<Tutorial> createTutorial(@RequestBody Tutorial tutorial) {
|
||||||
try {
|
try {
|
||||||
Tutorial _tutorial = tutorialRepository
|
Tutorial _tutorial = new Tutorial();
|
||||||
.save(new Tutorial(tutorial.getTitle(), tutorial.getDescription(), false));
|
_tutorial.setTitle(tutorial.getTitle());
|
||||||
|
_tutorial.setDescription(tutorial.getDescription());
|
||||||
|
_tutorial.setPublished(false);
|
||||||
|
_tutorial = tutorialRepository.save(_tutorial);
|
||||||
return new ResponseEntity<>(_tutorial, HttpStatus.CREATED);
|
return new ResponseEntity<>(_tutorial, HttpStatus.CREATED);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR);
|
return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR);
|
||||||
|
|||||||
@@ -1,15 +1,27 @@
|
|||||||
package com.example.demo_monolith.controllers;
|
package com.example.demo_monolith.controllers;
|
||||||
|
|
||||||
|
|
||||||
|
import com.example.demo_monolith.model.ERole;
|
||||||
|
import com.example.demo_monolith.model.Role;
|
||||||
import com.example.demo_monolith.model.User;
|
import com.example.demo_monolith.model.User;
|
||||||
|
import com.example.demo_monolith.payload.request.UpdateRolesRequest;
|
||||||
|
import com.example.demo_monolith.payload.response.MessageResponse;
|
||||||
|
import com.example.demo_monolith.repository.RoleRepository;
|
||||||
import com.example.demo_monolith.repository.UserRepository;
|
import com.example.demo_monolith.repository.UserRepository;
|
||||||
|
import com.example.demo_monolith.security.services.UserDetailsImpl;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@CrossOrigin(origins = "${app.origin}", maxAge = 3600, allowCredentials="true")
|
@CrossOrigin(origins = "${app.origin}", maxAge = 3600, allowCredentials="true")
|
||||||
@RestController
|
@RestController
|
||||||
@@ -18,12 +30,14 @@ public class UsersController {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private UserRepository userRepository;
|
private UserRepository userRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private RoleRepository roleRepository;
|
||||||
|
|
||||||
@GetMapping("/users")
|
@GetMapping("/users")
|
||||||
public ResponseEntity<List<User>> getUsers(@RequestParam(required = false) String title) {
|
public ResponseEntity<List<User>> getUsers(@RequestParam(required = false) String title) {
|
||||||
try {
|
try {
|
||||||
List<User> users = new ArrayList<User>();
|
List<User> users = new ArrayList<User>();
|
||||||
users = userRepository.findAll();
|
users = userRepository.findAll();
|
||||||
//userRepository.findAll().forEach(users::add);
|
|
||||||
|
|
||||||
|
|
||||||
if (users.isEmpty()) {
|
if (users.isEmpty()) {
|
||||||
@@ -35,4 +49,36 @@ public class UsersController {
|
|||||||
return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR);
|
return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PutMapping("/users/{id}/roles")
|
||||||
|
@Transactional
|
||||||
|
public ResponseEntity<?> updateRoles(@PathVariable("id") long id,
|
||||||
|
@Valid @RequestBody UpdateRolesRequest request,
|
||||||
|
@AuthenticationPrincipal UserDetailsImpl currentUser) {
|
||||||
|
Optional<User> userData = userRepository.findById(id);
|
||||||
|
if (userData.isEmpty()) {
|
||||||
|
return new ResponseEntity<>(new MessageResponse("User not found"), HttpStatus.NOT_FOUND);
|
||||||
|
}
|
||||||
|
User user = userData.get();
|
||||||
|
|
||||||
|
boolean isAdmin = user.getRoles().stream().anyMatch(role -> role.getName() == ERole.ROLE_ADMIN);
|
||||||
|
boolean staysAdmin = request.getRoles().contains(ERole.ROLE_ADMIN);
|
||||||
|
if (isAdmin && !staysAdmin) {
|
||||||
|
// Иначе администратор может случайно запереть себя или всех снаружи админки
|
||||||
|
if (user.getId().equals(currentUser.getId())) {
|
||||||
|
return ResponseEntity.badRequest().body(new MessageResponse("You cannot remove the admin role from yourself"));
|
||||||
|
}
|
||||||
|
if (userRepository.countByRolesName(ERole.ROLE_ADMIN) <= 1) {
|
||||||
|
return ResponseEntity.badRequest().body(new MessageResponse("At least one admin must remain"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<Role> roles = request.getRoles().stream()
|
||||||
|
.map(name -> roleRepository.findByName(name)
|
||||||
|
.orElseThrow(() -> new RuntimeException("Error: Role is not found.")))
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
user.setRoles(roles);
|
||||||
|
|
||||||
|
return ResponseEntity.ok(userRepository.save(user));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import org.springframework.beans.factory.annotation.Autowired;
|
|||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
//@ConditionalOnProperty(name = "app.init.data.enabled", havingValue = "true", matchIfMissing = true)
|
|
||||||
public class InitDataConfiguration {
|
public class InitDataConfiguration {
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import org.slf4j.LoggerFactory;
|
|||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.context.annotation.Primary;
|
import org.springframework.context.annotation.Primary;
|
||||||
import org.springframework.core.io.ClassPathResource;
|
import org.springframework.core.io.ClassPathResource;
|
||||||
import org.springframework.core.io.ResourceLoader;
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
@@ -36,8 +35,6 @@ public class InitDataService {
|
|||||||
|
|
||||||
protected ObjectMapper _mapper;
|
protected ObjectMapper _mapper;
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private ResourceLoader _resources;
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private RoleRepository _roleRepository;
|
private RoleRepository _roleRepository;
|
||||||
|
|
||||||
@@ -76,13 +73,13 @@ public class InitDataService {
|
|||||||
Path targetPath = _initDataPath.resolve(fileName);
|
Path targetPath = _initDataPath.resolve(fileName);
|
||||||
if (!Files.exists(targetPath)) {
|
if (!Files.exists(targetPath)) {
|
||||||
try (InputStream inputStream = resource.getInputStream()) {
|
try (InputStream inputStream = resource.getInputStream()) {
|
||||||
Files.copy(resource.getInputStream(), targetPath);
|
Files.copy(inputStream, targetPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.info("Copied {} to {}", fileName, targetPath.toAbsolutePath());
|
_logger.info("Copied {} to {}", fileName, targetPath.toAbsolutePath());
|
||||||
} else {
|
} else {
|
||||||
try (InputStream inputStream = resource.getInputStream()) {
|
try (InputStream inputStream = resource.getInputStream()) {
|
||||||
Files.copy(resource.getInputStream(), targetPath, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
|
Files.copy(inputStream, targetPath, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.warn("{} already exists, REPLACE", targetPath.toAbsolutePath());
|
_logger.warn("{} already exists, REPLACE", targetPath.toAbsolutePath());
|
||||||
@@ -99,9 +96,6 @@ public class InitDataService {
|
|||||||
List<RoleDto> items = readList(jsonFile, new TypeReference<List<RoleDto>>() {});
|
List<RoleDto> items = readList(jsonFile, new TypeReference<List<RoleDto>>() {});
|
||||||
for (RoleDto dto : items) {
|
for (RoleDto dto : items) {
|
||||||
Role roleEntity = dto.toEntity();
|
Role roleEntity = dto.toEntity();
|
||||||
// If a role with same name exists, reuse its ID to upsert
|
|
||||||
// _roleRepository.findByName(entity.getName())
|
|
||||||
// .ifPresent(existing -> entity.setId(existing.getId()));
|
|
||||||
|
|
||||||
if(!_roleRepository.existsByName(roleEntity.getName())) {
|
if(!_roleRepository.existsByName(roleEntity.getName())) {
|
||||||
_roleRepository.save(roleEntity);
|
_roleRepository.save(roleEntity);
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
package com.example.demo_monolith.model;
|
|
||||||
|
|
||||||
|
|
||||||
import jakarta.persistence.*;
|
|
||||||
|
|
||||||
@Entity
|
|
||||||
@Table(name = "settings")
|
|
||||||
public class Setting {
|
|
||||||
|
|
||||||
@Id
|
|
||||||
@GeneratedValue(strategy = GenerationType.AUTO)
|
|
||||||
private long id;
|
|
||||||
|
|
||||||
@Column(name = "useremaileUniq")
|
|
||||||
private Boolean useremaileUniq;
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,7 @@ import jakarta.persistence.*;
|
|||||||
public class Tutorial {
|
public class Tutorial {
|
||||||
|
|
||||||
@Id
|
@Id
|
||||||
@GeneratedValue(strategy = GenerationType.AUTO)
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
private long id;
|
private long id;
|
||||||
|
|
||||||
@Column(name = "title")
|
@Column(name = "title")
|
||||||
@@ -21,16 +21,12 @@ public class Tutorial {
|
|||||||
@Column(name = "published")
|
@Column(name = "published")
|
||||||
private boolean published;
|
private boolean published;
|
||||||
|
|
||||||
|
// Единственный конструктор: иначе Jackson 3 выбирает конструктор с параметрами
|
||||||
|
// и падает на запросах без поля published ("Cannot map `null` into type `boolean`").
|
||||||
public Tutorial() {
|
public Tutorial() {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public Tutorial(String title, String description, boolean published) {
|
|
||||||
this.title = title;
|
|
||||||
this.description = description;
|
|
||||||
this.published = published;
|
|
||||||
}
|
|
||||||
|
|
||||||
public long getId() {
|
public long getId() {
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.example.demo_monolith.model;
|
package com.example.demo_monolith.model;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
import jakarta.validation.constraints.Email;
|
import jakarta.validation.constraints.Email;
|
||||||
import jakarta.validation.constraints.NotBlank;
|
import jakarta.validation.constraints.NotBlank;
|
||||||
@@ -18,7 +19,7 @@ import java.util.Set;
|
|||||||
})
|
})
|
||||||
public class User {
|
public class User {
|
||||||
@Id
|
@Id
|
||||||
@GeneratedValue(strategy = GenerationType.AUTO)
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
private long id;
|
private long id;
|
||||||
|
|
||||||
@Column(name = "username")
|
@Column(name = "username")
|
||||||
@@ -31,6 +32,7 @@ public class User {
|
|||||||
@Email
|
@Email
|
||||||
private String email;
|
private String email;
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
@NotBlank
|
@NotBlank
|
||||||
@Size(max = 120)
|
@Size(max = 120)
|
||||||
private String password;
|
private String password;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user