mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-12-18 20:04:17 +01:00
Merge branch 'main' into remove-background-sign
This commit is contained in:
commit
6b9e88734f
12
.github/README.md
vendored
12
.github/README.md
vendored
@ -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.
|
||||
17
.github/workflows/build.yml
vendored
17
.github/workflows/build.yml
vendored
@ -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/
|
||||
|
||||
@ -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"));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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 =
|
||||
|
||||
51
build.gradle
51
build.gradle
@ -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"
|
||||
|
||||
33
frontend/package-lock.json
generated
33
frontend/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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"
|
||||
},
|
||||
|
||||
@ -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)"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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') {
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -3,6 +3,7 @@ export const VALID_NAV_KEYS = [
|
||||
'preferences',
|
||||
'notifications',
|
||||
'connections',
|
||||
'account',
|
||||
'general',
|
||||
'people',
|
||||
'teams',
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
}
|
||||
},
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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;
|
||||
@ -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) => (
|
||||
|
||||
@ -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;
|
||||
@ -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}
|
||||
|
||||
@ -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;
|
||||
@ -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>
|
||||
);
|
||||
|
||||
56
frontend/src/proprietary/constants/staticStripeLinks.ts
Normal file
56
frontend/src/proprietary/constants/staticStripeLinks.ts
Normal 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}`;
|
||||
}
|
||||
40
frontend/src/proprietary/utils/planTierUtils.ts
Normal file
40
frontend/src/proprietary/utils/planTierUtils.ts
Normal 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';
|
||||
}
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user