diff --git a/src/app/(dashboard)/admin/keys/page.tsx b/src/app/(dashboard)/admin/keys/page.tsx index 070e570..7f39d39 100644 --- a/src/app/(dashboard)/admin/keys/page.tsx +++ b/src/app/(dashboard)/admin/keys/page.tsx @@ -9,11 +9,96 @@ import { BreadcrumbPage, BreadcrumbSeparator, } from "@/components/ui/breadcrumb"; +import { useCallback, useEffect, useState } from "react"; import { toast } from "sonner"; +import { ApiKeyDataTable } from "@/components/apiKeyTable/apiKey-table"; +import { ApiKeyColumn } from "@/components/apiKeyTable/columns"; +import ApiKeyRevealCard from "@/components/forms/ApiKeyRevealForm"; +import { getApiKeysAction, deleteApiKeyByIdAction } from "@/lib/actions"; +import { getProjects } from "@/lib/sdkActions"; + +type CreatedKeyInfo = { + value: string; + description: string; + environment: string; + project: string; + dateCreated: string; +}; export default function KeyPage() { + const [apiKeys, setApiKeys] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [createdKey, setCreatedKey] = useState(null); + const [projectNames, setProjectNames] = useState>({}); + const [pageIndex, setPageIndex] = useState(0); + const [pageSize, setPageSize] = useState(10); + const [paginationLinks, setPaginationLinks] = useState({ + first: "", + prev: "", + next: "", + last: "", + }); + const [keysLoadTrigger, setKeysLoadTrigger] = useState(0); + + useEffect(() => { + async function fetchProjects() { + const result = await getProjects(); + if (result.success) { + setProjectNames( + Object.fromEntries( + result.projects.map((p) => [String(p.id), String(p.name ?? "")]), + ), + ); + } + } + fetchProjects(); + }, []); + + const fetchApiKeys = useCallback(async () => { + setIsLoading(true); + try { + const result = await getApiKeysAction({ + offset: pageIndex * pageSize, + limit: pageSize, + }); + if (result.success) { + const mapped: ApiKeyColumn[] = (result.keys ?? []).map((key) => ({ + id: Number(key.id), + description: key.description ?? "", + dateCreated: key.createdAt + ? new Date(key.createdAt).toISOString().split("T")[0] + : "N/A", + linkedProject: projectNames[String(key.project)] ?? "Unknown", + environment: key.environment ?? "N/A", + })); + setApiKeys(mapped); + setPaginationLinks(result.links); + } else { + toast.error(result.error ?? "Failed to fetch API keys"); + } + } catch { + toast.error("Failed to fetch API keys"); + } finally { + setIsLoading(false); + } + }, [pageIndex, pageSize, projectNames]); + + useEffect(() => { + fetchApiKeys(); + }, [fetchApiKeys, keysLoadTrigger]); + + const handleDelete = async (key: ApiKeyColumn) => { + const result = await deleteApiKeyByIdAction(String(key.id)); + if (result.success) { + setApiKeys((prev) => prev.filter((k) => k.id !== key.id)); + toast.success("API key deleted successfully."); + } else { + toast.error(result.error ?? "Failed to delete API key"); + } + }; + return ( -
+
@@ -25,12 +110,47 @@ export default function KeyPage() { +
+
+ { + setCreatedKey({ + value: newKey.value, + description: newKey.description, + environment: newKey.environment, + project: newKey.project.name, + dateCreated: new Date().toISOString().split("T")[0], + }); + toast.success("Successfully created API key"); + setKeysLoadTrigger((t) => t + 1); + }} + /> + + +
- { - toast.success("Successfully created API key"); - }} - /> + { + if (action === "delete") { + await handleDelete(key); + } + }} + /> +
); } diff --git a/src/app/(dashboard)/admin/layout.tsx b/src/app/(dashboard)/admin/layout.tsx index a294d2b..e0d77d3 100644 --- a/src/app/(dashboard)/admin/layout.tsx +++ b/src/app/(dashboard)/admin/layout.tsx @@ -12,7 +12,7 @@ export default function AdminLayout({ -
+
{children}
diff --git a/src/app/(dashboard)/admin/users/page.tsx b/src/app/(dashboard)/admin/users/page.tsx index 91ddcaa..284ceca 100644 --- a/src/app/(dashboard)/admin/users/page.tsx +++ b/src/app/(dashboard)/admin/users/page.tsx @@ -12,7 +12,7 @@ import { import { Separator } from "@/components/ui/separator"; import { getProjects, getUsers } from "@/lib/sdkActions"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { ProjectResponse } from "juno-sdk/build/main/internal/index"; +import type { ProjectResponse } from "juno-sdk/build/main/internal/index"; import { toast } from "sonner"; import { UserColumn } from "../../../../components/usertable/columns"; import { UserDataTable } from "../../../../components/usertable/data-table"; diff --git a/src/app/(dashboard)/projects/[projectId]/layout.tsx b/src/app/(dashboard)/projects/[projectId]/layout.tsx index 3788022..82d5606 100644 --- a/src/app/(dashboard)/projects/[projectId]/layout.tsx +++ b/src/app/(dashboard)/projects/[projectId]/layout.tsx @@ -21,7 +21,7 @@ export default async function ProjectLayout({ currProjName={project.name} projectId={Number(projectId)} /> -
+
{children}
diff --git a/src/app/(dashboard)/projects/[projectId]/page.tsx b/src/app/(dashboard)/projects/[projectId]/page.tsx index 4cd862e..84eff7e 100644 --- a/src/app/(dashboard)/projects/[projectId]/page.tsx +++ b/src/app/(dashboard)/projects/[projectId]/page.tsx @@ -30,7 +30,7 @@ import { getCustomEventTypes, } from "@/lib/settings"; import { useQuery } from "@tanstack/react-query"; -import { ProjectResponse } from "juno-sdk/build/main/internal/index"; +import type { ProjectResponse } from "juno-sdk/build/main/internal/index"; import { BarChart3, Settings } from "lucide-react"; import Link from "next/link"; import { useParams } from "next/navigation"; @@ -209,9 +209,18 @@ const DashboardPage = () => { refetchOnWindowFocus: true, }); - const clickData: Event[] = clickEventsResponse?.events?.events || []; - const inputData: Event[] = inputEventsResponse?.events?.events || []; - const visitData: Event[] = visitEventsResponse?.events?.events || []; + const clickData: Event[] = useMemo( + () => clickEventsResponse?.events?.events || [], + [clickEventsResponse], + ); + const inputData: Event[] = useMemo( + () => inputEventsResponse?.events?.events || [], + [inputEventsResponse], + ); + const visitData: Event[] = useMemo( + () => visitEventsResponse?.events?.events || [], + [visitEventsResponse], + ); const customEventTypes: CustomEventType[] = (() => { const types = customEventTypesResponse?.eventTypes; @@ -289,7 +298,7 @@ const DashboardPage = () => { }; fetchAllCustomEvents(); - }, [projectName, customEventTypesJson]); + }, [projectName, projectId, customEventTypesJson]); const customEventTypesById = useMemo( () => diff --git a/src/app/(dashboard)/projects/[projectId]/services/email/page.tsx b/src/app/(dashboard)/projects/[projectId]/services/email/page.tsx index 3b69ff4..1313223 100644 --- a/src/app/(dashboard)/projects/[projectId]/services/email/page.tsx +++ b/src/app/(dashboard)/projects/[projectId]/services/email/page.tsx @@ -16,7 +16,7 @@ import { Skeleton } from "@/components/ui/skeleton"; import { getProjectById } from "@/lib/project"; import { getEmailAnalytics, getEmailConfig } from "@/lib/settings"; import { useQuery } from "@tanstack/react-query"; -import { ProjectResponse } from "juno-sdk/build/main/internal/index"; +import type { ProjectResponse } from "juno-sdk/build/main/internal/index"; import { Mail, Settings } from "lucide-react"; import Link from "next/link"; import { useParams } from "next/navigation"; diff --git a/src/app/(dashboard)/projects/[projectId]/users/page.tsx b/src/app/(dashboard)/projects/[projectId]/users/page.tsx index a2571a9..dfa5732 100644 --- a/src/app/(dashboard)/projects/[projectId]/users/page.tsx +++ b/src/app/(dashboard)/projects/[projectId]/users/page.tsx @@ -13,7 +13,7 @@ import { UserColumn } from "@/components/usertable/columns"; import { UserDataTable } from "@/components/usertable/data-table"; import { getProjectUsers } from "@/lib/actions"; import { getProjects } from "@/lib/sdkActions"; -import { ProjectResponse } from "juno-sdk/build/main/internal/index"; +import type { ProjectResponse } from "juno-sdk/build/main/internal/index"; import { useParams } from "next/navigation"; import { useEffect, useState } from "react"; import { toast } from "sonner"; diff --git a/src/components/apiKeyTable/apiKey-table.tsx b/src/components/apiKeyTable/apiKey-table.tsx new file mode 100644 index 0000000..6376a7e --- /dev/null +++ b/src/components/apiKeyTable/apiKey-table.tsx @@ -0,0 +1,397 @@ +"use client"; + +import { + ColumnFiltersState, + SortingState, + VisibilityState, + flexRender, + getCoreRowModel, + getFilteredRowModel, + getSortedRowModel, + useReactTable, +} from "@tanstack/react-table"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { ChevronDown } from "lucide-react"; +import { useEffect, useState } from "react"; +import SkeletonRows from "../table/SkeletonRows"; +import { Card, CardDescription, CardTitle } from "../ui/card"; +import { ApiKeyColumn, apiKeyColumns } from "./columns"; + +export type PaginationLinks = { + first: string; + prev: string; + next: string; + last: string; +}; + +interface ApiKeyDataTableProps { + data: ApiKeyColumn[]; + isLoading: boolean; + onKeyAction: (key: ApiKeyColumn, action: "delete") => Promise; + pageIndex: number; + pageSize: number; + paginationLinks: PaginationLinks; + onPageIndexChange: (pageIndex: number) => void; + onPageSizeChange: (pageSize: number) => void; +} + +function parseOffsetFromLink(link: string, pageSize: number): number { + try { + const url = new URL(link, "http://placeholder"); + const offset = Number(url.searchParams.get("offset") ?? 0); + return Math.floor(offset / pageSize); + } catch { + return 0; + } +} + +export function ApiKeyDataTable({ + data, + isLoading, + onKeyAction, + pageIndex, + pageSize, + paginationLinks, + onPageIndexChange, + onPageSizeChange, +}: ApiKeyDataTableProps) { + const [sorting, setSorting] = useState([]); + const [columnFilters, setColumnFilters] = useState([]); + const [columnVisibility, setColumnVisibility] = useState({}); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [pendingDeleteKey, setPendingDeleteKey] = useState( + null, + ); + const [hasSelectedRows, setHasSelectedRows] = useState(false); + const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false); + + const handleSingleDelete = (apiKey: ApiKeyColumn) => { + setPendingDeleteKey(apiKey); + setIsDeleteDialogOpen(true); + }; + + const columns = apiKeyColumns(handleSingleDelete); + + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + onSortingChange: setSorting, + getSortedRowModel: getSortedRowModel(), + onColumnFiltersChange: setColumnFilters, + getFilteredRowModel: getFilteredRowModel(), + manualPagination: true, + onColumnVisibilityChange: setColumnVisibility, + onPaginationChange: (updater) => { + const next = + typeof updater === "function" + ? updater({ pageIndex, pageSize }) + : updater; + onPageIndexChange(next.pageIndex); + onPageSizeChange(next.pageSize); + }, + state: { + sorting, + columnFilters, + columnVisibility, + pagination: { pageIndex, pageSize }, + }, + }); + + const selectedRows = table.getSelectedRowModel().rows; + useEffect(() => { + setHasSelectedRows(selectedRows.length > 0); + }, [selectedRows]); + + const confirmSingleDelete = async () => { + if (pendingDeleteKey) { + await onKeyAction(pendingDeleteKey, "delete"); + } + setPendingDeleteKey(null); + setIsDeleteDialogOpen(false); + }; + + const handleBulkDelete = async () => { + await Promise.all( + selectedRows.map((row) => + onKeyAction(row.original as ApiKeyColumn, "delete"), + ), + ); + table.resetRowSelection(); + setIsBulkDeleteDialogOpen(false); + }; + + return ( + +
+ +

Your API Keys

+
+ +

+ View API keys you created for Juno projects. +

+
+
+
+
+ { + table + .getColumn("description") + ?.setFilterValue(event.target.value); + }} + /> + +
+ {hasSelectedRows && ( + + )} + + + + + + {table + .getAllColumns() + .filter((column) => column.getCanHide()) + .map((column) => ( + + column.toggleVisibility(!!value) + } + > + {column.id} + + ))} + + +
+
+ + {/* Single delete confirmation */} + + + + Delete API Key + + Are you sure you want to delete the API key " + {pendingDeleteKey?.description}"? This action cannot be + undone. + + + + + + + + + + {/* Bulk delete confirmation */} + + + + Delete Selected API Keys + + Are you sure you want to delete {selectedRows.length} selected + API key{selectedRows.length > 1 ? "s" : ""}? This action cannot + be undone. + + + + + + + + + +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ))} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row, index) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} + + )) + ) : isLoading ? ( + + ) : ( + + + No results. + + + )} + +
+
+ + {/* Pagination footer */} +
+
+ Rows per page + +
+
+ + + + +
+
+
+
+ ); +} diff --git a/src/components/apiKeyTable/columns.tsx b/src/components/apiKeyTable/columns.tsx new file mode 100644 index 0000000..32c48ad --- /dev/null +++ b/src/components/apiKeyTable/columns.tsx @@ -0,0 +1,185 @@ +"use client"; + +import { Checkbox } from "@/components/ui/checkbox"; +import { Button } from "@/components/ui/button"; +import { ColumnDef } from "@tanstack/react-table"; +import { ArrowUpDown, Trash2 } from "lucide-react"; +import { Badge } from "../ui/badge"; + +export type ApiKeyColumn = { + id: number; + description: string; + dateCreated: string; + linkedProject: string; + environment: string; +}; + +const PROJECT_COLORS = [ + "bg-emerald-900", + "bg-orange-900", + "bg-sky-700", + "bg-purple-700", + "bg-rose-700", + "bg-teal-700", +]; + +const projectColorMap = new Map(); + +function getProjectColor(project: string): string { + if (!projectColorMap.has(project)) { + projectColorMap.set( + project, + PROJECT_COLORS[projectColorMap.size % PROJECT_COLORS.length], + ); + } + return projectColorMap.get(project)!; +} + +export const apiKeyColumns = ( + onDelete: (apiKey: ApiKeyColumn) => void, +): ColumnDef[] => { + return [ + { + id: "select", + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!value)} + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!value)} + /> + ), + size: 50, + }, + { + accessorKey: "id", + header: ({ column }) => ( +
+ ID + +
+ ), + cell: ({ row }) =>
{row.original.id}
, + }, + { + accessorKey: "description", + header: ({ column }) => ( +
+ Description + +
+ ), + cell: ({ row }) => ( +
{row.original.description}
+ ), + size: 300, + }, + { + accessorKey: "dateCreated", + header: ({ column }) => ( +
+ Date Created + +
+ ), + cell: ({ row }) => ( +
+ + + {row.original.dateCreated} + + +
+ ), + }, + { + accessorKey: "linkedProject", + header: ({ column }) => ( +
+ Linked Project + +
+ ), + cell: ({ row }) => { + const project = row.original.linkedProject; + const colorClass = getProjectColor(project); + return ( +
+ + {project} + +
+ ); + }, + }, + { + accessorKey: "environment", + header: ({ column }) => ( +
+ Environment + +
+ ), + cell: ({ row }) => ( +
+ {row.original.environment} +
+ ), + }, + { + id: "actions", + cell: ({ row }) => ( + + ), + size: 50, + }, + ]; +}; diff --git a/src/components/forms/ApiKeyRevealForm.tsx b/src/components/forms/ApiKeyRevealForm.tsx new file mode 100644 index 0000000..543e6a2 --- /dev/null +++ b/src/components/forms/ApiKeyRevealForm.tsx @@ -0,0 +1,207 @@ +"use client"; + +import { + Check, + Clipboard, + Eye, + EyeOff, + Terminal, + TriangleAlert, +} from "lucide-react"; +import { useState } from "react"; +import { Card, CardContent, CardDescription, CardTitle } from "../ui/card"; + +type ApiKeyRevealCardProps = { + keyValue: string | null; + description: string; + environment: string; + project: string; + dateCreated: string; +}; + +const ApiKeyRevealCard = ({ + keyValue, + description, + environment, + project, + dateCreated, +}: ApiKeyRevealCardProps) => { + const [revealed, setRevealed] = useState(false); + const [copied, setCopied] = useState(false); + + const maskedValue = keyValue ? "*".repeat(6) : null; + const displayValue = keyValue ? (revealed ? keyValue : maskedValue) : null; + + const handleCopy = async () => { + if (!keyValue) return; + await navigator.clipboard.writeText(`JUNO_API_KEY=${keyValue}`); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + const lines = keyValue + ? [ + { + num: 1, + content: ( + + # Juno SDK Configuration — {project} ({environment}) + + ), + }, + { + num: 2, + content: ( + + # Created: {dateCreated} | {description} + + ), + }, + { num: 3, content: null }, + { + num: 4, + content: ( + <> + JUNO_API_KEY + = + {displayValue} + + ), + }, + { + num: 5, + content: ( + <> + JUNO_BASE_URL + = + http://localhost:8888 + + ), + }, + { num: 6, content: null }, + { + num: 7, + content: ( + + # Do not commit this file to version control + + ), + }, + { + num: 8, + content: ( + + # Add .env.local to your .gitignore + + ), + }, + ] + : []; + + return ( + +
+ +

API Key Details

+
+ + Paste the line below into your project's environment variables + file. + +
+ + + {/* Code Block Area */} +
+ {keyValue ? ( +
+ {/* Code editor */} +
+ {/* Code block header */} +
+ {/* Left: traffic lights + terminal icon + filename */} +
+ {/* macOS traffic lights */} +
+ + + +
+ + + .env.local + +
+ {/* Right: eye + copy */} +
+ +
+ +
+
+ {/* Code content */} +
+ {lines.map((line) => ( +
+ + {line.num} + + {line.content ?? "\u00A0"} +
+ ))} +
+
+ +
+ +

+ Warning: You will not be able to view your API key again after + leaving this page. Please copy it now. +

+
+
+ ) : ( +
+ +

No API key created yet.

+

+ Fill in the form and click "Create API Key" to + generate one. +

+
+ )} +
+ + + ); +}; + +export default ApiKeyRevealCard; diff --git a/src/components/forms/CreateAPIKeyForm.tsx b/src/components/forms/CreateAPIKeyForm.tsx index 2062bb0..f51e326 100644 --- a/src/components/forms/CreateAPIKeyForm.tsx +++ b/src/components/forms/CreateAPIKeyForm.tsx @@ -1,7 +1,23 @@ "use client"; import { zodResolver } from "@hookform/resolvers/zod"; +import { createKeyAction } from "@/lib/actions"; +import { ProjectIdentifier } from "juno-sdk/build/main/lib/identifiers"; +import { Check, ChevronsUpDown, Loader2 } from "lucide-react"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import * as z from "zod"; +import { cn } from "@/lib/utils"; import { Button } from "../ui/button"; +import { Card, CardContent, CardDescription, CardTitle } from "../ui/card"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "../ui/command"; import { Form, FormControl, @@ -12,12 +28,7 @@ import { FormMessage, } from "../ui/form"; import { Input } from "../ui/input"; -import * as z from "zod"; -import { useForm } from "react-hook-form"; -import { CircleX, Loader2 } from "lucide-react"; -import { useState } from "react"; -import { Alert } from "../ui/alert"; -import { ProjectIdentifier } from "juno-sdk/build/main/lib/identifiers"; +import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; import { Select, SelectContent, @@ -27,8 +38,8 @@ import { } from "../ui/select"; export enum Environment { - Dev = "Dev", - Prod = "Prod", + dev = "dev", + prod = "prod", } const createAPIKeySchema = z.object({ @@ -41,128 +52,203 @@ export type APIKey = { description: string; environment: Environment; project: ProjectIdentifier; + value: string; }; type CreateAPIKeyFormProps = { onKeyAdd: (newKey: APIKey) => void; onClose?: () => void; + projects?: string[]; }; -const CreateAPIKeyForm = ({ onKeyAdd, onClose }: CreateAPIKeyFormProps) => { - /** Form to create a user */ - const createUserForm = useForm({ +const CreateAPIKeyForm = ({ + onKeyAdd, + onClose, + projects = [], +}: CreateAPIKeyFormProps) => { + const createApiKeyForm = useForm({ resolver: zodResolver(createAPIKeySchema), defaultValues: { description: "", - environment: Environment.Dev, + environment: Environment.dev, projectName: "", }, }); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); + const [comboboxOpen, setComboboxOpen] = useState(false); - const handleCreateUser = async ( + const handleCreateApiKey = async ( data: Required>, ) => { setLoading(true); try { - // TODO: Placeholder until SDK has createKey authentication added + const result = await createKeyAction({ + projectName: data.projectName, + environment: data.environment, + description: data.description, + }); + + if (!result.success) { + setError(result.error ?? "Failed to create API key"); + setLoading(false); + return; + } + const key: APIKey = { environment: data.environment, description: data.description, project: { name: data.projectName }, + value: result.apiKey, }; + setError(""); onKeyAdd(key); setLoading(false); if (onClose) { onClose(); } - } catch (error) { - console.error("Error creating user:", error); - setError(error); + } catch { + setError("An error occurred while creating the API key"); + setLoading(false); } }; return ( -
- {error.length > 0 ? ( - -
- -
Error: {error}
-
-
- ) : ( - <> - )} - - ( - - Project Name - - - - - - Name of the project to add an API key - - - )} - /> - ( - - Email - - - - - - Description of what this API key is intended for. Please be - descriptive with your use case - - - )} - /> - ( - - Environment - - - + +
+ Create a New API Key + + API keys allow you to access Juno services programmatically. + +
+ + + {error.length > 0 && ( +

Error: {error}

)} - /> - - - +
+ ( + + Project Name + + + + + + + + + + + No projects found. + + {projects.map((project) => ( + { + field.onChange(project); + setComboboxOpen(false); + }} + > + + {project} + + ))} + + + + + + + + Name of the project to add an API key + + + )} + /> + ( + + Description + + + + + + Description of the use case for this API key + + + )} + /> + ( + + Environment + + + + Select the environment this API key will be used in. + + + )} + /> + + + +
+
); }; diff --git a/src/components/forms/EditUserForm.tsx b/src/components/forms/EditUserForm.tsx index 7761091..4582432 100644 --- a/src/components/forms/EditUserForm.tsx +++ b/src/components/forms/EditUserForm.tsx @@ -6,8 +6,8 @@ import { unlinkUserFromProject, } from "@/lib/actions"; import { zodResolver } from "@hookform/resolvers/zod"; -import { SetUserTypeModelTypeEnum } from "juno-sdk/build/main/internal/index"; -import { useState, useMemo } from "react"; +import { useMemo, useState } from "react"; +import { SetUserTypeModelTypeEnum } from "juno-sdk/build/main/internal"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import * as z from "zod"; diff --git a/src/components/usertable/columns.tsx b/src/components/usertable/columns.tsx index d1f6ecd..689fdae 100644 --- a/src/components/usertable/columns.tsx +++ b/src/components/usertable/columns.tsx @@ -5,8 +5,8 @@ import { Badge } from "@/components/ui/badge"; import { Checkbox } from "@/components/ui/checkbox"; import { UserAvatar } from "@/components/ui/user-avatar"; import { ColumnDef } from "@tanstack/react-table"; -import { SetUserTypeModelTypeEnum } from "juno-sdk/build/main/internal/index"; import { UserActionsCell } from "./user-actions-cell"; +import { SetUserTypeModelTypeEnum } from "juno-sdk/build/main/internal"; export type UserColumn = { id: number; diff --git a/src/lib/actions.ts b/src/lib/actions.ts index 58d41ab..86787b4 100644 --- a/src/lib/actions.ts +++ b/src/lib/actions.ts @@ -2,8 +2,6 @@ import { getJunoInstance } from "@/lib/juno"; import { cookies } from "next/headers"; -import { SetUserTypeModelTypeEnum } from "juno-sdk/build/main/internal/index"; -import { APIKey } from "@/components/forms/CreateAPIKeyForm"; import { getSession } from "./session"; import { verifyJWT, @@ -12,6 +10,7 @@ import { hasProjectAccess, } from "./auth"; import { getDefaultRouteForUser } from "./userRouting"; +import { SetUserTypeModelTypeEnum } from "juno-sdk/build/main/internal"; export async function setUserTypeAction(data: { email: string; @@ -94,9 +93,100 @@ export async function createUserAction(data: { } } -export async function createKeyAction(data: APIKey) { - // TODO: Create key requires JWT credential changes to the SDK. These should be done as soon as possible. - return data; +export async function createKeyAction(data: { + projectName: string; + environment: string; + description: string; +}) { + const session = await getSession(); + if (!session) { + return { success: false, error: "Unauthorized" }; + } + + if (!requireAdmin(session.user)) { + return { success: false, error: "Only admins can create API keys" }; + } + + const junoClient = getJunoInstance(); + + try { + const result = await junoClient.auth.createKey({ + project: data.projectName, + environment: data.environment, + description: data.description, + credentials: session.jwt, + }); + return { success: true, apiKey: result.apiKey }; + } catch (error) { + console.error("Error creating API key:", error); + return { success: false, error: "Failed to create API key" }; + } +} + +export async function getApiKeysAction(options: { + offset: number; + limit: number; +}) { + const { offset, limit } = options; + const session = await getSession(); + if (!session) { + return { success: false, error: "Unauthorized", keys: [] }; + } + + if (!requireAdmin(session.user)) { + return { + success: false, + error: "Only admins and superadmins can view API keys", + keys: [], + }; + } + + const junoClient = getJunoInstance(); + + try { + const res = await junoClient.auth.getAllApiKeys({ + offset, + limit, + credentials: session.jwt, + }); + return { + success: true, + keys: res.keys ?? [], + links: res.links ?? { first: "", prev: "", next: "", last: "" }, + }; + } catch (error) { + console.error("Error fetching API keys:", error); + return { + success: false, + error: "Failed to fetch API keys", + keys: [], + links: { first: "", prev: "", next: "", last: "" }, + }; + } +} + +export async function deleteApiKeyByIdAction(keyId: string) { + const session = await getSession(); + if (!session) { + return { success: false, error: "Unauthorized" }; + } + + if (!requireAdmin(session.user)) { + return { success: false, error: "Only admins can delete API keys" }; + } + + const junoClient = getJunoInstance(); + + try { + await junoClient.auth.deleteApiKeyById({ + keyId, + credentials: session.jwt, + }); + return { success: true }; + } catch (error) { + console.error("Error deleting API key:", error); + return { success: false, error: "Failed to delete API key" }; + } } export async function createProjectAction(data: { projectName: string }) { @@ -281,6 +371,8 @@ export async function createJWTAuthentication(data: { export async function deleteJWT() { const cookieStore = await cookies(); cookieStore.delete("jwt-token"); + cookieStore.delete("user-email"); + cookieStore.delete("user-password"); } export async function deleteUserAction(userId: string) {