Merge branch 'main' into remove-background-sign

This commit is contained in:
Balázs Szücs 2025-12-13 16:47:57 +01:00 committed by GitHub
commit 6b9e88734f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
48 changed files with 1851 additions and 837 deletions

12
.github/README.md vendored
View File

@ -1,12 +0,0 @@
# CI Configuration
## CI Lite Mode
Skip non-essential CI workflows by setting a repository variable:
**Settings → Secrets and variables → Actions → Variables → New repository variable**
- Name: `CI_PROFILE`
- Value: `lite`
Skips resource-intensive builds, releases, and OSS-specific workflows. Useful for deployment-only forks or faster CI runs.

View File

@ -262,7 +262,13 @@ jobs:
strategy:
fail-fast: false
matrix:
docker-rev: ["docker/embedded/Dockerfile", "docker/embedded/Dockerfile.ultra-lite", "docker/embedded/Dockerfile.fat"]
include:
- docker-rev: docker/embedded/Dockerfile
artifact-suffix: Dockerfile
- docker-rev: docker/embedded/Dockerfile.ultra-lite
artifact-suffix: Dockerfile.ultra-lite
- docker-rev: docker/embedded/Dockerfile.fat
artifact-suffix: Dockerfile.fat
steps:
- name: Harden Runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
@ -272,6 +278,13 @@ jobs:
- name: Checkout Repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Free disk space on runner
run: |
echo "Disk space before cleanup:" && df -h
sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/lib/android /usr/local/share/boost
docker system prune -af || true
echo "Disk space after cleanup:" && df -h
- name: Set up JDK 17
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
@ -313,7 +326,7 @@ jobs:
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: reports-docker-${{ matrix.docker-rev }}
name: reports-docker-${{ matrix.artifact-suffix }}
path: |
build/reports/tests/
build/test-results/

View File

@ -26,6 +26,7 @@ public class RequestUriUtils {
|| normalizedUri.startsWith("/public/")
|| normalizedUri.startsWith("/pdfjs/")
|| normalizedUri.startsWith("/pdfjs-legacy/")
|| normalizedUri.startsWith("/pdfium/")
|| normalizedUri.startsWith("/assets/")
|| normalizedUri.startsWith("/locales/")
|| normalizedUri.startsWith("/Login/")
@ -61,7 +62,8 @@ public class RequestUriUtils {
|| normalizedUri.endsWith(".css")
|| normalizedUri.endsWith(".mjs")
|| normalizedUri.endsWith(".html")
|| normalizedUri.endsWith(".toml");
|| normalizedUri.endsWith(".toml")
|| normalizedUri.endsWith(".wasm");
}
public static boolean isFrontendRoute(String contextPath, String requestURI) {
@ -125,11 +127,13 @@ public class RequestUriUtils {
|| requestURI.endsWith("popularity.txt")
|| requestURI.endsWith(".js")
|| requestURI.endsWith(".toml")
|| requestURI.endsWith(".wasm")
|| requestURI.contains("swagger")
|| requestURI.startsWith("/api/v1/info")
|| requestURI.startsWith("/site.webmanifest")
|| requestURI.startsWith("/fonts")
|| requestURI.startsWith("/pdfjs"));
|| requestURI.startsWith("/pdfjs")
|| requestURI.startsWith("/pdfium"));
}
/**

View File

@ -24,6 +24,9 @@ public class RequestUriUtilsTest {
assertTrue(
RequestUriUtils.isStaticResource("/pdfjs/pdf.worker.js"),
"PDF.js files should be static");
assertTrue(
RequestUriUtils.isStaticResource("/pdfium/pdfium.wasm"),
"PDFium wasm should be static");
assertTrue(
RequestUriUtils.isStaticResource("/api/v1/info/status"),
"API status should be static");
@ -110,7 +113,8 @@ public class RequestUriUtilsTest {
"/downloads/document.png",
"/assets/brand.ico",
"/any/path/with/image.svg",
"/deep/nested/folder/icon.png"
"/deep/nested/folder/icon.png",
"/pdfium/pdfium.wasm"
})
void testIsStaticResourceWithFileExtensions(String path) {
assertTrue(
@ -148,6 +152,9 @@ public class RequestUriUtilsTest {
assertFalse(
RequestUriUtils.isTrackableResource("/script.js"),
"JS files should not be trackable");
assertFalse(
RequestUriUtils.isTrackableResource("/pdfium/pdfium.wasm"),
"PDFium wasm should not be trackable");
assertFalse(
RequestUriUtils.isTrackableResource("/swagger/index.html"),
"Swagger files should not be trackable");
@ -224,7 +231,8 @@ public class RequestUriUtilsTest {
"/api/v1/info/health",
"/site.webmanifest",
"/fonts/roboto.woff",
"/pdfjs/viewer.js"
"/pdfjs/viewer.js",
"/pdfium/pdfium.wasm"
})
void testNonTrackableResources(String path) {
assertFalse(

View File

@ -66,7 +66,8 @@ public class OpenApiConfig {
if (swaggerServerUrl != null && !swaggerServerUrl.trim().isEmpty()) {
server = new Server().url(swaggerServerUrl).description("API Server");
} else {
// Use relative path so Swagger uses the current browser origin to avoid CORS issues when accessing via different ports
// Use relative path so Swagger uses the current browser origin to avoid CORS issues
// when accessing via different ports
server = new Server().url("/").description("Current Server");
}
openAPI.addServersItem(server);

View File

@ -1,10 +1,14 @@
package stirling.software.SPDF.config;
import java.util.concurrent.TimeUnit;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.CacheControl;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import lombok.RequiredArgsConstructor;
@ -25,6 +29,20 @@ public class WebMvcConfig implements WebMvcConfigurer {
registry.addInterceptor(endpointInterceptor);
}
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
// Cache hashed assets (JS/CSS with content hashes) for 1 year
// These files have names like index-ChAS4tCC.js that change when content changes
registry.addResourceHandler("/assets/**")
.addResourceLocations("classpath:/static/assets/")
.setCacheControl(CacheControl.maxAge(365, TimeUnit.DAYS).cachePublic());
// Don't cache index.html - it needs to be fresh to reference latest hashed assets
registry.addResourceHandler("/index.html")
.addResourceLocations("classpath:/static/")
.setCacheControl(CacheControl.noCache().mustRevalidate());
}
@Override
public void addCorsMappings(CorsRegistry registry) {
// Check if running in Tauri mode

View File

@ -197,7 +197,6 @@ public class SecurityConfiguration {
http.csrf(CsrfConfigurer::disable);
if (loginEnabledValue) {
boolean v2Enabled = appConfig.v2Enabled();
http.addFilterBefore(
userAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
@ -205,19 +204,9 @@ public class SecurityConfiguration {
.addFilterBefore(jwtAuthenticationFilter, UserAuthenticationFilter.class);
http.sessionManagement(
sessionManagement -> {
if (v2Enabled) {
sessionManagement ->
sessionManagement.sessionCreationPolicy(
SessionCreationPolicy.STATELESS);
} else {
sessionManagement
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
.maximumSessions(10)
.maxSessionsPreventsLogin(false)
.sessionRegistry(sessionRegistry)
.expiredUrl("/login?logout=true");
}
});
SessionCreationPolicy.STATELESS));
http.authenticationProvider(daoAuthenticationProvider());
http.requestCache(requestCache -> requestCache.requestCache(new NullRequestCache()));
@ -300,18 +289,7 @@ public class SecurityConfiguration {
if (securityProperties.isOauth2Active()) {
http.oauth2Login(
oauth2 -> {
// v1: Use /oauth2 as login page for Thymeleaf templates
if (!v2Enabled) {
oauth2.loginPage("/oauth2");
}
// v2: Don't set loginPage, let default OAuth2 flow handle it
oauth2
/*
This Custom handler is used to check if the OAUTH2 user trying to log in, already exists in the database.
If user exists, login proceeds as usual. If user does not exist, then it is auto-created but only if 'OAUTH2AutoCreateUser'
is set as true, else login fails with an error message advising the same.
*/
oauth2.loginPage("/login")
.successHandler(
new CustomOAuth2AuthenticationSuccessHandler(
loginAttemptService,
@ -345,12 +323,8 @@ public class SecurityConfiguration {
.saml2Login(
saml2 -> {
try {
// Only set login page for v1/Thymeleaf mode
if (!v2Enabled) {
saml2.loginPage("/saml2");
}
saml2.relyingPartyRegistrationRepository(
saml2.loginPage("/login")
.relyingPartyRegistrationRepository(
saml2RelyingPartyRegistrations)
.authenticationManager(
new ProviderManager(authenticationProvider))

View File

@ -226,7 +226,8 @@ public class EmailService {
@Async
public void sendPasswordChangedNotification(
String to, String username, String newPassword, String loginUrl) throws MessagingException {
String to, String username, String newPassword, String loginUrl)
throws MessagingException {
String subject = "Your Stirling PDF password has been updated";
String passwordSection =

View File

@ -12,6 +12,8 @@ plugins {
}
import com.github.jk1.license.render.*
import groovy.json.JsonOutput
import groovy.json.JsonSlurper
ext {
springBootVersion = "3.5.6"
@ -57,7 +59,7 @@ repositories {
allprojects {
group = 'stirling.software'
version = '2.1.2'
version = '2.1.3'
configurations.configureEach {
exclude group: 'commons-logging', module: 'commons-logging'
@ -65,6 +67,51 @@ allprojects {
}
}
def writeIfChanged(File targetFile, String newContent) {
if (targetFile.getText('UTF-8') != newContent) {
targetFile.write(newContent, 'UTF-8')
}
}
def updateTauriConfigVersion(String version) {
File tauriConfig = file('frontend/src-tauri/tauri.conf.json')
def parsed = new JsonSlurper().parse(tauriConfig)
parsed.version = version
def formatted = JsonOutput.prettyPrint(JsonOutput.toJson(parsed)) + System.lineSeparator()
writeIfChanged(tauriConfig, formatted)
}
def updateSimulationVersion(File fileToUpdate, String version) {
def content = fileToUpdate.getText('UTF-8')
def matcher = content =~ /(appVersion:\s*')([^']*)(')/
if (!matcher.find()) {
throw new GradleException("Could not locate appVersion in ${fileToUpdate} for synchronization")
}
def updatedContent = matcher.replaceFirst("${matcher.group(1)}${version}${matcher.group(3)}")
writeIfChanged(fileToUpdate, updatedContent)
}
tasks.register('syncAppVersion') {
group = 'versioning'
description = 'Synchronizes app version across desktop and simulation configs.'
doLast {
def appVersion = project.version.toString()
println "Synchronizing application version to ${appVersion}"
updateTauriConfigVersion(appVersion)
[
'frontend/src/core/testing/serverExperienceSimulations.ts',
'frontend/src/proprietary/testing/serverExperienceSimulations.ts'
].each { path ->
updateSimulationVersion(file(path), appVersion)
}
}
}
tasks.register('writeVersion', WriteProperties) {
destinationFile = layout.projectDirectory.file('app/common/src/main/resources/version.properties')
println "Writing version.properties to ${destinationFile.get().asFile.path}"
@ -314,7 +361,7 @@ tasks.named('bootRun') {
tasks.named('build') {
group = 'build'
description = 'Delegates to :stirling-pdf:bootJar'
dependsOn ':stirling-pdf:bootJar', 'buildRestartHelper'
dependsOn ':stirling-pdf:bootJar', 'buildRestartHelper', 'syncAppVersion'
doFirst {
println "Delegating to :stirling-pdf:bootJar"

View File

@ -105,6 +105,7 @@
"typescript": "^5.9.2",
"typescript-eslint": "^8.44.1",
"vite": "^7.1.7",
"vite-plugin-static-copy": "^3.1.4",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.2.4"
}
@ -11093,6 +11094,19 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-map": {
"version": "7.0.4",
"resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz",
"integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/pac-proxy-agent": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz",
@ -14503,6 +14517,25 @@
"url": "https://opencollective.com/vitest"
}
},
"node_modules/vite-plugin-static-copy": {
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/vite-plugin-static-copy/-/vite-plugin-static-copy-3.1.4.tgz",
"integrity": "sha512-iCmr4GSw4eSnaB+G8zc2f4dxSuDjbkjwpuBLLGvQYR9IW7rnDzftnUjOH5p4RYR+d4GsiBqXRvzuFhs5bnzVyw==",
"dev": true,
"license": "MIT",
"dependencies": {
"chokidar": "^3.6.0",
"p-map": "^7.0.3",
"picocolors": "^1.1.1",
"tinyglobby": "^0.2.15"
},
"engines": {
"node": "^18.0.0 || >=20.0.0"
},
"peerDependencies": {
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
"node_modules/vite-tsconfig-paths": {
"version": "5.1.4",
"resolved": "https://registry.npmjs.org/vite-tsconfig-paths/-/vite-tsconfig-paths-5.1.4.tgz",

View File

@ -152,6 +152,7 @@
"typescript": "^5.9.2",
"typescript-eslint": "^8.44.1",
"vite": "^7.1.7",
"vite-plugin-static-copy": "^3.1.4",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.2.4"
},

View File

@ -6131,8 +6131,8 @@ tags = "text,anmerkung,beschriftung"
applySignatures = "Text anwenden"
[addText.text]
name = "Textinhalt"
placeholder = "Geben Sie den hinzuzufügenden Text ein"
name = "Text"
placeholder = "Text eingeben"
fontLabel = "Schriftart"
fontSizeLabel = "Schriftgröße"
fontSizePlaceholder = "Schriftgröße eingeben oder wählen (8-200)"

View File

@ -435,6 +435,24 @@ latestVersion = "Latest Version"
checkForUpdates = "Check for Updates"
viewDetails = "View Details"
[settings.security]
title = "Security"
description = "Update your password to keep your account secure."
[settings.security.password]
subtitle = "Change your password. You will be logged out after updating."
required = "All fields are required."
mismatch = "New passwords do not match."
error = "Unable to update password. Please verify your current password and try again."
success = "Password updated successfully. Please sign in again."
current = "Current password"
currentPlaceholder = "Enter your current password"
new = "New password"
newPlaceholder = "Enter a new password"
confirm = "Confirm new password"
confirmPlaceholder = "Re-enter your new password"
update = "Update password"
[settings.hotkeys]
title = "Keyboard Shortcuts"
description = "Customize keyboard shortcuts for quick tool access. Click \"Change shortcut\" and press a new key combination. Press Esc to cancel."
@ -493,6 +511,10 @@ oldPassword = "Current Password"
newPassword = "New Password"
confirmNewPassword = "Confirm New Password"
submit = "Submit Changes"
credsUpdated = "Account updated"
description = "Changes saved. Please log in again."
error = "Unable to update username. Please verify your password and try again."
changeUsername = "Update your username. You will be logged out after updating."
[account]
title = "Account Settings"
@ -5072,6 +5094,7 @@ loading = "Loading..."
back = "Back"
continue = "Continue"
error = "Error"
save = "Save"
[config.overview]
title = "Application Configuration"
@ -5571,6 +5594,28 @@ contactSales = "Contact Sales"
contactToUpgrade = "Contact us to upgrade or customize your plan"
maxUsers = "Max Users"
upTo = "Up to"
getLicense = "Get Server License"
upgradeToEnterprise = "Upgrade to Enterprise"
selectPeriod = "Select Billing Period"
monthlyBilling = "Monthly Billing"
yearlyBilling = "Yearly Billing"
checkoutOpened = "Checkout Opened"
checkoutInstructions = "Complete your purchase in the Stripe tab. After payment, return here and refresh the page to activate your license. You will also receive an email with your license key."
activateLicense = "Activate Your License"
[plan.static.licenseActivation]
checkoutOpened = "Checkout Opened in New Tab"
instructions = "Complete your purchase in the Stripe tab. Once your payment is complete, you will receive an email with your license key."
enterKey = "Enter your license key below to activate your plan:"
keyDescription = "Paste the license key from your email"
activate = "Activate License"
doLater = "I'll do this later"
success = "License Activated!"
successMessage = "Your license has been successfully activated. You can now close this window."
[plan.static.billingPortal]
title = "Email Verification Required"
message = "You will need to verify your email address in the Stripe billing portal. Check your email for a login link."
[plan.period]
month = "month"

View File

@ -152,15 +152,27 @@ pub fn run() {
}
#[cfg(target_os = "macos")]
RunEvent::Opened { urls } => {
use urlencoding::decode;
add_log(format!("📂 Tauri file opened event: {:?}", urls));
let mut added_files = false;
for url in urls {
let url_str = url.as_str();
if url_str.starts_with("file://") {
let file_path = url_str.strip_prefix("file://").unwrap_or(url_str);
let encoded_path = url_str.strip_prefix("file://").unwrap_or(url_str);
// Decode URL-encoded characters (%20 -> space, etc.)
let file_path = match decode(encoded_path) {
Ok(decoded) => decoded.into_owned(),
Err(e) => {
add_log(format!("⚠️ Failed to decode file path: {} - {}", encoded_path, e));
encoded_path.to_string() // Fallback to encoded path
}
};
add_log(format!("📂 Processing opened file: {}", file_path));
add_opened_file(file_path.to_string());
add_opened_file(file_path);
added_files = true;
}
}

View File

@ -1,6 +1,5 @@
import React, { useState, useEffect } from 'react';
import { Stack, TextInput, Select, Combobox, useCombobox, Group, Box } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { ColorPicker } from '@app/components/annotation/shared/ColorPicker';
interface TextInputWithFontProps {
@ -13,8 +12,12 @@ interface TextInputWithFontProps {
textColor?: string;
onTextColorChange?: (color: string) => void;
disabled?: boolean;
label?: string;
placeholder?: string;
label: string;
placeholder: string;
fontLabel: string;
fontSizeLabel: string;
fontSizePlaceholder: string;
colorLabel?: string;
onAnyChange?: () => void;
}
@ -30,9 +33,12 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
disabled = false,
label,
placeholder,
fontLabel,
fontSizeLabel,
fontSizePlaceholder,
colorLabel,
onAnyChange
}) => {
const { t } = useTranslation();
const [fontSizeInput, setFontSizeInput] = useState(fontSize.toString());
const fontSizeCombobox = useCombobox();
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
@ -66,8 +72,8 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
return (
<Stack gap="sm">
<TextInput
label={label || t('sign.text.name', 'Signer name')}
placeholder={placeholder || t('sign.text.placeholder', 'Enter your full name')}
label={label}
placeholder={placeholder}
value={text}
onChange={(e) => {
onTextChange(e.target.value);
@ -79,7 +85,7 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
{/* Font Selection */}
<Select
label={t('sign.text.fontLabel', 'Font')}
label={fontLabel}
value={fontFamily}
onChange={(value) => {
onFontFamilyChange(value || 'Helvetica');
@ -107,8 +113,8 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
>
<Combobox.Target>
<TextInput
label={t('sign.text.fontSizeLabel', 'Font size')}
placeholder={t('sign.text.fontSizePlaceholder', 'Type or select font size (8-200)')}
label={fontSizeLabel}
placeholder={fontSizePlaceholder}
value={fontSizeInput}
onChange={(event) => {
const value = event.currentTarget.value;
@ -155,7 +161,7 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
{onTextColorChange && (
<Box>
<TextInput
label={t('sign.text.colorLabel', 'Text colour')}
label={colorLabel}
value={colorInput}
placeholder="#000000"
disabled={disabled}

View File

@ -1,57 +0,0 @@
import React, { useState } from 'react';
import { Stack } from '@mantine/core';
import { BaseAnnotationTool } from '@app/components/annotation/shared/BaseAnnotationTool';
import { TextInputWithFont } from '@app/components/annotation/shared/TextInputWithFont';
interface TextToolProps {
onTextChange?: (text: string) => void;
disabled?: boolean;
}
export const TextTool: React.FC<TextToolProps> = ({
onTextChange,
disabled = false
}) => {
const [text, setText] = useState('');
const [fontSize, setFontSize] = useState(16);
const [fontFamily, setFontFamily] = useState('Helvetica');
const handleTextChange = (newText: string) => {
setText(newText);
onTextChange?.(newText);
};
const handleSignatureDataChange = (data: string | null) => {
if (data) {
onTextChange?.(data);
}
};
const toolConfig = {
enableTextInput: true,
showPlaceButton: true,
placeButtonText: "Place Text"
};
return (
<BaseAnnotationTool
config={toolConfig}
onSignatureDataChange={handleSignatureDataChange}
disabled={disabled}
>
<Stack gap="sm">
<TextInputWithFont
text={text}
onTextChange={handleTextChange}
fontSize={fontSize}
onFontSizeChange={setFontSize}
fontFamily={fontFamily}
onFontFamilyChange={setFontFamily}
disabled={disabled}
label="Text Content"
placeholder="Enter text to place on the PDF"
/>
</Stack>
</BaseAnnotationTool>
);
};

View File

@ -1,5 +1,4 @@
import React from 'react';
import { ActionIcon } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { Tooltip } from '@app/components/shared/Tooltip';
import AppsIcon from '@mui/icons-material/AppsRounded';
@ -7,6 +6,7 @@ import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
import { useNavigationState, useNavigationActions } from '@app/contexts/NavigationContext';
import { useSidebarNavigation } from '@app/hooks/useSidebarNavigation';
import { handleUnlessSpecialClick } from '@app/utils/clickHandlers';
import QuickAccessButton from '@app/components/shared/quickAccessBar/QuickAccessButton';
interface AllToolsNavButtonProps {
activeButton: string;
@ -54,12 +54,6 @@ const AllToolsNavButton: React.FC<AllToolsNavButtonProps> = ({
handleUnlessSpecialClick(e, handleClick);
};
const iconNode = (
<span className="iconContainer">
<AppsIcon sx={{ fontSize: isActive ? '1.875rem' : '1.5rem' }} />
</span>
);
return (
<Tooltip
content={t("quickAccess.allTools", "Tools")}
@ -68,28 +62,17 @@ const AllToolsNavButton: React.FC<AllToolsNavButtonProps> = ({
containerStyle={{ marginTop: "-1rem" }}
maxWidth={200}
>
<div className="flex flex-col items-center gap-1 mt-4 mb-2">
<ActionIcon
component="a"
href={navProps.href}
<div className="mt-4 mb-2">
<QuickAccessButton
icon={<AppsIcon sx={{ fontSize: isActive ? '1.875rem' : '1.5rem' }} />}
label={t("quickAccess.allTools", "Tools")}
isActive={isActive}
onClick={handleNavClick}
size={isActive ? 'lg' : 'md'}
variant="subtle"
aria-label={t("quickAccess.allTools", "Tools")}
style={{
backgroundColor: isActive ? 'var(--icon-tools-bg)' : 'var(--icon-inactive-bg)',
color: isActive ? 'var(--icon-tools-color)' : 'var(--icon-inactive-color)',
border: 'none',
borderRadius: '8px',
textDecoration: 'none'
}}
className={isActive ? 'activeIconScale' : ''}
>
{iconNode}
</ActionIcon>
<span className={`all-tools-text ${isActive ? 'active' : 'inactive'}`}>
{t("quickAccess.allTools", "Tools")}
</span>
href={navProps.href}
ariaLabel={t("quickAccess.allTools", "Tools")}
textClassName="all-tools-text"
component="a"
/>
</div>
</Tooltip>
);

View File

@ -69,7 +69,7 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({ opened, onClose })
}), []);
// Get isAdmin and runningEE from app config
const isAdmin = true // config?.isAdmin ?? false;
const isAdmin = config?.isAdmin ?? false;
const runningEE = config?.runningEE ?? false;
const loginEnabled = config?.enableLogin ?? false;

View File

@ -53,16 +53,15 @@ const FitText: React.FC<FitTextProps> = ({
const clampStyles: CSSProperties = {
// Multi-line clamp with ellipsis fallback
whiteSpace: lines === 1 ? 'nowrap' : 'normal',
overflow: 'visible',
overflow: 'hidden',
textOverflow: 'ellipsis',
display: lines > 1 ? ('-webkit-box' as any) : undefined,
WebkitBoxOrient: lines > 1 ? ('vertical' as any) : undefined,
WebkitLineClamp: lines > 1 ? (lines as any) : undefined,
lineClamp: lines > 1 ? (lines as any) : undefined,
// Favor shrinking over breaking words; only break at natural spaces or softBreakChars
wordBreak: lines > 1 ? ('keep-all' as any) : ('normal' as any),
overflowWrap: 'normal',
hyphens: 'manual',
display: lines > 1 ? '-webkit-box' : undefined,
WebkitBoxOrient: lines > 1 ? 'vertical' : undefined,
WebkitLineClamp: lines > 1 ? lines : undefined,
// Favor breaking words when necessary to prevent overflow
wordBreak: lines > 1 ? 'break-word' : 'normal',
overflowWrap: lines > 1 ? 'break-word' : 'normal',
hyphens: lines > 1 ? 'auto' : 'manual',
// fontSize expects rem values (e.g., 1.2, 0.9) to scale with global font size
fontSize: fontSize ? `${fontSize}rem` : undefined,
};

View File

@ -173,7 +173,7 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({
.sort(([, nameA], [, nameB]) => nameA.localeCompare(nameB))
.map(([code, name]) => ({
value: code,
label: `${name} (${code})`,
label: name,
}));
// Hide the language selector if there's only one language option

View File

@ -1,10 +1,9 @@
import React, { useState, useRef, forwardRef, useEffect } from "react";
import { ActionIcon, Stack, Divider, Menu, Indicator } from "@mantine/core";
import { Stack, Divider, Menu, Indicator } from "@mantine/core";
import { useTranslation } from 'react-i18next';
import { useNavigate, useLocation } from 'react-router-dom';
import LocalIcon from '@app/components/shared/LocalIcon';
import { useRainbowThemeContext } from "@app/components/shared/RainbowThemeProvider";
import { useIsOverflowing } from '@app/hooks/useIsOverflowing';
import { useFilesModalContext } from '@app/contexts/FilesModalContext';
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
import { useNavigationState, useNavigationActions } from '@app/contexts/NavigationContext';
@ -18,6 +17,7 @@ import AppConfigModal from '@app/components/shared/AppConfigModal';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import { useLicenseAlert } from "@app/hooks/useLicenseAlert";
import { requestStartTour } from '@app/constants/events';
import QuickAccessButton from '@app/components/shared/quickAccessBar/QuickAccessButton';
import {
isNavButtonActive,
@ -41,7 +41,6 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
const [configModalOpen, setConfigModalOpen] = useState(false);
const [activeButton, setActiveButton] = useState<string>('tools');
const scrollableRef = useRef<HTMLDivElement>(null);
const isOverflow = useIsOverflowing(scrollableRef);
const isRTL = typeof document !== 'undefined' && document.documentElement.dir === 'rtl';
@ -85,37 +84,27 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
}
};
const buttonStyle = getNavButtonStyle(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView);
// Render navigation button with conditional URL support
return (
<div
key={config.id}
className="flex flex-col items-center gap-1"
style={{ marginTop: index === 0 ? '0.5rem' : "0rem" }}
data-tour={`${config.id}-button`}
>
<ActionIcon
{...(navProps ? {
component: "a" as const,
href: navProps.href,
onClick: (e: React.MouseEvent) => handleClick(e),
'aria-label': config.name
} : {
onClick: (e: React.MouseEvent) => handleClick(e),
'aria-label': config.name
})}
size={isActive ? 'lg' : 'md'}
variant="subtle"
style={getNavButtonStyle(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView)}
className={isActive ? 'activeIconScale' : ''}
data-testid={`${config.id}-button`}
>
<span className="iconContainer">
{config.icon}
</span>
</ActionIcon>
<span className={`button-text ${isActive ? 'active' : 'inactive'}`}>
{config.name}
</span>
<QuickAccessButton
icon={config.icon}
label={config.name}
isActive={isActive}
onClick={handleClick}
href={navProps?.href}
ariaLabel={config.name}
backgroundColor={buttonStyle.backgroundColor}
color={buttonStyle.color}
component={navProps ? 'a' : 'button'}
dataTestId={`${config.id}-button`}
dataTour={`${config.id}-button`}
/>
</div>
);
};
@ -150,6 +139,9 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
}
}
},
];
const middleButtons: ButtonConfig[] = [
{
id: 'files',
name: t("quickAccess.files", "Files"),
@ -160,8 +152,6 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
onClick: handleFilesButtonClick
},
];
const middleButtons: ButtonConfig[] = [];
//TODO: Activity
//{
// id: 'activity',
@ -211,13 +201,6 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
</div>
{/* Conditional divider when overflowing */}
{isOverflow && (
<Divider
size="xs"
className="overflow-divider"
/>
)}
{/* Scrollable content area */}
<div
@ -230,7 +213,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
>
<div className="scrollable-content">
{/* Main navigation section */}
<Stack gap="lg" align="center">
<Stack gap="lg" align="stretch">
{mainButtons.map((config, index) => (
<React.Fragment key={config.id}>
{renderNavButton(config, index, config.id === 'read' || config.id === 'automate')}
@ -238,14 +221,6 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
))}
</Stack>
{/* Divider after main buttons (creates gap) */}
{middleButtons.length === 0 && (
<Divider
size="xs"
className="content-divider"
/>
)}
{/* Middle section */}
{middleButtons.length > 0 && (
<>
@ -253,7 +228,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
size="xs"
className="content-divider"
/>
<Stack gap="lg" align="center">
<Stack gap="lg" align="stretch">
{middleButtons.map((config, index) => (
<React.Fragment key={config.id}>
{renderNavButton(config, index)}
@ -267,7 +242,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
<div className="spacer" />
{/* Bottom section */}
<Stack gap="lg" align="center">
<Stack gap="lg" align="stretch">
{bottomButtons.map((buttonConfig, index) => {
// Handle help button with menu or direct action
if (buttonConfig.id === 'help') {

View File

@ -256,10 +256,11 @@ export const Tooltip: React.FC<TooltipProps> = ({
(children.props as any)?.onBlur?.(e);
return;
}
clearTimers();
if (!isPinned) setOpen(false);
(children.props as any)?.onBlur?.(e);
},
[isPinned, setOpen, children.props]
[isPinned, setOpen, children.props, clearTimers]
);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {

View File

@ -3,6 +3,7 @@ export const VALID_NAV_KEYS = [
'preferences',
'notifications',
'connections',
'account',
'general',
'people',
'teams',

View File

@ -13,7 +13,7 @@
*/
import React, { useEffect, useRef, useState } from 'react';
import { ActionIcon } from '@mantine/core';
import { ActionIcon, Divider } from '@mantine/core';
import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
import { useNavigationState, useNavigationActions } from '@app/contexts/NavigationContext';
@ -195,6 +195,10 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ setActiveButton, to
className="button-text active current-tool-label"
/>
</div>
<Divider
size="xs"
className="current-tool-divider"
/>
</div>
)}
</div>

View File

@ -38,9 +38,9 @@
/* Main container styles */
.quick-access-bar-main {
background-color: var(--bg-muted);
width: 4rem;
min-width: 4rem;
max-width: 4rem;
width: 4.5rem;
min-width: 4.5rem;
max-width: 4.5rem;
position: relative;
z-index: 10;
border-right: 1px solid var(--border-default);
@ -52,9 +52,9 @@
/* Rainbow mode container */
.quick-access-bar-main.rainbow-mode {
background-color: var(--bg-muted);
width: 4rem;
min-width: 4rem;
max-width: 4rem;
width: 4.5rem;
min-width: 4.5rem;
max-width: 4.5rem;
position: relative;
z-index: 10;
border-right: 1px solid var(--border-default);
@ -72,7 +72,7 @@
/* Header padding */
.quick-access-header {
padding: 1rem 0.5rem 0.5rem 0.5rem;
padding: 1rem 0.25rem 0.5rem 0.25rem;
}
.nav-header {
@ -84,14 +84,6 @@
gap: 0.5rem;
}
/* Nav header divider */
.nav-header-divider {
width: 3rem;
border-color: var(--color-gray-300);
margin-top: 0.5rem;
margin-bottom: 1rem;
}
/* All tools text styles */
.all-tools-text {
margin-top: 0.75rem;
@ -116,16 +108,15 @@
.overflow-divider {
width: 3rem;
border-color: var(--color-gray-300);
margin: 0 0.5rem;
margin: 0 auto;
align-self: center;
}
/* Scrollable content area */
.quick-access-bar {
overflow-x: auto;
overflow-y: auto;
scrollbar-gutter: stable both-edges;
-webkit-overflow-scrolling: touch;
padding: 0 0.5rem 1rem 0.5rem;
overflow-x: hidden;
overflow-y: hidden;
padding: 0 0.25rem 1rem 0.25rem;
}
/* Scrollable content container */
@ -143,21 +134,21 @@
text-rendering: optimizeLegibility;
font-synthesis: none;
text-align: center;
display: block;
width: 100%;
}
/* Allow wrapping under the active top indicator; constrain to two lines */
/* Allow wrapping under the active top indicator; constrain to three lines */
.current-tool-label {
white-space: normal;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2; /* show up to two lines */
line-clamp: 2;
-webkit-line-clamp: 3; /* show up to three lines */
line-clamp: 3;
-webkit-box-orient: vertical;
word-break: keep-all;
overflow-wrap: normal;
hyphens: manual;
word-break: break-all;
width: 100%;
box-sizing: border-box;
text-align: center;
}
.button-text.active {
@ -174,13 +165,14 @@
.content-divider {
width: 3rem;
border-color: var(--color-gray-300);
margin: 1rem 0;
margin: 1rem auto;
align-self: center;
}
/* Spacer */
.spacer {
flex: 1;
margin-top: 1rem;
min-height: 1rem;
}
/* Config button text */
@ -242,8 +234,6 @@
.current-tool-slot.visible {
max-height: 8.25rem; /* icon + up to 3-line label + divider (132px) */
opacity: 1;
border-bottom: 1px solid var(--color-gray-300);
padding-bottom: 0.75rem; /* push border down for spacing */
margin-bottom: 1rem;
}
@ -268,27 +258,9 @@
}
}
/* Divider that animates growing from top */
/* Divider under active tool indicator */
.current-tool-divider {
width: 3rem;
border-color: var(--color-gray-300);
margin: 0.5rem auto 0.5rem auto;
transform-origin: top;
animation: dividerGrowDown 350ms ease-out;
animation-fill-mode: both;
}
@keyframes dividerGrowDown {
0% {
transform: scaleY(0);
opacity: 0;
margin-top: 0;
margin-bottom: 0;
}
100% {
transform: scaleY(1);
opacity: 1;
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}
margin: 0.75rem auto 0;
}

View File

@ -0,0 +1,90 @@
import React from 'react';
import { ActionIcon } from '@mantine/core';
import FitText from '@app/components/shared/FitText';
interface QuickAccessButtonProps {
icon: React.ReactNode;
label: string;
isActive: boolean;
onClick?: (e: React.MouseEvent) => void;
href?: string;
ariaLabel: string;
textClassName?: 'button-text' | 'all-tools-text';
backgroundColor?: string;
color?: string;
size?: 'sm' | 'md' | 'lg';
className?: string;
component?: 'a' | 'button';
dataTestId?: string;
dataTour?: string;
}
const QuickAccessButton: React.FC<QuickAccessButtonProps> = ({
icon,
label,
isActive,
onClick,
href,
ariaLabel,
textClassName = 'button-text',
backgroundColor,
color,
size,
className,
component = 'button',
dataTestId,
dataTour,
}) => {
const buttonSize = size || (isActive ? 'lg' : 'md');
const bgColor = backgroundColor || (isActive ? 'var(--icon-tools-bg)' : 'var(--icon-inactive-bg)');
const textColor = color || (isActive ? 'var(--icon-tools-color)' : 'var(--icon-inactive-color)');
const actionIconProps = component === 'a' && href
? {
component: 'a' as const,
href,
onClick,
'aria-label': ariaLabel,
}
: {
onClick,
'aria-label': ariaLabel,
};
return (
<div className="flex flex-col items-center gap-1" data-tour={dataTour}>
<ActionIcon
{...actionIconProps}
size={buttonSize}
variant="subtle"
style={{
backgroundColor: bgColor,
color: textColor,
border: 'none',
borderRadius: '8px',
textDecoration: 'none',
}}
className={className || (isActive ? 'activeIconScale' : '')}
data-testid={dataTestId}
>
<span className="iconContainer">{icon}</span>
</ActionIcon>
<div style={{ width: '100%' }}>
<FitText
as="span"
text={label}
lines={2}
minimumFontScale={0.5}
className={`${textClassName} ${isActive ? 'active' : 'inactive'}`}
style={{
fontSize: '0.75rem',
textAlign: 'center',
display: 'block',
}}
/>
</div>
</div>
);
};
export default QuickAccessButton;

View File

@ -847,6 +847,12 @@ const SignSettings = ({
textColor={parameters.textColor || '#000000'}
onTextColorChange={(color) => onParameterChange('textColor', color)}
disabled={disabled}
label={translate('text.name', 'Text')}
placeholder={translate('text.placeholder', 'Enter text')}
fontLabel={translate('text.fontLabel', 'Font')}
fontSizeLabel={translate('text.fontSizeLabel', 'Font size')}
fontSizePlaceholder={translate('text.fontSizePlaceholder', 'Type or select font size (8-200)')}
colorLabel={translate('text.colorLabel', 'Text colour')}
onAnyChange={() => {
setPlacementManuallyPaused(false);
lastAppliedPlacementKey.current = null;

View File

@ -44,8 +44,10 @@ export function CustomSearchLayer({
}
const unsubscribe = searchProvides.onSearchResultStateChange?.((state: SearchResultState) => {
if (!state) return;
// Auto-scroll to active search result
if (state?.results && state.activeResultIndex !== undefined && state.activeResultIndex >= 0) {
if (state.results && state.activeResultIndex !== undefined && state.activeResultIndex >= 0) {
const activeResult = state.results[state.activeResultIndex];
if (activeResult) {
const pageNumber = activeResult.pageIndex + 1; // Convert to 1-based page number

View File

@ -43,6 +43,8 @@ const EmbedPdfViewerContent = ({
isThumbnailSidebarVisible,
toggleThumbnailSidebar,
isBookmarkSidebarVisible,
isSearchInterfaceVisible,
searchInterfaceActions,
zoomActions,
panActions: _panActions,
rotationActions: _rotationActions,
@ -184,7 +186,7 @@ const EmbedPdfViewerContent = ({
onZoomOut: zoomActions.zoomOut,
});
// Handle keyboard zoom shortcuts
// Handle keyboard shortcuts (zoom and search)
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (!isViewerHovered) return;
@ -199,6 +201,16 @@ const EmbedPdfViewerContent = ({
// Ctrl+- for zoom out
event.preventDefault();
zoomActions.zoomOut();
} else if (event.key === 'f' || event.key === 'F') {
// Ctrl+F for search
event.preventDefault();
if (isSearchInterfaceVisible) {
// If already open, trigger refocus event
window.dispatchEvent(new CustomEvent('refocus-search-input'));
} else {
// Open search interface
searchInterfaceActions.open();
}
}
}
};
@ -207,7 +219,7 @@ const EmbedPdfViewerContent = ({
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [isViewerHovered]);
}, [isViewerHovered, isSearchInterfaceVisible, zoomActions, searchInterfaceActions]);
// Register checker for unsaved changes (annotations only for now)
useEffect(() => {

View File

@ -46,6 +46,7 @@ import { PrintAPIBridge } from '@app/components/viewer/PrintAPIBridge';
import { isPdfFile } from '@app/utils/fileUtils';
import { useTranslation } from 'react-i18next';
import { LinkLayer } from '@app/components/viewer/LinkLayer';
import { absoluteWithBasePath } from '@app/constants/app';
interface LocalEmbedPDFProps {
file?: File | Blob;
@ -167,8 +168,10 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur
];
}, [pdfUrl]);
// Initialize the engine with the React hook
const { engine, isLoading, error } = usePdfiumEngine();
// Initialize the engine with the React hook - use local WASM for offline support
const { engine, isLoading, error } = usePdfiumEngine({
wasmUrl: absoluteWithBasePath('/pdfium/pdfium.wasm'),
});
// Early return if no file or URL provided

View File

@ -28,11 +28,13 @@ export function SearchAPIBridge() {
if (!search) return;
const unsubscribe = search.onSearchResultStateChange?.((state: any) => {
if (!state) return;
const newState = {
results: state?.results || null,
activeIndex: (state?.activeResultIndex || 0) + 1 // Convert to 1-based index
results: state.results || null,
activeIndex: (state.activeResultIndex || 0) + 1 // Convert to 1-based index
};
setLocalState(prevState => {
// Only update if state actually changed
if (prevState.results !== newState.results || prevState.activeIndex !== newState.activeIndex) {
@ -52,16 +54,42 @@ export function SearchAPIBridge() {
state: localState,
api: {
search: async (query: string) => {
search.startSearch();
return search.searchAllPages(query);
if (search?.startSearch && search?.searchAllPages) {
search.startSearch();
return search.searchAllPages(query);
}
},
clear: () => {
search.stopSearch();
try {
if (search?.stopSearch) {
search.stopSearch();
}
} catch (error) {
console.warn('Error stopping search:', error);
}
setLocalState({ results: null, activeIndex: 0 });
},
next: () => search.nextResult(),
previous: () => search.previousResult(),
goToResult: (index: number) => search.goToResult(index),
next: () => {
try {
search?.nextResult?.();
} catch (error) {
console.warn('Error navigating to next result:', error);
}
},
previous: () => {
try {
search?.previousResult?.();
} catch (error) {
console.warn('Error navigating to previous result:', error);
}
},
goToResult: (index: number) => {
try {
search?.goToResult?.(index);
} catch (error) {
console.warn('Error going to result:', error);
}
},
}
});
}

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import { Box, TextInput, ActionIcon, Text, Group } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { LocalIcon } from '@app/components/shared/LocalIcon';
@ -12,7 +12,9 @@ interface SearchInterfaceProps {
export function SearchInterface({ visible, onClose }: SearchInterfaceProps) {
const { t } = useTranslation();
const viewerContext = React.useContext(ViewerContext);
const inputRef = useRef<HTMLInputElement>(null);
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const searchState = viewerContext?.getSearchState();
const searchResults = searchState?.results;
const searchActiveIndex = searchState?.activeIndex;
@ -26,6 +28,61 @@ export function SearchInterface({ visible, onClose }: SearchInterfaceProps) {
} | null>(null);
const [isSearching, setIsSearching] = useState(false);
// Auto-focus search input when visible
useEffect(() => {
if (visible) {
inputRef.current?.focus();
}
}, [visible]);
// Listen for refocus event (when Ctrl+F pressed while already open)
useEffect(() => {
const handleRefocus = () => {
inputRef.current?.focus();
inputRef.current?.select();
};
window.addEventListener('refocus-search-input', handleRefocus);
return () => {
window.removeEventListener('refocus-search-input', handleRefocus);
};
}, []);
// Auto-search as user types (debounced)
useEffect(() => {
// Clear existing timeout
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
// If query is empty, clear search immediately
if (!searchQuery.trim()) {
searchActions?.clear();
setResultInfo(null);
return;
}
// Debounce search by 300ms
searchTimeoutRef.current = setTimeout(async () => {
if (searchQuery.trim() && searchActions) {
setIsSearching(true);
try {
await searchActions.search(searchQuery.trim());
} catch (error) {
console.error('Search failed:', error);
} finally {
setIsSearching(false);
}
}
}, 300);
return () => {
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
};
}, [searchQuery, searchActions]);
// Monitor search state changes
useEffect(() => {
if (!visible) return;
@ -59,30 +116,21 @@ export function SearchInterface({ visible, onClose }: SearchInterfaceProps) {
return () => clearInterval(interval);
}, [visible, searchResults, searchActiveIndex, searchQuery]);
const handleSearch = async (query: string) => {
if (!query.trim()) {
// If query is empty, clear the search
handleClearSearch();
return;
}
if (query.trim() && searchActions) {
setIsSearching(true);
try {
await searchActions.search(query.trim());
} catch (error) {
console.error('Search failed:', error);
} finally {
setIsSearching(false);
}
}
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
handleSearch(searchQuery);
// Navigate to next result on Enter
event.preventDefault();
handleNext();
} else if (event.key === 'Escape') {
onClose();
} else if (event.key === 'ArrowDown') {
// Navigate to next result
event.preventDefault();
handleNext();
} else if (event.key === 'ArrowUp') {
// Navigate to previous result
event.preventDefault();
handlePrevious();
}
};
@ -103,17 +151,17 @@ export function SearchInterface({ visible, onClose }: SearchInterfaceProps) {
// No longer need to sync with external API on mount - removed
const handleJumpToResult = (index: number) => {
// Use context actions instead of window API - functionality simplified for now
if (resultInfo && index >= 1 && index <= resultInfo.totalResults) {
// Note: goToResult functionality would need to be implemented in SearchAPIBridge
console.log('Jump to result:', index);
// Convert to 0-based index for the API
searchActions?.goToResult?.(index - 1);
}
};
const handleJumpToSubmit = () => {
const index = parseInt(jumpToValue);
if (index && resultInfo && index >= 1 && index <= resultInfo.totalResults) {
const index = parseInt(jumpToValue, 10);
if (!isNaN(index) && resultInfo && index >= 1 && index <= resultInfo.totalResults) {
handleJumpToResult(index);
setJumpToValue(''); // Clear the input after jumping
}
};
@ -123,7 +171,14 @@ export function SearchInterface({ visible, onClose }: SearchInterfaceProps) {
}
};
const _handleClose = () => {
const handleInputBlur = () => {
// Close popover on blur if no text is entered
if (!searchQuery.trim()) {
onClose();
}
};
const handleCloseClick = () => {
handleClearSearch();
onClose();
};
@ -135,100 +190,99 @@ export function SearchInterface({ visible, onClose }: SearchInterfaceProps) {
padding: '0px'
}}
>
{/* Header */}
<Group mb="md">
{/* Header with close button */}
<Group mb="md" justify="space-between">
<Text size="sm" fw={600}>
{t('search.title', 'Search PDF')}
</Text>
<ActionIcon
variant="subtle"
size="sm"
onClick={handleCloseClick}
aria-label="Close search"
>
<LocalIcon icon="close" width="1rem" height="1rem" />
</ActionIcon>
</Group>
{/* Search input */}
<Group mb="md">
<TextInput
ref={inputRef}
placeholder={t('search.placeholder', 'Enter search term...')}
value={searchQuery}
onChange={(e) => {
const newValue = e.currentTarget.value;
setSearchQuery(newValue);
// If user clears the input, clear the search highlights
if (!newValue.trim()) {
handleClearSearch();
}
}}
onKeyDown={handleKeyDown}
onBlur={handleInputBlur}
style={{ flex: 1 }}
rightSection={
<ActionIcon
variant="subtle"
onClick={() => handleSearch(searchQuery)}
disabled={!searchQuery.trim() || isSearching}
loading={isSearching}
>
<LocalIcon icon="search" width="1rem" height="1rem" />
</ActionIcon>
searchQuery.trim() && (
<ActionIcon
variant="subtle"
onClick={handleClearSearch}
aria-label="Clear search"
>
<LocalIcon icon="close" width="0.875rem" height="0.875rem" />
</ActionIcon>
)
}
/>
</Group>
{/* Results info and navigation */}
{resultInfo && (
<Group justify="space-between" align="center">
{resultInfo.totalResults === 0 ? (
<Text size="sm" c="dimmed">
{t('search.noResults', 'No results found')}
</Text>
) : (
<Group gap="xs" align="center">
<TextInput
size="xs"
value={jumpToValue}
onChange={(e) => setJumpToValue(e.currentTarget.value)}
onKeyDown={handleJumpToKeyDown}
onBlur={handleJumpToSubmit}
placeholder={resultInfo.currentIndex.toString()}
style={{ width: '3rem' }}
type="number"
min="1"
max={resultInfo.totalResults}
/>
<Text size="sm" c="dimmed">
of {resultInfo.totalResults}
</Text>
</Group>
)}
{resultInfo.totalResults > 0 && (
<Group gap="xs">
<ActionIcon
variant="subtle"
size="sm"
onClick={handlePrevious}
disabled={resultInfo.currentIndex <= 1}
aria-label="Previous result"
>
<LocalIcon icon="keyboard-arrow-up" width="1rem" height="1rem" />
</ActionIcon>
<ActionIcon
variant="subtle"
size="sm"
onClick={handleNext}
disabled={resultInfo.currentIndex >= resultInfo.totalResults}
aria-label="Next result"
>
<LocalIcon icon="keyboard-arrow-down" width="1rem" height="1rem" />
</ActionIcon>
<ActionIcon
variant="subtle"
size="sm"
onClick={handleClearSearch}
aria-label="Clear search"
>
<LocalIcon icon="close" width="1rem" height="1rem" />
</ActionIcon>
</Group>
)}
{/* Results info and navigation - always show */}
<Group justify="space-between" align="center">
<Group gap="xs" align="center">
<TextInput
size="xs"
value={jumpToValue}
onChange={(e) => {
const newValue = e.currentTarget.value;
setJumpToValue(newValue);
// Jump immediately as user types
const index = parseInt(newValue, 10);
if (resultInfo && !isNaN(index) && index >= 1 && index <= resultInfo.totalResults) {
handleJumpToResult(index);
}
}}
onKeyDown={handleJumpToKeyDown}
onBlur={() => setJumpToValue('')} // Clear on blur instead of submit
placeholder={(resultInfo?.currentIndex || 0).toString()}
style={{ width: '3rem' }}
type="number"
min="1"
max={resultInfo?.totalResults || 0}
disabled={!resultInfo || resultInfo.totalResults === 0}
/>
<Text size="sm" c="dimmed">
of {resultInfo?.totalResults || 0}
</Text>
</Group>
)}
<Group gap="xs">
<ActionIcon
variant="subtle"
size="sm"
onClick={handlePrevious}
disabled={!resultInfo || resultInfo.currentIndex <= 1}
aria-label="Previous result"
>
<LocalIcon icon="keyboard-arrow-up" width="1rem" height="1rem" />
</ActionIcon>
<ActionIcon
variant="subtle"
size="sm"
onClick={handleNext}
disabled={!resultInfo || resultInfo.currentIndex >= resultInfo.totalResults}
aria-label="Next result"
>
<LocalIcon icon="keyboard-arrow-down" width="1rem" height="1rem" />
</ActionIcon>
</Group>
</Group>
{/* Loading state */}
{isSearching && (

View File

@ -36,7 +36,14 @@ export function useViewerRightRailButtons() {
order: 10,
render: ({ disabled }) => (
<Tooltip content={searchLabel} position={tooltipPosition} offset={12} arrow portalTarget={document.body}>
<Popover position={tooltipPosition} withArrow shadow="md" offset={8}>
<Popover
position={tooltipPosition}
withArrow
shadow="md"
offset={8}
opened={viewer.isSearchInterfaceVisible}
onClose={viewer.searchInterfaceActions.close}
>
<Popover.Target>
<div style={{ display: 'inline-flex' }}>
<ActionIcon
@ -45,6 +52,7 @@ export function useViewerRightRailButtons() {
className="right-rail-icon"
disabled={disabled}
aria-label={searchLabel}
onClick={viewer.searchInterfaceActions.toggle}
>
<LocalIcon icon="search" width="1.5rem" height="1.5rem" />
</ActionIcon>
@ -52,7 +60,7 @@ export function useViewerRightRailButtons() {
</Popover.Target>
<Popover.Dropdown>
<div style={{ minWidth: '20rem' }}>
<SearchInterface visible={true} onClose={() => {}} />
<SearchInterface visible={viewer.isSearchInterfaceVisible} onClose={viewer.searchInterfaceActions.close} />
</div>
</Popover.Dropdown>
</Popover>

View File

@ -80,6 +80,14 @@ interface ViewerContextType {
isBookmarkSidebarVisible: boolean;
toggleBookmarkSidebar: () => void;
// Search interface visibility
isSearchInterfaceVisible: boolean;
searchInterfaceActions: {
open: () => void;
close: () => void;
toggle: () => void;
};
// Annotation visibility toggle
isAnnotationsVisible: boolean;
toggleAnnotationsVisibility: () => void;
@ -145,6 +153,7 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
// UI state - only state directly managed by this context
const [isThumbnailSidebarVisible, setIsThumbnailSidebarVisible] = useState(false);
const [isBookmarkSidebarVisible, setIsBookmarkSidebarVisible] = useState(false);
const [isSearchInterfaceVisible, setSearchInterfaceVisible] = useState(false);
const [isAnnotationsVisible, setIsAnnotationsVisible] = useState(true);
const [isAnnotationMode, setIsAnnotationModeState] = useState(false);
const [activeFileIndex, setActiveFileIndex] = useState(0);
@ -207,6 +216,12 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
setIsBookmarkSidebarVisible(prev => !prev);
};
const searchInterfaceActions = {
open: () => setSearchInterfaceVisible(true),
close: () => setSearchInterfaceVisible(false),
toggle: () => setSearchInterfaceVisible(prev => !prev),
};
const toggleAnnotationsVisibility = () => {
setIsAnnotationsVisible(prev => !prev);
};
@ -294,6 +309,10 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
isBookmarkSidebarVisible,
toggleBookmarkSidebar,
// Search interface
isSearchInterfaceVisible,
searchInterfaceActions,
// Annotation controls
isAnnotationsVisible,
toggleAnnotationsVisibility,

View File

@ -52,6 +52,7 @@ export interface SearchActions {
next: () => void;
previous: () => void;
clear: () => void;
goToResult: (index: number) => void;
}
export interface ExportActions {
@ -287,6 +288,12 @@ export function createViewerActions({
api.clear();
}
},
goToResult: (index: number) => {
const api = registry.current.search?.api;
if (api?.goToResult) {
api.goToResult(index);
}
},
};
const exportActions: ExportActions = {

View File

@ -56,4 +56,14 @@ export const accountService = {
formData.append('newPassword', newPassword);
await apiClient.post('/api/v1/user/change-password-on-login', formData);
},
/**
* Change username
*/
async changeUsername(newUsername: string, currentPassword: string): Promise<void> {
const formData = new FormData();
formData.append('currentPasswordChangeUsername', currentPassword);
formData.append('newUsername', newUsername);
await apiClient.post('/api/v1/user/change-username', formData);
},
};

View File

@ -16,6 +16,8 @@ import AdminEndpointsSection from '@app/components/shared/config/configSections/
import AdminAuditSection from '@app/components/shared/config/configSections/AdminAuditSection';
import AdminUsageSection from '@app/components/shared/config/configSections/AdminUsageSection';
import ApiKeys from '@app/components/shared/config/configSections/ApiKeys';
import AccountSection from '@app/components/shared/config/configSections/AccountSection';
import GeneralSection from '@app/components/shared/config/configSections/GeneralSection';
/**
* Hook version of proprietary config nav sections with proper i18n support
@ -30,6 +32,23 @@ export const useConfigNavSections = (
// Get the core sections (just Preferences)
const sections = useCoreConfigNavSections(isAdmin, runningEE, loginEnabled);
// Add account management under Preferences
const preferencesSection = sections.find((section) => section.items.some((item) => item.key === 'general'));
if (preferencesSection) {
preferencesSection.items = preferencesSection.items.map((item) =>
item.key === 'general' ? { ...item, component: <GeneralSection /> } : item
);
if (loginEnabled) {
preferencesSection.items.push({
key: 'account',
label: t('account.accountSettings', 'Account'),
icon: 'person-rounded',
component: <AccountSection />
});
}
}
// Add Admin sections if user is admin OR if login is disabled (but mark as disabled)
if (isAdmin || !loginEnabled) {
const requiresLogin = !loginEnabled;
@ -220,6 +239,23 @@ export const createConfigNavSections = (
// Get the core sections (just Preferences)
const sections = createCoreConfigNavSections(isAdmin, runningEE, loginEnabled);
// Add account management under Preferences
const preferencesSection = sections.find((section) => section.items.some((item) => item.key === 'general'));
if (preferencesSection) {
preferencesSection.items = preferencesSection.items.map((item) =>
item.key === 'general' ? { ...item, component: <GeneralSection /> } : item
);
if (loginEnabled) {
preferencesSection.items.push({
key: 'account',
label: 'Account',
icon: 'person-rounded',
component: <AccountSection />
});
}
}
// Add Admin sections if user is admin OR if login is disabled (but mark as disabled)
if (isAdmin || !loginEnabled) {
const requiresLogin = !loginEnabled;

View File

@ -0,0 +1,260 @@
import React, { useCallback, useMemo, useState } from 'react';
import { Alert, Button, Group, Modal, Paper, PasswordInput, Stack, Text, TextInput } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import LocalIcon from '@app/components/shared/LocalIcon';
import { alert as showToast } from '@app/components/toast';
import { useAuth } from '@app/auth/UseSession';
import { accountService } from '@app/services/accountService';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
const AccountSection: React.FC = () => {
const { t } = useTranslation();
const { user, signOut } = useAuth();
const [passwordModalOpen, setPasswordModalOpen] = useState(false);
const [usernameModalOpen, setUsernameModalOpen] = useState(false);
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [passwordError, setPasswordError] = useState('');
const [passwordSubmitting, setPasswordSubmitting] = useState(false);
const [currentPasswordForUsername, setCurrentPasswordForUsername] = useState('');
const [newUsername, setNewUsername] = useState('');
const [usernameError, setUsernameError] = useState('');
const [usernameSubmitting, setUsernameSubmitting] = useState(false);
const userIdentifier = useMemo(() => user?.email || user?.username || '', [user?.email, user?.username]);
const redirectToLogin = useCallback(() => {
window.location.assign('/login');
}, []);
const handleLogout = useCallback(async () => {
try {
await signOut();
} finally {
redirectToLogin();
}
}, [redirectToLogin, signOut]);
const handlePasswordSubmit = async (event: React.FormEvent) => {
event.preventDefault();
if (!currentPassword || !newPassword || !confirmPassword) {
setPasswordError(t('settings.security.password.required', 'All fields are required.'));
return;
}
if (newPassword !== confirmPassword) {
setPasswordError(t('settings.security.password.mismatch', 'New passwords do not match.'));
return;
}
try {
setPasswordSubmitting(true);
setPasswordError('');
await accountService.changePassword(currentPassword, newPassword);
showToast({
alertType: 'success',
title: t('settings.security.password.success', 'Password updated successfully. Please sign in again.'),
});
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
setPasswordModalOpen(false);
await handleLogout();
} catch (err) {
const axiosError = err as { response?: { data?: { message?: string } } };
setPasswordError(
axiosError.response?.data?.message ||
t('settings.security.password.error', 'Unable to update password. Please verify your current password and try again.')
);
} finally {
setPasswordSubmitting(false);
}
};
const handleUsernameSubmit = async (event: React.FormEvent) => {
event.preventDefault();
if (!currentPasswordForUsername || !newUsername) {
setUsernameError(t('settings.security.password.required', 'All fields are required.'));
return;
}
try {
setUsernameSubmitting(true);
setUsernameError('');
await accountService.changeUsername(newUsername, currentPasswordForUsername);
showToast({
alertType: 'success',
title: t('changeCreds.credsUpdated', 'Account updated'),
body: t('changeCreds.description', 'Changes saved. Please log in again.'),
});
setNewUsername('');
setCurrentPasswordForUsername('');
setUsernameModalOpen(false);
await handleLogout();
} catch (err) {
const axiosError = err as { response?: { data?: { message?: string } } };
setUsernameError(
axiosError.response?.data?.message ||
t('changeCreds.error', 'Unable to update username. Please verify your password and try again.')
);
} finally {
setUsernameSubmitting(false);
}
};
return (
<Stack gap="md">
<div>
<Text fw={600} size="lg">
{t('account.accountSettings', 'Account')}
</Text>
<Text size="sm" c="dimmed">
{t('changeCreds.header', 'Update Your Account Details')}
</Text>
</div>
<Paper withBorder p="md" radius="md">
<Stack gap="sm">
<Text size="sm" c="dimmed">
{userIdentifier
? t('settings.general.user', 'User') + ': ' + userIdentifier
: t('account.accountSettings', 'Account Settings')}
</Text>
<Group gap="sm" wrap="wrap">
<Button leftSection={<LocalIcon icon="key-rounded" />} onClick={() => setPasswordModalOpen(true)}>
{t('settings.security.password.update', 'Update password')}
</Button>
<Button
variant="light"
leftSection={<LocalIcon icon="edit-rounded" />}
onClick={() => setUsernameModalOpen(true)}
>
{t('account.changeUsername', 'Change username')}
</Button>
<Button variant="outline" color="red" leftSection={<LocalIcon icon="logout-rounded" />} onClick={handleLogout}>
{t('settings.general.logout', 'Log out')}
</Button>
</Group>
</Stack>
</Paper>
<Modal
opened={passwordModalOpen}
onClose={() => setPasswordModalOpen(false)}
title={t('settings.security.title', 'Change password')}
withinPortal
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
<form onSubmit={handlePasswordSubmit}>
<Stack gap="md">
<Text size="sm" c="dimmed">
{t('settings.security.password.subtitle', 'Change your password. You will be logged out after updating.')}
</Text>
{passwordError && (
<Alert icon={<LocalIcon icon="error-rounded" width="1rem" height="1rem" />} color="red" variant="light">
{passwordError}
</Alert>
)}
<PasswordInput
label={t('settings.security.password.current', 'Current password')}
placeholder={t('settings.security.password.currentPlaceholder', 'Enter your current password')}
value={currentPassword}
onChange={(event) => setCurrentPassword(event.currentTarget.value)}
required
/>
<PasswordInput
label={t('settings.security.password.new', 'New password')}
placeholder={t('settings.security.password.newPlaceholder', 'Enter a new password')}
value={newPassword}
onChange={(event) => setNewPassword(event.currentTarget.value)}
required
/>
<PasswordInput
label={t('settings.security.password.confirm', 'Confirm new password')}
placeholder={t('settings.security.password.confirmPlaceholder', 'Re-enter your new password')}
value={confirmPassword}
onChange={(event) => setConfirmPassword(event.currentTarget.value)}
required
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={() => setPasswordModalOpen(false)}>
{t('common.cancel', 'Cancel')}
</Button>
<Button type="submit" loading={passwordSubmitting} leftSection={<LocalIcon icon="save-rounded" />}>
{t('settings.security.password.update', 'Update password')}
</Button>
</Group>
</Stack>
</form>
</Modal>
<Modal
opened={usernameModalOpen}
onClose={() => setUsernameModalOpen(false)}
title={t('account.changeUsername', 'Change username')}
withinPortal
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
<form onSubmit={handleUsernameSubmit}>
<Stack gap="md">
<Text size="sm" c="dimmed">
{t('changeCreds.changeUsername', 'Update your username. You will be logged out after updating.')}
</Text>
{usernameError && (
<Alert icon={<LocalIcon icon="error-rounded" width="1rem" height="1rem" />} color="red" variant="light">
{usernameError}
</Alert>
)}
<TextInput
label={t('changeCreds.newUsername', 'New Username')}
placeholder={t('changeCreds.newUsername', 'New Username')}
value={newUsername}
onChange={(event) => setNewUsername(event.currentTarget.value)}
required
/>
<PasswordInput
label={t('changeCreds.oldPassword', 'Current Password')}
placeholder={t('changeCreds.oldPassword', 'Current Password')}
value={currentPasswordForUsername}
onChange={(event) => setCurrentPasswordForUsername(event.currentTarget.value)}
required
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={() => setUsernameModalOpen(false)}>
{t('common.cancel', 'Cancel')}
</Button>
<Button type="submit" loading={usernameSubmitting} leftSection={<LocalIcon icon="save-rounded" />}>
{t('common.save', 'Save')}
</Button>
</Group>
</Stack>
</form>
</Modal>
</Stack>
);
};
export default AccountSection;

View File

@ -1,5 +1,5 @@
import React, { useState, useCallback, useEffect, useMemo } from 'react';
import { Divider, Loader, Alert, Group, Text, Collapse, Button, TextInput, Stack, Paper, SegmentedControl, FileButton } from '@mantine/core';
import { Divider, Loader, Alert } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { usePlans } from '@app/hooks/usePlans';
import licenseService, { PlanTierGroup, mapLicenseToTier } from '@app/services/licenseService';
@ -7,30 +7,25 @@ import { useCheckout } from '@app/contexts/CheckoutContext';
import { useLicense } from '@app/contexts/LicenseContext';
import AvailablePlansSection from '@app/components/shared/config/configSections/plan/AvailablePlansSection';
import StaticPlanSection from '@app/components/shared/config/configSections/plan/StaticPlanSection';
import LicenseKeySection from '@app/components/shared/config/configSections/plan/LicenseKeySection';
import { alert } from '@app/components/toast';
import LocalIcon from '@app/components/shared/LocalIcon';
import { InfoBanner } from '@app/components/shared/InfoBanner';
import { useLicenseAlert } from '@app/hooks/useLicenseAlert';
import { isSupabaseConfigured } from '@app/services/supabaseClient';
import { getPreferredCurrency, setCachedCurrency } from '@app/utils/currencyDetection';
import { useLoginRequired } from '@app/hooks/useLoginRequired';
import LoginRequiredBanner from '@core/components/shared/config/LoginRequiredBanner';
import { isSupabaseConfigured } from '@app/services/supabaseClient';
const AdminPlanSection: React.FC = () => {
const { t, i18n } = useTranslation();
const { loginEnabled, validateLoginEnabled } = useLoginRequired();
const { openCheckout } = useCheckout();
const { licenseInfo, refetchLicense } = useLicense();
const { licenseInfo } = useLicense();
const [currency, setCurrency] = useState<string>(() => {
// Initialize with auto-detected currency on first render
return getPreferredCurrency(i18n.language);
});
const [useStaticVersion, setUseStaticVersion] = useState(false);
const [showLicenseKey, setShowLicenseKey] = useState(false);
const [licenseKeyInput, setLicenseKeyInput] = useState<string>('');
const [savingLicense, setSavingLicense] = useState(false);
const [inputMethod, setInputMethod] = useState<'text' | 'file'>('text');
const [licenseFile, setLicenseFile] = useState<File | null>(null);
const { plans, loading, error, refetch } = usePlans(currency);
const licenseAlert = useLicenseAlert();
@ -43,69 +38,6 @@ const AdminPlanSection: React.FC = () => {
}
}, [error]);
const handleSaveLicense = async () => {
// Block save if login is disabled
if (!validateLoginEnabled()) {
return;
}
try {
setSavingLicense(true);
let response;
if (inputMethod === 'file' && licenseFile) {
// Upload file
response = await licenseService.saveLicenseFile(licenseFile);
} else if (inputMethod === 'text' && licenseKeyInput.trim()) {
// Save key string (allow empty string to clear/remove license)
response = await licenseService.saveLicenseKey(licenseKeyInput.trim());
} else {
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: t('admin.settings.premium.noInput', 'Please provide a license key or file'),
});
return;
}
if (response.success) {
// Refresh license context to update all components
await refetchLicense();
const successMessage = inputMethod === 'file'
? t('admin.settings.premium.file.successMessage', 'License file uploaded and activated successfully')
: t('admin.settings.premium.key.successMessage', 'License key activated successfully');
alert({
alertType: 'success',
title: t('success', 'Success'),
body: successMessage,
});
// Clear inputs
setLicenseKeyInput('');
setLicenseFile(null);
setInputMethod('text'); // Reset to default
} else {
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: response.error || t('admin.settings.saveError', 'Failed to save license'),
});
}
} catch (error) {
console.error('Failed to save license:', error);
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: t('admin.settings.saveError', 'Failed to save license'),
});
} finally {
setSavingLicense(false);
}
};
const currencyOptions = [
{ value: 'gbp', label: 'British pound (GBP, £)' },
{ value: 'usd', label: 'US dollar (USD, $)' },
@ -280,169 +212,7 @@ const AdminPlanSection: React.FC = () => {
<Divider />
{/* License Key Section */}
<div>
<Button
variant="subtle"
leftSection={<LocalIcon icon={showLicenseKey ? "expand-less-rounded" : "expand-more-rounded"} width="1.25rem" height="1.25rem" />}
onClick={() => setShowLicenseKey(!showLicenseKey)}
>
{t('admin.settings.premium.licenseKey.toggle', 'Got a license key or certificate file?')}
</Button>
<Collapse in={showLicenseKey} mt="md">
<Stack gap="md">
<Alert
variant="light"
color="blue"
icon={<LocalIcon icon="info-rounded" width="1rem" height="1rem" />}
>
<Text size="sm">
{t('admin.settings.premium.licenseKey.info', 'If you have a license key or certificate file from a direct purchase, you can enter it here to activate premium or enterprise features.')}
</Text>
</Alert>
{/* Severe warning if license already exists */}
{licenseInfo?.licenseKey && (
<Alert
variant="light"
color="red"
icon={<LocalIcon icon="warning-rounded" width="1rem" height="1rem" />}
title={t('admin.settings.premium.key.overwriteWarning.title', '⚠️ Warning: Existing License Detected')}
>
<Stack gap="xs">
<Text size="sm" fw={600}>
{t('admin.settings.premium.key.overwriteWarning.line1', 'Overwriting your current license key cannot be undone.')}
</Text>
<Text size="sm">
{t('admin.settings.premium.key.overwriteWarning.line2', 'Your previous license will be permanently lost unless you have backed it up elsewhere.')}
</Text>
<Text size="sm" fw={500}>
{t('admin.settings.premium.key.overwriteWarning.line3', 'Important: Keep license keys private and secure. Never share them publicly.')}
</Text>
</Stack>
</Alert>
)}
{/* Show current license source */}
{licenseInfo?.licenseKey && (
<Alert
variant="light"
color="green"
icon={<LocalIcon icon="check-circle-rounded" width="1rem" height="1rem" />}
>
<Stack gap="xs">
<Text size="sm" fw={500}>
{t('admin.settings.premium.currentLicense.title', 'Active License')}
</Text>
<Text size="xs">
{licenseInfo.licenseKey.startsWith('file:')
? t('admin.settings.premium.currentLicense.file', 'Source: License file ({{path}})', {
path: licenseInfo.licenseKey.substring(5)
})
: t('admin.settings.premium.currentLicense.key', 'Source: License key')}
</Text>
<Text size="xs">
{t('admin.settings.premium.currentLicense.type', 'Type: {{type}}', {
type: licenseInfo.licenseType
})}
</Text>
</Stack>
</Alert>
)}
{/* Input method selector */}
<SegmentedControl
value={inputMethod}
onChange={(value) => {
setInputMethod(value as 'text' | 'file');
// Clear opposite input when switching
if (value === 'text') setLicenseFile(null);
if (value === 'file') setLicenseKeyInput('');
}}
data={[
{
label: t('admin.settings.premium.inputMethod.text', 'License Key'),
value: 'text'
},
{
label: t('admin.settings.premium.inputMethod.file', 'Certificate File'),
value: 'file'
}
]}
disabled={!loginEnabled || savingLicense}
/>
{/* Input area */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
{inputMethod === 'text' ? (
/* Existing text input */
<TextInput
label={t('admin.settings.premium.key.label', 'License Key')}
description={t('admin.settings.premium.key.description', 'Enter your premium or enterprise license key. Premium features will be automatically enabled when a key is provided.')}
value={licenseKeyInput}
onChange={(e) => setLicenseKeyInput(e.target.value)}
placeholder={licenseInfo?.licenseKey || '00000000-0000-0000-0000-000000000000'}
type="password"
disabled={!loginEnabled || savingLicense}
/>
) : (
/* File upload */
<div>
<Text size="sm" fw={500} mb="xs">
{t('admin.settings.premium.file.label', 'License Certificate File')}
</Text>
<Text size="xs" c="dimmed" mb="md">
{t('admin.settings.premium.file.description', 'Upload your .lic or .cert license file')}
</Text>
<FileButton
onChange={setLicenseFile}
accept=".lic,.cert"
disabled={!loginEnabled || savingLicense}
>
{(props) => (
<Button
{...props}
variant="outline"
leftSection={<LocalIcon icon="upload-file-rounded" width="1rem" height="1rem" />}
disabled={!loginEnabled || savingLicense}
>
{licenseFile
? licenseFile.name
: t('admin.settings.premium.file.choose', 'Choose License File')}
</Button>
)}
</FileButton>
{licenseFile && (
<Text size="xs" c="dimmed" mt="xs">
{t('admin.settings.premium.file.selected', 'Selected: {{filename}} ({{size}})', {
filename: licenseFile.name,
size: (licenseFile.size / 1024).toFixed(2) + ' KB'
})}
</Text>
)}
</div>
)}
<Group justify="flex-end">
<Button
onClick={handleSaveLicense}
loading={savingLicense}
size="sm"
disabled={
!loginEnabled ||
(inputMethod === 'text' && !licenseKeyInput.trim()) ||
(inputMethod === 'file' && !licenseFile)
}
>
{t('admin.settings.save', 'Save Changes')}
</Button>
</Group>
</Stack>
</Paper>
</Stack>
</Collapse>
</div>
<LicenseKeySection currentLicenseInfo={licenseInfo ?? undefined} />
</div>
);
};

View File

@ -1,53 +0,0 @@
import React from 'react';
import { Stack, Text, Button } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useAuth } from '@app/auth/UseSession';
import { useNavigate } from 'react-router-dom';
import CoreGeneralSection from '@core/components/shared/config/configSections/GeneralSection';
/**
* Proprietary extension of GeneralSection that adds account management
*/
const GeneralSection: React.FC = () => {
const { t } = useTranslation();
const { signOut, user } = useAuth();
const navigate = useNavigate();
const handleLogout = async () => {
try {
await signOut();
navigate('/login');
} catch (error) {
console.error('Logout error:', error);
}
};
return (
<Stack gap="lg">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div>
<Text fw={600} size="lg">{t('settings.general.title', 'General')}</Text>
<Text size="sm" c="dimmed">
{t('settings.general.description', 'Configure general application preferences.')}
</Text>
</div>
{user && (
<Stack gap="xs" align="flex-end">
<Text size="sm" c="dimmed">
{t('settings.general.user', 'User')}: <strong>{user.email || user.username}</strong>
</Text>
<Button color="red" variant="outline" size="xs" onClick={handleLogout}>
{t('settings.general.logout', 'Log out')}
</Button>
</Stack>
)}
</div>
{/* Render core general section preferences (without title since we show it above) */}
<CoreGeneralSection hideTitle />
</Stack>
);
};
export default GeneralSection;

View File

@ -5,6 +5,7 @@ import licenseService, { PlanTier, PlanTierGroup, LicenseInfo, mapLicenseToTier
import PlanCard from '@app/components/shared/config/configSections/plan/PlanCard';
import FeatureComparisonTable from '@app/components/shared/config/configSections/plan/FeatureComparisonTable';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
import { isCurrentTier as checkIsCurrentTier, isDowngrade as checkIsDowngrade } from '@app/utils/planTierUtils';
interface AvailablePlansSectionProps {
plans: PlanTier[];
@ -43,28 +44,12 @@ const AvailablePlansSection: React.FC<AvailablePlansSectionProps> = ({
// Determine if the current tier matches (checks both Stripe subscription and license)
const isCurrentTier = (tierGroup: PlanTierGroup): boolean => {
// Check license tier match
if (currentTier && tierGroup.tier === currentTier) {
return true;
}
return false;
return checkIsCurrentTier(currentTier, tierGroup.tier);
};
// Determine if selecting this plan would be a downgrade
const isDowngrade = (tierGroup: PlanTierGroup): boolean => {
if (!currentTier) return false;
// Define tier hierarchy: enterprise > server > free
const tierHierarchy: Record<string, number> = {
'enterprise': 3,
'server': 2,
'free': 1
};
const currentLevel = tierHierarchy[currentTier] || 0;
const targetLevel = tierHierarchy[tierGroup.tier] || 0;
return currentLevel > targetLevel;
return checkIsDowngrade(currentTier, tierGroup.tier);
};
return (
@ -103,7 +88,7 @@ const AvailablePlansSection: React.FC<AvailablePlansSectionProps> = ({
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: '1rem',
marginBottom: '0.5rem',
marginBottom: '0.1rem',
}}
>
{groupedPlans.map((group) => (

View File

@ -0,0 +1,273 @@
import React, { useState } from 'react';
import { Button, Collapse, Alert, TextInput, Paper, Stack, Group, Text, SegmentedControl, FileButton } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import LocalIcon from '@app/components/shared/LocalIcon';
import { alert } from '@app/components/toast';
import { LicenseInfo } from '@app/services/licenseService';
import licenseService from '@app/services/licenseService';
import { useLicense } from '@app/contexts/LicenseContext';
import { useLoginRequired } from '@app/hooks/useLoginRequired';
interface LicenseKeySectionProps {
currentLicenseInfo?: LicenseInfo;
}
const LicenseKeySection: React.FC<LicenseKeySectionProps> = ({ currentLicenseInfo }) => {
const { t } = useTranslation();
const { refetchLicense } = useLicense();
const { loginEnabled, validateLoginEnabled } = useLoginRequired();
const [showLicenseKey, setShowLicenseKey] = useState(false);
const [licenseKeyInput, setLicenseKeyInput] = useState<string>('');
const [savingLicense, setSavingLicense] = useState(false);
const [inputMethod, setInputMethod] = useState<'text' | 'file'>('text');
const [licenseFile, setLicenseFile] = useState<File | null>(null);
const handleSaveLicense = async () => {
// Block save if login is disabled
if (!validateLoginEnabled()) {
return;
}
try {
setSavingLicense(true);
let response;
if (inputMethod === 'file' && licenseFile) {
// Upload file
response = await licenseService.saveLicenseFile(licenseFile);
} else if (inputMethod === 'text' && licenseKeyInput.trim()) {
// Save key string
response = await licenseService.saveLicenseKey(licenseKeyInput.trim());
} else {
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: t('admin.settings.premium.noInput', 'Please provide a license key or file'),
});
return;
}
if (response.success) {
// Refresh license context to update all components
await refetchLicense();
const successMessage =
inputMethod === 'file'
? t('admin.settings.premium.file.successMessage', 'License file uploaded and activated successfully')
: t('admin.settings.premium.key.successMessage', 'License key activated successfully');
alert({
alertType: 'success',
title: t('success', 'Success'),
body: successMessage,
});
// Clear inputs
setLicenseKeyInput('');
setLicenseFile(null);
setInputMethod('text'); // Reset to default
} else {
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: response.error || t('admin.settings.saveError', 'Failed to save license'),
});
}
} catch (error) {
console.error('Failed to save license:', error);
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: t('admin.settings.saveError', 'Failed to save license'),
});
} finally {
setSavingLicense(false);
}
};
return (
<div>
<Button
variant="subtle"
leftSection={
<LocalIcon
icon={showLicenseKey ? 'expand-less-rounded' : 'expand-more-rounded'}
width="1.25rem"
height="1.25rem"
/>
}
onClick={() => setShowLicenseKey(!showLicenseKey)}
>
{t('admin.settings.premium.licenseKey.toggle', 'Got a license key or certificate file?')}
</Button>
<Collapse in={showLicenseKey} mt="md">
<Stack gap="md">
<Alert variant="light" color="blue" icon={<LocalIcon icon="info-rounded" width="1rem" height="1rem" />}>
<Text size="sm">
{t(
'admin.settings.premium.licenseKey.info',
'If you have a license key or certificate file from a direct purchase, you can enter it here to activate premium or enterprise features.'
)}
</Text>
</Alert>
{/* Severe warning if license already exists */}
{currentLicenseInfo?.licenseKey && (
<Alert
variant="light"
color="red"
icon={<LocalIcon icon="warning-rounded" width="1rem" height="1rem" />}
title={t('admin.settings.premium.key.overwriteWarning.title', '⚠️ Warning: Existing License Detected')}
>
<Stack gap="xs">
<Text size="sm" fw={600}>
{t(
'admin.settings.premium.key.overwriteWarning.line1',
'Overwriting your current license key cannot be undone.'
)}
</Text>
<Text size="sm">
{t(
'admin.settings.premium.key.overwriteWarning.line2',
'Your previous license will be permanently lost unless you have backed it up elsewhere.'
)}
</Text>
<Text size="sm" fw={500}>
{t(
'admin.settings.premium.key.overwriteWarning.line3',
'Important: Keep license keys private and secure. Never share them publicly.'
)}
</Text>
</Stack>
</Alert>
)}
{/* Show current license source */}
{currentLicenseInfo?.licenseKey && (
<Alert
variant="light"
color="green"
icon={<LocalIcon icon="check-circle-rounded" width="1rem" height="1rem" />}
>
<Stack gap="xs">
<Text size="sm" fw={500}>
{t('admin.settings.premium.currentLicense.title', 'Active License')}
</Text>
<Text size="xs">
{currentLicenseInfo.licenseKey.startsWith('file:')
? t('admin.settings.premium.currentLicense.file', 'Source: License file ({{path}})', {
path: currentLicenseInfo.licenseKey.substring(5),
})
: t('admin.settings.premium.currentLicense.key', 'Source: License key')}
</Text>
<Text size="xs">
{t('admin.settings.premium.currentLicense.type', 'Type: {{type}}', {
type: currentLicenseInfo.licenseType,
})}
</Text>
</Stack>
</Alert>
)}
{/* Input method selector */}
<SegmentedControl
value={inputMethod}
onChange={(value) => {
setInputMethod(value as 'text' | 'file');
// Clear opposite input when switching
if (value === 'text') setLicenseFile(null);
if (value === 'file') setLicenseKeyInput('');
}}
data={[
{
label: t('admin.settings.premium.inputMethod.text', 'License Key'),
value: 'text',
},
{
label: t('admin.settings.premium.inputMethod.file', 'Certificate File'),
value: 'file',
},
]}
disabled={!loginEnabled || savingLicense}
/>
{/* Input area */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
{inputMethod === 'text' ? (
/* Text input */
<TextInput
label={t('admin.settings.premium.key.label', 'License Key')}
description={t(
'admin.settings.premium.key.description',
'Enter your premium or enterprise license key. Premium features will be automatically enabled when a key is provided.'
)}
value={licenseKeyInput}
onChange={(e) => setLicenseKeyInput(e.target.value)}
placeholder={currentLicenseInfo?.licenseKey || '00000000-0000-0000-0000-000000000000'}
type="password"
disabled={!loginEnabled || savingLicense}
/>
) : (
/* File upload */
<div>
<Text size="sm" fw={500} mb="xs">
{t('admin.settings.premium.file.label', 'License Certificate File')}
</Text>
<Text size="xs" c="dimmed" mb="md">
{t('admin.settings.premium.file.description', 'Upload your .lic or .cert license file')}
</Text>
<FileButton
onChange={setLicenseFile}
accept=".lic,.cert"
disabled={!loginEnabled || savingLicense}
>
{(props) => (
<Button
{...props}
variant="outline"
leftSection={<LocalIcon icon="upload-file-rounded" width="1rem" height="1rem" />}
disabled={!loginEnabled || savingLicense}
>
{licenseFile
? licenseFile.name
: t('admin.settings.premium.file.choose', 'Choose License File')}
</Button>
)}
</FileButton>
{licenseFile && (
<Text size="xs" c="dimmed" mt="xs">
{t('admin.settings.premium.file.selected', 'Selected: {{filename}} ({{size}})', {
filename: licenseFile.name,
size: (licenseFile.size / 1024).toFixed(2) + ' KB',
})}
</Text>
)}
</div>
)}
<Group justify="flex-end">
<Button
onClick={handleSaveLicense}
loading={savingLicense}
size="sm"
disabled={
!loginEnabled ||
(inputMethod === 'text' && !licenseKeyInput.trim()) ||
(inputMethod === 'file' && !licenseFile)
}
>
{t('admin.settings.save', 'Save Changes')}
</Button>
</Group>
</Stack>
</Paper>
</Stack>
</Collapse>
</div>
);
};
export default LicenseKeySection;

View File

@ -6,6 +6,7 @@ import { PricingBadge } from '@app/components/shared/stripeCheckout/components/P
import { PriceDisplay } from '@app/components/shared/stripeCheckout/components/PriceDisplay';
import { calculateDisplayPricing } from '@app/components/shared/stripeCheckout/utils/pricingUtils';
import { getBaseCardStyle } from '@app/components/shared/stripeCheckout/utils/cardStyles';
import { isEnterpriseBlockedForFree as checkIsEnterpriseBlockedForFree } from '@app/utils/planTierUtils';
interface PlanCardProps {
planGroup: PlanTierGroup;
@ -83,7 +84,7 @@ const PlanCard: React.FC<PlanCardProps> = ({ planGroup, isCurrentTier, isDowngra
const isEnterprise = planGroup.tier === 'enterprise';
// Block enterprise for free tier users (must have server first)
const isEnterpriseBlockedForFree = isEnterprise && currentTier === 'free';
const isEnterpriseBlockedForFree = checkIsEnterpriseBlockedForFree(currentTier, planGroup.tier);
// Calculate "From" pricing - show yearly price divided by 12 for lowest monthly equivalent
const { displayPrice, displaySeatPrice, displayCurrency } = calculateDisplayPricing(
@ -174,7 +175,7 @@ const PlanCard: React.FC<PlanCardProps> = ({ planGroup, isCurrentTier, isDowngra
withArrow
>
<Button
variant={isCurrentTier ? 'filled' : isDowngrade ? 'filled' : isEnterpriseBlockedForFree ? 'light' : 'filled'}
variant="filled"
fullWidth
onClick={() => isCurrentTier && onManageClick ? onManageClick() : onUpgradeClick(planGroup)}
disabled={!loginEnabled || isDowngrade || isEnterpriseBlockedForFree}

View File

@ -0,0 +1,338 @@
import React, { useState } from 'react';
import { Modal, Text, Group, ActionIcon, Stack, Paper, Grid, TextInput, Button, Alert } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import LocalIcon from '@app/components/shared/LocalIcon';
import { EmailStage } from '@app/components/shared/stripeCheckout/stages/EmailStage';
import { validateEmail } from '@app/components/shared/stripeCheckout/utils/checkoutUtils';
import { getClickablePaperStyle } from '@app/components/shared/stripeCheckout/utils/cardStyles';
import { STATIC_STRIPE_LINKS, buildStripeUrlWithEmail } from '@app/constants/staticStripeLinks';
import { alert } from '@app/components/toast';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
import { useIsMobile } from '@app/hooks/useIsMobile';
import licenseService from '@app/services/licenseService';
import { useLicense } from '@app/contexts/LicenseContext';
interface StaticCheckoutModalProps {
opened: boolean;
onClose: () => void;
planName: 'server' | 'enterprise';
isUpgrade?: boolean;
}
type Stage = 'email' | 'period-selection' | 'license-activation';
const StaticCheckoutModal: React.FC<StaticCheckoutModalProps> = ({
opened,
onClose,
planName,
isUpgrade = false,
}) => {
const { t } = useTranslation();
const isMobile = useIsMobile();
const { refetchLicense } = useLicense();
const [stage, setStage] = useState<Stage>('email');
const [email, setEmail] = useState('');
const [emailError, setEmailError] = useState('');
const [stageHistory, setStageHistory] = useState<Stage[]>([]);
// License activation state
const [licenseKey, setLicenseKey] = useState('');
const [savingLicense, setSavingLicense] = useState(false);
const [licenseActivated, setLicenseActivated] = useState(false);
const handleEmailSubmit = () => {
const validation = validateEmail(email);
if (validation.valid) {
setEmailError('');
setStageHistory([...stageHistory, 'email']);
setStage('period-selection');
} else {
setEmailError(validation.error);
}
};
const handlePeriodSelect = (period: 'monthly' | 'yearly') => {
const baseUrl = STATIC_STRIPE_LINKS[planName][period];
const urlWithEmail = buildStripeUrlWithEmail(baseUrl, email);
// Open Stripe checkout in new tab
window.open(urlWithEmail, '_blank');
// Transition to license activation stage
setStageHistory([...stageHistory, 'period-selection']);
setStage('license-activation');
};
const handleActivateLicense = async () => {
if (!licenseKey.trim()) {
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: t('admin.settings.premium.noInput', 'Please provide a license key'),
});
return;
}
try {
setSavingLicense(true);
const response = await licenseService.saveLicenseKey(licenseKey.trim());
if (response.success) {
// Refresh license context to update all components
await refetchLicense();
setLicenseActivated(true);
alert({
alertType: 'success',
title: t('success', 'Success'),
body: t(
'admin.settings.premium.key.successMessage',
'License key activated successfully'
),
});
} else {
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: response.error || t('admin.settings.saveError', 'Failed to save license'),
});
}
} catch (error) {
console.error('Failed to save license:', error);
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: t('admin.settings.saveError', 'Failed to save license'),
});
} finally {
setSavingLicense(false);
}
};
const handleGoBack = () => {
if (stageHistory.length > 0) {
const newHistory = [...stageHistory];
const previousStage = newHistory.pop();
setStageHistory(newHistory);
if (previousStage) {
setStage(previousStage);
}
}
};
const handleClose = () => {
// Reset state when closing
setStage('email');
setEmail('');
setEmailError('');
setStageHistory([]);
setLicenseKey('');
setSavingLicense(false);
setLicenseActivated(false);
onClose();
};
const getModalTitle = () => {
if (stage === 'email') {
if (isUpgrade) {
return t('plan.static.upgradeToEnterprise', 'Upgrade to Enterprise');
}
return planName === 'server'
? t('plan.static.getLicense', 'Get Server License')
: t('plan.static.upgradeToEnterprise', 'Upgrade to Enterprise');
}
if (stage === 'period-selection') {
return t('plan.static.selectPeriod', 'Select Billing Period');
}
if (stage === 'license-activation') {
return t('plan.static.activateLicense', 'Activate Your License');
}
return '';
};
const renderContent = () => {
switch (stage) {
case 'email':
return (
<EmailStage
emailInput={email}
setEmailInput={setEmail}
emailError={emailError}
onSubmit={handleEmailSubmit}
/>
);
case 'period-selection':
return (
<Stack gap="lg" style={{ padding: '1rem 2rem' }}>
<Grid gutter="xl" style={{ marginTop: '1rem' }}>
{/* Monthly Option */}
<Grid.Col span={6}>
<Paper
withBorder
p="xl"
radius="md"
style={getClickablePaperStyle()}
onClick={() => handlePeriodSelect('monthly')}
>
<Stack gap="md" style={{ height: '100%', minHeight: '120px' }} justify="space-between">
<Text size="lg" fw={600}>
{t('payment.monthly', 'Monthly')}
</Text>
<Text size="sm" c="dimmed">
{t('plan.static.monthlyBilling', 'Monthly Billing')}
</Text>
</Stack>
</Paper>
</Grid.Col>
{/* Yearly Option */}
<Grid.Col span={6}>
<Paper
withBorder
p="xl"
radius="md"
style={getClickablePaperStyle()}
onClick={() => handlePeriodSelect('yearly')}
>
<Stack gap="md" style={{ height: '100%', minHeight: '120px' }} justify="space-between">
<Text size="lg" fw={600}>
{t('payment.yearly', 'Yearly')}
</Text>
<Text size="sm" c="dimmed">
{t('plan.static.yearlyBilling', 'Yearly Billing')}
</Text>
</Stack>
</Paper>
</Grid.Col>
</Grid>
</Stack>
);
case 'license-activation':
return (
<Stack gap="lg" style={{ padding: '2rem', maxWidth: '600px', margin: '0 auto' }}>
<Alert
variant="light"
color="blue"
icon={<LocalIcon icon="info-rounded" width="1rem" height="1rem" />}
>
<Stack gap="sm">
<Text size="sm" fw={600}>
{t('plan.static.licenseActivation.checkoutOpened', 'Checkout Opened in New Tab')}
</Text>
<Text size="sm">
{t(
'plan.static.licenseActivation.instructions',
'Complete your purchase in the Stripe tab. Once your payment is complete, you will receive an email with your license key.'
)}
</Text>
</Stack>
</Alert>
{licenseActivated ? (
<Alert
variant="light"
color="green"
icon={<LocalIcon icon="check-circle-rounded" width="1rem" height="1rem" />}
title={t('plan.static.licenseActivation.success', 'License Activated!')}
>
<Text size="sm">
{t(
'plan.static.licenseActivation.successMessage',
'Your license has been successfully activated. You can now close this window.'
)}
</Text>
</Alert>
) : (
<Stack gap="md">
<Text size="sm" fw={500}>
{t(
'plan.static.licenseActivation.enterKey',
'Enter your license key below to activate your plan:'
)}
</Text>
<TextInput
label={t('admin.settings.premium.key.label', 'License Key')}
description={t(
'plan.static.licenseActivation.keyDescription',
'Paste the license key from your email'
)}
value={licenseKey}
onChange={(e) => setLicenseKey(e.target.value)}
placeholder="00000000-0000-0000-0000-000000000000"
disabled={savingLicense}
type="password"
/>
<Group justify="space-between">
<Button variant="subtle" onClick={handleClose} disabled={savingLicense}>
{t('plan.static.licenseActivation.doLater', "I'll do this later")}
</Button>
<Button
onClick={handleActivateLicense}
loading={savingLicense}
disabled={!licenseKey.trim()}
>
{t('plan.static.licenseActivation.activate', 'Activate License')}
</Button>
</Group>
</Stack>
)}
{licenseActivated && (
<Group justify="flex-end">
<Button onClick={handleClose}>
{t('common.close', 'Close')}
</Button>
</Group>
)}
</Stack>
);
default:
return null;
}
};
const canGoBack = stageHistory.length > 0 && stage !== 'license-activation';
return (
<Modal
opened={opened}
onClose={handleClose}
title={
<Group gap="sm" wrap="nowrap">
{canGoBack && (
<ActionIcon
variant="subtle"
size="lg"
onClick={handleGoBack}
aria-label={t('common.back', 'Back')}
>
<LocalIcon icon="arrow-back" width={20} height={20} />
</ActionIcon>
)}
<Text fw={600} size="lg">
{getModalTitle()}
</Text>
</Group>
}
size={isMobile ? '100%' : 600}
centered
radius="lg"
withCloseButton={true}
closeOnEscape={true}
closeOnClickOutside={false}
fullScreen={isMobile}
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
{renderContent()}
</Modal>
);
};
export default StaticCheckoutModal;

View File

@ -1,20 +1,16 @@
import React, { useState, useEffect } from 'react';
import { Card, Text, Group, Stack, Badge, Button, Collapse, Alert, TextInput, Paper, Loader, Divider } from '@mantine/core';
import React, { useState } from 'react';
import { Card, Text, Stack, Button, Collapse, Divider, Tooltip } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import LocalIcon from '@app/components/shared/LocalIcon';
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
import { useAdminSettings } from '@app/hooks/useAdminSettings';
import PendingBadge from '@app/components/shared/config/PendingBadge';
import { alert } from '@app/components/toast';
import { LicenseInfo, mapLicenseToTier } from '@app/services/licenseService';
import { PLAN_FEATURES, PLAN_HIGHLIGHTS } from '@app/constants/planConstants';
import FeatureComparisonTable from '@app/components/shared/config/configSections/plan/FeatureComparisonTable';
interface PremiumSettingsData {
key?: string;
enabled?: boolean;
}
import StaticCheckoutModal from '@app/components/shared/config/configSections/plan/StaticCheckoutModal';
import LicenseKeySection from '@app/components/shared/config/configSections/plan/LicenseKeySection';
import { STATIC_STRIPE_LINKS } from '@app/constants/staticStripeLinks';
import { PricingBadge } from '@app/components/shared/stripeCheckout/components/PricingBadge';
import { getBaseCardStyle } from '@app/components/shared/stripeCheckout/utils/cardStyles';
import { isCurrentTier as checkIsCurrentTier, isDowngrade as checkIsDowngrade, isEnterpriseBlockedForFree } from '@app/utils/planTierUtils';
interface StaticPlanSectionProps {
currentLicenseInfo?: LicenseInfo;
@ -22,38 +18,45 @@ interface StaticPlanSectionProps {
const StaticPlanSection: React.FC<StaticPlanSectionProps> = ({ currentLicenseInfo }) => {
const { t } = useTranslation();
const [showLicenseKey, setShowLicenseKey] = useState(false);
const [showComparison, setShowComparison] = useState(false);
// Premium/License key management
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
const {
settings: premiumSettings,
setSettings: setPremiumSettings,
loading: premiumLoading,
saving: premiumSaving,
fetchSettings: fetchPremiumSettings,
saveSettings: savePremiumSettings,
isFieldPending,
} = useAdminSettings<PremiumSettingsData>({
sectionName: 'premium',
});
// Static checkout modal state
const [checkoutModalOpened, setCheckoutModalOpened] = useState(false);
const [selectedPlan, setSelectedPlan] = useState<'server' | 'enterprise'>('server');
const [isUpgrade, setIsUpgrade] = useState(false);
useEffect(() => {
fetchPremiumSettings();
}, []);
const handleSaveLicense = async () => {
try {
await savePremiumSettings();
showRestartModal();
} catch (_error) {
const handleOpenCheckout = (plan: 'server' | 'enterprise', upgrade: boolean) => {
// Prevent Free → Enterprise (must have Server first)
const currentTier = mapLicenseToTier(currentLicenseInfo || null);
if (currentTier === 'free' && plan === 'enterprise') {
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: t('admin.settings.saveError', 'Failed to save settings'),
alertType: 'warning',
title: t('plan.enterprise.requiresServer', 'Server Plan Required'),
body: t(
'plan.enterprise.requiresServerMessage',
'Please upgrade to the Server plan first before upgrading to Enterprise.'
),
});
return;
}
setSelectedPlan(plan);
setIsUpgrade(upgrade);
setCheckoutModalOpened(true);
};
const handleManageBilling = () => {
// Show warning about email verification
alert({
alertType: 'warning',
title: t('plan.static.billingPortal.title', 'Email Verification Required'),
body: t(
'plan.static.billingPortal.message',
'You will need to verify your email address in the Stripe billing portal. Check your email for a login link.'
),
});
window.open(STATIC_STRIPE_LINKS.billingPortal, '_blank');
};
const staticPlans = [
@ -122,7 +125,7 @@ const StaticPlanSection: React.FC<StaticPlanSectionProps> = ({ currentLicenseInf
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: '1rem',
paddingBottom: '1rem',
paddingBottom: '0.1rem',
}}
>
{staticPlans.map((plan) => (
@ -131,53 +134,27 @@ const StaticPlanSection: React.FC<StaticPlanSectionProps> = ({ currentLicenseInf
padding="lg"
radius="md"
withBorder
style={{
position: 'relative',
display: 'flex',
flexDirection: 'column',
borderColor: plan.id === currentPlan.id ? 'var(--mantine-color-green-6)' : undefined,
borderWidth: plan.id === currentPlan.id ? '2px' : undefined,
}}
style={getBaseCardStyle(plan.id === currentPlan.id)}
className="plan-card"
>
{plan.id === currentPlan.id && (
<Badge
color="green"
variant="filled"
size="sm"
style={{ position: 'absolute', top: '1rem', right: '1rem' }}
>
{t('plan.current', 'Current Plan')}
</Badge>
<PricingBadge
type="current"
label={t('plan.current', 'Current Plan')}
/>
)}
{plan.popular && plan.id !== currentPlan.id && (
<Badge
variant="filled"
size="xs"
style={{ position: 'absolute', top: '0.5rem', right: '0.5rem' }}
>
{t('plan.popular', 'Popular')}
</Badge>
<PricingBadge
type="popular"
label={t('plan.popular', 'Popular')}
/>
)}
<Stack gap="md" style={{ height: '100%' }}>
<div>
<Text size="lg" fw={600}>
<Text size="xl" fw={700} style={{ fontSize: '2rem' }}>
{plan.name}
</Text>
<Group gap="xs" style={{ alignItems: 'baseline' }}>
<Text size="xl" fw={700} style={{ fontSize: '2rem' }}>
{plan.price === 0 && plan.id !== 'free'
? t('plan.customPricing', 'Custom')
: plan.price === 0
? t('plan.free.name', 'Free')
: `${plan.currency}${plan.price}`}
</Text>
{plan.period && (
<Text size="sm" c="dimmed">
{plan.period}
</Text>
)}
</Group>
<Text size="xs" c="dimmed" mt="xs">
{typeof plan.maxUsers === 'string'
? plan.maxUsers
@ -195,18 +172,123 @@ const StaticPlanSection: React.FC<StaticPlanSectionProps> = ({ currentLicenseInf
<div style={{ flexGrow: 1 }} />
<Button
variant={plan.id === currentPlan.id ? 'light' : 'filled'}
disabled={plan.id === currentPlan.id}
fullWidth
onClick={() =>
window.open('https://www.stirling.com/contact', '_blank')
{/* Tier-based button logic */}
{(() => {
const currentTier = mapLicenseToTier(currentLicenseInfo || null);
const isCurrent = checkIsCurrentTier(currentTier, plan.id);
const isDowngradePlan = checkIsDowngrade(currentTier, plan.id);
// Free Plan
if (plan.id === 'free') {
return (
<Button
variant="filled"
disabled
fullWidth
className="plan-button"
>
{isCurrent
? t('plan.current', 'Current Plan')
: t('plan.free.included', 'Included')}
</Button>
);
}
>
{plan.id === currentPlan.id
? t('plan.current', 'Current Plan')
: t('plan.contact', 'Contact Us')}
</Button>
// Server Plan
if (plan.id === 'server') {
if (currentTier === 'free') {
return (
<Button
variant="filled"
fullWidth
onClick={() => handleOpenCheckout('server', false)}
className="plan-button"
>
{t('plan.upgrade', 'Upgrade')}
</Button>
);
}
if (isCurrent) {
return (
<Button
variant="filled"
fullWidth
onClick={handleManageBilling}
className="plan-button"
>
{t('plan.manage', 'Manage')}
</Button>
);
}
if (isDowngradePlan) {
return (
<Button
variant="filled"
disabled
fullWidth
className="plan-button"
>
{t('plan.free.included', 'Included')}
</Button>
);
}
}
// Enterprise Plan
if (plan.id === 'enterprise') {
if (isEnterpriseBlockedForFree(currentTier, plan.id)) {
return (
<Tooltip label={t('plan.enterprise.requiresServer', 'Requires Server plan')} position="top" withArrow>
<Button
variant="filled"
disabled
fullWidth
className="plan-button"
>
{t('plan.enterprise.requiresServer', 'Requires Server')}
</Button>
</Tooltip>
);
}
if (currentTier === 'server') {
// TODO: Re-enable checkout flow when account syncing is ready
// return (
// <Button
// variant="filled"
// fullWidth
// onClick={() => handleOpenCheckout('enterprise', true)}
// className="plan-button"
// >
// {t('plan.selectPlan', 'Select Plan')}
// </Button>
// );
return (
<Button
variant="filled"
fullWidth
disabled
className="plan-button"
>
{t('plan.contact', 'Contact Us')}
</Button>
);
}
if (isCurrent) {
return (
<Button
variant="filled"
fullWidth
onClick={handleManageBilling}
className="plan-button"
>
{t('plan.manage', 'Manage')}
</Button>
);
}
}
return null;
})()}
</Stack>
</Card>
))}
@ -230,66 +312,14 @@ const StaticPlanSection: React.FC<StaticPlanSectionProps> = ({ currentLicenseInf
<Divider />
{/* License Key Section */}
<div>
<Button
variant="subtle"
leftSection={<LocalIcon icon={showLicenseKey ? "expand-less-rounded" : "expand-more-rounded"} width="1.25rem" height="1.25rem" />}
onClick={() => setShowLicenseKey(!showLicenseKey)}
>
{t('admin.settings.premium.licenseKey.toggle', 'Got a license key or certificate file?')}
</Button>
<LicenseKeySection currentLicenseInfo={currentLicenseInfo} />
<Collapse in={showLicenseKey} mt="md">
<Stack gap="md">
<Alert
variant="light"
color="blue"
icon={<LocalIcon icon="info-rounded" width="1rem" height="1rem" />}
>
<Text size="sm">
{t('admin.settings.premium.licenseKey.info', 'If you have a license key or certificate file from a direct purchase, you can enter it here to activate premium or enterprise features.')}
</Text>
</Alert>
{premiumLoading ? (
<Stack align="center" justify="center" h={100}>
<Loader size="md" />
</Stack>
) : (
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<div>
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.premium.key.label', 'License Key')}</span>
<PendingBadge show={isFieldPending('key')} />
</Group>
}
description={t('admin.settings.premium.key.description', 'Enter your premium or enterprise license key. Premium features will be automatically enabled when a key is provided.')}
value={premiumSettings.key || ''}
onChange={(e) => setPremiumSettings({ ...premiumSettings, key: e.target.value })}
placeholder="00000000-0000-0000-0000-000000000000"
/>
</div>
<Group justify="flex-end">
<Button onClick={handleSaveLicense} loading={premiumSaving} size="sm">
{t('admin.settings.save', 'Save Changes')}
</Button>
</Group>
</Stack>
</Paper>
)}
</Stack>
</Collapse>
</div>
{/* Restart Confirmation Modal */}
<RestartConfirmationModal
opened={restartModalOpened}
onClose={closeRestartModal}
onRestart={restartServer}
{/* Static Checkout Modal */}
<StaticCheckoutModal
opened={checkoutModalOpened}
onClose={() => setCheckoutModalOpened(false)}
planName={selectedPlan}
isUpgrade={isUpgrade}
/>
</div>
);

View File

@ -0,0 +1,56 @@
/**
* Static Stripe payment links for offline/self-hosted environments
*
* These links are used when Supabase is not configured, allowing users to
* purchase licenses directly through Stripe hosted checkout pages.
*
* NOTE: These are test environment URLs. Replace with production URLs before release.
*/
export interface StaticStripeLinks {
server: {
monthly: string;
yearly: string;
};
enterprise: {
monthly: string;
yearly: string;
};
billingPortal: string;
}
// PRODCUTION LINKS FOR LIVE SERVER
export const STATIC_STRIPE_LINKS: StaticStripeLinks = {
server: {
monthly: 'https://buy.stripe.com/fZu4gB8Nv6ysfAj0ts8Zq03',
yearly: 'https://buy.stripe.com/9B68wR6Fn0a40Fpcca8Zq02',
},
enterprise: {
monthly: '',
yearly: '',
},
billingPortal: 'https://billing.stripe.com/p/login/test_aFa5kv1Mz2s10Fr3Cp83C00',
};
// LINKS FOR TEST SERVER:
// export const STATIC_STRIPE_LINKS: StaticStripeLinks = {
// server: {
// monthly: 'https://buy.stripe.com/test_8x27sD4YL9Ut0Fr3Cp83C02',
// yearly: 'https://buy.stripe.com/test_4gMdR11Mz4A9ag17SF83C03',
// },
// enterprise: {
// monthly: 'https://buy.stripe.com/test_8x2cMX9f18Qp9bX0qd83C04',
// yearly: 'https://buy.stripe.com/test_6oU00b2QD2s173P6OB83C05',
// },
// billingPortal: 'https://billing.stripe.com/p/login/test_aFa5kv1Mz2s10Fr3Cp83C00',
// };
/**
* Builds a Stripe URL with a prefilled email parameter
* @param baseUrl - The base Stripe checkout URL
* @param email - The email address to prefill
* @returns The complete URL with encoded email parameter
*/
export function buildStripeUrlWithEmail(baseUrl: string, email: string): string {
const encodedEmail = encodeURIComponent(email);
return `${baseUrl}?locked_prefilled_email=${encodedEmail}`;
}

View File

@ -0,0 +1,40 @@
/**
* Shared utilities for plan tier comparisons and button logic
*/
export type PlanTier = 'free' | 'server' | 'enterprise';
const TIER_HIERARCHY: Record<PlanTier, number> = {
'free': 1,
'server': 2,
'enterprise': 3,
};
/**
* Get numeric level for a tier
*/
export function getTierLevel(tier: PlanTier | string | null | undefined): number {
if (!tier) return 1;
return TIER_HIERARCHY[tier as PlanTier] || 1;
}
/**
* Check if target tier is the current tier
*/
export function isCurrentTier(currentTier: PlanTier | string | null | undefined, targetTier: PlanTier | string): boolean {
return getTierLevel(currentTier) === getTierLevel(targetTier);
}
/**
* Check if target tier is a downgrade from current tier
*/
export function isDowngrade(currentTier: PlanTier | string | null | undefined, targetTier: PlanTier | string): boolean {
return getTierLevel(currentTier) > getTierLevel(targetTier);
}
/**
* Check if enterprise is blocked for free tier users
*/
export function isEnterpriseBlockedForFree(currentTier: PlanTier | string | null | undefined, targetTier: PlanTier | string): boolean {
return currentTier === 'free' && targetTier === 'enterprise';
}

View File

@ -1,6 +1,7 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react-swc';
import tsconfigPaths from 'vite-tsconfig-paths';
import { viteStaticCopy } from 'vite-plugin-static-copy';
export default defineConfig(({ mode }) => {
// When DISABLE_ADDITIONAL_FEATURES is false (or unset), enable proprietary features
@ -20,6 +21,15 @@ export default defineConfig(({ mode }) => {
tsconfigPaths({
projects: [tsconfigProject],
}),
viteStaticCopy({
targets: [
{
//provides static pdfium so embedpdf can run without cdn
src: 'node_modules/@embedpdf/pdfium/dist/pdfium.wasm',
dest: 'pdfium'
}
]
})
],
server: {
host: true,