19 Commits

Author SHA1 Message Date
Mathis HERRIOT
f34fd644b8 chore: bump version to 1.9.5
All checks were successful
CI/CD Pipeline / Valider backend (push) Successful in 1m38s
CI/CD Pipeline / Valider frontend (push) Successful in 1m43s
CI/CD Pipeline / Valider documentation (push) Successful in 1m47s
CI/CD Pipeline / Déploiement en Production (push) Successful in 17s
2026-01-29 21:42:34 +01:00
Mathis HERRIOT
c827c2e58d feat(database): increase passwordHash length and add migration snapshot
- Extended `passwordHash` field length in `users` schema from 100 to 255.
- Added migration snapshot for schema updates.
2026-01-29 21:42:05 +01:00
Mathis HERRIOT
30bcfdb436 chore: bump version to 1.9.4
All checks were successful
CI/CD Pipeline / Valider backend (push) Successful in 1m30s
CI/CD Pipeline / Valider documentation (push) Successful in 1m36s
CI/CD Pipeline / Valider frontend (push) Successful in 1m26s
CI/CD Pipeline / Déploiement en Production (push) Successful in 5m19s
2026-01-29 20:49:07 +01:00
Mathis HERRIOT
0b4753c47b style(messages): reformat import statements in MessagesService 2026-01-29 20:48:57 +01:00
Mathis HERRIOT
69b90849fd feat(messages): integrate UsersModule into MessagesModule with forward-ref
- Added `UsersModule` to `MessagesModule` imports using `forwardRef`.
- Injected `UsersService` into `MessagesService` to support user-related operations.
2026-01-29 20:44:35 +01:00
Mathis HERRIOT
f2950ecf86 chore: bump version to 1.9.3
All checks were successful
CI/CD Pipeline / Valider backend (push) Successful in 1m38s
CI/CD Pipeline / Valider frontend (push) Successful in 1m43s
CI/CD Pipeline / Valider documentation (push) Successful in 1m47s
CI/CD Pipeline / Déploiement en Production (push) Successful in 5m16s
2026-01-29 20:33:19 +01:00
Mathis HERRIOT
1e17308aab feat(realtime): add ConfigModule and UsersModule to RealtimeModule
- Integrated `ConfigModule` for configuration management.
- Added `UsersModule` to enable forward-ref dependencies in realtime services.
2026-01-29 20:32:34 +01:00
Mathis HERRIOT
ca4b594828 chore: bump version to 1.9.2
All checks were successful
CI/CD Pipeline / Valider backend (push) Successful in 1m37s
CI/CD Pipeline / Valider documentation (push) Successful in 1m48s
CI/CD Pipeline / Valider frontend (push) Successful in 1m47s
CI/CD Pipeline / Déploiement en Production (push) Successful in 5m36s
2026-01-29 18:22:00 +01:00
Mathis HERRIOT
2ea16773c8 feat(users): add boolean fields for online status and read receipts
- Added `showOnlineStatus` and `showReadReceipts` fields to `UpdateUserDto` with validation.
2026-01-29 18:21:54 +01:00
Mathis HERRIOT
616d7f76d7 feat: add support for online status and read receipt preferences
- Added `showOnlineStatus` and `showReadReceipts` fields to settings form.
- Introduced real-time synchronization for read receipts in message threads.
- Enhanced avatars to display online status indicators.
- Automatically mark messages as read when viewing active conversations.
2026-01-29 18:20:58 +01:00
Mathis HERRIOT
f882a70343 feat: add read receipt handling based on user preferences
- Integrated `UsersService` into `MessagesService` for retrieving user preferences.
- Updated `markAsRead` functionality to respect `showReadReceipts` preference.
- Enhanced real-time read receipt notifications via `EventsGateway`.
- Added `markAsRead` method to the frontend message service.
2026-01-29 18:20:18 +01:00
Mathis HERRIOT
779bb5c112 feat: integrate user preferences for online status in WebSocket gateway
- Added `UsersService` to manage user preferences in `EventsGateway`.
- Enhanced online/offline broadcasting to respect user `showOnlineStatus` preference.
- Updated `handleTyping` and `check_status` to verify user preferences before emitting events.
- Abstracted status broadcasting logic into `broadcastStatus`.
2026-01-29 18:20:04 +01:00
Mathis HERRIOT
5753477717 feat: add user preferences for online status and read receipts with real-time updates
- Introduced `showOnlineStatus` and `showReadReceipts` fields in the user schema and API.
- Integrated real-time status broadcasting in `UsersService` via `EventsGateway`.
- Updated repository and frontend user types to align with new fields.
- Enhanced user update handling to support dynamic preference changes for online status.
2026-01-29 18:18:52 +01:00
Mathis HERRIOT
7615ec670e chore: bump version to 1.9.1
All checks were successful
CI/CD Pipeline / Valider backend (push) Successful in 1m36s
CI/CD Pipeline / Valider frontend (push) Successful in 1m42s
CI/CD Pipeline / Valider documentation (push) Successful in 1m47s
CI/CD Pipeline / Déploiement en Production (push) Successful in 5m58s
2026-01-29 17:44:55 +01:00
Mathis HERRIOT
40cfff683d fix: ensure decrypted PGP values are cast to text in SQL queries
- Added `::text` cast to `pgp_sym_decrypt` function calls for consistent data type handling.
2026-01-29 17:44:50 +01:00
Mathis HERRIOT
bb52782226 feat: enhance environment configuration and CORS handling
- Added `NEXT_PUBLIC_APP_URL` and `NEXT_PUBLIC_CONTACT_EMAIL` to environment variables for frontend configuration.
- Updated CORS logic to support domain-based restrictions with dynamic origin validation.
- Improved frontend image hostname resolution using environment-driven URLs.
- Standardized contact email usage across the application.
2026-01-29 17:34:53 +01:00
Mathis HERRIOT
6a70274623 fix: handle null enriched comment in comments service
- Added a null check for `enrichedComment` to prevent processing invalid data and potential runtime errors.
2026-01-29 17:22:00 +01:00
Mathis HERRIOT
aabc615b89 feat: enhance CORS and user connection handling in WebSocket gateway
- Improved CORS configuration to allow specific origins for development and mobile use cases.
- Added validation for token payload to ensure `sub` property is present.
- Enhanced user connection management by using `userId` consistently for online status tracking and room joining.
2026-01-29 17:21:53 +01:00
Mathis HERRIOT
f9b202375f feat: improve accessibility, security & user interaction in notifications and setup
- Replaced `div` with `button` elements in `NotificationHandler` for better semantics and accessibility.
- Added conditional QR Code reveal in 2FA setup with `blur` effect for enhanced security and user control.
- Enhanced messages layout for responsiveness on smaller screens with dynamic chat/sidebar toggling.
- Simplified legacy prop handling in `ShareDialog`.
2026-01-29 17:21:19 +01:00
34 changed files with 4659 additions and 96 deletions

View File

@@ -0,0 +1,2 @@
ALTER TABLE "users" ADD COLUMN "show_online_status" boolean DEFAULT true NOT NULL;--> statement-breakpoint
ALTER TABLE "users" ADD COLUMN "show_read_receipts" boolean DEFAULT true NOT NULL;

View File

@@ -0,0 +1 @@
ALTER TABLE "users" ALTER COLUMN "password_hash" SET DATA TYPE varchar(255);

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -64,6 +64,20 @@
"when": 1769696731978, "when": 1769696731978,
"tag": "0008_bitter_darwin", "tag": "0008_bitter_darwin",
"breakpoints": true "breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1769717126917,
"tag": "0009_add_privacy_settings",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1769718997591,
"tag": "0010_update_password_hash_length",
"breakpoints": true
} }
] ]
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "@memegoat/backend", "name": "@memegoat/backend",
"version": "1.9.0", "version": "1.9.5",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,

View File

@@ -33,6 +33,7 @@ export class CommentsService {
// Récupérer le commentaire avec les infos utilisateur pour le WebSocket // Récupérer le commentaire avec les infos utilisateur pour le WebSocket
const enrichedComment = await this.findOneEnriched(comment.id, userId); const enrichedComment = await this.findOneEnriched(comment.id, userId);
if (!enrichedComment) return null;
// Notifier les autres utilisateurs sur ce contenu (room de contenu) // Notifier les autres utilisateurs sur ce contenu (room de contenu)
this.eventsGateway.sendToContent(contentId, "new_comment", enrichedComment); this.eventsGateway.sendToContent(contentId, "new_comment", enrichedComment);

View File

@@ -21,14 +21,19 @@ const getPgpKey = () => process.env.PGP_ENCRYPTION_KEY || "default-pgp-key";
* withAutomaticPgpDecrypt(users.email); * withAutomaticPgpDecrypt(users.email);
* ``` * ```
*/ */
export const pgpEncrypted = customType<{ data: string; driverData: Buffer }>({ export const pgpEncrypted = customType<{
data: string | null;
driverData: Buffer | string | null | SQL;
}>({
dataType() { dataType() {
return "bytea"; return "bytea";
}, },
toDriver(value: string): SQL { toDriver(value: string | null): SQL | null {
if (value === null) return null;
return sql`pgp_sym_encrypt(${value}, ${getPgpKey()})`; return sql`pgp_sym_encrypt(${value}, ${getPgpKey()})`;
}, },
fromDriver(value: Buffer | string): string { fromDriver(value: Buffer | string | null | any): string | null {
if (value === null || value === undefined) return null;
if (typeof value === "string") return value; if (typeof value === "string") return value;
return value.toString(); return value.toString();
}, },
@@ -41,7 +46,9 @@ export const pgpEncrypted = customType<{ data: string; driverData: Buffer }>({
export function withAutomaticPgpDecrypt<T extends AnyPgColumn>(column: T): T { export function withAutomaticPgpDecrypt<T extends AnyPgColumn>(column: T): T {
const originalGetSQL = column.getSQL.bind(column); const originalGetSQL = column.getSQL.bind(column);
column.getSQL = () => column.getSQL = () =>
sql`pgp_sym_decrypt(${originalGetSQL()}, ${getPgpKey()})`.mapWith(column); sql`pgp_sym_decrypt(${originalGetSQL()}, ${getPgpKey()})::text`.mapWith(
column,
);
return column; return column;
} }
@@ -59,5 +66,7 @@ export function pgpSymDecrypt(
column: AnyPgColumn, column: AnyPgColumn,
key: string | SQL, key: string | SQL,
): SQL<string> { ): SQL<string> {
return sql`pgp_sym_decrypt(${column}, ${key})`.mapWith(column) as SQL<string>; return sql`pgp_sym_decrypt(${column}, ${key})::text`.mapWith(
column,
) as SQL<string>;
} }

View File

@@ -29,13 +29,15 @@ export const users = pgTable(
displayName: varchar("display_name", { length: 32 }), displayName: varchar("display_name", { length: 32 }),
username: varchar("username", { length: 32 }).notNull().unique(), username: varchar("username", { length: 32 }).notNull().unique(),
passwordHash: varchar("password_hash", { length: 100 }).notNull(), passwordHash: varchar("password_hash", { length: 255 }).notNull(),
avatarUrl: varchar("avatar_url", { length: 512 }), avatarUrl: varchar("avatar_url", { length: 512 }),
bio: varchar("bio", { length: 255 }), bio: varchar("bio", { length: 255 }),
// Sécurité // Sécurité
twoFactorSecret: pgpEncrypted("two_factor_secret"), twoFactorSecret: pgpEncrypted("two_factor_secret"),
isTwoFactorEnabled: boolean("is_two_factor_enabled").notNull().default(false), isTwoFactorEnabled: boolean("is_two_factor_enabled").notNull().default(false),
showOnlineStatus: boolean("show_online_status").notNull().default(true),
showReadReceipts: boolean("show_read_receipts").notNull().default(true),
// RGPD & Conformité // RGPD & Conformité
termsVersion: varchar("terms_version", { length: 16 }), // Version des CGU acceptées termsVersion: varchar("terms_version", { length: 16 }), // Version des CGU acceptées

View File

@@ -1,12 +1,13 @@
import { Module } from "@nestjs/common"; import { forwardRef, Module } from "@nestjs/common";
import { AuthModule } from "../auth/auth.module"; import { AuthModule } from "../auth/auth.module";
import { RealtimeModule } from "../realtime/realtime.module"; import { RealtimeModule } from "../realtime/realtime.module";
import { UsersModule } from "../users/users.module";
import { MessagesController } from "./messages.controller"; import { MessagesController } from "./messages.controller";
import { MessagesService } from "./messages.service"; import { MessagesService } from "./messages.service";
import { MessagesRepository } from "./repositories/messages.repository"; import { MessagesRepository } from "./repositories/messages.repository";
@Module({ @Module({
imports: [AuthModule, RealtimeModule], imports: [AuthModule, RealtimeModule, forwardRef(() => UsersModule)],
controllers: [MessagesController], controllers: [MessagesController],
providers: [MessagesService, MessagesRepository], providers: [MessagesService, MessagesRepository],
exports: [MessagesService], exports: [MessagesService],

View File

@@ -1,6 +1,7 @@
import { ForbiddenException } from "@nestjs/common"; import { ForbiddenException } from "@nestjs/common";
import { Test, TestingModule } from "@nestjs/testing"; import { Test, TestingModule } from "@nestjs/testing";
import { EventsGateway } from "../realtime/events.gateway"; import { EventsGateway } from "../realtime/events.gateway";
import { UsersService } from "../users/users.service";
import { MessagesService } from "./messages.service"; import { MessagesService } from "./messages.service";
import { MessagesRepository } from "./repositories/messages.repository"; import { MessagesRepository } from "./repositories/messages.repository";
@@ -16,6 +17,7 @@ describe("MessagesService", () => {
createMessage: jest.fn(), createMessage: jest.fn(),
findAllConversations: jest.fn(), findAllConversations: jest.fn(),
isParticipant: jest.fn(), isParticipant: jest.fn(),
getParticipants: jest.fn(),
findMessagesByConversationId: jest.fn(), findMessagesByConversationId: jest.fn(),
markAsRead: jest.fn(), markAsRead: jest.fn(),
countUnreadMessages: jest.fn(), countUnreadMessages: jest.fn(),
@@ -25,12 +27,17 @@ describe("MessagesService", () => {
sendToUser: jest.fn(), sendToUser: jest.fn(),
}; };
const mockUsersService = {
findOne: jest.fn(),
};
beforeEach(async () => { beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
providers: [ providers: [
MessagesService, MessagesService,
{ provide: MessagesRepository, useValue: mockMessagesRepository }, { provide: MessagesRepository, useValue: mockMessagesRepository },
{ provide: EventsGateway, useValue: mockEventsGateway }, { provide: EventsGateway, useValue: mockEventsGateway },
{ provide: UsersService, useValue: mockUsersService },
], ],
}).compile(); }).compile();

View File

@@ -1,5 +1,11 @@
import { ForbiddenException, Injectable } from "@nestjs/common"; import {
ForbiddenException,
forwardRef,
Inject,
Injectable,
} from "@nestjs/common";
import { EventsGateway } from "../realtime/events.gateway"; import { EventsGateway } from "../realtime/events.gateway";
import { UsersService } from "../users/users.service";
import type { CreateMessageDto } from "./dto/create-message.dto"; import type { CreateMessageDto } from "./dto/create-message.dto";
import { MessagesRepository } from "./repositories/messages.repository"; import { MessagesRepository } from "./repositories/messages.repository";
@@ -8,6 +14,8 @@ export class MessagesService {
constructor( constructor(
private readonly messagesRepository: MessagesRepository, private readonly messagesRepository: MessagesRepository,
private readonly eventsGateway: EventsGateway, private readonly eventsGateway: EventsGateway,
@Inject(forwardRef(() => UsersService))
private readonly usersService: UsersService,
) {} ) {}
async sendMessage(senderId: string, dto: CreateMessageDto) { async sendMessage(senderId: string, dto: CreateMessageDto) {
@@ -62,8 +70,24 @@ export class MessagesService {
throw new ForbiddenException("You are not part of this conversation"); throw new ForbiddenException("You are not part of this conversation");
} }
// Marquer comme lus // Récupérer les préférences de l'utilisateur actuel
await this.messagesRepository.markAsRead(conversationId, userId); const user = await this.usersService.findOne(userId);
// Marquer comme lus seulement si l'utilisateur l'autorise
if (user?.showReadReceipts) {
await this.messagesRepository.markAsRead(conversationId, userId);
// Notifier l'expéditeur que les messages ont été lus
const participants =
await this.messagesRepository.getParticipants(conversationId);
const otherParticipant = participants.find((p) => p.userId !== userId);
if (otherParticipant) {
this.eventsGateway.sendToUser(otherParticipant.userId, "messages_read", {
conversationId,
readerId: userId,
});
}
}
return this.messagesRepository.findMessagesByConversationId(conversationId); return this.messagesRepository.findMessagesByConversationId(conversationId);
} }
@@ -76,6 +100,26 @@ export class MessagesService {
if (!isParticipant) { if (!isParticipant) {
throw new ForbiddenException("You are not part of this conversation"); throw new ForbiddenException("You are not part of this conversation");
} }
return this.messagesRepository.markAsRead(conversationId, userId);
const user = await this.usersService.findOne(userId);
if (!user?.showReadReceipts) return;
const result = await this.messagesRepository.markAsRead(
conversationId,
userId,
);
// Notifier l'autre participant
const participants =
await this.messagesRepository.getParticipants(conversationId);
const otherParticipant = participants.find((p) => p.userId !== userId);
if (otherParticipant) {
this.eventsGateway.sendToUser(otherParticipant.userId, "messages_read", {
conversationId,
readerId: userId,
});
}
return result;
} }
} }

View File

@@ -1,6 +1,7 @@
import { ConfigService } from "@nestjs/config"; import { ConfigService } from "@nestjs/config";
import { Test, TestingModule } from "@nestjs/testing"; import { Test, TestingModule } from "@nestjs/testing";
import { JwtService } from "../crypto/services/jwt.service"; import { JwtService } from "../crypto/services/jwt.service";
import { UsersService } from "../users/users.service";
import { EventsGateway } from "./events.gateway"; import { EventsGateway } from "./events.gateway";
describe("EventsGateway", () => { describe("EventsGateway", () => {
@@ -15,12 +16,17 @@ describe("EventsGateway", () => {
get: jest.fn().mockReturnValue("secret-password-32-chars-long-!!!"), get: jest.fn().mockReturnValue("secret-password-32-chars-long-!!!"),
}; };
const mockUsersService = {
findOne: jest.fn(),
};
beforeEach(async () => { beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
providers: [ providers: [
EventsGateway, EventsGateway,
{ provide: JwtService, useValue: mockJwtService }, { provide: JwtService, useValue: mockJwtService },
{ provide: ConfigService, useValue: mockConfigService }, { provide: ConfigService, useValue: mockConfigService },
{ provide: UsersService, useValue: mockUsersService },
], ],
}).compile(); }).compile();

View File

@@ -1,4 +1,4 @@
import { Logger } from "@nestjs/common"; import { forwardRef, Inject, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config"; import { ConfigService } from "@nestjs/config";
import { import {
ConnectedSocket, ConnectedSocket,
@@ -14,16 +14,39 @@ import { getIronSession } from "iron-session";
import { Server, Socket } from "socket.io"; import { Server, Socket } from "socket.io";
import { getSessionOptions, SessionData } from "../auth/session.config"; import { getSessionOptions, SessionData } from "../auth/session.config";
import { JwtService } from "../crypto/services/jwt.service"; import { JwtService } from "../crypto/services/jwt.service";
import { UsersService } from "../users/users.service";
@WebSocketGateway({ @WebSocketGateway({
transports: ["websocket"],
cors: { cors: {
origin: ( origin: (
_origin: string, origin: string,
callback: (err: Error | null, allow?: boolean) => void, callback: (err: Error | null, allow?: boolean) => void,
) => { ) => {
// En production, on pourrait restreindre ici // Autoriser si pas d'origine (ex: app mobile ou serveur à serveur)
// Pour l'instant on autorise tout en mode credentials pour faciliter le déploiement multi-domaines // ou si on est en développement local
callback(null, true); if (
!origin ||
origin.includes("localhost") ||
origin.includes("127.0.0.1")
) {
callback(null, true);
return;
}
// En production, on peut restreindre via une variable d'environnement
const domainName = process.env.CORS_DOMAIN_NAME;
if (!domainName || domainName === "*") {
callback(null, true);
return;
}
const allowedOrigins = domainName.split(",").map((o) => o.trim());
if (allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error("Not allowed by CORS"));
}
}, },
credentials: true, credentials: true,
methods: ["GET", "POST"], methods: ["GET", "POST"],
@@ -41,6 +64,8 @@ export class EventsGateway
constructor( constructor(
private readonly jwtService: JwtService, private readonly jwtService: JwtService,
private readonly configService: ConfigService, private readonly configService: ConfigService,
@Inject(forwardRef(() => UsersService))
private readonly usersService: UsersService,
) {} ) {}
afterInit(_server: Server) { afterInit(_server: Server) {
@@ -73,17 +98,28 @@ export class EventsGateway
} }
const payload = await this.jwtService.verifyJwt(session.accessToken); const payload = await this.jwtService.verifyJwt(session.accessToken);
if (!payload.sub) {
throw new Error("Invalid token payload: missing sub");
}
client.data.user = payload; client.data.user = payload;
// Rejoindre une room personnelle pour les notifications // Rejoindre une room personnelle pour les notifications
client.join(`user:${payload.sub}`); client.join(`user:${payload.sub}`);
// Gérer le statut en ligne // Gérer le statut en ligne
if (!this.onlineUsers.has(payload.sub)) { const userId = payload.sub as string;
this.onlineUsers.set(payload.sub, new Set());
this.server.emit("user_status", { userId: payload.sub, status: "online" }); if (!this.onlineUsers.has(userId)) {
this.onlineUsers.set(userId, new Set());
// Vérifier les préférences de l'utilisateur
const user = await this.usersService.findOne(userId);
if (user?.showOnlineStatus) {
this.broadcastStatus(userId, "online");
}
} }
this.onlineUsers.get(payload.sub)?.add(client.id); this.onlineUsers.get(userId)?.add(client.id);
this.logger.log(`Client connected: ${client.id} (User: ${payload.sub})`); this.logger.log(`Client connected: ${client.id} (User: ${payload.sub})`);
} catch (error) { } catch (error) {
@@ -92,19 +128,31 @@ export class EventsGateway
} }
} }
handleDisconnect(client: Socket) { async handleDisconnect(client: Socket) {
const userId = client.data.user?.sub; const userId = client.data.user?.sub;
if (userId && this.onlineUsers.has(userId)) { if (userId && this.onlineUsers.has(userId)) {
const sockets = this.onlineUsers.get(userId); const sockets = this.onlineUsers.get(userId);
sockets?.delete(client.id); sockets?.delete(client.id);
if (sockets?.size === 0) { if (sockets?.size === 0) {
this.onlineUsers.delete(userId); this.onlineUsers.delete(userId);
this.server.emit("user_status", { userId, status: "offline" });
const user = await this.usersService.findOne(userId);
if (user?.showOnlineStatus) {
this.broadcastStatus(userId, "offline");
}
} }
} }
this.logger.log(`Client disconnected: ${client.id}`); this.logger.log(`Client disconnected: ${client.id}`);
} }
broadcastStatus(userId: string, status: "online" | "offline") {
this.server.emit("user_status", { userId, status });
}
isUserOnline(userId: string): boolean {
return this.onlineUsers.has(userId);
}
@SubscribeMessage("join_content") @SubscribeMessage("join_content")
handleJoinContent( handleJoinContent(
@ConnectedSocket() client: Socket, @ConnectedSocket() client: Socket,
@@ -124,13 +172,20 @@ export class EventsGateway
} }
@SubscribeMessage("typing") @SubscribeMessage("typing")
handleTyping( async handleTyping(
@ConnectedSocket() client: Socket, @ConnectedSocket() client: Socket,
@MessageBody() data: { recipientId: string; isTyping: boolean }, @MessageBody() data: { recipientId: string; isTyping: boolean },
) { ) {
const userId = client.data.user?.sub; const userId = client.data.user?.sub;
if (!userId) return; if (!userId) return;
// Optionnel: vérifier si l'utilisateur autorise le statut en ligne avant d'émettre "typing"
// ou si on considère que typing est une interaction directe qui outrepasse le statut.
// Instagram affiche "Typing..." même si le statut en ligne est désactivé si on est dans le chat.
// Mais par souci de cohérence avec "showOnlineStatus", on peut le vérifier.
const user = await this.usersService.findOne(userId);
if (!user?.showOnlineStatus) return;
this.server.to(`user:${data.recipientId}`).emit("user_typing", { this.server.to(`user:${data.recipientId}`).emit("user_typing", {
userId, userId,
isTyping: data.isTyping, isTyping: data.isTyping,
@@ -138,13 +193,19 @@ export class EventsGateway
} }
@SubscribeMessage("check_status") @SubscribeMessage("check_status")
handleCheckStatus( async handleCheckStatus(
@ConnectedSocket() _client: Socket, @ConnectedSocket() _client: Socket,
@MessageBody() userId: string, @MessageBody() userId: string,
) { ) {
const isOnline = this.onlineUsers.has(userId);
if (!isOnline) return { userId, status: "offline" };
const user = await this.usersService.findOne(userId);
if (!user?.showOnlineStatus) return { userId, status: "offline" };
return { return {
userId, userId,
status: this.onlineUsers.has(userId) ? "online" : "offline", status: "online",
}; };
} }

View File

@@ -1,9 +1,11 @@
import { Module } from "@nestjs/common"; import { forwardRef, Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { CryptoModule } from "../crypto/crypto.module"; import { CryptoModule } from "../crypto/crypto.module";
import { UsersModule } from "../users/users.module";
import { EventsGateway } from "./events.gateway"; import { EventsGateway } from "./events.gateway";
@Module({ @Module({
imports: [CryptoModule], imports: [CryptoModule, ConfigModule, forwardRef(() => UsersModule)],
providers: [EventsGateway], providers: [EventsGateway],
exports: [EventsGateway], exports: [EventsGateway],
}) })

View File

@@ -1,4 +1,4 @@
import { IsOptional, IsString, MaxLength } from "class-validator"; import { IsBoolean, IsOptional, IsString, MaxLength } from "class-validator";
export class UpdateUserDto { export class UpdateUserDto {
@IsOptional() @IsOptional()
@@ -22,4 +22,12 @@ export class UpdateUserDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
role?: string; role?: string;
@IsOptional()
@IsBoolean()
showOnlineStatus?: boolean;
@IsOptional()
@IsBoolean()
showReadReceipts?: boolean;
} }

View File

@@ -47,6 +47,8 @@ export class UsersRepository {
bio: users.bio, bio: users.bio,
status: users.status, status: users.status,
isTwoFactorEnabled: users.isTwoFactorEnabled, isTwoFactorEnabled: users.isTwoFactorEnabled,
showOnlineStatus: users.showOnlineStatus,
showReadReceipts: users.showReadReceipts,
createdAt: users.createdAt, createdAt: users.createdAt,
updatedAt: users.updatedAt, updatedAt: users.updatedAt,
}) })

View File

@@ -1,13 +1,19 @@
import { forwardRef, Module } from "@nestjs/common"; import { forwardRef, Module } from "@nestjs/common";
import { AuthModule } from "../auth/auth.module"; import { AuthModule } from "../auth/auth.module";
import { MediaModule } from "../media/media.module"; import { MediaModule } from "../media/media.module";
import { RealtimeModule } from "../realtime/realtime.module";
import { S3Module } from "../s3/s3.module"; import { S3Module } from "../s3/s3.module";
import { UsersRepository } from "./repositories/users.repository"; import { UsersRepository } from "./repositories/users.repository";
import { UsersController } from "./users.controller"; import { UsersController } from "./users.controller";
import { UsersService } from "./users.service"; import { UsersService } from "./users.service";
@Module({ @Module({
imports: [forwardRef(() => AuthModule), MediaModule, S3Module], imports: [
forwardRef(() => AuthModule),
MediaModule,
S3Module,
forwardRef(() => RealtimeModule),
],
controllers: [UsersController], controllers: [UsersController],
providers: [UsersService, UsersRepository], providers: [UsersService, UsersRepository],
exports: [UsersService, UsersRepository], exports: [UsersService, UsersRepository],

View File

@@ -20,6 +20,7 @@ import { ConfigService } from "@nestjs/config";
import { Test, TestingModule } from "@nestjs/testing"; import { Test, TestingModule } from "@nestjs/testing";
import { RbacService } from "../auth/rbac.service"; import { RbacService } from "../auth/rbac.service";
import { MediaService } from "../media/media.service"; import { MediaService } from "../media/media.service";
import { EventsGateway } from "../realtime/events.gateway";
import { S3Service } from "../s3/s3.service"; import { S3Service } from "../s3/s3.service";
import { UsersRepository } from "./repositories/users.repository"; import { UsersRepository } from "./repositories/users.repository";
import { UsersService } from "./users.service"; import { UsersService } from "./users.service";
@@ -49,6 +50,7 @@ describe("UsersService", () => {
const mockRbacService = { const mockRbacService = {
getUserRoles: jest.fn(), getUserRoles: jest.fn(),
assignRoleToUser: jest.fn(),
}; };
const mockMediaService = { const mockMediaService = {
@@ -65,6 +67,11 @@ describe("UsersService", () => {
get: jest.fn(), get: jest.fn(),
}; };
const mockEventsGateway = {
isUserOnline: jest.fn(),
broadcastStatus: jest.fn(),
};
beforeEach(async () => { beforeEach(async () => {
jest.clearAllMocks(); jest.clearAllMocks();
@@ -77,6 +84,7 @@ describe("UsersService", () => {
{ provide: MediaService, useValue: mockMediaService }, { provide: MediaService, useValue: mockMediaService },
{ provide: S3Service, useValue: mockS3Service }, { provide: S3Service, useValue: mockS3Service },
{ provide: ConfigService, useValue: mockConfigService }, { provide: ConfigService, useValue: mockConfigService },
{ provide: EventsGateway, useValue: mockEventsGateway },
], ],
}).compile(); }).compile();

View File

@@ -12,6 +12,7 @@ import { RbacService } from "../auth/rbac.service";
import type { IMediaService } from "../common/interfaces/media.interface"; import type { IMediaService } from "../common/interfaces/media.interface";
import type { IStorageService } from "../common/interfaces/storage.interface"; import type { IStorageService } from "../common/interfaces/storage.interface";
import { MediaService } from "../media/media.service"; import { MediaService } from "../media/media.service";
import { EventsGateway } from "../realtime/events.gateway";
import { S3Service } from "../s3/s3.service"; import { S3Service } from "../s3/s3.service";
import { UpdateUserDto } from "./dto/update-user.dto"; import { UpdateUserDto } from "./dto/update-user.dto";
import { UsersRepository } from "./repositories/users.repository"; import { UsersRepository } from "./repositories/users.repository";
@@ -27,6 +28,8 @@ export class UsersService {
private readonly rbacService: RbacService, private readonly rbacService: RbacService,
@Inject(MediaService) private readonly mediaService: IMediaService, @Inject(MediaService) private readonly mediaService: IMediaService,
@Inject(S3Service) private readonly s3Service: IStorageService, @Inject(S3Service) private readonly s3Service: IStorageService,
@Inject(forwardRef(() => EventsGateway))
private readonly eventsGateway: EventsGateway,
) {} ) {}
private async clearUserCache(username?: string) { private async clearUserCache(username?: string) {
@@ -137,6 +140,9 @@ export class UsersService {
const { role, ...userData } = data; const { role, ...userData } = data;
// On récupère l'utilisateur actuel avant mise à jour pour comparer les préférences
const oldUser = await this.usersRepository.findOne(uuid);
const result = await this.usersRepository.update(uuid, userData); const result = await this.usersRepository.update(uuid, userData);
if (role) { if (role) {
@@ -145,6 +151,21 @@ export class UsersService {
if (result[0]) { if (result[0]) {
await this.clearUserCache(result[0].username); await this.clearUserCache(result[0].username);
// Gérer le changement de préférence de statut en ligne
if (
data.showOnlineStatus !== undefined &&
data.showOnlineStatus !== oldUser?.showOnlineStatus
) {
const isOnline = this.eventsGateway.isUserOnline(uuid);
if (isOnline) {
if (data.showOnlineStatus) {
this.eventsGateway.broadcastStatus(uuid, "online");
} else {
this.eventsGateway.broadcastStatus(uuid, "offline");
}
}
}
} }
return result; return result;
} }

View File

@@ -131,6 +131,8 @@ services:
environment: environment:
NODE_ENV: production NODE_ENV: production
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-https://api.memegoat.fr} NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-https://api.memegoat.fr}
NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL:-https://memegoat.fr}
NEXT_PUBLIC_CONTACT_EMAIL: ${MAIL_FROM:-noreply@memegoat.fr}
depends_on: depends_on:
- backend - backend

View File

@@ -1,5 +1,16 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const appUrl = process.env.NEXT_PUBLIC_APP_URL || "https://memegoat.fr";
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "https://api.memegoat.fr";
const getHostname = (url: string) => {
try {
return new URL(url).hostname;
} catch {
return url;
}
};
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ /* config options here */
reactCompiler: true, reactCompiler: true,
@@ -7,11 +18,11 @@ const nextConfig: NextConfig = {
remotePatterns: [ remotePatterns: [
{ {
protocol: "https", protocol: "https",
hostname: "memegoat.fr", hostname: getHostname(appUrl),
}, },
{ {
protocol: "https", protocol: "https",
hostname: "api.memegoat.fr", hostname: getHostname(apiUrl),
}, },
], ],
}, },

View File

@@ -1,6 +1,6 @@
{ {
"name": "@memegoat/frontend", "name": "@memegoat/frontend",
"version": "1.9.0", "version": "1.9.5",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",

View File

@@ -63,7 +63,9 @@ export default function HelpPage() {
<p className="text-muted-foreground"> <p className="text-muted-foreground">
N'hésitez pas à nous contacter sur nos réseaux sociaux ou par email. N'hésitez pas à nous contacter sur nos réseaux sociaux ou par email.
</p> </p>
<p className="font-semibold text-primary">contact@memegoat.fr</p> <p className="font-semibold text-primary">
{process.env.NEXT_PUBLIC_CONTACT_EMAIL || "contact@memegoat.fr"}
</p>
</div> </div>
</div> </div>
); );

View File

@@ -2,7 +2,15 @@
import { formatDistanceToNow } from "date-fns"; import { formatDistanceToNow } from "date-fns";
import { fr } from "date-fns/locale"; import { fr } from "date-fns/locale";
import { Search, Send, UserPlus, X } from "lucide-react"; import {
ArrowLeft,
Check,
CheckCheck,
Search,
Send,
UserPlus,
X,
} from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import * as React from "react"; import * as React from "react";
@@ -142,6 +150,8 @@ export default function MessagesPage() {
if (activeConv?.id === data.conversationId) { if (activeConv?.id === data.conversationId) {
setMessages((prev) => [...prev, data.message]); setMessages((prev) => [...prev, data.message]);
setIsOtherTyping(false); // S'il a envoyé un message, il ne tape plus setIsOtherTyping(false); // S'il a envoyé un message, il ne tape plus
// Marquer comme lu immédiatement si on est sur la conversation
MessageService.markAsRead(data.conversationId).catch(console.error);
} }
// Mettre à jour la liste des conversations // Mettre à jour la liste des conversations
setConversations((prev) => { setConversations((prev) => {
@@ -184,10 +194,26 @@ export default function MessagesPage() {
} }
}); });
socket.on(
"messages_read",
(data: { conversationId: string; readerId: string }) => {
if (activeConv?.id === data.conversationId) {
setMessages((prev) =>
prev.map((msg) =>
msg.senderId !== data.readerId && !msg.readAt
? { ...msg, readAt: new Date().toISOString() }
: msg,
),
);
}
},
);
return () => { return () => {
socket.off("new_message"); socket.off("new_message");
socket.off("user_status"); socket.off("user_status");
socket.off("user_typing"); socket.off("user_typing");
socket.off("messages_read");
}; };
} }
}, [socket, activeConv]); }, [socket, activeConv]);
@@ -238,7 +264,11 @@ export default function MessagesPage() {
return ( return (
<div className="h-[calc(100vh-4rem)] flex overflow-hidden bg-white dark:bg-zinc-950"> <div className="h-[calc(100vh-4rem)] flex overflow-hidden bg-white dark:bg-zinc-950">
{/* Sidebar - Liste des conversations */} {/* Sidebar - Liste des conversations */}
<div className="w-80 border-r flex flex-col"> <div
className={`w-full md:w-80 border-r flex flex-col ${
activeConv ? "hidden md:flex" : "flex"
}`}
>
<div className="p-4 border-b"> <div className="p-4 border-b">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-bold">Messages</h2> <h2 className="text-xl font-bold">Messages</h2>
@@ -347,7 +377,7 @@ export default function MessagesPage() {
: "hover:bg-zinc-100 dark:hover:bg-zinc-900" : "hover:bg-zinc-100 dark:hover:bg-zinc-900"
}`} }`}
> >
<Avatar> <Avatar isOnline={onlineUsers.has(conv.recipient.uuid)}>
<AvatarImage src={conv.recipient.avatarUrl} /> <AvatarImage src={conv.recipient.avatarUrl} />
<AvatarFallback> <AvatarFallback>
{conv.recipient.username[0].toUpperCase()} {conv.recipient.username[0].toUpperCase()}
@@ -378,16 +408,31 @@ export default function MessagesPage() {
</div> </div>
{/* Zone de chat */} {/* Zone de chat */}
<div className="flex-1 flex flex-col"> <div
className={`flex-1 flex flex-col ${
!activeConv ? "hidden md:flex" : "flex"
}`}
>
{activeConv ? ( {activeConv ? (
<> <>
{/* Header */} {/* Header */}
<div className="p-4 border-b flex items-center justify-between"> <div className="p-4 border-b flex items-center gap-2">
<Button
variant="ghost"
size="icon"
className="md:hidden rounded-full"
onClick={() => setActiveConv(null)}
>
<ArrowLeft className="h-5 w-5" />
</Button>
<Link <Link
href={`/user/${activeConv.recipient.username}`} href={`/user/${activeConv.recipient.username}`}
className="flex items-center gap-3 hover:opacity-80 transition-opacity" className="flex-1 flex items-center gap-3 hover:opacity-80 transition-opacity"
> >
<Avatar className="h-8 w-8"> <Avatar
className="h-8 w-8"
isOnline={onlineUsers.has(activeConv.recipient.uuid)}
>
<AvatarImage src={activeConv.recipient.avatarUrl} /> <AvatarImage src={activeConv.recipient.avatarUrl} />
<AvatarFallback> <AvatarFallback>
{activeConv.recipient.username[0].toUpperCase()} {activeConv.recipient.username[0].toUpperCase()}
@@ -449,8 +494,12 @@ export default function MessagesPage() {
})} })}
</span> </span>
{msg.senderId === user?.uuid && ( {msg.senderId === user?.uuid && (
<span className="font-bold"> <span className="flex items-center">
{msg.readAt ? "• Lu" : "• Envoyé"} {msg.readAt ? (
<CheckCheck className="h-3 w-3" />
) : (
<Check className="h-3 w-3" />
)}
</span> </span>
)} )}
</div> </div>

View File

@@ -10,6 +10,7 @@ import {
Palette, Palette,
Save, Save,
Settings, Settings,
Shield,
Sun, Sun,
Trash2, Trash2,
User as UserIcon, User as UserIcon,
@@ -53,6 +54,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { useAuth } from "@/providers/auth-provider"; import { useAuth } from "@/providers/auth-provider";
import { UserService } from "@/services/user.service"; import { UserService } from "@/services/user.service";
@@ -60,6 +62,8 @@ import { UserService } from "@/services/user.service";
const settingsSchema = z.object({ const settingsSchema = z.object({
displayName: z.string().max(32, "Le nom d'affichage est trop long").optional(), displayName: z.string().max(32, "Le nom d'affichage est trop long").optional(),
bio: z.string().max(255, "La bio est trop longue").optional(), bio: z.string().max(255, "La bio est trop longue").optional(),
showOnlineStatus: z.boolean(),
showReadReceipts: z.boolean(),
}); });
type SettingsFormValues = z.infer<typeof settingsSchema>; type SettingsFormValues = z.infer<typeof settingsSchema>;
@@ -82,6 +86,8 @@ export default function SettingsPage() {
defaultValues: { defaultValues: {
displayName: "", displayName: "",
bio: "", bio: "",
showOnlineStatus: true,
showReadReceipts: true,
}, },
}); });
@@ -90,6 +96,8 @@ export default function SettingsPage() {
form.reset({ form.reset({
displayName: user.displayName || "", displayName: user.displayName || "",
bio: user.bio || "", bio: user.bio || "",
showOnlineStatus: user.showOnlineStatus ?? true,
showReadReceipts: user.showReadReceipts ?? true,
}); });
} }
}, [user, form]); }, [user, form]);
@@ -265,6 +273,73 @@ export default function SettingsPage() {
</CardContent> </CardContent>
</Card> </Card>
{/* Confidentialité */}
<Card className="border-none shadow-sm">
<CardHeader className="pb-4">
<div className="flex items-center gap-2">
<Shield className="h-5 w-5 text-primary" />
<div>
<CardTitle>Confidentialité</CardTitle>
<CardDescription>Gérez la visibilité de vos activités.</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<div className="space-y-4">
<FormField
control={form.control}
name="showOnlineStatus"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">Statut en ligne</FormLabel>
<FormDescription>
Affiche quand vous êtes actif sur le site.
</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="showReadReceipts"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">
Confirmations de lecture
</FormLabel>
<FormDescription>
Permet aux autres de voir quand vous avez lu leurs messages.
</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
</div>
<div className="flex justify-end pt-2">
<Button type="submit" disabled={isSaving} className="min-w-[150px]">
{isSaving ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
Enregistrer
</Button>
</div>
</form>
</Form>
</CardContent>
</Card>
<TwoFactorSetup /> <TwoFactorSetup />
<Card className="border-none shadow-sm"> <Card className="border-none shadow-sm">

View File

@@ -32,7 +32,7 @@ export const metadata: Metadata = {
openGraph: { openGraph: {
type: "website", type: "website",
locale: "fr_FR", locale: "fr_FR",
url: "https://memegoat.local", url: "/",
siteName: "MemeGoat", siteName: "MemeGoat",
title: "MemeGoat | Partagez vos meilleurs mèmes", title: "MemeGoat | Partagez vos meilleurs mèmes",
description: "La plateforme ultime pour les mèmes. Rejoignez le troupeau !", description: "La plateforme ultime pour les mèmes. Rejoignez le troupeau !",

View File

@@ -30,10 +30,9 @@ export function NotificationHandler() {
toast.custom( toast.custom(
(t) => ( (t) => (
<div <button
role="button" type="button"
tabIndex={0} className="flex items-start gap-3 bg-white dark:bg-zinc-900 p-4 rounded-xl shadow-lg border border-zinc-200 dark:border-zinc-800 w-full max-w-sm cursor-pointer hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors text-left"
className="flex items-start gap-3 bg-white dark:bg-zinc-900 p-4 rounded-xl shadow-lg border border-zinc-200 dark:border-zinc-800 w-full max-w-sm cursor-pointer hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors"
onClick={() => { onClick={() => {
toast.dismiss(t); toast.dismiss(t);
if (data.type === "message") { if (data.type === "message") {
@@ -42,16 +41,6 @@ export function NotificationHandler() {
router.push(`/meme/${data.contentId}`); router.push(`/meme/${data.contentId}`);
} }
}} }}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
toast.dismiss(t);
if (data.type === "message") {
router.push("/messages");
} else if (data.contentId) {
router.push(`/meme/${data.contentId}`);
}
}
}}
> >
<div className="bg-primary/10 p-2 rounded-full shrink-0"> <div className="bg-primary/10 p-2 rounded-full shrink-0">
{data.type === "comment" && ( {data.type === "comment" && (
@@ -71,15 +60,15 @@ export function NotificationHandler() {
</div> </div>
<button <button
type="button" type="button"
className="text-muted-foreground hover:text-foreground p-1 rounded-full hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
toast.dismiss(t); toast.dismiss(t);
}} }}
className="text-muted-foreground hover:text-foreground"
> >
<Bell className="h-3 w-3" /> <Bell className="h-3 w-3" />
</button> </button>
</div> </button>
), ),
{ {
duration: 5000, duration: 5000,
@@ -91,20 +80,23 @@ export function NotificationHandler() {
socket.on("notification", handleNotification); socket.on("notification", handleNotification);
// Aussi pour les nouveaux messages (si on veut un toast global) // Aussi pour les nouveaux messages (si on veut un toast global)
socket.on("new_message", (data: { message: any }) => { socket.on(
if (window.location.pathname !== "/messages") { "new_message",
toast( (data: { message: { text: string; sender?: { username: string } } }) => {
`Nouveau message de @${data.message.sender?.username || "un membre"}`, if (window.location.pathname !== "/messages") {
{ toast(
description: data.message.text.substring(0, 50), `Nouveau message de @${data.message.sender?.username || "un membre"}`,
action: { {
label: "Voir", description: data.message.text.substring(0, 50),
onClick: () => router.push("/messages"), action: {
label: "Voir",
onClick: () => router.push("/messages"),
},
}, },
}, );
); }
} },
}); );
return () => { return () => {
socket.off("notification"); socket.off("notification");

View File

@@ -28,9 +28,10 @@ interface ShareDialogProps {
export function ShareDialog({ export function ShareDialog({
contentId, contentId,
contentTitle, contentTitle,
contentUrl: _unused, // Support legacy prop
open, open,
onOpenChange, onOpenChange,
}: Omit<ShareDialogProps, "contentUrl">) { }: ShareDialogProps) {
const [searchQuery, setSearchQuery] = React.useState(""); const [searchQuery, setSearchQuery] = React.useState("");
const [results, setResults] = React.useState<User[]>([]); const [results, setResults] = React.useState<User[]>([]);
const [isLoading, setIsLoading] = React.useState(false); const [isLoading, setIsLoading] = React.useState(false);

View File

@@ -29,6 +29,7 @@ export function TwoFactorSetup() {
const [secret, setSecret] = useState<string | null>(null); const [secret, setSecret] = useState<string | null>(null);
const [otpValue, setOtpValue] = useState(""); const [otpValue, setOtpValue] = useState("");
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isRevealed, setIsRevealed] = useState(false);
const handleSetup = async () => { const handleSetup = async () => {
setIsLoading(true); setIsLoading(true);
@@ -152,24 +153,59 @@ export function TwoFactorSetup() {
</CardHeader> </CardHeader>
<CardContent className="flex flex-col items-center gap-6"> <CardContent className="flex flex-col items-center gap-6">
{qrCode && ( {qrCode && (
<div className="bg-white p-4 rounded-xl border-4 border-zinc-100"> <div className="relative group">
<Image <div
src={qrCode} className={`bg-white p-4 rounded-xl border-4 border-zinc-100 transition-all duration-300 ${
alt="QR Code 2FA" !isRevealed ? "blur-md select-none" : ""
width={192} }`}
height={192} >
className="w-48 h-48" <Image
unoptimized src={qrCode}
/> alt="QR Code 2FA"
width={192}
height={192}
className="w-48 h-48"
unoptimized
/>
</div>
{!isRevealed && (
<div className="absolute inset-0 flex items-center justify-center">
<Button
variant="secondary"
size="sm"
onClick={() => setIsRevealed(true)}
className="shadow-lg"
>
Afficher le QR Code
</Button>
</div>
)}
</div> </div>
)} )}
<div className="w-full space-y-2"> <div className="w-full space-y-2">
<p className="text-sm font-medium text-center"> <p className="text-sm font-medium text-center">
Ou entrez ce code manuellement : Ou entrez ce code manuellement :
</p> </p>
<code className="block p-2 bg-muted text-center rounded text-xs font-mono break-all"> <div className="relative group">
{secret} <code
</code> className={`block p-2 bg-muted text-center rounded text-xs font-mono break-all transition-all duration-300 ${
!isRevealed ? "blur-[3px] select-none" : ""
}`}
>
{secret}
</code>
{!isRevealed && (
<div className="absolute inset-0 flex items-center justify-center">
<button
type="button"
onClick={() => setIsRevealed(true)}
className="text-[10px] font-bold uppercase tracking-wider text-primary hover:underline"
>
Afficher le code
</button>
</div>
)}
</div>
</div> </div>
<div className="flex flex-col items-center gap-4 w-full border-t pt-6"> <div className="flex flex-col items-center gap-4 w-full border-t pt-6">
<p className="text-sm font-medium"> <p className="text-sm font-medium">

View File

@@ -7,17 +7,23 @@ import { cn } from "@/lib/utils";
function Avatar({ function Avatar({
className, className,
isOnline,
...props ...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) { }: React.ComponentProps<typeof AvatarPrimitive.Root> & { isOnline?: boolean }) {
return ( return (
<AvatarPrimitive.Root <div className="relative inline-block">
data-slot="avatar" <AvatarPrimitive.Root
className={cn( data-slot="avatar"
"relative flex size-8 shrink-0 overflow-hidden rounded-full", className={cn(
className, "relative flex size-8 shrink-0 overflow-hidden rounded-full",
className,
)}
{...props}
/>
{isOnline && (
<span className="absolute bottom-0 right-0 block h-2.5 w-2.5 rounded-full bg-green-500 ring-2 ring-white dark:ring-zinc-900" />
)} )}
{...props} </div>
/>
); );
} }

View File

@@ -55,4 +55,8 @@ export const MessageService = {
}); });
return data; return data;
}, },
async markAsRead(conversationId: string): Promise<void> {
await api.patch(`/messages/conversations/${conversationId}/read`);
},
}; };

View File

@@ -9,6 +9,8 @@ export interface User {
role?: "user" | "admin" | "moderator"; role?: "user" | "admin" | "moderator";
status?: "active" | "verification" | "suspended" | "pending" | "deleted"; status?: "active" | "verification" | "suspended" | "pending" | "deleted";
twoFactorEnabled?: boolean; twoFactorEnabled?: boolean;
showOnlineStatus?: boolean;
showReadReceipts?: boolean;
createdAt: string; createdAt: string;
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "@memegoat/source", "name": "@memegoat/source",
"version": "1.9.0", "version": "1.9.5",
"description": "", "description": "",
"scripts": { "scripts": {
"version:get": "cmake -P version.cmake GET", "version:get": "cmake -P version.cmake GET",