diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/SettingsController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/SettingsController.java index 675fb93a5..33c0691a1 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/SettingsController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/SettingsController.java @@ -32,16 +32,19 @@ public class SettingsController { @AutoJobPostMapping("/update-enable-analytics") @Hidden - public ResponseEntity updateApiKey(@RequestParam Boolean enabled) throws IOException { + public ResponseEntity> updateApiKey(@RequestParam Boolean enabled) + throws IOException { if (applicationProperties.getSystem().getEnableAnalytics() != null) { return ResponseEntity.status(HttpStatus.ALREADY_REPORTED) .body( - "Setting has already been set, To adjust please edit " - + InstallationPathConfig.getSettingsPath()); + Map.of( + "message", + "Setting has already been set, To adjust please edit " + + InstallationPathConfig.getSettingsPath())); } GeneralUtils.saveKeyToSettings("system.enableAnalytics", enabled); applicationProperties.getSystem().setEnableAnalytics(enabled); - return ResponseEntity.ok("Updated"); + return ResponseEntity.ok(Map.of("message", "Updated")); } @GetMapping("/get-endpoints-status") diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java index a4bdd9da5..f250d60cc 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java @@ -163,12 +163,13 @@ public class AdminSettingsController { responseCode = "500", description = "Failed to save settings to configuration file") }) - public ResponseEntity updateSettings( + public ResponseEntity> updateSettings( @Valid @RequestBody UpdateSettingsRequest request) { try { Map settings = request.getSettings(); if (settings == null || settings.isEmpty()) { - return ResponseEntity.badRequest().body("No settings provided to update"); + return ResponseEntity.badRequest() + .body(Map.of("error", "No settings provided to update")); } int updatedCount = 0; @@ -178,7 +179,11 @@ public class AdminSettingsController { if (!isValidSettingKey(key)) { return ResponseEntity.badRequest() - .body("Invalid setting key format: " + HtmlUtils.htmlEscape(key)); + .body( + Map.of( + "error", + "Invalid setting key format: " + + HtmlUtils.htmlEscape(key))); } log.info("Admin updating setting: {} = {}", key, value); @@ -191,22 +196,26 @@ public class AdminSettingsController { } return ResponseEntity.ok( - String.format( - "Successfully updated %d setting(s). Changes will take effect on" - + " application restart.", - updatedCount)); + Map.of( + "message", + String.format( + "Successfully updated %d setting(s). Changes will take effect on" + + " application restart.", + updatedCount))); } catch (IOException e) { log.error("Failed to save settings to file: {}", e.getMessage(), e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(GENERIC_FILE_ERROR); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(Map.of("error", GENERIC_FILE_ERROR)); } catch (IllegalArgumentException e) { log.error("Invalid setting key or value: {}", e.getMessage(), e); - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(GENERIC_INVALID_SETTING); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", GENERIC_INVALID_SETTING)); } catch (Exception e) { log.error("Unexpected error while updating settings: {}", e.getMessage(), e); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(GENERIC_SERVER_ERROR); + .body(Map.of("error", GENERIC_SERVER_ERROR)); } } @@ -283,20 +292,23 @@ public class AdminSettingsController { description = "Access denied - Admin role required"), @ApiResponse(responseCode = "500", description = "Failed to save settings") }) - public ResponseEntity updateSettingsSection( + public ResponseEntity> updateSettingsSection( @PathVariable String sectionName, @Valid @RequestBody Map sectionData) { try { if (sectionData == null || sectionData.isEmpty()) { - return ResponseEntity.badRequest().body("No section data provided to update"); + return ResponseEntity.badRequest() + .body(Map.of("error", "No section data provided to update")); } if (!isValidSectionName(sectionName)) { return ResponseEntity.badRequest() .body( - "Invalid section name: " - + HtmlUtils.htmlEscape(sectionName) - + ". Valid sections: " - + String.join(", ", VALID_SECTION_NAMES)); + Map.of( + "error", + "Invalid section name: " + + HtmlUtils.htmlEscape(sectionName) + + ". Valid sections: " + + String.join(", ", VALID_SECTION_NAMES))); } // Auto-enable premium features if license key is provided @@ -317,7 +329,11 @@ public class AdminSettingsController { if (!isValidSettingKey(fullKey)) { return ResponseEntity.badRequest() - .body("Invalid setting key format: " + HtmlUtils.htmlEscape(fullKey)); + .body( + Map.of( + "error", + "Invalid setting key format: " + + HtmlUtils.htmlEscape(fullKey))); } log.info("Admin updating section setting: {} = {}", fullKey, value); @@ -331,21 +347,25 @@ public class AdminSettingsController { String escapedSectionName = HtmlUtils.htmlEscape(sectionName); return ResponseEntity.ok( - String.format( - "Successfully updated %d setting(s) in section '%s'. Changes will take" - + " effect on application restart.", - updatedCount, escapedSectionName)); + Map.of( + "message", + String.format( + "Successfully updated %d setting(s) in section '%s'. Changes will take" + + " effect on application restart.", + updatedCount, escapedSectionName))); } catch (IOException e) { log.error("Failed to save section settings to file: {}", e.getMessage(), e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(GENERIC_FILE_ERROR); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(Map.of("error", GENERIC_FILE_ERROR)); } catch (IllegalArgumentException e) { log.error("Invalid section data: {}", e.getMessage(), e); - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(GENERIC_INVALID_SECTION); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", GENERIC_INVALID_SECTION)); } catch (Exception e) { log.error("Unexpected error while updating section settings: {}", e.getMessage(), e); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(GENERIC_SERVER_ERROR); + .body(Map.of("error", GENERIC_SERVER_ERROR)); } } @@ -453,7 +473,7 @@ public class AdminSettingsController { description = "Access denied - Admin role required"), @ApiResponse(responseCode = "500", description = "Failed to initiate restart") }) - public ResponseEntity restartApplication() { + public ResponseEntity> restartApplication() { try { log.warn("Admin initiated application restart"); @@ -465,13 +485,18 @@ public class AdminSettingsController { log.error("Cannot restart: not running from JAR (likely development mode)"); return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE) .body( - "Restart not available in development mode. Please restart the application manually."); + Map.of( + "error", + "Restart not available in development mode. Please restart the application manually.")); } if (helperJar == null || !Files.isRegularFile(helperJar)) { log.error("Cannot restart: restart-helper.jar not found at expected location"); return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE) - .body("Restart helper not found. Please restart the application manually."); + .body( + Map.of( + "error", + "Restart helper not found. Cannot perform application restart.")); } // Get current application arguments @@ -526,12 +551,17 @@ public class AdminSettingsController { .start(); return ResponseEntity.ok( - "Application restart initiated. The server will be back online shortly."); + Map.of( + "message", + "Application restart initiated. The server will be back online shortly.")); } catch (Exception e) { log.error("Failed to initiate restart: {}", e.getMessage(), e); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body("Failed to initiate application restart: " + e.getMessage()); + .body( + Map.of( + "error", + "Failed to initiate application restart: " + e.getMessage())); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java index 6a18aeff0..9ffdeef1e 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java @@ -741,31 +741,35 @@ public class UserController { @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") @PostMapping("/get-api-key") - public ResponseEntity getApiKey(Principal principal) { + public ResponseEntity> getApiKey(Principal principal) { if (principal == null) { - return ResponseEntity.status(HttpStatus.FORBIDDEN).body("User not authenticated."); + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(Map.of("error", "User not authenticated.")); } String username = principal.getName(); String apiKey = userService.getApiKeyForUser(username); if (apiKey == null) { - return ResponseEntity.status(HttpStatus.NOT_FOUND).body("API key not found for user."); + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(Map.of("error", "API key not found for user.")); } - return ResponseEntity.ok(apiKey); + return ResponseEntity.ok(Map.of("apiKey", apiKey)); } @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") @PostMapping("/update-api-key") - public ResponseEntity updateApiKey(Principal principal) { + public ResponseEntity> updateApiKey(Principal principal) { if (principal == null) { - return ResponseEntity.status(HttpStatus.FORBIDDEN).body("User not authenticated."); + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(Map.of("error", "User not authenticated.")); } String username = principal.getName(); User user = userService.refreshApiKeyForUser(username); String apiKey = user.getApiKey(); if (apiKey == null) { - return ResponseEntity.status(HttpStatus.NOT_FOUND).body("API key not found for user."); + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(Map.of("error", "API key not found for user.")); } - return ResponseEntity.ok(apiKey); + return ResponseEntity.ok(Map.of("apiKey", apiKey)); } /** diff --git a/frontend/public/locales/de-DE/translation.toml b/frontend/public/locales/de-DE/translation.toml index d48c424f4..dd18085b5 100644 --- a/frontend/public/locales/de-DE/translation.toml +++ b/frontend/public/locales/de-DE/translation.toml @@ -128,6 +128,9 @@ undoQuotaError = "Rückgängig nicht möglich: unzureichender Speicherplatz" undoStorageError = "Rückgängig abgeschlossen, aber einige Dateien konnten nicht im Speicher gespeichert werden" undoSuccess = "Vorgang erfolgreich rückgängig gemacht" unsupported = "Nicht unterstützt" +discardRedactions = "Discard & Leave" +pendingRedactions = "You have unapplied redactions that will be lost." +pendingRedactionsTitle = "Unapplied Redactions" [toolPanel] placeholder = "Wählen Sie ein Tool, um zu starten" @@ -434,6 +437,13 @@ currentVersion = "Aktuelle Version" latestVersion = "Neueste Version" checkForUpdates = "Nach Updates suchen" viewDetails = "Details anzeigen" +serverNeedsUpdate = "Server needs to be updated by administrator" + +[settings.general.versionInfo] +description = "Desktop and server version details" +desktop = "Desktop Version" +server = "Server Version" +title = "Version Information" [settings.hotkeys] title = "Tastenkürzel" @@ -453,6 +463,25 @@ noShortcut = "Kein Kürzel festgelegt" mac = "Fügen Sie ⌘ (Command), ⌥ (Option) oder einen anderen Modifikator in Ihr Kürzel ein." windows = "Fügen Sie Strg, Alt oder einen anderen Modifikator in Ihr Kürzel ein." +[settings.security] +description = "Update your password to keep your account secure." +title = "Security" + +[settings.security.password] +confirm = "Confirm new password" +confirmPlaceholder = "Re-enter your new password" +current = "Current password" +currentPlaceholder = "Enter your current password" +error = "Unable to update password. Please verify your current password and try again." +mismatch = "New passwords do not match." +new = "New password" +newPlaceholder = "Enter a new password" +required = "All fields are required." +ssoDisabled = "Password changes are managed by your identity provider." +subtitle = "Change your password. You will be logged out after updating." +success = "Password updated successfully. Please sign in again." +update = "Update password" + [update] modalTitle = "Update verfügbar" current = "Aktuelle Version" @@ -493,6 +522,11 @@ oldPassword = "Aktuelles Passwort" newPassword = "Neues Passwort" confirmNewPassword = "Neues Passwort bestätigen" submit = "Änderung speichern" +changeUsername = "Update your username. You will be logged out after updating." +credsUpdated = "Account updated" +description = "Changes saved. Please log in again." +error = "Unable to update username. Please verify your password and try again." +ssoManaged = "Your account is managed by your identity provider." [account] title = "Kontoeinstellungen" @@ -514,6 +548,8 @@ property = "Eigenschaft" webBrowserSettings = "Webbrowser-Einstellung" syncToBrowser = "Synchronisiere Konto -> Browser" syncToAccount = "Synchronisiere Konto <- Browser" +changeUsernameDescription = "Update your username. You will be logged out after updating." +newUsernamePlaceholder = "Enter your new username" [adminUserSettings] title = "Benutzerkontrolle" @@ -926,10 +962,16 @@ tags = "text,anmerkung,beschriftung" title = "Text hinzufügen" desc = "Beliebigen Text überall in Ihrem PDF hinzufügen" +[home.annotate] +desc = "Highlight, draw, add notes and shapes in the viewer" +tags = "annotate,highlight,draw" +title = "Annotate" + [landing] addFiles = "Dateien hinzufügen" uploadFromComputer = "Vom Computer hochladen" openFromComputer = "Vom Computer öffnen" +mobileUpload = "Upload from Mobile" [viewPdf] tags = "anzeigen,lesen,kommentieren,text,bild" @@ -1245,6 +1287,21 @@ cbzOptions = "Optionen: CBZ zu PDF" optimizeForEbook = "PDF für E-Book-Reader optimieren (verwendet Ghostscript)" cbzOutputOptions = "Optionen: PDF zu CBZ" cbzDpi = "DPI für Bildrendering" +cbrDpi = "DPI for image rendering" +cbrOptions = "CBR Options" +cbrOutputOptions = "PDF to CBR Options" + +[convert.ebookOptions] +ebookOptions = "eBook to PDF Options" +ebookOptionsDesc = "Options for converting eBooks to PDF" +embedAllFonts = "Embed all fonts" +embedAllFontsDesc = "Embed all fonts from the eBook into the generated PDF" +includePageNumbers = "Include page numbers" +includePageNumbersDesc = "Add page numbers to the generated PDF" +includeTableOfContents = "Include table of contents" +includeTableOfContentsDesc = "Add a generated table of contents to the resulting PDF" +optimizeForEbookPdf = "Optimize for ebook readers" +optimizeForEbookPdfDesc = "Optimize the PDF for eBook reading (smaller file size, better rendering on eInk devices)" [imageToPdf] tags = "konvertierung,img,jpg,bild,foto" @@ -1362,6 +1419,11 @@ add = "Anhang hinzufügen" remove = "Anhang entfernen" embed = "Anhang einbetten" submit = "Anhänge hinzufügen" +convertToPdfA3b = "Convert to PDF/A-3b" +convertToPdfA3bDescription = "Creates an archival PDF with embedded attachments" +convertToPdfA3bTooltip = "PDF/A-3b is an archival format ensuring long-term preservation. It allows embedding arbitrary file formats as attachments. Conversion requires Ghostscript and may take longer for large files." +convertToPdfA3bTooltipHeader = "About PDF/A-3b Conversion" +convertToPdfA3bTooltipTitle = "What it does" [watermark] title = "Wasserzeichen hinzufügen" @@ -2306,6 +2368,10 @@ saved = "Gespeichert" label = "Unterschriftsbild hochladen" placeholder = "Bilddatei auswählen" hint = "Laden Sie ein PNG- oder JPG-Bild Ihrer Unterschrift hoch" +backgroundRemovalFailedMessage = "Could not remove the background from the image. Using original image instead." +backgroundRemovalFailedTitle = "Background removal failed" +processing = "Processing image..." +removeBackground = "Remove white background (make transparent)" [sign.instructions] title = "So fügen Sie eine Unterschrift hinzu" @@ -2376,6 +2442,11 @@ bullet2 = "Links funktionieren beim Anklicken weiterhin" bullet3 = "Kommentare und Notizen bleiben sichtbar" bullet4 = "Lesezeichen helfen weiterhin bei der Navigation" +[flatten.renderDpi] +help = "Leave blank to use the system default. Higher DPI sharpens output but increases processing time and file size." +label = "Rendering DPI (optional, recommended 150 DPI)" +placeholder = "e.g. 150" + [repair] tags = "reparieren,wiederherstellen,korrigieren,wiederherstellen" title = "Reparieren" @@ -2925,6 +2996,7 @@ header = "PDF zuschneiden" submit = "Abschicken" noFileSelected = "Wählen Sie eine PDF-Datei aus, um mit dem Zuschneiden zu beginnen" reset = "Auf vollständiges PDF zurücksetzen" +autoCrop = "Auto-crop whitespace" [crop.preview] title = "Zuschneidebereich-Auswahl" @@ -3158,6 +3230,7 @@ automaticDesc = "Text basierend auf Suchbegriffen schwärzen" manual = "Manuell" manualDesc = "Klicken und ziehen zum Schwärzen bestimmter Bereiche" manualComingSoon = "Manuelle Schwärzung kommt bald" +automaticDisabledTooltip = "Select files in the file manager to redact multiple files at once" [redact.auto] header = "Auto-Schwärzung" @@ -3225,6 +3298,24 @@ text = "Nur vollständige Wörter abgleichen, keine Teilübereinstimmungen. 'Joh title = "In PDF-Bild konvertieren" text = "Konvertiert das PDF nach der Schwärzung in ein bildbasiertes PDF. Dies stellt sicher, dass Text hinter Schwärzungskästen vollständig entfernt und nicht wiederherstellbar ist." +[redact.tooltip.manual.apply] +bullet1 = "Mark as many areas as needed before applying" +bullet2 = "All pending redactions are applied at once" +bullet3 = "Redactions cannot be undone after applying" +text = "After marking content, click 'Apply' to permanently redact all marked areas. The pending count shows how many redactions are ready to be applied." +title = "Apply Redactions" + +[redact.tooltip.manual.header] +title = "Manual Redaction Controls" + +[redact.tooltip.manual.markArea] +text = "Draw rectangular areas on the PDF to mark regions for redaction. Useful for redacting images, signatures, or irregular shapes." +title = "Mark Area Tool" + +[redact.tooltip.manual.markText] +text = "Select text directly on the PDF to mark it for redaction. Click and drag to highlight specific text that you want to redact." +title = "Mark Text Tool" + [redact.manual] header = "Manuelle Schwärzung" textBasedRedaction = "Textbasierte Schwärzung" @@ -3246,6 +3337,15 @@ showLayers = "Ebenen anzeigen (Doppelklick, um alle Ebenen auf den Standardzusta colourPicker = "Farbwähler" findCurrentOutlineItem = "Aktuelles Gliederungselement finden" applyChanges = "Änderungen anwenden" +apply = "Apply" +applyWarning = "⚠️ Permanent application, cannot be undone and the data underneath will be deleted" +controlsTitle = "Manual Redaction Controls" +instructions = "Select text or draw areas on the PDF to mark content for redaction." +markArea = "Mark Area" +markText = "Mark Text" +noMarks = "No redaction marks. Use the tools above to mark content for redaction." +pendingLabel = "Pending:" +title = "Redaction Tools" [redact.manual.pageRedactionNumbers] title = "Seiten" @@ -3342,6 +3442,19 @@ placeholder = "Anzahl horizontaler Teiler eingeben" label = "Vertikale Teiler" placeholder = "Anzahl vertikaler Teiler eingeben" +[split-by-sections.customPages] +label = "Custom Page Numbers" +placeholder = "e.g. 2,4,6" + +[split-by-sections.splitMode] +custom = "Custom pages" +description = "Choose how to split the pages" +label = "Split Mode" +splitAll = "Split all pages" +splitAllExceptFirst = "Split all except first" +splitAllExceptFirstAndLast = "Split all except first and last" +splitAllExceptLast = "Split all except last" + [AddStampRequest] tags = "stempeln,bild hinzufügen,bild zentrieren,wasserzeichen,pdf,einbetten,anpassen" header = "PDF Stempel" @@ -3720,6 +3833,10 @@ bullet2 = "Höhere Werte reduzieren die Dateigröße" title = "Graustufen" text = "Wählen Sie diese Option, um alle Bilder in Schwarz-Weiß zu konvertieren, was die Dateigröße erheblich reduzieren kann, insbesondere bei gescannten PDFs oder bildreichen Dokumenten." +[compress.tooltip.lineArt] +text = "Convert pages to high-contrast black and white using ImageMagick. Use detail level to control how much content becomes black, and edge emphasis to control how aggressively edges are detected." +title = "Line Art" + [compress.error] failed = "Ein Fehler ist beim Komprimieren der PDF aufgetreten." @@ -3732,6 +3849,24 @@ failed = "Ein Fehler ist beim Komprimieren der PDF aufgetreten." _value = "Kompressionseinstellungen" 1 = "1-3 PDF-Komprimierung,
4-6 Leichte Bildkomprimierung,
7-9 Intensive Bildkomprimierung verringert die Bildqualität dramatisch" +[compress.compressionLevel] +range1to3 = "Lower values preserve quality but result in larger files" +range4to6 = "Medium compression with moderate quality reduction" +range7to9 = "Higher values reduce file size significantly but may reduce image clarity" + +[compress.lineArt] +description = "Uses ImageMagick to reduce pages to high-contrast black and white for maximum size reduction." +detailLevel = "Detail level" +edgeEmphasis = "Edge emphasis" +edgeHigh = "Strong" +edgeLow = "Gentle" +edgeMedium = "Balanced" +label = "Convert images to line art" +unavailable = "ImageMagick is not installed or enabled on this server" + +[compress.linearize] +label = "Linearize PDF for fast web viewing" + [decrypt] passwordPrompt = "Diese Datei ist passwortgeschützt. Bitte geben Sie das Passwort ein:" cancelled = "Vorgang für PDF abgebrochen: {0}" @@ -3983,11 +4118,14 @@ rotateRight = "Nach rechts drehen" toggleSidebar = "Seitenleiste umschalten" exportSelected = "Ausgewählte Seiten exportieren" toggleAnnotations = "Anmerkungen ein-/ausblenden" -annotationMode = "Anmerkungsmodus umschalten" print = "PDF drucken" draw = "Zeichnen" save = "Speichern" saveChanges = "Änderungen speichern" +annotations = "Annotations" +applyRedactionsFirst = "Apply redactions first" +exitRedaction = "Exit Redaction Mode" +redact = "Redact" [search] title = "PDF durchsuchen" @@ -4038,12 +4176,20 @@ settings = "Optionen" adminSettings = "Admin Optionen" allTools = "Werkzeuge" reader = "Reader" +showMeAround = "Show me around" +tours = "Tours" [quickAccess.helpMenu] toolsTour = "Tool-Tour" toolsTourDesc = "Erfahren Sie, was die Tools können" adminTour = "Admin-Tour" adminTourDesc = "Entdecken Sie Admin-Einstellungen & Funktionen" +whatsNewTour = "See what's new in V2" +whatsNewTourDesc = "Tour the updated layout" + +[quickAccess.toursTooltip] +admin = "Watch walkthroughs here: Tools tour, New V2 layout tour, and the Admin tour." +user = "Watch walkthroughs here: Tools tour and the New V2 layout tour." [admin] error = "Fehler" @@ -4069,6 +4215,8 @@ loginRequired = "Der Anmeldemodus muss aktiviert sein, um Admin-Einstellungen zu restarting = "Server wird neu gestartet" restartingMessage = "Der Server wird neu gestartet. Bitte einen Moment warten..." restartError = "Server konnte nicht neu gestartet werden. Bitte manuell neu starten." +error = "Failed to save settings" +success = "Settings saved successfully" [admin.settings.unsavedChanges] title = "Ungespeicherte Änderungen" @@ -4185,6 +4333,10 @@ description = "Pfad zur WeasyPrint-Ausführungsdatei für HTML-zu-PDF-Konvertier label = "Unoconvert-Ausführbare Datei" description = "Pfad zu LibreOffice unoconvert für Dokumentkonvertierungen (leer lassen für Standard: /opt/venv/bin/unoconvert)" +[admin.settings.general.frontendUrl] +description = "Base URL for frontend (e.g., https://pdf.example.com). Used for email invite links and mobile QR code uploads. Leave empty to use backend URL." +label = "Frontend URL" + [admin.settings.security] title = "Sicherheit" description = "Authentifizierung, Anmeldeverhalten und Sicherheitsrichtlinien konfigurieren." @@ -4321,6 +4473,19 @@ connect = "Verbinden" disconnect = "Trennen" disconnected = "Anbieter erfolgreich getrennt" disconnectError = "Anbieter konnte nicht getrennt werden" +imageResolutionFull = "Full (Original Size)" +imageResolutionReduced = "Reduced (Max 1200px)" +mobileScannerConvertToPdf = "Convert Images to PDF" +mobileScannerConvertToPdfDesc = "Automatically convert uploaded images to PDF format. If disabled, images will be kept as-is." +mobileScannerImageResolution = "Image Resolution" +mobileScannerImageResolutionDesc = "Resolution of uploaded images. \"Reduced\" scales images to max 1200px to reduce file size." +mobileScannerPageFormat = "Page Format" +mobileScannerPageFormatDesc = "PDF page size for converted images. \"Keep\" uses original image dimensions." +mobileScannerStretchToFit = "Stretch to Fit" +mobileScannerStretchToFitDesc = "Stretch images to fill the entire page. If disabled, images are centered with preserved aspect ratio." +pageFormatA4 = "A4 (210×297mm)" +pageFormatKeep = "Keep (Original Dimensions)" +pageFormatLetter = "Letter (8.5×11in)" [admin.settings.connections.ssoAutoLogin] label = "SSO-Autoanmeldung" @@ -4389,6 +4554,26 @@ description = "Benutzerkonten bei der ersten SAML2-Anmeldung automatisch erstell label = "Registrierung blockieren" description = "Neue Benutzerregistrierung über SAML2 verhindern" +[admin.settings.connections.mobileScanner] +description = "Allow users to upload files from mobile devices by scanning a QR code" +enable = "Enable QR Code Upload" +imageResolutionFull = "Full (Original Size)" +imageResolutionReduced = "Reduced (Max 1200px)" +label = "Mobile Phone Upload" +link = "Configure in System Settings" +mobileScannerConvertToPdf = "Convert Images to PDF" +mobileScannerConvertToPdfDesc = "Automatically convert uploaded images to PDF format. If disabled, images will be kept as-is." +mobileScannerImageResolution = "Image Resolution" +mobileScannerImageResolutionDesc = "Resolution of uploaded images. \"Reduced\" scales images to max 1200px to reduce file size." +mobileScannerPageFormat = "Page Format" +mobileScannerPageFormatDesc = "PDF page size for converted images. \"Keep\" uses original image dimensions." +mobileScannerStretchToFit = "Stretch to Fit" +mobileScannerStretchToFitDesc = "Stretch images to fill the entire page. If disabled, images are centered with preserved aspect ratio." +note = "Note: Requires Frontend URL to be configured. " +pageFormatA4 = "A4 (210×297mm)" +pageFormatKeep = "Keep (Original Dimensions)" +pageFormatLetter = "Letter (8.5×11in)" + [admin.settings.database] title = "Datenbank" description = "Benutzerdefinierte Datenbankverbindungseinstellungen für Enterprise-Bereitstellungen konfigurieren." @@ -4570,6 +4755,10 @@ description = "Admins erlauben, Benutzer per E-Mail mit automatisch generierten label = "Frontend-URL" description = "Basis-URL für das Frontend (z. B. https://pdf.example.com). Wird zum Erzeugen von Einladungslinks in E-Mails verwendet. Leer lassen, um Backend-URL zu verwenden." +[admin.settings.mail.frontendUrlNote] +link = "Configure in System Settings" +note = "Note: Requires Frontend URL to be configured. " + [admin.settings.legal] title = "Rechtliche Dokumente" description = "Links zu rechtlichen Dokumenten und Richtlinien konfigurieren." @@ -4685,6 +4874,9 @@ description = "Einzelne zu deaktivierende Endpunkte auswählen" label = "Deaktivierte Endpunktgruppen" description = "Zu deaktivierende Endpunktgruppen auswählen" +[admin.settings.badge] +clickToUpgrade = "Click to view plan details" + [fileUpload] selectFile = "Datei auswählen" selectFiles = "Dateien auswählen" @@ -4784,6 +4976,9 @@ showAll = "Alle anzeigen" sortByDate = "Nach Datum sortieren" sortByName = "Nach Name sortieren" sortBySize = "Nach Größe sortieren" +mobileShort = "Mobile" +mobileUpload = "Mobile Upload" +mobileUploadNotAvailable = "Mobile upload not enabled" [storage] temporaryNotice = "Dateien werden temporär in Ihrem Browser gespeichert und können automatisch gelöscht werden" @@ -5069,6 +5264,7 @@ loading = "Laden..." back = "Zurück" continue = "Weiter" error = "Fehler" +save = "Save" [config.overview] title = "Anwendungskonfiguration" @@ -5131,6 +5327,15 @@ impact = "Alle Anwendungen oder Dienste, die derzeit diese Schlüssel verwenden, confirmPrompt = "Sind Sie sicher, dass Sie fortfahren möchten?" confirmCta = "Schlüssel aktualisieren" +[config.apiKeys.alert] +apiKeyErrorTitle = "API-Schlüssel-Fehler" +failedToCreateApiKey = "API-Schlüssel konnte nicht erstellt werden." +failedToRetrieveApiKey = "API-Schlüssel konnte nicht aus der Antwort abgerufen werden." +failedToFetchApiKey = "API-Schlüssel konnte nicht abgerufen werden." +apiKeyRefreshed = "API-Schlüssel aktualisiert" +apiKeyRefreshedBody = "Ihr API-Schlüssel wurde erfolgreich aktualisiert." +failedToRefreshApiKey = "API-Schlüssel konnte nicht aktualisiert werden." + [AddAttachmentsRequest] attachments = "Anhänge auswählen" info = "Wählen Sie Dateien aus, die Sie Ihrer PDF anhängen möchten. Diese Dateien werden eingebettet und über das Anhangs-Panel der PDF zugänglich sein." @@ -5279,6 +5484,20 @@ userBody = "Laden Sie Teammitglieder ein, weisen Sie Rollen zu und halten Sie Ih [onboarding.securityCheck] message = "Die Anwendung hat kürzlich bedeutende Änderungen erfahren. Möglicherweise ist Aufmerksamkeit Ihres Server-Admins erforderlich. Bitte bestätigen Sie Ihre Rolle, um fortzufahren." +[onboarding.tourOverview] +body = "Stirling PDF V2 ships with dozens of tools and a refreshed layout. Take a quick tour to see what changed and where to find the features you need." +title = "Tour Overview" + +[onboarding.whatsNew] +activeFilesView = "Use Active Files to see everything you have open and pick what to work on." +fileUpload = "Use the Files button to upload or pick a recent PDF. We will load a sample so you can see the workspace." +leftPanel = "The left Tools panel lists everything you can do. Browse categories or search to find a tool quickly." +pageEditorView = "Switch to the Page Editor to reorder, rotate, or delete pages." +quickAccess = "Start at the Quick Access rail to jump between Reader, Automate, your files, and all the tours." +rightRail = "The Right Rail holds quick actions to select files, change theme or language, and download results." +topBar = "The top bar lets you swap between Viewer, Page Editor, and Active Files." +wrapUp = "That is what is new in V2. Open the Tours menu anytime to replay this, the Tools tour, or the Admin tour." + [adminOnboarding] welcome = "Willkommen zur Admin-Tour! Entdecken wir die leistungsstarken Enterprise-Funktionen und Einstellungen für Systemadministratoren." configButton = "Klicken Sie auf die Schaltfläche Config, um alle Systemeinstellungen und Administrationskontrollen aufzurufen." @@ -5568,6 +5787,28 @@ contactSales = "Vertrieb kontaktieren" contactToUpgrade = "Kontaktieren Sie uns, um Ihren Plan zu upgraden oder anzupassen" maxUsers = "Max. Benutzer" upTo = "Bis zu" +activateLicense = "Activate Your License" +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." +checkoutOpened = "Checkout Opened" +getLicense = "Get Server License" +monthlyBilling = "Monthly Billing" +selectPeriod = "Select Billing Period" +upgradeToEnterprise = "Upgrade to Enterprise" +yearlyBilling = "Yearly Billing" + +[plan.static.billingPortal] +message = "You will need to verify your email address in the Stripe billing portal. Check your email for a login link." +title = "Email Verification Required" + +[plan.static.licenseActivation] +activate = "Activate License" +checkoutOpened = "Checkout Opened in New Tab" +doLater = "I'll do this later" +enterKey = "Enter your license key below to activate your plan:" +instructions = "Complete your purchase in the Stripe tab. Once your payment is complete, you will receive an email with your license key." +keyDescription = "Paste the license key from your email" +success = "License Activated!" +successMessage = "Your license has been successfully activated. You can now close this window." [plan.period] month = "Monat" @@ -5771,6 +6012,8 @@ notAvailable = "Audit-System nicht verfügbar" notAvailableMessage = "Das Audit-System ist nicht konfiguriert oder nicht verfügbar." disabled = "Audit-Protokollierung ist deaktiviert" disabledMessage = "Aktivieren Sie die Audit-Protokollierung in Ihrer Anwendungskonfiguration, um Systemereignisse nachzuverfolgen." +enterpriseRequired = "Enterprise License Required" +enterpriseRequiredMessage = "The audit logging system is an enterprise feature. Please upgrade to an enterprise license to access audit logs and analytics." [audit.error] title = "Fehler beim Laden des Audit-Systems" @@ -5945,6 +6188,7 @@ emptyUrl = "Bitte eine Server-URL eingeben" unreachable = "Verbindung zum Server konnte nicht hergestellt werden" testFailed = "Verbindungstest fehlgeschlagen" configFetch = "Serverkonfiguration konnte nicht abgerufen werden. Bitte überprüfen Sie die URL und versuchen Sie es erneut." +invalidUrl = "Invalid URL format. Please enter a valid URL like https://your-server.com" [setup.server.error.securityDisabled] title = "Anmeldung nicht aktiviert" @@ -5968,6 +6212,7 @@ instructions = "So aktivieren Sie die Anmeldung auf Ihrem Stirling PDF-Server:" instructionsEnvVar = "Setzen Sie die Umgebungsvariable:" instructionsOrYml = "Oder in der settings.yml:" instructionsRestart = "Starten Sie anschließend Ihren Server neu, damit die Änderungen wirksam werden." +sso = "Single Sign-On" [setup.login.username] label = "Benutzername" @@ -6025,6 +6270,8 @@ reset = "Änderungen zurücksetzen" downloadJson = "JSON herunterladen" generatePdf = "PDF generieren" saveChanges = "Änderungen speichern" +applyChanges = "Apply Changes" +downloadCopy = "Download Copy" [pdfTextEditor.options.autoScaleText] title = "Text automatisch in Rahmen einpassen" @@ -6043,6 +6290,9 @@ descriptionInline = "Tipp: Halten Sie Strg (Cmd) oder Umschalt, um mehrere Textf title = "Bearbeiteten Text auf ein einzelnes PDF-Element fixieren" description = "Wenn aktiviert, exportiert der Editor jedes bearbeitete Textfeld als ein PDF-Textelement, um überlappende Glyphen oder gemischte Schriften zu vermeiden." +[pdfTextEditor.options.advanced] +title = "Advanced Settings" + [pdfTextEditor.manual] mergeTooltip = "Ausgewählte Felder zusammenführen" merge = "Auswahl zusammenführen" @@ -6118,6 +6368,21 @@ subset = "Subset" invalidJson = "JSON-Datei kann nicht gelesen werden. Stellen Sie sicher, dass sie vom PDF-zu-JSON-Tool erzeugt wurde." pdfConversion = "Das bearbeitete JSON kann nicht zurück in ein PDF konvertiert werden." +[pdfTextEditor.tooltip.alpha] +text = "This alpha viewer is still evolving—certain fonts, colours, transparency effects, and layout details may shift slightly. Please double-check the generated PDF before sharing." +title = "Alpha Viewer" + +[pdfTextEditor.tooltip.header] +title = "Preview Limitations" + +[pdfTextEditor.tooltip.previewVariance] +text = "Some visuals (such as table borders, shapes, or annotation appearances) may not display exactly in the preview. The exported PDF keeps the original drawing commands whenever possible." +title = "Preview Variance" + +[pdfTextEditor.tooltip.textFocus] +text = "This workspace focuses on editing text and repositioning embedded images. Complex page artwork, form widgets, and layered graphics are preserved for export but are not fully editable here." +title = "Text and Image Focus" + [auth] sessionExpired = "Sitzung abgelaufen" pleaseLoginAgain = "Bitte melden Sie sich erneut an." @@ -6164,3 +6429,134 @@ title = "Ergebnisse: Text hinzufügen" [addText.error] failed = "Beim Hinzufügen von Text zum PDF ist ein Fehler aufgetreten." + +[annotation] +applyChanges = "Apply Changes" +backgroundColor = "Background colour" +borderOff = "Border: Off" +borderOn = "Border: On" +chooseColor = "Choose colour" +circle = "Circle" +clearBackground = "Remove background" +color = "Colour" +contents = "Text" +desc = "Use highlight, pen, text, and notes. Changes stay live—no flattening required." +drawing = "Drawing" +editCircle = "Edit Circle" +editInk = "Edit Pen" +editLine = "Edit Line" +editNote = "Edit Note" +editPolygon = "Edit Polygon" +editSelectDescription = "Click an existing annotation to edit its colour, opacity, text, or size." +editSelected = "Edit Annotation" +editSquare = "Edit Square" +editStampHint = "To change the image, delete this stamp and add a new one." +editSwitchToSelect = "Switch to Select & Edit to edit this annotation." +editText = "Edit Text Box" +editTextMarkup = "Edit Text Markup" +ellipse = "Ellipse" +exit = "Exit annotation mode" +fillColor = "Fill Colour" +fillOpacity = "Fill Opacity" +fontSize = "Font size" +freehandHighlighter = "Freehand Highlighter" +highlight = "Highlight" +imagePreview = "Preview" +inkHighlighter = "Freehand Highlighter" +line = "Line" +noBackground = "No background" +note = "Note" +noteIcon = "Note Icon" +notesStamps = "Notes & Stamps" +opacity = "Opacity" +pen = "Pen" +polygon = "Polygon" +rectangle = "Rectangle" +redo = "Redo" +saveChanges = "Save Changes" +saveFailed = "Unable to save copy" +saveReady = "Download ready" +savingCopy = "Preparing download..." +select = "Select" +selectAndMove = "Select and Edit" +settings = "Settings" +shapes = "Shapes" +square = "Square" +squiggly = "Squiggly" +stamp = "Add Image" +stampSettings = "Stamp Settings" +strikeout = "Strikeout" +strokeColor = "Stroke Colour" +strokeOpacity = "Stroke Opacity" +strokeWidth = "Width" +text = "Text box" +textAlignment = "Text Alignment" +textMarkup = "Text Markup" +title = "Annotate" +underline = "Underline" +undo = "Undo" +unsupportedType = "This annotation type is not fully supported for editing." + +[footer] +discord = "Discord" +issues = "GitHub" + +[mobileScanner] +addToBatch = "Add to Batch" +back = "Back" +batchImages = "Batch" +camera = "Camera" +cameraAccessDenied = "Camera access denied. Please enable camera access." +cameraDescription = "Scan documents using your device camera with automatic edge detection" +capture = "Capture Photo" +chooseMethod = "Choose Upload Method" +chooseMethodDescription = "Select how you want to scan and upload documents" +clearBatch = "Clear" +connected = "Connected" +connecting = "Connecting..." +edgeDetection = "Edge Detection" +fileDescription = "Upload existing photos or documents from your device" +fileUpload = "File Upload" +flash = "Flash" +flashlight = "Flashlight" +httpsRequired = "Camera access requires HTTPS or localhost. Please use HTTPS or access via localhost." +noSession = "Invalid Session" +noSessionMessage = "Please scan a valid QR code to access this page." +preview = "Preview" +processing = "Processing..." +retake = "Retake" +selectFilesPrompt = "Select files to upload" +selectImage = "Select Image" +sessionExpired = "This session has expired. Please refresh and try again." +sessionInvalid = "Session Error" +sessionNotFound = "Session not found. Please refresh and try again." +sessionValidationError = "Unable to verify session. Please try again." +settings = "Settings" +title = "Mobile Scanner" +upload = "Upload" +uploadAll = "Upload All" +uploadFailed = "Upload failed. Please try again." +uploadSuccess = "Upload Successful!" +uploadSuccessMessage = "Your images have been transferred." +uploading = "Uploading..." +validating = "Validating session..." + +[mobileUpload] +connected = "Mobile device connected" +description = "Scan to upload photos. Images auto-convert to PDF." +descriptionNoConvert = "Scan to upload photos from your mobile device." +error = "Connection Error" +expiryWarning = "Session Expiring Soon" +expiryWarningMessage = "This QR code will expire in {{seconds}} seconds. A new code will be generated automatically." +filesReceived = "{{count}} file(s) received" +instructions = "Scan with your phone camera. Images convert to PDF automatically." +instructionsNoConvert = "Scan with your phone camera to upload files." +pollingError = "Error checking for files" +sessionCreateError = "Failed to create session" +sessionId = "Session ID" +title = "Upload from Mobile" + +[textAlign] +center = "Center" +left = "Left" +right = "Right" diff --git a/frontend/public/locales/en-GB/translation.toml b/frontend/public/locales/en-GB/translation.toml index 621345bcc..499b407a9 100644 --- a/frontend/public/locales/en-GB/translation.toml +++ b/frontend/public/locales/en-GB/translation.toml @@ -5404,6 +5404,15 @@ impact = "Any applications or services currently using these keys will stop work confirmPrompt = "Are you sure you want to continue?" confirmCta = "Refresh Keys" +[config.apiKeys.alert] +apiKeyErrorTitle = "API Key Error" +failedToCreateApiKey = "Failed to create API key." +failedToRetrieveApiKey = "Failed to retrieve API key from response." +failedToFetchApiKey = "Failed to fetch API key." +apiKeyRefreshed = "API Key Refreshed" +apiKeyRefreshedBody = "Your API key has been successfully refreshed." +failedToRefreshApiKey = "Failed to refresh API key." + [AddAttachmentsRequest] attachments = "Select Attachments" info = "Select files to attach to your PDF. These files will be embedded and accessible through the PDF's attachment panel." diff --git a/frontend/src/core/components/shared/config/useRestartServer.ts b/frontend/src/core/components/shared/config/useRestartServer.ts index f87c4c5d6..60291d041 100644 --- a/frontend/src/core/components/shared/config/useRestartServer.ts +++ b/frontend/src/core/components/shared/config/useRestartServer.ts @@ -16,9 +16,12 @@ export function useRestartServer() { }; const restartServer = async () => { - try { - setRestartModalOpened(false); + setRestartModalOpened(false); + await apiClient.post('/api/v1/admin/settings/restart', undefined, { + suppressErrorToast: true, + }) + .then(() => { alert({ alertType: 'neutral', title: t('admin.settings.restarting', 'Restarting Server'), @@ -27,14 +30,12 @@ export function useRestartServer() { 'The server is restarting. Please wait a moment...' ), }); - - await apiClient.post('/api/v1/admin/settings/restart'); - // Wait a moment then reload the page setTimeout(() => { window.location.reload(); }, 3000); - } catch (_error) { + }) + .catch(async (_error) => { alert({ alertType: 'error', title: t('admin.error', 'Error'), @@ -43,7 +44,7 @@ export function useRestartServer() { 'Failed to restart server. Please restart manually.' ), }); - } + }) }; return { diff --git a/frontend/src/proprietary/components/shared/config/configSections/apiKeys/hooks/useApiKey.ts b/frontend/src/proprietary/components/shared/config/configSections/apiKeys/hooks/useApiKey.ts index c8efe73f4..3115eb12f 100644 --- a/frontend/src/proprietary/components/shared/config/configSections/apiKeys/hooks/useApiKey.ts +++ b/frontend/src/proprietary/components/shared/config/configSections/apiKeys/hooks/useApiKey.ts @@ -1,5 +1,7 @@ import { useCallback, useEffect, useState } from "react"; import apiClient from "@app/services/apiClient"; +import { alert } from "@app/components/toast"; +import { useTranslation } from "react-i18next"; export function useApiKey() { const [apiKey, setApiKey] = useState(null); @@ -7,49 +9,107 @@ export function useApiKey() { const [isRefreshing, setIsRefreshing] = useState(false); const [error, setError] = useState(null); const [hasAttempted, setHasAttempted] = useState(false); + const { t } = useTranslation(); + + function failedToCreateAlert() { + alert({ + alertType: "error", + title: t("config.apiKeys.alert.apiKeyErrorTitle", "API Key Error"), + body: t("config.apiKeys.alert.failedToCreateApiKey", "Failed to create API key."), + isPersistentPopup: false, + }); + } const fetchKey = useCallback(async () => { setIsLoading(true); setError(null); - try { - // Backend is POST for get and update - const res = await apiClient.post("/api/v1/user/get-api-key"); - const value = typeof res.data === "string" ? res.data : res.data?.apiKey; - if (typeof value === "string") setApiKey(value); - } catch (e: any) { - // If not found, try to create one by calling update endpoint - if (e?.response?.status === 404) { - try { - const createRes = await apiClient.post("/api/v1/user/update-api-key"); - const created = - typeof createRes.data === "string" - ? createRes.data - : createRes.data?.apiKey; - if (typeof created === "string") setApiKey(created); - } catch (createErr: any) { - setError(createErr); + // Backend is POST for get and update + await apiClient + .post("/api/v1/user/get-api-key", undefined, { + responseType: "json", + }) + .then((response) => { + const data = response.data; + const apiKeyValue = typeof data === "string" ? data : data?.apiKey; + if (typeof apiKeyValue === "string") { + setApiKey(apiKeyValue); + } else { + alert({ + alertType: "error", + title: t("config.apiKeys.alert.apiKeyErrorTitle", "API Key Error"), + body: t("config.apiKeys.alert.failedToRetrieveApiKey", "Failed to retrieve API key from response."), + isPersistentPopup: false, + }); } - } else { - setError(e); - } - } finally { - setIsLoading(false); - setHasAttempted(true); - } + }) + .catch(async (e) => { + // If not found, try to create one by calling update endpoint + if (e?.response?.status === 404) { + await apiClient + .post("/api/v1/user/update-api-key") + .then((createRes) => { + const created = typeof createRes.data === "string" ? createRes.data : createRes.data?.apiKey; + if (typeof created === "string") { + setApiKey(created); + } else { + failedToCreateAlert(); + } + }) + .catch((createErr) => { + failedToCreateAlert(); + setError(createErr); + }); + } else { + alert({ + alertType: "error", + title: t("config.apiKeys.alert.apiKeyErrorTitle", "API Key Error"), + body: t("config.apiKeys.alert.failedToFetchApiKey", "Failed to fetch API key."), + isPersistentPopup: false, + }); + setError(e); + } + }) + .finally(() => { + setIsLoading(false); + setHasAttempted(true); + }); }, []); const refresh = useCallback(async () => { setIsRefreshing(true); setError(null); - try { - const res = await apiClient.post("/api/v1/user/update-api-key"); + await apiClient.post("/api/v1/user/update-api-key", undefined, { + responseType: "json", + suppressErrorToast: true, + }).then((res) => { const value = typeof res.data === "string" ? res.data : res.data?.apiKey; - if (typeof value === "string") setApiKey(value); - } catch (e: any) { + if (typeof value === "string") { + alert({ + alertType: "success", + title: t("config.apiKeys.alert.apiKeyRefreshed", "API Key Refreshed"), + body: t("config.apiKeys.alert.apiKeyRefreshedBody", "Your API key has been successfully refreshed."), + isPersistentPopup: false, + }); + setApiKey(value); + } else { + alert({ + alertType: "error", + title: t("config.apiKeys.alert.apiKeyErrorTitle", "API Key Error"), + body: t("config.apiKeys.alert.failedToRefreshApiKey", "Failed to refresh API key."), + isPersistentPopup: false, + }); + } + }).catch((e) => { + alert({ + alertType: "error", + title: t("config.apiKeys.alert.apiKeyErrorTitle", "API Key Error"), + body: t("config.apiKeys.alert.failedToRefreshApiKey", "Failed to refresh API key."), + isPersistentPopup: false, + }); setError(e); - } finally { + }).finally(() => { setIsRefreshing(false); - } + }); }, []); useEffect(() => {