first commit

This commit is contained in:
2025-07-22 22:10:30 +02:00
commit 9a1ffc51d8
130 changed files with 20161 additions and 0 deletions

16
.editorconfig Normal file
View File

@@ -0,0 +1,16 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
[*.md]
max_line_length = off
trim_trailing_whitespace = false

21
.firebaserc Normal file
View File

@@ -0,0 +1,21 @@
{
"targets": {
"iwuzzler-web-app": {
"hosting": {
"frontend": [
"iwuzzler"
]
}
},
"iwuzzler-develop-16bee": {
"hosting": {
"frontend": [
"iwuzzler-development"
]
}
}
},
"projects": {
"default": "iwuzzler-web-app"
}
}

47
.gitignore vendored Normal file
View File

@@ -0,0 +1,47 @@
# See http://help.github.com/ignore-files/ for more about ignoring files.
# Compiled output
/dist
/tmp
/out-tsc
/bazel-out
# Node
/node_modules
npm-debug.log
yarn-error.log
# IDEs and editors
.idea/
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.history/*
# Miscellaneous
/.angular/cache
.sass-cache/
/connect.lock
/coverage
/libpeerconnection.log
testem.log
/typings
# System files
.DS_Store
Thumbs.db
# Firebase
.firebase
*-debug.log
.runtimeconfig.json

49
.tfignore Normal file
View File

@@ -0,0 +1,49 @@
#test
/testignore
test.txt
# Compiled output
/dist
/tmp
/out-tsc
/bazel-out
# Node
/node_modules
npm-debug.log
yarn-error.log
# IDEs and editors
.idea/
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.history/*
# Miscellaneous
/.angular/cache
.sass-cache/
/connect.lock
/coverage
/libpeerconnection.log
testem.log
/typings
# System files
.DS_Store
Thumbs.db
# Firebase
.firebase
*-debug.log
.runtimeconfig.json

22
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,22 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "iWuzzler",
"request": "launch",
"type": "chrome",
"url": "http://localhost:4200",
"webRoot": "${workspaceFolder}"
},
{
"name": "Launch Chrome",
"request": "launch",
"type": "chrome",
"url": "http://localhost:4200",
"webRoot": "${workspaceFolder}"
},
]
}

27
README.md Normal file
View File

@@ -0,0 +1,27 @@
# Frontend
This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 17.1.0.
## Development server
Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The application will automatically reload if you change any of the source files.
## Code scaffolding
Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`.
## Build
Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory.
## Running unit tests
Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io).
## Running end-to-end tests
Run `ng e2e` to execute the end-to-end tests via a platform of your choice. To use this command, you need to first add a package that implements end-to-end testing capabilities.
## Further help
To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI Overview and Command Reference](https://angular.io/cli) page.

126
angular.json Normal file
View File

@@ -0,0 +1,126 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"frontend": {
"projectType": "application",
"schematics": {},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:application",
"options": {
"outputPath": "dist/frontend",
"index": "src/index.html",
"browser": "src/main.ts",
"polyfills": [
"zone.js"
],
"tsConfig": "tsconfig.app.json",
"assets": [
"src/favicon.ico",
"src/assets"
],
"styles": [
"@angular/material/prebuilt-themes/indigo-pink.css",
"src/styles.scss"
],
"scripts": []
},
"configurations": {
"production": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true,
"budgets": [
{
"type": "initial",
"maximumWarning": "5mb",
"maximumError": "5mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kb",
"maximumError": "4kb"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true,
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.development.ts"
}
]
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"configurations": {
"production": {
"buildTarget": "frontend:build:production"
},
"development": {
"buildTarget": "frontend:build:development"
}
},
"defaultConfiguration": "development"
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n",
"options": {
"buildTarget": "frontend:build"
}
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"polyfills": [
"zone.js",
"zone.js/testing"
],
"tsConfig": "tsconfig.spec.json",
"assets": [
"src/favicon.ico",
"src/assets"
],
"styles": [
"@angular/material/prebuilt-themes/indigo-pink.css",
"src/styles.scss"
],
"scripts": []
}
},
"deploy": {
"builder": "@angular/fire:deploy",
"options": {
"version": 2
},
"configurations": {
"production": {
"buildTarget": "frontend:build:production",
"serveTarget": "frontend:serve:production"
},
"development": {
"buildTarget": "frontend:build:development",
"serveTarget": "frontend:serve:development"
}
},
"defaultConfiguration": "production"
}
}
}
},
"cli": {
"analytics": false
}
}

15
firebase-develop.json Normal file
View File

@@ -0,0 +1,15 @@
{
"hosting": [
{
"site": "iwuzzler-development",
"public": "dist/frontend/browser",
"ignore": ["firebase.json", "**/.*", "**/node_modules/**"],
"rewrites": [
{
"source": "**",
"destination": "/index.html"
}
]
}
]
}

19
firebase.json Normal file
View File

@@ -0,0 +1,19 @@
{
"hosting": [
{
"site": "iwuzzler",
"public": "dist/frontend/browser",
"ignore": [
"firebase.json",
"**/.*",
"**/node_modules/**"
],
"rewrites": [
{
"source": "**",
"destination": "/index.html"
}
]
}
]
}

9
firestore.rules Normal file
View File

@@ -0,0 +1,9 @@
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /{document=**} {
allow read, write: if request.time < timestamp.date(2024, 3, 31);
}
}
}firestore.rules

14791
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

49
package.json Normal file
View File

@@ -0,0 +1,49 @@
{
"name": "frontend",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build --configuration=production",
"build:dev": "ng build --configuration=development",
"watch": "ng build --watch --configuration development",
"deploy": "firebase -P iwuzzler deploy",
"deploy:dev": "firebase -P iwuzzler-development -c firebase-develop.json deploy",
"test": "ng test"
},
"private": true,
"dependencies": {
"@angular/animations": "^17.1.0",
"@angular/cdk": "^17.2.1",
"@angular/common": "^17.1.0",
"@angular/compiler": "^17.1.0",
"@angular/core": "^17.1.0",
"@angular/fire": "^17.0.1",
"@angular/forms": "^17.1.0",
"@angular/material": "^17.2.1",
"@angular/platform-browser": "^17.1.0",
"@angular/platform-browser-dynamic": "^17.1.0",
"@angular/router": "^17.1.0",
"@ngneat/hot-toast": "^7.0.0",
"@ngneat/until-destroy": "^10.0.0",
"@popperjs/core": "^2.11.8",
"eslint": "^8.57.0",
"firebase": "^10.8.0",
"rxjs": "~7.8.0",
"tslib": "^2.6.2",
"zone.js": "~0.14.3"
},
"devDependencies": {
"@angular-devkit/build-angular": "^17.1.0",
"@angular/cli": "^17.1.0",
"@angular/compiler-cli": "^17.1.0",
"@types/jasmine": "~5.1.0",
"jasmine-core": "~5.1.0",
"karma": "~6.4.0",
"karma-chrome-launcher": "~3.2.0",
"karma-coverage": "~2.2.0",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0",
"typescript": "~5.3.2"
}
}

View File

@@ -0,0 +1,34 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import {MatButtonModule} from '@angular/material/button';
import {MatFormFieldModule} from '@angular/material/form-field';
import {MatInputModule} from '@angular/material/input';
import {MatCardModule} from '@angular/material/card';
import {MatSnackBarModule} from '@angular/material/snack-bar';
import {MatIcon, MatIconModule} from '@angular/material/icon';
@NgModule({
declarations: [],
imports: [
CommonModule,
MatButtonModule,
MatFormFieldModule,
MatInputModule,
MatCardModule,
MatSnackBarModule,
MatIcon
],
exports: [
MatButtonModule,
MatFormFieldModule,
MatInputModule,
MatCardModule,
MatSnackBarModule,
MatIcon]
})
export class AngularMaterialModule { }

View File

View File

@@ -0,0 +1 @@
<router-outlet></router-outlet>

View File

@@ -0,0 +1,29 @@
import { TestBed } from '@angular/core/testing';
import { AppComponent } from './app.component';
describe('AppComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [AppComponent],
}).compileComponents();
});
it('should create the app', () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.componentInstance;
expect(app).toBeTruthy();
});
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');
});
});

20
src/app/app.component.ts Normal file
View File

@@ -0,0 +1,20 @@
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { AngularMaterialModule } from './angular-material/angular-material.module';
import { AuthService } from './auth/authService/auth.service';
@Component({
selector: 'app-root',
standalone: true,
imports: [RouterOutlet, AngularMaterialModule],
templateUrl: './app.component.html',
styleUrl: './app.component.css',
})
export class AppComponent {
title = 'Frontend';
constructor(private authService: AuthService) {}
ngOnInit() {
this.authService.reloadUserUntilVerified();
}
}

30
src/app/app.config.ts Normal file
View File

@@ -0,0 +1,30 @@
import { ApplicationConfig, importProvidersFrom } from '@angular/core';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
import { initializeApp, provideFirebaseApp } from '@angular/fire/app';
import { getAuth, provideAuth } from '@angular/fire/auth';
import { getFirestore, provideFirestore } from '@angular/fire/firestore';
import { environment } from '../environments/environment';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
provideAnimationsAsync(),
importProvidersFrom(
provideFirebaseApp(() =>
initializeApp({
projectId: environment.firebase.projectId,
appId: environment.firebase.appId,
storageBucket: environment.firebase.storageBucket,
apiKey: environment.firebase.apiKey,
authDomain: environment.firebase.authDomain,
messagingSenderId: environment.firebase.messagingSenderId,
})
)
),
importProvidersFrom(provideAuth(() => getAuth())),
importProvidersFrom(provideFirestore(() => getFirestore())),
provideAnimationsAsync(),
],
};

50
src/app/app.routes.ts Normal file
View File

@@ -0,0 +1,50 @@
import { Routes } from '@angular/router';
import { LoginPasswordComponent } from './auth/login-password/login-password.component';
import { SignupPasswordComponent } from './auth/signup-password/signup-password.component';
import { UserProfileComponent } from './user-profile/user-profile.component';
import {
canActivate,
redirectLoggedInTo,
redirectUnauthorizedTo,
} from '@angular/fire/auth-guard';
import { PendingScoreGuard } from './score/pending-score.guard';
import { ScoreTableComponent } from './score-table/score-table/score-table.component';
import { ResetPwComponent } from './auth/resetPw/resetPw.component';
import { EmailVerificationModalComponent } from './auth/email-verification-modal/email-verification-modal.component';
import { FinishSignupComponent } from './auth/finish-signup/finish-signup.component';
import { SignupComponent } from './auth/signup/signup.component';
import { LoginComponent } from './auth/login/login.component';
const redirectToHome = () => redirectLoggedInTo(['score-table']);
export const routes: Routes = [
{
path: 'score-table',
component: ScoreTableComponent,
},
{ path: 'login', component: LoginComponent, ...canActivate(redirectToHome) },
{
path: 'finish-signup',
component: FinishSignupComponent,
},
{
path: 'user-profile',
component: UserProfileComponent,
canActivate: [PendingScoreGuard],
},
{
path: 'user-profile/:id',
component: UserProfileComponent,
},
{
path: 'reset-password',
component: ResetPwComponent,
...canActivate(redirectToHome),
},
{
path: 'email-verification',
component: EmailVerificationModalComponent,
},
{ path: '', redirectTo: '/score-table', pathMatch: 'full' },
];

View File

@@ -0,0 +1,194 @@
import { Injectable } from '@angular/core';
import { Observable, catchError, from, map, throwError } from 'rxjs';
import {
Auth,
authState,
createUserWithEmailAndPassword,
signInWithEmailAndPassword,
sendEmailVerification,
sendPasswordResetEmail,
sendSignInLinkToEmail,
isSignInWithEmailLink,
signInWithEmailLink,
} from '@angular/fire/auth';
import { AuthErrorCodes } from 'firebase/auth';
import { NotifierService } from '../../shared/notifierService/notifier.service';
import { User } from '../model/user.model';
import { PendingActionsService } from '../../score/pending-actions.service';
import { UserService } from '../../user-profile/user.service';
import { Router } from '@angular/router';
@Injectable({
providedIn: 'root',
})
export class AuthService {
currentUser$ = authState(this.auth);
constructor(
private auth: Auth,
private notifierService: NotifierService,
private pendingService: PendingActionsService,
private router: Router
) {}
reloadUserUntilVerified() {
const interval = setInterval(async () => {
const user = this.getCurrentUser();
if (user) {
await user.reload();
if (user.emailVerified) {
clearInterval(interval);
}
}
}, 5000);
}
login(username: string, password: string) {
return from(signInWithEmailAndPassword(this.auth, username, password)).pipe(
catchError((error) => {
let errorMessage = 'Ein unbekannter Fehler ist aufgetreten.';
if (error.code === AuthErrorCodes.INVALID_LOGIN_CREDENTIALS) {
errorMessage = 'Email oder Passwort ungültig.';
}
return throwError(() => new Error(errorMessage));
})
);
}
loginWithEmail(): Observable<User> {
if (isSignInWithEmailLink(this.auth, window.location.href)) {
let email = window.localStorage.getItem('emailForSignIn');
if (!email) {
email = window.prompt('Please provide your email for confirmation');
}
if (email) {
const [firstName, lastName] = this.extractNamesFromEmail(email);
return from(
signInWithEmailLink(this.auth, email, window.location.href).then(
(result: any) => {
window.localStorage.removeItem('emailForSignIn');
const newUser: User = {
uid: result.user.uid,
email: email,
firstName: firstName,
lastName: lastName,
firmenposition: null,
photoUrl: null,
wins: null,
};
this.pendingService.addUserRefToQRCodeData(
newUser.uid,
this.pendingService.getTempQRId()
);
return newUser;
}
)
).pipe(
catchError((error) => {
let errorMessage = 'Ein unbekannter Fehler ist aufgetreten.';
if (error.code === AuthErrorCodes.EMAIL_EXISTS) {
errorMessage = 'Diese E-Mail-Adresse existiert bereits.';
}
return throwError(() => new Error(errorMessage));
})
);
}
}
return throwError(() => new Error('Kein Link zum Anmelden gefunden.'));
}
logout() {
return from(this.auth.signOut());
}
signUp(email: string, score?: string, uid?: string) {
let finishSignUpUrl = '/finish-signup';
if (score && uid) {
finishSignUpUrl += `?score=${score}&uid=${uid}`;
}
console.log('finishSignUpUrl=', finishSignUpUrl);
const actionCodeSettings = {
url: window.location.origin + finishSignUpUrl,
handleCodeInApp: true,
};
sendSignInLinkToEmail(this.auth, email, actionCodeSettings)
.then(() => {
window.localStorage.setItem('emailForSignIn', email);
})
.catch((error) => {
let errorMessage = 'Ein unbekannter Fehler ist aufgetreten.';
if (error.code === AuthErrorCodes.EMAIL_EXISTS) {
errorMessage = 'Diese E-Mail-Adresse existiert bereits.';
}
return throwError(() => new Error(errorMessage));
});
}
signUpPassword(email: string, password: string) {
return from(
createUserWithEmailAndPassword(this.auth, email, password)
).pipe(
map((userCredential) => {
// Send verification email after successful sign up
this.sendVerificationEmail();
return userCredential;
}),
catchError((error) => {
let errorMessage = 'Ein unbekannter Fehler ist aufgetreten.';
if (error.code === AuthErrorCodes.EMAIL_EXISTS) {
errorMessage = 'Diese E-Mail-Adresse existiert bereits.';
}
return throwError(() => new Error(errorMessage));
})
);
}
getCurrentUser() {
return this.auth.currentUser;
}
resetPassword(email: string) {
return sendPasswordResetEmail(this.auth, email);
}
get isVerifiedUser$(): Observable<boolean> {
return this.currentUser$.pipe(
map((user) => (user ? user.emailVerified : false))
);
}
sendVerificationEmail() {
sendEmailVerification(this.getCurrentUser()!, {
url: window.location.origin + '/email-verification',
}).catch((error) => {
this.notifierService.showNotification(
'Zu viele Versuche. Bitte probiere es später noch einmal.',
'OK'
);
});
}
private extractNamesFromEmail(email: string): [string, string] {
const sanitizedEmail = email.replace(/.\d|\.external/g, '');
const [fullName] = sanitizedEmail.split('@');
const names = fullName.split('.');
// Vor- und Nachname grossgeschrieben
const capitalize = (name: string) =>
name
.split('-')
.map(
(part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase()
)
.join('-');
// Capitalize both the first name and last name parts
const firstName = names.length > 0 ? capitalize(names[0]) : '';
const lastName =
names.length > 1 ? capitalize(names.slice(1).join('-')) : '';
return [firstName, lastName];
}
}

View File

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

View File

@@ -0,0 +1,28 @@
import { Component } from '@angular/core';
import { CustomDialogComponent } from '../../shared/custom-dialog/custom-dialog.component';
import { MatDialog } from '@angular/material/dialog';
import { Router } from '@angular/router';
@Component({
selector: 'app-email-verification-modal',
standalone: true,
imports: [],
templateUrl: './email-verification-modal.component.html',
styleUrl: './email-verification-modal.component.css',
})
export class EmailVerificationModalComponent {
constructor(private dialog: MatDialog, private router: Router) {}
ngOnInit() {
this.dialog.open(CustomDialogComponent, {
data: {
title: 'E-Mail-Verifizierungs-Hinweis',
bodyText:
'Falls du dich mit deinem Privathandy + Arbeitsprofil angemeldet hast, kehre bitte wieder in deinen Privat-Browser zurück und aktualisiere die iWuzzler Page.',
onConfirm: () => {
this.router.navigate(['/score-table']);
},
},
});
}
}

View File

@@ -0,0 +1,7 @@
<div class="flex">
<div class="m-auto kick-motto">
<div *ngIf="isLoading" class="loading-spinner-correct">
<app-loading-spinner></app-loading-spinner>
</div>
</div>
</div>

View File

@@ -0,0 +1,66 @@
import { Component } from '@angular/core';
import { AuthService } from '../authService/auth.service';
import { LoadingSpinnerComponent } from '../../shared/loading-spinner/loading-spinner.component';
import { CommonModule } from '@angular/common';
import { from, map, switchMap } from 'rxjs';
import { PendingActionsService } from '../../score/pending-actions.service';
import { UserService } from '../../user-profile/user.service';
import { ActivatedRoute, Router } from '@angular/router';
import { User } from '../model/user.model';
@Component({
selector: 'app-finish-signup',
standalone: true,
templateUrl: './finish-signup.component.html',
styleUrl: './finish-signup.component.css',
imports: [LoadingSpinnerComponent, CommonModule],
})
export class FinishSignupComponent {
isLoading = false;
constructor(
private authService: AuthService,
private pendingService: PendingActionsService,
private userService: UserService,
private router: Router,
private route: ActivatedRoute
) {}
ngOnInit() {
this.isLoading = true;
this.authService
.loginWithEmail()
.pipe(
switchMap(async (newUser: User) => {
const addUser$ = from(this.userService.addUser(newUser));
return addUser$;
})
)
.subscribe({
next: () => {
this.route.queryParams.subscribe((params) => {
const score = params['score'];
const uid = params['uid'];
if (score && uid) {
this.router.navigate(['/user-profile'], {
queryParams: {
score: score,
uid: uid,
},
});
} else {
this.router.navigate(['/score-table']);
}
});
this.isLoading = false;
},
error: (err) => {
console.error('Error:', err);
this.isLoading = false;
},
});
}
ngOnDestroy() {
this.isLoading = false;
}
}

View File

@@ -0,0 +1,16 @@
/* Form */
form {
display: flex;
flex-direction: column;
}
/* Form Fields */
mat-form-field {
width: 17.8rem;
margin-bottom: 1.5rem;
}
:host ::ng-deep .mat-mdc-text-field-wrapper {
border-radius: 10px;
background: #FFF;
}

View File

@@ -0,0 +1,101 @@
<!-- Flex container -->
<div class="flex" style="overflow: hidden">
<!-- Centered container -->
<div class="m-auto kick-motto">
<div *ngIf="isLoading" class="loading-spinner-correct">
<app-loading-spinner></app-loading-spinner>
</div>
<div *ngIf="isAuthenticated" style="margin-top: 100%">
<app-score-table></app-score-table>
</div>
<!-- Material card -->
<div *ngIf="!isAuthenticated">
<mat-card
class="mat-mdc-card_noshadow default-background-color"
*ngIf="!isLoading"
>
<!-- Card image -->
<header-lsr></header-lsr>
<!-- Card content -->
<mat-card-content>
<!-- Login form -->
<form [formGroup]="loginForm" (ngSubmit)="onSubmit()">
<!-- Email input field -->
<mat-form-field appearance="outline" class="custom-form-field">
<mat-label>Email</mat-label>
<input
type="email"
matInput
formControlName="email"
placeholder="@atos.net | @eviden.com"
/>
<mat-error *ngIf="loginForm.get('email')?.hasError('required')"
>Die E-Mail-Adresse wird benötigt.
</mat-error>
<mat-error *ngIf="loginForm.get('email')?.hasError('pattern')">
Bitte gib deine Firmen-E-Mail lautend auf '&#64;atos.net' oder
'&#64;eviden.com' an.
</mat-error>
</mat-form-field>
<!-- Password input field -->
<mat-form-field appearance="outline" class="custom-form-field">
<mat-label>Passwort</mat-label>
<input
[type]="hide ? 'password' : 'text'"
matInput
formControlName="password"
placeholder="Eingabe"
/>
<mat-error *ngIf="loginForm.get('password')?.hasError('required')"
>Ein Passwort wird benötigt.</mat-error
>
<!-- Suffix button to toggle visibility -->
<!-- Add type="button" to prevent form submission when clicking the button -->
<button
id="pwVisibility"
type="button"
mat-icon-button
matSuffix
(click)="hide = !hide"
[attr.aria-label]="hide ? 'Show password' : 'Hide password'"
[attr.aria-pressed]="!hide"
>
<mat-icon>{{
hide ? "visibility_off" : "visibility"
}}</mat-icon>
</button>
</mat-form-field>
<!-- Card footer with sign-up link -->
<mat-card-footer class="m-auto">
<a routerLink="/login">Login ohne Passwort!</a>
</mat-card-footer>
<!-- Card footer with sign-up link -->
<mat-card-footer class="m-auto margin-top-correct">
<a routerLink="/signup">Kein Konto? Registriere dich jetzt!</a>
</mat-card-footer>
<!-- Card footer with reset-password link -->
<mat-card-footer class="m-auto margin-top-correct">
<a routerLink="/reset-password">Passwort vergessen?</a>
</mat-card-footer>
<!-- Card actions with login button -->
<mat-card-actions>
<button
class="m-auto blue-buttons disabled-button"
mat-button
type="submit"
[disabled]="!loginForm.valid"
>
LOGIN
</button>
</mat-card-actions>
</form>
</mat-card-content>
</mat-card>
</div>
</div>
</div>

View File

@@ -0,0 +1,110 @@
import { Component, OnInit } from '@angular/core';
import { AngularMaterialModule } from '../../angular-material/angular-material.module';
import {
FormControl,
Validators,
FormsModule,
ReactiveFormsModule,
FormGroup,
} from '@angular/forms';
import { Router, RouterModule } from '@angular/router';
import { CommonModule } from '@angular/common';
import { AuthService } from '../authService/auth.service';
import { HttpClientModule } from '@angular/common/http';
import { LoadingSpinnerComponent } from '../../shared/loading-spinner/loading-spinner.component';
import { NotifierService } from '../../shared/notifierService/notifier.service';
import { PendingActionsService } from '../../score/pending-actions.service';
import { ScoreTableComponent } from '../../score-table/score-table/score-table.component';
import { HeaderLoginSignupResetpw } from '../../shared/header-login-signup-resetpw/header-lsr.component';
/**
* LoginComponent represents the component responsible for handling the login functionality.
* It allows users to enter their email and password to authenticate themselves.
* This component utilizes Angular reactive forms for validation and form control management.
*/
@Component({
selector: 'app-login-password',
standalone: true,
providers: [AuthService],
templateUrl: './login-password.component.html',
styleUrl: './login-password.component.css',
imports: [
CommonModule,
AngularMaterialModule,
FormsModule,
ReactiveFormsModule,
RouterModule,
HttpClientModule,
LoadingSpinnerComponent,
ScoreTableComponent,
HeaderLoginSignupResetpw,
],
})
export class LoginPasswordComponent implements OnInit {
loginForm!: FormGroup;
isLoading = false;
isAuthenticated = false;
hide = true;
constructor(
private authService: AuthService,
private notifierService: NotifierService,
private router: Router,
private pendingActionsService: PendingActionsService
) {}
ngOnInit() {
this.authService.currentUser$.subscribe((user) => {
this.isAuthenticated = !!user;
});
// Initialize the login form with form controls and validators
this.loginForm = new FormGroup({
email: new FormControl('', [
Validators.required,
Validators.email,
Validators.pattern(/\b[A-Za-z0-9._%+-]+@(atos\.net|eviden\.com)\b/),
]),
password: new FormControl('', [
Validators.required,
//Validation Text fehlt + blockiert LogIn nach PasswortReset mit einfachem Passwort
//Validators.minLength(10),
//Validators.maxLength(20),
]),
});
}
onSubmit() {
const email = this.loginForm.get('email')?.value;
const password = this.loginForm.get('password')?.value;
this.isLoading = true;
this.authService.login(email, password).subscribe(
async (resData) => {
this.isLoading = false;
// Handle the pending score after successful login
const pendingData = await this.pendingActionsService.getTempQRCodeData(
this.pendingActionsService.getTempQRId()
);
if (pendingData) {
this.router.navigate(['/user-profile'], {
queryParams: {
score: pendingData['score'],
uid: pendingData['uid'],
},
});
} else {
this.router.navigate(['/score-table']);
}
},
(errorMessage) => {
this.notifierService.showNotification(errorMessage, 'OK');
this.isLoading = false;
}
);
this.loginForm.reset();
}
}

View File

@@ -0,0 +1,16 @@
/* Form */
form {
display: flex;
flex-direction: column;
}
/* Form Fields */
mat-form-field {
width: 17.8rem;
margin-bottom: 1.5rem;
}
:host ::ng-deep .mat-mdc-text-field-wrapper {
border-radius: 10px;
background: #fff;
}

View File

@@ -0,0 +1,57 @@
<!-- Flex container -->
<div class="flex" style="overflow: hidden">
<!-- Centered container -->
<div class="m-auto kick-motto">
<div *ngIf="isLoading" class="loading-spinner-correct">
<app-loading-spinner></app-loading-spinner>
</div>
<div *ngIf="isAuthenticated" style="margin-top: 100%">
<app-score-table></app-score-table>
</div>
<!-- Material card -->
<div *ngIf="!isAuthenticated">
<mat-card
class="mat-mdc-card_noshadow default-background-color"
*ngIf="!isLoading"
>
<!-- Card image -->
<header-lsr></header-lsr>
<!-- Card content -->
<mat-card-content>
<!-- Login form -->
<form [formGroup]="loginForm" (ngSubmit)="onSubmit()">
<!-- Email input field -->
<mat-form-field appearance="outline" class="custom-form-field">
<mat-label>Email</mat-label>
<input
type="email"
matInput
formControlName="email"
placeholder="@atos.net | @eviden.com"
/>
<mat-error *ngIf="loginForm.get('email')?.hasError('required')"
>Die E-Mail-Adresse wird benötigt.
</mat-error>
<mat-error *ngIf="loginForm.get('email')?.hasError('pattern')">
Bitte gib deine Firmen-E-Mail lautend auf '&#64;atos.net' oder
'&#64;eviden.com' an.
</mat-error>
</mat-form-field>
<!-- Card actions with login button -->
<mat-card-actions>
<button
class="m-auto blue-buttons disabled-button"
mat-button
type="submit"
[disabled]="!loginForm.valid"
>
LOGIN | SIGN UP
</button>
</mat-card-actions>
</form>
</mat-card-content>
</mat-card>
</div>
</div>
</div>

View File

@@ -0,0 +1,88 @@
import { Component } from '@angular/core';
import { AngularMaterialModule } from '../../angular-material/angular-material.module';
import {
FormControl,
Validators,
FormsModule,
ReactiveFormsModule,
FormGroup,
} from '@angular/forms';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { CommonModule } from '@angular/common';
import { AuthService } from '../authService/auth.service';
import { HttpClientModule } from '@angular/common/http';
import { LoadingSpinnerComponent } from '../../shared/loading-spinner/loading-spinner.component';
import { PendingActionsService } from '../../score/pending-actions.service';
import { ScoreTableComponent } from '../../score-table/score-table/score-table.component';
import { HeaderLoginSignupResetpw } from '../../shared/header-login-signup-resetpw/header-lsr.component';
import { MatDialog } from '@angular/material/dialog';
import { CustomDialogComponent } from '../../shared/custom-dialog/custom-dialog.component';
@Component({
selector: 'app-login',
standalone: true,
imports: [
CommonModule,
AngularMaterialModule,
FormsModule,
ReactiveFormsModule,
RouterModule,
HttpClientModule,
LoadingSpinnerComponent,
ScoreTableComponent,
HeaderLoginSignupResetpw,
],
templateUrl: './login.component.html',
styleUrl: './login.component.css',
})
export class LoginComponent {
loginForm!: FormGroup;
isLoading = false;
isAuthenticated = false;
hide = true;
qrData: { score: string; uid: string } | null = null;
constructor(
private authService: AuthService,
private router: Router,
private pendingActionsService: PendingActionsService,
private dialog: MatDialog,
private route: ActivatedRoute
) {}
ngOnInit() {
this.authService.currentUser$.subscribe((user) => {
this.isAuthenticated = !!user;
});
this.loginForm = new FormGroup({
email: new FormControl('', [
Validators.required,
Validators.email,
Validators.pattern(/\b[A-Za-z0-9._%+-]+@(atos\.net|eviden\.com)\b/),
]),
});
this.route.queryParams.subscribe((params) => {
this.qrData = { score: params['score'], uid: params['uid'] };
});
}
async onSubmit() {
const email = this.loginForm.get('email')?.value;
this.isLoading = true;
console.log('login qrData=', this.qrData);
this.authService.signUp(email, this.qrData?.score, this.qrData?.uid);
this.dialog.open(CustomDialogComponent, {
data: {
title: 'E-Mail-Anmeldung',
bodyText:
'Eine Anmeldungs-Email wurde an deine Adresse gesendet. Bitte überprüfe dein Postfach und klicke auf den Link, um dich anzumelden.',
},
});
this.isLoading = false;
// Handle the pending score after successful login
this.loginForm.reset();
}
}

View File

@@ -0,0 +1,13 @@
export interface User {
uid: string;
email?: string | null;
photoUrl?: string | null;
wins?: number | null;
lastName?: string;
firstName?: string;
firmenposition?: string | null;
}
export interface RankedUser extends User {
rank: number;
}

View File

@@ -0,0 +1,16 @@
/* Form */
form {
display: flex;
flex-direction: column;
}
/* Form Fields */
mat-form-field {
width: 17.8rem;
margin-bottom: 1.5rem;
}
:host ::ng-deep .mat-mdc-text-field-wrapper {
border-radius: 10px;
background: #FFF;
}

View File

@@ -0,0 +1,45 @@
<!-- Flex container -->
<div class="flex">
<!-- Centered container -->
<div class="m-auto kick-motto">
<div *ngIf="isLoading" class="loading-spinner-correct">
<app-loading-spinner></app-loading-spinner>
</div>
<div *ngIf="isAuthenticated" style="margin-top: 100%;">
<app-score-table></app-score-table>
</div>
<!-- Material card -->
<div *ngIf="!isAuthenticated">
<mat-card class="default-background-color mat-mdc-card_noshadow" *ngIf="!isLoading">
<!-- Card image -->
<header-lsr></header-lsr>
<!-- Card content -->
<mat-card-content>
<!-- Login form -->
<form [formGroup]="loginForm" (ngSubmit)="onSubmit()">
<!-- Email input field -->
<mat-form-field appearance="outline" class="custom-form-field">
<mat-label>Email</mat-label>
<input type="email" matInput formControlName="email" placeholder="@atos.net | @eviden.com">
<mat-error *ngIf="loginForm.get('email')?.hasError('pattern')">
Bitte gib deine Firmen-E-Mail lautend auf '&#64;atos.net' oder '&#64;eviden.com' an.
</mat-error>
</mat-form-field>
<!-- Card footer with logIn link -->
<mat-card-footer class="m-auto">
<a routerLink="/login">Du hast bereits ein Konto? Einloggen.</a>
</mat-card-footer>
<!-- Card footer with sign-up link -->
<mat-card-footer class="m-auto margin-top-correct">
<a routerLink="/signup">Kein Konto? Registriere dich jetzt!</a>
</mat-card-footer>
<!-- Card actions with login button -->
<mat-card-actions>
<button class="m-auto blue-buttons disabled-button" mat-button type="submit" [disabled]="!loginForm.valid">RESET</button>
</mat-card-actions>
</form>
</mat-card-content>
</mat-card>
</div>
</div>
</div>

View File

@@ -0,0 +1,94 @@
import { Component, OnInit } from '@angular/core';
import { AngularMaterialModule } from '../../angular-material/angular-material.module';
import {
FormControl,
Validators,
FormsModule,
ReactiveFormsModule,
FormGroup,
} from '@angular/forms';
import { Router, RouterModule } from '@angular/router';
import { CommonModule } from '@angular/common';
import { AuthService } from '../authService/auth.service';
import { HttpClientModule } from '@angular/common/http';
import { LoadingSpinnerComponent } from '../../shared/loading-spinner/loading-spinner.component';
import { NotifierService } from '../../shared/notifierService/notifier.service';
import { ScoreTableComponent } from '../../score-table/score-table/score-table.component';
import {Firestore, getDocs, where} from "@angular/fire/firestore";
import {collection, query} from "firebase/firestore";
import { HeaderLoginSignupResetpw } from '../../shared/header-login-signup-resetpw/header-lsr.component'
@Component({
selector: 'app-login-password',
standalone: true,
providers: [AuthService],
templateUrl: './resetPw.component.html',
styleUrl: './resetPw.component.css',
imports: [
CommonModule,
AngularMaterialModule,
FormsModule,
ReactiveFormsModule,
RouterModule,
HttpClientModule,
LoadingSpinnerComponent,
ScoreTableComponent,
HeaderLoginSignupResetpw,
],
})
export class ResetPwComponent implements OnInit {
/** Represents the login form group */
loginForm!: FormGroup;
isLoading = false;
isAuthenticated = false;
constructor(
private authService: AuthService,
private notifierService: NotifierService,
private router: Router,
private firestore: Firestore,
) {}
ngOnInit() {
this.authService.currentUser$.subscribe((user) => {
this.isAuthenticated = !!user;
});
// Initialize the login form with form controls and validators
this.loginForm = new FormGroup({
email: new FormControl('', [
Validators.required,
Validators.email,
Validators.pattern(/^[a-zA-Z0-9._%+-]+\.+[a-zA-Z0-9._%+-]+@(eviden\.com|atos\.net)$/),
]),
});
}
onSubmit() {
const email = this.loginForm.get('email')?.value;
const lowercaseEmail = email.toLowerCase();
this.isLoading = true;
const usersCollection = collection(this.firestore, 'users');
const scoreQuery = query(usersCollection, where('email', '==', lowercaseEmail));
getDocs(scoreQuery)
.then(querySnapshot => {
if (querySnapshot.size > 0) {
// Email exists, perform desired action
this.authService.resetPassword(email).then(() => {
this.router.navigate(['/login']);
this.isLoading = false;
this.notifierService.showNotification("Reset Link gesendet an "+email.toLowerCase(), 'OK');
}
);
} else {
this.notifierService.showNotification("Fehler: Die E-Mail existiert nicht!", 'OK');
this.isLoading = false;
}
});
this.loginForm.reset();
}
}

View File

@@ -0,0 +1,15 @@
/* Form */
form {
display: flex;
flex-direction: column;
}
/* Form Fields */
mat-form-field {
margin-bottom: 1.5rem;
}
:host ::ng-deep .mat-mdc-text-field-wrapper {
border-radius: 10px;
background: #fff;
}

View File

@@ -0,0 +1,60 @@
<!-- Flex container -->
<div class="flex">
<!-- Centered container -->
<div class="m-auto kick-motto">
<div *ngIf="isLoading" class="loading-spinner-correct">
<app-loading-spinner></app-loading-spinner>
</div>
<!-- Material card -->
<div>
<mat-card
class="default-background-color mat-mdc-card_noshadow"
*ngIf="!isLoading"
>
<!-- Card image -->
<header-lsr></header-lsr>
<!-- Card content -->
<mat-card-content>
<!-- Login form -->
<form [formGroup]="signUpForm" (ngSubmit)="onSubmit()">
<!-- Email input field -->
<mat-form-field appearance="outline" class="custom-form-field">
<mat-label>Email</mat-label>
<input
type="email"
matInput
formControlName="email"
placeholder="@atos.net | @eviden.com"
/>
<mat-error *ngIf="signUpForm.get('email')?.hasError('pattern')">
Bitte gib deine Firmen-E-Mail lautend auf '&#64;atos.net' oder
'&#64;eviden.com' an.
</mat-error>
</mat-form-field>
<!-- Card footer with logIn link -->
<mat-card-footer class="m-auto">
<a routerLink="/login">Du hast bereits ein Konto? Einloggen.</a>
</mat-card-footer>
<!-- Card footer with sign-up link -->
<mat-card-footer class="m-auto margin-top-correct">
<a routerLink="/signup-password"
>Registriere dich hier mit E-Mail und Passwort!</a
>
</mat-card-footer>
<!-- Card actions with login button -->
<mat-card-actions>
<button
class="m-auto blue-buttons disabled-button"
mat-button
type="submit"
[disabled]="!signUpForm.valid"
>
REGISTRIEREN
</button>
</mat-card-actions>
</form>
</mat-card-content>
</mat-card>
</div>
</div>
</div>

View File

@@ -0,0 +1,76 @@
import { Component } from '@angular/core';
import { AngularMaterialModule } from '../../angular-material/angular-material.module';
import {
FormControl,
Validators,
FormsModule,
ReactiveFormsModule,
FormGroup,
} from '@angular/forms';
import { Router, RouterModule } from '@angular/router';
import { CommonModule } from '@angular/common';
import { AuthService } from '../authService/auth.service';
import { HttpClientModule } from '@angular/common/http';
import { LoadingSpinnerComponent } from '../../shared/loading-spinner/loading-spinner.component';
import { NotifierService } from '../../shared/notifierService/notifier.service';
import { ScoreTableComponent } from '../../score-table/score-table/score-table.component';
import { Firestore } from '@angular/fire/firestore';
import { HeaderLoginSignupResetpw } from '../../shared/header-login-signup-resetpw/header-lsr.component';
import { MatDialog } from '@angular/material/dialog';
import { pipe, switchMap } from 'rxjs';
import { User } from '../model/user.model';
import { PendingActionsService } from '../../score/pending-actions.service';
import { CustomDialogComponent } from '../../shared/custom-dialog/custom-dialog.component';
import { UserService } from '../../user-profile/user.service';
@Component({
selector: 'app-signup',
standalone: true,
imports: [
CommonModule,
AngularMaterialModule,
FormsModule,
ReactiveFormsModule,
RouterModule,
HttpClientModule,
LoadingSpinnerComponent,
ScoreTableComponent,
HeaderLoginSignupResetpw,
],
templateUrl: './signup.component.html',
styleUrl: './signup.component.css',
})
export class SignupComponent {
/** Represents the login form group */
signUpForm!: FormGroup;
isLoading = false;
constructor(private authService: AuthService, private dialog: MatDialog) {}
ngOnInit() {
// Initialize the login form with form controls and validators
this.signUpForm = new FormGroup({
email: new FormControl('', [
Validators.required,
Validators.email,
Validators.pattern(
/^[a-zA-Z0-9._%+-]+\.+[a-zA-Z0-9._%+-]+@(eviden\.com|atos\.net)$/
),
]),
});
}
onSubmit() {
const emailBig = this.signUpForm.get('email')?.value;
const email = emailBig.toLowerCase();
this.authService.signUp(email);
this.dialog.open(CustomDialogComponent, {
data: {
title: 'E-Mail-Regristrierung',
bodyText:
'Eine Regristrierungs-Email wurde an deine Adresse gesendet. Bitte überprüfe dein Postfach und klicke auf den Link, um dich zu registrieren.',
},
});
this.signUpForm.reset();
}
}

View File

@@ -0,0 +1,23 @@
/* Form */
form {
display: flex;
flex-direction: column;
}
/* Form Fields */
mat-form-field {
width: 17.8rem;
margin-bottom: 1.5rem;
}
/* Card Footer */
mat-card-footer {
color: #0596FF;
text-align: center;
text-decoration-line: underline;
}
:host ::ng-deep .mat-mdc-text-field-wrapper {
border-radius: 10px;
background: #FFF;
}

View File

@@ -0,0 +1,119 @@
<!-- Flex container -->
<div class="flex">
<!-- Centered container -->
<div class="m-auto kick-motto">
<div *ngIf="isLoading" class="loading-spinner-correct">
<app-loading-spinner></app-loading-spinner>
</div>
<!-- Material card -->
<mat-card
class="default-background-color mat-mdc-card_noshadow"
*ngIf="!isLoading"
>
<header-lsr></header-lsr>
<!-- Card content -->
<mat-card-content>
<!-- Sign up form -->
<form [formGroup]="signUpForm" (ngSubmit)="onSubmit()">
<!-- Email input field -->
<mat-form-field appearance="outline" class="custom-form-field">
<mat-label>Email</mat-label>
<input
type="email"
matInput
formControlName="email"
placeholder="@atos.net | @eviden.com"
/>
<mat-error *ngIf="signUpForm.get('email')?.hasError('required')"
>Die E-Mail-Adresse wird benötigt.
</mat-error>
<mat-error *ngIf="signUpForm.get('email')?.hasError('pattern')">
Bitte gib deine Firmen-E-Mail lautend auf '&#64;atos.net' oder
'&#64;eviden.com' an.
</mat-error>
</mat-form-field>
<!-- Password input field -->
<mat-form-field appearance="outline" class="example-full-width">
<mat-label>Passwort</mat-label>
<input
[type]="hide ? 'password' : 'text'"
matInput
formControlName="password"
placeholder="10-20 Zeichen"
autocomplete="new-password"
title="10-20 Zeichen, mind. 1 Großbuchstaben und 1 Zahl."
(blur)="updateErrorMessage()"
/>
<mat-error *ngIf="signUpForm.get('password')?.invalid">
{{ this.errorMessagePassword }}
</mat-error>
<!-- Suffix button to toggle visibility -->
<!-- Add type="button" to prevent form submission when clicking the button -->
<button
id="pwVisibility"
type="button"
mat-icon-button
matSuffix
(click)="hide = !hide"
[attr.aria-label]="hide ? 'Show password' : 'Hide password'"
[attr.aria-pressed]="!hide"
>
<mat-icon>{{ hide ? "visibility_off" : "visibility" }}</mat-icon>
</button>
</mat-form-field>
<!-- Repeat password input field -->
<mat-form-field appearance="outline" class="example-full-width">
<mat-label>Passwort bestätigen</mat-label>
<input
[type]="hide ? 'password' : 'text'"
matInput
formControlName="confirmPassword"
placeholder="10-20 Zeichen"
autocomplete="new-password"
title="10-20 Zeichen, mind. 1 Großbuchstaben und 1 Zahl."
(blur)="updateErrorMessage()"
/>
<mat-error *ngIf="signUpForm.get('confirmPassword')?.invalid">
{{ this.errorMessageConfirmPassword }}
</mat-error>
<!-- Suffix button to toggle visibility -->
<!-- Add type="button" to prevent form submission when clicking the button -->
<button
type="button"
mat-icon-button
matSuffix
(click)="hide = !hide"
[attr.aria-label]="hide ? 'Show password' : 'Hide password'"
[attr.aria-pressed]="!hide"
>
<mat-icon>{{ hide ? "visibility_off" : "visibility" }}</mat-icon>
</button>
</mat-form-field>
<!-- Card footer with login link -->
<mat-card-footer
><a routerLink="/login"
>Du hast bereits ein Konto? Einloggen.</a
></mat-card-footer
>
<!-- Card actions with sign up button -->
<mat-card-actions>
<button
mat-button
class="m-auto blue-buttons disabled-button"
type="submit"
[disabled]="!signUpForm.valid"
>
REGISTRIEREN
</button>
</mat-card-actions>
</form>
</mat-card-content>
</mat-card>
</div>
</div>

View File

@@ -0,0 +1,234 @@
import { Component, OnDestroy, OnInit } from '@angular/core';
import { AngularMaterialModule } from '../../angular-material/angular-material.module';
import {
FormControl,
Validators,
FormsModule,
ReactiveFormsModule,
FormGroup,
AbstractControl,
} from '@angular/forms';
import { Router, RouterModule } from '@angular/router';
import { CommonModule } from '@angular/common';
import { AuthService } from '../authService/auth.service';
import { HttpClientModule } from '@angular/common/http';
import { LoadingSpinnerComponent } from '../../shared/loading-spinner/loading-spinner.component';
import { NotifierService } from '../../shared/notifierService/notifier.service';
import { Subscription, switchMap } from 'rxjs';
import { UserService } from '../../user-profile/user.service';
import {
hasNumberValidator,
hasUpperCaseValidator,
} from '../../shared/validation/passwordSaferValidation';
import {
PasswordValidationType,
passwordValidator,
} from '../../shared/validation/passwordMatcherValidation';
import { User } from '../model/user.model';
import { MatDialog } from '@angular/material/dialog';
import { CustomDialogComponent } from '../../shared/custom-dialog/custom-dialog.component';
import { HeaderLoginSignupResetpw } from '../../shared/header-login-signup-resetpw/header-lsr.component';
import { PendingActionsService } from '../../score/pending-actions.service';
/**
* SignUpComponent represents the component responsible for user registration.
* It allows users to sign up by providing their email, password, and confirming the password.
* This component utilizes Angular reactive forms for validation and form control management.
*/
@Component({
selector: 'app-registrierung',
standalone: true,
imports: [
CommonModule,
AngularMaterialModule,
FormsModule,
ReactiveFormsModule,
RouterModule,
HttpClientModule,
LoadingSpinnerComponent,
HeaderLoginSignupResetpw,
],
providers: [AuthService, UserService],
templateUrl: './signup-password.component.html',
styleUrl: './signup-password.component.css',
})
export class SignupPasswordComponent implements OnInit, OnDestroy {
constructor(
private authService: AuthService,
private userService: UserService,
private notifierService: NotifierService,
private router: Router,
private dialog: MatDialog,
private pendingService: PendingActionsService
) {}
/** Represents the sign-up form group */
signUpForm!: FormGroup;
isLoading = false;
hide = true;
errorMessagePassword = '';
errorMessageConfirmPassword = '';
private passwordValueChangesSubscription!: Subscription;
ngOnInit() {
// Initialize the sign-up form with form controls and validators
this.signUpForm = new FormGroup({
email: new FormControl('', [
Validators.required,
Validators.email,
Validators.pattern(
/^[a-zA-Z0-9._%+-]+\.+[a-zA-Z0-9._%+-]+@(eviden\.com|atos\.net)$/
),
]),
password: new FormControl('', [
Validators.required,
Validators.minLength(10),
Validators.maxLength(20),
hasUpperCaseValidator(),
hasNumberValidator(),
]),
confirmPassword: new FormControl('', [
Validators.required,
passwordValidator('password', PasswordValidationType.Match),
]),
});
// Set up a subscription to the password field's valueChanges observable
const passwordControl = this.signUpForm.get('password');
const confirmPasswordControl = this.signUpForm.get('confirmPassword');
if (passwordControl && confirmPasswordControl) {
passwordControl.valueChanges.subscribe(() => {
this.updateErrorMessage();
confirmPasswordControl.updateValueAndValidity();
});
confirmPasswordControl.valueChanges.subscribe(() => {
this.updateErrorMessage();
});
}
}
//Logs the form data to the console.
onSubmit() {
const emailBig = this.signUpForm.get('email')?.value;
const email = emailBig.toLowerCase();
const password = this.signUpForm.get('password')?.value;
// Extract first name and last name from the email address
const [firstName, lastName] = this.extractNamesFromEmail(email);
this.isLoading = true;
this.authService
.signUpPassword(email, password)
.pipe(
switchMap(({ user: { uid } }) => {
const newUser: User = {
uid: uid,
email: email,
firstName: firstName,
lastName: lastName,
firmenposition: null,
photoUrl: null,
wins: null,
};
this.pendingService.addUserRefToQRCodeData(
uid,
this.pendingService.getTempQRId()
);
return this.userService.addUser(newUser);
})
)
.subscribe(
(resData) => {
this.isLoading = false;
this.router.navigate(['/score-table']);
this.dialog.open(CustomDialogComponent, {
data: {
title: 'E-Mail-Verifizierung',
bodyText:
'Eine Verifizierung-Email wurde an deine Adresse gesendet. Bitte überprüfe dein Postfach und klicke auf den Link, um dein Konto zu verifizieren und Spiele hinzuzufügen.',
},
});
},
(errorMessage) => {
this.notifierService.showNotification(errorMessage, 'OK');
this.isLoading = false;
}
);
this.signUpForm.reset();
}
ngOnDestroy() {
if (this.passwordValueChangesSubscription) {
this.passwordValueChangesSubscription.unsubscribe();
}
}
private extractNamesFromEmail(email: string): [string, string] {
const sanitizedEmail = email.replace(/.\d|\.external/g, '');
const [fullName] = sanitizedEmail.split('@');
const names = fullName.split('.');
// Vor- und Nachname grossgeschrieben
const capitalize = (name: string) =>
name
.split('-')
.map(
(part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase()
)
.join('-');
// Capitalize both the first name and last name parts
const firstName = names.length > 0 ? capitalize(names[0]) : '';
const lastName =
names.length > 1 ? capitalize(names.slice(1).join('-')) : '';
return [firstName, lastName];
}
updateErrorMessage() {
const passwordControl = this.signUpForm.get('password');
const confirmPasswordControl = this.signUpForm.get('confirmPassword');
this.errorMessagePassword = this.getPasswordErrorMessage(passwordControl);
this.errorMessageConfirmPassword = this.getConfirmPasswordErrorMessage(
confirmPasswordControl
);
}
private getPasswordErrorMessage(control: AbstractControl | null): string {
if (control?.hasError('required')) {
return 'Ein Passwort wird benötigt.';
} else if (control?.hasError('minlength')) {
return 'Das Passwort muss mindestens 10 Zeichen lang sein.';
} else if (control?.hasError('maxlength')) {
return 'Das Passwort darf maximal 20 Zeichen lang sein.';
} else if (control?.hasError('passwordsSame')) {
return 'Das neue Passwort muss sich vom aktuellen Passwort unterscheiden.';
} else if (
control?.hasError('hasNumber') &&
control?.hasError('hasUpperCase')
) {
return 'Mind. 1 Großbuchstaben und 1 Zahl.';
} else if (control?.hasError('hasUpperCase')) {
return 'Mind. 1 Großbuchstaben.';
} else if (control?.hasError('hasNumber')) {
return 'Mind. 1 Zahl.';
}
return '';
}
private getConfirmPasswordErrorMessage(
control: AbstractControl | null
): string {
if (control?.hasError('required')) {
return 'Passwortbestätigung eingeben.';
} else if (control?.hasError('passwordMismatch')) {
return 'Die eingegebenen Passwörter stimmen nicht überein.';
}
return '';
}
}

View File

@@ -0,0 +1,19 @@
import { MatPaginatorIntl } from '@angular/material/paginator';
import { Subject } from 'rxjs';
export class CustomMatPaginatorIntl implements MatPaginatorIntl {
changes = new Subject<void>();
itemsPerPageLabel = 'Einträge/Seite';
nextPageLabel = 'Nächste Seite';
previousPageLabel = 'Vorherige Seite';
firstPageLabel = 'Erste Seite';
lastPageLabel = 'Letzte Seite';
getRangeLabel(page: number, pageSize: number, length: number): string {
if (length === 0) {
return `1 von 1`;
}
const amountPages = Math.ceil(length / pageSize);
return `${page + 1} von ${amountPages}`;
}
}

View File

@@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { ScoreTableService } from './score-table.service';
describe('ScoreTableServiceService', () => {
let service: ScoreTableService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(ScoreTableService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View File

@@ -0,0 +1,185 @@
import { Injectable } from '@angular/core';
import {
Firestore,
orderBy,
onSnapshot,
doc,
where,
getDocs,
getDoc,
setDoc,
addDoc,
} from '@angular/fire/firestore';
import { BehaviorSubject, Observable, take } from 'rxjs';
import { collection, query } from 'firebase/firestore';
import { RankedUser, User } from '../auth/model/user.model';
import { Games } from './score-table/score-table.component';
import { AuthService } from '../auth/authService/auth.service';
import { UserService } from '../user-profile/user.service';
import { NotifierService } from '../shared/notifierService/notifier.service';
import { Router } from '@angular/router';
@Injectable({
providedIn: 'root',
})
export class ScoreTableService {
private showUserGamesSubject = new BehaviorSubject<boolean>(false);
showUserGames$ = this.showUserGamesSubject.asObservable();
constructor(
private firestore: Firestore,
private authService: AuthService,
private userService: UserService,
private notifierService: NotifierService,
private router: Router
) {}
setShowUserGames(value: boolean) {
this.showUserGamesSubject.next(value);
}
getTopUsers(): Observable<RankedUser[]> {
return new Observable<RankedUser[]>((observer) => {
const usersCollection = collection(this.firestore, 'users');
const scoreQuery = query(
usersCollection,
where('wins', '>', 0),
orderBy('wins', 'desc')
);
const unsubscribe = onSnapshot(
scoreQuery,
(querySnapshot) => {
const users: RankedUser[] = [];
querySnapshot.forEach((doc) => {
users.push({ uid: doc.id, ...doc.data() } as RankedUser);
});
// Assign ranks to users
const rankedUsers = this.assignRanks(users);
observer.next(rankedUsers);
},
(error) => {
observer.error(error);
}
);
// Unsubscribe when the observer unsubscribes
return () => unsubscribe();
});
}
private assignRanks(users: RankedUser[]): RankedUser[] {
if (users.length === 0) return users;
let currentRank = 1;
let previousWins = users[0].wins ?? 0; // Default to 0 if wins is null or undefined
users[0].rank = currentRank;
for (let i = 1; i < users.length; i++) {
const currentWins = users[i].wins ?? 0; // Default to 0 if wins is null or undefined
if (currentWins < previousWins) {
currentRank++;
}
users[i].rank = currentRank;
previousWins = currentWins;
}
return users;
}
validateAndAddGame(score: string, uid: string) {
this.authService.currentUser$.pipe(take(1)).subscribe((user) => {
if (user) {
const qrCodeRef = doc(this.firestore, 'qrCodes', uid);
getDoc(qrCodeRef).then((qrCodeDoc) => {
const usedBy =
qrCodeDoc.exists() && qrCodeDoc.data()['usedBy']
? qrCodeDoc.data()['usedBy']
: [];
let message = usedBy.includes(user.uid)
? 'Dieses Spiel wurde bereits zu deinem Konto hinzugefügt!'
: usedBy.length >= 2
? 'Dieser QR-Code ist nicht mehr gültig!'
: null;
if (!message) {
const updatedUsedBy = [...usedBy, user.uid];
setDoc(qrCodeRef, { usedBy: updatedUsedBy }, { merge: true }).then(
() => {
this.addGameToDatabase(score, user.uid);
}
);
} else {
this.router.navigate(['/score-table']);
this.notifierService.showNotification(message, 'OK');
}
});
}
});
}
addGameToDatabase(score: string, userId: string) {
const userRef = doc(this.firestore, 'users', userId);
const game = {
score: score,
date: new Date(),
userRef: userRef,
};
const gamesRef = collection(this.firestore, 'games');
addDoc(gamesRef, game)
.then(() => {
this.userService.updateUserWins(userId).subscribe(); // Update the user's wins count
})
.finally(() => {
this.router.navigate(['/score-table']);
this.notifierService.showNotification(
'Spiel erfolgreich zu deinem Konto hinzugefügt!',
'OK'
);
});
}
async loadUserGames(userId: string): Promise<Games[]> {
const gamesRef = collection(this.firestore, 'games');
const q = query(
gamesRef,
where('userRef', '==', doc(this.firestore, 'users', userId)),
orderBy('date', 'desc')
);
const querySnapshot = await getDocs(q);
const gamesArray: Games[] = querySnapshot.docs.map((doc) => {
const score = doc.data()['score'].split('-');
const scoreTransformed = (
score[0].length == 1 ? '0'.concat(score[0]) : score[0]
)
.concat(' - ')
.concat(score[1].length == 1 ? '0'.concat(score[1]) : score[1]);
const dateTime: Date = doc.data()['date'].toDate();
// Format the date and time as per the new requirements
const formattedDate = dateTime.toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: '2-digit',
});
const formattedTime = dateTime.toLocaleTimeString('de-DE', {
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
return {
id: doc.id,
...doc.data(),
score: scoreTransformed,
// Combine the formatted date and time
date: `${formattedDate} ${formattedTime}`,
};
});
return gamesArray;
}
}

View File

@@ -0,0 +1,149 @@
.btn-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 2rem;
margin-bottom: 1rem;
}
.center-container {
margin: 0 20%;
}
@media only screen and (max-width: 768px) {
.center-container {
margin: 0 5%;
}
::ng-deep .mat-mdc-paginator-container {
flex-wrap: nowrap !important;
}
::ng-deep .mat-mdc-paginator-range-label {
margin-left: 12px !important;
margin-right: 0 !important;
}
}
.btn-search {
background-color: inherit;
border: 0;
color: #002d3c;
width: 2rem !important;
height: 2rem;
margin: 0;
}
.btn-search .mat-icon {
position: relative;
right: 3px;
top: 1px;
}
.header-title {
color: #000;
text-align: center;
}
.mat-mdc-row .mat-mdc-cell {
border-bottom: 1px solid transparent;
border-top: 1px solid transparent;
cursor: pointer;
}
.mat-mdc-row:hover .mat-mdc-cell {
border-color: currentColor;
}
.mat-column-position {
width: 20%;
}
.mat-column-wins {
width: 20%;
}
.empty-table {
display: flex;
justify-content: center;
margin: 1rem;
flex-wrap: wrap;
}
/* Spalten gleich*/
.user-games-table .mat-elevation-z8 {
overflow-x: auto; /* Ensures the table is scrollable horizontally if it overflows */
}
table {
width: 100%;
table-layout: fixed; /* Use a fixed table layout */
}
.mat-column-date,
.mat-column-score {
width: 50%; /* Set each column to occupy half of the table's width */
}
.center-text {
text-align: center;
}
/* Additional styles for responsiveness and aesthetics */
@media only screen and (max-width: 768px) {
.center-container {
margin: 0 5%;
}
}
.mat-cell, .mat-header-cell {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis; /* Prevent text overflow */
}
/*
Mat Slide Toggle Button - change of colors when switching
*/
@keyframes shadow-color-change {
0%, 25% {
background-color: #0596ff;
}
50% {
background-color: #BCBCBC;
}
75%, 100% {
background-color: #ff6d43;
}
}
::ng-deep .mat-mdc-slide-toggle.mat-mdc-slide-toggle-checked:not(.mat-disabled) .mdc-switch__shadow {
background-color: #ff6d43;
}
::ng-deep .mat-mdc-slide-toggle.atos-company.mat-mdc-slide-toggle-checked:not(.mat-disabled) .mdc-switch__shadow {
background-color: #0596ff;
}
::ng-deep .mat-mdc-slide-toggle:not(.mat-mdc-slide-toggle-checked) .mdc-switch__shadow {
animation: shadow-color-change 3s infinite alternate;
}
::ng-deep .mat-mdc-slide-toggle.mat-mdc-slide-toggle-checked:not(.mat-disabled) .mdc-switch__track::after {
background-color: #e0e0e0 !important;
}
::ng-deep .mat-mdc-paginator-page-size-select {
width: 64px !important;
}
.bestenliste-text {
font-size: 16px;
font-weight: normal;
color: #000; /* Black color */
margin: 0 10px; /* Spacing between the toggle and search button */
padding: 0; /* Remove default padding if any */
line-height: 1; /* Adjust line height if needed */
}

View File

@@ -0,0 +1,124 @@
<app-header></app-header>
<!-- Flex container -->
<div class="flex">
<!-- Centered container -->
<div class="center-container">
<div class="btn-header">
<mat-slide-toggle
#slideToggle
[(ngModel)]="showUserGames"
(click)="setPageSize()"
[class.atos-company]="isCompanyAtos()"
></mat-slide-toggle>
<!-- Bestenliste/Deine Spiele as an h1 element -->
<h1 *ngIf="!showUserGames" class="bestenliste-text">
iWuzzler Bestenliste
</h1>
<h1 *ngIf="showUserGames" class="bestenliste-text">Deine Spiele</h1>
<button
[disabled]="showUserGames"
class="btn-search"
mat-button
(click)="toggleSearch()"
>
<mat-icon matPrefix>search</mat-icon>
</button>
</div>
<div class="score-table" *ngIf="!showUserGames">
<app-table-search
[isSearchActive]="isSearchActive"
[dataSource]="dataSource"
></app-table-search>
<!--- Table -->
<div class="mat-elevation-z8">
<table
mat-table
[dataSource]="dataSource"
aria-label="user-score-table"
>
<!-- Position Column -->
<ng-container matColumnDef="position">
<th class="center-text" mat-header-cell *matHeaderCellDef>
Position
</th>
<td class="center-text" mat-cell *matCellDef="let element">
{{ element.rank }}
</td>
</ng-container>
<!-- Name Column -->
<ng-container matColumnDef="name">
<th class="center-text" mat-header-cell *matHeaderCellDef>Name</th>
<td class="center-text" mat-cell *matCellDef="let element">
{{ element.firstName }} {{ element.lastName }}
</td>
</ng-container>
<!-- Score Column -->
<ng-container matColumnDef="wins">
<th class="center-text" mat-header-cell *matHeaderCellDef>Wins</th>
<td class="center-text" mat-cell *matCellDef="let element">
{{ element.wins }}
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr
(click)="goToUserPage(row.uid)"
mat-row
*matRowDef="let row; columns: displayedColumns"
></tr>
</table>
<mat-paginator [pageSizeOptions]="[10, 25, 50]"></mat-paginator>
</div>
</div>
<div class="user-games-table mat-elevation-z8" *ngIf="showUserGames">
<table
mat-table
[dataSource]="dataSourceUserGames"
aria-label="user-games-table"
>
<!-- Date Column -->
<ng-container matColumnDef="date">
<th class="center-text" mat-header-cell *matHeaderCellDef>Zeit</th>
<td class="center-text" mat-cell *matCellDef="let element">
{{ element.date }}
</td>
</ng-container>
<!-- Score Column -->
<ng-container matColumnDef="score">
<th class="center-text" mat-header-cell *matHeaderCellDef>Score</th>
<td class="center-text" mat-cell *matCellDef="let element">
{{ element.score }}
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumnsUserGames"></tr>
<tr
mat-row
*matRowDef="let row; columns: displayedColumnsUserGames"
></tr>
<tr class="mat-row" *matNoDataRow>
<td class="mat-cell" [attr.colspan]="displayedColumns.length">
<p *ngIf="isUserLoggedIn(); else loggedOut" class="empty-table">
Keine Spiele gefunden.
</p>
<ng-template #loggedOut>
<p class="empty-table">
<a routerLink="/login">Logge dich ein</a>, um deine Spiele zu
sehen. Kein Konto?&nbsp;
<a routerLink="/signup">Registriere dich jetzt</a>
</p>
</ng-template>
</td>
</tr>
</table>
<mat-paginator [pageSizeOptions]="[10, 25, 50]"></mat-paginator>
</div>
</div>
</div>

View File

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

View File

@@ -0,0 +1,233 @@
import { Component, ElementRef, ViewChild, OnInit, OnDestroy, AfterViewInit } from '@angular/core';
import { FormGroup, FormsModule } from '@angular/forms';
import { MatTableModule, MatTableDataSource } from '@angular/material/table';
import { TableSearchComponent } from '../../score-table/table-search/table-search.component';
import { MatPaginator, MatPaginatorIntl, MatPaginatorModule } from '@angular/material/paginator';
import { Observable, Subscription } from 'rxjs';
import { RankedUser } from '../../auth/model/user.model';
import { ScoreTableService } from '../score-table.service';
import { Router, RouterModule } from '@angular/router';
import { MatCard } from '@angular/material/card';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { CommonModule } from '@angular/common';
import { AuthService } from '../../auth/authService/auth.service';
import { MatIconModule, MatIcon } from '@angular/material/icon';
import { PendingActionsService } from '../../score/pending-actions.service';
import { HeaderComponent } from '../../shared/header/header.component';
import { CustomMatPaginatorIntl } from '../custom-mat-paginator-intl';
import { DocumentData } from '@angular/fire/firestore';
import { User as FirebaseUser } from 'firebase/auth';
import { CONSTANTS } from '../../shared/constants';
export interface Games {
id: string;
score: string;
date: string;
}
@Component({
selector: 'app-score-table',
standalone: true,
templateUrl: './score-table.component.html',
styleUrls: ['./score-table.component.css'],
imports: [
MatTableModule,
TableSearchComponent,
MatPaginatorModule,
MatCard,
MatSlideToggleModule,
FormsModule,
CommonModule,
MatIconModule,
MatIcon,
HeaderComponent,
RouterModule,
],
providers: [{ provide: MatPaginatorIntl, useClass: CustomMatPaginatorIntl }],
})
export class ScoreTableComponent implements OnInit, OnDestroy, AfterViewInit {
@ViewChild(MatPaginator) paginator!: MatPaginator;
@ViewChild('slideToggle', { read: ElementRef }) slideToggleElement: ElementRef | undefined;
displayedColumns: string[] = ['position', 'name', 'wins'];
displayedColumnsUserGames: string[] = ['date', 'score'];
dataSource = new MatTableDataSource<RankedUser>([]);
dataSourceUserGames = new MatTableDataSource<Games>([]);
users$: Observable<RankedUser[]>;
subscription = new Subscription();
showUserGames = false;
isSearchActive = false;
userProfileForm!: FormGroup;
constructor(
private scoreTableService: ScoreTableService,
private router: Router,
private authService: AuthService,
private pendingActionsService: PendingActionsService
) {
// Subscribe to the observable that provides the top users
this.users$ = scoreTableService.getTopUsers();
// Subscribe to the observable that indicates whether to show user games
this.subscription.add(
this.scoreTableService.showUserGames$.subscribe((data) => {
this.showUserGames = data;
})
);
}
/**
* Lifecycle hook that is called after Angular has initialized all data-bound properties.
* Here, we subscribe to the users$ observable to get the top users and set the data source.
*/
ngOnInit() {
this.subscription.add(
this.users$.subscribe((users) => {
this.setPageSize();
this.dataSource.data = users;
})
);
this.subscription.add(
this.authService.currentUser$.subscribe((user) => {
if (user) this.handleUser(user);
})
);
}
/**
* Lifecycle hook that is called when the component is destroyed.
* Here, we unsubscribe from all subscriptions to prevent memory leaks.
*/
ngOnDestroy() {
this.subscription.unsubscribe();
}
/**
* Lifecycle hook that is called after Angular has fully initialized a component's view.
* Here, we set the SVG paths for the slide toggle icons.
*/
ngAfterViewInit() {
if (this.slideToggleElement) {
this.slideToggleElement.nativeElement
.querySelector('.mdc-switch__icon--on')
.firstChild.setAttribute('d', CONSTANTS.SCORE_BOARD.PERSON_SVG_PATH);
this.slideToggleElement.nativeElement
.querySelector('.mdc-switch__icon--off')
.firstChild.setAttribute('d', CONSTANTS.SCORE_BOARD.GROUP_SVG_PATH);
}
}
/**
* Handles the user data when a user is logged in.
* Loads the user's games and checks for any pending QR data.
*
* @param user - The currently logged-in user.
*/
async handleUser(user: FirebaseUser) {
if (!user?.uid) return;
this.dataSourceUserGames.data = await this.scoreTableService.loadUserGames(
user.uid
);
const pendingData = await this.pendingActionsService.checkForPendingQRData(
user.uid
);
if (!pendingData) return;
if (this.isUserVerified()) {
this.navigateToUserProfile(pendingData.data());
this.pendingActionsService.deleteTempQRCodeData(
pendingData.data()['uid']
);
} else {
this.pendingActionsService.addUserRefToQRCodeData(
user.uid,
pendingData.data()['uid']
);
}
}
/**
* Navigates to the user profile page with the given pending data.
*
* @param pendingData - The data to pass to the user profile page after a QR code scan.
*/
navigateToUserProfile(pendingData: DocumentData) {
this.router.navigate(['/user-profile'], {
queryParams: {
score: pendingData['score'],
uid: pendingData['uid'],
},
});
}
/**
* Sets the page size for the paginator based on the screen width.
* Adjusts the page size for different screen sizes.
*/
setPageSize() {
const width = window.screen.width;
if (width < 768) {
this.paginator.pageSize = 10;
} else if (width >= 768 && width < 1024) {
this.paginator.pageSize = 25;
} else {
this.paginator.pageSize = 50;
}
if (this.showUserGames) {
this.dataSourceUserGames.paginator = this.paginator;
} else {
this.dataSource.paginator = this.paginator;
}
}
/**
* Toggles the search functionality on and off.
*/
toggleSearch() {
this.isSearchActive = !this.isSearchActive;
}
/**
* Navigates to user profile pages for the given user ID.
*
* @param uid - The user ID to navigate to.
*/
goToUserPage(uid: string) {
if (this.isUserVerified()) {
this.router.navigate(['/user-profile', uid]);
this.scoreTableService.setShowUserGames(false);
}
}
/**
* Checks if the current user's email belongs to the Atos company.
*
* @returns True if the user's email includes 'atos.net', false otherwise.
*/
isCompanyAtos() {
const email = this.authService.getCurrentUser()?.email;
return email && email.includes('atos.net');
}
/**
* Checks if the current user's email is verified after registration.
*
* @returns True if the user's email is verified, false otherwise.
*/
isUserVerified() {
return this.authService.getCurrentUser()?.emailVerified;
}
/**
* Checks if a user is currently logged in.
*
* @returns The current user if logged in, null otherwise.
*/
isUserLoggedIn() {
return this.authService.getCurrentUser();
}
}

View File

@@ -0,0 +1,15 @@
.flex-container {
display: flex;
flex-wrap: nowrap;
justify-content: space-between;
}
@media only screen and (max-width: 767px) {
.flex-container {
flex-wrap: wrap;
}
}
.flex-item {
flex: 1 1 auto;
}

View File

@@ -0,0 +1,47 @@
<!-- Flex container -->
<div class="flex-container" *ngIf="isSearchActive">
<!-- Search Input -->
<mat-form-field appearance="outline" class="flex-item">
<mat-label>Suche</mat-label>
<input
type="search"
matInput
[(ngModel)]="filteredValues.name"
(ngModelChange)="applyNameFilter($event)"
/>
</mat-form-field>
<mat-form-field appearance="outline" class="flex-item">
<mat-select
[(value)]="scoreSelect.defaultValue"
(selectionChange)="applyScoreFilter($event)"
>
<mat-option *ngFor="let op of scoreSelect.options" [value]="op">
{{ op }}
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="flex-item">
<mat-label>Score</mat-label>
<input
type="number"
min="0"
matInput
[(ngModel)]="filteredValues.score.value"
(ngModelChange)="applyFilter()"
/>
</mat-form-field>
<mat-form-field appearance="outline" class="flex-item">
<mat-label>{{ companySelect.name }}</mat-label>
<mat-select
[(value)]="companySelect.defaultValue"
(selectionChange)="applySelectFilter($event)"
>
<mat-option *ngFor="let op of companySelect.options" [value]="op">
{{ op }}
</mat-option>
</mat-select>
</mat-form-field>
</div>

View File

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

View File

@@ -0,0 +1,106 @@
import { Component, Input } from '@angular/core';
import { AngularMaterialModule } from '../../angular-material/angular-material.module';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatTableModule, MatTableDataSource } from '@angular/material/table';
import { MatSelectModule, MatSelectChange } from '@angular/material/select';
import { RankedUser, User } from '../../auth/model/user.model';
@Component({
selector: 'app-table-search',
standalone: true,
imports: [
AngularMaterialModule,
CommonModule,
FormsModule,
MatSelectModule,
MatTableModule,
],
templateUrl: './table-search.component.html',
styleUrl: './table-search.component.css',
})
export class TableSearchComponent {
@Input() dataSource = new MatTableDataSource<RankedUser>([]);
@Input() isSearchActive = false;
company: string[] = ['All', 'Atos', 'Eviden'];
companyDefaultValue = 'All';
companySelect = {
name: 'company',
options: this.company,
defaultValue: this.companyDefaultValue,
};
scoreOperation: string[] = ['weniger als', 'mehr als'];
scoreOperationDefaultValue = 'weniger als';
scoreSelect = {
name: 'operation',
options: this.scoreOperation,
defaultValue: this.scoreOperationDefaultValue,
};
filteredValues = {
name: '',
score: { value: null, operation: 'weniger als' },
company: '',
};
ngOnInit() {
this.dataSource.filterPredicate = this.customFilterPredicate();
}
customFilterPredicate() {
const myFilterPredicate = (data: User, filter: string): boolean => {
let searchString = JSON.parse(filter);
let name =
(data.firstName ? data.firstName : '') +
(data.lastName ? data.lastName : '');
let nameResult = name
.toString()
.trim()
.toLowerCase()
.includes(searchString.name.trim().toLowerCase());
let companyResult =
searchString.company == 'All' ||
(data.email ? data.email : '')
.toString()
.trim()
.toLowerCase()
.includes(searchString.company.trim().toLowerCase());
let scoreResult = true;
if (searchString.score.value != null && data.wins != undefined) {
switch (searchString.score.operation) {
case 'weniger als':
scoreResult = data.wins <= searchString.score.value;
break;
case 'mehr als':
scoreResult = data.wins >= searchString.score.value;
break;
}
}
return nameResult && companyResult && scoreResult;
};
return myFilterPredicate;
}
applyFilter() {
this.dataSource.filter = JSON.stringify(this.filteredValues);
this.dataSource.paginator?.firstPage();
}
applyNameFilter(filter: any) {
this.filteredValues.name = filter;
this.applyFilter();
}
applySelectFilter(event: MatSelectChange) {
this.filteredValues.company = event.value;
this.applyFilter();
}
applyScoreFilter(event: MatSelectChange) {
this.filteredValues.score.operation = event.value;
this.applyFilter();
}
}

View File

@@ -0,0 +1,115 @@
import { Injectable } from '@angular/core';
import {
Firestore,
collection,
deleteDoc,
doc,
getDoc,
getDocs,
query,
setDoc,
updateDoc,
where,
} from '@angular/fire/firestore';
@Injectable({
providedIn: 'root',
})
export class PendingActionsService {
private tempQRId: string | null = null;
constructor(private firestore: Firestore) {}
setTempQRId(id: string) {
this.tempQRId = id;
}
getTempQRId(): string | null {
return this.tempQRId;
}
clearTempQRId() {
this.tempQRId = null;
}
saveTempQRCodeData(qrData: any) {
const docRef = doc(collection(this.firestore, 'tempQRData'), qrData.uid);
setDoc(docRef, qrData).catch((error) => {
console.error('Error saving qr data', error);
throw error;
});
return docRef.id;
}
getTempQRCodeData(uid: string | null) {
if (uid === null) return null;
const docRef = doc(this.firestore, `tempQRData/${uid}`);
return getDoc(docRef)
.then((docSnap) => {
if (docSnap.exists()) {
return docSnap.data();
} else {
return null;
}
})
.catch((error) => {
console.error('Error getting qr data', error);
throw error;
});
}
deleteTempQRCodeData(uid: string | null) {
if (uid === null) return null;
const docRef = doc(this.firestore, `tempQRData/${uid}`);
return deleteDoc(docRef).catch((error) => {
console.error('Error deleting qr data', error);
throw error;
});
}
addUserRefToQRCodeData(userId: string | null, uid: string | null) {
if (userId === null || uid === null) return null;
const docRef = doc(this.firestore, `tempQRData/${uid}`);
const userRef = doc(this.firestore, 'users', userId);
return updateDoc(docRef, { userRef: userRef });
}
checkForPendingQRData(userId: string | null) {
if (userId === null) return null;
const userRef = doc(this.firestore, 'users', userId);
const tempQRDataRef = collection(this.firestore, 'tempQRData');
const q = query(tempQRDataRef, where('userRef', '==', userRef));
return getDocs(q)
.then((querySnapshot) => {
if (querySnapshot.empty) {
return null;
}
const firstDoc = querySnapshot.docs[0];
return firstDoc;
})
.catch((error) => {
console.error('Fehler beim Abrufen der Dokumente:', error);
return null;
});
}
setScoreLocalStorage(score: string, uid: string) {
localStorage.setItem('score', score);
localStorage.setItem('uid', uid);
}
getScoreLocalStorage() {
return {
score: localStorage.getItem('score'),
uid: localStorage.getItem('uid'),
};
}
resetLocalStorage() {
localStorage.removeItem('score');
localStorage.removeItem('uid');
}
}

View File

@@ -0,0 +1,64 @@
import { Injectable } from '@angular/core';
import {
CanActivate,
ActivatedRouteSnapshot,
RouterStateSnapshot,
Router,
} from '@angular/router';
import { Observable, of } from 'rxjs';
import { take, switchMap } from 'rxjs/operators';
import { AuthService } from '../auth/authService/auth.service';
import { PendingActionsService } from './pending-actions.service';
import { NotifierService } from '../shared/notifierService/notifier.service';
@Injectable({
providedIn: 'root',
})
export class PendingScoreGuard implements CanActivate {
constructor(
private authService: AuthService,
private pendingActionsService: PendingActionsService,
private router: Router,
private notifierService: NotifierService
) {}
canActivate(
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot
): Observable<boolean> {
return this.authService.currentUser$.pipe(
take(1),
switchMap((user) => {
if (user) {
// User is logged in, allow access to the route
return of(true);
} else {
// User is not logged in, store the score and redirect to login
const score = route.queryParams['score'];
const uid = route.queryParams['uid'];
if (score && uid) {
const uniqueID = this.pendingActionsService.saveTempQRCodeData({
score,
uid,
});
this.pendingActionsService.setTempQRId(uniqueID);
this.router.navigate(['/login'], {
queryParams: {
score: score,
uid: uid,
},
});
this.notifierService.showNotification(
'Bitte registriere oder logge dich ein, um ein Spiel zu deinem Konto hinzuzufügen.',
'OK'
);
return of(false);
} else {
this.router.navigate(['/login']);
return of(false);
}
}
})
);
}
}

View File

@@ -0,0 +1,7 @@
export const CONSTANTS = {
SCORE_BOARD: {
PERSON_SVG_PATH: 'M12 6c1.1 0 2 .9 2 2s-.9 2-2 2-2-.9-2-2 .9-2 2-2m0 10c2.7 0 5.8 1.29 6 2H6c.23-.72 3.31-2 6-2m0-12C9.79 4 8 5.79 8 8s1.79 4 4 4 4-1.79 4-4-1.79-4-4-4zm0 10c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z',
GROUP_SVG_PATH: 'M4,13c1.1,0,2-0.9,2-2c0-1.1-0.9-2-2-2s-2,0.9-2,2C2,12.1,2.9,13,4,13z M5.13,14.1C4.76,14.04,4.39,14,4,14 c-0.99,0-1.93,0.21-2.78,0.58C0.48,14.9,0,15.62,0,16.43V18l4.5,0v-1.61C4.5,15.56,4.73,14.78,5.13,14.1z M20,13c1.1,0,2-0.9,2-2 c0-1.1-0.9-2-2-2s-2,0.9-2,2C18,12.1,18.9,13,20,13z M24,16.43c0-0.81-0.48-1.53-1.22-1.85C21.93,14.21,20.99,14,20,14 c-0.39,0-0.76,0.04-1.13,0.1c0.4,0.68,0.63,1.46,0.63,2.29V18l4.5,0V16.43z M16.24,13.65c-1.17-0.52-2.61-0.9-4.24-0.9 c-1.63,0-3.07,0.39-4.24,0.9C6.68,14.13,6,15.21,6,16.39V18h12v-1.61C18,15.21,17.32,14.13,16.24,13.65z M8.07,16 c0.09-0.23,0.13-0.39,0.91-0.69c0.97-0.38,1.99-0.56,3.02-0.56s2.05,0.18,3.02,0.56c0.77,0.3,0.81,0.46,0.91,0.69H8.07z M12,8 c0.55,0,1,0.45,1,1s-0.45,1-1,1s-1-0.45-1-1S11.45,8,12,8 M12,6c-1.66,0-3,1.34-3,3c0,1.66,1.34,3,3,3s3-1.34,3-3 C15,7.34,13.66,6,12,6L12,6z'
}
}

View File

@@ -0,0 +1,3 @@
.dialog-container {
max-width: 500px;
}

View File

@@ -0,0 +1,18 @@
<div class="dialog-container">
<h2 mat-dialog-title>{{ data.title }}</h2>
<mat-dialog-content>
<p>
{{ data.bodyText }}
</p>
</mat-dialog-content>
<mat-dialog-actions [align]="'end'">
<button
mat-button
[mat-dialog-close]="data.returnValue"
(click)="data.onConfirm ? data.onConfirm() : null"
mat-dialog-close
>
OK
</button>
</mat-dialog-actions>
</div>

View File

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

View File

@@ -0,0 +1,32 @@
import { Component, Inject } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import {
MatDialogTitle,
MatDialogContent,
MatDialogActions,
MatDialogClose,
} from '@angular/material/dialog';
import { MAT_DIALOG_DATA } from '@angular/material/dialog';
@Component({
selector: 'app-custom-dialog',
standalone: true,
imports: [
MatDialogTitle,
MatDialogContent,
MatDialogActions,
MatDialogClose,
MatButtonModule,
],
templateUrl: './custom-dialog.component.html',
styleUrl: './custom-dialog.component.css',
})
export class CustomDialogComponent {
constructor(@Inject(MAT_DIALOG_DATA) public data: any) {}
onConfirm(): void {
if (this.data.onConfirm) {
this.data.onConfirm();
}
}
}

View File

@@ -0,0 +1,18 @@
import { Component } from '@angular/core';
import { Router } from '@angular/router';
import { AngularMaterialModule } from '../../angular-material/angular-material.module';
@Component({
selector: 'header-lsr',
standalone: true,
templateUrl: './header-lsr.html',
styleUrl: '././header-lsr.css',
imports: [AngularMaterialModule],
})
export class HeaderLoginSignupResetpw {
constructor(private router: Router) {}
goToScoreTable() {
this.router.navigate(['/score-table']);
}
}

View File

@@ -0,0 +1,24 @@
/* Card */
mat-card {
padding-left: 2.5rem;
padding-right: 2.5rem;
width: 100%;
min-height: 48rem;
}
/* Image */
img {
display: flex;
width: 3.1rem;
height: 3.1rem;
margin-top: 1.875rem;
}
/* Card Header */
mat-card-header {
color: #000;
text-align: center;
display: flex;
justify-content: center;
}

View File

@@ -0,0 +1,12 @@
<div class="m-auto kick-motto cursor-enable" (click)="goToScoreTable()">
<img
class="m-auto"
mat-card-image src="assets\img\AtosEvidenBall.png"
alt="AtosEvidenBall"
>
<!-- Card header -->
<mat-card-header class="m-auto">
<!-- Header text -->
<p class="kick-motto-style">Kick it like <span class="atos-color">Atos</span> & <span class="eviden-color">Eviden</span></p>
</mat-card-header>
</div>

View File

@@ -0,0 +1,21 @@
<button mat-icon-button [matMenuTriggerFor]="isUserLoggedIn() ? menu : menu2">
<mat-icon>more_vert</mat-icon>
</button>
<!-- User logged in -->
<mat-menu #menu="matMenu">
<button mat-menu-item (click)="navigateToProfileOrScoreboard()">
<mat-icon>{{ isOnScoreTable() ? 'account_circle' : 'scoreboard' }}</mat-icon>
<span>{{ isOnScoreTable() ? 'Mein Profil' : 'Scoreboard'}}</span>
</button>
<button mat-menu-item (click)="logout()">
<mat-icon>logout</mat-icon>
<span>Logout</span>
</button>
</mat-menu>
<!-- User not logged in -->
<mat-menu #menu2="matMenu">
<button mat-menu-item (click)="login()">
<mat-icon>login</mat-icon>
<span>Login</span>
</button>
</mat-menu>

View File

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

View File

@@ -0,0 +1,55 @@
import { Component, ViewChild } from '@angular/core';
import { MatIconModule } from '@angular/material/icon';
import { MatMenuModule, MatMenuTrigger } from '@angular/material/menu';
import { MatButtonModule } from '@angular/material/button';
import { Router } from '@angular/router';
import { AuthService } from '../../auth/authService/auth.service';
import { NotifierService } from '../notifierService/notifier.service';
import { ScoreTableService } from '../../score-table/score-table.service';
@Component({
selector: 'app-header-menu',
standalone: true,
imports: [MatButtonModule, MatMenuModule, MatIconModule],
templateUrl: './header-menu.component.html',
styleUrl: './header-menu.component.css',
})
export class HeaderMenuComponent {
@ViewChild(MatMenuTrigger) trigger!: MatMenuTrigger;
constructor(
private router: Router,
private authService: AuthService,
private notifierService: NotifierService,
private scoreTableService: ScoreTableService
) {}
isUserLoggedIn() {
return this.authService.getCurrentUser();
}
isOnScoreTable() {
return this.router.url === '/score-table';
}
navigateToProfileOrScoreboard() {
this.scoreTableService.setShowUserGames(false);
if (this.isOnScoreTable()) {
this.router.navigate(['/user-profile']);
} else {
this.router.navigate(['/score-table']);
}
}
login() {
this.router.navigate(['/login']);
}
logout() {
this.authService.logout();
this.trigger.closeMenu();
this.router.navigate(['/score-table']);
window.location.reload();
this.notifierService.showNotification('Du wurdest ausgeloggt.', 'OK');
}
}

View File

@@ -0,0 +1,58 @@
.header {
display: flex;
justify-content: space-between;
align-items: center;
position: sticky;
z-index: 100;
top: 0;
padding: 0.5rem 1rem;
background: #f6f9fc65;
backdrop-filter: blur(5px);
font-size: 18px ;
}
.header-title{
margin: 0 1rem;
font-size: 18px;
flex: 1;
text-align: center;
cursor: pointer;
}
.ball-container,
.header-menu-container {
display: flex;
flex: 0 0 5%;
justify-content: center;
}
.ball-container {
align-items: center;
}
.ball-container img {
cursor: pointer;
}
.clickable-image {
cursor: pointer; /* Changes the cursor to indicate the image is clickable */
transition: transform 0.3s ease; /* Smooth transition for feedback */
}
.clickable-image:active {
transform: scale(0.95); /* Scales down the image when clicked */
}
/* Define the spin animation */
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* Class to apply the spin animation */
.spin-animation {
animation: spin 0.8s; /* Run the spin animation for 1 second */
}

View File

@@ -0,0 +1,18 @@
<div class="header">
<div class="ball-container">
<img
#clickableImage
(click)="goToScoreTable(clickableImage)"
class="clickable-image"
src="assets/img/AtosEvidenBall 3.png"
alt="AtosEvidenBall"
title="Go to Score Table"
/>
</div>
<p class="header-title" (click)="goToScoreTable(clickableImage)">
Kick it like <span style="color: #0596FF;">Atos</span> & <span style="color: #FF6D43;">Eviden</span>
</p>
<div class="header-menu-container">
<app-header-menu></app-header-menu>
</div>
</div>

View File

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

View File

@@ -0,0 +1,25 @@
import {Component, Renderer2} from '@angular/core';
import { HeaderMenuComponent } from '../header-menu/header-menu.component';
import { Router } from '@angular/router';
@Component({
selector: 'app-header',
standalone: true,
templateUrl: './header.component.html',
styleUrl: './header.component.css',
imports: [HeaderMenuComponent],
})
export class HeaderComponent {
constructor(private router: Router, private renderer: Renderer2) {}
goToScoreTable(image: HTMLElement) {
this.renderer.addClass(image, 'spin-animation');
// Wait for the animation to complete before navigating
setTimeout(() => {
this.renderer.removeClass(image, 'spin-animation');
this.router.navigate(['/score-table']);
}, 800); // Adjust the timeout/delay to match the animation duration (milliseconds)
}
}

View File

@@ -0,0 +1,56 @@
<div class="loadingio-spinner-dual-ball-sv85ib7wgti"><div class="ldio-ia0w02586ob">
<div></div><div></div><div></div>
</div></div>
<style type="text/css">
@keyframes ldio-ia0w02586ob-o {
0% { opacity: 1; transform: translate(0 0) }
49.99% { opacity: 1; transform: translate(80px,0) }
50% { opacity: 0; transform: translate(80px,0) }
100% { opacity: 0; transform: translate(0,0) }
}
@keyframes ldio-ia0w02586ob {
0% { transform: translate(0,0) }
50% { transform: translate(80px,0) }
100% { transform: translate(0,0) }
}
.ldio-ia0w02586ob div {
position: absolute;
width: 80px;
height: 80px;
border-radius: 50%;
top: 60px;
left: 20px;
}
.ldio-ia0w02586ob div:nth-child(1) {
background: #ff6d43;
animation: ldio-ia0w02586ob 1.36986301369863s linear infinite;
animation-delay: -0.684931506849315s;
}
.ldio-ia0w02586ob div:nth-child(2) {
background: #0596ff;
animation: ldio-ia0w02586ob 1.36986301369863s linear infinite;
animation-delay: 0s;
}
.ldio-ia0w02586ob div:nth-child(3) {
background: #ff6d43;
animation: ldio-ia0w02586ob-o 1.36986301369863s linear infinite;
animation-delay: -0.684931506849315s;
}
.loadingio-spinner-dual-ball-sv85ib7wgti {
width: 200px;
height: 200px;
display: inline-block;
overflow: hidden;
background: #F6F9FC;
}
.ldio-ia0w02586ob {
width: 100%;
height: 100%;
position: relative;
transform: translateZ(0) scale(1);
backface-visibility: hidden;
transform-origin: 0 0; /* see note above */
}
.ldio-ia0w02586ob div { box-sizing: content-box; }
/* generated by https://loading.io/ */
</style>

View File

@@ -0,0 +1,12 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-loading-spinner',
standalone: true,
imports: [],
templateUrl: './loading-spinner.component.html',
styleUrl: './loading-spinner.component.css'
})
export class LoadingSpinnerComponent {
}

View File

@@ -0,0 +1,36 @@
import { Injectable } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
@Injectable({
providedIn: 'root'
})
export class NotifierService {
constructor(private snackBar: MatSnackBar) { }
// Method to display a notification message with a button
showNotification(displayMessage: string, button: string) {
// Opens a snack bar with the provided message and button text
this.snackBar.open(displayMessage, button, {
// Duration for which the notification is displayed (in milliseconds)
duration: 7000,
// Horizontal position of the notification on the screen
horizontalPosition: 'center',
});
}
// Method to display a notification message without a button
showInformation(displayMessage: string) {
this.snackBar.open(displayMessage, undefined, {
duration: 7000,
horizontalPosition: 'center',
});
}
//Close Snackbar
dismissNotification() {
if (this.snackBar) {
this.snackBar.dismiss();
}
}
}

View File

@@ -0,0 +1,17 @@
.spinner-section {
color: rgba(0, 0, 0, 0.87)
}
.custom-spinner-color .mat-progress-spinner circle {
stroke: #002D3C;
}
.custom-spinner-color .mat-progress-spinner {
width: 24px;
height: 24px;
}
mat-form-field.custom-form-field {
flex-grow: 1;
margin-right: 32px;
}

View File

@@ -0,0 +1,10 @@
<div class="spinner-section" *ngIf="showSpinner">
<!-- Show spinner when loading -->
<mat-progress-spinner
class="custom-spinner-color"
[mode]="mode"
[value]="value"
[diameter]="20"
[strokeWidth]="2"
></mat-progress-spinner>
</div>

View File

@@ -0,0 +1,16 @@
import { CommonModule } from '@angular/common';
import { Component, Input } from '@angular/core';
import { ProgressSpinnerMode, MatProgressSpinnerModule } from '@angular/material/progress-spinner';
@Component({
selector: 'app-progress-spinner',
standalone: true,
imports: [MatProgressSpinnerModule, CommonModule],
templateUrl: './progress-spinner.component.html',
styleUrls: ['./progress-spinner.component.css']
})
export class ProgressSpinnerComponent {
mode: ProgressSpinnerMode = 'indeterminate';
value = 0;
@Input() showSpinner: boolean = false;
}

View File

@@ -0,0 +1,26 @@
import { AbstractControl, ValidationErrors, ValidatorFn } from "@angular/forms";
export enum PasswordValidationType {
Match,
Different
}
export function passwordValidator(matchTo: string, validationType: PasswordValidationType): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
if (!control.parent || !control.parent.get(matchTo)) {
return null; // parent or matching control not found
}
const matchToControl = control.parent.get(matchTo);
// Depending on the validation type, return the appropriate error
switch (validationType) {
case PasswordValidationType.Match:
return control.value === matchToControl?.value ? null : { passwordMismatch: true };
case PasswordValidationType.Different:
return control.value !== matchToControl?.value ? null : { passwordsSame: true };
default:
return null;
}
};
}

View File

@@ -0,0 +1,47 @@
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
export function passwordStrengthValidator(): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const value = control.value;
if (!value) {
return null; // Don't validate empty value
}
const hasUpperCase = /[A-Z]/.test(value);
const hasNumber = /\d/.test(value);
//Special character validation commented out until Chrome decides to generate passwords with special characters -.-
//const hasSymbol = /[!@#$%^&*(),.?":{}|<>-]/.test(value);
const valid = hasUpperCase && hasNumber;
return valid ? null : { passwordStrength: true };
};
}
export function hasUpperCaseValidator(): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const value = control.value;
if (!value) {
return null;
}
const hasUpperCase = /[A-Z]/.test(value);
//Special character validation commented out until Chrome decides to generate passwords with special characters -.-
//const hasSymbol = /[!@#$%^&*(),.?":{}|<>-]/.test(value);
const valid = hasUpperCase;
return valid ? null : { hasUpperCase: true };
};
}
export function hasNumberValidator(): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const value = control.value;
if (!value) {
return null;
}
const hasNumber = /\d/.test(value);
//Special character validation commented out until Chrome decides to generate passwords with special characters -.-
//const hasSymbol = /[!@#$%^&*(),.?":{}|<>-]/.test(value);
const valid = hasNumber;
return valid ? null : { hasNumber: true };
};
}

View File

@@ -0,0 +1,30 @@
.upload-container {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
.upload-container button {
background-color: rgba(0, 0, 0, 0.25); ;
color: #FFF;
border-radius: 50%;
width: 2rem;
height: 2rem;
margin-right: 5px;
box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.25);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
overflow: hidden;
border: none;
}
.profile-photo-upload-container {
position: absolute;
top: 10px;
right: 10px;
z-index: 2;
}

View File

@@ -0,0 +1,13 @@
<div class="profile-photo-upload-container" style="margin-bottom: 40px;">
<!-- Hidden file input for styling purposes -->
<input type="file" #fileInput hidden (change)="onFileSelected($event)" accept="image/*">
<div class="upload-container">
<button *ngIf="!currentUser?.photoUrl" (click)="fileSelected ? uploadFile() : fileInput.click()" class="image-upload-button">
<mat-icon>{{ fileSelected ? 'upload_file' : 'image' }}</mat-icon>
</button>
<button *ngIf="currentUser?.photoUrl" mat-fab (click)="deleteFile()" class="close-icon-button">
<mat-icon>close</mat-icon>
</button>
</div>
</div>

View File

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

View File

@@ -0,0 +1,88 @@
import { Component, OnDestroy, OnInit } from '@angular/core';
import { UserService } from '../../user.service';
import { Subscription } from 'rxjs';
import { User } from '../../../auth/model/user.model';
import { ProfilePhotoUploadService } from './profile-photo-upload.service';
import { AngularMaterialModule } from '../../../angular-material/angular-material.module';
import { CommonModule } from '@angular/common';
import { NotifierService } from '../../../shared/notifierService/notifier.service';
@Component({
selector: 'app-profile-photo-upload',
standalone: true,
imports: [AngularMaterialModule, CommonModule],
templateUrl: './profile-photo-upload.component.html',
styleUrls: ['./profile-photo-upload.component.css'],
})
export class ProfilePhotoUploadComponent implements OnInit, OnDestroy {
//TODO check why the userId remains empty/null in component, but in service the uid is returned proper?
userProfileSubscription!: Subscription;
currentUser: User | null = null;
fileSelected: boolean = false;
metadata = {
contentType: 'image/jpeg',
customMetadata: {
imgId: undefined,
},
};
selectedFile: File | null = null;
constructor(
private userService: UserService,
private profilePhotoUploadService: ProfilePhotoUploadService,
private notifierService: NotifierService
) {}
ngOnInit(): void {
this.userProfileSubscription =
this.userService.currentUserProfile$.subscribe((user: User | null) => {
this.currentUser = user;
});
}
ngOnDestroy(): void {
if (this.userProfileSubscription) {
this.userProfileSubscription.unsubscribe();
}
}
onFileSelected(event: any) {
this.selectedFile = event.target.files[0];
this.fileSelected = true;
this.notifierService.showNotification(
`Datei wurde ausgewählt. Bitte klicke auf die Schaltfläche "Hochladen", um Bild einzustellen.`,
'OK'
);
}
async uploadFile() {
if (this.selectedFile && this.currentUser && this.currentUser.uid) {
const file = this.selectedFile;
const metadata = {
contentType: 'image/jpeg',
customMetadata: {
imgId: this.currentUser?.uid,
},
};
await this.profilePhotoUploadService.uploadFile(
this.currentUser.uid,
file,
metadata
);
this.notifierService.showNotification(`Datei wurde hochgeladen`, 'OK');
} else {
this.notifierService.showInformation(
'Keine Datei ausgewählt oder Benutzer-ID ist unbekannt.'
);
}
}
async deleteFile() {
if (this.currentUser && this.currentUser.uid) {
await this.profilePhotoUploadService.deleteFile(this.currentUser.uid);
this.notifierService.showNotification(`Datei wurde gelöscht`, 'OK');
} else {
this.notifierService.showInformation('Benutzer-ID ist unbekannt. Bitte melde dich an, um die Datei zu löschen.');
}
}
}

View File

@@ -0,0 +1,198 @@
import { Injectable } from '@angular/core';
import {
getStorage,
ref,
uploadBytesResumable,
getDownloadURL,
deleteObject,
} from 'firebase/storage';
import {
getFirestore,
doc,
setDoc,
getDoc,
deleteDoc,
updateDoc,
} from 'firebase/firestore';
import { NotifierService } from '../../../shared/notifierService/notifier.service';
@Injectable({
providedIn: 'root',
})
export class ProfilePhotoUploadService {
storage = getStorage();
firestore = getFirestore();
constructor(private notifierService: NotifierService) {}
async uploadFile(userId: string, file: File, metadata: any): Promise<void> {
// get the current user's file metadata from firestore
const userDocRef = doc(this.firestore, 'users', userId);
const fileDocRef = doc(this.firestore, 'files', userId);
const fileDoc = await getDoc(fileDocRef);
if (fileDoc.exists()) {
// if there's an existing file, delete it from storage
const existingFilePath = fileDoc.data()['filePath'];
const existingFileRef = ref(this.storage, existingFilePath);
await deleteObject(existingFileRef).catch((error) => {
(`Error deleting existing file: ${{error}}`);
});
}
if (file.size > 30 * 1024) {
// If the file is too large, compress the image
this.notifierService.showInformation('File size exceeds 30 KB. Compressing the image...')
file = await this.compressImage(file);
}
//creates a firebase storage for images
const filePath = `images/${userId}/${file.name}`;
const storageRef = ref(this.storage, filePath);
//uploads the image to the storage
const uploadTask = uploadBytesResumable(storageRef, file, metadata);
uploadTask.on(
'state_changed',
//show the upload process in the console
(snapshot) => {
switch (snapshot.state) {
case 'error':
this.notifierService.showInformation('Upload failed')
break;
case 'success':
this.notifierService.showInformation('Upload completed')
break;
}
},
//error handling, display currently only in the console
(error) => {
switch (error.code) {
case 'storage/unauthorized':
this.notifierService.showInformation('The user is not authenticated. Please authenticate yourself and try again.');
break;
case 'storage/canceled':
this.notifierService.showInformation('The user has cancelled the process.');
break;
case 'storage/unknown':
this.notifierService.showInformation('An unknown error has occurred.');
break;
}
},
() => {
//returns the download url of the uploaded image
getDownloadURL(uploadTask.snapshot.ref).then((downloadURL) => {
const fileMetadata = {
id: userId,
filePath: filePath,
downloadURL: downloadURL,
};
// Set the file metadata in the 'files' collection
setDoc(fileDocRef, fileMetadata).catch((error) => {
this.notifierService.showInformation(`Error updating file${{error}}`);
});
// Update the user's document with the new photoUrl
updateDoc(userDocRef, { photoUrl: downloadURL })
.catch((error) => {
this.notifierService.showInformation(`Error updating user document with photo URL: ${{error}}`);
});
});
}
);
}
async deleteFile(userId: string): Promise<void> {
try {
// Get the current user's file metadata from Firestore
const userDocRef = doc(this.firestore, 'users', userId);
const fileDocRef = doc(this.firestore, 'files', userId);
const fileDoc = await getDoc(fileDocRef);
if (fileDoc.exists()) {
// If there's an existing file, delete it from storage
const existingFilePath = fileDoc.data()['filePath'];
const existingFileRef = ref(this.storage, existingFilePath);
await deleteObject(existingFileRef);
await deleteDoc(fileDocRef);
updateDoc(userDocRef, { photoUrl: null })
.catch((error) => {
this.notifierService.showInformation(`Error removing photo URL from user document: ${{error}}`);
});
} else {
this.notifierService.showInformation('No file metadata found to delete');
}
} catch (error) {
this.notifierService.showInformation(`Error deleting file: ${{error}}`);
}
}
// Helper function to compress an image
private compressImage(file: File): Promise<File> {
return new Promise((resolve, reject) => {
const image = new Image();
image.src = URL.createObjectURL(file);
image.onload = () => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) {
reject(new Error('Failed to get canvas context'));
return;
}
const maxWidth = 1024; // Set the maximum width you want for the images
const maxHeight = 1024; // Set the maximum height you want for the images
let width = image.width;
let height = image.height;
if (width > height) {
if (width > maxWidth) {
height *= maxWidth / width;
width = maxWidth;
}
} else {
if (height > maxHeight) {
width *= maxHeight / height;
height = maxHeight;
}
}
canvas.width = width;
canvas.height = height;
ctx.drawImage(image, 0, 0, width, height);
// Function to attempt to compress the image
const attemptCompression = (quality: number, resolve: (file: File) => void, reject: (reason: Error) => void) => {
canvas.toBlob(
(blob) => {
if (blob === null) {
reject(new Error('Failed to create blob from canvas'));
return;
}
if (blob.size <= 30 * 1024) {
// If the blob size is less than or equal to 100 KB, resolve the promise
const compressedFile = new File([blob], file.name, {
type: 'image/jpeg',
lastModified: Date.now(),
});
resolve(compressedFile);
} else if (quality > 0.1) {
// If the quality can still be reduced, try again with lower quality
attemptCompression(quality - 0.1, resolve, reject);
} else {
// If the quality is already too low, reject the promise
reject(new Error('Cannot compress image to the desired size'));
}
},
'image/jpeg',
quality
);
};
// Start the compression attempt with an initial quality value
attemptCompression(0.5, resolve, reject);
};
image.onerror = () => {
reject(new Error('Image loading failed'));
};
});
}
}

View File

@@ -0,0 +1,214 @@
.flex-user {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh; /* Full viewport height */
}
/* Form */
form {
display: flex;
flex-direction: column;
position: relative;
}
.flex3 {
display: flex;
justify-content: space-between;
}
/* Card */
mat-card {
padding: 2.5rem;
width: min-content;
box-shadow: none;
}
/* Form Fields */
mat-form-field {
width: 17.8rem;
margin-bottom: 1.5rem;
}
/* Card Footer */
mat-card-footer {
color: #0596ff;
text-align: center;
text-decoration-line: underline;
}
/* Button */
#logOutButton {
color: #fff;
text-align: center;
width: 15.9375rem;
height: 3.75rem;
background-color: #002d3c;
flex-shrink: 0;
border-radius: 6.25rem;
margin-top: 3rem;
cursor: pointer;
}
/* Button-Disabled */
button[disabled] {
background-color: #cccccc;
color: #666666;
cursor: not-allowed;
}
:host ::ng-deep .mat-mdc-text-field-wrapper {
border-radius: 10px;
background: #fff;
}
::ng-deep
.mdc-text-field--outlined.mdc-text-field--disabled
.mdc-text-field__input {
color: #000010 !important;
}
::ng-deep
.mat-mdc-text-field-wrapper.mdc-text-field--outlined
.mdc-notched-outline--upgraded
.mdc-floating-label--float-above {
color: #949494 !important;
}
/* Spoiler text for the Change Password toggle */
.change-password-toggle {
display: flex;
justify-content: center; /* Center the text horizontally */
margin-top: 1rem; /* Add space above the text */
margin-bottom: 0.5rem; /* Reduce space below the text for closer proximity to fields */
cursor: pointer; /* Change cursor to indicate clickable text */
}
.change-password-toggle p {
color: #002d3c; /* Set the text color */
text-decoration: underline; /* Underline the text to indicate it's clickable */
}
.change-password-toggle p:hover {
color: #0596ff; /* Change text color on hover for visual feedback */
}
/* Container for the Change Password form fields */
.change-password-fields {
display: flex;
flex-direction: column; /* Stack the form fields vertically */
gap: 1.5rem; /* Space between each form field */
}
/* Style for each form field */
.change-password-fields mat-form-field {
margin-bottom: 1.5rem; /* Add space below each form field */
}
/* Style for the Update Password button */
.update-password-button {
display: flex;
justify-content: center; /* Center the button horizontally */
margin-top: 0.5rem; /* Space above the button, consistent with form field spacing */
}
.update-password-button button {
padding: 0.5rem 1rem; /* Increase padding for more space around the text */
font-size: 0.75rem; /* Smaller font size for the button text */
color: #333; /* Change text color for the button */
background-color: transparent; /* No background color for the button */
border: 1px solid #002d3c; /* Add a border with the desired color */
border-radius: 0.25rem; /* Slight rounding of corners */
cursor: pointer; /* Change cursor to indicate clickable button */
text-transform: uppercase; /* Uppercase button text */
letter-spacing: 0.05rem; /* Add some letter spacing */
transition: all 0.3s; /* Transition for color and border color change */
box-shadow: none; /* Remove the shadow for a flat style */
font-weight: bold; /* Make the text bold */
}
/* Override padding for suffix icon in LTR direction */
:host ::ng-deep .mat-mdc-form-field-icon-suffix {
padding: 0 !important;
}
/* Override padding for prefix icon in RTL direction */
:host ::ng-deep [dir="rtl"] .mat-mdc-form-field-icon-prefix {
padding: 0 !important;
}
.userProfileDiv {
position: relative;
margin-top: 1rem;
margin-bottom: 2.5rem;
max-width: 17.8rem;
max-height: 17.8rem;
border-radius: 10%;
border: 1px solid #0011;
overflow: hidden;
}
.userProfileImg {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.sync-icon {
margin-top: 13px;
position: absolute;
right: 0;
cursor: pointer;
width: 24px;
height: 24px;
}
.spinner-section {
margin-top: 13px;
position: absolute;
right: 0;
display: flex;
align-items: center;
justify-content: center;
}
.spinner-section-center {
margin-top: 2rem;
display: flex;
align-items: center;
justify-content: center;
}
.non-editable-field ::ng-deep .mat-mdc-text-field-wrapper {
background: transparent; /* Setzt die Farbe des Labels auf inaktiv */
}
.non-editable-field input[readonly] {
color: #000000; /*rgba(0, 0, 0, 0.4); Graue Textfarbe für nicht bearbeitbare Eingabe */
background-color: transparent;
cursor: default;
}
.user-info {
display: flex;
justify-content: center;
}
.center-container {
margin: 0 20%;
}
@media only screen and (max-width: 768px) {
.center-container {
margin: 0 5%;
}
}
.default-background-color {
background: #f6f9fc;
}
.editable-field {
width: 13.8rem;
}

View File

@@ -0,0 +1,176 @@
<app-header></app-header>
<!-- Flex container -->
<div class="flex" *ngIf="dataLoaded && showProfile">
<!-- Centered container -->
<div class="center-container">
<!-- Material card -->
<div class="user-info">
<mat-card class="default-background-color">
<!-- E-Mail verification warn card -->
<app-verify-email-warn-card
*ngIf="isProfileOwner"
></app-verify-email-warn-card>
<!-- Profile photo based on user -->
<div class="m-auto">
<div
*ngIf="
userProfilePhoto && userProfilePhoto != '';
else defaultAvatar
"
class="m-auto userProfileDiv"
>
<img
class="userProfileImg"
src="{{ userProfilePhoto }}"
alt="User Foto"
/>
<!-- Include the profile photo upload component here -->
<app-profile-photo-upload
*ngIf="isProfileOwner"
></app-profile-photo-upload>
</div>
<!-- Conditional SVG based on user's email domain -->
<ng-template #defaultAvatar>
<div class="userProfileDiv" style="margin-bottom: 40px">
<ng-container *ngIf="isCompanyAtos(); else otherAvatar">
<svg
width="100%"
height="100%"
viewBox="0 0 295 295"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<circle
cx="147.5"
cy="122.917"
r="36.875"
stroke="#0596FF"
stroke-width="4"
stroke-linecap="round"
/>
<path
d="M221.25 229.927C216.9 216.859 207.314 205.311 193.98 197.075C180.646 188.839 164.308 184.375 147.5 184.375C130.692 184.375 114.354 188.839 101.02 197.075C87.6857 205.311 78.1001 216.859 73.75 229.927"
stroke="#0596FF"
stroke-width="4"
stroke-linecap="round"
/>
</svg>
</ng-container>
<ng-template #otherAvatar>
<svg
width="100%"
height="100%"
viewBox="0 0 295 295"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<circle
cx="147.5"
cy="122.917"
r="36.875"
stroke="#FF6D43"
stroke-width="4"
stroke-linecap="round"
/>
<path
d="M221.25 229.927C216.9 216.859 207.314 205.311 193.98 197.075C180.646 188.839 164.308 184.375 147.5 184.375C130.692 184.375 114.354 188.839 101.02 197.075C87.6857 205.311 78.1001 216.859 73.75 229.927"
stroke="#FF6D43"
stroke-width="4"
stroke-linecap="round"
/>
</svg>
</ng-template>
<!-- Include the profile photo upload component here as well -->
<app-profile-photo-upload
*ngIf="isProfileOwner"
></app-profile-photo-upload>
</div>
</ng-template>
</div>
<!-- Conditional SVG based on user's email domain -->
<!-- Card content -->
<mat-card-content>
<form [formGroup]="userProfileForm" (ngSubmit)="saveProfile()">
<!-- Name input field -->
<div>
<mat-form-field appearance="outline" class="non-editable-field">
<mat-label>Name</mat-label>
<input
type="text"
matInput
[readonly]="true"
formControlName="name"
/>
</mat-form-field>
</div>
<!-- Email input field -->
<div>
<mat-form-field appearance="outline" class="non-editable-field">
<mat-label>Email</mat-label>
<input
type="text"
matInput
[readonly]="true"
formControlName="email"
/>
</mat-form-field>
</div>
<div
class="flex3"
*ngIf="isProfileOwner || userProfileForm.value.firmenposition"
>
<mat-form-field
appearance="outline"
class="{{
isProfileOwner ? 'editable-field' : 'non-editable-field'
}}"
>
<mat-label>Firmenposition</mat-label>
<input
type="text"
matInput
[readonly]="!isProfileOwner"
formControlName="firmenposition"
placeholder="z. B. Developer"
/>
</mat-form-field>
<!-- Save button -->
<mat-icon
class="sync-icon"
*ngIf="isProfileOwner && !showSpinnerModule"
(click)="saveProfile()"
>sync</mat-icon
>
<app-progress-spinner
class="spinner-section"
[showSpinner]="showSpinnerModule"
></app-progress-spinner>
</div>
<!-- User wins -->
<div class="flex3">
<h2>Siege:</h2>
<!-- Display user wins if available, otherwise show loading or blank -->
<h1 [style.color]="isCompanyAtos() ? '#0596ff' : '#ff6d43'">
{{
userProfileForm.controls["wins"].value
? userProfileForm.controls["wins"].value
: 0
}}
</h1>
</div>
<!-- ... other parts of your form ... -->
</form>
</mat-card-content>
</mat-card>
</div>
</div>
</div>
<app-progress-spinner
class="spinner-section-center"
[showSpinner]="!dataLoaded"
></app-progress-spinner>

View File

@@ -0,0 +1,393 @@
import { Component, OnDestroy, OnInit } from '@angular/core';
import {
AbstractControl,
FormControl,
FormGroup,
FormsModule,
ReactiveFormsModule,
Validators,
} from '@angular/forms';
import { AngularMaterialModule } from '../angular-material/angular-material.module';
import { CommonModule } from '@angular/common';
import { HttpClientModule } from '@angular/common/http';
import { Router, RouterModule, ActivatedRoute } from '@angular/router';
import { UserService } from './user.service';
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
import { AuthService } from '../auth/authService/auth.service';
import { Subscription, Subject } from 'rxjs';
import { take, takeUntil } from 'rxjs/operators';
import { User } from '../auth/model/user.model';
import { ScoreTableService } from '../score-table/score-table.service';
import { PendingActionsService } from '../score/pending-actions.service';
import { NotifierService } from '../shared/notifierService/notifier.service';
import { ProfilePhotoUploadComponent } from './profile-photo/profile-photo-upload/profile-photo-upload.component';
import { MatDividerModule } from '@angular/material/divider';
import { ProgressSpinnerComponent } from '../shared/progress-spinner/progress-spinner/progress-spinner.component';
import { HeaderComponent } from '../shared/header/header.component';
import {
PasswordValidationType,
passwordValidator,
} from '../shared/validation/passwordMatcherValidation';
import {
hasNumberValidator,
hasUpperCaseValidator,
} from '../shared/validation/passwordSaferValidation';
import { MatProgressSpinner } from '@angular/material/progress-spinner';
import { VerifiyWarnCardComponent } from './verify-email-warn-card/verify-email-warn-card.component';
@UntilDestroy()
@Component({
selector: 'app-test',
standalone: true,
providers: [UserService, AuthService],
templateUrl: './user-profile.component.html',
styleUrl: './user-profile.component.css',
imports: [
CommonModule,
AngularMaterialModule,
FormsModule,
ReactiveFormsModule,
RouterModule,
HttpClientModule,
ProfilePhotoUploadComponent,
MatDividerModule,
ProgressSpinnerComponent,
HeaderComponent,
MatProgressSpinner,
VerifiyWarnCardComponent,
],
})
export class UserProfileComponent implements OnInit, OnDestroy {
userProfileForm!: FormGroup; // Form group to manage user profile data
userProfilePhoto: string | undefined | null;
userProfileSubscription!: Subscription; // Subscription to user profile changes
user$ = this.userService.currentUserProfile$; // Observable of current user profile data
dataLoaded: boolean = false;
showProfile: boolean = true;
hide: boolean = true;
showSpinnerModule: boolean = false;
isProfileOwner: boolean = false;
isUpdatingPassword: boolean = false;
private passwordValueChangesSubscription?: Subscription;
private unsubscribe$ = new Subject<void>();
showChangePassword: boolean = false;
passwordForm: FormGroup;
errorMessageCurrentPassword = '';
errorMessageNewPassword = '';
errorMessageConfirmPassword = '';
constructor(
private userService: UserService,
private authService: AuthService,
private router: Router,
private route: ActivatedRoute,
private scoreTableService: ScoreTableService,
private pendingActionsService: PendingActionsService,
private notifierService: NotifierService
) {
this.dataLoaded = false;
// Initialize the password form with form controls and validators from the new branch
this.passwordForm = new FormGroup({
currentPassword: new FormControl('', [Validators.required]),
newPassword: new FormControl('', [
Validators.required,
Validators.minLength(10),
Validators.maxLength(20),
hasUpperCaseValidator(),
hasNumberValidator(),
]),
confirmNewPassword: new FormControl('', [Validators.required]),
});
// Apply the non-matching validator to the newPassword control
const newPasswordControl = this.passwordForm.get('newPassword');
const currentPasswordControl = this.passwordForm.get('currentPassword');
const confirmNewPasswordControl =
this.passwordForm.get('confirmNewPassword');
if (
newPasswordControl &&
currentPasswordControl &&
confirmNewPasswordControl
) {
newPasswordControl.setValidators([
Validators.required,
Validators.minLength(10),
Validators.maxLength(20),
passwordValidator('currentPassword', PasswordValidationType.Different),
hasUpperCaseValidator(),
hasNumberValidator(),
]);
// Apply the match validator to the confirmNewPassword control
confirmNewPasswordControl.setValidators([
Validators.required,
passwordValidator('newPassword', PasswordValidationType.Match),
]);
// Set up a subscription to the currentPassword field's valueChanges observable
newPasswordControl.valueChanges.subscribe(() => {
this.updateErrorMessage();
confirmNewPasswordControl.updateValueAndValidity();
});
confirmNewPasswordControl.valueChanges.subscribe(() => {
this.updateErrorMessage();
});
}
}
ngOnInit(): void {
// Subscribe to query parameters
this.route.queryParams
.pipe(untilDestroyed(this))
.subscribe(async (params) => {
const score = params['score'];
const uid = params['uid'];
if (this.isUserVerified() && score && uid) {
this.showProfile = false;
this.scoreTableService.validateAndAddGame(score, uid);
this.scoreTableService.setShowUserGames(true);
} else if (score && uid) {
this.showProfile = false;
const uniqueId = this.pendingActionsService.saveTempQRCodeData({
score,
uid,
});
if (this.authService.getCurrentUser())
this.pendingActionsService.addUserRefToQRCodeData(
this.authService.getCurrentUser()?.uid!,
uniqueId
);
this.router.navigate(['/score-table']);
}
// Subscribing to the user profile changes to update component properties
this.userProfileSubscription = this.user$.subscribe((userProfile) => {
this.userProfilePhoto = userProfile?.photoUrl;
});
});
// Initializing the user profile form
this.initForm();
// Subscribe to route params
this.route.params.pipe(untilDestroyed(this)).subscribe((params) => {
this.initUserProfile(params['id']);
});
}
// Function to save the updated profile data
saveProfile() {
this.showSpinnerModule = true;
this.authService.currentUser$.pipe(take(1)).subscribe((user) => {
if (user) {
// Check that the user is not null
const profileData = this.userProfileForm.value;
const updatedUser: User = {
uid: user.uid,
firmenposition: profileData.firmenposition,
// Include other fields as needed
};
this.userService.updateUser(updatedUser).subscribe();
setTimeout(() => (this.showSpinnerModule = false), 1000);
this.notifierService.showNotification(
'Firmenposition erfolgreich aktualisiert.',
'OK'
);
}
});
}
// Function to handle logout
onLogout() {
// Dismiss any open notifications
this.notifierService.dismissNotification();
this.authService.logout().subscribe(() => {
this.router.navigate(['/login']);
});
this.unsubscribe$.next();
}
ngOnDestroy(): void {
// Unsubscribing from subscriptions to prevent memory leaks
if (this.userProfileSubscription) {
this.userProfileSubscription.unsubscribe();
}
if (this.passwordValueChangesSubscription) {
this.passwordValueChangesSubscription.unsubscribe();
}
this.unsubscribe$.next();
this.unsubscribe$.complete();
}
// Function to update the user's password
updatePassword() {
if (!this.passwordForm.valid) {
return;
}
const currentUser = this.authService.getCurrentUser();
if (!currentUser) {
this.notifierService.showNotification(
'Kein Benutzer angemeldet. Bitte melde dich an.',
'OK'
);
return;
}
this.isUpdatingPassword = true;
const currentPassword = this.passwordForm.value.currentPassword;
const newPassword = this.passwordForm.value.newPassword;
this.userService
.updatePassword(currentPassword, newPassword)
.pipe(takeUntil(this.unsubscribe$))
.subscribe({
next: () => {
this.notifierService.showNotification(
'Das Passwort wurde geändert.',
'OK'
);
this.passwordForm.reset();
this.showChangePassword = false;
this.isUpdatingPassword = false;
},
error: (error) => {
this.isUpdatingPassword = false;
if (error.code === 'auth/invalid-credential') {
this.notifierService.showNotification(
'Das aktuelle Passwort ist falsch!',
'OK'
);
} else {
console.error('Error updating password:', error);
this.notifierService.showNotification(
'Bitte versuche es in 5 Min nochmal!',
'OK'
);
}
},
});
}
isUserVerified() {
return this.authService.getCurrentUser()?.emailVerified;
}
initForm() {
this.userProfileForm = new FormGroup({
uid: new FormControl(null),
name: new FormControl(''),
email: new FormControl(''),
firmenposition: new FormControl(''),
wins: new FormControl(''),
//New Password validation
currentPassword: new FormControl('', [
Validators.required,
Validators.minLength(10),
]),
newPassword: new FormControl('', [
Validators.required,
Validators.minLength(10),
Validators.maxLength(20),
]),
confirmNewPassword: new FormControl('', [
Validators.required,
passwordValidator('newPassword', PasswordValidationType.Match),
]),
});
}
updateForm(user: User | null) {
if (user) {
this.userProfileForm.patchValue({
name: user?.firstName + ' ' + user?.lastName,
...user,
});
this.setProfilePhoto(user.uid);
}
}
initUserProfile(uid: string) {
this.user$.pipe(untilDestroyed(this)).subscribe(async (user) => {
this.isProfileOwner = uid ? uid === user?.uid : true;
if (this.isProfileOwner) {
this.updateForm(user);
} else {
this.userService
.getUserProfile(uid)
.pipe(untilDestroyed(this))
.subscribe(async (user) => {
this.updateForm(user);
});
}
});
}
setProfilePhoto(uid: string) {
this.userService
.getPhotoUrl(uid)
.then((downloadURL) => {
this.userProfilePhoto = downloadURL;
})
.finally(() => {
this.dataLoaded = true;
});
}
isCompanyAtos() {
const email = this.userProfileForm.controls['email'].value;
return email.includes('atos.net');
}
updateErrorMessage() {
const currentPasswordControl = this.userProfileForm.get('currentPassword');
const newPasswordControl = this.passwordForm.get('newPassword');
const confirmNewPasswordControl =
this.passwordForm.get('confirmNewPassword');
this.errorMessageCurrentPassword = currentPasswordControl?.hasError(
'required'
)
? 'Aktuelles Passwort eingeben.'
: '';
this.errorMessageNewPassword =
this.getNewPasswordErrorMessage(newPasswordControl);
this.errorMessageConfirmPassword = this.getConfirmPasswordErrorMessage(
confirmNewPasswordControl
);
}
private getNewPasswordErrorMessage(control: AbstractControl | null): string {
if (control?.hasError('required')) {
return 'Neues Passwort eingeben.';
} else if (control?.hasError('minlength')) {
return 'Das Passwort muss mindestens 10 Zeichen lang sein.';
} else if (control?.hasError('maxlength')) {
return 'Das Passwort darf maximal 20 Zeichen lang sein.';
} else if (control?.hasError('passwordsSame')) {
return 'Das neue Passwort muss sich vom aktuellen Passwort unterscheiden.';
} else if (
control?.hasError('hasNumber') &&
control?.hasError('hasUpperCase')
) {
return 'Mind. 1 Großbuchstaben und 1 Zahl.';
} else if (control?.hasError('hasUpperCase')) {
return 'Mind. 1 Großbuchstaben.';
} else if (control?.hasError('hasNumber')) {
return 'Mind. 1 Zahl.';
}
return '';
}
private getConfirmPasswordErrorMessage(
control: AbstractControl | null
): string {
if (control?.hasError('required')) {
return 'Passwortbestätigung eingeben.';
} else if (control?.hasError('passwordMismatch')) {
return 'Die eingegebenen Passwörter stimmen nicht überein.';
}
return '';
}
}

View File

@@ -0,0 +1,196 @@
import { Injectable } from '@angular/core';
import {
Firestore,
doc,
docData,
setDoc,
updateDoc,
collection,
query,
where,
getDocs,
} from '@angular/fire/firestore';
import { User } from '../auth/model/user.model';
import { Observable, from, of, switchMap } from 'rxjs';
import { AuthService } from '../auth/authService/auth.service';
import {
EmailAuthProvider,
reauthenticateWithCredential,
updatePassword,
} from '@angular/fire/auth';
@Injectable({
providedIn: 'root',
})
export class UserService {
/**
* Retrieves the current user's profile.
* @returns An observable emitting the current user's profile.
*/
get currentUserProfile$(): Observable<User | null> {
return this.authService.currentUser$.pipe(
switchMap((user) => {
if (!user?.uid) {
return of(null);
}
const ref = doc(this.firestore, 'users', user?.uid);
return docData(ref) as Observable<User>;
})
);
}
/**
* Constructs a new UserService.
* @param firestore - The Angular Firestore service instance.
* @param authService - The authentication service instance.
* @param httpClient - The Angular HttpClient service instance.
*/
constructor(private firestore: Firestore, private authService: AuthService) {}
/**
* Adds a new user to the Firestore database.
* @param user - The user object to be added.
* @returns An observable indicating the success or failure of the operation.
*/
addUser(user: User): Observable<any> {
// Ensure that the user.uid is not undefined or an empty string
if (!user.uid) {
throw new Error(
'Invalid UID: UID is required to create a user document.'
);
}
const ref = doc(this.firestore, 'users', user?.uid);
return from(setDoc(ref, user));
}
/**
* Updates an existing user in the Firestore database.
* @param user - The updated user object.
* @returns An observable indicating the success or failure of the operation.
*/
updateUser(user: User): Observable<any> {
const ref = doc(this.firestore, 'users', user?.uid);
return from(updateDoc(ref, { ...user }));
}
// UserService method to count the user's games and update the wins field
updateUserWins(userId: string): Observable<any> {
const userRef = doc(this.firestore, 'users', userId);
const gamesRef = collection(this.firestore, 'games');
const q = query(gamesRef, where('userRef', '==', userRef));
return from(getDocs(q)).pipe(
switchMap((querySnapshot) => {
// The size property indicates the number of documents in the QuerySnapshot
const wins = querySnapshot.size;
// Update the wins field in the user document
return from(updateDoc(userRef, { wins: wins }));
})
);
}
updatePassword(
currentPassword: string,
newPassword: string
): Observable<any> {
return this.authService.currentUser$.pipe(
switchMap((user) => {
if (!user) {
throw new Error('No user signed in');
}
// Get the credential from the current password
return from(this.getCredentialFromPassword(currentPassword)).pipe(
switchMap((credential) => {
// Reauthenticate user with their current password
return from(reauthenticateWithCredential(user, credential)).pipe(
switchMap(() => {
// Re-authentication successful, proceed to update password
return from(updatePassword(user, newPassword));
})
);
})
);
})
);
}
getCredentialFromPassword(currentPassword: string): Promise<any> {
return new Promise((resolve, reject) => {
this.getUserEmail().subscribe(
(email: string | null) => {
if (!email) {
reject(new Error('User email is not available'));
} else {
const credential = EmailAuthProvider.credential(
email,
currentPassword
);
resolve(credential);
}
},
(error) => {
reject(error);
}
);
});
}
getUserEmail(): Observable<string | null> {
return this.authService.currentUser$.pipe(
switchMap((user) => {
if (!user?.uid) {
return of(null);
}
const ref = doc(this.firestore, 'users', user.uid);
return docData(ref).pipe(
switchMap((userData: any) => {
// Assuming 'email' is a field in your Firestore document
const email: string | null = userData.email || null;
return of(email);
})
);
})
);
}
getUserProfile(uid: string): Observable<User | null> {
return new Observable<User>((observer) => {
const usersCollection = collection(this.firestore, 'users');
const q = query(usersCollection, where('uid', '==', uid));
getDocs(q)
.then((querySnapshot) => {
querySnapshot.forEach((doc) => {
const userData = doc.data() as User;
observer.next(userData);
});
observer.complete();
})
.catch((error) => {
observer.error(error);
});
});
}
async getPhotoUrl(uid: string | undefined): Promise<string | null> {
if (!uid) {
return null;
}
try {
const filesQuery = query(
collection(this.firestore, 'files'),
where('id', '==', uid)
);
const querySnapshot = await getDocs(filesQuery);
if (!querySnapshot.empty) {
const fileDoc = querySnapshot.docs[0];
return fileDoc.data()['downloadURL'] as string;
} else {
return null;
}
} catch (error) {
console.error('Error getting image file:', error);
throw error;
}
}
}

View File

@@ -0,0 +1,5 @@
.warning-card {
margin: 1rem;
background-color: #fff3cd;
width: auto;
}

View File

@@ -0,0 +1,14 @@
<mat-card *ngIf="!hideCard" class="warning-card">
<mat-card-content>
<p>
Deine E-Mail wurde noch nicht verifiziert. Bitte überprüfen dein Postfach
und klicke auf den Verifizierungslink.
</p>
<mat-divider></mat-divider>
</mat-card-content>
<mat-card-actions>
<button mat-button (click)="resendVerificationEmail()">
Neue Verifizierungs-E-Mail anfordern
</button>
</mat-card-actions>
</mat-card>

View File

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

View File

@@ -0,0 +1,30 @@
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MatDividerModule } from '@angular/material/divider';
import { AngularMaterialModule } from '../../angular-material/angular-material.module';
import { AuthService } from '../../auth/authService/auth.service';
@Component({
selector: 'app-verify-email-warn-card',
standalone: true,
imports: [CommonModule, AngularMaterialModule, MatDividerModule],
templateUrl: './verify-email-warn-card.component.html',
styleUrl: './verify-email-warn-card.component.css',
})
export class VerifiyWarnCardComponent {
hideCard = false;
constructor(private authService: AuthService) {}
ngOnInit() {
this.hideCard = this.isUserVerified();
}
resendVerificationEmail() {
this.authService.sendVerificationEmail();
}
isUserVerified() {
return !!this.authService.getCurrentUser()?.emailVerified;
}
}

0
src/assets/.gitkeep Normal file
View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

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