From fbf9a9dccf8fec86404bd9eff1867f8c4627ba9e Mon Sep 17 00:00:00 2001 From: ashishsinghbora <135435891+ashishsinghbora@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:34:54 +0530 Subject: [PATCH] feat: implement Contributor Passport, public profiles, and privacy controls (Phase 2) --- src/app/api/user/settings/route.ts | 65 ++++ src/app/dashboard/page.tsx | 16 +- src/app/dashboard/settings/SettingsForm.tsx | 249 +++++++++++++ src/app/dashboard/settings/page.tsx | 90 +++++ src/app/people/[username]/opengraph-image.tsx | 160 +++++++++ src/app/people/[username]/page.tsx | 335 ++++++++++++++++++ src/app/people/page.tsx | 109 ++++++ src/components/Navbar.tsx | 3 + src/lib/passport/index.test.ts | 115 ++++++ src/lib/passport/index.ts | 234 ++++++++++++ 10 files changed, 1371 insertions(+), 5 deletions(-) create mode 100644 src/app/api/user/settings/route.ts create mode 100644 src/app/dashboard/settings/SettingsForm.tsx create mode 100644 src/app/dashboard/settings/page.tsx create mode 100644 src/app/people/[username]/opengraph-image.tsx create mode 100644 src/app/people/[username]/page.tsx create mode 100644 src/app/people/page.tsx create mode 100644 src/lib/passport/index.test.ts create mode 100644 src/lib/passport/index.ts diff --git a/src/app/api/user/settings/route.ts b/src/app/api/user/settings/route.ts new file mode 100644 index 0000000..7573a52 --- /dev/null +++ b/src/app/api/user/settings/route.ts @@ -0,0 +1,65 @@ +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 settingsSchema = z.object({ + displayName: z.string().max(100).optional(), + bio: z.string().max(300).optional(), + location: z.string().max(100).optional(), + portfolioUrl: z.string().url().or(z.literal("")).optional(), + linkedinUrl: z.string().url().or(z.literal("")).optional(), + isPublic: z.boolean().default(true), + skills: z.array(z.string()).default([]), +}); + +export async function PATCH(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) { + return NextResponse.json({ error: "Invalid session" }, { status: 401 }); + } + + try { + const body = await request.json(); + const data = settingsSchema.parse(body); + const db = await getDb(); + + // Update user record + await db + .update(schema.users) + .set({ + displayName: data.displayName !== undefined ? data.displayName : undefined, + bio: data.bio !== undefined ? data.bio : undefined, + location: data.location !== undefined ? data.location : undefined, + portfolioUrl: data.portfolioUrl || null, + linkedinUrl: data.linkedinUrl || null, + isPublic: data.isPublic, + updatedAt: new Date(), + }) + .where(eq(schema.users.id, sessionUser.id)); + + // Update profile skills if provided + if (data.skills) { + await db + .update(schema.profiles) + .set({ + skills: data.skills, + updatedAt: new Date(), + }) + .where(eq(schema.profiles.userId, sessionUser.id)); + } + + return NextResponse.json({ success: true, message: "Settings saved successfully" }); + } 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 update settings" }, { status: 500 }); + } +} diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index a4ce8f7..cff7fb3 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -89,20 +89,26 @@ export default async function DashboardPage() { -
+
- Find Issues → + Public Passport ↗ + + + Settings - GitHub Profile + GitHub
diff --git a/src/app/dashboard/settings/SettingsForm.tsx b/src/app/dashboard/settings/SettingsForm.tsx new file mode 100644 index 0000000..087c7da --- /dev/null +++ b/src/app/dashboard/settings/SettingsForm.tsx @@ -0,0 +1,249 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; + +export function SettingsForm({ + initialUser, + initialSkills, +}: { + initialUser: { + githubUsername: string; + displayName: string | null; + bio: string | null; + location: string | null; + portfolioUrl: string | null; + linkedinUrl: string | null; + isPublic: boolean; + }; + initialSkills: string[]; +}) { + const router = useRouter(); + const [displayName, setDisplayName] = useState(initialUser.displayName || ""); + const [bio, setBio] = useState(initialUser.bio || ""); + const [location, setLocation] = useState(initialUser.location || ""); + const [portfolioUrl, setPortfolioUrl] = useState(initialUser.portfolioUrl || ""); + const [linkedinUrl, setLinkedinUrl] = useState(initialUser.linkedinUrl || ""); + const [isPublic, setIsPublic] = useState(initialUser.isPublic); + const [skillInput, setSkillInput] = useState(""); + const [skills, setSkills] = useState(initialSkills || []); + const [submitting, setSubmitting] = useState(false); + const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null); + + const addSkill = () => { + const trimmed = skillInput.trim(); + if (trimmed && !skills.includes(trimmed)) { + setSkills([...skills, trimmed]); + setSkillInput(""); + } + }; + + const removeSkill = (skill: string) => { + setSkills(skills.filter((s) => s !== skill)); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setSubmitting(true); + setMessage(null); + + try { + const res = await fetch("/api/user/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + displayName, + bio, + location, + portfolioUrl: portfolioUrl || "", + linkedinUrl: linkedinUrl || "", + isPublic, + skills, + }), + }); + + const data = await res.json(); + if (!res.ok) { + throw new Error(data.error || "Failed to update profile settings"); + } + + setMessage({ type: "success", text: "Settings saved successfully!" }); + router.refresh(); + } catch (err: any) { + setMessage({ type: "error", text: err.message }); + } finally { + setSubmitting(false); + } + }; + + return ( +
+ {message && ( +
+ {message.text} +
+ )} + + {/* Privacy Toggle */} +
+
+
+

+ Public Contributor Passport +

+

+ When enabled, your passport is visible at /people/{initialUser.githubUsername}. +

+
+ +
+
+ + {/* Display Name */} +
+ + setDisplayName(e.target.value)} + placeholder={initialUser.githubUsername} + className="w-full rounded-lg border border-slate-800 bg-slate-900/60 px-3 py-2 text-xs text-white placeholder-slate-500 focus:border-sky-500 focus:outline-none font-mono" + /> +
+ + {/* Bio */} +
+ +