5 Commits

Author SHA1 Message Date
Roland Schneider
c169faf288 add e2e 2025-10-30 21:02:05 +01:00
Schneider Roland
b599e79273 add e2e with add groups 2025-10-30 03:51:50 +01:00
Schneider Roland
45a69eea8a add e2e with add groups 2025-10-29 22:56:11 +01:00
Roland Schneider
2f54770720 add e2e 2025-10-29 16:30:17 +01:00
Schneider Roland
bdf16a3189 add e2e with docker-compose support 2025-10-29 08:12:23 +01:00
135 changed files with 1988 additions and 12705 deletions

6
.env.e2e Normal file
View File

@@ -0,0 +1,6 @@
DATABASE_USER=test
DATABASE_PASS=test
DATABASE_HOST=localhost
DATABASE_NAME=test
DATABASE_PORT=4401
JWT_SECRET="secret"

14
.gitignore vendored
View File

@@ -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/
@@ -54,5 +54,3 @@ pids
# Diagnostic reports (https://nodejs.org/api/report.html) # Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
tmp

View File

@@ -1,17 +0,0 @@
# 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
View File

@@ -1,43 +0,0 @@
# 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

View File

@@ -1,5 +0,0 @@
{
"plugins": {
"@tailwindcss/postcss": {}
}
}

View File

@@ -1,4 +0,0 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
"recommendations": ["angular.ng-template"]
}

View File

@@ -1,20 +0,0 @@
{
// 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"
}
]
}

View File

@@ -1,42 +0,0 @@
{
// 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"
}
}
}
}
]
}

View File

@@ -1,59 +0,0 @@
# 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.

View File

@@ -1,111 +0,0 @@
{
"$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"
]
}
}
}
},
"@rschneider/ng-daisyui": {
"projectType": "library",
"root": "projects/rschneider/ng-daisyui",
"sourceRoot": "projects/rschneider/ng-daisyui/src",
"prefix": "rs-daisy",
"architect": {
"build": {
"builder": "@angular/build:ng-packagr",
"configurations": {
"production": {
"tsConfig": "projects/rschneider/ng-daisyui/tsconfig.lib.prod.json"
},
"development": {
"tsConfig": "projects/rschneider/ng-daisyui/tsconfig.lib.json"
}
},
"defaultConfiguration": "production"
},
"test": {
"builder": "@angular/build:karma",
"options": {
"tsConfig": "projects/rschneider/ng-daisyui/tsconfig.spec.json"
}
}
}
}
}
}

10664
admin/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,52 +0,0 @@
{
"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",
"ng-packagr": "^20.3.0",
"typescript": "~5.9.2"
}
}

View File

@@ -1,63 +0,0 @@
# NgDaisyui
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 20.3.0.
## 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 library, run:
```bash
ng build ng-daisyui
```
This command will compile your project, and the build artifacts will be placed in the `dist/` directory.
### Publishing the Library
Once the project is built, you can publish your library by following these steps:
1. Navigate to the `dist` directory:
```bash
cd dist/ng-daisyui
```
2. Run the `npm publish` command to publish your library to the npm registry:
```bash
npm publish
```
## 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.

View File

@@ -1,7 +0,0 @@
{
"$schema": "../../../node_modules/ng-packagr/ng-package.schema.json",
"dest": "../../../dist/rschneider/ng-daisyui",
"lib": {
"entryFile": "src/public-api.ts"
}
}

View File

@@ -1,12 +0,0 @@
{
"name": "@rschneider/ng-daisyui",
"version": "0.0.1",
"peerDependencies": {
"@angular/common": "^20.3.0",
"@angular/core": "^20.3.0"
},
"dependencies": {
"tslib": "^2.3.0"
},
"sideEffects": false
}

View File

@@ -1,38 +0,0 @@
<button
[aria-disabled]="disabled()"
role="button"
[ngClass]="{
'btn': true,
'btn-primary': variant() === 'primary',
'btn-secondary': variant() === 'secondary',
'btn-accent': variant() === 'accent',
'btn-info': variant() === 'info',
'btn-success': variant() === 'success',
'btn-warning': variant() === 'warning',
'btn-error': variant() === 'error',
'btn-ghost': variant() === 'ghost',
'btn-link': variant() === 'link',
'btn-lg': size() === 'lg',
'btn-md': size() === 'md',
'btn-sm': size() === 'sm',
'btn-xs': size() === 'xs',
'btn-outline': outline(),
'btn-active': active(),
'btn-glass': glass(),
'btn-square': square(),
'btn-circle': circle(),
'btn-wide': wide(),
'btn-block': block(),
'no-animation': noAnimation(),
'loading': loading(),
'btn-responsive': responsive(),
'btn-disabled': disabled()
}"
[class]="customClass() || ''"
(click)="onClick($event)"
>
<ng-content />
</button>

View File

@@ -1,23 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Button } from './button';
describe('Button', () => {
let component: Button;
let fixture: ComponentFixture<Button>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [Button]
})
.compileComponents();
fixture = TestBed.createComponent(Button);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -1,60 +0,0 @@
// rs-daisy-button.component.ts
import { Component, input, HostBinding, output } from '@angular/core';
import { NgClass } from '@angular/common';
@Component({
selector: 'rs-daisy-button',
standalone: true,
templateUrl: 'button.html',
imports: [NgClass]
})
export class Button {
// Daisy UI Button Props as Angular Inputs
// Sizes: lg, md, sm, xs
size = input<'lg' | 'md' | 'sm' | 'xs' | null>(null);
// States: primary, secondary, accent, info, success, warning, error, ghost, link
variant = input<'primary' | 'secondary' | 'accent' | 'info' | 'success' | 'warning' | 'error' | 'ghost' | 'link' | null>(null);
// Outlined
outline = input<boolean>(false);
// Active
active = input<boolean>(false);
// Disabled
disabled = input<boolean>(false);
// Glass
glass = input<boolean>(false);
// Square
square = input<boolean>(false);
// Circle
circle = input<boolean>(false);
// Wide
wide = input<boolean>(false);
// Block
block = input<boolean>(false);
// No animation
noAnimation = input<boolean>(false);
// Loading
loading = input<boolean>(false);
// Responsive
responsive = input<boolean>(false);
// You can also add a custom class input if needed
customClass = input<string | null>(null);
@HostBinding('attr.disabled') get isDisabled() {
return this.disabled() ? true : null;
}
// New Output for the click event
// By default, it emits 'void' (no specific data), but you could specify a type if needed.
readonly clickEvent = output<MouseEvent>();
// Event handler method
onClick(event: MouseEvent): void {
// Prevent default browser behavior for buttons if needed (e.g., form submission)
// event.preventDefault();
// Emit the click event
this.clickEvent.emit(event);
}
}

View File

@@ -1,32 +0,0 @@
<footer class="footer sm:footer-horizontal bg-neutral text-neutral-content p-10">
@if (config()) {
@for ( footerNav of config()?.navs; let i = $index; track i){
<nav>
<h6 class="footer-title">{{ footerNav.label }}</h6>
@for ( link of footerNav.links; let j = $index; track j){
<a class="link link-hover" [routerLink]="link.href" >{{link.text}}</a>
}
</nav>
}
}
<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>

View File

@@ -1,23 +0,0 @@
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();
});
});

View File

@@ -1,17 +0,0 @@
import { Component, input } from '@angular/core';
import { FooterConfig } from '../../daisy.types';
import { RouterLink } from '@angular/router';
@Component({
selector: 'rs-daisy-footer',
imports: [
RouterLink,
],
templateUrl: './footer.html',
styleUrl: './footer.css',
})
export class Footer {
config = input<FooterConfig>()
}

View File

@@ -1,13 +0,0 @@
export interface Link{
href: string;
text: string;
}
export interface FooterNav{
label: string;
links: Link[];
}
export interface FooterConfig{
navs: FooterNav[]
}

View File

@@ -1,83 +0,0 @@
<div class="drawer lg:drawer-open">
<input id="my-drawer-2" type="checkbox" class="drawer-toggle" />
<!-- Page Content -->
<div class="drawer-content flex flex-col h-screen">
<div class="navbar bg-base-200 p-4 shadow-md z-10">
<div class="flex-1">
<label for="my-drawer-2" class="btn btn-square btn-ghost drawer-button lg:hidden">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
class="inline-block w-6 h-6 stroke-current">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
</svg>
</label>
<a class="btn btn-ghost text-xl">daisyUI</a>
</div>
@if (loggedIn()) {
<div class="flex-none">
<div class="dropdown dropdown-end">
<div tabindex="0" role="button" class="btn btn-ghost btn-circle avatar">
<div class="w-10 rounded-full">
<img
alt="Tailwind CSS Navbar component"
src="https://img.daisyui.com/images/stock/photo-1534528741775-53994a69daeb.webp" />
</div>
</div>
<ul
tabindex="-1"
class="menu menu-sm dropdown-content bg-base-100 rounded-box z-1 mt-3 w-52 p-2 shadow">
<li>
<a class="justify-between">
Profile
<span class="badge">New</span>
</a>
</li>
<li><a>Settings</a></li>
<li><a (click)="onClick($event,'logout')">Logout</a></li>
</ul>
</div>
</div>
}
</div>
<!-- Main Content -->
<main class="flex-1 p-6 bg-base-100 overflow-y-auto">
<ng-content></ng-content>
</main>
<!-- Footer -->
<footer class="bg-base-200 p-4 text-center">
<p>Copyright © 2025 - All right reserved</p>
</footer>
</div>
<!-- Sidebar -->
<div class="drawer-side">
<label for="my-drawer-2" aria-label="close sidebar" class="drawer-overlay"></label>
<ul class="menu p-4 w-80 min-h-full bg-base-300 text-base-content">
<!-- Sidebar content here -->
<li class="text-lg font-bold p-4">My App</li>
<li><a>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
Dashboard</a></li>
<li><a>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Analytics</a></li>
<li><a>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
Reports</a></li>
</ul>
</div>
</div>

View File

@@ -1,23 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { AdminLayoutRs1 } from './admin-layout-rs1';
describe('AdminLayoutRs1', () => {
let component: AdminLayoutRs1;
let fixture: ComponentFixture<AdminLayoutRs1>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [AdminLayoutRs1]
})
.compileComponents();
fixture = TestBed.createComponent(AdminLayoutRs1);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -1,28 +0,0 @@
import { Component, input, output } from '@angular/core';
@Component({
selector: 'rs-daisy-admin-layout-rs1',
imports: [],
templateUrl: './admin-layout-rs1.html',
styleUrl: './admin-layout-rs1.css',
})
export class AdminLayoutRs1 {
readonly loggedIn = input<boolean>(false)
// New Output for the click event
// By default, it emits 'void' (no specific data), but you could specify a type if needed.
readonly clickEvent = output<MouseEvent>();
// Event handler method
onClick(event: MouseEvent, command: string): void {
// Prevent default browser behavior for buttons if needed (e.g., form submission)
// event.preventDefault();
// Emit the click event
this.clickEvent.emit(event);
}
}

View File

@@ -1,3 +0,0 @@
export * from './admin-layout-rs1/admin-layout-rs1';

View File

@@ -1,23 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NgDaisyui } from './ng-daisyui';
describe('NgDaisyui', () => {
let component: NgDaisyui;
let fixture: ComponentFixture<NgDaisyui>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [NgDaisyui]
})
.compileComponents();
fixture = TestBed.createComponent(NgDaisyui);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -1,15 +0,0 @@
import { Component } from '@angular/core';
@Component({
selector: 'rs-daisy-ng-daisyui',
imports: [],
template: `
<p>
ng-daisyui works!
</p>
`,
styles: ``,
})
export class NgDaisyui {
}

View File

@@ -1,9 +0,0 @@
/*
* Public API Surface of ng-daisyui
*/
export * from './lib/ng-daisyui';
export * from './lib/components/button/button';
export * from './lib/components/footer/footer';
export * from './lib/daisy.types';
export * from './lib/layout/';

View File

@@ -1,18 +0,0 @@
/* 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/lib",
"declaration": true,
"declarationMap": true,
"inlineSources": true,
"types": []
},
"include": [
"src/**/*.ts"
],
"exclude": [
"**/*.spec.ts"
]
}

View File

@@ -1,11 +0,0 @@
/* 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.lib.json",
"compilerOptions": {
"declarationMap": false
},
"angularCompilerOptions": {
"compilationMode": "partial"
}
}

View File

@@ -1,14 +0,0 @@
/* 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"
]
}

View File

@@ -1,8 +0,0 @@
{
"/api": {
"target": "http://localhost:3000",
"secure": false,
"logLevel": "debug",
"changeOrigin": true
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

View File

@@ -1,23 +0,0 @@
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 },
],
};

View File

View File

@@ -1,3 +0,0 @@
<rs-daisy-admin-layout-rs1 (clickEvent)="logout()" [loggedIn]="loggedIn()">
<router-outlet />
</rs-daisy-admin-layout-rs1>

View File

@@ -1,10 +0,0 @@
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
];

View File

@@ -1,25 +0,0 @@
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');
});
});

View File

@@ -1,41 +0,0 @@
import { Component, inject, 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';
import {Button} from '@rschneider/ng-daisyui';
import { AdminLayoutRs1 } from '../../projects/rschneider/ng-daisyui/src/lib/layout';
@Component({
selector: 'app-root',
imports: [RouterOutlet, MainMenu, Button, AdminLayoutRs1],
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();
},
});
}
loggedIn(){
return this.authService.isLoggedIn()
}
}

View File

@@ -1,23 +0,0 @@
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']);
}
}
}

View File

@@ -1,76 +0,0 @@
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);
}
}

View File

@@ -1,84 +0,0 @@
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}`,
},
});
}
}

View File

@@ -1,22 +0,0 @@
<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>

View File

@@ -1,23 +0,0 @@
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();
});
});

View File

@@ -1,11 +0,0 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-footer',
imports: [],
templateUrl: './footer.html',
styleUrl: './footer.css',
})
export class Footer {
}

View File

@@ -1,8 +0,0 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-home',
template: '<h1>Welcome to the Admin Panel!</h1>',
standalone: true,
})
export class HomeComponent {}

View File

@@ -1,17 +0,0 @@
<div class="bg-base-200 rounded-2xl p-4">
<h2 class="">Login</h2>
<form [formGroup]="loginForm" (ngSubmit)="onSubmit()">
<div class="pt-4">
<label class="label pe-2" for="username">Username:</label>
<input id="username" class="input " formControlName="username" />
</div>
<div class="pt-4">
<label class="label pe-2" for="password">Password:</label>
<input id="password" class="input " type="password" formControlName="password" />
</div>
<div class="button-container pt-4">
<rs-daisy-button type="submit" [disabled]="loginForm.invalid">Log In</rs-daisy-button>
</div>
</form>
<p *ngIf="errorMessage">{{ errorMessage }}</p>
</div>

View File

@@ -1,38 +0,0 @@
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';
import {Button} from '@rschneider/ng-daisyui';
@Component({
selector: 'app-login',
templateUrl: './login.component.html',
standalone: true,
imports: [ReactiveFormsModule, CommonModule, Button],
})
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'),
});
}
}
}

View File

@@ -1 +0,0 @@
<p>main-menu works!</p>

View File

@@ -1,23 +0,0 @@
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();
});
});

View File

@@ -1,11 +0,0 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-main-menu',
imports: [],
templateUrl: './main-menu.html',
styleUrl: './main-menu.css',
})
export class MainMenu {
}

View File

@@ -1,19 +0,0 @@
<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>

View File

@@ -1,23 +0,0 @@
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();
});
});

View File

@@ -1,11 +0,0 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-navbar',
imports: [],
templateUrl: './navbar.html',
styleUrl: './navbar.css',
})
export class Navbar {
}

View File

@@ -1,6 +0,0 @@
<div class="row">
<div class="col-6">02</div>
<div class="col-6">
content
</div>
</div>

View File

@@ -1,23 +0,0 @@
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();
});
});

View File

@@ -1,11 +0,0 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-admin-layout',
imports: [],
templateUrl: './admin-layout.html',
styleUrl: './admin-layout.css',
})
export class AdminLayout {
}

View File

@@ -1,13 +0,0 @@
<!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>

View File

@@ -1,6 +0,0 @@
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));

View File

@@ -1,6 +0,0 @@
/* You can add global styles to this file, and also import other style files */
@import "tailwindcss";
@import "./styles/grid.css";
@source "../projects/rschneider/ng-daisyui/src";
@plugin "daisyui";

View File

@@ -1,17 +0,0 @@
@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);
}
}

View File

@@ -1,15 +0,0 @@
/* 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"
]
}

View File

@@ -1,45 +0,0 @@
/* 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": {
"paths": {
"@rschneider/ng-daisyui": [
"./dist/rschneider/ng-daisyui"
]
},
"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"
},
{
"path": "./projects/rschneider/ng-daisyui/tsconfig.lib.json"
},
{
"path": "./projects/rschneider/ng-daisyui/tsconfig.spec.json"
}
]
}

View File

@@ -1,14 +0,0 @@
/* 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 Normal file
View File

@@ -0,0 +1,34 @@
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
}
]
}

View File

@@ -0,0 +1,17 @@
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: {}

File diff suppressed because it is too large Load Diff

View File

@@ -29,7 +29,6 @@
"@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",
@@ -53,6 +52,7 @@
"@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,6 +61,7 @@
"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",

View File

@@ -1,22 +0,0 @@
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}}

View File

@@ -1,5 +0,0 @@
{
"dev": {
"apiBaseUrl": "http://localhost:3000/api"
}
}

View File

@@ -1,37 +0,0 @@
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);
}
}

View File

@@ -1,120 +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';
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,
};
}
}

View File

@@ -1,5 +0,0 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtRefreshAuthGuard extends AuthGuard('jwt-refresh') {}

View File

@@ -1,47 +0,0 @@
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 };
}
}

View File

@@ -1,38 +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';
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,
};
}
}

View File

@@ -1,24 +0,0 @@
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);
}
}

View File

@@ -1,9 +0,0 @@
import { Global, Module } from '@nestjs/common';
import { DvbookingLoggerService } from './dvbooking-logger.service';
@Global()
@Module({
providers: [DvbookingLoggerService],
exports: [DvbookingLoggerService],
})
export class LoggerModule {}

View File

@@ -1,23 +0,0 @@
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();

View File

@@ -1,14 +0,0 @@
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"`);
}
}

View File

@@ -1,10 +0,0 @@
export interface LoginRequest{
username: string;
password: string;
}
export interface LoginResponse {
accessToken: string;
refreshToken: string;
}

View File

@@ -1,13 +0,0 @@
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 {}

View File

@@ -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,14 +28,16 @@ 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: [ imports: [moduleConfig, moduleTypeOrm, UserModule, AuthModule],
ConfigModule.forRoot(),
moduleTypeOrm,
UserModule,
AuthModule,
LoggerModule,
],
controllers: [AppController], controllers: [AppController],
providers: [AppService], providers: [AppService],
}) })

View File

@@ -0,0 +1,13 @@
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);
}
}

View File

@@ -6,27 +6,22 @@ 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, ConfigModule, // <--- Import ConfigModule here
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: { signOptions: { expiresIn: '60m' },
expiresIn: configService.get<StringValue>('JWT_EXPIRATION_TIME'),
},
}), }),
}), }),
], ],
providers: [AuthService, JwtStrategy, JwtRefreshTokenStrategy], providers: [AuthService, JwtStrategy],
controllers: [AuthController], controllers: [AuthController],
}) })
export class AuthModule {} export class AuthModule {}

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