diff --git a/listify-api/src/audit/audit-log.types.ts b/listify-api/src/audit/audit-log.types.ts index 22edba7..8413f37 100644 --- a/listify-api/src/audit/audit-log.types.ts +++ b/listify-api/src/audit/audit-log.types.ts @@ -6,6 +6,8 @@ export type AuditAction = | 'user.login_failed' | 'user.token_refreshed' | 'user.onboarding_updated' + | 'user.mcp_api_key_created' + | 'user.mcp_api_key_revoked' | 'template.created' | 'template.created_from_list' | 'template.updated' diff --git a/listify-api/src/auth/auth.controller.ts b/listify-api/src/auth/auth.controller.ts index eca413b..6a47f33 100644 --- a/listify-api/src/auth/auth.controller.ts +++ b/listify-api/src/auth/auth.controller.ts @@ -1,6 +1,7 @@ import { Body, Controller, + Delete, Get, HttpCode, HttpStatus, @@ -55,6 +56,24 @@ export class AuthController { return this.authService.getPublicUser(request.user!.sub); } + @Get('me/mcp-api-key') + @UseGuards(JwtAuthGuard) + getMcpApiKeyStatus(@Req() request: AuthenticatedRequest) { + return this.authService.getMcpApiKeyStatus(request.user!.sub); + } + + @Post('me/mcp-api-key') + @UseGuards(JwtAuthGuard) + createMcpApiKey(@Req() request: AuthenticatedRequest) { + return this.authService.createMcpApiKey(request.user!.sub); + } + + @Delete('me/mcp-api-key') + @UseGuards(JwtAuthGuard) + revokeMcpApiKey(@Req() request: AuthenticatedRequest) { + return this.authService.revokeMcpApiKey(request.user!.sub); + } + @Get('users/search') @UseGuards(JwtAuthGuard) searchUsers( diff --git a/listify-api/src/auth/auth.module.ts b/listify-api/src/auth/auth.module.ts index 02bdbd1..5690d1c 100644 --- a/listify-api/src/auth/auth.module.ts +++ b/listify-api/src/auth/auth.module.ts @@ -6,6 +6,7 @@ import { AuthController } from './auth.controller'; import { RefreshTokenEntity } from './refresh-token.entity'; import { AuthService } from './auth.service'; import { JwtAuthGuard } from './jwt-auth.guard'; +import { McpAuthGuard } from './mcp-auth.guard'; import { UserEntity } from './user.entity'; @Module({ @@ -15,7 +16,7 @@ import { UserEntity } from './user.entity'; TypeOrmModule.forFeature([UserEntity, RefreshTokenEntity]), ], controllers: [AuthController], - providers: [AuthService, JwtAuthGuard], - exports: [AuthService, JwtAuthGuard], + providers: [AuthService, JwtAuthGuard, McpAuthGuard], + exports: [AuthService, JwtAuthGuard, McpAuthGuard], }) export class AuthModule {} diff --git a/listify-api/src/auth/auth.service.spec.ts b/listify-api/src/auth/auth.service.spec.ts index dd278bb..9e60a65 100644 --- a/listify-api/src/auth/auth.service.spec.ts +++ b/listify-api/src/auth/auth.service.spec.ts @@ -2,7 +2,7 @@ import { EventEmitterModule } from '@nestjs/event-emitter'; import { JwtModule, JwtService } from '@nestjs/jwt'; import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; -import { JwtTokenPayload } from './auth.types'; +import { AuthTokenResponse, JwtTokenPayload } from './auth.types'; import { AuthService } from './auth.service'; import { MailModule } from '../mail/mail.module'; import { MailService } from '../mail/mail.service'; @@ -18,7 +18,11 @@ describe('AuthService', () => { beforeEach(async () => { module = await Test.createTestingModule({ - imports: [EventEmitterModule.forRoot(), JwtModule.register({}), MailModule], + imports: [ + EventEmitterModule.forRoot(), + JwtModule.register({}), + MailModule, + ], providers: [ AuthService, { @@ -169,7 +173,9 @@ describe('AuthService', () => { email: 'user@example.com', password: 'password123', }); - const payload = await authService.verifyAccessToken(loginResponse.accessToken); + const payload = await authService.verifyAccessToken( + loginResponse.accessToken, + ); expect(payload.type).toBe('access'); expect(payload.email).toBe('user@example.com'); @@ -178,6 +184,56 @@ describe('AuthService', () => { ).rejects.toThrow('Access token is invalid.'); }); + it('creates and validates a persistent MCP API key', async () => { + const loginResponse = await registerVerifiedUserAndLogin(); + const apiKeyResponse = await authService.createMcpApiKey( + loginResponse.user.id, + ); + const status = await authService.getMcpApiKeyStatus(loginResponse.user.id); + const payload = await authService.verifyMcpCredential( + apiKeyResponse.apiKey, + ); + + expect(apiKeyResponse.apiKey).toMatch(/^lfy_mcp_/); + expect(apiKeyResponse.createdAt).toBeDefined(); + expect(status.createdAt).toBe(apiKeyResponse.createdAt); + expect(payload.type).toBe('mcp_api_key'); + expect(payload.sub).toBe(loginResponse.user.id); + expect(payload.email).toBe('user@example.com'); + }); + + it('rotates MCP API keys and rejects the old key', async () => { + const loginResponse = await registerVerifiedUserAndLogin(); + const firstKey = await authService.createMcpApiKey(loginResponse.user.id); + const secondKey = await authService.createMcpApiKey(loginResponse.user.id); + + expect(secondKey.apiKey).not.toBe(firstKey.apiKey); + await expect(authService.verifyMcpApiKey(firstKey.apiKey)).rejects.toThrow( + 'MCP API key is invalid.', + ); + await expect( + authService.verifyMcpApiKey(secondKey.apiKey), + ).resolves.toMatchObject({ + sub: loginResponse.user.id, + type: 'mcp_api_key', + }); + }); + + it('revokes MCP API keys', async () => { + const loginResponse = await registerVerifiedUserAndLogin(); + const apiKeyResponse = await authService.createMcpApiKey( + loginResponse.user.id, + ); + const revokedStatus = await authService.revokeMcpApiKey( + loginResponse.user.id, + ); + + expect(revokedStatus.createdAt).toBeUndefined(); + await expect( + authService.verifyMcpApiKey(apiKeyResponse.apiKey), + ).rejects.toThrow('MCP API key is invalid.'); + }); + it('rejects duplicate registrations', async () => { await authService.register({ email: 'user@example.com', @@ -191,4 +247,20 @@ describe('AuthService', () => { }), ).rejects.toThrow('Email is already registered.'); }); + + async function registerVerifiedUserAndLogin(): Promise { + await authService.register({ + email: 'user@example.com', + password: 'password123', + }); + + const verificationUrl = mailService.getSentEmails()[0].verificationUrl; + const token = new URL(verificationUrl).searchParams.get('token'); + await authService.verifyEmail(token ?? undefined); + + return authService.login({ + email: 'user@example.com', + password: 'password123', + }); + } }); diff --git a/listify-api/src/auth/auth.service.ts b/listify-api/src/auth/auth.service.ts index 78aacdd..84c829d 100644 --- a/listify-api/src/auth/auth.service.ts +++ b/listify-api/src/auth/auth.service.ts @@ -8,7 +8,13 @@ import { import { EventEmitter2 } from '@nestjs/event-emitter'; import { JwtService } from '@nestjs/jwt'; import { InjectRepository } from '@nestjs/typeorm'; -import { randomBytes, randomUUID, scryptSync, timingSafeEqual } from 'crypto'; +import { + createHash, + randomBytes, + randomUUID, + scryptSync, + timingSafeEqual, +} from 'crypto'; import { Like, Repository } from 'typeorm'; import { AuditLogService } from '../audit/audit-log.service'; import { LoginDto } from './dto/login.dto'; @@ -19,6 +25,8 @@ import { AuthTokenResponse, AuthTokens, JwtTokenPayload, + McpApiKeyResponse, + McpApiKeyStatus, PublicUser, PublicUserSearchResult, } from './auth.types'; @@ -30,6 +38,7 @@ import { UserEntity } from './user.entity'; export class AuthService { private readonly accessTokenExpiresIn = '7d'; private readonly refreshTokenExpiresIn = '30d'; + private readonly mcpApiKeyPrefix = 'lfy_mcp_'; private readonly accessTokenSecret = process.env.JWT_ACCESS_SECRET ?? 'dev-access-secret'; private readonly refreshTokenSecret = @@ -125,7 +134,7 @@ export class AuthService { user: this.toPublicUser(savedUser), }; } catch { - throw new BadRequestException('user not saved.') + throw new BadRequestException('user not saved.'); } } @@ -204,7 +213,9 @@ export class AuthService { return response; } - async refresh(refreshTokenDto: RefreshTokenDto = {}): Promise { + async refresh( + refreshTokenDto: RefreshTokenDto = {}, + ): Promise { const refreshToken = this.requireRefreshToken(refreshTokenDto.refreshToken); const payload = this.verifyRefreshToken(refreshToken); const tokenRecord = await this.refreshTokensRepository.findOne({ @@ -270,6 +281,35 @@ export class AuthService { } } + async verifyMcpCredential(token: string): Promise { + if (token.startsWith(this.mcpApiKeyPrefix)) { + return this.verifyMcpApiKey(token); + } + + return this.verifyAccessToken(token); + } + + async verifyMcpApiKey(apiKey: string): Promise { + const apiKeyHash = this.hashApiKey(apiKey); + const user = await this.usersRepository.findOne({ + where: { mcpApiKeyHash: apiKeyHash }, + }); + + if ( + !user || + !user.verified || + !this.apiKeyHashMatches(apiKey, apiKeyHash) + ) { + throw new UnauthorizedException('MCP API key is invalid.'); + } + + return { + sub: user.id, + email: user.email, + type: 'mcp_api_key', + }; + } + async getUserDisplayName(userId: string): Promise { const user = await this.usersRepository.findOne({ where: { id: userId }, @@ -294,6 +334,57 @@ export class AuthService { return this.toPublicUser(user); } + async getMcpApiKeyStatus(userId: string): Promise { + const user = await this.requireUser(userId); + + return this.toMcpApiKeyStatus(user); + } + + async createMcpApiKey(userId: string): Promise { + const user = await this.requireUser(userId); + const hadExistingKey = Boolean(user.mcpApiKeyHash); + const apiKey = `${this.mcpApiKeyPrefix}${randomBytes(32).toString('base64url')}`; + const createdAt = new Date(); + + user.mcpApiKeyHash = this.hashApiKey(apiKey); + user.mcpApiKeyCreatedAt = createdAt; + + const savedUser = await this.usersRepository.save(user); + + await this.auditLogService?.record({ + actorUserId: savedUser.id, + actorEmail: savedUser.email, + action: 'user.mcp_api_key_created', + entityType: 'user', + entityId: savedUser.id, + metadata: { rotated: hadExistingKey }, + }); + + return { + apiKey, + ...this.toMcpApiKeyStatus(savedUser), + }; + } + + async revokeMcpApiKey(userId: string): Promise { + const user = await this.requireUser(userId); + + user.mcpApiKeyHash = null; + user.mcpApiKeyCreatedAt = null; + + const savedUser = await this.usersRepository.save(user); + + await this.auditLogService?.record({ + actorUserId: savedUser.id, + actorEmail: savedUser.email, + action: 'user.mcp_api_key_revoked', + entityType: 'user', + entityId: savedUser.id, + }); + + return this.toMcpApiKeyStatus(savedUser); + } + async searchUsers( actorUserId: string, query?: string, @@ -357,6 +448,18 @@ export class AuthService { return this.toPublicUser(savedUser); } + private async requireUser(userId: string): Promise { + const user = await this.usersRepository.findOne({ + where: { id: userId }, + }); + + if (!user) { + throw new UnauthorizedException('Authenticated user is required.'); + } + + return user; + } + private normalizeEmail(email?: string): string { const normalizedEmail = email?.trim().toLowerCase(); @@ -477,6 +580,20 @@ export class AuthService { return this.passwordMatches(token, tokenHash); } + private hashApiKey(apiKey: string): string { + return createHash('sha256').update(apiKey).digest('hex'); + } + + private apiKeyHashMatches(apiKey: string, apiKeyHash: string): boolean { + const expectedHashBuffer = Buffer.from(apiKeyHash, 'hex'); + const attemptedHashBuffer = Buffer.from(this.hashApiKey(apiKey), 'hex'); + + return ( + expectedHashBuffer.length === attemptedHashBuffer.length && + timingSafeEqual(expectedHashBuffer, attemptedHashBuffer) + ); + } + private createToken(): string { return randomBytes(32).toString('hex'); } @@ -495,4 +612,10 @@ export class AuthService { onboardingCompleted: user.onboardingCompleted === true, }; } + + private toMcpApiKeyStatus(user: UserEntity): McpApiKeyStatus { + return user.mcpApiKeyHash && user.mcpApiKeyCreatedAt + ? { createdAt: user.mcpApiKeyCreatedAt.toISOString() } + : {}; + } } diff --git a/listify-api/src/auth/auth.types.ts b/listify-api/src/auth/auth.types.ts index eb4110f..e443eb3 100644 --- a/listify-api/src/auth/auth.types.ts +++ b/listify-api/src/auth/auth.types.ts @@ -22,7 +22,7 @@ export interface AuthTokenResponse extends AuthTokens { export interface JwtTokenPayload { sub: string; email: string; - type: 'access' | 'refresh'; + type: 'access' | 'refresh' | 'mcp_api_key'; jti?: string; } @@ -43,3 +43,11 @@ export interface PublicUserSearchResult { email: string; name?: string; } + +export interface McpApiKeyStatus { + createdAt?: string; +} + +export interface McpApiKeyResponse extends McpApiKeyStatus { + apiKey: string; +} diff --git a/listify-api/src/auth/mcp-auth.guard.ts b/listify-api/src/auth/mcp-auth.guard.ts new file mode 100644 index 0000000..451361a --- /dev/null +++ b/listify-api/src/auth/mcp-auth.guard.ts @@ -0,0 +1,42 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { AuthenticatedRequest } from './auth.types'; +import { AuthService } from './auth.service'; + +@Injectable() +export class McpAuthGuard implements CanActivate { + constructor(private readonly authService: AuthService) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const token = this.extractBearerToken(request); + + request.user = await this.authService.verifyMcpCredential(token); + + return true; + } + + private extractBearerToken(request: AuthenticatedRequest): string { + const authorizationHeader = request.headers.authorization; + + if (!authorizationHeader) { + throw new UnauthorizedException( + 'Authorization bearer token is required.', + ); + } + + const [scheme, token] = authorizationHeader.split(' '); + + if (scheme.toLowerCase() !== 'bearer' || !token) { + throw new UnauthorizedException( + 'Authorization bearer token is required.', + ); + } + + return token; + } +} diff --git a/listify-api/src/auth/user.entity.ts b/listify-api/src/auth/user.entity.ts index 03e4223..1a58243 100644 --- a/listify-api/src/auth/user.entity.ts +++ b/listify-api/src/auth/user.entity.ts @@ -38,6 +38,13 @@ export class UserEntity { @Column({ type: 'boolean', default: false }) onboardingCompleted!: boolean; + @Index('IDX_users_mcp_api_key_hash', { unique: true }) + @Column({ type: 'varchar', length: 64, nullable: true }) + mcpApiKeyHash?: string | null; + + @Column({ type: 'datetime', precision: 3, nullable: true }) + mcpApiKeyCreatedAt?: Date | null; + @CreateDateColumn({ type: 'datetime', precision: 3, diff --git a/listify-api/src/database/migrations/1781600000000-AddMcpApiKeyToUsers.ts b/listify-api/src/database/migrations/1781600000000-AddMcpApiKeyToUsers.ts new file mode 100644 index 0000000..ac607f0 --- /dev/null +++ b/listify-api/src/database/migrations/1781600000000-AddMcpApiKeyToUsers.ts @@ -0,0 +1,45 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddMcpApiKeyToUsers1781600000000 implements MigrationInterface { + name = 'AddMcpApiKeyToUsers1781600000000'; + + public async up(queryRunner: QueryRunner): Promise { + if (!(await queryRunner.hasTable('users'))) { + return; + } + + if (!(await queryRunner.hasColumn('users', 'mcpApiKeyHash'))) { + await queryRunner.query( + 'ALTER TABLE `users` ADD `mcpApiKeyHash` varchar(64) NULL', + ); + await queryRunner.query( + 'CREATE UNIQUE INDEX `IDX_users_mcp_api_key_hash` ON `users` (`mcpApiKeyHash`)', + ); + } + + if (!(await queryRunner.hasColumn('users', 'mcpApiKeyCreatedAt'))) { + await queryRunner.query( + 'ALTER TABLE `users` ADD `mcpApiKeyCreatedAt` datetime(3) NULL', + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + if (!(await queryRunner.hasTable('users'))) { + return; + } + + if (await queryRunner.hasColumn('users', 'mcpApiKeyHash')) { + await queryRunner.query( + 'DROP INDEX `IDX_users_mcp_api_key_hash` ON `users`', + ); + await queryRunner.query('ALTER TABLE `users` DROP COLUMN `mcpApiKeyHash`'); + } + + if (await queryRunner.hasColumn('users', 'mcpApiKeyCreatedAt')) { + await queryRunner.query( + 'ALTER TABLE `users` DROP COLUMN `mcpApiKeyCreatedAt`', + ); + } + } +} diff --git a/listify-api/src/mcp/mcp.controller.ts b/listify-api/src/mcp/mcp.controller.ts index 5246aff..7d34fa5 100644 --- a/listify-api/src/mcp/mcp.controller.ts +++ b/listify-api/src/mcp/mcp.controller.ts @@ -14,7 +14,7 @@ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/ import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js'; import { randomUUID } from 'crypto'; import type { Response } from 'express'; -import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { McpAuthGuard } from '../auth/mcp-auth.guard'; import { McpServerService } from './mcp-server.service'; import type { AuthenticatedRequest } from '../auth/auth.types'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; @@ -26,7 +26,7 @@ interface McpSession { } @Controller('mcp') -@UseGuards(JwtAuthGuard) +@UseGuards(McpAuthGuard) export class McpController { private readonly sessions = new Map(); diff --git a/listify-client/src/app/account/account.component.html b/listify-client/src/app/account/account.component.html index 9c50540..081a045 100644 --- a/listify-client/src/app/account/account.component.html +++ b/listify-client/src/app/account/account.component.html @@ -17,6 +17,68 @@ {{ auth.user()?.onboardingCompleted ? 'Onboarding abgeschlossen' : 'Onboarding offen' }} + +
+
+
+

MCP Connector

+

+ @if (mcpApiKeyLoading()) { + Status wird geladen + } @else if (mcpApiKeyCreatedAt()) { + Key aktiv seit {{ formatDate(mcpApiKeyCreatedAt()!) }} + } @else { + Kein MCP-Key aktiv + } +

+
+ +
+ + @if (generatedMcpApiKey()) { +
+ {{ generatedMcpApiKey() }} + +
+

Der Key wird nur jetzt angezeigt.

+ } + + @if (mcpApiKeyMessage()) { +

{{ mcpApiKeyMessage() }}

+ } + +
+ + +
+
diff --git a/listify-client/src/app/account/account.component.scss b/listify-client/src/app/account/account.component.scss index 635383c..f7da6f6 100644 --- a/listify-client/src/app/account/account.component.scss +++ b/listify-client/src/app/account/account.component.scss @@ -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; diff --git a/listify-client/src/app/account/account.component.ts b/listify-client/src/app/account/account.component.ts index 59ea2d2..dd87198 100644 --- a/listify-client/src/app/account/account.component.ts +++ b/listify-client/src/app/account/account.component.ts @@ -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(null); + protected readonly generatedMcpApiKey = signal(null); + protected readonly mcpApiKeyLoading = signal(false); + protected readonly mcpApiKeySaving = signal(false); + protected readonly mcpApiKeyMessage = signal(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.'); + }, + }); + } } diff --git a/listify-client/src/app/auth/auth.models.ts b/listify-client/src/app/auth/auth.models.ts index a41befe..54c67f7 100644 --- a/listify-client/src/app/auth/auth.models.ts +++ b/listify-client/src/app/auth/auth.models.ts @@ -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; diff --git a/listify-client/src/app/auth/auth.service.ts b/listify-client/src/app/auth/auth.service.ts index 1823f1d..8484561 100644 --- a/listify-client/src/app/auth/auth.service.ts +++ b/listify-client/src/app/auth/auth.service.ts @@ -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 { - return this.http.post( - `${this.apiUrl}/resend-verification`, - { email }, - ); + return this.http.post(`${this.apiUrl}/resend-verification`, { + email, + }); } loadCurrentUser(): Observable { - return this.http - .get(`${this.apiUrl}/me`) - .pipe(tap((user) => this.storeUser(user))); + return this.http.get(`${this.apiUrl}/me`).pipe(tap((user) => this.storeUser(user))); } searchUsers(query: string): Observable { @@ -67,6 +66,18 @@ export class AuthService { .pipe(tap((user) => this.storeUser(user))); } + getMcpApiKeyStatus(): Observable { + return this.http.get(`${this.apiUrl}/me/mcp-api-key`); + } + + createMcpApiKey(): Observable { + return this.http.post(`${this.apiUrl}/me/mcp-api-key`, {}); + } + + revokeMcpApiKey(): Observable { + return this.http.delete(`${this.apiUrl}/me/mcp-api-key`); + } + accessToken(): string | null { return this.storage?.getItem(ACCESS_TOKEN_KEY) ?? null; }