Skip to content
Open
8 changes: 6 additions & 2 deletions backend/middleware/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import User from "../models/User.js"
export const protectRoute = async (req, res, next) => {
const token = req.headers.authorization?.split(" ")[1]

if (!token) {
if (!token || token === "undefined" || token === "null") {
return res.status(401).json({ message: "Not authorized, no token" })
}

Expand Down Expand Up @@ -35,7 +35,11 @@ export const protectRoute = async (req, res, next) => {
.status(401)
.json({ code: "TOKEN_EXPIRED", message: "Access token expired" })
}
console.error("Token verification failed:", error)
if (error.name === "JsonWebTokenError") {
console.warn(`Token verification failed: ${error.message}`)
} else {
console.error("Token verification failed:", error)
}
res.status(401).json({ message: "Not authorized, token failed" })
}
}
8 changes: 6 additions & 2 deletions backend/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ io.use((socket, next) => {

io.use(async (socket, next) => {
const token = socket.handshake.auth?.token
if (!token) {
if (!token || token === "undefined" || token === "null") {
return next(new Error("Authentication error: No token provided"))
}

Expand All @@ -142,7 +142,11 @@ io.use(async (socket, next) => {
if (err.name === "TokenExpiredError") {
return next(new Error("Authentication error: Token expired"))
}
console.error("Socket auth token invalid:", err.message)
if (err.name === "JsonWebTokenError") {
console.warn(`Socket auth token invalid: ${err.message}`)
} else {
console.error("Socket auth token invalid:", err.message)
}
return next(new Error("Authentication error: Invalid token"))
}
})
Expand Down
59 changes: 26 additions & 33 deletions frontend/e2e/message.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,54 +187,47 @@ test.describe("Message Flow", () => {
}
})

// Set up response waits before navigation to avoid races (auto-select can fetch immediately).
const sidebarResponsePromise = page.waitForResponse((response) => {
const pathname = new URL(response.url()).pathname
if (pathname !== "/api/v1/messages" && pathname !== "/api/v1/messages/") return false
return response.request().method() === "GET" && response.status() === 200
// Inject token before navigation to mimic proper hydration
await page.addInitScript(() => {
window.localStorage.setItem('token', 'mock-access-token')
})

const historyResponsePromise = page.waitForResponse((response) => {
const url = response.url()
return (
url.includes(`/api/v1/messages/${friendId}`) &&
response.request().method() === "GET" &&
response.status() === 200
)
})

// Navigate to chat
await page.goto("/chat")
await sidebarResponsePromise
// Navigate to chat and wait for the users to load simultaneously
const [sidebarResponse] = await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes("api/v1/messages") &&
!response.url().includes(friendId) &&
response.request().method() === "GET" &&
response.status() === 200,
{ timeout: 15000 }
),
page.goto("/chat")
])

// Wait for "Alice" to appear in the sidebar
await expect(page.getByText("Alice").first()).toBeVisible({
timeout: 10000,
})

// The app auto-selects the first user; wait for the conversation fetch to complete
await historyResponsePromise

// Verify chat history loaded
await expect(page.getByText("Hi there")).toBeVisible({ timeout: 10000 })

// Type a new message
const input = page.getByPlaceholder("Type a message...")
await input.fill("Hello Playwright!")

// Prepare to wait for the send message response
const sendResponsePromise = page.waitForResponse(
(response) =>
response.url().includes(`/api/v1/messages/send/${friendId}`) &&
response.request().method() === "POST" &&
response.status() === 201,
)

// Click Send
await page.getByRole("button", { name: "Send" }).click()

// Wait for the send message response
await sendResponsePromise
// Click Send and wait for the network response simultaneously
const [sendResponse] = await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes(`api/v1/messages/send/${friendId}`) &&
response.request().method() === "POST" &&
response.status() === 201,
{ timeout: 10000 }
),
page.getByRole("button", { name: "Send" }).click()
])

// Verify the new message appears in the UI
await expect(page.getByText("Hello Playwright!")).toBeVisible()
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/app/fetcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ export async function fetcher<T = any>(
}

const t = getToken()
if (t) headers.set("Authorization", `Bearer ${t}`)
if (t && t !== "undefined" && t !== "null") headers["Authorization"] = `Bearer ${t}`

const fetchOptions: RequestInit = {
...options,
Expand Down
16 changes: 2 additions & 14 deletions frontend/src/components/chat/ChatPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
} from "lucide-react"
import { SettingsDrawer } from "../ui/settings-drawer"
import MediaGallery from "./MediaGallery"
import { FileUpload } from "./FileUpload"

function MessageTick({
seen,
Expand Down Expand Up @@ -521,20 +522,7 @@ export default function ChatPanel({
/>
</div>
)}
<label
className="px-2 py-2 cursor-pointer border-2 border-sidebar-border rounded-full bg-accent hover:bg-[#b39ddb] flex items-center justify-center"
title="Attach image"
>
<input
type="file"
accept="image/*"
className="hidden"
onChange={handleImageChange}
/>
<span role="img" aria-label="Attach">
📎
</span>
</label>
<FileUpload roomId={selectedUser?._id || selectedUser?.id || ""} />
{image && (
<div className="relative flex items-center">
<img
Expand Down
95 changes: 95 additions & 0 deletions frontend/src/components/chat/FileUpload.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { useRef } from "react";
import "../../styles/fileUpload.css";
import { useFileUpload } from "../../hooks/useFileUpload";
import { Paperclip, X, CheckCircle, AlertCircle } from "lucide-react";

interface Props {
roomId: string;
}

export function FileUpload({ roomId }: Props) {
const inputRef = useRef<HTMLInputElement>(null);
const { progress, status, error, fileName, upload, cancel, reset } = useFileUpload();

const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
await upload(file, roomId);
// Reset input so same file can be re-selected
if (inputRef.current) inputRef.current.value = "";
};

return (
<div className="file-upload-wrapper flex items-center">
{/* Hidden native input */}
<input
ref={inputRef}
type="file"
className="hidden"
onChange={handleFileSelect}
aria-label="Attach file"
/>

{/* Upload trigger button */}
{status === "idle" || status === "success" || status === "cancelled" ? (
<button
type="button"
onClick={() => { reset(); inputRef.current?.click(); }}
className="px-2 py-2 cursor-pointer border-2 border-sidebar-border rounded-full bg-accent hover:bg-[#b39ddb] flex items-center justify-center transition-colors"
title="Attach file (max 10MB)"
>
<Paperclip className="w-5 h-5" />
</button>
) : null}

{/* Progress bar — shown during upload */}
{status === "uploading" || status === "validating" ? (
<div className="upload-progress-container absolute bottom-16 left-4 z-50 bg-card border-2 border-sidebar-border shadow-lg">
<span className="upload-filename truncate">{fileName}</span>

<div className="progress-bar-track">
<div
className="progress-bar-fill"
style={{ width: `${progress}%` }}
role="progressbar"
aria-valuenow={progress}
aria-valuemin={0}
aria-valuemax={100}
/>
</div>

<span className="upload-pct">{progress}%</span>

{/* Cancel button */}
<button
type="button"
onClick={cancel}
className="cancel-btn hover:text-red-500"
title="Cancel upload"
>
<X className="w-4 h-4" />
</button>
</div>
) : null}

{/* Error message */}
{status === "error" && error && (
<div className="upload-error absolute bottom-16 left-4 z-50 shadow-lg" role="alert">
<AlertCircle className="w-4 h-4 text-red-500 flex-shrink-0" />
<span className="flex-1">{error}</span>
<button type="button" onClick={reset} className="text-xs font-bold underline ml-2 whitespace-nowrap">
Dismiss
</button>
</div>
)}

{/* Success */}
{status === "success" && (
<div className="upload-success absolute bottom-16 left-4 z-50 bg-white border border-green-200 px-3 py-1.5 rounded-lg shadow-lg">
<CheckCircle className="w-4 h-4 text-green-500" />
<span className="font-medium text-green-700">File sent!</span>
</div>
)}
</div>
);
}
103 changes: 103 additions & 0 deletions frontend/src/hooks/useFileUpload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { useState, useRef, useCallback } from "react";
import { validateFile } from "../lib/fileValidation";
import { useMessageContext } from "../app/MessageContext";

interface UploadState {
progress: number; // 0-100
status: "idle" | "validating" | "uploading" | "success" | "error" | "cancelled";
error: string | null;
fileName: string | null;
}

export function useFileUpload() {
const [state, setState] = useState<UploadState>({
progress: 0,
status: "idle",
error: null,
fileName: null,
});

const { sendMessage } = useMessageContext();
const readerRef = useRef<FileReader | null>(null);

const upload = useCallback(async (file: File, roomId: string) => {
// Step 1: Validate
setState({ progress: 0, status: "validating", error: null, fileName: file.name });

const validation = validateFile(file);
if (!validation.valid) {
setState((s) => ({ ...s, status: "error", error: validation.error }));
return;
}

// Step 2: Read file and track progress, then send via existing API
setState((s) => ({ ...s, status: "uploading" }));

const reader = new FileReader();
readerRef.current = reader;

return new Promise<void>((resolve, reject) => {
// ✅ Track progress for reading large files
reader.onprogress = (e) => {
if (e.lengthComputable) {
const pct = Math.round((e.loaded / e.total) * 100);
setState((s) => ({ ...s, progress: pct }));
}
};

reader.onload = async () => {
try {
if (readerRef.current === null) {
reject();
return; // cancelled
}
const base64 = reader.result as string;

// Force progress to 100 once read
setState((s) => ({ ...s, progress: 100 }));

// Send via existing sendMessage API which expects base64
await sendMessage(roomId, "", base64);

setState((s) => ({ ...s, status: "success", progress: 100 }));
resolve();
} catch (err: any) {
if (err?.message?.includes("413") || err?.status === 413) {
setState((s) => ({
...s, status: "error",
error: "File too large. Server rejected the upload.",
}));
} else {
setState((s) => ({
...s, status: "error",
error: err?.message || "Upload failed. Please try again.",
}));
}
reject();
}
};

reader.onerror = () => {
setState((s) => ({ ...s, status: "error", error: "File reading error." }));
reject();
};

reader.readAsDataURL(file);
});
}, [sendMessage]);

// ✅ Cancel in-flight upload
const cancel = useCallback(() => {
if (readerRef.current) {
readerRef.current.abort();
readerRef.current = null;
setState({ progress: 0, status: "cancelled", error: null, fileName: null });
}
}, []);

const reset = useCallback(() => {
setState({ progress: 0, status: "idle", error: null, fileName: null });
}, []);

return { ...state, upload, cancel, reset };
}
Loading
Loading