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 ( + + + + + + + + + + {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 +

+ +
+

+ Copy this key now. You won't be able to see it again! +

+
+ + + +
+
+ )} + + {/* Create New Key Button */} +
+ { + setShowCreateDialog(open); + if (!open) { + setSelectedRoles([]); + } + }} + > + + + + + + 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. + + +
+
+ + setNewKeyDescription(e.target.value)} + /> +
+
+ + setNewKeyExpiration(e.target.value)} + /> +

+ Leave empty for no expiration +

+
+
+ + +

+ {isDev + ? "Dev environment: All roles available" + : `Available roles: ${availableRoles.length > 0 ? availableRoles.join(", ") : "None"}`} +

+
+
+ + + + +
+
+
+ + {/* 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 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 {