Merge pull request 'Feature: Added Login / Register Feature' (#3) from feature/register-view into main
Reviewed-on: #3
This commit is contained in:
commit
10e481669e
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
coverage
|
||||
dist
|
||||
node_modules
|
||||
pnpm-lock.yaml
|
||||
|
|
|
@ -4,5 +4,7 @@
|
|||
"semi": true,
|
||||
"endOfLine": "auto",
|
||||
"bracketSameLine": true,
|
||||
"htmlWhitespaceSensitivity": "ignore"
|
||||
"htmlWhitespaceSensitivity": "ignore",
|
||||
"printWidth": 80,
|
||||
"bracketSpacing": true
|
||||
}
|
||||
|
|
|
@ -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
|
@ -1,2 +1 @@
|
|||
<router-outlet></router-outlet>
|
||||
<button (click)="connectMollie()">Connect with Mollie</button>
|
||||
|
|
|
@ -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'
|
||||
);
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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(),
|
||||
],
|
||||
};
|
||||
|
|
|
@ -1,5 +1,12 @@
|
|||
import { Routes } from '@angular/router';
|
||||
|
||||
export const routes: Routes = [
|
||||
{ path: '', pathMatch: 'full', redirectTo: ''},
|
||||
{ path: '', pathMatch: 'full', redirectTo: '' },
|
||||
{
|
||||
path: 'signup',
|
||||
loadComponent: () =>
|
||||
import('./pages/register-root/register-root.component').then(
|
||||
(m) => m.RegisterRootComponent
|
||||
),
|
||||
},
|
||||
];
|
||||
|
|
|
@ -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>
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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());
|
||||
})
|
||||
);
|
||||
};
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
export * from './auth.service';
|
||||
export * from './local-storage.service';
|
||||
export * from './session-storage.service';
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
import DOMPurify from 'dompurify';
|
||||
|
||||
export class SanitizationService {
|
||||
public static sanitize(data: string): string {
|
||||
return DOMPurify.sanitize(data);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,2 @@
|
|||
export * from './login-credentials';
|
||||
export * from './tokens';
|
|
@ -0,0 +1,4 @@
|
|||
export type LoginCredentials = {
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
|
@ -0,0 +1,4 @@
|
|||
export type Tokens = {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
};
|
|
@ -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 };
|
||||
};
|
||||
}
|
|
@ -0,0 +1,2 @@
|
|||
export * from './email-validator';
|
||||
export * from './password-validator';
|
|
@ -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 };
|
||||
};
|
||||
}
|
|
@ -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',
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue