-
Notifications
You must be signed in to change notification settings - Fork 12
Full Stack - Completed appointment booking system #10
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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; | ||
| } | ||
|
|
||
| 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> | ||
| ); | ||
| } | ||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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 |
||
|
|
||
| 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> | ||
| ); | ||
| } | ||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This route requires Also applies to: 73-96 🤖 Prompt for AI Agents |
||
|
|
||
| // ✅ 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Validate Line 74 maps any value other than 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 |
||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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 |
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.