This commit is contained in:
Bastian Wagner
2026-06-24 10:09:12 +02:00
parent 35613eddb6
commit fc61ef5ba9
15 changed files with 581 additions and 19 deletions

View File

@@ -17,6 +17,68 @@
{{ auth.user()?.onboardingCompleted ? 'Onboarding abgeschlossen' : 'Onboarding offen' }}
</span>
</div>
<div class="mcp-key-panel">
<div class="mcp-key-header">
<div>
<h2>MCP Connector</h2>
<p>
@if (mcpApiKeyLoading()) {
Status wird geladen
} @else if (mcpApiKeyCreatedAt()) {
Key aktiv seit {{ formatDate(mcpApiKeyCreatedAt()!) }}
} @else {
Kein MCP-Key aktiv
}
</p>
</div>
<mat-icon aria-hidden="true">key</mat-icon>
</div>
@if (generatedMcpApiKey()) {
<div class="generated-key">
<code>{{ generatedMcpApiKey() }}</code>
<button
mat-icon-button
type="button"
aria-label="MCP-Key kopieren"
(click)="copyMcpApiKey()"
>
<mat-icon aria-hidden="true">content_copy</mat-icon>
</button>
</div>
<p class="key-hint">Der Key wird nur jetzt angezeigt.</p>
}
@if (mcpApiKeyMessage()) {
<p class="mcp-key-message">{{ mcpApiKeyMessage() }}</p>
}
<div class="mcp-key-actions">
<button
mat-flat-button
type="button"
[disabled]="mcpApiKeyLoading() || mcpApiKeySaving()"
(click)="createMcpApiKey()"
>
@if (mcpApiKeySaving()) {
<mat-progress-spinner mode="indeterminate" diameter="18" />
} @else {
<mat-icon aria-hidden="true">vpn_key</mat-icon>
}
{{ mcpApiKeyCreatedAt() ? 'Key rotieren' : 'Key erzeugen' }}
</button>
<button
mat-stroked-button
type="button"
[disabled]="!mcpApiKeyCreatedAt() || mcpApiKeyLoading() || mcpApiKeySaving()"
(click)="revokeMcpApiKey()"
>
<mat-icon aria-hidden="true">block</mat-icon>
Widerrufen
</button>
</div>
</div>
</mat-card-content>
<mat-card-actions align="end">

View File

@@ -30,6 +30,73 @@
color: var(--mat-sys-primary);
}
.mcp-key-panel {
display: grid;
gap: 1rem;
margin-top: 1rem;
padding: 1rem;
border: 1px solid color-mix(in srgb, var(--mat-sys-outline-variant) 72%, transparent);
border-radius: 8px;
background: color-mix(in srgb, var(--mat-sys-surface-container-low) 44%, var(--mat-sys-surface));
}
.mcp-key-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.mcp-key-header h2 {
margin: 0;
font-size: 1rem;
font-weight: 600;
line-height: 1.3;
}
.mcp-key-header p,
.key-hint,
.mcp-key-message {
margin: 0.25rem 0 0;
color: var(--mat-sys-on-surface-variant);
font-size: 0.875rem;
line-height: 1.4;
}
.mcp-key-header mat-icon {
color: var(--mat-sys-primary);
}
.generated-key {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 0.5rem;
padding: 0.625rem;
border: 1px solid color-mix(in srgb, var(--mat-sys-outline-variant) 72%, transparent);
border-radius: 8px;
background: var(--mat-sys-surface);
}
.generated-key code {
min-width: 0;
overflow-wrap: anywhere;
color: var(--mat-sys-on-surface);
font-size: 0.8125rem;
line-height: 1.4;
}
.mcp-key-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.mcp-key-actions mat-progress-spinner {
display: inline-flex;
margin-right: 0.5rem;
}
mat-card-actions {
flex-wrap: wrap;
gap: 0.5rem;

View File

@@ -1,9 +1,11 @@
import { Component, inject } from '@angular/core';
import { Component, DestroyRef, inject, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { Router } 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 { finalize } from 'rxjs';
import { AuthService } from '../auth/auth.service';
import { OnboardingService } from '../onboarding/onboarding.service';
@@ -16,14 +18,107 @@ import { OnboardingService } from '../onboarding/onboarding.service';
export class AccountComponent {
protected readonly auth = inject(AuthService);
protected readonly onboarding = inject(OnboardingService);
protected readonly mcpApiKeyCreatedAt = signal<string | null>(null);
protected readonly generatedMcpApiKey = signal<string | null>(null);
protected readonly mcpApiKeyLoading = signal(false);
protected readonly mcpApiKeySaving = signal(false);
protected readonly mcpApiKeyMessage = signal<string | null>(null);
private readonly destroyRef = inject(DestroyRef);
private readonly router = inject(Router);
constructor() {
this.loadMcpApiKeyStatus();
}
resetOnboarding(): void {
this.onboarding.resetForCurrentUser();
}
createMcpApiKey(): void {
this.mcpApiKeySaving.set(true);
this.mcpApiKeyMessage.set(null);
this.generatedMcpApiKey.set(null);
this.auth
.createMcpApiKey()
.pipe(
takeUntilDestroyed(this.destroyRef),
finalize(() => this.mcpApiKeySaving.set(false)),
)
.subscribe({
next: (response) => {
this.mcpApiKeyCreatedAt.set(response.createdAt ?? null);
this.generatedMcpApiKey.set(response.apiKey);
this.mcpApiKeyMessage.set('MCP-Key wurde erzeugt.');
},
error: () => {
this.mcpApiKeyMessage.set('MCP-Key konnte nicht erzeugt werden.');
},
});
}
revokeMcpApiKey(): void {
this.mcpApiKeySaving.set(true);
this.mcpApiKeyMessage.set(null);
this.generatedMcpApiKey.set(null);
this.auth
.revokeMcpApiKey()
.pipe(
takeUntilDestroyed(this.destroyRef),
finalize(() => this.mcpApiKeySaving.set(false)),
)
.subscribe({
next: () => {
this.mcpApiKeyCreatedAt.set(null);
this.mcpApiKeyMessage.set('MCP-Key wurde widerrufen.');
},
error: () => {
this.mcpApiKeyMessage.set('MCP-Key konnte nicht widerrufen werden.');
},
});
}
copyMcpApiKey(): void {
const apiKey = this.generatedMcpApiKey();
if (!apiKey || typeof navigator === 'undefined' || !navigator.clipboard) {
return;
}
void navigator.clipboard.writeText(apiKey).then(() => {
this.mcpApiKeyMessage.set('MCP-Key wurde kopiert.');
});
}
logout(): void {
this.auth.logout();
void this.router.navigateByUrl('/login');
}
protected formatDate(value: string): string {
return new Date(value).toLocaleString('de-DE', {
dateStyle: 'medium',
timeStyle: 'short',
});
}
private loadMcpApiKeyStatus(): void {
this.mcpApiKeyLoading.set(true);
this.auth
.getMcpApiKeyStatus()
.pipe(
takeUntilDestroyed(this.destroyRef),
finalize(() => this.mcpApiKeyLoading.set(false)),
)
.subscribe({
next: (status) => {
this.mcpApiKeyCreatedAt.set(status.createdAt ?? null);
},
error: () => {
this.mcpApiKeyMessage.set('MCP-Key-Status konnte nicht geladen werden.');
},
});
}
}

View File

@@ -32,6 +32,14 @@ export interface ResendVerificationResponse {
message: string;
}
export interface McpApiKeyStatus {
createdAt?: string;
}
export interface McpApiKeyResponse extends McpApiKeyStatus {
apiKey: string;
}
export interface LoginRequest {
email: string;
password: string;

View File

@@ -4,6 +4,8 @@ import { Observable, finalize, shareReplay, tap, throwError } from 'rxjs';
import {
AuthTokenResponse,
LoginRequest,
McpApiKeyResponse,
McpApiKeyStatus,
PublicUser,
PublicUserSearchResult,
RegisterRequest,
@@ -42,16 +44,13 @@ export class AuthService {
}
resendVerificationEmail(email: string): Observable<ResendVerificationResponse> {
return this.http.post<ResendVerificationResponse>(
`${this.apiUrl}/resend-verification`,
{ email },
);
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)));
return this.http.get<PublicUser>(`${this.apiUrl}/me`).pipe(tap((user) => this.storeUser(user)));
}
searchUsers(query: string): Observable<PublicUserSearchResult[]> {
@@ -67,6 +66,18 @@ export class AuthService {
.pipe(tap((user) => this.storeUser(user)));
}
getMcpApiKeyStatus(): Observable<McpApiKeyStatus> {
return this.http.get<McpApiKeyStatus>(`${this.apiUrl}/me/mcp-api-key`);
}
createMcpApiKey(): Observable<McpApiKeyResponse> {
return this.http.post<McpApiKeyResponse>(`${this.apiUrl}/me/mcp-api-key`, {});
}
revokeMcpApiKey(): Observable<McpApiKeyStatus> {
return this.http.delete<McpApiKeyStatus>(`${this.apiUrl}/me/mcp-api-key`);
}
accessToken(): string | null {
return this.storage?.getItem(ACCESS_TOKEN_KEY) ?? null;
}