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
162 changes: 162 additions & 0 deletions app/api/qa-gate/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
// app/api/qa-gate/route.ts
import { NextResponse } from "next/server";
import { serialize } from "cookie";
import crypto from "crypto";
import { ENV } from "@/lib/constants";
import { SECRETKEY } from "@/lib/constants";
import { GHTOKEN } from "@/lib/constants";
import { TEAMNAMES } from "@/lib/constants";
import { ORG } from "@/lib/constants";
// import { DOMAIN } from "@/lib/constants";

export async function POST(req: Request) {
console.log("QA Gate API called, ENV:", ENV);

if (ENV !== "QA") {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}

try {
const body = await req.json().catch(() => null);
if (!body) {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}

const { username, password } = body;

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

if (!SECRETKEY || !GHTOKEN || !TEAMNAMES) {
console.error("Missing required env vars:", {
hasSecretKey: !!SECRETKEY,
hasGhToken: !!GHTOKEN,
hasTeamNames: !!TEAMNAMES,
});
return NextResponse.json({ error: "Server configuration error" }, { status: 500 });
}

const expectedPassword = `${username}-${SECRETKEY}`;
const isValid =
password.length === expectedPassword.length &&
crypto.timingSafeEqual(Buffer.from(password), Buffer.from(expectedPassword));

if (!isValid) {
console.log("Invalid password for user:", username);
return NextResponse.json({ error: "Invalid password" }, { status: 401 });
}

console.log("Checking GitHub org membership for:", username);

// Check if user is in org
const orgRes = await fetch(`https://api.github.com/orgs/${ORG}/members/${username}`, {
headers: {
Authorization: `Bearer ${GHTOKEN}`,
Accept: "application/vnd.github+json",
"User-Agent": "QA-Gate-App",
},
});

console.log("Org check response:", orgRes.status);

if (orgRes.status === 404) {
return NextResponse.json({
error: "Not a member of Dijkstra-Edu organization"
}, { status: 403 });
} else if (orgRes.status === 401) {
return NextResponse.json({ error: "GitHub token invalid" }, { status: 500 });
} else if (!orgRes.ok) {
return NextResponse.json({ error: "Organization check failed" }, { status: 403 });
}

console.log("Checking team membership for:", username, "in teams:", TEAMNAMES);

// Split team names and check membership in any of them
const allowedTeams = TEAMNAMES.split(',').map(team => team.trim());
let userInTeam = false;
let memberOfTeams: string[] = [];

for (const teamName of allowedTeams) {
console.log("Checking membership in team:", teamName);

const teamRes = await fetch(
`https://api.github.com/orgs/${ORG}/teams/team-${teamName}/members/${username}`,
{
headers: {
Authorization: `Bearer ${GHTOKEN}`,
Accept: "application/vnd.github+json",
"User-Agent": "QA-Gate-App",
},
}
);

console.log(`Team '${teamName}' check response:`, teamRes.status);

if (teamRes.status === 200 || teamRes.status === 204) {
userInTeam = true;
memberOfTeams.push(teamName);
console.log(`User is a member of team: ${teamName}`);
break;
} else if (teamRes.status === 404) {
console.log(`User is not a member of team: ${teamName} (or team doesn't exist)`);
} else if (teamRes.status === 401) {
return NextResponse.json({ error: "Insufficient permissions to check team membership" }, { status: 500 });
} else {
console.error(`Team '${teamName}' membership check failed:`, teamRes.status);
}
}

if (!userInTeam) {
return NextResponse.json({
error: `Not a member of any required development teams`
}, { status: 403 });
}

// All checks passed - issue cookie
console.log("All checks passed, issuing cookie. User is member of teams:", memberOfTeams);
return issueSuccessResponse(`Access granted for team member of: ${memberOfTeams.join(', ')}`, req);

} catch (err) {
console.error("QA verify error:", err);
return NextResponse.json({
error: "Internal server error",
details: ENV === "QA" ? String(err) : undefined
}, { status: 500 });
}
}

function issueSuccessResponse(note: string, req: Request) {
const res = NextResponse.json({
success: true,
message: "Access granted",
note,
timestamp: new Date().toISOString(),
});

// Get the request URL
const url = new URL(req.url);
const isSecure = url.protocol === 'https:';
const hostname = url.hostname;

// More robust cookie settings
const cookieOptions: any = {
path: "/",
httpOnly: true,
secure: isSecure,
sameSite: "lax" as const,
maxAge: 60 * 60 * 8, // 8 hours
};

// Additional debug logging
console.log("Setting cookie with options:", {
...cookieOptions,
url: url.origin,
protocol: url.protocol,
hostname,
});

res.headers.set("Set-Cookie", serialize("qa_verified", "true", cookieOptions));

return res;
}
43 changes: 43 additions & 0 deletions app/api/qa-logout/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// app/api/qa-logout/route.ts
import { NextResponse } from "next/server";
import { serialize } from "cookie";
import { ENV } from "@/lib/constants";

export async function POST(req: Request) {
console.log("QA logout API called");

// Allow this in any environment for cleanup purposes
try {
const url = new URL(req.url);
const isSecure = url.protocol === "https:";

const cookieOptions = {
path: "/",
httpOnly: true,
secure: ENV === "QA" ? true : isSecure,
sameSite: "lax" as const,
maxAge: 0,
expires: new Date(0),
};

const cookieHeader = serialize("qa_verified", "", cookieOptions);
console.log("Setting cookie header:", cookieHeader);

const res = NextResponse.json({
success: true,
message: "QA access cleared",
timestamp: new Date().toISOString(),
cookieCleared: true,
environment: ENV
});

// Set multiple cookie clearing headers to be sure
res.headers.set("Set-Cookie", cookieHeader);

console.log("QA cookie cleared successfully");
return res;
} catch (err) {
console.error("QA logout error:", err);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}
138 changes: 138 additions & 0 deletions app/qa-gate/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
"use client";
import { useState } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Eye, EyeOff } from "lucide-react";
import { JOIN_PAGE } from "@/lib/constants";

export default function QAGatePage() {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);

async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError("");
setLoading(true);

try {
console.log("Submitting QA gate request...");
const res = await fetch("/api/qa-gate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
credentials: "include",
});

console.log("Response status:", res.status);
console.log("Response ok:", res.ok);

if (res.ok) {
const data = await res.json();
console.log("QA gate passed:", data);
// Redirect to login or home page
window.location.href = "/";
} else {
let errorMessage = "Access denied";
try {
const data = await res.json();
errorMessage = data.error || errorMessage;
} catch (parseError) {
console.error("Error parsing response:", parseError);
errorMessage = `Server error (${res.status})`;
}
setError(errorMessage);
}
} catch (err: unknown) {
if (err instanceof Error) {
console.error("QA gate error:", err);
setError(`Network error: ${err.message}`);
} else {
console.error("Unknown error:", err);
setError("An unknown error occurred. Please try again.");
}
} finally {
setLoading(false);
}
}

return (
<div className="fixed inset-0 bg-black bg-opacity-95 flex items-center justify-center z-50">
<Card className="w-[400px]">
<CardHeader className="text-center">
<div className="flex justify-center mb-4">
<img src="./icon.png" alt="Dijkstra Logo" className="w-16 h-16 rounded-lg"/>
</div>
<CardTitle className="text-center">Dijkstra QA Environment Access</CardTitle>
<p className="text-sm text-gray-600 text-center">
Enter your GitHub credentials to access the QA environment
</p>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<Input
placeholder="GitHub Username"
value={username}
onChange={(e) => setUsername(e.target.value)}
disabled={loading}
required
/>
<div className="relative">
<Input
type={showPassword ? "text" : "password"}
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={loading}
required
className="pr-10"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-500 hover:text-gray-700 focus:outline-none focus:text-gray-700"
disabled={loading}
tabIndex={-1}
>
{showPassword ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
</div>
{error && (
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-2 rounded">
<p className="text-sm">{error}</p>
</div>
)}
<Button
type="submit"
className="w-full"
disabled={!username || !password || loading}
>
{loading ? "Verifying..." : "Unlock QA Environment"}
</Button>
<div className="text-xs text-gray-500 text-center">
<p>
• Must be a member of Dijkstra-Edu organization.<br></br>Not a member?{" "}
<a
href={JOIN_PAGE}
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 underline hover:text-blue-700"
>
Join here
</a>
</p>
<p>• Must be part of the development team</p>
</div>
</form>
</CardContent>
</Card>
</div>
);
}
9 changes: 9 additions & 0 deletions lib/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export const ENV = process.env.ENVIRONMENT || "DEV";
export const SECRETKEY = process.env.SECRET_KEY;
export const GHTOKEN = process.env.GITHUB_TOKEN;
export const TEAMNAMES = process.env.TEAM_NAMES;
export const ORG = process.env.ORG;
export const JOIN_PAGE = process.env.NEXT_PUBLIC_JOINING_PAGE_URL || "https://github.com/Dijkstra-Edu";
export const DOMAIN = process.env.DOMAIN;


Loading