Spaces:
Paused
Paused
Upload 56 files
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitignore +41 -0
- DEBUG_READ_RECEIPTS.md +42 -0
- Dockerfile +18 -0
- README.md +0 -0
- READ_RECEIPTS.md +43 -0
- actions/login.ts +58 -0
- actions/register.ts +31 -0
- actions/two-factor.ts +67 -0
- app/(auth)/login/page.tsx +108 -0
- app/(auth)/register/page.tsx +67 -0
- app/api/auth/[...nextauth]/route.ts +2 -0
- app/api/files/[id]/route.ts +62 -0
- app/api/upload/route.ts +61 -0
- app/favicon.ico +0 -0
- app/globals.css +158 -0
- app/layout.tsx +44 -0
- app/page.tsx +264 -0
- app/room/[roomId]/page.tsx +15 -0
- app/settings/page.tsx +115 -0
- auth.ts +82 -0
- bun.lock +0 -0
- components.json +22 -0
- components/chat/CallInterface.tsx +125 -0
- components/chat/ChatRoom.tsx +1017 -0
- components/providers.tsx +7 -0
- components/theme-provider.tsx +11 -0
- components/theme-toggle.tsx +24 -0
- components/ui/button.tsx +60 -0
- components/ui/card.tsx +92 -0
- components/ui/input.tsx +21 -0
- components/ui/scroll-area.tsx +58 -0
- docs/details.md +1362 -0
- eslint.config.mjs +18 -0
- hooks/useWebRTC.ts +158 -0
- lib/crypto.test.ts +57 -0
- lib/crypto.ts +190 -0
- lib/db.ts +46 -0
- lib/socket.ts +8 -0
- lib/tokens.ts +15 -0
- lib/utils.ts +6 -0
- models/Room.ts +23 -0
- models/User.ts +21 -0
- next-env.d.ts +6 -0
- next.config.ts +7 -0
- package.json +47 -0
- postcss.config.mjs +7 -0
- public/file.svg +1 -0
- public/globe.svg +1 -0
- public/next.svg +1 -0
- public/vercel.svg +1 -0
.gitignore
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
| 2 |
+
|
| 3 |
+
# dependencies
|
| 4 |
+
/node_modules
|
| 5 |
+
/.pnp
|
| 6 |
+
.pnp.*
|
| 7 |
+
.yarn/*
|
| 8 |
+
!.yarn/patches
|
| 9 |
+
!.yarn/plugins
|
| 10 |
+
!.yarn/releases
|
| 11 |
+
!.yarn/versions
|
| 12 |
+
|
| 13 |
+
# testing
|
| 14 |
+
/coverage
|
| 15 |
+
|
| 16 |
+
# next.js
|
| 17 |
+
/.next/
|
| 18 |
+
/out/
|
| 19 |
+
|
| 20 |
+
# production
|
| 21 |
+
/build
|
| 22 |
+
|
| 23 |
+
# misc
|
| 24 |
+
.DS_Store
|
| 25 |
+
*.pem
|
| 26 |
+
|
| 27 |
+
# debug
|
| 28 |
+
npm-debug.log*
|
| 29 |
+
yarn-debug.log*
|
| 30 |
+
yarn-error.log*
|
| 31 |
+
.pnpm-debug.log*
|
| 32 |
+
|
| 33 |
+
# env files (can opt-in for committing if needed)
|
| 34 |
+
.env*
|
| 35 |
+
|
| 36 |
+
# vercel
|
| 37 |
+
.vercel
|
| 38 |
+
|
| 39 |
+
# typescript
|
| 40 |
+
*.tsbuildinfo
|
| 41 |
+
next-env.d.ts
|
DEBUG_READ_RECEIPTS.md
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
## Read Receipts Debug Steps
|
| 2 |
+
|
| 3 |
+
### Current Issue
|
| 4 |
+
Read receipts always show single tick (sent) - never upgrade to double tick (delivered/read).
|
| 5 |
+
|
| 6 |
+
### What We've Found
|
| 7 |
+
1. Client sends messages ✓
|
| 8 |
+
2. Client receives messages ✓
|
| 9 |
+
3. Client emits `message-delivered` and `message-read` ✓
|
| 10 |
+
4. Server receives these events (should be in logs) ✓
|
| 11 |
+
5. **Server broadcasts `message-status` but sender doesn't receive it** ❌
|
| 12 |
+
|
| 13 |
+
### Problem
|
| 14 |
+
The server terminal shows NO Socket.IO logs at all! This means either:
|
| 15 |
+
- Socket.IO isn't starting
|
| 16 |
+
- Logs are being buffered by Bun
|
| 17 |
+
|
| 18 |
+
### Next Steps
|
| 19 |
+
1. **Stop the server** (Ctrl+C in terminal)
|
| 20 |
+
2. **Restart**: `bun dev`
|
| 21 |
+
3. **Look for**:
|
| 22 |
+
- "✓ Socket.IO server initialized"
|
| 23 |
+
- "NEW CLIENT CONNECTED:" when you open the chat
|
| 24 |
+
|
| 25 |
+
4. **If NO logs appear:**
|
| 26 |
+
- Socket.IO might not be working
|
| 27 |
+
- Try using `node server.ts` instead of `bun server.ts`
|
| 28 |
+
|
| 29 |
+
5. **If logs DO appear:**
|
| 30 |
+
- Send a message
|
| 31 |
+
- Look for "Received message-delivered" in server logs
|
| 32 |
+
- Look for "Broadcasted message-status to room" in server logs
|
| 33 |
+
- Check if "Received message-status" appears in SENDER browser console
|
| 34 |
+
|
| 35 |
+
### Expected Flow
|
| 36 |
+
```
|
| 37 |
+
Sender -> send-message -> Server
|
| 38 |
+
Server -> receive-message -> Receiver
|
| 39 |
+
Receiver -> message-delivered/read -> Server
|
| 40 |
+
Server -> message-status -> Sender (THIS ISN'T WORKING)
|
| 41 |
+
Sender updates UI with ✓✓
|
| 42 |
+
```
|
Dockerfile
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM oven/bun:1 AS base
|
| 2 |
+
WORKDIR /app
|
| 3 |
+
|
| 4 |
+
# Install dependencies
|
| 5 |
+
COPY package.json bun.lock* ./
|
| 6 |
+
RUN bun install --frozen-lockfile
|
| 7 |
+
|
| 8 |
+
# Copy source
|
| 9 |
+
COPY . .
|
| 10 |
+
|
| 11 |
+
# Build
|
| 12 |
+
ENV NODE_ENV=production
|
| 13 |
+
RUN bun run build
|
| 14 |
+
|
| 15 |
+
# Run
|
| 16 |
+
ENV AUTH_TRUST_HOST=true
|
| 17 |
+
EXPOSE 8000
|
| 18 |
+
CMD ["bun", "server.ts"]
|
README.md
CHANGED
|
Binary files a/README.md and b/README.md differ
|
|
|
READ_RECEIPTS.md
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Read Receipts Implementation
|
| 2 |
+
|
| 3 |
+
## Overview
|
| 4 |
+
Read receipts are now fully functional in the chat application. Messages show different status indicators based on their delivery and read state.
|
| 5 |
+
|
| 6 |
+
## How It Works
|
| 7 |
+
|
| 8 |
+
### Client Side (ChatRoom.tsx)
|
| 9 |
+
1. **Sending Messages**: When a user sends a message, it's added locally with status `"sent"`
|
| 10 |
+
2. **Receiving Messages**: When a message is received:
|
| 11 |
+
- Client immediately emits `message-delivered` event to server
|
| 12 |
+
- After successful decryption and display, client emits `message-read` event
|
| 13 |
+
3. **Status Updates**: Client listens for `message-status` events and updates message status accordingly
|
| 14 |
+
|
| 15 |
+
### Server Side (server.ts)
|
| 16 |
+
1. **message-delivered**: Broadcasts status update to all users in the room
|
| 17 |
+
2. **message-read**: Broadcasts status update to all users in the room
|
| 18 |
+
3. Original sender filters updates by checking `originalSenderId === socket.id`
|
| 19 |
+
|
| 20 |
+
### Status Progression
|
| 21 |
+
- **sent** (single gray check): Message sent to server
|
| 22 |
+
- **delivered** (double gray check): Message received by recipient
|
| 23 |
+
- **read** (double blue check): Message decrypted and displayed by recipient
|
| 24 |
+
|
| 25 |
+
## Visual Indicators
|
| 26 |
+
- ✓ (gray) = Sent
|
| 27 |
+
- ✓✓ (gray) = Delivered
|
| 28 |
+
- ✓✓ (blue) = Read
|
| 29 |
+
|
| 30 |
+
## Testing
|
| 31 |
+
1. Open two browser windows/tabs
|
| 32 |
+
2. Join the same room from both
|
| 33 |
+
3. Send a message from Window A
|
| 34 |
+
4. Watch the status indicator change:
|
| 35 |
+
- Starts as single gray check (sent)
|
| 36 |
+
- Changes to double gray checks (delivered)
|
| 37 |
+
- Changes to double blue checks (read)
|
| 38 |
+
|
| 39 |
+
## Technical Details
|
| 40 |
+
- Message IDs are used to track status updates
|
| 41 |
+
- Status updates only apply to the original sender
|
| 42 |
+
- Status can only upgrade (sent → delivered → read), never downgrade
|
| 43 |
+
- All communication is end-to-end encrypted
|
actions/login.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use server";
|
| 2 |
+
|
| 3 |
+
import { signIn } from "@/auth";
|
| 4 |
+
import { AuthError } from "next-auth";
|
| 5 |
+
import User from "@/models/User";
|
| 6 |
+
import dbConnect from "@/lib/db";
|
| 7 |
+
import { compare } from "bcryptjs";
|
| 8 |
+
|
| 9 |
+
export const login = async (values: any) => {
|
| 10 |
+
const { email, password, token } = values;
|
| 11 |
+
|
| 12 |
+
try {
|
| 13 |
+
// Step 1: If no token provided, check credentials and 2FA status
|
| 14 |
+
if (!token) {
|
| 15 |
+
await dbConnect();
|
| 16 |
+
const user = await User.findOne({ email });
|
| 17 |
+
|
| 18 |
+
if (!user || !user.password) {
|
| 19 |
+
return { error: "Invalid credentials" };
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
const isPasswordValid = await compare(password, user.password);
|
| 23 |
+
|
| 24 |
+
if (!isPasswordValid) {
|
| 25 |
+
return { error: "Invalid credentials" };
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
// If 2FA is enabled, signal frontend to show 2FA input
|
| 29 |
+
if (user.isTwoFactorEnabled) {
|
| 30 |
+
return { requires2FA: true };
|
| 31 |
+
}
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
// Step 2: Proceed with signIn (either no 2FA, or 2FA token provided)
|
| 35 |
+
const result = await signIn("credentials", {
|
| 36 |
+
email,
|
| 37 |
+
password,
|
| 38 |
+
token,
|
| 39 |
+
redirect: false,
|
| 40 |
+
});
|
| 41 |
+
|
| 42 |
+
if (result?.error) {
|
| 43 |
+
return { error: "Invalid 2FA token" };
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
return { success: true };
|
| 47 |
+
} catch (error) {
|
| 48 |
+
if (error instanceof AuthError) {
|
| 49 |
+
switch (error.type) {
|
| 50 |
+
case "CredentialsSignin":
|
| 51 |
+
return { error: "Invalid 2FA token" };
|
| 52 |
+
default:
|
| 53 |
+
return { error: "Something went wrong" };
|
| 54 |
+
}
|
| 55 |
+
}
|
| 56 |
+
throw error;
|
| 57 |
+
}
|
| 58 |
+
};
|
actions/register.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use server";
|
| 2 |
+
|
| 3 |
+
import { hash } from "bcryptjs";
|
| 4 |
+
import User from "@/models/User";
|
| 5 |
+
import dbConnect from "@/lib/db";
|
| 6 |
+
|
| 7 |
+
export const registerUser = async (formData: FormData) => {
|
| 8 |
+
const email = formData.get("email") as string;
|
| 9 |
+
const password = formData.get("password") as string;
|
| 10 |
+
|
| 11 |
+
if (!email || !password) {
|
| 12 |
+
return { error: "Email and password are required" };
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
await dbConnect();
|
| 16 |
+
|
| 17 |
+
const existingUser = await User.findOne({ email });
|
| 18 |
+
|
| 19 |
+
if (existingUser) {
|
| 20 |
+
return { error: "User already exists" };
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
const hashedPassword = await hash(password, 10);
|
| 24 |
+
|
| 25 |
+
await User.create({
|
| 26 |
+
email,
|
| 27 |
+
password: hashedPassword,
|
| 28 |
+
});
|
| 29 |
+
|
| 30 |
+
return { success: "User created successfully" };
|
| 31 |
+
};
|
actions/two-factor.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use server";
|
| 2 |
+
|
| 3 |
+
import { auth } from "@/auth";
|
| 4 |
+
import User from "@/models/User";
|
| 5 |
+
import dbConnect from "@/lib/db";
|
| 6 |
+
import { generateTwoFactorSecret, verifyTwoFactorToken } from "@/lib/tokens";
|
| 7 |
+
|
| 8 |
+
export const getTwoFactorStatus = async () => {
|
| 9 |
+
const session = await auth();
|
| 10 |
+
if (!session?.user?.email) return { error: "Unauthorized" };
|
| 11 |
+
|
| 12 |
+
await dbConnect();
|
| 13 |
+
const user = await User.findOne({ email: session.user.email });
|
| 14 |
+
return { isEnabled: user?.isTwoFactorEnabled };
|
| 15 |
+
};
|
| 16 |
+
|
| 17 |
+
export const enableTwoFactor = async () => {
|
| 18 |
+
const session = await auth();
|
| 19 |
+
if (!session?.user?.email) return { error: "Unauthorized" };
|
| 20 |
+
|
| 21 |
+
await dbConnect();
|
| 22 |
+
const user = await User.findOne({ email: session.user.email });
|
| 23 |
+
|
| 24 |
+
if (!user) return { error: "User not found" };
|
| 25 |
+
|
| 26 |
+
const { secret, qrCodeUrl } = await generateTwoFactorSecret(user.email);
|
| 27 |
+
|
| 28 |
+
user.twoFactorSecret = secret;
|
| 29 |
+
await user.save();
|
| 30 |
+
|
| 31 |
+
return { secret, qrCodeUrl };
|
| 32 |
+
};
|
| 33 |
+
|
| 34 |
+
export const confirmTwoFactor = async (token: string) => {
|
| 35 |
+
const session = await auth();
|
| 36 |
+
if (!session?.user?.email) return { error: "Unauthorized" };
|
| 37 |
+
|
| 38 |
+
await dbConnect();
|
| 39 |
+
const user = await User.findOne({ email: session.user.email });
|
| 40 |
+
|
| 41 |
+
if (!user || !user.twoFactorSecret) return { error: "User not found or 2FA not initiated" };
|
| 42 |
+
|
| 43 |
+
const isValid = verifyTwoFactorToken(token, user.twoFactorSecret);
|
| 44 |
+
|
| 45 |
+
if (!isValid) return { error: "Invalid token" };
|
| 46 |
+
|
| 47 |
+
user.isTwoFactorEnabled = true;
|
| 48 |
+
await user.save();
|
| 49 |
+
|
| 50 |
+
return { success: true };
|
| 51 |
+
};
|
| 52 |
+
|
| 53 |
+
export const disableTwoFactor = async () => {
|
| 54 |
+
const session = await auth();
|
| 55 |
+
if (!session?.user?.email) return { error: "Unauthorized" };
|
| 56 |
+
|
| 57 |
+
await dbConnect();
|
| 58 |
+
const user = await User.findOne({ email: session.user.email });
|
| 59 |
+
|
| 60 |
+
if (!user) return { error: "User not found" };
|
| 61 |
+
|
| 62 |
+
user.isTwoFactorEnabled = false;
|
| 63 |
+
user.twoFactorSecret = undefined;
|
| 64 |
+
await user.save();
|
| 65 |
+
|
| 66 |
+
return { success: true };
|
| 67 |
+
};
|
app/(auth)/login/page.tsx
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useState } from "react";
|
| 4 |
+
import { useRouter } from "next/navigation";
|
| 5 |
+
import { login } from "@/actions/login";
|
| 6 |
+
import { Button } from "@/components/ui/button";
|
| 7 |
+
import { Input } from "@/components/ui/input";
|
| 8 |
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
| 9 |
+
import Link from "next/link";
|
| 10 |
+
|
| 11 |
+
export default function LoginPage() {
|
| 12 |
+
const [email, setEmail] = useState("");
|
| 13 |
+
const [password, setPassword] = useState("");
|
| 14 |
+
const [token, setToken] = useState("");
|
| 15 |
+
const [showTwoFactor, setShowTwoFactor] = useState(false);
|
| 16 |
+
const [error, setError] = useState("");
|
| 17 |
+
const router = useRouter();
|
| 18 |
+
|
| 19 |
+
const handleSubmit = async (e: React.FormEvent) => {
|
| 20 |
+
e.preventDefault();
|
| 21 |
+
setError("");
|
| 22 |
+
|
| 23 |
+
try {
|
| 24 |
+
const res = await login({
|
| 25 |
+
email,
|
| 26 |
+
password,
|
| 27 |
+
token: showTwoFactor ? token : undefined,
|
| 28 |
+
});
|
| 29 |
+
|
| 30 |
+
if (res?.requires2FA) {
|
| 31 |
+
setShowTwoFactor(true);
|
| 32 |
+
} else if (res?.error) {
|
| 33 |
+
setError(res.error);
|
| 34 |
+
} else {
|
| 35 |
+
router.push("/settings");
|
| 36 |
+
router.refresh();
|
| 37 |
+
}
|
| 38 |
+
} catch (err) {
|
| 39 |
+
setError("Something went wrong");
|
| 40 |
+
}
|
| 41 |
+
};
|
| 42 |
+
|
| 43 |
+
return (
|
| 44 |
+
<div className="flex items-center justify-center min-h-screen bg-slate-950">
|
| 45 |
+
<Card className="w-[350px] bg-slate-900 border-slate-800 text-slate-100">
|
| 46 |
+
<CardHeader>
|
| 47 |
+
<CardTitle>Login</CardTitle>
|
| 48 |
+
</CardHeader>
|
| 49 |
+
<CardContent>
|
| 50 |
+
<form onSubmit={handleSubmit} className="space-y-4">
|
| 51 |
+
{!showTwoFactor && (
|
| 52 |
+
<>
|
| 53 |
+
<div className="space-y-2">
|
| 54 |
+
<label htmlFor="email">Email</label>
|
| 55 |
+
<Input
|
| 56 |
+
id="email"
|
| 57 |
+
type="email"
|
| 58 |
+
value={email}
|
| 59 |
+
onChange={(e) => setEmail(e.target.value)}
|
| 60 |
+
required
|
| 61 |
+
className="bg-slate-950 border-slate-800"
|
| 62 |
+
/>
|
| 63 |
+
</div>
|
| 64 |
+
<div className="space-y-2">
|
| 65 |
+
<label htmlFor="password">Password</label>
|
| 66 |
+
<Input
|
| 67 |
+
id="password"
|
| 68 |
+
type="password"
|
| 69 |
+
value={password}
|
| 70 |
+
onChange={(e) => setPassword(e.target.value)}
|
| 71 |
+
required
|
| 72 |
+
className="bg-slate-950 border-slate-800"
|
| 73 |
+
/>
|
| 74 |
+
</div>
|
| 75 |
+
</>
|
| 76 |
+
)}
|
| 77 |
+
|
| 78 |
+
{showTwoFactor && (
|
| 79 |
+
<div className="space-y-2">
|
| 80 |
+
<label htmlFor="token">2FA Code</label>
|
| 81 |
+
<Input
|
| 82 |
+
id="token"
|
| 83 |
+
type="text"
|
| 84 |
+
value={token}
|
| 85 |
+
onChange={(e) => setToken(e.target.value)}
|
| 86 |
+
required
|
| 87 |
+
placeholder="123456"
|
| 88 |
+
className="bg-slate-950 border-slate-800"
|
| 89 |
+
/>
|
| 90 |
+
</div>
|
| 91 |
+
)}
|
| 92 |
+
|
| 93 |
+
{error && <p className="text-red-500 text-sm">{error}</p>}
|
| 94 |
+
|
| 95 |
+
<Button type="submit" className="w-full bg-emerald-600 hover:bg-emerald-700">
|
| 96 |
+
{showTwoFactor ? "Verify" : "Login"}
|
| 97 |
+
</Button>
|
| 98 |
+
</form>
|
| 99 |
+
<div className="mt-4 text-center text-sm">
|
| 100 |
+
<Link href="/register" className="text-emerald-500 hover:underline">
|
| 101 |
+
Don't have an account? Register
|
| 102 |
+
</Link>
|
| 103 |
+
</div>
|
| 104 |
+
</CardContent>
|
| 105 |
+
</Card>
|
| 106 |
+
</div>
|
| 107 |
+
);
|
| 108 |
+
}
|
app/(auth)/register/page.tsx
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useState } from "react";
|
| 4 |
+
import { useRouter } from "next/navigation";
|
| 5 |
+
import { Button } from "@/components/ui/button";
|
| 6 |
+
import { Input } from "@/components/ui/input";
|
| 7 |
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
| 8 |
+
import { registerUser } from "@/actions/register";
|
| 9 |
+
import Link from "next/link";
|
| 10 |
+
|
| 11 |
+
export default function RegisterPage() {
|
| 12 |
+
const [error, setError] = useState("");
|
| 13 |
+
const router = useRouter();
|
| 14 |
+
|
| 15 |
+
const handleSubmit = async (formData: FormData) => {
|
| 16 |
+
const res = await registerUser(formData);
|
| 17 |
+
|
| 18 |
+
if (res.error) {
|
| 19 |
+
setError(res.error);
|
| 20 |
+
} else {
|
| 21 |
+
router.push("/login");
|
| 22 |
+
}
|
| 23 |
+
};
|
| 24 |
+
|
| 25 |
+
return (
|
| 26 |
+
<div className="flex items-center justify-center min-h-screen bg-slate-950">
|
| 27 |
+
<Card className="w-[350px] bg-slate-900 border-slate-800 text-slate-100">
|
| 28 |
+
<CardHeader>
|
| 29 |
+
<CardTitle>Create Account</CardTitle>
|
| 30 |
+
</CardHeader>
|
| 31 |
+
<CardContent>
|
| 32 |
+
<form action={handleSubmit} className="space-y-4">
|
| 33 |
+
<div className="space-y-2">
|
| 34 |
+
<label htmlFor="email">Email</label>
|
| 35 |
+
<Input
|
| 36 |
+
id="email"
|
| 37 |
+
name="email"
|
| 38 |
+
type="email"
|
| 39 |
+
required
|
| 40 |
+
className="bg-slate-950 border-slate-800"
|
| 41 |
+
/>
|
| 42 |
+
</div>
|
| 43 |
+
<div className="space-y-2">
|
| 44 |
+
<label htmlFor="password">Password</label>
|
| 45 |
+
<Input
|
| 46 |
+
id="password"
|
| 47 |
+
name="password"
|
| 48 |
+
type="password"
|
| 49 |
+
required
|
| 50 |
+
className="bg-slate-950 border-slate-800"
|
| 51 |
+
/>
|
| 52 |
+
</div>
|
| 53 |
+
{error && <p className="text-red-500 text-sm">{error}</p>}
|
| 54 |
+
<Button type="submit" className="w-full bg-emerald-600 hover:bg-emerald-700">
|
| 55 |
+
Register
|
| 56 |
+
</Button>
|
| 57 |
+
</form>
|
| 58 |
+
<div className="mt-4 text-center text-sm">
|
| 59 |
+
<Link href="/login" className="text-emerald-500 hover:underline">
|
| 60 |
+
Already have an account? Login
|
| 61 |
+
</Link>
|
| 62 |
+
</div>
|
| 63 |
+
</CardContent>
|
| 64 |
+
</Card>
|
| 65 |
+
</div>
|
| 66 |
+
);
|
| 67 |
+
}
|
app/api/auth/[...nextauth]/route.ts
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { handlers } from "@/auth" // Referring to the auth.ts we just created
|
| 2 |
+
export const { GET, POST } = handlers
|
app/api/files/[id]/route.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from "next/server";
|
| 2 |
+
import { readFile, unlink } from "fs/promises";
|
| 3 |
+
import { join } from "path";
|
| 4 |
+
import { existsSync } from "fs";
|
| 5 |
+
|
| 6 |
+
const UPLOAD_DIR = join(process.cwd(), "uploads");
|
| 7 |
+
|
| 8 |
+
export async function GET(
|
| 9 |
+
request: NextRequest,
|
| 10 |
+
{ params }: { params: Promise<{ id: string }> }
|
| 11 |
+
) {
|
| 12 |
+
try {
|
| 13 |
+
const { id } = await params;
|
| 14 |
+
const fileId = id;
|
| 15 |
+
const filePath = join(UPLOAD_DIR, fileId);
|
| 16 |
+
|
| 17 |
+
if (!existsSync(filePath)) {
|
| 18 |
+
return NextResponse.json(
|
| 19 |
+
{ error: "File not found" },
|
| 20 |
+
{ status: 404 }
|
| 21 |
+
);
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
const fileBuffer = await readFile(filePath);
|
| 25 |
+
|
| 26 |
+
return new NextResponse(fileBuffer, {
|
| 27 |
+
headers: {
|
| 28 |
+
"Content-Type": "application/octet-stream",
|
| 29 |
+
"Content-Disposition": "attachment",
|
| 30 |
+
},
|
| 31 |
+
});
|
| 32 |
+
} catch (error) {
|
| 33 |
+
console.error("Download error:", error);
|
| 34 |
+
return NextResponse.json(
|
| 35 |
+
{ error: "Download failed" },
|
| 36 |
+
{ status: 500 }
|
| 37 |
+
);
|
| 38 |
+
}
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
export async function DELETE(
|
| 42 |
+
request: NextRequest,
|
| 43 |
+
{ params }: { params: Promise<{ id: string }> }
|
| 44 |
+
) {
|
| 45 |
+
try {
|
| 46 |
+
const { id } = await params;
|
| 47 |
+
const fileId = id;
|
| 48 |
+
const filePath = join(UPLOAD_DIR, fileId);
|
| 49 |
+
|
| 50 |
+
if (existsSync(filePath)) {
|
| 51 |
+
await unlink(filePath);
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
return NextResponse.json({ success: true });
|
| 55 |
+
} catch (error) {
|
| 56 |
+
console.error("Delete error:", error);
|
| 57 |
+
return NextResponse.json(
|
| 58 |
+
{ error: "Delete failed" },
|
| 59 |
+
{ status: 500 }
|
| 60 |
+
);
|
| 61 |
+
}
|
| 62 |
+
}
|
app/api/upload/route.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from "next/server";
|
| 2 |
+
import { writeFile, mkdir } from "fs/promises";
|
| 3 |
+
import { join } from "path";
|
| 4 |
+
import { existsSync } from "fs";
|
| 5 |
+
|
| 6 |
+
const UPLOAD_DIR = join(process.cwd(), "uploads");
|
| 7 |
+
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
| 8 |
+
|
| 9 |
+
// Ensure upload directory exists
|
| 10 |
+
async function ensureUploadDir() {
|
| 11 |
+
if (!existsSync(UPLOAD_DIR)) {
|
| 12 |
+
await mkdir(UPLOAD_DIR, { recursive: true });
|
| 13 |
+
}
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
export async function POST(request: NextRequest) {
|
| 17 |
+
try {
|
| 18 |
+
const formData = await request.formData();
|
| 19 |
+
const file = formData.get("file") as File;
|
| 20 |
+
|
| 21 |
+
if (!file) {
|
| 22 |
+
return NextResponse.json(
|
| 23 |
+
{ error: "No file provided" },
|
| 24 |
+
{ status: 400 }
|
| 25 |
+
);
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
// Check file size
|
| 29 |
+
if (file.size > MAX_FILE_SIZE) {
|
| 30 |
+
return NextResponse.json(
|
| 31 |
+
{ error: "File too large. Maximum size is 10MB" },
|
| 32 |
+
{ status: 400 }
|
| 33 |
+
);
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
// Ensure upload directory exists
|
| 37 |
+
await ensureUploadDir();
|
| 38 |
+
|
| 39 |
+
// Generate unique file ID
|
| 40 |
+
const fileId = crypto.randomUUID();
|
| 41 |
+
const bytes = await file.arrayBuffer();
|
| 42 |
+
const buffer = Buffer.from(bytes);
|
| 43 |
+
|
| 44 |
+
// Save encrypted file
|
| 45 |
+
const filePath = join(UPLOAD_DIR, fileId);
|
| 46 |
+
await writeFile(filePath, buffer);
|
| 47 |
+
|
| 48 |
+
return NextResponse.json({
|
| 49 |
+
fileId,
|
| 50 |
+
size: file.size,
|
| 51 |
+
name: file.name,
|
| 52 |
+
type: file.type,
|
| 53 |
+
});
|
| 54 |
+
} catch (error) {
|
| 55 |
+
console.error("Upload error:", error);
|
| 56 |
+
return NextResponse.json(
|
| 57 |
+
{ error: "Upload failed" },
|
| 58 |
+
{ status: 500 }
|
| 59 |
+
);
|
| 60 |
+
}
|
| 61 |
+
}
|
app/favicon.ico
ADDED
|
|
app/globals.css
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@import "tailwindcss";
|
| 2 |
+
|
| 3 |
+
@custom-variant dark (&:is(.dark *));
|
| 4 |
+
|
| 5 |
+
@theme inline {
|
| 6 |
+
--color-background: var(--background);
|
| 7 |
+
--color-foreground: var(--foreground);
|
| 8 |
+
--font-sans: var(--font-geist-sans);
|
| 9 |
+
--font-mono: var(--font-geist-mono);
|
| 10 |
+
--color-sidebar-ring: var(--sidebar-ring);
|
| 11 |
+
--color-sidebar-border: var(--sidebar-border);
|
| 12 |
+
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
| 13 |
+
--color-sidebar-accent: var(--sidebar-accent);
|
| 14 |
+
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
| 15 |
+
--color-sidebar-primary: var(--sidebar-primary);
|
| 16 |
+
--color-sidebar-foreground: var(--sidebar-foreground);
|
| 17 |
+
--color-sidebar: var(--sidebar);
|
| 18 |
+
--color-chart-5: var(--chart-5);
|
| 19 |
+
--color-chart-4: var(--chart-4);
|
| 20 |
+
--color-chart-3: var(--chart-3);
|
| 21 |
+
--color-chart-2: var(--chart-2);
|
| 22 |
+
--color-chart-1: var(--chart-1);
|
| 23 |
+
--color-ring: var(--ring);
|
| 24 |
+
--color-input: var(--input);
|
| 25 |
+
--color-border: var(--border);
|
| 26 |
+
--color-destructive: var(--destructive);
|
| 27 |
+
--color-accent-foreground: var(--accent-foreground);
|
| 28 |
+
--color-accent: var(--accent);
|
| 29 |
+
--color-muted-foreground: var(--muted-foreground);
|
| 30 |
+
--color-muted: var(--muted);
|
| 31 |
+
--color-secondary-foreground: var(--secondary-foreground);
|
| 32 |
+
--color-secondary: var(--secondary);
|
| 33 |
+
--color-primary-foreground: var(--primary-foreground);
|
| 34 |
+
--color-primary: var(--primary);
|
| 35 |
+
--color-popover-foreground: var(--popover-foreground);
|
| 36 |
+
--color-popover: var(--popover);
|
| 37 |
+
--color-card-foreground: var(--card-foreground);
|
| 38 |
+
--color-card: var(--card);
|
| 39 |
+
--radius-sm: calc(var(--radius) - 4px);
|
| 40 |
+
--radius-md: calc(var(--radius) - 2px);
|
| 41 |
+
--radius-lg: var(--radius);
|
| 42 |
+
--radius-xl: calc(var(--radius) + 4px);
|
| 43 |
+
|
| 44 |
+
--animate-fade-in: fade-in 0.5s ease-out;
|
| 45 |
+
--animate-fade-in-up: fade-in-up 0.5s ease-out;
|
| 46 |
+
--animate-pulse-slow: pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
| 47 |
+
|
| 48 |
+
@keyframes fade-in {
|
| 49 |
+
0% { opacity: 0; }
|
| 50 |
+
100% { opacity: 1; }
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
@keyframes fade-in-up {
|
| 54 |
+
0% { opacity: 0; transform: translateY(10px); }
|
| 55 |
+
100% { opacity: 1; transform: translateY(0); }
|
| 56 |
+
}
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
:root {
|
| 60 |
+
--radius: 0.625rem;
|
| 61 |
+
--background: oklch(1 0 0);
|
| 62 |
+
--foreground: oklch(0.145 0 0);
|
| 63 |
+
--card: oklch(1 0 0);
|
| 64 |
+
--card-foreground: oklch(0.145 0 0);
|
| 65 |
+
--popover: oklch(1 0 0);
|
| 66 |
+
--popover-foreground: oklch(0.145 0 0);
|
| 67 |
+
--primary: oklch(0.205 0 0);
|
| 68 |
+
--primary-foreground: oklch(0.985 0 0);
|
| 69 |
+
--secondary: oklch(0.97 0 0);
|
| 70 |
+
--secondary-foreground: oklch(0.205 0 0);
|
| 71 |
+
--muted: oklch(0.97 0 0);
|
| 72 |
+
--muted-foreground: oklch(0.556 0 0);
|
| 73 |
+
--accent: oklch(0.97 0 0);
|
| 74 |
+
--accent-foreground: oklch(0.205 0 0);
|
| 75 |
+
--destructive: oklch(0.577 0.245 27.325);
|
| 76 |
+
--border: oklch(0.922 0 0);
|
| 77 |
+
--input: oklch(0.922 0 0);
|
| 78 |
+
--ring: oklch(0.708 0 0);
|
| 79 |
+
--chart-1: oklch(0.646 0.222 41.116);
|
| 80 |
+
--chart-2: oklch(0.6 0.118 184.704);
|
| 81 |
+
--chart-3: oklch(0.398 0.07 227.392);
|
| 82 |
+
--chart-4: oklch(0.828 0.189 84.429);
|
| 83 |
+
--chart-5: oklch(0.769 0.188 70.08);
|
| 84 |
+
--sidebar: oklch(0.985 0 0);
|
| 85 |
+
--sidebar-foreground: oklch(0.145 0 0);
|
| 86 |
+
--sidebar-primary: oklch(0.205 0 0);
|
| 87 |
+
--sidebar-primary-foreground: oklch(0.985 0 0);
|
| 88 |
+
--sidebar-accent: oklch(0.97 0 0);
|
| 89 |
+
--sidebar-accent-foreground: oklch(0.205 0 0);
|
| 90 |
+
--sidebar-border: oklch(0.922 0 0);
|
| 91 |
+
--sidebar-ring: oklch(0.708 0 0);
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
.dark {
|
| 95 |
+
--background: oklch(0.12 0.01 240); /* Slightly richer dark blue-grey */
|
| 96 |
+
--foreground: oklch(0.985 0 0);
|
| 97 |
+
--card: oklch(0.18 0.02 240);
|
| 98 |
+
--card-foreground: oklch(0.985 0 0);
|
| 99 |
+
--popover: oklch(0.18 0.02 240);
|
| 100 |
+
--popover-foreground: oklch(0.985 0 0);
|
| 101 |
+
--primary: oklch(0.65 0.2 150); /* Emerald-ish */
|
| 102 |
+
--primary-foreground: oklch(0.145 0 0);
|
| 103 |
+
--secondary: oklch(0.25 0.02 240);
|
| 104 |
+
--secondary-foreground: oklch(0.985 0 0);
|
| 105 |
+
--muted: oklch(0.25 0.02 240);
|
| 106 |
+
--muted-foreground: oklch(0.7 0 0);
|
| 107 |
+
--accent: oklch(0.25 0.02 240);
|
| 108 |
+
--accent-foreground: oklch(0.985 0 0);
|
| 109 |
+
--destructive: oklch(0.6 0.2 20);
|
| 110 |
+
--border: oklch(0.3 0.02 240);
|
| 111 |
+
--input: oklch(0.3 0.02 240);
|
| 112 |
+
--ring: oklch(0.65 0.2 150);
|
| 113 |
+
--chart-1: oklch(0.488 0.243 264.376);
|
| 114 |
+
--chart-2: oklch(0.696 0.17 162.48);
|
| 115 |
+
--chart-3: oklch(0.769 0.188 70.08);
|
| 116 |
+
--chart-4: oklch(0.627 0.265 303.9);
|
| 117 |
+
--chart-5: oklch(0.645 0.246 16.439);
|
| 118 |
+
--sidebar: oklch(0.18 0.02 240);
|
| 119 |
+
--sidebar-foreground: oklch(0.985 0 0);
|
| 120 |
+
--sidebar-primary: oklch(0.65 0.2 150);
|
| 121 |
+
--sidebar-primary-foreground: oklch(0.985 0 0);
|
| 122 |
+
--sidebar-accent: oklch(0.25 0.02 240);
|
| 123 |
+
--sidebar-accent-foreground: oklch(0.985 0 0);
|
| 124 |
+
--sidebar-border: oklch(0.3 0.02 240);
|
| 125 |
+
--sidebar-ring: oklch(0.65 0.2 150);
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
@layer base {
|
| 129 |
+
* {
|
| 130 |
+
@apply border-border outline-ring/50;
|
| 131 |
+
}
|
| 132 |
+
body {
|
| 133 |
+
@apply bg-background text-foreground antialiased selection:bg-emerald-500/30 selection:text-emerald-200;
|
| 134 |
+
}
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
/* Custom Scrollbar */
|
| 138 |
+
::-webkit-scrollbar {
|
| 139 |
+
width: 8px;
|
| 140 |
+
height: 8px;
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
::-webkit-scrollbar-track {
|
| 144 |
+
background: transparent;
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
::-webkit-scrollbar-thumb {
|
| 148 |
+
@apply bg-muted-foreground/20 rounded-full hover:bg-muted-foreground/40 transition-colors;
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
/* Glassmorphism Utilities */
|
| 152 |
+
.glass {
|
| 153 |
+
@apply bg-background/60 backdrop-blur-md border border-border/50;
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
.glass-card {
|
| 157 |
+
@apply bg-card/40 backdrop-blur-sm border border-white/10 shadow-xl;
|
| 158 |
+
}
|
app/layout.tsx
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Metadata } from "next";
|
| 2 |
+
import { Geist, Geist_Mono } from "next/font/google";
|
| 3 |
+
import "./globals.css";
|
| 4 |
+
import { Providers } from "@/components/providers";
|
| 5 |
+
import { ThemeProvider } from "@/components/theme-provider";
|
| 6 |
+
|
| 7 |
+
const geistSans = Geist({
|
| 8 |
+
variable: "--font-geist-sans",
|
| 9 |
+
subsets: ["latin"],
|
| 10 |
+
});
|
| 11 |
+
|
| 12 |
+
const geistMono = Geist_Mono({
|
| 13 |
+
variable: "--font-geist-mono",
|
| 14 |
+
subsets: ["latin"],
|
| 15 |
+
});
|
| 16 |
+
|
| 17 |
+
export const metadata: Metadata = {
|
| 18 |
+
title: "Create Next App",
|
| 19 |
+
description: "Generated by create next app",
|
| 20 |
+
};
|
| 21 |
+
|
| 22 |
+
export default function RootLayout({
|
| 23 |
+
children,
|
| 24 |
+
}: Readonly<{
|
| 25 |
+
children: React.ReactNode;
|
| 26 |
+
}>) {
|
| 27 |
+
return (
|
| 28 |
+
<html lang="en" suppressHydrationWarning>
|
| 29 |
+
<body
|
| 30 |
+
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
| 31 |
+
>
|
| 32 |
+
<ThemeProvider
|
| 33 |
+
attribute="class"
|
| 34 |
+
defaultTheme="dark"
|
| 35 |
+
forcedTheme="dark"
|
| 36 |
+
enableSystem={false}
|
| 37 |
+
disableTransitionOnChange
|
| 38 |
+
>
|
| 39 |
+
<Providers>{children}</Providers>
|
| 40 |
+
</ThemeProvider>
|
| 41 |
+
</body>
|
| 42 |
+
</html>
|
| 43 |
+
);
|
| 44 |
+
}
|
app/page.tsx
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useState, useEffect } from "react";
|
| 4 |
+
import { useRouter } from "next/navigation";
|
| 5 |
+
import { Button } from "@/components/ui/button";
|
| 6 |
+
import { Input } from "@/components/ui/input";
|
| 7 |
+
import { Card, CardContent } from "@/components/ui/card";
|
| 8 |
+
import { Lock, Shield, Zap, Eye, Users, MessageSquare, ArrowRight, CheckCircle2, X } from "lucide-react";
|
| 9 |
+
|
| 10 |
+
export default function Home() {
|
| 11 |
+
const router = useRouter();
|
| 12 |
+
const [roomCode, setRoomCode] = useState("");
|
| 13 |
+
const [nickname, setNickname] = useState("");
|
| 14 |
+
const [userLimit, setUserLimit] = useState("10");
|
| 15 |
+
const [password, setPassword] = useState("");
|
| 16 |
+
const [showJoinModal, setShowJoinModal] = useState(false);
|
| 17 |
+
const [mounted, setMounted] = useState(false);
|
| 18 |
+
|
| 19 |
+
useEffect(() => {
|
| 20 |
+
setMounted(true);
|
| 21 |
+
}, []);
|
| 22 |
+
|
| 23 |
+
const createRoom = async () => {
|
| 24 |
+
if (!nickname.trim()) {
|
| 25 |
+
alert("Please enter a nickname");
|
| 26 |
+
return;
|
| 27 |
+
}
|
| 28 |
+
if (password) {
|
| 29 |
+
sessionStorage.setItem("temp_room_password", password);
|
| 30 |
+
}
|
| 31 |
+
const code = Math.random().toString(36).substring(2, 8).toUpperCase();
|
| 32 |
+
router.push(`/room/${code}?nickname=${encodeURIComponent(nickname)}&limit=${userLimit}`);
|
| 33 |
+
};
|
| 34 |
+
|
| 35 |
+
const joinRoom = () => {
|
| 36 |
+
if (!nickname.trim()) {
|
| 37 |
+
alert("Please enter a nickname");
|
| 38 |
+
return;
|
| 39 |
+
}
|
| 40 |
+
if (roomCode.trim()) {
|
| 41 |
+
if (password) {
|
| 42 |
+
sessionStorage.setItem("temp_room_password", password);
|
| 43 |
+
}
|
| 44 |
+
router.push(`/room/${roomCode.toUpperCase()}?nickname=${encodeURIComponent(nickname)}`);
|
| 45 |
+
}
|
| 46 |
+
};
|
| 47 |
+
|
| 48 |
+
if (!mounted) return null;
|
| 49 |
+
|
| 50 |
+
return (
|
| 51 |
+
<div className="min-h-screen bg-background relative overflow-hidden selection:bg-emerald-500/30">
|
| 52 |
+
{/* Background Gradients */}
|
| 53 |
+
<div className="fixed inset-0 z-0 pointer-events-none">
|
| 54 |
+
<div className="absolute top-[-10%] left-[-10%] w-[40%] h-[40%] bg-emerald-500/10 rounded-full blur-[120px] animate-pulse-slow" />
|
| 55 |
+
<div className="absolute bottom-[-10%] right-[-10%] w-[40%] h-[40%] bg-blue-600/10 rounded-full blur-[120px] animate-pulse-slow" style={{ animationDelay: '1.5s' }} />
|
| 56 |
+
</div>
|
| 57 |
+
|
| 58 |
+
{/* Navbar */}
|
| 59 |
+
<nav className="container mx-auto px-6 py-6 relative z-10">
|
| 60 |
+
<div className="flex items-center justify-between">
|
| 61 |
+
<div className="flex items-center gap-3">
|
| 62 |
+
<div className="relative w-10 h-10 bg-gradient-to-br from-emerald-400 to-cyan-400 rounded-xl flex items-center justify-center shadow-lg shadow-emerald-500/20">
|
| 63 |
+
<Lock className="w-6 h-6 text-slate-950" />
|
| 64 |
+
</div>
|
| 65 |
+
<span className="text-2xl font-bold bg-gradient-to-r from-emerald-400 to-cyan-400 bg-clip-text text-transparent">
|
| 66 |
+
WhisperNet
|
| 67 |
+
</span>
|
| 68 |
+
</div>
|
| 69 |
+
<div className="flex gap-4">
|
| 70 |
+
<Button variant="ghost" className="text-muted-foreground hover:text-foreground">Login</Button>
|
| 71 |
+
<Button className="bg-emerald-600 hover:bg-emerald-700 text-white shadow-lg shadow-emerald-500/20">Sign Up</Button>
|
| 72 |
+
</div>
|
| 73 |
+
</div>
|
| 74 |
+
</nav>
|
| 75 |
+
|
| 76 |
+
<main className="container mx-auto px-6 relative z-10">
|
| 77 |
+
{/* Hero Content */}
|
| 78 |
+
<div className="pt-20 pb-24 text-center">
|
| 79 |
+
<div className="inline-flex items-center gap-2 bg-emerald-500/10 border border-emerald-500/20 rounded-full px-4 py-2 mb-8 animate-fade-in-up">
|
| 80 |
+
<Shield className="w-4 h-4 text-emerald-400" />
|
| 81 |
+
<span className="text-sm text-emerald-400 font-medium">End-to-End Encrypted</span>
|
| 82 |
+
</div>
|
| 83 |
+
|
| 84 |
+
<h1 className="text-6xl md:text-7xl font-bold text-foreground mb-6 leading-tight tracking-tight animate-fade-in-up" style={{ animationDelay: '0.1s' }}>
|
| 85 |
+
Private Conversations,
|
| 86 |
+
<br />
|
| 87 |
+
<span className="bg-gradient-to-r from-emerald-400 via-cyan-400 to-blue-400 bg-clip-text text-transparent">
|
| 88 |
+
Zero Traces
|
| 89 |
+
</span>
|
| 90 |
+
</h1>
|
| 91 |
+
|
| 92 |
+
<p className="text-xl text-muted-foreground mb-12 max-w-2xl mx-auto animate-fade-in-up" style={{ animationDelay: '0.2s' }}>
|
| 93 |
+
Secure, ephemeral chat rooms with military-grade encryption.
|
| 94 |
+
No registration, no history, no compromises.
|
| 95 |
+
</p>
|
| 96 |
+
|
| 97 |
+
{/* CTA Buttons */}
|
| 98 |
+
<div className="flex flex-col sm:flex-row gap-4 justify-center mb-16 animate-fade-in-up" style={{ animationDelay: '0.3s' }}>
|
| 99 |
+
<Button
|
| 100 |
+
onClick={() => setShowJoinModal(true)}
|
| 101 |
+
className="h-14 px-8 text-lg bg-gradient-to-r from-emerald-500 to-cyan-500 hover:from-emerald-600 hover:to-cyan-600 text-white font-semibold shadow-lg shadow-emerald-500/25 hover:shadow-emerald-500/40 transition-all hover:scale-105"
|
| 102 |
+
>
|
| 103 |
+
Get Started
|
| 104 |
+
<ArrowRight className="ml-2 h-5 w-5" />
|
| 105 |
+
</Button>
|
| 106 |
+
<Button
|
| 107 |
+
variant="outline"
|
| 108 |
+
className="h-14 px-8 text-lg border-border bg-background/50 hover:bg-accent hover:text-accent-foreground backdrop-blur-sm transition-all"
|
| 109 |
+
>
|
| 110 |
+
Learn More
|
| 111 |
+
</Button>
|
| 112 |
+
</div>
|
| 113 |
+
|
| 114 |
+
{/* Feature Cards */}
|
| 115 |
+
<div className="grid md:grid-cols-3 gap-6 max-w-5xl mx-auto mb-24 animate-fade-in-up" style={{ animationDelay: '0.4s' }}>
|
| 116 |
+
{[
|
| 117 |
+
{ icon: Lock, title: "E2E Encryption", desc: "Every message encrypted with unique keys. Nobody can intercept your conversations." },
|
| 118 |
+
{ icon: Eye, title: "No Tracking", desc: "Zero data collection. No accounts required. Your privacy is absolute." },
|
| 119 |
+
{ icon: Zap, title: "Instant Setup", desc: "Create or join rooms in seconds. No downloads, no hassle." }
|
| 120 |
+
].map((feature, i) => (
|
| 121 |
+
<Card key={i} className="glass-card border-white/5 hover:border-emerald-500/30 transition-all duration-300 group hover:-translate-y-1">
|
| 122 |
+
<CardContent className="p-6">
|
| 123 |
+
<div className="w-12 h-12 bg-gradient-to-br from-emerald-500/20 to-cyan-500/20 rounded-lg flex items-center justify-center mb-4 group-hover:scale-110 transition-transform duration-300">
|
| 124 |
+
<feature.icon className="w-6 h-6 text-emerald-400" />
|
| 125 |
+
</div>
|
| 126 |
+
<h3 className="text-xl font-bold text-foreground mb-2">{feature.title}</h3>
|
| 127 |
+
<p className="text-muted-foreground leading-relaxed">
|
| 128 |
+
{feature.desc}
|
| 129 |
+
</p>
|
| 130 |
+
</CardContent>
|
| 131 |
+
</Card>
|
| 132 |
+
))}
|
| 133 |
+
</div>
|
| 134 |
+
|
| 135 |
+
{/* Features List */}
|
| 136 |
+
<div className="max-w-4xl mx-auto mb-24">
|
| 137 |
+
<h2 className="text-3xl font-bold text-foreground mb-12">
|
| 138 |
+
Everything you need for secure communication
|
| 139 |
+
</h2>
|
| 140 |
+
<div className="grid md:grid-cols-2 gap-6 text-left">
|
| 141 |
+
{[
|
| 142 |
+
{ icon: MessageSquare, title: "Real-time Messaging", desc: "Instant message delivery with read receipts" },
|
| 143 |
+
{ icon: Users, title: "Group Chats", desc: "Secure rooms for multiple participants" },
|
| 144 |
+
{ icon: Shield, title: "Perfect Forward Secrecy", desc: "New keys for every session" },
|
| 145 |
+
{ icon: CheckCircle2, title: "Typing Indicators", desc: "Know when others are responding" },
|
| 146 |
+
].map((feature, i) => (
|
| 147 |
+
<div key={i} className="flex gap-4 items-start p-4 rounded-xl bg-card/30 border border-border/50 hover:bg-card/50 transition-colors">
|
| 148 |
+
<div className="w-10 h-10 bg-emerald-500/10 rounded-lg flex items-center justify-center flex-shrink-0">
|
| 149 |
+
<feature.icon className="w-5 h-5 text-emerald-400" />
|
| 150 |
+
</div>
|
| 151 |
+
<div>
|
| 152 |
+
<h3 className="text-lg font-semibold text-foreground mb-1">{feature.title}</h3>
|
| 153 |
+
<p className="text-muted-foreground text-sm">{feature.desc}</p>
|
| 154 |
+
</div>
|
| 155 |
+
</div>
|
| 156 |
+
))}
|
| 157 |
+
</div>
|
| 158 |
+
</div>
|
| 159 |
+
</div>
|
| 160 |
+
</main>
|
| 161 |
+
|
| 162 |
+
{/* Footer */}
|
| 163 |
+
<footer className="border-t border-border/50 py-8 relative z-10 bg-background/50 backdrop-blur-sm">
|
| 164 |
+
<div className="container mx-auto px-6 text-center text-muted-foreground text-sm">
|
| 165 |
+
<p>© 2025 WhisperNet. Secure by design.</p>
|
| 166 |
+
</div>
|
| 167 |
+
</footer>
|
| 168 |
+
|
| 169 |
+
{/* Join Modal */}
|
| 170 |
+
{showJoinModal && (
|
| 171 |
+
<div className="fixed inset-0 bg-black/60 backdrop-blur-md flex items-center justify-center p-4 z-50 animate-fade-in" onClick={() => setShowJoinModal(false)}>
|
| 172 |
+
<Card className="w-full max-w-md bg-card border-border shadow-2xl animate-fade-in-up" onClick={(e) => e.stopPropagation()}>
|
| 173 |
+
<div className="absolute right-4 top-4">
|
| 174 |
+
<Button variant="ghost" size="icon" onClick={() => setShowJoinModal(false)} className="h-8 w-8 rounded-full hover:bg-muted">
|
| 175 |
+
<X className="w-4 h-4" />
|
| 176 |
+
</Button>
|
| 177 |
+
</div>
|
| 178 |
+
<CardContent className="p-8 space-y-6">
|
| 179 |
+
<div className="text-center">
|
| 180 |
+
<div className="w-12 h-12 bg-emerald-500/10 rounded-xl flex items-center justify-center mx-auto mb-4">
|
| 181 |
+
<Users className="w-6 h-6 text-emerald-500" />
|
| 182 |
+
</div>
|
| 183 |
+
<h2 className="text-2xl font-bold text-foreground mb-2">Join WhisperNet</h2>
|
| 184 |
+
<p className="text-muted-foreground text-sm">Enter a nickname to get started</p>
|
| 185 |
+
</div>
|
| 186 |
+
|
| 187 |
+
<div className="space-y-4">
|
| 188 |
+
<div className="space-y-2">
|
| 189 |
+
<label className="text-sm font-medium text-foreground">Your Nickname</label>
|
| 190 |
+
<Input
|
| 191 |
+
placeholder="Enter your display name"
|
| 192 |
+
value={nickname}
|
| 193 |
+
onChange={(e) => setNickname(e.target.value)}
|
| 194 |
+
className="bg-muted/50 border-border focus:ring-emerald-500 h-11"
|
| 195 |
+
autoFocus
|
| 196 |
+
/>
|
| 197 |
+
</div>
|
| 198 |
+
|
| 199 |
+
<div className="space-y-2">
|
| 200 |
+
<label className="text-sm font-medium text-foreground">Room Password (Optional)</label>
|
| 201 |
+
<Input
|
| 202 |
+
type="password"
|
| 203 |
+
placeholder="Set a password for the room"
|
| 204 |
+
value={password}
|
| 205 |
+
onChange={(e) => setPassword(e.target.value)}
|
| 206 |
+
className="bg-muted/50 border-border focus:ring-emerald-500 h-11"
|
| 207 |
+
/>
|
| 208 |
+
</div>
|
| 209 |
+
|
| 210 |
+
<div className="pt-4 border-t border-border">
|
| 211 |
+
<label className="text-sm font-medium text-foreground mb-2 block">Create Room</label>
|
| 212 |
+
<div className="flex gap-3">
|
| 213 |
+
<Input
|
| 214 |
+
type="number"
|
| 215 |
+
placeholder="Max"
|
| 216 |
+
value={userLimit}
|
| 217 |
+
onChange={(e) => setUserLimit(e.target.value)}
|
| 218 |
+
className="w-24 bg-muted/50 border-border focus:ring-emerald-500 h-11"
|
| 219 |
+
min={2}
|
| 220 |
+
max={50}
|
| 221 |
+
/>
|
| 222 |
+
<Button
|
| 223 |
+
onClick={createRoom}
|
| 224 |
+
className="flex-1 h-11 bg-emerald-600 hover:bg-emerald-700 text-white font-semibold shadow-lg shadow-emerald-500/20"
|
| 225 |
+
>
|
| 226 |
+
Create Room
|
| 227 |
+
</Button>
|
| 228 |
+
</div>
|
| 229 |
+
</div>
|
| 230 |
+
|
| 231 |
+
<div className="relative py-2">
|
| 232 |
+
<div className="absolute inset-0 flex items-center">
|
| 233 |
+
<span className="w-full border-t border-border" />
|
| 234 |
+
</div>
|
| 235 |
+
<div className="relative flex justify-center text-xs uppercase">
|
| 236 |
+
<span className="bg-card px-2 text-muted-foreground">Or join existing</span>
|
| 237 |
+
</div>
|
| 238 |
+
</div>
|
| 239 |
+
|
| 240 |
+
<div className="space-y-2">
|
| 241 |
+
<label className="text-sm font-medium text-foreground">Join Room</label>
|
| 242 |
+
<div className="flex gap-3">
|
| 243 |
+
<Input
|
| 244 |
+
placeholder="Enter Room Code"
|
| 245 |
+
value={roomCode}
|
| 246 |
+
onChange={(e) => setRoomCode(e.target.value)}
|
| 247 |
+
className="bg-muted/50 border-border focus:ring-emerald-500 h-11"
|
| 248 |
+
/>
|
| 249 |
+
<Button
|
| 250 |
+
onClick={joinRoom}
|
| 251 |
+
className="h-11 bg-secondary hover:bg-secondary/80 text-secondary-foreground"
|
| 252 |
+
>
|
| 253 |
+
Join
|
| 254 |
+
</Button>
|
| 255 |
+
</div>
|
| 256 |
+
</div>
|
| 257 |
+
</div>
|
| 258 |
+
</CardContent>
|
| 259 |
+
</Card>
|
| 260 |
+
</div>
|
| 261 |
+
)}
|
| 262 |
+
</div>
|
| 263 |
+
);
|
| 264 |
+
}
|
app/room/[roomId]/page.tsx
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import ChatRoom from "@/components/chat/ChatRoom";
|
| 2 |
+
|
| 3 |
+
interface PageProps {
|
| 4 |
+
params: Promise<{ roomId: string }>;
|
| 5 |
+
}
|
| 6 |
+
|
| 7 |
+
export default async function RoomPage({ params }: PageProps) {
|
| 8 |
+
const { roomId } = await params;
|
| 9 |
+
|
| 10 |
+
return (
|
| 11 |
+
<div className="min-h-screen bg-slate-950">
|
| 12 |
+
<ChatRoom roomId={roomId} />
|
| 13 |
+
</div>
|
| 14 |
+
);
|
| 15 |
+
}
|
app/settings/page.tsx
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useState, useEffect } from "react";
|
| 4 |
+
import { Button } from "@/components/ui/button";
|
| 5 |
+
import { Input } from "@/components/ui/input";
|
| 6 |
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
| 7 |
+
import { enableTwoFactor, confirmTwoFactor, disableTwoFactor, getTwoFactorStatus } from "@/actions/two-factor";
|
| 8 |
+
import { useSession, signOut } from "next-auth/react";
|
| 9 |
+
import Image from "next/image";
|
| 10 |
+
|
| 11 |
+
export default function SettingsPage() {
|
| 12 |
+
const { data: session } = useSession();
|
| 13 |
+
const [isEnabled, setIsEnabled] = useState(false);
|
| 14 |
+
const [qrCode, setQrCode] = useState("");
|
| 15 |
+
const [token, setToken] = useState("");
|
| 16 |
+
const [step, setStep] = useState<"idle" | "verify">("idle");
|
| 17 |
+
const [msg, setMsg] = useState("");
|
| 18 |
+
|
| 19 |
+
useEffect(() => {
|
| 20 |
+
getTwoFactorStatus().then((res) => {
|
| 21 |
+
if (res.isEnabled) setIsEnabled(true);
|
| 22 |
+
});
|
| 23 |
+
}, []);
|
| 24 |
+
|
| 25 |
+
const handleEnable = async () => {
|
| 26 |
+
const res = await enableTwoFactor();
|
| 27 |
+
if (res.qrCodeUrl) {
|
| 28 |
+
setQrCode(res.qrCodeUrl);
|
| 29 |
+
setStep("verify");
|
| 30 |
+
}
|
| 31 |
+
};
|
| 32 |
+
|
| 33 |
+
const handleVerify = async () => {
|
| 34 |
+
const res = await confirmTwoFactor(token);
|
| 35 |
+
if (res.success) {
|
| 36 |
+
setIsEnabled(true);
|
| 37 |
+
setStep("idle");
|
| 38 |
+
setQrCode("");
|
| 39 |
+
setMsg("2FA Enabled Successfully!");
|
| 40 |
+
} else {
|
| 41 |
+
setMsg("Invalid Token");
|
| 42 |
+
}
|
| 43 |
+
};
|
| 44 |
+
|
| 45 |
+
const handleDisable = async () => {
|
| 46 |
+
await disableTwoFactor();
|
| 47 |
+
setIsEnabled(false);
|
| 48 |
+
setMsg("2FA Disabled");
|
| 49 |
+
};
|
| 50 |
+
|
| 51 |
+
if (!session) return <div className="p-10 text-white">Loading...</div>;
|
| 52 |
+
|
| 53 |
+
return (
|
| 54 |
+
<div className="flex flex-col items-center justify-center min-h-screen bg-slate-950 text-slate-100">
|
| 55 |
+
<Card className="w-[400px] bg-slate-900 border-slate-800 text-slate-100">
|
| 56 |
+
<CardHeader>
|
| 57 |
+
<CardTitle>Settings</CardTitle>
|
| 58 |
+
</CardHeader>
|
| 59 |
+
<CardContent className="space-y-4">
|
| 60 |
+
<div className="flex justify-between items-center">
|
| 61 |
+
<span>Email:</span>
|
| 62 |
+
<span className="text-slate-400">{session.user?.email}</span>
|
| 63 |
+
</div>
|
| 64 |
+
|
| 65 |
+
<div className="border-t border-slate-800 pt-4">
|
| 66 |
+
<h3 className="text-lg font-semibold mb-2">Two-Factor Authentication</h3>
|
| 67 |
+
{isEnabled ? (
|
| 68 |
+
<div className="space-y-2">
|
| 69 |
+
<p className="text-emerald-500">✅ Enabled</p>
|
| 70 |
+
<Button onClick={handleDisable} variant="destructive" className="w-full">
|
| 71 |
+
Disable 2FA
|
| 72 |
+
</Button>
|
| 73 |
+
</div>
|
| 74 |
+
) : (
|
| 75 |
+
<div className="space-y-2">
|
| 76 |
+
<p className="text-slate-400">Secure your account with 2FA.</p>
|
| 77 |
+
{step === "idle" && (
|
| 78 |
+
<Button onClick={handleEnable} className="w-full bg-emerald-600 hover:bg-emerald-700">
|
| 79 |
+
Enable 2FA
|
| 80 |
+
</Button>
|
| 81 |
+
)}
|
| 82 |
+
</div>
|
| 83 |
+
)}
|
| 84 |
+
|
| 85 |
+
{step === "verify" && qrCode && (
|
| 86 |
+
<div className="mt-4 space-y-4">
|
| 87 |
+
<div className="flex justify-center bg-white p-2 rounded">
|
| 88 |
+
<Image src={qrCode} alt="QR Code" width={150} height={150} />
|
| 89 |
+
</div>
|
| 90 |
+
<p className="text-xs text-center text-slate-400">Scan with Google Authenticator</p>
|
| 91 |
+
<Input
|
| 92 |
+
value={token}
|
| 93 |
+
onChange={(e) => setToken(e.target.value)}
|
| 94 |
+
placeholder="Enter 6-digit code"
|
| 95 |
+
className="bg-slate-950 border-slate-800"
|
| 96 |
+
/>
|
| 97 |
+
<Button onClick={handleVerify} className="w-full">
|
| 98 |
+
Verify & Activate
|
| 99 |
+
</Button>
|
| 100 |
+
</div>
|
| 101 |
+
)}
|
| 102 |
+
|
| 103 |
+
{msg && <p className="text-center text-sm mt-2">{msg}</p>}
|
| 104 |
+
</div>
|
| 105 |
+
|
| 106 |
+
<div className="border-t border-slate-800 pt-4">
|
| 107 |
+
<Button onClick={() => signOut()} variant="outline" className="w-full border-slate-700 text-slate-300 hover:bg-slate-800">
|
| 108 |
+
Sign Out
|
| 109 |
+
</Button>
|
| 110 |
+
</div>
|
| 111 |
+
</CardContent>
|
| 112 |
+
</Card>
|
| 113 |
+
</div>
|
| 114 |
+
);
|
| 115 |
+
}
|
auth.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import NextAuth, { CredentialsSignin } from "next-auth";
|
| 2 |
+
import Credentials from "next-auth/providers/credentials";
|
| 3 |
+
import { compare } from "bcryptjs";
|
| 4 |
+
import User from "@/models/User";
|
| 5 |
+
import dbConnect from "@/lib/db";
|
| 6 |
+
import { verifyTwoFactorToken } from "@/lib/tokens";
|
| 7 |
+
|
| 8 |
+
class TwoFactorRequired extends CredentialsSignin {
|
| 9 |
+
code = "2FA_REQUIRED";
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
class InvalidToken extends CredentialsSignin {
|
| 13 |
+
code = "INVALID_2FA_TOKEN";
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
export const { handlers, signIn, signOut, auth } = NextAuth({
|
| 17 |
+
providers: [
|
| 18 |
+
Credentials({
|
| 19 |
+
credentials: {
|
| 20 |
+
email: { label: "Email", type: "email" },
|
| 21 |
+
password: { label: "Password", type: "password" },
|
| 22 |
+
token: { label: "2FA Token", type: "text" },
|
| 23 |
+
},
|
| 24 |
+
authorize: async (credentials) => {
|
| 25 |
+
await dbConnect();
|
| 26 |
+
|
| 27 |
+
const email = credentials.email as string;
|
| 28 |
+
const password = credentials.password as string;
|
| 29 |
+
const token = credentials.token as string | undefined;
|
| 30 |
+
|
| 31 |
+
if (!email || !password) {
|
| 32 |
+
throw new Error("Missing credentials");
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
const user = await User.findOne({ email });
|
| 36 |
+
|
| 37 |
+
if (!user || !user.password) {
|
| 38 |
+
throw new Error("Invalid credentials");
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
const isPasswordValid = await compare(password, user.password);
|
| 42 |
+
|
| 43 |
+
if (!isPasswordValid) {
|
| 44 |
+
throw new Error("Invalid credentials");
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
if (user.isTwoFactorEnabled) {
|
| 48 |
+
if (!token) {
|
| 49 |
+
throw new TwoFactorRequired();
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
const isTokenValid = verifyTwoFactorToken(token, user.twoFactorSecret!);
|
| 53 |
+
|
| 54 |
+
if (!isTokenValid) {
|
| 55 |
+
throw new InvalidToken();
|
| 56 |
+
}
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
return {
|
| 60 |
+
id: user._id.toString(),
|
| 61 |
+
email: user.email,
|
| 62 |
+
image: user.image,
|
| 63 |
+
};
|
| 64 |
+
},
|
| 65 |
+
}),
|
| 66 |
+
],
|
| 67 |
+
pages: {
|
| 68 |
+
signIn: "/login",
|
| 69 |
+
},
|
| 70 |
+
callbacks: {
|
| 71 |
+
async session({ session, token }) {
|
| 72 |
+
if (token.sub && session.user) {
|
| 73 |
+
session.user.id = token.sub;
|
| 74 |
+
}
|
| 75 |
+
return session;
|
| 76 |
+
},
|
| 77 |
+
async jwt({ token }) {
|
| 78 |
+
return token;
|
| 79 |
+
},
|
| 80 |
+
},
|
| 81 |
+
session: { strategy: "jwt" },
|
| 82 |
+
});
|
bun.lock
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
components.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"$schema": "https://ui.shadcn.com/schema.json",
|
| 3 |
+
"style": "new-york",
|
| 4 |
+
"rsc": true,
|
| 5 |
+
"tsx": true,
|
| 6 |
+
"tailwind": {
|
| 7 |
+
"config": "",
|
| 8 |
+
"css": "app/globals.css",
|
| 9 |
+
"baseColor": "neutral",
|
| 10 |
+
"cssVariables": true,
|
| 11 |
+
"prefix": ""
|
| 12 |
+
},
|
| 13 |
+
"iconLibrary": "lucide",
|
| 14 |
+
"aliases": {
|
| 15 |
+
"components": "@/components",
|
| 16 |
+
"utils": "@/lib/utils",
|
| 17 |
+
"ui": "@/components/ui",
|
| 18 |
+
"lib": "@/lib",
|
| 19 |
+
"hooks": "@/hooks"
|
| 20 |
+
},
|
| 21 |
+
"registries": {}
|
| 22 |
+
}
|
components/chat/CallInterface.tsx
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useEffect, useRef, useState } from "react";
|
| 4 |
+
import { Button } from "@/components/ui/button";
|
| 5 |
+
import { Mic, MicOff, Video, VideoOff, PhoneOff } from "lucide-react";
|
| 6 |
+
import { Card } from "@/components/ui/card";
|
| 7 |
+
|
| 8 |
+
interface CallInterfaceProps {
|
| 9 |
+
localStream: MediaStream | null;
|
| 10 |
+
remoteStreams: Map<string, MediaStream>;
|
| 11 |
+
onLeave: () => void;
|
| 12 |
+
nicknames: Map<string, string>;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
export default function CallInterface({ localStream, remoteStreams, onLeave, nicknames }: CallInterfaceProps) {
|
| 16 |
+
const localVideoRef = useRef<HTMLVideoElement>(null);
|
| 17 |
+
const [isMuted, setIsMuted] = useState(false);
|
| 18 |
+
const [isVideoOff, setIsVideoOff] = useState(false);
|
| 19 |
+
|
| 20 |
+
useEffect(() => {
|
| 21 |
+
if (localVideoRef.current && localStream) {
|
| 22 |
+
localVideoRef.current.srcObject = localStream;
|
| 23 |
+
}
|
| 24 |
+
}, [localStream]);
|
| 25 |
+
|
| 26 |
+
const toggleMute = () => {
|
| 27 |
+
if (localStream) {
|
| 28 |
+
localStream.getAudioTracks().forEach(track => track.enabled = !track.enabled);
|
| 29 |
+
setIsMuted(!isMuted);
|
| 30 |
+
}
|
| 31 |
+
};
|
| 32 |
+
|
| 33 |
+
const toggleVideo = () => {
|
| 34 |
+
if (localStream) {
|
| 35 |
+
localStream.getVideoTracks().forEach(track => track.enabled = !track.enabled);
|
| 36 |
+
setIsVideoOff(!isVideoOff);
|
| 37 |
+
}
|
| 38 |
+
};
|
| 39 |
+
|
| 40 |
+
return (
|
| 41 |
+
<div className="fixed inset-0 bg-background/95 z-50 flex flex-col p-4">
|
| 42 |
+
<div className="flex-1 grid grid-cols-2 md:grid-cols-3 gap-4 p-4 overflow-y-auto">
|
| 43 |
+
{/* Local Video */}
|
| 44 |
+
<Card className="relative overflow-hidden bg-black/50 aspect-video flex items-center justify-center">
|
| 45 |
+
<video
|
| 46 |
+
ref={localVideoRef}
|
| 47 |
+
autoPlay
|
| 48 |
+
muted
|
| 49 |
+
playsInline
|
| 50 |
+
className={`w-full h-full object-cover ${isVideoOff ? "hidden" : ""}`}
|
| 51 |
+
/>
|
| 52 |
+
{isVideoOff && (
|
| 53 |
+
<div className="absolute inset-0 flex items-center justify-center text-muted-foreground">
|
| 54 |
+
Video Off
|
| 55 |
+
</div>
|
| 56 |
+
)}
|
| 57 |
+
<div className="absolute bottom-2 left-2 bg-black/50 px-2 py-1 rounded text-xs text-white">
|
| 58 |
+
You {isMuted && "(Muted)"}
|
| 59 |
+
</div>
|
| 60 |
+
</Card>
|
| 61 |
+
|
| 62 |
+
{/* Remote Videos */}
|
| 63 |
+
{Array.from(remoteStreams.entries()).map(([socketId, stream]) => (
|
| 64 |
+
<RemoteVideo
|
| 65 |
+
key={socketId}
|
| 66 |
+
stream={stream}
|
| 67 |
+
nickname={nicknames.get(socketId) || `User ${socketId.slice(0, 4)}`}
|
| 68 |
+
/>
|
| 69 |
+
))}
|
| 70 |
+
</div>
|
| 71 |
+
|
| 72 |
+
{/* Controls */}
|
| 73 |
+
<div className="h-20 flex items-center justify-center gap-4 bg-card border-t border-border rounded-t-xl">
|
| 74 |
+
<Button
|
| 75 |
+
variant={isMuted ? "destructive" : "secondary"}
|
| 76 |
+
size="icon"
|
| 77 |
+
className="rounded-full h-12 w-12"
|
| 78 |
+
onClick={toggleMute}
|
| 79 |
+
>
|
| 80 |
+
{isMuted ? <MicOff className="w-5 h-5" /> : <Mic className="w-5 h-5" />}
|
| 81 |
+
</Button>
|
| 82 |
+
<Button
|
| 83 |
+
variant="destructive"
|
| 84 |
+
size="icon"
|
| 85 |
+
className="rounded-full h-14 w-14"
|
| 86 |
+
onClick={onLeave}
|
| 87 |
+
>
|
| 88 |
+
<PhoneOff className="w-6 h-6" />
|
| 89 |
+
</Button>
|
| 90 |
+
<Button
|
| 91 |
+
variant={isVideoOff ? "destructive" : "secondary"}
|
| 92 |
+
size="icon"
|
| 93 |
+
className="rounded-full h-12 w-12"
|
| 94 |
+
onClick={toggleVideo}
|
| 95 |
+
>
|
| 96 |
+
{isVideoOff ? <VideoOff className="w-5 h-5" /> : <Video className="w-5 h-5" />}
|
| 97 |
+
</Button>
|
| 98 |
+
</div>
|
| 99 |
+
</div>
|
| 100 |
+
);
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
function RemoteVideo({ stream, nickname }: { stream: MediaStream; nickname: string }) {
|
| 104 |
+
const videoRef = useRef<HTMLVideoElement>(null);
|
| 105 |
+
|
| 106 |
+
useEffect(() => {
|
| 107 |
+
if (videoRef.current) {
|
| 108 |
+
videoRef.current.srcObject = stream;
|
| 109 |
+
}
|
| 110 |
+
}, [stream]);
|
| 111 |
+
|
| 112 |
+
return (
|
| 113 |
+
<Card className="relative overflow-hidden bg-black/50 aspect-video flex items-center justify-center">
|
| 114 |
+
<video
|
| 115 |
+
ref={videoRef}
|
| 116 |
+
autoPlay
|
| 117 |
+
playsInline
|
| 118 |
+
className="w-full h-full object-cover"
|
| 119 |
+
/>
|
| 120 |
+
<div className="absolute bottom-2 left-2 bg-black/50 px-2 py-1 rounded text-xs text-white">
|
| 121 |
+
{nickname}
|
| 122 |
+
</div>
|
| 123 |
+
</Card>
|
| 124 |
+
);
|
| 125 |
+
}
|
components/chat/ChatRoom.tsx
ADDED
|
@@ -0,0 +1,1017 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useEffect, useState, useRef } from "react";
|
| 4 |
+
import { socket } from "@/lib/socket";
|
| 5 |
+
import {
|
| 6 |
+
generateKeyPair,
|
| 7 |
+
exportKey,
|
| 8 |
+
importKey,
|
| 9 |
+
encryptMessage,
|
| 10 |
+
decryptMessage,
|
| 11 |
+
generateSymKey,
|
| 12 |
+
encryptSymMessage,
|
| 13 |
+
decryptSymMessage,
|
| 14 |
+
exportSymKey,
|
| 15 |
+
importSymKey,
|
| 16 |
+
encryptFile,
|
| 17 |
+
decryptFile,
|
| 18 |
+
} from "@/lib/crypto";
|
| 19 |
+
import { Button } from "@/components/ui/button";
|
| 20 |
+
import { Input } from "@/components/ui/input";
|
| 21 |
+
import { ScrollArea } from "@/components/ui/scroll-area";
|
| 22 |
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
| 23 |
+
import { Send, User, Users, Lock, Check, CheckCheck, Smile, Paperclip, FileIcon, Download, Image as ImageIcon, Settings, Phone, Mic, MicOff, UserX, X } from "lucide-react";
|
| 24 |
+
import { useSearchParams, useRouter } from "next/navigation";
|
| 25 |
+
import dynamic from "next/dynamic";
|
| 26 |
+
import ReactMarkdown from "react-markdown";
|
| 27 |
+
import remarkGfm from "remark-gfm";
|
| 28 |
+
import { ThemeToggle } from "@/components/theme-toggle";
|
| 29 |
+
import { useTheme } from "next-themes";
|
| 30 |
+
import { useWebRTC } from "@/hooks/useWebRTC";
|
| 31 |
+
import CallInterface from "./CallInterface";
|
| 32 |
+
|
| 33 |
+
const EmojiPicker = dynamic(() => import("emoji-picker-react"), { ssr: false });
|
| 34 |
+
|
| 35 |
+
interface Message {
|
| 36 |
+
id: string;
|
| 37 |
+
senderId: string;
|
| 38 |
+
content: string;
|
| 39 |
+
timestamp: number;
|
| 40 |
+
status?: "sending" | "sent" | "delivered" | "read";
|
| 41 |
+
type?: "text" | "file";
|
| 42 |
+
file?: {
|
| 43 |
+
id: string;
|
| 44 |
+
name: string;
|
| 45 |
+
size: number;
|
| 46 |
+
mimeType: string;
|
| 47 |
+
url?: string;
|
| 48 |
+
};
|
| 49 |
+
encryptedKey?: string;
|
| 50 |
+
reactions?: Record<string, string[]>; // emoji -> [senderIds]
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
interface ChatRoomProps {
|
| 54 |
+
roomId: string;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
export default function ChatRoom({ roomId }: ChatRoomProps) {
|
| 58 |
+
const [messages, setMessages] = useState<Message[]>([]);
|
| 59 |
+
const [inputMessage, setInputMessage] = useState("");
|
| 60 |
+
const [isConnected, setIsConnected] = useState(false);
|
| 61 |
+
const [participantCount, setParticipantCount] = useState(1);
|
| 62 |
+
const [nicknames, setNicknames] = useState<Map<string, string>>(new Map());
|
| 63 |
+
const [typingUsers, setTypingUsers] = useState<Set<string>>(new Set());
|
| 64 |
+
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
|
| 65 |
+
const [isUploading, setIsUploading] = useState(false);
|
| 66 |
+
const [activeReactionMessageId, setActiveReactionMessageId] = useState<string | null>(null);
|
| 67 |
+
|
| 68 |
+
// Advanced Controls State
|
| 69 |
+
const [isCreator, setIsCreator] = useState(false);
|
| 70 |
+
const [isMuted, setIsMuted] = useState(false);
|
| 71 |
+
const [showPasswordModal, setShowPasswordModal] = useState(false);
|
| 72 |
+
const [passwordInput, setPasswordInput] = useState("");
|
| 73 |
+
const [showSettingsModal, setShowSettingsModal] = useState(false);
|
| 74 |
+
const [showParticipantsModal, setShowParticipantsModal] = useState(false);
|
| 75 |
+
const [participants, setParticipants] = useState<{ socketId: string; userId: string; nickname?: string; isMuted?: boolean }[]>([]);
|
| 76 |
+
const [newLimit, setNewLimit] = useState("");
|
| 77 |
+
const [newPassword, setNewPassword] = useState("");
|
| 78 |
+
|
| 79 |
+
const searchParams = useSearchParams();
|
| 80 |
+
const router = useRouter();
|
| 81 |
+
const nickname = searchParams.get("nickname") || "Anonymous";
|
| 82 |
+
const userLimit = searchParams.get("limit") ? parseInt(searchParams.get("limit")!) : undefined;
|
| 83 |
+
const { theme } = useTheme();
|
| 84 |
+
const { localStream, remoteStreams, joinCall, leaveCall } = useWebRTC(roomId);
|
| 85 |
+
|
| 86 |
+
const myKeys = useRef<{ public: CryptoKey; private: CryptoKey } | null>(null);
|
| 87 |
+
const otherUsersKeys = useRef<Map<string, CryptoKey>>(new Map());
|
| 88 |
+
const messagesEndRef = useRef<HTMLDivElement>(null);
|
| 89 |
+
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
| 90 |
+
const inputRef = useRef<HTMLInputElement>(null);
|
| 91 |
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
| 92 |
+
|
| 93 |
+
const scrollToBottom = () => {
|
| 94 |
+
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
| 95 |
+
};
|
| 96 |
+
|
| 97 |
+
useEffect(() => {
|
| 98 |
+
scrollToBottom();
|
| 99 |
+
}, [messages]);
|
| 100 |
+
|
| 101 |
+
useEffect(() => {
|
| 102 |
+
const init = async () => {
|
| 103 |
+
let userId = sessionStorage.getItem("userId");
|
| 104 |
+
if (!userId) {
|
| 105 |
+
userId = crypto.randomUUID();
|
| 106 |
+
sessionStorage.setItem("userId", userId);
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
const keys = await generateKeyPair();
|
| 110 |
+
myKeys.current = { public: keys.publicKey, private: keys.privateKey };
|
| 111 |
+
|
| 112 |
+
socket.auth = { userId };
|
| 113 |
+
socket.connect();
|
| 114 |
+
|
| 115 |
+
const storedPassword = sessionStorage.getItem("temp_room_password");
|
| 116 |
+
const password = storedPassword || searchParams.get("password");
|
| 117 |
+
if (storedPassword) sessionStorage.removeItem("temp_room_password");
|
| 118 |
+
|
| 119 |
+
socket.emit("join-room", { roomId, nickname, userLimit, password });
|
| 120 |
+
setIsConnected(true);
|
| 121 |
+
|
| 122 |
+
socket.on("error", (err: string) => {
|
| 123 |
+
if (err === "Password required" || err === "Invalid password") {
|
| 124 |
+
setShowPasswordModal(true);
|
| 125 |
+
if (err === "Invalid password") alert("Invalid password");
|
| 126 |
+
} else if (err === "You are muted") {
|
| 127 |
+
alert(err); // Just alert, don't redirect
|
| 128 |
+
} else {
|
| 129 |
+
alert(err);
|
| 130 |
+
router.push("/");
|
| 131 |
+
}
|
| 132 |
+
});
|
| 133 |
+
|
| 134 |
+
socket.on("room-role", (data: { role: string }) => {
|
| 135 |
+
if (data.role === "creator") setIsCreator(true);
|
| 136 |
+
});
|
| 137 |
+
|
| 138 |
+
socket.on("kicked", (msg: string) => {
|
| 139 |
+
alert(msg);
|
| 140 |
+
router.push("/");
|
| 141 |
+
});
|
| 142 |
+
|
| 143 |
+
socket.on("muted", (msg: string) => {
|
| 144 |
+
setIsMuted(true);
|
| 145 |
+
alert(msg);
|
| 146 |
+
});
|
| 147 |
+
|
| 148 |
+
socket.on("unmuted", (msg: string) => {
|
| 149 |
+
setIsMuted(false);
|
| 150 |
+
alert(msg);
|
| 151 |
+
});
|
| 152 |
+
|
| 153 |
+
socket.on("room-participants", (data: { socketId: string; userId: string; nickname?: string; isMuted: boolean }[]) => {
|
| 154 |
+
setParticipants(data);
|
| 155 |
+
const newMap = new Map(nicknames);
|
| 156 |
+
data.forEach(p => {
|
| 157 |
+
if (p.nickname) newMap.set(p.socketId, p.nickname);
|
| 158 |
+
});
|
| 159 |
+
setNicknames(newMap);
|
| 160 |
+
});
|
| 161 |
+
|
| 162 |
+
socket.on("user-muted", ({ userId }: { userId: string }) => {
|
| 163 |
+
setParticipants(prev => prev.map(p => p.userId === userId ? { ...p, isMuted: true } : p));
|
| 164 |
+
if (userId === sessionStorage.getItem("userId")) setIsMuted(true);
|
| 165 |
+
});
|
| 166 |
+
|
| 167 |
+
socket.on("user-unmuted", ({ userId }: { userId: string }) => {
|
| 168 |
+
setParticipants(prev => prev.map(p => p.userId === userId ? { ...p, isMuted: false } : p));
|
| 169 |
+
if (userId === sessionStorage.getItem("userId")) setIsMuted(false);
|
| 170 |
+
});
|
| 171 |
+
|
| 172 |
+
socket.on("room-info", (data: { count: number }) => {
|
| 173 |
+
setParticipantCount(data.count);
|
| 174 |
+
});
|
| 175 |
+
|
| 176 |
+
socket.on("user-joined", async (data: { socketId: string; userId: string; nickname?: string }) => {
|
| 177 |
+
if (data.nickname) {
|
| 178 |
+
setNicknames(prev => new Map(prev).set(data.socketId, data.nickname!));
|
| 179 |
+
}
|
| 180 |
+
setParticipants(prev => {
|
| 181 |
+
if (prev.find(p => p.socketId === data.socketId)) return prev;
|
| 182 |
+
return [...prev, { socketId: data.socketId, userId: data.userId, nickname: data.nickname }];
|
| 183 |
+
});
|
| 184 |
+
|
| 185 |
+
if (myKeys.current) {
|
| 186 |
+
const exportedPub = await exportKey(myKeys.current.public);
|
| 187 |
+
socket.emit("signal", {
|
| 188 |
+
target: data.socketId,
|
| 189 |
+
signal: { type: "offer-key", key: exportedPub },
|
| 190 |
+
});
|
| 191 |
+
}
|
| 192 |
+
});
|
| 193 |
+
|
| 194 |
+
socket.on("user-left", (data: { socketId: string; userId: string }) => {
|
| 195 |
+
otherUsersKeys.current.delete(data.socketId);
|
| 196 |
+
setParticipants(prev => prev.filter(p => p.socketId !== data.socketId));
|
| 197 |
+
});
|
| 198 |
+
|
| 199 |
+
socket.on("signal", async (data: { sender: string; signal: any }) => {
|
| 200 |
+
const { sender, signal } = data;
|
| 201 |
+
if (signal.type === "offer-key") {
|
| 202 |
+
const importedKey = await importKey(signal.key, ["encrypt"]);
|
| 203 |
+
otherUsersKeys.current.set(sender, importedKey);
|
| 204 |
+
if (myKeys.current) {
|
| 205 |
+
const exportedPub = await exportKey(myKeys.current.public);
|
| 206 |
+
socket.emit("signal", {
|
| 207 |
+
target: sender,
|
| 208 |
+
signal: { type: "answer-key", key: exportedPub },
|
| 209 |
+
});
|
| 210 |
+
}
|
| 211 |
+
} else if (signal.type === "answer-key") {
|
| 212 |
+
const importedKey = await importKey(signal.key, ["encrypt"]);
|
| 213 |
+
otherUsersKeys.current.set(sender, importedKey);
|
| 214 |
+
}
|
| 215 |
+
});
|
| 216 |
+
|
| 217 |
+
socket.on("message-status", (data: { messageId: string; status: "delivered" | "read"; originalSenderId: string }) => {
|
| 218 |
+
if (data.originalSenderId === socket.id) {
|
| 219 |
+
setMessages((prev) =>
|
| 220 |
+
prev.map((msg) => {
|
| 221 |
+
if (msg.id === data.messageId) {
|
| 222 |
+
if (msg.status === "read") return msg;
|
| 223 |
+
if (msg.status === "delivered" && data.status === "delivered") return msg;
|
| 224 |
+
return { ...msg, status: data.status };
|
| 225 |
+
}
|
| 226 |
+
return msg;
|
| 227 |
+
})
|
| 228 |
+
);
|
| 229 |
+
}
|
| 230 |
+
});
|
| 231 |
+
|
| 232 |
+
socket.on("receive-message", async (data: { senderId: string; payload: any; messageId: string; roomId: string; type?: string }) => {
|
| 233 |
+
const { senderId, payload, messageId, type } = data;
|
| 234 |
+
|
| 235 |
+
socket.emit("message-delivered", {
|
| 236 |
+
roomId: data.roomId,
|
| 237 |
+
messageId,
|
| 238 |
+
senderId,
|
| 239 |
+
recipientId: socket.id
|
| 240 |
+
});
|
| 241 |
+
|
| 242 |
+
try {
|
| 243 |
+
const myEncryptedKey = payload.keys[socket.id || ""];
|
| 244 |
+
if (!myEncryptedKey) return;
|
| 245 |
+
|
| 246 |
+
if (!myKeys.current) return;
|
| 247 |
+
const aesKeyRaw = await decryptMessage(myKeys.current.private, myEncryptedKey);
|
| 248 |
+
const aesKey = await importSymKey(aesKeyRaw);
|
| 249 |
+
|
| 250 |
+
let content = "";
|
| 251 |
+
let fileData = undefined;
|
| 252 |
+
|
| 253 |
+
if (type === "file") {
|
| 254 |
+
content = "[FILE]";
|
| 255 |
+
fileData = payload.file;
|
| 256 |
+
} else {
|
| 257 |
+
content = await decryptSymMessage(aesKey, payload.content);
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
setMessages((prev) => [
|
| 261 |
+
...prev,
|
| 262 |
+
{
|
| 263 |
+
id: messageId || crypto.randomUUID(),
|
| 264 |
+
senderId,
|
| 265 |
+
content,
|
| 266 |
+
timestamp: Date.now(),
|
| 267 |
+
type: (type as "text" | "file") || "text",
|
| 268 |
+
file: fileData,
|
| 269 |
+
encryptedKey: myEncryptedKey
|
| 270 |
+
},
|
| 271 |
+
]);
|
| 272 |
+
|
| 273 |
+
socket.emit("message-read", {
|
| 274 |
+
roomId,
|
| 275 |
+
messageId,
|
| 276 |
+
senderId,
|
| 277 |
+
recipientId: socket.id
|
| 278 |
+
});
|
| 279 |
+
} catch (err) {
|
| 280 |
+
console.error("Failed to decrypt message:", err);
|
| 281 |
+
}
|
| 282 |
+
});
|
| 283 |
+
|
| 284 |
+
socket.on("user-typing", ({ socketId, nickname }: { socketId: string; nickname: string }) => {
|
| 285 |
+
setTypingUsers(prev => new Set(prev).add(socketId));
|
| 286 |
+
setNicknames(prev => new Map(prev).set(socketId, nickname));
|
| 287 |
+
});
|
| 288 |
+
|
| 289 |
+
socket.on("user-stopped-typing", ({ socketId }: { socketId: string }) => {
|
| 290 |
+
setTypingUsers(prev => {
|
| 291 |
+
const newSet = new Set(prev);
|
| 292 |
+
newSet.delete(socketId);
|
| 293 |
+
return newSet;
|
| 294 |
+
});
|
| 295 |
+
});
|
| 296 |
+
|
| 297 |
+
socket.on("message-reaction-update", (data: { messageId: string; reaction: string; senderId: string }) => {
|
| 298 |
+
setMessages(prev => prev.map(msg => {
|
| 299 |
+
if (msg.id === data.messageId) {
|
| 300 |
+
const newReactions = { ...(msg.reactions || {}) };
|
| 301 |
+
|
| 302 |
+
// Check if user is toggling the same reaction
|
| 303 |
+
const currentUsers = newReactions[data.reaction] || [];
|
| 304 |
+
if (currentUsers.includes(data.senderId)) {
|
| 305 |
+
// Remove it (Toggle off)
|
| 306 |
+
newReactions[data.reaction] = currentUsers.filter(id => id !== data.senderId);
|
| 307 |
+
if (newReactions[data.reaction].length === 0) delete newReactions[data.reaction];
|
| 308 |
+
} else {
|
| 309 |
+
// Remove user from ALL other reactions first (Single reaction limit)
|
| 310 |
+
Object.keys(newReactions).forEach(key => {
|
| 311 |
+
const users = newReactions[key] || [];
|
| 312 |
+
if (users.includes(data.senderId)) {
|
| 313 |
+
newReactions[key] = users.filter(id => id !== data.senderId);
|
| 314 |
+
if (newReactions[key].length === 0) delete newReactions[key];
|
| 315 |
+
}
|
| 316 |
+
});
|
| 317 |
+
|
| 318 |
+
// Add new reaction
|
| 319 |
+
newReactions[data.reaction] = [...(newReactions[data.reaction] || []), data.senderId];
|
| 320 |
+
}
|
| 321 |
+
return { ...msg, reactions: newReactions };
|
| 322 |
+
}
|
| 323 |
+
return msg;
|
| 324 |
+
}));
|
| 325 |
+
});
|
| 326 |
+
};
|
| 327 |
+
|
| 328 |
+
init();
|
| 329 |
+
|
| 330 |
+
return () => {
|
| 331 |
+
socket.off("error");
|
| 332 |
+
socket.off("room-info");
|
| 333 |
+
socket.off("user-joined");
|
| 334 |
+
socket.off("user-left");
|
| 335 |
+
socket.off("signal");
|
| 336 |
+
socket.off("receive-message");
|
| 337 |
+
socket.off("message-status");
|
| 338 |
+
socket.off("user-typing");
|
| 339 |
+
socket.off("user-stopped-typing");
|
| 340 |
+
socket.off("room-role");
|
| 341 |
+
socket.off("kicked");
|
| 342 |
+
socket.off("muted");
|
| 343 |
+
socket.off("unmuted");
|
| 344 |
+
socket.off("room-participants");
|
| 345 |
+
socket.off("message-reaction-update");
|
| 346 |
+
socket.disconnect();
|
| 347 |
+
};
|
| 348 |
+
}, [roomId, nickname, userLimit, router, searchParams]);
|
| 349 |
+
|
| 350 |
+
const sendMessage = async () => {
|
| 351 |
+
if (!inputMessage.trim() || !myKeys.current) return;
|
| 352 |
+
|
| 353 |
+
if (typingTimeoutRef.current) {
|
| 354 |
+
clearTimeout(typingTimeoutRef.current);
|
| 355 |
+
}
|
| 356 |
+
socket.emit("typing-stop", { roomId });
|
| 357 |
+
|
| 358 |
+
try {
|
| 359 |
+
const aesKey = await generateSymKey();
|
| 360 |
+
const encryptedContent = await encryptSymMessage(aesKey, inputMessage);
|
| 361 |
+
const rawAesKey = await exportSymKey(aesKey);
|
| 362 |
+
const keysMap: Record<string, string> = {};
|
| 363 |
+
|
| 364 |
+
for (const [userId, pubKey] of otherUsersKeys.current.entries()) {
|
| 365 |
+
const encryptedAesKey = await encryptMessage(pubKey, rawAesKey);
|
| 366 |
+
keysMap[userId] = encryptedAesKey;
|
| 367 |
+
}
|
| 368 |
+
|
| 369 |
+
const messageId = crypto.randomUUID();
|
| 370 |
+
|
| 371 |
+
socket.emit("send-message", {
|
| 372 |
+
roomId,
|
| 373 |
+
payload: {
|
| 374 |
+
content: encryptedContent,
|
| 375 |
+
keys: keysMap,
|
| 376 |
+
},
|
| 377 |
+
senderId: socket.id,
|
| 378 |
+
messageId,
|
| 379 |
+
type: "text"
|
| 380 |
+
});
|
| 381 |
+
|
| 382 |
+
setMessages((prev) => [
|
| 383 |
+
...prev,
|
| 384 |
+
{
|
| 385 |
+
id: messageId,
|
| 386 |
+
senderId: "me",
|
| 387 |
+
content: inputMessage,
|
| 388 |
+
timestamp: Date.now(),
|
| 389 |
+
status: "sent",
|
| 390 |
+
type: "text"
|
| 391 |
+
},
|
| 392 |
+
]);
|
| 393 |
+
|
| 394 |
+
setInputMessage("");
|
| 395 |
+
} catch (err) {
|
| 396 |
+
console.error("Failed to send message:", err);
|
| 397 |
+
}
|
| 398 |
+
};
|
| 399 |
+
|
| 400 |
+
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
| 401 |
+
setInputMessage(e.target.value);
|
| 402 |
+
socket.emit("typing-start", { roomId });
|
| 403 |
+
if (typingTimeoutRef.current) {
|
| 404 |
+
clearTimeout(typingTimeoutRef.current);
|
| 405 |
+
}
|
| 406 |
+
typingTimeoutRef.current = setTimeout(() => {
|
| 407 |
+
socket.emit("typing-stop", { roomId });
|
| 408 |
+
}, 2000);
|
| 409 |
+
};
|
| 410 |
+
|
| 411 |
+
const handleEmojiClick = (emojiData: any) => {
|
| 412 |
+
const emoji = emojiData.emoji;
|
| 413 |
+
const input = inputRef.current;
|
| 414 |
+
if (input) {
|
| 415 |
+
const start = input.selectionStart || 0;
|
| 416 |
+
const end = input.selectionEnd || 0;
|
| 417 |
+
const newValue = inputMessage.substring(0, start) + emoji + inputMessage.substring(end);
|
| 418 |
+
setInputMessage(newValue);
|
| 419 |
+
setTimeout(() => {
|
| 420 |
+
input.focus();
|
| 421 |
+
input.setSelectionRange(start + emoji.length, start + emoji.length);
|
| 422 |
+
}, 0);
|
| 423 |
+
} else {
|
| 424 |
+
setInputMessage(inputMessage + emoji);
|
| 425 |
+
}
|
| 426 |
+
setShowEmojiPicker(false);
|
| 427 |
+
};
|
| 428 |
+
|
| 429 |
+
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
| 430 |
+
const file = e.target.files?.[0];
|
| 431 |
+
if (!file || !myKeys.current) return;
|
| 432 |
+
if (file.size > 10 * 1024 * 1024) {
|
| 433 |
+
alert("File size must be less than 10MB");
|
| 434 |
+
return;
|
| 435 |
+
}
|
| 436 |
+
setIsUploading(true);
|
| 437 |
+
try {
|
| 438 |
+
const fileAesKey = await generateSymKey();
|
| 439 |
+
const fileBuffer = await file.arrayBuffer();
|
| 440 |
+
const encryptedFileContent = await encryptFile(fileAesKey, fileBuffer);
|
| 441 |
+
const encryptedBlob = new Blob([encryptedFileContent], { type: "application/octet-stream" });
|
| 442 |
+
const formData = new FormData();
|
| 443 |
+
formData.append("file", encryptedBlob, file.name);
|
| 444 |
+
const response = await fetch("/api/upload", { method: "POST", body: formData });
|
| 445 |
+
if (!response.ok) throw new Error("Upload failed");
|
| 446 |
+
const fileData = await response.json();
|
| 447 |
+
const rawFileAesKey = await exportSymKey(fileAesKey);
|
| 448 |
+
const keysMap: Record<string, string> = {};
|
| 449 |
+
for (const [userId, pubKey] of otherUsersKeys.current.entries()) {
|
| 450 |
+
const encryptedKey = await encryptMessage(pubKey, rawFileAesKey);
|
| 451 |
+
keysMap[userId] = encryptedKey;
|
| 452 |
+
}
|
| 453 |
+
if (myKeys.current && socket.id) {
|
| 454 |
+
const myEncryptedKey = await encryptMessage(myKeys.current.public, rawFileAesKey);
|
| 455 |
+
keysMap[socket.id] = myEncryptedKey;
|
| 456 |
+
}
|
| 457 |
+
const messageId = crypto.randomUUID();
|
| 458 |
+
socket.emit("send-message", {
|
| 459 |
+
roomId,
|
| 460 |
+
payload: {
|
| 461 |
+
content: "[FILE]",
|
| 462 |
+
keys: keysMap,
|
| 463 |
+
file: {
|
| 464 |
+
id: fileData.fileId,
|
| 465 |
+
name: file.name,
|
| 466 |
+
size: file.size,
|
| 467 |
+
mimeType: file.type,
|
| 468 |
+
}
|
| 469 |
+
},
|
| 470 |
+
senderId: socket.id,
|
| 471 |
+
messageId,
|
| 472 |
+
type: "file"
|
| 473 |
+
});
|
| 474 |
+
setMessages((prev) => [
|
| 475 |
+
...prev,
|
| 476 |
+
{
|
| 477 |
+
id: messageId,
|
| 478 |
+
senderId: "me",
|
| 479 |
+
content: "[FILE]",
|
| 480 |
+
timestamp: Date.now(),
|
| 481 |
+
status: "sent",
|
| 482 |
+
type: "file",
|
| 483 |
+
file: {
|
| 484 |
+
id: fileData.fileId,
|
| 485 |
+
name: file.name,
|
| 486 |
+
size: file.size,
|
| 487 |
+
mimeType: file.type,
|
| 488 |
+
},
|
| 489 |
+
encryptedKey: socket.id ? keysMap[socket.id] : undefined
|
| 490 |
+
},
|
| 491 |
+
]);
|
| 492 |
+
if (fileInputRef.current) fileInputRef.current.value = "";
|
| 493 |
+
} catch (error) {
|
| 494 |
+
console.error("File upload error:", error);
|
| 495 |
+
alert("Failed to upload file");
|
| 496 |
+
} finally {
|
| 497 |
+
setIsUploading(false);
|
| 498 |
+
}
|
| 499 |
+
};
|
| 500 |
+
|
| 501 |
+
const getTypingIndicator = () => {
|
| 502 |
+
if (typingUsers.size === 0) return null;
|
| 503 |
+
const typingNames = Array.from(typingUsers).map(socketId =>
|
| 504 |
+
nicknames.get(socketId) || `User ${socketId.slice(0, 4)}`
|
| 505 |
+
);
|
| 506 |
+
if (typingNames.length === 1) return `${typingNames[0]} is typing...`;
|
| 507 |
+
if (typingNames.length === 2) return `${typingNames[0]} and ${typingNames[1]} are typing...`;
|
| 508 |
+
return `${typingNames.length} people are typing...`;
|
| 509 |
+
};
|
| 510 |
+
|
| 511 |
+
const handleDownload = async (fileId: string, fileName: string, encryptedKey: string) => {
|
| 512 |
+
try {
|
| 513 |
+
const response = await fetch(`/api/files/${fileId}`);
|
| 514 |
+
if (!response.ok) throw new Error("Download failed");
|
| 515 |
+
const encryptedBlob = await response.blob();
|
| 516 |
+
const encryptedBuffer = await encryptedBlob.arrayBuffer();
|
| 517 |
+
if (!myKeys.current) return;
|
| 518 |
+
const aesKeyRaw = await decryptMessage(myKeys.current.private, encryptedKey);
|
| 519 |
+
const aesKey = await importSymKey(aesKeyRaw);
|
| 520 |
+
const decryptedBuffer = await decryptFile(aesKey, encryptedBuffer);
|
| 521 |
+
const blob = new Blob([decryptedBuffer]);
|
| 522 |
+
const url = URL.createObjectURL(blob);
|
| 523 |
+
const a = document.createElement("a");
|
| 524 |
+
a.href = url;
|
| 525 |
+
a.download = fileName;
|
| 526 |
+
document.body.appendChild(a);
|
| 527 |
+
a.click();
|
| 528 |
+
document.body.removeChild(a);
|
| 529 |
+
URL.revokeObjectURL(url);
|
| 530 |
+
} catch (error) {
|
| 531 |
+
console.error("Download error:", error);
|
| 532 |
+
alert("Failed to download file");
|
| 533 |
+
}
|
| 534 |
+
};
|
| 535 |
+
|
| 536 |
+
const handleReaction = (messageId: string, emoji: string) => {
|
| 537 |
+
if (!socket.id) return;
|
| 538 |
+
|
| 539 |
+
// Optimistic update
|
| 540 |
+
setMessages(prev => prev.map(msg => {
|
| 541 |
+
if (msg.id === messageId) {
|
| 542 |
+
const newReactions = { ...(msg.reactions || {}) };
|
| 543 |
+
const users = newReactions[emoji] || [];
|
| 544 |
+
|
| 545 |
+
// Check if user is toggling the same reaction
|
| 546 |
+
if (users.includes(socket.id!)) {
|
| 547 |
+
newReactions[emoji] = users.filter(id => id !== socket.id);
|
| 548 |
+
if (newReactions[emoji].length === 0) delete newReactions[emoji];
|
| 549 |
+
} else {
|
| 550 |
+
// Remove user from ALL other reactions first (Single reaction limit)
|
| 551 |
+
Object.keys(newReactions).forEach(key => {
|
| 552 |
+
const rUsers = newReactions[key] || [];
|
| 553 |
+
if (rUsers.includes(socket.id!)) {
|
| 554 |
+
newReactions[key] = rUsers.filter(id => id !== socket.id!);
|
| 555 |
+
if (newReactions[key].length === 0) delete newReactions[key];
|
| 556 |
+
}
|
| 557 |
+
});
|
| 558 |
+
|
| 559 |
+
// Add new reaction
|
| 560 |
+
newReactions[emoji] = [...users, socket.id!];
|
| 561 |
+
}
|
| 562 |
+
return { ...msg, reactions: newReactions };
|
| 563 |
+
}
|
| 564 |
+
return msg;
|
| 565 |
+
}));
|
| 566 |
+
|
| 567 |
+
socket.emit("message-reaction", {
|
| 568 |
+
roomId,
|
| 569 |
+
messageId,
|
| 570 |
+
reaction: emoji,
|
| 571 |
+
senderId: socket.id
|
| 572 |
+
});
|
| 573 |
+
setActiveReactionMessageId(null);
|
| 574 |
+
};
|
| 575 |
+
|
| 576 |
+
const COMMON_REACTIONS = ["👍", "❤️", "😂", "😮", "😢", "😡"];
|
| 577 |
+
|
| 578 |
+
return (
|
| 579 |
+
<div className="flex flex-col h-screen max-w-5xl mx-auto p-2 md:p-4">
|
| 580 |
+
<Card className="flex-1 !py-0 !px-0 flex flex-col bg-background/50 backdrop-blur-sm border-border/50 shadow-2xl overflow-hidden">
|
| 581 |
+
<CardHeader className="border-b border-border/40 py-4 px-6 flex flex-row items-center justify-between bg-background/40 backdrop-blur-md sticky top-0 z-10">
|
| 582 |
+
<div className="flex items-center gap-3">
|
| 583 |
+
<div className="bg-gradient-to-br from-emerald-500 to-cyan-500 p-2.5 rounded-xl shadow-lg shadow-emerald-500/20">
|
| 584 |
+
<Lock className="w-5 h-5 text-white" />
|
| 585 |
+
</div>
|
| 586 |
+
<div>
|
| 587 |
+
<CardTitle className="text-lg flex items-center gap-2 font-bold tracking-tight">
|
| 588 |
+
Room: <span className="font-mono text-emerald-500">{roomId}</span>
|
| 589 |
+
</CardTitle>
|
| 590 |
+
<p className="text-xs text-muted-foreground flex items-center gap-1">
|
| 591 |
+
<span className={`w-1.5 h-1.5 rounded-full ${isConnected ? "bg-emerald-500 animate-pulse" : "bg-red-500"}`} />
|
| 592 |
+
{isConnected ? "Encrypted Connection Active" : "Disconnected"}
|
| 593 |
+
</p>
|
| 594 |
+
</div>
|
| 595 |
+
</div>
|
| 596 |
+
<div className="flex items-center gap-2">
|
| 597 |
+
<div className="flex items-center bg-muted/50 rounded-full p-1 border border-border/50">
|
| 598 |
+
<Button variant="ghost" size="sm" onClick={() => setShowParticipantsModal(true)} className="text-muted-foreground hover:text-foreground rounded-full h-8 px-3">
|
| 599 |
+
<Users className="w-4 h-4 mr-2" />
|
| 600 |
+
<span>{participantCount}</span>
|
| 601 |
+
</Button>
|
| 602 |
+
{isCreator && (
|
| 603 |
+
<Button variant="ghost" size="icon" onClick={() => setShowSettingsModal(true)} className="text-muted-foreground hover:text-foreground rounded-full h-8 w-8">
|
| 604 |
+
<Settings className="w-4 h-4" />
|
| 605 |
+
</Button>
|
| 606 |
+
)}
|
| 607 |
+
</div>
|
| 608 |
+
<div className="h-6 w-px bg-border/50 mx-1" />
|
| 609 |
+
<Button
|
| 610 |
+
variant="ghost"
|
| 611 |
+
size="icon"
|
| 612 |
+
onClick={joinCall}
|
| 613 |
+
className="text-muted-foreground hover:text-emerald-500 hover:bg-emerald-500/10 rounded-full transition-all"
|
| 614 |
+
title="Start Call"
|
| 615 |
+
>
|
| 616 |
+
<Phone className="w-5 h-5" />
|
| 617 |
+
</Button>
|
| 618 |
+
<ThemeToggle />
|
| 619 |
+
</div>
|
| 620 |
+
</CardHeader>
|
| 621 |
+
|
| 622 |
+
{localStream && (
|
| 623 |
+
<CallInterface
|
| 624 |
+
localStream={localStream}
|
| 625 |
+
remoteStreams={remoteStreams}
|
| 626 |
+
onLeave={leaveCall}
|
| 627 |
+
nicknames={nicknames}
|
| 628 |
+
/>
|
| 629 |
+
)}
|
| 630 |
+
|
| 631 |
+
<CardContent className="flex-1 overflow-hidden p-0 relative">
|
| 632 |
+
<ScrollArea className="h-full px-4 py-2">
|
| 633 |
+
<div className="flex flex-col gap-4">
|
| 634 |
+
{messages.map((msg) => {
|
| 635 |
+
const isMe = msg.senderId === "me";
|
| 636 |
+
const senderName = isMe ? "Me" : (nicknames.get(msg.senderId) || `User ${msg.senderId.slice(0, 4)}`);
|
| 637 |
+
|
| 638 |
+
return (
|
| 639 |
+
<div
|
| 640 |
+
key={msg.id}
|
| 641 |
+
className={`flex ${isMe ? "justify-end" : "justify-start"} animate-fade-in-up`}
|
| 642 |
+
>
|
| 643 |
+
{!isMe && (
|
| 644 |
+
<div className="flex flex-col items-start mr-2">
|
| 645 |
+
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-purple-500 to-indigo-500 flex items-center justify-center text-white text-xs font-bold shadow-sm">
|
| 646 |
+
{senderName.charAt(0).toUpperCase()}
|
| 647 |
+
</div>
|
| 648 |
+
</div>
|
| 649 |
+
)}
|
| 650 |
+
<div className="relative group max-w-[80%]">
|
| 651 |
+
{!isMe && (
|
| 652 |
+
<span className="text-[10px] text-muted-foreground ml-1 mb-1 block">
|
| 653 |
+
{senderName}
|
| 654 |
+
</span>
|
| 655 |
+
)}
|
| 656 |
+
<div
|
| 657 |
+
className={`rounded-2xl px-4 py-3 relative shadow-md transition-all ${isMe
|
| 658 |
+
? "bg-gradient-to-br from-emerald-500 to-emerald-600 text-white rounded-tr-sm"
|
| 659 |
+
: "bg-card border border-border/50 text-foreground rounded-tl-sm hover:border-emerald-500/30"
|
| 660 |
+
}`}
|
| 661 |
+
>
|
| 662 |
+
{/* Reaction Button (Visible on Hover) */}
|
| 663 |
+
<button
|
| 664 |
+
onClick={() => setActiveReactionMessageId(activeReactionMessageId === msg.id ? null : msg.id)}
|
| 665 |
+
className={`absolute ${isMe ? "-left-8" : "-right-8"} top-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-100 transition-all p-1.5 hover:bg-background/20 rounded-full backdrop-blur-sm`}
|
| 666 |
+
>
|
| 667 |
+
<Smile className="w-4 h-4 text-muted-foreground" />
|
| 668 |
+
</button>
|
| 669 |
+
|
| 670 |
+
{/* Reaction Picker Popover */}
|
| 671 |
+
{activeReactionMessageId === msg.id && (
|
| 672 |
+
<div className={`absolute ${isMe ? "right-full mr-2" : "left-full ml-2"} top-1/2 -translate-y-1/2 bg-popover/90 backdrop-blur-md border border-border rounded-full shadow-xl p-1.5 flex gap-1 z-50 animate-fade-in`}>
|
| 673 |
+
{COMMON_REACTIONS.map(emoji => (
|
| 674 |
+
<button
|
| 675 |
+
key={emoji}
|
| 676 |
+
onClick={() => handleReaction(msg.id, emoji)}
|
| 677 |
+
className="hover:bg-muted p-1.5 rounded-full text-lg transition-transform hover:scale-110"
|
| 678 |
+
>
|
| 679 |
+
{emoji}
|
| 680 |
+
</button>
|
| 681 |
+
))}
|
| 682 |
+
</div>
|
| 683 |
+
)}
|
| 684 |
+
|
| 685 |
+
{msg.type === "file" && msg.file ? (
|
| 686 |
+
<div className="flex items-center gap-3">
|
| 687 |
+
<div className={`p-2.5 rounded-xl ${isMe ? "bg-black/20" : "bg-muted"}`}>
|
| 688 |
+
<FileIcon className="w-6 h-6" />
|
| 689 |
+
</div>
|
| 690 |
+
<div className="flex flex-col overflow-hidden">
|
| 691 |
+
<span className="text-sm font-medium truncate max-w-[150px]">{msg.file.name}</span>
|
| 692 |
+
<span className="text-xs opacity-70">{(msg.file.size / 1024).toFixed(1)} KB</span>
|
| 693 |
+
</div>
|
| 694 |
+
{!isMe && msg.encryptedKey && (
|
| 695 |
+
<Button
|
| 696 |
+
variant="ghost"
|
| 697 |
+
size="icon"
|
| 698 |
+
className="h-8 w-8 hover:bg-black/10 rounded-full"
|
| 699 |
+
onClick={() => handleDownload(msg.file!.id, msg.file!.name, msg.encryptedKey!)}
|
| 700 |
+
>
|
| 701 |
+
<Download className="w-4 h-4" />
|
| 702 |
+
</Button>
|
| 703 |
+
)}
|
| 704 |
+
</div>
|
| 705 |
+
) : (
|
| 706 |
+
<div className={`markdown-content text-sm leading-relaxed break-words break-all whitespace-pre-wrap ${isMe ? 'text-white' : 'text-foreground'}`}>
|
| 707 |
+
<ReactMarkdown
|
| 708 |
+
remarkPlugins={[remarkGfm]}
|
| 709 |
+
components={{
|
| 710 |
+
p: ({ node, ...props }) => <p className="mb-1 last:mb-0" {...props} />,
|
| 711 |
+
a: ({ node, ...props }) => <a className="underline hover:opacity-80 transition-opacity" target="_blank" rel="noopener noreferrer" {...props} />,
|
| 712 |
+
code: ({ node, className, children, ...props }: any) => {
|
| 713 |
+
const match = /language-(\w+)/.exec(className || '')
|
| 714 |
+
return !match ? (
|
| 715 |
+
<code className={`rounded px-1 py-0.5 font-mono text-xs ${isMe ? "bg-black/20" : "bg-muted"}`} {...props}>
|
| 716 |
+
{children}
|
| 717 |
+
</code>
|
| 718 |
+
) : (
|
| 719 |
+
<code className={`block rounded p-2 font-mono text-xs overflow-x-auto my-1 ${isMe ? "bg-black/20" : "bg-muted"}`} {...props}>
|
| 720 |
+
{children}
|
| 721 |
+
</code>
|
| 722 |
+
)
|
| 723 |
+
},
|
| 724 |
+
ul: ({ node, ...props }) => <ul className="list-disc list-inside mb-1" {...props} />,
|
| 725 |
+
ol: ({ node, ...props }) => <ol className="list-decimal list-inside mb-1" {...props} />,
|
| 726 |
+
blockquote: ({ node, ...props }) => <blockquote className="border-l-2 border-current pl-2 italic my-1 opacity-80" {...props} />,
|
| 727 |
+
}}
|
| 728 |
+
>
|
| 729 |
+
{msg.content}
|
| 730 |
+
</ReactMarkdown>
|
| 731 |
+
</div>
|
| 732 |
+
)}
|
| 733 |
+
|
| 734 |
+
{/* Reactions Display */}
|
| 735 |
+
{msg.reactions && Object.keys(msg.reactions).length > 0 && (
|
| 736 |
+
<div className={`absolute -bottom-3 ${isMe ? 'right-0' : 'left-0'} flex items-center gap-0.5 bg-background/80 backdrop-blur-md border border-border/50 rounded-full px-1.5 py-0.5 shadow-sm z-10`}>
|
| 737 |
+
{Object.entries(msg.reactions).map(([emoji, users]) => (
|
| 738 |
+
<button
|
| 739 |
+
key={emoji}
|
| 740 |
+
onClick={() => handleReaction(msg.id, emoji)}
|
| 741 |
+
className={`text-[10px] min-w-[20px] h-[16px] flex items-center justify-center rounded-full transition-all hover:scale-110 ${users.includes(socket.id!) ? 'bg-emerald-500/20 text-emerald-500' : 'hover:bg-muted/50'}`}
|
| 742 |
+
title={users.map(u => nicknames.get(u) || u).join(", ")}
|
| 743 |
+
>
|
| 744 |
+
<span className="mr-0.5">{emoji}</span>
|
| 745 |
+
<span className="font-medium opacity-80">{users.length}</span>
|
| 746 |
+
</button>
|
| 747 |
+
))}
|
| 748 |
+
</div>
|
| 749 |
+
)}
|
| 750 |
+
<span className="text-[10px] opacity-60 block mt-1 flex items-center justify-end gap-1">
|
| 751 |
+
{new Date(msg.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
| 752 |
+
{isMe && (
|
| 753 |
+
<span>
|
| 754 |
+
{msg.status === "sending" && <Check className="w-3 h-3 opacity-70" />}
|
| 755 |
+
{msg.status === "sent" && <Check className="w-3 h-3 opacity-70" />}
|
| 756 |
+
{msg.status === "delivered" && <CheckCheck className="w-3 h-3 opacity-70" />}
|
| 757 |
+
{msg.status === "read" && <CheckCheck className="w-3 h-3 text-blue-200" />}
|
| 758 |
+
</span>
|
| 759 |
+
)}
|
| 760 |
+
</span>
|
| 761 |
+
</div>
|
| 762 |
+
</div>
|
| 763 |
+
</div>
|
| 764 |
+
);
|
| 765 |
+
})}
|
| 766 |
+
<div ref={messagesEndRef} />
|
| 767 |
+
</div>
|
| 768 |
+
</ScrollArea>
|
| 769 |
+
</CardContent>
|
| 770 |
+
|
| 771 |
+
<div className="p-3 border-t border-border/40 bg-background/40 backdrop-blur-md">
|
| 772 |
+
{typingUsers.size > 0 && (
|
| 773 |
+
<div className="text-xs text-emerald-500 mb-2 italic animate-pulse flex items-center gap-1 px-2">
|
| 774 |
+
<span className="w-1 h-1 rounded-full bg-emerald-500 animate-bounce" />
|
| 775 |
+
<span className="w-1 h-1 rounded-full bg-emerald-500 animate-bounce delay-75" />
|
| 776 |
+
<span className="w-1 h-1 rounded-full bg-emerald-500 animate-bounce delay-150" />
|
| 777 |
+
{getTypingIndicator()}
|
| 778 |
+
</div>
|
| 779 |
+
)}
|
| 780 |
+
<div className="relative">
|
| 781 |
+
{showEmojiPicker && (
|
| 782 |
+
<div className="absolute bottom-full right-0 mb-4 z-50 animate-fade-in-up">
|
| 783 |
+
<EmojiPicker
|
| 784 |
+
onEmojiClick={handleEmojiClick}
|
| 785 |
+
theme={"dark" as any}
|
| 786 |
+
lazyLoadEmojis={true}
|
| 787 |
+
style={{ boxShadow: '0 10px 40px rgba(0,0,0,0.5)', border: '1px solid var(--border)', backgroundColor: '#0f172a' }}
|
| 788 |
+
/>
|
| 789 |
+
</div>
|
| 790 |
+
)}
|
| 791 |
+
<form
|
| 792 |
+
onSubmit={(e) => {
|
| 793 |
+
e.preventDefault();
|
| 794 |
+
sendMessage();
|
| 795 |
+
}}
|
| 796 |
+
className="relative flex items-center gap-2 bg-muted/30 rounded-3xl border border-border/50 focus-within:border-emerald-500/50 focus-within:ring-1 focus-within:ring-emerald-500/20 transition-all p-1.5"
|
| 797 |
+
>
|
| 798 |
+
<input
|
| 799 |
+
type="file"
|
| 800 |
+
ref={fileInputRef}
|
| 801 |
+
onChange={handleFileSelect}
|
| 802 |
+
className="hidden"
|
| 803 |
+
/>
|
| 804 |
+
|
| 805 |
+
<Button
|
| 806 |
+
type="button"
|
| 807 |
+
size="icon"
|
| 808 |
+
variant="ghost"
|
| 809 |
+
onClick={() => fileInputRef.current?.click()}
|
| 810 |
+
className="text-muted-foreground hover:text-emerald-500 hover:bg-emerald-500/10 rounded-full h-9 w-9 transition-colors shrink-0"
|
| 811 |
+
disabled={isUploading}
|
| 812 |
+
>
|
| 813 |
+
{isUploading ? (
|
| 814 |
+
<div className="w-4 h-4 border-2 border-muted-foreground border-t-emerald-500 rounded-full animate-spin" />
|
| 815 |
+
) : (
|
| 816 |
+
<Paperclip className="w-5 h-5" />
|
| 817 |
+
)}
|
| 818 |
+
</Button>
|
| 819 |
+
|
| 820 |
+
<Input
|
| 821 |
+
ref={inputRef}
|
| 822 |
+
value={inputMessage}
|
| 823 |
+
onChange={handleInputChange}
|
| 824 |
+
maxLength={1000}
|
| 825 |
+
placeholder="Type a secure message..."
|
| 826 |
+
className="flex-1 bg-transparent border-none text-foreground focus-visible:ring-0 px-2 py-3 h-auto shadow-none placeholder:text-muted-foreground/70"
|
| 827 |
+
/>
|
| 828 |
+
|
| 829 |
+
{inputMessage.length > 0 && (
|
| 830 |
+
<span className={`text-[10px] font-mono mr-2 ${inputMessage.length > 900 ? "text-red-500 font-bold" : "text-muted-foreground/50"}`}>
|
| 831 |
+
{inputMessage.length}/1000
|
| 832 |
+
</span>
|
| 833 |
+
)}
|
| 834 |
+
|
| 835 |
+
<div className="flex items-center gap-1 pr-1">
|
| 836 |
+
<Button
|
| 837 |
+
type="button"
|
| 838 |
+
size="icon"
|
| 839 |
+
variant="ghost"
|
| 840 |
+
onClick={() => setShowEmojiPicker(!showEmojiPicker)}
|
| 841 |
+
className={`h-9 w-9 rounded-full transition-colors ${showEmojiPicker ? 'text-emerald-500 bg-emerald-500/10' : 'text-muted-foreground hover:text-emerald-500 hover:bg-emerald-500/10'}`}
|
| 842 |
+
>
|
| 843 |
+
<Smile className="w-5 h-5" />
|
| 844 |
+
</Button>
|
| 845 |
+
|
| 846 |
+
<Button
|
| 847 |
+
type="submit"
|
| 848 |
+
size="icon"
|
| 849 |
+
className="bg-emerald-600 hover:bg-emerald-700 text-white rounded-full h-9 w-9 shadow-lg shadow-emerald-500/20 transition-transform hover:scale-105 shrink-0"
|
| 850 |
+
disabled={!isConnected}
|
| 851 |
+
>
|
| 852 |
+
<Send className="w-4 h-4 ml-0.5" />
|
| 853 |
+
</Button>
|
| 854 |
+
</div>
|
| 855 |
+
</form>
|
| 856 |
+
</div>
|
| 857 |
+
</div>
|
| 858 |
+
</Card>
|
| 859 |
+
|
| 860 |
+
{/* Password Modal */}
|
| 861 |
+
{
|
| 862 |
+
showPasswordModal && (
|
| 863 |
+
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm flex items-center justify-center z-50">
|
| 864 |
+
<Card className="w-full max-w-sm bg-slate-900 border-slate-800">
|
| 865 |
+
<CardHeader>
|
| 866 |
+
<CardTitle className="text-white">Password Required</CardTitle>
|
| 867 |
+
</CardHeader>
|
| 868 |
+
<CardContent className="space-y-4">
|
| 869 |
+
<Input
|
| 870 |
+
type="password"
|
| 871 |
+
placeholder="Enter room password"
|
| 872 |
+
value={passwordInput}
|
| 873 |
+
onChange={(e) => setPasswordInput(e.target.value)}
|
| 874 |
+
className="bg-slate-950 border-slate-800 text-white"
|
| 875 |
+
/>
|
| 876 |
+
<Button
|
| 877 |
+
onClick={() => {
|
| 878 |
+
socket.emit("join-room", { roomId, nickname, userLimit, password: passwordInput });
|
| 879 |
+
setShowPasswordModal(false);
|
| 880 |
+
}}
|
| 881 |
+
className="w-full bg-emerald-600 hover:bg-emerald-700"
|
| 882 |
+
>
|
| 883 |
+
Join Room
|
| 884 |
+
</Button>
|
| 885 |
+
</CardContent>
|
| 886 |
+
</Card>
|
| 887 |
+
</div>
|
| 888 |
+
)
|
| 889 |
+
}
|
| 890 |
+
|
| 891 |
+
{/* Settings Modal */}
|
| 892 |
+
{
|
| 893 |
+
showSettingsModal && (
|
| 894 |
+
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm flex items-center justify-center z-50" onClick={() => setShowSettingsModal(false)}>
|
| 895 |
+
<Card className="w-full max-w-md bg-slate-900 border-slate-800" onClick={e => e.stopPropagation()}>
|
| 896 |
+
<CardHeader className="flex flex-row items-center justify-between">
|
| 897 |
+
<CardTitle className="text-white">Room Settings</CardTitle>
|
| 898 |
+
<Button variant="ghost" size="icon" onClick={() => setShowSettingsModal(false)}>
|
| 899 |
+
<X className="w-4 h-4 text-slate-400" />
|
| 900 |
+
</Button>
|
| 901 |
+
</CardHeader>
|
| 902 |
+
<CardContent className="space-y-4">
|
| 903 |
+
<div className="space-y-2">
|
| 904 |
+
<label className="text-sm text-slate-400">User Limit</label>
|
| 905 |
+
<Input
|
| 906 |
+
type="number"
|
| 907 |
+
placeholder="Max users"
|
| 908 |
+
value={newLimit}
|
| 909 |
+
onChange={(e) => setNewLimit(e.target.value)}
|
| 910 |
+
className="bg-slate-950 border-slate-800 text-white"
|
| 911 |
+
/>
|
| 912 |
+
</div>
|
| 913 |
+
<div className="space-y-2">
|
| 914 |
+
<label className="text-sm text-slate-400">Update Password (leave empty to remove)</label>
|
| 915 |
+
<Input
|
| 916 |
+
type="password"
|
| 917 |
+
placeholder="New password"
|
| 918 |
+
value={newPassword}
|
| 919 |
+
onChange={(e) => setNewPassword(e.target.value)}
|
| 920 |
+
className="bg-slate-950 border-slate-800 text-white"
|
| 921 |
+
/>
|
| 922 |
+
</div>
|
| 923 |
+
<Button
|
| 924 |
+
onClick={() => {
|
| 925 |
+
socket.emit("update-room-settings", {
|
| 926 |
+
roomId,
|
| 927 |
+
limit: newLimit ? parseInt(newLimit) : undefined,
|
| 928 |
+
password: newPassword
|
| 929 |
+
});
|
| 930 |
+
setShowSettingsModal(false);
|
| 931 |
+
setNewPassword("");
|
| 932 |
+
setNewLimit("");
|
| 933 |
+
}}
|
| 934 |
+
className="w-full bg-emerald-600 hover:bg-emerald-700"
|
| 935 |
+
>
|
| 936 |
+
Save Changes
|
| 937 |
+
</Button>
|
| 938 |
+
</CardContent>
|
| 939 |
+
</Card>
|
| 940 |
+
</div>
|
| 941 |
+
)
|
| 942 |
+
}
|
| 943 |
+
|
| 944 |
+
{/* Participants Modal */}
|
| 945 |
+
{
|
| 946 |
+
showParticipantsModal && (
|
| 947 |
+
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm flex items-center justify-center z-50" onClick={() => setShowParticipantsModal(false)}>
|
| 948 |
+
<Card className="w-full max-w-md bg-slate-900 border-slate-800 max-h-[80vh] flex flex-col" onClick={e => e.stopPropagation()}>
|
| 949 |
+
<CardHeader className="flex flex-row items-center justify-between border-b border-slate-800">
|
| 950 |
+
<CardTitle className="text-white">Participants ({participants.length})</CardTitle>
|
| 951 |
+
<Button variant="ghost" size="icon" onClick={() => setShowParticipantsModal(false)}>
|
| 952 |
+
<X className="w-4 h-4 text-slate-400" />
|
| 953 |
+
</Button>
|
| 954 |
+
</CardHeader>
|
| 955 |
+
<CardContent className="p-0 overflow-hidden flex-1">
|
| 956 |
+
<ScrollArea className="h-full max-h-[60vh]">
|
| 957 |
+
<div className="p-4 space-y-2">
|
| 958 |
+
{participants.map((p) => (
|
| 959 |
+
<div key={p.socketId} className="flex items-center justify-between p-2 rounded bg-slate-950/50 border border-slate-800">
|
| 960 |
+
<div className="flex items-center gap-3">
|
| 961 |
+
<div className="w-8 h-8 rounded-full bg-emerald-500/10 flex items-center justify-center">
|
| 962 |
+
<User className="w-4 h-4 text-emerald-400" />
|
| 963 |
+
</div>
|
| 964 |
+
<div>
|
| 965 |
+
<p className="text-sm font-medium text-white">
|
| 966 |
+
{p.nickname || "Anonymous"}
|
| 967 |
+
{p.socketId === socket.id && " (You)"}
|
| 968 |
+
</p>
|
| 969 |
+
<p className="text-xs text-slate-500">ID: {p.userId.slice(0, 8)}...</p>
|
| 970 |
+
</div>
|
| 971 |
+
</div>
|
| 972 |
+
{isCreator && p.socketId !== socket.id && (
|
| 973 |
+
<div className="flex items-center gap-1">
|
| 974 |
+
{p.isMuted ? (
|
| 975 |
+
<Button
|
| 976 |
+
variant="ghost"
|
| 977 |
+
size="icon"
|
| 978 |
+
className="h-8 w-8 text-red-400 hover:text-red-300"
|
| 979 |
+
onClick={() => socket.emit("unmute-user", { roomId, targetUserId: p.userId })}
|
| 980 |
+
title="Unmute"
|
| 981 |
+
>
|
| 982 |
+
<MicOff className="w-4 h-4" />
|
| 983 |
+
</Button>
|
| 984 |
+
) : (
|
| 985 |
+
<Button
|
| 986 |
+
variant="ghost"
|
| 987 |
+
size="icon"
|
| 988 |
+
className="h-8 w-8 text-slate-400 hover:text-yellow-400"
|
| 989 |
+
onClick={() => socket.emit("mute-user", { roomId, targetUserId: p.userId })}
|
| 990 |
+
title="Mute"
|
| 991 |
+
>
|
| 992 |
+
<Mic className="w-4 h-4" />
|
| 993 |
+
</Button>
|
| 994 |
+
)}
|
| 995 |
+
<Button
|
| 996 |
+
variant="ghost"
|
| 997 |
+
size="icon"
|
| 998 |
+
className="h-8 w-8 text-slate-400 hover:text-red-400"
|
| 999 |
+
onClick={() => socket.emit("kick-user", { roomId, targetUserId: p.userId })}
|
| 1000 |
+
title="Kick"
|
| 1001 |
+
>
|
| 1002 |
+
<UserX className="w-4 h-4" />
|
| 1003 |
+
</Button>
|
| 1004 |
+
</div>
|
| 1005 |
+
)}
|
| 1006 |
+
</div>
|
| 1007 |
+
))}
|
| 1008 |
+
</div>
|
| 1009 |
+
</ScrollArea>
|
| 1010 |
+
</CardContent>
|
| 1011 |
+
</Card>
|
| 1012 |
+
</div>
|
| 1013 |
+
)
|
| 1014 |
+
}
|
| 1015 |
+
</div >
|
| 1016 |
+
);
|
| 1017 |
+
}
|
components/providers.tsx
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { SessionProvider } from "next-auth/react";
|
| 4 |
+
|
| 5 |
+
export function Providers({ children }: { children: React.ReactNode }) {
|
| 6 |
+
return <SessionProvider>{children}</SessionProvider>;
|
| 7 |
+
}
|
components/theme-provider.tsx
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import * as React from "react"
|
| 4 |
+
import { ThemeProvider as NextThemesProvider } from "next-themes"
|
| 5 |
+
|
| 6 |
+
export function ThemeProvider({
|
| 7 |
+
children,
|
| 8 |
+
...props
|
| 9 |
+
}: React.ComponentProps<typeof NextThemesProvider>) {
|
| 10 |
+
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
|
| 11 |
+
}
|
components/theme-toggle.tsx
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import * as React from "react"
|
| 4 |
+
import { Moon, Sun } from "lucide-react"
|
| 5 |
+
import { useTheme } from "next-themes"
|
| 6 |
+
|
| 7 |
+
import { Button } from "@/components/ui/button"
|
| 8 |
+
|
| 9 |
+
export function ThemeToggle() {
|
| 10 |
+
const { setTheme, theme } = useTheme()
|
| 11 |
+
|
| 12 |
+
return (
|
| 13 |
+
<Button
|
| 14 |
+
variant="ghost"
|
| 15 |
+
size="icon"
|
| 16 |
+
onClick={() => setTheme(theme === "light" ? "dark" : "light")}
|
| 17 |
+
className="rounded-full"
|
| 18 |
+
>
|
| 19 |
+
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
| 20 |
+
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
| 21 |
+
<span className="sr-only">Toggle theme</span>
|
| 22 |
+
</Button>
|
| 23 |
+
)
|
| 24 |
+
}
|
components/ui/button.tsx
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react"
|
| 2 |
+
import { Slot } from "@radix-ui/react-slot"
|
| 3 |
+
import { cva, type VariantProps } from "class-variance-authority"
|
| 4 |
+
|
| 5 |
+
import { cn } from "@/lib/utils"
|
| 6 |
+
|
| 7 |
+
const buttonVariants = cva(
|
| 8 |
+
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
| 9 |
+
{
|
| 10 |
+
variants: {
|
| 11 |
+
variant: {
|
| 12 |
+
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
| 13 |
+
destructive:
|
| 14 |
+
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
| 15 |
+
outline:
|
| 16 |
+
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
| 17 |
+
secondary:
|
| 18 |
+
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
| 19 |
+
ghost:
|
| 20 |
+
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
| 21 |
+
link: "text-primary underline-offset-4 hover:underline",
|
| 22 |
+
},
|
| 23 |
+
size: {
|
| 24 |
+
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
| 25 |
+
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
| 26 |
+
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
| 27 |
+
icon: "size-9",
|
| 28 |
+
"icon-sm": "size-8",
|
| 29 |
+
"icon-lg": "size-10",
|
| 30 |
+
},
|
| 31 |
+
},
|
| 32 |
+
defaultVariants: {
|
| 33 |
+
variant: "default",
|
| 34 |
+
size: "default",
|
| 35 |
+
},
|
| 36 |
+
}
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
function Button({
|
| 40 |
+
className,
|
| 41 |
+
variant,
|
| 42 |
+
size,
|
| 43 |
+
asChild = false,
|
| 44 |
+
...props
|
| 45 |
+
}: React.ComponentProps<"button"> &
|
| 46 |
+
VariantProps<typeof buttonVariants> & {
|
| 47 |
+
asChild?: boolean
|
| 48 |
+
}) {
|
| 49 |
+
const Comp = asChild ? Slot : "button"
|
| 50 |
+
|
| 51 |
+
return (
|
| 52 |
+
<Comp
|
| 53 |
+
data-slot="button"
|
| 54 |
+
className={cn(buttonVariants({ variant, size, className }))}
|
| 55 |
+
{...props}
|
| 56 |
+
/>
|
| 57 |
+
)
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
export { Button, buttonVariants }
|
components/ui/card.tsx
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react"
|
| 2 |
+
|
| 3 |
+
import { cn } from "@/lib/utils"
|
| 4 |
+
|
| 5 |
+
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
| 6 |
+
return (
|
| 7 |
+
<div
|
| 8 |
+
data-slot="card"
|
| 9 |
+
className={cn(
|
| 10 |
+
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
| 11 |
+
className
|
| 12 |
+
)}
|
| 13 |
+
{...props}
|
| 14 |
+
/>
|
| 15 |
+
)
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
| 19 |
+
return (
|
| 20 |
+
<div
|
| 21 |
+
data-slot="card-header"
|
| 22 |
+
className={cn(
|
| 23 |
+
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
| 24 |
+
className
|
| 25 |
+
)}
|
| 26 |
+
{...props}
|
| 27 |
+
/>
|
| 28 |
+
)
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
| 32 |
+
return (
|
| 33 |
+
<div
|
| 34 |
+
data-slot="card-title"
|
| 35 |
+
className={cn("leading-none font-semibold", className)}
|
| 36 |
+
{...props}
|
| 37 |
+
/>
|
| 38 |
+
)
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
| 42 |
+
return (
|
| 43 |
+
<div
|
| 44 |
+
data-slot="card-description"
|
| 45 |
+
className={cn("text-muted-foreground text-sm", className)}
|
| 46 |
+
{...props}
|
| 47 |
+
/>
|
| 48 |
+
)
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
| 52 |
+
return (
|
| 53 |
+
<div
|
| 54 |
+
data-slot="card-action"
|
| 55 |
+
className={cn(
|
| 56 |
+
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
| 57 |
+
className
|
| 58 |
+
)}
|
| 59 |
+
{...props}
|
| 60 |
+
/>
|
| 61 |
+
)
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
| 65 |
+
return (
|
| 66 |
+
<div
|
| 67 |
+
data-slot="card-content"
|
| 68 |
+
className={cn("px-6", className)}
|
| 69 |
+
{...props}
|
| 70 |
+
/>
|
| 71 |
+
)
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
| 75 |
+
return (
|
| 76 |
+
<div
|
| 77 |
+
data-slot="card-footer"
|
| 78 |
+
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
| 79 |
+
{...props}
|
| 80 |
+
/>
|
| 81 |
+
)
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
export {
|
| 85 |
+
Card,
|
| 86 |
+
CardHeader,
|
| 87 |
+
CardFooter,
|
| 88 |
+
CardTitle,
|
| 89 |
+
CardAction,
|
| 90 |
+
CardDescription,
|
| 91 |
+
CardContent,
|
| 92 |
+
}
|
components/ui/input.tsx
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react"
|
| 2 |
+
|
| 3 |
+
import { cn } from "@/lib/utils"
|
| 4 |
+
|
| 5 |
+
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
| 6 |
+
return (
|
| 7 |
+
<input
|
| 8 |
+
type={type}
|
| 9 |
+
data-slot="input"
|
| 10 |
+
className={cn(
|
| 11 |
+
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
| 12 |
+
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
| 13 |
+
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
| 14 |
+
className
|
| 15 |
+
)}
|
| 16 |
+
{...props}
|
| 17 |
+
/>
|
| 18 |
+
)
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
export { Input }
|
components/ui/scroll-area.tsx
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import * as React from "react"
|
| 4 |
+
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
| 5 |
+
|
| 6 |
+
import { cn } from "@/lib/utils"
|
| 7 |
+
|
| 8 |
+
function ScrollArea({
|
| 9 |
+
className,
|
| 10 |
+
children,
|
| 11 |
+
...props
|
| 12 |
+
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
| 13 |
+
return (
|
| 14 |
+
<ScrollAreaPrimitive.Root
|
| 15 |
+
data-slot="scroll-area"
|
| 16 |
+
className={cn("relative", className)}
|
| 17 |
+
{...props}
|
| 18 |
+
>
|
| 19 |
+
<ScrollAreaPrimitive.Viewport
|
| 20 |
+
data-slot="scroll-area-viewport"
|
| 21 |
+
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
|
| 22 |
+
>
|
| 23 |
+
{children}
|
| 24 |
+
</ScrollAreaPrimitive.Viewport>
|
| 25 |
+
<ScrollBar />
|
| 26 |
+
<ScrollAreaPrimitive.Corner />
|
| 27 |
+
</ScrollAreaPrimitive.Root>
|
| 28 |
+
)
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
function ScrollBar({
|
| 32 |
+
className,
|
| 33 |
+
orientation = "vertical",
|
| 34 |
+
...props
|
| 35 |
+
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
| 36 |
+
return (
|
| 37 |
+
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
| 38 |
+
data-slot="scroll-area-scrollbar"
|
| 39 |
+
orientation={orientation}
|
| 40 |
+
className={cn(
|
| 41 |
+
"flex touch-none p-px transition-colors select-none",
|
| 42 |
+
orientation === "vertical" &&
|
| 43 |
+
"h-full w-2.5 border-l border-l-transparent",
|
| 44 |
+
orientation === "horizontal" &&
|
| 45 |
+
"h-2.5 flex-col border-t border-t-transparent",
|
| 46 |
+
className
|
| 47 |
+
)}
|
| 48 |
+
{...props}
|
| 49 |
+
>
|
| 50 |
+
<ScrollAreaPrimitive.ScrollAreaThumb
|
| 51 |
+
data-slot="scroll-area-thumb"
|
| 52 |
+
className="bg-border relative flex-1 rounded-full"
|
| 53 |
+
/>
|
| 54 |
+
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
| 55 |
+
)
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
export { ScrollArea, ScrollBar }
|
docs/details.md
ADDED
|
@@ -0,0 +1,1362 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
|
| 3 |
+
Project Synopsis
|
| 4 |
+
|
| 5 |
+
TITLE OF THE PROJECT:
|
| 6 |
+
|
| 7 |
+
End-to-End Encrypted Chat Room Web
|
| 8 |
+
|
| 9 |
+
Application
|
| 10 |
+
|
| 11 |
+
CONTENTS OF SYNOPSIS
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
Title of the Project........................................................................................................................................................... Catalog
|
| 16 |
+
|
| 17 |
+
Introduction and Objectives of the Project.........................................................................................................
|
| 18 |
+
|
| 19 |
+
2.1 Introduction...........................................................................................................................................................
|
| 20 |
+
|
| 21 |
+
2.2 Objectives...............................................................................................................................................................
|
| 22 |
+
|
| 23 |
+
Project Category...............................................................................................................................................................
|
| 24 |
+
|
| 25 |
+
Analysis................................................................................................................................................................................
|
| 26 |
+
|
| 27 |
+
4.1 User Flow Diagram (Activity Diagram)....................................................................................................
|
| 28 |
+
|
| 29 |
+
4.2 Context Level DFD (Level 0)..........................................................................................................................
|
| 30 |
+
|
| 31 |
+
4.3 Level 1 DFD............................................................................................................................................................
|
| 32 |
+
|
| 33 |
+
4.4 Level 2 DFDs.......................................................................................................................................................
|
| 34 |
+
|
| 35 |
+
4.4.1 Level 2 DFD for Process 1.0: Room Creation \& Access Control.............................................
|
| 36 |
+
|
| 37 |
+
4.4.2 Level 2 DFD for Message Exchange (Involving P3.0 and P4.0)..............................................
|
| 38 |
+
|
| 39 |
+
4.5 Entity Relationship Diagram (ERD) / Database Design (for Temporary Room Data)...
|
| 40 |
+
|
| 41 |
+
Complete System Structure.....................................................................................................................................
|
| 42 |
+
|
| 43 |
+
5.1 Number of Modules and their Description..........................................................................................
|
| 44 |
+
|
| 45 |
+
5.2 Data Structures..................................................................................................................................................
|
| 46 |
+
|
| 47 |
+
5.3 Process Logic of Each Module....................................................................................................................
|
| 48 |
+
|
| 49 |
+
5.4 Testing Process to be Used..........................................................................................................................
|
| 50 |
+
|
| 51 |
+
5.5 Reports Generation.........................................................................................................................................
|
| 52 |
+
|
| 53 |
+
Tools / Platform, Hardware and Software Requirement Specifications............................................
|
| 54 |
+
|
| 55 |
+
6.1 Software Requirements................................................................................................................................
|
| 56 |
+
|
| 57 |
+
6.2 Hardware Requirements..............................................................................................................................
|
| 58 |
+
|
| 59 |
+
Industry/Client Affiliation........................................................................................................................................
|
| 60 |
+
|
| 61 |
+
Future Scope and Further Enhancement of the Project.............................................................................
|
| 62 |
+
|
| 63 |
+
Limitations of the Project (Initial Version)......................................................................................................
|
| 64 |
+
|
| 65 |
+
Security and Validation Checks...........................................................................................................................
|
| 66 |
+
|
| 67 |
+
Bibliography (References).....................................................................................................................................
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
1\. Title of the Project........................................................................................................................................................... Catalog
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
End-to-End Encrypted Chat Room Web Application
|
| 76 |
+
|
| 77 |
+
2\. Introduction and Objectives of the Project.........................................................................................................
|
| 78 |
+
|
| 79 |
+
2.1 Introduction...........................................................................................................................................................
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
Today’s digital communication landscape is dominated by concerns about privacy and data
|
| 84 |
+
|
| 85 |
+
security. Most popular messaging platforms store our conversations on their servers, making
|
| 86 |
+
|
| 87 |
+
them vulnerable to data breaches, government surveillance, and unauthorized access. This
|
| 88 |
+
|
| 89 |
+
creates a real need for truly private communication tools.
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
My project addresses this problem by developing a web-basedchat application that prioritizes
|
| 94 |
+
|
| 95 |
+
user privacy through three key features:
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
► End-to-End Encryption (E2EE): Messages are encrypted on the sender’s device and
|
| 100 |
+
|
| 101 |
+
can only be decrypted by the intended recipients. Even the server hosting the
|
| 102 |
+
|
| 103 |
+
application cannot read the message content - it simply actsas a relay for encrypted
|
| 104 |
+
|
| 105 |
+
data..
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
► Ephemerality (Temporary Nature): Unlike traditional chat apps that store your
|
| 110 |
+
|
| 111 |
+
conversation history forever, this application is designed to be ephemeral. Messages
|
| 112 |
+
|
| 113 |
+
aren’t saved anywhere - not on servers, not in databases. When you close the chat room,
|
| 114 |
+
|
| 115 |
+
everything disappears.
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
► Room-Based, Code-Driven Access: Instead of requiring user accounts or friend lists,
|
| 120 |
+
|
| 121 |
+
people can create temporary chat rooms with unique access codes. Share the code with
|
| 122 |
+
|
| 123 |
+
whoever you want to chat with, and they can join instantly.
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
The technical foundation includes Next.js and Tailwind CSSfor a modern, responsive user
|
| 128 |
+
|
| 129 |
+
interface, Socket.io for real-time messaging capabilities, and MongoDB for managing temporary
|
| 130 |
+
|
| 131 |
+
room information (but never storing actual messages). The goal is creating a platform where
|
| 132 |
+
|
| 133 |
+
people can have genuinely private conversations without worrying about their data being
|
| 134 |
+
|
| 135 |
+
stored or accessed by others.
|
| 136 |
+
|
| 137 |
+
2.2 Objectives...............................................................................................................................................................
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
The principal objectives for this project are:
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
Develop a Secure Chat Platform: Create a web application where users can make
|
| 146 |
+
|
| 147 |
+
temporary chat rooms using unique access codes, without needing to create accounts or
|
| 148 |
+
|
| 149 |
+
provide personal information.
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
Implement Strong E2EE: Use proven cryptographic methods to ensure that only the
|
| 154 |
+
|
| 155 |
+
people in a conversation can read the messages. This includes secure key generation
|
| 156 |
+
|
| 157 |
+
and exchange between users.
|
| 158 |
+
|
| 159 |
+
Guarantee Message Ephemerality: To design the system so that chat messages are not
|
| 160 |
+
|
| 161 |
+
stored persistently. Message history will be volatile, existing only for the duration of an
|
| 162 |
+
|
| 163 |
+
active chat room session on the client-side.
|
| 164 |
+
|
| 165 |
+
Enable Real-Time Interaction: Use WebSocket technology through Socket.io to make
|
| 166 |
+
|
| 167 |
+
sure messages are delivered instantly between users in the same chat room.
|
| 168 |
+
|
| 169 |
+
Secure Room Access Control: To implement a system for generating unique, non-
|
| 170 |
+
|
| 171 |
+
guessable room codes and to allow controlled entry based on these codes, including
|
| 172 |
+
|
| 173 |
+
options for setting user limits per room.
|
| 174 |
+
|
| 175 |
+
Create an Intuitive User Interface: To build a user-friendly, responsive frontend that
|
| 176 |
+
|
| 177 |
+
simplifies the process of room creation, joining, and secure messaging.
|
| 178 |
+
|
| 179 |
+
Minimize Data Retention: To ensure the backend system only manages essential,
|
| 180 |
+
|
| 181 |
+
transient session data for active rooms (e.g., room IDs, participant session identifiers for
|
| 182 |
+
|
| 183 |
+
routing), without storing E2EE private keys or any decrypted message content.
|
| 184 |
+
|
| 185 |
+
Implement Effective Chat Destruction: When a chat room ends or everyone leaves,
|
| 186 |
+
|
| 187 |
+
automatically delete all related temporary data from the server and clear message
|
| 188 |
+
|
| 189 |
+
displays from users’ browsers.
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
3\. Project Category...............................................................................................................................................................
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
This project spans multiple areas of computer science and software development:
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
► Networking Application: It fundamentally relies on network protocols for real-time,
|
| 202 |
+
|
| 203 |
+
multi-user communication, primarily leveraging WebSockets through Socket.io.
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
► Web Application: The user interface and core functionality will be deliveredas a web-
|
| 208 |
+
|
| 209 |
+
based application, accessible via standard browsers, using Next.js for the frontend and
|
| 210 |
+
|
| 211 |
+
Node.js for the backend.
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
► Security Application: Privacy and security are central to the project, involving
|
| 216 |
+
|
| 217 |
+
cryptographic techniques like end-to-end encryption and principles of data
|
| 218 |
+
|
| 219 |
+
minimization and temporary storage.
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
► Real-Time Systems: The chat functionality requires immediate message processing
|
| 224 |
+
|
| 225 |
+
and delivery to create a smooth conversation experience forusers.
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
While the project uses MongoDB as a database, it’s specifically for temporary session
|
| 230 |
+
|
| 231 |
+
management rather than permanent data storage, which distinguishes it from typical RDBMS-
|
| 232 |
+
|
| 233 |
+
centric or data-intensive applications.
|
| 234 |
+
|
| 235 |
+
4\. Analysis................................................................................................................................................................................
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
This section outlines the system’s operational flow, data interactions, and structural design
|
| 240 |
+
|
| 241 |
+
through various diagrams.
|
| 242 |
+
|
| 243 |
+
4.1 User Flow Diagram (Activity Diagram)....................................................................................................
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
This diagram depicts the typical sequence of actions and decisions a user makes while
|
| 248 |
+
|
| 249 |
+
interacting with the application, from creating/joining a room to participating in a chat and
|
| 250 |
+
|
| 251 |
+
exiting.
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
Description of User Flow Diagram: The diagram illustrates the user journey: one user
|
| 256 |
+
|
| 257 |
+
(Creator) initiates a room, obtains a unique code, and shares it. Another user (Joiner) uses this
|
| 258 |
+
|
| 259 |
+
code to enter. Inside the room, a secure key exchange establishes E2EE. Encrypted messages
|
| 260 |
+
|
| 261 |
+
are then exchanged, relayed by the server but only readable by clients. Upon exit, local data is
|
| 262 |
+
|
| 263 |
+
cleared, and the server eventually purges room metadata if all users depart, ensuring
|
| 264 |
+
|
| 265 |
+
ephemerality.
|
| 266 |
+
|
| 267 |
+
4.2 Context Level DFD (Level 0)..........................................................................................................................
|
| 268 |
+
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
This diagram provides a high-level overview of the system asa single process, showing its
|
| 272 |
+
|
| 273 |
+
inputs from and outputs to the external entity (User).
|
| 274 |
+
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
Description of Context Level DFD: Users interact with the “ E2E Chat Room System” by
|
| 278 |
+
|
| 279 |
+
providing inputs like room creation requests, room joiningrequests, and message content
|
| 280 |
+
|
| 281 |
+
(which gets encrypted before leaving their device). The system responds with outputs
|
| 282 |
+
|
| 283 |
+
including room access confirmation, encrypted messages from other users, and status
|
| 284 |
+
|
| 285 |
+
notifications. The main purpose is facilitating secure, temporary communication between users.
|
| 286 |
+
|
| 287 |
+
4.3 Level 1 DFD............................................................................................................................................................
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
|
| 291 |
+
This diagram decomposes the system into its major functional processes, data stores, and the
|
| 292 |
+
|
| 293 |
+
data flows between them.
|
| 294 |
+
|
| 295 |
+
|
| 296 |
+
|
| 297 |
+
Description of Level 1 DFD:
|
| 298 |
+
|
| 299 |
+
|
| 300 |
+
|
| 301 |
+
► P1.0 Room Creation \& Access Control: Manages requests to create new rooms,
|
| 302 |
+
|
| 303 |
+
validates attempts to join existing rooms, and controls access based on room codes and
|
| 304 |
+
|
| 305 |
+
user limits. Interacts withD1to store and retrieve room status.
|
| 306 |
+
|
| 307 |
+
► P2.0 User Session Mgt. \& Signaling: Handles individual user connections to rooms,
|
| 308 |
+
|
| 309 |
+
manages their session lifecycle (join/leave), and crucially, facilitates the exchange of
|
| 310 |
+
|
| 311 |
+
signaling messages required for clients to perform E2EE keyestablishment.
|
| 312 |
+
|
| 313 |
+
► P3.0 Client-Side E2E Encryption/Decryption: This process resides entirely on the
|
| 314 |
+
|
| 315 |
+
client’s device. It encrypts outgoing messages before transmission and decrypts
|
| 316 |
+
|
| 317 |
+
incoming messages after reception, using keys unknown to the server.
|
| 318 |
+
|
| 319 |
+
► P4.0 Server-Side Real-time Encrypted Message Relay: The backend component that
|
| 320 |
+
|
| 321 |
+
receives encrypted messages from one user and forwards themto other authenticated
|
| 322 |
+
|
| 323 |
+
users in the same room. It can’t read the message content.
|
| 324 |
+
|
| 325 |
+
► D1: Temporary Active Room Metadata Store: A MongoDB database that holds
|
| 326 |
+
|
| 327 |
+
temporary information about active rooms (like room codes,user limits, and lists of
|
| 328 |
+
|
| 329 |
+
current participants). This data gets deleted when rooms become inactive.
|
| 330 |
+
|
| 331 |
+
|
| 332 |
+
|
| 333 |
+
4.4 Level 2 DFDs.......................................................................................................................................................
|
| 334 |
+
|
| 335 |
+
|
| 336 |
+
|
| 337 |
+
These diagrams provide a more detailed breakdown of selected processes from the Level 1
|
| 338 |
+
|
| 339 |
+
DFD.
|
| 340 |
+
|
| 341 |
+
4.4.1 Level 2 DFD for Process 1.0: Room Creation \& Access Control.............................................
|
| 342 |
+
|
| 343 |
+
4.4.2 Level 2 DFD for Message Exchange (Involving P3.0 and P4.0)..............................................
|
| 344 |
+
|
| 345 |
+
|
| 346 |
+
|
| 347 |
+
This diagram details the flow of a message from sender to receiver, highlighting client-side
|
| 348 |
+
|
| 349 |
+
encryption/decryption and server-side relay.
|
| 350 |
+
|
| 351 |
+
4.5 Entity Relationship Diagram (ERD) / Database Design (for Temporary Room Data)...
|
| 352 |
+
|
| 353 |
+
|
| 354 |
+
|
| 355 |
+
This ERD conceptualizes the entities and relationships forthe temporary metadata stored by
|
| 356 |
+
|
| 357 |
+
the server (e.g., in MongoDB) to manage active chat room sessions. No message content is
|
| 358 |
+
|
| 359 |
+
stored.
|
| 360 |
+
|
| 361 |
+
|
| 362 |
+
|
| 363 |
+
Conceptual MongoDB Document Structure (for anActiveRoomscollection):
|
| 364 |
+
|
| 365 |
+
|
| 366 |
+
|
| 367 |
+
This example illustrates how an active room’s metadata might be structured in a MongoDB
|
| 368 |
+
|
| 369 |
+
document.
|
| 370 |
+
|
| 371 |
+
|
| 372 |
+
|
| 373 |
+
{
|
| 374 |
+
|
| 375 |
+
"\_id": "", // Auto-generated by MongoDB
|
| 376 |
+
|
| 377 |
+
"roomCode": "A7B3C9", // Unique , application-generated room identifier
|
| 378 |
+
|
| 379 |
+
"userLimit": 25 ,
|
| 380 |
+
|
| 381 |
+
"participantSessions": \[ // Array of active participant session details
|
| 382 |
+
|
| 383 |
+
{ "sessionId": "socket\_io\_session\_id\_1", "joinedAt": "ISODate(...)"},
|
| 384 |
+
|
| 385 |
+
{ "sessionId": "socket\_io\_session\_id\_2", "joinedAt": "ISODate(...)"}
|
| 386 |
+
|
| 387 |
+
],
|
| 388 |
+
|
| 389 |
+
"createdAt": "ISODate(...)",
|
| 390 |
+
|
| 391 |
+
"lastActivityAt": "ISODate(...)" // Updated on new message or join/leave
|
| 392 |
+
|
| 393 |
+
}
|
| 394 |
+
|
| 395 |
+
|
| 396 |
+
|
| 397 |
+
This structure would be queried and updated by the backend (Process 1.0 and 2.0) to manage
|
| 398 |
+
|
| 399 |
+
room access and message routing.
|
| 400 |
+
|
| 401 |
+
5\. Complete System Structure.....................................................................................................................................
|
| 402 |
+
|
| 403 |
+
5.1 Number of Modules and their Description..........................................................................................
|
| 404 |
+
|
| 405 |
+
|
| 406 |
+
|
| 407 |
+
The application’s architecture is modular to promote clarity, maintainability, and testability.
|
| 408 |
+
|
| 409 |
+
Key modules include:
|
| 410 |
+
|
| 411 |
+
|
| 412 |
+
|
| 413 |
+
Frontend User Interface (UI) Module (Next.js, Tailwind CSS):
|
| 414 |
+
|
| 415 |
+
Description: This module handles everything users see and interact with.It
|
| 416 |
+
|
| 417 |
+
renders all the pages (home page, room creation forms, chat interface), manages
|
| 418 |
+
|
| 419 |
+
user interactions, displays decrypted messages, and maintains the visual state of
|
| 420 |
+
|
| 421 |
+
the application.
|
| 422 |
+
|
| 423 |
+
Client-Side End-to-End Encryption (E2EE) Module (Web Crypto API):
|
| 424 |
+
|
| 425 |
+
Description: Operating entirely within each user’s browser, this is the security
|
| 426 |
+
|
| 427 |
+
heart of the system. It generates encryption keys, handles secure key exchange
|
| 428 |
+
|
| 429 |
+
with other users in the room, encrypts outgoing messages, and decrypts
|
| 430 |
+
|
| 431 |
+
incoming messages. Importantly, all private keys stay on the user’s device and
|
| 432 |
+
|
| 433 |
+
never get sent to the server.
|
| 434 |
+
|
| 435 |
+
Client-Side Real-Time Communication Module (Socket.io Client):
|
| 436 |
+
|
| 437 |
+
Description: This module manages the persistent connection between the user’s
|
| 438 |
+
|
| 439 |
+
browser and the server. It handles sending encrypted messages to the server and
|
| 440 |
+
|
| 441 |
+
receiving relayed encrypted messages and setup signals from the server.
|
| 442 |
+
|
| 443 |
+
Backend Room Management \& Signaling Server Module (Node.js, Socket.io):
|
| 444 |
+
|
| 445 |
+
Description: The server-side component that coordinates chat room operations.
|
| 446 |
+
|
| 447 |
+
It processes room creation requests, validates room joining attempts, manages
|
| 448 |
+
|
| 449 |
+
active room lifecycles, helps facilitate encrypted key exchange between clients,
|
| 450 |
+
|
| 451 |
+
and relays encrypted messages. Crucially, it never has access to actual message
|
| 452 |
+
|
| 453 |
+
content or users’ private keys.
|
| 454 |
+
|
| 455 |
+
Temporary Room Metadata Storage Module (MongoDB Driver \& Logic):
|
| 456 |
+
|
| 457 |
+
Description: This backend module provides the connection and operationsfor
|
| 458 |
+
|
| 459 |
+
interacting with MongoDB, which stores temporary information about active
|
| 460 |
+
|
| 461 |
+
chat rooms (room codes, user limits, participant lists) butnever stores messages
|
| 462 |
+
|
| 463 |
+
or encryption keys. CRUD operations (Create, Read, Update,Delete) on
|
| 464 |
+
|
| 465 |
+
temporary room metadata, ensuring data is available for room management and
|
| 466 |
+
|
| 467 |
+
cleared upon room inactivity.
|
| 468 |
+
|
| 469 |
+
|
| 470 |
+
|
| 471 |
+
5.2 Data Structures..................................................................................................................................................
|
| 472 |
+
|
| 473 |
+
|
| 474 |
+
|
| 475 |
+
The application will utilize various data structures, bothclient-side and server-side, to manage
|
| 476 |
+
|
| 477 |
+
state and information flow:
|
| 478 |
+
|
| 479 |
+
|
| 480 |
+
|
| 481 |
+
► Client-Side Structures:
|
| 482 |
+
|
| 483 |
+
|
| 484 |
+
|
| 485 |
+
currentRoomDetails: An object holding details of the room the client is currently
|
| 486 |
+
|
| 487 |
+
in (e.g.,{ roomCode: string, userLimit: number, participantNicknames:
|
| 488 |
+
|
| 489 |
+
string\[] }).
|
| 490 |
+
|
| 491 |
+
displayedMessages: An array of message objects shown in the chat interface,
|
| 492 |
+
|
| 493 |
+
including sender name, decrypted content, and timestamp. This gets cleared
|
| 494 |
+
|
| 495 |
+
when leaving the room.
|
| 496 |
+
|
| 497 |
+
sessionEncryptionContext: Securely holds the cryptographic keys for the
|
| 498 |
+
|
| 499 |
+
current chat session. This exists only in memory and gets cleared when the
|
| 500 |
+
|
| 501 |
+
session ends.
|
| 502 |
+
|
| 503 |
+
roomParticipantsInfo: A Map or array storing temporary information about
|
| 504 |
+
|
| 505 |
+
other participants in the room, potentially including their public key fragments if
|
| 506 |
+
|
| 507 |
+
needed during the key exchange phase.
|
| 508 |
+
|
| 509 |
+
|
| 510 |
+
|
| 511 |
+
► Server-Side Structures (Node.js/Socket.io - In-Memory for active sessions, often
|
| 512 |
+
|
| 513 |
+
synced/backed by DB):
|
| 514 |
+
|
| 515 |
+
|
| 516 |
+
|
| 517 |
+
activeRoomsData: A JavaScript Map where keys are room codes and values
|
| 518 |
+
|
| 519 |
+
contain room details like user limits, sets of connected user IDs, and activity
|
| 520 |
+
|
| 521 |
+
timestamps.
|
| 522 |
+
|
| 523 |
+
|
| 524 |
+
|
| 525 |
+
► MongoDB Document Structure (forActiveRoomscollection):
|
| 526 |
+
|
| 527 |
+
|
| 528 |
+
|
| 529 |
+
Fields includeroomCode,userLimit, an array ofparticipantSessions(each
|
| 530 |
+
|
| 531 |
+
withsessionId,joinedAt),createdAt,lastActivityAt.
|
| 532 |
+
|
| 533 |
+
|
| 534 |
+
|
| 535 |
+
► Network Message Formats (Conceptual):
|
| 536 |
+
|
| 537 |
+
|
| 538 |
+
|
| 539 |
+
Encrypted Chat Message (Client <-> Server): A object like{ roomCode:
|
| 540 |
+
|
| 541 |
+
string, encryptedPayload: string (Base64 encoded ciphertext +
|
| 542 |
+
|
| 543 |
+
IV/nonce), senderSessionId?: string }.
|
| 544 |
+
|
| 545 |
+
Signaling Message (Client <-> Server, for E2EE key exchange): A object like
|
| 546 |
+
|
| 547 |
+
{ roomCode: string, signalType: string (e.g., 'offer', 'answer',
|
| 548 |
+
|
| 549 |
+
'candidate'), signalPayload: object, targetSessionId?: string }. The
|
| 550 |
+
|
| 551 |
+
signalPayloadstructure depends on the chosen key exchange protocol.
|
| 552 |
+
|
| 553 |
+
|
| 554 |
+
|
| 555 |
+
5.3 Process Logic of Each Module....................................................................................................................
|
| 556 |
+
|
| 557 |
+
|
| 558 |
+
|
| 559 |
+
This section describes the core logic flow for each module.
|
| 560 |
+
|
| 561 |
+
|
| 562 |
+
|
| 563 |
+
Frontend UI Module:
|
| 564 |
+
|
| 565 |
+
Room Creation: User clicks create → UI prompts for optional user limit → sends
|
| 566 |
+
|
| 567 |
+
request to communication module → receives room code from server → displays
|
| 568 |
+
|
| 569 |
+
code and navigates to chat interface.
|
| 570 |
+
|
| 571 |
+
Room Joining: User inputsroomCode-> UI triggers “join room” action with code
|
| 572 |
+
|
| 573 |
+
to Communication Client. On success/failure from server, UInavigates to chat or
|
| 574 |
+
|
| 575 |
+
displays error.
|
| 576 |
+
|
| 577 |
+
Message Display: Receives decrypted message object (from E2EE Module) ->
|
| 578 |
+
|
| 579 |
+
appends to chat view with appropriate styling (sender, timestamp).
|
| 580 |
+
|
| 581 |
+
Sending Message: User types message -> UI captures input -> passes plaintext
|
| 582 |
+
|
| 583 |
+
to E2EE Module for encryption.
|
| 584 |
+
|
| 585 |
+
Leaving Room: User clicks “leave” or closes tab -> UI triggers “leave room”
|
| 586 |
+
|
| 587 |
+
action to Communication Client -> clears local message display and any session-
|
| 588 |
+
|
| 589 |
+
specific E2EE keys.
|
| 590 |
+
|
| 591 |
+
Client-Side E2EE Module:
|
| 592 |
+
|
| 593 |
+
Initialization (on room entry): Generates necessary cryptographic material.
|
| 594 |
+
|
| 595 |
+
Key Exchange: Sends public key information to other users through the server,
|
| 596 |
+
|
| 597 |
+
receives their public keys, computes shared secrets, derives symmetric session
|
| 598 |
+
|
| 599 |
+
keys.
|
| 600 |
+
|
| 601 |
+
Message Encryption: Takes plaintext from UI → uses session key to encrypt
|
| 602 |
+
|
| 603 |
+
with unique nonce/IV → returns ciphertext to communication module.
|
| 604 |
+
|
| 605 |
+
Message Decryption: Takes ciphertext from communication module → uses
|
| 606 |
+
|
| 607 |
+
session key to decrypt → returns plaintext to UI module.
|
| 608 |
+
|
| 609 |
+
Client-Side Real-Time Communication Module:
|
| 610 |
+
|
| 611 |
+
Connection Management: Establishes and maintains WebSocket connection to
|
| 612 |
+
|
| 613 |
+
Socket.io server upon entering a room context. Handles reconnect logic if
|
| 614 |
+
|
| 615 |
+
necessary.
|
| 616 |
+
|
| 617 |
+
Event Emission: Sends structured events to server:
|
| 618 |
+
|
| 619 |
+
► create\_room\_request (with user limit)
|
| 620 |
+
|
| 621 |
+
► join\_room\_request (with roomCode)
|
| 622 |
+
|
| 623 |
+
► encrypted\_message\_to\_server (with roomCode, encryptedPayload)
|
| 624 |
+
|
| 625 |
+
|
| 626 |
+
|
| 627 |
+
► key\_exchange\_signal\_to\_server (with roomCode, signalType,
|
| 628 |
+
|
| 629 |
+
signalPayload, targetSessionId if applicable)
|
| 630 |
+
|
| 631 |
+
► leave\_room\_notification
|
| 632 |
+
|
| 633 |
+
|
| 634 |
+
|
| 635 |
+
Event Listening: Handles events from server:
|
| 636 |
+
|
| 637 |
+
► room\_created\_success (with roomCode)
|
| 638 |
+
|
| 639 |
+
► join\_room\_status (success or error message)
|
| 640 |
+
|
| 641 |
+
► new\_encrypted\_message\_from\_server (passes encryptedPayload to E2EE
|
| 642 |
+
|
| 643 |
+
Module)
|
| 644 |
+
|
| 645 |
+
► key\_exchange\_signal\_from\_server (passes signalPayloadto E2E Module)
|
| 646 |
+
|
| 647 |
+
► user\_joined\_room\_notification, user\_left\_room\_notification (for UI
|
| 648 |
+
|
| 649 |
+
updates)
|
| 650 |
+
|
| 651 |
+
|
| 652 |
+
|
| 653 |
+
Backend Room Management \& Signaling Server Module:
|
| 654 |
+
|
| 655 |
+
|
| 656 |
+
|
| 657 |
+
Oncreate\_room\_request: Generates unique room code → creates database
|
| 658 |
+
|
| 659 |
+
entry → adds creator to Socket.io room → confirms creation touser.
|
| 660 |
+
|
| 661 |
+
Onjoin\_room\_request: Validates room code and capacity → adds user to
|
| 662 |
+
|
| 663 |
+
Socket.io room and database → notifies user and others in room.
|
| 664 |
+
|
| 665 |
+
Onencrypted\_message\_to\_server: Receives encrypted message → broadcasts
|
| 666 |
+
|
| 667 |
+
to other users in same room without decrypting\_.\_
|
| 668 |
+
|
| 669 |
+
Onkey\_exchange\_signal\_to\_server Receives setup signals → forwards to
|
| 670 |
+
|
| 671 |
+
appropriate recipients without interpreting content.
|
| 672 |
+
|
| 673 |
+
On clientdisconnectorleave\_room\_notification: Removes user from room
|
| 674 |
+
|
| 675 |
+
→ updates database → notifies remaining users → deletes roomif empty.
|
| 676 |
+
|
| 677 |
+
|
| 678 |
+
|
| 679 |
+
Temporary Room Metadata Storage Module:
|
| 680 |
+
|
| 681 |
+
|
| 682 |
+
|
| 683 |
+
createRoom(details): Inserts a new document into theActiveRoomsMongoDB
|
| 684 |
+
|
| 685 |
+
collection.
|
| 686 |
+
|
| 687 |
+
findRoomByCode(roomCode): Retrieves a room document.
|
| 688 |
+
|
| 689 |
+
addParticipantToRoom(roomCode, sessionId): Updates the specified room
|
| 690 |
+
|
| 691 |
+
document to add a participant.
|
| 692 |
+
|
| 693 |
+
removeParticipantFromRoom(roomCode, sessionId): Updates room document
|
| 694 |
+
|
| 695 |
+
to remove a participant.
|
| 696 |
+
|
| 697 |
+
deleteRoom(roomCode): Deletes a room document.
|
| 698 |
+
|
| 699 |
+
getParticipantCount(roomCode): Returns current number of participants.
|
| 700 |
+
|
| 701 |
+
|
| 702 |
+
|
| 703 |
+
5.4 Testing Process to be Used..........................................................................................................................
|
| 704 |
+
|
| 705 |
+
|
| 706 |
+
|
| 707 |
+
A multi-layered testing strategy will be implemented to ensure application quality, security,
|
| 708 |
+
|
| 709 |
+
and reliability:
|
| 710 |
+
|
| 711 |
+
|
| 712 |
+
|
| 713 |
+
Unit Testing:
|
| 714 |
+
|
| 715 |
+
Focus on individual functions and components in isolation.
|
| 716 |
+
|
| 717 |
+
Client-Side: Test encryption/decryption functions with known test vectors, test
|
| 718 |
+
|
| 719 |
+
React components for proper rendering and state management.
|
| 720 |
+
|
| 721 |
+
Server-Side: Test individual Socket.io event handlers and helper functions with
|
| 722 |
+
|
| 723 |
+
mocked dependencies.
|
| 724 |
+
|
| 725 |
+
Integration Testing:
|
| 726 |
+
|
| 727 |
+
Verify interactions between different modules.
|
| 728 |
+
|
| 729 |
+
Client-Server: Test the complete flow of Socket.io events between client and
|
| 730 |
+
|
| 731 |
+
server for room creation, joining, message relay, and signaling.
|
| 732 |
+
|
| 733 |
+
Module Interactions: Test Frontend UI <-> E2EE Module, Backend Server <->
|
| 734 |
+
|
| 735 |
+
MongoDB Storage Module.
|
| 736 |
+
|
| 737 |
+
End-to-End (E2E) Testing:
|
| 738 |
+
|
| 739 |
+
Simulate real user scenarios from start to finish using browser automation tools
|
| 740 |
+
|
| 741 |
+
(e.g., Cypress, Playwright).
|
| 742 |
+
|
| 743 |
+
Key Scenarios: User A creates a room, User B joins; both exchange multiple
|
| 744 |
+
|
| 745 |
+
encrypted messages; one user leaves, then the other; attempts to join full/invalid
|
| 746 |
+
|
| 747 |
+
rooms. Verify message display and ephemerality.
|
| 748 |
+
|
| 749 |
+
Security Testing:
|
| 750 |
+
|
| 751 |
+
E2EE Verification: Manually inspect network traffic using browser developer
|
| 752 |
+
|
| 753 |
+
tools to confirm all transmitted data is properly encrypted.
|
| 754 |
+
|
| 755 |
+
Vulnerability Assessment: Check for common web security issues and assess
|
| 756 |
+
|
| 757 |
+
room code generation strength.
|
| 758 |
+
|
| 759 |
+
Logical Flaw Detection: Review logic for key exchange and session
|
| 760 |
+
|
| 761 |
+
management for potential weaknesses.
|
| 762 |
+
|
| 763 |
+
Usability Testing:
|
| 764 |
+
|
| 765 |
+
Gather qualitative feedback from a small group of test users regarding the
|
| 766 |
+
|
| 767 |
+
application’s ease of use, clarity of instructions, and overall user experience.
|
| 768 |
+
|
| 769 |
+
|
| 770 |
+
|
| 771 |
+
Primary Testing Tools:
|
| 772 |
+
|
| 773 |
+
|
| 774 |
+
|
| 775 |
+
► Jest and React Testing Library (for frontend unit/integration)
|
| 776 |
+
|
| 777 |
+
► Jest or Mocha/Chai (for backend unit/integration)
|
| 778 |
+
|
| 779 |
+
► Cypress or Playwright (for E2E tests)
|
| 780 |
+
|
| 781 |
+
► Browser Developer Tools
|
| 782 |
+
|
| 783 |
+
|
| 784 |
+
|
| 785 |
+
5.5 Reports Generation.........................................................................................................................................
|
| 786 |
+
|
| 787 |
+
|
| 788 |
+
|
| 789 |
+
Given the application’s core principles of ephemerality and privacy, traditional report
|
| 790 |
+
|
| 791 |
+
generation is intentionally minimal and avoids storing sensitive user data:
|
| 792 |
+
|
| 793 |
+
|
| 794 |
+
|
| 795 |
+
Client-Side Debugging Logs (Developer-Enabled):
|
| 796 |
+
|
| 797 |
+
Content: Timestamps of significant client-side events (e.g., “Roomjoined: XYZ”,
|
| 798 |
+
|
| 799 |
+
“Key exchange step 1 complete”, “Message encrypted”, “Error: Decryption
|
| 800 |
+
|
| 801 |
+
failed”). Strictly no message content or cryptographic key material will be
|
| 802 |
+
|
| 803 |
+
logged.
|
| 804 |
+
|
| 805 |
+
Purpose: For developers or advanced users to diagnose local issues related to
|
| 806 |
+
|
| 807 |
+
connectivity, E2EE setup, or UI errors.
|
| 808 |
+
|
| 809 |
+
Generation: Implemented viaconsole.logor a lightweight client-side logging
|
| 810 |
+
|
| 811 |
+
utility, typically enabled via a browser console command ora debug flag in
|
| 812 |
+
|
| 813 |
+
development builds.
|
| 814 |
+
|
| 815 |
+
Server-Side Operational Logs (Anonymized ):
|
| 816 |
+
|
| 817 |
+
Content: Event timestamps, server operations (room created, user joined,
|
| 818 |
+
|
| 819 |
+
message relayed), anonymized room identifiers, error codes and stack traces,
|
| 820 |
+
|
| 821 |
+
aggregate metrics (active connections, active rooms).
|
| 822 |
+
|
| 823 |
+
Purpose: For system administrators/developers to monitor server health,
|
| 824 |
+
|
| 825 |
+
performance, identify bottlenecks, track error rates, anddebug server-side
|
| 826 |
+
|
| 827 |
+
operational issues.
|
| 828 |
+
|
| 829 |
+
Generation: Using a robust logging library (e.g., Winston, Pino) in the Node.js
|
| 830 |
+
|
| 831 |
+
backend, with configurable log levels.
|
| 832 |
+
|
| 833 |
+
Ephemeral Session “Report” (User Interface):
|
| 834 |
+
|
| 835 |
+
Content: The dynamically rendered chat messages displayed within the user’s
|
| 836 |
+
|
| 837 |
+
active browser session.
|
| 838 |
+
|
| 839 |
+
Purpose: This is the primary “report” visible to the user – their live conversation.
|
| 840 |
+
|
| 841 |
+
Generation: This “report” is the application’s core user interface. It is ephemeral
|
| 842 |
+
|
| 843 |
+
by design; when the user leaves the room or closes the browsertab/window,
|
| 844 |
+
|
| 845 |
+
this displayed information is cleared from their client.
|
| 846 |
+
|
| 847 |
+
|
| 848 |
+
|
| 849 |
+
No persistent reports containing chat message content, user identities (beyond
|
| 850 |
+
|
| 851 |
+
temporary session identifiers), or detailed user activity logs will be generated or stored
|
| 852 |
+
|
| 853 |
+
by the server. The system prioritizes not collecting data that doesn’t absolutely need to be
|
| 854 |
+
|
| 855 |
+
collected.
|
| 856 |
+
|
| 857 |
+
6\. Tools / Platform, Hardware and Software Requirement Specifications............................................
|
| 858 |
+
|
| 859 |
+
6.1 Software Requirements................................................................................................................................
|
| 860 |
+
|
| 861 |
+
|
| 862 |
+
|
| 863 |
+
► Frontend Development:
|
| 864 |
+
|
| 865 |
+
|
| 866 |
+
|
| 867 |
+
Programming Languages: JavaScript (ES6+), TypeScript
|
| 868 |
+
|
| 869 |
+
Core Framework/Library: Next.js (React-based framework)
|
| 870 |
+
|
| 871 |
+
Styling: Tailwind CSS
|
| 872 |
+
|
| 873 |
+
Client-Side Cryptography: Web Crypto API (built into modern browsers)
|
| 874 |
+
|
| 875 |
+
Real-Time Client Communication: Socket.io Client library
|
| 876 |
+
|
| 877 |
+
Package Management: bun (Node Package Manager)
|
| 878 |
+
|
| 879 |
+
Version Control System: Git
|
| 880 |
+
|
| 881 |
+
|
| 882 |
+
|
| 883 |
+
► Backend Development:
|
| 884 |
+
|
| 885 |
+
|
| 886 |
+
|
| 887 |
+
Runtime Environment: Node.js
|
| 888 |
+
|
| 889 |
+
Real-Time Server Framework: Socket.io
|
| 890 |
+
|
| 891 |
+
Programming Languages: JavaScript (ES6+), TypeScript
|
| 892 |
+
|
| 893 |
+
Database: MongoDB (for temporary room metadata)
|
| 894 |
+
|
| 895 |
+
Package Management: bun
|
| 896 |
+
|
| 897 |
+
|
| 898 |
+
|
| 899 |
+
► Development Environment:
|
| 900 |
+
|
| 901 |
+
|
| 902 |
+
|
| 903 |
+
Operating System: Windows 11
|
| 904 |
+
|
| 905 |
+
Code Editor/IDE: Visual Studio Code
|
| 906 |
+
|
| 907 |
+
Web Browsers (for development \& testing): Latest stable versions of Google
|
| 908 |
+
|
| 909 |
+
Chrome, Mozilla Firefox, Microsoft Edge, Safari.
|
| 910 |
+
|
| 911 |
+
Terminal/Command Line Interface: For running scripts, Gitcommands, etc.
|
| 912 |
+
|
| 913 |
+
|
| 914 |
+
|
| 915 |
+
► Deployment Environment (Server-Side):
|
| 916 |
+
|
| 917 |
+
|
| 918 |
+
|
| 919 |
+
Operating System: Linux-based OS (e.g., Ubuntu Server) isstandard for Node.js.
|
| 920 |
+
|
| 921 |
+
Process Manager (for Node.js application): PM2, or systemd.
|
| 922 |
+
|
| 923 |
+
Database Server: MongoDB instance.
|
| 924 |
+
|
| 925 |
+
Cloud Platform: Vercel (ideal for Next.js).
|
| 926 |
+
|
| 927 |
+
|
| 928 |
+
|
| 929 |
+
► User Environment (Client-Side):
|
| 930 |
+
|
| 931 |
+
|
| 932 |
+
|
| 933 |
+
Operating System: Any modern OS capable of running currentweb browsers
|
| 934 |
+
|
| 935 |
+
(Windows, macOS, Linux, Android, iOS).
|
| 936 |
+
|
| 937 |
+
Web Browser: Latest stable versions of Google Chrome, Mozilla Firefox,
|
| 938 |
+
|
| 939 |
+
Microsoft Edge, Safari, with full support for WebSockets and the Web Crypto API.
|
| 940 |
+
|
| 941 |
+
|
| 942 |
+
|
| 943 |
+
6.2 Hardware Requirements..............................................................................................................................
|
| 944 |
+
|
| 945 |
+
|
| 946 |
+
|
| 947 |
+
► Development Machine:
|
| 948 |
+
|
| 949 |
+
|
| 950 |
+
|
| 951 |
+
Processor: Multi-core processor AMD Ryzen 5
|
| 952 |
+
|
| 953 |
+
RAM: Minimum 8 GB.
|
| 954 |
+
|
| 955 |
+
Storage: 100 GB free disk space SSD.
|
| 956 |
+
|
| 957 |
+
Network: Broadband internet connection.
|
| 958 |
+
|
| 959 |
+
|
| 960 |
+
|
| 961 |
+
► Server Machine (for Deployment - indicative for a small to moderate load):
|
| 962 |
+
|
| 963 |
+
|
| 964 |
+
|
| 965 |
+
Processor: 1-2+ vCPUs (AWS t3.small/medium equivalent).
|
| 966 |
+
|
| 967 |
+
RAM: 2-8 GB (depends on concurrent user load - Socket.io canbe memory-
|
| 968 |
+
|
| 969 |
+
intensive).
|
| 970 |
+
|
| 971 |
+
Storage: 20 GB - 50 GB+ SSD (for OS, application, logs, and MongoDB data if co-
|
| 972 |
+
|
| 973 |
+
hosted).
|
| 974 |
+
|
| 975 |
+
Network: Reliable connection with sufficient bandwidth for real-time WebSocket
|
| 976 |
+
|
| 977 |
+
traffic.
|
| 978 |
+
|
| 979 |
+
|
| 980 |
+
|
| 981 |
+
► User Machine (Client-Side):
|
| 982 |
+
|
| 983 |
+
|
| 984 |
+
|
| 985 |
+
Standard Requirements: Any desktop, laptop, tablet, or smartphone from the last 5-
|
| 986 |
+
|
| 987 |
+
years
|
| 988 |
+
|
| 989 |
+
Processor: Any modern CPU capable of handling JavaScript execution forencryption
|
| 990 |
+
|
| 991 |
+
without significant delay
|
| 992 |
+
|
| 993 |
+
RAM: 2 GB minimum (more recommended for better browser performance)
|
| 994 |
+
|
| 995 |
+
Network: Stable internet connection (WiFi, Ethernet, or reliable mobile data)
|
| 996 |
+
|
| 997 |
+
|
| 998 |
+
|
| 999 |
+
7\. Industry/Client Affiliation........................................................................................................................................
|
| 1000 |
+
|
| 1001 |
+
No.
|
| 1002 |
+
|
| 1003 |
+
|
| 1004 |
+
|
| 1005 |
+
This “ End-to-End Encrypted Chat Room Application” is beingdeveloped purely as an academic
|
| 1006 |
+
|
| 1007 |
+
project. It is intended to fulfill educational requirements and explore concepts in secure web
|
| 1008 |
+
|
| 1009 |
+
application development. It is not commissioned by, associated with, or undertaken for any
|
| 1010 |
+
|
| 1011 |
+
specific industry, client, or commercial organization.
|
| 1012 |
+
|
| 1013 |
+
8\. Future Scope and Further Enhancement of the Project.............................................................................
|
| 1014 |
+
|
| 1015 |
+
|
| 1016 |
+
|
| 1017 |
+
While the initial version focuses on core secure chat functionality, several enhancements could
|
| 1018 |
+
|
| 1019 |
+
be added in future iterations:
|
| 1020 |
+
|
| 1021 |
+
|
| 1022 |
+
|
| 1023 |
+
Advanced Room Controls:
|
| 1024 |
+
|
| 1025 |
+
Room Passwords: Add optional password protection in addition to room codes.
|
| 1026 |
+
|
| 1027 |
+
Moderation Tools: Give room creators basic moderation tools (mute or remove
|
| 1028 |
+
|
| 1029 |
+
disruptive users).
|
| 1030 |
+
|
| 1031 |
+
Customizable User Limits: Allow room creators to adjust user limits during
|
| 1032 |
+
|
| 1033 |
+
active sessions
|
| 1034 |
+
|
| 1035 |
+
.
|
| 1036 |
+
|
| 1037 |
+
Rich Media \& Interaction (E2EE):
|
| 1038 |
+
|
| 1039 |
+
Encrypted File Sharing: Allow secure file transfers within chat rooms.
|
| 1040 |
+
|
| 1041 |
+
Markdown/Rich Text Formatting: Support for basic message formatting to
|
| 1042 |
+
|
| 1043 |
+
improve readability and expression.
|
| 1044 |
+
|
| 1045 |
+
Emoji Reactions: Allow users to react to messages with emojis.
|
| 1046 |
+
|
| 1047 |
+
E2EE Voice/Video Calls: Integration of WebRTC for encrypted peer-to-peer
|
| 1048 |
+
|
| 1049 |
+
voice and video calls
|
| 1050 |
+
|
| 1051 |
+
.
|
| 1052 |
+
|
| 1053 |
+
User Experience Improvements:
|
| 1054 |
+
|
| 1055 |
+
Typing Indicators: Securely implement indicators to show when other users are
|
| 1056 |
+
|
| 1057 |
+
typing.
|
| 1058 |
+
|
| 1059 |
+
Read Receipts (Optional \& E2EE): A privacy-conscious implementation of
|
| 1060 |
+
|
| 1061 |
+
message read receipts.
|
| 1062 |
+
|
| 1063 |
+
UI Themes \& Personalization: Allow users to choose different visual themes.
|
| 1064 |
+
|
| 1065 |
+
Improved Notification System: More refined in-app notifications.
|
| 1066 |
+
|
| 1067 |
+
Advanced Security Features:
|
| 1068 |
+
|
| 1069 |
+
Key Verification Mechanisms: Allow users to verify each other’s identities
|
| 1070 |
+
|
| 1071 |
+
through safety numbers or QR code scanning.
|
| 1072 |
+
|
| 1073 |
+
Formal Security Audit: Engage professional security reviewers to assess the
|
| 1074 |
+
|
| 1075 |
+
cryptographic implementation.
|
| 1076 |
+
|
| 1077 |
+
Scalability and Performance:
|
| 1078 |
+
|
| 1079 |
+
Horizontal Scaling for Socket.io: mplement Redis adapter for Socket.io to
|
| 1080 |
+
|
| 1081 |
+
handle more concurrent users across multiple server instances.
|
| 1082 |
+
|
| 1083 |
+
Optimized Message Broadcasting: More efficient message delivery
|
| 1084 |
+
|
| 1085 |
+
mechanisms for very large rooms (if user limits are increased).
|
| 1086 |
+
|
| 1087 |
+
|
| 1088 |
+
|
| 1089 |
+
These potential enhancements would progressively build upon the foundational secure and
|
| 1090 |
+
|
| 1091 |
+
ephemeral chat system, adding value and utility.
|
| 1092 |
+
|
| 1093 |
+
9\. Limitations of the Project (Initial Version)......................................................................................................
|
| 1094 |
+
|
| 1095 |
+
|
| 1096 |
+
|
| 1097 |
+
The initial development phase will focus on delivering corefunctionality, and as such, certain
|
| 1098 |
+
|
| 1099 |
+
limitations will exist:
|
| 1100 |
+
|
| 1101 |
+
|
| 1102 |
+
|
| 1103 |
+
No User Accounts: The application operates without traditional registration or login
|
| 1104 |
+
|
| 1105 |
+
systems. Users remain anonymous within each chat session.
|
| 1106 |
+
|
| 1107 |
+
No Message History: All conversations are temporary. Messages aren’t saved
|
| 1108 |
+
|
| 1109 |
+
anywhere and disappear when rooms close or users leave.
|
| 1110 |
+
|
| 1111 |
+
Text Messages Only: Initial version supports only text-based communication. File
|
| 1112 |
+
|
| 1113 |
+
sharing, voice messages, or video calls aren’t included yet.
|
| 1114 |
+
|
| 1115 |
+
Basic Key Exchange: While end-to-end encryption is implemented, advanced features
|
| 1116 |
+
|
| 1117 |
+
like Perfect Forward Secrecy or explicit key fingerprint verification aren’t included
|
| 1118 |
+
|
| 1119 |
+
initially.
|
| 1120 |
+
|
| 1121 |
+
Room Code Security: Room security relies primarily on keeping the generated codes
|
| 1122 |
+
|
| 1123 |
+
secret. While codes are designed to be hard to guess, additional security measures
|
| 1124 |
+
|
| 1125 |
+
against code compromise aren’t a primary focus initially.
|
| 1126 |
+
|
| 1127 |
+
Single Server Focus: The backend architecture is optimized for single server
|
| 1128 |
+
|
| 1129 |
+
deployment. Horizontal scaling strategies are consideredfuture enhancements.
|
| 1130 |
+
|
| 1131 |
+
No Offline Support: Users must be actively connected to send or receive messages.
|
| 1132 |
+
|
| 1133 |
+
There’s no message queuing for offline users.
|
| 1134 |
+
|
| 1135 |
+
Trust in Client Code: The effectiveness of encryption depends on the integrity of
|
| 1136 |
+
|
| 1137 |
+
JavaScript code running in users’ browsers. Users must trust that this code correctly
|
| 1138 |
+
|
| 1139 |
+
implements encryption and doesn’t compromise security.
|
| 1140 |
+
|
| 1141 |
+
Limited Room Management: Initial version doesn’t include features for room creators
|
| 1142 |
+
|
| 1143 |
+
to manage participants (like kicking or banning users).
|
| 1144 |
+
|
| 1145 |
+
Modern Browser Dependency: Requires recent browser versions with WebSocket and
|
| 1146 |
+
|
| 1147 |
+
Web Crypto API support, which may limit compatibility with very old browsers.
|
| 1148 |
+
|
| 1149 |
+
|
| 1150 |
+
|
| 1151 |
+
These limitations help keep the project scope manageable while ensuring robust
|
| 1152 |
+
|
| 1153 |
+
implementation of core security and communication features.
|
| 1154 |
+
|
| 1155 |
+
10\. Security and Validation Checks...........................................................................................................................
|
| 1156 |
+
|
| 1157 |
+
|
| 1158 |
+
|
| 1159 |
+
Security is a foundational requirement of this application. The following checks, principles, and
|
| 1160 |
+
|
| 1161 |
+
validations will be integral to its design and implementation:
|
| 1162 |
+
|
| 1163 |
+
|
| 1164 |
+
|
| 1165 |
+
End-to-End Encryption (E2EE) Implementation:
|
| 1166 |
+
|
| 1167 |
+
Core: All user-to-user message content will be encrypted on the sender’s client
|
| 1168 |
+
|
| 1169 |
+
device and decrypted only on the recipient(s)’ client device(s).
|
| 1170 |
+
|
| 1171 |
+
Algorithms: ndustry-standard cryptography using AES-256-GCM for message
|
| 1172 |
+
|
| 1173 |
+
encryption and secure key exchange protocols like Diffie-Hellman.
|
| 1174 |
+
|
| 1175 |
+
Key Management: ll private keys and session keys are generated and stored
|
| 1176 |
+
|
| 1177 |
+
only on client devices - never transmitted to or stored by theserver.
|
| 1178 |
+
|
| 1179 |
+
Message and Data Ephemerality:
|
| 1180 |
+
|
| 1181 |
+
No Server-Side Message Storage: The server will not store any chat message
|
| 1182 |
+
|
| 1183 |
+
content, either in plaintext or ciphertext form, in any database or persistent logs.
|
| 1184 |
+
|
| 1185 |
+
Client-Side Data Clearing: When users leave rooms, their browsers
|
| 1186 |
+
|
| 1187 |
+
automatically clear displayed messages and encryption keys from active Java
|
| 1188 |
+
|
| 1189 |
+
memory.
|
| 1190 |
+
|
| 1191 |
+
Temporary Room Metadata Purging: Server-side metadata related to active
|
| 1192 |
+
|
| 1193 |
+
chat rooms (e.g., room ID, list of active participant sessions) will be actively
|
| 1194 |
+
|
| 1195 |
+
deleted from the temporary store (MongoDB/memory) once a room becomes
|
| 1196 |
+
|
| 1197 |
+
empty or after a defined period of inactivity.
|
| 1198 |
+
|
| 1199 |
+
Secure Room Access and Control:
|
| 1200 |
+
|
| 1201 |
+
Unique and Complex Room Codes: Chat rooms will be accessed via unique,
|
| 1202 |
+
|
| 1203 |
+
randomly generated codes of sufficient complexity to make guessing impractical.
|
| 1204 |
+
|
| 1205 |
+
Server-Side Validation: The backend server will rigorously validate room codes
|
| 1206 |
+
|
| 1207 |
+
and enforce any defined user limits before granting a user access to a chat room.
|
| 1208 |
+
|
| 1209 |
+
Rate Limiting (Consideration): Basic rate limiting on attempts to join rooms
|
| 1210 |
+
|
| 1211 |
+
may be implemented on the server-side to mitigate brute-force attacks on room
|
| 1212 |
+
|
| 1213 |
+
codes.
|
| 1214 |
+
|
| 1215 |
+
Input Validation (Client-Side and Server-Side):
|
| 1216 |
+
|
| 1217 |
+
|
| 1218 |
+
|
| 1219 |
+
Data Sanitization: All user input is validated and sanitized on both client and
|
| 1220 |
+
|
| 1221 |
+
server sides to prevent injection attacks.
|
| 1222 |
+
|
| 1223 |
+
Socket.io Event Payload Validation: Socket.io event payloads are validated to
|
| 1224 |
+
|
| 1225 |
+
ensure they conform to expected formats.
|
| 1226 |
+
|
| 1227 |
+
|
| 1228 |
+
|
| 1229 |
+
Transport Layer Security (TLS/SSL):
|
| 1230 |
+
|
| 1231 |
+
|
| 1232 |
+
|
| 1233 |
+
All communication between the client’s browser and the webserver (serving the
|
| 1234 |
+
|
| 1235 |
+
Next.js application) and the Socket.io server will be enforced over HTTPS and
|
| 1236 |
+
|
| 1237 |
+
WSS (Secure WebSockets) respectively. This protects the already E2E-encrypted
|
| 1238 |
+
|
| 1239 |
+
message payloads and critical signaling messages while they are in transit
|
| 1240 |
+
|
| 1241 |
+
to/from the server.
|
| 1242 |
+
|
| 1243 |
+
|
| 1244 |
+
|
| 1245 |
+
Protection Against Common Web Vulnerabilities:
|
| 1246 |
+
|
| 1247 |
+
|
| 1248 |
+
|
| 1249 |
+
Cross-Site Scripting (XSS): Although message content is E2E encrypted, any
|
| 1250 |
+
|
| 1251 |
+
user-generated input that might be displayed directly by the UI (e.g., user-chosen
|
| 1252 |
+
|
| 1253 |
+
temporary nicknames, if implemented) will be properly escaped/sanitized by the
|
| 1254 |
+
|
| 1255 |
+
frontend framework (Next.js/React) to prevent XSS.
|
| 1256 |
+
|
| 1257 |
+
Secure Headers: Implement appropriate HTTP security headers (e.g., Content
|
| 1258 |
+
|
| 1259 |
+
Security Policy, X-Content-Type-Options).
|
| 1260 |
+
|
| 1261 |
+
|
| 1262 |
+
|
| 1263 |
+
Secure Code Practices:
|
| 1264 |
+
|
| 1265 |
+
|
| 1266 |
+
|
| 1267 |
+
Dependency Management: Regularly update all third-party libraries and
|
| 1268 |
+
|
| 1269 |
+
dependencies (both frontend and backend) to patch known vulnerabilities, using
|
| 1270 |
+
|
| 1271 |
+
tools likenpm audit.
|
| 1272 |
+
|
| 1273 |
+
Principle of Least Privilege: Server-side processes will operate with the
|
| 1274 |
+
|
| 1275 |
+
minimum necessary permissions.
|
| 1276 |
+
|
| 1277 |
+
|
| 1278 |
+
|
| 1279 |
+
Signaling Channel Security:
|
| 1280 |
+
|
| 1281 |
+
|
| 1282 |
+
|
| 1283 |
+
Ensure that signaling messages (used for E2EE key exchangesetup) are relayed
|
| 1284 |
+
|
| 1285 |
+
correctly only to the intended participants within a specific room and are
|
| 1286 |
+
|
| 1287 |
+
protected in transit by WSS.
|
| 1288 |
+
|
| 1289 |
+
|
| 1290 |
+
|
| 1291 |
+
Validation Approach:
|
| 1292 |
+
|
| 1293 |
+
|
| 1294 |
+
|
| 1295 |
+
► Conduct regular code reviews with a focus on security implementation details.
|
| 1296 |
+
|
| 1297 |
+
► Perform manual security testing, including attempts to bypass E2EE and inspecting
|
| 1298 |
+
|
| 1299 |
+
network traffic.
|
| 1300 |
+
|
| 1301 |
+
► Utilize browser developer tools for examining client-sidedata handling and storage.
|
| 1302 |
+
|
| 1303 |
+
► Write automated tests specifically targeting the encryption/decryption logic and key
|
| 1304 |
+
|
| 1305 |
+
exchange process.
|
| 1306 |
+
|
| 1307 |
+
|
| 1308 |
+
|
| 1309 |
+
11\. Bibliography (References).....................................................................................................................................
|
| 1310 |
+
|
| 1311 |
+
|
| 1312 |
+
|
| 1313 |
+
Cryptography and Security:
|
| 1314 |
+
|
| 1315 |
+
|
| 1316 |
+
|
| 1317 |
+
OWASP Foundation. (2021). OWASP Top Ten Web Application Security Risks. Retrieved
|
| 1318 |
+
|
| 1319 |
+
fromowasp.org/www-project-top-ten/
|
| 1320 |
+
|
| 1321 |
+
Mozilla Developer Network (MDN). Web Crypto API. Retrieved from
|
| 1322 |
+
|
| 1323 |
+
developer.mozilla.org/en-US/docs/Web/API/Web\_Crypto\_API
|
| 1324 |
+
|
| 1325 |
+
|
| 1326 |
+
|
| 1327 |
+
Web Technologies and Development:
|
| 1328 |
+
|
| 1329 |
+
|
| 1330 |
+
|
| 1331 |
+
Next.js Official Documentation. Vercel. Retrieved fromnextjs.org/docs
|
| 1332 |
+
|
| 1333 |
+
Socket.IO Official Documentation. Retrieved fromsocket.io/docs/
|
| 1334 |
+
|
| 1335 |
+
Tailwind CSS Official Documentation. Tailwind Labs. Retrieved from
|
| 1336 |
+
|
| 1337 |
+
tailwindcss.com/docs
|
| 1338 |
+
|
| 1339 |
+
|
| 1340 |
+
|
| 1341 |
+
Powered by TCPDF (www.tcpdf.org)
|
| 1342 |
+
|
| 1343 |
+
|
| 1344 |
+
|
| 1345 |
+
Index of comments
|
| 1346 |
+
|
| 1347 |
+
|
| 1348 |
+
|
| 1349 |
+
1.1 Implement asymmetric encryption (e.g., RSA) for secure key exchange between users.
|
| 1350 |
+
|
| 1351 |
+
Integrate message integrity checks using digital signatures or hash verification (e.g., SHA-256).
|
| 1352 |
+
|
| 1353 |
+
Add support for multimedia (images/files) sharing with encrypted transmission and storage.
|
| 1354 |
+
|
| 1355 |
+
Enable user authentication with two-factor authentication (2FA) for enhanced account security.
|
| 1356 |
+
|
| 1357 |
+
Include real-time message delivery status (sent, delivered, seen) with WebSocket-based communication.
|
| 1358 |
+
|
| 1359 |
+
|
| 1360 |
+
|
| 1361 |
+
|
| 1362 |
+
|
eslint.config.mjs
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { defineConfig, globalIgnores } from "eslint/config";
|
| 2 |
+
import nextVitals from "eslint-config-next/core-web-vitals";
|
| 3 |
+
import nextTs from "eslint-config-next/typescript";
|
| 4 |
+
|
| 5 |
+
const eslintConfig = defineConfig([
|
| 6 |
+
...nextVitals,
|
| 7 |
+
...nextTs,
|
| 8 |
+
// Override default ignores of eslint-config-next.
|
| 9 |
+
globalIgnores([
|
| 10 |
+
// Default ignores of eslint-config-next:
|
| 11 |
+
".next/**",
|
| 12 |
+
"out/**",
|
| 13 |
+
"build/**",
|
| 14 |
+
"next-env.d.ts",
|
| 15 |
+
]),
|
| 16 |
+
]);
|
| 17 |
+
|
| 18 |
+
export default eslintConfig;
|
hooks/useWebRTC.ts
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useEffect, useRef, useCallback } from "react";
|
| 2 |
+
import { socket } from "@/lib/socket";
|
| 3 |
+
|
| 4 |
+
const STUN_SERVERS = {
|
| 5 |
+
iceServers: [
|
| 6 |
+
{ urls: "stun:stun.l.google.com:19302" },
|
| 7 |
+
{ urls: "stun:stun1.l.google.com:19302" },
|
| 8 |
+
],
|
| 9 |
+
};
|
| 10 |
+
|
| 11 |
+
export function useWebRTC(roomId: string) {
|
| 12 |
+
const [localStream, setLocalStream] = useState<MediaStream | null>(null);
|
| 13 |
+
const [remoteStreams, setRemoteStreams] = useState<Map<string, MediaStream>>(new Map());
|
| 14 |
+
const peersRef = useRef<Map<string, RTCPeerConnection>>(new Map());
|
| 15 |
+
|
| 16 |
+
const createPeer = useCallback((targetSocketId: string, initiator: boolean, stream: MediaStream) => {
|
| 17 |
+
const peer = new RTCPeerConnection(STUN_SERVERS);
|
| 18 |
+
peersRef.current.set(targetSocketId, peer);
|
| 19 |
+
|
| 20 |
+
// Add local tracks
|
| 21 |
+
stream.getTracks().forEach(track => peer.addTrack(track, stream));
|
| 22 |
+
|
| 23 |
+
// Handle ICE candidates
|
| 24 |
+
peer.onicecandidate = (event) => {
|
| 25 |
+
if (event.candidate) {
|
| 26 |
+
socket.emit("signal", {
|
| 27 |
+
target: targetSocketId,
|
| 28 |
+
signal: { type: "ice-candidate", candidate: event.candidate }
|
| 29 |
+
});
|
| 30 |
+
}
|
| 31 |
+
};
|
| 32 |
+
|
| 33 |
+
// Handle remote stream
|
| 34 |
+
peer.ontrack = (event) => {
|
| 35 |
+
console.log("Received remote track from:", targetSocketId);
|
| 36 |
+
setRemoteStreams(prev => {
|
| 37 |
+
const newMap = new Map(prev);
|
| 38 |
+
newMap.set(targetSocketId, event.streams[0]);
|
| 39 |
+
return newMap;
|
| 40 |
+
});
|
| 41 |
+
};
|
| 42 |
+
|
| 43 |
+
// Create Offer if initiator
|
| 44 |
+
if (initiator) {
|
| 45 |
+
peer.createOffer()
|
| 46 |
+
.then(offer => peer.setLocalDescription(offer))
|
| 47 |
+
.then(() => {
|
| 48 |
+
socket.emit("signal", {
|
| 49 |
+
target: targetSocketId,
|
| 50 |
+
signal: { type: "offer", sdp: peer.localDescription }
|
| 51 |
+
});
|
| 52 |
+
})
|
| 53 |
+
.catch(err => console.error("Error creating offer:", err));
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
return peer;
|
| 57 |
+
}, []);
|
| 58 |
+
|
| 59 |
+
const joinCall = useCallback(async () => {
|
| 60 |
+
try {
|
| 61 |
+
const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
|
| 62 |
+
setLocalStream(stream);
|
| 63 |
+
socket.emit("join-call", { roomId });
|
| 64 |
+
return stream;
|
| 65 |
+
} catch (err) {
|
| 66 |
+
console.error("Error accessing media devices:", err);
|
| 67 |
+
alert("Could not access camera/microphone");
|
| 68 |
+
return null;
|
| 69 |
+
}
|
| 70 |
+
}, [roomId]);
|
| 71 |
+
|
| 72 |
+
const leaveCall = useCallback(() => {
|
| 73 |
+
// Stop local stream
|
| 74 |
+
if (localStream) {
|
| 75 |
+
localStream.getTracks().forEach(track => track.stop());
|
| 76 |
+
setLocalStream(null);
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
// Close all peers
|
| 80 |
+
peersRef.current.forEach(peer => peer.close());
|
| 81 |
+
peersRef.current.clear();
|
| 82 |
+
setRemoteStreams(new Map());
|
| 83 |
+
|
| 84 |
+
socket.emit("leave-call", { roomId });
|
| 85 |
+
}, [localStream, roomId]);
|
| 86 |
+
|
| 87 |
+
useEffect(() => {
|
| 88 |
+
if (!localStream) return;
|
| 89 |
+
|
| 90 |
+
const handleUserConnected = ({ socketId }: { socketId: string }) => {
|
| 91 |
+
console.log("User connected to call:", socketId);
|
| 92 |
+
// Initiate connection to new user
|
| 93 |
+
createPeer(socketId, true, localStream);
|
| 94 |
+
};
|
| 95 |
+
|
| 96 |
+
const handleUserDisconnected = ({ socketId }: { socketId: string }) => {
|
| 97 |
+
console.log("User disconnected from call:", socketId);
|
| 98 |
+
if (peersRef.current.has(socketId)) {
|
| 99 |
+
peersRef.current.get(socketId)!.close();
|
| 100 |
+
peersRef.current.delete(socketId);
|
| 101 |
+
}
|
| 102 |
+
setRemoteStreams(prev => {
|
| 103 |
+
const newMap = new Map(prev);
|
| 104 |
+
newMap.delete(socketId);
|
| 105 |
+
return newMap;
|
| 106 |
+
});
|
| 107 |
+
};
|
| 108 |
+
|
| 109 |
+
const handleSignal = async (data: { sender: string; signal: any }) => {
|
| 110 |
+
const { sender, signal } = data;
|
| 111 |
+
|
| 112 |
+
// Ignore key exchange signals (offer-key, answer-key)
|
| 113 |
+
if (signal.type === "offer-key" || signal.type === "answer-key") return;
|
| 114 |
+
|
| 115 |
+
let peer = peersRef.current.get(sender);
|
| 116 |
+
|
| 117 |
+
if (!peer) {
|
| 118 |
+
// If receiving offer, create peer (not initiator)
|
| 119 |
+
if (signal.type === "offer") {
|
| 120 |
+
peer = createPeer(sender, false, localStream);
|
| 121 |
+
} else {
|
| 122 |
+
console.warn("Received signal for unknown peer:", sender);
|
| 123 |
+
return;
|
| 124 |
+
}
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
try {
|
| 128 |
+
if (signal.type === "offer") {
|
| 129 |
+
await peer.setRemoteDescription(new RTCSessionDescription(signal.sdp));
|
| 130 |
+
const answer = await peer.createAnswer();
|
| 131 |
+
await peer.setLocalDescription(answer);
|
| 132 |
+
socket.emit("signal", {
|
| 133 |
+
target: sender,
|
| 134 |
+
signal: { type: "answer", sdp: peer.localDescription }
|
| 135 |
+
});
|
| 136 |
+
} else if (signal.type === "answer") {
|
| 137 |
+
await peer.setRemoteDescription(new RTCSessionDescription(signal.sdp));
|
| 138 |
+
} else if (signal.type === "ice-candidate") {
|
| 139 |
+
await peer.addIceCandidate(new RTCIceCandidate(signal.candidate));
|
| 140 |
+
}
|
| 141 |
+
} catch (err) {
|
| 142 |
+
console.error("Error handling signal:", err);
|
| 143 |
+
}
|
| 144 |
+
};
|
| 145 |
+
|
| 146 |
+
socket.on("user-connected-to-call", handleUserConnected);
|
| 147 |
+
socket.on("user-disconnected-from-call", handleUserDisconnected);
|
| 148 |
+
socket.on("signal", handleSignal);
|
| 149 |
+
|
| 150 |
+
return () => {
|
| 151 |
+
socket.off("user-connected-to-call", handleUserConnected);
|
| 152 |
+
socket.off("user-disconnected-from-call", handleUserDisconnected);
|
| 153 |
+
socket.off("signal", handleSignal);
|
| 154 |
+
};
|
| 155 |
+
}, [localStream, createPeer]);
|
| 156 |
+
|
| 157 |
+
return { localStream, remoteStreams, joinCall, leaveCall };
|
| 158 |
+
}
|
lib/crypto.test.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, expect, test } from "bun:test";
|
| 2 |
+
import {
|
| 3 |
+
generateKeyPair,
|
| 4 |
+
exportKey,
|
| 5 |
+
importKey,
|
| 6 |
+
encryptMessage,
|
| 7 |
+
decryptMessage,
|
| 8 |
+
generateSymKey,
|
| 9 |
+
encryptSymMessage,
|
| 10 |
+
decryptSymMessage,
|
| 11 |
+
exportSymKey,
|
| 12 |
+
importSymKey
|
| 13 |
+
} from "./crypto";
|
| 14 |
+
|
| 15 |
+
describe("Crypto Logic", () => {
|
| 16 |
+
test("should generate key pair", async () => {
|
| 17 |
+
const keyPair = await generateKeyPair();
|
| 18 |
+
expect(keyPair.publicKey).toBeDefined();
|
| 19 |
+
expect(keyPair.privateKey).toBeDefined();
|
| 20 |
+
});
|
| 21 |
+
|
| 22 |
+
test("should export and import keys", async () => {
|
| 23 |
+
const keyPair = await generateKeyPair();
|
| 24 |
+
const exportedPublic = await exportKey(keyPair.publicKey);
|
| 25 |
+
const importedPublic = await importKey(exportedPublic, ["encrypt"]);
|
| 26 |
+
expect(importedPublic).toBeDefined();
|
| 27 |
+
});
|
| 28 |
+
|
| 29 |
+
test("should encrypt and decrypt message", async () => {
|
| 30 |
+
const keyPair = await generateKeyPair();
|
| 31 |
+
const message = "Hello, World!";
|
| 32 |
+
const encrypted = await encryptMessage(keyPair.publicKey, message);
|
| 33 |
+
const decrypted = await decryptMessage(keyPair.privateKey, encrypted);
|
| 34 |
+
expect(decrypted).toBe(message);
|
| 35 |
+
});
|
| 36 |
+
|
| 37 |
+
test("should encrypt and decrypt symmetric message", async () => {
|
| 38 |
+
const key = await generateSymKey();
|
| 39 |
+
const message = "Secret Message";
|
| 40 |
+
const encrypted = await encryptSymMessage(key, message);
|
| 41 |
+
const decrypted = await decryptSymMessage(key, encrypted);
|
| 42 |
+
expect(decrypted).toBe(message);
|
| 43 |
+
});
|
| 44 |
+
|
| 45 |
+
test("should export and import symmetric keys", async () => {
|
| 46 |
+
const key = await generateSymKey();
|
| 47 |
+
const exported = await exportSymKey(key);
|
| 48 |
+
const imported = await importSymKey(exported);
|
| 49 |
+
expect(imported).toBeDefined();
|
| 50 |
+
|
| 51 |
+
// Verify imported key works
|
| 52 |
+
const message = "Test Key Import";
|
| 53 |
+
const encrypted = await encryptSymMessage(imported, message);
|
| 54 |
+
const decrypted = await decryptSymMessage(key, encrypted); // Decrypt with original key
|
| 55 |
+
expect(decrypted).toBe(message);
|
| 56 |
+
});
|
| 57 |
+
});
|
lib/crypto.ts
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export async function generateKeyPair() {
|
| 2 |
+
return await crypto.subtle.generateKey(
|
| 3 |
+
{
|
| 4 |
+
name: "RSA-OAEP",
|
| 5 |
+
modulusLength: 2048,
|
| 6 |
+
publicExponent: new Uint8Array([1, 0, 1]),
|
| 7 |
+
hash: "SHA-256",
|
| 8 |
+
},
|
| 9 |
+
true,
|
| 10 |
+
["encrypt", "decrypt"]
|
| 11 |
+
);
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
export async function exportKey(key: CryptoKey) {
|
| 15 |
+
const exported = await crypto.subtle.exportKey("jwk", key);
|
| 16 |
+
return exported;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
export async function importKey(jwk: JsonWebKey, usage: KeyUsage[]) {
|
| 20 |
+
return await crypto.subtle.importKey(
|
| 21 |
+
"jwk",
|
| 22 |
+
jwk,
|
| 23 |
+
{
|
| 24 |
+
name: "RSA-OAEP",
|
| 25 |
+
hash: "SHA-256",
|
| 26 |
+
},
|
| 27 |
+
true,
|
| 28 |
+
usage
|
| 29 |
+
);
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
export async function encryptMessage(publicKey: CryptoKey, message: string) {
|
| 33 |
+
const encoder = new TextEncoder();
|
| 34 |
+
const data = encoder.encode(message);
|
| 35 |
+
const encrypted = await crypto.subtle.encrypt(
|
| 36 |
+
{
|
| 37 |
+
name: "RSA-OAEP",
|
| 38 |
+
},
|
| 39 |
+
publicKey,
|
| 40 |
+
data
|
| 41 |
+
);
|
| 42 |
+
return arrayBufferToBase64(encrypted);
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
export async function decryptMessage(privateKey: CryptoKey, encryptedMessage: string) {
|
| 46 |
+
const data = base64ToArrayBuffer(encryptedMessage);
|
| 47 |
+
const decrypted = await crypto.subtle.decrypt(
|
| 48 |
+
{
|
| 49 |
+
name: "RSA-OAEP",
|
| 50 |
+
},
|
| 51 |
+
privateKey,
|
| 52 |
+
data
|
| 53 |
+
);
|
| 54 |
+
const decoder = new TextDecoder();
|
| 55 |
+
return decoder.decode(decrypted);
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
function arrayBufferToBase64(buffer: ArrayBuffer) {
|
| 59 |
+
let binary = "";
|
| 60 |
+
const bytes = new Uint8Array(buffer);
|
| 61 |
+
const len = bytes.byteLength;
|
| 62 |
+
for (let i = 0; i < len; i++) {
|
| 63 |
+
binary += String.fromCharCode(bytes[i]);
|
| 64 |
+
}
|
| 65 |
+
return btoa(binary);
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
function base64ToArrayBuffer(base64: string) {
|
| 69 |
+
const binaryString = atob(base64);
|
| 70 |
+
const bytes = new Uint8Array(binaryString.length);
|
| 71 |
+
for (let i = 0; i < binaryString.length; i++) {
|
| 72 |
+
bytes[i] = binaryString.charCodeAt(i);
|
| 73 |
+
}
|
| 74 |
+
return bytes.buffer;
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
// ========== AES-GCM Functions for Hybrid Encryption ==========
|
| 78 |
+
|
| 79 |
+
export async function generateSymKey() {
|
| 80 |
+
return await crypto.subtle.generateKey(
|
| 81 |
+
{
|
| 82 |
+
name: "AES-GCM",
|
| 83 |
+
length: 256,
|
| 84 |
+
},
|
| 85 |
+
true,
|
| 86 |
+
["encrypt", "decrypt"]
|
| 87 |
+
);
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
export async function encryptSymMessage(key: CryptoKey, message: string) {
|
| 91 |
+
const encoder = new TextEncoder();
|
| 92 |
+
const data = encoder.encode(message);
|
| 93 |
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
| 94 |
+
const encrypted = await crypto.subtle.encrypt(
|
| 95 |
+
{
|
| 96 |
+
name: "AES-GCM",
|
| 97 |
+
iv: iv,
|
| 98 |
+
},
|
| 99 |
+
key,
|
| 100 |
+
data
|
| 101 |
+
);
|
| 102 |
+
|
| 103 |
+
// Combine IV and ciphertext
|
| 104 |
+
const combined = new Uint8Array(iv.length + encrypted.byteLength);
|
| 105 |
+
combined.set(iv);
|
| 106 |
+
combined.set(new Uint8Array(encrypted), iv.length);
|
| 107 |
+
|
| 108 |
+
return arrayBufferToBase64(combined.buffer);
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
export async function decryptSymMessage(key: CryptoKey, encryptedMessage: string) {
|
| 112 |
+
const combined = base64ToArrayBuffer(encryptedMessage);
|
| 113 |
+
const combinedArray = new Uint8Array(combined);
|
| 114 |
+
const iv = combinedArray.slice(0, 12);
|
| 115 |
+
const data = combinedArray.slice(12);
|
| 116 |
+
|
| 117 |
+
const decrypted = await crypto.subtle.decrypt(
|
| 118 |
+
{
|
| 119 |
+
name: "AES-GCM",
|
| 120 |
+
iv: iv,
|
| 121 |
+
},
|
| 122 |
+
key,
|
| 123 |
+
data
|
| 124 |
+
);
|
| 125 |
+
|
| 126 |
+
const decoder = new TextDecoder();
|
| 127 |
+
return decoder.decode(decrypted);
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
export async function exportSymKey(key: CryptoKey) {
|
| 131 |
+
const exported = await crypto.subtle.exportKey("raw", key);
|
| 132 |
+
return arrayBufferToBase64(exported);
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
export async function importSymKey(rawKey: string) {
|
| 136 |
+
const buffer = base64ToArrayBuffer(rawKey);
|
| 137 |
+
return await crypto.subtle.importKey(
|
| 138 |
+
"raw",
|
| 139 |
+
buffer,
|
| 140 |
+
{
|
| 141 |
+
name: "AES-GCM",
|
| 142 |
+
},
|
| 143 |
+
true,
|
| 144 |
+
["encrypt", "decrypt"]
|
| 145 |
+
);
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
// ========== FILE ENCRYPTION FUNCTIONS ==========
|
| 149 |
+
|
| 150 |
+
/**
|
| 151 |
+
* Encrypt a file (ArrayBuffer) with AES-GCM
|
| 152 |
+
*/
|
| 153 |
+
export async function encryptFile(aesKey: CryptoKey, fileData: ArrayBuffer): Promise<ArrayBuffer> {
|
| 154 |
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
| 155 |
+
const encrypted = await crypto.subtle.encrypt(
|
| 156 |
+
{
|
| 157 |
+
name: "AES-GCM",
|
| 158 |
+
iv,
|
| 159 |
+
},
|
| 160 |
+
aesKey,
|
| 161 |
+
fileData
|
| 162 |
+
);
|
| 163 |
+
|
| 164 |
+
// Combine IV + encrypted data
|
| 165 |
+
const combined = new Uint8Array(iv.length + encrypted.byteLength);
|
| 166 |
+
combined.set(iv, 0);
|
| 167 |
+
combined.set(new Uint8Array(encrypted), iv.length);
|
| 168 |
+
|
| 169 |
+
return combined.buffer;
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
/**
|
| 173 |
+
* Decrypt a file (ArrayBuffer) with AES-GCM
|
| 174 |
+
*/
|
| 175 |
+
export async function decryptFile(aesKey: CryptoKey, encryptedData: ArrayBuffer): Promise<ArrayBuffer> {
|
| 176 |
+
const combined = new Uint8Array(encryptedData);
|
| 177 |
+
const iv = combined.slice(0, 12);
|
| 178 |
+
const data = combined.slice(12);
|
| 179 |
+
|
| 180 |
+
const decrypted = await crypto.subtle.decrypt(
|
| 181 |
+
{
|
| 182 |
+
name: "AES-GCM",
|
| 183 |
+
iv: iv,
|
| 184 |
+
},
|
| 185 |
+
aesKey,
|
| 186 |
+
data
|
| 187 |
+
);
|
| 188 |
+
|
| 189 |
+
return decrypted;
|
| 190 |
+
}
|
lib/db.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import mongoose from 'mongoose';
|
| 2 |
+
|
| 3 |
+
const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/secrate_chat';
|
| 4 |
+
|
| 5 |
+
if (!MONGODB_URI) {
|
| 6 |
+
throw new Error(
|
| 7 |
+
'Please define the MONGODB_URI environment variable inside .env.local'
|
| 8 |
+
);
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
/**
|
| 12 |
+
* Global is used here to maintain a cached connection across hot reloads
|
| 13 |
+
* in development. This prevents connections growing exponentially
|
| 14 |
+
* during API Route usage.
|
| 15 |
+
*/
|
| 16 |
+
let cached = (global as any).mongoose;
|
| 17 |
+
|
| 18 |
+
if (!cached) {
|
| 19 |
+
cached = (global as any).mongoose = { conn: null, promise: null };
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
async function dbConnect() {
|
| 23 |
+
if (cached.conn) {
|
| 24 |
+
return cached.conn;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
if (!cached.promise) {
|
| 28 |
+
const opts = {
|
| 29 |
+
bufferCommands: false,
|
| 30 |
+
};
|
| 31 |
+
|
| 32 |
+
cached.promise = mongoose.connect(MONGODB_URI, opts).then((mongoose) => {
|
| 33 |
+
return mongoose;
|
| 34 |
+
});
|
| 35 |
+
}
|
| 36 |
+
try {
|
| 37 |
+
cached.conn = await cached.promise;
|
| 38 |
+
} catch (e) {
|
| 39 |
+
cached.promise = null;
|
| 40 |
+
throw e;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
return cached.conn;
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
export default dbConnect;
|
lib/socket.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { io } from "socket.io-client";
|
| 2 |
+
|
| 3 |
+
// "undefined" means the URL will be computed from the `window.location` object
|
| 4 |
+
const URL = process.env.NEXT_PUBLIC_SOCKET_URL || "http://localhost:8000";
|
| 5 |
+
|
| 6 |
+
export const socket = io(URL, {
|
| 7 |
+
autoConnect: false,
|
| 8 |
+
});
|
lib/tokens.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { authenticator } from "otplib";
|
| 2 |
+
import QRCode from "qrcode";
|
| 3 |
+
|
| 4 |
+
export const generateTwoFactorSecret = async (email: string) => {
|
| 5 |
+
const secret = authenticator.generateSecret();
|
| 6 |
+
const otpauth = authenticator.keyuri(email, "SecureChatApp", secret);
|
| 7 |
+
const qrCodeUrl = await QRCode.toDataURL(otpauth);
|
| 8 |
+
|
| 9 |
+
return { secret, qrCodeUrl };
|
| 10 |
+
};
|
| 11 |
+
|
| 12 |
+
export const verifyTwoFactorToken = (token: string, secret: string) => {
|
| 13 |
+
authenticator.options = { window: 1 };
|
| 14 |
+
return authenticator.verify({ token, secret });
|
| 15 |
+
};
|
lib/utils.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { clsx, type ClassValue } from "clsx"
|
| 2 |
+
import { twMerge } from "tailwind-merge"
|
| 3 |
+
|
| 4 |
+
export function cn(...inputs: ClassValue[]) {
|
| 5 |
+
return twMerge(clsx(inputs))
|
| 6 |
+
}
|
models/Room.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import mongoose, { Schema, Document } from 'mongoose';
|
| 2 |
+
|
| 3 |
+
export interface IRoom extends Document {
|
| 4 |
+
roomId: string;
|
| 5 |
+
createdAt: Date;
|
| 6 |
+
expiresAt: Date;
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
const RoomSchema: Schema = new Schema({
|
| 10 |
+
roomId: { type: String, required: true, unique: true },
|
| 11 |
+
userLimit: { type: Number, default: 10 }, // Default limit
|
| 12 |
+
participants: [
|
| 13 |
+
{
|
| 14 |
+
userId: String,
|
| 15 |
+
nickname: String,
|
| 16 |
+
joinedAt: { type: Date, default: Date.now },
|
| 17 |
+
},
|
| 18 |
+
],
|
| 19 |
+
createdAt: { type: Date, default: Date.now },
|
| 20 |
+
expiresAt: { type: Date, index: { expires: "1h" } }, // Auto-delete after 1 hour of inactivity (or custom logic)
|
| 21 |
+
});
|
| 22 |
+
|
| 23 |
+
export default mongoose.models.Room || mongoose.model<IRoom>('Room', RoomSchema);
|
models/User.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import mongoose, { Schema, Document } from "mongoose";
|
| 2 |
+
|
| 3 |
+
export interface IUser extends Document {
|
| 4 |
+
email: string;
|
| 5 |
+
password?: string;
|
| 6 |
+
image?: string;
|
| 7 |
+
twoFactorSecret?: string;
|
| 8 |
+
isTwoFactorEnabled: boolean;
|
| 9 |
+
createdAt: Date;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
const UserSchema: Schema = new Schema({
|
| 13 |
+
email: { type: String, required: true, unique: true },
|
| 14 |
+
password: { type: String, required: false }, // Optional for OAuth, but we are using Credentials
|
| 15 |
+
image: { type: String },
|
| 16 |
+
twoFactorSecret: { type: String },
|
| 17 |
+
isTwoFactorEnabled: { type: Boolean, default: false },
|
| 18 |
+
createdAt: { type: Date, default: Date.now },
|
| 19 |
+
});
|
| 20 |
+
|
| 21 |
+
export default mongoose.models.User || mongoose.model<IUser>("User", UserSchema);
|
next-env.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/// <reference types="next" />
|
| 2 |
+
/// <reference types="next/image-types/global" />
|
| 3 |
+
import "./.next/types/routes.d.ts";
|
| 4 |
+
|
| 5 |
+
// NOTE: This file should not be edited
|
| 6 |
+
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
next.config.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { NextConfig } from "next";
|
| 2 |
+
|
| 3 |
+
const nextConfig: NextConfig = {
|
| 4 |
+
/* config options here */
|
| 5 |
+
};
|
| 6 |
+
|
| 7 |
+
export default nextConfig;
|
package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "secrate_chat",
|
| 3 |
+
"version": "0.1.0",
|
| 4 |
+
"private": true,
|
| 5 |
+
"scripts": {
|
| 6 |
+
"dev": "bun server.ts",
|
| 7 |
+
"build": "next build",
|
| 8 |
+
"start": "NODE_ENV=production bun server.ts",
|
| 9 |
+
"lint": "eslint"
|
| 10 |
+
},
|
| 11 |
+
"dependencies": {
|
| 12 |
+
"@radix-ui/react-scroll-area": "^1.2.10",
|
| 13 |
+
"@radix-ui/react-slot": "^1.2.4",
|
| 14 |
+
"bcryptjs": "^3.0.3",
|
| 15 |
+
"class-variance-authority": "^0.7.1",
|
| 16 |
+
"clsx": "^2.1.1",
|
| 17 |
+
"emoji-picker-react": "^4.15.2",
|
| 18 |
+
"lucide-react": "^0.554.0",
|
| 19 |
+
"mongoose": "^9.0.0",
|
| 20 |
+
"next": "16.0.4",
|
| 21 |
+
"next-auth": "^5.0.0-beta.30",
|
| 22 |
+
"next-themes": "^0.4.6",
|
| 23 |
+
"otplib": "^12.0.1",
|
| 24 |
+
"qrcode": "^1.5.4",
|
| 25 |
+
"react": "19.2.0",
|
| 26 |
+
"react-dom": "19.2.0",
|
| 27 |
+
"react-markdown": "^10.1.0",
|
| 28 |
+
"remark-gfm": "^4.0.1",
|
| 29 |
+
"socket.io": "^4.8.1",
|
| 30 |
+
"socket.io-client": "^4.8.1",
|
| 31 |
+
"tailwind-merge": "^3.4.0",
|
| 32 |
+
"tailwindcss-animate": "^1.0.7"
|
| 33 |
+
},
|
| 34 |
+
"devDependencies": {
|
| 35 |
+
"@tailwindcss/postcss": "^4",
|
| 36 |
+
"@types/bcryptjs": "^3.0.0",
|
| 37 |
+
"@types/node": "^20",
|
| 38 |
+
"@types/qrcode": "^1.5.6",
|
| 39 |
+
"@types/react": "^19",
|
| 40 |
+
"@types/react-dom": "^19",
|
| 41 |
+
"eslint": "^9",
|
| 42 |
+
"eslint-config-next": "16.0.4",
|
| 43 |
+
"tailwindcss": "^4",
|
| 44 |
+
"tw-animate-css": "^1.4.0",
|
| 45 |
+
"typescript": "^5"
|
| 46 |
+
}
|
| 47 |
+
}
|
postcss.config.mjs
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const config = {
|
| 2 |
+
plugins: {
|
| 3 |
+
"@tailwindcss/postcss": {},
|
| 4 |
+
},
|
| 5 |
+
};
|
| 6 |
+
|
| 7 |
+
export default config;
|
public/file.svg
ADDED
|
|
public/globe.svg
ADDED
|
|
public/next.svg
ADDED
|
|
public/vercel.svg
ADDED
|
|