# Description of Changes

<img width="1253" height="816" alt="image"
src="https://github.com/user-attachments/assets/51154a0e-4353-4e62-83c5-0896bc4e98df"
/>


---

## 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.
This commit is contained in:
Anthony Stirling 2025-11-13 17:29:53 +00:00 committed by GitHub
parent 350fdcf29a
commit 052a3ae653
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 368 additions and 5 deletions

View File

@ -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",

View File

@ -64,17 +64,19 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ 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(() => {

View File

@ -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[] = [
{

View File

@ -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 */

View File

@ -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: <ApiKeys />
},
],
};
// Add Developer section after Preferences (or Workspace if it exists)
const insertIndex = isAdmin ? 2 : 1;
sections.splice(insertIndex, 0, developerSection);
}
return sections;
};

View File

@ -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<string | null>(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 (
<Stack gap={20} p={0}>
<Text size="sm" c="dimmed">
{t('config.apiKeys.intro', 'Use your API key to programmatically access Stirling PDF\'s processing capabilities.')}
</Text>
<Paper
p="md"
radius="md"
style={{
background: "var(--bg-muted)",
border: "1px solid var(--border-subtle)"
}}
>
<Group gap="xs" wrap="nowrap" align="flex-start">
<LocalIcon icon="info-rounded" width={18} height={18} style={{ marginTop: 2, flexShrink: 0, opacity: 0.7 }} />
<Stack gap={8} style={{ flex: 1 }}>
<Text size="sm" fw={500}>
{t('config.apiKeys.docsTitle', 'API Documentation')}
</Text>
<Text size="sm" c="dimmed">
{t('config.apiKeys.docsDescription', 'Learn more about integrating with Stirling PDF:')}
</Text>
<Stack gap={4}>
<Text size="sm">
<Anchor
href="https://docs.stirlingpdf.com/API"
target="_blank"
rel="noopener noreferrer"
style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}
>
{t('config.apiKeys.docsLink', 'API Documentation')}
<LocalIcon icon="open-in-new-rounded" width={14} height={14} />
</Anchor>
</Text>
<Text size="sm">
<Anchor
href="https://registry.scalar.com/@stirlingpdf/apis/stirling-pdf-processing-api/"
target="_blank"
rel="noopener noreferrer"
style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}
>
{t('config.apiKeys.schemaLink', 'API Schema Reference')}
<LocalIcon icon="open-in-new-rounded" width={14} height={14} />
</Anchor>
</Text>
</Stack>
</Stack>
</Group>
</Paper>
{apiKeyError && (
<Text size="sm" c="red.5">
{t('config.apiKeys.generateError', "We couldn't generate your API key.")} {" "}
<Anchor component="button" underline="always" onClick={refetch} c="red.4">
{t('common.retry', 'Retry')}
</Anchor>
</Text>
)}
{apiKeyLoading ? (
<div style={{ padding: 18, borderRadius: 12, background: "var(--api-keys-card-bg)", border: "1px solid var(--api-keys-card-border)", boxShadow: "0 2px 8px var(--api-keys-card-shadow)" }}>
<Group align="center" gap={12} wrap="nowrap">
<Skeleton height={36} style={{ flex: 1 }} />
<Skeleton height={32} width={76} />
<Skeleton height={32} width={92} />
</Group>
</div>
) : (
<ApiKeySection
publicKey={apiKey ?? ""}
copied={copied}
onCopy={copy}
onRefresh={() => setShowRefreshModal(true)}
disabled={isRefreshing}
/>
)}
<Text size="sm" c="dimmed" style={{ marginTop: -8 }}>
{t('config.apiKeys.usage', 'Include this key in the X-API-KEY header with all API requests.')}
</Text>
<RefreshModal
opened={showRefreshModal}
onClose={() => setShowRefreshModal(false)}
onConfirm={refreshKeys}
/>
</Stack>
);
}

View File

@ -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 (
<>
<Paper radius="md" p={18} style={{ background: "var(--api-keys-card-bg)", border: "1px solid var(--api-keys-card-border)", boxShadow: "0 2px 8px var(--api-keys-card-shadow)" }}>
<Group align="flex-end" wrap="nowrap">
<Box style={{ flex: 1 }}>
<Box
style={{
background: "var(--api-keys-input-bg)",
border: "1px solid var(--api-keys-input-border)",
borderRadius: 8,
padding: "8px 12px",
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
fontSize: 14,
minHeight: 36,
display: "flex",
alignItems: "center",
}}
aria-label={t('config.apiKeys.publicKeyAriaLabel', 'Public API key')}
>
<FitText text={publicKey} />
</Box>
</Box>
<Button
size="sm"
variant="light"
onClick={() => onCopy(publicKey, "public")}
leftSection={<LocalIcon icon="content-copy-rounded" width={14} height={14} />}
styles={{ root: { background: "var(--api-keys-button-bg)", color: "var(--api-keys-button-color)", border: "none", marginLeft: 12 } }}
aria-label={t('config.apiKeys.copyKeyAriaLabel', 'Copy API key')}
>
{copied === "public" ? t('common.copied', 'Copied!') : t('common.copy', 'Copy')}
</Button>
<Button
size="sm"
variant="light"
onClick={onRefresh}
leftSection={<LocalIcon icon="refresh-rounded" width={14} height={14} />}
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')}
</Button>
</Group>
</Paper>
</>
);
}

View File

@ -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 (
<Modal
opened={opened}
onClose={onClose}
title={t('config.apiKeys.refreshModal.title', 'Refresh API Keys')}
centered
size="sm"
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
<Stack gap="md">
<Text size="sm" c="red">
{t('config.apiKeys.refreshModal.warning', '⚠️ Warning: This action will generate new API keys and make your previous keys invalid.')}
</Text>
<Text size="sm">
{t('config.apiKeys.refreshModal.impact', 'Any applications or services currently using these keys will stop working until you update them with the new keys.')}
</Text>
<Text size="sm" fw={500}>
{t('config.apiKeys.refreshModal.confirmPrompt', 'Are you sure you want to continue?')}
</Text>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={onClose}>
{t('common.cancel', 'Cancel')}
</Button>
<Button color="red" onClick={onConfirm}>
{t('config.apiKeys.refreshModal.confirmCta', 'Refresh Keys')}
</Button>
</Group>
</Stack>
</Modal>
);
}

View File

@ -0,0 +1,64 @@
import { useCallback, useEffect, useState } from "react";
import apiClient from "@app/services/apiClient";
export function useApiKey() {
const [apiKey, setApiKey] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [isRefreshing, setIsRefreshing] = useState<boolean>(false);
const [error, setError] = useState<Error | null>(null);
const [hasAttempted, setHasAttempted] = useState<boolean>(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;