Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
26 changes: 25 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,13 +1,37 @@
# 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
161 changes: 161 additions & 0 deletions app/admin/dashboard/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
"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() {
// Check if admin is logged in
const email = localStorage.getItem("adminEmail");
if (!email) {
router.push("/admin/login");
return;
}
Comment thread
OswaldShilo marked this conversation as resolved.
Outdated

setAdminEmail(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[]) ?? []);
}
setLoading(false);
}

load();
}, [router]);

async function handleLogout() {
await fetch("/api/admin/logout", { method: "POST" });
localStorage.removeItem("adminEmail");
localStorage.removeItem("adminId");
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>
);
}
91 changes: 91 additions & 0 deletions app/admin/login/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
"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();
localStorage.setItem("adminEmail", data.admin.email);
localStorage.setItem("adminId", data.admin.id);

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;
}
50 changes: 48 additions & 2 deletions app/api/appointments/book/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,51 @@
import { NextRequest, NextResponse } from "next/server";
import {
extractBearerToken,
verifyUserAuth,
unauthorizedResponse,
badRequestResponse,
errorResponse,
} from "@/lib/auth-utils";
import { validateBookAppointment } from "@/lib/validators/appointment.validator";
import { appointmentService } from "@/lib/services/appointment.service";

export async function POST(_req: NextRequest) {
return NextResponse.json({ error: "Not implemented" }, { status: 501 });
export async function POST(req: NextRequest) {
try {
// Validate input
const body = await req.json();
const { slotId, doctorId } = validateBookAppointment(body);

// Verify authentication
const token = extractBearerToken(req);
if (!token) return unauthorizedResponse();

const { user, error: authError } = await verifyUserAuth(token);
if (authError || !user) return unauthorizedResponse();

// Book appointment
const result = await appointmentService.bookAppointment(user.id, slotId, doctorId);

return NextResponse.json(result, { status: 201 });
} catch (error: any) {
console.error("Error booking appointment:", error);

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

if (error.message.includes("Unauthorized")) {
return unauthorizedResponse();
}

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

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

return errorResponse(error.message);
}
}

Loading