commit 8a96117383987ada8b5816a5fe4763df41d4876c Author: christian Date: Sun Sep 28 22:42:48 2025 +0100 init diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a74c665 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +Dockerfile +.dockerignore +node_modules +npm-debug.log +keygen-docker-compose +README.md +.next +docker +.git \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4735382 --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +KEYGEN_HOST=api.keygen.localhost +KEYGEN_ADMIN_USER=me@email.com +KEYGEN_ADMIN_PASS=password123 + + +NEXT_PUBLIC_KEYGEN_HOST=https://api.keygen.localhost/v1 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e72b4d6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9e70570 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,67 @@ +# syntax=docker.io/docker/dockerfile:1 + +FROM node:20-alpine AS base + +# Install dependencies only when needed +FROM base AS deps +# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. +RUN apk add --no-cache libc6-compat +WORKDIR /app + +# Install dependencies based on the preferred package manager +COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./ +RUN \ + if [ -f yarn.lock ]; then yarn --frozen-lockfile; \ + elif [ -f package-lock.json ]; then npm ci; \ + elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \ + else echo "Lockfile not found." && exit 1; \ + fi + + +# Rebuild the source code only when needed +FROM base AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +# Next.js collects completely anonymous telemetry data about general usage. +# Learn more here: https://nextjs.org/telemetry +# Uncomment the following line in case you want to disable telemetry during the build. +# ENV NEXT_TELEMETRY_DISABLED=1 + +RUN \ + if [ -f yarn.lock ]; then yarn run build; \ + elif [ -f package-lock.json ]; then npm run build; \ + elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \ + else echo "Lockfile not found." && exit 1; \ + fi + +# Production image, copy all the files and run next +FROM base AS runner +WORKDIR /app + +ENV NODE_ENV=${NODE_ENV} +# Uncomment the following line in case you want to disable telemetry during runtime. +# ENV NEXT_TELEMETRY_DISABLED=1 + +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +COPY --from=builder /app/public ./public + +# Automatically leverage output traces to reduce image size +# https://nextjs.org/docs/advanced-features/output-file-tracing +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +USER nextjs + +EXPOSE ${DASHBOARD_PORT} + +ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL} +ENV PORT=${DASHBOARD_PORT} + +# server.js is created by next build from the standalone output +# https://nextjs.org/docs/pages/api-reference/config/next-config-js/output +ENV HOSTNAME="0.0.0.0" +CMD ["node", "server.js"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..e215bc4 --- /dev/null +++ b/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/app/dashboard/entitlements/page.tsx b/app/dashboard/entitlements/page.tsx new file mode 100644 index 0000000..2502cd8 --- /dev/null +++ b/app/dashboard/entitlements/page.tsx @@ -0,0 +1,239 @@ +'use client'; + +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { ColumnDef } from '@tanstack/react-table'; +import { format } from 'date-fns'; +import { Edit, Gift, MoreHorizontal, Plus, Trash2 } from 'lucide-react'; +import { useState } from 'react'; + +import { DataTable } from '@/components/data-table'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { apiClient } from '@/lib/api-client'; +import { KeygenEntitlement } from '@/lib/types'; +import { toast } from 'sonner'; + +export default function EntitlementsPage() { + const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [selectedEntitlement, setSelectedEntitlement] = useState(null); + const [formData, setFormData] = useState({ + name: '', + code: '', + }); + + const queryClient = useQueryClient(); + + const { data, isLoading } = useQuery({ + queryKey: ['entitlements'], + queryFn: () => apiClient.getEntitlements(1, 100), + }); + + const createMutation = useMutation({ + mutationFn: (entitlementData: { name: string; code: string }) => apiClient.createEntitlement(entitlementData), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ['entitlements'] }); + setIsCreateDialogOpen(false); + setFormData({ name: '', code: '' }); + toast.success('Entitlement created successfully'); + }, + onError: (error) => { + toast.error(error.message); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: (id: string) => apiClient.deleteEntitlement(id), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ['entitlements'] }); + setIsDeleteDialogOpen(false); + setSelectedEntitlement(null); + toast.success('Entitlement deleted successfully'); + }, + onError: (error) => { + toast.error(error.message); + }, + }); + + const handleCreate = () => { + createMutation.mutate(formData); + }; + + const handleDelete = () => { + if (selectedEntitlement) { + deleteMutation.mutate(selectedEntitlement.id); + } + }; + + const columns: ColumnDef[] = [ + { + accessorKey: 'attributes.name', + header: 'Name', + cell: ({ row }) => ( +
+ + {row.original.attributes.name} +
+ ), + }, + { + accessorKey: 'attributes.code', + header: 'Code', + cell: ({ row }) => ( + + {row.original.attributes.code} + + ), + }, + { + accessorKey: 'attributes.created', + header: 'Created', + cell: ({ row }) => format(new Date(row.original.attributes.created), 'MMM d, yyyy'), + }, + { + id: 'actions', + cell: ({ row }) => { + const entitlement = row.original; + return ( + + + + + + + + Edit + + { + setSelectedEntitlement(entitlement); + setIsDeleteDialogOpen(true); + }} + className="text-red-600" + > + + Delete + + + + ); + }, + }, + ]; + + if (isLoading) { + return ( + <> +
+
+
+

Loading entitlements...

+
+
+ + ); + } + + return ( + <> +
+
+
+

Entitlements

+

+ Manage feature entitlements and access controls +

+
+ +
+ + + + {/* Create Entitlement Dialog */} + + + + Create Entitlement + + Define a new feature entitlement for your products + + +
+
+ + setFormData({ ...formData, name: e.target.value })} + placeholder="Premium Features" + /> +
+
+ + setFormData({ ...formData, code: e.target.value })} + placeholder="PREMIUM_FEATURES" + /> +
+
+ + + + +
+
+ + {/* Delete Entitlement Dialog */} + + + + Delete Entitlement + + Are you sure you want to delete {selectedEntitlement?.attributes.name}? This action cannot be undone. + + + + + + + + +
+ + ); +} \ No newline at end of file diff --git a/app/dashboard/layout.tsx b/app/dashboard/layout.tsx new file mode 100644 index 0000000..fa70016 --- /dev/null +++ b/app/dashboard/layout.tsx @@ -0,0 +1,13 @@ +import { DashboardLayout } from "@/components/dashboard-layout"; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + <> + {children} + + ); +} diff --git a/app/dashboard/licenses/[id]/page.tsx b/app/dashboard/licenses/[id]/page.tsx new file mode 100644 index 0000000..55de144 --- /dev/null +++ b/app/dashboard/licenses/[id]/page.tsx @@ -0,0 +1,652 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { ArrowLeft, Copy, Key, Loader, Save, Trash2 } from "lucide-react"; +import { useParams, useRouter } from "next/navigation"; +import { useEffect, useMemo, useState } from "react"; // Import useEffect +import { useForm } from "react-hook-form"; +import * as z from "zod"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; +import { apiClient } from "@/lib/api-client"; +import { + KeygenGroup, + KeygenLicense, + KeygenPolicy, + KeygenProduct, + KeygenUser, +} from "@/lib/types"; +import { parseMetadata } from "@/lib/utils"; +import { toast } from "sonner"; + +const licenseSchema = z.object({ + name: z.string().min(1, "License name is required"), + user: z.string().optional(), + policy: z.string(), + expiry: z.string().min(1, "Expiry date is required"), + group: z.string().optional(), + product: z.string().optional(), + metadata: z.string().optional(), +}); + +type LicenseFormData = z.infer; + +export default function LicenseDetailPage() { + const params = useParams(); + const router = useRouter(); + const queryClient = useQueryClient(); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + + const licenseId = params.id as string; + const isNew = licenseId === "new"; + + const { + data: license, + isLoading: isLicenseLoading, + isFetched: isLicenseFetched, + } = useQuery({ + queryKey: ["license", licenseId], + queryFn: async () => await apiClient.getLicense(licenseId), + enabled: !isNew, + }); + + const { data: policies, isLoading: policiesLoading } = useQuery({ + queryKey: ["policies"], + queryFn: async () => await apiClient.getPolicies(1, 100), + staleTime: 1000, + }); + + const { data: users, isLoading: usersLoading } = useQuery({ + queryKey: ["users"], + queryFn: async () => await apiClient.getUsers(1, 100), + }); + + const { data: products, isLoading: productsLoading } = useQuery({ + queryKey: ["products"], + queryFn: async () => await apiClient.getProducts(1, 100), + }); + + const { data: groups, isLoading: groupsLoading } = useQuery({ + queryKey: ["groups"], + queryFn: async () => await apiClient.getGroups(1, 100), + }); + + const createMutation = useMutation({ + mutationFn: async (licenseData: Partial) => + await apiClient.createLicense({ + attributes: licenseData.attributes, + relationships: licenseData.relationships, + }), + onSuccess: async (data) => { + await queryClient.invalidateQueries({ queryKey: ["licenses"] }); + toast.success("License created successfully"); + router.push(`/dashboard/licenses/${data.data.id}`); + }, + onError: (error) => { + toast.error(error.message || "Failed to create license"); + }, + }); + + const updateMutation = useMutation({ + mutationFn: async (licenseData: Partial) => + await apiClient.updateLicense(licenseId, { + attributes: licenseData.attributes, + relationships: licenseData.relationships, + }), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["licenses"] }); + await queryClient.invalidateQueries({ queryKey: ["license", licenseId] }); + toast.success("License updated successfully"); + }, + onError: (error) => { + toast.error(error.message || "Failed to update license"); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: () => apiClient.deleteLicense(licenseId), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["licenses"] }); + toast.success("License deleted successfully"); + router.push("/dashboard/licenses"); + }, + onError: (error) => { + toast.error(error.message || "Failed to delete license"); + }, + }); + + // Function to create relationships object + const getRelationships = (data: LicenseFormData) => ({ + ...(data.user && { + owner: { + data: { type: "users", id: data.user }, + }, + }), + ...(data.policy && { + policy: { + data: { type: "policies", id: data.policy }, + }, + }), + ...(data.product && { + product: { + data: { type: "products", id: data.product }, + }, + }), + ...(data.group && { + group: { + data: { type: "groups", id: data.group }, + }, + }), + }); + + const onSubmit = async (data: LicenseFormData) => { + const formattedExpiry = new Date(data.expiry).toISOString(); + + const licenseData = { + attributes: { + name: data.name, + expiry: formattedExpiry, + metadata: parseMetadata(data.metadata || ""), + }, + relationships: isNew ? getRelationships(data) : undefined, + }; + + if (isNew) { + await createMutation.mutateAsync(licenseData as Partial); + } else { + await updateMutation.mutateAsync(licenseData as Partial); + } + }; + + // Memoized default values + const defaultValues = useMemo(() => { + if (isNew || !license?.data) { + return { + name: "", + user: undefined, + policy: undefined, + expiry: "", + group: undefined, + product: undefined, + }; + } + + if (!license) return undefined; + + const expiry = license.data.attributes.expiry + ? new Date(license.data.attributes.expiry).toISOString().slice(0, 16) + : ""; +console.log({license}) + return { + name: license.data.attributes.name ?? "", + user: license.data.relationships?.owner?.data?.id, + policy: license.data.relationships?.policy?.data?.id, + expiry, + group: license.data.relationships?.group?.data?.id, + product: license.data.relationships?.product?.data?.id, + metadata: license.data.attributes.metadata + ? JSON.stringify(license.data.attributes.metadata) + : undefined, + }; + }, [isNew, license]); + + const form = useForm({ + resolver: zodResolver(licenseSchema), + mode: "onBlur", + defaultValues, + }); + + useEffect(() => { + if ( + !isNew && + isLicenseFetched && + license?.data && + !form.formState.isDirty + ) { + form.reset(defaultValues); + } else if (isNew && !form.formState.isDirty) { + form.reset(defaultValues); + } + }, [form, isNew, isLicenseFetched, license?.data, defaultValues]); + + const handleDelete = () => { + deleteMutation.mutate(); + setIsDeleteDialogOpen(false); + }; + + const copyLicenseKey = (key: string) => { + navigator.clipboard.writeText(key); + toast("License key copied to clipboard"); + }; + + const allLoading = + isLicenseLoading || + policiesLoading || + usersLoading || + productsLoading || + groupsLoading; + + if (allLoading || !defaultValues) { + return ( +
+
+ +

Loading license details...

+
+
+ ); + } + + if (!isNew && !license?.data) { + return ( +
+

License not found.

+ +
+ ); + } + + console.log({ defaultValues }); + return ( + <> +
+
+
+ +
+

+ {isNew ? "Create License" : "Edit License"} +

+

+ {isNew + ? "Generate a new license" + : "Update license information and settings"} +

+
+
+ {!isNew && ( + + )} +
+ + {!isNew && license && ( + + + License Key + + This is the generated license key for this license + + + +
+ + {/* It's safer to only show the key if it exists */} + {license.data.attributes.key + ? "*********************************************************************************" + : "Not available"} + + {license.data.attributes.key && ( + + )} +
+
+ + {license.data.attributes.status} + + + Uses: {license.data.attributes.uses} + +
+
+
+ )} + + + + + + License Information + + + {isNew + ? "Configure the new license settings" + : "Update the license information below"} + + + +
+ +
+ ( + + License Name + + + + + + )} + /> + ( + + Expiry Date + + + + + + )} + /> + + {/* USER SELECT */} + ( + + Assign to User + + + + )} + /> + + {/* POLICY SELECT */} + ( + + Policy + + + + )} + /> + + {/* GROUP SELECT */} + ( + + Assign to Group (Optional) + + + + )} + /> + + {/* PRODUCT SELECT */} + ( + + Product (Optional) + + + + )} + /> + ( + + Metadata + +