Contents
@@ -199,6 +235,14 @@ const StoragePage = (props) => {
export const getServerSideProps = wrapper.getServerSideProps(
(store) => async (ctx) => {
const { query, req, res } = ctx;
+ const adminSession = readAdminSessionCookie(req);
+
+ if (!validateAdminSession(adminSession)) {
+ return {
+ redirect: { destination: "/admin/login", permanent: false }
+ };
+ }
+
// IP restriction check
const ALLOWED_IPS = process.env.ALLOWED_IPS
? process.env.ALLOWED_IPS.split(",").map((ip) => ip.trim())
diff --git a/pages/admin/login.js b/pages/admin/login.js
new file mode 100644
index 00000000..9a9029ff
--- /dev/null
+++ b/pages/admin/login.js
@@ -0,0 +1,155 @@
+import Head from "next/head";
+import { useRouter } from "next/router";
+import { useState } from "react";
+import CookieBanner from "../../components/cookieBanner";
+import Footer from "../../components/footer";
+import Header from "../../components/header";
+import {
+ readAdminSessionCookie,
+ validateAdminSession
+} from "../../lib/admin/adminAuth";
+
+const AdminLoginPage = () => {
+ const router = useRouter();
+ const [passphrase, setPassphrase] = useState("");
+ const [error, setError] = useState("");
+ const [submitting, setSubmitting] = useState(false);
+
+ const handleSubmit = async (event) => {
+ event.preventDefault();
+ setError("");
+ setSubmitting(true);
+
+ try {
+ const response = await fetch("/api/admin/auth/login", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ credentials: "same-origin",
+ body: JSON.stringify({ passphrase })
+ });
+
+ if (!response.ok) {
+ setError("Sign in failed. Check the details and try again.");
+ return;
+ }
+
+ await router.replace("/admin");
+ } catch (_error) {
+ setError("Sign in failed. Check the details and try again.");
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ return (
+
+
+
Administrative sign in
+
+
+
+
+
+
+
+
+ Administrative sign in
+
+
+ {error && (
+
+
+ There is a problem
+
+
+
+ )}
+
+
+
+
+
+
+
+
+ );
+};
+
+export const getServerSideProps = async ({ req }) => {
+ const adminSession = readAdminSessionCookie(req);
+
+ if (validateAdminSession(adminSession)) {
+ return {
+ redirect: { destination: "/admin", permanent: false }
+ };
+ }
+
+ return { props: {} };
+};
+
+export default AdminLoginPage;
\ No newline at end of file
diff --git a/tests/phase21/admin-login-integration.test.cjs b/tests/phase21/admin-login-integration.test.cjs
new file mode 100644
index 00000000..de1cf8ee
--- /dev/null
+++ b/tests/phase21/admin-login-integration.test.cjs
@@ -0,0 +1,284 @@
+const assert = require("assert");
+const fs = require("fs");
+const path = require("path");
+const vm = require("vm");
+
+const rootDir = path.resolve(__dirname, "..", "..");
+const tests = [];
+const test = (name, fn) => tests.push({ name, fn });
+
+const readSource = (relativePath) =>
+ fs.readFileSync(path.join(rootDir, relativePath), "utf8");
+
+const loadAdminPageLoader = (overrides = {}) => {
+ const filePath = path.join(rootDir, "pages", "admin", "index.js");
+ let source = fs.readFileSync(filePath, "utf8");
+
+ source = source.replace(/import[\s\S]*?from\s+"[^"]+";\n?/g, "");
+ source = source.slice(source.indexOf("export const getServerSideProps"));
+ source = source.replace(
+ /export\s+const\s+getServerSideProps\s*=\s*/,
+ "const getServerSideProps = "
+ );
+ source = source.replace(/const mapDispatchToProps[\s\S]*$/, "");
+ source += "\nmodule.exports = { getServerSideProps };\n";
+
+ const context = {
+ module: { exports: {} },
+ exports: {},
+ wrapper: {
+ getServerSideProps: (factory) => async (ctx) => {
+ const store = {
+ dispatch: () => {},
+ getState: () => ({})
+ };
+ return factory(store)(ctx);
+ }
+ },
+ readAdminSessionCookie: () => "valid-token",
+ validateAdminSession: () => true,
+ fetchAdminStorageData: async () => ({
+ data: [],
+ userData: [],
+ repCompleteCount: 0
+ }),
+ getNewAppeals: async () => ({ value: [] }),
+ getStatusByAppeal: async () => [],
+ getStatusByAppealLPA: async () => [],
+ getSearchDetails: async () => ({}),
+ setSearchResults: () => ({}),
+ setSearchDetails: () => ({}),
+ setCurrentPage: () => ({}),
+ process: {
+ env: {
+ ALLOWED_IPS: "203.0.113.10"
+ }
+ },
+ ...overrides
+ };
+
+ vm.runInNewContext(source, context, { filename: filePath });
+ return context.module.exports;
+};
+
+const loadAdminLoginLoader = (overrides = {}) => {
+ const filePath = path.join(rootDir, "pages", "admin", "login.js");
+ let source = fs.readFileSync(filePath, "utf8");
+
+ source = source.replace(/import[\s\S]*?from\s+"[^"]+";\n?/g, "");
+ source = source.slice(source.indexOf("export const getServerSideProps"));
+ source = source.replace(
+ /export\s+const\s+getServerSideProps\s*=\s*/,
+ "const getServerSideProps = "
+ );
+ source = source.replace(/export default[\s\S]*$/, "");
+ source += "\nmodule.exports = { getServerSideProps };\n";
+
+ const context = {
+ module: { exports: {} },
+ exports: {},
+ readAdminSessionCookie: () => null,
+ validateAdminSession: () => false,
+ ...overrides
+ };
+
+ vm.runInNewContext(source, context, { filename: filePath });
+ return context.module.exports;
+};
+
+const createAdminContext = (ip = "203.0.113.10") => ({
+ query: {},
+ req: {
+ headers: { "x-forwarded-for": ip },
+ socket: { remoteAddress: ip }
+ },
+ res: {}
+});
+
+test("unauthenticated admin access redirects before privileged data loads", async () => {
+ let dataLoads = 0;
+ const mod = loadAdminPageLoader({
+ readAdminSessionCookie: () => null,
+ validateAdminSession: () => false,
+ fetchAdminStorageData: async () => {
+ dataLoads += 1;
+ return { data: [], userData: [], repCompleteCount: 0 };
+ },
+ getNewAppeals: async () => {
+ dataLoads += 1;
+ return { value: [] };
+ }
+ });
+
+ const result = await mod.getServerSideProps(createAdminContext());
+
+ assert.strictEqual(result.redirect.destination, "/admin/login");
+ assert.strictEqual(result.redirect.permanent, false);
+ assert.strictEqual(dataLoads, 0);
+});
+
+test("invalid or expired administrative sessions cannot access admin", async () => {
+ for (const token of ["invalid-token", "expired-token"]) {
+ const mod = loadAdminPageLoader({
+ readAdminSessionCookie: () => token,
+ validateAdminSession: () => false
+ });
+ const result = await mod.getServerSideProps(createAdminContext());
+ assert.strictEqual(result.redirect.destination, "/admin/login");
+ }
+});
+
+test("valid administrative session and allowed IP load existing admin data", async () => {
+ const calls = [];
+ const mod = loadAdminPageLoader({
+ fetchAdminStorageData: async () => {
+ calls.push("storage");
+ return { data: ["storage"], userData: ["user"], repCompleteCount: 1 };
+ },
+ getNewAppeals: async () => {
+ calls.push("appeals");
+ return { value: [] };
+ },
+ getStatusByAppeal: async () => {
+ calls.push("status");
+ return ["status"];
+ },
+ getStatusByAppealLPA: async () => {
+ calls.push("lpa-status");
+ return ["lpa-status"];
+ },
+ getSearchDetails: async () => {
+ calls.push("search-details");
+ return {};
+ }
+ });
+
+ const result = await mod.getServerSideProps(createAdminContext());
+
+ assert.deepStrictEqual(calls, [
+ "storage",
+ "appeals",
+ "status",
+ "lpa-status",
+ "search-details"
+ ]);
+ assert.strictEqual(result.props.repCompleteCount, 1);
+});
+
+test("valid administrative session and disallowed IP retains 403 redirect", async () => {
+ let dataLoads = 0;
+ const mod = loadAdminPageLoader({
+ fetchAdminStorageData: async () => {
+ dataLoads += 1;
+ return { data: [], userData: [], repCompleteCount: 0 };
+ }
+ });
+
+ const result = await mod.getServerSideProps(
+ createAdminContext("198.51.100.25")
+ );
+
+ assert.strictEqual(result.redirect.destination, "/403");
+ assert.strictEqual(dataLoads, 0);
+});
+
+test("missing administrative configuration fails closed at the page boundary", async () => {
+ const mod = loadAdminPageLoader({ validateAdminSession: () => false });
+ const result = await mod.getServerSideProps(createAdminContext());
+ assert.strictEqual(result.redirect.destination, "/admin/login");
+});
+
+test("login page remains available for missing or invalid sessions", async () => {
+ const mod = loadAdminLoginLoader();
+ const result = await mod.getServerSideProps({ req: { headers: {} } });
+
+ assert.deepStrictEqual(JSON.parse(JSON.stringify(result)), { props: {} });
+});
+
+test("login page redirects an already authenticated administrator to admin", async () => {
+ const mod = loadAdminLoginLoader({
+ readAdminSessionCookie: () => "valid-token",
+ validateAdminSession: () => true
+ });
+ const result = await mod.getServerSideProps({ req: { headers: {} } });
+
+ assert.strictEqual(result.redirect.destination, "/admin");
+ assert.strictEqual(result.redirect.permanent, false);
+});
+
+test("login page uses the existing login API and generic error handling", () => {
+ const source = readSource("pages/admin/login.js");
+
+ assert.match(source, /fetch\("\/api\/admin\/auth\/login"/);
+ assert.match(source, /method:\s*"POST"/);
+ assert.match(source, /JSON\.stringify\(\{ passphrase \}\)/);
+ assert.match(source, /type="password"/);
+ assert.match(source, /htmlFor="admin-passphrase"/);
+ assert.match(source, /Sign in failed\. Check the details and try again\./);
+ assert.match(source, /router\.replace\("\/admin"\)/);
+ assert.doesNotMatch(source, /localStorage|sessionStorage/);
+});
+
+test("admin logout uses the existing endpoint and returns to login", () => {
+ const source = readSource("pages/admin/index.js");
+
+ assert.match(source, /fetch\("\/api\/admin\/auth\/logout"/);
+ assert.match(source, /method:\s*"POST"/);
+ assert.match(source, /router\.replace\("\/admin\/login"\)/);
+ assert.match(source, />\s*\{signingOut \? "Signing out\.\.\." : "Sign out"\}/);
+ assert.doesNotMatch(source, /document\.cookie/);
+});
+
+test("admin login integration adds no NextAuth, Prisma, or CRM authentication dependency", () => {
+ for (const relativePath of [
+ "pages/admin/login.js",
+ "lib/admin/adminAuth.js",
+ "pages/api/admin/auth/login.js",
+ "pages/api/admin/auth/logout.js",
+ "pages/api/admin/auth/session.js"
+ ]) {
+ const source = readSource(relativePath);
+ assert.doesNotMatch(source, /next-auth|@prisma|Prisma|getPortalLogin|CRM/i);
+ }
+});
+
+test("existing signing and document service files remain unchanged", () => {
+ const protectedPaths = [
+ "pages/api/endpoint/gethash_api.js",
+ "actions/services/documentDirectService.js"
+ ];
+ const changedPaths = require("child_process")
+ .execFileSync("git", ["diff", "--name-only", "HEAD"], {
+ cwd: rootDir,
+ encoding: "utf8"
+ })
+ .trim()
+ .split("\n")
+ .filter(Boolean);
+
+ for (const protectedPath of protectedPaths) {
+ assert.ok(!changedPaths.includes(protectedPath));
+ }
+});
+
+const run = async () => {
+ let passed = 0;
+
+ for (const currentTest of tests) {
+ await currentTest.fn();
+ passed += 1;
+ }
+
+ console.log(
+ `Administrative login integration tests passed (${passed}/${tests.length}).`
+ );
+};
+
+module.exports = run;
+
+if (require.main === module) {
+ run().catch((error) => {
+ console.error(error);
+ process.exit(1);
+ });
+}
\ No newline at end of file