Installing ng-matero
This commit is contained in:
84
front/app/.eslintrc.json
Normal file
84
front/app/.eslintrc.json
Normal file
@@ -0,0 +1,84 @@
|
||||
{
|
||||
"root": true,
|
||||
"ignorePatterns": ["dist"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["*.ts"],
|
||||
"parserOptions": {
|
||||
"project": ["tsconfig.json", "e2e/tsconfig.json"],
|
||||
"createDefaultProgram": true
|
||||
},
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:@angular-eslint/recommended",
|
||||
"plugin:@angular-eslint/template/process-inline-templates"
|
||||
],
|
||||
"rules": {
|
||||
"dot-notation": ["warn"],
|
||||
"max-len": [
|
||||
"warn",
|
||||
{
|
||||
"code": 100,
|
||||
"ignoreComments": true,
|
||||
"ignoreStrings": true,
|
||||
"ignoreTemplateLiterals": true
|
||||
}
|
||||
],
|
||||
"object-shorthand": [
|
||||
"warn",
|
||||
"always",
|
||||
{
|
||||
"avoidQuotes": true
|
||||
}
|
||||
],
|
||||
"quote-props": ["warn", "consistent-as-needed"],
|
||||
"quotes": [
|
||||
"warn",
|
||||
"single",
|
||||
{
|
||||
"allowTemplateLiterals": true
|
||||
}
|
||||
],
|
||||
"semi": ["warn", "always"],
|
||||
"@typescript-eslint/explicit-module-boundary-types": "off",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-empty-function": "off",
|
||||
"@typescript-eslint/no-non-null-assertion": "off",
|
||||
"@typescript-eslint/no-unused-vars": "off",
|
||||
"@angular-eslint/component-selector": [
|
||||
"error",
|
||||
{
|
||||
"type": "element",
|
||||
"style": "kebab-case"
|
||||
}
|
||||
],
|
||||
"@angular-eslint/directive-selector": [
|
||||
"error",
|
||||
{
|
||||
"type": "attribute",
|
||||
"style": "camelCase"
|
||||
}
|
||||
],
|
||||
"@angular-eslint/no-empty-lifecycle-method": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["*.html"],
|
||||
"extends": ["plugin:@angular-eslint/template/recommended"],
|
||||
"rules": {}
|
||||
},
|
||||
{
|
||||
"files": ["*.js"],
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 11
|
||||
},
|
||||
"env": {
|
||||
"node": true,
|
||||
"amd": true
|
||||
},
|
||||
"extends": ["eslint:recommended"],
|
||||
"rules": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
3
front/app/.prettierignore
Normal file
3
front/app/.prettierignore
Normal file
@@ -0,0 +1,3 @@
|
||||
# add files you wish to ignore here
|
||||
dist/
|
||||
node_modules/
|
||||
17
front/app/.prettierrc
Normal file
17
front/app/.prettierrc
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"arrowParens": "avoid",
|
||||
"bracketSpacing": true,
|
||||
"htmlWhitespaceSensitivity": "css",
|
||||
"insertPragma": false,
|
||||
"jsxBracketSameLine": false,
|
||||
"jsxSingleQuote": false,
|
||||
"printWidth": 100,
|
||||
"proseWrap": "preserve",
|
||||
"quoteProps": "consistent",
|
||||
"requirePragma": false,
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "es5",
|
||||
"useTabs": false
|
||||
}
|
||||
39
front/app/.stylelintrc
Normal file
39
front/app/.stylelintrc
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"ignoreFiles": [
|
||||
"dist/**",
|
||||
"schematics/**"
|
||||
],
|
||||
"extends": [
|
||||
"stylelint-config-standard",
|
||||
"stylelint-config-recommended-scss",
|
||||
"stylelint-config-rational-order"
|
||||
],
|
||||
"plugins": [
|
||||
"stylelint-order"
|
||||
],
|
||||
"rules": {
|
||||
"alpha-value-notation": null,
|
||||
"annotation-no-unknown": null,
|
||||
"at-rule-empty-line-before": null,
|
||||
"color-function-notation": "legacy",
|
||||
"function-no-unknown": null,
|
||||
"no-descending-specificity": null,
|
||||
"no-empty-source": null,
|
||||
"number-leading-zero": "never",
|
||||
"selector-pseudo-element-no-unknown": [
|
||||
true,
|
||||
{
|
||||
"ignorePseudoElements": [
|
||||
"ng-deep"
|
||||
]
|
||||
}
|
||||
],
|
||||
"selector-class-pattern": null,
|
||||
"selector-type-no-unknown": null,
|
||||
"string-quotes": "single",
|
||||
"value-keyword-case": null,
|
||||
"scss/at-extend-no-missing-placeholder": null,
|
||||
"scss/comment-no-empty": null,
|
||||
"scss/operator-no-unspaced": null
|
||||
}
|
||||
}
|
||||
21
front/app/.vscode/extensions.json
vendored
21
front/app/.vscode/extensions.json
vendored
@@ -1,4 +1,17 @@
|
||||
{
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
|
||||
"recommendations": ["angular.ng-template"]
|
||||
}
|
||||
{
|
||||
// See http://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations.
|
||||
// Extension identifier format: ${publisher}.${name}. Example: vscode.csharp
|
||||
|
||||
// List of extensions which should be recommended for users of this workspace.
|
||||
"recommendations": [
|
||||
"angular.ng-template",
|
||||
"cyrilletuzi.angular-schematics",
|
||||
"apk27.ngx-translate-lookup",
|
||||
"esbenp.prettier-vscode",
|
||||
"stylelint.vscode-stylelint",
|
||||
"syler.sass-indented",
|
||||
"editorconfig.editorconfig",
|
||||
"box-of-hats.quick-material-icons",
|
||||
"mrmlnc.vscode-scss"
|
||||
]
|
||||
}
|
||||
|
||||
20
front/app/.vscode/settings.json
vendored
Normal file
20
front/app/.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"editor.rulers": [100],
|
||||
"html.format.wrapLineLength": 100,
|
||||
"html.format.wrapAttributes": "preserve-aligned",
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[jsonc]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": true,
|
||||
"source.fixAll.stylelint": true
|
||||
},
|
||||
"ngx-translate.lookup.resourcesType": "json",
|
||||
"ngx-translate.lookup.resourcesPath": "src/assets/i18n/zh-CN.json",
|
||||
}
|
||||
21
front/app/LICENSE
Normal file
21
front/app/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2019 Zongbin
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -25,7 +25,7 @@
|
||||
"src/assets"
|
||||
],
|
||||
"styles": [
|
||||
"@angular/material/prebuilt-themes/deeppurple-amber.css",
|
||||
"src/styles.scss",
|
||||
"src/styles.css"
|
||||
],
|
||||
"scripts": []
|
||||
@@ -36,12 +36,12 @@
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "500kb",
|
||||
"maximumError": "1mb"
|
||||
"maximumError": "4mb"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "2kb",
|
||||
"maximumError": "4kb"
|
||||
"maximumError": "16kb"
|
||||
}
|
||||
],
|
||||
"outputHashing": "all"
|
||||
@@ -65,6 +65,9 @@
|
||||
},
|
||||
"development": {
|
||||
"browserTarget": "app:build:development"
|
||||
},
|
||||
"options": {
|
||||
"proxyConfig": "proxy.config.js"
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "development"
|
||||
@@ -88,11 +91,20 @@
|
||||
"src/assets"
|
||||
],
|
||||
"styles": [
|
||||
"@angular/material/prebuilt-themes/deeppurple-amber.css",
|
||||
"src/styles.scss",
|
||||
"src/styles.css"
|
||||
],
|
||||
"scripts": []
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"builder": "@angular-eslint/builder:lint",
|
||||
"options": {
|
||||
"lintFilePatterns": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.html"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
6621
front/app/package-lock.json
generated
6621
front/app/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -6,35 +6,70 @@
|
||||
"start": "ng serve",
|
||||
"build": "ng build",
|
||||
"watch": "ng build --watch --configuration development",
|
||||
"test": "ng test"
|
||||
"test": "ng test",
|
||||
"build:prod": "ng build --prod",
|
||||
"lint": "npm run lint:ts && npm run lint:scss",
|
||||
"lint:ts": "eslint \"src/**/*.ts\" --fix",
|
||||
"lint:scss": "stylelint \"src/**/*.scss\" --fix",
|
||||
"hmr": "ng serve --hmr --disable-host-check"
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/animations": "^15.0.0",
|
||||
"@angular/cdk": "^15.0.4",
|
||||
"@angular/cdk": "~15.0.3",
|
||||
"@angular/common": "^15.0.0",
|
||||
"@angular/compiler": "^15.0.0",
|
||||
"@angular/core": "^15.0.0",
|
||||
"@angular/forms": "^15.0.0",
|
||||
"@angular/material": "^15.0.4",
|
||||
"@angular/material": "~15.0.3",
|
||||
"@angular/material-moment-adapter": "~15.0.3",
|
||||
"@angular/platform-browser": "^15.0.0",
|
||||
"@angular/platform-browser-dynamic": "^15.0.0",
|
||||
"@angular/router": "^15.0.0",
|
||||
"@ng-matero/extensions": "^15.0.1",
|
||||
"@ng-matero/extensions-moment-adapter": "^15.0.0",
|
||||
"@ngx-formly/core": "^6.0.4",
|
||||
"@ngx-formly/material": "^6.0.4",
|
||||
"@ngx-translate/core": "^14.0.0",
|
||||
"@ngx-translate/http-loader": "^7.0.0",
|
||||
"moment": "^2.29.4",
|
||||
"ng-matero": "^15.1.1",
|
||||
"ngx-permissions": "^14.0.0",
|
||||
"ngx-progressbar": "^9.0.0",
|
||||
"ngx-toastr": "^16.0.1",
|
||||
"photoviewer": "^3.6.6",
|
||||
"rxjs": "~7.5.0",
|
||||
"screenfull": "^6.0.2",
|
||||
"tslib": "^2.3.0",
|
||||
"zone.js": "~0.12.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-devkit/build-angular": "^15.0.5",
|
||||
"@angular-eslint/builder": "^15.1.0",
|
||||
"@angular-eslint/eslint-plugin": "^15.1.0",
|
||||
"@angular-eslint/eslint-plugin-template": "^15.1.0",
|
||||
"@angular-eslint/schematics": "^15.1.0",
|
||||
"@angular-eslint/template-parser": "^15.1.0",
|
||||
"@angular/cli": "~15.0.5",
|
||||
"@angular/compiler-cli": "^15.0.0",
|
||||
"@types/jasmine": "~4.3.0",
|
||||
"@typescript-eslint/eslint-plugin": "^5.46.0",
|
||||
"@typescript-eslint/parser": "^5.46.0",
|
||||
"eslint": "^8.30.0",
|
||||
"jasmine-core": "~4.5.0",
|
||||
"karma": "~6.4.0",
|
||||
"karma-chrome-launcher": "~3.1.0",
|
||||
"karma-coverage": "~2.2.0",
|
||||
"karma-jasmine": "~5.1.0",
|
||||
"karma-jasmine-html-reporter": "~2.0.0",
|
||||
"parse5": "^7.1.2",
|
||||
"prettier": "^2.8.1",
|
||||
"stylelint": "^14.16.0",
|
||||
"stylelint-config-rational-order": "^0.1.2",
|
||||
"stylelint-config-recommended-scss": "^8.0.0",
|
||||
"stylelint-config-standard": "^29.0.0",
|
||||
"stylelint-order": "^5.0.0",
|
||||
"stylelint-scss": "^4.3.0",
|
||||
"typescript": "~4.8.2"
|
||||
}
|
||||
}
|
||||
22
front/app/proxy.config.js
Normal file
22
front/app/proxy.config.js
Normal file
@@ -0,0 +1,22 @@
|
||||
// https://angular.io/guide/build#proxying-to-a-backend-server
|
||||
|
||||
const PROXY_CONFIG = {
|
||||
'/users/**': {
|
||||
target: 'https://api.github.com',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
logLevel: 'debug',
|
||||
// onProxyReq: (proxyReq, req, res) => {
|
||||
// const cookieMap = {
|
||||
// SID: '',
|
||||
// };
|
||||
// let cookie = '';
|
||||
// for (const key of Object.keys(cookieMap)) {
|
||||
// cookie += `${key}=${cookieMap[key]}; `;
|
||||
// }
|
||||
// proxyReq.setHeader('cookie', cookie);
|
||||
// },
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = PROXY_CONFIG;
|
||||
@@ -1,10 +0,0 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { RouterModule, Routes } from '@angular/router';
|
||||
|
||||
const routes: Routes = [];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forRoot(routes)],
|
||||
exports: [RouterModule]
|
||||
})
|
||||
export class AppRoutingModule { }
|
||||
@@ -1,3 +0,0 @@
|
||||
<app-auth></app-auth>
|
||||
|
||||
<router-outlet></router-outlet>
|
||||
@@ -1,35 +0,0 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { RouterTestingModule } from '@angular/router/testing';
|
||||
import { AppComponent } from './app.component';
|
||||
|
||||
describe('AppComponent', () => {
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [
|
||||
RouterTestingModule
|
||||
],
|
||||
declarations: [
|
||||
AppComponent
|
||||
],
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
it('should create the app', () => {
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
const app = fixture.componentInstance;
|
||||
expect(app).toBeTruthy();
|
||||
});
|
||||
|
||||
it(`should have as title 'app'`, () => {
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
const app = fixture.componentInstance;
|
||||
expect(app.title).toEqual('app');
|
||||
});
|
||||
|
||||
it('should render title', () => {
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
fixture.detectChanges();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
expect(compiled.querySelector('.content span')?.textContent).toContain('app app is running!');
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,16 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { Component, OnInit, AfterViewInit } from '@angular/core';
|
||||
import { PreloaderService } from '@core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
templateUrl: './app.component.html',
|
||||
styleUrls: ['./app.component.css']
|
||||
template: '<router-outlet></router-outlet>',
|
||||
})
|
||||
export class AppComponent {
|
||||
title = 'app';
|
||||
export class AppComponent implements OnInit, AfterViewInit {
|
||||
constructor(private preloader: PreloaderService) {}
|
||||
|
||||
ngOnInit() {}
|
||||
|
||||
ngAfterViewInit() {
|
||||
this.preloader.hide();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,58 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { HttpClientModule, HttpClient } from '@angular/common/http';
|
||||
import { BrowserModule } from '@angular/platform-browser';
|
||||
|
||||
import { AppRoutingModule } from './app-routing.module';
|
||||
import { AppComponent } from './app.component';
|
||||
import { AuthComponent } from './auth/auth.component';
|
||||
|
||||
import { CoreModule } from '@core/core.module';
|
||||
import { ThemeModule } from '@theme/theme.module';
|
||||
import { SharedModule } from '@shared/shared.module';
|
||||
import { RoutesModule } from './routes/routes.module';
|
||||
import { FormlyConfigModule } from './formly-config.module';
|
||||
import { NgxPermissionsModule } from 'ngx-permissions';
|
||||
import { ToastrModule } from 'ngx-toastr';
|
||||
import { TranslateModule, TranslateLoader } from '@ngx-translate/core';
|
||||
import { TranslateHttpLoader } from '@ngx-translate/http-loader';
|
||||
|
||||
import { environment } from '@env/environment';
|
||||
import { BASE_URL, httpInterceptorProviders, appInitializerProviders } from '@core';
|
||||
|
||||
// Required for AOT compilation
|
||||
export function TranslateHttpLoaderFactory(http: HttpClient) {
|
||||
return new TranslateHttpLoader(http, './assets/i18n/', '.json');
|
||||
}
|
||||
|
||||
import { LoginService } from '@core/authentication/login.service';
|
||||
import { FakeLoginService } from './fake-login.service';
|
||||
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
AppComponent,
|
||||
AuthComponent
|
||||
|
||||
],
|
||||
declarations: [AppComponent],
|
||||
imports: [
|
||||
BrowserModule,
|
||||
AppRoutingModule,
|
||||
BrowserAnimationsModule
|
||||
HttpClientModule,
|
||||
CoreModule,
|
||||
ThemeModule,
|
||||
RoutesModule,
|
||||
SharedModule,
|
||||
FormlyConfigModule.forRoot(),
|
||||
NgxPermissionsModule.forRoot(),
|
||||
ToastrModule.forRoot(),
|
||||
TranslateModule.forRoot({
|
||||
loader: {
|
||||
provide: TranslateLoader,
|
||||
useFactory: TranslateHttpLoaderFactory,
|
||||
deps: [HttpClient],
|
||||
},
|
||||
}),
|
||||
BrowserAnimationsModule,
|
||||
],
|
||||
providers: [],
|
||||
bootstrap: [AppComponent]
|
||||
providers: [
|
||||
{ provide: BASE_URL, useValue: environment.baseUrl },
|
||||
{ provide: LoginService, useClass: FakeLoginService }, // <= Remove it in the real APP
|
||||
httpInterceptorProviders,
|
||||
appInitializerProviders,
|
||||
],
|
||||
bootstrap: [AppComponent],
|
||||
})
|
||||
export class AppModule { }
|
||||
export class AppModule {}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<p>auth works!</p>
|
||||
@@ -1,23 +0,0 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { AuthComponent } from './auth.component';
|
||||
|
||||
describe('AuthComponent', () => {
|
||||
let component: AuthComponent;
|
||||
let fixture: ComponentFixture<AuthComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [ AuthComponent ]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(AuthComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,10 +0,0 @@
|
||||
import { Component } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-auth',
|
||||
templateUrl: './auth.component.html',
|
||||
styleUrls: ['./auth.component.css']
|
||||
})
|
||||
export class AuthComponent {
|
||||
|
||||
}
|
||||
17
front/app/src/app/core/authentication/README.md
Normal file
17
front/app/src/app/core/authentication/README.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# Authentication
|
||||
|
||||
1. Modify the token key at `token.service` to another name such as `TOKEN` or `your-app-token`. By default set to `ng-matero-token`.
|
||||
|
||||
2. Replace the APIs at `login.service` with your owns.
|
||||
|
||||
- `/auth/login` Login
|
||||
- `/auth/refresh` Refresh
|
||||
- `/auth/logout` Logout
|
||||
- `/me` Get user information
|
||||
- `/me/menu` Get user menu
|
||||
|
||||
3. If you have modified the login url (defaults to `auth/login`), you should correct it in the following files.
|
||||
|
||||
- `auth.guard.ts`
|
||||
- `error-interceptor.ts`
|
||||
- `token-interceptor.ts`
|
||||
56
front/app/src/app/core/authentication/auth.guard.spec.ts
Normal file
56
front/app/src/app/core/authentication/auth.guard.spec.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { Router } from '@angular/router';
|
||||
import { HttpClientTestingModule } from '@angular/common/http/testing';
|
||||
import { Component } from '@angular/core';
|
||||
import { RouterTestingModule } from '@angular/router/testing';
|
||||
import { LocalStorageService, MemoryStorageService } from '@shared/services/storage.service';
|
||||
import { TokenService, AuthGuard, AuthService } from '@core/authentication';
|
||||
|
||||
@Component({ template: '' })
|
||||
class DummyComponent {}
|
||||
|
||||
describe('AuthGuard', () => {
|
||||
const route: any = {};
|
||||
const state: any = {};
|
||||
let router: Router;
|
||||
let authGuard: AuthGuard;
|
||||
let authService: AuthService;
|
||||
let tokenService: TokenService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [
|
||||
HttpClientTestingModule,
|
||||
RouterTestingModule.withRoutes([
|
||||
{ path: 'dashboard', component: DummyComponent, canActivate: [AuthGuard] },
|
||||
{ path: 'auth/login', component: DummyComponent },
|
||||
]),
|
||||
],
|
||||
declarations: [DummyComponent],
|
||||
providers: [{ provide: LocalStorageService, useClass: MemoryStorageService }],
|
||||
});
|
||||
TestBed.createComponent(DummyComponent);
|
||||
|
||||
router = TestBed.inject(Router);
|
||||
authGuard = TestBed.inject(AuthGuard);
|
||||
authService = TestBed.inject(AuthService);
|
||||
tokenService = TestBed.inject(TokenService);
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(authGuard).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should be authenticated', () => {
|
||||
tokenService.set({ access_token: 'token', token_type: 'bearer' });
|
||||
|
||||
expect(authGuard.canActivate(route, state)).toBeTrue();
|
||||
});
|
||||
|
||||
it('should redirect to /auth/login when authenticate failed', () => {
|
||||
spyOn(authService, 'check').and.returnValue(false);
|
||||
|
||||
expect(authGuard.canActivate(route, state)).toEqual(router.parseUrl('/auth/login'));
|
||||
});
|
||||
});
|
||||
32
front/app/src/app/core/authentication/auth.guard.ts
Normal file
32
front/app/src/app/core/authentication/auth.guard.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import {
|
||||
ActivatedRouteSnapshot,
|
||||
CanActivate,
|
||||
CanActivateChild,
|
||||
Router,
|
||||
RouterStateSnapshot,
|
||||
UrlTree,
|
||||
} from '@angular/router';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class AuthGuard implements CanActivate, CanActivateChild {
|
||||
constructor(private auth: AuthService, private router: Router) {}
|
||||
|
||||
canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) {
|
||||
return this.authenticate();
|
||||
}
|
||||
|
||||
canActivateChild(
|
||||
childRoute: ActivatedRouteSnapshot,
|
||||
state: RouterStateSnapshot
|
||||
): boolean | UrlTree {
|
||||
return this.authenticate();
|
||||
}
|
||||
|
||||
private authenticate(): boolean | UrlTree {
|
||||
return this.auth.check() ? true : this.router.parseUrl('/auth/login');
|
||||
}
|
||||
}
|
||||
133
front/app/src/app/core/authentication/auth.service.spec.ts
Normal file
133
front/app/src/app/core/authentication/auth.service.spec.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { fakeAsync, TestBed, tick } from '@angular/core/testing';
|
||||
|
||||
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
|
||||
import { Observable } from 'rxjs';
|
||||
import { skip } from 'rxjs/operators';
|
||||
import { HttpRequest } from '@angular/common/http';
|
||||
import { LocalStorageService, MemoryStorageService } from '@shared/services/storage.service';
|
||||
import { AuthService, LoginService, TokenService, User } from '@core/authentication';
|
||||
|
||||
describe('AuthService', () => {
|
||||
let authService: AuthService;
|
||||
let loginService: LoginService;
|
||||
let tokenService: TokenService;
|
||||
let httpMock: HttpTestingController;
|
||||
let user$: Observable<User>;
|
||||
const email = 'foo@bar.com';
|
||||
const token = { access_token: 'token', token_type: 'bearer' };
|
||||
const user = { id: 1, email };
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HttpClientTestingModule],
|
||||
providers: [{ provide: LocalStorageService, useClass: MemoryStorageService }],
|
||||
});
|
||||
loginService = TestBed.inject(LoginService);
|
||||
authService = TestBed.inject(AuthService);
|
||||
tokenService = TestBed.inject(TokenService);
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
|
||||
user$ = authService.user();
|
||||
authService.change().subscribe(user => {
|
||||
expect(user).toBeInstanceOf(Object);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => httpMock.verify());
|
||||
|
||||
it('should be created', () => {
|
||||
expect(authService).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should log in failed', () => {
|
||||
authService.login(email, 'password', false).subscribe(isLogin => expect(isLogin).toBeFalse());
|
||||
httpMock.expectOne('/auth/login').flush({});
|
||||
|
||||
expect(authService.check()).toBeFalse();
|
||||
});
|
||||
|
||||
it('should log in successful and get user info', () => {
|
||||
user$.pipe(skip(1)).subscribe(currentUser => expect(currentUser.id).toEqual(user.id));
|
||||
authService.login(email, 'password', false).subscribe(isLogin => expect(isLogin).toBeTrue());
|
||||
httpMock.expectOne('/auth/login').flush(token);
|
||||
|
||||
expect(authService.check()).toBeTrue();
|
||||
httpMock.expectOne('/me').flush(user);
|
||||
});
|
||||
|
||||
it('should log out failed when user is not login', () => {
|
||||
spyOn(loginService, 'logout').and.callThrough();
|
||||
expect(authService.check()).toBeFalse();
|
||||
|
||||
authService.logout().subscribe();
|
||||
httpMock.expectOne('/auth/logout');
|
||||
|
||||
expect(authService.check()).toBeFalse();
|
||||
expect(loginService.logout).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should log out successful when user is login', () => {
|
||||
tokenService.set(token);
|
||||
expect(authService.check()).toBeTrue();
|
||||
httpMock.expectOne('/me').flush(user);
|
||||
|
||||
user$.pipe(skip(1)).subscribe(currentUser => expect(currentUser.id).toBeUndefined());
|
||||
authService.logout().subscribe();
|
||||
httpMock.expectOne('/auth/logout').flush({});
|
||||
|
||||
expect(authService.check()).toBeFalse();
|
||||
});
|
||||
|
||||
it('should refresh token when access_token is valid', fakeAsync(() => {
|
||||
tokenService.set(Object.assign({ expires_in: 5 }, token));
|
||||
expect(authService.check()).toBeTrue();
|
||||
httpMock.expectOne('/me').flush(user);
|
||||
const match = (req: HttpRequest<any>) => req.url === '/auth/refresh' && !req.body.refresh_token;
|
||||
|
||||
tick(4000);
|
||||
expect(authService.check()).toBeTrue();
|
||||
httpMock.match(match)[0].flush(token);
|
||||
|
||||
expect(authService.check()).toBeTrue();
|
||||
httpMock.expectNone('/me');
|
||||
tokenService.ngOnDestroy();
|
||||
}));
|
||||
|
||||
it('should refresh token when access_token is invalid and refresh_token is valid', fakeAsync(() => {
|
||||
tokenService.set(Object.assign({ expires_in: 5, refresh_token: 'foo' }, token));
|
||||
const match = (req: HttpRequest<any>) =>
|
||||
req.url === '/auth/refresh' && req.body.refresh_token === 'foo';
|
||||
|
||||
expect(authService.check()).toBeTrue();
|
||||
httpMock.expectOne('/me').flush(user);
|
||||
tick(10000);
|
||||
expect(authService.check()).toBeFalse();
|
||||
httpMock.match(match)[0].flush(token);
|
||||
|
||||
expect(authService.check()).toBeTrue();
|
||||
httpMock.expectNone('/me');
|
||||
tokenService.ngOnDestroy();
|
||||
}));
|
||||
|
||||
it('it should clear token when access_token is invalid and refresh token response is 401', fakeAsync(() => {
|
||||
spyOn(tokenService, 'set').and.callThrough();
|
||||
tokenService.set(Object.assign({ expires_in: 5, refresh_token: 'foo' }, token));
|
||||
const match = (req: HttpRequest<any>) =>
|
||||
req.url === '/auth/refresh' && req.body.refresh_token === 'foo';
|
||||
|
||||
tick(10000);
|
||||
expect(authService.check()).toBeFalse();
|
||||
httpMock.expectOne('/me').flush({});
|
||||
httpMock.match(match)[0].flush({}, { status: 401, statusText: 'Unauthorized' });
|
||||
|
||||
expect(authService.check()).toBeFalse();
|
||||
expect(tokenService.set).toHaveBeenCalledWith(undefined);
|
||||
tokenService.ngOnDestroy();
|
||||
}));
|
||||
|
||||
it('it only call http request once when on change subscribe twice', () => {
|
||||
authService.change().subscribe();
|
||||
tokenService.set(token);
|
||||
httpMock.expectOne('/me').flush({});
|
||||
});
|
||||
});
|
||||
79
front/app/src/app/core/authentication/auth.service.ts
Normal file
79
front/app/src/app/core/authentication/auth.service.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { BehaviorSubject, iif, merge, of } from 'rxjs';
|
||||
import { catchError, map, share, switchMap, tap } from 'rxjs/operators';
|
||||
import { TokenService } from './token.service';
|
||||
import { LoginService } from './login.service';
|
||||
import { filterObject, isEmptyObject } from './helpers';
|
||||
import { User } from './interface';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class AuthService {
|
||||
private user$ = new BehaviorSubject<User>({});
|
||||
private change$ = merge(
|
||||
this.tokenService.change(),
|
||||
this.tokenService.refresh().pipe(switchMap(() => this.refresh()))
|
||||
).pipe(
|
||||
switchMap(() => this.assignUser()),
|
||||
share()
|
||||
);
|
||||
|
||||
constructor(private loginService: LoginService, private tokenService: TokenService) {}
|
||||
|
||||
init() {
|
||||
return new Promise<void>(resolve => this.change$.subscribe(() => resolve()));
|
||||
}
|
||||
|
||||
change() {
|
||||
return this.change$;
|
||||
}
|
||||
|
||||
check() {
|
||||
return this.tokenService.valid();
|
||||
}
|
||||
|
||||
login(username: string, password: string, rememberMe = false) {
|
||||
return this.loginService.login(username, password, rememberMe).pipe(
|
||||
tap(token => this.tokenService.set(token)),
|
||||
map(() => this.check())
|
||||
);
|
||||
}
|
||||
|
||||
refresh() {
|
||||
return this.loginService
|
||||
.refresh(filterObject({ refresh_token: this.tokenService.getRefreshToken() }))
|
||||
.pipe(
|
||||
catchError(() => of(undefined)),
|
||||
tap(token => this.tokenService.set(token)),
|
||||
map(() => this.check())
|
||||
);
|
||||
}
|
||||
|
||||
logout() {
|
||||
return this.loginService.logout().pipe(
|
||||
tap(() => this.tokenService.clear()),
|
||||
map(() => !this.check())
|
||||
);
|
||||
}
|
||||
|
||||
user() {
|
||||
return this.user$.pipe(share());
|
||||
}
|
||||
|
||||
menu() {
|
||||
return iif(() => this.check(), this.loginService.menu(), of([]));
|
||||
}
|
||||
|
||||
private assignUser() {
|
||||
if (!this.check()) {
|
||||
return of({}).pipe(tap(user => this.user$.next(user)));
|
||||
}
|
||||
|
||||
if (!isEmptyObject(this.user$.getValue())) {
|
||||
return of(this.user$.getValue());
|
||||
}
|
||||
|
||||
return this.loginService.me().pipe(tap(user => this.user$.next(user)));
|
||||
}
|
||||
}
|
||||
57
front/app/src/app/core/authentication/helpers.ts
Normal file
57
front/app/src/app/core/authentication/helpers.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { fromByteArray, toByteArray } from 'base64-js';
|
||||
|
||||
export class Base64 {
|
||||
static encode(plainText: string): string {
|
||||
return fromByteArray(pack(plainText)).replace(/[+/=]/g, m => {
|
||||
return { '+': '-', '/': '_', '=': '' }[m] as string;
|
||||
});
|
||||
}
|
||||
|
||||
static decode(b64: string): string {
|
||||
b64 = b64.replace(/[-_]/g, m => {
|
||||
return { '-': '+', '_': '/' }[m] as string;
|
||||
});
|
||||
while (b64.length % 4) {
|
||||
b64 += '=';
|
||||
}
|
||||
|
||||
return unpack(toByteArray(b64));
|
||||
}
|
||||
}
|
||||
|
||||
export function pack(str: string) {
|
||||
const bytes: any = [];
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
bytes.push(...[str.charCodeAt(i)]);
|
||||
}
|
||||
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export function unpack(byteArray: any) {
|
||||
return String.fromCharCode(...byteArray);
|
||||
}
|
||||
|
||||
export const base64 = { encode: Base64.encode, decode: Base64.decode };
|
||||
|
||||
export function capitalize(text: string): string {
|
||||
return text.substring(0, 1).toUpperCase() + text.substring(1, text.length).toLowerCase();
|
||||
}
|
||||
|
||||
export function currentTimestamp(): number {
|
||||
return Math.ceil(new Date().getTime() / 1000);
|
||||
}
|
||||
|
||||
export function timeLeft(expiredAt: number): number {
|
||||
return Math.max(0, expiredAt - currentTimestamp());
|
||||
}
|
||||
|
||||
export function filterObject<T extends Record<string, unknown>>(obj: T) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(obj).filter(([, value]) => value !== undefined && value !== null)
|
||||
);
|
||||
}
|
||||
|
||||
export function isEmptyObject(obj: Record<string, any>) {
|
||||
return Object.keys(obj).length === 0;
|
||||
}
|
||||
9
front/app/src/app/core/authentication/index.ts
Normal file
9
front/app/src/app/core/authentication/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export * from './interface';
|
||||
export * from './auth.guard';
|
||||
export * from './auth.service';
|
||||
export * from './token-factory.service';
|
||||
export * from './token.service';
|
||||
export * from './token';
|
||||
export * from './login.service';
|
||||
export * from './user';
|
||||
export * from './helpers';
|
||||
20
front/app/src/app/core/authentication/interface.ts
Normal file
20
front/app/src/app/core/authentication/interface.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export interface User {
|
||||
[prop: string]: any;
|
||||
|
||||
id?: number | string | null;
|
||||
name?: string;
|
||||
email?: string;
|
||||
avatar?: string;
|
||||
roles?: any[];
|
||||
permissions?: any[];
|
||||
}
|
||||
|
||||
export interface Token {
|
||||
[prop: string]: any;
|
||||
|
||||
access_token: string;
|
||||
token_type?: string;
|
||||
expires_in?: number;
|
||||
exp?: number;
|
||||
refresh_token?: string;
|
||||
}
|
||||
32
front/app/src/app/core/authentication/login.service.ts
Normal file
32
front/app/src/app/core/authentication/login.service.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Token, User } from './interface';
|
||||
import { Menu } from '@core';
|
||||
import { map } from 'rxjs/operators';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class LoginService {
|
||||
constructor(protected http: HttpClient) {}
|
||||
|
||||
login(username: string, password: string, rememberMe = false) {
|
||||
return this.http.post<Token>('/auth/login', { username, password, rememberMe });
|
||||
}
|
||||
|
||||
refresh(params: Record<string, any>) {
|
||||
return this.http.post<Token>('/auth/refresh', params);
|
||||
}
|
||||
|
||||
logout() {
|
||||
return this.http.post<any>('/auth/logout', {});
|
||||
}
|
||||
|
||||
me() {
|
||||
return this.http.get<User>('/me');
|
||||
}
|
||||
|
||||
menu() {
|
||||
return this.http.get<{ menu: Menu[] }>('/me/menu').pipe(map(res => res.menu));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Token } from './interface';
|
||||
import { SimpleToken, JwtToken, BaseToken } from './token';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class TokenFactory {
|
||||
create(attributes: Token): BaseToken | undefined {
|
||||
if (!attributes.access_token) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (JwtToken.is(attributes.access_token)) {
|
||||
return new JwtToken(attributes);
|
||||
}
|
||||
|
||||
return new SimpleToken(attributes);
|
||||
}
|
||||
}
|
||||
52
front/app/src/app/core/authentication/token.service.spec.ts
Normal file
52
front/app/src/app/core/authentication/token.service.spec.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { tap } from 'rxjs/operators';
|
||||
import { MemoryStorageService, LocalStorageService } from '@shared/services/storage.service';
|
||||
import { TokenService, currentTimestamp, TokenFactory, SimpleToken } from '@core/authentication';
|
||||
|
||||
describe('TokenService', () => {
|
||||
let tokenService: TokenService;
|
||||
let tokenFactory: TokenFactory;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [{ provide: LocalStorageService, useClass: MemoryStorageService }],
|
||||
});
|
||||
tokenService = TestBed.inject(TokenService);
|
||||
tokenFactory = TestBed.inject(TokenFactory);
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(tokenService).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should get authorization header value', () => {
|
||||
tokenService.set({ access_token: 'token', token_type: 'bearer' });
|
||||
|
||||
expect(tokenService.getBearerToken()).toEqual('Bearer token');
|
||||
});
|
||||
|
||||
it('cannot get authorization header value', () => {
|
||||
tokenService.set({ access_token: '', token_type: 'bearer' });
|
||||
|
||||
expect(tokenService.getBearerToken()).toBe('');
|
||||
});
|
||||
|
||||
it('should not has exp when token has expires_in', () => {
|
||||
tokenService.set({ access_token: 'token', token_type: 'bearer' });
|
||||
|
||||
tokenService
|
||||
.change()
|
||||
.pipe(tap(token => expect(token!.exp).toBeUndefined()))
|
||||
.subscribe();
|
||||
});
|
||||
|
||||
it('should has exp when token has expires_in', () => {
|
||||
const expiresIn = 3600;
|
||||
tokenService.set({ access_token: 'token', token_type: 'bearer', expires_in: expiresIn });
|
||||
|
||||
tokenService
|
||||
.change()
|
||||
.pipe(tap(token => expect(token!.exp).toEqual(currentTimestamp() + expiresIn)))
|
||||
.subscribe();
|
||||
});
|
||||
});
|
||||
99
front/app/src/app/core/authentication/token.service.ts
Normal file
99
front/app/src/app/core/authentication/token.service.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { Injectable, OnDestroy } from '@angular/core';
|
||||
import { BehaviorSubject, Observable, Subject, Subscription, timer } from 'rxjs';
|
||||
import { share } from 'rxjs/operators';
|
||||
import { LocalStorageService } from '@shared';
|
||||
import { Token } from './interface';
|
||||
import { BaseToken } from './token';
|
||||
import { TokenFactory } from './token-factory.service';
|
||||
import { currentTimestamp, filterObject } from './helpers';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class TokenService implements OnDestroy {
|
||||
private key = 'ng-matero-token';
|
||||
|
||||
private change$ = new BehaviorSubject<BaseToken | undefined>(undefined);
|
||||
private refresh$ = new Subject<BaseToken | undefined>();
|
||||
private timer$?: Subscription;
|
||||
|
||||
private _token?: BaseToken;
|
||||
|
||||
constructor(private store: LocalStorageService, private factory: TokenFactory) {}
|
||||
|
||||
private get token(): BaseToken | undefined {
|
||||
if (!this._token) {
|
||||
this._token = this.factory.create(this.store.get(this.key));
|
||||
}
|
||||
|
||||
return this._token;
|
||||
}
|
||||
|
||||
change(): Observable<BaseToken | undefined> {
|
||||
return this.change$.pipe(share());
|
||||
}
|
||||
|
||||
refresh(): Observable<BaseToken | undefined> {
|
||||
this.buildRefresh();
|
||||
|
||||
return this.refresh$.pipe(share());
|
||||
}
|
||||
|
||||
set(token?: Token): TokenService {
|
||||
this.save(token);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.save();
|
||||
}
|
||||
|
||||
valid(): boolean {
|
||||
return this.token?.valid() ?? false;
|
||||
}
|
||||
|
||||
getBearerToken(): string {
|
||||
return this.token?.getBearerToken() ?? '';
|
||||
}
|
||||
|
||||
getRefreshToken(): string | void {
|
||||
return this.token?.refresh_token;
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.clearRefresh();
|
||||
}
|
||||
|
||||
private save(token?: Token): void {
|
||||
this._token = undefined;
|
||||
|
||||
if (!token) {
|
||||
this.store.remove(this.key);
|
||||
} else {
|
||||
const value = Object.assign({ access_token: '', token_type: 'Bearer' }, token, {
|
||||
exp: token.expires_in ? currentTimestamp() + token.expires_in : null,
|
||||
});
|
||||
this.store.set(this.key, filterObject(value));
|
||||
}
|
||||
|
||||
this.change$.next(this.token);
|
||||
this.buildRefresh();
|
||||
}
|
||||
|
||||
private buildRefresh() {
|
||||
this.clearRefresh();
|
||||
|
||||
if (this.token?.needRefresh()) {
|
||||
this.timer$ = timer(this.token.getRefreshTime() * 1000).subscribe(() => {
|
||||
this.refresh$.next(this.token);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private clearRefresh() {
|
||||
if (this.timer$ && !this.timer$.closed) {
|
||||
this.timer$.unsubscribe();
|
||||
}
|
||||
}
|
||||
}
|
||||
41
front/app/src/app/core/authentication/token.spec.ts
Normal file
41
front/app/src/app/core/authentication/token.spec.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { base64, currentTimestamp, JwtToken } from '@core/authentication';
|
||||
|
||||
describe('Token', () => {
|
||||
describe('JwtToken', () => {
|
||||
function generateToken(params: any, typ = 'JWT') {
|
||||
return [
|
||||
base64.encode(JSON.stringify({ typ, alg: 'HS256' })),
|
||||
base64.encode(JSON.stringify(params)),
|
||||
base64.encode('ng-matero'),
|
||||
].join('.');
|
||||
}
|
||||
|
||||
const exp = currentTimestamp() + 3600;
|
||||
const token = new JwtToken({
|
||||
access_token: generateToken({ exp }, 'at+JWT'),
|
||||
token_type: 'Bearer',
|
||||
});
|
||||
|
||||
it('test access_token is JWT', () => {
|
||||
expect(JwtToken.is(token.access_token)).toBeTrue();
|
||||
});
|
||||
|
||||
it('test bearer token', function () {
|
||||
expect(token.getBearerToken()).toBe(`Bearer ${token.access_token}`);
|
||||
});
|
||||
|
||||
it('test payload has exp attribute', () => {
|
||||
expect(token.exp).toEqual(exp);
|
||||
});
|
||||
|
||||
it('test payload does not has exp attribute', () => {
|
||||
expect(token.exp).toEqual(exp);
|
||||
});
|
||||
|
||||
it('test does not has exp attribute', () => {
|
||||
const token = new JwtToken({ access_token: generateToken({}), token_type: 'Bearer' });
|
||||
|
||||
expect(token.exp).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
87
front/app/src/app/core/authentication/token.ts
Normal file
87
front/app/src/app/core/authentication/token.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { base64, capitalize, currentTimestamp, timeLeft } from './helpers';
|
||||
import { Token } from './interface';
|
||||
|
||||
export abstract class BaseToken {
|
||||
constructor(protected attributes: Token) {}
|
||||
|
||||
get access_token(): string {
|
||||
return this.attributes.access_token;
|
||||
}
|
||||
|
||||
get refresh_token(): string | void {
|
||||
return this.attributes.refresh_token;
|
||||
}
|
||||
|
||||
get token_type(): string {
|
||||
return this.attributes.token_type ?? 'bearer';
|
||||
}
|
||||
|
||||
get exp(): number | void {
|
||||
return this.attributes.exp;
|
||||
}
|
||||
|
||||
valid(): boolean {
|
||||
return this.hasAccessToken() && !this.isExpired();
|
||||
}
|
||||
|
||||
getBearerToken(): string {
|
||||
return this.access_token
|
||||
? [capitalize(this.token_type), this.access_token].join(' ').trim()
|
||||
: '';
|
||||
}
|
||||
|
||||
needRefresh(): boolean {
|
||||
return this.exp !== undefined && this.exp >= 0;
|
||||
}
|
||||
|
||||
getRefreshTime(): number {
|
||||
return timeLeft((this.exp ?? 0) - 5);
|
||||
}
|
||||
|
||||
private hasAccessToken(): boolean {
|
||||
return !!this.access_token;
|
||||
}
|
||||
|
||||
private isExpired(): boolean {
|
||||
return this.exp !== undefined && this.exp - currentTimestamp() <= 0;
|
||||
}
|
||||
}
|
||||
|
||||
export class SimpleToken extends BaseToken {}
|
||||
|
||||
export class JwtToken extends SimpleToken {
|
||||
private _payload?: { exp?: number | void };
|
||||
|
||||
static is(accessToken: string): boolean {
|
||||
try {
|
||||
const [_header] = accessToken.split('.');
|
||||
const header = JSON.parse(base64.decode(_header));
|
||||
|
||||
return header.typ.toUpperCase().includes('JWT');
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
get exp(): number | void {
|
||||
return this.payload?.exp;
|
||||
}
|
||||
|
||||
private get payload(): { exp?: number | void } {
|
||||
if (!this.access_token) {
|
||||
return {};
|
||||
}
|
||||
|
||||
if (this._payload) {
|
||||
return this._payload;
|
||||
}
|
||||
|
||||
const [, payload] = this.access_token.split('.');
|
||||
const data = JSON.parse(base64.decode(payload));
|
||||
if (!data.exp) {
|
||||
data.exp = this.attributes.exp;
|
||||
}
|
||||
|
||||
return (this._payload = data);
|
||||
}
|
||||
}
|
||||
14
front/app/src/app/core/authentication/user.ts
Normal file
14
front/app/src/app/core/authentication/user.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { User } from './interface';
|
||||
|
||||
export const admin: User = {
|
||||
id: 1,
|
||||
name: 'Zongbin',
|
||||
email: 'nzb329@163.com',
|
||||
avatar: './assets/images/avatar.jpg',
|
||||
};
|
||||
|
||||
export const guest: User = {
|
||||
name: 'unknown',
|
||||
email: 'unknown',
|
||||
avatar: './assets/images/avatar-default.jpg',
|
||||
};
|
||||
3
front/app/src/app/core/bootstrap/README.md
Normal file
3
front/app/src/app/core/bootstrap/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Bootstrap
|
||||
|
||||
The services in this folder should be singletons and used for sharing data and functionality.
|
||||
150
front/app/src/app/core/bootstrap/menu.service.ts
Normal file
150
front/app/src/app/core/bootstrap/menu.service.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { BehaviorSubject, Observable } from 'rxjs';
|
||||
import { share } from 'rxjs/operators';
|
||||
|
||||
export interface MenuTag {
|
||||
color: string; // background color
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface MenuPermissions {
|
||||
only?: string | string[];
|
||||
except?: string | string[];
|
||||
}
|
||||
|
||||
export interface MenuChildrenItem {
|
||||
route: string;
|
||||
name: string;
|
||||
type: 'link' | 'sub' | 'extLink' | 'extTabLink';
|
||||
children?: MenuChildrenItem[];
|
||||
permissions?: MenuPermissions;
|
||||
}
|
||||
|
||||
export interface Menu {
|
||||
route: string;
|
||||
name: string;
|
||||
type: 'link' | 'sub' | 'extLink' | 'extTabLink';
|
||||
icon: string;
|
||||
label?: MenuTag;
|
||||
badge?: MenuTag;
|
||||
children?: MenuChildrenItem[];
|
||||
permissions?: MenuPermissions;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class MenuService {
|
||||
private menu$: BehaviorSubject<Menu[]> = new BehaviorSubject<Menu[]>([]);
|
||||
|
||||
/** Get all the menu data. */
|
||||
getAll(): Observable<Menu[]> {
|
||||
return this.menu$.asObservable();
|
||||
}
|
||||
|
||||
/** Observe the change of menu data. */
|
||||
change(): Observable<Menu[]> {
|
||||
return this.menu$.pipe(share());
|
||||
}
|
||||
|
||||
/** Initialize the menu data. */
|
||||
set(menu: Menu[]): Observable<Menu[]> {
|
||||
this.menu$.next(menu);
|
||||
return this.menu$.asObservable();
|
||||
}
|
||||
|
||||
/** Add one item to the menu data. */
|
||||
add(menu: Menu) {
|
||||
const tmpMenu = this.menu$.value;
|
||||
tmpMenu.push(menu);
|
||||
this.menu$.next(tmpMenu);
|
||||
}
|
||||
|
||||
/** Reset the menu data. */
|
||||
reset() {
|
||||
this.menu$.next([]);
|
||||
}
|
||||
|
||||
/** Delete empty values and rebuild route. */
|
||||
buildRoute(routeArr: string[]): string {
|
||||
let route = '';
|
||||
routeArr.forEach(item => {
|
||||
if (item && item.trim()) {
|
||||
route += '/' + item.replace(/^\/+|\/+$/g, '');
|
||||
}
|
||||
});
|
||||
return route;
|
||||
}
|
||||
|
||||
/** Get the menu item name based on current route. */
|
||||
getItemName(routeArr: string[]): string {
|
||||
return this.getLevel(routeArr)[routeArr.length - 1];
|
||||
}
|
||||
|
||||
// Whether is a leaf menu
|
||||
private isLeafItem(item: MenuChildrenItem): boolean {
|
||||
const cond0 = item.route === undefined;
|
||||
const cond1 = item.children === undefined;
|
||||
const cond2 = !cond1 && item.children?.length === 0;
|
||||
return cond0 || cond1 || cond2;
|
||||
}
|
||||
|
||||
// Deep clone object could be jsonized
|
||||
private deepClone(obj: any): any {
|
||||
return JSON.parse(JSON.stringify(obj));
|
||||
}
|
||||
|
||||
// Whether two objects could be jsonized equal
|
||||
private isJsonObjEqual(obj0: any, obj1: any): boolean {
|
||||
return JSON.stringify(obj0) === JSON.stringify(obj1);
|
||||
}
|
||||
|
||||
// Whether routeArr equals realRouteArr (after remove empty route element)
|
||||
private isRouteEqual(routeArr: Array<string>, realRouteArr: Array<string>): boolean {
|
||||
realRouteArr = this.deepClone(realRouteArr);
|
||||
realRouteArr = realRouteArr.filter(r => r !== '');
|
||||
return this.isJsonObjEqual(routeArr, realRouteArr);
|
||||
}
|
||||
|
||||
/** Get the menu level. */
|
||||
getLevel(routeArr: string[]): string[] {
|
||||
let tmpArr: any[] = [];
|
||||
this.menu$.value.forEach(item => {
|
||||
// Breadth-first traverse
|
||||
let unhandledLayer = [{ item, parentNamePathList: [], realRouteArr: [] }];
|
||||
while (unhandledLayer.length > 0) {
|
||||
let nextUnhandledLayer: any[] = [];
|
||||
for (const ele of unhandledLayer) {
|
||||
const eachItem = ele.item;
|
||||
const currentNamePathList = this.deepClone(ele.parentNamePathList).concat(eachItem.name);
|
||||
const currentRealRouteArr = this.deepClone(ele.realRouteArr).concat(eachItem.route);
|
||||
// Compare the full Array for expandable
|
||||
if (this.isRouteEqual(routeArr, currentRealRouteArr)) {
|
||||
tmpArr = currentNamePathList;
|
||||
break;
|
||||
}
|
||||
if (!this.isLeafItem(eachItem)) {
|
||||
const wrappedChildren = eachItem.children?.map(child => ({
|
||||
item: child,
|
||||
parentNamePathList: currentNamePathList,
|
||||
realRouteArr: currentRealRouteArr,
|
||||
}));
|
||||
nextUnhandledLayer = nextUnhandledLayer.concat(wrappedChildren);
|
||||
}
|
||||
}
|
||||
unhandledLayer = nextUnhandledLayer;
|
||||
}
|
||||
});
|
||||
return tmpArr;
|
||||
}
|
||||
|
||||
/** Add namespace for translation. */
|
||||
addNamespace(menu: Menu[] | MenuChildrenItem[], namespace: string) {
|
||||
menu.forEach(menuItem => {
|
||||
menuItem.name = `${namespace}.${menuItem.name}`;
|
||||
if (menuItem.children && menuItem.children.length > 0) {
|
||||
this.addNamespace(menuItem.children, menuItem.name);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
28
front/app/src/app/core/bootstrap/preloader.service.ts
Normal file
28
front/app/src/app/core/bootstrap/preloader.service.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Inject, Injectable } from '@angular/core';
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class PreloaderService {
|
||||
private selector = 'globalLoader';
|
||||
|
||||
constructor(@Inject(DOCUMENT) private document: Document) {}
|
||||
|
||||
private getElement() {
|
||||
return this.document.getElementById(this.selector);
|
||||
}
|
||||
|
||||
hide() {
|
||||
const el = this.getElement();
|
||||
if (el) {
|
||||
el.addEventListener('transitionend', () => {
|
||||
el.className = 'global-loader-hidden';
|
||||
});
|
||||
|
||||
if (!el.classList.contains('global-loader-hidden')) {
|
||||
el.className += ' global-loader-fade-in';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
78
front/app/src/app/core/bootstrap/sanctum.service.spec.ts
Normal file
78
front/app/src/app/core/bootstrap/sanctum.service.spec.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { BASE_URL } from '../interceptors/base-url-interceptor';
|
||||
import { SANCTUM_PREFIX, SanctumService } from '@core';
|
||||
|
||||
describe('SanctumService', () => {
|
||||
let httpMock: HttpTestingController;
|
||||
let http: HttpClient;
|
||||
let sanctumService: SanctumService;
|
||||
|
||||
const setBaseUrlAndSanctumPrefix = (baseUrl: string | null, sanctumPrefix: string | null) => {
|
||||
TestBed.overrideProvider(BASE_URL, { useValue: baseUrl });
|
||||
TestBed.overrideProvider(SANCTUM_PREFIX, { useValue: sanctumPrefix });
|
||||
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
http = TestBed.inject(HttpClient);
|
||||
sanctumService = TestBed.inject(SanctumService);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HttpClientTestingModule],
|
||||
providers: [
|
||||
{ provide: BASE_URL, useValue: null },
|
||||
{ provide: SANCTUM_PREFIX, useValue: null },
|
||||
SanctumService,
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => httpMock.verify());
|
||||
|
||||
it('should get csrf cookie once', done => {
|
||||
setBaseUrlAndSanctumPrefix(null, null);
|
||||
|
||||
sanctumService.load().then(data => {
|
||||
expect(data).toEqual({ cookie: true });
|
||||
done();
|
||||
});
|
||||
|
||||
httpMock.expectOne('/sanctum/csrf-cookie').flush({ cookie: true });
|
||||
});
|
||||
|
||||
it('should get csrf cookie with base url', done => {
|
||||
setBaseUrlAndSanctumPrefix('http://foo.bar/api', '');
|
||||
|
||||
sanctumService.load().then((data: any) => {
|
||||
expect(data).toEqual({ cookie: true });
|
||||
done();
|
||||
});
|
||||
|
||||
httpMock.expectOne('http://foo.bar/sanctum/csrf-cookie').flush({ cookie: true });
|
||||
});
|
||||
|
||||
it('should get csrf cookie with sanctum prefix', done => {
|
||||
setBaseUrlAndSanctumPrefix(null, '/foobar/');
|
||||
|
||||
sanctumService.load().then((data: any) => {
|
||||
expect(data).toEqual({ cookie: true });
|
||||
done();
|
||||
});
|
||||
|
||||
httpMock.expectOne('/foobar/csrf-cookie').flush({ cookie: true });
|
||||
});
|
||||
|
||||
it('should get csrf cookie with base url and sanctum prefix', done => {
|
||||
setBaseUrlAndSanctumPrefix('http://foo.bar/api/', '/foobar');
|
||||
|
||||
sanctumService.load().then((data: any) => {
|
||||
expect(data).toEqual({ cookie: true });
|
||||
done();
|
||||
});
|
||||
|
||||
httpMock.expectOne('http://foo.bar/foobar/csrf-cookie').flush({ cookie: true });
|
||||
});
|
||||
});
|
||||
38
front/app/src/app/core/bootstrap/sanctum.service.ts
Normal file
38
front/app/src/app/core/bootstrap/sanctum.service.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Inject, Injectable, InjectionToken, Optional } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { BASE_URL } from '../interceptors/base-url-interceptor';
|
||||
|
||||
export const SANCTUM_PREFIX = new InjectionToken<string>('SANCTUM_PREFIX');
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class SanctumService {
|
||||
constructor(
|
||||
private http: HttpClient,
|
||||
@Optional() @Inject(BASE_URL) private baseUrl?: string,
|
||||
@Optional() @Inject(SANCTUM_PREFIX) private prefix?: string
|
||||
) {}
|
||||
|
||||
load(): Promise<unknown> {
|
||||
return new Promise(resolve => this.toObservable().subscribe(resolve));
|
||||
}
|
||||
|
||||
toObservable(): Observable<any> {
|
||||
return this.http.get(this.getUrl());
|
||||
}
|
||||
|
||||
private getUrl(): string {
|
||||
const prefix = this.prefix || 'sanctum';
|
||||
const path = `/${prefix.replace(/^\/|\/$/g, '')}/csrf-cookie`;
|
||||
|
||||
if (!this.baseUrl) {
|
||||
return path;
|
||||
}
|
||||
|
||||
const url = new URL(this.baseUrl);
|
||||
|
||||
return url.origin + path;
|
||||
}
|
||||
}
|
||||
34
front/app/src/app/core/bootstrap/settings.service.ts
Normal file
34
front/app/src/app/core/bootstrap/settings.service.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { BehaviorSubject, Observable } from 'rxjs';
|
||||
import { AppSettings, defaults } from '../settings';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class SettingsService {
|
||||
get notify(): Observable<Record<string, any>> {
|
||||
return this.notify$.asObservable();
|
||||
}
|
||||
|
||||
private notify$ = new BehaviorSubject<Record<string, any>>({});
|
||||
|
||||
getOptions() {
|
||||
return this.options;
|
||||
}
|
||||
|
||||
setOptions(options: AppSettings) {
|
||||
this.options = Object.assign(defaults, options);
|
||||
this.notify$.next(this.options);
|
||||
}
|
||||
|
||||
private options = defaults;
|
||||
|
||||
getLanguage() {
|
||||
return this.options.language;
|
||||
}
|
||||
|
||||
setLanguage(lang: string) {
|
||||
this.options.language = lang;
|
||||
this.notify$.next({ lang });
|
||||
}
|
||||
}
|
||||
87
front/app/src/app/core/bootstrap/startup.service.spec.ts
Normal file
87
front/app/src/app/core/bootstrap/startup.service.spec.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { NgxPermissionsModule, NgxPermissionsService, NgxRolesService } from 'ngx-permissions';
|
||||
import { LocalStorageService, MemoryStorageService } from '@shared/services/storage.service';
|
||||
import { admin, TokenService } from '@core/authentication';
|
||||
import { MenuService } from '@core/bootstrap/menu.service';
|
||||
import { StartupService } from '@core/bootstrap/startup.service';
|
||||
|
||||
describe('StartupService', () => {
|
||||
let httpMock: HttpTestingController;
|
||||
let startup: StartupService;
|
||||
let tokenService: TokenService;
|
||||
let menuService: MenuService;
|
||||
let mockPermissionsService: NgxPermissionsService;
|
||||
let mockRolesService: NgxRolesService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HttpClientTestingModule, NgxPermissionsModule.forRoot()],
|
||||
providers: [
|
||||
{
|
||||
provide: LocalStorageService,
|
||||
useClass: MemoryStorageService,
|
||||
},
|
||||
{
|
||||
provide: NgxPermissionsService,
|
||||
useValue: {
|
||||
loadPermissions: (permissions: string[]) => void 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: NgxRolesService,
|
||||
useValue: {
|
||||
flushRoles: () => void 0,
|
||||
addRoles: (params: { ADMIN: string[] }) => void 0,
|
||||
},
|
||||
},
|
||||
StartupService,
|
||||
],
|
||||
});
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
startup = TestBed.inject(StartupService);
|
||||
tokenService = TestBed.inject(TokenService);
|
||||
menuService = TestBed.inject(MenuService);
|
||||
mockPermissionsService = TestBed.inject(NgxPermissionsService);
|
||||
mockRolesService = TestBed.inject(NgxRolesService);
|
||||
});
|
||||
|
||||
afterEach(() => httpMock.verify());
|
||||
|
||||
it('should load menu when token changed and token valid', async () => {
|
||||
const menuData = { menu: [] };
|
||||
const permissions = ['canAdd', 'canDelete', 'canEdit', 'canRead'];
|
||||
spyOn(menuService, 'addNamespace');
|
||||
spyOn(menuService, 'set');
|
||||
spyOn(mockPermissionsService, 'loadPermissions');
|
||||
spyOn(mockRolesService, 'flushRoles');
|
||||
spyOn(mockRolesService, 'addRoles');
|
||||
|
||||
await startup.load();
|
||||
|
||||
tokenService.set({ access_token: 'token', token_type: 'bearer' });
|
||||
|
||||
httpMock.expectOne('/me').flush(admin);
|
||||
httpMock.expectOne('/me/menu').flush(menuData);
|
||||
|
||||
expect(menuService.addNamespace).toHaveBeenCalledWith(menuData.menu, 'menu');
|
||||
expect(menuService.set).toHaveBeenCalledWith(menuData.menu);
|
||||
expect(mockPermissionsService.loadPermissions).toHaveBeenCalledWith(permissions);
|
||||
expect(mockRolesService.flushRoles).toHaveBeenCalledWith();
|
||||
expect(mockRolesService.addRoles).toHaveBeenCalledWith({ ADMIN: permissions });
|
||||
});
|
||||
|
||||
it('should clear menu when token changed and token invalid', async () => {
|
||||
spyOn(menuService, 'addNamespace');
|
||||
spyOn(menuService, 'set');
|
||||
|
||||
await startup.load();
|
||||
|
||||
tokenService.set({ access_token: '', token_type: 'bearer' });
|
||||
|
||||
httpMock.expectNone('/me/menu');
|
||||
|
||||
expect(menuService.addNamespace).toHaveBeenCalledWith([], 'menu');
|
||||
expect(menuService.set).toHaveBeenCalledWith([]);
|
||||
});
|
||||
});
|
||||
53
front/app/src/app/core/bootstrap/startup.service.ts
Normal file
53
front/app/src/app/core/bootstrap/startup.service.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { switchMap, tap } from 'rxjs/operators';
|
||||
import { NgxPermissionsService, NgxRolesService } from 'ngx-permissions';
|
||||
import { AuthService, User } from '@core/authentication';
|
||||
import { Menu, MenuService } from './menu.service';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class StartupService {
|
||||
constructor(
|
||||
private authService: AuthService,
|
||||
private menuService: MenuService,
|
||||
private permissonsService: NgxPermissionsService,
|
||||
private rolesService: NgxRolesService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Load the application only after get the menu or other essential informations
|
||||
* such as permissions and roles.
|
||||
*/
|
||||
load() {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
this.authService
|
||||
.change()
|
||||
.pipe(
|
||||
tap(user => this.setPermissions(user)),
|
||||
switchMap(() => this.authService.menu()),
|
||||
tap(menu => this.setMenu(menu))
|
||||
)
|
||||
.subscribe(
|
||||
() => resolve(),
|
||||
() => resolve()
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private setMenu(menu: Menu[]) {
|
||||
this.menuService.addNamespace(menu, 'menu');
|
||||
this.menuService.set(menu);
|
||||
}
|
||||
|
||||
private setPermissions(user: User) {
|
||||
// In a real app, you should get permissions and roles from the user information.
|
||||
const permissions = ['canAdd', 'canDelete', 'canEdit', 'canRead'];
|
||||
this.permissonsService.loadPermissions(permissions);
|
||||
this.rolesService.flushRoles();
|
||||
this.rolesService.addRoles({ ADMIN: permissions });
|
||||
|
||||
// Tips: Alternatively you can add permissions with role at the same time.
|
||||
// this.rolesService.addRolesWithPermissions({ ADMIN: permissions });
|
||||
}
|
||||
}
|
||||
33
front/app/src/app/core/bootstrap/translate-lang.service.ts
Normal file
33
front/app/src/app/core/bootstrap/translate-lang.service.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Injectable, Injector } from '@angular/core';
|
||||
import { LOCATION_INITIALIZED } from '@angular/common';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { SettingsService } from './settings.service';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class TranslateLangService {
|
||||
constructor(
|
||||
private injector: Injector,
|
||||
private translate: TranslateService,
|
||||
private settings: SettingsService
|
||||
) {}
|
||||
|
||||
load() {
|
||||
return new Promise<void>(resolve => {
|
||||
const locationInitialized = this.injector.get(LOCATION_INITIALIZED, Promise.resolve());
|
||||
locationInitialized.then(() => {
|
||||
const browserLang = navigator.language;
|
||||
const defaultLang = browserLang.match(/en-US|zh-CN|zh-TW/) ? browserLang : 'en-US';
|
||||
|
||||
this.settings.setLanguage(defaultLang);
|
||||
this.translate.setDefaultLang(defaultLang);
|
||||
this.translate.use(defaultLang).subscribe(
|
||||
() => console.log(`Successfully initialized '${defaultLang}' language.'`),
|
||||
() => console.error(`Problem with '${defaultLang}' language initialization.'`),
|
||||
() => resolve()
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
13
front/app/src/app/core/core.module.ts
Normal file
13
front/app/src/app/core/core.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { NgModule, Optional, SkipSelf } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { throwIfAlreadyLoaded } from './module-import-guard';
|
||||
|
||||
@NgModule({
|
||||
declarations: [],
|
||||
imports: [CommonModule],
|
||||
})
|
||||
export class CoreModule {
|
||||
constructor(@Optional() @SkipSelf() parentModule: CoreModule) {
|
||||
throwIfAlreadyLoaded(parentModule, 'CoreModule');
|
||||
}
|
||||
}
|
||||
16
front/app/src/app/core/index.ts
Normal file
16
front/app/src/app/core/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export * from './settings';
|
||||
export * from './initializers';
|
||||
|
||||
// Bootstrap
|
||||
export * from './bootstrap/menu.service';
|
||||
export * from './bootstrap/settings.service';
|
||||
export * from './bootstrap/startup.service';
|
||||
export * from './bootstrap/preloader.service';
|
||||
export * from './bootstrap/translate-lang.service';
|
||||
export * from './bootstrap/sanctum.service';
|
||||
|
||||
// Interceptors
|
||||
export * from './interceptors';
|
||||
|
||||
// Authentication
|
||||
export * from './authentication';
|
||||
37
front/app/src/app/core/initializers.ts
Normal file
37
front/app/src/app/core/initializers.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { APP_INITIALIZER } from '@angular/core';
|
||||
|
||||
// import { SanctumService } from './bootstrap/sanctum.service';
|
||||
// export function SanctumServiceFactory(sanctumService: SanctumService) {
|
||||
// return () => sanctumService.load();
|
||||
// }
|
||||
|
||||
import { TranslateLangService } from './bootstrap/translate-lang.service';
|
||||
export function TranslateLangServiceFactory(translateLangService: TranslateLangService) {
|
||||
return () => translateLangService.load();
|
||||
}
|
||||
|
||||
import { StartupService } from './bootstrap/startup.service';
|
||||
export function StartupServiceFactory(startupService: StartupService) {
|
||||
return () => startupService.load();
|
||||
}
|
||||
|
||||
export const appInitializerProviders = [
|
||||
// {
|
||||
// provide: APP_INITIALIZER,
|
||||
// useFactory: SanctumServiceFactory,
|
||||
// deps: [SanctumService],
|
||||
// multi: true,
|
||||
// },
|
||||
{
|
||||
provide: APP_INITIALIZER,
|
||||
useFactory: TranslateLangServiceFactory,
|
||||
deps: [TranslateLangService],
|
||||
multi: true,
|
||||
},
|
||||
{
|
||||
provide: APP_INITIALIZER,
|
||||
useFactory: StartupServiceFactory,
|
||||
deps: [StartupService],
|
||||
multi: true,
|
||||
},
|
||||
];
|
||||
3
front/app/src/app/core/interceptors/README.md
Normal file
3
front/app/src/app/core/interceptors/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Interceptors
|
||||
|
||||
https://angular.io/guide/http#intercepting-requests-and-responses
|
||||
@@ -0,0 +1,47 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { HTTP_INTERCEPTORS, HttpClient } from '@angular/common/http';
|
||||
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
|
||||
import { BASE_URL, BaseUrlInterceptor } from './base-url-interceptor';
|
||||
|
||||
describe('BaseUrlInterceptor', () => {
|
||||
let httpMock: HttpTestingController;
|
||||
let http: HttpClient;
|
||||
const baseUrl = 'https://foo.bar';
|
||||
|
||||
const setBaseUrl = (url: string | null) => {
|
||||
TestBed.overrideProvider(BASE_URL, { useValue: url });
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
http = TestBed.inject(HttpClient);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HttpClientTestingModule],
|
||||
providers: [
|
||||
{ provide: BASE_URL, useValue: null },
|
||||
{ provide: HTTP_INTERCEPTORS, useClass: BaseUrlInterceptor, multi: true },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => httpMock.verify());
|
||||
|
||||
it('should not prepend base url when base url is empty', () => {
|
||||
setBaseUrl(null);
|
||||
|
||||
http.get('/me').subscribe(data => expect(data).toEqual({ success: true }));
|
||||
|
||||
httpMock.expectOne('/me').flush({ success: true });
|
||||
});
|
||||
|
||||
it('should prepend base url when request url does not has http scheme', () => {
|
||||
setBaseUrl(baseUrl);
|
||||
|
||||
http.get('./me').subscribe(data => expect(data).toEqual({ success: true }));
|
||||
httpMock.expectOne(baseUrl + '/me').flush({ success: true });
|
||||
|
||||
http.get('').subscribe(data => expect(data).toEqual({ success: true }));
|
||||
httpMock.expectOne(baseUrl).flush({ success: true });
|
||||
});
|
||||
});
|
||||
24
front/app/src/app/core/interceptors/base-url-interceptor.ts
Normal file
24
front/app/src/app/core/interceptors/base-url-interceptor.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Inject, Injectable, InjectionToken, Optional } from '@angular/core';
|
||||
import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
export const BASE_URL = new InjectionToken<string>('BASE_URL');
|
||||
|
||||
@Injectable()
|
||||
export class BaseUrlInterceptor implements HttpInterceptor {
|
||||
private hasScheme = (url: string) => this.baseUrl && new RegExp('^http(s)?://', 'i').test(url);
|
||||
|
||||
constructor(@Optional() @Inject(BASE_URL) private baseUrl?: string) {}
|
||||
|
||||
intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
|
||||
return this.hasScheme(request.url) === false
|
||||
? next.handle(request.clone({ url: this.prependBaseUrl(request.url) }))
|
||||
: next.handle(request);
|
||||
}
|
||||
|
||||
private prependBaseUrl(url: string) {
|
||||
return [this.baseUrl?.replace(/\/$/g, ''), url.replace(/^\.?\//, '')]
|
||||
.filter(val => val)
|
||||
.join('/');
|
||||
}
|
||||
}
|
||||
41
front/app/src/app/core/interceptors/default-interceptor.ts
Normal file
41
front/app/src/app/core/interceptors/default-interceptor.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import {
|
||||
HttpEvent,
|
||||
HttpHandler,
|
||||
HttpInterceptor,
|
||||
HttpRequest,
|
||||
HttpResponse,
|
||||
} from '@angular/common/http';
|
||||
import { Observable, of, throwError } from 'rxjs';
|
||||
import { mergeMap } from 'rxjs/operators';
|
||||
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
|
||||
@Injectable()
|
||||
export class DefaultInterceptor implements HttpInterceptor {
|
||||
constructor(private toast: ToastrService) {}
|
||||
|
||||
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
|
||||
if (!req.url.includes('/api/')) {
|
||||
return next.handle(req);
|
||||
}
|
||||
|
||||
return next.handle(req).pipe(mergeMap((event: HttpEvent<any>) => this.handleOkReq(event)));
|
||||
}
|
||||
|
||||
private handleOkReq(event: HttpEvent<any>): Observable<any> {
|
||||
if (event instanceof HttpResponse) {
|
||||
const body: any = event.body;
|
||||
// failure: { code: **, msg: 'failure' }
|
||||
// success: { code: 0, msg: 'success', data: {} }
|
||||
if (body && 'code' in body && body.code !== 0) {
|
||||
if (body.msg) {
|
||||
this.toast.error(body.msg);
|
||||
}
|
||||
return throwError([]);
|
||||
}
|
||||
}
|
||||
// Pass down event if everything is OK
|
||||
return of(event);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
|
||||
import { HTTP_INTERCEPTORS, HttpClient } from '@angular/common/http';
|
||||
import { RouterTestingModule } from '@angular/router/testing';
|
||||
import { Router } from '@angular/router';
|
||||
import { ToastrModule, ToastrService } from 'ngx-toastr';
|
||||
import { ErrorInterceptor } from './error-interceptor';
|
||||
|
||||
describe('ErrorInterceptor', () => {
|
||||
let httpMock: HttpTestingController;
|
||||
let http: HttpClient;
|
||||
let router: Router;
|
||||
let toast: ToastrService;
|
||||
const emptyFn = () => {};
|
||||
|
||||
function assertStatus(status: number, statusText: string) {
|
||||
spyOn(router, 'navigateByUrl');
|
||||
|
||||
http.get('/me').subscribe(emptyFn, emptyFn, emptyFn);
|
||||
|
||||
httpMock.expectOne('/me').flush({}, { status, statusText });
|
||||
|
||||
expect(router.navigateByUrl).toHaveBeenCalledWith(`/${status}`, {
|
||||
skipLocationChange: true,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HttpClientTestingModule, RouterTestingModule, ToastrModule.forRoot()],
|
||||
providers: [{ provide: HTTP_INTERCEPTORS, useClass: ErrorInterceptor, multi: true }],
|
||||
});
|
||||
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
http = TestBed.inject(HttpClient);
|
||||
router = TestBed.inject(Router);
|
||||
toast = TestBed.inject(ToastrService);
|
||||
});
|
||||
|
||||
afterEach(() => httpMock.verify());
|
||||
|
||||
it('should handle status code 401', () => {
|
||||
spyOn(router, 'navigateByUrl');
|
||||
spyOn(toast, 'error');
|
||||
|
||||
http.get('/me').subscribe(emptyFn, emptyFn, emptyFn);
|
||||
httpMock.expectOne('/me').flush({}, { status: 401, statusText: 'Unauthorized' });
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith('401 Unauthorized');
|
||||
expect(router.navigateByUrl).toHaveBeenCalledWith('/auth/login');
|
||||
});
|
||||
|
||||
it('should handle status code 403', () => {
|
||||
assertStatus(403, 'Forbidden');
|
||||
});
|
||||
|
||||
it('should handle status code 404', () => {
|
||||
assertStatus(404, 'Not Found');
|
||||
});
|
||||
|
||||
it('should handle status code 500', () => {
|
||||
assertStatus(500, 'Internal Server Error');
|
||||
});
|
||||
|
||||
it('should handle others status code', () => {
|
||||
spyOn(toast, 'error');
|
||||
|
||||
http.get('/me').subscribe(emptyFn, emptyFn, emptyFn);
|
||||
|
||||
httpMock.expectOne('/me').flush({}, { status: 504, statusText: 'Gateway Timeout' });
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith('504 Gateway Timeout');
|
||||
});
|
||||
});
|
||||
60
front/app/src/app/core/interceptors/error-interceptor.ts
Normal file
60
front/app/src/app/core/interceptors/error-interceptor.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import {
|
||||
HttpErrorResponse,
|
||||
HttpEvent,
|
||||
HttpHandler,
|
||||
HttpInterceptor,
|
||||
HttpRequest,
|
||||
} from '@angular/common/http';
|
||||
import { Router } from '@angular/router';
|
||||
import { Observable, throwError } from 'rxjs';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
|
||||
export enum STATUS {
|
||||
UNAUTHORIZED = 401,
|
||||
FORBIDDEN = 403,
|
||||
NOT_FOUND = 404,
|
||||
INTERNAL_SERVER_ERROR = 500,
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ErrorInterceptor implements HttpInterceptor {
|
||||
private errorPages = [STATUS.FORBIDDEN, STATUS.NOT_FOUND, STATUS.INTERNAL_SERVER_ERROR];
|
||||
|
||||
private getMessage = (error: HttpErrorResponse) => {
|
||||
if (error.error?.message) {
|
||||
return error.error.message;
|
||||
}
|
||||
|
||||
if (error.error?.msg) {
|
||||
return error.error.msg;
|
||||
}
|
||||
|
||||
return `${error.status} ${error.statusText}`;
|
||||
};
|
||||
|
||||
constructor(private router: Router, private toast: ToastrService) {}
|
||||
|
||||
intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
|
||||
return next
|
||||
.handle(request)
|
||||
.pipe(catchError((error: HttpErrorResponse) => this.handleError(error)));
|
||||
}
|
||||
|
||||
private handleError(error: HttpErrorResponse) {
|
||||
if (this.errorPages.includes(error.status)) {
|
||||
this.router.navigateByUrl(`/${error.status}`, {
|
||||
skipLocationChange: true,
|
||||
});
|
||||
} else {
|
||||
console.error('ERROR', error);
|
||||
this.toast.error(this.getMessage(error));
|
||||
if (error.status === STATUS.UNAUTHORIZED) {
|
||||
this.router.navigateByUrl('/auth/login');
|
||||
}
|
||||
}
|
||||
|
||||
return throwError(error);
|
||||
}
|
||||
}
|
||||
31
front/app/src/app/core/interceptors/index.ts
Normal file
31
front/app/src/app/core/interceptors/index.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { HTTP_INTERCEPTORS } from '@angular/common/http';
|
||||
|
||||
import { NoopInterceptor } from './noop-interceptor';
|
||||
// import { SanctumInterceptor } from './sanctum-interceptor';
|
||||
import { BaseUrlInterceptor } from './base-url-interceptor';
|
||||
import { SettingsInterceptor } from './settings-interceptor';
|
||||
import { TokenInterceptor } from './token-interceptor';
|
||||
import { DefaultInterceptor } from './default-interceptor';
|
||||
import { ErrorInterceptor } from './error-interceptor';
|
||||
import { LoggingInterceptor } from './logging-interceptor';
|
||||
|
||||
export * from './noop-interceptor';
|
||||
// export * from './sanctum-interceptor';
|
||||
export * from './base-url-interceptor';
|
||||
export * from './settings-interceptor';
|
||||
export * from './token-interceptor';
|
||||
export * from './default-interceptor';
|
||||
export * from './error-interceptor';
|
||||
export * from './logging-interceptor';
|
||||
|
||||
/** Http interceptor providers in outside-in order */
|
||||
export const httpInterceptorProviders = [
|
||||
{ provide: HTTP_INTERCEPTORS, useClass: NoopInterceptor, multi: true },
|
||||
// { provide: HTTP_INTERCEPTORS, useClass: SanctumInterceptor, multi: true },
|
||||
{ provide: HTTP_INTERCEPTORS, useClass: BaseUrlInterceptor, multi: true },
|
||||
{ provide: HTTP_INTERCEPTORS, useClass: SettingsInterceptor, multi: true },
|
||||
{ provide: HTTP_INTERCEPTORS, useClass: TokenInterceptor, multi: true },
|
||||
{ provide: HTTP_INTERCEPTORS, useClass: DefaultInterceptor, multi: true },
|
||||
{ provide: HTTP_INTERCEPTORS, useClass: ErrorInterceptor, multi: true },
|
||||
{ provide: HTTP_INTERCEPTORS, useClass: LoggingInterceptor, multi: true },
|
||||
];
|
||||
30
front/app/src/app/core/interceptors/logging-interceptor.ts
Normal file
30
front/app/src/app/core/interceptors/logging-interceptor.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpInterceptor, HttpHandler, HttpRequest, HttpResponse } from '@angular/common/http';
|
||||
import { finalize, tap } from 'rxjs/operators';
|
||||
import { MessageService } from '@shared';
|
||||
|
||||
@Injectable()
|
||||
export class LoggingInterceptor implements HttpInterceptor {
|
||||
constructor(private messenger: MessageService) {}
|
||||
|
||||
intercept(req: HttpRequest<any>, next: HttpHandler) {
|
||||
const started = Date.now();
|
||||
let ok: string;
|
||||
|
||||
// extend server response observable with logging
|
||||
return next.handle(req).pipe(
|
||||
tap(
|
||||
// Succeeds when there is a response; ignore other events
|
||||
event => (ok = event instanceof HttpResponse ? 'succeeded' : ''),
|
||||
// Operation failed; error is an HttpErrorResponse
|
||||
error => (ok = 'failed')
|
||||
),
|
||||
// Log when response observable either completes or errors
|
||||
finalize(() => {
|
||||
const elapsed = Date.now() - started;
|
||||
const msg = `${req.method} "${req.urlWithParams}" ${ok} in ${elapsed} ms.`;
|
||||
this.messenger.add(msg);
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
10
front/app/src/app/core/interceptors/noop-interceptor.ts
Normal file
10
front/app/src/app/core/interceptors/noop-interceptor.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpEvent, HttpInterceptor, HttpHandler, HttpRequest } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
@Injectable()
|
||||
export class NoopInterceptor implements HttpInterceptor {
|
||||
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
|
||||
return next.handle(req);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
|
||||
import { HTTP_INTERCEPTORS, HttpClient } from '@angular/common/http';
|
||||
import { switchMap } from 'rxjs/operators';
|
||||
import { SanctumInterceptor } from './sanctum-interceptor';
|
||||
import { BASE_URL } from './base-url-interceptor';
|
||||
import { SANCTUM_PREFIX } from '@core/bootstrap/sanctum.service';
|
||||
|
||||
describe('SanctumInterceptor', () => {
|
||||
let httpMock: HttpTestingController;
|
||||
let http: HttpClient;
|
||||
|
||||
const setBaseUrlAndSanctumPrefix = (baseUrl: string | null, sanctumPrefix: string | null) => {
|
||||
TestBed.overrideProvider(BASE_URL, { useValue: baseUrl });
|
||||
TestBed.overrideProvider(SANCTUM_PREFIX, { useValue: sanctumPrefix });
|
||||
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
http = TestBed.inject(HttpClient);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HttpClientTestingModule],
|
||||
providers: [
|
||||
{ provide: BASE_URL, useValue: null },
|
||||
{ provide: SANCTUM_PREFIX, useValue: null },
|
||||
{ provide: HTTP_INTERCEPTORS, useClass: SanctumInterceptor, multi: true },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => httpMock.verify());
|
||||
|
||||
it('should get csrf cookie once', () => {
|
||||
setBaseUrlAndSanctumPrefix(null, null);
|
||||
|
||||
http
|
||||
.post('/auth/login', {
|
||||
username: 'foo',
|
||||
password: 'bar',
|
||||
})
|
||||
.pipe(switchMap(() => http.get('/me')))
|
||||
.subscribe(data => expect(data).toEqual({ me: true }));
|
||||
|
||||
httpMock.expectOne('/sanctum/csrf-cookie').flush({ cookie: true });
|
||||
httpMock.expectOne('/auth/login').flush({ login: true });
|
||||
httpMock.expectOne('/me').flush({ me: true });
|
||||
});
|
||||
|
||||
it('should get csrf cookie with base url', () => {
|
||||
setBaseUrlAndSanctumPrefix('https://foo.bar/api', null);
|
||||
|
||||
http
|
||||
.post('/auth/login', {
|
||||
username: 'foo',
|
||||
password: 'bar',
|
||||
})
|
||||
.pipe(switchMap(() => http.get('/me')))
|
||||
.subscribe(data => expect(data).toEqual({ me: true }));
|
||||
|
||||
httpMock.expectOne('https://foo.bar/sanctum/csrf-cookie').flush({ cookie: true });
|
||||
httpMock.expectOne('/auth/login').flush({ login: true });
|
||||
httpMock.expectOne('/me').flush({ me: true });
|
||||
});
|
||||
|
||||
it('should get csrf cookie with sanctum prefix', () => {
|
||||
setBaseUrlAndSanctumPrefix(null, 'foobar');
|
||||
|
||||
http
|
||||
.post('/auth/login', {
|
||||
username: 'foo',
|
||||
password: 'bar',
|
||||
})
|
||||
.pipe(switchMap(() => http.get('/me')))
|
||||
.subscribe(data => expect(data).toEqual({ me: true }));
|
||||
|
||||
httpMock.expectOne('/foobar/csrf-cookie').flush({ cookie: true });
|
||||
httpMock.expectOne('/auth/login').flush({ login: true });
|
||||
httpMock.expectOne('/me').flush({ me: true });
|
||||
});
|
||||
|
||||
it('should get csrf cookie with base url and sanctum prefix', () => {
|
||||
setBaseUrlAndSanctumPrefix('https://foo.bar/api', 'foobar');
|
||||
|
||||
http
|
||||
.post('/auth/login', {
|
||||
username: 'foo',
|
||||
password: 'bar',
|
||||
})
|
||||
.pipe(switchMap(() => http.get('/me')))
|
||||
.subscribe(data => expect(data).toEqual({ me: true }));
|
||||
|
||||
httpMock.expectOne('https://foo.bar/foobar/csrf-cookie').flush({ cookie: true });
|
||||
httpMock.expectOne('/auth/login').flush({ login: true });
|
||||
httpMock.expectOne('/me').flush({ me: true });
|
||||
});
|
||||
});
|
||||
22
front/app/src/app/core/interceptors/sanctum-interceptor.ts
Normal file
22
front/app/src/app/core/interceptors/sanctum-interceptor.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { switchMap } from 'rxjs/operators';
|
||||
import { SanctumService } from '@core';
|
||||
|
||||
@Injectable()
|
||||
export class SanctumInterceptor implements HttpInterceptor {
|
||||
private ready = false;
|
||||
|
||||
constructor(private sanctum: SanctumService) {}
|
||||
|
||||
intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
|
||||
if (!this.ready) {
|
||||
this.ready = true;
|
||||
|
||||
return this.sanctum.toObservable().pipe(switchMap(() => next.handle(request)));
|
||||
}
|
||||
|
||||
return next.handle(request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
|
||||
import { HTTP_INTERCEPTORS, HttpClient } from '@angular/common/http';
|
||||
import { SettingsService } from '@core/bootstrap/settings.service';
|
||||
import { SettingsInterceptor } from './settings-interceptor';
|
||||
|
||||
describe('SettingsInterceptor', () => {
|
||||
let httpMock: HttpTestingController;
|
||||
let http: HttpClient;
|
||||
let settings: SettingsService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HttpClientTestingModule],
|
||||
providers: [{ provide: HTTP_INTERCEPTORS, useClass: SettingsInterceptor, multi: true }],
|
||||
});
|
||||
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
http = TestBed.inject(HttpClient);
|
||||
settings = TestBed.inject(SettingsService);
|
||||
});
|
||||
|
||||
it('should set accept language', () => {
|
||||
settings.setLanguage('zh-TW');
|
||||
|
||||
http.get('/me').subscribe();
|
||||
const testRequest = httpMock.expectOne('/me');
|
||||
testRequest.flush({ me: true });
|
||||
|
||||
expect(testRequest.request.headers.get('Accept-Language')).toEqual('zh-TW');
|
||||
});
|
||||
});
|
||||
17
front/app/src/app/core/interceptors/settings-interceptor.ts
Normal file
17
front/app/src/app/core/interceptors/settings-interceptor.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpRequest, HttpHandler, HttpEvent, HttpInterceptor } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { SettingsService } from '@core';
|
||||
|
||||
@Injectable()
|
||||
export class SettingsInterceptor implements HttpInterceptor {
|
||||
constructor(private settings: SettingsService) {}
|
||||
|
||||
intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
|
||||
return next.handle(
|
||||
request.clone({
|
||||
headers: request.headers.append('Accept-Language', this.settings.getLanguage()),
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
116
front/app/src/app/core/interceptors/token-interceptor.spec.ts
Normal file
116
front/app/src/app/core/interceptors/token-interceptor.spec.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { TokenInterceptor } from './token-interceptor';
|
||||
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
|
||||
import { HTTP_INTERCEPTORS, HttpClient } from '@angular/common/http';
|
||||
import { STATUS } from 'angular-in-memory-web-api';
|
||||
import { RouterTestingModule } from '@angular/router/testing';
|
||||
import { Router } from '@angular/router';
|
||||
import { LocalStorageService, MemoryStorageService } from '@shared/services/storage.service';
|
||||
import { TokenService, User } from '@core/authentication';
|
||||
import { BASE_URL } from './base-url-interceptor';
|
||||
|
||||
describe('TokenInterceptor', () => {
|
||||
let httpMock: HttpTestingController;
|
||||
let http: HttpClient;
|
||||
let router: Router;
|
||||
let tokenService: TokenService;
|
||||
const emptyFn = () => {};
|
||||
const baseUrl = 'https://foo.bar';
|
||||
const user: User = { id: 1, email: 'foo@bar.com' };
|
||||
|
||||
function init(url: string, access_token: string) {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HttpClientTestingModule, RouterTestingModule],
|
||||
providers: [
|
||||
{ provide: LocalStorageService, useClass: MemoryStorageService },
|
||||
{ provide: BASE_URL, useValue: url },
|
||||
{ provide: HTTP_INTERCEPTORS, useClass: TokenInterceptor, multi: true },
|
||||
],
|
||||
});
|
||||
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
http = TestBed.inject(HttpClient);
|
||||
router = TestBed.inject(Router);
|
||||
tokenService = TestBed.inject(TokenService).set({ access_token, token_type: 'bearer' });
|
||||
}
|
||||
|
||||
function mockRequest(url: string, body?: any, headers?: any) {
|
||||
http.get(url).subscribe(emptyFn, emptyFn, emptyFn);
|
||||
const testRequest = httpMock.expectOne(url);
|
||||
testRequest.flush(body ?? {}, headers ?? {});
|
||||
|
||||
return testRequest;
|
||||
}
|
||||
|
||||
afterEach(() => httpMock.verify());
|
||||
|
||||
it('should append token when url does not has http scheme', () => {
|
||||
init('', 'token');
|
||||
|
||||
const headers = mockRequest('/me', user).request.headers;
|
||||
|
||||
expect(headers.get('Authorization')).toEqual('Bearer token');
|
||||
});
|
||||
|
||||
it('should append token when url does not has http and base url not empty', () => {
|
||||
init(baseUrl, 'token');
|
||||
|
||||
const headers = mockRequest('/me', user).request.headers;
|
||||
|
||||
expect(headers.get('Authorization')).toEqual('Bearer token');
|
||||
});
|
||||
|
||||
it('should append token when url include base url', () => {
|
||||
init(baseUrl, 'token');
|
||||
|
||||
const headers = mockRequest(`${baseUrl}/me`, user).request.headers;
|
||||
|
||||
expect(headers.get('Authorization')).toEqual('Bearer token');
|
||||
});
|
||||
|
||||
it('should not append token when url not include baseUrl', () => {
|
||||
init(baseUrl, 'token');
|
||||
|
||||
const headers = mockRequest('https://api.github.com', { success: true }).request.headers;
|
||||
|
||||
expect(headers.has('Authorization')).toBeFalse();
|
||||
});
|
||||
|
||||
it('should not append token when base url is empty and url is not same site', () => {
|
||||
init('', 'token');
|
||||
|
||||
const headers = mockRequest('https://api.github.com', { success: true }).request.headers;
|
||||
|
||||
expect(headers.has('Authorization')).toBeFalse();
|
||||
});
|
||||
|
||||
it('should clear token when response status is unauthorized', () => {
|
||||
init('', 'token');
|
||||
spyOn(tokenService, 'clear');
|
||||
|
||||
mockRequest('/me', {}, { status: STATUS.UNAUTHORIZED, statusText: 'Unauthorized' });
|
||||
|
||||
expect(tokenService.clear).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should navigate /auth/login when api url is /auth/logout and token is valid', () => {
|
||||
init('', 'token');
|
||||
const navigateByUrl = spyOn(router, 'navigateByUrl');
|
||||
navigateByUrl.and.returnValue(Promise.resolve(true));
|
||||
|
||||
mockRequest('/auth/logout');
|
||||
|
||||
expect(navigateByUrl).toHaveBeenCalledWith('/auth/login');
|
||||
});
|
||||
|
||||
it('should navigate /auth/login when api url is /auth/logout and token is invalid', () => {
|
||||
init('', '');
|
||||
const navigateByUrl = spyOn(router, 'navigateByUrl');
|
||||
navigateByUrl.and.returnValue(Promise.resolve(true));
|
||||
|
||||
mockRequest('/auth/logout');
|
||||
|
||||
expect(navigateByUrl).toHaveBeenCalledWith('/auth/login');
|
||||
});
|
||||
});
|
||||
71
front/app/src/app/core/interceptors/token-interceptor.ts
Normal file
71
front/app/src/app/core/interceptors/token-interceptor.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { Inject, Injectable, Optional } from '@angular/core';
|
||||
import {
|
||||
HttpErrorResponse,
|
||||
HttpEvent,
|
||||
HttpHandler,
|
||||
HttpInterceptor,
|
||||
HttpRequest,
|
||||
} from '@angular/common/http';
|
||||
import { Router } from '@angular/router';
|
||||
import { Observable, throwError } from 'rxjs';
|
||||
import { catchError, tap } from 'rxjs/operators';
|
||||
import { TokenService } from '@core/authentication';
|
||||
import { BASE_URL } from './base-url-interceptor';
|
||||
|
||||
@Injectable()
|
||||
export class TokenInterceptor implements HttpInterceptor {
|
||||
private hasHttpScheme = (url: string) => new RegExp('^http(s)?://', 'i').test(url);
|
||||
|
||||
constructor(
|
||||
private tokenService: TokenService,
|
||||
private router: Router,
|
||||
@Optional() @Inject(BASE_URL) private baseUrl?: string
|
||||
) {}
|
||||
|
||||
intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
|
||||
const handler = () => {
|
||||
if (request.url.includes('/auth/logout')) {
|
||||
this.router.navigateByUrl('/auth/login');
|
||||
}
|
||||
|
||||
if (this.router.url.includes('/auth/login')) {
|
||||
this.router.navigateByUrl('/dashboard');
|
||||
}
|
||||
};
|
||||
|
||||
if (this.tokenService.valid() && this.shouldAppendToken(request.url)) {
|
||||
return next
|
||||
.handle(
|
||||
request.clone({
|
||||
headers: request.headers.append('Authorization', this.tokenService.getBearerToken()),
|
||||
withCredentials: true,
|
||||
})
|
||||
)
|
||||
.pipe(
|
||||
catchError((error: HttpErrorResponse) => {
|
||||
if (error.status === 401) {
|
||||
this.tokenService.clear();
|
||||
}
|
||||
return throwError(error);
|
||||
}),
|
||||
tap(() => handler())
|
||||
);
|
||||
}
|
||||
|
||||
return next.handle(request).pipe(tap(() => handler()));
|
||||
}
|
||||
|
||||
private shouldAppendToken(url: string) {
|
||||
return !this.hasHttpScheme(url) || this.includeBaseUrl(url);
|
||||
}
|
||||
|
||||
private includeBaseUrl(url: string) {
|
||||
if (!this.baseUrl) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const baseUrl = this.baseUrl.replace(/\/$/, '');
|
||||
|
||||
return new RegExp(`^${baseUrl}`, 'i').test(url);
|
||||
}
|
||||
}
|
||||
7
front/app/src/app/core/module-import-guard.ts
Normal file
7
front/app/src/app/core/module-import-guard.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export function throwIfAlreadyLoaded(parentModule: any, moduleName: string) {
|
||||
if (parentModule) {
|
||||
throw new Error(
|
||||
`${moduleName} has already been loaded. Import Core modules in the AppModule only.`
|
||||
);
|
||||
}
|
||||
}
|
||||
23
front/app/src/app/core/settings.ts
Normal file
23
front/app/src/app/core/settings.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export interface AppSettings {
|
||||
navPos: 'side' | 'top';
|
||||
theme: 'light' | 'dark' | 'auto';
|
||||
dir: 'ltr' | 'rtl';
|
||||
showHeader: boolean;
|
||||
headerPos: 'fixed' | 'static' | 'above';
|
||||
showUserPanel: boolean;
|
||||
sidenavOpened: boolean;
|
||||
sidenavCollapsed: boolean;
|
||||
language: string;
|
||||
}
|
||||
|
||||
export const defaults: AppSettings = {
|
||||
navPos: 'side',
|
||||
theme: 'auto',
|
||||
dir: 'ltr',
|
||||
showHeader: true,
|
||||
headerPos: 'fixed',
|
||||
showUserPanel: true,
|
||||
sidenavOpened: true,
|
||||
sidenavCollapsed: false,
|
||||
language: 'en-US',
|
||||
};
|
||||
34
front/app/src/app/fake-login.service.ts
Normal file
34
front/app/src/app/fake-login.service.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { of } from 'rxjs';
|
||||
import { admin, LoginService, Menu } from '@core';
|
||||
import { map } from 'rxjs/operators';
|
||||
|
||||
/**
|
||||
* You should delete this file in the real APP.
|
||||
*/
|
||||
@Injectable()
|
||||
export class FakeLoginService extends LoginService {
|
||||
private token = { access_token: 'MW56YjMyOUAxNjMuY29tWm9uZ2Jpbg==', token_type: 'bearer' };
|
||||
|
||||
login() {
|
||||
return of(this.token);
|
||||
}
|
||||
|
||||
refresh() {
|
||||
return of(this.token);
|
||||
}
|
||||
|
||||
logout() {
|
||||
return of({});
|
||||
}
|
||||
|
||||
me() {
|
||||
return of(admin);
|
||||
}
|
||||
|
||||
menu() {
|
||||
return this.http
|
||||
.get<{ menu: Menu[] }>('assets/data/menu.json?_t=' + Date.now())
|
||||
.pipe(map(res => res.menu));
|
||||
}
|
||||
}
|
||||
53
front/app/src/app/formly-config.module.ts
Normal file
53
front/app/src/app/formly-config.module.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { NgModule, ModuleWithProviders, Provider } from '@angular/core';
|
||||
import { SharedModule } from './shared/shared.module';
|
||||
|
||||
import { FormlyModule } from '@ngx-formly/core';
|
||||
import { FormlyFieldComboboxComponent } from './formly-templates';
|
||||
import { FormlyWrapperCardComponent, FormlyWrapperDivComponent } from './formly-wrappers';
|
||||
import { FormlyValidations } from './formly-validations';
|
||||
|
||||
/**
|
||||
* Formly global configuration
|
||||
*/
|
||||
const formlyModuleProviders = FormlyModule.forRoot({
|
||||
types: [
|
||||
{
|
||||
name: 'combobox',
|
||||
component: FormlyFieldComboboxComponent,
|
||||
wrappers: ['form-field'],
|
||||
},
|
||||
],
|
||||
wrappers: [
|
||||
{
|
||||
name: 'card',
|
||||
component: FormlyWrapperCardComponent,
|
||||
},
|
||||
{
|
||||
name: 'div',
|
||||
component: FormlyWrapperDivComponent,
|
||||
},
|
||||
],
|
||||
validationMessages: [],
|
||||
}).providers as Provider[];
|
||||
|
||||
@NgModule({
|
||||
imports: [SharedModule],
|
||||
declarations: [
|
||||
FormlyFieldComboboxComponent,
|
||||
FormlyWrapperCardComponent,
|
||||
FormlyWrapperDivComponent,
|
||||
],
|
||||
providers: [FormlyValidations],
|
||||
})
|
||||
export class FormlyConfigModule {
|
||||
constructor(formlyValidations: FormlyValidations) {
|
||||
formlyValidations.init();
|
||||
}
|
||||
|
||||
static forRoot(): ModuleWithProviders<FormlyConfigModule> {
|
||||
return {
|
||||
ngModule: FormlyConfigModule,
|
||||
providers: [formlyModuleProviders],
|
||||
};
|
||||
}
|
||||
}
|
||||
45
front/app/src/app/formly-templates.ts
Normal file
45
front/app/src/app/formly-templates.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { ViewChild, ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
import { FieldType } from '@ngx-formly/material/form-field';
|
||||
import { MtxSelect } from '@ng-matero/extensions/select';
|
||||
import { FieldTypeConfig } from '@ngx-formly/core';
|
||||
|
||||
/**
|
||||
* This is just an example.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'formly-field-combobox',
|
||||
template: `<mtx-select
|
||||
#select
|
||||
[formControl]="formControl"
|
||||
[items]="props.options | toObservable | async"
|
||||
[bindLabel]="bindLabel"
|
||||
[bindValue]="bindValue!"
|
||||
[multiple]="props.multiple"
|
||||
[placeholder]="props.placeholder!"
|
||||
[required]="props.required!"
|
||||
[closeOnSelect]="!props.multiple"
|
||||
[compareWith]="props.compareWith"
|
||||
>
|
||||
</mtx-select>`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class FormlyFieldComboboxComponent extends FieldType<FieldTypeConfig> {
|
||||
@ViewChild('select', { static: true }) select!: MtxSelect;
|
||||
|
||||
get bindLabel() {
|
||||
return typeof this.props.labelProp === 'string' ? this.props.labelProp : '';
|
||||
}
|
||||
|
||||
get bindValue() {
|
||||
return typeof this.props.valueProp === 'string' ? this.props.valueProp : undefined;
|
||||
}
|
||||
|
||||
// The original `onContainerClick` has been covered up in FieldType, so we should redefine it.
|
||||
onContainerClick(event: MouseEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
if (/mat-form-field|mtx-select/g.test(target.parentElement?.classList[0] || '')) {
|
||||
this.select.focus();
|
||||
this.select.open();
|
||||
}
|
||||
}
|
||||
}
|
||||
53
front/app/src/app/formly-validations.ts
Normal file
53
front/app/src/app/formly-validations.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { FormlyFieldConfig, FormlyConfig } from '@ngx-formly/core';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
|
||||
@Injectable()
|
||||
export class FormlyValidations {
|
||||
constructor(private translate: TranslateService, private formlyConfig: FormlyConfig) {}
|
||||
|
||||
init(): void {
|
||||
// message without params
|
||||
this.formlyConfig.addValidatorMessage('required', (_err, _field) =>
|
||||
this.translate.stream('validations.required')
|
||||
);
|
||||
|
||||
// message with params
|
||||
this.formlyConfig.addValidatorMessage('minLength', (err, field) =>
|
||||
this.minLengthValidationMessage(err, field, this.translate)
|
||||
);
|
||||
this.formlyConfig.addValidatorMessage('maxLength', (err, field) =>
|
||||
this.maxLengthValidationMessage(err, field, this.translate)
|
||||
);
|
||||
this.formlyConfig.addValidatorMessage('min', (err, field) =>
|
||||
this.minValidationMessage(err, field, this.translate)
|
||||
);
|
||||
this.formlyConfig.addValidatorMessage('max', (err, field) =>
|
||||
this.maxValidationMessage(err, field, this.translate)
|
||||
);
|
||||
}
|
||||
|
||||
private minLengthValidationMessage(
|
||||
err: any,
|
||||
field: FormlyFieldConfig,
|
||||
translate: TranslateService
|
||||
) {
|
||||
return translate.stream('validations.minlength', { number: field.props?.minLength });
|
||||
}
|
||||
|
||||
private maxLengthValidationMessage(
|
||||
err: any,
|
||||
field: FormlyFieldConfig,
|
||||
translate: TranslateService
|
||||
) {
|
||||
return translate.stream('validations.maxlength', { number: field.props?.maxLength });
|
||||
}
|
||||
|
||||
private minValidationMessage(err: any, field: FormlyFieldConfig, translate: TranslateService) {
|
||||
return translate.stream('validations.min', { number: field.props?.min });
|
||||
}
|
||||
|
||||
private maxValidationMessage(err: any, field: FormlyFieldConfig, translate: TranslateService) {
|
||||
return translate.stream('validations.max', { number: field.props?.max });
|
||||
}
|
||||
}
|
||||
29
front/app/src/app/formly-wrappers.ts
Normal file
29
front/app/src/app/formly-wrappers.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { FieldWrapper } from '@ngx-formly/core';
|
||||
|
||||
/**
|
||||
* This is just an example.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'formly-wrapper-card',
|
||||
template: `
|
||||
<div class="card">
|
||||
<h3 class="card-header">Its time to party</h3>
|
||||
<h3 class="card-header">{{ props.label }}</h3>
|
||||
<div class="card-body">
|
||||
<ng-container #fieldComponent></ng-container>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class FormlyWrapperCardComponent extends FieldWrapper {}
|
||||
|
||||
@Component({
|
||||
selector: 'formly-wrapper-div',
|
||||
template: `
|
||||
<div>
|
||||
<ng-container #fieldComponent></ng-container>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class FormlyWrapperDivComponent extends FieldWrapper {}
|
||||
66
front/app/src/app/material-extensions.module.ts
Normal file
66
front/app/src/app/material-extensions.module.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
|
||||
import { MtxAlertModule } from '@ng-matero/extensions/alert';
|
||||
import { MtxButtonModule } from '@ng-matero/extensions/button';
|
||||
import { MtxCheckboxGroupModule } from '@ng-matero/extensions/checkbox-group';
|
||||
import { MtxColorpickerModule } from '@ng-matero/extensions/colorpicker';
|
||||
import { MtxDatetimepickerModule } from '@ng-matero/extensions/datetimepicker';
|
||||
import { MtxDialogModule } from '@ng-matero/extensions/dialog';
|
||||
import { MtxDrawerModule } from '@ng-matero/extensions/drawer';
|
||||
import { MtxGridModule } from '@ng-matero/extensions/grid';
|
||||
import { MtxLoaderModule } from '@ng-matero/extensions/loader';
|
||||
import { MtxPopoverModule } from '@ng-matero/extensions/popover';
|
||||
import { MtxProgressModule } from '@ng-matero/extensions/progress';
|
||||
import { MtxSelectModule } from '@ng-matero/extensions/select';
|
||||
import { MtxSliderModule } from '@ng-matero/extensions/slider';
|
||||
import { MtxSplitModule } from '@ng-matero/extensions/split';
|
||||
import { MtxTooltipModule } from '@ng-matero/extensions/tooltip';
|
||||
import { MTX_DATETIME_FORMATS } from '@ng-matero/extensions/core';
|
||||
import { MtxMomentDatetimeModule } from '@ng-matero/extensions-moment-adapter';
|
||||
|
||||
@NgModule({
|
||||
exports: [
|
||||
MtxAlertModule,
|
||||
MtxButtonModule,
|
||||
MtxCheckboxGroupModule,
|
||||
MtxColorpickerModule,
|
||||
MtxDatetimepickerModule,
|
||||
MtxDialogModule,
|
||||
MtxDrawerModule,
|
||||
MtxGridModule,
|
||||
MtxLoaderModule,
|
||||
MtxPopoverModule,
|
||||
MtxProgressModule,
|
||||
MtxSelectModule,
|
||||
MtxSliderModule,
|
||||
MtxSplitModule,
|
||||
MtxTooltipModule,
|
||||
MtxMomentDatetimeModule, // <= You can import the other adapter you need (luxon, date-fns)
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: MTX_DATETIME_FORMATS,
|
||||
useValue: {
|
||||
parse: {
|
||||
dateInput: 'YYYY-MM-DD',
|
||||
yearInput: 'YYYY',
|
||||
monthInput: 'MMMM',
|
||||
datetimeInput: 'YYYY-MM-DD HH:mm',
|
||||
timeInput: 'HH:mm',
|
||||
},
|
||||
display: {
|
||||
dateInput: 'YYYY-MM-DD',
|
||||
yearInput: 'YYYY',
|
||||
monthInput: 'MMMM',
|
||||
datetimeInput: 'YYYY-MM-DD HH:mm',
|
||||
timeInput: 'HH:mm',
|
||||
monthYearLabel: 'YYYY MMMM',
|
||||
dateA11yLabel: 'LL',
|
||||
monthYearA11yLabel: 'MMMM YYYY',
|
||||
popupHeaderDateLabel: 'MMM DD, ddd',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
export class MaterialExtensionsModule {}
|
||||
117
front/app/src/app/material.module.ts
Normal file
117
front/app/src/app/material.module.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
|
||||
import { MatAutocompleteModule } from '@angular/material/autocomplete';
|
||||
import { MatBadgeModule } from '@angular/material/badge';
|
||||
import { MatBottomSheetModule } from '@angular/material/bottom-sheet';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatButtonToggleModule } from '@angular/material/button-toggle';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
import { MatRippleModule, MAT_DATE_FORMATS, MAT_DATE_LOCALE } from '@angular/material/core';
|
||||
import { MatDatepickerModule } from '@angular/material/datepicker';
|
||||
import {
|
||||
MatDialogConfig,
|
||||
MatDialogModule,
|
||||
MAT_DIALOG_DEFAULT_OPTIONS,
|
||||
} from '@angular/material/dialog';
|
||||
import { MatDividerModule } from '@angular/material/divider';
|
||||
import { MatExpansionModule } from '@angular/material/expansion';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatGridListModule } from '@angular/material/grid-list';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatListModule } from '@angular/material/list';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatPaginatorIntl, MatPaginatorModule } from '@angular/material/paginator';
|
||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatRadioModule } from '@angular/material/radio';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatSidenavModule } from '@angular/material/sidenav';
|
||||
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
||||
import { MatSliderModule } from '@angular/material/slider';
|
||||
import { MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { MatSortModule } from '@angular/material/sort';
|
||||
import { MatStepperModule } from '@angular/material/stepper';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { MatTabsModule } from '@angular/material/tabs';
|
||||
import { MatToolbarModule } from '@angular/material/toolbar';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { MatTreeModule } from '@angular/material/tree';
|
||||
import { MatMomentDateModule } from '@angular/material-moment-adapter';
|
||||
|
||||
import { PaginatorI18nService } from '@shared/services/paginator-i18n.service';
|
||||
|
||||
@NgModule({
|
||||
exports: [
|
||||
MatAutocompleteModule,
|
||||
MatBadgeModule,
|
||||
MatBottomSheetModule,
|
||||
MatButtonModule,
|
||||
MatButtonToggleModule,
|
||||
MatCardModule,
|
||||
MatCheckboxModule,
|
||||
MatChipsModule,
|
||||
MatStepperModule,
|
||||
MatDatepickerModule,
|
||||
MatMomentDateModule,
|
||||
MatDialogModule,
|
||||
MatDividerModule,
|
||||
MatExpansionModule,
|
||||
MatFormFieldModule,
|
||||
MatGridListModule,
|
||||
MatIconModule,
|
||||
MatInputModule,
|
||||
MatListModule,
|
||||
MatMenuModule,
|
||||
MatPaginatorModule,
|
||||
MatProgressBarModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatRadioModule,
|
||||
MatRippleModule,
|
||||
MatSelectModule,
|
||||
MatSidenavModule,
|
||||
MatSliderModule,
|
||||
MatSlideToggleModule,
|
||||
MatSnackBarModule,
|
||||
MatSortModule,
|
||||
MatTableModule,
|
||||
MatTabsModule,
|
||||
MatToolbarModule,
|
||||
MatTooltipModule,
|
||||
MatTreeModule,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: MatPaginatorIntl,
|
||||
deps: [PaginatorI18nService],
|
||||
useFactory: (paginatorI18nSrv: PaginatorI18nService) => paginatorI18nSrv.getPaginatorIntl(),
|
||||
},
|
||||
{
|
||||
provide: MAT_DIALOG_DEFAULT_OPTIONS,
|
||||
useValue: {
|
||||
...new MatDialogConfig(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: MAT_DATE_LOCALE,
|
||||
useFactory: () => navigator.language, // <= This will be overrided by runtime setting
|
||||
},
|
||||
{
|
||||
provide: MAT_DATE_FORMATS,
|
||||
useValue: {
|
||||
parse: {
|
||||
dateInput: 'YYYY-MM-DD',
|
||||
},
|
||||
display: {
|
||||
dateInput: 'YYYY-MM-DD',
|
||||
monthYearLabel: 'YYYY MMM',
|
||||
dateA11yLabel: 'LL',
|
||||
monthYearA11yLabel: 'YYYY MMM',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
export class MaterialModule {}
|
||||
@@ -0,0 +1 @@
|
||||
<page-header></page-header>
|
||||
13
front/app/src/app/routes/dashboard/dashboard.component.ts
Normal file
13
front/app/src/app/routes/dashboard/dashboard.component.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Component, OnInit, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-dashboard',
|
||||
templateUrl: './dashboard.component.html',
|
||||
styleUrls: ['./dashboard.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class DashboardComponent implements OnInit {
|
||||
constructor(private cdr: ChangeDetectorRef) {}
|
||||
|
||||
ngOnInit() {}
|
||||
}
|
||||
48
front/app/src/app/routes/routes-routing.module.ts
Normal file
48
front/app/src/app/routes/routes-routing.module.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { Routes, RouterModule } from '@angular/router';
|
||||
import { environment } from '@env/environment';
|
||||
|
||||
import { AdminLayoutComponent } from '../theme/admin-layout/admin-layout.component';
|
||||
import { AuthLayoutComponent } from '../theme/auth-layout/auth-layout.component';
|
||||
import { DashboardComponent } from './dashboard/dashboard.component';
|
||||
import { LoginComponent } from './sessions/login/login.component';
|
||||
import { RegisterComponent } from './sessions/register/register.component';
|
||||
import { Error403Component } from './sessions/403.component';
|
||||
import { Error404Component } from './sessions/404.component';
|
||||
import { Error500Component } from './sessions/500.component';
|
||||
import { AuthGuard } from '@core';
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: AdminLayoutComponent,
|
||||
canActivate: [AuthGuard],
|
||||
canActivateChild: [AuthGuard],
|
||||
children: [
|
||||
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
|
||||
{ path: 'dashboard', component: DashboardComponent },
|
||||
{ path: '403', component: Error403Component },
|
||||
{ path: '404', component: Error404Component },
|
||||
{ path: '500', component: Error500Component },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'auth',
|
||||
component: AuthLayoutComponent,
|
||||
children: [
|
||||
{ path: 'login', component: LoginComponent },
|
||||
{ path: 'register', component: RegisterComponent },
|
||||
],
|
||||
},
|
||||
{ path: '**', redirectTo: 'dashboard' },
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
RouterModule.forRoot(routes, {
|
||||
useHash: environment.useHash,
|
||||
}),
|
||||
],
|
||||
exports: [RouterModule],
|
||||
})
|
||||
export class RoutesRoutingModule {}
|
||||
26
front/app/src/app/routes/routes.module.ts
Normal file
26
front/app/src/app/routes/routes.module.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { SharedModule } from '@shared/shared.module';
|
||||
import { RoutesRoutingModule } from './routes-routing.module';
|
||||
|
||||
import { DashboardComponent } from './dashboard/dashboard.component';
|
||||
import { LoginComponent } from './sessions/login/login.component';
|
||||
import { RegisterComponent } from './sessions/register/register.component';
|
||||
import { Error403Component } from './sessions/403.component';
|
||||
import { Error404Component } from './sessions/404.component';
|
||||
import { Error500Component } from './sessions/500.component';
|
||||
|
||||
const COMPONENTS: any[] = [
|
||||
DashboardComponent,
|
||||
LoginComponent,
|
||||
RegisterComponent,
|
||||
Error403Component,
|
||||
Error404Component,
|
||||
Error500Component,
|
||||
];
|
||||
const COMPONENTS_DYNAMIC: any[] = [];
|
||||
|
||||
@NgModule({
|
||||
imports: [SharedModule, RoutesRoutingModule],
|
||||
declarations: [...COMPONENTS, ...COMPONENTS_DYNAMIC],
|
||||
})
|
||||
export class RoutesModule {}
|
||||
13
front/app/src/app/routes/sessions/403.component.ts
Normal file
13
front/app/src/app/routes/sessions/403.component.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Component } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-error-403',
|
||||
template: `
|
||||
<error-code
|
||||
code="403"
|
||||
[title]="'Permission denied!'"
|
||||
[message]="'You do not have permission to access the requested data.'"
|
||||
></error-code>
|
||||
`,
|
||||
})
|
||||
export class Error403Component {}
|
||||
13
front/app/src/app/routes/sessions/404.component.ts
Normal file
13
front/app/src/app/routes/sessions/404.component.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Component } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-error-404',
|
||||
template: `
|
||||
<error-code
|
||||
code="404"
|
||||
[title]="'Page not found!'"
|
||||
[message]="'This is not the web page you are looking for.'"
|
||||
></error-code>
|
||||
`,
|
||||
})
|
||||
export class Error404Component {}
|
||||
14
front/app/src/app/routes/sessions/500.component.ts
Normal file
14
front/app/src/app/routes/sessions/500.component.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Component } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-error-500',
|
||||
template: `
|
||||
<error-code
|
||||
code="500"
|
||||
[title]="'Server went wrong!'"
|
||||
[message]="'Just kidding, looks like we have an internal issue, please try refreshing.'"
|
||||
>
|
||||
</error-code>
|
||||
`,
|
||||
})
|
||||
export class Error500Component {}
|
||||
45
front/app/src/app/routes/sessions/login/login.component.html
Normal file
45
front/app/src/app/routes/sessions/login/login.component.html
Normal file
@@ -0,0 +1,45 @@
|
||||
<div class="d-flex w-full h-full">
|
||||
<mat-card class="m-auto" style="max-width: 380px;">
|
||||
<mat-card-header class="m-b-24">
|
||||
<mat-card-title>{{'login.title' | translate}}!</mat-card-title>
|
||||
</mat-card-header>
|
||||
|
||||
<mat-card-content>
|
||||
<form class="form-field-full" [formGroup]="loginForm">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>{{'login.username' | translate}}: ng-matero</mat-label>
|
||||
<input matInput placeholder="ng-matero" formControlName="username" required>
|
||||
<mat-error *ngIf="username.invalid">
|
||||
<span *ngIf="username.errors?.required">{{'login.please_enter' | translate}}
|
||||
<strong>ng-matero</strong>
|
||||
</span>
|
||||
<span *ngIf="username.errors?.remote">{{ username.errors?.remote }}</span>
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>{{'login.password' | translate}}: ng-matero</mat-label>
|
||||
<input matInput placeholder="ng-matero" type="password"
|
||||
formControlName="password" required>
|
||||
<mat-error *ngIf="password.invalid">
|
||||
<span *ngIf="password.errors?.required">{{'login.please_enter' | translate}}
|
||||
<strong>ng-matero</strong>
|
||||
</span>
|
||||
<span *ngIf="password.errors?.remote">{{ password.errors?.remote }}</span>
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-checkbox formControlName="rememberMe">{{'login.remember_me' | translate}}
|
||||
</mat-checkbox>
|
||||
|
||||
<button class="w-full m-y-16" mat-raised-button color="primary"
|
||||
[disabled]="!!loginForm.invalid" [loading]="isSubmitting"
|
||||
(click)="login()">{{'login.login' | translate}}</button>
|
||||
|
||||
<div>{{'login.have_no_account' | translate}}?
|
||||
<a routerLink="/auth/register">{{'login.create_one' | translate}}</a>
|
||||
</div>
|
||||
</form>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
58
front/app/src/app/routes/sessions/login/login.component.ts
Normal file
58
front/app/src/app/routes/sessions/login/login.component.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { FormBuilder, Validators } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { filter } from 'rxjs/operators';
|
||||
import { AuthService } from '@core/authentication';
|
||||
|
||||
@Component({
|
||||
selector: 'app-login',
|
||||
templateUrl: './login.component.html',
|
||||
styleUrls: ['./login.component.scss'],
|
||||
})
|
||||
export class LoginComponent {
|
||||
isSubmitting = false;
|
||||
|
||||
loginForm = this.fb.nonNullable.group({
|
||||
username: ['ng-matero', [Validators.required]],
|
||||
password: ['ng-matero', [Validators.required]],
|
||||
rememberMe: [false],
|
||||
});
|
||||
|
||||
constructor(private fb: FormBuilder, private router: Router, private auth: AuthService) {}
|
||||
|
||||
get username() {
|
||||
return this.loginForm.get('username')!;
|
||||
}
|
||||
|
||||
get password() {
|
||||
return this.loginForm.get('password')!;
|
||||
}
|
||||
|
||||
get rememberMe() {
|
||||
return this.loginForm.get('rememberMe')!;
|
||||
}
|
||||
|
||||
login() {
|
||||
this.isSubmitting = true;
|
||||
|
||||
this.auth
|
||||
.login(this.username.value, this.password.value, this.rememberMe.value)
|
||||
.pipe(filter(authenticated => authenticated))
|
||||
.subscribe(
|
||||
() => this.router.navigateByUrl('/'),
|
||||
(errorRes: HttpErrorResponse) => {
|
||||
if (errorRes.status === 422) {
|
||||
const form = this.loginForm;
|
||||
const errors = errorRes.error.errors;
|
||||
Object.keys(errors).forEach(key => {
|
||||
form.get(key === 'email' ? 'username' : key)?.setErrors({
|
||||
remote: errors[key][0],
|
||||
});
|
||||
});
|
||||
}
|
||||
this.isSubmitting = false;
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<div class="d-flex w-full h-full">
|
||||
<mat-card class="m-auto" style="max-width: 380px;">
|
||||
<mat-card-header class="m-b-24">
|
||||
<mat-card-title>
|
||||
{{'register.welcome' | translate}}, <br />
|
||||
{{'register.title' | translate}}
|
||||
</mat-card-title>
|
||||
</mat-card-header>
|
||||
|
||||
<mat-card-content>
|
||||
<form class="form-field-full" [formGroup]="registerForm">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>{{'login.username' | translate}}</mat-label>
|
||||
<input matInput formControlName="username" required>
|
||||
<mat-error *ngIf="registerForm.get('username')?.invalid">
|
||||
<span>{{'validations.required' | translate}}</span>
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>{{'login.password' | translate}}</mat-label>
|
||||
<input matInput type="password" formControlName="password" required>
|
||||
<mat-error *ngIf="registerForm.get('password')?.invalid">
|
||||
<span>{{'validations.required' | translate}}</span>
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>{{'register.confirm_password' | translate}}</mat-label>
|
||||
<input matInput type="password" formControlName="confirmPassword" required>
|
||||
<mat-error *ngIf="registerForm.get('confirmPassword')?.hasError('required')">
|
||||
<span>{{'validations.required' | translate}}</span>
|
||||
</mat-error>
|
||||
<mat-error *ngIf="registerForm.get('confirmPassword')?.hasError('mismatch')"
|
||||
translate [translateParams]="{value: 'login.password' | translate}">
|
||||
<span>{{'validations.inconsistent'}}</span>
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-checkbox>{{'register.agree' | translate}}</mat-checkbox>
|
||||
|
||||
<button class="w-full m-y-16" mat-raised-button color="primary">
|
||||
{{'register.register' | translate}}
|
||||
</button>
|
||||
|
||||
<div>{{'register.have_an_account' | translate}}?
|
||||
<a routerLink="/auth/login">{{'login.login' | translate}}</a>
|
||||
</div>
|
||||
</form>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { FormBuilder, Validators, AbstractControl } from '@angular/forms';
|
||||
|
||||
@Component({
|
||||
selector: 'app-register',
|
||||
templateUrl: './register.component.html',
|
||||
styleUrls: ['./register.component.scss'],
|
||||
})
|
||||
export class RegisterComponent {
|
||||
registerForm = this.fb.nonNullable.group(
|
||||
{
|
||||
username: ['', [Validators.required]],
|
||||
password: ['', [Validators.required]],
|
||||
confirmPassword: ['', [Validators.required]],
|
||||
},
|
||||
{
|
||||
validators: [this.matchValidator('password', 'confirmPassword')],
|
||||
}
|
||||
);
|
||||
|
||||
constructor(private fb: FormBuilder) {}
|
||||
|
||||
matchValidator(source: string, target: string) {
|
||||
return (control: AbstractControl) => {
|
||||
const sourceControl = control.get(source)!;
|
||||
const targetControl = control.get(target)!;
|
||||
if (targetControl.errors && !targetControl.errors.mismatch) {
|
||||
return null;
|
||||
}
|
||||
if (sourceControl.value !== targetControl.value) {
|
||||
targetControl.setErrors({ mismatch: true });
|
||||
return { mismatch: true };
|
||||
} else {
|
||||
targetControl.setErrors(null);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="matero-breadcrumb">
|
||||
<li class="matero-breadcrumb-item"
|
||||
*ngFor="let navLink of nav; trackBy: trackByNavlink; first as isFirst;">
|
||||
<a href="#" class="link" *ngIf="isFirst">{{navLink}}</a>
|
||||
<ng-container *ngIf="!isFirst">
|
||||
<mat-icon class="chevron">chevron_right</mat-icon>
|
||||
<span>{{navLink | translate}}</span>
|
||||
</ng-container>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
@@ -0,0 +1,33 @@
|
||||
.matero-breadcrumb {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0;
|
||||
font-size: .875rem;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.matero-breadcrumb-item {
|
||||
line-height: 18px;
|
||||
text-transform: capitalize;
|
||||
|
||||
> * {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
> a.link {
|
||||
color: currentColor;
|
||||
|
||||
&:hover {
|
||||
color: currentColor;
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
> .chevron {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
font-size: 18px;
|
||||
user-select: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Component, OnInit, ViewEncapsulation, Input } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { MenuService } from '@core/bootstrap/menu.service';
|
||||
|
||||
@Component({
|
||||
selector: 'breadcrumb',
|
||||
templateUrl: './breadcrumb.component.html',
|
||||
styleUrls: ['./breadcrumb.component.scss'],
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
})
|
||||
export class BreadcrumbComponent implements OnInit {
|
||||
@Input() nav: string[] = [];
|
||||
|
||||
constructor(private router: Router, private menu: MenuService) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.nav = Array.isArray(this.nav) ? this.nav : [];
|
||||
|
||||
if (this.nav.length === 0) {
|
||||
this.genBreadcrumb();
|
||||
}
|
||||
}
|
||||
|
||||
trackByNavlink(index: number, navLink: string): string {
|
||||
return navLink;
|
||||
}
|
||||
|
||||
genBreadcrumb() {
|
||||
const routes = this.router.url.slice(1).split('/');
|
||||
this.nav = this.menu.getLevel(routes);
|
||||
this.nav.unshift('home');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
@use 'sass:map';
|
||||
@use '@angular/material' as mat;
|
||||
|
||||
@mixin theme($theme) {
|
||||
$background: map.get($theme, background);
|
||||
$foreground: map.get($theme, foreground);
|
||||
|
||||
.matero-error-code {
|
||||
color: mat.get-color-from-palette($foreground, text);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// Long Shadow
|
||||
//
|
||||
// https://codepen.io/c_fitzmaurice/pen/ZYJeRY
|
||||
|
||||
@use 'sass:color';
|
||||
@use 'sass:list';
|
||||
@use 'sass:meta';
|
||||
@use 'sass:map';
|
||||
@use 'sass:math';
|
||||
|
||||
@function long-shadow($direction, $length, $color, $fade: false, $shadow-count: 100) {
|
||||
$shadows: ();
|
||||
$conversion-map: (
|
||||
to top: 180deg,
|
||||
to top right: 135deg,
|
||||
to right top: 135deg,
|
||||
to right: 90deg,
|
||||
to bottom right: 45deg,
|
||||
to right bottom: 45deg,
|
||||
to bottom: 0deg,
|
||||
to bottom left: 315deg,
|
||||
to left bottom: 315deg,
|
||||
to left: 270deg,
|
||||
to left top: 225deg,
|
||||
to top left: 225deg
|
||||
);
|
||||
|
||||
@if map-has-key($conversion-map, $direction) {
|
||||
$direction: map.get($conversion-map, $direction);
|
||||
}
|
||||
|
||||
@for $i from 1 through $shadow-count {
|
||||
$current-step: math.div($i * $length, $shadow-count);
|
||||
$current-color: if(
|
||||
not $fade,
|
||||
$color,
|
||||
if(
|
||||
meta.type-of($fade) == 'color',
|
||||
color.mix($fade, $color, math.div($i, $shadow-count) * 100%),
|
||||
color.rgba($color, 1 - math.div($i, $shadow-count))
|
||||
)
|
||||
);
|
||||
|
||||
$shadows: list.append(
|
||||
$shadows,
|
||||
(math.sin(0deg + $direction) * $current-step)
|
||||
(math.cos(0deg + $direction) * $current-step)
|
||||
0
|
||||
$current-color,
|
||||
'comma'
|
||||
);
|
||||
}
|
||||
|
||||
@return $shadows;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<div class="matero-error-wrap">
|
||||
<div class="matero-error-code">{{code}}</div>
|
||||
<div class="matero-error-title" *ngIf="title">{{title}}</div>
|
||||
<div class="matero-error-message" *ngIf="message">{{message}}</div>
|
||||
<div><a mat-raised-button color="primary" routerLink="/">Back to Home</a></div>
|
||||
</div>
|
||||
@@ -0,0 +1,32 @@
|
||||
@use 'long-shadow';
|
||||
|
||||
.matero-error-wrap {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.matero-error-code {
|
||||
padding: 20px 0;
|
||||
font-size: 150px;
|
||||
text-shadow:
|
||||
long-shadow.long-shadow(
|
||||
$direction: 45deg,
|
||||
$length: 60px,
|
||||
$color: rgba(0, 0, 0, .03),
|
||||
$fade: rgba(0, 0, 0, .0015),
|
||||
$shadow-count: 20
|
||||
);
|
||||
}
|
||||
|
||||
.matero-error-title {
|
||||
margin: 0 0 16px;
|
||||
font-weight: 500;
|
||||
font-size: 20px;
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
.matero-error-message {
|
||||
margin: 0 0 16px;
|
||||
font-weight: 400;
|
||||
font-size: 16px;
|
||||
line-height: 28px;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Component, ViewEncapsulation, Input } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'error-code',
|
||||
templateUrl: './error-code.component.html',
|
||||
styleUrls: ['./error-code.component.scss'],
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
})
|
||||
export class ErrorCodeComponent {
|
||||
@Input() code = '';
|
||||
@Input() title = '';
|
||||
@Input() message = '';
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<div class="matero-page-header-inner">
|
||||
<h1 class="matero-page-title">{{title | translate}} <small>{{subtitle}}</small></h1>
|
||||
<breadcrumb *ngIf="!hideBreadcrumb" [nav]="nav"></breadcrumb>
|
||||
</div>
|
||||
@@ -0,0 +1,18 @@
|
||||
.matero-page-header {
|
||||
display: block;
|
||||
margin: -16px -16px 16px;
|
||||
padding: 16px;
|
||||
color: #fff;
|
||||
background-color: #3f51b5;
|
||||
|
||||
.matero-breadcrumb {
|
||||
margin-top: 8px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.matero-page-title {
|
||||
margin: 0;
|
||||
font-weight: 400;
|
||||
font-size: 24px;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Component, OnInit, ViewEncapsulation, Input, HostBinding } from '@angular/core';
|
||||
import { MenuService } from '@core/bootstrap/menu.service';
|
||||
import { Router } from '@angular/router';
|
||||
import { BooleanInput, coerceBooleanProperty } from '@angular/cdk/coercion';
|
||||
|
||||
@Component({
|
||||
selector: 'page-header',
|
||||
templateUrl: './page-header.component.html',
|
||||
styleUrls: ['./page-header.component.scss'],
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
})
|
||||
export class PageHeaderComponent implements OnInit {
|
||||
@HostBinding('class') class = 'matero-page-header';
|
||||
|
||||
@Input() title = '';
|
||||
@Input() subtitle = '';
|
||||
@Input() nav: string[] = [];
|
||||
@Input()
|
||||
get hideBreadcrumb() {
|
||||
return this._hideBreadCrumb;
|
||||
}
|
||||
set hideBreadcrumb(value: boolean) {
|
||||
this._hideBreadCrumb = coerceBooleanProperty(value);
|
||||
}
|
||||
private _hideBreadCrumb = false;
|
||||
|
||||
constructor(private router: Router, private menu: MenuService) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.nav = Array.isArray(this.nav) ? this.nav : [];
|
||||
|
||||
if (this.nav.length === 0) {
|
||||
this.genBreadcrumb();
|
||||
}
|
||||
|
||||
this.title = this.title || this.nav[this.nav.length - 1];
|
||||
}
|
||||
|
||||
genBreadcrumb() {
|
||||
const routes = this.router.url.slice(1).split('/');
|
||||
this.nav = this.menu.getLevel(routes);
|
||||
this.nav.unshift('home');
|
||||
}
|
||||
|
||||
static ngAcceptInputType_hideBreadcrumb: BooleanInput;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { NgControl } from '@angular/forms';
|
||||
import { Directive, Input, SkipSelf, Optional } from '@angular/core';
|
||||
|
||||
@Directive({
|
||||
selector: '[disableControl]',
|
||||
})
|
||||
export class DisableControlDirective {
|
||||
@Input() set disableControl(condition: boolean) {
|
||||
const action = condition ? 'disable' : 'enable';
|
||||
this.ngControl.control?.[action]();
|
||||
}
|
||||
|
||||
constructor(@Optional() @SkipSelf() private ngControl: NgControl) {}
|
||||
}
|
||||
16
front/app/src/app/shared/in-mem/in-mem-data.service.spec.ts
Normal file
16
front/app/src/app/shared/in-mem/in-mem-data.service.spec.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { InMemDataService } from './in-mem-data.service';
|
||||
|
||||
describe('InMemDataService', () => {
|
||||
let service: InMemDataService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({});
|
||||
service = TestBed.inject(InMemDataService);
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
});
|
||||
209
front/app/src/app/shared/in-mem/in-mem-data.service.ts
Normal file
209
front/app/src/app/shared/in-mem/in-mem-data.service.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpRequest } from '@angular/common/http';
|
||||
import { InMemoryDbService, RequestInfo, STATUS } from 'angular-in-memory-web-api';
|
||||
import { from, Observable } from 'rxjs';
|
||||
import { ajax } from 'rxjs/ajax';
|
||||
import { find, map, switchMap } from 'rxjs/operators';
|
||||
import { environment } from '@env/environment';
|
||||
import { base64, currentTimestamp, filterObject, User } from '@core/authentication';
|
||||
|
||||
class JWT {
|
||||
generate(user: User) {
|
||||
const expiresIn = 3600;
|
||||
const refreshTokenExpiresIn = 86400;
|
||||
|
||||
return filterObject({
|
||||
access_token: this.createToken(user, expiresIn),
|
||||
token_type: 'bearer',
|
||||
expires_in: user.refresh_token ? expiresIn : undefined,
|
||||
refresh_token: user.refresh_token ? this.createToken(user, refreshTokenExpiresIn) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
getUser(req: HttpRequest<any>) {
|
||||
let token = '';
|
||||
|
||||
if (req.body?.refresh_token) {
|
||||
token = req.body.refresh_token;
|
||||
} else if (req.headers.has('Authorization')) {
|
||||
const authorization = req.headers.get('Authorization');
|
||||
const result = (authorization as string).split(' ');
|
||||
token = result[1];
|
||||
}
|
||||
|
||||
try {
|
||||
const now = new Date();
|
||||
const data = JWT.parseToken(token);
|
||||
|
||||
return JWT.isExpired(data, now) ? null : data.user;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
createToken(user: User, expiresIn = 0) {
|
||||
const exp = user.refresh_token ? currentTimestamp() + expiresIn : undefined;
|
||||
|
||||
return [
|
||||
base64.encode(JSON.stringify({ typ: 'JWT', alg: 'HS256' })),
|
||||
base64.encode(JSON.stringify(filterObject(Object.assign({ exp, user })))),
|
||||
base64.encode('ng-matero'),
|
||||
].join('.');
|
||||
}
|
||||
|
||||
private static parseToken(accessToken: string) {
|
||||
const [, payload] = accessToken.split('.');
|
||||
|
||||
return JSON.parse(base64.decode(payload));
|
||||
}
|
||||
|
||||
private static isExpired(data: any, now: Date) {
|
||||
const expiresIn = new Date();
|
||||
expiresIn.setTime(data.exp * 1000);
|
||||
const diff = this.dateToSeconds(expiresIn) - this.dateToSeconds(now);
|
||||
|
||||
return diff <= 0;
|
||||
}
|
||||
|
||||
private static dateToSeconds(date: Date) {
|
||||
return Math.ceil(date.getTime() / 1000);
|
||||
}
|
||||
}
|
||||
|
||||
const jwt = new JWT();
|
||||
|
||||
function is(reqInfo: RequestInfo, path: string) {
|
||||
if (environment.baseUrl) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return new RegExp(`${path}(/)?$`, 'i').test(reqInfo.req.url);
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class InMemDataService implements InMemoryDbService {
|
||||
private users: User[] = [
|
||||
{
|
||||
id: 1,
|
||||
username: 'ng-matero',
|
||||
password: 'ng-matero',
|
||||
name: 'Zongbin',
|
||||
email: 'nzb329@163.com',
|
||||
avatar: './assets/images/avatar.jpg',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
username: 'recca0120',
|
||||
password: 'password',
|
||||
name: 'recca0120',
|
||||
email: 'recca0120@gmail.com',
|
||||
avatar: './assets/images/avatars/avatar-10.jpg',
|
||||
refresh_token: true,
|
||||
},
|
||||
];
|
||||
|
||||
createDb(
|
||||
reqInfo?: RequestInfo
|
||||
):
|
||||
| Record<string, unknown>
|
||||
| Observable<Record<string, unknown>>
|
||||
| Promise<Record<string, unknown>> {
|
||||
return { users: this.users };
|
||||
}
|
||||
|
||||
get(reqInfo: RequestInfo) {
|
||||
const { headers, url } = reqInfo;
|
||||
|
||||
if (is(reqInfo, 'sanctum/csrf-cookie')) {
|
||||
const response = { headers, url, status: STATUS.NO_CONTENT, body: {} };
|
||||
|
||||
return reqInfo.utils.createResponse$(() => response);
|
||||
}
|
||||
|
||||
if (is(reqInfo, 'me/menu')) {
|
||||
return ajax('assets/data/menu.json?_t=' + Date.now()).pipe(
|
||||
map((response: any) => {
|
||||
return { headers, url, status: STATUS.OK, body: { menu: response.response.menu } };
|
||||
}),
|
||||
switchMap(response => reqInfo.utils.createResponse$(() => response))
|
||||
);
|
||||
}
|
||||
|
||||
if (is(reqInfo, 'me')) {
|
||||
const user = jwt.getUser(reqInfo.req as HttpRequest<any>);
|
||||
const result = user
|
||||
? { status: STATUS.OK, body: user }
|
||||
: { status: STATUS.UNAUTHORIZED, body: {} };
|
||||
const response = Object.assign({ headers, url }, result);
|
||||
|
||||
return reqInfo.utils.createResponse$(() => response);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
post(reqInfo: RequestInfo) {
|
||||
if (is(reqInfo, 'auth/login')) {
|
||||
return this.login(reqInfo);
|
||||
}
|
||||
|
||||
if (is(reqInfo, 'auth/refresh')) {
|
||||
return this.refresh(reqInfo);
|
||||
}
|
||||
|
||||
if (is(reqInfo, 'auth/logout')) {
|
||||
return this.logout(reqInfo);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
private login(reqInfo: RequestInfo) {
|
||||
const { headers, url } = reqInfo;
|
||||
const req = reqInfo.req as HttpRequest<any>;
|
||||
const { username, password } = req.body;
|
||||
|
||||
return from(this.users).pipe(
|
||||
find(user => user.username === username || user.email === username),
|
||||
map(user => {
|
||||
if (!user) {
|
||||
return { headers, url, status: STATUS.UNAUTHORIZED, body: {} };
|
||||
}
|
||||
|
||||
if (user.password !== password) {
|
||||
const result = {
|
||||
status: STATUS.UNPROCESSABLE_ENTRY,
|
||||
error: { errors: { password: ['The provided password is incorrect.'] } },
|
||||
};
|
||||
|
||||
return Object.assign({ headers, url }, result);
|
||||
}
|
||||
|
||||
const currentUser = Object.assign({}, user);
|
||||
delete currentUser.password;
|
||||
return { headers, url, status: STATUS.OK, body: jwt.generate(currentUser) };
|
||||
}),
|
||||
switchMap(response => reqInfo.utils.createResponse$(() => response))
|
||||
);
|
||||
}
|
||||
|
||||
private refresh(reqInfo: RequestInfo) {
|
||||
const { headers, url } = reqInfo;
|
||||
const user = jwt.getUser(reqInfo.req as HttpRequest<any>);
|
||||
const result = user
|
||||
? { status: STATUS.OK, body: jwt.generate(user) }
|
||||
: { status: STATUS.UNAUTHORIZED, body: {} };
|
||||
const response = Object.assign({ headers, url }, result);
|
||||
|
||||
return reqInfo.utils.createResponse$(() => response);
|
||||
}
|
||||
|
||||
private logout(reqInfo: RequestInfo) {
|
||||
const { headers, url } = reqInfo;
|
||||
const response = { headers, url, status: STATUS.OK, body: {} };
|
||||
|
||||
return reqInfo.utils.createResponse$(() => response);
|
||||
}
|
||||
}
|
||||
12
front/app/src/app/shared/index.ts
Normal file
12
front/app/src/app/shared/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
// Module
|
||||
export * from './shared.module';
|
||||
|
||||
// Services
|
||||
export * from './services/directionality.service';
|
||||
export * from './services/message.service';
|
||||
export * from './services/storage.service';
|
||||
export * from './services/paginator-i18n.service';
|
||||
|
||||
// Utils
|
||||
export * from './utils/colors';
|
||||
export * from './utils/icons';
|
||||
10
front/app/src/app/shared/pipes/safe-url.pipe.ts
Normal file
10
front/app/src/app/shared/pipes/safe-url.pipe.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core';
|
||||
import { DomSanitizer } from '@angular/platform-browser';
|
||||
|
||||
@Pipe({ name: 'safeUrl' })
|
||||
export class SafeUrlPipe implements PipeTransform {
|
||||
constructor(private sanitizer: DomSanitizer) {}
|
||||
transform(url: string) {
|
||||
return this.sanitizer.bypassSecurityTrustResourceUrl(url);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user