diff --git a/app/api/[...path]/route.ts b/app/api/[...path]/route.ts
index eb5c3a0..069f352 100644
--- a/app/api/[...path]/route.ts
+++ b/app/api/[...path]/route.ts
@@ -4,6 +4,7 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '../auth/[...nextauth]/authOptions'
+import { getEncodedJWT } from '@/lib/api/jwt-utils'
import {
getArchivistBaseUrl,
getDataForgeBaseUrl,
@@ -31,11 +32,16 @@ function getBaseUrlForInternalService(service: string): string | null {
async function proxyToBackend(req: NextRequest, path: string[]) {
const session = await getServerSession(authOptions)
-
- if (!session?.user?.access_token) {
+ if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
+ // Backend expects a signed NextAuth-style JWT (HS256), not the GitHub OAuth token.
+ const backendToken = await getEncodedJWT(req)
+ if (!backendToken) {
+ return NextResponse.json({ error: 'Unauthorized', message: 'No valid session token for backend' }, { status: 401 })
+ }
+
const [service, ...backendPathSegments] = path
if (!service || backendPathSegments.length === 0) {
return NextResponse.json(
@@ -60,7 +66,7 @@ async function proxyToBackend(req: NextRequest, path: string[]) {
method: req.method,
headers: {
'Content-Type': 'application/json',
- Authorization: `Bearer ${session.user.access_token}`,
+ Authorization: `Bearer ${backendToken}`,
'X-Internal-Secret': process.env.INTERNAL_API_SECRET!,
},
body: req.method !== 'GET' && req.method !== 'DELETE'
diff --git a/app/api/auth/[...nextauth]/authOptions.ts b/app/api/auth/[...nextauth]/authOptions.ts
index a44166b..59effb4 100644
--- a/app/api/auth/[...nextauth]/authOptions.ts
+++ b/app/api/auth/[...nextauth]/authOptions.ts
@@ -93,6 +93,7 @@ export const authOptions: NextAuthOptions = {
u.user_id = (token as any).user_id;
u.profile_id = (token as any).profile_id;
u.requires_onboarding = (token as any).requires_onboarding;
+ u.roles = (token as any).roles || [];
u.access_token = (token as any).access_token;
}
return session;
@@ -121,6 +122,7 @@ export const authOptions: NextAuthOptions = {
updated_at: (profile as any).updated_at,
organization: (profile as any).organization,
hireable: (profile as any).hireable,
+ roles: [] as string[],
access_token: account.access_token,
};
@@ -131,7 +133,7 @@ export const authOptions: NextAuthOptions = {
if (onboardingStatus.onboarded) {
// User is onboarded - fetch auth credentials
const authData = await getAuthDataByGithubUsername(githubUsername);
-
+ newToken.roles = authData.roles;
newToken.user_id = authData.user_id;
newToken.profile_id = authData.profile_id;
newToken.github_user_name = githubUsername;
@@ -172,7 +174,7 @@ export const authOptions: NextAuthOptions = {
if (onboardingStatus.onboarded) {
// User is now onboarded - fetch auth credentials
const authData = await getAuthDataByGithubUsername(token.github_user_name);
-
+ token.roles = authData.roles;
token.user_id = authData.user_id;
token.profile_id = authData.profile_id;
token.requires_onboarding = false;
diff --git a/app/api/qa-gate/route.ts b/app/api/qa-gate/route.ts
index c083c9e..cf002cb 100644
--- a/app/api/qa-gate/route.ts
+++ b/app/api/qa-gate/route.ts
@@ -9,9 +9,7 @@ import { TEAMNAMES } from "@/constants/constants";
import { ORG } from "@/constants/constants";
// import { DOMAIN } from "@/lib/constants";
-export async function POST(req: Request) {
- console.log("QA Gate API called, ENV:", ENV);
-
+export async function POST(req: Request) {
if (ENV !== "QA") {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
@@ -43,12 +41,9 @@ export async function POST(req: Request) {
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: {
@@ -58,7 +53,6 @@ export async function POST(req: Request) {
},
});
- console.log("Org check response:", orgRes.status);
if (orgRes.status === 404) {
return NextResponse.json({
@@ -70,7 +64,6 @@ export async function POST(req: Request) {
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());
@@ -78,7 +71,6 @@ export async function POST(req: Request) {
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}`,
@@ -91,7 +83,6 @@ export async function POST(req: Request) {
}
);
- console.log(`Team '${teamName}' check response:`, teamRes.status);
if (teamRes.status === 200 || teamRes.status === 204) {
userInTeam = true;
@@ -148,14 +139,6 @@ function issueSuccessResponse(note: string, req: Request) {
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;
diff --git a/app/api/qa-logout/route.ts b/app/api/qa-logout/route.ts
index 20a8b48..4761335 100644
--- a/app/api/qa-logout/route.ts
+++ b/app/api/qa-logout/route.ts
@@ -3,9 +3,7 @@ import { NextResponse } from "next/server";
import { serialize } from "cookie";
import { ENV } from "@/constants/constants";
-export async function POST(req: Request) {
- console.log("QA logout API called");
-
+export async function POST(req: Request) {
// Allow this in any environment for cleanup purposes
try {
const url = new URL(req.url);
@@ -21,7 +19,6 @@ export async function POST(req: Request) {
};
const cookieHeader = serialize("qa_verified", "", cookieOptions);
- console.log("Setting cookie header:", cookieHeader);
const res = NextResponse.json({
success: true,
@@ -34,7 +31,6 @@ export async function POST(req: Request) {
// 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);
diff --git a/app/qa-gate/page.tsx b/app/qa-gate/page.tsx
index 2a54282..4fa2075 100644
--- a/app/qa-gate/page.tsx
+++ b/app/qa-gate/page.tsx
@@ -19,7 +19,6 @@ export default function QAGatePage() {
setLoading(true);
try {
- console.log("Submitting QA gate request...");
const res = await fetch("/api/qa-gate", {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -27,12 +26,8 @@ export default function QAGatePage() {
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 {
diff --git a/app/types/next-auth.d.ts b/app/types/next-auth.d.ts
index 05fed0b..903e24c 100644
--- a/app/types/next-auth.d.ts
+++ b/app/types/next-auth.d.ts
@@ -27,6 +27,7 @@ declare module "next-auth" {
user_id?: string;
profile_id?: string;
requires_onboarding?: boolean;
+ roles?: string[];
access_token?: string;
};
}
diff --git a/components/dijkstra-gpt.tsx b/components/dijkstra-gpt.tsx
index 7f46d70..6dfe62b 100644
--- a/components/dijkstra-gpt.tsx
+++ b/components/dijkstra-gpt.tsx
@@ -250,14 +250,11 @@ export default function DijkstraGPT() {
const response = await callGemini("test");
if (response) {
setApiStatus('active');
- console.log('✅ API key is active');
} else {
setApiStatus('inactive');
- console.log('❌ API key is not configured or invalid');
}
} catch (error) {
setApiStatus('inactive');
- console.error('❌ API check failed:', error);
}
};
diff --git a/components/login/sign-in-form.tsx b/components/login/sign-in-form.tsx
index e4e130a..c8dd2de 100644
--- a/components/login/sign-in-form.tsx
+++ b/components/login/sign-in-form.tsx
@@ -19,10 +19,6 @@ export function SignInForm() {
// Redirect based on onboarding status from session
useEffect(() => {
if (justLoggedIn && session?.user) {
- console.log('Login verification:', {
- githubUsername: session.user.github_user_name || session.user.login,
- requiresOnboarding: session.user.requires_onboarding
- });
// Ensure we have GitHub username (either from github_user_name or login field)
const githubUsername = session.user.github_user_name || (session.user as any).login;
@@ -37,10 +33,10 @@ export function SignInForm() {
const requiresOnboarding = session.user.requires_onboarding !== false;
if (requiresOnboarding) {
- console.log('User not onboarded, redirecting to onboarding');
+ // User not onboarded, redirecting to onboarding
window.location.href = '/onboarding';
} else {
- console.log('User onboarded, redirecting to dashboard');
+ // User onboarded, redirecting to dashboard
window.location.href = '/dashboard';
}
}
diff --git a/components/multiselects/role-multi-select.tsx b/components/multiselects/role-multi-select.tsx
new file mode 100644
index 0000000..43b5989
--- /dev/null
+++ b/components/multiselects/role-multi-select.tsx
@@ -0,0 +1,140 @@
+"use client"
+import { useState } from "react"
+import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover"
+import { Command, CommandInput, CommandItem, CommandList, CommandEmpty, CommandGroup } from "@/components/ui/command"
+import { Button } from "@/components/ui/button"
+import { Badge } from "@/components/ui/badge"
+import { Check, ChevronsUpDown, X, Shield } from "lucide-react"
+import { cn } from "@/lib/utils"
+import { ROLE_OPTIONS } from "@/constants/roles"
+import type { Role } from "@/constants/roles"
+
+interface RoleMultiSelectProps {
+ value: string[]
+ onChange: (roles: string[]) => void
+ availableRoles: string[]
+ placeholder?: string
+ disabled?: boolean
+}
+
+export function RoleMultiSelect({
+ value,
+ onChange,
+ availableRoles,
+ placeholder = "Select roles...",
+ disabled = false
+}: RoleMultiSelectProps) {
+ const [open, setOpen] = useState(false)
+ const [query, setQuery] = useState("")
+
+ const selectedRoles = value || []
+
+ // Filter roles based on query and available roles
+ const filteredRoles = ROLE_OPTIONS.filter(role =>
+ availableRoles.includes(role.value) &&
+ role.label.toLowerCase().includes(query.toLowerCase()) &&
+ !selectedRoles.includes(role.value)
+ )
+
+ const handleSelect = (roleValue: Role) => {
+ if (!selectedRoles.includes(roleValue)) {
+ onChange([...selectedRoles, roleValue])
+ }
+ setQuery("")
+ }
+
+ const handleRemove = (roleValue: string) => {
+ onChange(selectedRoles.filter(role => role !== roleValue))
+ }
+
+ const formatDisplayText = () => {
+ if (selectedRoles.length === 0) return placeholder
+ if (selectedRoles.length === 1) {
+ const role = ROLE_OPTIONS.find(r => r.value === selectedRoles[0])
+ return role?.label || selectedRoles[0]
+ }
+ return `${selectedRoles.length} roles selected`
+ }
+
+ return (
+
+
+
+
+
+
+ {selectedRoles.length > 0 ? (
+ selectedRoles.map((roleValue) => {
+ const role = ROLE_OPTIONS.find(r => r.value === roleValue)
+ return (
+
+ {role?.label || roleValue}
+ {
+ e.stopPropagation()
+ handleRemove(roleValue)
+ }}
+ className="ml-1 hover:bg-muted-foreground/20 rounded-full p-0.5 cursor-pointer"
+ >
+
+
+
+ )
+ })
+ ) : (
+
{placeholder}
+ )}
+
+
+
+
+
+
+
+
+
+
+ {filteredRoles.length === 0 && query && (
+ No roles found.
+ )}
+
+ {filteredRoles.length > 0 && (
+
+ {filteredRoles.map((role) => (
+ handleSelect(role.value)}
+ className="flex items-center gap-2 hover:bg-accent"
+ >
+
+ {role.label}
+
+
+ ))}
+
+ )}
+
+
+
+
+ )
+}
+
diff --git a/components/profile/profile-container.tsx b/components/profile/profile-container.tsx
index 889c2a9..d0446a8 100644
--- a/components/profile/profile-container.tsx
+++ b/components/profile/profile-container.tsx
@@ -51,8 +51,6 @@ export function ProfileContainer() {
});
};
- console.log("Session", session);
-
return (
{/* Header */}
diff --git a/components/settings-dialog.tsx b/components/settings-dialog.tsx
index c70590c..5b544bc 100644
--- a/components/settings-dialog.tsx
+++ b/components/settings-dialog.tsx
@@ -11,6 +11,7 @@ import {
Globe,
Home,
Keyboard,
+ Key,
Lock,
Paintbrush,
Plus,
@@ -58,6 +59,7 @@ import { Slider } from "@/components/ui/slider";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
+import APIKeysPage from "@/components/settings/api-keys-page";
import {
IconBrandDiscord,
IconBrandGithub,
@@ -69,8 +71,8 @@ import {
IconLayoutDashboard,
} from "@tabler/icons-react";
-const data = {
- nav: [
+function getNavItems(developerMode: boolean) {
+ const baseNav = [
{ name: "Home", icon: Home },
{ name: "Accounts", icon: User },
{ name: "Notifications", icon: Bell },
@@ -79,9 +81,15 @@ const data = {
{ name: "Accessibility", icon: Keyboard },
{ name: "Privacy & visibility", icon: Lock },
{ name: "Developer Settings", icon: Code },
- { name: "Advanced", icon: Settings },
- ],
-};
+ ];
+
+ if (developerMode) {
+ baseNav.push({ name: "API Keys", icon: Key });
+ }
+
+ baseNav.push({ name: "Advanced", icon: Settings });
+ return baseNav;
+}
function NotificationsPage() {
@@ -1485,10 +1493,16 @@ export function SettingsDialog({
}: { open?: boolean; onOpenChange?: (open: boolean) => void } = {}) {
const [internalOpen, setInternalOpen] = React.useState(false);
const [activePage, setActivePage] = React.useState("Home");
+ const settings = useSettingsStore();
const open = controlledOpen !== undefined ? controlledOpen : internalOpen;
const setOpen = controlledOnOpenChange || setInternalOpen;
+ const navItems = React.useMemo(
+ () => getNavItems(settings.developerMode),
+ [settings.developerMode]
+ );
+
const renderPageContent = () => {
switch (activePage) {
case "Home":
@@ -1507,6 +1521,8 @@ export function SettingsDialog({
return
;
case "Developer Settings":
return
;
+ case "API Keys":
+ return
;
case "Advanced":
return
;
default:
@@ -1538,7 +1554,7 @@ export function SettingsDialog({
- {data.nav.map((item) => (
+ {navItems.map((item) => (
([]);
+ const [newlyCreatedKey, setNewlyCreatedKey] = React.useState(null);
+ const [showNewKey, setShowNewKey] = React.useState(false);
+
+ // Get user roles from session
+ const userRoles = (session?.user as any)?.roles || [];
+
+ // Check if dev environment
+ const isDev = ENV === "DEV";
+
+ // Filter available roles based on environment
+ const availableRoles = isDev ? ALL_ROLES : userRoles;
+
+ const handleCreateKey = async () => {
+ try {
+ const data: CreateAPIKey = {
+ description: newKeyDescription || null,
+ expires_in: newKeyExpiration || null,
+ roles: selectedRoles.length > 0 ? selectedRoles : null,
+ };
+
+ const response = await createMutation.mutateAsync({ data });
+ setNewlyCreatedKey(response.key);
+ setShowNewKey(true);
+ setShowCreateDialog(false);
+ setNewKeyDescription("");
+ setNewKeyExpiration("");
+ setSelectedRoles([]);
+ toast.success("API key created successfully");
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : "Failed to create API key");
+ }
+ };
+
+ const handleRevokeKey = async (keyId: string) => {
+ try {
+ await revokeMutation.mutateAsync();
+ toast.success("API key revoked successfully");
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : "Failed to revoke API key");
+ }
+ };
+
+ const copyToClipboard = (text: string) => {
+ navigator.clipboard.writeText(text);
+ toast.success("Copied to clipboard");
+ };
+
+ const formatDate = (dateString: string) => {
+ return new Date(dateString).toLocaleDateString("en-US", {
+ year: "numeric",
+ month: "short",
+ day: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+ };
+
+ const maskKey = (key: string) => {
+ if (key.length <= 8) return "•".repeat(key.length);
+ return `${key.substring(0, 4)}${"•".repeat(key.length - 8)}${key.substring(key.length - 4)}`;
+ };
+
+ return (
+
+
+
API Keys
+
+ Manage your API keys for accessing the Dijkstra API
+
+
+
+
+ {/* Security Warning */}
+
+
+
+
+
+
+
+ Security Notice
+
+
+ API keys provide full access to your account. Keep them secure and never share them publicly.
+ You will only see the full key once when it's created.
+
+
+
+
+
+ {/* Newly Created Key Display */}
+ {newlyCreatedKey && showNewKey && (
+
+
+
+ New API Key Created
+
+
{
+ setShowNewKey(false);
+ setNewlyCreatedKey(null);
+ }}
+ >
+
+
+
+
+ Copy this key now. You won't be able to see it again!
+
+
+
+ copyToClipboard(newlyCreatedKey)}
+ >
+
+ Copy
+
+ setShowNewKey(!showNewKey)}
+ >
+ {showNewKey ? : }
+
+
+
+ )}
+
+ {/* Create New Key Button */}
+
+
{
+ setShowCreateDialog(open);
+ if (!open) {
+ setSelectedRoles([]);
+ }
+ }}
+ >
+
+
+
+ Generate New API Key
+
+
+
+
+ Create New API Key
+
+ Create a new API key to access the Dijkstra API. You can add a description, optional expiration date, and assign roles.
+
+
+
+
+ Description (optional)
+ setNewKeyDescription(e.target.value)}
+ />
+
+
+
Expiration Date (optional)
+
setNewKeyExpiration(e.target.value)}
+ />
+
+ Leave empty for no expiration
+
+
+
+
Roles (optional)
+
+
+ {isDev
+ ? "Dev environment: All roles available"
+ : `Available roles: ${availableRoles.length > 0 ? availableRoles.join(", ") : "None"}`}
+
+
+
+
+ {
+ setShowCreateDialog(false);
+ setSelectedRoles([]);
+ }}
+ >
+ Cancel
+
+
+ {createMutation.isPending ? "Creating..." : "Create Key"}
+
+
+
+
+
+
+ {/* API Keys List */}
+ {isLoading && (
+
+ Loading API keys...
+
+ )}
+
+ {error && (
+
+
+ Error loading API keys: {error instanceof Error ? error.message : "Unknown error"}
+
+
+ )}
+
+ {!isLoading && !error && apiKeys && (
+
+ {apiKeys.length === 0 ? (
+
+ No API keys found. Create your first API key to get started.
+
+ ) : (
+ apiKeys.map((key) => (
+
+
+
+
+
+
+ {key.description || "Unnamed API Key"}
+
+ {key.active ? (
+
+
+ Active
+
+ ) : (
+
+
+ Revoked
+
+ )}
+
+
+
+
+ Created: {formatDate(key.created_at)}
+
+ {key.expires_in && (
+
+
+ Expires: {formatDate(key.expires_in)}
+
+ )}
+
+
+ Key ID: {key.id}
+
+
+ {key.active && (
+
+
+
+
+ Revoke
+
+
+
+
+ Revoke API Key?
+
+ This action cannot be undone. This will permanently revoke the API key
+ and it will no longer be able to access the API.
+
+
+
+ Cancel
+ handleRevokeKey(key.id)}
+ className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
+ >
+ Revoke
+
+
+
+
+ )}
+
+
+ ))
+ )}
+
+ )}
+
+ );
+}
+
+export default APIKeysPage;
+
diff --git a/components/site-header.tsx b/components/site-header.tsx
index 2f16724..294d309 100644
--- a/components/site-header.tsx
+++ b/components/site-header.tsx
@@ -153,12 +153,6 @@ export function SiteHeader({ title, services }: { title: string; services?: Serv
}
}, [services]);
- // Debug logging (can be removed later)
- React.useEffect(() => {
- console.log('Preset Pins:', presetPins);
- console.log('Enabled Preset Pins:', presetPins?.filter((pin) => pin.enabled));
- }, [presetPins]);
-
// Filter enabled pins and group them
const enabledPresetPins = presetPins?.filter((pin) => pin.enabled) || [];
const enabledCustomPins = customPins?.filter((pin) => pin.enabled) || [];
diff --git a/components/ui/alert-dialog.tsx b/components/ui/alert-dialog.tsx
new file mode 100644
index 0000000..d22004a
--- /dev/null
+++ b/components/ui/alert-dialog.tsx
@@ -0,0 +1,142 @@
+"use client"
+
+import * as React from "react"
+import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
+
+import { cn } from "@/lib/utils"
+import { buttonVariants } from "@/components/ui/button"
+
+const AlertDialog = AlertDialogPrimitive.Root
+
+const AlertDialogTrigger = AlertDialogPrimitive.Trigger
+
+const AlertDialogPortal = AlertDialogPrimitive.Portal
+
+const AlertDialogOverlay = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
+
+const AlertDialogContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+
+))
+AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
+
+const AlertDialogHeader = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+)
+AlertDialogHeader.displayName = "AlertDialogHeader"
+
+const AlertDialogFooter = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+)
+AlertDialogFooter.displayName = "AlertDialogFooter"
+
+const AlertDialogTitle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
+
+const AlertDialogDescription = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AlertDialogDescription.displayName =
+ AlertDialogPrimitive.Description.displayName
+
+const AlertDialogAction = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
+
+const AlertDialogCancel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
+
+export {
+ AlertDialog,
+ AlertDialogPortal,
+ AlertDialogOverlay,
+ AlertDialogTrigger,
+ AlertDialogContent,
+ AlertDialogHeader,
+ AlertDialogFooter,
+ AlertDialogTitle,
+ AlertDialogDescription,
+ AlertDialogAction,
+ AlertDialogCancel,
+}
+
diff --git a/components/waitlist-signup.tsx b/components/waitlist-signup.tsx
index 52e516d..921f001 100644
--- a/components/waitlist-signup.tsx
+++ b/components/waitlist-signup.tsx
@@ -60,6 +60,18 @@ export function WaitlistSignup() {
>
Login
+
+ Dashboard
+
+
+ Generate API Key
+
diff --git a/constants/roles.ts b/constants/roles.ts
new file mode 100644
index 0000000..2d4f09e
--- /dev/null
+++ b/constants/roles.ts
@@ -0,0 +1,23 @@
+/**
+ * Role constants matching backend Role enum
+ */
+
+export type Role =
+ | "GLOBAL_ADMIN"
+ | "LOCAL_ADMIN"
+ | "TEAM_LEAD"
+ | "ORGANIZATION_READ"
+ | "PERSONAL_READ"
+ | "PERSONAL_WRITE";
+
+export const ROLE_OPTIONS: { value: Role; label: string }[] = [
+ { value: "GLOBAL_ADMIN", label: "Global Admin" },
+ { value: "LOCAL_ADMIN", label: "Local Admin" },
+ { value: "TEAM_LEAD", label: "Team Lead" },
+ { value: "ORGANIZATION_READ", label: "Organization Read" },
+ { value: "PERSONAL_READ", label: "Personal Read" },
+ { value: "PERSONAL_WRITE", label: "Personal Write" },
+];
+
+export const ALL_ROLES: Role[] = ROLE_OPTIONS.map((r) => r.value);
+
diff --git a/hooks/api-keys/use-api-keys.ts b/hooks/api-keys/use-api-keys.ts
new file mode 100644
index 0000000..ba56a11
--- /dev/null
+++ b/hooks/api-keys/use-api-keys.ts
@@ -0,0 +1,51 @@
+"use client";
+
+import { useQuery, useMutation, useQueryClient, queryOptions } from "@tanstack/react-query";
+import type { APIKeyResponse, CreateAPIKey } from "@/types/server/dataforge/User/api-keys";
+import { createAPIKeyByGithubUsername, listAPIKeysByGithubUsername, revokeAPIKeyByKeyId } from "@/services/user/APIKeyService";
+
+/**
+ * Hook to fetch all API keys
+ */
+export function useGetAllAPIKeysByGithubUsername(username: string) {
+ return useQuery(
+ queryOptions({
+ queryKey: ["api-keys", "list", username],
+ queryFn: () => listAPIKeysByGithubUsername(username),
+ staleTime: 1000 * 60 * 5, // 5 minutes
+ gcTime: 1000 * 60 * 30, // 30 minutes
+ })
+ );
+}
+
+/**
+ * Hook to create a new API key
+ */
+export function useCreateAPIKeyByGithubUsername(username: string) {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: ({ data }: { data: CreateAPIKey }) =>
+ createAPIKeyByGithubUsername(username, data),
+ onSuccess: () => {
+ // Invalidate and refetch API keys list
+ queryClient.invalidateQueries({ queryKey: ["api-keys"] });
+ },
+ });
+}
+
+/**
+ * Hook to revoke an API key
+ */
+export function useRevokeAPIKeyByKeyId(keyId: string) {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: () =>
+ revokeAPIKeyByKeyId(keyId),
+ onSuccess: () => {
+ // Invalidate and refetch API keys list
+ queryClient.invalidateQueries({ queryKey: ["api-keys"] });
+ },
+ });
+}
diff --git a/lib/api/jwt-utils.ts b/lib/api/jwt-utils.ts
new file mode 100644
index 0000000..76b50ca
--- /dev/null
+++ b/lib/api/jwt-utils.ts
@@ -0,0 +1,67 @@
+import { NextRequest } from "next/server";
+import { getToken } from "next-auth/jwt";
+import jwt from "jsonwebtoken";
+
+/**
+ * Get the encoded NextAuth JWT token to send to backend
+ * The backend expects a signed JWT token (HS256) with:
+ * - githubUsername (camelCase)
+ * - sub (subject)
+ * - exp (expiration as Unix timestamp)
+ * - iat (issued at as Unix timestamp)
+ *
+ * Note: We use jsonwebtoken to create a signed JWT (HS256) instead of
+ * NextAuth's encrypted JWT (A256GCM) because the backend expects a signed token.
+ */
+export async function getEncodedJWT(req: NextRequest): Promise {
+ const token = await getToken({ req });
+ if (!token) {
+ console.log("[JWT] No token found in request");
+ return null;
+ }
+
+ // Prepare token payload with backend-required fields
+ const now = Math.floor(Date.now() / 1000); // Current time as Unix timestamp
+ const maxAge = 30 * 24 * 60 * 60; // 30 days in seconds
+
+ const tokenPayload: any = {
+ ...token,
+ // Ensure githubUsername is set (backend expects camelCase)
+ githubUsername: (token as any).githubUsername || (token as any).github_user_name,
+ // Ensure sub (subject) is set if not already present
+ sub: (token as any).sub || (token as any).user_id || String((token as any).id),
+ // Add exp and iat explicitly (required for JWT)
+ exp: now + maxAge,
+ iat: now,
+ };
+ if (process.env.NEXTAUTH_ISSUER) {
+ tokenPayload.iss = process.env.NEXTAUTH_ISSUER;
+ }
+ if (process.env.NEXTAUTH_AUDIENCE) {
+ tokenPayload.aud = process.env.NEXTAUTH_AUDIENCE;
+ }
+
+ // Sign the JWT using HS256 algorithm (not encrypt)
+ const secret = process.env.NEXTAUTH_SECRET;
+ if (!secret) {
+ console.error("[JWT] NEXTAUTH_SECRET is not set");
+ return null;
+ }
+
+ const encoded = jwt.sign(tokenPayload, secret, { algorithm: "HS256" });
+
+ // Decode the signed token to verify what was actually encoded
+ try {
+ const decodedEncoded = jwt.decode(encoded, { complete: true });
+
+ if (decodedEncoded && typeof decodedEncoded === 'object' && 'payload' in decodedEncoded) {
+ const payload = decodedEncoded.payload as any;
+ const exp = typeof payload.exp === 'number' ? payload.exp : null;
+ const iat = typeof payload.iat === 'number' ? payload.iat : null;
+ }
+ } catch (error) {
+ console.warn("[JWT] Failed to decode signed token for verification:", error);
+ }
+
+ return encoded;
+}
\ No newline at end of file
diff --git a/lib/geminiClient.ts b/lib/geminiClient.ts
index 5e92f28..4a1a6f2 100644
--- a/lib/geminiClient.ts
+++ b/lib/geminiClient.ts
@@ -100,18 +100,4 @@ export async function callGeminiStreaming(
// Adjust delay value (in ms) to control typing speed
await new Promise(resolve => setTimeout(resolve, 30));
}
-}
-
-/**
- * Example usage:
- *
- * // Standard call
- * const response = await callGemini("What is recursion?");
- * console.log(response);
- *
- * // Streaming call
- * await callGeminiStreaming(
- * "Explain binary search",
- * (chunk) => console.log(chunk)
- * );
- */
\ No newline at end of file
+}
\ No newline at end of file
diff --git a/lib/logout.ts b/lib/logout.ts
index 29ff492..6d0bc03 100644
--- a/lib/logout.ts
+++ b/lib/logout.ts
@@ -17,9 +17,6 @@ export const handleLogout = async (options: LogoutOptions = {}) => {
} = options;
try {
- console.log("Starting logout process...");
- console.log("Current cookies before clear:", document.cookie);
-
// Clearing the QA cookie (if it exists)
const qaLogoutResponse = await fetch("/api/qa-logout", {
method: "POST",
@@ -28,14 +25,10 @@ export const handleLogout = async (options: LogoutOptions = {}) => {
if (qaLogoutResponse.ok) {
const data = await qaLogoutResponse.json();
- console.log("QA logout response:", data);
- console.log("Current cookies after clear:", document.cookie);
} else {
console.warn("QA logout failed:", qaLogoutResponse.status);
}
-
- console.log("Proceeding with NextAuth signout");
-
+
// Sign out from NextAuth
await signOut({
callbackUrl,
diff --git a/package-lock.json b/package-lock.json
index 207114a..e23f306 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -16,6 +16,7 @@
"@google/genai": "^1.27.0",
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-accordion": "^1.2.11",
+ "@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
@@ -66,6 +67,7 @@
"framer-motion": "^12.19.2",
"gsap": "^3.13.0",
"html2canvas": "^1.4.1",
+ "jsonwebtoken": "^9.0.2",
"jspdf": "^3.0.1",
"lucide-react": "^0.523.0",
"motion": "^12.19.2",
@@ -92,6 +94,7 @@
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@tanstack/eslint-plugin-query": "^5.91.0",
+ "@types/jsonwebtoken": "^9.0.10",
"@types/node": "^20",
"@types/react": "^19.1.8",
"@types/react-calendar-heatmap": "^1.9.0",
@@ -1920,6 +1923,34 @@
}
}
},
+ "node_modules/@radix-ui/react-alert-dialog": {
+ "version": "1.1.15",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.15.tgz",
+ "integrity": "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-dialog": "1.1.15",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-slot": "1.2.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@radix-ui/react-arrow": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
@@ -4500,6 +4531,17 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/jsonwebtoken": {
+ "version": "9.0.10",
+ "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
+ "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/ms": "*",
+ "@types/node": "*"
+ }
+ },
"node_modules/@types/linkify-it": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
@@ -9345,6 +9387,49 @@
"node": ">=6"
}
},
+ "node_modules/jsonwebtoken": {
+ "version": "9.0.2",
+ "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz",
+ "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==",
+ "license": "MIT",
+ "dependencies": {
+ "jws": "^3.2.2",
+ "lodash.includes": "^4.3.0",
+ "lodash.isboolean": "^3.0.3",
+ "lodash.isinteger": "^4.0.4",
+ "lodash.isnumber": "^3.0.3",
+ "lodash.isplainobject": "^4.0.6",
+ "lodash.isstring": "^4.0.1",
+ "lodash.once": "^4.0.0",
+ "ms": "^2.1.1",
+ "semver": "^7.5.4"
+ },
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ }
+ },
+ "node_modules/jsonwebtoken/node_modules/jwa": {
+ "version": "1.4.2",
+ "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz",
+ "integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==",
+ "license": "MIT",
+ "dependencies": {
+ "buffer-equal-constant-time": "^1.0.1",
+ "ecdsa-sig-formatter": "1.0.11",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/jsonwebtoken/node_modules/jws": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz",
+ "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==",
+ "license": "MIT",
+ "dependencies": {
+ "jwa": "^1.4.1",
+ "safe-buffer": "^5.0.1"
+ }
+ },
"node_modules/jspdf": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-3.0.3.tgz",
@@ -9749,6 +9834,42 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/lodash.includes": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
+ "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isboolean": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
+ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isinteger": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
+ "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isnumber": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
+ "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isplainobject": {
+ "version": "4.0.6",
+ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
+ "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isstring": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
+ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
+ "license": "MIT"
+ },
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@@ -9756,6 +9877,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/lodash.once": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
+ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
+ "license": "MIT"
+ },
"node_modules/longest-streak": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz",
@@ -12906,7 +13033,6 @@
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
- "devOptional": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
diff --git a/package.json b/package.json
index 3b400a3..4ee28a8 100644
--- a/package.json
+++ b/package.json
@@ -17,6 +17,7 @@
"@google/genai": "^1.27.0",
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-accordion": "^1.2.11",
+ "@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
@@ -67,6 +68,7 @@
"framer-motion": "^12.19.2",
"gsap": "^3.13.0",
"html2canvas": "^1.4.1",
+ "jsonwebtoken": "^9.0.2",
"jspdf": "^3.0.1",
"lucide-react": "^0.523.0",
"motion": "^12.19.2",
@@ -93,6 +95,7 @@
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@tanstack/eslint-plugin-query": "^5.91.0",
+ "@types/jsonwebtoken": "^9.0.10",
"@types/node": "^20",
"@types/react": "^19.1.8",
"@types/react-calendar-heatmap": "^1.9.0",
diff --git a/services/user/APIKeyService.ts b/services/user/APIKeyService.ts
new file mode 100644
index 0000000..8bb1bb4
--- /dev/null
+++ b/services/user/APIKeyService.ts
@@ -0,0 +1,53 @@
+import { apiCall } from "@/services/CoreApiService";
+import type {
+ CreateAPIKey,
+ APIKeyResponse,
+ ReadAPIKey,
+} from "@/types/server/dataforge/User/api-keys";
+
+const API_KEYS_PATH = "Dijkstra/v1/api-keys";
+
+/**
+ * List all API keys for the given GitHub username.
+ * Uses session JWT via CoreApiService → /api/dataforge/... → proxy.
+ * Backend path: GET /Dijkstra/v1/api-keys/{github_username}
+ */
+export async function listAPIKeysByGithubUsername(
+ username: string
+): Promise {
+ const response = await apiCall(
+ "dataforge",
+ `${API_KEYS_PATH}/${encodeURIComponent(username)}`
+ );
+ return response ?? [];
+}
+
+/**
+ * Create a new API key for the given GitHub username.
+ * Backend path: POST /Dijkstra/v1/api-keys/{github_username}
+ */
+export async function createAPIKeyByGithubUsername(
+ username: string,
+ data: CreateAPIKey
+): Promise {
+ return apiCall(
+ "dataforge",
+ `${API_KEYS_PATH}/${encodeURIComponent(username)}`,
+ {
+ method: "POST",
+ body: JSON.stringify(data),
+ }
+ );
+}
+
+/**
+ * Revoke an API key (DELETE on backend).
+ * Path: DELETE /Dijkstra/v1/api-keys/{api_key_id}
+ */
+export async function revokeAPIKeyByKeyId(keyId: string): Promise {
+ await apiCall(
+ "dataforge",
+ `${API_KEYS_PATH}/${encodeURIComponent(keyId)}`,
+ { method: "DELETE" }
+ );
+}
diff --git a/types/server/dataforge/User/api-keys.ts b/types/server/dataforge/User/api-keys.ts
new file mode 100644
index 0000000..a4458d8
--- /dev/null
+++ b/types/server/dataforge/User/api-keys.ts
@@ -0,0 +1,35 @@
+/**
+ * Type definitions for API Keys based on OpenAPI schema
+ */
+
+export interface CreateAPIKey {
+ description?: string | null;
+ expires_in?: string | null; // ISO 8601 date-time format
+ roles?: string[] | null; // Array of Role enum values
+}
+
+export interface UpdateAPIKey {
+ description?: string | null;
+ active?: boolean | null;
+ roles?: string[] | null;
+}
+
+export interface APIKeyResponse {
+ key: string; // Plain API key (only returned on creation)
+ id: string; // UUID
+ created_at: string; // ISO 8601 date-time format
+ expires_in?: string | null; // ISO 8601 date-time format
+ description?: string | null;
+}
+
+export interface ReadAPIKey {
+ id: string; // UUID
+ created_at: string; // ISO 8601 date-time format
+ updated_at: string; // ISO 8601 date-time format
+ expires_in?: string | null; // ISO 8601 date-time format
+ github_username: string;
+ description?: string | null;
+ active: boolean;
+ roles: string[]; // Array of Role enum values
+}
+
diff --git a/types/server/dataforge/User/user.ts b/types/server/dataforge/User/user.ts
index fa7f5d0..d01e101 100644
--- a/types/server/dataforge/User/user.ts
+++ b/types/server/dataforge/User/user.ts
@@ -56,6 +56,7 @@ export interface GetAuthDataResponse {
user_id: string;
profile_id: string;
github_user_name: string;
+ roles: string[];
}
export interface GetUserBasicResponse {