Compare commits
10 Commits
19ca0c086c
...
feature/ng
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e5a4fc505 | ||
|
|
ce76bf75c9 | ||
|
|
e2211e2de7 | ||
|
|
f1f2fefdab | ||
|
|
d11b0c65e0 | ||
|
|
96af8e564b | ||
|
|
42158d1fd4 | ||
|
|
f4c0bb0b76 | ||
|
|
5ab072992b | ||
|
|
cbede30d65 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -54,3 +54,5 @@ pids
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
tmp
|
||||
|
||||
5
admin/.postcssrc.json
Normal file
5
admin/.postcssrc.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"plugins": {
|
||||
"@tailwindcss/postcss": {}
|
||||
}
|
||||
}
|
||||
@@ -80,6 +80,32 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"@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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
1034
admin/package-lock.json
generated
1034
admin/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -28,7 +28,11 @@
|
||||
"@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": {
|
||||
@@ -42,6 +46,7 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
63
admin/projects/rschneider/ng-daisyui/README.md
Normal file
63
admin/projects/rschneider/ng-daisyui/README.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# 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.
|
||||
7
admin/projects/rschneider/ng-daisyui/ng-package.json
Normal file
7
admin/projects/rschneider/ng-daisyui/ng-package.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "../../../node_modules/ng-packagr/ng-package.schema.json",
|
||||
"dest": "../../../dist/rschneider/ng-daisyui",
|
||||
"lib": {
|
||||
"entryFile": "src/public-api.ts"
|
||||
}
|
||||
}
|
||||
12
admin/projects/rschneider/ng-daisyui/package.json
Normal file
12
admin/projects/rschneider/ng-daisyui/package.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<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>
|
||||
@@ -0,0 +1,23 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
// 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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<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>
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
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>()
|
||||
}
|
||||
|
||||
13
admin/projects/rschneider/ng-daisyui/src/lib/daisy.types.ts
Normal file
13
admin/projects/rschneider/ng-daisyui/src/lib/daisy.types.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export interface Link{
|
||||
href: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface FooterNav{
|
||||
label: string;
|
||||
links: Link[];
|
||||
}
|
||||
|
||||
export interface FooterConfig{
|
||||
navs: FooterNav[]
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<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>
|
||||
@@ -0,0 +1,23 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
|
||||
|
||||
export * from './admin-layout-rs1/admin-layout-rs1';
|
||||
@@ -0,0 +1,23 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
15
admin/projects/rschneider/ng-daisyui/src/lib/ng-daisyui.ts
Normal file
15
admin/projects/rschneider/ng-daisyui/src/lib/ng-daisyui.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Component } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'rs-daisy-ng-daisyui',
|
||||
imports: [],
|
||||
template: `
|
||||
<p>
|
||||
ng-daisyui works!
|
||||
</p>
|
||||
`,
|
||||
styles: ``,
|
||||
})
|
||||
export class NgDaisyui {
|
||||
|
||||
}
|
||||
9
admin/projects/rschneider/ng-daisyui/src/public-api.ts
Normal file
9
admin/projects/rschneider/ng-daisyui/src/public-api.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/*
|
||||
* 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/';
|
||||
18
admin/projects/rschneider/ng-daisyui/tsconfig.lib.json
Normal file
18
admin/projects/rschneider/ng-daisyui/tsconfig.lib.json
Normal file
@@ -0,0 +1,18 @@
|
||||
/* 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"
|
||||
]
|
||||
}
|
||||
11
admin/projects/rschneider/ng-daisyui/tsconfig.lib.prod.json
Normal file
11
admin/projects/rschneider/ng-daisyui/tsconfig.lib.prod.json
Normal file
@@ -0,0 +1,11 @@
|
||||
/* 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"
|
||||
}
|
||||
}
|
||||
14
admin/projects/rschneider/ng-daisyui/tsconfig.spec.json
Normal file
14
admin/projects/rschneider/ng-daisyui/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"
|
||||
]
|
||||
}
|
||||
BIN
admin/public/sample-profile-photo.webp
Normal file
BIN
admin/public/sample-profile-photo.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
@@ -1,5 +1,3 @@
|
||||
<div>
|
||||
<button (click)="logout()">Logout</button>
|
||||
</div>
|
||||
<app-main-menu></app-main-menu>
|
||||
<router-outlet />
|
||||
<rs-daisy-admin-layout-rs1 (clickEvent)="logout()" [loggedIn]="loggedIn()">
|
||||
<router-outlet />
|
||||
</rs-daisy-admin-layout-rs1>
|
||||
|
||||
@@ -1,21 +1,41 @@
|
||||
import { Component, signal } from '@angular/core';
|
||||
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],
|
||||
|
||||
imports: [RouterOutlet, MainMenu, Button, AdminLayoutRs1],
|
||||
templateUrl: './app.html',
|
||||
styleUrl: './app.css'
|
||||
styleUrl: './app.css',
|
||||
})
|
||||
export class App {
|
||||
protected readonly title = signal('admin');
|
||||
|
||||
|
||||
constructor(private authService: AuthService, private router: Router) {}
|
||||
|
||||
logout(): void {
|
||||
this.authService.logout();
|
||||
this.router.navigate(['/login']);
|
||||
// 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()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,36 +1,76 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { Observable, of, throwError } from 'rxjs';
|
||||
import { tap } from 'rxjs/operators';
|
||||
import { Router } from '@angular/router';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class AuthService {
|
||||
private readonly TOKEN_KEY = 'access_token';
|
||||
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) {}
|
||||
constructor(private http: HttpClient, private router: Router) {}
|
||||
|
||||
login(credentials: { username: string; password: string }): Observable<any> {
|
||||
return this.http.post<{ access_token: string }>(`${this.apiUrl}/login`, credentials).pipe(
|
||||
tap((response) => this.setToken(response.access_token))
|
||||
return this.http.post<{ accessToken: string; refreshToken: string }>(`${this.apiUrl}/login`, credentials).pipe(
|
||||
tap((response) => this.setTokens(response.accessToken, response.refreshToken))
|
||||
);
|
||||
}
|
||||
|
||||
logout(): void {
|
||||
localStorage.removeItem(this.TOKEN_KEY);
|
||||
/**
|
||||
* Makes a best-effort call to the server to invalidate the refresh token.
|
||||
*/
|
||||
serverSideLogout(): Observable<any> {
|
||||
return this.http.post(`${this.apiUrl}/logout`, {});
|
||||
}
|
||||
|
||||
getToken(): string | null {
|
||||
return localStorage.getItem(this.TOKEN_KEY);
|
||||
/**
|
||||
* 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.getToken() !== null;
|
||||
return this.getAccessToken() !== null;
|
||||
}
|
||||
|
||||
private setToken(token: string): void {
|
||||
localStorage.setItem(this.TOKEN_KEY, token);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,26 +4,81 @@ import {
|
||||
HttpHandler,
|
||||
HttpInterceptor,
|
||||
HttpRequest,
|
||||
HttpErrorResponse,
|
||||
} from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
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>> {
|
||||
const token = this.authService.getToken();
|
||||
if (token) {
|
||||
request = request.clone({
|
||||
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}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
return next.handle(request);
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
}
|
||||
@@ -1,15 +1,17 @@
|
||||
<div>
|
||||
<h2>Login</h2>
|
||||
<div class="bg-base-200 rounded-2xl p-4">
|
||||
<h2 class="">Login</h2>
|
||||
<form [formGroup]="loginForm" (ngSubmit)="onSubmit()">
|
||||
<div>
|
||||
<label for="username">Username:</label>
|
||||
<input id="username" formControlName="username" />
|
||||
<div class="pt-4">
|
||||
<label class="label pe-2" for="username">Username:</label>
|
||||
<input id="username" class="input " formControlName="username" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="password">Password:</label>
|
||||
<input id="password" type="password" formControlName="password" />
|
||||
<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>
|
||||
<button type="submit" [disabled]="loginForm.invalid">Log In</button>
|
||||
</form>
|
||||
<p *ngIf="errorMessage">{{ errorMessage }}</p>
|
||||
</div>
|
||||
|
||||
@@ -3,12 +3,14 @@ import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angula
|
||||
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],
|
||||
imports: [ReactiveFormsModule, CommonModule, Button],
|
||||
})
|
||||
export class LoginComponent {
|
||||
loginForm: FormGroup;
|
||||
|
||||
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 {
|
||||
|
||||
}
|
||||
@@ -1 +1,6 @@
|
||||
/* 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";
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,11 @@
|
||||
{
|
||||
"compileOnSave": false,
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@rschneider/ng-daisyui": [
|
||||
"./dist/rschneider/ng-daisyui"
|
||||
]
|
||||
},
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
@@ -29,6 +34,12 @@
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
},
|
||||
{
|
||||
"path": "./projects/rschneider/ng-daisyui/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "./projects/rschneider/ng-daisyui/tsconfig.spec.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
POST http://localhost:3000/auth/login
|
||||
POST {{apiBaseUrl}}/auth/login
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
@@ -6,10 +6,17 @@ Content-Type: application/json
|
||||
"password": "123456"
|
||||
}
|
||||
|
||||
> {% client.global.set("auth_token", response.body.access_token); %}
|
||||
> {% client.global.set("auth_token", response.body.accessToken); %}
|
||||
|
||||
|
||||
### GET request with parameter
|
||||
GET http://localhost:3000/users
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,16 @@
|
||||
import { Controller, Post, Body, ValidationPipe } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
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 {
|
||||
@@ -10,4 +20,18 @@ export class AuthController {
|
||||
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 { JwtStrategy } from './jwt.strategy';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { JwtRefreshTokenStrategy } from './jwt-refresh.strategy';
|
||||
import { StringValue } from 'ms';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule, // <--- Import ConfigModule here
|
||||
ConfigModule,
|
||||
UserModule,
|
||||
PassportModule,
|
||||
// Restore the correct async registration for JwtModule
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (configService: ConfigService) => ({
|
||||
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],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
@@ -4,12 +4,15 @@ 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> {
|
||||
@@ -34,19 +37,84 @@ export class AuthService {
|
||||
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 payload = {
|
||||
|
||||
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 {
|
||||
access_token: this.jwtService.sign(payload),
|
||||
accessToken,
|
||||
refreshToken,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
}
|
||||
@@ -3,18 +3,32 @@ 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) {
|
||||
constructor(configService: ConfigService) {
|
||||
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
constructor(private configService: ConfigService) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: configService.get<string>('JWT_SECRET') as string,
|
||||
// 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);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
validate(payload: { sub: string; username: string; roles: Role[] }) {
|
||||
// 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,
|
||||
|
||||
@@ -24,4 +24,7 @@ export class User {
|
||||
@ManyToMany(() => UserGroup)
|
||||
@JoinTable()
|
||||
groups: UserGroup[];
|
||||
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
hashedRefreshToken: string | null;
|
||||
}
|
||||
|
||||
@@ -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"`);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -4,8 +4,7 @@ export interface LoginRequest{
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface LoginResponse{
|
||||
access_token: string;
|
||||
export interface LoginResponse {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -54,4 +54,20 @@ export class UserService {
|
||||
this.logger.log(`Removing user with id: ${id}`, 'UserService');
|
||||
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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user