SSO implementiert

This commit is contained in:
Bastian Wagner
2026-07-14 17:32:48 +02:00
parent e4d4e78d74
commit f45583f3ea
36 changed files with 896 additions and 653 deletions

View File

@@ -8,7 +8,7 @@
<mat-card-content>
<div class="status-row">
<mat-icon aria-hidden="true">verified_user</mat-icon>
<span>{{ auth.user()?.verified ? 'E-Mail verifiziert' : 'E-Mail nicht verifiziert' }}</span>
<span>SSO-Konto aktiv</span>
</div>
<div class="status-row">

View File

@@ -41,15 +41,6 @@
<mat-icon aria-hidden="true">login</mat-icon>
Login
</a>
<a
mat-flat-button
routerLink="/register"
routerLinkActive="active-link"
ariaCurrentWhenActive="page"
>
<mat-icon aria-hidden="true">person_add</mat-icon>
Registrieren
</a>
}
</mat-toolbar>

View File

@@ -2,8 +2,7 @@ import { Routes } from '@angular/router';
import { authGuard } from './auth/auth.guard';
import { unauthGuard } from './auth/unauth.guard';
import { LoginComponent } from './auth/login/login.component';
import { RegisterComponent } from './auth/register/register.component';
import { VerifyEmailComponent } from './auth/verify-email/verify-email.component';
import { SsoCallbackComponent } from './auth/sso-callback/sso-callback.component';
import { ListDetailComponent } from './lists/list-detail/list-detail.component';
import { ListsComponent } from './lists/lists.component';
import { TemplatesComponent } from './templates/templates.component';
@@ -12,12 +11,7 @@ import { TemplateDetailComponent } from './templates/template-detail/template-de
export const routes: Routes = [
{ path: '', pathMatch: 'full', redirectTo: 'dashboard' },
{ path: 'login', component: LoginComponent, canActivate: [unauthGuard] },
{ path: 'register', component: RegisterComponent, canActivate: [unauthGuard] },
{ path: 'verify-email', component: VerifyEmailComponent, canActivate: [unauthGuard] },
{
path: 'auth',
children: [{ path: 'verify-email', component: VerifyEmailComponent }],
},
{ path: 'auth/sso/callback', component: SsoCallbackComponent },
{
path: 'dashboard',
loadComponent: () =>

View File

@@ -9,8 +9,16 @@
align-items: start;
padding: 1.25rem;
background:
linear-gradient(140deg, color-mix(in srgb, var(--mat-sys-primary) 14%, transparent), transparent 38%),
linear-gradient(320deg, color-mix(in srgb, var(--mat-sys-tertiary) 12%, transparent), transparent 36%),
linear-gradient(
140deg,
color-mix(in srgb, var(--mat-sys-primary) 14%, transparent),
transparent 38%
),
linear-gradient(
320deg,
color-mix(in srgb, var(--mat-sys-tertiary) 12%, transparent),
transparent 36%
),
var(--mat-sys-surface-container);
}
@@ -21,7 +29,9 @@
border-radius: 8px;
background: var(--mat-sys-surface);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
transition: box-shadow 0.3s ease, transform 0.3s ease;
transition:
box-shadow 0.3s ease,
transform 0.3s ease;
}
.auth-card:hover {
@@ -54,7 +64,11 @@
border: 1px solid color-mix(in srgb, var(--mat-sys-primary) 16%, transparent);
border-radius: 8px;
background:
linear-gradient(145deg, color-mix(in srgb, var(--mat-sys-primary) 18%, transparent), transparent),
linear-gradient(
145deg,
color-mix(in srgb, var(--mat-sys-primary) 18%, transparent),
transparent
),
var(--mat-sys-surface-container-low);
color: var(--mat-sys-primary);
}
@@ -101,6 +115,18 @@
margin-right: 0.5rem;
}
.sso-login-button {
width: 100%;
min-height: 48px;
margin-top: 1rem;
border-radius: 8px;
}
.sso-login-button mat-progress-spinner {
display: inline-flex;
margin-right: 0.5rem;
}
.auth-card mat-card-actions {
flex-wrap: wrap;
gap: 0.75rem;

View File

@@ -25,9 +25,7 @@ export const authInterceptor: HttpInterceptorFn = (
}
return auth.refreshSession().pipe(
switchMap((response) =>
next(withAccessToken(request, response.accessToken)),
),
switchMap((response) => next(withAccessToken(request, response.accessToken))),
catchError((refreshError: unknown) => {
auth.logout();
void router.navigateByUrl('/login');
@@ -70,6 +68,7 @@ function isAuthRequest(request: HttpRequest<unknown>): boolean {
return [
'/api/auth/login',
'/api/auth/register',
'/api/auth/sso',
'/api/auth/refresh',
'/api/auth/resend-verification',
'/api/auth/verify-email',

View File

@@ -4,7 +4,6 @@ export interface PublicUser {
id: string;
email: string;
name?: string;
verified: boolean;
onboardingCompleted: boolean;
taskDigestPreference: TaskDigestPreference;
}
@@ -26,15 +25,6 @@ export interface RegisterResponse {
user: PublicUser;
}
export interface VerifyEmailResponse {
message: string;
user: PublicUser;
}
export interface ResendVerificationResponse {
message: string;
}
export interface LoginRequest {
email: string;
password: string;

View File

@@ -8,9 +8,7 @@ import {
PublicUserSearchResult,
RegisterRequest,
RegisterResponse,
ResendVerificationResponse,
TaskDigestPreference,
VerifyEmailResponse,
} from './auth.models';
const ACCESS_TOKEN_KEY = 'listify.accessToken';
@@ -33,21 +31,26 @@ export class AuthService {
.pipe(tap((response) => this.storeSession(response)));
}
startSsoLogin(): void {
if (typeof window !== 'undefined') {
window.location.href = `${this.apiUrl}/sso/login`;
}
}
completeSsoLogin(response: AuthTokenResponse): void {
this.storeSession(response);
}
exchangeSsoCode(code: string, state: string): Observable<AuthTokenResponse> {
return this.http
.post<AuthTokenResponse>(`${this.apiUrl}/sso/exchange`, { code, state })
.pipe(tap((response) => this.storeSession(response)));
}
register(data: RegisterRequest): Observable<RegisterResponse> {
return this.http.post<RegisterResponse>(`${this.apiUrl}/register`, data);
}
verifyEmail(token: string): Observable<VerifyEmailResponse> {
const params = new HttpParams().set('token', token);
return this.http.get<VerifyEmailResponse>(`${this.apiUrl}/verify-email`, { params });
}
resendVerificationEmail(email: string): Observable<ResendVerificationResponse> {
return this.http.post<ResendVerificationResponse>(`${this.apiUrl}/resend-verification`, {
email,
});
}
loadCurrentUser(): Observable<PublicUser> {
return this.http.get<PublicUser>(`${this.apiUrl}/me`).pipe(tap((user) => this.storeUser(user)));
}

View File

@@ -5,81 +5,25 @@
</div>
<mat-card-header>
<mat-card-title>Willkommen zurueck</mat-card-title>
<mat-card-subtitle>Melden Sie sich mit Ihrem Listify-Konto an</mat-card-subtitle>
<mat-card-subtitle>Melden Sie sich mit Ihrem SSO-Konto an</mat-card-subtitle>
</mat-card-header>
<mat-card-content>
<form [formGroup]="form" (ngSubmit)="submit()" class="auth-form">
<mat-form-field appearance="outline">
<mat-label>E-Mail</mat-label>
<input matInput type="email" formControlName="email" autocomplete="email" />
<mat-icon matSuffix aria-hidden="true">mail</mat-icon>
@if (form.controls.email.hasError('required')) {
<mat-error>E-Mail ist erforderlich</mat-error>
} @else if (form.controls.email.hasError('email')) {
<mat-error>Bitte geben Sie eine gueltige E-Mail ein</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Passwort</mat-label>
<input
matInput
[type]="hidePassword ? 'password' : 'text'"
formControlName="password"
autocomplete="current-password"
/>
<button
mat-icon-button
matSuffix
type="button"
[attr.aria-label]="hidePassword ? 'Passwort anzeigen' : 'Passwort verbergen'"
(click)="hidePassword = !hidePassword"
>
<mat-icon aria-hidden="true">{{ hidePassword ? 'visibility' : 'visibility_off' }}</mat-icon>
</button>
@if (form.controls.password.hasError('required')) {
<mat-error>Passwort ist erforderlich</mat-error>
} @else if (form.controls.password.hasError('minlength')) {
<mat-error>Mindestens 8 Zeichen</mat-error>
}
</mat-form-field>
<button mat-flat-button color="primary" type="submit" [disabled]="loading">
@if (loading) {
<mat-progress-spinner mode="indeterminate" diameter="18" />
} @else {
<mat-icon aria-hidden="true">login</mat-icon>
}
Einloggen
</button>
<div class="divider-container">
<span class="divider-text">oder</span>
</div>
<button
mat-stroked-button
type="button"
[disabled]="resendingVerification || loading"
(click)="resendVerificationEmail()"
color="accent"
>
@if (resendingVerification) {
<mat-progress-spinner mode="indeterminate" diameter="18" />
} @else {
<mat-icon aria-hidden="true">mark_email_unread</mat-icon>
}
Verifizierungsmail erneut senden
</button>
</form>
<button
mat-flat-button
color="primary"
type="button"
class="sso-login-button"
[disabled]="loading"
(click)="loginWithSso()"
>
@if (loading) {
<mat-progress-spinner mode="indeterminate" diameter="18" />
} @else {
<mat-icon aria-hidden="true">login</mat-icon>
}
Mit SSO anmelden
</button>
</mat-card-content>
<mat-card-actions>
<span>Neu hier?</span>
<a mat-flat-button routerLink="/register" color="primary" class="register-link">
Konto erstellen
</a>
</mat-card-actions>
</mat-card>
</section>

View File

@@ -1,45 +1,21 @@
import { Component, inject, OnInit } from '@angular/core';
import { NonNullableFormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { Router, RouterLink } from '@angular/router';
import { finalize } from 'rxjs';
import { Router } from '@angular/router';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { AuthService } from '../auth.service';
import { getAuthErrorMessage } from '../error-message';
import { OnboardingService } from '../../onboarding/onboarding.service';
@Component({
selector: 'app-login',
imports: [
ReactiveFormsModule,
RouterLink,
MatButtonModule,
MatCardModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
MatProgressSpinnerModule,
MatSnackBarModule,
],
imports: [MatButtonModule, MatCardModule, MatIconModule, MatProgressSpinnerModule],
templateUrl: './login.component.html',
styleUrl: '../auth-page.scss',
})
export class LoginComponent implements OnInit {
private readonly auth = inject(AuthService);
private readonly formBuilder = inject(NonNullableFormBuilder);
private readonly router = inject(Router);
private readonly snackBar = inject(MatSnackBar);
private readonly onboarding = inject(OnboardingService);
protected readonly form = this.formBuilder.group({
email: ['', [Validators.required, Validators.email]],
password: ['', [Validators.required, Validators.minLength(8)]],
});
protected loading = false;
ngOnInit(): void {
@@ -47,51 +23,9 @@ export class LoginComponent implements OnInit {
void this.router.navigateByUrl('/lists');
}
}
protected resendingVerification = false;
protected hidePassword = true;
submit(): void {
if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}
loginWithSso(): void {
this.loading = true;
this.auth
.login(this.form.getRawValue())
.pipe(finalize(() => (this.loading = false)))
.subscribe({
next: () => {
this.snackBar.open('Login erfolgreich.', 'OK', { duration: 3000 });
if (!this.onboarding.startForCurrentUser()) {
void this.router.navigateByUrl('/account');
}
},
error: (error: unknown) => {
this.snackBar.open(getAuthErrorMessage(error), 'OK', { duration: 5000 });
},
});
}
resendVerificationEmail(): void {
const emailControl = this.form.controls.email;
if (emailControl.invalid) {
emailControl.markAsTouched();
return;
}
this.resendingVerification = true;
this.auth
.resendVerificationEmail(emailControl.value)
.pipe(finalize(() => (this.resendingVerification = false)))
.subscribe({
next: (response) => {
this.snackBar.open(response.message, 'OK', { duration: 6000 });
},
error: (error: unknown) => {
this.snackBar.open(getAuthErrorMessage(error), 'OK', { duration: 5000 });
},
});
this.auth.startSsoLogin();
}
}

View File

@@ -0,0 +1,22 @@
<section class="auth-page">
<mat-card class="auth-card">
<div class="auth-logo">
<mat-icon>{{ failed ? 'error' : 'login' }}</mat-icon>
</div>
<mat-card-header>
<mat-card-title>
{{ failed ? 'Anmeldung fehlgeschlagen' : 'Anmeldung wird abgeschlossen' }}
</mat-card-title>
</mat-card-header>
@if (!failed) {
<mat-card-content>
<mat-progress-spinner mode="indeterminate" diameter="32" />
</mat-card-content>
} @else if (errorMessage) {
<mat-card-content>
<p>{{ errorMessage }}</p>
</mat-card-content>
}
</mat-card>
</section>

View File

@@ -0,0 +1,115 @@
import { Component, OnInit, inject } from '@angular/core';
import { HttpErrorResponse } from '@angular/common/http';
import { Router } from '@angular/router';
import { finalize } from 'rxjs';
import { MatCardModule } from '@angular/material/card';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { AuthTokenResponse } from '../auth.models';
import { AuthService } from '../auth.service';
import { OnboardingService } from '../../onboarding/onboarding.service';
@Component({
selector: 'app-sso-callback',
imports: [MatCardModule, MatIconModule, MatProgressSpinnerModule],
templateUrl: './sso-callback.component.html',
styleUrl: '../auth-page.scss',
})
export class SsoCallbackComponent implements OnInit {
private readonly auth = inject(AuthService);
private readonly onboarding = inject(OnboardingService);
private readonly router = inject(Router);
protected failed = false;
protected loading = false;
protected errorMessage = '';
ngOnInit(): void {
if (this.exchangeAuthorizationCode()) {
return;
}
const response = this.readAuthResponse();
if (!response) {
this.failed = true;
this.errorMessage = 'Die SSO-Antwort war unvollstaendig.';
window.setTimeout(() => void this.router.navigateByUrl('/login'), 2000);
return;
}
this.auth.completeSsoLogin(response);
if (!this.onboarding.startForCurrentUser()) {
void this.router.navigateByUrl('/account');
}
}
private exchangeAuthorizationCode(): boolean {
const params = new URLSearchParams(window.location.search);
const code = params.get('code');
const state = params.get('state');
if (!code || !state) {
return false;
}
this.loading = true;
this.auth
.exchangeSsoCode(code, state)
.pipe(finalize(() => (this.loading = false)))
.subscribe({
next: () => this.completeLoginNavigation(),
error: (error: unknown) => {
this.failed = true;
this.errorMessage = this.toErrorMessage(error);
window.setTimeout(() => void this.router.navigateByUrl('/login'), 3000);
},
});
return true;
}
private completeLoginNavigation(): void {
window.history.replaceState(null, '', '/auth/sso/callback');
if (!this.onboarding.startForCurrentUser()) {
void this.router.navigateByUrl('/account');
}
}
private readAuthResponse(): AuthTokenResponse | null {
const params = new URLSearchParams(window.location.hash.replace(/^#/, ''));
const accessToken = params.get('accessToken');
const refreshToken = params.get('refreshToken');
const userJson = params.get('user');
if (!accessToken || !refreshToken || !userJson) {
return null;
}
try {
return {
accessToken,
refreshToken,
user: JSON.parse(userJson) as AuthTokenResponse['user'],
};
} catch {
return null;
}
}
private toErrorMessage(error: unknown): string {
if (error instanceof HttpErrorResponse) {
const responseError = error.error as { message?: unknown } | null;
if (typeof responseError?.message === 'string') {
return responseError.message;
}
return error.message;
}
return 'Die SSO-Anmeldung konnte nicht abgeschlossen werden.';
}
}

View File

@@ -9,29 +9,17 @@
</mat-card-header>
<mat-card-content>
<div class="verification-state" [class.success]="state() === 'success'" [class.error]="state() === 'error' || state() === 'missing-token'">
@if (state() === 'loading') {
<mat-progress-spinner mode="indeterminate" diameter="44" />
} @else if (state() === 'success') {
<mat-icon class="state-icon" aria-hidden="true">mark_email_read</mat-icon>
} @else {
<mat-icon class="state-icon" aria-hidden="true">error</mat-icon>
}
<div class="verification-state success">
<mat-icon class="state-icon" aria-hidden="true">mark_email_read</mat-icon>
<p>{{ message() }}</p>
</div>
</mat-card-content>
<mat-card-actions align="end">
@if (state() === 'success') {
<a mat-flat-button routerLink="/login">
<mat-icon aria-hidden="true">login</mat-icon>
Zum Login
</a>
} @else if (state() !== 'loading') {
<a mat-button routerLink="/register">Neu registrieren</a>
<a mat-flat-button routerLink="/login">Zum Login</a>
}
<a mat-flat-button routerLink="/login">
<mat-icon aria-hidden="true">login</mat-icon>
Zum Login
</a>
</mat-card-actions>
</mat-card>
</section>

View File

@@ -1,13 +1,9 @@
import { Component, OnInit, inject, signal } from '@angular/core';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { Router, RouterLink } from '@angular/router';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { AuthService } from '../auth.service';
import { getAuthErrorMessage } from '../error-message';
type VerificationState = 'loading' | 'success' | 'error' | 'missing-token';
@Component({
selector: 'app-verify-email',
@@ -16,18 +12,15 @@ type VerificationState = 'loading' | 'success' | 'error' | 'missing-token';
MatButtonModule,
MatCardModule,
MatIconModule,
MatProgressSpinnerModule,
],
templateUrl: './verify-email.component.html',
styleUrl: '../auth-page.scss',
})
export class VerifyEmailComponent implements OnInit {
private readonly auth = inject(AuthService);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
protected readonly state = signal<VerificationState>('loading');
protected readonly message = signal('E-Mail wird bestätigt.');
protected readonly message = signal('E-Mail-Verifikation ist nicht mehr erforderlich.');
protected readonly email = signal<string | null>(null);
ngOnInit(): void {
@@ -35,25 +28,5 @@ export class VerifyEmailComponent implements OnInit {
void this.router.navigateByUrl('/lists');
return;
}
const token = this.route.snapshot.queryParamMap.get('token');
if (!token) {
this.state.set('missing-token');
this.message.set('Der Verifikationslink enthält keinen Token.');
return;
}
this.auth.verifyEmail(token).subscribe({
next: (response) => {
this.email.set(response.user.email);
this.message.set(response.message);
this.state.set('success');
},
error: (error: unknown) => {
this.message.set(getAuthErrorMessage(error));
this.state.set('error');
},
});
}
}