Skip to content

Commit 4c0b721

Browse files
committed
feat: Migrate admin authentication from password-based to NextAuth.js with GitHub username authorization, adding new types, UI, and tests
1 parent 8a314d2 commit 4c0b721

10 files changed

Lines changed: 273 additions & 87 deletions

File tree

.env.example

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ KV_REST_API_URL="your_kv_rest_api_url"
1414
KV_URL="your_kv_url"
1515
REDIS_URL="your_redis_url"
1616

17-
# admin password
18-
ADMIN_PASSWORD="your_admin_password"
17+
# Admin access
18+
ADMIN_GITHUB_USERNAME="your_admin_github_username"
1919

2020
# NextAuth
2121
AUTH_GITHUB_ID="your_github_client_id"

src/app/actions.ts

Lines changed: 1 addition & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
* No orchestration, no raw GitHub shapes, no inline context building.
1111
*/
1212

13-
import { headers, cookies } from "next/headers";
13+
import { headers } from "next/headers";
1414
import { auth } from "@/lib/auth";
1515
import { kv } from "@vercel/kv";
1616
import {
@@ -177,32 +177,6 @@ export async function trackReportConversion(event: ReportConversionEvent, scanId
177177
await trackReportConversionEvent(event, scanId);
178178
}
179179

180-
/**
181-
* Verify admin password and set a session cookie (10 min)
182-
*/
183-
export async function verifyAdminPassword(password: string) {
184-
const adminPassword = process.env.ADMIN_PASSWORD;
185-
186-
if (!adminPassword) {
187-
console.error("ADMIN_PASSWORD environment variable is not set");
188-
return { success: false, error: "Authentication system is misconfigured. Please contact administrator." };
189-
}
190-
191-
if (password === adminPassword) {
192-
const cookieStore = await cookies();
193-
cookieStore.set("admin_session", "authenticated", {
194-
maxAge: 10 * 60, // 10 minutes
195-
httpOnly: true,
196-
secure: process.env.NODE_ENV === "production",
197-
sameSite: "strict",
198-
path: "/"
199-
});
200-
return { success: true };
201-
}
202-
203-
return { success: false, error: "Invalid password" };
204-
}
205-
206180
/**
207181
* Fetch file content and assemble a context string.
208182
* @deprecated Prefer generateAnswer(query, ..., filePaths) which uses the
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
"use client";
2+
3+
import { useState } from "react";
4+
import { ShieldAlert, Loader2, ArrowLeftRight } from "lucide-react";
5+
import { useRouter } from "next/navigation";
6+
import { signOut } from "next-auth/react";
7+
import { motion } from "framer-motion";
8+
9+
export default function AdminAccessDeniedPage() {
10+
const [isSwitching, setIsSwitching] = useState(false);
11+
const router = useRouter();
12+
13+
const handleSwitchAccount = async () => {
14+
setIsSwitching(true);
15+
await signOut({ callbackUrl: "/admin/stats" });
16+
};
17+
18+
return (
19+
<div className="min-h-screen bg-black flex items-center justify-center p-4">
20+
<motion.div
21+
initial={{ opacity: 0, y: 20 }}
22+
animate={{ opacity: 1, y: 0 }}
23+
className="w-full max-w-md space-y-8 bg-zinc-900/50 border border-white/10 p-8 rounded-2xl backdrop-blur-sm"
24+
>
25+
<div className="text-center space-y-2">
26+
<div className="inline-flex p-3 bg-amber-500/10 rounded-xl mb-4">
27+
<ShieldAlert className="w-8 h-8 text-amber-400" />
28+
</div>
29+
<h1 className="text-2xl font-bold text-white">Access Denied</h1>
30+
<p className="text-zinc-400 text-sm">
31+
This GitHub account is not authorized to view admin analytics.
32+
</p>
33+
</div>
34+
35+
<div className="space-y-3">
36+
<button
37+
type="button"
38+
onClick={handleSwitchAccount}
39+
disabled={isSwitching}
40+
className="w-full bg-white text-black font-semibold py-3 rounded-lg hover:bg-zinc-200 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2 group"
41+
>
42+
{isSwitching ? (
43+
<Loader2 className="w-5 h-5 animate-spin" />
44+
) : (
45+
<>
46+
Switch GitHub Account
47+
<ArrowLeftRight className="w-5 h-5 group-hover:scale-110 transition-transform" />
48+
</>
49+
)}
50+
</button>
51+
52+
<button
53+
type="button"
54+
onClick={() => router.push("/")}
55+
className="w-full bg-zinc-800 text-zinc-200 font-medium py-3 rounded-lg hover:bg-zinc-700 transition-colors"
56+
>
57+
Back to Home
58+
</button>
59+
</div>
60+
</motion.div>
61+
</div>
62+
);
63+
}

src/app/admin/stats/AdminLoginPage.tsx

Lines changed: 9 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -2,30 +2,18 @@
22

33
import { useState } from "react";
44
import { Lock, Loader2, ArrowRight } from "lucide-react";
5-
import { verifyAdminPassword } from "@/app/actions";
65
import { useRouter } from "next/navigation";
76
import { motion } from "framer-motion";
7+
import { signIn } from "next-auth/react";
88

99
export default function AdminLoginPage() {
10-
const [password, setPassword] = useState("");
1110
const [loading, setLoading] = useState(false);
12-
const [error, setError] = useState("");
1311
const router = useRouter();
1412

15-
const handleSubmit = async (e: React.FormEvent) => {
16-
e.preventDefault();
13+
const handleSignIn = async () => {
1714
setLoading(true);
18-
setError("");
19-
2015
try {
21-
const result = await verifyAdminPassword(password);
22-
if (result.success) {
23-
router.refresh(); // Refresh to show the dashboard
24-
} else {
25-
setError(result.error || "Invalid password");
26-
}
27-
} catch (err) {
28-
setError("Something went wrong. Please try again.");
16+
await signIn("github", { callbackUrl: "/admin/stats" });
2917
} finally {
3018
setLoading(false);
3119
}
@@ -43,29 +31,14 @@ export default function AdminLoginPage() {
4331
<Lock className="w-8 h-8 text-purple-400" />
4432
</div>
4533
<h1 className="text-2xl font-bold text-white">Admin Access</h1>
46-
<p className="text-zinc-400 text-sm">Please enter the administrator password to continue.</p>
34+
<p className="text-zinc-400 text-sm">Sign in with your authorized GitHub account to continue.</p>
4735
</div>
4836

49-
<form onSubmit={handleSubmit} className="space-y-4">
50-
<div className="space-y-2">
51-
<div className="relative group">
52-
<input
53-
type="password"
54-
value={password}
55-
onChange={(e) => setPassword(e.target.value)}
56-
placeholder="Enter password"
57-
className="w-full bg-black border border-white/10 rounded-lg px-4 py-3 outline-none focus:border-purple-500/50 transition-colors text-white placeholder-zinc-600"
58-
autoFocus
59-
/>
60-
</div>
61-
{error && (
62-
<p className="text-red-400 text-sm pl-1">{error}</p>
63-
)}
64-
</div>
65-
37+
<div className="space-y-4">
6638
<button
67-
type="submit"
68-
disabled={loading || !password}
39+
type="button"
40+
onClick={handleSignIn}
41+
disabled={loading}
6942
className="w-full bg-white text-black font-semibold py-3 rounded-lg hover:bg-zinc-200 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2 group"
7043
>
7144
{loading ? (
@@ -77,7 +50,7 @@ export default function AdminLoginPage() {
7750
</>
7851
)}
7952
</button>
80-
</form>
53+
</div>
8154

8255
<div className="pt-4 text-center">
8356
<button

src/app/admin/stats/page.test.tsx

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import type { ReactElement } from "react";
2+
import { beforeEach, describe, expect, it, vi } from "vitest";
3+
import AdminAccessDeniedPage from "@/app/admin/stats/AdminAccessDeniedPage";
4+
import AdminLoginPage from "@/app/admin/stats/AdminLoginPage";
5+
import StatsDashboardClient from "@/app/admin/stats/StatsDashboardClient";
6+
7+
const {
8+
authMock,
9+
isAdminUserMock,
10+
getAnalyticsDataMock,
11+
headersMock,
12+
} = vi.hoisted(() => ({
13+
authMock: vi.fn(),
14+
isAdminUserMock: vi.fn(),
15+
getAnalyticsDataMock: vi.fn(),
16+
headersMock: vi.fn(),
17+
}));
18+
19+
vi.mock("@/lib/auth", () => ({
20+
auth: authMock,
21+
}));
22+
23+
vi.mock("@/lib/admin-auth", () => ({
24+
isAdminUser: isAdminUserMock,
25+
}));
26+
27+
vi.mock("@/lib/analytics", () => ({
28+
getAnalyticsData: getAnalyticsDataMock,
29+
}));
30+
31+
vi.mock("next/headers", () => ({
32+
headers: headersMock,
33+
}));
34+
35+
import AdminStatsPage from "@/app/admin/stats/page";
36+
37+
describe("AdminStatsPage", () => {
38+
beforeEach(() => {
39+
authMock.mockReset();
40+
isAdminUserMock.mockReset();
41+
getAnalyticsDataMock.mockReset();
42+
headersMock.mockReset();
43+
headersMock.mockResolvedValue({
44+
get: () => null,
45+
});
46+
});
47+
48+
it("renders the GitHub login view when unauthenticated", async () => {
49+
authMock.mockResolvedValue(null);
50+
51+
const view = await AdminStatsPage() as ReactElement;
52+
53+
expect(view.type).toBe(AdminLoginPage);
54+
expect(getAnalyticsDataMock).not.toHaveBeenCalled();
55+
});
56+
57+
it("renders access denied for authenticated non-admin users", async () => {
58+
const session = { user: { id: "123", username: "someone-else" } };
59+
authMock.mockResolvedValue(session);
60+
isAdminUserMock.mockReturnValue(false);
61+
62+
const view = await AdminStatsPage() as ReactElement;
63+
64+
expect(isAdminUserMock).toHaveBeenCalledWith(session);
65+
expect(view.type).toBe(AdminAccessDeniedPage);
66+
expect(getAnalyticsDataMock).not.toHaveBeenCalled();
67+
});
68+
69+
it("renders stats dashboard for authorized admin users", async () => {
70+
const session = { user: { id: "123", username: "403errors" } };
71+
const data = {
72+
totalVisitors: 10,
73+
totalQueries: 20,
74+
recentVisitors: [],
75+
countryStats: {},
76+
deviceStats: {},
77+
kvStats: { currentSize: 10, maxSize: 100 },
78+
};
79+
80+
authMock.mockResolvedValue(session);
81+
isAdminUserMock.mockReturnValue(true);
82+
getAnalyticsDataMock.mockResolvedValue(data);
83+
headersMock.mockResolvedValue({
84+
get: (key: string) => {
85+
if (key === "user-agent") return "Mozilla/5.0 (iPhone)";
86+
if (key === "x-vercel-ip-country") return "IN";
87+
return null;
88+
},
89+
});
90+
91+
const view = await AdminStatsPage() as ReactElement<{
92+
data: unknown;
93+
country: string;
94+
isMobile: boolean;
95+
}>;
96+
97+
expect(view.type).toBe(StatsDashboardClient);
98+
expect(getAnalyticsDataMock).toHaveBeenCalledOnce();
99+
expect(view.props.data).toEqual(data);
100+
expect(view.props.country).toBe("IN");
101+
expect(view.props.isMobile).toBe(true);
102+
});
103+
});

src/app/admin/stats/page.tsx

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,22 @@
11
import { getAnalyticsData } from "@/lib/analytics";
2-
import { headers, cookies } from "next/headers";
2+
import { auth } from "@/lib/auth";
3+
import { isAdminUser } from "@/lib/admin-auth";
4+
import { headers } from "next/headers";
35
import AdminLoginPage from "./AdminLoginPage";
6+
import AdminAccessDeniedPage from "./AdminAccessDeniedPage";
47
import StatsDashboardClient from "./StatsDashboardClient";
58

69
export const dynamic = 'force-dynamic'; // Ensure real-time data
710

811
export default async function AdminStatsPage() {
9-
const cookieStore = await cookies();
10-
const isAdmin = cookieStore.get("admin_session")?.value === "authenticated";
12+
const session = await auth();
1113

12-
if (!isAdmin) {
14+
if (!session?.user) {
1315
return <AdminLoginPage />;
1416
}
17+
if (!isAdminUser(session)) {
18+
return <AdminAccessDeniedPage />;
19+
}
1520

1621
const data = await getAnalyticsData();
1722

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { afterEach, describe, expect, it } from "vitest";
2+
import type { Session } from "next-auth";
3+
import { isAdminUser } from "@/lib/admin-auth";
4+
5+
const ORIGINAL_ADMIN_USERNAME = process.env.ADMIN_GITHUB_USERNAME;
6+
7+
function withUsername(username?: string): Session {
8+
return {
9+
expires: "2099-01-01T00:00:00.000Z",
10+
user: {
11+
name: "Test User",
12+
email: "test@example.com",
13+
image: null,
14+
username,
15+
},
16+
};
17+
}
18+
19+
afterEach(() => {
20+
if (ORIGINAL_ADMIN_USERNAME === undefined) {
21+
delete process.env.ADMIN_GITHUB_USERNAME;
22+
} else {
23+
process.env.ADMIN_GITHUB_USERNAME = ORIGINAL_ADMIN_USERNAME;
24+
}
25+
});
26+
27+
describe("isAdminUser", () => {
28+
it("returns true for the configured admin username", () => {
29+
process.env.ADMIN_GITHUB_USERNAME = "403errors";
30+
expect(isAdminUser(withUsername("403errors"))).toBe(true);
31+
});
32+
33+
it("returns false for unauthenticated sessions", () => {
34+
process.env.ADMIN_GITHUB_USERNAME = "403errors";
35+
expect(isAdminUser(null)).toBe(false);
36+
});
37+
38+
it("returns false for authenticated non-admin users", () => {
39+
process.env.ADMIN_GITHUB_USERNAME = "403errors";
40+
expect(isAdminUser(withUsername("someone-else"))).toBe(false);
41+
});
42+
43+
it("returns false when ADMIN_GITHUB_USERNAME is missing", () => {
44+
delete process.env.ADMIN_GITHUB_USERNAME;
45+
expect(isAdminUser(withUsername("403errors"))).toBe(false);
46+
});
47+
});

src/lib/admin-auth.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import type { Session } from "next-auth";
2+
3+
export function isAdminUser(session: Session | null | undefined): boolean {
4+
const configuredAdmin = process.env.ADMIN_GITHUB_USERNAME;
5+
if (!configuredAdmin) return false;
6+
7+
return session?.user?.username === configuredAdmin;
8+
}

0 commit comments

Comments
 (0)