From 052a3ae653348ee0db7f766e611a856ae848a13e Mon Sep 17 00:00:00 2001
From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
Date: Thu, 13 Nov 2025 17:29:53 +0000
Subject: [PATCH] api (#4892)
# Description of Changes
---
## Checklist
### General
- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings
### Documentation
- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)
### Translations (if applicable)
- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)
### UI Changes (if applicable)
- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)
### Testing (if applicable)
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
---
.../public/locales/en-GB/translation.json | 6 +
.../core/components/shared/AppConfigModal.tsx | 8 +-
.../shared/config/configNavSections.tsx | 3 +-
frontend/src/core/styles/theme.css | 18 +++
.../shared/config/configNavSections.tsx | 23 +++-
.../shared/config/configSections/ApiKeys.tsx | 126 ++++++++++++++++++
.../configSections/apiKeys/ApiKeySection.tsx | 75 +++++++++++
.../configSections/apiKeys/RefreshModal.tsx | 50 +++++++
.../configSections/apiKeys/hooks/useApiKey.ts | 64 +++++++++
9 files changed, 368 insertions(+), 5 deletions(-)
create mode 100644 frontend/src/proprietary/components/shared/config/configSections/ApiKeys.tsx
create mode 100644 frontend/src/proprietary/components/shared/config/configSections/apiKeys/ApiKeySection.tsx
create mode 100644 frontend/src/proprietary/components/shared/config/configSections/apiKeys/RefreshModal.tsx
create mode 100644 frontend/src/proprietary/components/shared/config/configSections/apiKeys/hooks/useApiKey.ts
diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json
index 2665b3420..9cfe5380b 100644
--- a/frontend/public/locales/en-GB/translation.json
+++ b/frontend/public/locales/en-GB/translation.json
@@ -4650,6 +4650,12 @@
}
},
"apiKeys": {
+ "intro": "Use your API key to programmatically access Stirling PDF's processing capabilities.",
+ "docsTitle": "API Documentation",
+ "docsDescription": "Learn more about integrating with Stirling PDF:",
+ "docsLink": "API Documentation",
+ "schemaLink": "API Schema Reference",
+ "usage": "Include this key in the X-API-KEY header with all API requests.",
"description": "Your API key for accessing Stirling's suite of PDF tools. Copy it to your project or refresh to generate a new one.",
"publicKeyAriaLabel": "Public API key",
"copyKeyAriaLabel": "Copy API key",
diff --git a/frontend/src/core/components/shared/AppConfigModal.tsx b/frontend/src/core/components/shared/AppConfigModal.tsx
index e69aa97e8..f9937f775 100644
--- a/frontend/src/core/components/shared/AppConfigModal.tsx
+++ b/frontend/src/core/components/shared/AppConfigModal.tsx
@@ -64,17 +64,19 @@ const AppConfigModal: React.FC = ({ opened, onClose }) => {
headerBorder: 'var(--modal-header-border)',
}), []);
- // Get isAdmin and runningEE from app config
+ // Get isAdmin, runningEE, and loginEnabled from app config
const isAdmin = config?.isAdmin ?? false;
const runningEE = config?.runningEE ?? false;
+ const loginEnabled = config?.enableLogin ?? false;
// Left navigation structure and icons
const configNavSections = useMemo(() =>
createConfigNavSections(
isAdmin,
- runningEE
+ runningEE,
+ loginEnabled
),
- [isAdmin, runningEE]
+ [isAdmin, runningEE, loginEnabled]
);
const activeLabel = useMemo(() => {
diff --git a/frontend/src/core/components/shared/config/configNavSections.tsx b/frontend/src/core/components/shared/config/configNavSections.tsx
index cbef8d27e..a1f66480b 100644
--- a/frontend/src/core/components/shared/config/configNavSections.tsx
+++ b/frontend/src/core/components/shared/config/configNavSections.tsx
@@ -41,7 +41,8 @@ export interface ConfigColors {
export const createConfigNavSections = (
isAdmin: boolean = false,
- runningEE: boolean = false
+ runningEE: boolean = false,
+ _loginEnabled: boolean = false
): ConfigNavSection[] => {
const sections: ConfigNavSection[] = [
{
diff --git a/frontend/src/core/styles/theme.css b/frontend/src/core/styles/theme.css
index 3051ced06..5e8d4087b 100644
--- a/frontend/src/core/styles/theme.css
+++ b/frontend/src/core/styles/theme.css
@@ -279,6 +279,15 @@
--modal-content-bg: #ffffff;
--modal-header-border: rgba(0, 0, 0, 0.06);
+ /* API Keys section colors (light mode) */
+ --api-keys-card-bg: #ffffff;
+ --api-keys-card-border: #e0e0e0;
+ --api-keys-card-shadow: rgba(0, 0, 0, 0.06);
+ --api-keys-input-bg: #f8f8f8;
+ --api-keys-input-border: #e0e0e0;
+ --api-keys-button-bg: #f5f5f5;
+ --api-keys-button-color: #333333;
+
/* PDF Report Colors (always light) */
--pdf-light-header-bg: 239 246 255;
--pdf-light-accent: 59 130 246;
@@ -539,6 +548,15 @@
--modal-content-bg: #2A2F36;
--modal-header-border: rgba(255, 255, 255, 0.08);
+ /* API Keys section colors (dark mode) */
+ --api-keys-card-bg: #2A2F36;
+ --api-keys-card-border: #3A4047;
+ --api-keys-card-shadow: none;
+ --api-keys-input-bg: #1F2329;
+ --api-keys-input-border: #3A4047;
+ --api-keys-button-bg: #3A4047;
+ --api-keys-button-color: #D0D6DC;
+
/* Code token colors (dark mode - Cursor-like) */
--code-kw-color: #C792EA; /* purple */
--code-str-color: #C3E88D; /* green */
diff --git a/frontend/src/proprietary/components/shared/config/configNavSections.tsx b/frontend/src/proprietary/components/shared/config/configNavSections.tsx
index 1df66b409..9be7640cc 100644
--- a/frontend/src/proprietary/components/shared/config/configNavSections.tsx
+++ b/frontend/src/proprietary/components/shared/config/configNavSections.tsx
@@ -2,13 +2,15 @@ import React from 'react';
import { createConfigNavSections as createCoreConfigNavSections, ConfigNavSection } from '@core/components/shared/config/configNavSections';
import PeopleSection from '@app/components/shared/config/configSections/PeopleSection';
import TeamsSection from '@app/components/shared/config/configSections/TeamsSection';
+import ApiKeys from '@app/components/shared/config/configSections/ApiKeys';
/**
* Proprietary extension of createConfigNavSections that adds workspace sections
*/
export const createConfigNavSections = (
isAdmin: boolean = false,
- runningEE: boolean = false
+ runningEE: boolean = false,
+ loginEnabled: boolean = false
): ConfigNavSection[] => {
// Get the core sections
const sections = createCoreConfigNavSections(isAdmin, runningEE);
@@ -37,6 +39,25 @@ export const createConfigNavSections = (
sections.splice(1, 0, workspaceSection);
}
+ // Add Developer section if login is enabled
+ if (loginEnabled) {
+ const developerSection: ConfigNavSection = {
+ title: 'Developer',
+ items: [
+ {
+ key: 'api-keys',
+ label: 'API Keys',
+ icon: 'key-rounded',
+ component:
+ },
+ ],
+ };
+
+ // Add Developer section after Preferences (or Workspace if it exists)
+ const insertIndex = isAdmin ? 2 : 1;
+ sections.splice(insertIndex, 0, developerSection);
+ }
+
return sections;
};
diff --git a/frontend/src/proprietary/components/shared/config/configSections/ApiKeys.tsx b/frontend/src/proprietary/components/shared/config/configSections/ApiKeys.tsx
new file mode 100644
index 000000000..fcc910887
--- /dev/null
+++ b/frontend/src/proprietary/components/shared/config/configSections/ApiKeys.tsx
@@ -0,0 +1,126 @@
+import React, { useState } from "react";
+import { Anchor, Group, Stack, Text, Paper, Skeleton } from "@mantine/core";
+// eslint-disable-next-line no-restricted-imports
+import ApiKeySection from "./apiKeys/ApiKeySection";
+// eslint-disable-next-line no-restricted-imports
+import RefreshModal from "./apiKeys/RefreshModal";
+// eslint-disable-next-line no-restricted-imports
+import useApiKey from "./apiKeys/hooks/useApiKey";
+import { useTranslation } from "react-i18next";
+import LocalIcon from "@app/components/shared/LocalIcon";
+
+export default function ApiKeys() {
+ const [copied, setCopied] = useState(null);
+ const [showRefreshModal, setShowRefreshModal] = useState(false);
+ const { t } = useTranslation();
+
+ const { apiKey, isLoading: apiKeyLoading, refresh, isRefreshing, error: apiKeyError, refetch } = useApiKey();
+
+ const copy = async (text: string, tag: string) => {
+ try {
+ await navigator.clipboard.writeText(text);
+ setCopied(tag);
+ setTimeout(() => setCopied(null), 1600);
+ } catch (e) {
+ console.error(e);
+ }
+ };
+
+ const refreshKeys = async () => {
+ try {
+ await refresh();
+ } finally {
+ setShowRefreshModal(false);
+ }
+ };
+
+ return (
+
+
+ {t('config.apiKeys.intro', 'Use your API key to programmatically access Stirling PDF\'s processing capabilities.')}
+
+
+
+
+
+
+
+ {t('config.apiKeys.docsTitle', 'API Documentation')}
+
+
+ {t('config.apiKeys.docsDescription', 'Learn more about integrating with Stirling PDF:')}
+
+
+
+
+ {t('config.apiKeys.docsLink', 'API Documentation')}
+
+
+
+
+
+ {t('config.apiKeys.schemaLink', 'API Schema Reference')}
+
+
+
+
+
+
+
+
+ {apiKeyError && (
+
+ {t('config.apiKeys.generateError', "We couldn't generate your API key.")} {" "}
+
+ {t('common.retry', 'Retry')}
+
+
+ )}
+
+ {apiKeyLoading ? (
+
+
+
+
+
+
+
+ ) : (
+ setShowRefreshModal(true)}
+ disabled={isRefreshing}
+ />
+ )}
+
+
+ {t('config.apiKeys.usage', 'Include this key in the X-API-KEY header with all API requests.')}
+
+
+ setShowRefreshModal(false)}
+ onConfirm={refreshKeys}
+ />
+
+ );
+}
diff --git a/frontend/src/proprietary/components/shared/config/configSections/apiKeys/ApiKeySection.tsx b/frontend/src/proprietary/components/shared/config/configSections/apiKeys/ApiKeySection.tsx
new file mode 100644
index 000000000..e258dc6dc
--- /dev/null
+++ b/frontend/src/proprietary/components/shared/config/configSections/apiKeys/ApiKeySection.tsx
@@ -0,0 +1,75 @@
+import React from "react";
+import {
+ Box,
+ Button,
+ Group,
+ Paper,
+} from "@mantine/core";
+import LocalIcon from "@app/components/shared/LocalIcon";
+import FitText from "@app/components/shared/FitText";
+import { useTranslation } from "react-i18next";
+
+interface ApiKeySectionProps {
+ publicKey: string;
+ copied: string | null;
+ onCopy: (text: string, tag: string) => void;
+ onRefresh: () => void;
+ disabled?: boolean;
+}
+
+export default function ApiKeySection({
+ publicKey,
+ copied,
+ onCopy,
+ onRefresh,
+ disabled,
+}: ApiKeySectionProps) {
+ const { t } = useTranslation();
+ return (
+ <>
+
+
+
+
+
+
+
+
+ }
+ styles={{ root: { background: "var(--api-keys-button-bg)", color: "var(--api-keys-button-color)", border: "none", marginLeft: 8 } }}
+ disabled={disabled}
+ aria-label={t('config.apiKeys.refreshAriaLabel', 'Refresh API key')}
+ >
+ {t('common.refresh', 'Refresh')}
+
+
+
+ >
+ );
+}
diff --git a/frontend/src/proprietary/components/shared/config/configSections/apiKeys/RefreshModal.tsx b/frontend/src/proprietary/components/shared/config/configSections/apiKeys/RefreshModal.tsx
new file mode 100644
index 000000000..faa6e60b4
--- /dev/null
+++ b/frontend/src/proprietary/components/shared/config/configSections/apiKeys/RefreshModal.tsx
@@ -0,0 +1,50 @@
+import React from "react";
+import {
+ Modal,
+ Stack,
+ Text,
+ Group,
+ Button,
+} from "@mantine/core";
+import { useTranslation } from "react-i18next";
+import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
+
+interface RefreshModalProps {
+ opened: boolean;
+ onClose: () => void;
+ onConfirm: () => void;
+}
+
+export default function RefreshModal({ opened, onClose, onConfirm }: RefreshModalProps) {
+ const { t } = useTranslation();
+ return (
+
+
+
+ {t('config.apiKeys.refreshModal.warning', '⚠️ Warning: This action will generate new API keys and make your previous keys invalid.')}
+
+
+ {t('config.apiKeys.refreshModal.impact', 'Any applications or services currently using these keys will stop working until you update them with the new keys.')}
+
+
+ {t('config.apiKeys.refreshModal.confirmPrompt', 'Are you sure you want to continue?')}
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/proprietary/components/shared/config/configSections/apiKeys/hooks/useApiKey.ts b/frontend/src/proprietary/components/shared/config/configSections/apiKeys/hooks/useApiKey.ts
new file mode 100644
index 000000000..c8efe73f4
--- /dev/null
+++ b/frontend/src/proprietary/components/shared/config/configSections/apiKeys/hooks/useApiKey.ts
@@ -0,0 +1,64 @@
+import { useCallback, useEffect, useState } from "react";
+import apiClient from "@app/services/apiClient";
+
+export function useApiKey() {
+ const [apiKey, setApiKey] = useState(null);
+ const [isLoading, setIsLoading] = useState(false);
+ const [isRefreshing, setIsRefreshing] = useState(false);
+ const [error, setError] = useState(null);
+ const [hasAttempted, setHasAttempted] = useState(false);
+
+ const fetchKey = useCallback(async () => {
+ setIsLoading(true);
+ setError(null);
+ try {
+ // Backend is POST for get and update
+ const res = await apiClient.post("/api/v1/user/get-api-key");
+ const value = typeof res.data === "string" ? res.data : res.data?.apiKey;
+ if (typeof value === "string") setApiKey(value);
+ } catch (e: any) {
+ // If not found, try to create one by calling update endpoint
+ if (e?.response?.status === 404) {
+ try {
+ const createRes = await apiClient.post("/api/v1/user/update-api-key");
+ const created =
+ typeof createRes.data === "string"
+ ? createRes.data
+ : createRes.data?.apiKey;
+ if (typeof created === "string") setApiKey(created);
+ } catch (createErr: any) {
+ setError(createErr);
+ }
+ } else {
+ setError(e);
+ }
+ } finally {
+ setIsLoading(false);
+ setHasAttempted(true);
+ }
+ }, []);
+
+ const refresh = useCallback(async () => {
+ setIsRefreshing(true);
+ setError(null);
+ try {
+ const res = await apiClient.post("/api/v1/user/update-api-key");
+ const value = typeof res.data === "string" ? res.data : res.data?.apiKey;
+ if (typeof value === "string") setApiKey(value);
+ } catch (e: any) {
+ setError(e);
+ } finally {
+ setIsRefreshing(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ if (!hasAttempted) {
+ fetchKey();
+ }
+ }, [hasAttempted, fetchKey]);
+
+ return { apiKey, isLoading, isRefreshing, error, refetch: fetchKey, refresh, hasAttempted } as const;
+}
+
+export default useApiKey;