Compare commits
17 Commits
feature/e2
...
f1f2fefdab
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1f2fefdab | ||
|
|
d11b0c65e0 | ||
|
|
96af8e564b | ||
|
|
42158d1fd4 | ||
|
|
f4c0bb0b76 | ||
|
|
5ab072992b | ||
|
|
cbede30d65 | ||
|
|
19ca0c086c | ||
|
|
e09110346e | ||
|
|
327c641137 | ||
|
|
08d4d0bccc | ||
|
|
efee1a0239 | ||
|
|
d99c410876 | ||
|
|
532299c864 | ||
|
|
ea74d34363 | ||
|
|
4b025b9ec7 | ||
|
|
5c37de40c6 |
6
.env.e2e
6
.env.e2e
@@ -1,6 +0,0 @@
|
|||||||
DATABASE_USER=test
|
|
||||||
DATABASE_PASS=test
|
|
||||||
DATABASE_HOST=localhost
|
|
||||||
DATABASE_NAME=test
|
|
||||||
DATABASE_PORT=4401
|
|
||||||
JWT_SECRET="secret"
|
|
||||||
12
.gitignore
vendored
12
.gitignore
vendored
@@ -1,7 +1,7 @@
|
|||||||
# compiled output
|
# compiled output
|
||||||
/dist
|
dist
|
||||||
/node_modules
|
node_modules
|
||||||
/build
|
build
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
logs
|
logs
|
||||||
@@ -16,11 +16,11 @@ lerna-debug.log*
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
# Tests
|
# Tests
|
||||||
/coverage
|
coverage
|
||||||
/.nyc_output
|
.nyc_output
|
||||||
|
|
||||||
# IDEs and editors
|
# IDEs and editors
|
||||||
/.idea
|
.idea
|
||||||
.project
|
.project
|
||||||
.classpath
|
.classpath
|
||||||
.c9/
|
.c9/
|
||||||
|
|||||||
17
admin/.editorconfig
Normal file
17
admin/.editorconfig
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Editor configuration, see https://editorconfig.org
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[*.ts]
|
||||||
|
quote_type = single
|
||||||
|
ij_typescript_use_double_quotes = false
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
max_line_length = off
|
||||||
|
trim_trailing_whitespace = false
|
||||||
43
admin/.gitignore
vendored
Normal file
43
admin/.gitignore
vendored
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
|
||||||
|
|
||||||
|
# Compiled output
|
||||||
|
/dist
|
||||||
|
/tmp
|
||||||
|
/out-tsc
|
||||||
|
/bazel-out
|
||||||
|
|
||||||
|
# Node
|
||||||
|
/node_modules
|
||||||
|
npm-debug.log
|
||||||
|
yarn-error.log
|
||||||
|
|
||||||
|
# IDEs and editors
|
||||||
|
.idea/
|
||||||
|
.project
|
||||||
|
.classpath
|
||||||
|
.c9/
|
||||||
|
*.launch
|
||||||
|
.settings/
|
||||||
|
*.sublime-workspace
|
||||||
|
|
||||||
|
# Visual Studio Code
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/settings.json
|
||||||
|
!.vscode/tasks.json
|
||||||
|
!.vscode/launch.json
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.history/*
|
||||||
|
|
||||||
|
# Miscellaneous
|
||||||
|
/.angular/cache
|
||||||
|
.sass-cache/
|
||||||
|
/connect.lock
|
||||||
|
/coverage
|
||||||
|
/libpeerconnection.log
|
||||||
|
testem.log
|
||||||
|
/typings
|
||||||
|
__screenshots__/
|
||||||
|
|
||||||
|
# System files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
5
admin/.postcssrc.json
Normal file
5
admin/.postcssrc.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"plugins": {
|
||||||
|
"@tailwindcss/postcss": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
4
admin/.vscode/extensions.json
vendored
Normal file
4
admin/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
|
||||||
|
"recommendations": ["angular.ng-template"]
|
||||||
|
}
|
||||||
20
admin/.vscode/launch.json
vendored
Normal file
20
admin/.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "ng serve",
|
||||||
|
"type": "chrome",
|
||||||
|
"request": "launch",
|
||||||
|
"preLaunchTask": "npm: start",
|
||||||
|
"url": "http://localhost:4200/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ng test",
|
||||||
|
"type": "chrome",
|
||||||
|
"request": "launch",
|
||||||
|
"preLaunchTask": "npm: test",
|
||||||
|
"url": "http://localhost:9876/debug.html"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
42
admin/.vscode/tasks.json
vendored
Normal file
42
admin/.vscode/tasks.json
vendored
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
|
||||||
|
"version": "2.0.0",
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"type": "npm",
|
||||||
|
"script": "start",
|
||||||
|
"isBackground": true,
|
||||||
|
"problemMatcher": {
|
||||||
|
"owner": "typescript",
|
||||||
|
"pattern": "$tsc",
|
||||||
|
"background": {
|
||||||
|
"activeOnStart": true,
|
||||||
|
"beginsPattern": {
|
||||||
|
"regexp": "(.*?)"
|
||||||
|
},
|
||||||
|
"endsPattern": {
|
||||||
|
"regexp": "bundle generation complete"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "npm",
|
||||||
|
"script": "test",
|
||||||
|
"isBackground": true,
|
||||||
|
"problemMatcher": {
|
||||||
|
"owner": "typescript",
|
||||||
|
"pattern": "$tsc",
|
||||||
|
"background": {
|
||||||
|
"activeOnStart": true,
|
||||||
|
"beginsPattern": {
|
||||||
|
"regexp": "(.*?)"
|
||||||
|
},
|
||||||
|
"endsPattern": {
|
||||||
|
"regexp": "bundle generation complete"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
59
admin/README.md
Normal file
59
admin/README.md
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
# Admin
|
||||||
|
|
||||||
|
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 20.3.9.
|
||||||
|
|
||||||
|
## Development server
|
||||||
|
|
||||||
|
To start a local development server, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ng serve
|
||||||
|
```
|
||||||
|
|
||||||
|
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
|
||||||
|
|
||||||
|
## Code scaffolding
|
||||||
|
|
||||||
|
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ng generate component component-name
|
||||||
|
```
|
||||||
|
|
||||||
|
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ng generate --help
|
||||||
|
```
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
To build the project run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ng build
|
||||||
|
```
|
||||||
|
|
||||||
|
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
|
||||||
|
|
||||||
|
## Running unit tests
|
||||||
|
|
||||||
|
To execute unit tests with the [Karma](https://karma-runner.github.io) test runner, use the following command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ng test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running end-to-end tests
|
||||||
|
|
||||||
|
For end-to-end (e2e) testing, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ng e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
|
||||||
|
|
||||||
|
## Additional Resources
|
||||||
|
|
||||||
|
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.
|
||||||
85
admin/angular.json
Normal file
85
admin/angular.json
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||||
|
"version": 1,
|
||||||
|
"newProjectRoot": "projects",
|
||||||
|
"projects": {
|
||||||
|
"admin": {
|
||||||
|
"projectType": "application",
|
||||||
|
"schematics": {},
|
||||||
|
"root": "",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"prefix": "app",
|
||||||
|
"architect": {
|
||||||
|
"build": {
|
||||||
|
"builder": "@angular/build:application",
|
||||||
|
"options": {
|
||||||
|
"browser": "src/main.ts",
|
||||||
|
"tsConfig": "tsconfig.app.json",
|
||||||
|
"assets": [
|
||||||
|
{
|
||||||
|
"glob": "**/*",
|
||||||
|
"input": "public"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"styles": [
|
||||||
|
"src/styles.css"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"configurations": {
|
||||||
|
"production": {
|
||||||
|
"budgets": [
|
||||||
|
{
|
||||||
|
"type": "initial",
|
||||||
|
"maximumWarning": "500kB",
|
||||||
|
"maximumError": "1MB"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "anyComponentStyle",
|
||||||
|
"maximumWarning": "4kB",
|
||||||
|
"maximumError": "8kB"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputHashing": "all"
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"optimization": false,
|
||||||
|
"extractLicenses": false,
|
||||||
|
"sourceMap": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"defaultConfiguration": "production"
|
||||||
|
},
|
||||||
|
"serve": {
|
||||||
|
"builder": "@angular/build:dev-server",
|
||||||
|
"configurations": {
|
||||||
|
"production": {
|
||||||
|
"buildTarget": "admin:build:production"
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"buildTarget": "admin:build:development"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"defaultConfiguration": "development"
|
||||||
|
},
|
||||||
|
"extract-i18n": {
|
||||||
|
"builder": "@angular/build:extract-i18n"
|
||||||
|
},
|
||||||
|
"test": {
|
||||||
|
"builder": "@angular/build:karma",
|
||||||
|
"options": {
|
||||||
|
"tsConfig": "tsconfig.spec.json",
|
||||||
|
"assets": [
|
||||||
|
{
|
||||||
|
"glob": "**/*",
|
||||||
|
"input": "public"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"styles": [
|
||||||
|
"src/styles.css"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10231
admin/package-lock.json
generated
Normal file
10231
admin/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
51
admin/package.json
Normal file
51
admin/package.json
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
{
|
||||||
|
"name": "admin",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"scripts": {
|
||||||
|
"ng": "ng",
|
||||||
|
"start": "ng serve --proxy-config proxy.conf.json",
|
||||||
|
"build": "ng build",
|
||||||
|
"watch": "ng build --watch --configuration development",
|
||||||
|
"test": "ng test"
|
||||||
|
},
|
||||||
|
"prettier": {
|
||||||
|
"printWidth": 100,
|
||||||
|
"singleQuote": true,
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": "*.html",
|
||||||
|
"options": {
|
||||||
|
"parser": "angular"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"private": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@angular/common": "^20.3.0",
|
||||||
|
"@angular/compiler": "^20.3.0",
|
||||||
|
"@angular/core": "^20.3.0",
|
||||||
|
"@angular/forms": "^20.3.0",
|
||||||
|
"@angular/platform-browser": "^20.3.0",
|
||||||
|
"@angular/router": "^20.3.0",
|
||||||
|
"@tailwindcss/postcss": "^4.1.17",
|
||||||
|
"daisyui": "^5.4.5",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
|
"rxjs": "~7.8.0",
|
||||||
|
"tailwindcss": "^4.1.17",
|
||||||
|
"tslib": "^2.3.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@angular/build": "^20.3.9",
|
||||||
|
"@angular/cli": "^20.3.9",
|
||||||
|
"@angular/compiler-cli": "^20.3.0",
|
||||||
|
"@types/jasmine": "~5.1.0",
|
||||||
|
"jasmine-core": "~5.9.0",
|
||||||
|
"karma": "~6.4.0",
|
||||||
|
"karma-chrome-launcher": "~3.2.0",
|
||||||
|
"karma-coverage": "~2.2.0",
|
||||||
|
"karma-jasmine": "~5.1.0",
|
||||||
|
"karma-jasmine-html-reporter": "~2.1.0",
|
||||||
|
"typescript": "~5.9.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
8
admin/proxy.conf.json
Normal file
8
admin/proxy.conf.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"/api": {
|
||||||
|
"target": "http://localhost:3000",
|
||||||
|
"secure": false,
|
||||||
|
"logLevel": "debug",
|
||||||
|
"changeOrigin": true
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
admin/public/favicon.ico
Normal file
BIN
admin/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
23
admin/src/app/app.config.ts
Normal file
23
admin/src/app/app.config.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import {
|
||||||
|
ApplicationConfig, provideBrowserGlobalErrorListeners, provideZoneChangeDetection,
|
||||||
|
provideZonelessChangeDetection,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { provideRouter } from '@angular/router';
|
||||||
|
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
|
||||||
|
import { HTTP_INTERCEPTORS } from '@angular/common/http';
|
||||||
|
import { routes } from './app.routes';
|
||||||
|
import { JwtInterceptor } from './auth/jwt.interceptor';
|
||||||
|
import { AuthService } from './auth/auth.service';
|
||||||
|
import { AuthGuard } from './auth/auth.guard';
|
||||||
|
|
||||||
|
export const appConfig: ApplicationConfig = {
|
||||||
|
providers: [
|
||||||
|
provideBrowserGlobalErrorListeners(),
|
||||||
|
provideZonelessChangeDetection(),
|
||||||
|
provideRouter(routes),
|
||||||
|
provideHttpClient(withInterceptorsFromDi()),
|
||||||
|
AuthService,
|
||||||
|
AuthGuard,
|
||||||
|
{ provide: HTTP_INTERCEPTORS, useClass: JwtInterceptor, multi: true },
|
||||||
|
],
|
||||||
|
};
|
||||||
0
admin/src/app/app.css
Normal file
0
admin/src/app/app.css
Normal file
5
admin/src/app/app.html
Normal file
5
admin/src/app/app.html
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<div>
|
||||||
|
<button (click)="logout()">Logout</button>
|
||||||
|
</div>
|
||||||
|
<app-main-menu></app-main-menu>
|
||||||
|
<router-outlet />
|
||||||
10
admin/src/app/app.routes.ts
Normal file
10
admin/src/app/app.routes.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Routes } from '@angular/router';
|
||||||
|
import { LoginComponent } from './components/login/login.component';
|
||||||
|
import { AuthGuard } from './auth/auth.guard';
|
||||||
|
import { HomeComponent } from './components/home/home.component'; // Assuming you have a HomeComponent
|
||||||
|
|
||||||
|
export const routes: Routes = [
|
||||||
|
{ path: 'login', component: LoginComponent },
|
||||||
|
{ path: '', component: HomeComponent, canActivate: [AuthGuard] },
|
||||||
|
{ path: '**', redirectTo: '' } // Redirect to home for any other route
|
||||||
|
];
|
||||||
25
admin/src/app/app.spec.ts
Normal file
25
admin/src/app/app.spec.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { provideZonelessChangeDetection } from '@angular/core';
|
||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { App } from './app';
|
||||||
|
|
||||||
|
describe('App', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [App],
|
||||||
|
providers: [provideZonelessChangeDetection()]
|
||||||
|
}).compileComponents();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create the app', () => {
|
||||||
|
const fixture = TestBed.createComponent(App);
|
||||||
|
const app = fixture.componentInstance;
|
||||||
|
expect(app).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render title', () => {
|
||||||
|
const fixture = TestBed.createComponent(App);
|
||||||
|
fixture.detectChanges();
|
||||||
|
const compiled = fixture.nativeElement as HTMLElement;
|
||||||
|
expect(compiled.querySelector('h1')?.textContent).toContain('Hello, admin');
|
||||||
|
});
|
||||||
|
});
|
||||||
33
admin/src/app/app.ts
Normal file
33
admin/src/app/app.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { Component, signal } from '@angular/core';
|
||||||
|
import { Router, RouterOutlet } from '@angular/router';
|
||||||
|
import { MainMenu } from './components/main-menu/main-menu';
|
||||||
|
import { AuthService } from './auth/auth.service';
|
||||||
|
import { AdminLayout } from './layout/admin-layout/admin-layout';
|
||||||
|
import { finalize } from 'rxjs/operators';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-root',
|
||||||
|
imports: [RouterOutlet, AdminLayout,MainMenu],
|
||||||
|
templateUrl: './app.html',
|
||||||
|
styleUrl: './app.css'
|
||||||
|
})
|
||||||
|
export class App {
|
||||||
|
protected readonly title = signal('admin');
|
||||||
|
|
||||||
|
constructor(private authService: AuthService, private router: Router) {}
|
||||||
|
|
||||||
|
logout(): void {
|
||||||
|
// With the interceptor fixed, this is now the correct and robust way.
|
||||||
|
// The error from a failed server logout will propagate here.
|
||||||
|
this.authService.serverSideLogout().subscribe({
|
||||||
|
next: () => {
|
||||||
|
console.log('Server-side logout successful.');
|
||||||
|
this.authService.clientSideLogout();
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
console.error('Server-side logout failed, logging out client-side anyway.', err);
|
||||||
|
this.authService.clientSideLogout();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
23
admin/src/app/auth/auth.guard.ts
Normal file
23
admin/src/app/auth/auth.guard.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { CanActivate, Router, ActivatedRouteSnapshot, RouterStateSnapshot, UrlTree } from '@angular/router';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root',
|
||||||
|
})
|
||||||
|
export class AuthGuard implements CanActivate {
|
||||||
|
constructor(private authService: AuthService, private router: Router) {}
|
||||||
|
|
||||||
|
canActivate(
|
||||||
|
route: ActivatedRouteSnapshot,
|
||||||
|
state: RouterStateSnapshot
|
||||||
|
): Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree {
|
||||||
|
if (this.authService.isLoggedIn()) {
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
// Redirect to the login page
|
||||||
|
return this.router.createUrlTree(['/login']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
76
admin/src/app/auth/auth.service.ts
Normal file
76
admin/src/app/auth/auth.service.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { Observable, of, throwError } from 'rxjs';
|
||||||
|
import { tap } from 'rxjs/operators';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root',
|
||||||
|
})
|
||||||
|
export class AuthService {
|
||||||
|
private readonly ACCESS_TOKEN_KEY = 'accessToken';
|
||||||
|
private readonly REFRESH_TOKEN_KEY = 'refreshToken';
|
||||||
|
private apiUrl = 'http://localhost:4200/api/auth'; // Adjust if your server URL is different
|
||||||
|
|
||||||
|
constructor(private http: HttpClient, private router: Router) {}
|
||||||
|
|
||||||
|
login(credentials: { username: string; password: string }): Observable<any> {
|
||||||
|
return this.http.post<{ accessToken: string; refreshToken: string }>(`${this.apiUrl}/login`, credentials).pipe(
|
||||||
|
tap((response) => this.setTokens(response.accessToken, response.refreshToken))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Makes a best-effort call to the server to invalidate the refresh token.
|
||||||
|
*/
|
||||||
|
serverSideLogout(): Observable<any> {
|
||||||
|
return this.http.post(`${this.apiUrl}/logout`, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Performs the client-side cleanup, removing tokens and redirecting to login.
|
||||||
|
* This is the definitive logout action from the user's perspective.
|
||||||
|
*/
|
||||||
|
clientSideLogout(): void {
|
||||||
|
console.info("clientSideLogout")
|
||||||
|
this.removeTokens();
|
||||||
|
this.router.navigate(['/login']);
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshToken(): Observable<any> {
|
||||||
|
const refreshToken = this.getRefreshToken();
|
||||||
|
if (!refreshToken) {
|
||||||
|
// If no refresh token is present, logout and return an error.
|
||||||
|
this.clientSideLogout();
|
||||||
|
return throwError(() => new Error('No refresh token available'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.http.post<{ accessToken: string; refreshToken: string }>(`${this.apiUrl}/refresh`, {}, {
|
||||||
|
headers: { Authorization: `Bearer ${refreshToken}` }
|
||||||
|
}).pipe(
|
||||||
|
tap((response) => this.setTokens(response.accessToken, response.refreshToken))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getAccessToken(): string | null {
|
||||||
|
return localStorage.getItem(this.ACCESS_TOKEN_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
getRefreshToken(): string | null {
|
||||||
|
return localStorage.getItem(this.REFRESH_TOKEN_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoggedIn(): boolean {
|
||||||
|
return this.getAccessToken() !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private setTokens(accessToken: string, refreshToken: string): void {
|
||||||
|
localStorage.setItem(this.ACCESS_TOKEN_KEY, accessToken);
|
||||||
|
localStorage.setItem(this.REFRESH_TOKEN_KEY, refreshToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private removeTokens(): void {
|
||||||
|
localStorage.removeItem(this.ACCESS_TOKEN_KEY);
|
||||||
|
localStorage.removeItem(this.REFRESH_TOKEN_KEY);
|
||||||
|
}
|
||||||
|
}
|
||||||
84
admin/src/app/auth/jwt.interceptor.ts
Normal file
84
admin/src/app/auth/jwt.interceptor.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import {
|
||||||
|
HttpEvent,
|
||||||
|
HttpHandler,
|
||||||
|
HttpInterceptor,
|
||||||
|
HttpRequest,
|
||||||
|
HttpErrorResponse,
|
||||||
|
} from '@angular/common/http';
|
||||||
|
import { Observable, throwError, BehaviorSubject } from 'rxjs';
|
||||||
|
import { catchError, switchMap, filter, take, finalize } from 'rxjs/operators'; // Import finalize
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class JwtInterceptor implements HttpInterceptor {
|
||||||
|
private isRefreshing = false;
|
||||||
|
// Initialize refreshTokenSubject with null
|
||||||
|
private refreshTokenSubject: BehaviorSubject<any> = new BehaviorSubject<any>(null);
|
||||||
|
|
||||||
|
constructor(private authService: AuthService) {}
|
||||||
|
|
||||||
|
intercept(
|
||||||
|
request: HttpRequest<any>,
|
||||||
|
next: HttpHandler
|
||||||
|
): Observable<HttpEvent<any>> {
|
||||||
|
if (request.url.includes('/auth/refresh')) {
|
||||||
|
return next.handle(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessToken = this.authService.getAccessToken();
|
||||||
|
if (accessToken) {
|
||||||
|
request = this.addToken(request, accessToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
return next.handle(request).pipe(
|
||||||
|
catchError((error) => {
|
||||||
|
if (error instanceof HttpErrorResponse && error.status === 401) {
|
||||||
|
return this.handle401Error(request, next);
|
||||||
|
}
|
||||||
|
return throwError(() => error);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private handle401Error(request: HttpRequest<any>, next: HttpHandler): Observable<any> {
|
||||||
|
if (!this.isRefreshing) {
|
||||||
|
this.isRefreshing = true;
|
||||||
|
// Reset the refreshTokenSubject to null so that subsequent requests will wait
|
||||||
|
// this.refreshTokenSubject.next(null);
|
||||||
|
this.refreshTokenSubject = new BehaviorSubject<any>(null);
|
||||||
|
|
||||||
|
|
||||||
|
return this.authService.refreshToken().pipe(
|
||||||
|
switchMap((token: any) => {
|
||||||
|
this.refreshTokenSubject.next(token.accessToken);
|
||||||
|
return next.handle(this.addToken(request, token.accessToken));
|
||||||
|
}),
|
||||||
|
catchError((err) => {
|
||||||
|
// If refresh fails, logout the user
|
||||||
|
this.authService.clientSideLogout();
|
||||||
|
return throwError(() => err);
|
||||||
|
}),
|
||||||
|
finalize(() => {
|
||||||
|
// When the refresh attempt completes, set isRefreshing to false
|
||||||
|
this.isRefreshing = false;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// If a refresh is already in progress, wait for it to complete
|
||||||
|
return this.refreshTokenSubject.pipe(
|
||||||
|
filter(token => token != null),
|
||||||
|
take(1),
|
||||||
|
switchMap(jwt => next.handle(this.addToken(request, jwt)))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private addToken(request: HttpRequest<any>, token: string) {
|
||||||
|
return request.clone({
|
||||||
|
setHeaders: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
0
admin/src/app/components/footer/footer.css
Normal file
0
admin/src/app/components/footer/footer.css
Normal file
22
admin/src/app/components/footer/footer.html
Normal file
22
admin/src/app/components/footer/footer.html
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<footer class="footer sm:footer-horizontal bg-neutral text-neutral-content p-10">
|
||||||
|
<nav>
|
||||||
|
<h6 class="footer-title">Services</h6>
|
||||||
|
<a class="link link-hover">Branding</a>
|
||||||
|
<a class="link link-hover">Design</a>
|
||||||
|
<a class="link link-hover">Marketing</a>
|
||||||
|
<a class="link link-hover">Advertisement</a>
|
||||||
|
</nav>
|
||||||
|
<nav>
|
||||||
|
<h6 class="footer-title">Company</h6>
|
||||||
|
<a class="link link-hover">About us</a>
|
||||||
|
<a class="link link-hover">Contact</a>
|
||||||
|
<a class="link link-hover">Jobs</a>
|
||||||
|
<a class="link link-hover">Press kit</a>
|
||||||
|
</nav>
|
||||||
|
<nav>
|
||||||
|
<h6 class="footer-title">Legal</h6>
|
||||||
|
<a class="link link-hover">Terms of use</a>
|
||||||
|
<a class="link link-hover">Privacy policy</a>
|
||||||
|
<a class="link link-hover">Cookie policy</a>
|
||||||
|
</nav>
|
||||||
|
</footer>
|
||||||
23
admin/src/app/components/footer/footer.spec.ts
Normal file
23
admin/src/app/components/footer/footer.spec.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { Footer } from './footer';
|
||||||
|
|
||||||
|
describe('Footer', () => {
|
||||||
|
let component: Footer;
|
||||||
|
let fixture: ComponentFixture<Footer>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [Footer]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(Footer);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
11
admin/src/app/components/footer/footer.ts
Normal file
11
admin/src/app/components/footer/footer.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-footer',
|
||||||
|
imports: [],
|
||||||
|
templateUrl: './footer.html',
|
||||||
|
styleUrl: './footer.css',
|
||||||
|
})
|
||||||
|
export class Footer {
|
||||||
|
|
||||||
|
}
|
||||||
8
admin/src/app/components/home/home.component.ts
Normal file
8
admin/src/app/components/home/home.component.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-home',
|
||||||
|
template: '<h1>Welcome to the Admin Panel!</h1>',
|
||||||
|
standalone: true,
|
||||||
|
})
|
||||||
|
export class HomeComponent {}
|
||||||
15
admin/src/app/components/login/login.component.html
Normal file
15
admin/src/app/components/login/login.component.html
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<div>
|
||||||
|
<h2>Login</h2>
|
||||||
|
<form [formGroup]="loginForm" (ngSubmit)="onSubmit()">
|
||||||
|
<div>
|
||||||
|
<label for="username">Username:</label>
|
||||||
|
<input id="username" formControlName="username" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="password">Password:</label>
|
||||||
|
<input id="password" type="password" formControlName="password" />
|
||||||
|
</div>
|
||||||
|
<button type="submit" [disabled]="loginForm.invalid">Log In</button>
|
||||||
|
</form>
|
||||||
|
<p *ngIf="errorMessage">{{ errorMessage }}</p>
|
||||||
|
</div>
|
||||||
36
admin/src/app/components/login/login.component.ts
Normal file
36
admin/src/app/components/login/login.component.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
import { AuthService } from '../../auth/auth.service';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-login',
|
||||||
|
templateUrl: './login.component.html',
|
||||||
|
standalone: true,
|
||||||
|
imports: [ReactiveFormsModule, CommonModule],
|
||||||
|
})
|
||||||
|
export class LoginComponent {
|
||||||
|
loginForm: FormGroup;
|
||||||
|
errorMessage: string = '';
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private fb: FormBuilder,
|
||||||
|
private authService: AuthService,
|
||||||
|
private router: Router
|
||||||
|
) {
|
||||||
|
this.loginForm = this.fb.group({
|
||||||
|
username: ['', Validators.required],
|
||||||
|
password: ['', Validators.required],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onSubmit(): void {
|
||||||
|
if (this.loginForm.valid) {
|
||||||
|
this.authService.login(this.loginForm.value).subscribe({
|
||||||
|
next: () => this.router.navigate(['/']),
|
||||||
|
error: (err) => (this.errorMessage = 'Invalid username or password'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
0
admin/src/app/components/main-menu/main-menu.css
Normal file
0
admin/src/app/components/main-menu/main-menu.css
Normal file
1
admin/src/app/components/main-menu/main-menu.html
Normal file
1
admin/src/app/components/main-menu/main-menu.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<p>main-menu works!</p>
|
||||||
23
admin/src/app/components/main-menu/main-menu.spec.ts
Normal file
23
admin/src/app/components/main-menu/main-menu.spec.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { MainMenu } from './main-menu';
|
||||||
|
|
||||||
|
describe('MainMenu', () => {
|
||||||
|
let component: MainMenu;
|
||||||
|
let fixture: ComponentFixture<MainMenu>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [MainMenu]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(MainMenu);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
11
admin/src/app/components/main-menu/main-menu.ts
Normal file
11
admin/src/app/components/main-menu/main-menu.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-main-menu',
|
||||||
|
imports: [],
|
||||||
|
templateUrl: './main-menu.html',
|
||||||
|
styleUrl: './main-menu.css',
|
||||||
|
})
|
||||||
|
export class MainMenu {
|
||||||
|
|
||||||
|
}
|
||||||
0
admin/src/app/components/navbar/navbar.css
Normal file
0
admin/src/app/components/navbar/navbar.css
Normal file
19
admin/src/app/components/navbar/navbar.html
Normal file
19
admin/src/app/components/navbar/navbar.html
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<div class="navbar bg-base-100 shadow-sm">
|
||||||
|
<div class="flex-1">
|
||||||
|
<a class="btn btn-ghost text-xl">daisyUI</a>
|
||||||
|
</div>
|
||||||
|
<div class="flex-none">
|
||||||
|
<ul class="menu menu-horizontal px-1">
|
||||||
|
<li><a>Link</a></li>
|
||||||
|
<li>
|
||||||
|
<details>
|
||||||
|
<summary>Parent</summary>
|
||||||
|
<ul class="bg-base-100 rounded-t-none p-2">
|
||||||
|
<li><a>Link 1</a></li>
|
||||||
|
<li><a>Link 2</a></li>
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
23
admin/src/app/components/navbar/navbar.spec.ts
Normal file
23
admin/src/app/components/navbar/navbar.spec.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { Navbar } from './navbar';
|
||||||
|
|
||||||
|
describe('Navbar', () => {
|
||||||
|
let component: Navbar;
|
||||||
|
let fixture: ComponentFixture<Navbar>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [Navbar]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(Navbar);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
11
admin/src/app/components/navbar/navbar.ts
Normal file
11
admin/src/app/components/navbar/navbar.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-navbar',
|
||||||
|
imports: [],
|
||||||
|
templateUrl: './navbar.html',
|
||||||
|
styleUrl: './navbar.css',
|
||||||
|
})
|
||||||
|
export class Navbar {
|
||||||
|
|
||||||
|
}
|
||||||
0
admin/src/app/layout/admin-layout/admin-layout.css
Normal file
0
admin/src/app/layout/admin-layout/admin-layout.css
Normal file
6
admin/src/app/layout/admin-layout/admin-layout.html
Normal file
6
admin/src/app/layout/admin-layout/admin-layout.html
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<div class="row">
|
||||||
|
<div class="col-6">02</div>
|
||||||
|
<div class="col-6">
|
||||||
|
content
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
23
admin/src/app/layout/admin-layout/admin-layout.spec.ts
Normal file
23
admin/src/app/layout/admin-layout/admin-layout.spec.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { AdminLayout } from './admin-layout';
|
||||||
|
|
||||||
|
describe('AdminLayout', () => {
|
||||||
|
let component: AdminLayout;
|
||||||
|
let fixture: ComponentFixture<AdminLayout>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [AdminLayout]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(AdminLayout);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
11
admin/src/app/layout/admin-layout/admin-layout.ts
Normal file
11
admin/src/app/layout/admin-layout/admin-layout.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-admin-layout',
|
||||||
|
imports: [],
|
||||||
|
templateUrl: './admin-layout.html',
|
||||||
|
styleUrl: './admin-layout.css',
|
||||||
|
})
|
||||||
|
export class AdminLayout {
|
||||||
|
|
||||||
|
}
|
||||||
13
admin/src/index.html
Normal file
13
admin/src/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Admin</title>
|
||||||
|
<base href="/">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<app-root></app-root>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
6
admin/src/main.ts
Normal file
6
admin/src/main.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { bootstrapApplication } from '@angular/platform-browser';
|
||||||
|
import { appConfig } from './app/app.config';
|
||||||
|
import { App } from './app/app';
|
||||||
|
|
||||||
|
bootstrapApplication(App, appConfig)
|
||||||
|
.catch((err) => console.error(err));
|
||||||
5
admin/src/styles.css
Normal file
5
admin/src/styles.css
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
/* You can add global styles to this file, and also import other style files */
|
||||||
|
|
||||||
|
@import "tailwindcss";
|
||||||
|
@import "./styles/grid.css";
|
||||||
|
@plugin "daisyui";
|
||||||
17
admin/src/styles/grid.css
Normal file
17
admin/src/styles/grid.css
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
@utility row {
|
||||||
|
--col-num: 12;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
@utility cols-* {
|
||||||
|
&.row {
|
||||||
|
--col-num: --value(integer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@utility col-* {
|
||||||
|
&:where(.row > *) {
|
||||||
|
flex: 0 0 calc((100% / var(--col-num)) * --value(integer));
|
||||||
|
max-width: calc((100% / var(--col-num)) * --value(integer));
|
||||||
|
padding: 0 calc(var(--spacing) * 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
15
admin/tsconfig.app.json
Normal file
15
admin/tsconfig.app.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||||
|
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./out-tsc/app",
|
||||||
|
"types": []
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"src/**/*.spec.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
34
admin/tsconfig.json
Normal file
34
admin/tsconfig.json
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||||
|
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||||
|
{
|
||||||
|
"compileOnSave": false,
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"noPropertyAccessFromIndexSignature": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"importHelpers": true,
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "preserve"
|
||||||
|
},
|
||||||
|
"angularCompilerOptions": {
|
||||||
|
"enableI18nLegacyMessageIdFormat": false,
|
||||||
|
"strictInjectionParameters": true,
|
||||||
|
"strictInputAccessModifiers": true,
|
||||||
|
"typeCheckHostBindings": true,
|
||||||
|
"strictTemplates": true
|
||||||
|
},
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.app.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.spec.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
14
admin/tsconfig.spec.json
Normal file
14
admin/tsconfig.spec.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||||
|
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./out-tsc/spec",
|
||||||
|
"types": [
|
||||||
|
"jasmine"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
34
api.http
34
api.http
@@ -1,34 +0,0 @@
|
|||||||
POST http://localhost:3000/auth/login
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
{
|
|
||||||
"username": "admin",
|
|
||||||
"password": "123456"
|
|
||||||
}
|
|
||||||
|
|
||||||
> {% client.global.set("auth_token", response.body.access_token); %}
|
|
||||||
|
|
||||||
|
|
||||||
### GET request with parameter
|
|
||||||
GET http://localhost:3000/users
|
|
||||||
Accept: application/json
|
|
||||||
Authorization: Bearer {{auth_token}}
|
|
||||||
|
|
||||||
### POST create user
|
|
||||||
POST http://localhost:3000/users
|
|
||||||
Content-Type: application/json
|
|
||||||
Accept: application/json
|
|
||||||
Authorization: Bearer {{auth_token}}
|
|
||||||
|
|
||||||
{
|
|
||||||
"username": "test1",
|
|
||||||
"password": "123456",
|
|
||||||
"email": "test1@gmail.com",
|
|
||||||
"groups": [
|
|
||||||
{
|
|
||||||
"id": 1
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
version: '3.8'
|
|
||||||
services:
|
|
||||||
postgres:
|
|
||||||
image: postgres:18
|
|
||||||
restart: always
|
|
||||||
environment:
|
|
||||||
POSTGRES_USER: test
|
|
||||||
POSTGRES_PASSWORD: test
|
|
||||||
POSTGRES_DB: test
|
|
||||||
ports:
|
|
||||||
- '4401:5432'
|
|
||||||
volumes:
|
|
||||||
- e2epgdata:/var/lib/postgresql
|
|
||||||
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
e2epgdata: {}
|
|
||||||
22
server/api.http
Normal file
22
server/api.http
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
POST {{apiBaseUrl}}/auth/login
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"username": "admin",
|
||||||
|
"password": "123456"
|
||||||
|
}
|
||||||
|
|
||||||
|
> {% client.global.set("auth_token", response.body.accessToken); %}
|
||||||
|
|
||||||
|
|
||||||
|
### GET request with parameter
|
||||||
|
GET {{apiBaseUrl}}/users
|
||||||
|
Accept: application/json
|
||||||
|
Authorization: Bearer {{auth_token}}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### GET request with parameter
|
||||||
|
POST {{apiBaseUrl}}/auth/logout
|
||||||
|
Accept: application/json
|
||||||
|
Authorization: Bearer {{auth_token}}
|
||||||
5
server/http-client.env.json
Normal file
5
server/http-client.env.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"dev": {
|
||||||
|
"apiBaseUrl": "http://localhost:3000/api"
|
||||||
|
}
|
||||||
|
}
|
||||||
1436
package-lock.json → server/package-lock.json
generated
1436
package-lock.json → server/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -29,6 +29,7 @@
|
|||||||
"@nestjs/jwt": "^11.0.1",
|
"@nestjs/jwt": "^11.0.1",
|
||||||
"@nestjs/passport": "^11.0.5",
|
"@nestjs/passport": "^11.0.5",
|
||||||
"@nestjs/platform-express": "^11.0.1",
|
"@nestjs/platform-express": "^11.0.1",
|
||||||
|
"@nestjs/swagger": "^11.2.1",
|
||||||
"@nestjs/typeorm": "^11.0.0",
|
"@nestjs/typeorm": "^11.0.0",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
@@ -52,7 +53,6 @@
|
|||||||
"@types/node": "^22.10.7",
|
"@types/node": "^22.10.7",
|
||||||
"@types/passport-jwt": "^4.0.0",
|
"@types/passport-jwt": "^4.0.0",
|
||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^6.0.2",
|
||||||
"dotenv": "^16.4.5",
|
|
||||||
"eslint": "^9.18.0",
|
"eslint": "^9.18.0",
|
||||||
"eslint-config-prettier": "^10.0.1",
|
"eslint-config-prettier": "^10.0.1",
|
||||||
"eslint-plugin-prettier": "^5.2.2",
|
"eslint-plugin-prettier": "^5.2.2",
|
||||||
@@ -61,7 +61,6 @@
|
|||||||
"prettier": "^3.4.2",
|
"prettier": "^3.4.2",
|
||||||
"source-map-support": "^0.5.21",
|
"source-map-support": "^0.5.21",
|
||||||
"supertest": "^7.0.0",
|
"supertest": "^7.0.0",
|
||||||
"testcontainers": "^10.9.0",
|
|
||||||
"ts-jest": "^29.2.5",
|
"ts-jest": "^29.2.5",
|
||||||
"ts-loader": "^9.5.2",
|
"ts-loader": "^9.5.2",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
@@ -8,12 +8,12 @@ import { AuthModule } from './auth/auth.module';
|
|||||||
import { User } from './entity/user';
|
import { User } from './entity/user';
|
||||||
import { UserGroup } from './entity/user-group';
|
import { UserGroup } from './entity/user-group';
|
||||||
import { UserRole } from './entity/user-role';
|
import { UserRole } from './entity/user-role';
|
||||||
|
import { LoggerModule } from './logger/logger.module';
|
||||||
|
|
||||||
const moduleTypeOrm = TypeOrmModule.forRootAsync({
|
const moduleTypeOrm = TypeOrmModule.forRootAsync({
|
||||||
imports: [ConfigModule],
|
imports: [ConfigModule],
|
||||||
inject: [ConfigService],
|
inject: [ConfigService],
|
||||||
useFactory: (configService: ConfigService) => {
|
useFactory: (configService: ConfigService) => {
|
||||||
// console.log("config Service", configService)
|
|
||||||
return {
|
return {
|
||||||
type: 'postgres',
|
type: 'postgres',
|
||||||
host: configService.get<string>('DATABASE_HOST'),
|
host: configService.get<string>('DATABASE_HOST'),
|
||||||
@@ -28,16 +28,14 @@ const moduleTypeOrm = TypeOrmModule.forRootAsync({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const envFilePath =
|
|
||||||
process.env.NODE_ENV === 'test' ? '.env.e2e' : '.env';
|
|
||||||
|
|
||||||
// throw new Error("envFilePath:"+envFilePath);
|
|
||||||
const moduleConfig = ConfigModule.forRoot({
|
|
||||||
envFilePath,
|
|
||||||
});
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [moduleConfig, moduleTypeOrm, UserModule, AuthModule],
|
imports: [
|
||||||
|
ConfigModule.forRoot(),
|
||||||
|
moduleTypeOrm,
|
||||||
|
UserModule,
|
||||||
|
AuthModule,
|
||||||
|
LoggerModule,
|
||||||
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [AppService],
|
providers: [AppService],
|
||||||
})
|
})
|
||||||
37
server/src/auth/auth.controller.ts
Normal file
37
server/src/auth/auth.controller.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Post,
|
||||||
|
Body,
|
||||||
|
ValidationPipe,
|
||||||
|
UseGuards,
|
||||||
|
Req,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { LoginRequestDto } from './dto/login-request.dto';
|
||||||
|
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||||
|
import { JwtRefreshAuthGuard } from './jwt-refresh-auth.guard';
|
||||||
|
import express from 'express';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
|
||||||
|
@Controller('auth')
|
||||||
|
export class AuthController {
|
||||||
|
constructor(private authService: AuthService) {}
|
||||||
|
|
||||||
|
@Post('login')
|
||||||
|
async login(@Body(new ValidationPipe()) body: LoginRequestDto) {
|
||||||
|
return await this.authService.login(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Post('logout')
|
||||||
|
async logout(@Req() req: express.Request) {
|
||||||
|
const user = req.user as { sub: number };
|
||||||
|
return await this.authService.logout(user.sub);
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(JwtRefreshAuthGuard)
|
||||||
|
@Post('refresh')
|
||||||
|
async refresh(@Req() req: express.Request) {
|
||||||
|
const user = req.user as { sub: number; refreshToken: string };
|
||||||
|
return await this.authService.refreshToken(user.sub, user.refreshToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,22 +6,27 @@ import { AuthService } from './auth.service';
|
|||||||
import { AuthController } from './auth.controller';
|
import { AuthController } from './auth.controller';
|
||||||
import { JwtStrategy } from './jwt.strategy';
|
import { JwtStrategy } from './jwt.strategy';
|
||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
|
import { JwtRefreshTokenStrategy } from './jwt-refresh.strategy';
|
||||||
|
import { StringValue } from 'ms';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
ConfigModule, // <--- Import ConfigModule here
|
ConfigModule,
|
||||||
UserModule,
|
UserModule,
|
||||||
PassportModule,
|
PassportModule,
|
||||||
|
// Restore the correct async registration for JwtModule
|
||||||
JwtModule.registerAsync({
|
JwtModule.registerAsync({
|
||||||
imports: [ConfigModule],
|
imports: [ConfigModule],
|
||||||
inject: [ConfigService],
|
inject: [ConfigService],
|
||||||
useFactory: (configService: ConfigService) => ({
|
useFactory: (configService: ConfigService) => ({
|
||||||
secret: configService.get<string>('JWT_SECRET'),
|
secret: configService.get<string>('JWT_SECRET'),
|
||||||
signOptions: { expiresIn: '60m' },
|
signOptions: {
|
||||||
|
expiresIn: configService.get<StringValue>('JWT_EXPIRATION_TIME'),
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
providers: [AuthService, JwtStrategy],
|
providers: [AuthService, JwtStrategy, JwtRefreshTokenStrategy],
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule {}
|
||||||
120
server/src/auth/auth.service.ts
Normal file
120
server/src/auth/auth.service.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { UserService } from '../user/user.service';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import * as bcrypt from 'bcrypt';
|
||||||
|
import { User } from '../entity/user';
|
||||||
|
import { LoginRequest, LoginResponse } from '../types';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import type { StringValue } from 'ms';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuthService {
|
||||||
|
constructor(
|
||||||
|
private userService: UserService,
|
||||||
|
private jwtService: JwtService,
|
||||||
|
private configService: ConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async validateUser(username: string, pass: string): Promise<User | null> {
|
||||||
|
const user = await this.userService.findByUsername(username, {
|
||||||
|
groups: {
|
||||||
|
roles: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (user && (await bcrypt.compare(pass, user.password))) {
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async login(loginData: LoginRequest): Promise<LoginResponse> {
|
||||||
|
const user: User | null = await this.validateUser(
|
||||||
|
loginData.username,
|
||||||
|
loginData.password,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new UnauthorizedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = await this.getTokens(user);
|
||||||
|
await this.userService.setRefreshToken(user.id, tokens.refreshToken);
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
async logout(userId: number): Promise<void> {
|
||||||
|
await this.userService.setRefreshToken(userId, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshToken(
|
||||||
|
userId: number,
|
||||||
|
refreshToken: string,
|
||||||
|
): Promise<LoginResponse> {
|
||||||
|
const user = await this.userService.findOne(userId);
|
||||||
|
if (!user || !user.hashedRefreshToken) {
|
||||||
|
throw new UnauthorizedException('Access Denied');
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshTokenMatches = await bcrypt.compare(
|
||||||
|
refreshToken,
|
||||||
|
user.hashedRefreshToken,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!refreshTokenMatches) {
|
||||||
|
throw new UnauthorizedException('Access Denied');
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = await this.getTokens(user);
|
||||||
|
await this.userService.setRefreshToken(user.id, tokens.refreshToken);
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getTokens(user: User): Promise<LoginResponse> {
|
||||||
|
const roles: Set<string> = new Set<string>();
|
||||||
|
for (const group of user.groups ?? []) {
|
||||||
|
for (const role of group.roles ?? []) {
|
||||||
|
roles.add(role.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const jwtSecret = this.configService.get<string>('JWT_SECRET');
|
||||||
|
const jwtexpirationtime = this.configService.get<string>(
|
||||||
|
'JWT_EXPIRATION_TIME',
|
||||||
|
);
|
||||||
|
|
||||||
|
console.info(
|
||||||
|
'creating. jwt secret is: ' + jwtSecret + ',' + jwtexpirationtime,
|
||||||
|
);
|
||||||
|
|
||||||
|
// let accessToken: string, refreshToken: string;
|
||||||
|
const [accessToken, refreshToken] = await Promise.all([
|
||||||
|
this.jwtService.signAsync(
|
||||||
|
{
|
||||||
|
username: user.username,
|
||||||
|
sub: user.id,
|
||||||
|
roles: Array.from(roles),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
secret: this.configService.get<string>('JWT_SECRET'),
|
||||||
|
expiresIn: +this.configService.get<StringValue>('JWT_EXPIRATION_TIME')!,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
this.jwtService.signAsync(
|
||||||
|
{
|
||||||
|
sub: user.id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
secret: this.configService.get<string>('JWT_REFRESH_SECRET'),
|
||||||
|
expiresIn: this.configService.get<StringValue>(
|
||||||
|
'JWT_REFRESH_EXPIRATION_TIME',
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
import { IsString } from 'class-validator';
|
import { IsString } from 'class-validator';
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
export class LoginRequestDto {
|
export class LoginRequestDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
|
@ApiProperty()
|
||||||
username: string;
|
username: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
|
@ApiProperty()
|
||||||
password: string;
|
password: string;
|
||||||
}
|
}
|
||||||
5
server/src/auth/jwt-refresh-auth.guard.ts
Normal file
5
server/src/auth/jwt-refresh-auth.guard.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class JwtRefreshAuthGuard extends AuthGuard('jwt-refresh') {}
|
||||||
47
server/src/auth/jwt-refresh.strategy.ts
Normal file
47
server/src/auth/jwt-refresh.strategy.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
|
import { ExtractJwt, Strategy, StrategyOptionsWithRequest } from 'passport-jwt';
|
||||||
|
import { Request } from 'express';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
|
// Define the shape of the JWT payload
|
||||||
|
interface JwtPayload {
|
||||||
|
sub: number;
|
||||||
|
// iat and exp are automatically added by passport-jwt
|
||||||
|
iat: number;
|
||||||
|
exp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Define the shape of the object returned by the validate function
|
||||||
|
interface JwtPayloadWithRefreshToken extends JwtPayload {
|
||||||
|
refreshToken: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class JwtRefreshTokenStrategy extends PassportStrategy(
|
||||||
|
Strategy,
|
||||||
|
'jwt-refresh',
|
||||||
|
) {
|
||||||
|
constructor(private configService: ConfigService) {
|
||||||
|
super({
|
||||||
|
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||||
|
secretOrKey: configService.get<string>('JWT_REFRESH_SECRET'),
|
||||||
|
passReqToCallback: true,
|
||||||
|
} as StrategyOptionsWithRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
validate(req: Request, payload: JwtPayload): JwtPayloadWithRefreshToken {
|
||||||
|
const refreshToken = req.get('Authorization')?.replace('Bearer', '').trim();
|
||||||
|
|
||||||
|
// Ensure refreshToken is not undefined before returning
|
||||||
|
if (!refreshToken) {
|
||||||
|
// This case should be rare given the guard is used, but it's good practice
|
||||||
|
// to handle it. Depending on strictness, you might throw an error.
|
||||||
|
// For now, we'll proceed, but in a real-world scenario, logging or an error
|
||||||
|
// might be better.
|
||||||
|
return { ...payload, refreshToken: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...payload, refreshToken };
|
||||||
|
}
|
||||||
|
}
|
||||||
38
server/src/auth/jwt.strategy.ts
Normal file
38
server/src/auth/jwt.strategy.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
|
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { Role } from './role.enum';
|
||||||
|
import { Request } from 'express';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||||
|
constructor(private configService: ConfigService) {
|
||||||
|
super({
|
||||||
|
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||||
|
ignoreExpiration: false,
|
||||||
|
// DO NOT use secretOrKey here. It causes a race condition with ConfigService.
|
||||||
|
// Instead, use secretOrKeyProvider to look up the secret dynamically
|
||||||
|
// at request time, ensuring ConfigService is ready.
|
||||||
|
secretOrKeyProvider: (
|
||||||
|
request: Request,
|
||||||
|
rawJwtToken: any,
|
||||||
|
done: (err: any, secretOrKey?: string | Buffer) => void,
|
||||||
|
) => {
|
||||||
|
const secretKey = this.configService.get<string>('JWT_SECRET');
|
||||||
|
console.info('secretKey', secretKey);
|
||||||
|
done(null, secretKey);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// The payload is already validated by passport-jwt at this point,
|
||||||
|
// so we can trust its contents.
|
||||||
|
validate(payload: { sub: number; username: string; roles: Role[] }) {
|
||||||
|
return {
|
||||||
|
userId: payload.sub,
|
||||||
|
username: payload.username,
|
||||||
|
roles: payload.roles,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,14 +5,8 @@ import { User } from './entity/user';
|
|||||||
import * as dotenv from 'dotenv';
|
import * as dotenv from 'dotenv';
|
||||||
import { UserGroup } from './entity/user-group';
|
import { UserGroup } from './entity/user-group';
|
||||||
import { UserRole } from './entity/user-role';
|
import { UserRole } from './entity/user-role';
|
||||||
import path from 'path';
|
|
||||||
|
|
||||||
let envFilePath = path.resolve(process.cwd(), '.env');
|
dotenv.config();
|
||||||
if (process.env.DATA_SOURCE_ENV) {
|
|
||||||
envFilePath = path.resolve(process.cwd(), process.env.DATA_SOURCE_ENV);
|
|
||||||
}
|
|
||||||
|
|
||||||
dotenv.config({ path: envFilePath });
|
|
||||||
|
|
||||||
export const AppDataSource = new DataSource({
|
export const AppDataSource = new DataSource({
|
||||||
type: 'postgres',
|
type: 'postgres',
|
||||||
@@ -24,6 +18,8 @@ export const AppDataSource = new DataSource({
|
|||||||
synchronize: false,
|
synchronize: false,
|
||||||
logging: false,
|
logging: false,
|
||||||
entities: [User, UserGroup, UserRole],
|
entities: [User, UserGroup, UserRole],
|
||||||
migrations: ['src/migration/**/*.ts'],
|
migrations: [
|
||||||
|
'src/migration/**/*.ts'
|
||||||
|
],
|
||||||
subscribers: [],
|
subscribers: [],
|
||||||
});
|
});
|
||||||
@@ -24,4 +24,7 @@ export class User {
|
|||||||
@ManyToMany(() => UserGroup)
|
@ManyToMany(() => UserGroup)
|
||||||
@JoinTable()
|
@JoinTable()
|
||||||
groups: UserGroup[];
|
groups: UserGroup[];
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', nullable: true })
|
||||||
|
hashedRefreshToken: string | null;
|
||||||
}
|
}
|
||||||
24
server/src/logger/dvbooking-logger.service.ts
Normal file
24
server/src/logger/dvbooking-logger.service.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { ConsoleLogger, Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DvbookingLoggerService extends ConsoleLogger {
|
||||||
|
log(message: string, context?: string) {
|
||||||
|
super.log(message, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
error(message: string, trace?: string, context?: string) {
|
||||||
|
super.error(message, trace, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
warn(message: string, context?: string) {
|
||||||
|
super.warn(message, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
debug(message: string, context?: string) {
|
||||||
|
super.debug(message, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
verbose(message: string, context?: string) {
|
||||||
|
super.verbose(message, context);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
server/src/logger/logger.module.ts
Normal file
9
server/src/logger/logger.module.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { DvbookingLoggerService } from './dvbooking-logger.service';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [DvbookingLoggerService],
|
||||||
|
exports: [DvbookingLoggerService],
|
||||||
|
})
|
||||||
|
export class LoggerModule {}
|
||||||
23
server/src/main.ts
Normal file
23
server/src/main.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { AppModule } from './app.module';
|
||||||
|
import { DvbookingLoggerService } from './logger/dvbooking-logger.service';
|
||||||
|
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
const app = await NestFactory.create(AppModule);
|
||||||
|
app.useLogger(app.get(DvbookingLoggerService));
|
||||||
|
|
||||||
|
app.setGlobalPrefix('api');
|
||||||
|
|
||||||
|
const config = new DocumentBuilder()
|
||||||
|
.setTitle('DV Booking API')
|
||||||
|
.setDescription('The DV Booking API description')
|
||||||
|
.setVersion('1.0')
|
||||||
|
.addTag('dvbooking')
|
||||||
|
.build();
|
||||||
|
const document = SwaggerModule.createDocument(app, config);
|
||||||
|
SwaggerModule.setup('api', app, document);
|
||||||
|
|
||||||
|
await app.listen(process.env.PORT ?? 3000);
|
||||||
|
}
|
||||||
|
bootstrap();
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
export class AddRefreshTokenToUserObject1763106308120 implements MigrationInterface {
|
||||||
|
name = 'AddRefreshTokenToUserObject1763106308120'
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`ALTER TABLE "user" ADD "hashedRefreshToken" character varying`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "hashedRefreshToken"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
10
server/src/types.ts
Normal file
10
server/src/types.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
|
||||||
|
export interface LoginRequest{
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginResponse {
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken: string;
|
||||||
|
}
|
||||||
@@ -1,17 +1,18 @@
|
|||||||
import { IsString, IsEmail, MinLength, IsArray } from 'class-validator';
|
import { IsString, IsEmail, MinLength } from 'class-validator';
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
export class CreateUserDto {
|
export class CreateUserDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(3)
|
@MinLength(3)
|
||||||
|
@ApiProperty()
|
||||||
username: string;
|
username: string;
|
||||||
|
|
||||||
@IsEmail()
|
@IsEmail()
|
||||||
|
@ApiProperty()
|
||||||
email: string;
|
email: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(6)
|
@MinLength(6)
|
||||||
|
@ApiProperty()
|
||||||
password: string;
|
password: string;
|
||||||
|
|
||||||
@IsArray()
|
|
||||||
groups: [{ id: number }];
|
|
||||||
}
|
}
|
||||||
@@ -1,17 +1,24 @@
|
|||||||
import { IsString, IsEmail, MinLength, IsOptional } from 'class-validator';
|
import { IsString, IsEmail, MinLength, IsOptional } from 'class-validator';
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
export class UpdateUserDto {
|
export class UpdateUserDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(3)
|
@MinLength(3)
|
||||||
|
@ApiProperty()
|
||||||
username?: string;
|
username?: string;
|
||||||
|
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsEmail()
|
@IsEmail()
|
||||||
|
@ApiProperty()
|
||||||
email?: string;
|
email?: string;
|
||||||
|
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(6)
|
@MinLength(6)
|
||||||
|
@ApiProperty()
|
||||||
password?: string;
|
password?: string;
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -33,7 +33,6 @@ export class UserController {
|
|||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
findAll(): Promise<User[]> {
|
findAll(): Promise<User[]> {
|
||||||
console.log("findall", process.env);
|
|
||||||
return this.userService.findAll();
|
return this.userService.findAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
13
server/src/user/user.module.ts
Normal file
13
server/src/user/user.module.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { UserService } from './user.service';
|
||||||
|
import { UserController } from './user.controller';
|
||||||
|
import { User } from '../entity/user';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([User])],
|
||||||
|
providers: [UserService],
|
||||||
|
controllers: [UserController],
|
||||||
|
exports: [UserService],
|
||||||
|
})
|
||||||
|
export class UserModule {}
|
||||||
@@ -1,59 +1,48 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { User } from '../entity/user';
|
import { User } from '../entity/user';
|
||||||
import * as bcrypt from 'bcrypt';
|
import * as bcrypt from 'bcrypt';
|
||||||
import { FindOptionsRelations } from 'typeorm/find-options/FindOptionsRelations';
|
import { FindOptionsRelations } from 'typeorm/find-options/FindOptionsRelations';
|
||||||
import { UserGroupService } from './user-group.service';
|
import { DvbookingLoggerService } from '../logger/dvbooking-logger.service';
|
||||||
import { CreateUserDto } from './dto/create-user.dto';
|
|
||||||
import { UserGroup } from '../entity/user-group';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UserService {
|
export class UserService {
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(User)
|
@InjectRepository(User)
|
||||||
private usersRepository: Repository<User>,
|
private usersRepository: Repository<User>,
|
||||||
private userGroupService: UserGroupService,
|
private readonly logger: DvbookingLoggerService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
findAll(): Promise<User[]> {
|
findAll(): Promise<User[]> {
|
||||||
|
this.logger.log('Finding all users', 'UserService');
|
||||||
return this.usersRepository.find();
|
return this.usersRepository.find();
|
||||||
}
|
}
|
||||||
|
|
||||||
findOne(id: number): Promise<User | null> {
|
findOne(id: number): Promise<User | null> {
|
||||||
|
this.logger.log(`Finding user with id: ${id}`, 'UserService');
|
||||||
return this.usersRepository.findOneBy({ id });
|
return this.usersRepository.findOneBy({ id });
|
||||||
}
|
}
|
||||||
|
|
||||||
findByUsername(
|
findByUsername(
|
||||||
username: string,
|
username: string,
|
||||||
relations?: FindOptionsRelations<User>,
|
relations: FindOptionsRelations<User>,
|
||||||
): Promise<User | null> {
|
): Promise<User | null> {
|
||||||
|
this.logger.log(`Finding user with username: ${username}`, 'UserService');
|
||||||
return this.usersRepository.findOne({ where: { username }, relations });
|
return this.usersRepository.findOne({ where: { username }, relations });
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(userDto: Partial<CreateUserDto>): Promise<User> {
|
async create(user: Partial<User>): Promise<User> {
|
||||||
const { groups, ...user } = userDto;
|
this.logger.log('Creating a new user', 'UserService');
|
||||||
if (user.password) {
|
if (user.password) {
|
||||||
user.password = await bcrypt.hash(user.password, 12);
|
user.password = await bcrypt.hash(user.password, 12);
|
||||||
}
|
}
|
||||||
if (groups) {
|
|
||||||
const userGroups: UserGroup[] = [];
|
|
||||||
for (const group of groups) {
|
|
||||||
const userGroup = await this.userGroupService.findOne(group.id);
|
|
||||||
if (userGroup) {
|
|
||||||
userGroups.push(userGroup);
|
|
||||||
} else {
|
|
||||||
throw new NotFoundException('User group does not exist:' + group.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(user as Partial<User>).groups = userGroups;
|
|
||||||
}
|
|
||||||
const newUser = this.usersRepository.create(user);
|
const newUser = this.usersRepository.create(user);
|
||||||
|
|
||||||
return this.usersRepository.save(newUser);
|
return this.usersRepository.save(newUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(id: number, user: Partial<User>): Promise<User | null> {
|
async update(id: number, user: Partial<User>): Promise<User | null> {
|
||||||
|
this.logger.log(`Updating user with id: ${id}`, 'UserService');
|
||||||
if (user.password) {
|
if (user.password) {
|
||||||
user.password = await bcrypt.hash(user.password, 12);
|
user.password = await bcrypt.hash(user.password, 12);
|
||||||
}
|
}
|
||||||
@@ -62,6 +51,23 @@ export class UserService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async remove(id: number): Promise<void> {
|
async remove(id: number): Promise<void> {
|
||||||
|
this.logger.log(`Removing user with id: ${id}`, 'UserService');
|
||||||
await this.usersRepository.delete(id);
|
await this.usersRepository.delete(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async setRefreshToken(
|
||||||
|
id: number,
|
||||||
|
refreshToken: string | null,
|
||||||
|
): Promise<void> {
|
||||||
|
this.logger.log(
|
||||||
|
`Updating refresh token for user with id: ${id}`,
|
||||||
|
'UserService',
|
||||||
|
);
|
||||||
|
if (refreshToken) {
|
||||||
|
const hashedRefreshToken = await bcrypt.hash(refreshToken, 10);
|
||||||
|
await this.usersRepository.update(id, { hashedRefreshToken });
|
||||||
|
} else {
|
||||||
|
await this.usersRepository.update(id, { hashedRefreshToken: null });
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -5,7 +5,5 @@
|
|||||||
"testRegex": ".e2e-spec.ts$",
|
"testRegex": ".e2e-spec.ts$",
|
||||||
"transform": {
|
"transform": {
|
||||||
"^.+\\.(t|j)s$": "ts-jest"
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
},
|
}
|
||||||
"globalSetup": "<rootDir>/global-setup.ts",
|
|
||||||
"globalTeardown": "<rootDir>/global-teardown.ts"
|
|
||||||
}
|
}
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import { Controller, Post, Body, ValidationPipe } from '@nestjs/common';
|
|
||||||
import { AuthService } from './auth.service';
|
|
||||||
import { LoginRequestDto } from './dto/login-request.dto';
|
|
||||||
|
|
||||||
@Controller('auth')
|
|
||||||
export class AuthController {
|
|
||||||
constructor(private authService: AuthService) {}
|
|
||||||
|
|
||||||
@Post('login')
|
|
||||||
async login(@Body(new ValidationPipe()) body: LoginRequestDto) {
|
|
||||||
return await this.authService.login(body);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
|
||||||
import { UserService } from '../user/user.service';
|
|
||||||
import { JwtService } from '@nestjs/jwt';
|
|
||||||
import * as bcrypt from 'bcrypt';
|
|
||||||
import { User } from '../entity/user';
|
|
||||||
import { LoginRequest, LoginResponse } from '../types';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class AuthService {
|
|
||||||
constructor(
|
|
||||||
private userService: UserService,
|
|
||||||
private jwtService: JwtService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async validateUser(username: string, pass: string): Promise<User | null> {
|
|
||||||
const user = await this.userService.findByUsername(username, {
|
|
||||||
groups: {
|
|
||||||
roles: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (user && (await bcrypt.compare(pass, user.password))) {
|
|
||||||
return user;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async login(loginData: LoginRequest): Promise<LoginResponse> {
|
|
||||||
const user: User | null = await this.validateUser(
|
|
||||||
loginData.username,
|
|
||||||
loginData.password,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
throw new UnauthorizedException();
|
|
||||||
}
|
|
||||||
|
|
||||||
const roles: Set<string> = new Set<string>();
|
|
||||||
for (const group of user.groups ?? []) {
|
|
||||||
for (const role of group.roles ?? []) {
|
|
||||||
roles.add(role.name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const payload = {
|
|
||||||
username: user.username,
|
|
||||||
sub: user.id,
|
|
||||||
roles: Array.from(roles),
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
access_token: this.jwtService.sign(payload),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import { PassportStrategy } from '@nestjs/passport';
|
|
||||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import { Role } from './role.enum';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
|
||||||
constructor(configService: ConfigService) {
|
|
||||||
super({
|
|
||||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
|
||||||
ignoreExpiration: false,
|
|
||||||
secretOrKey: configService.get<string>('JWT_SECRET') as string,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
validate(payload: { sub: string; username: string; roles: Role[] }) {
|
|
||||||
return {
|
|
||||||
userId: payload.sub,
|
|
||||||
username: payload.username,
|
|
||||||
roles: payload.roles,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import { NestFactory } from '@nestjs/core';
|
|
||||||
import { AppModule } from './app.module';
|
|
||||||
|
|
||||||
async function bootstrap() {
|
|
||||||
const app = await NestFactory.create(AppModule);
|
|
||||||
await app.listen(process.env.PORT ?? 3000);
|
|
||||||
}
|
|
||||||
bootstrap();
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user