option to hide google drive and add settings (#5863)

Co-authored-by: EthanHealy01 <[email protected]>
This commit is contained in:
Anthony Stirling
2026-03-06 10:09:33 +00:00
committed by GitHub
co-authored by EthanHealy01
parent cafcee6c99
commit 7d640e9ce6
13 changed files with 905 additions and 332 deletions
@@ -713,6 +713,7 @@ public class ApplicationProperties {
private String logoStyle = "classic"; // Options: "classic" (default) or "modern" private String logoStyle = "classic"; // Options: "classic" (default) or "modern"
private boolean defaultHideUnavailableTools = false; private boolean defaultHideUnavailableTools = false;
private boolean defaultHideUnavailableConversions = false; private boolean defaultHideUnavailableConversions = false;
private HideDisabledTools hideDisabledTools = new HideDisabledTools();
public String getAppNameNavbar() { public String getAppNameNavbar() {
return appNameNavbar != null && !appNameNavbar.trim().isEmpty() ? appNameNavbar : null; return appNameNavbar != null && !appNameNavbar.trim().isEmpty() ? appNameNavbar : null;
@@ -725,6 +726,12 @@ public class ApplicationProperties {
} }
return "classic"; // default return "classic"; // default
} }
@Data
public static class HideDisabledTools {
private boolean googleDrive = false;
private boolean mobileQRScanner = false;
}
} }
@Data @Data
@@ -912,6 +919,15 @@ public class ApplicationProperties {
private boolean ssoAutoLogin; private boolean ssoAutoLogin;
private boolean database; private boolean database;
private CustomMetadata customMetadata = new CustomMetadata(); private CustomMetadata customMetadata = new CustomMetadata();
private GoogleDrive googleDrive = new GoogleDrive();
@Data
public static class GoogleDrive {
private boolean enabled = false;
private String clientId = "";
private String apiKey = "";
private String appId = "";
}
@Data @Data
public static class CustomMetadata { public static class CustomMetadata {
@@ -159,6 +159,24 @@ public class ConfigController {
"defaultHideUnavailableConversions", "defaultHideUnavailableConversions",
applicationProperties.getUi().isDefaultHideUnavailableConversions()); applicationProperties.getUi().isDefaultHideUnavailableConversions());
// Hide disabled tools settings
configData.put(
"hideDisabledToolsGoogleDrive",
applicationProperties.getUi().getHideDisabledTools().isGoogleDrive());
configData.put(
"hideDisabledToolsMobileQRScanner",
applicationProperties.getUi().getHideDisabledTools().isMobileQRScanner());
// Google Drive backend settings (only if enabled)
ApplicationProperties.Premium.ProFeatures.GoogleDrive googleDrive =
applicationProperties.getPremium().getProFeatures().getGoogleDrive();
if (googleDrive.isEnabled()) {
configData.put("googleDriveEnabled", true);
configData.put("googleDriveClientId", googleDrive.getClientId());
configData.put("googleDriveApiKey", googleDrive.getApiKey());
configData.put("googleDriveAppId", googleDrive.getAppId());
}
// Security settings // Security settings
// enableLogin requires both the config flag AND proprietary features to be loaded // enableLogin requires both the config flag AND proprietary features to be loaded
// If userService is null, proprietary module isn't loaded // If userService is null, proprietary module isn't loaded
@@ -96,6 +96,11 @@ premium:
author: username author: username
creator: Stirling-PDF creator: Stirling-PDF
producer: Stirling-PDF producer: Stirling-PDF
googleDrive:
enabled: false # Enable Google Drive file picker integration
clientId: "" # Google OAuth 2.0 client ID (obtain from Google Cloud Console)
apiKey: "" # Google API key for Google Picker API (obtain from Google Cloud Console)
appId: "" # Google Drive app ID
enterpriseFeatures: enterpriseFeatures:
audit: audit:
enabled: true # Enable audit logging for security and compliance tracking enabled: true # Enable audit logging for security and compliance tracking
@@ -238,6 +243,9 @@ ui:
languages: [] # If empty, all languages are enabled. To display only German and Polish ["de_DE", "pl_PL"]. British English is always enabled. languages: [] # If empty, all languages are enabled. To display only German and Polish ["de_DE", "pl_PL"]. British English is always enabled.
defaultHideUnavailableTools: false # Default user preference: hide disabled tools instead of greying them out defaultHideUnavailableTools: false # Default user preference: hide disabled tools instead of greying them out
defaultHideUnavailableConversions: false # Default user preference: hide disabled conversion options instead of greying them out defaultHideUnavailableConversions: false # Default user preference: hide disabled conversion options instead of greying them out
hideDisabledTools:
googleDrive: false # Hide Google Drive button when not enabled
mobileQRScanner: false # Hide mobile QR scanner button when not enabled
endpoints: endpoints:
toRemove: [] # list endpoints to disable (e.g. ['img-to-pdf', 'remove-pages']) toRemove: [] # list endpoints to disable (e.g. ['img-to-pdf', 'remove-pages'])
@@ -7323,3 +7323,204 @@ title = "Team Members"
button = "Upgrade to Team" button = "Upgrade to Team"
description = "Unlock team collaboration features" description = "Unlock team collaboration features"
title = "Upgrade to Team Plan" title = "Upgrade to Team Plan"
[provider.oauth2.google]
scope = "Sign-in authentication"
[provider.oauth2.google.clientId]
label = "Client ID"
description = "The OAuth2 client ID from Google Cloud Console"
[provider.oauth2.google.clientSecret]
label = "Client Secret"
description = "The OAuth2 client secret from Google Cloud Console"
[provider.oauth2.google.scopes]
label = "Scopes"
description = "Comma-separated OAuth2 scopes"
[provider.oauth2.google.useAsUsername]
label = "Use as Username"
description = "Field to use as username (email, name, given_name, family_name)"
[provider.oauth2.github]
scope = "Sign-in authentication"
[provider.oauth2.github.clientId]
label = "Client ID"
description = "The OAuth2 client ID from GitHub Developer Settings"
[provider.oauth2.github.clientSecret]
label = "Client Secret"
description = "The OAuth2 client secret from GitHub Developer Settings"
[provider.oauth2.github.scopes]
label = "Scopes"
description = "Comma-separated OAuth2 scopes"
[provider.oauth2.github.useAsUsername]
label = "Use as Username"
description = "Field to use as username (email, login, name)"
[provider.oauth2.keycloak]
scope = "SSO"
[provider.oauth2.keycloak.issuer]
label = "Issuer URL"
description = "URL of the Keycloak realm's OpenID Connect Discovery endpoint"
[provider.oauth2.keycloak.clientId]
label = "Client ID"
description = "The OAuth2 client ID from Keycloak"
[provider.oauth2.keycloak.clientSecret]
label = "Client Secret"
description = "The OAuth2 client secret from Keycloak"
[provider.oauth2.keycloak.scopes]
label = "Scopes"
description = "Comma-separated OAuth2 scopes"
[provider.oauth2.keycloak.useAsUsername]
label = "Use as Username"
description = "Field to use as username (email, name, given_name, family_name, preferred_username)"
[provider.oauth2.generic]
name = "Generic OAuth2"
scope = "SSO"
[provider.oauth2.generic.enabled]
label = "Enable Generic OAuth2"
description = "Enable authentication using a custom OAuth2 provider"
[provider.oauth2.generic.provider]
label = "Provider Name"
description = "The name of your OAuth2 provider (e.g., Azure AD, Okta)"
[provider.oauth2.generic.issuer]
label = "Issuer URL"
description = "Provider that supports OpenID Connect Discovery (/.well-known/openid-configuration)"
[provider.oauth2.generic.clientId]
label = "Client ID"
description = "The OAuth2 client ID from your provider"
[provider.oauth2.generic.clientSecret]
label = "Client Secret"
description = "The OAuth2 client secret from your provider"
[provider.oauth2.generic.scopes]
label = "Scopes"
description = "Comma-separated OAuth2 scopes"
[provider.oauth2.generic.useAsUsername]
label = "Use as Username"
description = "Field to use as username"
[provider.oauth2.generic.autoCreateUser]
label = "Auto Create Users"
description = "Automatically create user accounts on first OAuth2 login"
[provider.oauth2.generic.blockRegistration]
label = "Block Registration"
description = "Prevent new user registration via OAuth2"
[provider.smtp]
name = "SMTP Mail"
scope = "Email Notifications"
[provider.smtp.enabled]
label = "Enable Mail"
description = "Enable email notifications and SMTP functionality"
[provider.smtp.host]
label = "SMTP Host"
description = "The hostname or IP address of your SMTP server"
[provider.smtp.port]
label = "SMTP Port"
description = "The port number for SMTP connection (typically 25, 465, or 587)"
[provider.smtp.username]
label = "SMTP Username"
description = "Username for SMTP authentication"
[provider.smtp.password]
label = "SMTP Password"
description = "Password for SMTP authentication"
[provider.smtp.from]
label = "From Address"
description = "The email address to use as the sender"
[provider.saml2]
name = "SAML2"
scope = "SSO (SAML)"
[provider.saml2.enabled]
label = "Enable SAML2"
description = "Enable SAML2 authentication (Enterprise only)"
[provider.saml2.provider]
label = "Provider Name"
description = "The name of your SAML2 provider"
[provider.saml2.registrationId]
label = "Registration ID"
description = "The name of your Service Provider (SP) app name"
[provider.saml2.idpMetadataUri]
label = "IDP Metadata URI"
description = "The URI for your provider's metadata"
[provider.saml2.idpSingleLoginUrl]
label = "IDP Single Login URL"
description = "The URL for initiating SSO"
[provider.saml2.idpSingleLogoutUrl]
label = "IDP Single Logout URL"
description = "The URL for initiating SLO"
[provider.saml2.idpIssuer]
label = "IDP Issuer"
description = "The ID of your provider"
[provider.saml2.idpCert]
label = "IDP Certificate"
description = "The certificate path (e.g., classpath:okta.cert)"
[provider.saml2.privateKey]
label = "Private Key"
description = "Your private key path"
[provider.saml2.spCert]
label = "SP Certificate"
description = "Your signing certificate path"
[provider.saml2.autoCreateUser]
label = "Auto Create Users"
description = "Automatically create user accounts on first SAML2 login"
[provider.saml2.blockRegistration]
label = "Block Registration"
description = "Prevent new user registration via SAML2"
[provider.googledrive]
name = "Google Drive"
scope = "File Import"
[provider.googledrive.enabled]
label = "Enable Google Drive File Picker"
description = "Allow users to import files directly from Google Drive"
[provider.googledrive.clientId]
label = "Client ID"
description = "Google OAuth 2.0 Client ID from Google Cloud Console"
[provider.googledrive.apiKey]
label = "API Key"
description = "Google API Key for Google Picker API from Google Cloud Console"
[provider.googledrive.appId]
label = "App ID"
description = "Google Drive App ID from Google Cloud Console"
+11 -4
View File
@@ -1,16 +1,17 @@
import React, { useState, useCallback, useEffect } from 'react'; import React, { useState, useCallback, useEffect, useMemo } from 'react';
import { Modal } from '@mantine/core'; import { Modal } from '@mantine/core';
import { Dropzone } from '@mantine/dropzone'; import { Dropzone } from '@mantine/dropzone';
import { StirlingFileStub } from '@app/types/fileContext'; import { StirlingFileStub } from '@app/types/fileContext';
import { useFileManager } from '@app/hooks/useFileManager'; import { useFileManager } from '@app/hooks/useFileManager';
import { useFilesModalContext } from '@app/contexts/FilesModalContext'; import { useFilesModalContext } from '@app/contexts/FilesModalContext';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import { Tool } from '@app/types/tool'; import { Tool } from '@app/types/tool';
import MobileLayout from '@app/components/fileManager/MobileLayout'; import MobileLayout from '@app/components/fileManager/MobileLayout';
import DesktopLayout from '@app/components/fileManager/DesktopLayout'; import DesktopLayout from '@app/components/fileManager/DesktopLayout';
import DragOverlay from '@app/components/fileManager/DragOverlay'; import DragOverlay from '@app/components/fileManager/DragOverlay';
import { FileManagerProvider } from '@app/contexts/FileManagerContext'; import { FileManagerProvider } from '@app/contexts/FileManagerContext';
import { Z_INDEX_FILE_MANAGER_MODAL } from '@app/styles/zIndex'; import { Z_INDEX_FILE_MANAGER_MODAL } from '@app/styles/zIndex';
import { isGoogleDriveConfigured } from '@app/services/googleDrivePickerService'; import { isGoogleDriveConfigured, extractGoogleDriveBackendConfig } from '@app/services/googleDrivePickerService';
import { loadScript } from '@app/utils/scriptLoader'; import { loadScript } from '@app/utils/scriptLoader';
import { useAllFiles } from '@app/contexts/FileContext'; import { useAllFiles } from '@app/contexts/FileContext';
@@ -20,6 +21,7 @@ interface FileManagerProps {
const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => { const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
const { isFilesModalOpen, closeFilesModal, onFileUpload, onRecentFileSelect } = useFilesModalContext(); const { isFilesModalOpen, closeFilesModal, onFileUpload, onRecentFileSelect } = useFilesModalContext();
const { config } = useAppConfig();
const [recentFiles, setRecentFiles] = useState<StirlingFileStub[]>([]); const [recentFiles, setRecentFiles] = useState<StirlingFileStub[]>([]);
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const [isMobile, setIsMobile] = useState(false); const [isMobile, setIsMobile] = useState(false);
@@ -92,9 +94,14 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
}, []); }, []);
// Preload Google Drive scripts if configured // Preload Google Drive scripts if configured
// Use useMemo to only track Google Drive config changes, not all config updates
const googleDriveBackendConfig = useMemo(
() => extractGoogleDriveBackendConfig(config),
[config?.googleDriveEnabled, config?.googleDriveClientId, config?.googleDriveApiKey, config?.googleDriveAppId]
);
useEffect(() => { useEffect(() => {
if (isGoogleDriveConfigured()) { if (isGoogleDriveConfigured(googleDriveBackendConfig)) {
// Load scripts in parallel without blocking // Load scripts in parallel without blocking
Promise.all([ Promise.all([
loadScript({ loadScript({
@@ -113,7 +120,7 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
console.warn('Failed to preload Google Drive scripts:', error); console.warn('Failed to preload Google Drive scripts:', error);
}); });
} }
}, []); }, [googleDriveBackendConfig]);
// Modal size constants for consistent scaling // Modal size constants for consistent scaling
const modalHeight = '80vh'; const modalHeight = '80vh';
@@ -1,7 +1,6 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Stack, Text, Button, Group } from '@mantine/core'; import { Stack, Text, Button, Group } from '@mantine/core';
import HistoryIcon from '@mui/icons-material/History'; import HistoryIcon from '@mui/icons-material/History';
import CloudIcon from '@mui/icons-material/Cloud';
import PhonelinkIcon from '@mui/icons-material/Phonelink'; import PhonelinkIcon from '@mui/icons-material/Phonelink';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useFileManagerContext } from '@app/contexts/FileManagerContext'; import { useFileManagerContext } from '@app/contexts/FileManagerContext';
@@ -16,6 +15,23 @@ interface FileSourceButtonsProps {
horizontal?: boolean; horizontal?: boolean;
} }
/**
* Google Drive icon component - displays the branded SVG icon
* Shows grayscale when disabled
*/
const GoogleDriveIcon: React.FC<{ disabled?: boolean }> = ({ disabled }) => (
<img
src="/images/google-drive.svg"
alt="Google Drive"
style={{
width: '20px',
height: '20px',
opacity: disabled ? 0.5 : 1,
filter: disabled ? 'grayscale(100%)' : 'none',
}}
/>
);
const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
horizontal = false horizontal = false
}) => { }) => {
@@ -51,6 +67,12 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
} }
}; };
// Determine visibility of Google Drive button
const shouldHideGoogleDrive = !isGoogleDriveEnabled && config?.hideDisabledToolsGoogleDrive;
// Determine visibility of Mobile QR Scanner button
const shouldHideMobileQR = !isMobileUploadEnabled && config?.hideDisabledToolsMobileQRScanner;
const buttonProps = { const buttonProps = {
variant: (source: string) => activeSource === source ? 'filled' : 'subtle', variant: (source: string) => activeSource === source ? 'filled' : 'subtle',
getColor: (source: string) => activeSource === source ? 'var(--mantine-color-gray-2)' : undefined, getColor: (source: string) => activeSource === source ? 'var(--mantine-color-gray-2)' : undefined,
@@ -101,10 +123,11 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
{horizontal ? terminology.upload : terminology.uploadFiles} {horizontal ? terminology.upload : terminology.uploadFiles}
</Button> </Button>
{!shouldHideGoogleDrive && (
<Button <Button
variant="subtle" variant="subtle"
color='var(--mantine-color-gray-6)' color='var(--mantine-color-gray-6)'
leftSection={<CloudIcon />} leftSection={<GoogleDriveIcon disabled={!isGoogleDriveEnabled} />}
justify={horizontal ? "center" : "flex-start"} justify={horizontal ? "center" : "flex-start"}
onClick={handleGoogleDriveClick} onClick={handleGoogleDriveClick}
fullWidth={!horizontal} fullWidth={!horizontal}
@@ -123,7 +146,9 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
> >
{horizontal ? t('fileManager.googleDriveShort', 'Drive') : t('fileManager.googleDrive', 'Google Drive')} {horizontal ? t('fileManager.googleDriveShort', 'Drive') : t('fileManager.googleDrive', 'Google Drive')}
</Button> </Button>
)}
{!shouldHideMobileQR && (
<Button <Button
variant="subtle" variant="subtle"
color='var(--mantine-color-gray-6)' color='var(--mantine-color-gray-6)'
@@ -146,6 +171,7 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
> >
{horizontal ? t('fileManager.mobileShort', 'Mobile') : t('fileManager.mobileUpload', 'Mobile Upload')} {horizontal ? t('fileManager.mobileShort', 'Mobile') : t('fileManager.mobileUpload', 'Mobile Upload')}
</Button> </Button>
)}
</> </>
); );
@@ -11,6 +11,7 @@ import {
Switch, Switch,
NumberInput, NumberInput,
TagsInput, TagsInput,
Anchor,
} from '@mantine/core'; } from '@mantine/core';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import LocalIcon from '@app/components/shared/LocalIcon'; import LocalIcon from '@app/components/shared/LocalIcon';
@@ -231,6 +232,18 @@ export default function ProviderCard({
{/* Expandable Settings */} {/* Expandable Settings */}
<Collapse in={expanded}> <Collapse in={expanded}>
<Stack gap="md" mt="xs"> <Stack gap="md" mt="xs">
{/* Documentation Link */}
{provider.documentationUrl && (
<Anchor
href={provider.documentationUrl}
target="_blank"
size="xs"
c="blue"
>
{t('admin.settings.connections.documentation', 'View documentation')}
</Anchor>
)}
{provider.fields.map((field) => renderField(field))} {provider.fields.map((field) => renderField(field))}
{!readOnly && (onSave || onDisconnect) && ( {!readOnly && (onSave || onDisconnect) && (
@@ -1,6 +1,6 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
export type ProviderType = 'oauth2' | 'saml2' | 'telegram'; export type ProviderType = 'oauth2' | 'saml2' | 'telegram' | 'googledrive';
export interface ProviderField { export interface ProviderField {
key: string; key: string;
@@ -18,248 +18,274 @@ export interface Provider {
type: ProviderType; type: ProviderType;
scope: string; // Summary of what this provider does scope: string; // Summary of what this provider does
businessTier?: boolean; // Enterprise only businessTier?: boolean; // Enterprise only
documentationUrl?: string; // Optional link to documentation
fields: ProviderField[]; fields: ProviderField[];
} }
export const OAUTH2_PROVIDERS: Provider[] = [ const useGoogleProvider = (): Provider => {
{ const { t } = useTranslation();
return {
id: 'google', id: 'google',
name: 'Google', name: 'Google',
icon: '/Login/google.svg', icon: '/Login/google.svg',
type: 'oauth2', type: 'oauth2',
scope: 'Sign-in authentication', scope: t('provider.oauth2.google.scope', 'Sign-in authentication'),
documentationUrl: 'https://docs.stirlingpdf.com/Configuration/OAuth%20SSO%20Configuration',
fields: [ fields: [
{ {
key: 'clientId', key: 'clientId',
type: 'text', type: 'text',
label: 'Client ID', label: t('provider.oauth2.google.clientId.label', 'Client ID'),
description: 'The OAuth2 client ID from Google Cloud Console', description: t('provider.oauth2.google.clientId.description', 'The OAuth2 client ID from Google Cloud Console'),
placeholder: 'your-client-id.apps.googleusercontent.com', placeholder: 'your-client-id.apps.googleusercontent.com',
}, },
{ {
key: 'clientSecret', key: 'clientSecret',
type: 'password', type: 'password',
label: 'Client Secret', label: t('provider.oauth2.google.clientSecret.label', 'Client Secret'),
description: 'The OAuth2 client secret from Google Cloud Console', description: t('provider.oauth2.google.clientSecret.description', 'The OAuth2 client secret from Google Cloud Console'),
}, },
{ {
key: 'scopes', key: 'scopes',
type: 'text', type: 'text',
label: 'Scopes', label: t('provider.oauth2.google.scopes.label', 'Scopes'),
description: 'Comma-separated OAuth2 scopes', description: t('provider.oauth2.google.scopes.description', 'Comma-separated OAuth2 scopes'),
defaultValue: 'email, profile', defaultValue: 'email, profile',
}, },
{ {
key: 'useAsUsername', key: 'useAsUsername',
type: 'text', type: 'text',
label: 'Use as Username', label: t('provider.oauth2.google.useAsUsername.label', 'Use as Username'),
description: 'Field to use as username (email, name, given_name, family_name)', description: t('provider.oauth2.google.useAsUsername.description', 'Field to use as username (email, name, given_name, family_name)'),
defaultValue: 'email', defaultValue: 'email',
}, },
], ],
}, };
{ };
const useGitHubProvider = (): Provider => {
const { t } = useTranslation();
return {
id: 'github', id: 'github',
name: 'GitHub', name: 'GitHub',
icon: '/Login/github.svg', icon: '/Login/github.svg',
type: 'oauth2', type: 'oauth2',
scope: 'Sign-in authentication', scope: t('provider.oauth2.github.scope', 'Sign-in authentication'),
documentationUrl: 'https://docs.stirlingpdf.com/Configuration/OAuth%20SSO%20Configuration',
fields: [ fields: [
{ {
key: 'clientId', key: 'clientId',
type: 'text', type: 'text',
label: 'Client ID', label: t('provider.oauth2.github.clientId.label', 'Client ID'),
description: 'The OAuth2 client ID from GitHub Developer Settings', description: t('provider.oauth2.github.clientId.description', 'The OAuth2 client ID from GitHub Developer Settings'),
}, },
{ {
key: 'clientSecret', key: 'clientSecret',
type: 'password', type: 'password',
label: 'Client Secret', label: t('provider.oauth2.github.clientSecret.label', 'Client Secret'),
description: 'The OAuth2 client secret from GitHub Developer Settings', description: t('provider.oauth2.github.clientSecret.description', 'The OAuth2 client secret from GitHub Developer Settings'),
}, },
{ {
key: 'scopes', key: 'scopes',
type: 'text', type: 'text',
label: 'Scopes', label: t('provider.oauth2.github.scopes.label', 'Scopes'),
description: 'Comma-separated OAuth2 scopes', description: t('provider.oauth2.github.scopes.description', 'Comma-separated OAuth2 scopes'),
defaultValue: 'read:user', defaultValue: 'read:user',
}, },
{ {
key: 'useAsUsername', key: 'useAsUsername',
type: 'text', type: 'text',
label: 'Use as Username', label: t('provider.oauth2.github.useAsUsername.label', 'Use as Username'),
description: 'Field to use as username (email, login, name)', description: t('provider.oauth2.github.useAsUsername.description', 'Field to use as username (email, login, name)'),
defaultValue: 'login', defaultValue: 'login',
}, },
], ],
}, };
{ };
const useKeycloakProvider = (): Provider => {
const { t } = useTranslation();
return {
id: 'keycloak', id: 'keycloak',
name: 'Keycloak', name: 'Keycloak',
icon: 'key-rounded', icon: 'key-rounded',
type: 'oauth2', type: 'oauth2',
scope: 'SSO', scope: t('provider.oauth2.keycloak.scope', 'SSO'),
businessTier: false, // Server tier - OAuth2/OIDC SSO businessTier: false,
documentationUrl: 'https://docs.stirlingpdf.com/Configuration/OAuth%20SSO%20Configuration',
fields: [ fields: [
{ {
key: 'issuer', key: 'issuer',
type: 'text', type: 'text',
label: 'Issuer URL', label: t('provider.oauth2.keycloak.issuer.label', 'Issuer URL'),
description: "URL of the Keycloak realm's OpenID Connect Discovery endpoint", description: t('provider.oauth2.keycloak.issuer.description', "URL of the Keycloak realm's OpenID Connect Discovery endpoint"),
placeholder: 'https://keycloak.example.com/realms/myrealm', placeholder: 'https://keycloak.example.com/realms/myrealm',
}, },
{ {
key: 'clientId', key: 'clientId',
type: 'text', type: 'text',
label: 'Client ID', label: t('provider.oauth2.keycloak.clientId.label', 'Client ID'),
description: 'The OAuth2 client ID from Keycloak', description: t('provider.oauth2.keycloak.clientId.description', 'The OAuth2 client ID from Keycloak'),
}, },
{ {
key: 'clientSecret', key: 'clientSecret',
type: 'password', type: 'password',
label: 'Client Secret', label: t('provider.oauth2.keycloak.clientSecret.label', 'Client Secret'),
description: 'The OAuth2 client secret from Keycloak', description: t('provider.oauth2.keycloak.clientSecret.description', 'The OAuth2 client secret from Keycloak'),
}, },
{ {
key: 'scopes', key: 'scopes',
type: 'text', type: 'text',
label: 'Scopes', label: t('provider.oauth2.keycloak.scopes.label', 'Scopes'),
description: 'Comma-separated OAuth2 scopes', description: t('provider.oauth2.keycloak.scopes.description', 'Comma-separated OAuth2 scopes'),
defaultValue: 'openid, profile, email', defaultValue: 'openid, profile, email',
}, },
{ {
key: 'useAsUsername', key: 'useAsUsername',
type: 'text', type: 'text',
label: 'Use as Username', label: t('provider.oauth2.keycloak.useAsUsername.label', 'Use as Username'),
description: 'Field to use as username (email, name, given_name, family_name, preferred_username)', description: t('provider.oauth2.keycloak.useAsUsername.description', 'Field to use as username (email, name, given_name, family_name, preferred_username)'),
defaultValue: 'preferred_username', defaultValue: 'preferred_username',
}, },
], ],
}, };
]; };
export const GENERIC_OAUTH2_PROVIDER: Provider = { const useGenericOAuth2Provider = (): Provider => {
const { t } = useTranslation();
return {
id: 'oauth2-generic', id: 'oauth2-generic',
name: 'Generic OAuth2', name: t('provider.oauth2.generic.name', 'Generic OAuth2'),
icon: 'link-rounded', icon: 'link-rounded',
type: 'oauth2', type: 'oauth2',
scope: 'SSO', scope: t('provider.oauth2.generic.scope', 'SSO'),
businessTier: false, // Server tier - OAuth2/OIDC SSO businessTier: false,
documentationUrl: 'https://docs.stirlingpdf.com/Configuration/OAuth%20SSO%20Configuration',
fields: [ fields: [
{ {
key: 'enabled', key: 'enabled',
type: 'switch', type: 'switch',
label: 'Enable Generic OAuth2', label: t('provider.oauth2.generic.enabled.label', 'Enable Generic OAuth2'),
description: 'Enable authentication using a custom OAuth2 provider', description: t('provider.oauth2.generic.enabled.description', 'Enable authentication using a custom OAuth2 provider'),
defaultValue: false, defaultValue: false,
}, },
{ {
key: 'provider', key: 'provider',
type: 'text', type: 'text',
label: 'Provider Name', label: t('provider.oauth2.generic.provider.label', 'Provider Name'),
description: 'The name of your OAuth2 provider (e.g., Azure AD, Okta)', description: t('provider.oauth2.generic.provider.description', 'The name of your OAuth2 provider (e.g., Azure AD, Okta)'),
placeholder: 'azure-ad', placeholder: 'azure-ad',
}, },
{ {
key: 'issuer', key: 'issuer',
type: 'text', type: 'text',
label: 'Issuer URL', label: t('provider.oauth2.generic.issuer.label', 'Issuer URL'),
description: 'Provider that supports OpenID Connect Discovery (/.well-known/openid-configuration)', description: t('provider.oauth2.generic.issuer.description', 'Provider that supports OpenID Connect Discovery (/.well-known/openid-configuration)'),
placeholder: 'https://login.microsoftonline.com/{tenant-id}/v2.0', placeholder: 'https://login.microsoftonline.com/{tenant-id}/v2.0',
}, },
{ {
key: 'clientId', key: 'clientId',
type: 'text', type: 'text',
label: 'Client ID', label: t('provider.oauth2.generic.clientId.label', 'Client ID'),
description: 'The OAuth2 client ID from your provider', description: t('provider.oauth2.generic.clientId.description', 'The OAuth2 client ID from your provider'),
}, },
{ {
key: 'clientSecret', key: 'clientSecret',
type: 'password', type: 'password',
label: 'Client Secret', label: t('provider.oauth2.generic.clientSecret.label', 'Client Secret'),
description: 'The OAuth2 client secret from your provider', description: t('provider.oauth2.generic.clientSecret.description', 'The OAuth2 client secret from your provider'),
}, },
{ {
key: 'scopes', key: 'scopes',
type: 'text', type: 'text',
label: 'Scopes', label: t('provider.oauth2.generic.scopes.label', 'Scopes'),
description: 'Comma-separated OAuth2 scopes', description: t('provider.oauth2.generic.scopes.description', 'Comma-separated OAuth2 scopes'),
defaultValue: 'openid, profile, email', defaultValue: 'openid, profile, email',
}, },
{ {
key: 'useAsUsername', key: 'useAsUsername',
type: 'text', type: 'text',
label: 'Use as Username', label: t('provider.oauth2.generic.useAsUsername.label', 'Use as Username'),
description: 'Field to use as username', description: t('provider.oauth2.generic.useAsUsername.description', 'Field to use as username'),
defaultValue: 'email', defaultValue: 'email',
}, },
{ {
key: 'autoCreateUser', key: 'autoCreateUser',
type: 'switch', type: 'switch',
label: 'Auto Create Users', label: t('provider.oauth2.generic.autoCreateUser.label', 'Auto Create Users'),
description: 'Automatically create user accounts on first OAuth2 login', description: t('provider.oauth2.generic.autoCreateUser.description', 'Automatically create user accounts on first OAuth2 login'),
defaultValue: true, defaultValue: true,
}, },
{ {
key: 'blockRegistration', key: 'blockRegistration',
type: 'switch', type: 'switch',
label: 'Block Registration', label: t('provider.oauth2.generic.blockRegistration.label', 'Block Registration'),
description: 'Prevent new user registration via OAuth2', description: t('provider.oauth2.generic.blockRegistration.description', 'Prevent new user registration via OAuth2'),
defaultValue: false, defaultValue: false,
}, },
], ],
}; };
};
export const SMTP_PROVIDER: Provider = { const useSMTPProvider = (): Provider => {
const { t } = useTranslation();
return {
id: 'smtp', id: 'smtp',
name: 'SMTP Mail', name: t('provider.smtp.name', 'SMTP Mail'),
icon: 'mail-rounded', icon: 'mail-rounded',
type: 'oauth2', // Using oauth2 as the base type, but it's really just a generic provider type: 'oauth2',
scope: 'Email Notifications', scope: t('provider.smtp.scope', 'Email Notifications'),
documentationUrl: 'https://docs.stirlingpdf.com/Configuration/System%20and%20Security/#email-configuration',
fields: [ fields: [
{ {
key: 'enabled', key: 'enabled',
type: 'switch', type: 'switch',
label: 'Enable Mail', label: t('provider.smtp.enabled.label', 'Enable Mail'),
description: 'Enable email notifications and SMTP functionality', description: t('provider.smtp.enabled.description', 'Enable email notifications and SMTP functionality'),
defaultValue: false, defaultValue: false,
}, },
{ {
key: 'host', key: 'host',
type: 'text', type: 'text',
label: 'SMTP Host', label: t('provider.smtp.host.label', 'SMTP Host'),
description: 'The hostname or IP address of your SMTP server', description: t('provider.smtp.host.description', 'The hostname or IP address of your SMTP server'),
placeholder: 'smtp.example.com', placeholder: 'smtp.example.com',
}, },
{ {
key: 'port', key: 'port',
type: 'number', type: 'number',
label: 'SMTP Port', label: t('provider.smtp.port.label', 'SMTP Port'),
description: 'The port number for SMTP connection (typically 25, 465, or 587)', description: t('provider.smtp.port.description', 'The port number for SMTP connection (typically 25, 465, or 587)'),
placeholder: '587', placeholder: '587',
defaultValue: '587', defaultValue: '587',
}, },
{ {
key: 'username', key: 'username',
type: 'text', type: 'text',
label: 'SMTP Username', label: t('provider.smtp.username.label', 'SMTP Username'),
description: 'Username for SMTP authentication', description: t('provider.smtp.username.description', 'Username for SMTP authentication'),
}, },
{ {
key: 'password', key: 'password',
type: 'password', type: 'password',
label: 'SMTP Password', label: t('provider.smtp.password.label', 'SMTP Password'),
description: 'Password for SMTP authentication', description: t('provider.smtp.password.description', 'Password for SMTP authentication'),
}, },
{ {
key: 'from', key: 'from',
type: 'text', type: 'text',
label: 'From Address', label: t('provider.smtp.from.label', 'From Address'),
description: 'The email address to use as the sender', description: t('provider.smtp.from.description', 'The email address to use as the sender'),
placeholder: '[email protected]', placeholder: '[email protected]',
}, },
], ],
}; };
};
const useTelegramProvider = (): Provider => { const useTelegramProvider = (): Provider => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -471,107 +497,165 @@ const useTelegramProvider = (): Provider => {
}; };
}; };
export const SAML2_PROVIDER: Provider = { const useSAML2Provider = (): Provider => {
const { t } = useTranslation();
return {
id: 'saml2', id: 'saml2',
name: 'SAML2', name: t('provider.saml2.name', 'SAML2'),
icon: 'verified-user-rounded', icon: 'verified-user-rounded',
type: 'saml2', type: 'saml2',
scope: 'SSO (SAML)', scope: t('provider.saml2.scope', 'SSO (SAML)'),
businessTier: true, // Enterprise tier - SAML only businessTier: true,
documentationUrl: 'https://docs.stirlingpdf.com/Configuration/SAML%20SSO%20Configuration/',
fields: [ fields: [
{ {
key: 'enabled', key: 'enabled',
type: 'switch', type: 'switch',
label: 'Enable SAML2', label: t('provider.saml2.enabled.label', 'Enable SAML2'),
description: 'Enable SAML2 authentication (Enterprise only)', description: t('provider.saml2.enabled.description', 'Enable SAML2 authentication (Enterprise only)'),
defaultValue: false, defaultValue: false,
}, },
{ {
key: 'provider', key: 'provider',
type: 'text', type: 'text',
label: 'Provider Name', label: t('provider.saml2.provider.label', 'Provider Name'),
description: 'The name of your SAML2 provider', description: t('provider.saml2.provider.description', 'The name of your SAML2 provider'),
}, },
{ {
key: 'registrationId', key: 'registrationId',
type: 'text', type: 'text',
label: 'Registration ID', label: t('provider.saml2.registrationId.label', 'Registration ID'),
description: 'The name of your Service Provider (SP) app name', description: t('provider.saml2.registrationId.description', 'The name of your Service Provider (SP) app name'),
defaultValue: 'stirling', defaultValue: 'stirling',
}, },
{ {
key: 'idpMetadataUri', key: 'idpMetadataUri',
type: 'text', type: 'text',
label: 'IDP Metadata URI', label: t('provider.saml2.idpMetadataUri.label', 'IDP Metadata URI'),
description: 'The URI for your provider\'s metadata', description: t('provider.saml2.idpMetadataUri.description', 'The URI for your provider\'s metadata'),
placeholder: 'https://dev-XXXXXXXX.okta.com/app/externalKey/sso/saml/metadata', placeholder: 'https://dev-XXXXXXXX.okta.com/app/externalKey/sso/saml/metadata',
}, },
{ {
key: 'idpSingleLoginUrl', key: 'idpSingleLoginUrl',
type: 'text', type: 'text',
label: 'IDP Single Login URL', label: t('provider.saml2.idpSingleLoginUrl.label', 'IDP Single Login URL'),
description: 'The URL for initiating SSO', description: t('provider.saml2.idpSingleLoginUrl.description', 'The URL for initiating SSO'),
placeholder: 'https://dev-XXXXXXXX.okta.com/app/dev-XXXXXXXX_stirlingpdf_1/externalKey/sso/saml', placeholder: 'https://dev-XXXXXXXX.okta.com/app/dev-XXXXXXXX_stirlingpdf_1/externalKey/sso/saml',
}, },
{ {
key: 'idpSingleLogoutUrl', key: 'idpSingleLogoutUrl',
type: 'text', type: 'text',
label: 'IDP Single Logout URL', label: t('provider.saml2.idpSingleLogoutUrl.label', 'IDP Single Logout URL'),
description: 'The URL for initiating SLO', description: t('provider.saml2.idpSingleLogoutUrl.description', 'The URL for initiating SLO'),
placeholder: 'https://dev-XXXXXXXX.okta.com/app/dev-XXXXXXXX_stirlingpdf_1/externalKey/slo/saml', placeholder: 'https://dev-XXXXXXXX.okta.com/app/dev-XXXXXXXX_stirlingpdf_1/externalKey/slo/saml',
}, },
{ {
key: 'idpIssuer', key: 'idpIssuer',
type: 'text', type: 'text',
label: 'IDP Issuer', label: t('provider.saml2.idpIssuer.label', 'IDP Issuer'),
description: 'The ID of your provider', description: t('provider.saml2.idpIssuer.description', 'The ID of your provider'),
}, },
{ {
key: 'idpCert', key: 'idpCert',
type: 'text', type: 'text',
label: 'IDP Certificate', label: t('provider.saml2.idpCert.label', 'IDP Certificate'),
description: 'The certificate path (e.g., classpath:okta.cert)', description: t('provider.saml2.idpCert.description', 'The certificate path (e.g., classpath:okta.cert)'),
placeholder: 'classpath:okta.cert', placeholder: 'classpath:okta.cert',
}, },
{ {
key: 'privateKey', key: 'privateKey',
type: 'text', type: 'text',
label: 'Private Key', label: t('provider.saml2.privateKey.label', 'Private Key'),
description: 'Your private key path', description: t('provider.saml2.privateKey.description', 'Your private key path'),
placeholder: 'classpath:saml-private-key.key', placeholder: 'classpath:saml-private-key.key',
}, },
{ {
key: 'spCert', key: 'spCert',
type: 'text', type: 'text',
label: 'SP Certificate', label: t('provider.saml2.spCert.label', 'SP Certificate'),
description: 'Your signing certificate path', description: t('provider.saml2.spCert.description', 'Your signing certificate path'),
placeholder: 'classpath:saml-public-cert.crt', placeholder: 'classpath:saml-public-cert.crt',
}, },
{ {
key: 'autoCreateUser', key: 'autoCreateUser',
type: 'switch', type: 'switch',
label: 'Auto Create Users', label: t('provider.saml2.autoCreateUser.label', 'Auto Create Users'),
description: 'Automatically create user accounts on first SAML2 login', description: t('provider.saml2.autoCreateUser.description', 'Automatically create user accounts on first SAML2 login'),
defaultValue: true, defaultValue: true,
}, },
{ {
key: 'blockRegistration', key: 'blockRegistration',
type: 'switch', type: 'switch',
label: 'Block Registration', label: t('provider.saml2.blockRegistration.label', 'Block Registration'),
description: 'Prevent new user registration via SAML2', description: t('provider.saml2.blockRegistration.description', 'Prevent new user registration via SAML2'),
defaultValue: false, defaultValue: false,
}, },
], ],
}; };
};
const useGoogleDriveProvider = (): Provider => {
const { t } = useTranslation();
return {
id: 'googledrive',
name: t('provider.googledrive.name', 'Google Drive'),
icon: '/images/google-drive.svg',
type: 'googledrive',
scope: t('provider.googledrive.scope', 'File Import'),
documentationUrl: 'https://docs.stirlingpdf.com/Configuration/Google%20Drive%20File%20Picker/',
fields: [
{
key: 'enabled',
type: 'switch',
label: t('provider.googledrive.enabled.label', 'Enable Google Drive File Picker'),
description: t('provider.googledrive.enabled.description', 'Allow users to import files directly from Google Drive'),
defaultValue: false,
},
{
key: 'clientId',
type: 'text',
label: t('provider.googledrive.clientId.label', 'Client ID'),
description: t('provider.googledrive.clientId.description', 'Google OAuth 2.0 Client ID from Google Cloud Console'),
placeholder: 'xxx.apps.googleusercontent.com',
},
{
key: 'apiKey',
type: 'text',
label: t('provider.googledrive.apiKey.label', 'API Key'),
description: t('provider.googledrive.apiKey.description', 'Google API Key for Google Picker API from Google Cloud Console'),
placeholder: 'AIza...',
},
{
key: 'appId',
type: 'text',
label: t('provider.googledrive.appId.label', 'App ID'),
description: t('provider.googledrive.appId.description', 'Google Drive App ID from Google Cloud Console'),
placeholder: 'xxxxxxxxxxxxx',
},
],
};
};
export const useAllProviders = (): Provider[] => { export const useAllProviders = (): Provider[] => {
const googleProvider = useGoogleProvider();
const gitHubProvider = useGitHubProvider();
const keycloakProvider = useKeycloakProvider();
const genericOAuth2Provider = useGenericOAuth2Provider();
const smtpProvider = useSMTPProvider();
const telegramProvider = useTelegramProvider(); const telegramProvider = useTelegramProvider();
const saml2Provider = useSAML2Provider();
const googleDriveProvider = useGoogleDriveProvider();
return [ return [
...OAUTH2_PROVIDERS, googleProvider,
GENERIC_OAUTH2_PROVIDER, gitHubProvider,
SAML2_PROVIDER, keycloakProvider,
SMTP_PROVIDER, genericOAuth2Provider,
saml2Provider,
smtpProvider,
telegramProvider, telegramProvider,
googleDriveProvider,
]; ];
}; };
@@ -2,11 +2,13 @@
* React hook for Google Drive file picker * React hook for Google Drive file picker
*/ */
import { useState, useCallback, useEffect } from 'react'; import { useState, useCallback, useEffect, useMemo } from 'react';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import { import {
getGoogleDrivePickerService, getGoogleDrivePickerService,
isGoogleDriveConfigured, isGoogleDriveConfigured,
getGoogleDriveConfig, getGoogleDriveConfig,
extractGoogleDriveBackendConfig,
} from '@app/services/googleDrivePickerService'; } from '@app/services/googleDrivePickerService';
interface UseGoogleDrivePickerOptions { interface UseGoogleDrivePickerOptions {
@@ -25,16 +27,27 @@ interface UseGoogleDrivePickerReturn {
* Hook to use Google Drive file picker * Hook to use Google Drive file picker
*/ */
export function useGoogleDrivePicker(): UseGoogleDrivePickerReturn { export function useGoogleDrivePicker(): UseGoogleDrivePickerReturn {
const { config } = useAppConfig();
const [isEnabled, setIsEnabled] = useState(false); const [isEnabled, setIsEnabled] = useState(false);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [isInitialized, setIsInitialized] = useState(false); const [isInitialized, setIsInitialized] = useState(false);
// Check if Google Drive is configured on mount // Memoize backend config to only track Google Drive specific properties
const googleDriveBackendConfig = useMemo(
() => extractGoogleDriveBackendConfig(config),
[config?.googleDriveEnabled, config?.googleDriveClientId, config?.googleDriveApiKey, config?.googleDriveAppId]
);
// Check if Google Drive is configured and reset initialization if disabled
useEffect(() => { useEffect(() => {
const configured = isGoogleDriveConfigured(); const configured = isGoogleDriveConfigured(googleDriveBackendConfig);
setIsEnabled(configured); setIsEnabled(configured);
}, []); // Reset initialization state if Google Drive becomes disabled
if (!configured) {
setIsInitialized(false);
}
}, [googleDriveBackendConfig]);
/** /**
* Initialize the Google Drive service (lazy initialization) * Initialize the Google Drive service (lazy initialization)
@@ -42,15 +55,15 @@ export function useGoogleDrivePicker(): UseGoogleDrivePickerReturn {
const initializeService = useCallback(async () => { const initializeService = useCallback(async () => {
if (isInitialized) return; if (isInitialized) return;
const config = getGoogleDriveConfig(); const googleDriveConfig = getGoogleDriveConfig(googleDriveBackendConfig);
if (!config) { if (!googleDriveConfig) {
throw new Error('Google Drive is not configured'); throw new Error('Google Drive is not configured');
} }
const service = getGoogleDrivePickerService(); const service = getGoogleDrivePickerService();
await service.initialize(config); await service.initialize(googleDriveConfig);
setIsInitialized(true); setIsInitialized(true);
}, [isInitialized]); }, [isInitialized, googleDriveBackendConfig]);
/** /**
* Open the Google Drive picker * Open the Google Drive picker
@@ -14,6 +14,13 @@ interface GoogleDriveConfig {
appId: string; appId: string;
} }
interface BackendGoogleDriveConfig {
enabled?: boolean;
clientId?: string;
apiKey?: string;
appId?: string;
}
interface PickerOptions { interface PickerOptions {
multiple?: boolean; multiple?: boolean;
mimeTypes?: string | null; mimeTypes?: string | null;
@@ -270,8 +277,21 @@ export function getGoogleDrivePickerService(): GoogleDrivePickerService {
/** /**
* Check if Google Drive credentials are configured * Check if Google Drive credentials are configured
* Supports both backend settings and environment variables
*/ */
export function isGoogleDriveConfigured(): boolean { export function isGoogleDriveConfigured(backendConfig?: BackendGoogleDriveConfig): boolean {
// If backend config is provided, use it exclusively (don't fall back to env vars)
if (backendConfig) {
if (backendConfig.enabled === false) {
return false; // Admin explicitly disabled it
}
if (backendConfig.enabled) {
return !!(backendConfig.clientId && backendConfig.apiKey && backendConfig.appId);
}
return false; // No backend config provided
}
// Fall back to environment variables only when backend config is not available
const clientId = import.meta.env.VITE_GOOGLE_DRIVE_CLIENT_ID; const clientId = import.meta.env.VITE_GOOGLE_DRIVE_CLIENT_ID;
const apiKey = import.meta.env.VITE_GOOGLE_DRIVE_API_KEY; const apiKey = import.meta.env.VITE_GOOGLE_DRIVE_API_KEY;
const appId = import.meta.env.VITE_GOOGLE_DRIVE_APP_ID; const appId = import.meta.env.VITE_GOOGLE_DRIVE_APP_ID;
@@ -280,16 +300,50 @@ export function isGoogleDriveConfigured(): boolean {
} }
/** /**
* Get Google Drive configuration from environment variables * Get Google Drive configuration from backend settings or environment variables
* Backend settings take priority over environment variables
*/ */
export function getGoogleDriveConfig(): GoogleDriveConfig | null { export function getGoogleDriveConfig(backendConfig?: BackendGoogleDriveConfig): GoogleDriveConfig | null {
if (!isGoogleDriveConfigured()) { // If backend config is provided, use it exclusively (don't fall back to env vars)
if (backendConfig) {
if (backendConfig.enabled === false) {
return null; // Admin explicitly disabled it
}
if (backendConfig.enabled && backendConfig.clientId && backendConfig.apiKey && backendConfig.appId) {
return {
clientId: backendConfig.clientId,
apiKey: backendConfig.apiKey,
appId: backendConfig.appId,
};
}
return null; // Backend config provided but incomplete
}
// Fall back to environment variables only when backend config is not available
const envClientId = import.meta.env.VITE_GOOGLE_DRIVE_CLIENT_ID;
const envApiKey = import.meta.env.VITE_GOOGLE_DRIVE_API_KEY;
const envAppId = import.meta.env.VITE_GOOGLE_DRIVE_APP_ID;
if (envClientId && envApiKey && envAppId) {
return {
clientId: envClientId,
apiKey: envApiKey,
appId: envAppId,
};
}
return null; return null;
} }
/**
* Extract Google Drive backend config from AppConfig object
* Eliminates duplicated config construction pattern
*/
export function extractGoogleDriveBackendConfig(appConfig: any): BackendGoogleDriveConfig {
return { return {
clientId: import.meta.env.VITE_GOOGLE_DRIVE_CLIENT_ID, enabled: appConfig?.googleDriveEnabled,
apiKey: import.meta.env.VITE_GOOGLE_DRIVE_API_KEY, clientId: appConfig?.googleDriveClientId,
appId: import.meta.env.VITE_GOOGLE_DRIVE_APP_ID, apiKey: appConfig?.googleDriveApiKey,
appId: appConfig?.googleDriveAppId,
}; };
} }
+6
View File
@@ -44,6 +44,12 @@ export interface AppConfig {
isNewUser?: boolean; isNewUser?: boolean;
defaultHideUnavailableTools?: boolean; defaultHideUnavailableTools?: boolean;
defaultHideUnavailableConversions?: boolean; defaultHideUnavailableConversions?: boolean;
hideDisabledToolsGoogleDrive?: boolean;
hideDisabledToolsMobileQRScanner?: boolean;
googleDriveEnabled?: boolean;
googleDriveClientId?: string;
googleDriveApiKey?: string;
googleDriveAppId?: string;
} }
export type AppConfigBootstrapMode = 'blocking' | 'non-blocking'; export type AppConfigBootstrapMode = 'blocking' | 'non-blocking';
@@ -78,6 +78,10 @@ interface ConnectionsSettingsData {
mobileScannerImageResolution?: string; mobileScannerImageResolution?: string;
mobileScannerPageFormat?: string; mobileScannerPageFormat?: string;
mobileScannerStretchToFit?: boolean; mobileScannerStretchToFit?: boolean;
googleDriveEnabled?: boolean;
googleDriveClientId?: string;
googleDriveApiKey?: string;
googleDriveAppId?: string;
} }
export default function AdminConnectionsSection() { export default function AdminConnectionsSection() {
@@ -120,24 +124,61 @@ export default function AdminConnectionsSection() {
mobileScannerConvertToPdf: systemData.mobileScannerSettings?.convertToPdf !== false, mobileScannerConvertToPdf: systemData.mobileScannerSettings?.convertToPdf !== false,
mobileScannerImageResolution: systemData.mobileScannerSettings?.imageResolution || 'full', mobileScannerImageResolution: systemData.mobileScannerSettings?.imageResolution || 'full',
mobileScannerPageFormat: systemData.mobileScannerSettings?.pageFormat || 'A4', mobileScannerPageFormat: systemData.mobileScannerSettings?.pageFormat || 'A4',
mobileScannerStretchToFit: systemData.mobileScannerSettings?.stretchToFit || false mobileScannerStretchToFit: systemData.mobileScannerSettings?.stretchToFit || false,
googleDriveEnabled: premiumData.proFeatures?.googleDrive?.enabled || false,
googleDriveClientId: premiumData.proFeatures?.googleDrive?.clientId || '',
googleDriveApiKey: premiumData.proFeatures?.googleDrive?.apiKey || '',
googleDriveAppId: premiumData.proFeatures?.googleDrive?.appId || ''
}; };
// Merge pending blocks from all endpoints - initialize with defaults to avoid warnings // Merge pending blocks from all endpoints
const pendingBlock: Record<string, any> = { const pendingBlock: Record<string, any> = {};
oauth2: securityData._pending?.oauth2, if (securityData._pending?.oauth2) {
saml2: securityData._pending?.saml2, pendingBlock.oauth2 = securityData._pending.oauth2;
mail: mailData._pending, }
telegram: telegramData._pending, if (securityData._pending?.saml2) {
ssoAutoLogin: premiumData._pending?.proFeatures?.ssoAutoLogin, pendingBlock.saml2 = securityData._pending.saml2;
enableMobileScanner: systemData._pending?.enableMobileScanner, }
mobileScannerConvertToPdf: systemData._pending?.mobileScannerSettings?.convertToPdf, if (mailData._pending) {
mobileScannerImageResolution: systemData._pending?.mobileScannerSettings?.imageResolution, pendingBlock.mail = mailData._pending;
mobileScannerPageFormat: systemData._pending?.mobileScannerSettings?.pageFormat, }
mobileScannerStretchToFit: systemData._pending?.mobileScannerSettings?.stretchToFit, if (telegramData._pending) {
}; pendingBlock.telegram = telegramData._pending;
}
if (premiumData._pending?.proFeatures?.ssoAutoLogin !== undefined) {
pendingBlock.ssoAutoLogin = premiumData._pending.proFeatures.ssoAutoLogin;
}
if (systemData._pending?.enableMobileScanner !== undefined) {
pendingBlock.enableMobileScanner = systemData._pending.enableMobileScanner;
}
if (systemData._pending?.mobileScannerSettings?.convertToPdf !== undefined) {
pendingBlock.mobileScannerConvertToPdf = systemData._pending.mobileScannerSettings.convertToPdf;
}
if (systemData._pending?.mobileScannerSettings?.imageResolution !== undefined) {
pendingBlock.mobileScannerImageResolution = systemData._pending.mobileScannerSettings.imageResolution;
}
if (systemData._pending?.mobileScannerSettings?.pageFormat !== undefined) {
pendingBlock.mobileScannerPageFormat = systemData._pending.mobileScannerSettings.pageFormat;
}
if (systemData._pending?.mobileScannerSettings?.stretchToFit !== undefined) {
pendingBlock.mobileScannerStretchToFit = systemData._pending.mobileScannerSettings.stretchToFit;
}
if (premiumData._pending?.proFeatures?.googleDrive?.enabled !== undefined) {
pendingBlock.googleDriveEnabled = premiumData._pending.proFeatures.googleDrive.enabled;
}
if (premiumData._pending?.proFeatures?.googleDrive?.clientId !== undefined) {
pendingBlock.googleDriveClientId = premiumData._pending.proFeatures.googleDrive.clientId;
}
if (premiumData._pending?.proFeatures?.googleDrive?.apiKey !== undefined) {
pendingBlock.googleDriveApiKey = premiumData._pending.proFeatures.googleDrive.apiKey;
}
if (premiumData._pending?.proFeatures?.googleDrive?.appId !== undefined) {
pendingBlock.googleDriveAppId = premiumData._pending.proFeatures.googleDrive.appId;
}
if (Object.keys(pendingBlock).length > 0) {
result._pending = pendingBlock; result._pending = pendingBlock;
}
return result; return result;
}, },
@@ -207,6 +248,20 @@ export default function AdminConnectionsSection() {
deltaSettings['system.mobileScannerSettings.stretchToFit'] = currentSettings.mobileScannerStretchToFit; deltaSettings['system.mobileScannerSettings.stretchToFit'] = currentSettings.mobileScannerStretchToFit;
} }
// Google Drive settings
if (currentSettings?.googleDriveEnabled !== undefined) {
deltaSettings['premium.proFeatures.googleDrive.enabled'] = currentSettings.googleDriveEnabled;
}
if (currentSettings?.googleDriveClientId !== undefined) {
deltaSettings['premium.proFeatures.googleDrive.clientId'] = currentSettings.googleDriveClientId;
}
if (currentSettings?.googleDriveApiKey !== undefined) {
deltaSettings['premium.proFeatures.googleDrive.apiKey'] = currentSettings.googleDriveApiKey;
}
if (currentSettings?.googleDriveAppId !== undefined) {
deltaSettings['premium.proFeatures.googleDrive.appId'] = currentSettings.googleDriveAppId;
}
return { return {
sectionData: {}, sectionData: {},
deltaSettings deltaSettings
@@ -264,6 +319,10 @@ export default function AdminConnectionsSection() {
return settings?.telegram?.enabled === true; return settings?.telegram?.enabled === true;
} }
if (provider.id === 'googledrive') {
return settings?.googleDriveEnabled === true;
}
if (provider.id === 'oauth2-generic') { if (provider.id === 'oauth2-generic') {
return settings?.oauth2?.enabled === true; return settings?.oauth2?.enabled === true;
} }
@@ -286,6 +345,15 @@ export default function AdminConnectionsSection() {
return settings?.telegram || {}; return settings?.telegram || {};
} }
if (provider.id === 'googledrive') {
return {
enabled: settings?.googleDriveEnabled,
clientId: settings?.googleDriveClientId,
apiKey: settings?.googleDriveApiKey,
appId: settings?.googleDriveAppId,
};
}
if (provider.id === 'oauth2-generic') { if (provider.id === 'oauth2-generic') {
// Generic OAuth2 settings are at the root oauth2 level // Generic OAuth2 settings are at the root oauth2 level
return { return {
@@ -323,6 +391,14 @@ export default function AdminConnectionsSection() {
setSettings({ ...settings, mail: updatedSettings }); setSettings({ ...settings, mail: updatedSettings });
} else if (provider.id === 'telegram') { } else if (provider.id === 'telegram') {
setSettings({ ...settings, telegram: updatedSettings }); setSettings({ ...settings, telegram: updatedSettings });
} else if (provider.id === 'googledrive') {
setSettings({
...settings,
googleDriveEnabled: updatedSettings.enabled,
googleDriveClientId: updatedSettings.clientId,
googleDriveApiKey: updatedSettings.apiKey,
googleDriveAppId: updatedSettings.appId,
});
} else if (provider.id === 'saml2') { } else if (provider.id === 'saml2') {
setSettings({ ...settings, saml2: updatedSettings }); setSettings({ ...settings, saml2: updatedSettings });
} else if (provider.id === 'oauth2-generic') { } else if (provider.id === 'oauth2-generic') {
@@ -407,6 +483,16 @@ export default function AdminConnectionsSection() {
<Text fw={600} size="sm">{t('admin.settings.connections.mobileScanner.label', 'Mobile Phone Upload')}</Text> <Text fw={600} size="sm">{t('admin.settings.connections.mobileScanner.label', 'Mobile Phone Upload')}</Text>
</Group> </Group>
{/* Documentation Link */}
<Anchor
href="https://docs.stirlingpdf.com/Functionality/Mobile-Scanner"
target="_blank"
size="xs"
c="blue"
>
{t('admin.settings.connections.documentation', 'View documentation')}
</Anchor>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div> <div>
<Text fw={500} size="sm">{t('admin.settings.connections.mobileScanner.enable', 'Enable QR Code Upload')}</Text> <Text fw={500} size="sm">{t('admin.settings.connections.mobileScanner.enable', 'Enable QR Code Upload')}</Text>
@@ -22,6 +22,10 @@ interface GeneralSettingsData {
appNameNavbar?: string; appNameNavbar?: string;
languages?: string[]; languages?: string[];
logoStyle?: 'modern' | 'classic'; logoStyle?: 'modern' | 'classic';
hideDisabledTools?: {
googleDrive?: boolean;
mobileQRScanner?: boolean;
};
}; };
system: { system: {
defaultLocale?: string; defaultLocale?: string;
@@ -162,6 +166,8 @@ export default function AdminGeneralSection() {
'ui.appNameNavbar': settings.ui?.appNameNavbar, 'ui.appNameNavbar': settings.ui?.appNameNavbar,
'ui.languages': settings.ui?.languages, 'ui.languages': settings.ui?.languages,
'ui.logoStyle': settings.ui?.logoStyle, 'ui.logoStyle': settings.ui?.logoStyle,
'ui.hideDisabledTools.googleDrive': settings.ui?.hideDisabledTools?.googleDrive,
'ui.hideDisabledTools.mobileQRScanner': settings.ui?.hideDisabledTools?.mobileQRScanner,
// System settings // System settings
'system.defaultLocale': settings.system?.defaultLocale, 'system.defaultLocale': settings.system?.defaultLocale,
'system.showUpdate': settings.system?.showUpdate, 'system.showUpdate': settings.system?.showUpdate,
@@ -503,6 +509,41 @@ export default function AdminGeneralSection() {
/> />
</div> </div>
{/* Hide Disabled Tools Settings */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '1rem' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.general.hideDisabledTools.googleDrive.label', 'Hide Google Drive')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.general.hideDisabledTools.googleDrive.description', 'Hide Google Drive button when not enabled')}
</Text>
</div>
<Group gap="xs">
<Switch
checked={settings.ui?.hideDisabledTools?.googleDrive || false}
onChange={(e) => setSettings({ ...settings, ui: { ...settings.ui, hideDisabledTools: { ...settings.ui?.hideDisabledTools, googleDrive: e.target.checked } } })}
disabled={!loginEnabled}
/>
<PendingBadge show={isFieldPending('ui.hideDisabledTools.googleDrive')} />
</Group>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.general.hideDisabledTools.mobileScanner.label', 'Hide Mobile Scanner')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.general.hideDisabledTools.mobileScanner.description', 'Hide mobile QR scanner button when not enabled')}
</Text>
</div>
<Group gap="xs">
<Switch
checked={settings.ui?.hideDisabledTools?.mobileQRScanner || false}
onChange={(e) => setSettings({ ...settings, ui: { ...settings.ui, hideDisabledTools: { ...settings.ui?.hideDisabledTools, mobileQRScanner: e.target.checked } } })}
disabled={!loginEnabled}
/>
<PendingBadge show={isFieldPending('ui.hideDisabledTools.mobileQRScanner')} />
</Group>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div> <div>
<Text fw={500} size="sm">{t('admin.settings.general.showUpdate.label', 'Show Update Notifications')}</Text> <Text fw={500} size="sm">{t('admin.settings.general.showUpdate.label', 'Show Update Notifications')}</Text>