Merge pull request 'Feature: Added Login / Register Feature' (#3) from feature/register-view into main

Reviewed-on: #3
This commit is contained in:
Igor Hrenowitsch Propisnov 2024-05-21 21:24:10 +02:00
commit 10e481669e
34 changed files with 3844 additions and 5132 deletions

View File

@ -8,6 +8,7 @@ import { SecurityHeadersMiddleware } from './middleware/security-middleware/secu
import { HttpsRedirectMiddleware } from './middleware/https-middlware/https-redirect.middleware';
import { AuthModule } from './modules/auth-module/auth.module';
import { AccessTokenGuard } from './modules/auth-module/common/guards';
import { CorsMiddleware } from './middleware/cors-middleware/cors.middlware';
@Module({
imports: [
@ -24,7 +25,12 @@ export class AppModule {
configure(consumer: MiddlewareConsumer) {
consumer
// TODO: Redirect via Reverse Proxy all HTTP requests to HTTPS
.apply(CspMiddleware, SecurityHeadersMiddleware, HttpsRedirectMiddleware)
.apply(
CspMiddleware,
SecurityHeadersMiddleware,
HttpsRedirectMiddleware,
CorsMiddleware
)
.forRoutes({ path: '*', method: RequestMethod.ALL });
}
}

View File

@ -0,0 +1,36 @@
import { Injectable, NestMiddleware } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Request, Response, NextFunction } from 'express';
@Injectable()
export class CorsMiddleware implements NestMiddleware {
constructor(private readonly configService: ConfigService) {}
public use(req: Request, res: Response, next: NextFunction): void {
if (this.configService.get<string>('NODE_ENV') === 'development') {
const allowedOrigin = this.configService.get<string>('CORS_ALLOW_ORIGIN');
if (req.headers.origin === allowedOrigin) {
res.header('Access-Control-Allow-Origin', allowedOrigin);
res.header(
'Access-Control-Allow-Methods',
this.configService.get<string>('CORS_ALLOW_METHODS')
);
res.header(
'Access-Control-Allow-Headers',
this.configService.get<string>('CORS_ALLOW_HEADERS')
);
if (req.method === 'OPTIONS') {
res.sendStatus(200);
} else {
next();
}
} else {
res.status(403).json({ message: 'Forbidden' });
}
} else {
next();
}
}
}

View File

@ -1,6 +1,7 @@
{
"root": true,
"ignorePatterns": ["projects/**/*"],
"plugins": ["import", "prettier", "@stylistic/eslint-plugin-ts", "sort-class-members", "unused-imports"],
"overrides": [
{
"files": ["*.ts"],
@ -9,9 +10,78 @@
"plugin:@typescript-eslint/recommended",
"plugin:@angular-eslint/recommended",
"plugin:@angular-eslint/template/process-inline-templates",
"prettier"
"prettier",
"plugin:prettier/recommended"
],
"rules": {
"@stylistic/ts/lines-between-class-members": [
"error",
{
"enforce": [
{ "blankLine": "always", "prev": "*", "next": "method" },
{ "blankLine": "always", "prev": "method", "next": "*" },
{ "blankLine": "never", "prev": "field", "next": "field" }
]
}
],
"@stylistic/ts/block-spacing": ["error"],
"@stylistic/ts/brace-style": "off",
"@stylistic/ts/key-spacing": ["error", { "afterColon": true }],
"@stylistic/ts/keyword-spacing": ["error", { "before": true }],
"@stylistic/ts/no-extra-parens": ["error", "all", {
"nestedBinaryExpressions": false,
"ternaryOperandBinaryExpressions": false
}],
"@stylistic/ts/no-extra-semi": ["error"],
"@stylistic/ts/object-curly-spacing": ["error", "always"],
"@stylistic/ts/quotes": ["error", "single"],
"@stylistic/ts/semi": ["error", "always"],
"@stylistic/ts/space-before-blocks": ["error"],
"@stylistic/ts/space-before-function-paren": ["error", {"anonymous": "always", "named": "never", "asyncArrow": "always"}],
"@stylistic/ts/space-infix-ops": ["error"],
// "@stylistic/ts/max-statements-per-line": ["error", { "max": 1 }],
// "@stylistic/ts/multiline-ternary": ["error", "always"],
// "@stylistic/ts/newline-per-chained-call": ["error", { "ignoreChainWithDepth": 2 }],
// "@stylistic/ts/no-confusing-arrow": ["error"],
// "@stylistic/ts/no-floating-decimal": ["error"],
// "@stylistic/ts/no-mixed-operators": ["error"],
// "@stylistic/ts/no-mixed-spaces-and-tabs": ["error"],/
// "@stylistic/ts/no-multi-spaces": ["error"],
// "@stylistic/ts/no-multiple-empty-lines": ["error", { "max": 2 }],
// "@stylistic/ts/no-tabs": ["error", { "allowIndentationTabs": true }],
// "@stylistic/ts/no-whitespace-before-property": ["error"],
// "@stylistic/ts/nonblock-statement-body-position": ["error", "below"],
// "@stylistic/ts/object-curly-newline": ["error", "always"],
// "@stylistic/ts/object-property-newline": ["error"],
// "@stylistic/ts/one-var-declaration-per-line": ["error", "always"],
// "@stylistic/ts/operator-linebreak": ["error", "before"],
// "@stylistic/ts/padded-blocks": ["error", "never"],
// "@stylistic/ts/rest-spread-spacing": ["error", "never"],
// "@stylistic/ts/semi-spacing": ["error"],
// "@stylistic/ts/semi-style": ["error", "last"],
// "@stylistic/ts/space-in-parens": ["error", "never"],
// "@stylistic/ts/space-unary-ops": ["error"],
// "@stylistic/ts/template-curly-spacing": ["error"],
// "@stylistic/ts/template-tag-spacing": ["error"],
// "@stylistic/ts/wrap-regex": ["error"],
"no-console": ["warn", { "allow": ["warn", "error"] }],
"no-debugger": "error",
"no-var": ["error"],
"eqeqeq": ["error", "always"],
"no-eval": "error",
"prefer-const": ["error", { "destructuring": "all", "ignoreReadBeforeAssign": true }],
"prettier/prettier": ["error", { "printWidth": 80 }],
"no-unused-vars": "off", // or "@typescript-eslint/no-unused-vars": "off",
"unused-imports/no-unused-imports": "error",
"unused-imports/no-unused-vars": [
"warn",
{
"vars": "all",
"varsIgnorePattern": "^_",
"args": "after-used",
"argsIgnorePattern": "^_"
}
],
"@angular-eslint/directive-selector": [
"error",
{
@ -27,6 +97,125 @@
"prefix": "app",
"style": "kebab-case"
}
],
"@typescript-eslint/explicit-member-accessibility": [
"error",
{
"accessibility": "explicit"
}
],
"@typescript-eslint/explicit-function-return-type": [
"error",
{
"allowExpressions": false,
"allowTypedFunctionExpressions": true,
"allowHigherOrderFunctions": false,
"allowDirectConstAssertionInArrowFunctions": false,
"allowConciseArrowFunctionExpressionsStartingWithVoid": false
}
],
"@typescript-eslint/member-ordering": [
"error",
{
"default": [
"public-static-field",
"protected-static-field",
"private-static-field",
"public-instance-field",
"protected-instance-field",
"private-instance-field",
"public-constructor",
"protected-constructor",
"private-constructor",
"public-static-method",
"protected-static-method",
"private-static-method",
"public-instance-method",
"protected-instance-method",
"private-instance-method"
]
}
],
// https://github.com/bryanrsmith/eslint-plugin-sort-class-members -> Read Docs and replace @typescript-eslint/member-ordering
// "sort-class-members/sort-class-members": [
// 2,
// {
// "order": [
// "[public-properties]",
// "[protected-properties]",
// "[private-properties]",
// "everything-else"
// ],
// "groups": {
// "public-properties": [{ "type": "property", "accessibility": "public" }],
// "protected-properties": [{ "type": "property", "accessibility": "protected" }],
// "private-properties": [{ "type": "property", "accessibility": "private" }]
// }
// }
// ],
"padding-line-between-statements": [
"error",
{ "blankLine": "always", "prev": "const", "next": "*" },
{ "blankLine": "always", "prev": "let", "next": "*" },
{ "blankLine": "always", "prev": "var", "next": "*" },
{ "blankLine": "any", "prev": ["const", "let", "var"], "next": ["const", "let", "var"] }
],
"@typescript-eslint/no-unused-vars": [
"error",
{
"vars": "all",
"args": "after-used",
"ignoreRestSiblings": false
}
],
"@typescript-eslint/typedef": [
"error",
{
"arrayDestructuring": true,
"arrowParameter": false,
"memberVariableDeclaration": true,
"objectDestructuring": true,
"parameter": true,
"propertyDeclaration": true,
"variableDeclaration": false,
"variableDeclarationIgnoreFunction": true
}
],
"import/order": [
"error",
{
"groups": [
"builtin",
"external",
"internal",
"parent",
"sibling",
"index"
],
"pathGroups": [
{
"pattern": "@angular/**",
"group": "external",
"position": "before"
},
{
"pattern": "@app/**",
"group": "internal",
"position": "before"
},
{
"pattern": "@env/**",
"group": "internal",
"position": "before"
}
],
"pathGroupsExcludedImportTypes": ["builtin"],
"newlines-between": "always",
"alphabetize": {
"order": "asc",
"caseInsensitive": true
}
}
]
}
},
@ -36,7 +225,14 @@
"plugin:@angular-eslint/template/recommended",
"plugin:@angular-eslint/template/accessibility"
],
"rules": {}
"rules": {
"@angular-eslint/template/elements-content": [
"error",
{
"allowList": ["label"]
}
]
}
}
]
}

View File

@ -1,3 +1,4 @@
coverage
dist
node_modules
pnpm-lock.yaml

View File

@ -4,5 +4,7 @@
"semi": true,
"endOfLine": "auto",
"bracketSameLine": true,
"htmlWhitespaceSensitivity": "ignore"
"htmlWhitespaceSensitivity": "ignore",
"printWidth": 80,
"bracketSpacing": true
}

View File

@ -11,9 +11,11 @@
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"lint": "ng lint",
"prettier:fix": "npx prettier --write .",
"prettier:check": "npx prettier --check .",
"lint:check": "ng lint",
"lint:fix": "ng lint --fix",
"prettier:fix": "prettier --write .",
"prettier:check": "prettier --check .",
"format": "pnpm run lint:fix && pnpm run prettier:fix",
"ngrok-tunnel": "pnpm wait-on http://localhost:4200 && ngrok http --domain=commonly-hot-airedale.ngrok-free.app 4200"
},
"private": true,
@ -26,6 +28,9 @@
"@angular/platform-browser": "^17.3.0",
"@angular/platform-browser-dynamic": "^17.3.0",
"@angular/router": "^17.3.0",
"@types/dompurify": "^3.0.5",
"crypto-js": "^4.2.0",
"dompurify": "^3.1.3",
"primeng": "^17.11.0",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
@ -40,12 +45,20 @@
"@angular-eslint/template-parser": "17.2.1",
"@angular/cli": "^17.3.0",
"@angular/compiler-cli": "^17.3.0",
"@stylistic/eslint-plugin": "^2.1.0",
"@stylistic/eslint-plugin-migrate": "^2.1.0",
"@stylistic/eslint-plugin-ts": "^2.1.0",
"@types/crypto-js": "^4.2.2",
"@types/jest": "^29.5.12",
"@typescript-eslint/eslint-plugin": "6.19.0",
"@typescript-eslint/parser": "6.19.0",
"concurrently": "^8.2.2",
"eslint": "^8.56.0",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-import": "^2.29.1",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-sort-class-members": "^1.20.0",
"eslint-plugin-unused-imports": "^3.2.0",
"jest": "^29.7.0",
"jest-preset-angular": "^14.0.3",
"prettier": "3.2.5",

File diff suppressed because it is too large Load Diff

View File

@ -1,2 +1 @@
<router-outlet></router-outlet>
<button (click)="connectMollie()">Connect with Mollie</button>

View File

@ -1,4 +1,5 @@
import { TestBed } from '@angular/core/testing';
import { AppComponent } from './app.component';
describe('AppComponent', () => {
@ -11,19 +12,23 @@ describe('AppComponent', () => {
it('should create the app', () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.componentInstance;
expect(app).toBeTruthy();
});
it(`should have the 'frontend' title`, () => {
it('should have the "frontend" title', () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.componentInstance;
expect(app.title).toEqual('frontend');
});
it('should render title', () => {
const fixture = TestBed.createComponent(AppComponent);
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('h1')?.textContent).toContain(
'Hello, frontend'
);

View File

@ -1,13 +1,15 @@
import { Component } from '@angular/core';
import { Component, inject } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { AuthService } from './shared/service';
@Component({
selector: 'app-root',
standalone: true,
providers: [],
providers: [AuthService],
imports: [RouterOutlet],
templateUrl: './app.component.html',
styleUrl: './app.component.scss',
})
export class AppComponent {
constructor() {}
private readonly authService: AuthService = inject(AuthService);
}

View File

@ -1,9 +1,15 @@
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { ApplicationConfig } from '@angular/core';
import { provideRouter, withComponentInputBinding } from '@angular/router';
import { provideAnimations } from '@angular/platform-browser/animations';
import { provideRouter, withComponentInputBinding } from '@angular/router';
import { routes } from './app.routes';
import { AuthInterceptor } from './shared/interceptors/auth.interceptor';
export const appConfig: ApplicationConfig = {
providers: [provideRouter(routes, withComponentInputBinding()), provideAnimations()],
providers: [
provideHttpClient(withInterceptors([AuthInterceptor])),
provideRouter(routes, withComponentInputBinding()),
provideAnimations(),
],
};

View File

@ -2,4 +2,11 @@ import { Routes } from '@angular/router';
export const routes: Routes = [
{ path: '', pathMatch: 'full', redirectTo: '' },
{
path: 'signup',
loadComponent: () =>
import('./pages/register-root/register-root.component').then(
(m) => m.RegisterRootComponent
),
},
];

View File

@ -0,0 +1,94 @@
<div id="background">
<div class="img-zone">
<div class="img-wrapper">
<h1>Hi, Welcome to Ticket App.</h1>
</div>
</div>
<div class="content-zone">
<h1>
@if (isSignupSignal()) {
Anmelden
} @else if (isRegisterSignal()) {
Registrieren
} @else {
Erste Schritte
}
</h1>
@if (isDisplayButtons()) {
<div class="action">
<button
pButton
type="button"
label="Anmelden"
(click)="toggleAction('signup')"></button>
<button
pButton
type="button"
label="Registrieren"
(click)="toggleAction('register')"></button>
</div>
}
@if (isSignupSignal() || isRegisterSignal()) {
<div class="register-wrapper">
@if (form) {
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<div class="e-mail">
<div class="label">
<label for="email">E-Mail</label>
</div>
<input
pInputText
id="email"
formControlName="email"
aria-describedby="e-mail" />
</div>
<div class="password">
<div class="label">
<label for="password">Password</label>
</div>
<p-password
class="custom-p-password"
id="password"
formControlName="password"
aria-describedby="password"
[toggleMask]="true"></p-password>
</div>
@if (isRegisterSignal()) {
<div class="terms">
<p-checkbox
formControlName="terms"
label="Ich habe die AGB gelesen und stimme zu."
name="terms"
[binary]="true"></p-checkbox>
</div>
}
<div class="signup">
<button
pButton
type="submit"
[label]="
isSignupSignal()
? 'Anmelden'
: '✨ Jetzt KOSTENFREI loslegen ✨'
"></button>
</div>
<div class="change-mask">
<a
(click)="switchMask()"
(keyup.enter)="switchMask()"
tabindex="0">
@if (isSignupSignal()) {
Kein Account? Erstellen Sie jetzt KOSTENFREI einen!
} @else {
Schon einen Account? Hier einloggen
}
</a>
</div>
</form>
}
</div>
}
</div>
</div>

View File

@ -0,0 +1,94 @@
#background {
display: flex;
height: 100%;
}
.img-zone {
flex: 65;
background-color: lightsalmon;
display: flex;
.img-wrapper {
display: flex;
align-items: center;
h1 {
font-size: 4em;
margin-left: 1em;
}
}
}
.content-zone {
flex: 35;
background-color: lightcyan;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
.action {
display: flex;
justify-content: center;
gap: 1em;
button {
min-width: 200px;
}
}
.register-wrapper {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
h1 {
font-size: 3em;
}
.label {
padding: 0 0 0.5em;
}
.e-mail,
.password,
.terms {
.label {
font-size: 1;
}
input {
min-width: 500px;
}
::ng-deep p-password.custom-p-password div input {
min-width: 500px;
}
}
.terms {
padding-top: 1.33em;
}
.password {
padding-top: 2em;
}
.signup {
padding-top: 3em;
button {
min-width: 500px;
}
}
.change-mask {
padding-top: 1.33em;
display: flex;
justify-content: center;
a {
cursor: pointer;
}
}
}
}

View File

@ -0,0 +1,215 @@
import { CommonModule } from '@angular/common';
import { HttpClientModule } from '@angular/common/http';
import {
ChangeDetectionStrategy,
Component,
OnInit,
WritableSignal,
signal,
effect,
} from '@angular/core';
import {
FormBuilder,
FormControl,
FormGroup,
FormsModule,
ReactiveFormsModule,
Validators,
} from '@angular/forms';
import { ButtonModule } from 'primeng/button';
import { CheckboxModule } from 'primeng/checkbox';
import { InputTextModule } from 'primeng/inputtext';
import { PasswordModule } from 'primeng/password';
import { AuthService } from '../../shared/service';
import { LoginCredentials } from '../../shared/types';
import {
customEmailValidator,
customPasswordValidator,
} from '../../shared/validator';
type AuthAction = 'register' | 'signup';
@Component({
selector: 'app-register-root',
standalone: true,
imports: [
CommonModule,
FormsModule,
InputTextModule,
ReactiveFormsModule,
ButtonModule,
CheckboxModule,
PasswordModule,
HttpClientModule,
],
providers: [AuthService],
templateUrl: './register-root.component.html',
styleUrl: './register-root.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class RegisterRootComponent implements OnInit {
public form: FormGroup | undefined;
public isRegisterSignal: WritableSignal<boolean> = signal(false);
public isSignupSignal: WritableSignal<boolean> = signal(false);
public isDisplayButtons: WritableSignal<boolean> = signal(true);
public emailInvalid: WritableSignal<string | null> = signal(null);
public passwordInvalid: WritableSignal<string | null> = signal(null);
public termsInvalid: WritableSignal<string | null> = signal(null);
public constructor(
private readonly formBuilder: FormBuilder,
private readonly authService: AuthService
) {
effect(() => {
if (this.form) {
if (this.isRegisterSignal()) {
this.form.addControl(
'terms',
new FormControl(false, Validators.requiredTrue)
);
} else {
this.form.removeControl('terms');
}
}
});
}
public ngOnInit(): void {
this.initializeForm();
this.setupValueChanges();
}
public toggleAction(action: AuthAction): void {
if (action === 'register') {
this.isRegisterSignal.set(true);
this.isSignupSignal.set(false);
} else {
this.isRegisterSignal.set(false);
this.isSignupSignal.set(true);
}
this.isDisplayButtons.set(false);
}
public onSubmit(): void {
this.markControlsAsTouchedAndDirty(['email', 'password', 'terms']);
if (this.form?.valid) {
if (this.isRegisterSignal()) {
this.register(this.form.value);
} else {
this.signin(this.form.value);
}
}
}
public switchMask(): void {
this.resetFormValidation();
if (this.isSignupSignal()) {
this.isSignupSignal.set(false);
this.isRegisterSignal.set(true);
} else if (this.isRegisterSignal()) {
this.isSignupSignal.set(true);
this.isRegisterSignal.set(false);
}
}
private initializeForm(): void {
this.form = this.formBuilder.group({
email: new FormControl('', {
validators: [Validators.required, customEmailValidator()],
updateOn: 'change',
}),
password: new FormControl('', {
validators: [Validators.required, customPasswordValidator()],
updateOn: 'change',
}),
terms: new FormControl(false, {
validators: [Validators.requiredTrue],
updateOn: 'change',
}),
});
}
private setupValueChanges(): void {
this.setupEmailValueChanges();
this.setupPasswordValueChanges();
}
private setupEmailValueChanges(): void {
const emailControl = this.form?.get('email');
emailControl?.valueChanges.subscribe((value: string) => {
if (value?.length >= 4) {
emailControl.setValidators([
Validators.required,
customEmailValidator(),
]);
} else {
emailControl.setValidators([
Validators.required,
Validators.minLength(4),
]);
}
emailControl.updateValueAndValidity({ emitEvent: false });
});
}
private setupPasswordValueChanges(): void {
const passwordControl = this.form?.get('password');
passwordControl?.valueChanges.subscribe((value: string) => {
if (value?.length >= 8) {
passwordControl.setValidators([
Validators.required,
customPasswordValidator(),
]);
} else {
passwordControl.setValidators([
Validators.required,
Validators.minLength(8),
]);
}
passwordControl.updateValueAndValidity({ emitEvent: false });
});
}
private markControlsAsTouchedAndDirty(controlNames: string[]): void {
controlNames.forEach((controlName: string) => {
const control = this.form?.get(controlName);
if (control) {
control.markAsTouched();
control.markAsDirty();
control.updateValueAndValidity();
}
});
}
private resetFormValidation(): void {
['email', 'password', 'terms'].forEach((controlName: string) => {
this.resetControlValidation(controlName);
});
}
private resetControlValidation(controlName: string): void {
const control = this.form?.get(controlName);
if (control) {
control.reset();
control.markAsPristine();
control.markAsUntouched();
control.updateValueAndValidity();
}
}
private signin(logiCredentials: LoginCredentials): void {
this.authService.signin(logiCredentials);
}
private register(logiCredentials: LoginCredentials): void {
this.authService.signup(logiCredentials);
}
}

View File

@ -0,0 +1,51 @@
import {
HttpErrorResponse,
HttpEvent,
HttpHandlerFn,
HttpInterceptorFn,
HttpRequest,
} from '@angular/common/http';
import { inject } from '@angular/core';
import { Observable, catchError, switchMap, throwError } from 'rxjs';
import { AuthService } from '../service';
import { Tokens } from '../types';
export const AuthInterceptor: HttpInterceptorFn = (
request: HttpRequest<unknown>,
next: HttpHandlerFn
): Observable<HttpEvent<unknown>> => {
const authService: AuthService = inject(AuthService);
const accessToken: string | null = authService.access_token;
if (accessToken) {
request = request.clone({
setHeaders: {
Authorization: `Bearer ${accessToken}`,
},
});
}
return next(request).pipe(
catchError((error: HttpErrorResponse) => {
if (error.status === 401) {
return authService.refreshToken().pipe(
switchMap((tokens: Tokens) => {
request = request.clone({
setHeaders: {
Authorization: `Bearer ${tokens.access_token}`,
},
});
return next(request);
}),
catchError((refreshError) => {
authService.signout();
return throwError(() => new Error(refreshError));
})
);
}
return throwError(() => new Error());
})
);
};

View File

@ -0,0 +1,100 @@
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable, tap } from 'rxjs';
import { environment } from '../../../environments/environment';
import { LoginCredentials, Tokens } from '../types';
import { LocalStorageService } from './local-storage.service';
import { SessionStorageService } from './session-storage.service';
@Injectable({
providedIn: 'root',
})
export class AuthService {
private readonly _path: string = '/api/auth';
private _access_token: string | null = null;
private _refresh_token: string | null = null;
private _isAuthenticated$: BehaviorSubject<boolean> =
new BehaviorSubject<boolean>(false);
public get access_token(): string | null {
return this._access_token;
}
public constructor(
private readonly httpClient: HttpClient,
private readonly localStorageService: LocalStorageService,
private readonly sessionStorageService: SessionStorageService
) {
this.autoLogin();
}
public signin(credentials: LoginCredentials): void {
this.httpClient
.post<Tokens>(environment.api.base + `${this._path}/signin`, credentials)
.subscribe((response: Tokens) => {
this.handleSuccess(response);
});
}
public signup(credentials: LoginCredentials): void {
this.httpClient
.post<Tokens>(environment.api.base + `${this._path}/signup`, credentials)
.subscribe((response: Tokens) => {
//TODO The checked accept terms should be saved with a timestamp in the db
this.handleSuccess(response);
});
}
public signout(): void {
this._access_token = null;
this._refresh_token = null;
this.localStorageService.removeItem('access_token');
this.sessionStorageService.removeItem('refresh_token');
this._isAuthenticated$.next(false);
}
public autoLogin(): void {
const storedAccessToken: string | null =
this.localStorageService.getItem('access_token');
const storedRefreshToken: string | null =
this.sessionStorageService.getItem('refresh_token');
if (storedAccessToken && storedRefreshToken) {
this._refresh_token = storedRefreshToken;
this._isAuthenticated$.next(true);
//TODO Validate tokens with backend or decode JWT to check expiration
} else {
this.signout();
}
}
public refreshToken(): Observable<Tokens> {
const headers = new HttpHeaders().set(
'Authorization',
'Bearer ' + this._refresh_token
);
return this.httpClient
.post<Tokens>(
environment.api.base + `${this._path}/refresh`,
{},
{ headers: headers }
)
.pipe(
tap((response: Tokens) => {
this.handleSuccess(response);
})
);
}
private handleSuccess(tokens: Tokens): void {
this._access_token = tokens.access_token;
this._refresh_token = tokens.refresh_token;
this.localStorageService.setItem('access_token', tokens.access_token);
this.sessionStorageService.setItem('refresh_token', tokens.refresh_token);
this._isAuthenticated$.next(true);
}
}

View File

@ -0,0 +1,33 @@
import { EncryptionService } from './encryption.service';
import { SanitizationService } from './sanitization.service';
export abstract class BaseStorageService {
protected abstract getStorage(): Storage;
public setItem<T>(key: string, value: T): void {
const sanitizedValue = SanitizationService.sanitize(JSON.stringify(value));
const encryptedValue = EncryptionService.encrypt(sanitizedValue);
this.getStorage().setItem(key, encryptedValue);
}
public getItem<T>(key: string): T | null {
const encryptedValue = this.getStorage().getItem(key);
if (encryptedValue) {
const decryptedValue = EncryptionService.decrypt(encryptedValue);
const sanitizedValue = SanitizationService.sanitize(decryptedValue);
return JSON.parse(sanitizedValue) as T;
}
return null;
}
public removeItem(key: string): void {
this.getStorage().removeItem(key);
}
public clear(): void {
this.getStorage().clear();
}
}

View File

@ -0,0 +1,15 @@
import * as CryptoJS from 'crypto-js';
import { environment } from '../../../environments/environment';
export class EncryptionService {
private static readonly key: string = environment.security.encryptionKey;
public static encrypt(data: string): string {
return CryptoJS.AES.encrypt(data, this.key).toString();
}
public static decrypt(data: string): string {
return CryptoJS.AES.decrypt(data, this.key).toString(CryptoJS.enc.Utf8);
}
}

View File

@ -0,0 +1,3 @@
export * from './auth.service';
export * from './local-storage.service';
export * from './session-storage.service';

View File

@ -0,0 +1,12 @@
import { Injectable } from '@angular/core';
import { BaseStorageService } from './base-storage.service';
@Injectable({
providedIn: 'root',
})
export class LocalStorageService extends BaseStorageService {
protected getStorage(): Storage {
return localStorage;
}
}

View File

@ -0,0 +1,7 @@
import DOMPurify from 'dompurify';
export class SanitizationService {
public static sanitize(data: string): string {
return DOMPurify.sanitize(data);
}
}

View File

@ -0,0 +1,12 @@
import { Injectable } from '@angular/core';
import { BaseStorageService } from './base-storage.service';
@Injectable({
providedIn: 'root',
})
export class SessionStorageService extends BaseStorageService {
protected getStorage(): Storage {
return sessionStorage;
}
}

View File

@ -0,0 +1,2 @@
export * from './login-credentials';
export * from './tokens';

View File

@ -0,0 +1,4 @@
export type LoginCredentials = {
email: string;
password: string;
};

View File

@ -0,0 +1,4 @@
export type Tokens = {
access_token: string;
refresh_token: string;
};

View File

@ -0,0 +1,19 @@
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
export function customEmailValidator(): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const value = control.value;
if (!value) {
return { emailInvalid: true };
}
if (value.length < 4) {
return { emailTooShort: true };
}
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailPattern.test(value) ? null : { emailInvalid: true };
};
}

View File

@ -0,0 +1,2 @@
export * from './email-validator';
export * from './password-validator';

View File

@ -0,0 +1,13 @@
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
export function customPasswordValidator(): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const value = control.value;
if (!value) {
return { passwordTooShort: true };
}
return value.length >= 8 ? null : { passwordTooShort: true };
};
}

View File

@ -2,6 +2,6 @@ export const environment = {
production: true,
oauth: {
clinetId: 'app_FLXnxSBnnaKkXoYCgk3J62iA',
redirectUri: 'https://commonly-hot-airedale.ngrok-free.app/oauth'
}
redirectUri: 'https://commonly-hot-airedale.ngrok-free.app/oauth',
},
};

View File

@ -1,7 +1,13 @@
export const environment = {
production: false,
api: {
base: 'http://localhost:3000',
},
security: {
encryptionKey: 'my-secret',
},
oauth: {
clinetId: 'app_FLXnxSBnnaKkXoYCgk3J62iA',
redirectUri: 'https://commonly-hot-airedale.ngrok-free.app/oauth'
}
redirectUri: 'https://commonly-hot-airedale.ngrok-free.app/oauth',
},
};

View File

@ -1,6 +1,7 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { AppComponent } from './app/app.component';
import { appConfig } from './app/app.config';
bootstrapApplication(AppComponent, appConfig).catch((err) =>
console.error(err)

View File

@ -1,3 +1,12 @@
// Import PrimeNG styles
@import 'primeng/resources/themes/lara-light-blue/theme.css';
@import 'primeng/resources/primeng.css';
// PrimeNG icons
@import 'primeicons/primeicons.css';
html,
body {
height: 100%;
margin: 0;
}