mcp key
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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<AuthTokenResponse> {
|
||||
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',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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<AuthTokenResponse> {
|
||||
async refresh(
|
||||
refreshTokenDto: RefreshTokenDto = {},
|
||||
): Promise<AuthTokenResponse> {
|
||||
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<JwtTokenPayload> {
|
||||
if (token.startsWith(this.mcpApiKeyPrefix)) {
|
||||
return this.verifyMcpApiKey(token);
|
||||
}
|
||||
|
||||
return this.verifyAccessToken(token);
|
||||
}
|
||||
|
||||
async verifyMcpApiKey(apiKey: string): Promise<JwtTokenPayload> {
|
||||
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<string> {
|
||||
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<McpApiKeyStatus> {
|
||||
const user = await this.requireUser(userId);
|
||||
|
||||
return this.toMcpApiKeyStatus(user);
|
||||
}
|
||||
|
||||
async createMcpApiKey(userId: string): Promise<McpApiKeyResponse> {
|
||||
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<McpApiKeyStatus> {
|
||||
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<UserEntity> {
|
||||
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() }
|
||||
: {};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
42
listify-api/src/auth/mcp-auth.guard.ts
Normal file
42
listify-api/src/auth/mcp-auth.guard.ts
Normal file
@@ -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<boolean> {
|
||||
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddMcpApiKeyToUsers1781600000000 implements MigrationInterface {
|
||||
name = 'AddMcpApiKeyToUsers1781600000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
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<void> {
|
||||
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`',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<string, McpSession>();
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.');
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user