feat: improve chat UX and add optional auth for public feedback

- Add debounced search (200ms) in chat sidebar for better performance
- Add toast notifications for conversation actions (archive, restore, delete, pin)
- Add race condition protection when loading conversations
- Add OptionalAuthGuard for public feedback endpoint (unauthenticated access)
- Add backHref prop to PageHeader component for back navigation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-11-29 23:10:03 +01:00
parent 0893ed7daa
commit c85cd4556c
7 changed files with 192 additions and 53 deletions

View file

@ -0,0 +1,57 @@
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as jwt from 'jsonwebtoken';
/**
* Optional authentication guard
* Attaches user to request if valid token is present, but doesn't require it
*/
@Injectable()
export class OptionalAuthGuard implements CanActivate {
constructor(private configService: ConfigService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const token = this.extractTokenFromHeader(request);
if (!token) {
// No token - allow request but no user
request.user = null;
return true;
}
try {
const publicKey = this.configService.get<string>('jwt.publicKey');
if (!publicKey) {
request.user = null;
return true;
}
const audience = this.configService.get<string>('jwt.audience');
const issuer = this.configService.get<string>('jwt.issuer');
const payload = jwt.verify(token, publicKey, {
algorithms: ['RS256'],
audience,
issuer,
}) as jwt.JwtPayload;
// Attach user to request
request.user = {
userId: payload.sub,
email: payload.email,
role: payload.role,
};
} catch {
// Invalid token - allow request but no user
request.user = null;
}
return true;
}
private extractTokenFromHeader(request: any): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
}

View file

@ -11,15 +11,16 @@ import {
} from '@nestjs/common';
import { FeedbackService } from './feedback.service';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import { OptionalAuthGuard } from '../common/guards/optional-auth.guard';
import { CurrentUser, CurrentUserData } from '../common/decorators/current-user.decorator';
import { CreateFeedbackDto, FeedbackQueryDto } from './dto';
@Controller('feedback')
@UseGuards(JwtAuthGuard)
export class FeedbackController {
constructor(private readonly feedbackService: FeedbackService) {}
@Post()
@UseGuards(JwtAuthGuard)
async createFeedback(
@CurrentUser() user: CurrentUserData,
@Body() dto: CreateFeedbackDto,
@ -30,11 +31,16 @@ export class FeedbackController {
}
@Get('public')
async getPublicFeedback(@CurrentUser() user: CurrentUserData, @Query() query: FeedbackQueryDto) {
return this.feedbackService.getPublicFeedback(user.userId, query);
@UseGuards(OptionalAuthGuard)
async getPublicFeedback(
@CurrentUser() user: CurrentUserData | null,
@Query() query: FeedbackQueryDto
) {
return this.feedbackService.getPublicFeedback(user?.userId || null, query);
}
@Get('my')
@UseGuards(JwtAuthGuard)
async getMyFeedback(
@CurrentUser() user: CurrentUserData,
@Query('appId') appId?: string
@ -43,11 +49,13 @@ export class FeedbackController {
}
@Post(':id/vote')
@UseGuards(JwtAuthGuard)
async vote(@CurrentUser() user: CurrentUserData, @Param('id') feedbackId: string) {
return this.feedbackService.vote(user.userId, feedbackId);
}
@Delete(':id/vote')
@UseGuards(JwtAuthGuard)
async unvote(@CurrentUser() user: CurrentUserData, @Param('id') feedbackId: string) {
return this.feedbackService.unvote(user.userId, feedbackId);
}

View file

@ -58,7 +58,7 @@ export class FeedbackService {
};
}
async getPublicFeedback(userId: string, query: FeedbackQueryDto) {
async getPublicFeedback(userId: string | null, query: FeedbackQueryDto) {
const db = this.getDb();
const { appId, status, category, sort = 'votes', limit = 20, offset = 0 } = query;
@ -90,22 +90,25 @@ export class FeedbackService {
.from(userFeedback)
.where(and(...conditions));
// Get user's votes
const feedbackIds = feedbackItems.map((f) => f.id);
const userVotes =
feedbackIds.length > 0
? await db
.select({ feedbackId: feedbackVotes.feedbackId })
.from(feedbackVotes)
.where(
and(
eq(feedbackVotes.userId, userId),
sql`${feedbackVotes.feedbackId} = ANY(${feedbackIds})`
// Get user's votes (only if authenticated)
let votedFeedbackIds = new Set<string>();
if (userId) {
const feedbackIds = feedbackItems.map((f) => f.id);
const userVotes =
feedbackIds.length > 0
? await db
.select({ feedbackId: feedbackVotes.feedbackId })
.from(feedbackVotes)
.where(
and(
eq(feedbackVotes.userId, userId),
sql`${feedbackVotes.feedbackId} = ANY(${feedbackIds})`
)
)
)
: [];
: [];
const votedFeedbackIds = new Set(userVotes.map((v) => v.feedbackId));
votedFeedbackIds = new Set(userVotes.map((v) => v.feedbackId));
}
return {
success: true,