+ Are you sure you want to remove the API key{" "}
+ {selected?.name || selected?.id} from the
+ organization?
+
+
+
+
+ Once removed, the API key will no longer be
+ able to be used.
+
+
+
+
+ To confirm, please type the name of the API key
+ below.
+
+
+ }
+ buttonText="Confirm Delete API Key"
+ onConfirm={async () => deleteSite(selected!.id)}
+ string={selected.name}
+ title="Delete API Key"
+ />
+ )}
+
+ {
+ router.push(`/${orgId}/settings/api-keys/create`);
+ }}
+ />
+ >
+ );
+}
diff --git a/src/app/[orgId]/settings/api-keys/[apiKeyId]/layout.tsx b/src/app/[orgId]/settings/api-keys/[apiKeyId]/layout.tsx
new file mode 100644
index 00000000..a4c13c9a
--- /dev/null
+++ b/src/app/[orgId]/settings/api-keys/[apiKeyId]/layout.tsx
@@ -0,0 +1,62 @@
+// This file is licensed under the Fossorial Commercial License.
+// Unauthorized use, copying, modification, or distribution is strictly prohibited.
+//
+// Copyright (c) 2025 Fossorial LLC. All rights reserved.
+
+import { internal } from "@app/lib/api";
+import { AxiosResponse } from "axios";
+import { redirect } from "next/navigation";
+import { authCookieHeader } from "@app/lib/api/cookies";
+import { SidebarSettings } from "@app/components/SidebarSettings";
+import Link from "next/link";
+import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
+import {
+ Breadcrumb,
+ BreadcrumbItem,
+ BreadcrumbList,
+ BreadcrumbPage,
+ BreadcrumbSeparator
+} from "@app/components/ui/breadcrumb";
+import { GetApiKeyResponse } from "@server/routers/apiKeys";
+import ApiKeyProvider from "@app/providers/ApiKeyProvider";
+import { HorizontalTabs } from "@app/components/HorizontalTabs";
+
+interface SettingsLayoutProps {
+ children: React.ReactNode;
+ params: Promise<{ apiKeyId: string; orgId: string }>;
+}
+
+export default async function SettingsLayout(props: SettingsLayoutProps) {
+ const params = await props.params;
+
+ const { children } = props;
+
+ let apiKey = null;
+ try {
+ const res = await internal.get>(
+ `/org/${params.orgId}/api-key/${params.apiKeyId}`,
+ await authCookieHeader()
+ );
+ apiKey = res.data.data;
+ } catch (e) {
+ console.log(e);
+ redirect(`/${params.orgId}/settings/api-keys`);
+ }
+
+ const navItems = [
+ {
+ title: "Permissions",
+ href: "/{orgId}/settings/api-keys/{apiKeyId}/permissions"
+ }
+ ];
+
+ return (
+ <>
+
+
+
+ {children}
+
+ >
+ );
+}
diff --git a/src/app/[orgId]/settings/api-keys/[apiKeyId]/page.tsx b/src/app/[orgId]/settings/api-keys/[apiKeyId]/page.tsx
new file mode 100644
index 00000000..7df37cd6
--- /dev/null
+++ b/src/app/[orgId]/settings/api-keys/[apiKeyId]/page.tsx
@@ -0,0 +1,13 @@
+// This file is licensed under the Fossorial Commercial License.
+// Unauthorized use, copying, modification, or distribution is strictly prohibited.
+//
+// Copyright (c) 2025 Fossorial LLC. All rights reserved.
+
+import { redirect } from "next/navigation";
+
+export default async function ApiKeysPage(props: {
+ params: Promise<{ orgId: string; apiKeyId: string }>;
+}) {
+ const params = await props.params;
+ redirect(`/${params.orgId}/settings/api-keys/${params.apiKeyId}/permissions`);
+}
diff --git a/src/app/[orgId]/settings/api-keys/[apiKeyId]/permissions/page.tsx b/src/app/[orgId]/settings/api-keys/[apiKeyId]/permissions/page.tsx
new file mode 100644
index 00000000..d1e6f518
--- /dev/null
+++ b/src/app/[orgId]/settings/api-keys/[apiKeyId]/permissions/page.tsx
@@ -0,0 +1,138 @@
+// This file is licensed under the Fossorial Commercial License.
+// Unauthorized use, copying, modification, or distribution is strictly prohibited.
+//
+// Copyright (c) 2025 Fossorial LLC. All rights reserved.
+
+"use client";
+
+import PermissionsSelectBox from "@app/components/PermissionsSelectBox";
+import {
+ SettingsContainer,
+ SettingsSection,
+ SettingsSectionBody,
+ SettingsSectionDescription,
+ SettingsSectionFooter,
+ SettingsSectionHeader,
+ SettingsSectionTitle
+} from "@app/components/Settings";
+import { Button } from "@app/components/ui/button";
+import { useEnvContext } from "@app/hooks/useEnvContext";
+import { toast } from "@app/hooks/useToast";
+import { createApiClient, formatAxiosError } from "@app/lib/api";
+import { ListApiKeyActionsResponse } from "@server/routers/apiKeys";
+import { AxiosResponse } from "axios";
+import { useParams } from "next/navigation";
+import { useEffect, useState } from "react";
+
+export default function Page() {
+ const { env } = useEnvContext();
+ const api = createApiClient({ env });
+ const { orgId, apiKeyId } = useParams();
+
+ const [loadingPage, setLoadingPage] = useState(true);
+ const [selectedPermissions, setSelectedPermissions] = useState<
+ Record
+ >({});
+ const [loadingSavePermissions, setLoadingSavePermissions] =
+ useState(false);
+
+ useEffect(() => {
+ async function load() {
+ setLoadingPage(true);
+
+ const res = await api
+ .get<
+ AxiosResponse
+ >(`/org/${orgId}/api-key/${apiKeyId}/actions`)
+ .catch((e) => {
+ toast({
+ variant: "destructive",
+ title: "Error loading API key actions",
+ description: formatAxiosError(
+ e,
+ "Error loading API key actions"
+ )
+ });
+ });
+
+ if (res && res.status === 200) {
+ const data = res.data.data;
+ for (const action of data.actions) {
+ setSelectedPermissions((prev) => ({
+ ...prev,
+ [action.actionId]: true
+ }));
+ }
+ }
+
+ setLoadingPage(false);
+ }
+
+ load();
+ }, []);
+
+ async function savePermissions() {
+ setLoadingSavePermissions(true);
+
+ const actionsRes = await api
+ .post(`/org/${orgId}/api-key/${apiKeyId}/actions`, {
+ actionIds: Object.keys(selectedPermissions).filter(
+ (key) => selectedPermissions[key]
+ )
+ })
+ .catch((e) => {
+ console.error("Error setting permissions", e);
+ toast({
+ variant: "destructive",
+ title: "Error setting permissions",
+ description: formatAxiosError(e)
+ });
+ });
+
+ if (actionsRes && actionsRes.status === 200) {
+ toast({
+ title: "Permissions updated",
+ description: "The permissions have been updated."
+ });
+ }
+
+ setLoadingSavePermissions(false);
+ }
+
+ return (
+ <>
+ {!loadingPage && (
+
+
+
+
+ Permissions
+
+
+ Determine what this API key can do
+
+
+
+
+
+
+
+
+
+
+
+ )}
+ >
+ );
+}
diff --git a/src/app/[orgId]/settings/api-keys/create/page.tsx b/src/app/[orgId]/settings/api-keys/create/page.tsx
new file mode 100644
index 00000000..3ede2ac0
--- /dev/null
+++ b/src/app/[orgId]/settings/api-keys/create/page.tsx
@@ -0,0 +1,412 @@
+// This file is licensed under the Fossorial Commercial License.
+// Unauthorized use, copying, modification, or distribution is strictly prohibited.
+//
+// Copyright (c) 2025 Fossorial LLC. All rights reserved.
+
+"use client";
+
+import {
+ SettingsContainer,
+ SettingsSection,
+ SettingsSectionBody,
+ SettingsSectionDescription,
+ SettingsSectionForm,
+ SettingsSectionHeader,
+ SettingsSectionTitle
+} from "@app/components/Settings";
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage
+} from "@app/components/ui/form";
+import HeaderTitle from "@app/components/SettingsSectionTitle";
+import { z } from "zod";
+import { useEffect, useState } from "react";
+import { useForm } from "react-hook-form";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { Input } from "@app/components/ui/input";
+import { InfoIcon } from "lucide-react";
+import { Button } from "@app/components/ui/button";
+import { Checkbox, CheckboxWithLabel } from "@app/components/ui/checkbox";
+import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
+import { createApiClient, formatAxiosError } from "@app/lib/api";
+import { useEnvContext } from "@app/hooks/useEnvContext";
+import { toast } from "@app/hooks/useToast";
+import { AxiosResponse } from "axios";
+import { useParams, useRouter } from "next/navigation";
+import {
+ Breadcrumb,
+ BreadcrumbItem,
+ BreadcrumbList,
+ BreadcrumbPage,
+ BreadcrumbSeparator
+} from "@app/components/ui/breadcrumb";
+import Link from "next/link";
+import {
+ CreateOrgApiKeyBody,
+ CreateOrgApiKeyResponse
+} from "@server/routers/apiKeys";
+import { ApiKey } from "@server/db/schemas";
+import {
+ InfoSection,
+ InfoSectionContent,
+ InfoSections,
+ InfoSectionTitle
+} from "@app/components/InfoSection";
+import CopyToClipboard from "@app/components/CopyToClipboard";
+import moment from "moment";
+import CopyCodeBox from "@server/emails/templates/components/CopyCodeBox";
+import CopyTextBox from "@app/components/CopyTextBox";
+import PermissionsSelectBox from "@app/components/PermissionsSelectBox";
+
+const createFormSchema = z.object({
+ name: z
+ .string()
+ .min(2, {
+ message: "Name must be at least 2 characters."
+ })
+ .max(255, {
+ message: "Name must not be longer than 255 characters."
+ })
+});
+
+type CreateFormValues = z.infer;
+
+const copiedFormSchema = z
+ .object({
+ copied: z.boolean()
+ })
+ .refine(
+ (data) => {
+ return data.copied;
+ },
+ {
+ message: "You must confirm that you have copied the API key.",
+ path: ["copied"]
+ }
+ );
+
+type CopiedFormValues = z.infer;
+
+export default function Page() {
+ const { env } = useEnvContext();
+ const api = createApiClient({ env });
+ const { orgId } = useParams();
+ const router = useRouter();
+
+ const [loadingPage, setLoadingPage] = useState(true);
+ const [createLoading, setCreateLoading] = useState(false);
+ const [apiKey, setApiKey] = useState(null);
+ const [selectedPermissions, setSelectedPermissions] = useState<
+ Record
+ >({});
+
+ const form = useForm({
+ resolver: zodResolver(createFormSchema),
+ defaultValues: {
+ name: ""
+ }
+ });
+
+ const copiedForm = useForm({
+ resolver: zodResolver(copiedFormSchema),
+ defaultValues: {
+ copied: false
+ }
+ });
+
+ async function onSubmit(data: CreateFormValues) {
+ setCreateLoading(true);
+
+ let payload: CreateOrgApiKeyBody = {
+ name: data.name
+ };
+
+ const res = await api
+ .put<
+ AxiosResponse
+ >(`/org/${orgId}/api-key/`, payload)
+ .catch((e) => {
+ toast({
+ variant: "destructive",
+ title: "Error creating API key",
+ description: formatAxiosError(e)
+ });
+ });
+
+ if (res && res.status === 201) {
+ const data = res.data.data;
+
+ console.log({
+ actionIds: Object.keys(selectedPermissions).filter(
+ (key) => selectedPermissions[key]
+ )
+ });
+
+ const actionsRes = await api
+ .post(`/org/${orgId}/api-key/${data.apiKeyId}/actions`, {
+ actionIds: Object.keys(selectedPermissions).filter(
+ (key) => selectedPermissions[key]
+ )
+ })
+ .catch((e) => {
+ console.error("Error setting permissions", e);
+ toast({
+ variant: "destructive",
+ title: "Error setting permissions",
+ description: formatAxiosError(e)
+ });
+ });
+
+ if (actionsRes) {
+ setApiKey(data);
+ }
+ }
+
+ setCreateLoading(false);
+ }
+
+ async function onCopiedSubmit(data: CopiedFormValues) {
+ if (!data.copied) {
+ return;
+ }
+
+ router.push(`/${orgId}/settings/api-keys`);
+ }
+
+ const formatLabel = (str: string) => {
+ return str
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
+ .replace(/^./, (char) => char.toUpperCase());
+ };
+
+ useEffect(() => {
+ const load = async () => {
+ setLoadingPage(false);
+ };
+
+ load();
+ }, []);
+
+ return (
+ <>
+
+
+
+
+
+ {!loadingPage && (
+
+
+ {!apiKey && (
+ <>
+
+
+
+ API Key Information
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Permissions
+
+
+ Determine what this API key can do
+
+
+
+
+
+
+ >
+ )}
+
+ {apiKey && (
+
+
+
+ Your API Key
+
+
+
+
+
+
+ Name
+
+
+
+
+
+
+
+ Created
+
+
+ {moment(
+ apiKey.createdAt
+ ).format("lll")}
+
+
+
+
+
+
+
+ Save Your API Key
+
+
+ You will only be able to see this
+ once. Make sure to copy it to a
+ secure place.
+
+
+
+
+ License Violation: This server is using{" "}
+ {licenseStatus.usedSites} sites which exceeds its licensed
+ limit of {licenseStatus.maxSites} sites. Follow license
+ terms to continue using all features.
+
+
+ );
+ }
+
+ return null;
+}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 1d8deaed..e0089bc5 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -1,25 +1,17 @@
import type { Metadata } from "next";
import "./globals.css";
-import {
- Figtree,
- Inter,
- Red_Hat_Display,
- Red_Hat_Mono,
- Red_Hat_Text,
- Space_Grotesk
-} from "next/font/google";
+import { Inter } from "next/font/google";
import { Toaster } from "@/components/ui/toaster";
import { ThemeProvider } from "@app/providers/ThemeProvider";
import EnvProvider from "@app/providers/EnvProvider";
-import { Separator } from "@app/components/ui/separator";
import { pullEnv } from "@app/lib/pullEnv";
-import { BookOpenText, ExternalLink } from "lucide-react";
-import Image from "next/image";
import SupportStatusProvider from "@app/providers/SupporterStatusProvider";
-import { createApiClient, internal, priv } from "@app/lib/api";
+import { priv } from "@app/lib/api";
import { AxiosResponse } from "axios";
import { IsSupporterKeyVisibleResponse } from "@server/routers/supporterKey";
-import SupporterMessage from "./components/SupporterMessage";
+import LicenseStatusProvider from "@app/providers/LicenseStatusProvider";
+import { GetLicenseStatusResponse } from "@server/routers/license";
+import LicenseViolation from "./components/LicenseViolation";
export const metadata: Metadata = {
title: `Dashboard - Pangolin`,
@@ -48,6 +40,12 @@ export default async function RootLayout({
supporterData.visible = res.data.data.visible;
supporterData.tier = res.data.data.tier;
+ const licenseStatusRes =
+ await priv.get>(
+ "/license/status"
+ );
+ const licenseStatus = licenseStatusRes.data.data;
+
return (
@@ -58,14 +56,19 @@ export default async function RootLayout({
disableTransitionOnChange
>
-
- {/* Main content */}
-