Implement authentication feature with role-based access control and login dialog

This commit is contained in:
2025-11-07 17:53:11 +01:00
parent 87a1ab06d9
commit b4f0b7256a
11 changed files with 224 additions and 7 deletions

View File

@@ -18,6 +18,7 @@ import { MatInputModule } from '@angular/material/input';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatButtonModule, MatIconButton } from '@angular/material/button';
import { MatDividerModule } from '@angular/material/divider';
import { MatDialogClose, MatDialogTitle, MatDialogContent, MatDialogActions } from "@angular/material/dialog";
import { HeaderComponent } from './header/header.component';
import { HomeComponent } from './home/home.component';
@@ -46,6 +47,7 @@ import { MovieScheduleNoSearchResultComponent } from './movie-schedule-no-search
import { MovieImporterComponent } from './movie-importer/movie-importer.component';
import { MovieImportNoSearchResultComponent } from './movie-import-no-search-result/movie-import-no-search-result.component';
import { MovieImportSearchInfoComponent } from './movie-import-search-info/movie-import-search-info.component';
import { LoginDialog } from './login/login.dialog';
@NgModule({
@@ -77,6 +79,7 @@ import { MovieImportSearchInfoComponent } from './movie-import-search-info/movie
MovieImporterComponent,
MovieImportNoSearchResultComponent,
MovieImportSearchInfoComponent,
LoginDialog,
],
imports: [
AppRoutingModule,
@@ -94,8 +97,12 @@ import { MovieImportSearchInfoComponent } from './movie-import-search-info/movie
MatFormFieldModule,
MatIconButton,
MatDividerModule,
MatButtonModule
],
MatButtonModule,
MatDialogClose,
MatDialogTitle,
MatDialogContent,
MatDialogActions
],
providers: [
provideBrowserGlobalErrorListeners(),
provideHttpClient(

View File

@@ -7,10 +7,11 @@ import { MainComponent } from './main/main.component';
import { ScheduleComponent } from './schedule/schedule.component';
import { TheaterOverlayComponent} from './theater-overlay/theater-overlay.component';
import { MovieImporterComponent } from './movie-importer/movie-importer.component';
import { AuthGuard } from './auth.guard';
const routes: Routes = [
// Seiten ohne Layout
{ path: 'info', component: HomeComponent },
{ path: 'landing', component: HomeComponent },
{ path: 'poc-model', component: PocModelComponent },
// Seiten mit MainLayout
@@ -20,8 +21,13 @@ const routes: Routes = [
children: [
{ path: '', component: MainComponent },
{ path: 'schedule', component: ScheduleComponent },
{ path: 'admin/movie-importer', component: MovieImporterComponent },
{ path: 'selection/performance/:id', component: TheaterOverlayComponent}, //?
{
path: 'admin/movie-importer',
component: MovieImporterComponent,
canActivate: [AuthGuard],
data: { roles: ['admin'] }, // Array von erlaubten Rollen. Derzeit gäbe es 'admin' und 'employee'
},
{ path: 'selection/performance/:id', component: TheaterOverlayComponent},
],
},

43
src/app/auth.guard.ts Normal file
View File

@@ -0,0 +1,43 @@
import { AuthService, User, UserRole } from './auth.service';
import { inject, Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, CanActivate } from '@angular/router';
import { MatDialog } from '@angular/material/dialog';
import { LoginDialog } from './login/login.dialog';
import { firstValueFrom } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class AuthGuard implements CanActivate {
private auth = inject(AuthService);
private dialog = inject(MatDialog);
async canActivate(route: ActivatedRouteSnapshot): Promise<boolean> {
const allowedRoles: UserRole[] = route.data['roles'];
if (!allowedRoles || allowedRoles.length === 0) {
throw new Error('Keine erlaubten Rollen für diese Route definiert.');
}
const currentUser = this.auth.user()
if (currentUser && allowedRoles.includes(currentUser.role)) {
return true;
}
const roleToLogin = allowedRoles[0]; // Standardmäßig erste Rolle auswählen
const user: User | null = this.auth.getUserDataByRole(roleToLogin);
if (!user) {
throw new Error(`Ungültige Rolle: ${roleToLogin}`);
}
const dialogRef = this.dialog.open(LoginDialog, {
disableClose: true,
backdropClass: 'backdropBackground',
data: { user },
});
const result = await firstValueFrom(dialogRef.afterClosed());
return result === true
}
}

63
src/app/auth.service.ts Normal file
View File

@@ -0,0 +1,63 @@
import { Injectable, signal } from '@angular/core';
import { Router } from '@angular/router';
export type UserRole = 'admin' | 'employee';
export interface User {
role: UserRole;
displayName: string;
}
@Injectable({
providedIn: 'root',
})
export class AuthService {
private readonly SESSION_KEY = 'userRole';
private readonly USERS: Record<UserRole, { password: string; displayName: string }> = {
admin: { password: 'admin123', displayName: 'Admin' },
employee: { password: 'employee123', displayName: 'Mitarbeiter' },
};
user = signal<User | null>(this.loadUserFromSession());
constructor(private router: Router) {}
private loadUserFromSession(): User | null {
const stored = sessionStorage.getItem(this.SESSION_KEY);
return stored ? JSON.parse(stored) as User : null;
}
login(role: UserRole, password: string): boolean {
const user = this.USERS[role];
if (!user) {
return false
};
if (password === user.password) {
const userObj: User = { role, displayName: user.displayName };
sessionStorage.setItem(this.SESSION_KEY, JSON.stringify(userObj));
this.user.set(userObj);
return true;
}
return false;
}
logout() {
sessionStorage.removeItem(this.SESSION_KEY);
this.user.set(null);
this.router.navigate(['/']);
}
getUserDataByRole(role: UserRole): User | null {
const userDef = this.USERS[role];
return userDef ? { role, displayName: userDef.displayName } : null;
}
isLoggedIn(role?: UserRole): boolean {
const current = this.user();
if (!current) return false;
return role ? current.role === role : true;
}
}

View File

@@ -0,0 +1,3 @@
button {
min-width: 100px;
}

View File

@@ -0,0 +1,31 @@
<div class="flex justify-between">
<h2 mat-dialog-title>{{ data.user.displayName }} Login</h2>
<img class="mr-4" src="assets/logo.png" width="75" height="75" alt="Logo" />
</div>
<mat-dialog-content class="min-w-[400px]">
<p class="text-sm text-gray-600 mb-2">Bitte {{ data.user.displayName }}-Passwort eingeben:</p>
<mat-form-field appearance="fill" class="w-full">
<mat-label>Passwort</mat-label>
<input
matInput
[formControl]="passwordControl"
type="password"
(keyup.enter)="submit()"
/>
@if (passwordControl.hasError('required')) {
<mat-error>Passwort ist erforderlich.</mat-error>
}
@else if (passwordControl.hasError('wrongPassword')) {
<mat-error>Falsches Passwort. Bitte erneut versuchen.</mat-error>
}
</mat-form-field>
<div class="h-4"></div>
</mat-dialog-content>
<mat-dialog-actions class="justify-end gap-2">
<button mat-stroked-button color="warn" (click)="cancel()">Abbrechen</button>
<button mat-flat-button color="primary" (click)="submit()">Anmelden</button>
</mat-dialog-actions>

View File

@@ -0,0 +1,40 @@
import { Component, Inject, inject } from '@angular/core';
import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
import { FormControl, Validators } from '@angular/forms';
import { AuthService, User } from '../auth.service';
@Component({
selector: 'app-login',
standalone: false,
templateUrl: './login.dialog.html',
styleUrls: ['./login.dialog.css'],
})
export class LoginDialog {
auth = inject(AuthService);
passwordControl = new FormControl('', Validators.required);
constructor(
private dialogRef: MatDialogRef<LoginDialog>,
@Inject(MAT_DIALOG_DATA) public data: { user: User }
) {}
submit(): void {
const role = this.data.user.role;
if (!this.passwordControl.value) {
this.passwordControl.setErrors({ required: true });
this.passwordControl.markAsTouched();
return;
}
if (!this.auth.login(role, this.passwordControl.value)) {
this.passwordControl.setErrors({ wrongPassword: true });
this.passwordControl.markAsTouched();
return;
}
this.dialogRef.close(true);
}
cancel(): void {
this.dialogRef.close(false);
}
}

View File

@@ -13,7 +13,7 @@ nav {
}
.gradient-text:hover {
background: linear-gradient(to right, #6366f1, #db2777);
background: linear-gradient(to right, #db2777, #db2777);
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;

View File

@@ -15,4 +15,15 @@
}
</div>
</ul>
@if (currentUser() != null) {
<div class="mt-auto flex items-center justify-between gap-x-2 px-3 pb-3 text-gray-600">
<div class="flex items-center gap-x-1">
<span>Angemeldet als:</span>
<span class="text-indigo-500">{{ currentUser()?.displayName }}</span>
</div>
<button (click)="logout()" class="relative top-[1px] rounded opacity-50 hover:opacity-100 text-black">
<mat-icon style="font-size: 20px; width: 20px; height: 20px;">logout</mat-icon>
</button>
</div>
}
</nav>

View File

@@ -1,4 +1,5 @@
import { Component } from '@angular/core';
import { AuthService } from './../auth.service';
import { Component, inject, computed, OnInit } from '@angular/core';
@Component({
selector: 'app-navbar',
@@ -11,4 +12,12 @@ export class NavbarComponent {
{label: 'Programm', path: '/schedule'},
{label: 'Film importieren', path: '/admin/movie-importer'},
]
private auth = inject(AuthService)
currentUser = computed(() => this.auth.user());
logout() {
this.auth.logout();
}
}