Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 124 additions & 0 deletions app/admin/dashboard/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
"use client";

import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { supabase } from "@/lib/supabase";

type Appointment = {
id: string;
status: "active" | "done" | "cancelled";
created_at: string;
doctors: { name: string; specialty: string };
patients: { name: string };
slots: { start_time: string; end_time: string };
};

export default function AdminDashboard() {
const router = useRouter();

const [appointments, setAppointments] = useState<Appointment[]>([]);
const [loading, setLoading] = useState(true);

useEffect(() => {
// ✅ Check admin login
const isAdmin = localStorage.getItem("admin");
if (!isAdmin) {
router.push("/admin/login");
return;
}
Comment on lines +24 to +28
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

The admin check relies on a value in localStorage, which is insecure as it can be easily manipulated by users in the browser console. This allows unauthorized access to the admin dashboard. Authentication should be verified on the server side or via a secure session token managed by an authentication provider.


async function load() {
const { data } = await supabase
.from("appointments")
.select(`
id,
status,
created_at,
doctors(name, specialty),
patients(name),
slots(start_time, end_time)
`)
.order("created_at", { ascending: false });

setAppointments((data as any) ?? []);
setLoading(false);
}

load();
}, [router]);

function formatDateTime(iso: string) {
return new Date(iso).toLocaleString("en-IN", {
dateStyle: "medium",
timeStyle: "short",
});
}

return (
<main className="p-8">
<h1 className="text-2xl font-bold mb-6">Admin Dashboard</h1>

<button
onClick={() => {
localStorage.removeItem("admin");
router.push("/admin/login");
}}
className="mb-4 bg-red-500 text-white px-4 py-2 rounded"
>
Logout
</button>

{loading ? (
<p>Loading...</p>
) : (
<div className="rounded-xl border overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-100">
<tr>
<th className="px-4 py-3 text-left">Doctor</th>
<th className="px-4 py-3 text-left">Patient</th>
<th className="px-4 py-3 text-left">Slot</th>
<th className="px-4 py-3 text-left">Status</th>
</tr>
</thead>

<tbody className="divide-y">
{appointments.map((appt) => (
<tr key={appt.id}>
<td className="px-4 py-3">
{appt.doctors?.name || "Unknown"}
<div className="text-xs text-gray-500">
{appt.doctors?.specialty}
</div>
</td>

<td className="px-4 py-3">
{appt.patients?.name || "Unknown"}
</td>

<td className="px-4 py-3">
{formatDateTime(appt.slots?.start_time)}
</td>

<td className="px-4 py-3">
<span
className={`rounded-full px-2 py-1 text-xs font-medium ${
appt.status === "active"
? "bg-blue-100 text-blue-700"
: appt.status === "done"
? "bg-green-100 text-green-700"
: "bg-gray-100 text-gray-600"
}`}
>
{appt.status}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</main>
);
}
82 changes: 82 additions & 0 deletions app/admin/login/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
"use client";

import { useState } from "react";
import { useRouter } from "next/navigation";
import { supabase } from "@/lib/supabase";

export default function AdminLogin() {
const router = useRouter();

const [email, setEmail] = useState("");
const [password, setPassword] = useState("");

const [error, setError] = useState("");
const [loading, setLoading] = useState(false);

async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError("");
setLoading(true);

// ✅ Check admin in DB
const { data, error } = await supabase
.from("system_admins")
.select("*")
.eq("email", email)
.eq("password", password)
.single();
Comment on lines +22 to +27
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-critical critical

Comparing passwords in plain text within a database query is a severe security vulnerability. Passwords should never be stored or compared in plain text. This approach exposes administrative credentials to anyone with access to the database or the query logs. You should use a secure authentication service like Supabase Auth to handle user credentials and session management.

Comment on lines +21 to +27
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Move admin credential verification off the client immediately.

Line 22–27 performs admin auth in the browser and compares plaintext passwords from DB. This exposes authentication logic and requires insecure password storage.

🔧 Suggested direction
- const { data, error } = await supabase
-   .from("system_admins")
-   .select("*")
-   .eq("email", email)
-   .eq("password", password)
-   .single();
-
- if (error || !data) {
+ const res = await fetch("/api/admin/login", {
+   method: "POST",
+   headers: { "Content-Type": "application/json" },
+   body: JSON.stringify({ email, password }),
+ });
+ const data = await res.json();
+ if (!res.ok) {
    setError("Invalid admin credentials");
    setLoading(false);
    return;
  }

Based on learnings: In the Next.js admin area, do not authenticate by comparing passwords in plaintext and use a secure, verifiable mechanism (e.g., signed JWT or Supabase Auth claims).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/admin/login/page.tsx` around lines 21 - 27, The current client-side admin
check is insecure: the code in app/admin/login/page.tsx uses
supabase.from("system_admins").select(...).eq("email", email).eq("password",
password).single() to compare plaintext passwords in the browser; move this
logic to a server-side endpoint (API route or Next.js server action) and stop
querying plaintext passwords from "system_admins". Instead, store only hashed
passwords and perform verification server-side using a secure hash compare (or
migrate to Supabase Auth and validate via its JWT/claims), then return a session
token or signed JWT to the client; update references to the client-side call
(the supabase .from(...).select(...) usage) to instead call the new server
endpoint (or use Supabase Auth) so authentication and password verification
happen only on the server.


if (error || !data) {
setError("Invalid admin credentials");
setLoading(false);
return;
}

// ✅ Save session
localStorage.setItem("admin", "true");

router.push("/admin/dashboard");
}

return (
<main className="flex min-h-screen items-center justify-center bg-gray-50">
<div className="w-full max-w-md rounded-xl border bg-white p-8 shadow-sm">
<h1 className="mb-6 text-2xl font-bold">Admin Login</h1>

<form onSubmit={handleSubmit} className="flex flex-col gap-4">
{/* Email */}
<input
type="email"
placeholder="admin@test.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="border p-2 rounded"
/>

{/* Password */}
<input
type="password"
placeholder="admin123"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="border p-2 rounded"
/>

{/* Error */}
{error && <p className="text-red-500 text-sm">{error}</p>}

{/* Button */}
<button
type="submit"
disabled={loading}
className="bg-black text-white py-2 rounded"
>
{loading ? "Logging in..." : "Login"}
</button>
</form>
</div>
</main>
);
}
105 changes: 102 additions & 3 deletions app/api/appointments/book/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,104 @@
import { NextRequest, NextResponse } from "next/server";
import { createClient } from "@supabase/supabase-js";

export async function POST(_req: NextRequest) {
return NextResponse.json({ error: "Not implemented" }, { status: 501 });
}
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);

export async function POST(req: NextRequest) {
try {
const body = await req.json();
const { appointmentId, action } = body;

if (!appointmentId || !action) {
return NextResponse.json(
{ error: "Missing appointmentId or action" },
{ status: 400 }
);
}
Comment on lines +12 to +19
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

/book endpoint currently performs status updates, not booking.

This route requires appointmentId/action and updates existing appointments, while booking flow sends slot_id/patient_id from app/patient/dashboard/page.tsx (Lines 118-121). As implemented, booking requests fail at Line 14 with 400.

Also applies to: 73-96

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/api/appointments/book/route.ts` around lines 12 - 19, The route currently
only accepts appointmentId/action and returns 400 for the booking payload sent
from the front end (slot_id and patient_id); update the handler in route.ts to
support both flows by branching: if body contains slot_id and patient_id, run
the "create booking" logic (create new appointment using slot_id and
patient_id), else if body contains appointmentId and action, run the existing
status update logic; validate each branch's required fields separately and only
return 400 when neither set of required fields is present, ensuring you check
for the exact front-end keys slot_id and patient_id and preserve the
appointmentId/action update behavior.


// ✅ STEP 1: Get logged-in user
const {
data: { user },
error: userError,
} = await supabase.auth.getUser();

if (userError || !user) {
return NextResponse.json(
{ error: "Unauthorized" },
{ status: 401 }
);
}

// ✅ STEP 2: Check if doctor
const { data: doctor } = await supabase
.from("doctors")
.select("id")
.eq("id", user.id)
.single();

const isDoctor = !!doctor;

// ❌ Only doctor can mark "done"
if (action === "done" && !isDoctor) {
return NextResponse.json(
{ error: "Only doctor can mark appointment as done" },
{ status: 403 }
);
}

// ✅ STEP 3: Get appointment
const { data: appt } = await supabase
.from("appointments")
.select("*")
.eq("id", appointmentId)
.single();

if (!appt) {
return NextResponse.json(
{ error: "Appointment not found" },
{ status: 404 }
);
}

// ❌ Patient can only cancel their own appointment
if (!isDoctor && appt.patient_id !== user.id) {
return NextResponse.json(
{ error: "Not allowed" },
{ status: 403 }
);
}

// ✅ STEP 4: Update status
const newStatus = action === "done" ? "done" : "cancelled";

Comment on lines +74 to +75
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Validate action against an explicit allowlist before mutation.

Line 74 maps any value other than "done" to "cancelled", so invalid input can cancel appointments unintentionally.

Proposed fix
-    const newStatus = action === "done" ? "done" : "cancelled";
+    if (action !== "done" && action !== "cancel") {
+      return NextResponse.json({ error: "Invalid action" }, { status: 400 });
+    }
+    const newStatus = action === "done" ? "done" : "cancelled";
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/api/appointments/book/route.ts` around lines 74 - 75, The current
assignment to newStatus (const newStatus = action === "done" ? "done" :
"cancelled") treats any unknown action as "cancelled"; validate action against
an explicit allowlist (e.g., allowed = {"done","cancelled"} or include any other
valid tokens) before mapping it to newStatus, and reject/return a 4xx error for
invalid input instead of silently treating it as "cancelled"; update the handler
that reads action and the newStatus assignment to first check
allowed.has(action) and only then set newStatus = action (or map known aliases),
otherwise respond with a clear validation error.

const { error: updateError } = await supabase
.from("appointments")
.update({ status: newStatus })
.eq("id", appointmentId);

if (updateError) {
return NextResponse.json(
{ error: updateError.message },
{ status: 500 }
);
}

// ✅ STEP 5: Free slot if cancelled
if (newStatus === "cancelled") {
await supabase
.from("slots")
.update({ is_booked: false })
.eq("id", appt.slot_id);
}

return NextResponse.json({ success: true });

} catch (err: any) {
return NextResponse.json(
{ error: err.message },
{ status: 500 }
);
}
}
Comment on lines +9 to +104
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

The implementation of this route is fundamentally incorrect. It contains logic for updating appointment status (marking as 'done' or 'cancelled') rather than booking a new appointment. Additionally, it expects appointmentId and action in the request body, which contradicts the client-side implementation in app/patient/dashboard/page.tsx that sends slot_id and patient_id. This mismatch will cause the booking process to fail entirely.

Loading