Merge pull request #2 from AeThex-Corporation/claude/find-fix-bug-mkkscfv6hiao6uvi-LBUkV
Claude/find fix bug mkkscfv6hiao6uvi lb uk v
This commit is contained in:
commit
fa1d0fcc70
11 changed files with 442 additions and 40 deletions
13
.env.example
13
.env.example
|
|
@ -4,6 +4,11 @@ DATABASE_URL=postgresql://user:password@localhost:5432/aethex_passport
|
|||
# Server Configuration
|
||||
PORT=3000
|
||||
NODE_ENV=development
|
||||
FRONTEND_URL=http://localhost:5173
|
||||
|
||||
# Development Security (ONLY for development, DO NOT enable in production)
|
||||
# Allows bypassing authentication - requires BOTH NODE_ENV=development AND ALLOW_DEV_BYPASS=true
|
||||
ALLOW_DEV_BYPASS=true
|
||||
|
||||
# Blockchain Configuration (for .aethex domain verification)
|
||||
RPC_ENDPOINT=https://polygon-mainnet.infura.io/v3/YOUR_INFURA_KEY
|
||||
|
|
@ -16,7 +21,13 @@ JWT_SECRET=your-secret-key-here
|
|||
RATE_LIMIT_WINDOW_MS=900000
|
||||
RATE_LIMIT_MAX_REQUESTS=100
|
||||
# TURN Server Configuration (for WebRTC NAT traversal)
|
||||
# CRITICAL: These MUST be set in production - no defaults allowed
|
||||
TURN_SERVER_HOST=turn.example.com
|
||||
TURN_SERVER_PORT=3478
|
||||
TURN_SECRET=your-turn-secret-key
|
||||
TURN_TTL=86400
|
||||
TURN_TTL=86400
|
||||
|
||||
# Stripe Configuration (for payments)
|
||||
STRIPE_SECRET_KEY=sk_test_...
|
||||
STRIPE_PUBLISHABLE_KEY=pk_test_...
|
||||
STRIPE_WEBHOOK_SECRET=whsec_...
|
||||
|
|
@ -7,21 +7,28 @@ const jwt = require('jsonwebtoken');
|
|||
function authenticateUser(req, res, next) {
|
||||
try {
|
||||
// In development mode, allow requests without auth for testing
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
// SECURITY: This bypass only works if BOTH conditions are met:
|
||||
// 1. NODE_ENV is 'development'
|
||||
// 2. ALLOW_DEV_BYPASS is explicitly set to 'true'
|
||||
const isDevelopment = process.env.NODE_ENV === 'development';
|
||||
const allowDevBypass = process.env.ALLOW_DEV_BYPASS === 'true';
|
||||
|
||||
if (isDevelopment && allowDevBypass) {
|
||||
// Check for token, but if not present, use demo user
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
// Use demo user for development
|
||||
console.warn('⚠️ Development mode: Using demo user without authentication');
|
||||
req.user = {
|
||||
id: 'demo-user-123',
|
||||
email: 'demo@aethex.dev'
|
||||
};
|
||||
return next();
|
||||
}
|
||||
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
|
||||
|
||||
// Try to verify, but don't fail if invalid
|
||||
try {
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
|
|
@ -31,15 +38,16 @@ function authenticateUser(req, res, next) {
|
|||
};
|
||||
} catch {
|
||||
// Use demo user if token invalid
|
||||
console.warn('⚠️ Development mode: Invalid token, using demo user');
|
||||
req.user = {
|
||||
id: 'demo-user-123',
|
||||
email: 'demo@aethex.dev'
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
return next();
|
||||
}
|
||||
|
||||
|
||||
// Production: Strict auth required
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ const express = require('express');
|
|||
const router = express.Router();
|
||||
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
|
||||
const db = require('../../database/db');
|
||||
const notificationService = require('../../services/notificationService');
|
||||
|
||||
/**
|
||||
* Stripe webhook endpoint
|
||||
|
|
@ -143,8 +144,8 @@ async function handleSubscriptionDeleted(subscription) {
|
|||
|
||||
console.log(`[Webhook] User ${userId} downgraded to free tier`);
|
||||
|
||||
// TODO: Send notification to user about downgrade
|
||||
// Could trigger email, in-app notification, etc.
|
||||
// Send notification to user about downgrade
|
||||
await notificationService.notifySubscriptionDowngrade(userId);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -177,7 +178,13 @@ async function handlePaymentSucceeded(invoice) {
|
|||
|
||||
console.log(`[Webhook] Payment logged for user ${subResult.rows[0].user_id}`);
|
||||
|
||||
// TODO: Send receipt email to user
|
||||
// Send receipt email to user
|
||||
const amount = invoice.amount_paid / 100;
|
||||
await notificationService.notifyPaymentSuccess(
|
||||
subResult.rows[0].user_id,
|
||||
amount,
|
||||
invoice.id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -213,8 +220,10 @@ async function handlePaymentFailed(invoice) {
|
|||
|
||||
console.log(`[Webhook] Failed payment logged for user ${userId}`);
|
||||
|
||||
// TODO: Send notification to user about failed payment
|
||||
// Include link to update payment method
|
||||
// Send notification to user about failed payment
|
||||
const amount = invoice.amount_due / 100;
|
||||
const reason = invoice.last_payment_error?.message || 'Unknown error';
|
||||
await notificationService.notifyPaymentFailed(userId, amount, reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -232,9 +241,16 @@ async function handleTrialWillEnd(subscription) {
|
|||
);
|
||||
|
||||
if (subResult.rows.length > 0) {
|
||||
// TODO: Send notification to user that trial is ending
|
||||
// Remind them to add payment method
|
||||
console.log(`[Webhook] Trial ending notification for user ${subResult.rows[0].user_id}`);
|
||||
const userId = subResult.rows[0].user_id;
|
||||
|
||||
// Calculate days remaining in trial
|
||||
const trialEnd = new Date(subscription.trial_end * 1000);
|
||||
const now = new Date();
|
||||
const daysRemaining = Math.ceil((trialEnd - now) / (1000 * 60 * 60 * 24));
|
||||
|
||||
// Send notification to user that trial is ending
|
||||
await notificationService.notifyTrialEnding(userId, daysRemaining);
|
||||
console.log(`[Webhook] Trial ending notification sent to user ${userId}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -29,6 +29,10 @@ class CallService {
|
|||
[conversationId]
|
||||
);
|
||||
|
||||
if (conversationResult.rows.length === 0) {
|
||||
throw new Error(`Conversation ${conversationId} not found`);
|
||||
}
|
||||
|
||||
const conversation = conversationResult.rows[0];
|
||||
|
||||
// Determine if group call
|
||||
|
|
@ -249,9 +253,12 @@ class CallService {
|
|||
async generateTURNCredentials(userId) {
|
||||
const timestamp = Math.floor(Date.now() / 1000) + 86400; // 24 hour TTL
|
||||
const username = `${timestamp}:${userId}`;
|
||||
|
||||
|
||||
// Generate credential using HMAC
|
||||
const turnSecret = process.env.TURN_SECRET || 'default-secret-change-me';
|
||||
const turnSecret = process.env.TURN_SECRET;
|
||||
if (!turnSecret) {
|
||||
throw new Error('TURN_SECRET environment variable must be set for TURN server authentication');
|
||||
}
|
||||
const hmac = crypto.createHmac('sha1', turnSecret);
|
||||
hmac.update(username);
|
||||
const credential = hmac.digest('base64');
|
||||
|
|
@ -265,8 +272,12 @@ class CallService {
|
|||
[userId, username, credential, timestamp]
|
||||
);
|
||||
|
||||
const turnHost = process.env.TURN_SERVER_HOST || 'turn.aethex.app';
|
||||
const turnPort = process.env.TURN_SERVER_PORT || '3478';
|
||||
const turnHost = process.env.TURN_SERVER_HOST;
|
||||
const turnPort = process.env.TURN_SERVER_PORT;
|
||||
|
||||
if (!turnHost || !turnPort) {
|
||||
throw new Error('TURN_SERVER_HOST and TURN_SERVER_PORT environment variables must be set');
|
||||
}
|
||||
|
||||
return {
|
||||
urls: [
|
||||
|
|
|
|||
|
|
@ -36,6 +36,10 @@ class GameForgeIntegrationService {
|
|||
]
|
||||
);
|
||||
|
||||
if (integrationResult.rows.length === 0) {
|
||||
throw new Error('Failed to create GameForge integration');
|
||||
}
|
||||
|
||||
const integration = integrationResult.rows[0];
|
||||
|
||||
// Create channels
|
||||
|
|
@ -88,13 +92,17 @@ class GameForgeIntegrationService {
|
|||
const description = this.getChannelDescription(channelName);
|
||||
|
||||
const conversationResult = await db.query(
|
||||
`INSERT INTO conversations
|
||||
`INSERT INTO conversations
|
||||
(type, title, description, created_by, gameforge_project_id)
|
||||
VALUES ('group', $1, $2, $3, $4)
|
||||
RETURNING *`,
|
||||
[title, description, ownerId, projectId]
|
||||
);
|
||||
|
||||
if (conversationResult.rows.length === 0) {
|
||||
throw new Error('Failed to create conversation for GameForge channel');
|
||||
}
|
||||
|
||||
const conversation = conversationResult.rows[0];
|
||||
|
||||
// Add team members based on permissions
|
||||
|
|
|
|||
|
|
@ -263,9 +263,37 @@ class MessagingService {
|
|||
* Send a message
|
||||
*/
|
||||
async sendMessage(userId, conversationId, { content, contentType = 'text', metadata = null, replyToId = null, identityId = null }) {
|
||||
// Validate required parameters
|
||||
if (!userId || typeof userId !== 'string') {
|
||||
throw new Error('Invalid userId parameter');
|
||||
}
|
||||
|
||||
if (!conversationId || typeof conversationId !== 'string') {
|
||||
throw new Error('Invalid conversationId parameter');
|
||||
}
|
||||
|
||||
if (!content || typeof content !== 'string' || content.trim().length === 0) {
|
||||
throw new Error('Message content cannot be empty');
|
||||
}
|
||||
|
||||
if (content.length > 10000) {
|
||||
throw new Error('Message content exceeds maximum length of 10000 characters');
|
||||
}
|
||||
|
||||
// Validate contentType
|
||||
const validContentTypes = ['text', 'image', 'file', 'audio', 'video', 'code'];
|
||||
if (!validContentTypes.includes(contentType)) {
|
||||
throw new Error(`Invalid contentType. Must be one of: ${validContentTypes.join(', ')}`);
|
||||
}
|
||||
|
||||
// Validate metadata if provided
|
||||
if (metadata !== null && typeof metadata !== 'object') {
|
||||
throw new Error('metadata must be an object or null');
|
||||
}
|
||||
|
||||
// Verify user is participant
|
||||
await this.verifyParticipant(conversationId, userId);
|
||||
|
||||
|
||||
// In production, content should already be encrypted by client
|
||||
// For now, we'll just store it as-is
|
||||
const contentEncrypted = content;
|
||||
|
|
@ -287,8 +315,13 @@ class MessagingService {
|
|||
`SELECT username, verified_domain, avatar_url FROM users WHERE id = $1`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
if (userResult.rows.length === 0) {
|
||||
throw new Error(`User ${userId} not found`);
|
||||
}
|
||||
|
||||
const user = userResult.rows[0];
|
||||
|
||||
|
||||
return {
|
||||
id: message.id,
|
||||
conversationId: message.conversation_id,
|
||||
|
|
|
|||
223
src/backend/services/notificationService.js
Normal file
223
src/backend/services/notificationService.js
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
/**
|
||||
* Notification Service
|
||||
* Handles sending notifications to users via multiple channels
|
||||
*/
|
||||
|
||||
const db = require('../database/db');
|
||||
|
||||
class NotificationService {
|
||||
/**
|
||||
* Send notification to user
|
||||
* @param {string} userId - User ID to notify
|
||||
* @param {Object} notification - Notification details
|
||||
* @param {string} notification.type - Type of notification (email, in-app, both)
|
||||
* @param {string} notification.category - Category (payment, subscription, system, etc.)
|
||||
* @param {string} notification.title - Notification title
|
||||
* @param {string} notification.message - Notification message
|
||||
* @param {Object} notification.metadata - Additional metadata
|
||||
*/
|
||||
async sendNotification(userId, notification) {
|
||||
const { type = 'in-app', category, title, message, metadata = {} } = notification;
|
||||
|
||||
try {
|
||||
// Store notification in database
|
||||
const result = await db.query(
|
||||
`INSERT INTO notifications
|
||||
(user_id, type, category, title, message, metadata, read, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, false, NOW())
|
||||
RETURNING *`,
|
||||
[userId, type, category, title, message, JSON.stringify(metadata)]
|
||||
);
|
||||
|
||||
const savedNotification = result.rows[0];
|
||||
|
||||
// If email notification requested, queue email
|
||||
if (type === 'email' || type === 'both') {
|
||||
await this.queueEmail(userId, notification);
|
||||
}
|
||||
|
||||
// Emit real-time notification via Socket.io if available
|
||||
if (global.io) {
|
||||
global.io.to(`user:${userId}`).emit('notification', {
|
||||
id: savedNotification.id,
|
||||
category,
|
||||
title,
|
||||
message,
|
||||
metadata,
|
||||
createdAt: savedNotification.created_at
|
||||
});
|
||||
}
|
||||
|
||||
return savedNotification;
|
||||
} catch (error) {
|
||||
console.error('Failed to send notification:', error);
|
||||
// Don't throw - notifications should be best-effort
|
||||
// Log error but don't fail the parent operation
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue email for sending
|
||||
* In production, this would integrate with an email service like SendGrid, SES, etc.
|
||||
*/
|
||||
async queueEmail(userId, notification) {
|
||||
const { title, message, metadata = {} } = notification;
|
||||
|
||||
try {
|
||||
// Get user email
|
||||
const userResult = await db.query(
|
||||
`SELECT email, username FROM users WHERE id = $1`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
if (userResult.rows.length === 0) {
|
||||
console.error(`User ${userId} not found for email notification`);
|
||||
return;
|
||||
}
|
||||
|
||||
const user = userResult.rows[0];
|
||||
|
||||
// Queue email in database (to be processed by email worker)
|
||||
await db.query(
|
||||
`INSERT INTO email_queue
|
||||
(user_id, recipient_email, subject, body, template, metadata, status, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 'pending', NOW())`,
|
||||
[
|
||||
userId,
|
||||
user.email,
|
||||
title,
|
||||
message,
|
||||
metadata.template || 'default',
|
||||
JSON.stringify({ ...metadata, username: user.username })
|
||||
]
|
||||
);
|
||||
|
||||
console.log(`Email queued for ${user.email}: ${title}`);
|
||||
} catch (error) {
|
||||
console.error('Failed to queue email:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send subscription downgrade notification
|
||||
*/
|
||||
async notifySubscriptionDowngrade(userId) {
|
||||
return this.sendNotification(userId, {
|
||||
type: 'both',
|
||||
category: 'subscription',
|
||||
title: 'Subscription Cancelled',
|
||||
message: 'Your premium subscription has been cancelled and your account has been downgraded to the free tier. You still have access to premium features until the end of your billing period.',
|
||||
metadata: {
|
||||
template: 'subscription-downgrade',
|
||||
action: {
|
||||
label: 'Resubscribe',
|
||||
url: '/premium'
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send payment receipt
|
||||
*/
|
||||
async notifyPaymentSuccess(userId, amount, invoiceId) {
|
||||
return this.sendNotification(userId, {
|
||||
type: 'email',
|
||||
category: 'payment',
|
||||
title: 'Payment Receipt',
|
||||
message: `Your payment of $${amount.toFixed(2)} has been processed successfully. Thank you for your continued subscription!`,
|
||||
metadata: {
|
||||
template: 'payment-receipt',
|
||||
amount,
|
||||
invoiceId,
|
||||
action: {
|
||||
label: 'View Invoice',
|
||||
url: `/billing/invoices/${invoiceId}`
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send payment failure notification
|
||||
*/
|
||||
async notifyPaymentFailed(userId, amount, reason = 'Unknown') {
|
||||
return this.sendNotification(userId, {
|
||||
type: 'both',
|
||||
category: 'payment',
|
||||
title: 'Payment Failed',
|
||||
message: `We were unable to process your payment of $${amount.toFixed(2)}. Please update your payment method to continue your subscription.`,
|
||||
metadata: {
|
||||
template: 'payment-failed',
|
||||
amount,
|
||||
reason,
|
||||
action: {
|
||||
label: 'Update Payment Method',
|
||||
url: '/settings/billing'
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send trial ending notification
|
||||
*/
|
||||
async notifyTrialEnding(userId, daysRemaining = 3) {
|
||||
return this.sendNotification(userId, {
|
||||
type: 'both',
|
||||
category: 'subscription',
|
||||
title: 'Trial Ending Soon',
|
||||
message: `Your premium trial will end in ${daysRemaining} days. Add a payment method to continue enjoying premium features without interruption.`,
|
||||
metadata: {
|
||||
template: 'trial-ending',
|
||||
daysRemaining,
|
||||
action: {
|
||||
label: 'Add Payment Method',
|
||||
url: '/settings/billing'
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unread notifications for user
|
||||
*/
|
||||
async getUnreadNotifications(userId, limit = 50) {
|
||||
const result = await db.query(
|
||||
`SELECT * FROM notifications
|
||||
WHERE user_id = $1 AND read = false
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2`,
|
||||
[userId, limit]
|
||||
);
|
||||
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark notification as read
|
||||
*/
|
||||
async markAsRead(notificationId, userId) {
|
||||
await db.query(
|
||||
`UPDATE notifications
|
||||
SET read = true, read_at = NOW()
|
||||
WHERE id = $1 AND user_id = $2`,
|
||||
[notificationId, userId]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark all notifications as read for user
|
||||
*/
|
||||
async markAllAsRead(userId) {
|
||||
await db.query(
|
||||
`UPDATE notifications
|
||||
SET read = true, read_at = NOW()
|
||||
WHERE user_id = $1 AND read = false`,
|
||||
[userId]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new NotificationService();
|
||||
|
|
@ -86,21 +86,88 @@ class SocketService {
|
|||
// Join all conversations user is part of
|
||||
this.joinUserConversations(socket, userId);
|
||||
|
||||
// Handle events
|
||||
socket.on('join_conversation', (data) => this.handleJoinConversation(socket, data));
|
||||
socket.on('leave_conversation', (data) => this.handleLeaveConversation(socket, data));
|
||||
socket.on('typing_start', (data) => this.handleTypingStart(socket, data));
|
||||
socket.on('typing_stop', (data) => this.handleTypingStop(socket, data));
|
||||
socket.on('call_signal', (data) => this.handleCallSignal(socket, data));
|
||||
|
||||
// Handle events with error handling
|
||||
socket.on('join_conversation', async (data) => {
|
||||
try {
|
||||
await this.handleJoinConversation(socket, data);
|
||||
} catch (error) {
|
||||
console.error('Error handling join_conversation:', error);
|
||||
socket.emit('error', { event: 'join_conversation', message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('leave_conversation', async (data) => {
|
||||
try {
|
||||
await this.handleLeaveConversation(socket, data);
|
||||
} catch (error) {
|
||||
console.error('Error handling leave_conversation:', error);
|
||||
socket.emit('error', { event: 'leave_conversation', message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('typing_start', async (data) => {
|
||||
try {
|
||||
await this.handleTypingStart(socket, data);
|
||||
} catch (error) {
|
||||
console.error('Error handling typing_start:', error);
|
||||
socket.emit('error', { event: 'typing_start', message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('typing_stop', async (data) => {
|
||||
try {
|
||||
await this.handleTypingStop(socket, data);
|
||||
} catch (error) {
|
||||
console.error('Error handling typing_stop:', error);
|
||||
socket.emit('error', { event: 'typing_stop', message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('call_signal', async (data) => {
|
||||
try {
|
||||
await this.handleCallSignal(socket, data);
|
||||
} catch (error) {
|
||||
console.error('Error handling call_signal:', error);
|
||||
socket.emit('error', { event: 'call_signal', message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Call signaling events
|
||||
socket.on('call:offer', (data) => this.handleCallOffer(socket, data));
|
||||
socket.on('call:answer', (data) => this.handleCallAnswer(socket, data));
|
||||
socket.on('call:ice-candidate', (data) => this.handleIceCandidate(socket, data));
|
||||
|
||||
socket.on('call:offer', async (data) => {
|
||||
try {
|
||||
await this.handleCallOffer(socket, data);
|
||||
} catch (error) {
|
||||
console.error('Error handling call:offer:', error);
|
||||
socket.emit('call:error', { type: 'offer', message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('call:answer', async (data) => {
|
||||
try {
|
||||
await this.handleCallAnswer(socket, data);
|
||||
} catch (error) {
|
||||
console.error('Error handling call:answer:', error);
|
||||
socket.emit('call:error', { type: 'answer', message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('call:ice-candidate', async (data) => {
|
||||
try {
|
||||
await this.handleIceCandidate(socket, data);
|
||||
} catch (error) {
|
||||
console.error('Error handling call:ice-candidate:', error);
|
||||
socket.emit('call:error', { type: 'ice-candidate', message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Disconnect handler
|
||||
socket.on('disconnect', () => {
|
||||
this.handleDisconnect(socket, userId);
|
||||
socket.on('disconnect', async () => {
|
||||
try {
|
||||
await this.handleDisconnect(socket, userId);
|
||||
} catch (error) {
|
||||
console.error('Error handling disconnect:', error);
|
||||
// Don't emit error as socket is disconnecting
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,8 +17,9 @@ export default function Chat() {
|
|||
const [loading, setLoading] = useState(true);
|
||||
const [typingUsers, setTypingUsers] = useState(new Set());
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
|
||||
const typingTimeoutRef = useRef(null);
|
||||
const activeConversationRef = useRef(null);
|
||||
|
||||
// Load conversations on mount
|
||||
useEffect(() => {
|
||||
|
|
@ -101,7 +102,9 @@ export default function Chat() {
|
|||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
// Only update messages if this conversation is still active
|
||||
// This prevents race conditions when rapidly switching conversations
|
||||
if (data.success && activeConversationRef.current === conversationId) {
|
||||
setMessages(data.messages);
|
||||
|
||||
// Mark as read
|
||||
|
|
@ -116,8 +119,16 @@ export default function Chat() {
|
|||
|
||||
// Select conversation
|
||||
const selectConversation = async (conversation) => {
|
||||
const conversationId = conversation.id;
|
||||
|
||||
// Update state and ref immediately to prevent race conditions
|
||||
setActiveConversation(conversation);
|
||||
await loadMessages(conversation.id);
|
||||
activeConversationRef.current = conversationId;
|
||||
|
||||
// Clear current messages while loading
|
||||
setMessages([]);
|
||||
|
||||
await loadMessages(conversationId);
|
||||
};
|
||||
|
||||
// Handle new message
|
||||
|
|
|
|||
|
|
@ -33,7 +33,8 @@ export function AuthProvider({ children }) {
|
|||
const login = async (email, password) => {
|
||||
// Mock login - in production, call actual API
|
||||
try {
|
||||
const response = await fetch('http://localhost:3000/api/auth/login', {
|
||||
const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:3000';
|
||||
const response = await fetch(`${apiUrl}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password })
|
||||
|
|
|
|||
|
|
@ -175,6 +175,7 @@ export async function encryptMessage(message, recipientPublicKeys) {
|
|||
|
||||
// Encrypt AES key for each recipient with their RSA public key
|
||||
const encryptedKeys = {};
|
||||
const failedRecipients = [];
|
||||
|
||||
for (const recipientKeyB64 of recipientPublicKeys) {
|
||||
try {
|
||||
|
|
@ -201,10 +202,22 @@ export async function encryptMessage(message, recipientPublicKeys) {
|
|||
|
||||
encryptedKeys[recipientKeyB64] = arrayBufferToBase64(encryptedKey);
|
||||
} catch (error) {
|
||||
console.error('Failed to encrypt for recipient:', error);
|
||||
failedRecipients.push({
|
||||
publicKey: recipientKeyB64.substring(0, 20) + '...', // Truncate for error message
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Throw error if any encryptions failed to prevent partial message delivery
|
||||
if (failedRecipients.length > 0) {
|
||||
throw new Error(
|
||||
`Failed to encrypt message for ${failedRecipients.length} recipient(s): ${
|
||||
failedRecipients.map(r => r.error).join(', ')
|
||||
}`
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
ciphertext: arrayBufferToBase64(encryptedMessage),
|
||||
iv: arrayBufferToBase64(iv),
|
||||
|
|
|
|||
Loading…
Reference in a new issue