idp angepasst

This commit is contained in:
Bastian Wagner
2026-07-15 16:59:17 +02:00
parent 0dfba43aa6
commit fadb5dd049
59 changed files with 1954 additions and 367 deletions

View File

@@ -18,11 +18,11 @@
</span>
</div>
<section class="groups-section" aria-label="Keycloak Gruppen">
<section class="groups-section" aria-label="OIDC Gruppen">
<div class="settings-heading">
<mat-icon aria-hidden="true">groups</mat-icon>
<div>
<h2>Keycloak-Gruppen</h2>
<h2>OIDC-Gruppen</h2>
<p>{{ auth.user()?.groups?.length || 0 }} synchronisiert</p>
</div>
</div>
@@ -38,6 +38,44 @@
}
</section>
<section class="access-section" aria-label="App Rechte">
<div class="settings-heading">
<mat-icon aria-hidden="true">admin_panel_settings</mat-icon>
<div>
<h2>App-Rollen</h2>
<p>{{ auth.user()?.roles?.length || 0 }} aktiv</p>
</div>
</div>
@if (auth.user()?.roles?.length) {
<ul class="group-list">
@for (role of auth.user()?.roles ?? []; track role) {
<li>{{ role }}</li>
}
</ul>
} @else {
<p class="settings-description">Keine App-Rollen hinterlegt.</p>
}
<div class="settings-heading compact-heading">
<mat-icon aria-hidden="true">key</mat-icon>
<div>
<h2>App-Rechte</h2>
<p>{{ auth.user()?.permissions?.length || 0 }} aktiv</p>
</div>
</div>
@if (auth.user()?.permissions?.length) {
<ul class="group-list">
@for (permission of auth.user()?.permissions ?? []; track permission) {
<li>{{ permission }}</li>
}
</ul>
} @else {
<p class="settings-description">Keine App-Rechte hinterlegt.</p>
}
</section>
<section class="settings-section" aria-label="Task-Mail Einstellungen">
<div class="settings-heading">
<mat-icon aria-hidden="true">mark_email_unread</mat-icon>

View File

@@ -40,7 +40,8 @@
background: color-mix(in srgb, var(--mat-sys-surface-container-low) 36%, var(--mat-sys-surface));
}
.groups-section {
.groups-section,
.access-section {
display: grid;
gap: 0.8rem;
margin-top: 1rem;
@@ -50,6 +51,10 @@
background: color-mix(in srgb, var(--mat-sys-surface-container-low) 36%, var(--mat-sys-surface));
}
.compact-heading {
padding-top: 0.4rem;
}
.settings-heading {
display: flex;
gap: 0.75rem;

View File

@@ -1,5 +1,4 @@
import { Component, inject } from '@angular/core';
import { Router } from '@angular/router';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatIconModule } from '@angular/material/icon';
@@ -30,7 +29,6 @@ export class AccountComponent {
protected readonly auth = inject(AuthService);
protected readonly onboarding = inject(OnboardingService);
protected readonly taskPush = inject(TaskPushService);
private readonly router = inject(Router);
private readonly snackBar = inject(MatSnackBar);
protected savingTaskDigestPreference = false;
protected readonly taskDigestPreferenceOptions: ReadonlyArray<{
@@ -116,7 +114,6 @@ export class AccountComponent {
}
logout(): void {
this.auth.logout();
void this.router.navigateByUrl('/login');
this.auth.logoutThroughProvider();
}
}

View File

@@ -105,16 +105,18 @@
<mat-icon matListItemIcon aria-hidden="true">account_circle</mat-icon>
<span matListItemTitle>Account</span>
</a>
<a
mat-list-item
routerLink="/assistant/logs"
routerLinkActive="active-nav-link"
ariaCurrentWhenActive="page"
(click)="closeSidebarOnCompact()"
>
<mat-icon matListItemIcon aria-hidden="true">manage_search</mat-icon>
<span matListItemTitle>Assistant Logs</span>
</a>
@if (auth.hasPermission('assistant.logs.view')) {
<a
mat-list-item
routerLink="/assistant/logs"
routerLinkActive="active-nav-link"
ariaCurrentWhenActive="page"
(click)="closeSidebarOnCompact()"
>
<mat-icon matListItemIcon aria-hidden="true">manage_search</mat-icon>
<span matListItemTitle>Assistant Logs</span>
</a>
}
</mat-nav-list>
</nav>
</mat-sidenav>
@@ -128,7 +130,9 @@
</mat-sidenav-content>
</mat-sidenav-container>
<app-assistant-chat />
@if (auth.hasPermission('assistant.chat')) {
<app-assistant-chat />
}
<nav class="bottom-nav" aria-label="Mobile Hauptnavigation">
<a

View File

@@ -70,6 +70,7 @@ function isAuthRequest(request: HttpRequest<unknown>): boolean {
'/api/auth/register',
'/api/auth/sso',
'/api/auth/refresh',
'/api/auth/logout',
'/api/auth/resend-verification',
'/api/auth/verify-email',
].some((publicAuthUrl) => request.url.startsWith(publicAuthUrl));

View File

@@ -7,6 +7,8 @@ export interface PublicUser {
onboardingCompleted: boolean;
taskDigestPreference: TaskDigestPreference;
groups: string[];
roles: string[];
permissions: string[];
}
export interface PublicUserSearchResult {
@@ -18,9 +20,14 @@ export interface PublicUserSearchResult {
export interface AuthTokenResponse {
accessToken: string;
refreshToken: string;
idToken?: string;
user: PublicUser;
}
export interface AuthLogoutResponse {
logoutUrl: string;
}
export interface RegisterResponse {
message: string;
user: PublicUser;

View File

@@ -2,6 +2,7 @@ import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable, computed, inject, signal } from '@angular/core';
import { Observable, finalize, shareReplay, tap, throwError } from 'rxjs';
import {
AuthLogoutResponse,
AuthTokenResponse,
LoginRequest,
PublicUser,
@@ -13,6 +14,7 @@ import {
const ACCESS_TOKEN_KEY = 'listify.accessToken';
const REFRESH_TOKEN_KEY = 'listify.refreshToken';
const ID_TOKEN_KEY = 'listify.idToken';
const USER_KEY = 'listify.user';
@Injectable({ providedIn: 'root' })
@@ -82,6 +84,18 @@ export class AuthService {
return this.storage?.getItem(REFRESH_TOKEN_KEY) ?? null;
}
idToken(): string | null {
return this.storage?.getItem(ID_TOKEN_KEY) ?? null;
}
hasPermission(permission: string): boolean {
return this.userSignal()?.permissions.includes(permission) === true;
}
hasAnyPermission(permissions: string[]): boolean {
return permissions.some((permission) => this.hasPermission(permission));
}
refreshSession(): Observable<AuthTokenResponse> {
const refreshToken = this.refreshToken();
@@ -103,8 +117,31 @@ export class AuthService {
}
logout(): void {
this.clearSession();
}
logoutThroughProvider(): void {
const payload = {
refreshToken: this.refreshToken(),
idTokenHint: this.idToken(),
};
this.http.post<AuthLogoutResponse>(`${this.apiUrl}/logout`, payload).subscribe({
next: (response) => {
this.clearSession();
window.location.href = response.logoutUrl;
},
error: () => {
this.clearSession();
window.location.href = '/login';
},
});
}
private clearSession(): void {
this.storage?.removeItem(ACCESS_TOKEN_KEY);
this.storage?.removeItem(REFRESH_TOKEN_KEY);
this.storage?.removeItem(ID_TOKEN_KEY);
this.storage?.removeItem(USER_KEY);
this.userSignal.set(null);
}
@@ -112,6 +149,11 @@ export class AuthService {
private storeSession(response: AuthTokenResponse): void {
this.storage?.setItem(ACCESS_TOKEN_KEY, response.accessToken);
this.storage?.setItem(REFRESH_TOKEN_KEY, response.refreshToken);
if (response.idToken) {
this.storage?.setItem(ID_TOKEN_KEY, response.idToken);
} else {
this.storage?.removeItem(ID_TOKEN_KEY);
}
this.storeUser(response.user);
}
@@ -140,6 +182,8 @@ export class AuthService {
return {
...user,
groups: Array.isArray(user.groups) ? user.groups : [],
roles: Array.isArray(user.roles) ? user.roles : [],
permissions: Array.isArray(user.permissions) ? user.permissions : [],
};
}

View File

@@ -82,6 +82,7 @@ export class SsoCallbackComponent implements OnInit {
const params = new URLSearchParams(window.location.hash.replace(/^#/, ''));
const accessToken = params.get('accessToken');
const refreshToken = params.get('refreshToken');
const idToken = params.get('idToken') ?? undefined;
const userJson = params.get('user');
if (!accessToken || !refreshToken || !userJson) {
@@ -92,6 +93,7 @@ export class SsoCallbackComponent implements OnInit {
return {
accessToken,
refreshToken,
idToken,
user: JSON.parse(userJson) as AuthTokenResponse['user'],
};
} catch {