Skip to content
Merged
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
10 changes: 10 additions & 0 deletions .idea/.gitignore

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions .idea/CIS-GPACAL.iml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions .idea/modules.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions .idea/vcs.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

185 changes: 185 additions & 0 deletions app/admin/login/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
"use client";

import { useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { useToast } from "@/components/ui/use-toast";
import { ShieldCheck, Loader2, Eye, EyeOff } from "lucide-react";

export default function AdminLoginPage() {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
const { toast } = useToast();

const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);

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

const data = await res.json();

if (!res.ok) {
toast({
variant: "destructive",
title: "Login Failed",
description: data.error || "Invalid credentials",
});
return;
}

toast({
title: "Welcome back!",
description: "Redirecting to dashboard…",
});

router.push(data.redirect);
} catch {
toast({
variant: "destructive",
title: "Error",
description: "Something went wrong. Please try again.",
});
} finally {
setIsLoading(false);
}
};

return (
<div className="min-h-screen flex flex-col bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900">
{/* ── Branding Header ─────────────────────────────────────────────── */}
<header className="w-full py-6 px-4 text-center">
<div className="inline-flex items-center gap-2">
<div className="h-8 w-8 rounded-lg bg-gradient-to-br from-blue-500 to-indigo-600 flex items-center justify-center">
<span className="text-white font-bold text-sm">C</span>
</div>
<div>
<h1 className="text-lg sm:text-xl font-semibold text-white tracking-tight">
Department of Computing
</h1>
<p className="text-xs sm:text-sm text-slate-400 -mt-0.5">
GPA Portal
</p>
</div>
</div>
</header>

{/* ── Login Card ──────────────────────────────────────────────────── */}
<main className="flex-1 flex items-center justify-center px-4 pb-12">
<Card className="w-full max-w-md border-slate-700/50 bg-slate-800/60 backdrop-blur-xl shadow-2xl shadow-blue-500/5">
<CardHeader className="text-center pb-2">
<div className="mx-auto mb-4 h-14 w-14 rounded-2xl bg-gradient-to-br from-blue-500 to-indigo-600 flex items-center justify-center shadow-lg shadow-blue-500/25">
<ShieldCheck className="h-7 w-7 text-white" />
</div>
<CardTitle className="text-2xl font-bold text-white">
Admin Login
</CardTitle>
<CardDescription className="text-slate-400">
Sign in to manage the GPA portal
</CardDescription>
</CardHeader>

<CardContent>
<form onSubmit={handleSubmit} className="space-y-5">
{/* Username */}
<div className="space-y-2">
<Label
htmlFor="admin-username"
className="text-slate-300 text-sm"
>
Username
</Label>
<Input
id="admin-username"
type="text"
placeholder="Enter your username"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
autoComplete="username"
disabled={isLoading}
className="h-11 bg-slate-700/50 border-slate-600/50 text-white placeholder:text-slate-500 focus-visible:ring-blue-500/50 focus-visible:border-blue-500/50 transition-colors"
/>
</div>

{/* Password */}
<div className="space-y-2">
<Label
htmlFor="admin-password"
className="text-slate-300 text-sm"
>
Password
</Label>
<div className="relative">
<Input
id="admin-password"
type={showPassword ? "text" : "password"}
placeholder="Enter your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="current-password"
disabled={isLoading}
className="h-11 pr-10 bg-slate-700/50 border-slate-600/50 text-white placeholder:text-slate-500 focus-visible:ring-blue-500/50 focus-visible:border-blue-500/50 transition-colors"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-200 transition-colors"
tabIndex={-1}
aria-label={showPassword ? "Hide password" : "Show password"}
>
Comment on lines +142 to +148
{showPassword ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
</div>
</div>

{/* Submit */}
<Button
id="admin-login-submit"
type="submit"
disabled={isLoading}
className="w-full h-11 bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 text-white font-medium shadow-lg shadow-blue-500/20 transition-all duration-200 hover:shadow-blue-500/30"
>
{isLoading ? (
<span className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
Signing in…
</span>
) : (
"Sign In"
)}
</Button>
</form>
</CardContent>
</Card>
</main>

{/* ── Footer ──────────────────────────────────────────────────────── */}
<footer className="py-4 text-center text-xs text-slate-500">
Department of Computing &amp; Information Systems
</footer>
</div>
);
}
72 changes: 72 additions & 0 deletions app/api/auth/admin/login/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { NextRequest, NextResponse } from "next/server";
import { eq } from "drizzle-orm";
import { db } from "@/lib/db";
import { admins } from "@/lib/schema";
import { comparePassword, signToken } from "@/lib/auth";

export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { username, password } = body;
Comment on lines +9 to +10

// ── Validate input ──────────────────────────────────────────────────
if (!username || !password) {
return NextResponse.json(
{ error: "Username and password are required" },
{ status: 400 }
);
}

// ── Find admin ──────────────────────────────────────────────────────
const [admin] = await db
.select()
.from(admins)
.where(eq(admins.username, username))
.limit(1);

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

// ── Verify password ─────────────────────────────────────────────────
const isValid = await comparePassword(password, admin.passwordHash);
if (!isValid) {
return NextResponse.json(
{ error: "Invalid credentials" },
{ status: 401 }
);
}

// ── Sign JWT ────────────────────────────────────────────────────────
const token = signToken({
id: admin.id,
role: "admin",
identifier: admin.username,
});

// ── Set httpOnly cookie and respond ─────────────────────────────────
const response = NextResponse.json({
success: true,
redirect: "/admin/dashboard",
});

response.cookies.set("auth-token", token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 24, // 24 hours
});

return response;
} catch (error) {
console.error("Admin login error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
15 changes: 15 additions & 0 deletions app/api/auth/logout/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { NextResponse } from "next/server";

export async function POST() {
const response = NextResponse.json({ success: true });

response.cookies.set("auth-token", "", {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 0, // Expire immediately
});

return response;
}
Loading