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
27 changes: 26 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,13 +1,38 @@
# Environment variables
.env
.env.local
.env*.local
.env.test

# Dependencies
node_modules/

# Next.js
.next/
out/
dist/

# Testing
coverage/
.nyc_output/

# Vercel & Deployment
.vercel/

# Build tools
.turbo/

# OS
.DS_Store
*.pem
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db

# Logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
*.pem
*.log
Comment on lines +37 to +38
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Confirm *.pem ignore scope (may hide required non-secret PEMs).

Ignoring all *.pem is usually intended to avoid committing secrets, but if the repo ever needs to include public certs (or other non-sensitive .pem files) you may want explicit allow-exceptions (e.g., !public-*.pem) or a narrower pattern.

If you tell me which PEM files (if any) must be committed, I can suggest precise ! exception patterns.

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

In @.gitignore around lines 37 - 38, The current .gitignore entry ignores all
PEM files via the pattern "*.pem", which may accidentally hide non-secret PEMs;
confirm which PEMs (if any) must be committed and narrow the scope by replacing
the broad "*.pem" with specific patterns or add allow-exceptions (for example
add explicit include patterns like "!public-*.pem" or whitelist named files) so
only secrets remain ignored; update the ".gitignore" entry for "*.pem" and add
explicit negation patterns for any required committed PEM filenames or tighter
patterns to avoid hiding necessary public certs.

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 | 🟡 Minor

Fix potential trailing-whitespace bug in the *.log ignore pattern.

The *.log entry in the provided snippet appears to include trailing spaces (*.log ). If those spaces are present in the actual file, the ignore rule may not match as intended, causing log files to be tracked/committed.

Suggested change
-*.log   
+*.log
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

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

In @.gitignore at line 38, The .gitignore entry for the log pattern contains
trailing whitespace ("*.log  ") which prevents the rule from matching; open the
.gitignore and remove any trailing spaces so the pattern is exactly "*.log"
(ensure the "*.log" token is the only text on that line and save with no
trailing whitespace or invisible characters).

165 changes: 165 additions & 0 deletions app/admin/dashboard/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
"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;
patients: { name: string; email: string };
doctors: { name: string; specialty: string };
slots: { start_time: string; end_time: string };
};

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

function getStatusColor(status: string) {
switch (status) {
case "active":
return "bg-blue-100 text-blue-700";
case "done":
return "bg-green-100 text-green-700";
case "cancelled":
return "bg-red-100 text-red-700";
default:
return "bg-gray-100 text-gray-700";
}
}

export default function AdminDashboard() {
const router = useRouter();
const [appointments, setAppointments] = useState<Appointment[]>([]);
const [loading, setLoading] = useState(true);
const [adminEmail, setAdminEmail] = useState("");

useEffect(() => {
async function load() {
try {
// Verify session via API (server-side verified)
const sessionRes = await fetch("/api/admin/verify-session");
if (!sessionRes.ok) {
router.push("/admin/login");
return;
}

const sessionData = await sessionRes.json();
setAdminEmail(sessionData.email);

// Fetch all appointments
const { data: apptData, error } = await supabase
.from("appointments")
.select(
"id, status, created_at, patients(name, email), doctors(name, specialty), slots(start_time, end_time)"
)
.order("created_at", { ascending: false });

if (!error) {
setAppointments((apptData as Appointment[]) ?? []);
}
} catch (err) {
router.push("/admin/login");
} finally {
setLoading(false);
}
}

load();
}, [router]);

async function handleLogout() {
await fetch("/api/admin/logout", { method: "POST" });
router.push("/admin/login");
}

if (loading) {
return (
<main className="flex min-h-screen items-center justify-center">
<p className="text-gray-500">Loading...</p>
</main>
);
}

return (
<main className="min-h-screen bg-gray-50 p-6">
<div className="mx-auto max-w-6xl">
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">Admin Dashboard</h1>
<p className="text-sm text-gray-500">Logged in as: {adminEmail}</p>
</div>
<button
onClick={handleLogout}
className="rounded-lg border px-4 py-2 text-sm text-gray-600 hover:bg-gray-100"
>
Logout
</button>
</div>

<section>
<h2 className="mb-3 text-lg font-semibold">All Appointments</h2>
{appointments.length === 0 ? (
<p className="text-sm text-gray-500">No appointments found.</p>
) : (
<div className="overflow-hidden rounded-xl border bg-white">
<table className="w-full text-sm">
<thead className="bg-gray-50 text-left text-gray-600">
<tr>
<th className="px-4 py-3 font-medium">Patient</th>
<th className="px-4 py-3 font-medium">Doctor</th>
<th className="px-4 py-3 font-medium">Specialty</th>
<th className="px-4 py-3 font-medium">Date & Time</th>
<th className="px-4 py-3 font-medium">Status</th>
<th className="px-4 py-3 font-medium">Booked On</th>
</tr>
</thead>
<tbody className="divide-y">
{appointments.map((appt) => (
<tr key={appt.id}>
<td className="px-4 py-3">
<div className="font-medium">{appt.patients?.name}</div>
<div className="text-xs text-gray-500">
{appt.patients?.email}
</div>
</td>
<td className="px-4 py-3">{appt.doctors?.name}</td>
<td className="px-4 py-3 text-gray-500">
{appt.doctors?.specialty}
</td>
<td className="px-4 py-3">
{formatDateTime(appt.slots?.start_time)} —{" "}
{new Date(appt.slots?.end_time).toLocaleTimeString(
"en-IN",
{ timeStyle: "short" }
)}
</td>
<td className="px-4 py-3">
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium ${getStatusColor(
appt.status
)}`}
>
{appt.status.charAt(0).toUpperCase() +
appt.status.slice(1)}
</span>
</td>
<td className="px-4 py-3 text-sm text-gray-500">
{formatDateTime(appt.created_at)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</div>
</main>
);
}
89 changes: 89 additions & 0 deletions app/admin/login/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
"use client";

import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";

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);

try {
const res = await fetch("/api/admin/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});

if (!res.ok) {
const data = await res.json();
setError(data.error || "Login failed");
setLoading(false);
return;
}

const data = await res.json();
// Session cookie is set server-side, just redirect
router.push("/admin/dashboard");
} catch (err) {
setError("An error occurred. Please try again.");
setLoading(false);
}
}

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">
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">
Email
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
placeholder="admin@test.com"
className="w-full rounded-lg border px-3 py-2 focus:outline-none focus:ring-2 focus:ring-purple-500"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">
Password
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="w-full rounded-lg border px-3 py-2 focus:outline-none focus:ring-2 focus:ring-purple-500"
Comment on lines +48 to +69
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

Associate each label with its input.

These labels are not programmatically connected to the corresponding fields, so assistive tech users lose the field names and clicking the label will not focus the input.

♿ Proposed fix
-            <label className="mb-1 block text-sm font-medium text-gray-700">
+            <label
+              htmlFor="admin-email"
+              className="mb-1 block text-sm font-medium text-gray-700"
+            >
               Email
             </label>
             <input
+              id="admin-email"
               type="email"
               value={email}
               onChange={(e) => setEmail(e.target.value)}
@@
-            <label className="mb-1 block text-sm font-medium text-gray-700">
+            <label
+              htmlFor="admin-password"
+              className="mb-1 block text-sm font-medium text-gray-700"
+            >
               Password
             </label>
             <input
+              id="admin-password"
               type="password"
               value={password}
               onChange={(e) => setPassword(e.target.value)}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<label className="mb-1 block text-sm font-medium text-gray-700">
Email
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
placeholder="admin@test.com"
className="w-full rounded-lg border px-3 py-2 focus:outline-none focus:ring-2 focus:ring-purple-500"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">
Password
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="w-full rounded-lg border px-3 py-2 focus:outline-none focus:ring-2 focus:ring-purple-500"
<label
htmlFor="admin-email"
className="mb-1 block text-sm font-medium text-gray-700"
>
Email
</label>
<input
id="admin-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
placeholder="admin@test.com"
className="w-full rounded-lg border px-3 py-2 focus:outline-none focus:ring-2 focus:ring-purple-500"
/>
</div>
<div>
<label
htmlFor="admin-password"
className="mb-1 block text-sm font-medium text-gray-700"
>
Password
</label>
<input
id="admin-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="w-full rounded-lg border px-3 py-2 focus:outline-none focus:ring-2 focus:ring-purple-500"
🤖 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 48 - 69, Add explicit id attributes to
the email and password inputs and reference them from their corresponding label
elements using htmlFor so labels are programmatically associated; specifically,
give the email input an id (e.g., "email") and update its label to
htmlFor="email", and give the password input an id (e.g., "password") and update
its label to htmlFor="password" to ensure clicking the label focuses the input
and assistive tech reads the field names (targets: the input elements using
value={email} / onChange={e => setEmail(...)} and value={password} / onChange={e
=> setPassword(...)}).

/>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
<button
type="submit"
disabled={loading}
className="rounded-lg bg-purple-600 py-2 font-medium text-white hover:bg-purple-700 disabled:opacity-50"
>
{loading ? "Logging in..." : "Login"}
</button>
</form>
<p className="mt-4 text-center text-sm text-gray-500">
<Link href="/" className="text-purple-600 hover:underline">
← Back to home
</Link>
</p>
</div>
</main>
);
}
34 changes: 34 additions & 0 deletions app/api/admin/login/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from "next/server";
import { badRequestResponse, unauthorizedResponse } from "@/lib/auth-utils";
import { validateAdminLogin } from "@/lib/validators/admin.validator";
import { adminService } from "@/lib/services/admin.service";

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

const admin = await adminService.loginAdmin(email, password);

const res = NextResponse.json({ success: true, admin });
res.cookies.set("admin_session", admin.id, {
httpOnly: true,
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 8,
});
return res;
} catch (error: any) {
console.error("Error logging in admin:", error);

if (error.message.includes("required")) {
return badRequestResponse(error.message);
}

if (error.message.includes("Invalid")) {
return unauthorizedResponse();
}
Comment on lines +24 to +30
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The error handling logic relies on string matching against the error message (error.message.includes(...)). This is a brittle pattern that can easily break if the error messages from the service or validator layers are changed in the future.

A more robust approach is to use custom error classes (e.g., ValidationError, InvalidCredentialsError) or error codes. This allows you to catch specific error types and handle them reliably without depending on the text of the error message.

This pattern is repeated in other API routes as well, such as app/api/appointments/book/route.ts and app/api/appointments/cancel/route.ts.


return unauthorizedResponse();
}
}
7 changes: 7 additions & 0 deletions app/api/admin/logout/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { NextRequest, NextResponse } from "next/server";

export async function POST(_req: NextRequest) {
const res = NextResponse.json({ success: true });
res.cookies.delete("admin_session");
return res;
}
28 changes: 28 additions & 0 deletions app/api/admin/verify-session/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { NextRequest, NextResponse } from "next/server";
import { createAdminClient } from "@/lib/auth-utils";

export async function GET(req: NextRequest) {
try {
const adminSessionCookie = req.cookies.get("admin_session")?.value;

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

// Verify the session exists and get admin details
const supabase = createAdminClient();
const { data: admin } = await supabase
.from("system_admins")
.select("id, email")
.eq("id", adminSessionCookie)
.maybeSingle();

if (!admin) {
return NextResponse.json({ error: "Invalid session" }, { status: 401 });
}

return NextResponse.json({ id: admin.id, email: admin.email }, { status: 200 });
} catch (error) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
}
Loading