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
82 changes: 82 additions & 0 deletions src/app/api/admin/credentials/revoke/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { verifySessionToken, COOKIE_NAME } from "@/lib/auth/session";
import { getDb, schema } from "@/lib/db";
import { eq } from "drizzle-orm";

const revokeSchema = z.object({
credentialId: z.string().min(1, "Credential ID is required"),
reason: z.string().min(5, "Reason must be at least 5 characters long"),
});

export async function POST(request: NextRequest) {
const token = request.cookies.get(COOKIE_NAME)?.value;
if (!token) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

const sessionUser = await verifySessionToken(token);
if (!sessionUser || sessionUser.role !== "admin") {
return NextResponse.json(
{ error: "Forbidden. Administrative privileges required." },
{ status: 403 }
);
}

try {
const body = await request.json();
const data = revokeSchema.parse(body);

const db = await getDb();

// Verify credential exists
const credRecords = await db
.select()
.from(schema.credentials)
.where(eq(schema.credentials.id, data.credentialId))
.limit(1);

if (credRecords.length === 0) {
return NextResponse.json({ error: "Credential not found" }, { status: 404 });
}

const cred = credRecords[0];

// Revoke credential
await db
.update(schema.credentials)
.set({
status: "revoked",
revokedReason: data.reason,
})
.where(eq(schema.credentials.id, data.credentialId));

// Create immutable audit log
await db.insert(schema.auditLogs).values({
id: `audit_${crypto.randomUUID()}`,
actorId: sessionUser.id,
action: "credential.revoked",
targetType: "credential",
targetId: data.credentialId,
metadata: {
revokedBy: sessionUser.githubUsername,
reason: data.reason,
targetUserId: cred.userId,
credentialType: cred.type,
},
});

return NextResponse.json({
success: true,
message: `Credential ${data.credentialId} has been revoked.`,
});
} catch (error: any) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: error.errors[0].message }, { status: 400 });
}
return NextResponse.json(
{ error: error.message || "Failed to revoke credential" },
{ status: 500 }
);
}
}
37 changes: 37 additions & 0 deletions src/app/api/badges/credential/[id]/badge.svg/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from "next/server";
import { getDb, schema } from "@/lib/db";
import { eq } from "drizzle-orm";
import { generateCredentialSvgBadge } from "@/lib/credentials/badge";

export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const db = await getDb();

const credRecords = await db
.select()
.from(schema.credentials)
.where(eq(schema.credentials.id, id))
.limit(1);

let svg: string;
if (credRecords.length === 0) {
svg = generateCredentialSvgBadge("Unverified", "revoked");
} else {
const cred = credRecords[0];
svg = generateCredentialSvgBadge(
cred.title.split("—")[0].trim(),
cred.status as "active" | "revoked"
);
}

return new NextResponse(svg, {
status: 200,
headers: {
"Content-Type": "image/svg+xml; charset=utf-8",
"Cache-Control": "public, max-age=3600, s-maxage=3600",
},
});
}
60 changes: 60 additions & 0 deletions src/app/verify/[id]/BadgeSnippet.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"use client";

import { useState } from "react";

export function BadgeSnippet({
credentialId,
title,
appUrl,
}: {
credentialId: string;
title: string;
appUrl: string;
}) {
const [copied, setCopied] = useState(false);

const snippet = `[![TechNexusOrg — ${title}](${appUrl}/api/badges/credential/${credentialId}/badge.svg)](${appUrl}/verify/${credentialId})`;

const handleCopy = () => {
navigator.clipboard.writeText(snippet);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};

return (
<div className="rounded-xl border border-slate-800 bg-slate-900/60 p-5 space-y-3">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<h4 className="text-xs font-mono font-bold uppercase text-white">
Embed on GitHub Profile README
</h4>
<p className="text-[11px] text-slate-400">
Showcase this verified proof of work on your personal GitHub README.
</p>
</div>
<button
type="button"
onClick={handleCopy}
className="rounded-lg bg-slate-800 px-3 py-1.5 text-xs font-mono text-slate-200 hover:bg-slate-700 hover:text-white transition-colors"
>
{copied ? "Copied! ✓" : "Copy Markdown"}
</button>
</div>

<div className="rounded-lg bg-slate-950 p-3 overflow-x-auto border border-slate-800/80">
<code className="text-[11px] font-mono text-sky-300 whitespace-pre">
{snippet}
</code>
</div>

<div className="flex items-center gap-3 pt-1">
<span className="text-[10px] font-mono text-slate-500 uppercase">Live Badge Preview:</span>
<img
src={`/api/badges/credential/${credentialId}/badge.svg`}
alt="Badge Preview"
className="h-6"
/>
</div>
</div>
);
}
97 changes: 97 additions & 0 deletions src/app/verify/[id]/RevokeButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
"use client";

import { useState } from "react";
import { useRouter } from "next/navigation";

export function RevokeButton({ credentialId }: { credentialId: string }) {
const router = useRouter();
const [isOpen, setIsOpen] = useState(false);
const [reason, setReason] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);

const handleRevoke = async () => {
if (!reason.trim() || reason.length < 5) {
setError("Please provide a legitimate justification (min 5 chars).");
return;
}

setSubmitting(true);
setError(null);

try {
const res = await fetch("/api/admin/credentials/revoke", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ credentialId, reason }),
});

const data = await res.json();
if (!res.ok) {
throw new Error(data.error || "Failed to revoke credential");
}

setIsOpen(false);
router.refresh();
} catch (err: any) {
setError(err.message);
} finally {
setSubmitting(false);
}
};

if (!isOpen) {
return (
<button
type="button"
onClick={() => setIsOpen(true)}
className="rounded border border-red-500/30 bg-red-950/20 px-3 py-1 text-xs font-mono text-red-400 hover:bg-red-950/40 transition-colors"
>
Admin: Revoke Credential
</button>
);
}

return (
<div className="rounded-xl border border-red-500/40 bg-red-950/30 p-4 space-y-3">
<h4 className="text-xs font-mono font-bold text-red-300 uppercase">
Administrative Revocation Action
</h4>
<p className="text-[11px] text-slate-300">
This action is permanent and will be logged in the immutable audit registry.
</p>

{error && (
<div className="text-[11px] font-mono text-red-400">
{error}
</div>
)}

<input
type="text"
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="Reason for revocation (e.g. PR reverted, plagiarized code)"
className="w-full rounded border border-red-500/30 bg-slate-900 px-3 py-1.5 text-xs text-white placeholder-slate-500 focus:outline-none font-mono"
/>

<div className="flex items-center gap-2 justify-end">
<button
type="button"
onClick={() => setIsOpen(false)}
className="rounded px-3 py-1 text-xs font-mono text-slate-400 hover:text-white"
>
Cancel
</button>
<button
type="button"
onClick={handleRevoke}
disabled={submitting}
className="rounded bg-red-600 px-3 py-1 text-xs font-mono font-semibold text-white hover:bg-red-500 disabled:opacity-50"
>
{submitting ? "Revoking..." : "Confirm Revocation"}
</button>
</div>
</div>
);
}
51 changes: 42 additions & 9 deletions src/app/verify/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { getDb, schema } from "@/lib/db";
import { eq } from "drizzle-orm";
import Link from "next/link";
import { cookies } from "next/headers";
import type { Metadata } from "next";
import { verifySessionToken, COOKIE_NAME } from "@/lib/auth/session";
import { BadgeSnippet } from "./BadgeSnippet";
import { RevokeButton } from "./RevokeButton";

export const dynamic = "force-dynamic";

Expand All @@ -23,6 +27,10 @@ export default async function VerifyCredentialPage({
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const cookieStore = await cookies();
const token = cookieStore.get(COOKIE_NAME)?.value;
const sessionUser = token ? await verifySessionToken(token) : null;

const db = await getDb();

const credRecords = await db
Expand Down Expand Up @@ -67,14 +75,15 @@ export default async function VerifyCredentialPage({
const holder = holderUsers[0];
const evidence = credential.evidenceData as any;
const isRevoked = credential.status === "revoked";
const appUrl = process.env.APP_URL || "http://localhost:3000";

return (
<div className="mx-auto max-w-3xl px-4 py-16 sm:px-6 lg:px-8 space-y-8">
{/* Verification Banner */}
<div
className={`rounded-2xl border p-6 sm:p-8 space-y-6 ${
isRevoked
? "border-red-500/30 bg-red-950/20"
? "border-red-500/40 bg-red-950/20"
: "border-emerald-500/30 bg-emerald-950/10"
}`}
>
Expand All @@ -83,7 +92,7 @@ export default async function VerifyCredentialPage({
<div className="flex items-center gap-2">
<span
className={`flex h-2 w-2 rounded-full ${
isRevoked ? "bg-red-400" : "bg-emerald-400"
isRevoked ? "bg-red-400 animate-pulse" : "bg-emerald-400"
}`}
/>
<span
Expand All @@ -107,6 +116,18 @@ export default async function VerifyCredentialPage({
</div>
</div>

{/* If Revoked Banner */}
{isRevoked && (
<div className="rounded-lg border border-red-500/30 bg-red-950/40 p-4 space-y-1">
<div className="text-xs font-mono font-bold text-red-300 uppercase">
Notice of Revocation
</div>
<p className="text-xs text-red-200">
{credential.revokedReason || "This credential was revoked by repository maintainers."}
</p>
</div>
)}

{/* Holder & Issuer */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-xs">
<div className="rounded-lg border border-slate-800 bg-slate-900/60 p-4 space-y-2">
Expand All @@ -125,14 +146,12 @@ export default async function VerifyCredentialPage({
<div className="font-bold text-white">
{holder?.displayName || holder?.githubUsername}
</div>
<a
href={`https://github.com/${holder?.githubUsername}`}
target="_blank"
rel="noopener noreferrer"
<Link
href={`/people/${holder?.githubUsername}`}
className="text-sky-400 hover:text-sky-300 font-mono text-[11px]"
>
@{holder?.githubUsername}
</a>
@{holder?.githubUsername} (Passport ↗)
</Link>
</div>
</div>
</div>
Expand Down Expand Up @@ -192,7 +211,7 @@ export default async function VerifyCredentialPage({
</div>
</div>

<div className="pt-2">
<div className="pt-2 flex flex-wrap items-center justify-between gap-3">
<a
href={evidence?.prUrl}
target="_blank"
Expand All @@ -202,9 +221,23 @@ export default async function VerifyCredentialPage({
<span>Inspect PR on GitHub</span>
<span>→</span>
</a>

{/* Admin safety control */}
{sessionUser?.role === "admin" && !isRevoked && (
<RevokeButton credentialId={credential.id} />
)}
</div>
</div>

{/* Embeddable Badge Snippet */}
{!isRevoked && (
<BadgeSnippet
credentialId={credential.id}
title={credential.title.split("—")[0].trim()}
appUrl={appUrl}
/>
)}

{/* Anti-certificate mill disclaimer */}
<p className="text-[11px] text-slate-500 font-mono text-center">
TechNexusOrg credentials represent verifiable open-source engineering work. This record is linked to public GitHub contributions.
Expand Down
Loading
Loading