Compare commits

..

274 Commits

Author SHA1 Message Date
google-labs-jules[bot] 10df0b21bf 🧪 Extract and complete tests for saved searches API
Extracted existing `TestSavedSearchesCRUD` from `tests/test_api_advanced_filters.py` into a dedicated `tests/test_api_saved_searches.py` file to better organize testing logic and reflect the application's file structure.

Significantly improved code coverage of `app/api/saved_searches.py` from 0% (missing configuration imports during tests) to 100% by testing previously untested edge cases including:
- Reaching the maximum saved search limit per user.
- Database commit errors (`HTTP_500_INTERNAL_SERVER_ERROR`) during create, update, and delete actions.
- Validation failures for `filters` field checking for non-dict types (`status.HTTP_422_UNPROCESSABLE_ENTITY`).
- Conflicting names during updates where an existing saved search matches the new name.
- Proper fallback logic across authentication methods for `_get_user_id`.

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-23 16:31:34 +00:00
github-actions[bot] 2732dafba9 chore(release): update build metadata files [skip ci] 2026-03-15 21:39:28 +00:00
semantic-release 987974b317 0.145.2
Automatically generated by python-semantic-release
2026-03-15 21:39:26 +00:00
Christian Krakau-Louis 237af31f5f Merge pull request #691 from christianlouis/copilot/update-user-login-case-sensitivity 2026-03-15 22:39:04 +01:00
copilot-swe-agent[bot] f58b8943fb fix(auth): case-insensitive login + mobile auth debug logging
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-15 21:15:19 +00:00
github-actions[bot] 95b8c42244 chore(release): update build metadata files [skip ci] 2026-03-15 21:07:52 +00:00
semantic-release 279ae44e29 0.145.1
Automatically generated by python-semantic-release
2026-03-15 21:07:48 +00:00
Christian Krakau-Louis 4b06e850d5 Merge pull request #690 from christianlouis/copilot/fix-ios-build-error
fix(mobile): suppress Node.js [DEP0169] url.parse() deprecation and document Apple session expiry fix
2026-03-15 22:07:25 +01:00
copilot-swe-agent[bot] dd02ff5677 fix(mobile): suppress Node.js url.parse() deprecation and document Apple session fix
- Add NODE_NO_WARNINGS=1 to all eas.json build profiles (development,
  preview, production) to suppress [DEP0169] url.parse() deprecation
  warnings emitted by EAS CLI when the build image's system Node is 22+
- Add NODE_NO_WARNINGS=1 env to both EAS Cloud Workflow jobs
  (.eas/workflows/create-builds.yml) with explanatory comments
- Fix outdated Node.js prerequisite in docs/MobileApp.md (was "18 or
  later", now "20.19.4 or later" with nvm guidance)
- Add troubleshooting sections in docs/MobileApp.md and mobile/README.md
  covering both the "Session expired Local session" error (Apple ID
  session expiry + App Store Connect API key recommendation) and the
  [DEP0169] Node.js deprecation warning

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-15 21:06:07 +00:00
copilot-swe-agent[bot] 4ee43bb9a4 Initial plan 2026-03-15 21:05:04 +00:00
github-actions[bot] 4de574370c docs(changelog): update changelog [skip ci] 2026-03-15 21:01:26 +00:00
Christian Krakau-Louis 02e74185bd Merge pull request #689 from christianlouis/copilot/fix-webdav-localhost-test
test: fix webdav localhost SSRF assertion to match actual error message
2026-03-15 22:01:03 +01:00
copilot-swe-agent[bot] fe48e2aedc test: fix webdav localhost assertion to match actual error message
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-15 20:49:52 +00:00
copilot-swe-agent[bot] db115a3c5d Initial plan 2026-03-15 20:46:23 +00:00
github-actions[bot] 8fe91e198e chore(release): update build metadata files [skip ci] 2026-03-15 20:46:03 +00:00
copilot-swe-agent[bot] 800d1051a7 Initial plan 2026-03-15 20:46:01 +00:00
semantic-release cb7bb22780 0.145.0
Automatically generated by python-semantic-release
2026-03-15 20:46:00 +00:00
Christian Krakau-Louis 91ff221ccb Merge pull request #688 from christianlouis/copilot/localize-admin-files-page
feat(i18n): localize admin files page
2026-03-15 21:45:40 +01:00
copilot-swe-agent[bot] 2bb47e7631 feat(i18n): localize admin files page (filemanager.html)
Replace all hardcoded English strings in the admin file manager
template with _() translation calls and add 47 new admin_files.*
keys to en.json.

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-15 18:06:27 +00:00
copilot-swe-agent[bot] ad092b8c15 Initial plan 2026-03-15 17:55:49 +00:00
github-actions[bot] e7621f1ffd chore(release): update build metadata files [skip ci] 2026-03-15 17:55:07 +00:00
semantic-release 5c96c76e89 0.144.0
Automatically generated by python-semantic-release
2026-03-15 17:55:05 +00:00
Christian Krakau-Louis 5fdf5365e7 Merge pull request #687 from christianlouis/copilot/update-readme-and-check-docs
docs: rewrite README.md and update UserGuide/Troubleshooting
2026-03-15 18:54:46 +01:00
Christian Krakau-Louis c326bc4899 Merge pull request #686 from christianlouis/copilot/localize-admin-plans-page
feat(i18n): localize admin plans page (/admin/plans)
2026-03-15 18:54:26 +01:00
copilot-swe-agent[bot] de8922bb8e fix(i18n): fix overage buffer sentence spacing in admin_plans template
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-15 17:53:13 +00:00
copilot-swe-agent[bot] 36aef73969 feat(i18n): localize admin plans page
Add 108 admin_plans.* translation keys to en.json and update
admin_plans.html to use _() for all static text and window.__i18nAdminPlans
for dynamic Alpine.js / JavaScript strings.

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-15 17:51:10 +00:00
copilot-swe-agent[bot] fcf2452b62 docs: rewrite README.md and update UserGuide and Troubleshooting docs
- Rewrite README.md to reflect current state of the project:
  - Updated Overview with all 12 storage, 7 AI, 6 OCR providers
  - Comprehensive Features section (mobile, CLI, browser ext, pipelines, etc.)
  - Updated Workflow with all ingestion channels and distribution targets
  - Expanded Documentation index with all doc links organized by category
  - Updated Tech Stack table (Meilisearch, MkDocs, Expo, etc.)
  - Added Kubernetes/Helm quick start
  - Added status-view screenshot
  - Updated dependency licenses table
- Updated docs/UserGuide.md with cross-references to Mobile App, CLI,
  Browser Extension, and API docs
- Expanded docs/Troubleshooting.md from 175 to 300+ lines with new
  sections for Search, Pipelines, Mobile App, CLI, Performance, and
  updated all existing sections with current information

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-15 17:51:09 +00:00
github-actions[bot] 87a2311234 chore(release): update build metadata files [skip ci] 2026-03-15 17:46:26 +00:00
semantic-release 178ce70a4a 0.143.1
Automatically generated by python-semantic-release
2026-03-15 17:46:23 +00:00
Christian Krakau-Louis f3c12a6122 Merge pull request #685 from christianlouis/copilot/fix-sso-browser-widget-issue
fix(mobile): SSO callback closes browser and delivers token to app; Expo Go support; SafeAreaView deprecation
2026-03-15 18:46:01 +01:00
copilot-swe-agent[bot] 14285567d5 fix(auth): Expo Go support via Linking.createURL; safe token URL construction; clean up return type annotation
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-15 17:42:36 +00:00
copilot-swe-agent[bot] 474706da44 Initial plan 2026-03-15 17:41:35 +00:00
github-actions[bot] 6ab9d120c4 chore(release): update build metadata files [skip ci] 2026-03-15 17:40:26 +00:00
copilot-swe-agent[bot] 6e86919336 Initial plan 2026-03-15 17:40:23 +00:00
semantic-release c03ac42bff 0.143.0
Automatically generated by python-semantic-release
2026-03-15 17:40:23 +00:00
Christian Krakau-Louis e173059b3f Merge pull request #684 from christianlouis/copilot/localize-admin-users-page
feat(i18n): localize admin users page
2026-03-15 18:40:03 +01:00
copilot-swe-agent[bot] 5c373e7224 fix(auth): store mobile_redirect_uri in session and redirect to deep-link after SSO; fix SafeAreaView deprecation
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-15 17:35:49 +00:00
copilot-swe-agent[bot] 3fc0d52126 feat(i18n): localize admin users page
Add 119 new admin_users.* translation keys to en.json and update
admin_users.html to use _() for all static HTML strings, inline
Alpine.js x-text translations, and a window.__i18nAdminUsers block
for JavaScript alert/status messages."

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-15 17:34:47 +00:00
copilot-swe-agent[bot] 64adce7445 Initial plan 2026-03-15 17:24:10 +00:00
github-actions[bot] 854b0f16e3 chore(release): update build metadata files [skip ci] 2026-03-15 17:19:42 +00:00
semantic-release 672e66994a 0.142.0
Automatically generated by python-semantic-release
2026-03-15 17:19:39 +00:00
copilot-swe-agent[bot] ad3d7c0934 Initial plan 2026-03-15 17:19:37 +00:00
Christian Krakau-Louis 80fb2add65 Merge pull request #682 from christianlouis/copilot/localize-terms-and-conditions-page
feat(i18n): localize terms and conditions page
2026-03-15 18:19:18 +01:00
github-actions[bot] 14a18fbc3b chore(release): update build metadata files [skip ci] 2026-03-15 16:44:46 +00:00
semantic-release b4eb27b168 0.141.2
Automatically generated by python-semantic-release
2026-03-15 16:44:44 +00:00
Christian Krakau-Louis 0661c46447 Merge pull request #683 from christianlouis/copilot/fix-expo-device-import-error
fix(mobile): align React version with react-native-renderer to resolve version mismatch crash
2026-03-15 17:44:26 +01:00
copilot-swe-agent[bot] 073627884c fix(mobile): align React version with react-native-renderer to fix version mismatch crash
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-15 16:28:36 +00:00
copilot-swe-agent[bot] c5b08e778d chore: initial plan placeholder
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-15 16:26:15 +00:00
copilot-swe-agent[bot] 0232d20e42 Initial plan 2026-03-15 16:21:14 +00:00
github-actions[bot] ebb280f685 chore(release): update build metadata files [skip ci] 2026-03-15 16:19:24 +00:00
semantic-release 3f06c1312d 0.141.1
Automatically generated by python-semantic-release
2026-03-15 16:19:22 +00:00
Christian Krakau-Louis e2acbdc7d3 Merge pull request #679 from christianlouis/copilot/fix-typeerror-expo-start
feat(mobile): bootstrap expo-router app/ directory, WelcomeScreen, and web support
2026-03-15 17:19:03 +01:00
copilot-swe-agent[bot] 8c18f0da17 feat(i18n): localize terms and conditions page
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-15 16:08:59 +00:00
copilot-swe-agent[bot] 82d90b48e2 fix(mobile): correct asset logo path depth and add expo-router app/ routes for web support
- Fix fatal crash: require('../../../assets/logo.png') in LoginScreen and
  WelcomeScreen resolved 3 levels above mobile/src/screens/ — outside the
  mobile/ directory. Changed to ../../assets/logo.png which correctly
  resolves to the existing mobile/assets/logo.png.
- Add expo-router app/ directory (root cause of missing welcome screen and
  web support): app/_layout.tsx, (auth)/, (tabs)/ with all route files
- Add WelcomeScreen.tsx: branded intro screen with feature highlights
- Update LoginScreen/WelcomeScreen to use useRouter() (expo-router style)
- Add react-native-web ~0.20.0 and react-dom 19.2.4 for web channel
- Add expo-device ~7.0.3 (was imported but missing from package.json)
- Remove android.googleServicesFile from app.json (file is gitignored;
  README documents how to restore it for Android FCM builds)
- Add web.bundler: metro and web.output: single to app.json
- Fix aria-hidden to explicit boolean value in WelcomeScreen

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-15 16:02:02 +00:00
github-actions[bot] 4d81d275a6 chore(release): update build metadata files [skip ci] 2026-03-15 16:01:36 +00:00
semantic-release 1780adcffa 0.141.0
Automatically generated by python-semantic-release
2026-03-15 16:01:34 +00:00
copilot-swe-agent[bot] ed76bdfd9b Initial plan 2026-03-15 16:01:32 +00:00
Christian Krakau-Louis 2860bc9024 Merge pull request #681 from christianlouis/copilot/localize-status-page
feat(ui): localize status page
2026-03-15 17:01:15 +01:00
copilot-swe-agent[bot] e46dadbf45 feat(ui): localize status page with i18n translations
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-15 15:56:55 +00:00
copilot-swe-agent[bot] a2de72ab72 wip: tracking plan
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-15 15:54:00 +00:00
copilot-swe-agent[bot] 9d5b1e25e2 wip: plan
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-15 15:49:32 +00:00
github-actions[bot] 3631cd630c chore(release): update build metadata files [skip ci] 2026-03-15 15:44:48 +00:00
copilot-swe-agent[bot] 19b7eb2ae8 Initial plan 2026-03-15 15:44:46 +00:00
semantic-release ce72626121 0.140.0
Automatically generated by python-semantic-release
2026-03-15 15:44:45 +00:00
Christian Krakau-Louis f08c0d6273 Merge pull request #680 from christianlouis/copilot/localize-attribution-page
feat(i18n): localize attribution page
2026-03-15 16:44:25 +01:00
copilot-swe-agent[bot] 246ef70245 feat(i18n): localize attribution page
Add 13 attribution.* keys to en.json and replace all hardcoded
English strings in attribution.html with _() helper calls.
Also adds aria-hidden to the decorative warning SVG icon.

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-15 15:37:47 +00:00
copilot-swe-agent[bot] b4b50e8340 fix(mobile): update Node.js minimum requirement to 20.19.4
metro-config@0.83.3 uses Array.prototype.toReversed() which was added
in Node.js 20.0.0. The README incorrectly stated "Node.js 18+" causing
users to run `npx expo start` with Node 18 and hit:
  TypeError: configs.toReversed is not a function

- Update README.md to say "Node.js 20.19.4+" with nvm hint
- Update package.json engines from >=20.16.0 to >=20.19.4 to match
  metro-config's exact minimum (as declared in package-lock.json)

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-15 15:34:12 +00:00
github-actions[bot] baf760729e chore(release): update build metadata files [skip ci] 2026-03-15 15:32:07 +00:00
semantic-release 5ae36f6890 0.139.0
Automatically generated by python-semantic-release
2026-03-15 15:32:03 +00:00
copilot-swe-agent[bot] 4d305d166e Initial plan 2026-03-15 15:31:58 +00:00
Christian Krakau-Louis 940fe7c357 Merge pull request #678 from christianlouis/copilot/localize-license-page
feat(ui): localize license page
2026-03-15 16:31:43 +01:00
copilot-swe-agent[bot] 0ea652fa53 Initial plan 2026-03-15 15:31:32 +00:00
copilot-swe-agent[bot] 9c73ab8e59 feat(ui): localize license page with i18n translation keys
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-15 14:11:42 +00:00
github-actions[bot] a6b81f9d42 chore(release): update build metadata files [skip ci] 2026-03-15 14:05:24 +00:00
semantic-release e46303c2f3 0.138.0
Automatically generated by python-semantic-release
2026-03-15 14:05:21 +00:00
copilot-swe-agent[bot] e72836c027 Initial plan 2026-03-15 14:05:19 +00:00
Christian Krakau-Louis 3f5adadd51 Merge pull request #677 from christianlouis/copilot/localize-cookies-policy-page
feat(i18n): localize cookies policy page
2026-03-15 15:04:55 +01:00
github-actions[bot] eaba8ec6a3 chore(release): update build metadata files [skip ci] 2026-03-15 14:04:34 +00:00
semantic-release 98ec537b68 0.137.1
Automatically generated by python-semantic-release
2026-03-15 14:04:31 +00:00
Christian Krakau-Louis 501319c70b Merge pull request #676 from christianlouis/copilot/fix-ios-app-crash
fix(mobile): iOS TurboModule SIGABRT crash + npm install / EAS build failure
2026-03-15 15:04:12 +01:00
copilot-swe-agent[bot] ca962f8040 fix(mobile): add missing react@19.2.4 integrity hash to package-lock.json
EAS builds run `npm ci` which validates integrity hashes for every package.
The react entry was updated to 19.2.4 but its integrity hash was removed,
causing the EAS iOS build to fail at the Install dependencies phase.

- Runs npm install --package-lock-only to regenerate full lockfile
  (also adds react-dom@19.2.4 entry with its integrity hash)
- Patches node_modules/react entry with canonical integrity hash from
  the npm registry: sha512-9nfp...
- Verified with npm ci --dry-run: 938 packages, exit 0

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-15 14:02:25 +00:00
copilot-swe-agent[bot] 01811438dc chore: initial plan for npm install lockfile fix
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-15 14:00:03 +00:00
copilot-swe-agent[bot] f983b20a8f fix(mobile): fix iOS TurboModule crash and npm install peer dep conflict
- UploadScreen.tsx: replace deprecated ImagePicker.MediaTypeOptions.Images
  with new array syntax ['images'] — the old enum is not accepted by the
  expo-image-picker v17 TurboModule on iOS, causing SIGABRT on Thread 2
- package.json: bump react 19.1.0 → 19.2.4 so react-dom@19.2.4 (peerOptional
  of @expo/metro-runtime) no longer conflicts; update @types/react to ~19.2.0
- package-lock.json: update react entry to 19.2.4 (integrity removed, will be
  regenerated by npm install on the next EAS build)

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-15 13:53:27 +00:00
copilot-swe-agent[bot] 182868c0d7 feat(i18n): localize cookies policy page
Replace all hardcoded English strings in cookies.html with _() i18n
helper calls and add 35 new cookie_policy.* translation keys to
frontend/translations/en.json. Also improve table accessibility with
aria-label and scope attributes."

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-15 13:52:03 +00:00
github-actions[bot] 7707db6dc6 chore(release): update build metadata files [skip ci] 2026-03-15 13:45:05 +00:00
semantic-release b294f11720 0.137.0
Automatically generated by python-semantic-release
2026-03-15 13:45:03 +00:00
copilot-swe-agent[bot] 13d451dc36 Initial plan 2026-03-15 13:45:00 +00:00
Christian Krakau-Louis 6ac9a55c77 Merge pull request #674 from christianlouis/copilot/localize-imprint-page
feat(ui): localize imprint page
2026-03-15 14:44:45 +01:00
copilot-swe-agent[bot] f188e81e3a Initial plan 2026-03-15 13:44:28 +00:00
github-actions[bot] 8a1dc2aa0e docs(changelog): update changelog [skip ci] 2026-03-15 13:42:26 +00:00
Christian Krakau-Louis b0e1fb5305 Merge pull request #675 from christianlouis/copilot/itms-90725-update-sdk-version
chore(mobile): upgrade iOS build image to Xcode 26 for iOS 26 SDK compliance
2026-03-15 14:42:06 +01:00
copilot-swe-agent[bot] 69664ec6cd chore(mobile): update iOS build image to Xcode 26 for iOS 26 SDK compliance
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-15 11:57:30 +00:00
copilot-swe-agent[bot] 552c4ea819 Initial plan 2026-03-15 11:55:20 +00:00
copilot-swe-agent[bot] 1f6a3a5803 feat(ui): localize imprint page
Add 25 imprint.* translation keys to en.json and update imprint.html
to use _() helpers for all user-visible strings. Static business data
(company name, address, contact details, VAT number) remain hardcoded
as proper nouns that must not be altered by translation.

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-15 10:23:33 +00:00
github-actions[bot] 02c399239c docs(changelog): update changelog [skip ci] 2026-03-15 10:23:25 +00:00
Christian Krakau-Louis d2ec99e7d5 Update eas.json 2026-03-15 11:23:07 +01:00
copilot-swe-agent[bot] 23d952235e Initial plan 2026-03-15 10:17:31 +00:00
github-actions[bot] 8f00f5ddee chore(release): update build metadata files [skip ci] 2026-03-15 10:10:52 +00:00
semantic-release 67a3f96129 0.136.1
Automatically generated by python-semantic-release
2026-03-15 10:10:50 +00:00
Christian Krakau-Louis d352324f58 Merge pull request #672 from christianlouis/copilot/debug-build-archive-error
fix(mobile): resolve iOS build failures caused by Expo SDK 54 precompiled XCFrameworks
2026-03-15 11:10:31 +01:00
Christian Krakau-Louis 9c791c4573 Merge pull request #673 from christianlouis/sentinel-fix-webdav-ssrf-2142784158541650346
🛡️ Sentinel: [HIGH] Fix SSRF in WebDAV connection test
2026-03-15 11:10:07 +01:00
github-actions[bot] b7a195d3bc style: apply ruff auto-fix
- Auto-formatted code with ruff format
- Applied ruff linting fixes with --fix

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-15 04:10:28 +00:00
google-labs-jules[bot] 8079db7893 🛡️ Sentinel: [HIGH] Fix SSRF in WebDAV connection test
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-15 04:10:11 +00:00
copilot-swe-agent[bot] 3372a93f71 fix(mobile): resolve iOS build errors by enabling buildReactNativeFromSource for Expo SDK 54
Expo SDK 54 switched to precompiled React Native XCFrameworks by default
for faster iOS builds. However, the precompiled frameworks do not expose
legacy bridge headers (RCTBridge, RCTViewManager, RCTSurfaceHostingProxyRootView,
RCTPackagerConnection, RCTDevSettings.isDebuggingRemotely, rootViewFactory)
that some native modules (e.g. expo-dev-client) still reference.

Add expo-build-properties (v1.0.10, the SDK 54-compatible version) and
configure buildReactNativeFromSource: true for iOS. This compiles React
Native from source, making all native headers available to linked modules
and resolving the Xcode compilation errors seen in the EAS production build.

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-14 22:50:29 +00:00
github-actions[bot] 7cd6252bf7 chore(release): update build metadata files [skip ci] 2026-03-14 22:42:14 +00:00
semantic-release 5d8a6e26bb 0.136.0
Automatically generated by python-semantic-release
2026-03-14 22:42:11 +00:00
Christian Krakau-Louis d60c3cd303 Merge pull request #671 from christianlouis/copilot/update-logos-and-ux
[WIP] Update mobile app to use DocuElevate's original UX and logos
2026-03-14 23:41:52 +01:00
copilot-swe-agent[bot] 971e578dd7 feat(mobile): update DocuElevate mobile app branding with original logo and UX
- Regenerate all app icons (icon.png, adaptive-icon.png, splash.png,
  favicon.png, notification-icon.png) using the DocuElevate folder+gear
  SVG logo on brand blue (#1e40af) background
- Add assets/logo.png (200×200 circular logo) for the login screen
- Update LoginScreen.tsx to display the DocuElevate logo image above
  the brand name text instead of plain text only
- Replace emoji tab bar icons (⬆️📄👤) with Ionicons vector icons
  (cloud-upload-outline, document-text-outline, person-circle-outline)
  from the already-installed @expo/vector-icons package

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-14 22:41:02 +00:00
github-actions[bot] 0c9938155b chore(release): update build metadata files [skip ci] 2026-03-14 22:39:52 +00:00
semantic-release f3eedce835 0.135.4
Automatically generated by python-semantic-release
2026-03-14 22:39:49 +00:00
Christian Krakau-Louis 833af6f161 Merge pull request #670 from christianlouis/copilot/fix-dependency-version-mismatches
fix(mobile): align Expo SDK 54 dependency versions to pass expo doctor
2026-03-14 23:39:28 +01:00
copilot-swe-agent[bot] 911a995c6a Initial plan 2026-03-14 22:35:55 +00:00
copilot-swe-agent[bot] 15f4069592 Initial plan 2026-03-14 22:33:24 +00:00
copilot-swe-agent[bot] f6e955acce fix(mobile): fix expo doctor dependency version mismatches for SDK 54
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-14 20:45:03 +00:00
copilot-swe-agent[bot] 9bff4e0d00 Initial plan 2026-03-14 20:40:41 +00:00
github-actions[bot] 256d97136e chore(release): update build metadata files [skip ci] 2026-03-14 20:37:20 +00:00
semantic-release c336bf1eb1 0.135.3
Automatically generated by python-semantic-release
2026-03-14 20:37:18 +00:00
Christian Krakau-Louis c5db5c94e2 Merge pull request #669 from christianlouis/copilot/update-ios-production-build
fix(mobile): update invalid EAS iOS build image to macos-sequoia-15.3-xcode-16.2
2026-03-14 21:36:57 +01:00
copilot-swe-agent[bot] fe66e51996 fix(mobile): update iOS build image from macos-sequoia-15.1-xcode-16.2 to macos-sequoia-15.3-xcode-16.2
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-14 20:35:50 +00:00
copilot-swe-agent[bot] 2aac612f10 Initial plan 2026-03-14 20:34:53 +00:00
github-actions[bot] defea2eadd chore(release): update build metadata files [skip ci] 2026-03-14 20:32:44 +00:00
semantic-release 24949fd05d 0.135.2
Automatically generated by python-semantic-release
2026-03-14 20:32:41 +00:00
Christian Krakau-Louis b1d6188b5e Merge pull request #668 from christianlouis/copilot/fix-build-failure-expo-modules
fix(mobile): sync app.json and eas.json to expo.dev project credentials
2026-03-14 21:32:04 +01:00
copilot-swe-agent[bot] 5f3d134d7e fix(mobile): sync app.json and eas.json to expo.dev project credentials
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-14 20:31:35 +00:00
copilot-swe-agent[bot] 7633cdf4e4 fix(mobile): sync app.json slug and owner to expo.dev project
- slug: docuelevate → docuelevate-mobile
- owner: christian-krakau-louis → christianlouis
- projectId 16925679-cb94-411c-83b5-a62c9addb872 already correct

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-14 20:28:52 +00:00
copilot-swe-agent[bot] 4f38075de4 fix(mobile): use org.docuelevate.mobile as app identifier
Update iOS bundleIdentifier and Android package from
com.christianlouis.docuelevatemobile to org.docuelevate.mobile.

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-14 20:26:51 +00:00
copilot-swe-agent[bot] 1aa02aea24 fix(mobile): add missing assets and EAS workflow for automatic builds
- Add mobile/assets/ directory with all required Expo/EAS asset files:
  - icon.png (1024x1024 branded app icon)
  - adaptive-icon.png (1024x1024 transparent fg for Android)
  - splash.png (1284x2778 branded splash screen)
  - favicon.png (48x48 web favicon)
  - notification-icon.png (96x96 white-on-transparent for Android)
  - notification-sound.wav (0.1s silent WAV, 44100 Hz mono)
- Add mobile/.eas/workflows/create-builds.yml for automatic production
  builds triggered on every push to main (per Expo EAS Workflows docs)

The Xcode image pin (macos-sequoia-15.1-xcode-16.2) was already present
in eas.json from the prior fix resolving the XCode 15.4 vs >=16.1 error.

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-14 20:21:44 +00:00
copilot-swe-agent[bot] 958c5950b3 Initial plan 2026-03-14 20:13:04 +00:00
github-actions[bot] 97263cd81a chore(release): update build metadata files [skip ci] 2026-03-14 16:30:33 +00:00
semantic-release 7c7a80ee32 0.135.1
Automatically generated by python-semantic-release
2026-03-14 16:30:30 +00:00
Christian Krakau-Louis c9cee854c3 Merge pull request #667 from christianlouis/copilot/fix-missing-project-id
fix(mobile): resolve EAS CI build failures — app.json wrapper, expo-dev-client upgrade, Xcode image pin
2026-03-14 17:30:07 +01:00
copilot-swe-agent[bot] 3bf9589462 fix(mobile): resolve EAS CI build failures – wrap app.json, update deps, pin Xcode image
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-14 15:02:08 +00:00
copilot-swe-agent[bot] 0b45fa6130 Initial plan 2026-03-14 14:55:50 +00:00
github-actions[bot] 209f78d740 chore(release): update build metadata files [skip ci] 2026-03-14 14:55:44 +00:00
semantic-release 84663d53e3 0.135.0
Automatically generated by python-semantic-release
2026-03-14 14:55:41 +00:00
Christian Krakau-Louis 0c070bffd8 Merge pull request #657 from christianlouis/copilot/translate-privacy-page
feat(ui): i18n support for the privacy page
2026-03-14 15:55:20 +01:00
copilot-swe-agent[bot] e1941adfc9 feat(ui): add i18n support to privacy page
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-14 14:49:41 +00:00
github-actions[bot] 6e6f9d83a9 chore(release): update build metadata files [skip ci] 2026-03-14 14:40:22 +00:00
semantic-release 63dcf4a3f6 0.134.5
Automatically generated by python-semantic-release
2026-03-14 14:40:19 +00:00
Christian Krakau-Louis 1f267892f1 Merge pull request #656 from christianlouis/copilot/fix-npm-dependency-conflict
fix(mobile): resolve npm ERESOLVE by bumping react to 19.2.4 and locking deps
2026-03-14 15:39:56 +01:00
copilot-swe-agent[bot] c960d05acc Initial plan 2026-03-14 14:35:46 +00:00
copilot-swe-agent[bot] 8653247e44 fix(mobile): update react to 19.2.4 and add package-lock.json to fix npm ERESOLVE build failure
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-14 14:32:18 +00:00
copilot-swe-agent[bot] 0cb2f6c362 chore: initial plan for mobile npm peer dep fix
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-14 14:30:38 +00:00
copilot-swe-agent[bot] e3e93a0487 Initial plan 2026-03-14 14:21:09 +00:00
github-actions[bot] ef4c513908 docs(changelog): update changelog [skip ci] 2026-03-14 14:17:07 +00:00
Christian Krakau-Louis c6642203c0 chore(i18n): auto-update translations via LLM 2026-03-14 14:15:13 +00:00
Christian Krakau-Louis 31218c8860 chore(i18n): auto-update translations via LLM 2026-03-14 14:15:13 +00:00
github-actions[bot] 2cfacb24f0 chore(release): update build metadata files [skip ci] 2026-03-14 14:14:18 +00:00
semantic-release df19da1379 0.134.4
Automatically generated by python-semantic-release
2026-03-14 14:14:15 +00:00
Christian Krakau-Louis 2a84747cd1 Merge pull request #655 from christianlouis/copilot/fix-npm-dependency-conflict
fix(mobile): bump Node to 20.19.4 and fix @react-navigation/native peer dep
2026-03-14 15:13:57 +01:00
copilot-swe-agent[bot] 4c6596b561 fix(mobile): bump Node to 20.19.4 and fix react-navigation peer dependency
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-14 14:06:52 +00:00
copilot-swe-agent[bot] 5380becac2 Initial plan 2026-03-14 14:04:50 +00:00
github-actions[bot] caf90f8383 chore(release): update build metadata files [skip ci] 2026-03-14 14:00:13 +00:00
semantic-release 4a2d0d8c79 0.134.3
Automatically generated by python-semantic-release
2026-03-14 14:00:10 +00:00
Christian Krakau-Louis eeaf61cc41 Merge pull request #653 from christianlouis/copilot/install-expo-dev-client-manually
fix(mobile): resolve EAS build failures for iOS development and production
2026-03-14 14:59:51 +01:00
copilot-swe-agent[bot] 492b2c643c fix(mobile): add owner field to app.json to match EAS project owner
EAS was failing because the projectId belongs to 'christian-krakau-louis'
but the 'owner' field was not set, causing a mismatch with the logged-in
user. Adding 'owner': 'christian-krakau-louis' resolves the error.

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-14 13:56:57 +00:00
copilot-swe-agent[bot] 6834f3ef1b fix(mobile): add expo-dev-client, pin Node version, and fix EAS build config
- Add expo-dev-client ~5.0.0 to dependencies (required for developmentClient:true profile)
- Add node: 18.19.1 to all EAS build profiles (matches .nvmrc, silences version warning)
- Add appVersionSource: local to cli section in eas.json (silences future-required warning)
- Add ITSAppUsesNonExemptEncryption: false to ios.infoPlist in app.json (eliminates App Store Connect manual config warning)

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-14 13:51:50 +00:00
copilot-swe-agent[bot] 5ff1413adf Initial plan 2026-03-14 13:48:50 +00:00
github-actions[bot] 73c3cb36e8 chore(release): update build metadata files [skip ci] 2026-03-14 13:42:46 +00:00
semantic-release 63316249cf 0.134.2
Automatically generated by python-semantic-release
2026-03-14 13:42:43 +00:00
Christian Krakau-Louis 5e088995da Merge pull request #652 from christianlouis/copilot/fix-expo-sdk-version-mismatch
fix(mobile): upgrade Expo SDK 51 → 54 and set real EAS project ID
2026-03-14 14:42:21 +01:00
copilot-swe-agent[bot] f148785079 fix(mobile): upgrade Expo SDK from 51 to 54 and fix EAS project ID
- Update mobile/app.json: replace YOUR_EAS_PROJECT_ID placeholder with
  the real EAS project ID (8455f39d-ef0d-4850-98e4-f79e388642c1)
- Update mobile/package.json: upgrade all dependencies to Expo SDK 54
  compatible versions (react 19.1.0, react-native 0.81.5, expo ~54.0.0,
  all expo-* packages, react-navigation v6 → v7, @types/react ~19.1.10)
- Update mobile/README.md: remove outdated eas init step, document that
  the EAS project ID is already configured and explain when to update it

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-14 13:28:01 +00:00
copilot-swe-agent[bot] 76ce7dc834 Initial plan 2026-03-14 13:21:42 +00:00
github-actions[bot] 90be14b249 chore(release): update build metadata files [skip ci] 2026-03-14 13:15:25 +00:00
semantic-release 50c046136d 0.134.1
Automatically generated by python-semantic-release
2026-03-14 13:15:22 +00:00
Christian Krakau-Louis c3d1e0161e Merge pull request #651 from christianlouis/copilot/fix-incompatible-node-version
fix(mobile): remove expo-sharing from plugins, align .nvmrc to Node 18 LTS
2026-03-14 14:15:04 +01:00
copilot-swe-agent[bot] 8f9405b5ac fix(mobile): remove expo-sharing from plugins and align .nvmrc with Node 18 LTS
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-14 13:11:58 +00:00
copilot-swe-agent[bot] fb5d494e42 fix(mobile): relax Node.js engine constraint to >=18.0.0
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-14 13:05:02 +00:00
copilot-swe-agent[bot] 0b20753225 Initial plan 2026-03-14 13:03:30 +00:00
github-actions[bot] 813820443e chore(release): update build metadata files [skip ci] 2026-03-14 12:49:18 +00:00
semantic-release 35f3d7e712 0.134.0
Automatically generated by python-semantic-release
2026-03-14 12:49:16 +00:00
Christian Krakau-Louis d9b8ef7725 Merge pull request #649 from christianlouis/copilot/update-i18n-en-json
feat(i18n): extract UI text from integrations, profile, and subscription pages; internationalize user menu
2026-03-14 13:48:57 +01:00
copilot-swe-agent[bot] eaa533760d fix(i18n): replace fragment translation keys with placeholder-based complete sentences
- Use {gravatar} placeholder in profile.avatar_upload_hint for proper i18n
- Use {settings_link} placeholder in subscription.single_user_body
- Use {api_tokens_link} placeholder in integrations.webhook_step1
- Add optional chaining in common.js for window.__i18n safety

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-14 12:45:47 +00:00
copilot-swe-agent[bot] 08853e823c fix(ui): restore missing template x-if tag and fix Nextcloud URL label key
- Restore missing '<template x-if="quota.max_sources !== null && quota.max_sources > 0">' wrapping the sources mini usage bar
- Add dedicated 'integrations.nextcloud_url_label' key instead of misusing 'integrations.nextcloud_settings' as a prefix

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-14 12:32:24 +00:00
github-actions[bot] 4eb1e0515a chore(release): update build metadata files [skip ci] 2026-03-14 12:31:51 +00:00
semantic-release 025c2382d8 0.133.7
Automatically generated by python-semantic-release
2026-03-14 12:31:48 +00:00
Christian Krakau-Louis 06109fd14e Merge pull request #650 from christianlouis/copilot/update-glob-dependencies
fix(mobile): resolve EAS build failure due to Node.js engine incompatibility
2026-03-14 13:31:24 +01:00
copilot-swe-agent[bot] 5a025576c4 feat(ui): internationalize profile, subscription, integrations templates and user menu JS
- Add 155 new translation keys to en.json (common, nav, integrations, profile, subscription sections)
- Add window.__i18n script block to base.html for user menu strings
- Replace all hardcoded English strings in common.js with window.__i18n lookups using safe fallback pattern
- Internationalize profile.html (page title, headings, labels, hints, placeholders, theme options)
- Internationalize subscription.html (page title, plan cards, usage stats, plan actions, badges)
- Internationalize integrations_dashboard.html (header, quota bars, modal, all form fields, action buttons, webhook section, delete modal)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-14 12:30:27 +00:00
copilot-swe-agent[bot] 8717538967 fix(mobile): fix EAS build Node.js version incompatibility for iOS and Android
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-14 12:28:06 +00:00
copilot-swe-agent[bot] d72fcd463f Initial plan 2026-03-14 12:23:34 +00:00
copilot-swe-agent[bot] 36172160ac Initial plan 2026-03-14 12:08:38 +00:00
github-actions[bot] 55b9da50d4 chore(release): update build metadata files [skip ci] 2026-03-14 11:45:44 +00:00
semantic-release cf6c502555 0.133.6
Automatically generated by python-semantic-release
2026-03-14 11:45:41 +00:00
Christian Krakau-Louis 996deb76ff Merge pull request #638 from christianlouis/security-fix-sql-injection-index-mgmt-16594348298290829845
🔒 [security fix] Fix SQL injection in index management queries
2026-03-14 12:45:01 +01:00
Christian Krakau-Louis 51cf0995f7 Merge pull request #637 from christianlouis/test-improvement-api-tokens-revoke-error-paths-8200211521533028876
🧪 [testing improvement] Add error path tests for API token revocation
2026-03-14 12:44:44 +01:00
Christian Krakau-Louis d5fb0da335 Merge pull request #639 from christianlouis/refactor-audit-logs-annotated-b008-6104828608171783200
🧹 Use Annotated pattern in audit_logs.py to resolve Ruff B008
2026-03-14 12:44:26 +01:00
Christian Krakau-Louis 777910b653 Merge pull request #640 from christianlouis/refactor-filename-regex-constant-13933144971632372772
Refactor filename regex to shared constant
2026-03-14 12:44:11 +01:00
Christian Krakau-Louis ee95173de0 Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-14 12:43:59 +01:00
github-actions[bot] e1ad8eae8c chore(release): update build metadata files [skip ci] 2026-03-14 11:24:54 +00:00
semantic-release 12bea9a861 0.133.5
Automatically generated by python-semantic-release
2026-03-14 11:24:51 +00:00
Christian Krakau-Louis 8ce459fc23 Merge pull request #647 from christianlouis/copilot/add-missing-help-page-texts
fix(ui): add 22 missing help page translation keys to en.json
2026-03-14 12:24:31 +01:00
copilot-swe-agent[bot] 48a93036ca fix(ui): add 22 missing help page translation keys to en.json
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-14 11:16:06 +00:00
copilot-swe-agent[bot] 4b60e0bd48 Initial plan 2026-03-14 11:09:51 +00:00
github-actions[bot] faecaad306 chore(release): update build metadata files [skip ci] 2026-03-14 10:36:09 +00:00
semantic-release 662de20e89 0.133.4
Automatically generated by python-semantic-release
2026-03-14 10:36:05 +00:00
Christian Krakau-Louis 8487dcd13d Merge pull request #645 from christianlouis/copilot/fix-language-selection-bug
fix(i18n): persist language preference server-side, fix dropdown rendering and avatar 404
2026-03-14 11:35:43 +01:00
copilot-swe-agent[bot] f41ecc9b2c fix(i18n): add noqa comment to intentionally broad exception in hydration helper
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-14 10:34:31 +00:00
copilot-swe-agent[bot] 1416d901d0 fix(i18n): persist language preference server-side, fix dropdown rendering and avatar 404
- Fix language dropdown in base.html by moving tojson data out of HTML
  attribute into a script tag (prevents quote conflicts breaking Alpine.js)
- Fix avatar placeholder 404 by correcting filename reference from
  avatar-placeholder.svg to default-avatar.svg
- Add session hydration from DB in _inject_global_context() so
  detect_language() uses the stored preference on every request
- Sync session and cookie in PATCH /api/profile when language changes
- Reload page after language change in profile to reflect new locale
- Add tests for session/cookie sync and DB hydration

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-14 10:30:50 +00:00
github-actions[bot] 01739e8c22 docs(changelog): update changelog [skip ci] 2026-03-14 10:25:20 +00:00
Christian Krakau-Louis d09227b61c chore(i18n): auto-update translations via LLM 2026-03-14 10:24:41 +00:00
github-actions[bot] 3da3953a4b chore(release): update build metadata files [skip ci] 2026-03-14 10:19:51 +00:00
semantic-release cb7693c428 0.133.3
Automatically generated by python-semantic-release
2026-03-14 10:19:48 +00:00
Christian Krakau-Louis a8174371f5 Merge pull request #646 from christianlouis/copilot/add-missing-translations-keys
fix(i18n): add missing index page translation keys to en.json
2026-03-14 11:19:28 +01:00
copilot-swe-agent[bot] 1f24b1b95b fix(i18n): add missing index page translation keys to en.json
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-14 10:14:43 +00:00
copilot-swe-agent[bot] ae8bd0ca47 Initial plan 2026-03-14 10:10:53 +00:00
Christian Krakau-Louis 91855b0763 Merge pull request #643 from christianlouis/copilot/sub-pr-637
test: exercise real auth path in test_revoke_token_unauthenticated
2026-03-14 11:03:55 +01:00
Christian Krakau-Louis 9ded099a82 Merge pull request #644 from christianlouis/copilot/sub-pr-637-again
test: strengthen rollback assertion in test_revoke_token_database_error
2026-03-14 11:03:31 +01:00
copilot-swe-agent[bot] c3e389d957 Initial plan 2026-03-14 10:02:30 +00:00
copilot-swe-agent[bot] 9e9b1fb158 test: strengthen test_revoke_token_database_error to verify rollback is called
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-14 09:58:46 +00:00
Christian Krakau-Louis db446e9ba3 Merge pull request #642 from christianlouis/copilot/sub-pr-638
test(database): add regression test for quoted-identifier index drop
2026-03-14 10:57:44 +01:00
copilot-swe-agent[bot] 751b16d804 test: clarify caller cleanup responsibility in _make_unauthenticated_client docstring
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-14 09:55:33 +00:00
copilot-swe-agent[bot] 33eded02b1 test(database): add unit test for quoted identifier in filehash index drop
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-14 09:54:28 +00:00
copilot-swe-agent[bot] 96ef7d4769 test: exercise real auth path in test_revoke_token_unauthenticated
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-14 09:54:20 +00:00
copilot-swe-agent[bot] 3f8841a589 Initial plan 2026-03-14 09:49:49 +00:00
copilot-swe-agent[bot] 8656434f51 Initial plan 2026-03-14 09:49:43 +00:00
copilot-swe-agent[bot] 12bf5d9724 Initial plan 2026-03-14 09:48:43 +00:00
github-actions[bot] 9b944d4a87 chore(release): update build metadata files [skip ci] 2026-03-14 09:48:20 +00:00
semantic-release 10edd6c6a5 0.133.2
Automatically generated by python-semantic-release
2026-03-14 09:48:17 +00:00
Christian Krakau-Louis 0ef7bf23d7 Merge pull request #636 from christianlouis/copilot/fix-json-serialization-errors
fix(ui): add missing `suggested_languages` global to error templates
2026-03-14 10:47:57 +01:00
github-actions[bot] de0b674025 style: apply ruff auto-fix
- Auto-formatted code with ruff format
- Applied ruff linting fixes with --fix

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-14 09:45:05 +00:00
Christian Krakau-Louis b148675ba8 Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-14 10:44:14 +01:00
google-labs-jules[bot] 917b282614 Refactor: extract filename regex to shared constant
Move the valid filename regex pattern to a shared constant in app/utils/filename_utils.py and update both the task logic and security tests to use it. This eliminates duplication and ensures consistency across the codebase. Also normalized line endings in app/tasks/extract_metadata_with_gpt.py.

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-14 09:42:40 +00:00
google-labs-jules[bot] f96fcbe6c9 style: use Annotated pattern in audit_logs.py to resolve Ruff B008
Refactor `app/api/audit_logs.py` to use the `Annotated` type hint pattern for FastAPI dependencies (`Depends`) and query parameters (`Query`).

- Resolves B008: Function-call in default argument.
- Improves code maintainability and readability by following modern FastAPI best practices.
- Maintains consistency with other modules in the codebase (e.g., `files.py`, `integrations.py`).
- No changes to API runtime behavior.

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-14 09:42:23 +00:00
github-actions[bot] 0b064b9d20 style: apply ruff auto-fix
- Auto-formatted code with ruff format
- Applied ruff linting fixes with --fix

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-14 09:42:23 +00:00
google-labs-jules[bot] 120002b394 fix(database): quote identifiers in index management queries to prevent SQL injection
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-14 09:42:04 +00:00
google-labs-jules[bot] fdf5053ace test: add error path tests for API token revocation
- Enhanced existing tests for 400 (already revoked) and 404 (not found)
- Added test_revoke_token_unauthenticated (401)
- Added test_revoke_token_database_error (500 + rollback check)
- Added test_revoke_token_invalid_id_format (422)

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-14 09:41:38 +00:00
copilot-swe-agent[bot] 0592c59174 fix(ui): add missing suggested_languages global to error templates
The error templates (404.html, 500.html) extend base.html which uses
{{ suggested_languages | tojson }} in the language selector dropdown.
The _error_templates instance in app/main.py was missing this global,
causing Jinja2 Undefined objects to be passed to the tojson filter,
resulting in "TypeError: Object of type Undefined is not JSON serializable"
errors in 35 tests.

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-14 09:07:17 +00:00
copilot-swe-agent[bot] ac5495134b Initial plan 2026-03-14 08:59:57 +00:00
github-actions[bot] 54585e028e chore(release): update build metadata files [skip ci] 2026-03-14 08:51:33 +00:00
semantic-release 4cf240d478 0.133.1
Automatically generated by python-semantic-release
2026-03-14 08:51:30 +00:00
Christian Krakau-Louis 6c8bb4fec8 Merge pull request #635 from christianlouis/copilot/remove-translation-content-tests
fix(test): remove tests that assert specific translation content
2026-03-14 09:50:58 +01:00
copilot-swe-agent[bot] 08fabe154a fix(test): remove tests that check specific translation content
Remove three tests that asserted exact translated strings for German,
French, and Chinese locales. These tests broke whenever translation
files were updated externally.

Replace content-checking tests with behavioral assertions:
- Translated values are non-empty strings (not the raw key)
- Fallback and None-locale return the English translation
- Placeholder interpolation injects the kwarg value

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-14 08:47:56 +00:00
copilot-swe-agent[bot] f0f9165958 Initial plan 2026-03-14 08:43:40 +00:00
github-actions[bot] 971d450128 docs(changelog): update changelog [skip ci] 2026-03-14 08:38:50 +00:00
Christian Krakau-Louis 0cb6017b50 chore(i18n): auto-update translations via LLM 2026-03-14 08:38:11 +00:00
github-actions[bot] 3e68634681 chore(release): update build metadata files [skip ci] 2026-03-14 08:35:03 +00:00
semantic-release 57f204ece9 0.133.0
Automatically generated by python-semantic-release
2026-03-14 08:35:00 +00:00
Christian Krakau-Louis 64d3f27afe Merge pull request #633 from christianlouis/copilot/enhance-language-selector-menu
feat(ui): smart compact language selector with flags and search
2026-03-14 09:34:42 +01:00
github-actions[bot] bc34ed0104 chore(release): update build metadata files [skip ci] 2026-03-14 08:33:58 +00:00
semantic-release c51ad019d2 0.132.1
Automatically generated by python-semantic-release
2026-03-14 08:33:55 +00:00
Christian Krakau-Louis 74eaf6fcef Merge pull request #631 from christianlouis/copilot/remove-translation-test
test(i18n): stop asserting specific translation content in kwargs interpolation test
2026-03-14 09:33:33 +01:00
Christian Krakau-Louis d97fda4840 Merge pull request #634 from christianlouis/sentinel-fix-sql-injection-db-migrate-454366986287082539
🛡️ Sentinel: [SECURITY] Fix potential SQL injection in db_migrate
2026-03-14 09:33:13 +01:00
Christian Krakau-Louis 3bfa5ad266 Resolve translations: keep main for all except en.json 2026-03-14 08:32:00 +00:00
github-actions[bot] 4a46f372b9 docs(changelog): update changelog [skip ci] 2026-03-14 08:27:44 +00:00
Christian Krakau-Louis 8e865180a5 chore(i18n): auto-update translations via LLM 2026-03-14 08:24:11 +00:00
google-labs-jules[bot] 82b1f89332 fix(security): prevent potential SQL injection in database migration
Replaced manual double-quoting of table names with SQLAlchemy's dialect-specific
identifier preparer in `app/utils/db_migrate.py`. This ensures proper quoting
for any database dialect and acts as a defense-in-depth measure against
SQL injection or syntax errors if a table name contains unexpected characters.

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-14 03:39:53 +00:00
Christian Krakau-Louis 9a480e3d68 chore(i18n): auto-update translations via LLM 2026-03-14 01:34:50 +00:00
copilot-swe-agent[bot] 3557590679 feat(ui): smart compact language selector with search and flags
- Add get_suggested_languages() to i18n.py returning ≤6 ranked
  suggestions (current locale → Accept-Language header → popular
  language fallbacks); refactor _parse_accept_language to share
  a common _parse_accept_language_entries() helper
- Inject suggested_languages into every template context (base.py)
- Redesign nav-bar language dropdown (base.html): button shows
  current-language flag emoji; dropdown lists 5-7 suggestions with
  flags and native names; Alpine.js search input filters all 77
  languages live; footer shows count and Search shortcut
- Add language.search_placeholder and language.no_results keys to
  all 77 translation JSON files (en values; external script
  propagates translations to other locales)
- Remove test_all_languages_have_same_keys (external sync script
  owns key completeness); add TestGetSuggestedLanguages (7 unit
  tests); update test_language_selector_in_nav for new HTML
- Update InternationalizationGuide.md: single-step en.json-only
  workflow for adding new translation keys
- Update .github/copilot-instructions.md: add i18n/l10n section
  documenting the en.json-only rule for future agents

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-13 21:31:16 +00:00
Christian Krakau-Louis f4515aaaca added bg translation 2026-03-13 21:27:13 +00:00
copilot-swe-agent[bot] d27140f809 Initial plan 2026-03-13 21:20:48 +00:00
github-actions[bot] 11c816c145 chore(release): update build metadata files [skip ci] 2026-03-13 18:56:19 +00:00
semantic-release 874dc0cee3 0.132.0
Automatically generated by python-semantic-release
2026-03-13 18:56:16 +00:00
Christian Krakau-Louis ee82d0f5a4 Merge pull request #632 from christianlouis/copilot/update-language-support
feat(i18n): expand language support from 49 to 77 languages
2026-03-13 19:55:55 +01:00
copilot-swe-agent[bot] 59d851272d feat(i18n): update language support to 77 languages
- Remove Russian (ru) per requirement
- Keep Esperanto (eo) per user feedback
- Add 29 new languages: am, az, bn, gu, ha, hi, hy, id, ig, jv, ka,
  kk, km, ml, mn, mr, ms, ne, si, sw, ta, te, th, tl, ur, uz, yo,
  zh-TW, zu
- Create 29 new translation JSON files seeded from en.json
- Remove frontend/translations/ru.json
- Update SUPPORTED_LANGUAGES list and docstring (49 → 77)
- Update tests: count 49→77, code set updated
- Update docs/InternationalizationGuide.md: count and table

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-13 18:36:07 +00:00
copilot-swe-agent[bot] 1563073bd4 Initial plan 2026-03-13 18:27:04 +00:00
copilot-swe-agent[bot] 9b32cbc91b test(i18n): remove translation content assertion from test_translate_with_kwargs_german
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-13 16:47:48 +00:00
copilot-swe-agent[bot] 6cae9e425f Initial plan 2026-03-13 16:46:26 +00:00
github-actions[bot] 031b51b9b8 chore(release): update build metadata files [skip ci] 2026-03-13 14:54:29 +00:00
semantic-release ed75fc9da1 0.131.0
Automatically generated by python-semantic-release
2026-03-13 14:54:26 +00:00
Christian Krakau-Louis a47db95129 Merge pull request #629 from christianlouis/copilot/update-translation-file-indexing
feat(i18n): index all 49 translation files and add flags/locale formats
2026-03-13 15:54:02 +01:00
copilot-swe-agent[bot] ce4e797bd1 docs(i18n): add comment on Welsh subdivision flag Unicode representation
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-13 14:07:07 +00:00
copilot-swe-agent[bot] 87d85624be feat(i18n): expand supported languages from 31 to 49 with flags and locale formats
Add 18 previously missing language entries to SUPPORTED_LANGUAGES in
app/utils/i18n.py so every translation JSON file in frontend/translations/
is properly indexed and served by the language selector:

New languages: af, ar, cy, eo, fa, fy, gl, he, ja, kn, ko, li, nds, no, pa, sr, vi, vls

Each new language entry includes:
- Correct ISO 639-1/639-3 code matching its JSON filename
- Native name and display name
- Appropriate country/language flag emoji
- Locale-specific date and number formatting rules in _LOCALE_FORMATS

Also: rename nb "Norwegian" → "Norwegian Bokmål" to distinguish it from no "Norwegian".

Update tests/test_i18n.py:
- Count assertions: 31 → 49
- Expected code set expanded to all 49 codes
- Fix test_unsupported_language_fallback (ja/ko now supported, use xx/yy)

Update docs/InternationalizationGuide.md:
- Language count: 10 → 49
- Full language table with flags, native names, tiers
- Complete file structure listing all 49 JSON files

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-13 14:03:31 +00:00
copilot-swe-agent[bot] 34c5d9e55e Initial plan 2026-03-13 13:55:46 +00:00
github-actions[bot] bd35cdd268 docs(changelog): update changelog [skip ci] 2026-03-13 13:44:34 +00:00
Christian Krakau-Louis b2d4560039 Merge pull request #628 from christianlouis/copilot/update-icelandic-translation
True-up Icelandic translation (is.json) with en.json
2026-03-13 14:43:50 +01:00
github-actions[bot] bb71f0aab8 docs(changelog): update changelog [skip ci] 2026-03-13 13:43:44 +00:00
Christian Krakau-Louis e85acd590d Merge pull request #622 from christianlouis/copilot/update-finnish-translation-file
True-up Finnish translation with en.json
2026-03-13 14:43:24 +01:00
github-actions[bot] 04a81ef7a3 chore(release): update build metadata files [skip ci] 2026-03-13 13:43:14 +00:00
semantic-release 46c78e6cce 0.130.0
Automatically generated by python-semantic-release
2026-03-13 13:43:11 +00:00
Christian Krakau-Louis f2d8f2e129 Merge pull request #618 from christianlouis/copilot/true-up-catalan-translation
feat(i18n): Complete Catalan translation for ca.json
2026-03-13 14:42:52 +01:00
copilot-swe-agent[bot] 217217dd01 feat(i18n): add complete Catalan (ca) translation
Translate all 927 keys from English to Catalan in frontend/translations/ca.json.
Previously the file contained only English placeholder values. All strings
have been translated to proper Catalan, preserving technical terms, product
names, and placeholder variables (e.g. {size}, {year}, {count}).

- 872 out of 927 keys now have Catalan translations
- 55 values intentionally kept unchanged (proper names, product names,
  technical acronyms like IP/ID/ENV, language names in their native form,
  numeric error codes, and words identical in Catalan and English)"

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-13 11:37:29 +00:00
copilot-swe-agent[bot] 8e9e80f175 Initial plan 2026-03-13 11:28:02 +00:00
copilot-swe-agent[bot] a776622b1c Initial plan 2026-03-13 11:27:05 +00:00
copilot-swe-agent[bot] 424da0a343 Initial plan 2026-03-13 11:26:32 +00:00
161 changed files with 188648 additions and 42539 deletions
+7
View File
@@ -163,6 +163,13 @@ pytest --tb=short -q
- Keep JavaScript minimal - prefer server-side rendering
- Follow existing template structure and patterns
### Internationalization (i18n) & Localization (l10n)
- **Always** use the `_("key")` helper in Jinja2 templates and `translate("key", locale)` in Python for every user-visible string — never hardcode UI text.
- **Only add new keys to `frontend/translations/en.json`** — that is the one and only file you must touch when introducing new UI strings.
- Do **not** manually edit any non-English translation file (`de.json`, `fr.json`, etc.). An external automation script syncs all other language files from `en.json` automatically.
- Key naming convention: `<section>.<descriptor>` in snake_case, e.g. `language.search_placeholder`, `nav.help`, `common.cancel`.
- The `test_all_languages_have_same_keys` check has been intentionally removed — key completeness across locales is enforced by the external sync script, not by the test suite.
### Testing
- Write tests in `tests/` directory, mirroring `app/` structure
- Use pytest markers: `@pytest.mark.unit`, `@pytest.mark.integration`, etc.
+4
View File
@@ -0,0 +1,4 @@
## 2024-05-24 - SSRF in WebDAV connection test
**Vulnerability:** The `_test_webdav_connection` function had a custom SSRF check that failed to resolve DNS names, allowing attackers to bypass the check by providing a domain that resolves to an internal IP (e.g., `127.0.0.1`).
**Learning:** DNS resolution is required for robust SSRF protection when validating URLs provided by users.
**Prevention:** Use a centralized `is_private_ip` function (now in `app/utils/network.py`) that resolves the hostname to its IPs and checks if any are private.
+1 -1
View File
@@ -1 +1 @@
2026-03-13T08:33:59Z
2026-03-15T21:39:26Z
+574
View File
@@ -10,6 +10,580 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
<!-- version list -->
## v0.145.2 (2026-03-15)
### Bug Fixes
- **auth**: Case-insensitive login + mobile auth debug logging
([`f58b894`](https://github.com/christianlouis/DocuElevate/commit/f58b8943fbec892ebcc612b28e301605a72232b4))
## v0.145.1 (2026-03-15)
### Bug Fixes
- **mobile**: Suppress Node.js url.parse() deprecation and document Apple session fix
([`dd02ff5`](https://github.com/christianlouis/DocuElevate/commit/dd02ff5677fdd9704da4bacdaf2850882e9f6743))
### Documentation
- **changelog**: Update changelog [skip ci]
([`4de5743`](https://github.com/christianlouis/DocuElevate/commit/4de574370c700708915c4241c1b0ac91e4478707))
### Testing
- Fix webdav localhost assertion to match actual error message
([`fe48e2a`](https://github.com/christianlouis/DocuElevate/commit/fe48e2aedc6cae822cd06a85293833ce103ebcce))
## Unreleased
### Testing
- Fix webdav localhost assertion to match actual error message
([`fe48e2a`](https://github.com/christianlouis/DocuElevate/commit/fe48e2aedc6cae822cd06a85293833ce103ebcce))
## v0.145.0 (2026-03-15)
### Features
- **i18n**: Localize admin files page (filemanager.html)
([`2bb47e7`](https://github.com/christianlouis/DocuElevate/commit/2bb47e7631836880873e51b131455ff8a500d2a6))
## v0.144.0 (2026-03-15)
### Bug Fixes
- **i18n**: Fix overage buffer sentence spacing in admin_plans template
([`de8922b`](https://github.com/christianlouis/DocuElevate/commit/de8922bb8e1e837ffc1ff29cbbeb1a2cd10d8d20))
### Documentation
- Rewrite README.md and update UserGuide and Troubleshooting docs
([`fcf2452`](https://github.com/christianlouis/DocuElevate/commit/fcf2452b625109dbd7f7464929d5482b94db4a32))
### Features
- **i18n**: Localize admin plans page
([`36aef73`](https://github.com/christianlouis/DocuElevate/commit/36aef7396951ff2b91677f877415b8bce4fd1fc3))
## v0.143.1 (2026-03-15)
### Bug Fixes
- **auth**: Expo Go support via Linking.createURL; safe token URL construction; clean up return type
annotation
([`1428556`](https://github.com/christianlouis/DocuElevate/commit/14285567d5bbf30200bd56ca30c75c906d09a376))
- **auth**: Store mobile_redirect_uri in session and redirect to deep-link after SSO; fix
SafeAreaView deprecation
([`5c373e7`](https://github.com/christianlouis/DocuElevate/commit/5c373e72243ab18a4bb0f25bb23379ef6ccfa6fc))
## v0.143.0 (2026-03-15)
### Features
- **i18n**: Localize admin users page
([`3fc0d52`](https://github.com/christianlouis/DocuElevate/commit/3fc0d5212675a3a54ee32ecaede8a0494f309c40))
## v0.142.0 (2026-03-15)
### Features
- **i18n**: Localize terms and conditions page
([`8c18f0d`](https://github.com/christianlouis/DocuElevate/commit/8c18f0da1718c53ab6ba5bf70ad2679cc287f0d1))
## v0.141.2 (2026-03-15)
### Bug Fixes
- **mobile**: Align React version with react-native-renderer to fix version mismatch crash
([`0736278`](https://github.com/christianlouis/DocuElevate/commit/073627884ca08ddb1b7838ce95815fc20f6ca5a0))
### Chores
- Initial plan placeholder
([`c5b08e7`](https://github.com/christianlouis/DocuElevate/commit/c5b08e778d99d9a5a677f38a3ff5ba60cc657981))
## v0.141.1 (2026-03-15)
### Bug Fixes
- **mobile**: Correct asset logo path depth and add expo-router app/ routes for web support
([`82d90b4`](https://github.com/christianlouis/DocuElevate/commit/82d90b48e24320734f6b602044d2845efbbead05))
- **mobile**: Update Node.js minimum requirement to 20.19.4
([`b4b50e8`](https://github.com/christianlouis/DocuElevate/commit/b4b50e834088045510b3c235464862789d31c646))
## v0.141.0 (2026-03-15)
### Features
- **ui**: Localize status page with i18n translations
([`e46dadb`](https://github.com/christianlouis/DocuElevate/commit/e46dadbf45bc91f1c14f544538bf143a438b1c0a))
## v0.140.0 (2026-03-15)
### Features
- **i18n**: Localize attribution page
([`246ef70`](https://github.com/christianlouis/DocuElevate/commit/246ef702453fdfbca240b2bf0d1aa410abd9f179))
## v0.139.0 (2026-03-15)
### Features
- **ui**: Localize license page with i18n translation keys
([`9c73ab8`](https://github.com/christianlouis/DocuElevate/commit/9c73ab8e5963ae061c5ca3b494efb5620d4c8468))
## v0.138.0 (2026-03-15)
### Features
- **i18n**: Localize cookies policy page
([`182868c`](https://github.com/christianlouis/DocuElevate/commit/182868c0d71f7accfd801c7c41864c639aca5149))
## v0.137.1 (2026-03-15)
### Bug Fixes
- **mobile**: Add missing react@19.2.4 integrity hash to package-lock.json
([`ca962f8`](https://github.com/christianlouis/DocuElevate/commit/ca962f8040c4f6fb5f7e20cf4bf04f4c4532c100))
- **mobile**: Fix iOS TurboModule crash and npm install peer dep conflict
([`f983b20`](https://github.com/christianlouis/DocuElevate/commit/f983b20a8f0424353cbda5e876428fe97459e303))
### Chores
- Initial plan for npm install lockfile fix
([`0181143`](https://github.com/christianlouis/DocuElevate/commit/01811438dcc46b5b335dc6054cbde6b229ccd332))
## v0.137.0 (2026-03-15)
### Chores
- **mobile**: Update iOS build image to Xcode 26 for iOS 26 SDK compliance
([`69664ec`](https://github.com/christianlouis/DocuElevate/commit/69664ec6cdb0bcfc3742fa9d75eed2125ed9ca12))
### Documentation
- **changelog**: Update changelog [skip ci]
([`8a1dc2a`](https://github.com/christianlouis/DocuElevate/commit/8a1dc2aa0e6b03408501ee8964ba60f9385c552b))
- **changelog**: Update changelog [skip ci]
([`02c3992`](https://github.com/christianlouis/DocuElevate/commit/02c399239c8260cb0ba217128bb2c62271a847f7))
### Features
- **ui**: Localize imprint page
([`1f6a3a5`](https://github.com/christianlouis/DocuElevate/commit/1f6a3a58037f24fc838b2d4471afa01c68f4178d))
## Unreleased
### Chores
- **mobile**: Update iOS build image to Xcode 26 for iOS 26 SDK compliance
([`69664ec`](https://github.com/christianlouis/DocuElevate/commit/69664ec6cdb0bcfc3742fa9d75eed2125ed9ca12))
### Documentation
- **changelog**: Update changelog [skip ci]
([`02c3992`](https://github.com/christianlouis/DocuElevate/commit/02c399239c8260cb0ba217128bb2c62271a847f7))
## Unreleased
## v0.136.1 (2026-03-15)
### Bug Fixes
- **mobile**: Resolve iOS build errors by enabling buildReactNativeFromSource for Expo SDK 54
([`3372a93`](https://github.com/christianlouis/DocuElevate/commit/3372a93f71f76d0dacb9c41458240b4146e33d9f))
### Code Style
- Apply ruff auto-fix
([`b7a195d`](https://github.com/christianlouis/DocuElevate/commit/b7a195d3bced215bf4d197c867883c2277940b56))
## v0.136.0 (2026-03-14)
### Features
- **mobile**: Update DocuElevate mobile app branding with original logo and UX
([`971e578`](https://github.com/christianlouis/DocuElevate/commit/971e578dd7f356d5a7cf2d2dfbb8c7a168f334d0))
## v0.135.4 (2026-03-14)
### Bug Fixes
- **mobile**: Fix expo doctor dependency version mismatches for SDK 54
([`f6e955a`](https://github.com/christianlouis/DocuElevate/commit/f6e955acce84366cab4138634dbc8b258bb10f3d))
## v0.135.3 (2026-03-14)
### Bug Fixes
- **mobile**: Update iOS build image from macos-sequoia-15.1-xcode-16.2 to
macos-sequoia-15.3-xcode-16.2
([`fe66e51`](https://github.com/christianlouis/DocuElevate/commit/fe66e519966c983cee9b8867cc33261459a085df))
## v0.135.2 (2026-03-14)
### Bug Fixes
- **mobile**: Add missing assets and EAS workflow for automatic builds
([`1aa02ae`](https://github.com/christianlouis/DocuElevate/commit/1aa02aea24fbfdc40cc5cf92a5907616f4d9c706))
- **mobile**: Sync app.json and eas.json to expo.dev project credentials
([`5f3d134`](https://github.com/christianlouis/DocuElevate/commit/5f3d134d7ef42380b6f99fa70f8fdf71bed9391b))
- **mobile**: Sync app.json slug and owner to expo.dev project
([`7633cdf`](https://github.com/christianlouis/DocuElevate/commit/7633cdf4e49277612a830ef9c6aaf9c5ed783648))
- **mobile**: Use org.docuelevate.mobile as app identifier
([`4f38075`](https://github.com/christianlouis/DocuElevate/commit/4f38075de4b6647b41f776517ff6499fb616b6e6))
## v0.135.1 (2026-03-14)
### Bug Fixes
- **mobile**: Resolve EAS CI build failures wrap app.json, update deps, pin Xcode image
([`3bf9589`](https://github.com/christianlouis/DocuElevate/commit/3bf9589462ffb2cff80ef3d73b38be879869f157))
## v0.135.0 (2026-03-14)
### Features
- **ui**: Add i18n support to privacy page
([`e1941ad`](https://github.com/christianlouis/DocuElevate/commit/e1941adfc9aa59301de9f3114a7c2370c26b547b))
## v0.134.5 (2026-03-14)
### Bug Fixes
- **mobile**: Update react to 19.2.4 and add package-lock.json to fix npm ERESOLVE build failure
([`8653247`](https://github.com/christianlouis/DocuElevate/commit/8653247e442b64d1d8456f716d7baa2b3f61d73a))
### Chores
- Initial plan for mobile npm peer dep fix
([`0cb2f6c`](https://github.com/christianlouis/DocuElevate/commit/0cb2f6c36263616b68f8c7799fd4c967fb1cf3bd))
- **i18n**: Auto-update translations via LLM
([`c664220`](https://github.com/christianlouis/DocuElevate/commit/c6642203c095c59329d6ce460c31a0bdb53dc948))
- **i18n**: Auto-update translations via LLM
([`31218c8`](https://github.com/christianlouis/DocuElevate/commit/31218c886021aced8f74881e6015d7d660cc99c2))
### Documentation
- **changelog**: Update changelog [skip ci]
([`ef4c513`](https://github.com/christianlouis/DocuElevate/commit/ef4c513908eee9fbc40f8c3bcae8cbfc072415bc))
## Unreleased
### Chores
- **i18n**: Auto-update translations via LLM
([`c664220`](https://github.com/christianlouis/DocuElevate/commit/c6642203c095c59329d6ce460c31a0bdb53dc948))
- **i18n**: Auto-update translations via LLM
([`31218c8`](https://github.com/christianlouis/DocuElevate/commit/31218c886021aced8f74881e6015d7d660cc99c2))
## v0.134.4 (2026-03-14)
### Bug Fixes
- **mobile**: Bump Node to 20.19.4 and fix react-navigation peer dependency
([`4c6596b`](https://github.com/christianlouis/DocuElevate/commit/4c6596b56192341f2be4bb14a8a9ace1182bd38e))
## v0.134.3 (2026-03-14)
### Bug Fixes
- **mobile**: Add expo-dev-client, pin Node version, and fix EAS build config
([`6834f3e`](https://github.com/christianlouis/DocuElevate/commit/6834f3ef1b3bce099e4f8f9f76cee94a2a635f64))
- **mobile**: Add owner field to app.json to match EAS project owner
([`492b2c6`](https://github.com/christianlouis/DocuElevate/commit/492b2c643c42686c950f871bd284a4e702af52d5))
## v0.134.2 (2026-03-14)
### Bug Fixes
- **mobile**: Upgrade Expo SDK from 51 to 54 and fix EAS project ID
([`f148785`](https://github.com/christianlouis/DocuElevate/commit/f148785079b220530ff4ba8a41ca4258727c9f2d))
## v0.134.1 (2026-03-14)
### Bug Fixes
- **mobile**: Relax Node.js engine constraint to >=18.0.0
([`fb5d494`](https://github.com/christianlouis/DocuElevate/commit/fb5d494e42e1d4caf8b5d62480e38a168118de21))
- **mobile**: Remove expo-sharing from plugins and align .nvmrc with Node 18 LTS
([`8f9405b`](https://github.com/christianlouis/DocuElevate/commit/8f9405b5ac33df9a0f211061038814aac3d57286))
## v0.134.0 (2026-03-14)
### Bug Fixes
- **i18n**: Replace fragment translation keys with placeholder-based complete sentences
([`eaa5337`](https://github.com/christianlouis/DocuElevate/commit/eaa533760dbb8e143fadfece808d1a229e29940b))
- **ui**: Restore missing template x-if tag and fix Nextcloud URL label key
([`08853e8`](https://github.com/christianlouis/DocuElevate/commit/08853e823c949d9b220d02004a9ac1196b15bb23))
### Features
- **ui**: Internationalize profile, subscription, integrations templates and user menu JS
([`5a02557`](https://github.com/christianlouis/DocuElevate/commit/5a025576c42107ac01caa3d8fcf9ffe5b35223f8))
## v0.133.7 (2026-03-14)
### Bug Fixes
- **mobile**: Fix EAS build Node.js version incompatibility for iOS and Android
([`8717538`](https://github.com/christianlouis/DocuElevate/commit/8717538967a8d8b141984abf8c070e19728f07f8))
## v0.133.6 (2026-03-14)
### Bug Fixes
- **database**: Quote identifiers in index management queries to prevent SQL injection
([`120002b`](https://github.com/christianlouis/DocuElevate/commit/120002b39408fb610d06c7555abdd40ab40c2ae4))
### Code Style
- Apply ruff auto-fix
([`0b064b9`](https://github.com/christianlouis/DocuElevate/commit/0b064b9d20f600b0a6ca140d590bc93cf6148491))
- Use Annotated pattern in audit_logs.py to resolve Ruff B008
([`f96fcbe`](https://github.com/christianlouis/DocuElevate/commit/f96fcbe6c9d1b306e1b40c0485da8c4c97062f9b))
### Testing
- Add error path tests for API token revocation
([`fdf5053`](https://github.com/christianlouis/DocuElevate/commit/fdf5053acedae711fc8b1f874e6057141fd4cd6d))
- Clarify caller cleanup responsibility in _make_unauthenticated_client docstring
([`751b16d`](https://github.com/christianlouis/DocuElevate/commit/751b16d804b0659514ac682b6b5d6c66a4e83e94))
- Exercise real auth path in test_revoke_token_unauthenticated
([`96ef7d4`](https://github.com/christianlouis/DocuElevate/commit/96ef7d4769fc5cdfbd552cd49a13778f8717a4d9))
- Strengthen test_revoke_token_database_error to verify rollback is called
([`9e9b1fb`](https://github.com/christianlouis/DocuElevate/commit/9e9b1fb158cf906de47b61997436104d924b45d6))
- **database**: Add unit test for quoted identifier in filehash index drop
([`33eded0`](https://github.com/christianlouis/DocuElevate/commit/33eded02b1a4a603136a41b46dcd3ec6a5356489))
## v0.133.5 (2026-03-14)
### Bug Fixes
- **ui**: Add 22 missing help page translation keys to en.json
([`48a9303`](https://github.com/christianlouis/DocuElevate/commit/48a93036ca94cf910eb854c210382884911502c2))
## v0.133.4 (2026-03-14)
### Bug Fixes
- **i18n**: Add noqa comment to intentionally broad exception in hydration helper
([`f41ecc9`](https://github.com/christianlouis/DocuElevate/commit/f41ecc9b2c234062d5d5565380c3c409011e3883))
- **i18n**: Persist language preference server-side, fix dropdown rendering and avatar 404
([`1416d90`](https://github.com/christianlouis/DocuElevate/commit/1416d901d0615adf0c77551d1678207ae3308b3a))
### Chores
- **i18n**: Auto-update translations via LLM
([`d09227b`](https://github.com/christianlouis/DocuElevate/commit/d09227b61cad3fc77396b86834da9849a222e8a0))
### Documentation
- **changelog**: Update changelog [skip ci]
([`01739e8`](https://github.com/christianlouis/DocuElevate/commit/01739e8c228f1bd1278cb472998eb7c05e851a51))
## Unreleased
### Chores
- **i18n**: Auto-update translations via LLM
([`d09227b`](https://github.com/christianlouis/DocuElevate/commit/d09227b61cad3fc77396b86834da9849a222e8a0))
## v0.133.3 (2026-03-14)
### Bug Fixes
- **i18n**: Add missing index page translation keys to en.json
([`1f24b1b`](https://github.com/christianlouis/DocuElevate/commit/1f24b1b95be512c0f9a09b54001ae5bf71b47e37))
## v0.133.2 (2026-03-14)
### Bug Fixes
- **ui**: Add missing suggested_languages global to error templates
([`0592c59`](https://github.com/christianlouis/DocuElevate/commit/0592c59174bfb2cbb2b96f62297963c9a6c1bb60))
### Code Style
- Apply ruff auto-fix
([`de0b674`](https://github.com/christianlouis/DocuElevate/commit/de0b674025943dcc6aa21edc9e8027512964a605))
## v0.133.1 (2026-03-14)
### Bug Fixes
- **test**: Remove tests that check specific translation content
([`08fabe1`](https://github.com/christianlouis/DocuElevate/commit/08fabe154a35e9f8def21f86178b3619e021a595))
### Chores
- **i18n**: Auto-update translations via LLM
([`0cb6017`](https://github.com/christianlouis/DocuElevate/commit/0cb6017b50c7e2bb06a39a41a17d504b68bcb09f))
### Documentation
- **changelog**: Update changelog [skip ci]
([`971d450`](https://github.com/christianlouis/DocuElevate/commit/971d4501282790c73fa43823039d5d4cea0111f4))
## Unreleased
### Chores
- **i18n**: Auto-update translations via LLM
([`0cb6017`](https://github.com/christianlouis/DocuElevate/commit/0cb6017b50c7e2bb06a39a41a17d504b68bcb09f))
## v0.133.0 (2026-03-14)
### Features
- **ui**: Smart compact language selector with search and flags
([`3557590`](https://github.com/christianlouis/DocuElevate/commit/3557590679f17ad88fe7cf91b5665fd669ba0447))
## v0.132.1 (2026-03-14)
### Bug Fixes
- **security**: Prevent potential SQL injection in database migration
([`82b1f89`](https://github.com/christianlouis/DocuElevate/commit/82b1f89332d7bb3e7d8a16651b17a060e8ae4d0f))
### Chores
- **i18n**: Auto-update translations via LLM
([`8e86518`](https://github.com/christianlouis/DocuElevate/commit/8e865180a5e751566ea3312bef916efc9dc7cdb1))
- **i18n**: Auto-update translations via LLM
([`9a480e3`](https://github.com/christianlouis/DocuElevate/commit/9a480e3d68e2e162c73cb526b2887dbcae0cedac))
### Documentation
- **changelog**: Update changelog [skip ci]
([`4a46f37`](https://github.com/christianlouis/DocuElevate/commit/4a46f372b97f89a3c0059ce8c451424e9c121c7a))
### Testing
- **i18n**: Remove translation content assertion from test_translate_with_kwargs_german
([`9b32cbc`](https://github.com/christianlouis/DocuElevate/commit/9b32cbc91b26f83cd451d0c5d369b0692dd7edf7))
## Unreleased
### Chores
- **i18n**: Auto-update translations via LLM
([`8e86518`](https://github.com/christianlouis/DocuElevate/commit/8e865180a5e751566ea3312bef916efc9dc7cdb1))
- **i18n**: Auto-update translations via LLM
([`9a480e3`](https://github.com/christianlouis/DocuElevate/commit/9a480e3d68e2e162c73cb526b2887dbcae0cedac))
## v0.132.0 (2026-03-13)
### Features
- **i18n**: Update language support to 77 languages
([`59d8512`](https://github.com/christianlouis/DocuElevate/commit/59d851272dccd0213c4b718a9a07e98d9fed1be8))
## v0.131.0 (2026-03-13)
### Documentation
- **changelog**: Update changelog [skip ci]
([`bd35cdd`](https://github.com/christianlouis/DocuElevate/commit/bd35cdd268b796cefa257e395d9ef3039c2fc63f))
- **changelog**: Update changelog [skip ci]
([`bb71f0a`](https://github.com/christianlouis/DocuElevate/commit/bb71f0aab8d4375c02553eb93d8c5c6cb71cde09))
- **i18n**: Add comment on Welsh subdivision flag Unicode representation
([`ce4e797`](https://github.com/christianlouis/DocuElevate/commit/ce4e797bd10f0cde5268a2a335510c5bd08b41e3))
### Features
- **i18n**: Expand supported languages from 31 to 49 with flags and locale formats
([`87d8562`](https://github.com/christianlouis/DocuElevate/commit/87d85624be78b2c97faec27745e7c896480a4438))
## Unreleased
### Documentation
- **changelog**: Update changelog [skip ci]
([`bb71f0a`](https://github.com/christianlouis/DocuElevate/commit/bb71f0aab8d4375c02553eb93d8c5c6cb71cde09))
## Unreleased
## v0.130.0 (2026-03-13)
### Features
- **i18n**: Add complete Catalan (ca) translation
([`217217d`](https://github.com/christianlouis/DocuElevate/commit/217217dd01eaa29eeb2510f953438240fb8fc235))
## v0.129.0 (2026-03-13)
### Bug Fixes
+1 -1
View File
@@ -1 +1 @@
3c7f088
237af31
+203 -97
View File
@@ -24,121 +24,154 @@
</div>
<div align="center">
<a href="https://www.docuelevate.org"><img src="frontend/static/hero.png" alt="DocuElevate Logo" width="80%" /></a>
<a href="https://www.docuelevate.org"><img src="frontend/static/hero.png" alt="DocuElevate Hero" width="80%" /></a>
</div>
## Overview
DocuElevate automates the handling, extraction, and processing of documents using a variety of services, including:
DocuElevate is an intelligent document processing system that automates the ingestion, OCR, AI-powered metadata extraction, and distribution of documents. It supports a wide range of AI providers, OCR engines, and cloud storage destinations out of the box.
- **AI Provider** (pluggable OpenAI, Anthropic, Gemini, Ollama, OpenRouter, Portkey, and more) for metadata extraction and text refinement.
- **Dropbox**, **Nextcloud**, and **Google Drive** for file storage and uploads.
- **Paperless NGX** for document indexing and management.
- **Azure Document Intelligence** for OCR on PDFs.
- **Gotenberg** for file-to-PDF conversions.
- **Authentik** for authentication and user management.
**Key capabilities:**
It is designed for flexibility and configurability through environment variables, making it easily customizable for different workflows. The system can fetch documents from multiple IMAP mailboxes, process them (OCR, metadata extraction, PDF conversion), and store them in the desired destinations.
- **AI-Powered Metadata Extraction** — pluggable AI providers including OpenAI, Anthropic Claude, Google Gemini, Ollama (local), OpenRouter, Portkey, and Azure OpenAI via LiteLLM
- **Multi-Engine OCR** — Azure Document Intelligence, Tesseract, EasyOCR, Mistral OCR, Google Cloud Document AI, and AWS Textract with configurable merge strategies
- **12 Storage Destinations** — Dropbox, Google Drive, OneDrive, Amazon S3, Nextcloud, WebDAV, FTP, SFTP, iCloud Drive, Email (SMTP), Paperless-ngx, and Rclone
- **Multi-Channel Ingestion** — web upload, browser extension, mobile app, CLI, REST API, IMAP email, and watched folders (local, cloud, FTP/SFTP)
- **Processing Pipelines** — customizable multi-step workflows with conditional routing rules
- **Full-Text Search** — powered by Meilisearch for instant document discovery
- **Multi-User with SSO** — local accounts, OAuth2/OIDC (Authentik), and social login (Google, Microsoft, Apple, Dropbox)
The project includes a **UI** for uploading and managing files, and an API documentation page is available at `/docs` (powered by **FastAPI**).
## Documentation Index
- [User Guide](docs/UserGuide.md) - How to use DocuElevate
- [Browser Extension Guide](docs/BrowserExtension.md) - Install and use the browser extension
- [API Documentation](docs/API.md) - API reference
- [Deployment Guide](docs/DeploymentGuide.md) - How to deploy DocuElevate
- [Configuration Guide](docs/ConfigurationGuide.md) - Available configuration options
- [Build Metadata](docs/BuildMetadata.md) - Automated version and build information
- [CI/CD Tools Guide](docs/CIToolsGuide.md) - CI/CD pipeline and tool documentation
- [CI Workflow Guide](docs/CIWorkflow.md) - Detailed workflow documentation
- [Development Guide](CONTRIBUTING.md) - How to contribute to DocuElevate
- [Troubleshooting](docs/Troubleshooting.md) - Common issues and solutions
The project ships with a web UI, a REST + GraphQL API, a CLI tool, a native mobile app (iOS & Android), a browser extension, and Helm charts for Kubernetes deployment.
## Screenshots
<div align="center">
<img src="docs/upload-view.png" alt="DocuElevate Upload Interface" width="80%" />
<p><em>Upload interface for adding new documents</em></p>
<p><em>Upload interface — drag-and-drop file upload with real-time progress</em></p>
<img src="docs/files-view.png" alt="DocuElevate Files View" width="80%" />
<p><em>Files view with processed documents and metadata</em></p>
<p><em>Files view processed documents with AI-extracted metadata</em></p>
<img src="docs/status-view.png" alt="DocuElevate Status View" width="80%" />
<p><em>Status view — system health and service monitoring</em></p>
</div>
> **Note:** Screenshots may not reflect the very latest UI. For the most current look, visit [docuelevate.org](https://www.docuelevate.org).
## Workflow Process
DocuElevate follows a streamlined document processing workflow:
## Workflow
<div align="center">
<img src="docs/workflow-diagram.png" alt="DocuElevate Workflow" width="90%" />
</div>
### Document Ingestion
Documents enter DocuElevate through four possible channels:
1. **Web Upload**: Users manually upload files via the web interface
2. **Browser Extension**: Send files directly from your browser with one click
3. **Email Attachments**: Automatic polling of configured IMAP mailboxes (supports multiple accounts)
4. **API**: Direct programmatic uploads via the REST API
### Ingestion
Documents enter DocuElevate through multiple channels:
| Channel | Description |
|---------|-------------|
| **Web Upload** | Drag-and-drop interface with real-time progress (up to 1 GB per file) |
| **Browser Extension** | Clip web pages or send files from Chrome, Firefox, or Edge |
| **Mobile App** | Capture documents with the device camera or upload from the photo library |
| **CLI** | Batch uploads and scripted workflows via the `docuelevate` command-line tool |
| **REST API** | Programmatic uploads with full API-token authentication |
| **Email (IMAP)** | Automatic polling of multiple mailboxes with attachment filtering |
| **Watched Folders** | Monitor local paths, FTP, SFTP, S3, Dropbox, Google Drive, OneDrive, Nextcloud, or WebDAV for new files |
### Processing Pipeline
Every document goes through the following steps:
1. **PDF Conversion**: Non-PDF files are converted to PDF format using Gotenberg
2. **OCR Processing**: Azure Document Intelligence extracts text from images/scans
3. **Metadata Extraction**: The configured AI provider analyzes document content to identify:
- Document type (invoice, receipt, contract, etc.)
- Key entities (dates, names, amounts, account numbers)
- Important data points specific to the document type
4. **Enrichment**: Metadata is attached to the document in a structured format
Each document passes through a configurable set of steps:
1. **PDF Conversion** — Non-PDF files are converted using Gotenberg, with optional PDF/A archival conversion
2. **OCR** — Text extraction via one or more OCR engines (Azure, Tesseract, EasyOCR, Mistral, Google Document AI, AWS Textract) with configurable merge strategies
3. **AI Metadata Extraction** — The configured AI provider classifies the document and extracts structured metadata (type, dates, amounts, entities)
4. **Enrichment** — Metadata is embedded into the PDF and stored alongside the document
5. **Embedding Generation** — Vector embeddings for similarity search and duplicate detection
Steps can be customized using **Pipelines** and **Routing Rules** for conditional processing.
### Distribution
Processed documents with their metadata can be automatically sent to:
- **Dropbox**: For cloud storage and sharing
- **Nextcloud**: For self-hosted file storage
- **Google Drive**: For Google Workspace integration
- **Paperless-NGX**: For advanced document management with search capabilities
Users can choose to send documents to any combination of these destinations through configuration settings or manual selection.
Processed documents are distributed to any combination of configured destinations:
| Destination | Type |
|------------|------|
| **Dropbox** | Cloud storage |
| **Google Drive** | Cloud storage |
| **OneDrive** | Cloud storage |
| **Amazon S3** | Object storage |
| **Nextcloud** | Self-hosted cloud |
| **WebDAV** | Protocol-based |
| **FTP / SFTP** | File transfer |
| **iCloud Drive** | Apple cloud |
| **Email (SMTP)** | Send as attachment |
| **Paperless-ngx** | Document management system |
| **Rclone** | 70+ cloud providers via Rclone |
## Features
- **Intuitive File Upload**:
- Drag-and-drop file upload on both Upload and Files pages—upload anywhere on the Files page
- Real-time upload progress with validation
- Support for PDF, Office documents, images, and more (up to 500MB per file)
- **Browser Extension**:
- Send files directly from your browser to DocuElevate with one click
- Compatible with Chrome, Firefox, Edge, and other Chromium-based browsers
- Context menu integration for quick access
- See [Browser Extension Guide](docs/BrowserExtension.md) for installation and usage
- **Document Upload & Storage**:
- Manual uploads (via API or UI) to Dropbox, Nextcloud, Google Drive, or Paperless
- **OCR Processing (Azure)**:
- Extract text from scanned PDFs using Azure Document Intelligence
- **Metadata Extraction (AI Provider)**:
- Use any supported AI provider (OpenAI, Anthropic, Gemini, Ollama, etc.) to classify, label, or otherwise enrich the text with structured metadata
- **PDF Conversion (Gotenberg)**:
- Convert non-PDF attachments (e.g., Word docs, images) into PDFs
- **Document Management (Paperless NGX)**:
- Store processed documents and metadata in a Paperless NGX instance
- **IMAP Integration**:
- Fetch documents from multiple mailboxes (including Gmail) and automatically enqueue them for processing
- **Authentication**:
- Secure access to the system using **Authentik** for OAuth2-based login
### Document Processing
- **Multi-engine OCR** with quality checks and configurable merge strategies (AI merge, longest, primary)
- **AI metadata extraction** using any supported provider (OpenAI, Anthropic, Gemini, Ollama, OpenRouter, Portkey, Azure OpenAI)
- **PDF conversion** via Gotenberg with optional PDF/A archival format
- **Duplicate detection** — exact (SHA-256) and near-duplicate (content similarity with vector embeddings)
- **Customizable pipelines** — define multi-step processing workflows with conditional routing rules
## Frameworks Used
### Document Management
- **Full-text search** powered by Meilisearch with saved searches
- **File detail view** with metadata, text preview, processing history, and similarity analysis
- **Shared links** for public document access with expiration controls
- **Bulk operations** — reprocess, delete, or reassign documents in batch
- **FastAPI**: High-performance web framework for APIs.
- **Celery**: Task queue for asynchronous processing.
- **Redis**: Message broker and result backend.
- **SQLAlchemy**: ORM for database interactions.
- **Tailwind CSS**: Utility-first CSS framework.
- **Docker**: Containerization for easy deployment.
### Multi-Channel Ingestion
- **Web UI** — drag-and-drop upload with real-time progress
- **Browser extension** — clip web pages or send files from Chrome, Firefox, Edge ([guide](docs/BrowserExtension.md))
- **Mobile app** — iOS and Android with camera capture, push notifications, and SSO ([guide](docs/MobileApp.md))
- **CLI tool** — batch uploads, downloads, search, and API-token management ([guide](docs/CLIGuide.md))
- **REST API & GraphQL** — full programmatic access with Swagger documentation at `/docs`
- **IMAP email** — poll multiple mailboxes with attachment filtering and auto-processing
- **Watched folders** — local filesystem, FTP, SFTP, and cloud storage providers
### Administration
- **Multi-user mode** with per-user document isolation and ownership
- **Subscription & billing** — Stripe integration with configurable plans and quotas
- **Scheduled jobs** — IMAP polling, watched folder scans, automated backups, uptime monitoring
- **Audit logging** with SIEM integration support
- **Compliance templates** — GDPR, HIPAA, SOC 2
- **Admin dashboard** — user management, queue monitoring, credential management, backup/restore
### Authentication & Security
- **Local accounts** with self-service registration and password reset
- **OAuth2/OIDC** via Authentik or any OIDC provider
- **Social login** — Google, Microsoft, Apple, Dropbox
- **API tokens** for CLI, mobile, and automation access
- **Security headers** — HSTS, CSP, X-Frame-Options, X-Content-Type-Options
- **Rate limiting** with configurable per-endpoint controls
### Notifications
- **100+ notification backends** via Apprise — Discord, Telegram, Slack, Microsoft Teams, Email, webhooks, and more
- **Configurable events** — task failures, credential issues, file processed, user signup, payment issues
- **In-app notification inbox** with per-user preferences
- **Webhooks** — push events to external systems with HMAC signature verification and retry
## Tech Stack
| Component | Technology |
|-----------|-----------|
| **Backend** | FastAPI, Celery, Redis, SQLAlchemy, Alembic |
| **Frontend** | Jinja2, Tailwind CSS |
| **Search** | Meilisearch |
| **Mobile** | React Native (Expo) — iOS & Android |
| **AI** | LiteLLM (OpenAI, Anthropic, Gemini, Ollama, OpenRouter, Portkey) |
| **OCR** | Azure Document Intelligence, Tesseract, EasyOCR, Mistral, Google Doc AI, AWS Textract |
| **PDF** | Gotenberg, pypdf |
| **Auth** | Authlib (OAuth2/OIDC), MSAL, social providers |
| **Infrastructure** | Docker, Docker Compose, Helm/Kubernetes |
| **Docs** | MkDocs Material |
## Quick Start
For detailed installation and deployment instructions, please refer to the [Deployment Guide](docs/DeploymentGuide.md).
For detailed installation and deployment instructions, see the [Deployment Guide](docs/DeploymentGuide.md).
```bash
# Clone the repository
@@ -147,20 +180,96 @@ cd DocuElevate
# Configure environment variables
cp .env.demo .env
# Edit .env with your settings
# Edit .env with your settings (see Configuration Guide for all options)
# Run with Docker Compose
docker-compose up -d
docker compose up -d
```
The API will be available at **`http://localhost:8000`**, and the API documentation is available at **`http://localhost:8000/docs`**.
The web UI is available at **`http://localhost:8000`** and the interactive API documentation at **`http://localhost:8000/docs`**.
### Kubernetes / Helm
```bash
helm repo add docuelevate https://christianlouis.github.io/DocuElevate
helm install docuelevate docuelevate/docuelevate -f values.yaml
```
See the [Kubernetes Deployment Guide](docs/KubernetesDeployment.md) for full details.
## Documentation
### Getting Started
| Guide | Description |
|-------|-------------|
| [Setup Wizard](docs/SetupWizard.md) | Interactive first-run setup |
| [User Guide](docs/UserGuide.md) | How to use DocuElevate |
| [Browser Extension](docs/BrowserExtension.md) | Install and use the browser extension |
| [Mobile App](docs/MobileApp.md) | iOS and Android mobile app |
| [CLI Guide](docs/CLIGuide.md) | Command-line tool for automation |
### How-To Guides
| Guide | Description |
|-------|-------------|
| [How-To Overview](docs/HowToGuides.md) | Index of all how-to guides |
| [Email Ingestion](docs/howto/EmailIngestion.md) | Set up IMAP email polling |
| [Watched Folder](docs/howto/WatchedFolderSetup.md) | Monitor local or remote folders |
| [Mobile Scanning](docs/howto/MobileScanning.md) | Scan documents with your phone |
### Reference
| Guide | Description |
|-------|-------------|
| [API Documentation](docs/API.md) | REST & GraphQL API reference |
| [Configuration Guide](docs/ConfigurationGuide.md) | All environment variables |
| [Configuration Master](docs/ConfigurationMaster.md) | Configuration overview |
| [Settings Management](docs/SettingsManagement.md) | Runtime settings UI |
### Deployment & Operations
| Guide | Description |
|-------|-------------|
| [Deployment Guide](docs/DeploymentGuide.md) | Docker Compose deployment |
| [Kubernetes / Helm](docs/KubernetesDeployment.md) | Kubernetes deployment with Helm charts |
| [Production Readiness](docs/ProductionReadiness.md) | Checklist for production environments |
| [Database Configuration](docs/DatabaseConfiguration.md) | Database setup and migration |
| [Backup & Restore](docs/ConfigurationGuide.md#backup--restore) | Automated backup configuration |
### Storage Integration Setup
| Guide | Description |
|-------|-------------|
| [Dropbox](docs/DropboxSetup.md) | Dropbox OAuth setup |
| [Google Drive](docs/GoogleDriveSetup.md) | Google Drive service account / OAuth |
| [OneDrive](docs/OneDriveSetup.md) | Microsoft OneDrive setup |
| [Amazon S3](docs/AmazonS3Setup.md) | S3 bucket configuration |
| [Authentication](docs/AuthenticationSetup.md) | OAuth2, OIDC, and social login |
| [Notifications](docs/NotificationsSetup.md) | Notification backend setup |
### Security & Compliance
| Guide | Description |
|-------|-------------|
| [Credential Rotation](docs/CredentialRotationGuide.md) | Rotate secrets safely |
| [Licensing Compliance](docs/LicensingCompliance.md) | Dependency licenses |
| [Privacy & GDPR](docs/PrivacyCompliance.md) | Privacy compliance |
### Development
| Guide | Description |
|-------|-------------|
| [Contributing](CONTRIBUTING.md) | Code style, commits, and PR process |
| [Troubleshooting](docs/Troubleshooting.md) | Common issues and solutions |
| [Configuration Troubleshooting](docs/ConfigurationTroubleshooting.md) | Configuration-specific issues |
| [Build Metadata](docs/BuildMetadata.md) | Version and build information |
| [Internationalization](docs/InternationalizationGuide.md) | Translation and localization |
## Development & Testing
### Running Tests
DocuElevate includes comprehensive test coverage. To run tests:
```bash
# Install development dependencies
pip install -r requirements-dev.txt
@@ -175,21 +284,21 @@ pytest --cov=app --cov-report=term-missing
pytest -m unit
```
Tests are automatically configured with the necessary environment variables - **no manual setup required!**
Tests are automatically configured with the necessary environment variables **no manual setup required!**
For detailed testing information, including integration tests with Docker and authentication testing, see the [Contributing Guide](CONTRIBUTING.md#running-tests).
For detailed testing information, see the [Contributing Guide](CONTRIBUTING.md#running-tests).
### Contributing
We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for:
- Code style guidelines
- Code style guidelines (Ruff for formatting and linting)
- Commit message format (Conventional Commits)
- Testing requirements
- Pull request process
## License
This project is licensed under the Apache License 2.0 - see the [LICENSE](LICENSE) file for details.
This project is licensed under the Apache License 2.0 see the [LICENSE](LICENSE) file for details.
## Third-Party Software
@@ -214,13 +323,10 @@ The following is a summary of the licenses used by our direct dependencies:
| Uvicorn | BSD |
| SQLAlchemy | MIT |
| Pydantic | MIT |
| openai | MIT |
| litellm | MIT |
| pypdf | BSD |
| Requests | Apache 2.0 |
| puremagic | MIT |
| filetype | MIT |
| Dropbox | MIT |
| Dropbox SDK | MIT |
| Azure AI Document Intelligence | MIT |
| Authlib | BSD |
| Starlette | BSD |
@@ -229,15 +335,15 @@ The following is a summary of the licenses used by our direct dependencies:
| Microsoft Graph Core | MIT |
| MSAL | MIT |
| Boto3 | Apache 2.0 |
| Paramiko | LGPL-2.1|
| Paramiko | LGPL-2.1 |
| Apprise | MIT |
| Redis | BSD |
| Gotenberg | MIT |
| Redis (py) | BSD |
| Gotenberg Client | MIT |
| Meilisearch | MIT |
For a comprehensive list of all dependencies and their licenses, run:
```
```bash
pip install pip-licenses
pip-licenses
```
+6 -6
View File
@@ -1,10 +1,10 @@
DocuElevate Build Information
==============================
Version: 0.129.0
Build Date: 2026-03-13T08:33:59Z
Git Commit: 3c7f0888de98fa7cf4a89c4210768c4f138093ae
Git Short SHA: 3c7f088
Version: 0.145.2
Build Date: 2026-03-15T21:39:26Z
Git Commit: 237af31f5fe598cfc3c08f2bbba79b3d0925787e
Git Short SHA: 237af31
Git Branch: main
Commit Date: 2026-03-13T09:33:37+01:00
Build Timestamp: 2026-03-13T08:33:59Z
Commit Date: 2026-03-15T22:39:04+01:00
Build Timestamp: 2026-03-15T21:39:26Z
==============================
+1 -1
View File
@@ -1 +1 @@
0.129.0
0.145.2
+14 -12
View File
@@ -7,7 +7,7 @@ Events are append-only — there are no update or delete endpoints.
import logging
from datetime import datetime
from typing import Any
from typing import Annotated, Any
from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy.orm import Session
@@ -20,20 +20,22 @@ logger = logging.getLogger(__name__)
router = APIRouter()
DbSession = Annotated[Session, Depends(get_db)]
@router.get("/audit-logs")
@require_login
async def list_audit_logs(
request: Request,
db: Session = Depends(get_db),
action: str | None = Query(None, description="Filter by action (exact match)"),
user: str | None = Query(None, description="Filter by username"),
resource_type: str | None = Query(None, description="Filter by resource type"),
severity: str | None = Query(None, description="Filter by severity level"),
since: datetime | None = Query(None, description="Only events at or after this ISO-8601 timestamp"),
until: datetime | None = Query(None, description="Only events at or before this ISO-8601 timestamp"),
limit: int = Query(50, ge=1, le=500, description="Max rows to return"),
offset: int = Query(0, ge=0, description="Rows to skip for pagination"),
db: DbSession,
action: Annotated[str | None, Query(description="Filter by action (exact match)")] = None,
user: Annotated[str | None, Query(description="Filter by username")] = None,
resource_type: Annotated[str | None, Query(description="Filter by resource type")] = None,
severity: Annotated[str | None, Query(description="Filter by severity level")] = None,
since: Annotated[datetime | None, Query(description="Only events at or after this ISO-8601 timestamp")] = None,
until: Annotated[datetime | None, Query(description="Only events at or before this ISO-8601 timestamp")] = None,
limit: Annotated[int, Query(ge=1, le=500, description="Max rows to return")] = 50,
offset: Annotated[int, Query(ge=0, description="Rows to skip for pagination")] = 0,
) -> dict[str, Any]:
"""Return audit log entries with optional filtering and pagination.
@@ -71,7 +73,7 @@ async def list_audit_logs(
@require_login
async def list_distinct_actions(
request: Request,
db: Session = Depends(get_db),
db: DbSession,
) -> list[str]:
"""Return the distinct action values present in the audit log."""
from app.models import AuditLog
@@ -84,7 +86,7 @@ async def list_distinct_actions(
@require_login
async def list_distinct_users(
request: Request,
db: Session = Depends(get_db),
db: DbSession,
) -> list[str]:
"""Return the distinct user values present in the audit log."""
from app.models import AuditLog
+4 -9
View File
@@ -564,7 +564,6 @@ def _test_webdav_connection(config: dict[str, Any] | None, credentials: dict[str
return {"success": False, "message": "Missing required field: url"}
# Only allow http/https to prevent file:// or other custom scheme attacks
import ipaddress
from urllib.parse import urlparse
parsed = urlparse(url)
@@ -574,14 +573,10 @@ def _test_webdav_connection(config: dict[str, Any] | None, credentials: dict[str
# Block requests to private/internal IPs to prevent SSRF
hostname = parsed.hostname or ""
if hostname:
try:
addr = ipaddress.ip_address(hostname)
if addr.is_private or addr.is_loopback or addr.is_link_local:
return {"success": False, "message": "URLs pointing to internal or private networks are not allowed"}
except ValueError:
# Hostname is not an IP literal — allow DNS names through
if hostname in ("localhost", "localhost.localdomain"):
return {"success": False, "message": "URLs pointing to localhost are not allowed"}
from app.utils.network import is_private_ip
if is_private_ip(hostname):
return {"success": False, "message": "URLs pointing to internal or private networks are not allowed"}
try:
import base64
+22 -2
View File
@@ -18,7 +18,7 @@ import logging
from hashlib import md5
from typing import Annotated
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status
from fastapi import APIRouter, Depends, File, HTTPException, Request, Response, UploadFile, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
@@ -156,7 +156,9 @@ async def get_profile(request: Request, db: DbSession) -> ProfileResponse:
@router.patch("", response_model=ProfileResponse)
@require_login
async def update_profile(body: ProfileUpdateRequest, request: Request, db: DbSession) -> ProfileResponse:
async def update_profile(
body: ProfileUpdateRequest, request: Request, response: Response, db: DbSession
) -> ProfileResponse:
"""Update the current user's editable profile settings."""
user_id = _get_user_id(request)
profile = _get_or_create_profile(db, user_id)
@@ -171,6 +173,24 @@ async def update_profile(body: ProfileUpdateRequest, request: Request, db: DbSes
)
profile.preferred_language = lang or None # type: ignore[assignment]
# Keep session and cookie in sync so detect_language() picks up
# the new preference immediately (without a DB round-trip).
if hasattr(request, "session"):
if lang:
request.session["preferred_language"] = lang
else:
request.session.pop("preferred_language", None)
if lang:
response.set_cookie(
key="docuelevate_lang",
value=lang,
max_age=30 * 24 * 60 * 60,
httponly=False,
samesite="lax",
)
else:
response.delete_cookie(key="docuelevate_lang")
# Validate theme
if body.preferred_theme is not None:
theme = body.preferred_theme.lower().strip()
+1 -32
View File
@@ -2,7 +2,6 @@
API endpoint for processing files from URLs
"""
import ipaddress
import logging
import mimetypes
import os
@@ -19,6 +18,7 @@ from app.config import settings
from app.tasks.process_document import process_document
from app.utils.allowed_types import ALLOWED_MIME_TYPES
from app.utils.filename_utils import sanitize_filename
from app.utils.network import is_private_ip
# Set up logging
logger = logging.getLogger(__name__)
@@ -42,37 +42,6 @@ class URLUploadRequest(BaseModel):
return v
def is_private_ip(hostname: str) -> bool:
"""
Check if a hostname resolves to a private/internal IP address.
Protects against SSRF attacks by blocking access to internal networks.
"""
try:
# Try to parse as IP address directly
ip = ipaddress.ip_address(hostname)
return ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved
except ValueError:
# Not a direct IP, try to resolve hostname
try:
import socket
# Get all IP addresses for this hostname
addr_info = socket.getaddrinfo(hostname, None)
for info in addr_info:
ip_str = info[4][0]
ip = ipaddress.ip_address(ip_str)
# Block if ANY resolved IP is private/internal
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
return True
return False
except (socket.gaierror, socket.error):
# Cannot resolve - allow for testing/development
# In production, DNS should work properly
# Log this for debugging
logger.warning(f"Could not resolve hostname: {hostname}")
return False # Changed from True to False to allow external domains in tests
def validate_url_safety(url: str) -> None:
"""
Validate that URL is safe to fetch (SSRF protection).
+175 -4
View File
@@ -4,12 +4,13 @@ import logging
import pathlib
from datetime import datetime, timezone
from functools import wraps
from urllib.parse import urlparse
from urllib.parse import urlencode, urlparse
from authlib.integrations.starlette_client import OAuth
from fastapi import APIRouter, Depends, Request, status
from fastapi.responses import JSONResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy import func
from sqlalchemy.orm import Session
from starlette.responses import RedirectResponse
@@ -254,6 +255,38 @@ def get_gravatar_url(email):
async def login(request: Request):
"""Show login page with appropriate authentication options."""
# Persist the mobile deep-link redirect URI in the session so it survives
# the OAuth provider round-trip and is available when auth completes.
# Accepted schemes:
# • "docuelevate://" — production / EAS builds (custom app scheme)
# • "exp://" — Expo Go development client
# Only custom (non-HTTP) schemes are accepted to prevent open-redirect abuse.
_MOBILE_ALLOWED_SCHEMES = ("docuelevate://", "exp://")
if request.query_params.get("mobile") == "1":
redirect_uri = request.query_params.get("redirect_uri", "")
logger.debug(
"[MOBILE] Login page opened with mobile=1: redirect_uri=%r client_ip=%s",
redirect_uri,
get_client_ip(request),
)
if any(redirect_uri.startswith(s) for s in _MOBILE_ALLOWED_SCHEMES):
request.session["mobile_redirect_uri"] = redirect_uri
logger.info(
"[MOBILE] Mobile redirect URI stored in session: %r",
redirect_uri,
)
else:
logger.warning(
"[MOBILE] Rejected redirect_uri with disallowed scheme: %r (allowed: %s)",
redirect_uri,
", ".join(_MOBILE_ALLOWED_SCHEMES),
)
else:
logger.debug(
"[MOBILE] Login page opened without mobile=1 (standard browser flow) client_ip=%s",
get_client_ip(request),
)
return templates.TemplateResponse(
"login.html",
{
@@ -407,6 +440,12 @@ async def social_callback(request: Request, provider: str, db: Session = Depends
user_id = (
user_data.get("sub") or user_data.get("preferred_username") or user_data.get("email") or user_data.get("id")
)
# Mobile app flow: issue an inline API token and redirect back to the app.
mobile_resp = _create_mobile_redirect(request, db)
if mobile_resp:
return mobile_resp
if user_id:
profile = db.query(_UserProfile).filter(_UserProfile.user_id == user_id).first()
if profile and not profile.onboarding_completed:
@@ -568,6 +607,19 @@ async def oauth_callback(request: Request, db: Session = Depends(get_db)):
user_id = (
user_data.get("sub") or user_data.get("preferred_username") or user_data.get("email") or user_data.get("id")
)
# Mobile app flow: issue an inline API token and redirect back to the app.
# This check runs before onboarding so native-app users are never sent
# to the web-based onboarding wizard.
logger.debug(
"[MOBILE] oauth_callback: checking for mobile redirect (session has mobile_redirect_uri=%s)",
"mobile_redirect_uri" in request.session,
)
mobile_resp = _create_mobile_redirect(request, db)
if mobile_resp:
logger.info("[MOBILE] oauth_callback: returning mobile redirect response")
return mobile_resp
if user_id:
profile = db.query(_UserProfile).filter(_UserProfile.user_id == user_id).first()
if profile and not profile.onboarding_completed:
@@ -625,6 +677,98 @@ def _record_login_event(
logger.debug("Failed to write login audit event for user=%s", username, exc_info=True)
def _create_mobile_redirect(request: Request, db: Session) -> RedirectResponse | None:
"""Generate a mobile API token and return a redirect to the mobile app.
If ``mobile_redirect_uri`` is stored in the session (set when the login
page was opened with ``?mobile=1&redirect_uri=docuelevate://...``), this
function creates a long-lived API token, appends it as a ``?token=``
query parameter to the redirect URI, and returns the redirect so that
``WebBrowser.openAuthSessionAsync`` in the Expo app intercepts the
deep link and stores the token.
Returns ``None`` when the request is not part of a mobile SSO flow.
Args:
request: The current FastAPI request. The ``user`` dict must already
be stored in ``request.session`` before calling this function.
db: Active database session used to persist the new API token.
Returns:
A ``RedirectResponse`` to the deep-link URI with ``?token=<plaintext>``,
or ``None`` if no mobile redirect URI is pending.
"""
mobile_redirect_uri = request.session.pop("mobile_redirect_uri", None)
if not mobile_redirect_uri:
logger.debug("[MOBILE] _create_mobile_redirect: no mobile_redirect_uri in session — skipping mobile flow")
return None
logger.info(
"[MOBILE] _create_mobile_redirect: mobile flow detected, redirect_uri=%r",
mobile_redirect_uri,
)
user = request.session.get("user") or {}
owner_id = user.get("sub") or user.get("preferred_username") or user.get("email") or user.get("id")
logger.debug(
"[MOBILE] Resolving owner_id from session user: sub=%r preferred_username=%r email=%r id=%r → owner_id=%r",
user.get("sub"),
user.get("preferred_username"),
user.get("email"),
user.get("id"),
owner_id,
)
if not owner_id:
logger.warning("Mobile SSO redirect requested but no owner_id could be resolved from session")
return None
# Lazy imports to avoid circular dependency via app.api.__init__
from app.api.api_tokens import generate_api_token, hash_token
from app.models import ApiToken as _ApiToken
plaintext = generate_api_token()
token_hash_value = hash_token(plaintext)
prefix = plaintext[:12]
db_token = _ApiToken(
owner_id=owner_id,
name="Mobile App",
token_hash=token_hash_value,
token_prefix=prefix,
)
try:
db.add(db_token)
db.commit()
except Exception:
db.rollback()
logger.exception("Failed to create mobile API token for owner_id=%s", owner_id)
return None
# Safely append the token as a query parameter, preserving any existing params.
separator = "&" if "?" in mobile_redirect_uri else "?"
redirect_url = f"{mobile_redirect_uri}{separator}{urlencode({'token': plaintext})}"
# Log the full redirect URL at DEBUG so it's visible when debug logging is enabled.
# At INFO level, log a sanitised version (scheme + host only, token prefix only)
# so the plaintext token is never written to persistent info logs.
parsed = urlparse(redirect_url)
existing_params = f"&{parsed.query.replace(f'token={plaintext}', '')}" if parsed.query else ""
sanitised_url = (
f"{parsed.scheme}://{parsed.netloc}{parsed.path}?token={prefix}…[redacted]{existing_params.rstrip('&')}"
)
logger.info(
"[MOBILE] MOBILE_SSO_TOKEN_ISSUED owner=%s token_id=%s redirect_target=%s",
owner_id,
db_token.id,
sanitised_url,
)
logger.debug(
"[MOBILE] Full redirect URL being sent to client: %s",
redirect_url,
)
return RedirectResponse(url=redirect_url, status_code=status.HTTP_302_FOUND)
async def auth(request: Request, db: Session = Depends(get_db)):
"""Handle local username/password authentication.
@@ -659,8 +803,13 @@ async def auth(request: Request, db: Session = Depends(get_db)):
)
return RedirectResponse(url="/login?error=Invalid+username+or+password", status_code=302)
username_lower = username.lower()
local_user = (
db.query(_LocalUser).filter((_LocalUser.username == username) | (_LocalUser.email == username)).first()
db.query(_LocalUser)
.filter(
(func.lower(_LocalUser.username) == username_lower) | (func.lower(_LocalUser.email) == username_lower)
)
.first()
)
logger.debug(
"[AUTH] LocalUser lookup: username=%r found=%s",
@@ -694,6 +843,17 @@ async def auth(request: Request, db: Session = Depends(get_db)):
logger.info("[SECURITY] LOCAL_LOGIN_SUCCESS user=%s", local_user.email)
_record_login_event(db, request, local_user.email, success=True)
_ensure_user_profile(db, user_data, is_admin=bool(local_user.is_admin))
# Mobile app flow: issue an inline API token and redirect back to the app.
logger.debug(
"[MOBILE] local auth: checking for mobile redirect (session has mobile_redirect_uri=%s)",
"mobile_redirect_uri" in request.session,
)
mobile_resp = _create_mobile_redirect(request, db)
if mobile_resp:
logger.info("[MOBILE] local auth: returning mobile redirect response")
return mobile_resp
profile = db.query(_UserProfile).filter(_UserProfile.user_id == local_user.email).first()
if profile and not profile.onboarding_completed:
post_onboarding = request.session.pop("redirect_after_login", "/upload")
@@ -718,13 +878,13 @@ async def auth(request: Request, db: Session = Depends(get_db)):
logger.debug(
"[AUTH] Admin credential check: admin_configured=%s username_match=%s multi_user_enabled=%s",
admin_configured,
username == settings.admin_username if admin_configured else False,
(username or "").lower() == settings.admin_username.lower() if admin_configured else False,
settings.multi_user_enabled,
)
if (
settings.admin_username
and settings.admin_password
and username == settings.admin_username
and (username or "").lower() == settings.admin_username.lower()
and password == settings.admin_password
):
admin_user_data = {
@@ -739,6 +899,17 @@ async def auth(request: Request, db: Session = Depends(get_db)):
logger.info("[SECURITY] LOCAL_LOGIN_SUCCESS user=%s", username)
_record_login_event(db, request, username, success=True)
_ensure_user_profile(db, admin_user_data, is_admin=True)
# Mobile app flow: issue an inline API token and redirect back to the app.
logger.debug(
"[MOBILE] admin auth: checking for mobile redirect (session has mobile_redirect_uri=%s)",
"mobile_redirect_uri" in request.session,
)
mobile_resp = _create_mobile_redirect(request, db)
if mobile_resp:
logger.info("[MOBILE] admin auth: returning mobile redirect response")
return mobile_resp
redirect_url = request.session.pop("redirect_after_login", "/upload")
return RedirectResponse(url=redirect_url, status_code=302)
else:
+8 -2
View File
@@ -210,8 +210,10 @@ def _run_schema_migrations(engine: Any) -> None:
if unique_filehash_indexes:
logger.info("Migrating files: dropping unique index on 'filehash'")
with engine.begin() as conn:
preparer = conn.dialect.identifier_preparer
for index in unique_filehash_indexes:
conn.execute(text(f"DROP INDEX IF EXISTS {index['name']}"))
quoted_idx = preparer.quote(index["name"])
conn.execute(text(f"DROP INDEX IF EXISTS {quoted_idx}"))
logger.info("Migration complete: unique index on 'filehash' removed")
except Exception as exc:
logger.warning(f"Skipping filehash unique index drop: {exc}")
@@ -263,12 +265,16 @@ def _ensure_indexes(engine: Any, inspector: Any) -> None:
table_names = inspector.get_table_names()
columns_by_table: dict[str, set[str]] = {}
with engine.begin() as conn:
preparer = conn.dialect.identifier_preparer
for idx_name, table, column in _PERF_INDEXES:
if table in table_names:
if table not in columns_by_table:
columns_by_table[table] = {col["name"] for col in inspector.get_columns(table)}
if column in columns_by_table[table]:
conn.execute(text(f"CREATE INDEX IF NOT EXISTS {idx_name} ON {table} ({column})"))
quoted_idx = preparer.quote(idx_name)
quoted_table = preparer.quote(table)
quoted_col = preparer.quote(column)
conn.execute(text(f"CREATE INDEX IF NOT EXISTS {quoted_idx} ON {quoted_table} ({quoted_col})"))
logger.info("Performance indexes ensured")
+2
View File
@@ -261,12 +261,14 @@ _error_templates = Jinja2Templates(directory=str(_error_templates_dir))
# Register the i18n translate helper as a global so error templates can use {{ _("key") }}.
# Error pages use the default language (English); request-specific locale is not needed here.
from app.utils.i18n import SUPPORTED_LANGUAGES as _SUPPORTED_LANGUAGES # noqa: E402
from app.utils.i18n import get_suggested_languages as _get_suggested_languages # noqa: E402
from app.utils.i18n import translate as _translate_fn # noqa: E402
_error_templates.env.globals["_"] = lambda key, **kwargs: _translate_fn(key, "en", **kwargs)
_error_templates.env.globals["min"] = min
_error_templates.env.globals["max"] = max
_error_templates.env.globals["supported_languages"] = _SUPPORTED_LANGUAGES
_error_templates.env.globals["suggested_languages"] = _get_suggested_languages("en", "")
@app.exception_handler(HTTPException)
+192 -191
View File
@@ -1,191 +1,192 @@
#!/usr/bin/env python3
import json
import logging
import os
import re
# Import the shared Celery instance
from app.celery_app import celery
from app.config import settings
from app.database import SessionLocal
from app.models import FileRecord
from app.tasks.embed_metadata_into_pdf import embed_metadata_into_pdf
from app.tasks.retry_config import BaseTaskWithRetry
from app.utils import log_task_progress
from app.utils.ai_provider import get_ai_provider
logger = logging.getLogger(__name__)
def extract_json_from_text(text):
"""
Try to extract a JSON object from the text.
- First, check for a JSON block inside triple backticks.
- If not found, try to extract text from the first '{' to the last '}'.
"""
pattern = r"```(?:json)?\s*(\{.*?\})\s*```"
match = re.search(pattern, text, re.DOTALL)
if match:
return match.group(1)
else:
start = text.find("{")
end = text.rfind("}")
if start != -1 and end != -1 and end > start:
return text[start : end + 1]
return None
@celery.task(base=BaseTaskWithRetry, bind=True)
def extract_metadata_with_gpt(self, filename: str, cleaned_text: str, file_id: int = None):
"""
Uses OpenAI to classify document metadata.
Args:
filename: Can be either a basename (e.g., "file.pdf") or a full path (e.g., "/workdir/processed/file.pdf")
cleaned_text: The extracted text from the document
file_id: Optional file ID for tracking
"""
task_id = self.request.id
logger.info(f"[{task_id}] Starting metadata extraction for: {filename}")
log_task_progress(
task_id,
"extract_metadata_with_gpt",
"in_progress",
f"Extracting metadata for {os.path.basename(filename)}",
file_id=file_id,
)
# Get file_id from database if not provided
if file_id is None:
tmp_dir = os.path.join(settings.workdir, "tmp")
# Handle both basename and full path
if os.path.isabs(filename):
file_path = filename
else:
file_path = os.path.join(tmp_dir, filename)
if os.path.exists(file_path):
with SessionLocal() as db:
file_record = db.query(FileRecord).filter_by(local_filename=file_path).first()
if file_record:
file_id = file_record.id
prompt = (
"You are a specialized document analyzer trained to extract structured metadata from documents.\n"
"Your task is to analyze the given text and return a well-structured JSON object.\n\n"
"Extract and return the following fields:\n"
"1. **filename**: Machine-readable filename "
"(YYYY-MM-DD_DescriptiveTitle, use only letters, numbers, periods, and underscores).\n"
'2. **empfaenger**: The recipient, or "Unknown" if not found.\n'
'3. **absender**: The sender, or "Unknown" if not found.\n'
"4. **correspondent**: The entity or company that issued the document "
'(shortest possible name, e.g., "Amazon" instead of "Amazon EU SARL, German branch").\n'
"5. **kommunikationsart**: One of [Behoerdlicher_Brief, Rechnung, Kontoauszug, Vertrag, "
"Quittung, Privater_Brief, Einladung, Gewerbliche_Korrespondenz, Newsletter, Werbung, Sonstiges].\n"
"6. **kommunikationskategorie**: One of [Amtliche_Postbehoerdliche_Dokumente, "
"Finanz_und_Vertragsdokumente, Geschaeftliche_Kommunikation, "
"Private_Korrespondenz, Sonstige_Informationen].\n"
"7. **document_type**: Precise classification (e.g., Invoice, Contract, Information, Unknown).\n"
"8. **tags**: A list of up to 4 relevant thematic keywords.\n"
'9. **language**: Detected document language (ISO 639-1 code, e.g., "de" or "en").\n'
"10. **title**: A human-readable title summarizing the document content.\n"
"11. **confidence_score**: A numeric value (0-100) indicating the confidence level "
"of the extracted metadata.\n"
"12. **reference_number**: Extracted invoice/order/reference number if available.\n"
"13. **monetary_amounts**: A list of key monetary values detected in the document.\n\n"
"### Important Rules:\n"
"- **OCR Correction**: Assume the text has been corrected for OCR errors.\n"
"- **Tagging**: Max 4 tags, avoiding generic or overly specific terms.\n"
"- **Title**: Concise, no addresses, and contains key identifying features.\n"
"- **Date Selection**: Use the most relevant date if multiple are found.\n"
"- **Output Language**: Maintain the document's original language.\n\n"
f"Extracted text:\n{cleaned_text}\n\n"
"Return only valid JSON with no additional commentary.\n"
)
try:
logger.info(f"[{task_id}] Sending classification request for {filename}...")
log_task_progress(task_id, "call_ai_provider", "in_progress", "Calling AI provider API", file_id=file_id)
provider = get_ai_provider()
model = settings.ai_model or settings.openai_model
content = provider.chat_completion(
messages=[
{"role": "system", "content": "You are an intelligent document classifier."},
{"role": "user", "content": prompt},
],
model=model,
temperature=0,
)
logger.info(f"[{task_id}] Raw classification response for {filename}: {content[:200]}...")
log_task_progress(
task_id,
"call_ai_provider",
"success",
"Received AI provider response",
file_id=file_id,
detail=f"Raw classification response:\n{content}",
)
json_text = extract_json_from_text(content)
if not json_text:
logger.error(f"[{task_id}] Could not find valid JSON in GPT response for {filename}.")
log_task_progress(
task_id,
"extract_metadata_with_gpt",
"failure",
"Invalid JSON in response",
file_id=file_id,
detail=f"Could not parse valid JSON from GPT response.\nRaw response:\n{content}",
)
return {}
metadata = json.loads(json_text)
# SECURITY: Validate filename format from GPT to prevent path traversal
# The prompt requests filenames with only letters, numbers, periods, and underscores
# Enforce this constraint to prevent malicious filenames
suggested_filename = metadata.get("filename", "")
if suggested_filename:
# Check if filename contains only safe characters AND explicitly check for ".."
# Defense in depth: While the regex [\w\-\. ]+ already excludes / and \,
# we explicitly reject ".." to guard against:
# 1. Potential locale-specific \w behavior
# 2. Files literally named ".." which are valid but problematic
# 3. Future code changes that might relax the regex
if not re.match(r"^[\w\-\. ]+$", suggested_filename) or ".." in suggested_filename:
logger.warning(f"[{task_id}] Invalid filename format from GPT: '{suggested_filename}', using fallback")
# Reset to empty to trigger fallback to original filename
metadata["filename"] = ""
logger.info(f"[{task_id}] Extracted metadata: {metadata}")
log_task_progress(
task_id,
"parse_metadata",
"success",
f"Parsed metadata: {list(metadata.keys())}",
file_id=file_id,
detail=f"Extracted metadata:\n{json.dumps(metadata, ensure_ascii=False, indent=2)}",
)
# Trigger the next step: embedding metadata into the PDF
# Pass the filename (can be basename or full path) so embed_metadata_into_pdf can find the file on disk
logger.info(f"[{task_id}] Queueing metadata embedding task")
log_task_progress(
task_id, "extract_metadata_with_gpt", "success", "Metadata extracted, queuing embed task", file_id=file_id
)
embed_metadata_into_pdf.delay(filename, cleaned_text, metadata, file_id)
return {"s3_file": os.path.basename(filename), "metadata": metadata}
except Exception as e:
logger.exception(f"[{task_id}] AI provider classification failed for {filename}: {e}")
log_task_progress(
task_id,
"extract_metadata_with_gpt",
"failure",
f"Exception: {str(e)}",
file_id=file_id,
detail=f"AI provider classification failed for {filename}.\nException: {str(e)}",
)
return {}
#!/usr/bin/env python3
import json
import logging
import os
import re
# Import the shared Celery instance
from app.celery_app import celery
from app.config import settings
from app.database import SessionLocal
from app.models import FileRecord
from app.tasks.embed_metadata_into_pdf import embed_metadata_into_pdf
from app.tasks.retry_config import BaseTaskWithRetry
from app.utils import log_task_progress
from app.utils.ai_provider import get_ai_provider
from app.utils.filename_utils import VALID_FILENAME_RE
logger = logging.getLogger(__name__)
def extract_json_from_text(text):
"""
Try to extract a JSON object from the text.
- First, check for a JSON block inside triple backticks.
- If not found, try to extract text from the first '{' to the last '}'.
"""
pattern = r"```(?:json)?\s*(\{.*?\})\s*```"
match = re.search(pattern, text, re.DOTALL)
if match:
return match.group(1)
else:
start = text.find("{")
end = text.rfind("}")
if start != -1 and end != -1 and end > start:
return text[start : end + 1]
return None
@celery.task(base=BaseTaskWithRetry, bind=True)
def extract_metadata_with_gpt(self, filename: str, cleaned_text: str, file_id: int = None):
"""
Uses OpenAI to classify document metadata.
Args:
filename: Can be either a basename (e.g., "file.pdf") or a full path (e.g., "/workdir/processed/file.pdf")
cleaned_text: The extracted text from the document
file_id: Optional file ID for tracking
"""
task_id = self.request.id
logger.info(f"[{task_id}] Starting metadata extraction for: {filename}")
log_task_progress(
task_id,
"extract_metadata_with_gpt",
"in_progress",
f"Extracting metadata for {os.path.basename(filename)}",
file_id=file_id,
)
# Get file_id from database if not provided
if file_id is None:
tmp_dir = os.path.join(settings.workdir, "tmp")
# Handle both basename and full path
if os.path.isabs(filename):
file_path = filename
else:
file_path = os.path.join(tmp_dir, filename)
if os.path.exists(file_path):
with SessionLocal() as db:
file_record = db.query(FileRecord).filter_by(local_filename=file_path).first()
if file_record:
file_id = file_record.id
prompt = (
"You are a specialized document analyzer trained to extract structured metadata from documents.\n"
"Your task is to analyze the given text and return a well-structured JSON object.\n\n"
"Extract and return the following fields:\n"
"1. **filename**: Machine-readable filename "
"(YYYY-MM-DD_DescriptiveTitle, use only letters, numbers, spaces, dashes, periods, and underscores).\n"
'2. **empfaenger**: The recipient, or "Unknown" if not found.\n'
'3. **absender**: The sender, or "Unknown" if not found.\n'
"4. **correspondent**: The entity or company that issued the document "
'(shortest possible name, e.g., "Amazon" instead of "Amazon EU SARL, German branch").\n'
"5. **kommunikationsart**: One of [Behoerdlicher_Brief, Rechnung, Kontoauszug, Vertrag, "
"Quittung, Privater_Brief, Einladung, Gewerbliche_Korrespondenz, Newsletter, Werbung, Sonstiges].\n"
"6. **kommunikationskategorie**: One of [Amtliche_Postbehoerdliche_Dokumente, "
"Finanz_und_Vertragsdokumente, Geschaeftliche_Kommunikation, "
"Private_Korrespondenz, Sonstige_Informationen].\n"
"7. **document_type**: Precise classification (e.g., Invoice, Contract, Information, Unknown).\n"
"8. **tags**: A list of up to 4 relevant thematic keywords.\n"
'9. **language**: Detected document language (ISO 639-1 code, e.g., "de" or "en").\n'
"10. **title**: A human-readable title summarizing the document content.\n"
"11. **confidence_score**: A numeric value (0-100) indicating the confidence level "
"of the extracted metadata.\n"
"12. **reference_number**: Extracted invoice/order/reference number if available.\n"
"13. **monetary_amounts**: A list of key monetary values detected in the document.\n\n"
"### Important Rules:\n"
"- **OCR Correction**: Assume the text has been corrected for OCR errors.\n"
"- **Tagging**: Max 4 tags, avoiding generic or overly specific terms.\n"
"- **Title**: Concise, no addresses, and contains key identifying features.\n"
"- **Date Selection**: Use the most relevant date if multiple are found.\n"
"- **Output Language**: Maintain the document's original language.\n\n"
f"Extracted text:\n{cleaned_text}\n\n"
"Return only valid JSON with no additional commentary.\n"
)
try:
logger.info(f"[{task_id}] Sending classification request for {filename}...")
log_task_progress(task_id, "call_ai_provider", "in_progress", "Calling AI provider API", file_id=file_id)
provider = get_ai_provider()
model = settings.ai_model or settings.openai_model
content = provider.chat_completion(
messages=[
{"role": "system", "content": "You are an intelligent document classifier."},
{"role": "user", "content": prompt},
],
model=model,
temperature=0,
)
logger.info(f"[{task_id}] Raw classification response for {filename}: {content[:200]}...")
log_task_progress(
task_id,
"call_ai_provider",
"success",
"Received AI provider response",
file_id=file_id,
detail=f"Raw classification response:\n{content}",
)
json_text = extract_json_from_text(content)
if not json_text:
logger.error(f"[{task_id}] Could not find valid JSON in GPT response for {filename}.")
log_task_progress(
task_id,
"extract_metadata_with_gpt",
"failure",
"Invalid JSON in response",
file_id=file_id,
detail=f"Could not parse valid JSON from GPT response.\nRaw response:\n{content}",
)
return {}
metadata = json.loads(json_text)
# SECURITY: Validate filename format from GPT to prevent path traversal
# The prompt requests filenames with only letters, numbers, periods, and underscores
# Enforce this constraint to prevent malicious filenames
suggested_filename = metadata.get("filename", "")
if suggested_filename:
# Check if filename contains only safe characters AND explicitly check for ".."
# Defense in depth: While the regex VALID_FILENAME_PATTERN already excludes / and \,
# we explicitly reject ".." to guard against:
# 1. Potential locale-specific \w behavior
# 2. Files literally named ".." which are valid but problematic
# 3. Future code changes that might relax the regex
if not VALID_FILENAME_RE.match(suggested_filename) or ".." in suggested_filename:
logger.warning(f"[{task_id}] Invalid filename format from GPT: '{suggested_filename}', using fallback")
# Reset to empty to trigger fallback to original filename
metadata["filename"] = ""
logger.info(f"[{task_id}] Extracted metadata: {metadata}")
log_task_progress(
task_id,
"parse_metadata",
"success",
f"Parsed metadata: {list(metadata.keys())}",
file_id=file_id,
detail=f"Extracted metadata:\n{json.dumps(metadata, ensure_ascii=False, indent=2)}",
)
# Trigger the next step: embedding metadata into the PDF
# Pass the filename (can be basename or full path) so embed_metadata_into_pdf can find the file on disk
logger.info(f"[{task_id}] Queueing metadata embedding task")
log_task_progress(
task_id, "extract_metadata_with_gpt", "success", "Metadata extracted, queuing embed task", file_id=file_id
)
embed_metadata_into_pdf.delay(filename, cleaned_text, metadata, file_id)
return {"s3_file": os.path.basename(filename), "metadata": metadata}
except Exception as e:
logger.exception(f"[{task_id}] AI provider classification failed for {filename}: {e}")
log_task_progress(
task_id,
"extract_metadata_with_gpt",
"failure",
f"Exception: {str(e)}",
file_id=file_id,
detail=f"AI provider classification failed for {filename}.\nException: {str(e)}",
)
return {}
+2 -1
View File
@@ -85,7 +85,8 @@ def preview_migration(source_url: str) -> dict[str, Any]:
with src_engine.connect() as conn:
for table_name in tables:
# table_name is safe — sourced from inspect().get_table_names(), not user input
row = conn.execute(text(f'SELECT COUNT(*) FROM "{table_name}"')).fetchone() # noqa: S608
quoted_table = conn.dialect.identifier_preparer.quote(table_name)
row = conn.execute(text(f"SELECT COUNT(*) FROM {quoted_table}")).fetchone() # noqa: S608
count = row[0] if row else 0
result.append({"name": table_name, "row_count": count})
total += count
+5
View File
@@ -8,6 +8,11 @@ from pathlib import Path
logger = logging.getLogger(__name__)
# Pattern for valid filenames (alphanumeric, dash, underscore, period, and space)
# Used for validating GPT-provided filenames and other inputs
VALID_FILENAME_PATTERN = r"^[\w\-\. ]+$"
VALID_FILENAME_RE = re.compile(VALID_FILENAME_PATTERN)
def get_unique_filename(original_path: str, check_exists_func: Callable[[str], bool] | None = None) -> str:
"""
+237 -14
View File
@@ -2,7 +2,7 @@
Provides a JSON-based translation system for the DocuElevate UI with:
* **31 supported languages** covering all major European languages plus ZH
* **77 supported languages** covering European, Asian, Middle-Eastern, African, and other languages
* Browser ``Accept-Language`` detection with cookie & user-profile persistence
* AI-powered fallback translation via the configured LLM provider
* Locale-aware date, number, and file-size formatting helpers
@@ -42,7 +42,8 @@ SUPPORTED_LANGUAGES: list[dict[str, str]] = [
{"code": "pt", "name": "Portuguese", "native": "Português", "flag": "🇵🇹"},
# --- Tier 2: Western & Northern European ---
{"code": "nl", "name": "Dutch", "native": "Nederlands", "flag": "🇳🇱"},
{"code": "nb", "name": "Norwegian", "native": "Norsk", "flag": "🇳🇴"},
{"code": "nb", "name": "Norwegian Bokmål", "native": "Norsk bokmål", "flag": "🇳🇴"},
{"code": "no", "name": "Norwegian", "native": "Norsk", "flag": "🇳🇴"},
{"code": "da", "name": "Danish", "native": "Dansk", "flag": "🇩🇰"},
{"code": "sv", "name": "Swedish", "native": "Svenska", "flag": "🇸🇪"},
{"code": "fi", "name": "Finnish", "native": "Suomi", "flag": "🇫🇮"},
@@ -50,6 +51,12 @@ SUPPORTED_LANGUAGES: list[dict[str, str]] = [
{"code": "ga", "name": "Irish", "native": "Gaeilge", "flag": "🇮🇪"},
{"code": "lb", "name": "Luxembourgish", "native": "Lëtzebuergesch", "flag": "🇱🇺"},
{"code": "ca", "name": "Catalan", "native": "Català", "flag": "🏴"},
{"code": "cy", "name": "Welsh", "native": "Cymraeg", "flag": "🏴󠁧󠁢󠁷󠁬󠁳󠁿"}, # Wales subdivision flag (U+1F3F4 + tag chars)
{"code": "fy", "name": "Western Frisian", "native": "Frysk", "flag": "🇳🇱"},
{"code": "gl", "name": "Galician", "native": "Galego", "flag": "🇪🇸"},
{"code": "li", "name": "Limburgish", "native": "Limburgs", "flag": "🇳🇱"},
{"code": "vls", "name": "Flemish", "native": "West-Vlams", "flag": "🇧🇪"},
{"code": "nds", "name": "Low German", "native": "Plattdüütsch", "flag": "🇩🇪"},
# --- Tier 3: Central & Eastern European ---
{"code": "pl", "name": "Polish", "native": "Polski", "flag": "🇵🇱"},
{"code": "cs", "name": "Czech", "native": "Čeština", "flag": "🇨🇿"},
@@ -63,16 +70,64 @@ SUPPORTED_LANGUAGES: list[dict[str, str]] = [
{"code": "et", "name": "Estonian", "native": "Eesti", "flag": "🇪🇪"},
{"code": "lv", "name": "Latvian", "native": "Latviešu", "flag": "🇱🇻"},
{"code": "lt", "name": "Lithuanian", "native": "Lietuvių", "flag": "🇱🇹"},
# --- Tier 4: Non-EU European & Other ---
{"code": "sr", "name": "Serbian", "native": "Српски", "flag": "🇷🇸"},
# --- Tier 4: Non-EU European, Middle Eastern & African ---
{"code": "tr", "name": "Turkish", "native": "Türkçe", "flag": "🇹🇷"},
{"code": "uk", "name": "Ukrainian", "native": "Українська", "flag": "🇺🇦"},
{"code": "ru", "name": "Russian", "native": "Русский", "flag": "🇷🇺"},
{"code": "he", "name": "Hebrew", "native": "עברית", "flag": "🇮🇱"},
{"code": "ar", "name": "Arabic", "native": "العربية", "flag": "🇸🇦"},
{"code": "fa", "name": "Persian", "native": "فارسی", "flag": "🇮🇷"},
{"code": "af", "name": "Afrikaans", "native": "Afrikaans", "flag": "🇿🇦"},
# --- Tier 5: Asian languages ---
{"code": "zh", "name": "Chinese", "native": "中文", "flag": "🇨🇳"},
{"code": "zh-TW", "name": "Traditional Chinese", "native": "繁體中文", "flag": "🇹🇼"},
{"code": "ja", "name": "Japanese", "native": "日本語", "flag": "🇯🇵"},
{"code": "ko", "name": "Korean", "native": "한국어", "flag": "🇰🇷"},
{"code": "vi", "name": "Vietnamese", "native": "Tiếng Việt", "flag": "🇻🇳"},
{"code": "pa", "name": "Punjabi", "native": "ਪੰਜਾਬੀ", "flag": "🇮🇳"},
{"code": "kn", "name": "Kannada", "native": "ಕನ್ನಡ", "flag": "🇮🇳"},
{"code": "hi", "name": "Hindi", "native": "हिन्दी", "flag": "🇮🇳"},
{"code": "bn", "name": "Bengali", "native": "বাংলা", "flag": "🇧🇩"},
{"code": "gu", "name": "Gujarati", "native": "ગુજરાતી", "flag": "🇮🇳"},
{"code": "ml", "name": "Malayalam", "native": "മലയാളം", "flag": "🇮🇳"},
{"code": "mr", "name": "Marathi", "native": "मराठी", "flag": "🇮🇳"},
{"code": "ta", "name": "Tamil", "native": "தமிழ்", "flag": "🇮🇳"},
{"code": "te", "name": "Telugu", "native": "తెలుగు", "flag": "🇮🇳"},
{"code": "ur", "name": "Urdu", "native": "اردو", "flag": "🇵🇰"},
{"code": "si", "name": "Sinhala", "native": "සිංහල", "flag": "🇱🇰"},
{"code": "ne", "name": "Nepali", "native": "नेपाली", "flag": "🇳🇵"},
{"code": "th", "name": "Thai", "native": "ไทย", "flag": "🇹🇭"},
{"code": "km", "name": "Khmer", "native": "ខ្មែរ", "flag": "🇰🇭"},
{"code": "id", "name": "Indonesian", "native": "Bahasa Indonesia", "flag": "🇮🇩"},
{"code": "ms", "name": "Malay", "native": "Bahasa Melayu", "flag": "🇲🇾"},
{"code": "jv", "name": "Javanese", "native": "Basa Jawa", "flag": "🇮🇩"},
{"code": "tl", "name": "Tagalog", "native": "Filipino", "flag": "🇵🇭"},
{"code": "mn", "name": "Mongolian", "native": "Монгол", "flag": "🇲🇳"},
{"code": "kk", "name": "Kazakh", "native": "Қазақ тілі", "flag": "🇰🇿"},
{"code": "uz", "name": "Uzbek", "native": "Oʻzbekcha", "flag": "🇺🇿"},
{"code": "az", "name": "Azerbaijani", "native": "Azərbaycan dili", "flag": "🇦🇿"},
{"code": "hy", "name": "Armenian", "native": "Հայերեն", "flag": "🇦🇲"},
{"code": "ka", "name": "Georgian", "native": "ქართული", "flag": "🇬🇪"},
# --- Tier 6: African languages ---
{"code": "sw", "name": "Swahili", "native": "Kiswahili", "flag": "🇰🇪"},
{"code": "am", "name": "Amharic", "native": "አማርኛ", "flag": "🇪🇹"},
{"code": "ha", "name": "Hausa", "native": "Hausa", "flag": "🇳🇬"},
{"code": "yo", "name": "Yoruba", "native": "Yorùbá", "flag": "🇳🇬"},
{"code": "ig", "name": "Igbo", "native": "Igbo", "flag": "🇳🇬"},
{"code": "zu", "name": "Zulu", "native": "isiZulu", "flag": "🇿🇦"},
# --- Tier 7: Constructed & other languages ---
{"code": "eo", "name": "Esperanto", "native": "Esperanto", "flag": "🌍"},
]
SUPPORTED_LANGUAGE_CODES: set[str] = {lang["code"] for lang in SUPPORTED_LANGUAGES}
DEFAULT_LANGUAGE = "en"
# Lookup map for fast code → language-dict resolution
_LANG_CODE_MAP: dict[str, dict[str, str]] = {lang["code"]: lang for lang in SUPPORTED_LANGUAGES}
# Global-usage order used to fill remaining slots in the smart suggestions list
_POPULAR_LANGUAGE_CODES: list[str] = ["en", "zh", "es", "ar", "fr", "de", "ja", "pt", "hi", "ko"]
# ---------------------------------------------------------------------------
# Translation file loading
# ---------------------------------------------------------------------------
@@ -243,14 +298,10 @@ def detect_language(request: Request) -> str:
return DEFAULT_LANGUAGE
def _parse_accept_language(header: str) -> str | None:
"""Extract the best matching language from an ``Accept-Language`` header.
Parses quality values and returns the highest-priority match among
:data:`SUPPORTED_LANGUAGE_CODES`, or ``None`` if nothing matches.
"""
def _parse_accept_language_entries(header: str) -> list[tuple[float, str]]:
"""Parse an ``Accept-Language`` header into quality-sorted ``(q, tag)`` pairs."""
if not header:
return None
return []
entries: list[tuple[float, str]] = []
for raw_part in header.split(","):
@@ -268,11 +319,17 @@ def _parse_accept_language(header: str) -> str | None:
quality = 1.0
entries.append((quality, lang_tag.strip().lower()))
# Sort by quality descending
entries.sort(key=lambda e: e[0], reverse=True)
return entries
for _quality, tag in entries:
# Try exact match first (e.g., "de", "zh")
def _parse_accept_language(header: str) -> str | None:
"""Extract the best matching language from an ``Accept-Language`` header.
Parses quality values and returns the highest-priority match among
:data:`SUPPORTED_LANGUAGE_CODES`, or ``None`` if nothing matches.
"""
for _quality, tag in _parse_accept_language_entries(header):
code = tag.split("-")[0]
if code in SUPPORTED_LANGUAGE_CODES:
return code
@@ -280,6 +337,45 @@ def _parse_accept_language(header: str) -> str | None:
return None
# Maximum number of languages shown in the compact nav-bar dropdown
_SUGGESTED_LANGUAGES_MAX = 6
def get_suggested_languages(current_locale: str, accept_language_header: str = "") -> list[dict[str, str]]:
"""Return up to :data:`_SUGGESTED_LANGUAGES_MAX` suggested languages for the compact picker.
Selection priority:
1. The currently active language (always included first).
2. Languages listed in the browser's ``Accept-Language`` header.
3. Popular global languages (by estimated speaker count) as fillers.
The resulting list is de-duplicated and capped at
:data:`_SUGGESTED_LANGUAGES_MAX` entries.
"""
candidates: list[str] = []
# 1. Active locale first
if current_locale in SUPPORTED_LANGUAGE_CODES:
candidates.append(current_locale)
# 2. Browser preferences
for _quality, tag in _parse_accept_language_entries(accept_language_header):
if len(candidates) >= _SUGGESTED_LANGUAGES_MAX:
break
code = tag.split("-")[0]
if code in SUPPORTED_LANGUAGE_CODES and code not in candidates:
candidates.append(code)
# 3. Popular language fillers
for code in _POPULAR_LANGUAGE_CODES:
if len(candidates) >= _SUGGESTED_LANGUAGES_MAX:
break
if code not in candidates and code in SUPPORTED_LANGUAGE_CODES:
candidates.append(code)
return [_LANG_CODE_MAP[c] for c in candidates[:_SUGGESTED_LANGUAGES_MAX] if c in _LANG_CODE_MAP]
# ---------------------------------------------------------------------------
# Localization helpers (l10n)
# ---------------------------------------------------------------------------
@@ -503,6 +599,133 @@ _LOCALE_FORMATS: dict[str, dict[str, Any]] = {
"thousands_sep": "\u00a0",
"decimal_sep": ",",
},
# --- New languages ---
"no": {
"date": "%d. %B %Y",
"date_short": "%d.%m.%Y",
"datetime": "%d. %B %Y %H:%M",
"thousands_sep": "\u00a0",
"decimal_sep": ",",
},
"cy": {
"date": "%d %B %Y",
"date_short": "%d/%m/%Y",
"datetime": "%d %B %Y %H:%M",
"thousands_sep": ",",
"decimal_sep": ".",
},
"fy": {
"date": "%d %B %Y",
"date_short": "%d-%m-%Y",
"datetime": "%d %B %Y %H:%M",
"thousands_sep": ".",
"decimal_sep": ",",
},
"gl": {
"date": "%d de %B de %Y",
"date_short": "%d/%m/%Y",
"datetime": "%d de %B de %Y %H:%M",
"thousands_sep": ".",
"decimal_sep": ",",
},
"li": {
"date": "%d %B %Y",
"date_short": "%d-%m-%Y",
"datetime": "%d %B %Y %H:%M",
"thousands_sep": ".",
"decimal_sep": ",",
},
"vls": {
"date": "%d %B %Y",
"date_short": "%d/%m/%Y",
"datetime": "%d %B %Y %H:%M",
"thousands_sep": ".",
"decimal_sep": ",",
},
"nds": {
"date": "%d. %B %Y",
"date_short": "%d.%m.%Y",
"datetime": "%d. %B %Y %H:%M",
"thousands_sep": ".",
"decimal_sep": ",",
},
"sr": {
"date": "%d. %B %Y.",
"date_short": "%d.%m.%Y.",
"datetime": "%d. %B %Y. %H:%M",
"thousands_sep": ".",
"decimal_sep": ",",
},
"he": {
"date": "%d %B %Y",
"date_short": "%d/%m/%Y",
"datetime": "%d %B %Y %H:%M",
"thousands_sep": ",",
"decimal_sep": ".",
},
"ar": {
"date": "%d %B %Y",
"date_short": "%Y/%m/%d",
"datetime": "%d %B %Y %H:%M",
"thousands_sep": ",",
"decimal_sep": ".",
},
"fa": {
"date": "%d %B %Y",
"date_short": "%Y/%m/%d",
"datetime": "%d %B %Y %H:%M",
"thousands_sep": ",",
"decimal_sep": ".",
},
"af": {
"date": "%d %B %Y",
"date_short": "%Y/%m/%d",
"datetime": "%d %B %Y %H:%M",
"thousands_sep": "\u00a0",
"decimal_sep": ",",
},
"ja": {
"date": "%Y年%m月%d",
"date_short": "%Y/%m/%d",
"datetime": "%Y年%m月%d%H:%M",
"thousands_sep": ",",
"decimal_sep": ".",
},
"ko": {
"date": "%Y년 %m월 %d",
"date_short": "%Y.%m.%d",
"datetime": "%Y년 %m월 %d%H:%M",
"thousands_sep": ",",
"decimal_sep": ".",
},
"vi": {
"date": "ngày %d tháng %m năm %Y",
"date_short": "%d/%m/%Y",
"datetime": "ngày %d tháng %m năm %Y %H:%M",
"thousands_sep": ".",
"decimal_sep": ",",
},
"pa": {
"date": "%d %B %Y",
"date_short": "%d/%m/%Y",
"datetime": "%d %B %Y %H:%M",
"thousands_sep": ",",
"decimal_sep": ".",
},
"kn": {
"date": "%d %B %Y",
"date_short": "%d/%m/%Y",
"datetime": "%d %B %Y %H:%M",
"thousands_sep": ",",
"decimal_sep": ".",
},
"eo": {
"date": "%d-a de %B %Y",
"date_short": "%Y-%m-%d",
"datetime": "%d-a de %B %Y %H:%M",
"thousands_sep": "\u00a0",
"decimal_sep": ",",
},
}
+34
View File
@@ -0,0 +1,34 @@
import ipaddress
import logging
import socket
logger = logging.getLogger(__name__)
def is_private_ip(hostname: str) -> bool:
"""
Check if a hostname resolves to a private/internal IP address.
Protects against SSRF attacks by blocking access to internal networks.
"""
try:
# Try to parse as IP address directly
ip = ipaddress.ip_address(hostname)
return ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved
except ValueError:
# Not a direct IP, try to resolve hostname
try:
# Get all IP addresses for this hostname
addr_info = socket.getaddrinfo(hostname, None)
for info in addr_info:
ip_str = info[4][0]
ip = ipaddress.ip_address(ip_str)
# Block if ANY resolved IP is private/internal
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
return True
return False
except (socket.gaierror, socket.error):
# Cannot resolve - allow for testing/development
# In production, DNS should work properly
# Log this for debugging
logger.warning(f"Could not resolve hostname: {hostname}")
return False # Changed from True to False to allow external domains in tests
+50 -1
View File
@@ -11,13 +11,15 @@ from sqlalchemy.orm import Session # noqa: F401
from app.auth import require_login # noqa: F401
from app.config import settings
from app.database import get_db # noqa: F401
from app.database import SessionLocal, get_db # noqa: F401
from app.models import UserProfile
from app.utils.i18n import (
SUPPORTED_LANGUAGES,
detect_language,
format_date,
format_datetime,
format_number,
get_suggested_languages,
translate,
)
@@ -46,6 +48,41 @@ templates.env.globals["_"] = lambda key, **kwargs: translate(key, "en", **kwargs
original_template_response = templates.TemplateResponse
def _hydrate_language_from_db(request: Request, session_user: object) -> None:
"""Load the user's preferred language from the DB into the session.
Called once per session when ``preferred_language`` is not yet in the
session. A lightweight DB query fetches the stored preference so that
:func:`detect_language` picks it up from the session on all subsequent
requests without further DB access.
"""
from app.utils.i18n import SUPPORTED_LANGUAGE_CODES
user_id: str | None = None
if isinstance(session_user, dict):
user_id = (
session_user.get("sub")
or session_user.get("preferred_username")
or session_user.get("email")
or session_user.get("id")
)
elif isinstance(session_user, str):
user_id = session_user
if not user_id:
return
db = SessionLocal()
try:
profile = db.query(UserProfile).filter(UserProfile.user_id == user_id).first()
if profile and profile.preferred_language and profile.preferred_language in SUPPORTED_LANGUAGE_CODES:
request.session["preferred_language"] = profile.preferred_language
except Exception: # noqa: BLE001 — intentionally broad; DB may be temporarily unavailable
logger.debug("Could not hydrate language preference for user_id=%s", user_id)
finally:
db.close()
def _inject_global_context(ctx: dict) -> None:
"""Inject shared global variables into every template context dict."""
ctx.setdefault("version", settings.version)
@@ -70,10 +107,22 @@ def _inject_global_context(ctx: dict) -> None:
# When auth is disabled every visitor is effectively "logged in"
ctx.setdefault("is_logged_in", not getattr(settings, "auth_enabled", True) or session_user is not None)
# --- Hydrate session language from DB (once per session) ---
# If the session doesn't have a preferred_language yet but the user
# is logged in, load the stored preference from the database so that
# detect_language() picks it up from the session on this and all
# subsequent requests.
if hasattr(req, "session") and "preferred_language" not in req.session and session_user is not None:
_hydrate_language_from_db(req, session_user)
# --- i18n: detect language and register template helpers ---
current_locale = detect_language(req)
ctx.setdefault("current_locale", current_locale)
# Smart language suggestions for the compact nav-bar dropdown (5-7 languages)
accept_header = req.headers.get("accept-language", "") if hasattr(req, "headers") else ""
ctx.setdefault("suggested_languages", get_suggested_languages(current_locale, accept_header))
def _translate(key: str, **kwargs: object) -> str:
return translate(key, current_locale, **kwargs)
+134 -22
View File
@@ -1,27 +1,97 @@
# Internationalization (i18n) & Localization (l10n) Guide
DocuElevate supports **10 languages** for its web UI, with automatic browser
DocuElevate supports **77 languages** for its web UI, with automatic browser
language detection, user-preference persistence, and an AI-powered fallback
translator for strings that haven't been manually translated yet.
## Supported Languages
| Code | Language | Native Name | Priority |
|------|------------|-------------|----------|
| `en` | English | English | Tier 1 |
| `de` | German | Deutsch | Tier 1 |
| `fr` | French | Français | Tier 1 |
| `es` | Spanish | Español | Tier 1 |
| `it` | Italian | Italiano | Tier 1 |
| `pt` | Portuguese | Português | Tier 1 |
| `nl` | Dutch | Nederlands | Tier 2 |
| `pl` | Polish | Polski | Tier 2 |
| `zh` | Chinese | 中文 | Tier 2 |
| `ru` | Russian | Русский | Tier 2 |
| Code | Language | Native Name | Flag | Priority |
|---------|--------------------|--------------------|------|----------|
| `en` | English | English | 🇬🇧 | Tier 1 |
| `de` | German | Deutsch | 🇩🇪 | Tier 1 |
| `fr` | French | Français | 🇫🇷 | Tier 1 |
| `es` | Spanish | Español | 🇪🇸 | Tier 1 |
| `it` | Italian | Italiano | 🇮🇹 | Tier 1 |
| `pt` | Portuguese | Português | 🇵🇹 | Tier 1 |
| `nl` | Dutch | Nederlands | 🇳🇱 | Tier 2 |
| `nb` | Norwegian Bokmål | Norsk bokmål | 🇳🇴 | Tier 2 |
| `no` | Norwegian | Norsk | 🇳🇴 | Tier 2 |
| `da` | Danish | Dansk | 🇩🇰 | Tier 2 |
| `sv` | Swedish | Svenska | 🇸🇪 | Tier 2 |
| `fi` | Finnish | Suomi | 🇫🇮 | Tier 2 |
| `is` | Icelandic | Íslenska | 🇮🇸 | Tier 2 |
| `ga` | Irish | Gaeilge | 🇮🇪 | Tier 2 |
| `lb` | Luxembourgish | Lëtzebuergesch | 🇱🇺 | Tier 2 |
| `ca` | Catalan | Català | 🏴 | Tier 2 |
| `cy` | Welsh | Cymraeg | 🏴󠁧󠁢󠁷󠁬󠁳󠁿 | Tier 2 |
| `fy` | Western Frisian | Frysk | 🇳🇱 | Tier 2 |
| `gl` | Galician | Galego | 🇪🇸 | Tier 2 |
| `li` | Limburgish | Limburgs | 🇳🇱 | Tier 2 |
| `vls` | Flemish | West-Vlams | 🇧🇪 | Tier 2 |
| `nds` | Low German | Plattdüütsch | 🇩🇪 | Tier 2 |
| `pl` | Polish | Polski | 🇵🇱 | Tier 3 |
| `cs` | Czech | Čeština | 🇨🇿 | Tier 3 |
| `sk` | Slovak | Slovenčina | 🇸🇰 | Tier 3 |
| `hu` | Hungarian | Magyar | 🇭🇺 | Tier 3 |
| `sl` | Slovenian | Slovenščina | 🇸🇮 | Tier 3 |
| `hr` | Croatian | Hrvatski | 🇭🇷 | Tier 3 |
| `ro` | Romanian | Română | 🇷🇴 | Tier 3 |
| `bg` | Bulgarian | Български | 🇧🇬 | Tier 3 |
| `el` | Greek | Ελληνικά | 🇬🇷 | Tier 3 |
| `et` | Estonian | Eesti | 🇪🇪 | Tier 3 |
| `lv` | Latvian | Latviešu | 🇱🇻 | Tier 3 |
| `lt` | Lithuanian | Lietuvių | 🇱🇹 | Tier 3 |
| `sr` | Serbian | Српски | 🇷🇸 | Tier 3 |
| `tr` | Turkish | Türkçe | 🇹🇷 | Tier 4 |
| `uk` | Ukrainian | Українська | 🇺🇦 | Tier 4 |
| `he` | Hebrew | עברית | 🇮🇱 | Tier 4 |
| `ar` | Arabic | العربية | 🇸🇦 | Tier 4 |
| `fa` | Persian | فارسی | 🇮🇷 | Tier 4 |
| `af` | Afrikaans | Afrikaans | 🇿🇦 | Tier 4 |
| `zh` | Chinese | 中文 | 🇨🇳 | Tier 5 |
| `zh-TW` | Traditional Chinese | 繁體中文 | 🇹🇼 | Tier 5 |
| `ja` | Japanese | 日本語 | 🇯🇵 | Tier 5 |
| `ko` | Korean | 한국어 | 🇰🇷 | Tier 5 |
| `vi` | Vietnamese | Tiếng Việt | 🇻🇳 | Tier 5 |
| `pa` | Punjabi | ਪੰਜਾਬੀ | 🇮🇳 | Tier 5 |
| `kn` | Kannada | ಕನ್ನಡ | 🇮🇳 | Tier 5 |
| `hi` | Hindi | हिन्दी | 🇮🇳 | Tier 5 |
| `bn` | Bengali | বাংলা | 🇧🇩 | Tier 5 |
| `gu` | Gujarati | ગુજરાતી | 🇮🇳 | Tier 5 |
| `ml` | Malayalam | മലയാളം | 🇮🇳 | Tier 5 |
| `mr` | Marathi | मराठी | 🇮🇳 | Tier 5 |
| `ta` | Tamil | தமிழ் | 🇮🇳 | Tier 5 |
| `te` | Telugu | తెలుగు | 🇮🇳 | Tier 5 |
| `ur` | Urdu | اردو | 🇵🇰 | Tier 5 |
| `si` | Sinhala | සිංහල | 🇱🇰 | Tier 5 |
| `ne` | Nepali | नेपाली | 🇳🇵 | Tier 5 |
| `th` | Thai | ไทย | 🇹🇭 | Tier 5 |
| `km` | Khmer | ខ្មែរ | 🇰🇭 | Tier 5 |
| `id` | Indonesian | Bahasa Indonesia | 🇮🇩 | Tier 5 |
| `ms` | Malay | Bahasa Melayu | 🇲🇾 | Tier 5 |
| `jv` | Javanese | Basa Jawa | 🇮🇩 | Tier 5 |
| `tl` | Tagalog | Filipino | 🇵🇭 | Tier 5 |
| `mn` | Mongolian | Монгол | 🇲🇳 | Tier 5 |
| `kk` | Kazakh | Қазақ тілі | 🇰🇿 | Tier 5 |
| `uz` | Uzbek | Oʻzbekcha | 🇺🇿 | Tier 5 |
| `az` | Azerbaijani | Azərbaycan dili | 🇦🇿 | Tier 5 |
| `hy` | Armenian | Հայերեն | 🇦🇲 | Tier 5 |
| `ka` | Georgian | ქართული | 🇬🇪 | Tier 5 |
| `sw` | Swahili | Kiswahili | 🇰🇪 | Tier 6 |
| `am` | Amharic | አማርኛ | 🇪🇹 | Tier 6 |
| `ha` | Hausa | Hausa | 🇳🇬 | Tier 6 |
| `yo` | Yoruba | Yorùbá | 🇳🇬 | Tier 6 |
| `ig` | Igbo | Igbo | 🇳🇬 | Tier 6 |
| `zu` | Zulu | isiZulu | 🇿🇦 | Tier 6 |
| `eo` | Esperanto | Esperanto | 🌍 | Tier 7 |
> **Tier 1** languages (European priority) have complete, manually-reviewed
> translations. **Tier 2** languages have complete translations but may
> receive less frequent updates.
> **Tier 1** languages (major European) have complete, manually-reviewed
> translations. **Tier 23** languages have complete translations but may
> receive less frequent updates. **Tier 4** covers Non-EU European, Middle
> Eastern, and South African (Afrikaans) languages. **Tier 5** covers Asian
> and Central Asian languages. **Tier 6** covers African languages. **Tier 7**
> covers constructed languages.
## How Language Is Detected
@@ -71,15 +141,54 @@ Translations are stored as flat JSON files in `frontend/translations/`:
```
frontend/translations/
├── en.json # English (base / reference)
├── af.json # Afrikaans
├── ar.json # Arabic
├── bg.json # Bulgarian
├── ca.json # Catalan
├── cs.json # Czech
├── cy.json # Welsh
├── da.json # Danish
├── de.json # German
├── fr.json # French
├── el.json # Greek
├── eo.json # Esperanto
├── es.json # Spanish
├── et.json # Estonian
├── fa.json # Persian
├── fi.json # Finnish
├── fr.json # French
├── fy.json # Frisian
├── ga.json # Irish
├── gl.json # Galician
├── he.json # Hebrew
├── hr.json # Croatian
├── hu.json # Hungarian
├── is.json # Icelandic
├── it.json # Italian
├── pt.json # Portuguese
├── ja.json # Japanese
├── kn.json # Kannada
├── ko.json # Korean
├── lb.json # Luxembourgish
├── li.json # Limburgish
├── lt.json # Lithuanian
├── lv.json # Latvian
├── nb.json # Norwegian Bokmål
├── nds.json # Low German (Plattdeutsch)
├── nl.json # Dutch
├── no.json # Norwegian
├── pa.json # Punjabi
├── pl.json # Polish
├── zh.json # Chinese
── ru.json # Russian
├── pt.json # Portuguese
── ro.json # Romanian
├── ru.json # Russian
├── sk.json # Slovak
├── sl.json # Slovenian
├── sr.json # Serbian
├── sv.json # Swedish
├── tr.json # Turkish
├── uk.json # Ukrainian
├── vi.json # Vietnamese
├── vls.json # West Flemish
└── zh.json # Chinese
```
Each file is a flat key-value dictionary with dot-notation namespacing:
@@ -143,8 +252,11 @@ format_number(1234.56, "en") # → "1,234.56"
### Adding a New Translation Key
1. Add the key and English text to `frontend/translations/en.json`
2. Add translations for all other languages in their respective files
3. Use `{{ _("your.new.key") }}` in templates
2. Use `{{ _("your.new.key") }}` in templates or `translate("your.new.key", locale)` in Python
That's it. An external automation script picks up new keys in `en.json` and propagates
translations to all other language files. You never need to touch the non-English JSON
files manually — the translate-and-sync pipeline takes care of it.
### AI Fallback Translation
+59 -6
View File
@@ -20,7 +20,7 @@ DocuElevate includes a native mobile application for iOS and Android built with
### Prerequisites
- Node.js 18 or later
- Node.js 20.19.4 or later (use [nvm](https://github.com/nvm-sh/nvm): `nvm install` inside `mobile/` reads `.nvmrc` automatically)
- [Expo CLI](https://docs.expo.dev/get-started/installation/): `npm install -g @expo/cli`
- [Expo Go](https://expo.dev/client) app on your iOS or Android device (for development)
- A running DocuElevate server reachable from your device
@@ -53,6 +53,8 @@ eas build --platform ios
eas build --platform android
```
> **Note:** The mobile app uses `expo-build-properties` with `buildReactNativeFromSource: true` for iOS builds. This is required for Expo SDK 54 (React Native 0.81) compatibility — some native modules still use legacy bridge APIs (`RCTBridge`, `RCTViewManager`, etc.) that are no longer included in the default precompiled XCFrameworks. Building React Native from source makes these headers available, at the cost of slightly longer iOS build times.
See the [EAS Build documentation](https://docs.expo.dev/build/introduction/) for full setup instructions.
## Authentication
@@ -62,11 +64,25 @@ See the [EAS Build documentation](https://docs.expo.dev/build/introduction/) for
The mobile app uses the server's existing OAuth2/SSO setup:
1. User enters the DocuElevate server URL on the login screen.
2. The app opens `<server>/login?mobile=1&redirect_uri=docuelevate://callback` in the **system browser** (Safari / Chrome).
3. The user authenticates via SSO or local credentials.
4. The server redirects back to `docuelevate://callback`.
5. The app calls `POST /api/mobile/generate-token` to exchange the session for a **long-lived API token**.
6. The token is stored securely in the device's keychain (`expo-secure-store`).
2. The app opens `<server>/login?mobile=1&redirect_uri=docuelevate://callback` in the **system browser** (Safari / Chrome Custom Tabs).
3. The server stores `docuelevate://callback` in the browser session and presents the login page.
4. The user authenticates via SSO or local credentials.
5. After successful authentication the server mints a long-lived API token and redirects the browser to `docuelevate://callback?token=<token>`.
6. `WebBrowser.openAuthSessionAsync` intercepts the `docuelevate://` deep link and returns the URL to the app.
7. The app extracts the token from the URL and stores it securely in the device's keychain (`expo-secure-store`).
> **Security note:** The `redirect_uri` is validated server-side; only URIs with the `docuelevate://` custom scheme (production) or the `exp://` scheme (Expo Go development) are accepted, preventing open-redirect attacks.
### Testing in Expo Go
When developing with **Expo Go** the app does not have the `docuelevate://` custom URL scheme registered. The auth flow adapts automatically:
1. `Linking.createURL('callback')` returns an `exp://` URI pointing at the local dev server (e.g. `exp://192.168.1.5:8081/--/callback`).
2. This URI is sent to the server as `redirect_uri`; the server accepts it alongside the production `docuelevate://` scheme.
3. After successful authentication the server redirects back to the `exp://` URI.
4. `WebBrowser.openAuthSessionAsync` intercepts the deep link and the Expo Go app receives the token.
No extra configuration is needed — just run `npx expo start` and scan the QR code with the **Expo Go** app.
### Auto-generated Mobile Token
@@ -226,6 +242,43 @@ mobile/
## Troubleshooting
### "Session expired Local session" during iOS build
EAS stores an Apple ID session locally (in `~/.expo/`) to manage code-signing certificates and provisioning profiles. This session expires after a few weeks.
**To fix:**
1. **Refresh the session** by running `eas credentials` and re-authenticating with your Apple ID.
2. **Recommended for automation:** Replace the Apple ID session with an [App Store Connect API Key](https://docs.expo.dev/app-signing/app-credentials/#app-store-connect-api-key). API keys do not expire and work fully non-interactively:
- Create a key at [appstoreconnect.apple.com → Users → Integrations → Keys](https://appstoreconnect.apple.com/access/integrations/api)
- Download the `.p8` file and note the **Key ID** and **Issuer ID**
- Run `eas credentials` → iOS → *Add an App Store Connect API key*
- Upload the `.p8` file when prompted
Once an API key is configured in EAS, automated builds (including CI and EAS Cloud Workflows) will no longer prompt for a password.
### Node.js deprecation warning `[DEP0169]` during EAS build
```
(node:XXXXX) [DEP0169] DeprecationWarning: `url.parse()` behavior is not standardized…
```
This warning is emitted by **EAS CLI** (an external tool) when it runs on **Node.js 22 or later**, which deprecates `url.parse()`. It does not indicate a problem in the DocuElevate mobile app itself and will not cause a build failure on its own.
The `eas.json` build profiles already include `"NODE_NO_WARNINGS": "1"` in their `env` sections to suppress this warning during EAS Cloud builds. For local builds with a system Node.js ≥ 22, suppress it by running:
```bash
NODE_NO_WARNINGS=1 eas build --platform ios
```
or by activating the project's pinned Node.js version first:
```bash
cd mobile
nvm use # reads .nvmrc → Node 20.19.4 (no deprecation warning)
eas build --platform ios
```
### "Authentication was cancelled or failed"
- Ensure the server URL is correct (including `https://`).
+197 -39
View File
@@ -2,6 +2,8 @@
This document provides solutions to common problems encountered when using DocuElevate.
> **Tip:** For configuration-specific issues, see also the [Configuration Troubleshooting](ConfigurationTroubleshooting.md) guide.
## Common Issues
### Application Won't Start
@@ -14,7 +16,7 @@ This document provides solutions to common problems encountered when using DocuE
#### Possible Solutions
1. **Check environment variables**
```bash
docker-compose config
docker compose config
```
Ensure all required variables are set properly in your `.env` file.
@@ -30,6 +32,18 @@ This document provides solutions to common problems encountered when using DocuE
```
Ensure the port isn't already in use by another application.
4. **Check Redis connectivity**
```bash
docker compose logs redis
```
Ensure Redis is running — both the API server and Celery worker depend on it.
5. **Check database migrations**
```bash
docker compose exec api alembic upgrade head
```
Ensure the database schema is up-to-date.
### Document Upload Fails
#### Symptoms
@@ -39,41 +53,51 @@ This document provides solutions to common problems encountered when using DocuE
#### Possible Solutions
1. **Check file size limits**
- Default maximum file size is 100MB
- Adjust `client_max_body_size` in your reverse proxy configuration
- Default maximum file size is 1 GB (`MAX_UPLOAD_SIZE`)
- Individual file limit: `MAX_SINGLE_FILE_SIZE` (default: same as `MAX_UPLOAD_SIZE`)
- If using a reverse proxy, adjust `client_max_body_size` (Nginx) or equivalent
2. **Verify storage space**
```bash
df -h
```
Ensure there's sufficient disk space available.
Ensure there's sufficient disk space on the workdir volume.
3. **Check worker process**
```bash
docker-compose logs worker
docker compose logs worker
```
Verify the Celery worker is running and processing tasks.
4. **Check upload quota**
If multi-user mode and subscriptions are enabled, verify the user hasn't exceeded their daily upload limit (`DEFAULT_DAILY_UPLOAD_LIMIT`).
### OCR or Text Extraction Issues
#### Symptoms
- Documents upload but text isn't extracted
- Poor quality text extraction
- API errors related to Azure services
- API errors related to OCR services
#### Possible Solutions
1. **Verify API credentials**
Check the Azure Document Intelligence API key and endpoint in your `.env` file.
1. **Verify the configured OCR provider**
Check which provider is set via the `OCR_PROVIDER` environment variable (defaults to Azure Document Intelligence).
2. **Check document quality**
2. **Verify API credentials**
Check the credentials for your configured OCR provider in your `.env` file:
- **Azure**: `AZURE_DI_KEY` and `AZURE_DI_ENDPOINT`
- **Tesseract**: No credentials required (local), but ensure `TESSERACT_LANGUAGES` is set
- **EasyOCR**: No credentials required (local)
- **Mistral**: `MISTRAL_OCR_API_KEY`
- **Google Document AI**: `GOOGLE_DOCAI_PROJECT_ID`, `GOOGLE_DOCAI_LOCATION`, `GOOGLE_DOCAI_PROCESSOR_ID`
- **AWS Textract**: `AWS_TEXTRACT_ACCESS_KEY_ID`, `AWS_TEXTRACT_SECRET_ACCESS_KEY`, `AWS_TEXTRACT_REGION`
3. **Check document quality**
- Ensure documents are clearly scanned
- Try preprocessing images to improve quality before upload
3. **Test API connectivity**
```bash
curl -X GET -H "Ocp-Apim-Subscription-Key: YOUR_KEY" "YOUR_ENDPOINT"
```
Ensure the API is accessible from your server.
4. **Try multi-provider OCR**
Configure `OCR_PROVIDERS` (comma-separated list) with a merge strategy (`OCR_MERGE_STRATEGY`: `ai_merge`, `longest`, or `primary`) for better results.
### Email Integration Problems
@@ -84,92 +108,226 @@ This document provides solutions to common problems encountered when using DocuE
#### Possible Solutions
1. **Verify IMAP settings**
Check host, port, username, and password in your configuration.
Check host, port, username, and password for `IMAP1_*` / `IMAP2_*` in your configuration.
2. **Test IMAP connectivity**
```bash
telnet mail.example.com 993
docker compose exec api python -c "import imaplib; m = imaplib.IMAP4_SSL('mail.example.com', 993); print('OK')"
```
Ensure the IMAP server is accessible.
Ensure the IMAP server is accessible from the container.
3. **Enable less secure apps**
For Gmail and some providers, you may need to enable access for less secure apps or use app-specific passwords.
3. **Check for app-specific passwords**
For Gmail and some providers, you must use app-specific passwords instead of your account password.
4. **Check firewall settings**
Ensure your server can make outbound connections to the mail server.
Ensure your server can make outbound connections to the mail server on port 993 (IMAP SSL).
5. **Check attachment filter**
If only certain attachments are expected, verify `IMAP_ATTACHMENT_FILTER` is set correctly (`documents_only` or `all`).
### Storage Integration Issues
#### Symptoms
- Files aren't appearing in Dropbox/Nextcloud/Paperless
- Files aren't appearing in configured storage destinations
- Authentication errors in logs
- API rate limiting errors
#### Possible Solutions
1. **Verify API credentials**
Double-check all API keys, tokens, and secrets.
Double-check all API keys, tokens, and secrets for the relevant service.
2. **Check access permissions**
Ensure the application has write permissions to the specified folders.
Ensure the application has write permissions to the specified folders/buckets.
3. **Refresh tokens**
For OAuth-based services like Dropbox, try generating new refresh tokens.
For OAuth-based services like Dropbox, Google Drive, and OneDrive, try re-authorizing through the integration setup pages.
4. **Examine detailed logs**
```bash
docker-compose logs worker | grep -i dropbox
docker compose logs worker | grep -i "upload_to"
```
Look for specific error messages related to the service.
Look for specific error messages related to the storage service.
5. **Check integration status**
Visit the **Integrations** page in the web UI to verify the connection status of each configured storage backend.
## Search Issues
### Symptoms
- Search returns no results or incomplete results
- Search page shows an error
### Possible Solutions
1. **Check Meilisearch is running**
```bash
docker compose logs meilisearch
```
Ensure the Meilisearch container is healthy and accepting connections.
2. **Verify Meilisearch URL**
Check `MEILISEARCH_URL` in your `.env` file (default: `http://meilisearch:7700`).
3. **Rebuild the search index**
If documents are missing from search results, reprocessing them will re-index their content.
## Pipeline & Routing Issues
### Symptoms
- Documents are not processed according to pipeline steps
- Routing rules don't match expected documents
### Possible Solutions
1. **Verify pipeline assignment**
On the file detail page, check which pipeline (if any) is assigned. The system pipeline applies to all documents by default.
2. **Test routing rules**
Use the **Evaluate** button on the Routing Rules page to test whether a rule matches a specific document.
3. **Check step ordering**
Pipeline steps execute in order — ensure OCR comes before metadata extraction if the AI step depends on extracted text.
## Database Issues
#### Symptoms
### Symptoms
- Application errors related to database connections
- Missing or corrupt data
- Slow performance
#### Possible Solutions
### Possible Solutions
1. **Check database connection string**
Verify the `DATABASE_URL` variable in your `.env` file.
2. **Inspect database integrity**
For SQLite:
```bash
sqlite3 database.db "PRAGMA integrity_check;"
```
(For SQLite databases)
For PostgreSQL (recommended for production):
```bash
docker compose exec api python -c "from app.database import engine; print(engine.url)"
```
3. **Perform database migrations**
```bash
docker-compose exec api alembic upgrade head
docker compose exec api alembic upgrade head
```
Ensure the database schema is up-to-date.
4. **Consider PostgreSQL for production**
SQLite is suitable for small deployments, but PostgreSQL is recommended for multi-user production environments. See the [Database Configuration Guide](DatabaseConfiguration.md).
## Authentication Problems
#### Symptoms
### Symptoms
- Unable to log in
- Redirect loops during authentication
- OAuth errors
#### Possible Solutions
1. **Verify Authentik configuration**
Check client ID, client secret, and configuration URL.
### Possible Solutions
1. **Verify OAuth/OIDC configuration**
Check client ID, client secret, and configuration URL for your identity provider.
2. **Check callback URLs**
Ensure the redirect URIs are correctly configured in your OAuth provider.
Ensure the redirect URIs are correctly configured in your OAuth provider. The callback URL is typically `https://your-domain/auth/callback`.
3. **Clear browser cookies and cache**
Authentication issues can sometimes be resolved by clearing browser data.
4. **Check social login credentials**
If using social login (Google, Microsoft, Apple, Dropbox), verify the corresponding `SOCIAL_AUTH_*` environment variables.
5. **Verify `EXTERNAL_HOSTNAME`**
The `EXTERNAL_HOSTNAME` setting must match the domain users access DocuElevate from — OAuth redirect URLs depend on it.
## Mobile App Issues
### Symptoms
- Can't connect to DocuElevate from the mobile app
- Push notifications not received
- Login fails
### Possible Solutions
1. **Verify the server URL**
Ensure the mobile app is configured with the correct DocuElevate server URL (including `https://`).
2. **Check API token**
Generate a fresh API token from the web UI (Profile → API Tokens) and enter it in the mobile app settings.
3. **Check network connectivity**
The mobile device must be able to reach your DocuElevate server. If using a private network, ensure VPN is connected.
4. **Push notifications**
Push notifications require a valid Expo push token. Check the app settings and ensure notifications are enabled at the OS level.
See the [Mobile App Guide](MobileApp.md) for detailed setup instructions.
## CLI Issues
### Symptoms
- CLI commands fail with connection errors
- Authentication rejected
### Possible Solutions
1. **Verify URL and token**
```bash
docuelevate --url https://your-instance --token de_xxx list
```
Ensure the URL is correct (include the scheme) and the API token is valid.
2. **Check environment variables**
The CLI reads `DOCUELEVATE_URL` and `DOCUELEVATE_API_TOKEN` from the environment. Verify they are exported.
3. **Test API directly**
```bash
curl -H "Authorization: Bearer de_xxx" https://your-instance/api/files
```
If this fails, the issue is with the server, not the CLI.
See the [CLI Guide](CLIGuide.md) for detailed usage.
## Performance Issues
### Symptoms
- Slow document processing
- High memory usage
- Queue backing up
### Possible Solutions
1. **Check worker concurrency**
The Celery worker processes tasks in parallel. If the queue is backing up, consider scaling workers or adjusting concurrency.
2. **Enable batch throttling**
Set `PROCESSALL_THROTTLE_THRESHOLD` and `PROCESSALL_THROTTLE_DELAY` to prevent overwhelming external APIs.
3. **Monitor the queue**
Visit the **Admin → Queue** page to see pending, active, and failed tasks.
4. **Use PostgreSQL**
SQLite can become a bottleneck under load. Migrate to PostgreSQL for better concurrent performance. See the [Database Configuration Guide](DatabaseConfiguration.md).
5. **Check Redis memory**
```bash
docker compose exec redis redis-cli info memory
```
Ensure Redis has sufficient memory for the task queue and cache.
## Getting Additional Help
If you continue to experience issues after trying these solutions:
1. **Check the logs** for detailed error messages
1. **Check the logs** for detailed error messages:
```bash
docker-compose logs --tail=100
docker compose logs --tail=200
```
2. **Open an issue** on the [GitHub repository](https://github.com/christianlouis/document-processor/issues)
2. **Check the status page** at `/status` in the web UI for an overview of all service connections.
3. **Contact the developer** via the information provided on the About page
3. **Open an issue** on the [GitHub repository](https://github.com/christianlouis/DocuElevate/issues) with:
- A description of the problem
- Relevant log output
- Your DocuElevate version (shown on the About page or in the `VERSION` file)
4. **Consult additional documentation**:
- [Configuration Guide](ConfigurationGuide.md) — All environment variables
- [Configuration Troubleshooting](ConfigurationTroubleshooting.md) — Configuration-specific issues
- [Deployment Guide](DeploymentGuide.md) — Infrastructure and deployment
+9
View File
@@ -63,6 +63,15 @@ DocuElevate features a simple navigation system with the following main sections
- **Search**: Dedicated full-text search across all document content
- **About**: Information about DocuElevate
### Other Ways to Use DocuElevate
Beyond the web interface, DocuElevate is available through several additional clients:
- **Mobile App (iOS & Android)** — Capture documents with your phone camera or upload from your photo library. See the [Mobile App Guide](MobileApp.md) for setup and usage.
- **Browser Extension** — Clip web pages or send files to DocuElevate directly from Chrome, Firefox, or Edge. See the [Browser Extension Guide](BrowserExtension.md) for installation.
- **CLI Tool** — Upload, download, search, and manage documents from the command line or scripts. See the [CLI Guide](CLIGuide.md) for details.
- **REST & GraphQL API** — Full programmatic access for automation and integrations. See the [API Documentation](API.md).
## Uploading Documents
DocuElevate provides multiple convenient ways to upload documents to the system.
+18 -18
View File
@@ -165,11 +165,11 @@ function _makeMenuLink(href, iconClass, label, extraClasses = '') {
'flex items-center gap-2 px-2 py-1 rounded-md text-gray-700 hover:text-gray-900 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500';
btn.setAttribute('aria-haspopup', 'true');
btn.setAttribute('aria-expanded', 'false');
btn.setAttribute('aria-label', `Account menu for ${displayName}`);
btn.setAttribute('aria-label', `${((window.__i18n?.accountMenuFor) || 'Account menu for {name}').replace('{name}', displayName)}`);
const avatar = document.createElement('img');
avatar.src = data.picture;
avatar.alt = 'Avatar';
avatar.alt = window.__i18n.avatar || 'Avatar';
avatar.className = 'w-8 h-8 rounded-full';
const nameSpan = document.createElement('span');
@@ -189,7 +189,7 @@ function _makeMenuLink(href, iconClass, label, extraClasses = '') {
menu.className =
'absolute right-0 mt-2 w-52 bg-white rounded-md shadow-lg ring-1 ring-black ring-opacity-5 z-50 hidden';
menu.setAttribute('role', 'menu');
menu.setAttribute('aria-label', 'Account menu');
menu.setAttribute('aria-label', window.__i18n.accountMenu || 'Account menu');
// User info header
const header = document.createElement('div');
@@ -207,16 +207,16 @@ function _makeMenuLink(href, iconClass, label, extraClasses = '') {
const linksDiv = document.createElement('div');
linksDiv.className = 'py-1';
linksDiv.appendChild(
_makeMenuLink('/profile', 'fas fa-user-circle text-blue-400', 'Profile Settings', 'text-gray-700')
_makeMenuLink('/profile', 'fas fa-user-circle text-blue-400', window.__i18n.profileSettings || 'Profile Settings', 'text-gray-700')
);
linksDiv.appendChild(
_makeMenuLink('/subscription', 'fas fa-layer-group text-indigo-400', 'My Subscription', 'text-gray-700')
_makeMenuLink('/subscription', 'fas fa-layer-group text-indigo-400', window.__i18n.mySubscription || 'My Subscription', 'text-gray-700')
);
linksDiv.appendChild(
_makeMenuLink('/api-tokens', 'fas fa-key text-yellow-500', 'API Tokens', 'text-gray-700')
_makeMenuLink('/api-tokens', 'fas fa-key text-yellow-500', window.__i18n.apiTokens || 'API Tokens', 'text-gray-700')
);
linksDiv.appendChild(
_makeMenuLink('/shared-links', 'fas fa-share-alt text-blue-400', 'Shared Links', 'text-gray-700')
_makeMenuLink('/shared-links', 'fas fa-share-alt text-blue-400', window.__i18n.sharedLinks || 'Shared Links', 'text-gray-700')
);
// Divider + Sign Out
@@ -225,7 +225,7 @@ function _makeMenuLink(href, iconClass, label, extraClasses = '') {
const signOutDiv = document.createElement('div');
signOutDiv.className = 'py-1';
signOutDiv.appendChild(
_makeMenuLink('/logout', 'fas fa-sign-out-alt text-red-400', 'Sign Out', 'text-red-600')
_makeMenuLink('/logout', 'fas fa-sign-out-alt text-red-400', window.__i18n.signOut || 'Sign Out', 'text-red-600')
);
menu.appendChild(header);
@@ -259,7 +259,7 @@ function _makeMenuLink(href, iconClass, label, extraClasses = '') {
userRow.className = 'flex items-center gap-3 px-3 py-3';
const mAvatar = document.createElement('img');
mAvatar.src = data.picture;
mAvatar.alt = 'Avatar';
mAvatar.alt = window.__i18n.avatar || 'Avatar';
mAvatar.className = 'w-8 h-8 rounded-full flex-shrink-0';
const mUserInfo = document.createElement('div');
mUserInfo.className = 'min-w-0';
@@ -284,7 +284,7 @@ function _makeMenuLink(href, iconClass, label, extraClasses = '') {
profileIcon.className = 'fas fa-user-circle mr-2 text-blue-400';
profileIcon.setAttribute('aria-hidden', 'true');
profileLink.appendChild(profileIcon);
profileLink.appendChild(document.createTextNode('Profile Settings'));
profileLink.appendChild(document.createTextNode(window.__i18n.profileSettings || 'Profile Settings'));
mobileAuthSection.appendChild(profileLink);
// Subscription link
@@ -296,7 +296,7 @@ function _makeMenuLink(href, iconClass, label, extraClasses = '') {
subIcon.className = 'fas fa-layer-group mr-2 text-indigo-400';
subIcon.setAttribute('aria-hidden', 'true');
subLink.appendChild(subIcon);
subLink.appendChild(document.createTextNode('My Subscription'));
subLink.appendChild(document.createTextNode(window.__i18n.mySubscription || 'My Subscription'));
mobileAuthSection.appendChild(subLink);
// API Tokens link
@@ -308,7 +308,7 @@ function _makeMenuLink(href, iconClass, label, extraClasses = '') {
tokensIcon.className = 'fas fa-key mr-2 text-yellow-500';
tokensIcon.setAttribute('aria-hidden', 'true');
tokensLink.appendChild(tokensIcon);
tokensLink.appendChild(document.createTextNode('API Tokens'));
tokensLink.appendChild(document.createTextNode(window.__i18n.apiTokens || 'API Tokens'));
mobileAuthSection.appendChild(tokensLink);
// Shared Links link
@@ -320,7 +320,7 @@ function _makeMenuLink(href, iconClass, label, extraClasses = '') {
sharedLinksIcon.className = 'fas fa-share-alt mr-2 text-blue-400';
sharedLinksIcon.setAttribute('aria-hidden', 'true');
sharedLinksLink.appendChild(sharedLinksIcon);
sharedLinksLink.appendChild(document.createTextNode('Shared Links'));
sharedLinksLink.appendChild(document.createTextNode(window.__i18n.sharedLinks || 'Shared Links'));
mobileAuthSection.appendChild(sharedLinksLink);
// Logout link
@@ -332,7 +332,7 @@ function _makeMenuLink(href, iconClass, label, extraClasses = '') {
logoutIcon.className = 'fas fa-sign-out-alt mr-2';
logoutIcon.setAttribute('aria-hidden', 'true');
logoutLink.appendChild(logoutIcon);
logoutLink.appendChild(document.createTextNode('Sign Out'));
logoutLink.appendChild(document.createTextNode(window.__i18n.signOut || 'Sign Out'));
mobileAuthSection.appendChild(logoutLink);
}
} else {
@@ -367,7 +367,7 @@ function _renderLoggedOutAuth(authSection, mobileAuthSection) {
loginLink.href = '/login';
loginLink.className =
'px-3 py-1.5 rounded-md text-sm font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-100 border border-gray-300';
loginLink.textContent = 'Log In';
loginLink.textContent = window.__i18n.logIn || 'Log In';
row.appendChild(loginLink);
if (multiUser) {
@@ -375,7 +375,7 @@ function _renderLoggedOutAuth(authSection, mobileAuthSection) {
startLink.href = startHref;
startLink.className =
'px-3 py-1.5 rounded-md text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500';
startLink.textContent = allowSignup ? 'Sign Up' : 'Get Started';
startLink.textContent = allowSignup ? (window.__i18n.signUp || 'Sign Up') : (window.__i18n.getStarted || 'Get Started');
row.appendChild(startLink);
}
@@ -393,7 +393,7 @@ function _renderLoggedOutAuth(authSection, mobileAuthSection) {
loginIcon.className = 'fas fa-sign-in-alt mr-2';
loginIcon.setAttribute('aria-hidden', 'true');
loginLink.appendChild(loginIcon);
loginLink.appendChild(document.createTextNode('Log In'));
loginLink.appendChild(document.createTextNode(window.__i18n.logIn || 'Log In'));
mobileAuthSection.appendChild(loginLink);
if (multiUser) {
@@ -405,7 +405,7 @@ function _renderLoggedOutAuth(authSection, mobileAuthSection) {
startIcon.className = 'fas fa-arrow-right mr-2';
startIcon.setAttribute('aria-hidden', 'true');
startLink.appendChild(startIcon);
startLink.appendChild(document.createTextNode(allowSignup ? 'Sign Up' : 'Get Started'));
startLink.appendChild(document.createTextNode(allowSignup ? (window.__i18n.signUp || 'Sign Up') : (window.__i18n.getStarted || 'Get Started')));
mobileAuthSection.appendChild(startLink);
}
}
+137 -102
View File
@@ -1,6 +1,6 @@
{% extends "base.html" %}
{% block title %}Plan Designer — DocuElevate Admin{% endblock %}
{% block title %}{{ _("admin_plans.page_title") }}{% endblock %}
{% block content %}
<main id="main-content" class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"
@@ -10,43 +10,41 @@
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold text-gray-900">Plan Designer</h1>
<p class="text-sm text-gray-500 mt-1">Manage subscription plans shown on the public pricing page.</p>
<h1 class="text-2xl font-bold text-gray-900">{{ _("admin_plans.heading") }}</h1>
<p class="text-sm text-gray-500 mt-1">{{ _("admin_plans.subheading") }}</p>
</div>
<div class="flex items-center gap-3">
<a href="/admin/stripe-wizard"
class="inline-flex items-center px-4 py-2 border border-indigo-300 rounded-md text-sm font-medium text-indigo-700 bg-indigo-50 hover:bg-indigo-100 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-indigo-400"
title="Open the Stripe Setup Wizard to configure API keys and sync plans"
title="{{ _('admin_plans.btn_stripe_setup_title') }}"
>
<i class="fab fa-stripe mr-2" aria-hidden="true"></i> Stripe Setup
<i class="fab fa-stripe mr-2" aria-hidden="true"></i> {{ _("admin_plans.btn_stripe_setup") }}
</a>
<button
@click="seedDefaults()"
:disabled="seeding"
class="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-indigo-400 disabled:opacity-50"
title="Restore all four default plans (only if no plans exist yet)"
title="{{ _('admin_plans.btn_restore_defaults_title') }}"
>
<i class="fas fa-undo mr-2 text-gray-400" aria-hidden="true"></i>
<span x-show="!seeding">Restore Defaults</span>
<span x-show="seeding"><i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i> Restoring</span>
<span x-show="!seeding">{{ _("admin_plans.btn_restore_defaults") }}</span>
<span x-show="seeding"><i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i> {{ _("admin_plans.btn_restoring") }}</span>
</button>
<button
@click="openCreateModal()"
class="inline-flex items-center px-4 py-2 rounded-md text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-indigo-500"
>
<i class="fas fa-plus mr-2" aria-hidden="true"></i> Add Plan
<i class="fas fa-plus mr-2" aria-hidden="true"></i> {{ _("admin_plans.btn_add_plan") }}
</button>
</div>
</div>
<!-- Overage callout -->
<div class="mb-6 rounded-lg border border-indigo-200 bg-indigo-50 p-4 text-sm text-indigo-800" role="note">
<p class="font-semibold mb-1"><i class="fas fa-info-circle mr-1" aria-hidden="true"></i> About the Overage Buffer</p>
<p class="font-semibold mb-1"><i class="fas fa-info-circle mr-1" aria-hidden="true"></i> {{ _("admin_plans.overage_buffer_title") }}</p>
<p>
The overage buffer is <strong>invisible to users</strong>. We advertise X docs/month but only enforce
at <strong>X × (1 + buffer%)</strong> docs. For example, a 150-doc/month plan with a 20% buffer
enforces at 180 docs. This prevents hard cutoffs at the exact announced limit, giving users a
graceful soft landing.
{{ _("admin_plans.overage_buffer_body_prefix") }} <strong>{{ _("admin_plans.overage_buffer_invisible") }}</strong>. {{ _("admin_plans.overage_buffer_body_suffix") }}
<strong>{{ _("admin_plans.overage_buffer_formula") }}</strong> {{ _("admin_plans.overage_buffer_tail") }}
</p>
</div>
@@ -63,29 +61,29 @@
<!-- Loading -->
<div x-show="loading" class="text-center py-12 text-gray-400">
<i class="fas fa-spinner fa-spin text-2xl" aria-hidden="true"></i>
<p class="mt-2 text-sm">Loading plans</p>
<p class="mt-2 text-sm">{{ _("admin_plans.loading") }}</p>
</div>
<!-- Plans table -->
<div x-show="!loading" class="bg-white shadow-sm rounded-lg overflow-hidden border border-gray-200">
<table class="min-w-full divide-y divide-gray-200" aria-label="Subscription plans">
<table class="min-w-full divide-y divide-gray-200" aria-label="{{ _('admin_plans.table_aria_label') }}">
<thead class="bg-gray-50">
<tr>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Order</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Plan</th>
<th scope="col" class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Monthly</th>
<th scope="col" class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Yearly</th>
<th scope="col" class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Monthly Limit</th>
<th scope="col" class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Overage %</th>
<th scope="col" class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">Active</th>
<th scope="col" class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ _("admin_plans.col_order") }}</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ _("admin_plans.col_plan") }}</th>
<th scope="col" class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">{{ _("admin_plans.col_monthly") }}</th>
<th scope="col" class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">{{ _("admin_plans.col_yearly") }}</th>
<th scope="col" class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">{{ _("admin_plans.col_monthly_limit") }}</th>
<th scope="col" class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">{{ _("admin_plans.col_overage_pct") }}</th>
<th scope="col" class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">{{ _("admin_plans.col_active") }}</th>
<th scope="col" class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">{{ _("admin_plans.col_actions") }}</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-100">
<template x-if="plans.length === 0">
<tr>
<td colspan="8" class="px-4 py-8 text-center text-sm text-gray-400">
No plans yet. Click <strong>Restore Defaults</strong> to seed the four built-in plans.
{{ _("admin_plans.no_plans_intro") }} <strong>{{ _("admin_plans.btn_restore_defaults") }}</strong> {{ _("admin_plans.no_plans_suffix") }}
</td>
</tr>
</template>
@@ -97,13 +95,13 @@
@click="moveUp(idx)"
:disabled="idx === 0"
class="p-1 rounded hover:bg-gray-200 disabled:opacity-30"
:aria-label="'Move ' + plan.name + ' up'"
:aria-label="i18n.ariaMoveUp.replace('{name}', plan.name)"
><i class="fas fa-chevron-up text-xs" aria-hidden="true"></i></button>
<button
@click="moveDown(idx)"
:disabled="idx === plans.length - 1"
class="p-1 rounded hover:bg-gray-200 disabled:opacity-30"
:aria-label="'Move ' + plan.name + ' down'"
:aria-label="i18n.ariaMoveDown.replace('{name}', plan.name)"
><i class="fas fa-chevron-down text-xs" aria-hidden="true"></i></button>
<span x-text="idx + 1" class="w-5 text-center text-xs text-gray-400"></span>
</div>
@@ -115,13 +113,13 @@
<span class="text-xs px-2 py-0.5 rounded-full bg-indigo-100 text-indigo-700 font-medium" x-text="plan.badge_text"></span>
</template>
<template x-if="plan.is_highlighted">
<span class="text-xs px-2 py-0.5 rounded-full bg-yellow-100 text-yellow-700 font-medium"><i class="fas fa-star" aria-hidden="true"></i> Featured</span>
<span class="text-xs px-2 py-0.5 rounded-full bg-yellow-100 text-yellow-700 font-medium"><i class="fas fa-star" aria-hidden="true"></i> <span x-text="i18n.featuredBadge"></span></span>
</template>
</div>
<div class="text-xs text-gray-400 mt-0.5" x-text="plan.plan_id"></div>
</td>
<td class="px-4 py-3 text-right text-sm text-gray-700">
<span x-text="plan.price_monthly === 0 ? 'Free' : '$' + plan.price_monthly.toFixed(2)"></span>
<span x-text="plan.price_monthly === 0 ? i18n.freeLabel : '$' + plan.price_monthly.toFixed(2)"></span>
</td>
<td class="px-4 py-3 text-right text-sm text-gray-700">
<span x-text="plan.price_yearly === 0 ? '—' : '$' + plan.price_yearly.toFixed(2)"></span>
@@ -136,7 +134,7 @@
<span
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium"
:class="plan.is_active ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'"
x-text="plan.is_active ? 'Active' : 'Inactive'"
x-text="plan.is_active ? i18n.statusActive : i18n.statusInactive"
></span>
</td>
<td class="px-4 py-3 text-right">
@@ -144,16 +142,16 @@
<button
@click="openEditModal(plan)"
class="text-sm text-indigo-600 hover:text-indigo-800 font-medium"
:aria-label="'Edit ' + plan.name"
:aria-label="i18n.ariaEditPlan.replace('{name}', plan.name)"
>
<i class="fas fa-pencil-alt" aria-hidden="true"></i> Edit
<i class="fas fa-pencil-alt" aria-hidden="true"></i> <span x-text="i18n.btnEdit"></span>
</button>
<button
@click="deletePlan(plan.plan_id)"
class="text-sm text-red-500 hover:text-red-700 font-medium"
:aria-label="'Delete ' + plan.name"
:aria-label="i18n.ariaDeletePlan.replace('{name}', plan.name)"
>
<i class="fas fa-trash-alt" aria-hidden="true"></i> Delete
<i class="fas fa-trash-alt" aria-hidden="true"></i> <span x-text="i18n.btnDelete"></span>
</button>
</div>
</td>
@@ -171,8 +169,8 @@
class="inline-flex items-center px-4 py-2 rounded-md text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-indigo-500 disabled:opacity-50"
>
<i class="fas fa-save mr-2" aria-hidden="true"></i>
<span x-show="!saving">Save Order</span>
<span x-show="saving"><i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i> Saving…</span>
<span x-show="!saving">{{ _("admin_plans.btn_save_order") }}</span>
<span x-show="saving"><i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i> {{ _("admin_plans.btn_saving") }}</span>
</button>
</div>
@@ -195,8 +193,8 @@
<div class="bg-white rounded-lg shadow-xl w-full max-w-2xl my-auto" @click.outside="modalOpen = false">
<!-- Modal header -->
<div class="flex items-center justify-between px-6 py-4 border-b">
<h2 id="modal-title" class="text-lg font-semibold text-gray-900" x-text="isCreate ? 'Add Plan' : 'Edit Plan: ' + form.name"></h2>
<button @click="modalOpen = false" class="text-gray-400 hover:text-gray-600" aria-label="Close modal">
<h2 id="modal-title" class="text-lg font-semibold text-gray-900" x-text="isCreate ? i18n.modalCreateTitle : i18n.modalEditTitlePrefix + form.name"></h2>
<button @click="modalOpen = false" class="text-gray-400 hover:text-gray-600" aria-label="{{ _('admin_plans.modal_close_aria') }}">
<i class="fas fa-times" aria-hidden="true"></i>
</button>
</div>
@@ -205,66 +203,66 @@
<!-- Basic Info -->
<div class="px-6 py-5 space-y-4">
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wider">Basic Info</h3>
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wider">{{ _("admin_plans.section_basic_info") }}</h3>
<div class="grid grid-cols-2 gap-4">
<div>
<label for="f-name" class="block text-sm font-medium text-gray-700 mb-1">Name <span class="text-red-500" aria-hidden="true">*</span></label>
<label for="f-name" class="block text-sm font-medium text-gray-700 mb-1">{{ _("admin_plans.field_name") }} <span class="text-red-500" aria-hidden="true">*</span></label>
<input id="f-name" type="text" x-model="form.name" required
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400" />
</div>
<div>
<label for="f-plan-id" class="block text-sm font-medium text-gray-700 mb-1">Plan ID <span class="text-red-500" aria-hidden="true">*</span></label>
<label for="f-plan-id" class="block text-sm font-medium text-gray-700 mb-1">{{ _("admin_plans.field_plan_id") }} <span class="text-red-500" aria-hidden="true">*</span></label>
<input id="f-plan-id" type="text" x-model="form.plan_id" :readonly="!isCreate"
:class="!isCreate ? 'bg-gray-50 text-gray-400 cursor-not-allowed' : ''"
required pattern="[a-z0-9_-]+" title="Lowercase letters, numbers, _ and - only"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400" />
<p class="text-xs text-gray-400 mt-0.5">Lowercase slug, cannot be changed after creation.</p>
<p class="text-xs text-gray-400 mt-0.5">{{ _("admin_plans.hint_plan_id") }}</p>
</div>
</div>
<div>
<label for="f-tagline" class="block text-sm font-medium text-gray-700 mb-1">Tagline</label>
<label for="f-tagline" class="block text-sm font-medium text-gray-700 mb-1">{{ _("admin_plans.field_tagline") }}</label>
<input id="f-tagline" type="text" x-model="form.tagline" maxlength="255"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400" />
</div>
<div class="grid grid-cols-3 gap-4">
<div>
<label for="f-sort-order" class="block text-sm font-medium text-gray-700 mb-1">Sort Order</label>
<label for="f-sort-order" class="block text-sm font-medium text-gray-700 mb-1">{{ _("admin_plans.field_sort_order") }}</label>
<input id="f-sort-order" type="number" x-model.number="form.sort_order" min="0"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400" />
</div>
<div class="flex items-center gap-3 pt-6">
<input id="f-active" type="checkbox" x-model="form.is_active"
class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" />
<label for="f-active" class="text-sm font-medium text-gray-700">Active</label>
<label for="f-active" class="text-sm font-medium text-gray-700">{{ _("admin_plans.field_active") }}</label>
</div>
<div class="flex items-center gap-3 pt-6">
<input id="f-highlighted" type="checkbox" x-model="form.is_highlighted"
class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" />
<label for="f-highlighted" class="text-sm font-medium text-gray-700">Featured / Highlighted</label>
<label for="f-highlighted" class="text-sm font-medium text-gray-700">{{ _("admin_plans.field_featured") }}</label>
</div>
</div>
</div>
<!-- Pricing -->
<div class="px-6 py-5 space-y-4">
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wider">Pricing</h3>
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wider">{{ _("admin_plans.section_pricing") }}</h3>
<div class="grid grid-cols-3 gap-4">
<div>
<label for="f-price-monthly" class="block text-sm font-medium text-gray-700 mb-1">Monthly Price ($)</label>
<label for="f-price-monthly" class="block text-sm font-medium text-gray-700 mb-1">{{ _("admin_plans.field_price_monthly") }}</label>
<input id="f-price-monthly" type="number" x-model.number="form.price_monthly" min="0" step="0.01"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400" />
</div>
<div>
<label for="f-price-yearly" class="block text-sm font-medium text-gray-700 mb-1">Yearly Price ($)</label>
<label for="f-price-yearly" class="block text-sm font-medium text-gray-700 mb-1">{{ _("admin_plans.field_price_yearly") }}</label>
<input id="f-price-yearly" type="number" x-model.number="form.price_yearly" min="0" step="0.01"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400" />
<p class="text-xs mt-0.5"
:class="yearlySavingPct > 0 ? 'text-green-600 font-medium' : 'text-gray-400'"
x-text="yearlySavingPct > 0 ? 'Save ' + yearlySavingPct.toFixed(0) + '% vs monthly' : 'Enter yearly price to show savings'">
x-text="yearlySavingPct > 0 ? i18n.yearlySave.replace('{pct}', yearlySavingPct.toFixed(0)) : i18n.yearlyEnter">
</p>
</div>
<div>
<label for="f-trial-days" class="block text-sm font-medium text-gray-700 mb-1">Trial Days</label>
<label for="f-trial-days" class="block text-sm font-medium text-gray-700 mb-1">{{ _("admin_plans.field_trial_days") }}</label>
<input id="f-trial-days" type="number" x-model.number="form.trial_days" min="0"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400" />
</div>
@@ -274,21 +272,21 @@
<!-- Stripe Integration -->
<div class="px-6 py-5 space-y-4">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wider">Stripe Integration</h3>
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wider">{{ _("admin_plans.section_stripe") }}</h3>
<a href="/admin/stripe-wizard" target="_blank"
class="text-xs text-indigo-600 hover:text-indigo-800 font-medium focus:outline-none focus:ring-1 focus:ring-indigo-400 rounded"
aria-label="Open Stripe Setup Wizard in a new tab"
><i class="fab fa-stripe mr-1" aria-hidden="true"></i>Stripe Wizard</a>
aria-label="{{ _('admin_plans.stripe_wizard_aria') }}"
><i class="fab fa-stripe mr-1" aria-hidden="true"></i>{{ _("admin_plans.stripe_wizard_link") }}</a>
</div>
<p class="text-xs text-gray-500">
Enter the Stripe Price IDs for this plan, or use the
<a href="/admin/stripe-wizard" target="_blank" class="text-indigo-600 hover:text-indigo-800 underline">Stripe Setup Wizard</a>
to auto-create them. Free plans do not need Stripe Price IDs.
{{ _("admin_plans.stripe_desc_before") }}
<a href="/admin/stripe-wizard" target="_blank" class="text-indigo-600 hover:text-indigo-800 underline">{{ _("admin_plans.stripe_wizard_text") }}</a>
{{ _("admin_plans.stripe_desc_after") }}
</p>
<div class="grid grid-cols-2 gap-4">
<div>
<label for="f-stripe-monthly" class="block text-sm font-medium text-gray-700 mb-1">
Stripe Price ID (monthly)
{{ _("admin_plans.field_stripe_monthly") }}
</label>
<input id="f-stripe-monthly" type="text" x-model="form.stripe_price_id_monthly"
placeholder="price_1OtAbc…"
@@ -296,7 +294,7 @@
</div>
<div>
<label for="f-stripe-yearly" class="block text-sm font-medium text-gray-700 mb-1">
Stripe Price ID (yearly)
{{ _("admin_plans.field_stripe_yearly") }}
</label>
<input id="f-stripe-yearly" type="text" x-model="form.stripe_price_id_yearly"
placeholder="price_1OtAbc… (optional)"
@@ -307,36 +305,36 @@
<!-- Volume Limits -->
<div class="px-6 py-5 space-y-4">
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wider">Volume Limits</h3>
<p class="text-xs text-gray-500">Enter <strong>0</strong> for unlimited.</p>
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wider">{{ _("admin_plans.section_volume") }}</h3>
<p class="text-xs text-gray-500">{{ _("admin_plans.hint_zero_unlimited") }}</p>
<div class="grid grid-cols-3 gap-4">
<div>
<label for="f-monthly-limit" class="block text-sm font-medium text-gray-700 mb-1">Docs / Month</label>
<label for="f-monthly-limit" class="block text-sm font-medium text-gray-700 mb-1">{{ _("admin_plans.field_docs_month") }}</label>
<input id="f-monthly-limit" type="number" x-model.number="form.monthly_upload_limit" min="0"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400" />
</div>
<div>
<label for="f-lifetime-limit" class="block text-sm font-medium text-gray-700 mb-1">Lifetime Docs</label>
<label for="f-lifetime-limit" class="block text-sm font-medium text-gray-700 mb-1">{{ _("admin_plans.field_lifetime_docs") }}</label>
<input id="f-lifetime-limit" type="number" x-model.number="form.lifetime_file_limit" min="0"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400" />
</div>
<div>
<label for="f-ocr-pages" class="block text-sm font-medium text-gray-700 mb-1">OCR Pages / Month</label>
<label for="f-ocr-pages" class="block text-sm font-medium text-gray-700 mb-1">{{ _("admin_plans.field_ocr_pages") }}</label>
<input id="f-ocr-pages" type="number" x-model.number="form.max_ocr_pages_monthly" min="0"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400" />
</div>
<div>
<label for="f-dests" class="block text-sm font-medium text-gray-700 mb-1">Storage Destinations</label>
<label for="f-dests" class="block text-sm font-medium text-gray-700 mb-1">{{ _("admin_plans.field_storage_dests") }}</label>
<input id="f-dests" type="number" x-model.number="form.max_storage_destinations" min="0"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400" />
</div>
<div>
<label for="f-mailboxes" class="block text-sm font-medium text-gray-700 mb-1">Email Mailboxes</label>
<label for="f-mailboxes" class="block text-sm font-medium text-gray-700 mb-1">{{ _("admin_plans.field_mailboxes") }}</label>
<input id="f-mailboxes" type="number" x-model.number="form.max_mailboxes" min="0"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400" />
</div>
<div>
<label for="f-filesize" class="block text-sm font-medium text-gray-700 mb-1">Max File Size (MB)</label>
<label for="f-filesize" class="block text-sm font-medium text-gray-700 mb-1">{{ _("admin_plans.field_max_file_size") }}</label>
<input id="f-filesize" type="number" x-model.number="form.max_file_size_mb" min="0"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400" />
</div>
@@ -345,15 +343,15 @@
<!-- Overage Designer -->
<div class="px-6 py-5 space-y-4">
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wider">Overage Designer</h3>
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wider">{{ _("admin_plans.section_overage") }}</h3>
<div>
<label for="f-overage-pct" class="block text-sm font-medium text-gray-700 mb-2">
Buffer:
{{ _("admin_plans.field_buffer") }}
<span class="text-indigo-700 font-semibold" x-text="form.overage_percent + '%'"></span>
<template x-if="form.monthly_upload_limit > 0">
<span class="text-gray-500 font-normal ml-2">
announce <strong x-text="form.monthly_upload_limit"></strong> docs,
enforce at <strong class="text-indigo-700" x-text="Math.round(form.monthly_upload_limit * (1 + form.overage_percent / 100))"></strong> docs
{{ _("admin_plans.overage_announce") }} <strong x-text="form.monthly_upload_limit"></strong> {{ _("admin_plans.overage_docs") }}
{{ _("admin_plans.overage_enforce_at") }} <strong class="text-indigo-700" x-text="Math.round(form.monthly_upload_limit * (1 + form.overage_percent / 100))"></strong> {{ _("admin_plans.overage_docs_end") }}
</span>
</template>
</label>
@@ -368,27 +366,27 @@
aria-valuemax="100"
/>
<div class="flex justify-between text-xs text-gray-400 mt-1">
<span>0% (exact)</span>
<span>50%</span>
<span>100%</span>
<span>{{ _("admin_plans.overage_0pct") }}</span>
<span>{{ _("admin_plans.overage_50pct") }}</span>
<span>{{ _("admin_plans.overage_100pct") }}</span>
</div>
</div>
<div class="grid grid-cols-2 gap-4 opacity-60">
<div class="relative">
<label class="block text-sm font-medium text-gray-700 mb-1">Allow Overage Billing</label>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ _("admin_plans.field_allow_overage") }}</label>
<div class="flex items-center gap-2">
<input type="checkbox" disabled class="h-4 w-4 rounded border-gray-300 text-indigo-600" />
<span class="text-xs text-gray-400">Coming soon</span>
<span class="text-xs text-gray-400">{{ _("admin_plans.coming_soon") }}</span>
</div>
</div>
<div class="relative">
<label class="block text-sm font-medium text-gray-700 mb-1">Overage price / doc ($)</label>
<input type="number" disabled placeholder="Coming soon"
<label class="block text-sm font-medium text-gray-700 mb-1">{{ _("admin_plans.field_overage_doc_price") }}</label>
<input type="number" disabled placeholder="{{ _('admin_plans.coming_soon') }}"
class="w-full px-3 py-2 border border-gray-200 rounded-md text-sm bg-gray-50 text-gray-400 cursor-not-allowed" />
</div>
<div class="relative">
<label class="block text-sm font-medium text-gray-700 mb-1">Overage price / OCR page ($)</label>
<input type="number" disabled placeholder="Coming soon"
<label class="block text-sm font-medium text-gray-700 mb-1">{{ _("admin_plans.field_overage_ocr_price") }}</label>
<input type="number" disabled placeholder="{{ _('admin_plans.coming_soon') }}"
class="w-full px-3 py-2 border border-gray-200 rounded-md text-sm bg-gray-50 text-gray-400 cursor-not-allowed" />
</div>
</div>
@@ -396,18 +394,18 @@
<!-- Features -->
<div class="px-6 py-5 space-y-3">
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wider">Features List</h3>
<p class="text-xs text-gray-500">These bullet points appear on the pricing page card for this plan.</p>
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wider">{{ _("admin_plans.section_features") }}</h3>
<p class="text-xs text-gray-500">{{ _("admin_plans.hint_features") }}</p>
<template x-for="(feat, i) in form.features" :key="i">
<div class="flex items-center gap-2">
<input
type="text"
x-model="form.features[i]"
:aria-label="'Feature ' + (i+1)"
:aria-label="i18n.ariaFeatureN.replace('{n}', i + 1)"
class="flex-1 px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
/>
<button type="button" @click="form.features.splice(i, 1)"
:aria-label="'Remove feature ' + (i+1)"
:aria-label="i18n.ariaRemoveFeatureN.replace('{n}', i + 1)"
class="text-red-400 hover:text-red-600 px-2 py-2">
<i class="fas fa-times" aria-hidden="true"></i>
</button>
@@ -415,21 +413,21 @@
</template>
<button type="button" @click="form.features.push('')"
class="inline-flex items-center text-sm text-indigo-600 hover:text-indigo-800 font-medium">
<i class="fas fa-plus mr-1" aria-hidden="true"></i> Add Feature
<i class="fas fa-plus mr-1" aria-hidden="true"></i> {{ _("admin_plans.btn_add_feature") }}
</button>
</div>
<!-- Display -->
<div class="px-6 py-5 space-y-4">
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wider">Display</h3>
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wider">{{ _("admin_plans.section_display") }}</h3>
<div class="grid grid-cols-2 gap-4">
<div>
<label for="f-cta" class="block text-sm font-medium text-gray-700 mb-1">CTA Button Text</label>
<label for="f-cta" class="block text-sm font-medium text-gray-700 mb-1">{{ _("admin_plans.field_cta_text") }}</label>
<input id="f-cta" type="text" x-model="form.cta_text" maxlength="100"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400" />
</div>
<div>
<label for="f-badge" class="block text-sm font-medium text-gray-700 mb-1">Badge Text</label>
<label for="f-badge" class="block text-sm font-medium text-gray-700 mb-1">{{ _("admin_plans.field_badge_text") }}</label>
<input id="f-badge" type="text" x-model="form.badge_text" maxlength="50"
placeholder="e.g. Most Popular"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400" />
@@ -438,7 +436,7 @@
<div class="flex items-center gap-3">
<input id="f-api" type="checkbox" x-model="form.api_access"
class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" />
<label for="f-api" class="text-sm font-medium text-gray-700">API Access</label>
<label for="f-api" class="text-sm font-medium text-gray-700">{{ _("admin_plans.field_api_access") }}</label>
</div>
</div>
@@ -449,15 +447,15 @@
@click="modalOpen = false"
class="px-4 py-2 text-sm font-medium border border-gray-300 rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-gray-400"
>
Cancel
{{ _("admin_plans.btn_cancel") }}
</button>
<button
type="submit"
:disabled="saving"
class="px-4 py-2 text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
<span x-show="!saving" x-text="isCreate ? 'Create Plan' : 'Save Changes'"></span>
<span x-show="saving"><i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i> Saving…</span>
<span x-show="!saving" x-text="isCreate ? i18n.btnCreate : i18n.btnSaveChanges"></span>
<span x-show="saving"><i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i> <span x-text="i18n.btnSaving"></span></span>
</button>
</div>
@@ -467,9 +465,46 @@
</main>
<script>
window.__i18nAdminPlans = {
modalCreateTitle: {{ _("admin_plans.modal_create_title") | tojson }},
modalEditTitlePrefix: {{ _("admin_plans.modal_edit_title_prefix") | tojson }},
statusActive: {{ _("admin_plans.status_active") | tojson }},
statusInactive: {{ _("admin_plans.status_inactive") | tojson }},
featuredBadge: {{ _("admin_plans.featured_badge") | tojson }},
freeLabel: {{ _("admin_plans.free_label") | tojson }},
btnEdit: {{ _("admin_plans.btn_edit") | tojson }},
btnDelete: {{ _("admin_plans.btn_delete") | tojson }},
btnCreate: {{ _("admin_plans.btn_create") | tojson }},
btnSaveChanges: {{ _("admin_plans.btn_save_changes") | tojson }},
btnSaving: {{ _("admin_plans.btn_saving") | tojson }},
ariaMoveUp: {{ _("admin_plans.aria_move_up") | tojson }},
ariaMoveDown: {{ _("admin_plans.aria_move_down") | tojson }},
ariaEditPlan: {{ _("admin_plans.aria_edit_plan") | tojson }},
ariaDeletePlan: {{ _("admin_plans.aria_delete_plan") | tojson }},
ariaFeatureN: {{ _("admin_plans.aria_feature_n") | tojson }},
ariaRemoveFeatureN: {{ _("admin_plans.aria_remove_feature_n") | tojson }},
yearlySave: {{ _("admin_plans.js_yearly_save") | tojson }},
yearlyEnter: {{ _("admin_plans.js_yearly_enter") | tojson }},
jsFailedLoad: {{ _("admin_plans.js_failed_load") | tojson }},
jsPlanCreated: {{ _("admin_plans.js_plan_created") | tojson }},
jsPlanUpdated: {{ _("admin_plans.js_plan_updated") | tojson }},
jsDeleteConfirm: {{ _("admin_plans.js_delete_confirm") | tojson }},
jsDeleteFailed: {{ _("admin_plans.js_delete_failed") | tojson }},
jsPlanDeleted: {{ _("admin_plans.js_plan_deleted") | tojson }},
jsSeedConfirm: {{ _("admin_plans.js_seed_confirm") | tojson }},
jsSeedFailed: {{ _("admin_plans.js_seed_failed") | tojson }},
jsReorderFailed: {{ _("admin_plans.js_reorder_failed") | tojson }},
jsOrderSaved: {{ _("admin_plans.js_order_saved") | tojson }},
jsSaveFailed: {{ _("admin_plans.js_save_failed") | tojson }},
};
</script>
<script>
function planDesigner() {
const i18n = window.__i18nAdminPlans;
return {
i18n,
plans: [],
loading: true,
saving: false,
@@ -524,7 +559,7 @@ function planDesigner() {
this.errorMsg = '';
try {
const resp = await fetch('/api/plans/admin');
if (!resp.ok) throw new Error('Failed to load plans');
if (!resp.ok) throw new Error(i18n.jsFailedLoad);
const data = await resp.json();
this.plans = data.plans || [];
} catch (e) {
@@ -568,9 +603,9 @@ function planDesigner() {
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({ detail: resp.statusText }));
throw new Error(err.detail || 'Save failed');
throw new Error(err.detail || i18n.jsSaveFailed);
}
this.successMsg = this.isCreate ? 'Plan created!' : 'Plan updated!';
this.successMsg = this.isCreate ? i18n.jsPlanCreated : i18n.jsPlanUpdated;
this.modalOpen = false;
await this.loadPlans();
} catch (e) {
@@ -581,15 +616,15 @@ function planDesigner() {
},
async deletePlan(planId) {
if (!confirm(`Delete plan "${planId}"? This cannot be undone.`)) return;
if (!confirm(i18n.jsDeleteConfirm.replace('{id}', planId))) return;
this.errorMsg = '';
try {
const resp = await fetch(`/api/plans/${encodeURIComponent(planId)}`, { method: 'DELETE' });
if (!resp.ok && resp.status !== 204) {
const err = await resp.json().catch(() => ({ detail: resp.statusText }));
throw new Error(err.detail || 'Delete failed');
throw new Error(err.detail || i18n.jsDeleteFailed);
}
this.successMsg = `Plan "${planId}" deleted.`;
this.successMsg = i18n.jsPlanDeleted.replace('{id}', planId);
await this.loadPlans();
} catch (e) {
this.errorMsg = e.message;
@@ -597,12 +632,12 @@ function planDesigner() {
},
async seedDefaults() {
if (!confirm('Seed the four default plans? This is a no-op if plans already exist.')) return;
if (!confirm(i18n.jsSeedConfirm)) return;
this.seeding = true;
this.errorMsg = '';
try {
const resp = await fetch('/api/plans/seed', { method: 'POST' });
if (!resp.ok) throw new Error('Seed failed');
if (!resp.ok) throw new Error(i18n.jsSeedFailed);
const data = await resp.json();
this.successMsg = data.message;
await this.loadPlans();
@@ -635,8 +670,8 @@ function planDesigner() {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ order }),
});
if (!resp.ok) throw new Error('Reorder failed');
this.successMsg = 'Order saved!';
if (!resp.ok) throw new Error(i18n.jsReorderFailed);
this.successMsg = i18n.jsOrderSaved;
this.orderDirty = false;
} catch (e) {
this.errorMsg = e.message;
+193 -157
View File
@@ -1,5 +1,5 @@
{% extends "base.html" %}
{% block title %}User Management Admin DocuElevate{% endblock %}
{% block title %}{{ _("admin_users.page_title") }}{% endblock %}
{% block content %}
<div class="container mx-auto px-4 py-8" x-data="adminUsersApp()">
@@ -9,11 +9,11 @@
<div>
<h1 class="text-3xl font-bold text-gray-900 flex items-center gap-2">
<i class="fas fa-users text-blue-500" aria-hidden="true"></i>
User Management
<span class="text-xs font-semibold text-red-600 bg-red-50 border border-red-200 rounded px-2 py-0.5 ml-1">Admin Only</span>
{{ _("admin_users.heading") }}
<span class="text-xs font-semibold text-red-600 bg-red-50 border border-red-200 rounded px-2 py-0.5 ml-1">{{ _("admin_users.admin_only_badge") }}</span>
</h1>
<p class="text-gray-500 text-sm mt-1">
Manage user profiles, per-user upload limits, and document ownership.
{{ _("admin_users.subheading") }}
</p>
</div>
<button
@@ -21,14 +21,14 @@
@click="openCreateModal()"
class="inline-flex items-center px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
<i class="fas fa-user-plus mr-2" aria-hidden="true"></i> Add User Profile
<i class="fas fa-user-plus mr-2" aria-hidden="true"></i> {{ _("admin_users.add_user_profile_btn") }}
</button>
<button
type="button"
@click="openCreateLocalUserModal()"
class="inline-flex items-center px-4 py-2 bg-green-600 hover:bg-green-700 text-white text-sm font-medium rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500"
>
<i class="fas fa-user-lock mr-2" aria-hidden="true"></i> Create Local Account
<i class="fas fa-user-lock mr-2" aria-hidden="true"></i> {{ _("admin_users.create_local_account_btn") }}
</button>
</div>
@@ -48,7 +48,7 @@
<!-- ── Search & pagination controls ──────────────────────────────────────── -->
<div class="mb-4 flex flex-col sm:flex-row gap-3 items-start sm:items-center justify-between">
<div class="flex-1 max-w-sm">
<label for="userSearch" class="sr-only">Search users</label>
<label for="userSearch" class="sr-only">{{ _("admin_users.search_users_label") }}</label>
<div class="relative">
<span class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-search text-gray-400 text-sm" aria-hidden="true"></i>
@@ -58,7 +58,7 @@
type="search"
x-model="search"
@input.debounce.300ms="fetchUsers(1)"
placeholder="Filter by user ID…"
placeholder="{{ _('admin_users.filter_placeholder') }}"
class="w-full pl-9 pr-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-400"
/>
</div>
@@ -68,24 +68,24 @@
<!-- ── Table ──────────────────────────────────────────────────────────────── -->
<div class="bg-white shadow rounded-lg overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200" aria-label="User list">
<table class="min-w-full divide-y divide-gray-200" aria-label="{{ _('admin_users.heading') }}">
<thead class="bg-gray-50">
<tr>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">User ID</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Display Name</th>
<th scope="col" class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">Documents</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Last Upload</th>
<th scope="col" class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">Plan</th>
<th scope="col" class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">Upload Limit</th>
<th scope="col" class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th scope="col" class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ _("admin_users.col_user_id") }}</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ _("admin_users.col_display_name") }}</th>
<th scope="col" class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">{{ _("admin_users.col_documents") }}</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ _("admin_users.col_last_upload") }}</th>
<th scope="col" class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">{{ _("admin_users.col_plan") }}</th>
<th scope="col" class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">{{ _("admin_users.col_upload_limit") }}</th>
<th scope="col" class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">{{ _("common.status") }}</th>
<th scope="col" class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">{{ _("common.actions") }}</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<template x-if="loading">
<tr>
<td colspan="8" class="px-4 py-8 text-center text-gray-400">
<i class="fas fa-spinner fa-spin mr-2" aria-hidden="true"></i> Loading users
<i class="fas fa-spinner fa-spin mr-2" aria-hidden="true"></i> {{ _("admin_users.loading_users") }}
</td>
</tr>
</template>
@@ -93,9 +93,9 @@
<tr>
<td colspan="8" class="px-4 py-8 text-center text-gray-400">
<i class="fas fa-users-slash text-3xl block mb-2" aria-hidden="true"></i>
No users found.
<span x-show="search"> Try a different search term.</span>
<span x-show="!search"> Upload some documents or add a profile above.</span>
{{ _("admin_users.no_users_found") }}
<span x-show="search"> {{ _("admin_users.no_users_search_hint") }}</span>
<span x-show="!search"> {{ _("admin_users.no_users_add_hint") }}</span>
</td>
</tr>
</template>
@@ -159,9 +159,9 @@
<td class="px-4 py-3 text-sm text-center text-gray-700">
<span x-show="user.daily_upload_limit !== null && user.daily_upload_limit !== undefined"
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-indigo-100 text-indigo-700"
x-text="user.daily_upload_limit + ' / day'"></span>
x-text="user.daily_upload_limit + ' {{ _("admin_users.per_day") }}'"></span>
<span x-show="user.daily_upload_limit === null || user.daily_upload_limit === undefined"
class="text-gray-400 text-xs">global default</span>
class="text-gray-400 text-xs">{{ _("admin_users.global_default") }}</span>
</td>
<!-- Status -->
<td class="px-4 py-3 text-sm text-center">
@@ -172,7 +172,7 @@
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium"
>
<i :class="user.is_blocked ? 'fas fa-ban' : 'fas fa-check-circle'" aria-hidden="true"></i>
<span x-text="user.is_blocked ? 'Blocked' : 'Active'"></span>
<span x-text="user.is_blocked ? '{{ _("admin_users.status_blocked") }}' : '{{ _("common.active") }}'"></span>
</span>
</td>
<!-- Actions -->
@@ -181,18 +181,18 @@
type="button"
@click="openEditModal(user)"
class="inline-flex items-center px-2.5 py-1.5 text-xs font-medium rounded border border-gray-300 text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-blue-500 mr-1"
:aria-label="`Edit profile for ${user.user_id}`"
:aria-label="`{{ _('common.edit') }} ${user.user_id}`"
>
<i class="fas fa-edit mr-1" aria-hidden="true"></i> Edit
<i class="fas fa-edit mr-1" aria-hidden="true"></i> {{ _("common.edit") }}
</button>
<button
type="button"
@click="confirmDelete(user)"
x-show="user.profile_id !== null"
class="inline-flex items-center px-2.5 py-1.5 text-xs font-medium rounded border border-red-300 text-red-600 bg-white hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-red-400"
:aria-label="`Delete profile for ${user.user_id}`"
:aria-label="`{{ _('common.delete') }} ${user.user_id}`"
>
<i class="fas fa-trash mr-1" aria-hidden="true"></i> Delete
<i class="fas fa-trash mr-1" aria-hidden="true"></i> {{ _("common.delete") }}
</button>
</td>
</tr>
@@ -209,10 +209,10 @@
:disabled="currentPage <= 1"
class="px-3 py-1.5 text-sm border border-gray-300 rounded hover:bg-gray-50 disabled:opacity-40 disabled:cursor-not-allowed"
>
<i class="fas fa-chevron-left mr-1" aria-hidden="true"></i> Previous
<i class="fas fa-chevron-left mr-1" aria-hidden="true"></i> {{ _("common.previous") }}
</button>
<span class="text-sm text-gray-500">
Page <span x-text="currentPage"></span> of <span x-text="pages"></span>
{{ _("common.page") }} <span x-text="currentPage"></span> {{ _("admin_users.pagination_page_of") }} <span x-text="pages"></span>
</span>
<button
type="button"
@@ -220,7 +220,7 @@
:disabled="currentPage >= pages"
class="px-3 py-1.5 text-sm border border-gray-300 rounded hover:bg-gray-50 disabled:opacity-40 disabled:cursor-not-allowed"
>
Next <i class="fas fa-chevron-right ml-1" aria-hidden="true"></i>
{{ _("common.next") }} <i class="fas fa-chevron-right ml-1" aria-hidden="true"></i>
</button>
</div>
@@ -250,7 +250,7 @@
type="button"
@click="modalOpen = false"
class="text-gray-400 hover:text-gray-600 focus:outline-none"
aria-label="Close dialog"
aria-label="{{ _('admin_users.modal_close_aria') }}"
>
<i class="fas fa-times" aria-hidden="true"></i>
</button>
@@ -261,7 +261,7 @@
<!-- User ID (read-only when editing) -->
<div>
<label for="modal-user-id" class="block text-sm font-medium text-gray-700 mb-1">User ID</label>
<label for="modal-user-id" class="block text-sm font-medium text-gray-700 mb-1">{{ _("admin_users.modal_user_id_label") }}</label>
<input
id="modal-user-id"
type="text"
@@ -269,32 +269,32 @@
:readonly="!isCreate"
:class="!isCreate ? 'bg-gray-100 cursor-not-allowed' : ''"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-400"
placeholder="user@example.com or OAuth sub"
placeholder="{{ _('admin_users.modal_user_id_placeholder') }}"
required
aria-required="true"
autocomplete="off"
/>
<p class="text-xs text-gray-400 mt-1">The stable identifier that matches <code>owner_id</code> in documents.</p>
<p class="text-xs text-gray-400 mt-1">{{ _("admin_users.modal_user_id_hint") }}</p>
</div>
<!-- Display name -->
<div>
<label for="modal-display-name" class="block text-sm font-medium text-gray-700 mb-1">Display Name</label>
<label for="modal-display-name" class="block text-sm font-medium text-gray-700 mb-1">{{ _("admin_users.modal_display_name_label") }}</label>
<input
id="modal-display-name"
type="text"
x-model="form.display_name"
maxlength="255"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-400"
placeholder="Alice Smith (optional)"
placeholder="{{ _('admin_users.modal_display_name_placeholder') }}"
/>
</div>
<!-- Daily upload limit -->
<div>
<label for="modal-limit" class="block text-sm font-medium text-gray-700 mb-1">
Daily Upload Limit
<span class="ml-1 text-xs font-normal text-gray-400">(leave empty to use global default)</span>
{{ _("admin_users.modal_daily_limit_label") }}
<span class="ml-1 text-xs font-normal text-gray-400">{{ _("admin_users.modal_daily_limit_hint") }}</span>
</label>
<input
id="modal-limit"
@@ -302,20 +302,20 @@
x-model.number="form.daily_upload_limit"
min="0"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-400"
placeholder="e.g. 50 (0 = unlimited)"
placeholder="{{ _('admin_users.modal_daily_limit_placeholder') }}"
/>
</div>
<!-- Notes -->
<div>
<label for="modal-notes" class="block text-sm font-medium text-gray-700 mb-1">Admin Notes</label>
<label for="modal-notes" class="block text-sm font-medium text-gray-700 mb-1">{{ _("admin_users.modal_notes_label") }}</label>
<textarea
id="modal-notes"
x-model="form.notes"
rows="3"
maxlength="4096"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-400 resize-y"
placeholder="Internal notes visible only to admins…"
placeholder="{{ _('admin_users.modal_notes_placeholder') }}"
></textarea>
</div>
@@ -336,50 +336,50 @@
></span>
</button>
<span id="modal-blocked-label" class="text-sm font-medium text-gray-700">
Block this user
<span class="text-xs text-gray-400 font-normal">(prevents new document uploads)</span>
{{ _("admin_users.modal_block_label") }}
<span class="text-xs text-gray-400 font-normal">{{ _("admin_users.modal_block_hint") }}</span>
</span>
</div>
<!-- Subscription tier -->
<div>
<label for="modal-tier" class="block text-sm font-medium text-gray-700 mb-1">
Subscription Plan
{{ _("admin_users.modal_plan_label") }}
</label>
<select
id="modal-tier"
x-model="form.subscription_tier"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-400 bg-white"
>
<option value="free">Free — 25 lifetime files</option>
<option value="starter">Starter — $2.99/mo (50/mo, 1 mailbox)</option>
<option value="professional">Professional — $5.99/mo (150/mo, 3 mailboxes)</option>
<option value="business">Business — $7.99/mo (300/mo, unlimited mailboxes)</option>
<option value="free">{{ _("admin_users.modal_plan_free") }}</option>
<option value="starter">{{ _("admin_users.modal_plan_starter") }}</option>
<option value="professional">{{ _("admin_users.modal_plan_professional") }}</option>
<option value="business">{{ _("admin_users.modal_plan_business") }}</option>
</select>
<p class="text-xs text-gray-400 mt-1">
Sets the quota limits for this user. Limits are enforced on upload.
{{ _("admin_users.modal_plan_hint") }}
</p>
</div>
<!-- Billing cycle -->
<div>
<label for="modal-billing-cycle" class="block text-sm font-medium text-gray-700 mb-1">
Billing Cycle
{{ _("admin_users.modal_billing_cycle_label") }}
</label>
<select
id="modal-billing-cycle"
x-model="form.subscription_billing_cycle"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-400 bg-white"
>
<option value="monthly">Monthly</option>
<option value="yearly">Yearly</option>
<option value="monthly">{{ _("admin_users.modal_billing_monthly") }}</option>
<option value="yearly">{{ _("admin_users.modal_billing_yearly") }}</option>
</select>
</div>
<!-- Subscription period start (only shown for yearly) -->
<div x-show="form.subscription_billing_cycle === 'yearly'" x-cloak>
<label for="modal-period-start" class="block text-sm font-medium text-gray-700 mb-1">
Subscription Period Start
{{ _("admin_users.modal_period_start_label") }}
</label>
<input
type="date"
@@ -388,7 +388,7 @@
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-400"
/>
<p class="text-xs text-gray-400 mt-1">
Annual carry-over calculates from this date. Leave blank for monthly enforcement.
{{ _("admin_users.modal_period_start_hint") }}
</p>
</div>
@@ -409,8 +409,8 @@
></span>
</button>
<span id="modal-complimentary-label" class="text-sm font-medium text-gray-700">
Complimentary plan
<span class="text-xs text-gray-400 font-normal">(user keeps tier benefits but is never billed — set automatically for admin accounts)</span>
{{ _("admin_users.modal_complimentary_label") }}
<span class="text-xs text-gray-400 font-normal">{{ _("admin_users.modal_complimentary_hint") }}</span>
</span>
</div>
@@ -423,15 +423,15 @@
@click="modalOpen = false"
class="px-4 py-2 text-sm font-medium border border-gray-300 rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-gray-400"
>
Cancel
{{ _("common.cancel") }}
</button>
<button
type="submit"
:disabled="saving"
class="px-4 py-2 text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
<span x-show="!saving" x-text="isCreate ? 'Create' : 'Save Changes'"></span>
<span x-show="saving"><i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i> Saving…</span>
<span x-show="!saving" x-text="isCreate ? '{{ _('common.create') }}' : '{{ _('admin_users.modal_save_changes') }}'"></span>
<span x-show="saving"><i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i> {{ _("admin_users.modal_saving") }}</span>
</button>
</div>
</form>
@@ -444,10 +444,10 @@
<div>
<h2 class="text-lg font-semibold text-gray-900 flex items-center gap-2">
<i class="fas fa-user-lock text-green-600" aria-hidden="true"></i>
Local User Accounts
{{ _("admin_users.local_accounts_heading") }}
</h2>
<p class="text-sm text-gray-500 mt-0.5">
Email/password accounts created directly on this server.
{{ _("admin_users.local_accounts_subheading") }}
</p>
</div>
<button
@@ -455,35 +455,35 @@
@click="openCreateLocalUserModal()"
class="inline-flex items-center px-3 py-1.5 bg-green-600 hover:bg-green-700 text-white text-sm font-medium rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500"
>
<i class="fas fa-plus mr-1" aria-hidden="true"></i> New Account
<i class="fas fa-plus mr-1" aria-hidden="true"></i> {{ _("admin_users.new_account_btn") }}
</button>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200" aria-label="Local user accounts">
<table class="min-w-full divide-y divide-gray-200" aria-label="{{ _('admin_users.local_accounts_heading') }}">
<thead class="bg-gray-50">
<tr>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Username</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Email</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Display Name</th>
<th scope="col" class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th scope="col" class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">Role</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Created</th>
<th scope="col" class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ _("admin_users.col_username") }}</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ _("admin_users.col_email") }}</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ _("admin_users.col_display_name") }}</th>
<th scope="col" class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">{{ _("common.status") }}</th>
<th scope="col" class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">{{ _("admin_users.col_role") }}</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ _("common.created") }}</th>
<th scope="col" class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">{{ _("common.actions") }}</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<template x-if="localUsersLoading">
<tr>
<td colspan="7" class="px-4 py-6 text-center text-gray-400">
<i class="fas fa-spinner fa-spin mr-2" aria-hidden="true"></i> Loading
<i class="fas fa-spinner fa-spin mr-2" aria-hidden="true"></i> {{ _("admin_users.local_loading") }}
</td>
</tr>
</template>
<template x-if="!localUsersLoading && localUsers.length === 0">
<tr>
<td colspan="7" class="px-4 py-6 text-center text-gray-400">
No local accounts yet.
<button type="button" @click="openCreateLocalUserModal()" class="text-green-600 hover:underline ml-1">Create one.</button>
{{ _("admin_users.local_no_accounts") }}
<button type="button" @click="openCreateLocalUserModal()" class="text-green-600 hover:underline ml-1">{{ _("admin_users.local_create_one") }}</button>
</td>
</tr>
</template>
@@ -496,14 +496,14 @@
<span
:class="lu.is_active ? 'bg-green-100 text-green-800' : 'bg-yellow-100 text-yellow-800'"
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
x-text="lu.is_active ? 'Active' : 'Unverified'"
x-text="lu.is_active ? '{{ _("common.active") }}' : '{{ _("admin_users.status_unverified") }}'"
></span>
</td>
<td class="px-4 py-3 text-sm text-center">
<span
:class="lu.is_admin ? 'bg-red-100 text-red-700' : 'bg-gray-100 text-gray-600'"
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
x-text="lu.is_admin ? 'Admin' : 'User'"
x-text="lu.is_admin ? '{{ _("admin_users.role_admin") }}' : '{{ _("admin_users.role_user") }}'"
></span>
</td>
<td class="px-4 py-3 text-sm text-gray-500 whitespace-nowrap" x-text="lu.created_at ? formatDate(lu.created_at) : '—'"></td>
@@ -512,34 +512,34 @@
type="button"
@click="openEditLocalUserModal(lu)"
class="inline-flex items-center px-2.5 py-1.5 text-xs font-medium rounded border border-gray-300 text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-blue-500 mr-1"
:aria-label="`Edit account for ${lu.username}`"
:aria-label="`{{ _('common.edit') }} ${lu.username}`"
>
<i class="fas fa-edit mr-1" aria-hidden="true"></i> Edit
<i class="fas fa-edit mr-1" aria-hidden="true"></i> {{ _("common.edit") }}
</button>
<button
type="button"
@click="openSetPasswordModal(lu)"
class="inline-flex items-center px-2.5 py-1.5 text-xs font-medium rounded border border-yellow-300 text-yellow-700 bg-white hover:bg-yellow-50 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-yellow-400 mr-1"
:aria-label="`Set password for ${lu.username}`"
:aria-label="`{{ _('admin_users.btn_password') }} ${lu.username}`"
>
<i class="fas fa-key mr-1" aria-hidden="true"></i> Password
<i class="fas fa-key mr-1" aria-hidden="true"></i> {{ _("admin_users.btn_password") }}
</button>
<button
type="button"
@click="sendPasswordReset(lu)"
class="inline-flex items-center px-2.5 py-1.5 text-xs font-medium rounded border border-indigo-300 text-indigo-600 bg-white hover:bg-indigo-50 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-indigo-400 mr-1"
:aria-label="`Send password reset email to ${lu.username}`"
title="Send password reset email"
:aria-label="`{{ _('admin_users.btn_reset') }} ${lu.username}`"
title="{{ _('admin_users.btn_reset') }}"
>
<i class="fas fa-envelope mr-1" aria-hidden="true"></i> Reset
<i class="fas fa-envelope mr-1" aria-hidden="true"></i> {{ _("admin_users.btn_reset") }}
</button>
<button
type="button"
@click="confirmDeleteLocalUser(lu)"
class="inline-flex items-center px-2.5 py-1.5 text-xs font-medium rounded border border-red-300 text-red-600 bg-white hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-red-400"
:aria-label="`Delete account for ${lu.username}`"
:aria-label="`{{ _('common.delete') }} ${lu.username}`"
>
<i class="fas fa-trash mr-1" aria-hidden="true"></i> Delete
<i class="fas fa-trash mr-1" aria-hidden="true"></i> {{ _("common.delete") }}
</button>
</td>
</tr>
@@ -562,44 +562,44 @@
>
<div class="bg-white rounded-lg shadow-xl w-full max-w-lg" @click.outside="localUserModal.open = false">
<div class="px-6 py-4 border-b flex items-center justify-between">
<h2 id="create-local-user-title" class="text-lg font-semibold text-gray-900">Create Local Account</h2>
<button type="button" @click="localUserModal.open = false" aria-label="Close" class="text-gray-400 hover:text-gray-600">
<h2 id="create-local-user-title" class="text-lg font-semibold text-gray-900">{{ _("admin_users.create_local_title") }}</h2>
<button type="button" @click="localUserModal.open = false" aria-label="{{ _('common.close') }}" class="text-gray-400 hover:text-gray-600">
<i class="fas fa-times" aria-hidden="true"></i>
</button>
</div>
<form @submit.prevent="submitCreateLocalUser" class="px-6 py-5 space-y-4">
<div>
<label for="lu-email" class="block text-sm font-medium text-gray-700">Email <span aria-hidden="true" class="text-red-500">*</span></label>
<label for="lu-email" class="block text-sm font-medium text-gray-700">{{ _("admin_users.col_email") }} <span aria-hidden="true" class="text-red-500">*</span></label>
<input type="email" id="lu-email" x-model="localUserModal.form.email" required autocomplete="off"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-green-500 focus:ring focus:ring-green-500 focus:ring-opacity-50 text-sm"
style="min-height:40px;" aria-required="true">
</div>
<div>
<label for="lu-username" class="block text-sm font-medium text-gray-700">Username <span aria-hidden="true" class="text-red-500">*</span></label>
<label for="lu-username" class="block text-sm font-medium text-gray-700">{{ _("admin_users.col_username") }} <span aria-hidden="true" class="text-red-500">*</span></label>
<input type="text" id="lu-username" x-model="localUserModal.form.username" required autocomplete="off"
pattern="^[a-zA-Z0-9_-]+$" minlength="3" maxlength="64"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-green-500 focus:ring focus:ring-green-500 focus:ring-opacity-50 text-sm"
style="min-height:40px;" aria-required="true" aria-describedby="lu-username-hint">
<p id="lu-username-hint" class="mt-1 text-xs text-gray-500">364 characters. Letters, numbers, hyphens and underscores only.</p>
<p id="lu-username-hint" class="mt-1 text-xs text-gray-500">{{ _("admin_users.local_username_hint") }}</p>
</div>
<div>
<label for="lu-display-name" class="block text-sm font-medium text-gray-700">Display Name <span class="text-gray-400">(optional)</span></label>
<label for="lu-display-name" class="block text-sm font-medium text-gray-700">{{ _("admin_users.col_display_name") }} <span class="text-gray-400">{{ _("admin_users.local_display_name_optional") }}</span></label>
<input type="text" id="lu-display-name" x-model="localUserModal.form.display_name" autocomplete="off" maxlength="255"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-green-500 focus:ring focus:ring-green-500 focus:ring-opacity-50 text-sm"
style="min-height:40px;">
</div>
<div>
<label for="lu-password" class="block text-sm font-medium text-gray-700">Password <span aria-hidden="true" class="text-red-500">*</span></label>
<label for="lu-password" class="block text-sm font-medium text-gray-700">{{ _("auth.password_label") }} <span aria-hidden="true" class="text-red-500">*</span></label>
<input type="password" id="lu-password" x-model="localUserModal.form.password" required autocomplete="new-password"
minlength="8" maxlength="128"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-green-500 focus:ring focus:ring-green-500 focus:ring-opacity-50 text-sm"
style="min-height:40px;" aria-required="true" aria-describedby="lu-password-hint">
<p id="lu-password-hint" class="mt-1 text-xs text-gray-500">Minimum 8 characters.</p>
<p id="lu-password-hint" class="mt-1 text-xs text-gray-500">{{ _("admin_users.local_password_hint") }}</p>
</div>
<div class="flex items-center gap-2">
<input type="checkbox" id="lu-is-admin" x-model="localUserModal.form.is_admin"
class="h-4 w-4 rounded border-gray-300 text-green-600 focus:ring-green-500">
<label for="lu-is-admin" class="text-sm text-gray-700">Grant admin privileges</label>
<label for="lu-is-admin" class="text-sm text-gray-700">{{ _("admin_users.local_admin_privileges") }}</label>
</div>
<div x-show="localUserModal.error" x-cloak
class="bg-red-50 border-l-4 border-red-500 text-red-700 p-3 rounded text-sm"
@@ -608,12 +608,12 @@
<div class="flex justify-end gap-3 pt-2">
<button type="button" @click="localUserModal.open = false"
class="px-4 py-2 text-sm font-medium border border-gray-300 rounded-md text-gray-700 bg-white hover:bg-gray-50">
Cancel
{{ _("common.cancel") }}
</button>
<button type="submit" :disabled="localUserModal.saving"
class="px-4 py-2 text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-green-500 disabled:opacity-50">
<span x-show="!localUserModal.saving">Create Account</span>
<span x-show="localUserModal.saving" x-cloak><i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i> Creating</span>
<span x-show="!localUserModal.saving">{{ _("admin_users.local_create_btn") }}</span>
<span x-show="localUserModal.saving" x-cloak><i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i> {{ _("admin_users.local_creating") }}</span>
</button>
</div>
</form>
@@ -633,20 +633,20 @@
>
<div class="bg-white rounded-lg shadow-xl w-full max-w-lg" @click.outside="editLocalUserModal.open = false">
<div class="px-6 py-4 border-b flex items-center justify-between">
<h2 id="edit-local-user-title" class="text-lg font-semibold text-gray-900">Edit Local Account</h2>
<button type="button" @click="editLocalUserModal.open = false" aria-label="Close" class="text-gray-400 hover:text-gray-600">
<h2 id="edit-local-user-title" class="text-lg font-semibold text-gray-900">{{ _("admin_users.edit_local_title") }}</h2>
<button type="button" @click="editLocalUserModal.open = false" aria-label="{{ _('common.close') }}" class="text-gray-400 hover:text-gray-600">
<i class="fas fa-times" aria-hidden="true"></i>
</button>
</div>
<form @submit.prevent="submitEditLocalUser" class="px-6 py-5 space-y-4">
<div>
<label for="elu-email" class="block text-sm font-medium text-gray-700">Email <span aria-hidden="true" class="text-red-500">*</span></label>
<label for="elu-email" class="block text-sm font-medium text-gray-700">{{ _("admin_users.col_email") }} <span aria-hidden="true" class="text-red-500">*</span></label>
<input type="email" id="elu-email" x-model="editLocalUserModal.form.email" required autocomplete="off"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-500 focus:ring-opacity-50 text-sm"
style="min-height:40px;" aria-required="true">
</div>
<div>
<label for="elu-display-name" class="block text-sm font-medium text-gray-700">Display Name <span class="text-gray-400">(optional)</span></label>
<label for="elu-display-name" class="block text-sm font-medium text-gray-700">{{ _("admin_users.col_display_name") }} <span class="text-gray-400">{{ _("admin_users.local_display_name_optional") }}</span></label>
<input type="text" id="elu-display-name" x-model="editLocalUserModal.form.display_name" autocomplete="off" maxlength="255"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-500 focus:ring-opacity-50 text-sm"
style="min-height:40px;">
@@ -654,12 +654,12 @@
<div class="flex items-center gap-2">
<input type="checkbox" id="elu-is-admin" x-model="editLocalUserModal.form.is_admin"
class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500">
<label for="elu-is-admin" class="text-sm text-gray-700">Admin privileges</label>
<label for="elu-is-admin" class="text-sm text-gray-700">{{ _("admin_users.local_admin_privileges_short") }}</label>
</div>
<div class="flex items-center gap-2">
<input type="checkbox" id="elu-is-active" x-model="editLocalUserModal.form.is_active"
class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500">
<label for="elu-is-active" class="text-sm text-gray-700">Account active</label>
<label for="elu-is-active" class="text-sm text-gray-700">{{ _("admin_users.local_account_active") }}</label>
</div>
<div x-show="editLocalUserModal.error" x-cloak
class="bg-red-50 border-l-4 border-red-500 text-red-700 p-3 rounded text-sm"
@@ -668,12 +668,12 @@
<div class="flex justify-end gap-3 pt-2">
<button type="button" @click="editLocalUserModal.open = false"
class="px-4 py-2 text-sm font-medium border border-gray-300 rounded-md text-gray-700 bg-white hover:bg-gray-50">
Cancel
{{ _("common.cancel") }}
</button>
<button type="submit" :disabled="editLocalUserModal.saving"
class="px-4 py-2 text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-blue-500 disabled:opacity-50">
<span x-show="!editLocalUserModal.saving">Save Changes</span>
<span x-show="editLocalUserModal.saving" x-cloak><i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i> Saving…</span>
<span x-show="!editLocalUserModal.saving">{{ _("admin_users.modal_save_changes") }}</span>
<span x-show="editLocalUserModal.saving" x-cloak><i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i> {{ _("admin_users.local_saving") }}</span>
</button>
</div>
</form>
@@ -693,23 +693,23 @@
>
<div class="bg-white rounded-lg shadow-xl w-full max-w-md" @click.outside="setPasswordModal.open = false">
<div class="px-6 py-4 border-b flex items-center justify-between">
<h2 id="set-password-title" class="text-lg font-semibold text-gray-900">Set Temporary Password</h2>
<button type="button" @click="setPasswordModal.open = false" aria-label="Close" class="text-gray-400 hover:text-gray-600">
<h2 id="set-password-title" class="text-lg font-semibold text-gray-900">{{ _("admin_users.set_password_title") }}</h2>
<button type="button" @click="setPasswordModal.open = false" aria-label="{{ _('common.close') }}" class="text-gray-400 hover:text-gray-600">
<i class="fas fa-times" aria-hidden="true"></i>
</button>
</div>
<form @submit.prevent="submitSetPassword" class="px-6 py-5 space-y-4">
<p class="text-sm text-gray-600">
Set a new password directly for <strong class="font-mono" x-text="setPasswordModal.username"></strong>.
The user should change this password after logging in.
{{ _("admin_users.set_password_desc_pre") }} <strong class="font-mono" x-text="setPasswordModal.username"></strong>.
{{ _("admin_users.set_password_desc") }}
</p>
<div>
<label for="sp-password" class="block text-sm font-medium text-gray-700">New Password <span aria-hidden="true" class="text-red-500">*</span></label>
<label for="sp-password" class="block text-sm font-medium text-gray-700">{{ _("admin_users.new_password_label") }} <span aria-hidden="true" class="text-red-500">*</span></label>
<input type="password" id="sp-password" x-model="setPasswordModal.password" required autocomplete="new-password"
minlength="8" maxlength="128"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-yellow-500 focus:ring focus:ring-yellow-500 focus:ring-opacity-50 text-sm"
style="min-height:40px;" aria-required="true" aria-describedby="sp-password-hint">
<p id="sp-password-hint" class="mt-1 text-xs text-gray-500">Minimum 8 characters.</p>
<p id="sp-password-hint" class="mt-1 text-xs text-gray-500">{{ _("admin_users.local_password_hint") }}</p>
</div>
<div x-show="setPasswordModal.error" x-cloak
class="bg-red-50 border-l-4 border-red-500 text-red-700 p-3 rounded text-sm"
@@ -718,12 +718,12 @@
<div class="flex justify-end gap-3 pt-2">
<button type="button" @click="setPasswordModal.open = false"
class="px-4 py-2 text-sm font-medium border border-gray-300 rounded-md text-gray-700 bg-white hover:bg-gray-50">
Cancel
{{ _("common.cancel") }}
</button>
<button type="submit" :disabled="setPasswordModal.saving"
class="px-4 py-2 text-sm font-medium rounded-md text-white bg-yellow-600 hover:bg-yellow-700 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-yellow-500 disabled:opacity-50">
<span x-show="!setPasswordModal.saving">Set Password</span>
<span x-show="setPasswordModal.saving" x-cloak><i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i> Setting</span>
<span x-show="!setPasswordModal.saving">{{ _("admin_users.set_password_btn") }}</span>
<span x-show="setPasswordModal.saving" x-cloak><i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i> {{ _("admin_users.setting") }}</span>
</button>
</div>
</form>
@@ -743,25 +743,25 @@
>
<div class="bg-white rounded-lg shadow-xl w-full max-w-md" @click.outside="deleteLocalUserModal.open = false">
<div class="px-6 py-4 border-b">
<h2 id="delete-local-user-title" class="text-lg font-semibold text-gray-900">Delete Local Account</h2>
<h2 id="delete-local-user-title" class="text-lg font-semibold text-gray-900">{{ _("admin_users.delete_local_title") }}</h2>
</div>
<div class="px-6 py-5">
<p class="text-sm text-gray-700">
Are you sure you want to delete the account for
{{ _("admin_users.delete_local_confirm") }}
<strong class="font-mono" x-text="deleteLocalUserModal.username"></strong>
(<span class="font-mono" x-text="deleteLocalUserModal.email"></span>)?
</p>
<p class="text-sm text-gray-500 mt-2">This cannot be undone. Documents owned by this user are <strong>not</strong> deleted.</p>
<p class="text-sm text-gray-500 mt-2">{{ _("admin_users.delete_local_warning") }}</p>
</div>
<div class="px-6 py-4 border-t flex justify-end gap-3">
<button type="button" @click="deleteLocalUserModal.open = false"
class="px-4 py-2 text-sm font-medium border border-gray-300 rounded-md text-gray-700 bg-white hover:bg-gray-50">
Cancel
{{ _("common.cancel") }}
</button>
<button type="button" @click="executeDeleteLocalUser()" :disabled="deleteLocalUserModal.deleting"
class="px-4 py-2 text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 disabled:opacity-50">
<span x-show="!deleteLocalUserModal.deleting">Delete Account</span>
<span x-show="deleteLocalUserModal.deleting" x-cloak><i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i> Deleting</span>
<span x-show="!deleteLocalUserModal.deleting">{{ _("admin_users.delete_account_btn") }}</span>
<span x-show="deleteLocalUserModal.deleting" x-cloak><i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i> {{ _("admin_users.deleting") }}</span>
</button>
</div>
</div>
@@ -780,15 +780,15 @@
>
<div class="bg-white rounded-lg shadow-xl w-full max-w-md" @click.outside="deleteModal.open = false">
<div class="px-6 py-4 border-b">
<h2 id="delete-modal-title" class="text-lg font-semibold text-gray-900">Delete User Profile</h2>
<h2 id="delete-modal-title" class="text-lg font-semibold text-gray-900">{{ _("admin_users.delete_profile_title") }}</h2>
</div>
<div class="px-6 py-5">
<p class="text-sm text-gray-700">
Are you sure you want to delete the profile for
{{ _("admin_users.delete_profile_confirm") }}
<strong class="font-mono" x-text="deleteModal.user_id"></strong>?
</p>
<p class="text-sm text-gray-500 mt-2">
This only removes the admin-managed profile record. Documents owned by this user are <strong>not</strong> deleted.
{{ _("admin_users.delete_profile_warning") }}
</p>
</div>
<div class="px-6 py-4 border-t flex justify-end gap-3">
@@ -797,7 +797,7 @@
@click="deleteModal.open = false"
class="px-4 py-2 text-sm font-medium border border-gray-300 rounded-md text-gray-700 bg-white hover:bg-gray-50"
>
Cancel
{{ _("common.cancel") }}
</button>
<button
type="button"
@@ -805,8 +805,8 @@
:disabled="deleteModal.deleting"
class="px-4 py-2 text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 disabled:opacity-50"
>
<span x-show="!deleteModal.deleting">Delete Profile</span>
<span x-show="deleteModal.deleting"><i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i> Deleting</span>
<span x-show="!deleteModal.deleting">{{ _("admin_users.delete_profile_btn") }}</span>
<span x-show="deleteModal.deleting"><i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i> {{ _("admin_users.deleting") }}</span>
</button>
</div>
</div>
@@ -814,8 +814,43 @@
</div>
<script>
window.__i18nAdminUsers = {
noUsers: {{ _("admin_users.total_no_users") | tojson }},
oneUser: {{ _("admin_users.total_one_user") | tojson }},
countUsers: {{ _("admin_users.total_count_users") | tojson }},
modalAddTitle: {{ _("admin_users.modal_add_title") | tojson }},
modalEditTitle: {{ _("admin_users.modal_edit_title") | tojson }},
failedLoadUsers: {{ _("admin_users.js_failed_load_users") | tojson }},
networkError: {{ _("admin_users.js_network_error") | tojson }},
saveFailed: {{ _("admin_users.js_save_failed") | tojson }},
saved: {{ _("admin_users.js_saved") | tojson }},
profileSaved: {{ _("admin_users.js_profile_saved") | tojson }},
deleteFailed: {{ _("admin_users.js_delete_failed") | tojson }},
deleted: {{ _("admin_users.js_deleted") | tojson }},
profileDeleted: {{ _("admin_users.js_profile_deleted") | tojson }},
failedLoadLocal: {{ _("admin_users.js_failed_load_local") | tojson }},
accountCreated: {{ _("admin_users.js_account_created") | tojson }},
accountCreatedMsg: {{ _("admin_users.js_account_created_msg") | tojson }},
failedCreate: {{ _("admin_users.js_failed_create") | tojson }},
accountDeletedMsg: {{ _("admin_users.js_account_deleted_msg") | tojson }},
updated: {{ _("admin_users.js_updated") | tojson }},
accountUpdated: {{ _("admin_users.js_account_updated") | tojson }},
failedUpdate: {{ _("admin_users.js_failed_update") | tojson }},
passwordSet: {{ _("admin_users.js_password_set") | tojson }},
passwordSetMsg: {{ _("admin_users.js_password_set_msg") | tojson }},
failedSetPassword: {{ _("admin_users.js_failed_set_password") | tojson }},
emailSent: {{ _("admin_users.js_email_sent") | tojson }},
emailSentMsg: {{ _("admin_users.js_email_sent_msg") | tojson }},
emailNotSent: {{ _("admin_users.js_email_not_sent") | tojson }},
smtpNotConfigured: {{ _("admin_users.js_smtp_not_configured") | tojson }},
failed: {{ _("admin_users.js_failed") | tojson }},
};
</script>
<script>
function adminUsersApp() {
const i18n = window.__i18nAdminUsers;
return {
// State
users: [],
@@ -887,8 +922,9 @@ function adminUsersApp() {
get totalLabel() {
if (this.loading) return '';
if (this.total === 0) return 'No users';
return `${this.total} user${this.total !== 1 ? 's' : ''}`;
if (this.total === 0) return i18n.noUsers;
if (this.total === 1) return i18n.oneUser;
return i18n.countUsers.replace('{count}', this.total);
},
async init() {
@@ -908,7 +944,7 @@ function adminUsersApp() {
const resp = await fetch(`/api/admin/users/?${params}`);
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
this.showAlert('error', 'Failed to load users', err.detail || resp.statusText);
this.showAlert('error', i18n.failedLoadUsers, err.detail || resp.statusText);
return;
}
const data = await resp.json();
@@ -916,7 +952,7 @@ function adminUsersApp() {
this.total = data.total;
this.pages = data.pages;
} catch (e) {
this.showAlert('error', 'Network error', e.message);
this.showAlert('error', i18n.networkError, e.message);
} finally {
this.loading = false;
}
@@ -924,14 +960,14 @@ function adminUsersApp() {
openCreateModal() {
this.isCreate = true;
this.modalTitle = 'Add User Profile';
this.modalTitle = i18n.modalAddTitle;
this.form = { user_id: '', display_name: '', daily_upload_limit: null, notes: '', is_blocked: false, subscription_tier: 'free', subscription_billing_cycle: 'monthly', subscription_period_start: null, is_complimentary: false };
this.modalOpen = true;
},
openEditModal(user) {
this.isCreate = false;
this.modalTitle = 'Edit User Profile';
this.modalTitle = i18n.modalEditTitle;
this.form = {
user_id: user.user_id,
display_name: user.display_name || '',
@@ -970,14 +1006,14 @@ function adminUsersApp() {
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
this.showAlert('error', 'Save failed', err.detail || resp.statusText);
this.showAlert('error', i18n.saveFailed, err.detail || resp.statusText);
return;
}
this.modalOpen = false;
this.showAlert('success', 'Saved', `Profile for "${this.form.user_id}" has been saved.`);
this.showAlert('success', i18n.saved, i18n.profileSaved.replace('{id}', this.form.user_id));
await this.fetchUsers(this.currentPage);
} catch (e) {
this.showAlert('error', 'Network error', e.message);
this.showAlert('error', i18n.networkError, e.message);
} finally {
this.saving = false;
}
@@ -1001,15 +1037,15 @@ function adminUsersApp() {
});
if (resp.status === 204) {
this.deleteModal.open = false;
this.showAlert('success', 'Deleted', `Profile for "${this.deleteModal.user_id}" has been removed.`);
this.showAlert('success', i18n.deleted, i18n.profileDeleted.replace('{id}', this.deleteModal.user_id));
await this.fetchUsers(this.currentPage);
} else {
const err = await resp.json().catch(() => ({}));
this.showAlert('error', 'Delete failed', err.detail || resp.statusText);
this.showAlert('error', i18n.deleteFailed, err.detail || resp.statusText);
this.deleteModal.open = false;
}
} catch (e) {
this.showAlert('error', 'Network error', e.message);
this.showAlert('error', i18n.networkError, e.message);
this.deleteModal.open = false;
} finally {
this.deleteModal.deleting = false;
@@ -1044,12 +1080,12 @@ function adminUsersApp() {
headers: { 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || '' },
});
if (!resp.ok) {
this.showAlert('error', 'Failed to load local users', resp.statusText);
this.showAlert('error', i18n.failedLoadLocal, resp.statusText);
return;
}
this.localUsers = await resp.json();
} catch (e) {
this.showAlert('error', 'Network error', e.message);
this.showAlert('error', i18n.networkError, e.message);
} finally {
this.localUsersLoading = false;
}
@@ -1076,14 +1112,14 @@ function adminUsersApp() {
});
if (resp.ok) {
this.localUserModal.open = false;
this.showAlert('success', 'Account created', `Local account for "${this.localUserModal.form.username}" was created successfully.`);
this.showAlert('success', i18n.accountCreated, i18n.accountCreatedMsg.replace('{username}', this.localUserModal.form.username));
await this.fetchLocalUsers();
} else {
const err = await resp.json().catch(() => ({}));
this.localUserModal.error = err.detail || 'Failed to create account.';
this.localUserModal.error = err.detail || i18n.failedCreate;
}
} catch (e) {
this.localUserModal.error = 'Network error: ' + e.message;
this.localUserModal.error = i18n.networkError + ': ' + e.message;
} finally {
this.localUserModal.saving = false;
}
@@ -1129,14 +1165,14 @@ function adminUsersApp() {
});
if (resp.ok) {
this.editLocalUserModal.open = false;
this.showAlert('success', 'Updated', `Account has been updated.`);
this.showAlert('success', i18n.updated, i18n.accountUpdated);
await this.fetchLocalUsers();
} else {
const err = await resp.json().catch(() => ({}));
this.editLocalUserModal.error = err.detail || 'Failed to update account.';
this.editLocalUserModal.error = err.detail || i18n.failedUpdate;
}
} catch (e) {
this.editLocalUserModal.error = 'Network error: ' + e.message;
this.editLocalUserModal.error = i18n.networkError + ': ' + e.message;
} finally {
this.editLocalUserModal.saving = false;
}
@@ -1165,13 +1201,13 @@ function adminUsersApp() {
});
if (resp.ok) {
this.setPasswordModal.open = false;
this.showAlert('success', 'Password set', `Password for "${this.setPasswordModal.username}" has been updated.`);
this.showAlert('success', i18n.passwordSet, i18n.passwordSetMsg.replace('{username}', this.setPasswordModal.username));
} else {
const err = await resp.json().catch(() => ({}));
this.setPasswordModal.error = err.detail || 'Failed to set password.';
this.setPasswordModal.error = err.detail || i18n.failedSetPassword;
}
} catch (e) {
this.setPasswordModal.error = 'Network error: ' + e.message;
this.setPasswordModal.error = i18n.networkError + ': ' + e.message;
} finally {
this.setPasswordModal.saving = false;
}
@@ -1187,14 +1223,14 @@ function adminUsersApp() {
});
const data = await resp.json().catch(() => ({}));
if (resp.ok && data.sent) {
this.showAlert('success', 'Email sent', `Password reset email sent to "${lu.email}".`);
this.showAlert('success', i18n.emailSent, i18n.emailSentMsg.replace('{email}', lu.email));
} else if (resp.ok && !data.sent) {
this.showAlert('error', 'Email not sent', data.reason || 'SMTP is not configured.');
this.showAlert('error', i18n.emailNotSent, data.reason || i18n.smtpNotConfigured);
} else {
this.showAlert('error', 'Failed', data.detail || resp.statusText);
this.showAlert('error', i18n.failed, data.detail || resp.statusText);
}
} catch (e) {
this.showAlert('error', 'Network error', e.message);
this.showAlert('error', i18n.networkError, e.message);
}
},
@@ -1207,15 +1243,15 @@ function adminUsersApp() {
});
if (resp.status === 204) {
this.deleteLocalUserModal.open = false;
this.showAlert('success', 'Deleted', `Account for "${this.deleteLocalUserModal.username}" has been removed.`);
this.showAlert('success', i18n.deleted, i18n.accountDeletedMsg.replace('{username}', this.deleteLocalUserModal.username));
await this.fetchLocalUsers();
} else {
const err = await resp.json().catch(() => ({}));
this.showAlert('error', 'Delete failed', err.detail || resp.statusText);
this.showAlert('error', i18n.deleteFailed, err.detail || resp.statusText);
this.deleteLocalUserModal.open = false;
}
} catch (e) {
this.showAlert('error', 'Network error', e.message);
this.showAlert('error', i18n.networkError, e.message);
this.deleteLocalUserModal.open = false;
} finally {
this.deleteLocalUserModal.deleting = false;
+14 -14
View File
@@ -1,40 +1,40 @@
{% extends "base.html" %}
{% block title %}DocuElevate - Third-Party Attributions{% endblock %}
{% block title %}{{ _("attribution.page_title") }}{% endblock %}
{% block content %}
<div class="container mx-auto px-4 py-8">
<h1 class="text-2xl font-bold mb-4">Third-Party Software Attributions</h1>
<h1 class="text-2xl font-bold mb-4">{{ _("attribution.heading") }}</h1>
<div class="bg-white shadow-md rounded-lg p-6 mb-6">
<p class="mb-4">
DocuElevate uses several open source libraries and tools. We are grateful to the
developers of these projects for their contributions to open source software.
{{ _("attribution.intro") }}
</p>
<div class="bg-yellow-50 border-l-4 border-yellow-400 p-4 mb-6">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-yellow-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<svg class="h-5 w-5 text-yellow-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<p class="text-sm text-yellow-700">
<strong>Special Attribution:</strong> This software includes Paramiko, which is licensed under LGPL. The source code for Paramiko is available at
<strong>{{ _("attribution.special_title") }}</strong>
{{ _("attribution.special_source_pre") }}
<a href="https://github.com/paramiko/paramiko" class="font-medium underline text-yellow-700 hover:text-yellow-600">
https://github.com/paramiko/paramiko
</a>.
A copy of the LGPL license can be found
</a>{{ _("attribution.special_source_post") }}
{{ _("attribution.special_lgpl_pre") }}
<a href="/static/licenses/lgpl.txt" class="font-medium underline text-yellow-700 hover:text-yellow-600">
here
</a>.
{{ _("attribution.special_lgpl_link") }}
</a>{{ _("attribution.special_lgpl_post") }}
</p>
</div>
</div>
</div>
<h2 class="text-xl font-semibold mb-2">Python Dependencies</h2>
<h2 class="text-xl font-semibold mb-2">{{ _("attribution.section_python") }}</h2>
<ul class="list-disc pl-5 mb-4">
<li class="mb-2">
<span class="font-semibold">FastAPI</span> (MIT License)<br>
@@ -130,7 +130,7 @@
<span class="font-semibold">Paramiko</span> (LGPL-2.1 License)<br>
Copyright (c) 2003-2009 Robey Pointer<br>
<a href="https://github.com/paramiko/paramiko" class="text-blue-600 hover:underline">https://github.com/paramiko/paramiko</a><br>
<span class="text-sm text-gray-600">Note: This library is licensed under the GNU Lesser General Public License v2.1 (LGPL-2.1)</span>
<span class="text-sm text-gray-600">{{ _("attribution.paramiko_lgpl_note") }}</span>
</li>
<li class="mb-2">
<span class="font-semibold">Apprise</span> (MIT License)<br>
@@ -139,7 +139,7 @@
</li>
</ul>
<h2 class="text-xl font-semibold mb-2">Docker Images</h2>
<h2 class="text-xl font-semibold mb-2">{{ _("attribution.section_docker") }}</h2>
<ul class="list-disc pl-5 mb-4">
<li class="mb-2">
<span class="font-semibold">Redis</span> (BSD License)<br>
@@ -153,7 +153,7 @@
</li>
</ul>
<h2 class="text-xl font-semibold mb-2">Frontend Dependencies</h2>
<h2 class="text-xl font-semibold mb-2">{{ _("attribution.section_frontend") }}</h2>
<ul class="list-disc pl-5 mb-4">
<li class="mb-2">
<span class="font-semibold">Tailwind CSS</span> (MIT License)<br>
+84 -20
View File
@@ -216,11 +216,31 @@
aria-live="polite"></span>
</a>
<!-- Language selector data (kept outside the HTML attribute to avoid quote conflicts with tojson) -->
<script>
window.__langSuggested = {{ suggested_languages | tojson }};
window.__langAll = {{ supported_languages | tojson }};
</script>
<!-- Language selector dropdown -->
<div x-data="{ langOpen: false }" class="relative">
<div x-data="{
langOpen: false,
search: '',
suggested: window.__langSuggested,
all: window.__langAll,
get filtered() {
if (!this.search.trim()) return this.suggested;
const q = this.search.trim().toLowerCase();
return this.all.filter(l =>
l.native.toLowerCase().includes(q) ||
l.name.toLowerCase().includes(q) ||
l.code.toLowerCase().startsWith(q)
);
}
}" class="relative" @keydown.escape="langOpen = false; search = ''">
<button
@click="langOpen = !langOpen"
@click.outside="langOpen = false"
@click="langOpen = !langOpen; if (langOpen) $nextTick(() => { if ($refs.langSearch) $refs.langSearch.focus(); })"
@click.outside="langOpen = false; search = ''"
type="button"
class="p-1.5 rounded-md text-gray-500 hover:text-gray-700 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500"
aria-label="{{ _('language.selector') }}"
@@ -228,7 +248,10 @@
:aria-expanded="langOpen"
aria-haspopup="true"
>
<i class="fas fa-globe"></i>
<span class="text-base leading-none" aria-hidden="true">
{% set cur_lang = suggested_languages | selectattr("code", "equalto", current_locale) | list %}
{% if cur_lang %}{{ cur_lang[0].flag }}{% else %}🌐{% endif %}
</span>
</button>
<div
x-show="langOpen"
@@ -238,25 +261,49 @@
x-transition:leave="transition ease-in duration-75 transform"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
class="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg ring-1 ring-black ring-opacity-5 z-50"
class="absolute right-0 mt-2 w-56 bg-white dark:bg-gray-800 rounded-md shadow-lg ring-1 ring-black ring-opacity-5 z-50"
role="menu"
aria-label="{{ _('language.selector') }}"
>
<div class="py-1">
{% for lang in supported_languages %}
<button
type="button"
role="menuitem"
class="flex items-center w-full px-4 py-2 text-sm text-left hover:bg-gray-100 {% if current_locale == lang.code %}bg-blue-50 text-blue-700 font-medium{% else %}text-gray-700{% endif %}"
onclick="setLanguage('{{ lang.code }}')"
>
<span class="mr-2">{{ lang.flag }}</span>
<span>{{ lang.native }}</span>
{% if current_locale == lang.code %}
<i class="fas fa-check ml-auto text-blue-500" aria-hidden="true"></i>
{% endif %}
</button>
{% endfor %}
<!-- Search input -->
<div class="px-3 pt-2 pb-1">
<div class="relative">
<i class="fas fa-magnifying-glass absolute left-2 top-1/2 -translate-y-1/2 text-gray-400 text-xs pointer-events-none" aria-hidden="true"></i>
<input
x-ref="langSearch"
x-model="search"
type="search"
placeholder="{{ _('language.search_placeholder') }}"
class="w-full pl-6 pr-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-1 focus:ring-blue-400 dark:bg-gray-700 dark:text-white"
aria-label="{{ _('language.search_placeholder') }}"
autocomplete="off"
/>
</div>
</div>
<!-- Language list -->
<div class="py-1 max-h-56 overflow-y-auto" role="none">
<template x-for="lang in filtered" :key="lang.code">
<button
type="button"
role="menuitem"
:class="lang.code === '{{ current_locale }}' ? 'bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 font-medium' : 'text-gray-700 dark:text-gray-200'"
class="flex items-center w-full px-4 py-2 text-sm text-left hover:bg-gray-100 dark:hover:bg-gray-700"
:onclick="`setLanguage('${lang.code}')`"
>
<span class="mr-2 text-base" x-text="lang.flag"></span>
<span x-text="lang.native"></span>
<i x-show="lang.code === '{{ current_locale }}'" class="fas fa-check ml-auto text-blue-500" aria-hidden="true"></i>
</button>
</template>
<p
x-show="filtered.length === 0"
class="px-4 py-2 text-sm text-gray-400 dark:text-gray-500"
>{{ _('language.no_results') }}</p>
</div>
<!-- Footer hint -->
<div x-show="!search.trim()" class="px-3 py-1.5 text-xs text-gray-400 dark:text-gray-500 border-t border-gray-100 dark:border-gray-700 flex items-center justify-between">
<span x-text="`${suggested.length} / {{ supported_languages | length }}`"></span>
<button type="button" @click="$refs.langSearch.focus(); $refs.langSearch.select()" class="text-blue-500 hover:underline">{{ _('common.search') }}…</button>
</div>
</div>
</div>
@@ -501,6 +548,23 @@
})();
</script>
<!-- i18n strings for common.js (user menu) -->
<script>
window.__i18n = {
accountMenu: {{ _("nav.account_menu") | tojson }},
accountMenuFor: {{ _("nav.account_menu_for") | tojson }},
avatar: {{ _("common.avatar") | tojson }},
profileSettings: {{ _("nav.profile_settings") | tojson }},
mySubscription: {{ _("nav.my_subscription") | tojson }},
apiTokens: {{ _("nav.api_tokens") | tojson }},
sharedLinks: {{ _("nav.shared_links") | tojson }},
signOut: {{ _("nav.sign_out") | tojson }},
logIn: {{ _("nav.login") | tojson }},
signUp: {{ _("nav.signup") | tojson }},
getStarted: {{ _("nav.get_started") | tojson }},
};
</script>
<!-- Common JS (shared) -->
<script src="/static/js/common.js"></script>
+30 -29
View File
@@ -1,58 +1,58 @@
{% extends "base.html" %}
{% block title %}Cookie Policy - DocuElevate{% endblock %}
{% block title %}{{ _("cookie_policy.page_title") }}{% endblock %}
{% block content %}
<div class="container mx-auto px-4 py-8 max-w-4xl">
<h1 class="text-4xl font-bold mb-6">Cookie Policy</h1>
<p class="text-sm text-gray-500 mb-8">Last Updated: {{ build_date|default(current_date|default('May 14, 2024')) }}</p>
<h1 class="text-4xl font-bold mb-6">{{ _("cookie_policy.heading") }}</h1>
<p class="text-sm text-gray-500 mb-8">{{ _("cookie_policy.last_updated") }} {{ build_date|default(current_date|default('May 14, 2024')) }}</p>
<div class="bg-white shadow rounded-lg p-6 mb-6">
<h2 class="text-2xl font-semibold mb-4">What Are Cookies</h2>
<h2 class="text-2xl font-semibold mb-4">{{ _("cookie_policy.s1_heading") }}</h2>
<p class="text-gray-700 mb-3">
Cookies are small text files that are stored on your computer or mobile device when you visit a website. They are widely used to make websites work more efficiently and provide information to the website owners.
{{ _("cookie_policy.s1_p1") }}
</p>
</div>
<div class="bg-white shadow rounded-lg p-6 mb-6">
<h2 class="text-2xl font-semibold mb-4">How We Use Cookies</h2>
<h2 class="text-2xl font-semibold mb-4">{{ _("cookie_policy.s2_heading") }}</h2>
<p class="text-gray-700 mb-3">
DocuElevate uses <strong>only strictly necessary session cookies</strong> for the following purpose:
{{ _("cookie_policy.s2_p1_pre") }} <strong>{{ _("cookie_policy.s2_p1_strong") }}</strong> {{ _("cookie_policy.s2_p1_post") }}
</p>
<ul class="list-disc list-inside text-gray-700 ml-4 mb-3">
<li><strong>Authentication &amp; Session Management:</strong> To identify you when you sign in and maintain your session while you use the application.</li>
<li><strong>{{ _("cookie_policy.s2_li1_label") }}</strong> {{ _("cookie_policy.s2_li1_body") }}</li>
</ul>
<p class="text-gray-700 mb-3">
These cookies are mandatory for the proper functioning of our service. Without these cookies, you would be required to log in repeatedly during your browsing session.
{{ _("cookie_policy.s2_p2") }}
</p>
<p class="text-gray-700">
Because these cookies are strictly necessary for the service to function, they are exempt from prior-consent requirements under the EU ePrivacy Directive (Art. 5(3)) and equivalent national implementations. We do not set any optional, analytics, advertising, or tracking cookies.
{{ _("cookie_policy.s2_p3") }}
</p>
</div>
<div class="bg-white shadow rounded-lg p-6 mb-6">
<h2 class="text-2xl font-semibold mb-4">Cookie Details</h2>
<h2 class="text-2xl font-semibold mb-4">{{ _("cookie_policy.s3_heading") }}</h2>
<div class="overflow-x-auto">
<table class="min-w-full text-sm text-gray-700 border border-gray-200 rounded">
<table class="min-w-full text-sm text-gray-700 border border-gray-200 rounded" aria-label="{{ _('cookie_policy.s3_heading') }}">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-2 text-left font-semibold border-b">Name</th>
<th class="px-4 py-2 text-left font-semibold border-b">Type</th>
<th class="px-4 py-2 text-left font-semibold border-b">Purpose</th>
<th class="px-4 py-2 text-left font-semibold border-b">Duration</th>
<th class="px-4 py-2 text-left font-semibold border-b" scope="col">{{ _("cookie_policy.s3_col_name") }}</th>
<th class="px-4 py-2 text-left font-semibold border-b" scope="col">{{ _("cookie_policy.s3_col_type") }}</th>
<th class="px-4 py-2 text-left font-semibold border-b" scope="col">{{ _("cookie_policy.s3_col_purpose") }}</th>
<th class="px-4 py-2 text-left font-semibold border-b" scope="col">{{ _("cookie_policy.s3_col_duration") }}</th>
</tr>
</thead>
<tbody>
<tr class="border-b">
<td class="px-4 py-2 font-mono">session</td>
<td class="px-4 py-2">Strictly Necessary</td>
<td class="px-4 py-2">Maintains your authenticated session; required for login to function.</td>
<td class="px-4 py-2">Session (deleted on browser close or logout)</td>
<td class="px-4 py-2">{{ _("cookie_policy.s3_row1_type") }}</td>
<td class="px-4 py-2">{{ _("cookie_policy.s3_row1_purpose") }}</td>
<td class="px-4 py-2">{{ _("cookie_policy.s3_row1_duration") }}</td>
</tr>
<tr>
<td class="px-4 py-2 font-mono">cookieNoticeDismissed</td>
<td class="px-4 py-2">Strictly Necessary</td>
<td class="px-4 py-2">Stores your acknowledgement of the cookie notice so it is not shown repeatedly (stored in localStorage, not a cookie).</td>
<td class="px-4 py-2">Persistent (browser localStorage)</td>
<td class="px-4 py-2">{{ _("cookie_policy.s3_row2_type") }}</td>
<td class="px-4 py-2">{{ _("cookie_policy.s3_row2_purpose") }}</td>
<td class="px-4 py-2">{{ _("cookie_policy.s3_row2_duration") }}</td>
</tr>
</tbody>
</table>
@@ -60,25 +60,26 @@
</div>
<div class="bg-white shadow rounded-lg p-6 mb-6">
<h2 class="text-2xl font-semibold mb-4">No Third-Party Cookies</h2>
<h2 class="text-2xl font-semibold mb-4">{{ _("cookie_policy.s4_heading") }}</h2>
<p class="text-gray-700 mb-3">
DocuElevate does not use any third-party cookies, tracking cookies, advertising cookies, or analytics cookies. We respect your privacy and only implement the minimum cookies required for our service to function.
{{ _("cookie_policy.s4_p1") }}
</p>
<p class="text-gray-700">
For more information about how we handle your data, please see our <a href="/privacy" class="text-blue-600 hover:underline">Privacy Notice</a>.
{{ _("cookie_policy.s4_p2_pre") }} <a href="/privacy" class="text-blue-600 hover:underline">{{ _("cookie_policy.s4_privacy_link") }}</a>.
</p>
</div>
<div class="bg-white shadow rounded-lg p-6 mb-6">
<h2 class="text-2xl font-semibold mb-4">Managing Cookies</h2>
<h2 class="text-2xl font-semibold mb-4">{{ _("cookie_policy.s5_heading") }}</h2>
<p class="text-gray-700 mb-3">
Most web browsers allow you to control cookies through their settings. However, blocking or deleting our session cookies will prevent DocuElevate from functioning, as user authentication relies on these cookies.
{{ _("cookie_policy.s5_p1") }}
</p>
<p class="text-gray-700 mb-3">
You may also clear the cookie notice acknowledgement stored in your browser's localStorage at any time via your browser's developer tools (Application &rarr; Local Storage).
{{ _("cookie_policy.s5_p2") }}
</p>
<p class="text-gray-700">
This Cookie Policy is part of and incorporated into our <a href="/terms" class="text-blue-600 hover:underline">Terms of Service</a> and <a href="/privacy" class="text-blue-600 hover:underline">Privacy Notice</a>.
{{ _("cookie_policy.s5_p3_pre") }} <a href="/terms" class="text-blue-600 hover:underline">{{ _("cookie_policy.s5_terms_link") }}</a>
{{ _("cookie_policy.s5_p3_and") }} <a href="/privacy" class="text-blue-600 hover:underline">{{ _("cookie_policy.s5_privacy_link") }}</a>.
</p>
</div>
</div>
+64 -65
View File
@@ -1,5 +1,5 @@
{% extends "base.html" %}
{% block title %}File Manager Admin{% endblock %}
{% block title %}{{ _("admin_files.page_title") }}{% endblock %}
{% block content %}
<div class="container mx-auto px-4 py-8">
@@ -8,9 +8,9 @@
<div class="mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
<h1 class="text-3xl font-bold text-gray-900 flex items-center gap-2">
<i class="fas fa-folder-open text-blue-500"></i>
File Manager
<span class="text-xs font-semibold text-red-600 bg-red-50 border border-red-200 rounded px-2 py-0.5 ml-1">Admin Only</span>
<i class="fas fa-folder-open text-blue-500" aria-hidden="true"></i>
{{ _("admin_files.heading") }}
<span class="text-xs font-semibold text-red-600 bg-red-50 border border-red-200 rounded px-2 py-0.5 ml-1">{{ _("admin_files.admin_only_badge") }}</span>
</h1>
<p class="text-gray-400 text-sm mt-1">
<code class="bg-gray-100 rounded px-1 text-xs">{{ workdir }}</code>
@@ -19,15 +19,15 @@
<!-- Summary badges -->
<div class="flex gap-3 text-sm flex-wrap">
<span class="inline-flex items-center gap-1.5 bg-blue-50 border border-blue-200 text-blue-700 rounded-full px-3 py-1">
<i class="fas fa-hdd text-xs"></i>
{% if total_disk is none %}…{% else %}{{ total_disk }}{% endif %} on disk
<i class="fas fa-hdd text-xs" aria-hidden="true"></i>
{% if total_disk is none %}…{% else %}{{ total_disk }}{% endif %} {{ _("admin_files.badge_on_disk") }}
</span>
<span class="inline-flex items-center gap-1.5 bg-green-50 border border-green-200 text-green-700 rounded-full px-3 py-1">
<i class="fas fa-database text-xs"></i> {{ total_db }} in DB
<i class="fas fa-database text-xs" aria-hidden="true"></i> {{ total_db }} {{ _("admin_files.badge_in_db") }}
</span>
{% if total_disk is not none and total_disk != total_db %}
<span class="inline-flex items-center gap-1.5 bg-amber-50 border border-amber-300 text-amber-700 rounded-full px-3 py-1 font-medium">
<i class="fas fa-triangle-exclamation text-xs"></i> Delta detected
<i class="fas fa-triangle-exclamation text-xs" aria-hidden="true"></i> {{ _("admin_files.badge_delta_detected") }}
</span>
{% endif %}
</div>
@@ -38,17 +38,17 @@
<a href="?view=filesystem"
class="tab-link px-5 py-2.5 text-sm font-medium rounded-t border-b-2 transition
{% if view == 'filesystem' %}border-blue-600 text-blue-600 bg-blue-50{% else %}border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300{% endif %}">
<i class="fas fa-folder-tree mr-1.5"></i>Filesystem
<i class="fas fa-folder-tree mr-1.5" aria-hidden="true"></i>{{ _("admin_files.tab_filesystem") }}
</a>
<a href="?view=database"
class="tab-link px-5 py-2.5 text-sm font-medium rounded-t border-b-2 transition
{% if view == 'database' %}border-green-600 text-green-600 bg-green-50{% else %}border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300{% endif %}">
<i class="fas fa-database mr-1.5"></i>Database Records
<i class="fas fa-database mr-1.5" aria-hidden="true"></i>{{ _("admin_files.tab_database") }}
</a>
<a href="?view=reconcile"
class="tab-link px-5 py-2.5 text-sm font-medium rounded-t border-b-2 transition
{% if view == 'reconcile' %}border-amber-500 text-amber-600 bg-amber-50{% else %}border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300{% endif %}">
<i class="fas fa-code-compare mr-1.5"></i>Reconcile
<i class="fas fa-code-compare mr-1.5" aria-hidden="true"></i>{{ _("admin_files.tab_reconcile") }}
{% if total_disk != total_db %}
<span class="ml-1 bg-amber-500 text-white rounded-full text-xs px-1.5 py-0.5">!</span>
{% endif %}
@@ -62,21 +62,21 @@
<!-- Legend -->
<div class="flex flex-wrap gap-4 text-xs text-gray-500 mb-4">
<span class="flex items-center gap-1"><span class="h-2.5 w-2.5 rounded-full bg-green-400 inline-block"></span> Found in DB</span>
<span class="flex items-center gap-1"><span class="h-2.5 w-2.5 rounded-full bg-amber-400 inline-block"></span> Not in DB (orphan)</span>
<span class="flex items-center gap-1"><span class="h-2.5 w-2.5 rounded-full bg-green-400 inline-block"></span> {{ _("admin_files.legend_found_in_db") }}</span>
<span class="flex items-center gap-1"><span class="h-2.5 w-2.5 rounded-full bg-amber-400 inline-block"></span> {{ _("admin_files.legend_not_in_db") }}</span>
</div>
<!-- Breadcrumb -->
<nav class="flex mb-4 text-sm" aria-label="Breadcrumb">
<nav class="flex mb-4 text-sm" aria-label="{{ _("admin_files.aria_breadcrumb") }}">
<ol class="inline-flex items-center space-x-1 flex-wrap">
<li>
<a href="?view=filesystem" class="text-blue-600 hover:underline flex items-center gap-1">
<i class="fas fa-home"></i> workdir
<i class="fas fa-home" aria-hidden="true"></i> {{ _("admin_files.breadcrumb_workdir") }}
</a>
</li>
{% for crumb in breadcrumbs %}
<li class="flex items-center">
<i class="fas fa-chevron-right text-gray-300 mx-1 text-xs"></i>
<i class="fas fa-chevron-right text-gray-300 mx-1 text-xs" aria-hidden="true"></i>
{% if loop.last %}
<span class="text-gray-700 font-medium">{{ crumb.name }}</span>
{% else %}
@@ -93,22 +93,22 @@
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left w-8"></th>
<th class="px-4 py-3 text-left font-semibold text-gray-600 uppercase tracking-wider text-xs">Name</th>
<th class="px-4 py-3 text-left font-semibold text-gray-600 uppercase tracking-wider text-xs hidden sm:table-cell">Size</th>
<th class="px-4 py-3 text-left font-semibold text-gray-600 uppercase tracking-wider text-xs hidden md:table-cell">Modified</th>
<th class="px-4 py-3 text-left font-semibold text-gray-600 uppercase tracking-wider text-xs">DB</th>
<th class="px-4 py-3 text-left font-semibold text-gray-600 uppercase tracking-wider text-xs">Actions</th>
<th class="px-4 py-3 text-left font-semibold text-gray-600 uppercase tracking-wider text-xs">{{ _("admin_files.col_name") }}</th>
<th class="px-4 py-3 text-left font-semibold text-gray-600 uppercase tracking-wider text-xs hidden sm:table-cell">{{ _("admin_files.col_size") }}</th>
<th class="px-4 py-3 text-left font-semibold text-gray-600 uppercase tracking-wider text-xs hidden md:table-cell">{{ _("admin_files.col_modified") }}</th>
<th class="px-4 py-3 text-left font-semibold text-gray-600 uppercase tracking-wider text-xs">{{ _("admin_files.col_db") }}</th>
<th class="px-4 py-3 text-left font-semibold text-gray-600 uppercase tracking-wider text-xs">{{ _("admin_files.col_actions") }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
{% if current_path %}
<tr class="hover:bg-gray-50">
<td class="px-4 py-3 text-yellow-400"><i class="fas fa-folder"></i></td>
<td class="px-4 py-3 text-yellow-400"><i class="fas fa-folder" aria-hidden="true"></i></td>
<td class="px-4 py-3 font-medium text-gray-900" colspan="4">
<a href="?view=filesystem{% if parent_path %}&path={{ parent_path | urlencode }}{% endif %}"
class="text-blue-600 hover:underline flex items-center gap-1">
<i class="fas fa-level-up-alt text-xs"></i> ..
<i class="fas fa-level-up-alt text-xs" aria-hidden="true"></i> ..
</a>
</td>
<td></td>
@@ -118,7 +118,7 @@
{% if fs_entries %}
{% for entry in fs_entries %}
<tr class="hover:bg-gray-50 {% if entry.db_status == 'orphan' %}bg-amber-50{% endif %}">
<td class="px-4 py-3"><i class="{{ entry.icon }}"></i></td>
<td class="px-4 py-3"><i class="{{ entry.icon }}" aria-hidden="true"></i></td>
<td class="px-4 py-3 font-medium text-gray-900 max-w-xs truncate">
{% if entry.is_dir %}
<a href="?view=filesystem&path={{ entry.rel_path | urlencode }}" class="text-blue-600 hover:underline">
@@ -133,11 +133,11 @@
<td class="px-4 py-3">
{% if entry.db_status == 'in_db' %}
<span class="inline-flex items-center gap-1 text-xs text-green-700 bg-green-50 border border-green-200 rounded-full px-2 py-0.5">
<span class="h-1.5 w-1.5 rounded-full bg-green-500"></span> In DB
<span class="h-1.5 w-1.5 rounded-full bg-green-500"></span> {{ _("admin_files.status_in_db") }}
</span>
{% elif entry.db_status == 'orphan' %}
<span class="inline-flex items-center gap-1 text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded-full px-2 py-0.5">
<span class="h-1.5 w-1.5 rounded-full bg-amber-500"></span> Orphan
<span class="h-1.5 w-1.5 rounded-full bg-amber-500"></span> {{ _("admin_files.status_orphan") }}
</span>
{% endif %}
</td>
@@ -145,7 +145,7 @@
{% if not entry.is_dir %}
<a href="/admin/files/download?path={{ entry.rel_path | urlencode }}"
class="inline-flex items-center gap-1 px-2.5 py-1 border border-gray-200 text-xs font-medium rounded text-gray-600 bg-white hover:bg-gray-50">
<i class="fas fa-download text-xs"></i> Download
<i class="fas fa-download text-xs" aria-hidden="true"></i> {{ _("admin_files.btn_download") }}
</a>
{% endif %}
</td>
@@ -154,8 +154,8 @@
{% else %}
<tr>
<td colspan="6" class="px-6 py-10 text-center text-sm text-gray-400">
<i class="fas fa-folder-open text-gray-200 text-3xl mb-2 block"></i>
This directory is empty.
<i class="fas fa-folder-open text-gray-200 text-3xl mb-2 block" aria-hidden="true"></i>
{{ _("admin_files.empty_directory") }}
</td>
</tr>
{% endif %}
@@ -171,9 +171,9 @@
<!-- Legend -->
<div class="flex flex-wrap gap-4 text-xs text-gray-500 mb-4">
<span class="flex items-center gap-1"><span class="h-2.5 w-2.5 rounded-full bg-green-400 inline-block"></span> File exists on disk</span>
<span class="flex items-center gap-1"><span class="h-2.5 w-2.5 rounded-full bg-red-400 inline-block"></span> File missing from disk</span>
<span class="flex items-center gap-1"><span class="h-2.5 w-2.5 rounded-full bg-gray-300 inline-block"></span> Path not set</span>
<span class="flex items-center gap-1"><span class="h-2.5 w-2.5 rounded-full bg-green-400 inline-block"></span> {{ _("admin_files.legend_file_exists") }}</span>
<span class="flex items-center gap-1"><span class="h-2.5 w-2.5 rounded-full bg-red-400 inline-block"></span> {{ _("admin_files.legend_file_missing") }}</span>
<span class="flex items-center gap-1"><span class="h-2.5 w-2.5 rounded-full bg-gray-300 inline-block"></span> {{ _("admin_files.legend_path_not_set") }}</span>
</div>
{% if db_records %}
@@ -181,14 +181,14 @@
<table class="min-w-full divide-y divide-gray-200 text-sm">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs">ID</th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs">Original Filename</th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs hidden md:table-cell">Size</th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs hidden lg:table-cell">Ingested</th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs">local_filename</th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs">original_file_path</th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs">processed_file_path</th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs">Health</th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs">{{ _("admin_files.col_id") }}</th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs">{{ _("admin_files.col_original_filename") }}</th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs hidden md:table-cell">{{ _("admin_files.col_size") }}</th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs hidden lg:table-cell">{{ _("admin_files.col_ingested") }}</th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs">{{ _("admin_files.col_local_filename") }}</th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs">{{ _("admin_files.col_original_file_path") }}</th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs">{{ _("admin_files.col_processed_file_path") }}</th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs">{{ _("admin_files.col_health") }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@@ -197,7 +197,7 @@
<td class="px-4 py-3 text-gray-400 font-mono text-xs">{{ rec.id }}</td>
<td class="px-4 py-3 font-medium text-gray-800 max-w-xs truncate" title="{{ rec.original_filename }}">
{% if rec.is_duplicate %}
<span class="text-xs text-purple-600 bg-purple-50 border border-purple-200 rounded px-1 mr-1">dup</span>
<span class="text-xs text-purple-600 bg-purple-50 border border-purple-200 rounded px-1 mr-1">{{ _("admin_files.badge_duplicate") }}</span>
{% endif %}
{{ rec.original_filename }}
</td>
@@ -226,11 +226,11 @@
<td class="px-4 py-3">
{% if rec.health == 'ok' %}
<span class="inline-flex items-center gap-1 text-xs text-green-700 bg-green-50 border border-green-200 rounded-full px-2 py-0.5">
<i class="fas fa-check text-xs"></i> OK
<i class="fas fa-check text-xs" aria-hidden="true"></i> {{ _("admin_files.health_ok") }}
</span>
{% else %}
<span class="inline-flex items-center gap-1 text-xs text-red-700 bg-red-50 border border-red-200 rounded-full px-2 py-0.5">
<i class="fas fa-triangle-exclamation text-xs"></i> Missing
<i class="fas fa-triangle-exclamation text-xs" aria-hidden="true"></i> {{ _("admin_files.health_missing") }}
</span>
{% endif %}
</td>
@@ -241,8 +241,8 @@
</div>
{% else %}
<div class="bg-white rounded-lg shadow p-10 text-center text-gray-400">
<i class="fas fa-database text-4xl mb-3 block text-gray-200"></i>
No file records found in the database.
<i class="fas fa-database text-4xl mb-3 block text-gray-200" aria-hidden="true"></i>
{{ _("admin_files.empty_database") }}
</div>
{% endif %}
{% endif %}
@@ -254,17 +254,16 @@
{% if orphan_files or ghost_records %}
<div class="bg-amber-50 border border-amber-300 rounded-lg px-5 py-4 mb-6 flex items-start gap-3">
<i class="fas fa-triangle-exclamation text-amber-500 mt-0.5"></i>
<i class="fas fa-triangle-exclamation text-amber-500 mt-0.5" aria-hidden="true"></i>
<div class="text-sm text-amber-800">
<strong>Delta detected.</strong>
Found <strong>{{ orphan_files | length }}</strong> orphan file(s) on disk with no DB record,
and <strong>{{ ghost_records | length }}</strong> DB record(s) with missing files on disk.
<strong>{{ _("admin_files.delta_detected_title") }}</strong>
{{ _("admin_files.delta_detected_detail", orphan_count=(orphan_files | length), ghost_count=(ghost_records | length)) }}
</div>
</div>
{% else %}
<div class="bg-green-50 border border-green-300 rounded-lg px-5 py-4 mb-6 flex items-center gap-3">
<i class="fas fa-circle-check text-green-500"></i>
<span class="text-sm text-green-800 font-medium">No delta found — filesystem and database are in sync.</span>
<i class="fas fa-circle-check text-green-500" aria-hidden="true"></i>
<span class="text-sm text-green-800 font-medium">{{ _("admin_files.no_delta") }}</span>
</div>
{% endif %}
@@ -272,7 +271,7 @@
<div class="mb-8">
<h2 class="text-base font-semibold text-gray-800 mb-3 flex items-center gap-2">
<span class="h-3 w-3 rounded-full bg-amber-400 inline-block"></span>
Orphan files <span class="text-gray-400 font-normal text-sm ml-1">(on disk, no DB record)</span>
{{ _("admin_files.orphan_files_heading") }} <span class="text-gray-400 font-normal text-sm ml-1">{{ _("admin_files.orphan_files_desc") }}</span>
<span class="text-xs bg-amber-100 text-amber-700 border border-amber-200 rounded-full px-2 py-0.5 ml-auto">{{ orphan_files | length }}</span>
</h2>
{% if orphan_files %}
@@ -281,23 +280,23 @@
<thead class="bg-amber-50">
<tr>
<th class="px-4 py-3 text-left w-8"></th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs">Path (relative to workdir)</th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs hidden sm:table-cell">Size</th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs hidden md:table-cell">Modified</th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs">Actions</th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs">{{ _("admin_files.col_path_relative") }}</th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs hidden sm:table-cell">{{ _("admin_files.col_size") }}</th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs hidden md:table-cell">{{ _("admin_files.col_modified") }}</th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs">{{ _("admin_files.col_actions") }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
{% for f in orphan_files %}
<tr class="hover:bg-amber-50">
<td class="px-4 py-3"><i class="{{ f.icon }}"></i></td>
<td class="px-4 py-3"><i class="{{ f.icon }}" aria-hidden="true"></i></td>
<td class="px-4 py-3 font-mono text-xs text-amber-800 max-w-xs truncate" title="{{ f.rel_path }}">{{ f.rel_path }}</td>
<td class="px-4 py-3 text-gray-500 hidden sm:table-cell">{{ f.size }}</td>
<td class="px-4 py-3 text-gray-400 text-xs hidden md:table-cell">{{ f.modified }}</td>
<td class="px-4 py-3">
<a href="/admin/files/download?path={{ f.rel_path | urlencode }}"
class="inline-flex items-center gap-1 px-2.5 py-1 border border-gray-200 text-xs font-medium rounded text-gray-600 bg-white hover:bg-gray-50">
<i class="fas fa-download text-xs"></i> Download
<i class="fas fa-download text-xs" aria-hidden="true"></i> {{ _("admin_files.btn_download") }}
</a>
</td>
</tr>
@@ -306,7 +305,7 @@
</table>
</div>
{% else %}
<p class="text-sm text-gray-400 italic">No orphan files found.</p>
<p class="text-sm text-gray-400 italic">{{ _("admin_files.no_orphan_files") }}</p>
{% endif %}
</div>
@@ -314,7 +313,7 @@
<div>
<h2 class="text-base font-semibold text-gray-800 mb-3 flex items-center gap-2">
<span class="h-3 w-3 rounded-full bg-red-400 inline-block"></span>
Ghost records <span class="text-gray-400 font-normal text-sm ml-1">(in DB, file(s) missing on disk)</span>
{{ _("admin_files.ghost_records_heading") }} <span class="text-gray-400 font-normal text-sm ml-1">{{ _("admin_files.ghost_records_desc") }}</span>
<span class="text-xs bg-red-100 text-red-700 border border-red-200 rounded-full px-2 py-0.5 ml-auto">{{ ghost_records | length }}</span>
</h2>
{% if ghost_records %}
@@ -322,10 +321,10 @@
<table class="min-w-full divide-y divide-gray-200 text-sm">
<thead class="bg-red-50">
<tr>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs">ID</th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs">Original Filename</th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs hidden md:table-cell">Ingested</th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs">Missing paths</th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs">{{ _("admin_files.col_id") }}</th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs">{{ _("admin_files.col_original_filename") }}</th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs hidden md:table-cell">{{ _("admin_files.col_ingested") }}</th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs">{{ _("admin_files.col_missing_paths") }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@@ -352,7 +351,7 @@
</table>
</div>
{% else %}
<p class="text-sm text-gray-400 italic">No ghost records found.</p>
<p class="text-sm text-gray-400 italic">{{ _("admin_files.no_ghost_records") }}</p>
{% endif %}
</div>
{% endif %}
+21 -21
View File
@@ -1,13 +1,13 @@
{% extends "base.html" %}
{% block title %}Imprint - DocuElevate{% endblock %}
{% block title %}{{ _("imprint.page_title") }}{% endblock %}
{% block content %}
<div class="container mx-auto px-4 py-8 max-w-4xl">
<h1 class="text-4xl font-bold mb-6">Imprint</h1>
<p class="text-sm text-gray-500 mb-8">Information according to § 5 TMG (German Telemedia Act)</p>
<h1 class="text-4xl font-bold mb-6">{{ _("imprint.heading") }}</h1>
<p class="text-sm text-gray-500 mb-8">{{ _("imprint.subtitle") }}</p>
<div class="bg-white shadow rounded-lg p-6 mb-6">
<h2 class="text-2xl font-semibold mb-4">Service Provider</h2>
<h2 class="text-2xl font-semibold mb-4">{{ _("imprint.provider_heading") }}</h2>
<p class="text-gray-700 mb-3">
<strong>Christian Louis IT Beratung</strong><br>
Alter Steinweg 3<br>
@@ -17,7 +17,7 @@
</div>
<div class="bg-white shadow rounded-lg p-6 mb-6">
<h2 class="text-2xl font-semibold mb-4">Contact Information</h2>
<h2 class="text-2xl font-semibold mb-4">{{ _("imprint.contact_heading") }}</h2>
<p class="text-gray-700 mb-3">
Fax: +49 40 97074609<br>
Email: christianlouis@gmail.com<br>
@@ -26,16 +26,16 @@
</div>
<div class="bg-white shadow rounded-lg p-6 mb-6">
<h2 class="text-2xl font-semibold mb-4">Business Registration</h2>
<h2 class="text-2xl font-semibold mb-4">{{ _("imprint.business_registration_heading") }}</h2>
<p class="text-gray-700 mb-3">
VAT Identification Number according to §27a Value Added Tax Act: DE202899017
{{ _("imprint.business_registration_vat") }} DE202899017
</p>
</div>
<div class="bg-white shadow rounded-lg p-6 mb-6">
<h2 class="text-2xl font-semibold mb-4">Responsible for Content</h2>
<h2 class="text-2xl font-semibold mb-4">{{ _("imprint.responsible_content_heading") }}</h2>
<p class="text-gray-700 mb-3">
According to § 55 Abs. 2 RStV:<br>
{{ _("imprint.responsible_content_rstv") }}<br>
Christian Krakau-Louis<br>
Alter Steinweg 3<br>
20459 Hamburg<br>
@@ -44,36 +44,36 @@
</div>
<div class="bg-white shadow rounded-lg p-6 mb-6">
<h2 class="text-2xl font-semibold mb-4">Legal Notices</h2>
<h2 class="text-2xl font-semibold mb-4">{{ _("imprint.legal_heading") }}</h2>
<p class="text-gray-700 mb-3">
Despite careful content control, we assume no liability for the content of external links. The operators of the linked pages are solely responsible for their content.
{{ _("imprint.legal_liability") }}
</p>
<p class="text-gray-700 mb-3">
All content on this website is protected by copyright. Any use outside the limits of copyright law requires the written consent of the respective author or creator.
{{ _("imprint.legal_copyright") }}
</p>
</div>
<div class="bg-white shadow rounded-lg p-6 mb-6">
<h2 class="text-2xl font-semibold mb-4">Online Dispute Resolution</h2>
<h2 class="text-2xl font-semibold mb-4">{{ _("imprint.dispute_heading") }}</h2>
<p class="text-gray-700 mb-3">
The European Commission provides a platform for online dispute resolution (OS):
{{ _("imprint.dispute_p1") }}
<a href="https://ec.europa.eu/consumers/odr/" target="_blank" rel="noopener noreferrer" class="text-blue-600 hover:underline">https://ec.europa.eu/consumers/odr/</a>
</p>
<p class="text-gray-700 mb-3">
We are not willing or obligated to participate in dispute resolution proceedings before a consumer arbitration board.
{{ _("imprint.dispute_p2") }}
</p>
</div>
<div class="bg-white shadow rounded-lg p-6">
<h2 class="text-2xl font-semibold mb-4">Related Policies</h2>
<h2 class="text-2xl font-semibold mb-4">{{ _("imprint.policies_heading") }}</h2>
<p class="text-gray-700 mb-3">
Our service is governed by the following policies:
{{ _("imprint.policies_intro") }}
</p>
<ul class="list-disc list-inside text-gray-700 ml-4 mb-3">
<li><a href="/terms" class="text-blue-600 hover:underline">Terms of Service</a> - Rules for using DocuElevate</li>
<li><a href="/privacy" class="text-blue-600 hover:underline">Privacy Policy</a> - How we handle your data</li>
<li><a href="/cookies" class="text-blue-600 hover:underline">Cookie Policy</a> - Information about cookies we use</li>
<li><a href="/license" class="text-blue-600 hover:underline">License Information</a> - How our software is licensed</li>
<li><a href="/terms" class="text-blue-600 hover:underline">{{ _("imprint.policies_terms_label") }}</a> - {{ _("imprint.policies_terms_desc") }}</li>
<li><a href="/privacy" class="text-blue-600 hover:underline">{{ _("imprint.policies_privacy_label") }}</a> - {{ _("imprint.policies_privacy_desc") }}</li>
<li><a href="/cookies" class="text-blue-600 hover:underline">{{ _("imprint.policies_cookie_label") }}</a> - {{ _("imprint.policies_cookie_desc") }}</li>
<li><a href="/license" class="text-blue-600 hover:underline">{{ _("imprint.policies_license_label") }}</a> - {{ _("imprint.policies_license_desc") }}</li>
</ul>
</div>
</div>
+117 -125
View File
@@ -1,5 +1,5 @@
{% extends "base.html" %}
{% block title %}Integrations DocuElevate{% endblock %}
{% block title %}{{ _("integrations.page_title") }} DocuElevate{% endblock %}
{% block content %}
<div
@@ -13,10 +13,10 @@
<div>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
<i class="fas fa-plug text-indigo-500" aria-hidden="true"></i>
Integrations
{{ _("integrations.page_title") }}
</h1>
<p class="text-gray-500 dark:text-gray-400 text-sm mt-1">
Manage your ingestion sources and storage destinations in one place.
{{ _("integrations.subtitle") }}
</p>
</div>
<button
@@ -27,7 +27,7 @@
style="min-height:44px;"
:aria-disabled="!canAddAny"
>
<i class="fas fa-plus mr-2" aria-hidden="true"></i> Add Integration
<i class="fas fa-plus mr-2" aria-hidden="true"></i> {{ _("integrations.add_button") }}
</button>
</div>
@@ -41,20 +41,20 @@
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-700 dark:text-gray-200">
Mailbox Sources:
{{ _("integrations.sources_quota_label") }}
<strong x-text="quota.src_count"></strong>
<template x-if="quota.max_sources !== null && quota.max_sources > 0">
<span> / <strong x-text="quota.max_sources"></strong></span>
</template>
<template x-if="quota.max_sources === null">
<span class="text-gray-400"> / unlimited</span>
<span class="text-gray-400"> {{ _("integrations.quota_unlimited") }}</span>
</template>
<template x-if="quota.max_sources === 0">
<span class="text-gray-400 ml-1">(not available)</span>
<span class="text-gray-400 ml-1">{{ _("integrations.quota_not_available") }}</span>
</template>
</p>
<p class="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
Plan: <span x-text="quota.tier_name"></span>
{{ _("integrations.plan_label") }} <span x-text="quota.tier_name"></span>
</p>
</div>
<!-- Mini usage bar -->
@@ -77,7 +77,7 @@
</div>
<template x-if="quota.max_sources !== null && quota.src_count >= quota.max_sources && quota.max_sources >= 0">
<a href="/subscription" class="inline-flex items-center text-xs text-indigo-600 hover:text-indigo-800 mt-2">
<i class="fas fa-arrow-up mr-1" aria-hidden="true"></i> Upgrade Plan
<i class="fas fa-arrow-up mr-1" aria-hidden="true"></i> {{ _("integrations.upgrade_plan") }}
</a>
</template>
</div>
@@ -90,17 +90,17 @@
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-700 dark:text-gray-200">
Storage Destinations:
{{ _("integrations.destinations_quota_label") }}
<strong x-text="quota.dest_count"></strong>
<template x-if="quota.max_destinations !== null">
<span> / <strong x-text="quota.max_destinations"></strong></span>
</template>
<template x-if="quota.max_destinations === null">
<span class="text-gray-400"> / unlimited</span>
<span class="text-gray-400"> {{ _("integrations.quota_unlimited") }}</span>
</template>
</p>
<p class="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
Plan: <span x-text="quota.tier_name"></span>
{{ _("integrations.plan_label") }} <span x-text="quota.tier_name"></span>
</p>
</div>
<!-- Mini usage bar -->
@@ -123,7 +123,7 @@
</div>
<template x-if="quota.max_destinations !== null && quota.dest_count >= quota.max_destinations">
<a href="/subscription" class="inline-flex items-center text-xs text-indigo-600 hover:text-indigo-800 mt-2">
<i class="fas fa-arrow-up mr-1" aria-hidden="true"></i> Upgrade Plan
<i class="fas fa-arrow-up mr-1" aria-hidden="true"></i> {{ _("integrations.upgrade_plan") }}
</a>
</template>
</div>
@@ -151,7 +151,7 @@
<template x-if="loading">
<div class="text-center py-12 text-gray-400">
<i class="fas fa-spinner fa-spin text-3xl mb-3" aria-hidden="true"></i>
<p>Loading integrations</p>
<p>{{ _("integrations.loading") }}</p>
</div>
</template>
@@ -159,9 +159,9 @@
<template x-if="!loading && integrations.length === 0">
<div class="text-center py-16 bg-white dark:bg-gray-800 rounded-lg shadow">
<i class="fas fa-plug text-5xl text-gray-300 mb-4" aria-hidden="true"></i>
<h2 class="text-xl font-semibold text-gray-700 dark:text-gray-300 mb-2">No integrations configured</h2>
<h2 class="text-xl font-semibold text-gray-700 dark:text-gray-300 mb-2">{{ _("integrations.empty_state") }}</h2>
<p class="text-gray-500 dark:text-gray-400 mb-6 max-w-md mx-auto">
Add your first integration to connect ingestion sources (like IMAP) and storage destinations (like S3, Dropbox, or Google Drive).
{{ _("integrations.no_integrations_body") }}
</p>
<button
type="button"
@@ -170,7 +170,7 @@
class="inline-flex items-center px-5 py-2.5 bg-indigo-600 hover:bg-indigo-700 disabled:bg-gray-300 disabled:cursor-not-allowed text-white text-sm font-medium rounded-md"
style="min-height:44px;"
>
<i class="fas fa-plus mr-2" aria-hidden="true"></i> Add Your First Integration
<i class="fas fa-plus mr-2" aria-hidden="true"></i> {{ _("integrations.add_first_button") }}
</button>
</div>
</template>
@@ -179,7 +179,7 @@
<template x-if="!loading && sources.length > 0">
<section class="mb-8">
<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-3 flex items-center gap-2">
<i class="fas fa-inbox text-blue-500" aria-hidden="true"></i> Sources
<i class="fas fa-inbox text-blue-500" aria-hidden="true"></i> {{ _("integrations.sources_section") }}
</h2>
<div class="space-y-3">
<template x-for="intg in sources" :key="intg.id">
@@ -196,7 +196,7 @@
<span
class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
:class="intg.is_active ? 'bg-green-50 text-green-700 dark:bg-green-900 dark:text-green-300' : 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400'"
x-text="intg.is_active ? 'Active' : 'Paused'"
x-text="intg.is_active ? '{{ _('common.active') }}' : '{{ _('integrations.paused') }}'"
></span>
</p>
<template x-if="intg.last_error">
@@ -216,7 +216,7 @@
:aria-label="`${intg.has_credentials ? 'Re-authorize' : 'Authorize'} ${intg.name}`"
>
<i class="fas fa-key mr-1" aria-hidden="true"></i>
<span x-text="intg.has_credentials ? 'Re-Authorize' : 'Authorize'"></span>
<span x-text="intg.has_credentials ? '{{ _('integrations.authorize_reauth') }}' : '{{ _('integrations.authorize_btn') }}'"></span>
</a>
</template>
<button
@@ -233,7 +233,7 @@
<template x-if="testingId !== intg.id">
<i class="fas fa-plug mr-1" aria-hidden="true"></i>
</template>
Test
{{ _("common.test") }}
</button>
<button
type="button"
@@ -242,7 +242,7 @@
style="min-height:36px;"
:aria-label="`Edit ${intg.name}`"
>
<i class="fas fa-edit mr-1" aria-hidden="true"></i>Edit
<i class="fas fa-edit mr-1" aria-hidden="true"></i>{{ _("common.edit") }}
</button>
<button
type="button"
@@ -251,7 +251,7 @@
style="min-height:36px;"
:aria-label="`Delete ${intg.name}`"
>
<i class="fas fa-trash-alt mr-1" aria-hidden="true"></i>Delete
<i class="fas fa-trash-alt mr-1" aria-hidden="true"></i>{{ _("common.delete") }}
</button>
</div>
</div>
@@ -265,7 +265,7 @@
<template x-if="!loading && destinations.length > 0">
<section class="mb-8">
<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-3 flex items-center gap-2">
<i class="fas fa-hdd text-green-500" aria-hidden="true"></i> Destinations
<i class="fas fa-hdd text-green-500" aria-hidden="true"></i> {{ _("integrations.destinations_section") }}
</h2>
<div class="space-y-3">
<template x-for="intg in destinations" :key="intg.id">
@@ -282,7 +282,7 @@
<span
class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
:class="intg.is_active ? 'bg-green-50 text-green-700 dark:bg-green-900 dark:text-green-300' : 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400'"
x-text="intg.is_active ? 'Active' : 'Paused'"
x-text="intg.is_active ? '{{ _('common.active') }}' : '{{ _('integrations.paused') }}'"
></span>
</p>
<template x-if="intg.last_error">
@@ -302,7 +302,7 @@
:aria-label="`${intg.has_credentials ? 'Re-authorize' : 'Authorize'} ${intg.name}`"
>
<i class="fas fa-key mr-1" aria-hidden="true"></i>
<span x-text="intg.has_credentials ? 'Re-Authorize' : 'Authorize'"></span>
<span x-text="intg.has_credentials ? '{{ _('integrations.authorize_reauth') }}' : '{{ _('integrations.authorize_btn') }}'"></span>
</a>
</template>
<button
@@ -319,7 +319,7 @@
<template x-if="testingId !== intg.id">
<i class="fas fa-plug mr-1" aria-hidden="true"></i>
</template>
Test
{{ _("common.test") }}
</button>
<button
type="button"
@@ -328,7 +328,7 @@
style="min-height:36px;"
:aria-label="`Edit ${intg.name}`"
>
<i class="fas fa-edit mr-1" aria-hidden="true"></i>Edit
<i class="fas fa-edit mr-1" aria-hidden="true"></i>{{ _("common.edit") }}
</button>
<button
type="button"
@@ -337,7 +337,7 @@
style="min-height:36px;"
:aria-label="`Delete ${intg.name}`"
>
<i class="fas fa-trash-alt mr-1" aria-hidden="true"></i>Delete
<i class="fas fa-trash-alt mr-1" aria-hidden="true"></i>{{ _("common.delete") }}
</button>
</div>
</div>
@@ -371,13 +371,13 @@
<h2
:id="editingIntegration ? 'modal-title-edit' : 'modal-title-create'"
class="text-lg font-semibold text-gray-900 dark:text-white"
x-text="editingIntegration ? 'Edit Integration' : 'Add Integration'"
x-text="editingIntegration ? '{{ _('integrations.modal_title_edit') }}' : '{{ _('integrations.modal_title_add') }}'"
></h2>
<button
type="button"
@click="closeModal()"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 focus:outline-none focus:ring-2 focus:ring-indigo-500 rounded"
aria-label="Close modal"
aria-label="{{ _('integrations.modal_close') }}"
>
<i class="fas fa-times text-xl" aria-hidden="true"></i>
</button>
@@ -388,7 +388,7 @@
<!-- Direction -->
<div>
<label for="intg-direction" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">
Direction <span class="text-red-500" aria-hidden="true">*</span>
{{ _("integrations.direction_label") }} <span class="text-red-500" aria-hidden="true">*</span>
</label>
<select
id="intg-direction"
@@ -399,16 +399,16 @@
aria-required="true"
@change="onDirectionChange()"
>
<option value="">— Select —</option>
<option value="SOURCE">Source (ingestion)</option>
<option value="DESTINATION">Destination (storage)</option>
<option value="">{{ _("integrations.direction_placeholder") }}</option>
<option value="SOURCE">{{ _("integrations.direction_source") }}</option>
<option value="DESTINATION">{{ _("integrations.direction_destination") }}</option>
</select>
</div>
<!-- Integration type -->
<div>
<label for="intg-type" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">
Integration Type <span class="text-red-500" aria-hidden="true">*</span>
{{ _("integrations.type_label") }} <span class="text-red-500" aria-hidden="true">*</span>
</label>
<select
id="intg-type"
@@ -419,7 +419,7 @@
aria-required="true"
@change="onTypeChange()"
>
<option value="">— Select direction first —</option>
<option value="">{{ _("integrations.type_placeholder") }}</option>
<template x-for="t in availableTypes" :key="t">
<option :value="t" x-text="typeLabel(t)"></option>
</template>
@@ -429,13 +429,13 @@
<!-- Name -->
<div>
<label for="intg-name" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">
Label <span class="text-red-500" aria-hidden="true">*</span>
{{ _("integrations.name_label") }} <span class="text-red-500" aria-hidden="true">*</span>
</label>
<input
id="intg-name"
type="text"
x-model="form.name"
placeholder="e.g. Work Gmail, S3 Archive"
placeholder="{{ _('integrations.name_placeholder') }}"
required
maxlength="255"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm"
@@ -448,46 +448,46 @@
<!-- IMAP fields -->
<template x-if="form.integration_type === 'IMAP'">
<div class="space-y-3 border-t border-gray-200 dark:border-gray-700 pt-3">
<p class="text-xs font-semibold text-gray-400 uppercase tracking-wider">IMAP Settings</p>
<p class="text-xs font-semibold text-gray-400 uppercase tracking-wider">{{ _("integrations.imap_settings") }}</p>
<div class="grid grid-cols-3 gap-3">
<div class="col-span-2">
<label for="imap-host" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Host <span class="text-red-500" aria-hidden="true">*</span></label>
<label for="imap-host" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.host_label") }} <span class="text-red-500" aria-hidden="true">*</span></label>
<input id="imap-host" type="text" x-model="form.config.host" placeholder="imap.example.com" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" aria-required="true" />
</div>
<div>
<label for="imap-port" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Port</label>
<label for="imap-port" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.port_label") }}</label>
<input id="imap-port" type="number" x-model.number="form.config.port" min="1" max="65535" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" />
</div>
</div>
<div>
<label for="imap-username" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Username <span class="text-red-500" aria-hidden="true">*</span></label>
<label for="imap-username" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.username_label") }} <span class="text-red-500" aria-hidden="true">*</span></label>
<input id="imap-username" type="text" x-model="form.config.username" placeholder="user@example.com" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" aria-required="true" />
</div>
<div>
<label for="imap-password" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Password <span class="text-red-500" aria-hidden="true">*</span></label>
<label for="imap-password" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.password_label") }} <span class="text-red-500" aria-hidden="true">*</span></label>
<input id="imap-password" :type="showPassword ? 'text' : 'password'" x-model="form.credentials.password" :placeholder="editingIntegration ? '(unchanged if blank)' : ''" :required="!editingIntegration" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" />
</div>
<div class="flex flex-wrap items-center gap-4">
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-200 cursor-pointer">
<input type="checkbox" x-model="form.config.use_ssl" class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" />
Use SSL
{{ _("integrations.use_ssl") }}
</label>
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-200 cursor-pointer">
<input type="checkbox" x-model="form.config.delete_after_process" class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" />
Delete emails after processing
{{ _("integrations.delete_emails_label") }}
</label>
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-200 cursor-pointer">
<input type="checkbox" x-model="form.config.gmail_apply_labels" class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" />
Apply Gmail labels &amp; star
{{ _("integrations.gmail_labels") }}
</label>
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-200 cursor-pointer">
<input type="checkbox" x-model="showPassword" class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" />
Show password
{{ _("integrations.show_password") }}
</label>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
<i class="fas fa-info-circle mr-1" aria-hidden="true"></i>
Gmail labels &amp; star: when enabled, processed emails are starred and tagged with an "Ingested" label in Gmail. Only applies to Gmail servers.
{{ _("integrations.gmail_hint") }}
</p>
</div>
</template>
@@ -495,36 +495,36 @@
<!-- S3 fields -->
<template x-if="form.integration_type === 'S3'">
<div class="space-y-3 border-t border-gray-200 dark:border-gray-700 pt-3">
<p class="text-xs font-semibold text-gray-400 uppercase tracking-wider">S3 Settings</p>
<p class="text-xs font-semibold text-gray-400 uppercase tracking-wider">{{ _("integrations.s3_settings") }}</p>
<div>
<label for="s3-bucket" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Bucket <span class="text-red-500" aria-hidden="true">*</span></label>
<label for="s3-bucket" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.bucket_label") }} <span class="text-red-500" aria-hidden="true">*</span></label>
<input id="s3-bucket" type="text" x-model="form.config.bucket" placeholder="my-bucket" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" aria-required="true" />
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label for="s3-region" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Region</label>
<label for="s3-region" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.region_label") }}</label>
<input id="s3-region" type="text" x-model="form.config.region" placeholder="us-east-1" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" />
</div>
<div>
<label for="s3-prefix" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Folder Prefix</label>
<label for="s3-prefix" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.folder_prefix_label") }}</label>
<input id="s3-prefix" type="text" x-model="form.config.folder_prefix" placeholder="documents/" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" />
</div>
</div>
<div>
<label for="s3-endpoint" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Endpoint URL <span class="text-gray-400">(optional, for S3-compatible)</span></label>
<label for="s3-endpoint" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.endpoint_label") }} <span class="text-gray-400">{{ _("integrations.endpoint_optional") }}</span></label>
<input id="s3-endpoint" type="url" x-model="form.config.endpoint_url" placeholder="https://s3.example.com" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" />
</div>
<div>
<label for="s3-key" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Access Key ID <span class="text-red-500" aria-hidden="true">*</span></label>
<label for="s3-key" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.access_key_label") }} <span class="text-red-500" aria-hidden="true">*</span></label>
<input id="s3-key" type="text" x-model="form.credentials.access_key_id" :placeholder="editingIntegration ? '(unchanged if blank)' : ''" :required="!editingIntegration" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" />
</div>
<div>
<label for="s3-secret" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Secret Access Key <span class="text-red-500" aria-hidden="true">*</span></label>
<label for="s3-secret" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.secret_key_label") }} <span class="text-red-500" aria-hidden="true">*</span></label>
<input id="s3-secret" :type="showPassword ? 'text' : 'password'" x-model="form.credentials.secret_access_key" :placeholder="editingIntegration ? '(unchanged if blank)' : ''" :required="!editingIntegration" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" />
</div>
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-200 cursor-pointer">
<input type="checkbox" x-model="showPassword" class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" />
Show secrets
{{ _("integrations.show_secrets") }}
</label>
</div>
</template>
@@ -534,24 +534,24 @@
<div class="space-y-3 border-t border-gray-200 dark:border-gray-700 pt-3">
<p class="text-xs font-semibold text-gray-400 uppercase tracking-wider" x-text="form.integration_type === 'NEXTCLOUD' ? 'Nextcloud Settings' : 'WebDAV Settings'"></p>
<div>
<label for="webdav-url" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">URL <span class="text-red-500" aria-hidden="true">*</span></label>
<label for="webdav-url" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.webdav_url_label") }} <span class="text-red-500" aria-hidden="true">*</span></label>
<input id="webdav-url" type="url" x-model="form.config.url" placeholder="https://cloud.example.com/remote.php/dav/files/user/" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" aria-required="true" />
</div>
<div>
<label for="webdav-folder" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Folder Path</label>
<label for="webdav-folder" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.folder_path_label") }}</label>
<input id="webdav-folder" type="text" x-model="form.config.folder" placeholder="/Documents" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" />
</div>
<div>
<label for="webdav-user" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Username <span class="text-red-500" aria-hidden="true">*</span></label>
<label for="webdav-user" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.username_label") }} <span class="text-red-500" aria-hidden="true">*</span></label>
<input id="webdav-user" type="text" x-model="form.credentials.username" :required="!editingIntegration" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" />
</div>
<div>
<label for="webdav-pass" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Password <span class="text-red-500" aria-hidden="true">*</span></label>
<label for="webdav-pass" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.password_label") }} <span class="text-red-500" aria-hidden="true">*</span></label>
<input id="webdav-pass" :type="showPassword ? 'text' : 'password'" x-model="form.credentials.password" :placeholder="editingIntegration ? '(unchanged if blank)' : ''" :required="!editingIntegration" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" />
</div>
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-200 cursor-pointer">
<input type="checkbox" x-model="showPassword" class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" />
Show password
{{ _("integrations.show_password") }}
</label>
</div>
</template>
@@ -562,29 +562,29 @@
<p class="text-xs font-semibold text-gray-400 uppercase tracking-wider" x-text="form.integration_type + ' Settings'"></p>
<div class="grid grid-cols-3 gap-3">
<div class="col-span-2">
<label for="ftp-host" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Host <span class="text-red-500" aria-hidden="true">*</span></label>
<label for="ftp-host" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.host_label") }} <span class="text-red-500" aria-hidden="true">*</span></label>
<input id="ftp-host" type="text" x-model="form.config.host" placeholder="ftp.example.com" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" aria-required="true" />
</div>
<div>
<label for="ftp-port" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Port</label>
<label for="ftp-port" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.port_label") }}</label>
<input id="ftp-port" type="number" x-model.number="form.config.port" :placeholder="form.integration_type === 'SFTP' ? '22' : '21'" min="1" max="65535" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" />
</div>
</div>
<div>
<label for="ftp-folder" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Remote Path</label>
<label for="ftp-folder" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.remote_path_label") }}</label>
<input id="ftp-folder" type="text" x-model="form.config.folder" placeholder="/upload" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" />
</div>
<div>
<label for="ftp-user" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Username <span class="text-red-500" aria-hidden="true">*</span></label>
<label for="ftp-user" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.username_label") }} <span class="text-red-500" aria-hidden="true">*</span></label>
<input id="ftp-user" type="text" x-model="form.credentials.username" :required="!editingIntegration" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" />
</div>
<div>
<label for="ftp-pass" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Password <span class="text-red-500" aria-hidden="true">*</span></label>
<label for="ftp-pass" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.password_label") }} <span class="text-red-500" aria-hidden="true">*</span></label>
<input id="ftp-pass" :type="showPassword ? 'text' : 'password'" x-model="form.credentials.password" :placeholder="editingIntegration ? '(unchanged if blank)' : ''" :required="!editingIntegration" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" />
</div>
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-200 cursor-pointer">
<input type="checkbox" x-model="showPassword" class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" />
Show password
{{ _("integrations.show_password") }}
</label>
</div>
</template>
@@ -594,13 +594,12 @@
<div class="space-y-3 border-t border-gray-200 dark:border-gray-700 pt-3">
<p class="text-xs font-semibold text-gray-400 uppercase tracking-wider" x-text="typeLabel(form.integration_type) + ' Settings'"></p>
<div>
<label for="oauth-folder" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Folder <span class="text-gray-400">(optional)</span></label>
<label for="oauth-folder" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.oauth_folder_label") }} <span class="text-gray-400">{{ _("integrations.folder_optional") }}</span></label>
<input id="oauth-folder" type="text" x-model="form.config.folder" placeholder="/DocuElevate" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" />
</div>
<div class="bg-blue-50 dark:bg-blue-900/30 rounded-md p-3 text-sm text-blue-700 dark:text-blue-300">
<i class="fas fa-info-circle mr-1" aria-hidden="true"></i>
Save this integration first, then use the <strong>Authorize</strong> button to complete the OAuth flow.
Your credentials will be stored securely in your personal integration record.
{{ _("integrations.oauth_hint") }}
</div>
</div>
</template>
@@ -608,9 +607,9 @@
<!-- Email (destination) fields -->
<template x-if="form.integration_type === 'EMAIL'">
<div class="space-y-3 border-t border-gray-200 dark:border-gray-700 pt-3">
<p class="text-xs font-semibold text-gray-400 uppercase tracking-wider">Email Forward Settings</p>
<p class="text-xs font-semibold text-gray-400 uppercase tracking-wider">{{ _("integrations.email_settings") }}</p>
<div>
<label for="email-to" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Recipient Email <span class="text-red-500" aria-hidden="true">*</span></label>
<label for="email-to" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.recipient_label") }} <span class="text-red-500" aria-hidden="true">*</span></label>
<input id="email-to" type="email" x-model="form.config.recipient" placeholder="archive@example.com" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" aria-required="true" />
</div>
</div>
@@ -619,11 +618,11 @@
<!-- Watch Folder fields -->
<template x-if="form.integration_type === 'WATCH_FOLDER'">
<div class="space-y-3 border-t border-gray-200 dark:border-gray-700 pt-3">
<p class="text-xs font-semibold text-gray-400 uppercase tracking-wider">Watch Folder Settings</p>
<p class="text-xs font-semibold text-gray-400 uppercase tracking-wider">{{ _("integrations.watch_folder_settings") }}</p>
<div>
<label for="wf-source-type" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Source Type <span class="text-red-500" aria-hidden="true">*</span></label>
<label for="wf-source-type" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.source_type_label") }} <span class="text-red-500" aria-hidden="true">*</span></label>
<select id="wf-source-type" x-model="form.config.source_type" @change="onWatchFolderSourceTypeChange()" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" aria-required="true">
<option value="local">Local Filesystem</option>
<option value="local">{{ _("integrations.source_local") }}</option>
<option value="s3">Amazon S3</option>
<option value="dropbox">Dropbox</option>
<option value="google_drive">Google Drive</option>
@@ -636,7 +635,7 @@
<!-- Local filesystem fields -->
<template x-if="form.config.source_type === 'local'">
<div>
<label for="wf-path" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Folder Path <span class="text-red-500" aria-hidden="true">*</span></label>
<label for="wf-path" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.folder_path_label") }} <span class="text-red-500" aria-hidden="true">*</span></label>
<input id="wf-path" type="text" x-model="form.config.folder_path" placeholder="/data/inbox" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" aria-required="true" />
</div>
</template>
@@ -646,29 +645,29 @@
<div class="space-y-3">
<div class="grid grid-cols-2 gap-3">
<div>
<label for="wf-s3-bucket" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Bucket <span class="text-red-500" aria-hidden="true">*</span></label>
<label for="wf-s3-bucket" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.bucket_label") }} <span class="text-red-500" aria-hidden="true">*</span></label>
<input id="wf-s3-bucket" type="text" x-model="form.config.bucket" placeholder="my-bucket" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" aria-required="true" />
</div>
<div>
<label for="wf-s3-region" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Region</label>
<label for="wf-s3-region" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.region_label") }}</label>
<input id="wf-s3-region" type="text" x-model="form.config.region" placeholder="us-east-1" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" />
</div>
</div>
<div>
<label for="wf-s3-prefix" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Prefix (folder path)</label>
<label for="wf-s3-prefix" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.s3_prefix_label") }}</label>
<input id="wf-s3-prefix" type="text" x-model="form.config.prefix" placeholder="inbox/" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" />
</div>
<div>
<label for="wf-s3-endpoint" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Endpoint URL <span class="text-xs text-gray-400">(optional, for S3-compatible)</span></label>
<label for="wf-s3-endpoint" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.endpoint_label") }} <span class="text-xs text-gray-400">{{ _("integrations.endpoint_optional") }}</span></label>
<input id="wf-s3-endpoint" type="text" x-model="form.config.endpoint_url" placeholder="https://s3.example.com" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" />
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label for="wf-s3-ak" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Access Key ID <span class="text-red-500" aria-hidden="true">*</span></label>
<label for="wf-s3-ak" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.access_key_label") }} <span class="text-red-500" aria-hidden="true">*</span></label>
<input id="wf-s3-ak" type="text" x-model="form.credentials.access_key_id" :required="!editingIntegration" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" aria-required="true" />
</div>
<div>
<label for="wf-s3-sk" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Secret Access Key <span class="text-red-500" aria-hidden="true">*</span></label>
<label for="wf-s3-sk" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.secret_key_label") }} <span class="text-red-500" aria-hidden="true">*</span></label>
<input id="wf-s3-sk" :type="showPassword ? 'text' : 'password'" x-model="form.credentials.secret_access_key" :placeholder="editingIntegration ? '(unchanged if blank)' : ''" :required="!editingIntegration" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" />
</div>
</div>
@@ -679,7 +678,7 @@
<template x-if="form.config.source_type === 'dropbox'">
<div class="space-y-3">
<div>
<label for="wf-dbx-folder" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Folder Path <span class="text-red-500" aria-hidden="true">*</span></label>
<label for="wf-dbx-folder" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.folder_path_label") }} <span class="text-red-500" aria-hidden="true">*</span></label>
<input id="wf-dbx-folder" type="text" x-model="form.config.folder_path" placeholder="/Inbox/Scanner" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" aria-required="true" />
</div>
<div>
@@ -717,7 +716,7 @@
<template x-if="form.config.source_type === 'onedrive'">
<div class="space-y-3">
<div>
<label for="wf-od-folder" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Folder Path <span class="text-red-500" aria-hidden="true">*</span></label>
<label for="wf-od-folder" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.folder_path_label") }} <span class="text-red-500" aria-hidden="true">*</span></label>
<input id="wf-od-folder" type="text" x-model="form.config.folder_path" placeholder="/Documents/Inbox" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" aria-required="true" />
</div>
<div>
@@ -741,20 +740,20 @@
<template x-if="form.config.source_type === 'nextcloud'">
<div class="space-y-3">
<div>
<label for="wf-nc-url" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Nextcloud URL <span class="text-red-500" aria-hidden="true">*</span></label>
<label for="wf-nc-url" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.nextcloud_url_label") }} <span class="text-red-500" aria-hidden="true">*</span></label>
<input id="wf-nc-url" type="url" x-model="form.config.url" placeholder="https://cloud.example.com" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" aria-required="true" />
</div>
<div>
<label for="wf-nc-folder" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Folder Path <span class="text-red-500" aria-hidden="true">*</span></label>
<label for="wf-nc-folder" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.folder_path_label") }} <span class="text-red-500" aria-hidden="true">*</span></label>
<input id="wf-nc-folder" type="text" x-model="form.config.folder_path" placeholder="/Documents/Inbox" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" aria-required="true" />
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label for="wf-nc-user" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Username <span class="text-red-500" aria-hidden="true">*</span></label>
<label for="wf-nc-user" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.username_label") }} <span class="text-red-500" aria-hidden="true">*</span></label>
<input id="wf-nc-user" type="text" x-model="form.credentials.username" :required="!editingIntegration" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" aria-required="true" />
</div>
<div>
<label for="wf-nc-pass" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Password <span class="text-red-500" aria-hidden="true">*</span></label>
<label for="wf-nc-pass" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.password_label") }} <span class="text-red-500" aria-hidden="true">*</span></label>
<input id="wf-nc-pass" :type="showPassword ? 'text' : 'password'" x-model="form.credentials.password" :placeholder="editingIntegration ? '(unchanged if blank)' : ''" :required="!editingIntegration" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" />
</div>
</div>
@@ -765,20 +764,20 @@
<template x-if="form.config.source_type === 'webdav'">
<div class="space-y-3">
<div>
<label for="wf-dav-url" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">WebDAV URL <span class="text-red-500" aria-hidden="true">*</span></label>
<label for="wf-dav-url" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.webdav_url_label") }} <span class="text-red-500" aria-hidden="true">*</span></label>
<input id="wf-dav-url" type="url" x-model="form.config.url" placeholder="https://webdav.example.com/dav/" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" aria-required="true" />
</div>
<div>
<label for="wf-dav-folder" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Folder Path <span class="text-red-500" aria-hidden="true">*</span></label>
<label for="wf-dav-folder" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.folder_path_label") }} <span class="text-red-500" aria-hidden="true">*</span></label>
<input id="wf-dav-folder" type="text" x-model="form.config.folder_path" placeholder="/remote.php/webdav/Inbox" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" aria-required="true" />
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label for="wf-dav-user" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Username <span class="text-red-500" aria-hidden="true">*</span></label>
<label for="wf-dav-user" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.username_label") }} <span class="text-red-500" aria-hidden="true">*</span></label>
<input id="wf-dav-user" type="text" x-model="form.credentials.username" :required="!editingIntegration" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" aria-required="true" />
</div>
<div>
<label for="wf-dav-pass" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Password <span class="text-red-500" aria-hidden="true">*</span></label>
<label for="wf-dav-pass" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.password_label") }} <span class="text-red-500" aria-hidden="true">*</span></label>
<input id="wf-dav-pass" :type="showPassword ? 'text' : 'password'" x-model="form.credentials.password" :placeholder="editingIntegration ? '(unchanged if blank)' : ''" :required="!editingIntegration" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" />
</div>
</div>
@@ -789,12 +788,12 @@
<div class="flex flex-wrap items-center gap-4 pt-2">
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-200 cursor-pointer">
<input type="checkbox" x-model="form.config.delete_after_process" class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" />
Delete files after processing
{{ _("integrations.delete_files_label") }}
</label>
<template x-if="form.config.source_type !== 'local'">
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-200 cursor-pointer">
<input type="checkbox" x-model="showPassword" class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" />
Show secrets
{{ _("integrations.show_secrets") }}
</label>
</template>
</div>
@@ -804,18 +803,18 @@
<!-- Paperless fields -->
<template x-if="form.integration_type === 'PAPERLESS'">
<div class="space-y-3 border-t border-gray-200 dark:border-gray-700 pt-3">
<p class="text-xs font-semibold text-gray-400 uppercase tracking-wider">Paperless NGX Settings</p>
<p class="text-xs font-semibold text-gray-400 uppercase tracking-wider">{{ _("integrations.paperless_settings") }}</p>
<div>
<label for="paperless-url" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">URL <span class="text-red-500" aria-hidden="true">*</span></label>
<input id="paperless-url" type="url" x-model="form.config.url" placeholder="https://paperless.example.com" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" aria-required="true" />
</div>
<div>
<label for="paperless-token" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">API Token <span class="text-red-500" aria-hidden="true">*</span></label>
<label for="paperless-token" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.api_token_label") }} <span class="text-red-500" aria-hidden="true">*</span></label>
<input id="paperless-token" :type="showPassword ? 'text' : 'password'" x-model="form.credentials.api_token" :placeholder="editingIntegration ? '(unchanged if blank)' : ''" :required="!editingIntegration" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" />
</div>
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-200 cursor-pointer">
<input type="checkbox" x-model="showPassword" class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" />
Show token
{{ _("integrations.show_token") }}
</label>
</div>
</template>
@@ -824,38 +823,33 @@
<template x-if="form.integration_type === 'WEBHOOK'">
<div class="space-y-4 border-t border-gray-200 dark:border-gray-700 pt-3">
<p class="text-xs font-semibold text-purple-500 uppercase tracking-wider">
<i class="fas fa-bolt mr-1" aria-hidden="true"></i> Webhook Ingestion
<i class="fas fa-bolt mr-1" aria-hidden="true"></i> {{ _("integrations.webhook_heading") }}
</p>
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<h4 class="text-sm font-semibold text-blue-800 dark:text-blue-200 mb-2">
<i class="fas fa-info-circle mr-1" aria-hidden="true"></i>
How Webhook Ingestion Works
{{ _("integrations.webhook_how_heading") }}
</h4>
<p class="text-sm text-blue-700 dark:text-blue-300 leading-relaxed">
A webhook integration allows external systems to push documents directly into DocuElevate
via the REST API. Instead of DocuElevate polling for new files (like IMAP), <strong>your
application sends files to DocuElevate</strong> using an HTTP request with an API token for
authentication.
{{ _("integrations.webhook_how_text") }}
</p>
<p class="text-sm text-blue-700 dark:text-blue-300 leading-relaxed mt-2">
This is ideal for <strong>CI/CD pipelines</strong>, <strong>automation scripts</strong>,
<strong>scanner integrations</strong>, or any system that generates documents and needs to
send them for processing.
{{ _("integrations.webhook_ideal_text") }}
</p>
</div>
<div class="bg-gray-50 dark:bg-gray-750 rounded-lg p-4">
<h4 class="text-sm font-semibold text-gray-800 dark:text-gray-200 mb-2">
<i class="fas fa-terminal mr-1" aria-hidden="true"></i>
Quick Start
{{ _("integrations.webhook_quick_start") }}
</h4>
<ol class="text-sm text-gray-600 dark:text-gray-400 space-y-2 list-decimal list-inside">
<li>
Go to <a href="/api-tokens" class="text-indigo-600 hover:underline font-medium">API Tokens</a>
and create a personal token.
{% set api_tokens_link %}<a href="/api-tokens" class="text-indigo-600 hover:underline font-medium">{{ _("nav.api_tokens") }}</a>{% endset %}
{{ _("integrations.webhook_step1", api_tokens_link=api_tokens_link) | safe }}
</li>
<li>Use the token to upload documents via the API:</li>
<li>{{ _("integrations.webhook_upload_with_token") }}</li>
</ol>
<div class="mt-3 relative">
@@ -901,9 +895,7 @@ print(response.json())</code></pre>
<div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-3">
<p class="text-xs text-yellow-800 dark:text-yellow-300">
<i class="fas fa-shield-alt mr-1" aria-hidden="true"></i>
<strong>Security tip:</strong> Create a dedicated API token for each integration and
revoke it immediately if compromised. Tokens can be managed on the
<a href="/api-tokens" class="underline font-medium">API Tokens</a> page.
{{ _("integrations.webhook_security_tip") }}
</p>
</div>
</div>
@@ -914,7 +906,7 @@ print(response.json())</code></pre>
<div class="space-y-3 border-t border-gray-200 dark:border-gray-700 pt-3">
<p class="text-xs font-semibold text-gray-400 uppercase tracking-wider" x-text="form.integration_type + ' Settings'"></p>
<p class="text-sm text-gray-500 dark:text-gray-400">
Configuration for this integration type can be provided as JSON after creation via the API.
{{ _("integrations.generic_settings_hint") }}
</p>
</div>
</template>
@@ -923,7 +915,7 @@ print(response.json())</code></pre>
<div class="flex items-center gap-2 pt-2">
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-200 cursor-pointer">
<input type="checkbox" x-model="form.is_active" class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" />
Active
{{ _("integrations.active_label") }}
</label>
</div>
@@ -934,7 +926,7 @@ print(response.json())</code></pre>
:class="testResult.success ? 'bg-green-50 border-green-400 text-green-800 dark:bg-opacity-10' : 'bg-red-50 border-red-400 text-red-800 dark:bg-opacity-10'"
role="status"
>
<p class="font-medium" x-text="testResult.success ? 'Connection successful' : 'Connection failed'"></p>
<p class="font-medium" x-text="testResult.success ? '{{ _('integrations.connection_success') }}' : '{{ _('integrations.connection_failed') }}'"></p>
<p class="text-xs mt-0.5" x-text="testResult.message"></p>
</div>
</template>
@@ -962,7 +954,7 @@ print(response.json())</code></pre>
<template x-if="!testing">
<i class="fas fa-plug mr-2" aria-hidden="true"></i>
</template>
Test Connection
{{ _("common.test_connection") }}
</button>
<div class="flex gap-2">
<button
@@ -971,7 +963,7 @@ print(response.json())</code></pre>
class="px-4 py-2 text-sm font-medium rounded border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-500"
style="min-height:40px;"
>
Cancel
{{ _("common.cancel") }}
</button>
<button
type="submit"
@@ -982,7 +974,7 @@ print(response.json())</code></pre>
<template x-if="saving">
<i class="fas fa-spinner fa-spin mr-2" aria-hidden="true"></i>
</template>
<span x-text="editingIntegration ? 'Update' : 'Save'"></span>
<span x-text="editingIntegration ? '{{ _('common.update') }}' : '{{ _('common.save') }}'"></span>
</button>
</div>
</div>
@@ -1003,12 +995,12 @@ print(response.json())</code></pre>
>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-sm p-6" @click.stop>
<h2 id="delete-modal-title" class="text-lg font-semibold text-gray-900 dark:text-white mb-2">
Delete Integration?
{{ _("integrations.delete_confirm_title") }}
</h2>
<p class="text-sm text-gray-600 dark:text-gray-300 mb-4">
Are you sure you want to delete
{{ _("integrations.delete_confirm_are_you_sure") }}
<strong x-text="integrationToDelete ? integrationToDelete.name : ''"></strong>?
This action cannot be undone.
{{ _("integrations.delete_confirm_undone") }}
</p>
<div class="flex justify-end gap-2">
<button
@@ -1017,7 +1009,7 @@ print(response.json())</code></pre>
class="px-4 py-2 text-sm font-medium rounded border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-500"
style="min-height:40px;"
>
Cancel
{{ _("common.cancel") }}
</button>
<button
type="button"
@@ -1029,7 +1021,7 @@ print(response.json())</code></pre>
<template x-if="deleting">
<i class="fas fa-spinner fa-spin mr-2" aria-hidden="true"></i>
</template>
Delete
{{ _("common.delete") }}
</button>
</div>
</div>
+12 -13
View File
@@ -1,36 +1,35 @@
{% extends "base.html" %}
{% block title %}DocuElevate - License{% endblock %}
{% block title %}{{ _("license.page_title") }}{% endblock %}
{% block content %}
<div class="container mx-auto px-4 py-8">
<h1 class="text-2xl font-bold mb-4">License Information</h1>
<h1 class="text-2xl font-bold mb-4">{{ _("license.heading") }}</h1>
<div class="bg-white shadow-md rounded-lg p-6 mb-6">
<h2 class="text-xl font-semibold mb-4">Apache License 2.0</h2>
<h2 class="text-xl font-semibold mb-4">{{ _("license.apache_heading") }}</h2>
<div class="prose">
<pre class="whitespace-pre-wrap text-sm font-mono bg-gray-50 p-4 rounded border overflow-auto max-h-96 mb-4">{{ license_text }}</pre>
</div>
<p class="mt-4 text-gray-600">
DocuElevate is distributed under the Apache License 2.0, which is a permissive
open-source software license that allows you to use, modify, distribute, and
contribute to the project.
{{ _("license.apache_description") }}
</p>
</div>
<div class="bg-white shadow-md rounded-lg p-6">
<h2 class="text-xl font-semibold mb-4">Related Information</h2>
<h2 class="text-xl font-semibold mb-4">{{ _("license.related_heading") }}</h2>
<p class="mb-3 text-gray-600">
While this license governs the use of our software, please also review our
<a href="/terms" class="text-blue-600 hover:underline">Terms of Service</a> and
<a href="/privacy" class="text-blue-600 hover:underline">Privacy Policy</a> for
information about using the DocuElevate service.
{{ _("license.related_p1_pre") }}
<a href="/terms" class="text-blue-600 hover:underline">{{ _("license.related_terms_link") }}</a>
{{ _("license.related_and") }}
<a href="/privacy" class="text-blue-600 hover:underline">{{ _("license.related_privacy_link") }}</a>
{{ _("license.related_p1_post") }}
</p>
<p class="text-gray-600">
For more information about DocuElevate, please visit the
<a href="/about" class="text-blue-600 hover:underline">About page</a>.
{{ _("license.related_p2_pre") }}
<a href="/about" class="text-blue-600 hover:underline">{{ _("license.related_about_link") }}</a>{{ _("license.related_p2_post") }}
</p>
</div>
</div>
+140 -212
View File
@@ -1,388 +1,316 @@
{% extends "base.html" %}
{% block title %}Privacy Notice - DocuElevate{% endblock %}
{% block title %}{{ _("privacy.page_title") }}{% endblock %}
{% block content %}
<div class="container mx-auto px-4 py-8 max-w-4xl">
<h1 class="text-4xl font-bold mb-6">DocuElevate Privacy Notice</h1>
<p class="text-sm text-gray-500 mb-8">Last Updated: {{ build_date|default(current_date|default('April 7, 2024')) }}</p>
<h1 class="text-4xl font-bold mb-6">{{ _("privacy.heading") }}</h1>
<p class="text-sm text-gray-500 mb-8">{{ _("privacy.last_updated") }} {{ build_date|default(current_date|default('April 7, 2024')) }}</p>
<!-- Table of Contents -->
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
<h2 class="text-lg font-semibold mb-2 text-blue-800">Contents</h2>
<h2 class="text-lg font-semibold mb-2 text-blue-800">{{ _("privacy.toc_heading") }}</h2>
<ol class="list-decimal list-inside text-blue-700 space-y-1 text-sm">
<li><a href="#controller" class="hover:underline">Data Controller</a></li>
<li><a href="#scope" class="hover:underline">Scope of this Privacy Notice</a></li>
<li><a href="#data-collection" class="hover:underline">Data Collection &amp; Purposes</a></li>
<li><a href="#data-minimization" class="hover:underline">Data Minimization &amp; Purpose Limitation</a></li>
<li><a href="#cookies" class="hover:underline">Use of Cookies &amp; Similar Technologies</a></li>
<li><a href="#third-parties" class="hover:underline">Third-Party Services</a></li>
<li><a href="#international-transfers" class="hover:underline">International Data Transfers</a></li>
<li><a href="#retention" class="hover:underline">Data Retention</a></li>
<li><a href="#security" class="hover:underline">Data Security</a></li>
<li><a href="#gdpr-rights" class="hover:underline">Your Rights (EU / EEA / UK / Switzerland)</a></li>
<li><a href="#us-rights" class="hover:underline">Additional Rights United States (CCPA/CPRA)</a></li>
<li><a href="#canada-rights" class="hover:underline">Additional Rights Canada (PIPEDA / Law 25)</a></li>
<li><a href="#latam-rights" class="hover:underline">Additional Rights Latin America (LGPD &amp; others)</a></li>
<li><a href="#apj-rights" class="hover:underline">Additional Rights Asia-Pacific &amp; Japan</a></li>
<li><a href="#ukraine-rights" class="hover:underline">Additional Rights Ukraine</a></li>
<li><a href="#updates" class="hover:underline">Updates to this Privacy Notice</a></li>
<li><a href="#controller" class="hover:underline">{{ _("privacy.toc_1") }}</a></li>
<li><a href="#scope" class="hover:underline">{{ _("privacy.toc_2") }}</a></li>
<li><a href="#data-collection" class="hover:underline">{{ _("privacy.toc_3") }}</a></li>
<li><a href="#data-minimization" class="hover:underline">{{ _("privacy.toc_4") }}</a></li>
<li><a href="#cookies" class="hover:underline">{{ _("privacy.toc_5") }}</a></li>
<li><a href="#third-parties" class="hover:underline">{{ _("privacy.toc_6") }}</a></li>
<li><a href="#international-transfers" class="hover:underline">{{ _("privacy.toc_7") }}</a></li>
<li><a href="#retention" class="hover:underline">{{ _("privacy.toc_8") }}</a></li>
<li><a href="#security" class="hover:underline">{{ _("privacy.toc_9") }}</a></li>
<li><a href="#gdpr-rights" class="hover:underline">{{ _("privacy.toc_10") }}</a></li>
<li><a href="#us-rights" class="hover:underline">{{ _("privacy.toc_11") }}</a></li>
<li><a href="#canada-rights" class="hover:underline">{{ _("privacy.toc_12") }}</a></li>
<li><a href="#latam-rights" class="hover:underline">{{ _("privacy.toc_13") }}</a></li>
<li><a href="#apj-rights" class="hover:underline">{{ _("privacy.toc_14") }}</a></li>
<li><a href="#ukraine-rights" class="hover:underline">{{ _("privacy.toc_15") }}</a></li>
<li><a href="#updates" class="hover:underline">{{ _("privacy.toc_16") }}</a></li>
</ol>
</div>
<div id="controller" class="bg-white shadow rounded-lg p-6 mb-6">
<h2 class="text-2xl font-semibold mb-4">1. Data Controller</h2>
<h2 class="text-2xl font-semibold mb-4">{{ _("privacy.s1_heading") }}</h2>
<p class="text-gray-700 mb-3">
The controller responsible for processing your personal data under the EU General Data Protection Regulation (GDPR) and equivalent privacy laws worldwide is:
{{ _("privacy.s1_p1") }}
</p>
<p class="text-gray-700 mb-3">
<strong>Christian Louis IT Beratung</strong><br />
Alter Steinweg 3, 20459 Hamburg, Germany
<strong>{{ _("privacy.s1_company") }}</strong><br />
{{ _("privacy.s1_address") }}
</p>
<p class="text-gray-700 mb-3">
Contact Email: <a href="mailto:docuelevate@christian-louis.de" class="text-blue-600 hover:underline">docuelevate@christian-louis.de</a>
{{ _("privacy.s1_contact_label") }} <a href="mailto:docuelevate@christian-louis.de" class="text-blue-600 hover:underline">docuelevate@christian-louis.de</a>
</p>
<p class="text-gray-700">
For all privacy-related requests (access, deletion, rectification, opt-out, or complaints), please contact us at the email address above.
We will respond within 30 days (or the period prescribed by applicable law).
{{ _("privacy.s1_p3") }}
</p>
</div>
<div id="scope" class="bg-white shadow rounded-lg p-6 mb-6">
<h2 class="text-2xl font-semibold mb-4">2. Scope of this Privacy Notice</h2>
<h2 class="text-2xl font-semibold mb-4">{{ _("privacy.s2_heading") }}</h2>
<p class="text-gray-700 mb-3">
This notice applies to the DocuElevate web application, hosted at
{{ _("privacy.s2_p1_pre") }}
<a href="{{ request.url.scheme }}://{{ request.url.netloc }}/" class="text-blue-600 hover:underline" target="_blank">{{ request.url.scheme }}://{{ request.url.netloc }}/</a>.
</p>
<p class="text-gray-700">
It covers all users globally, including those in the European Union (EU), European Economic Area (EEA),
Germany, United Kingdom (UK), Switzerland, Ukraine, United States (US), Canada, Latin America (Latam),
Asia-Pacific, and Japan. Market-specific disclosures are provided in dedicated sections below.
{{ _("privacy.s2_p2") }}
</p>
</div>
<div id="data-collection" class="bg-white shadow rounded-lg p-6 mb-6">
<h2 class="text-2xl font-semibold mb-4">3. Data Collection &amp; Purposes</h2>
<h2 class="text-2xl font-semibold mb-4">{{ _("privacy.s3_heading") }}</h2>
<p class="text-gray-700 mb-3">
<strong>User Authentication:</strong> We use OAuth 2.0 (Google, Dropbox, Microsoft/OneDrive) and optional
local authentication. Through OAuth, we may receive your name, email address, and profile picture.
<strong>{{ _("privacy.s3_auth_label") }}</strong> {{ _("privacy.s3_auth_body") }}
</p>
<p class="text-gray-700 mb-3">
<strong>Document Processing:</strong> Documents you upload are processed for OCR (optical character recognition),
metadata extraction, and storage to your chosen cloud provider. Document content is processed only for the
purpose you initiate and is not stored beyond what is operationally necessary.
<strong>{{ _("privacy.s3_doc_label") }}</strong> {{ _("privacy.s3_doc_body") }}
</p>
<p class="text-gray-700 mb-3">
<strong>Audit Logs:</strong> We maintain limited audit logs (action type, timestamp, user identifier) to
ensure service integrity and security. These logs do not include document content.
<strong>{{ _("privacy.s3_audit_label") }}</strong> {{ _("privacy.s3_audit_body") }}
</p>
<p class="text-gray-700 mb-3">
<strong>Legal Basis (GDPR Art. 6):</strong> Our primary legal bases for processing are:
<strong>{{ _("privacy.s3_legal_label") }}</strong> {{ _("privacy.s3_legal_body") }}
</p>
<ul class="list-disc list-inside text-gray-700 ml-4 mb-3">
<li class="mb-1">(1)(b) Performance of a contract: to provide the DocuElevate service you have requested.</li>
<li class="mb-1">(1)(c) Legal obligation: to comply with applicable laws and regulations.</li>
<li>(1)(f) Legitimate interests: ensuring the security of the service and preventing fraud.</li>
<li class="mb-1">{{ _("privacy.s3_li1") }}</li>
<li class="mb-1">{{ _("privacy.s3_li2") }}</li>
<li>{{ _("privacy.s3_li3") }}</li>
</ul>
</div>
<div id="data-minimization" class="bg-white shadow rounded-lg p-6 mb-6">
<h2 class="text-2xl font-semibold mb-4">4. Data Minimization &amp; Purpose Limitation</h2>
<h2 class="text-2xl font-semibold mb-4">{{ _("privacy.s4_heading") }}</h2>
<p class="text-gray-700 mb-3">
DocuElevate is designed with data minimization as a core principle (GDPR Art. 5(1)(c)):
{{ _("privacy.s4_p1") }}
</p>
<ul class="list-disc list-inside text-gray-700 ml-4 mb-3">
<li class="mb-1">We collect only the minimum personal data required to operate the service.</li>
<li class="mb-1">Document content is processed strictly for the purpose you initiate (OCR, storage, metadata extraction).
We do not use your documents to train AI models or for any secondary purpose.</li>
<li class="mb-1">No advertising, behavioural tracking, or profiling is performed.</li>
<li class="mb-1">No tracking cookies or analytics scripts are loaded.</li>
<li>Third-party AI services (e.g., OpenAI, Azure Document Intelligence) are invoked only when you initiate
document processing, and data is transmitted under data processing agreements.</li>
<li class="mb-1">{{ _("privacy.s4_li1") }}</li>
<li class="mb-1">{{ _("privacy.s4_li2") }}</li>
<li class="mb-1">{{ _("privacy.s4_li3") }}</li>
<li class="mb-1">{{ _("privacy.s4_li4") }}</li>
<li>{{ _("privacy.s4_li5") }}</li>
</ul>
</div>
<div id="cookies" class="bg-white shadow rounded-lg p-6 mb-6">
<h2 class="text-2xl font-semibold mb-4">5. Use of Cookies &amp; Similar Technologies</h2>
<h2 class="text-2xl font-semibold mb-4">{{ _("privacy.s5_heading") }}</h2>
<p class="text-gray-700 mb-3">
DocuElevate uses <strong>only strictly necessary session cookies</strong> to maintain your authenticated
session. These cookies are essential for the service to function and are exempt from prior-consent
requirements under the EU ePrivacy Directive (Art. 5(3)) and equivalent national laws.
{{ _("privacy.s5_p1_pre") }} <strong>{{ _("privacy.s5_p1_strong") }}</strong> {{ _("privacy.s5_p1_post") }}
</p>
<p class="text-gray-700 mb-3">
<strong>We do not use:</strong> analytics cookies, advertising cookies, tracking pixels, or any
third-party cookies that would require your consent.
<strong>{{ _("privacy.s5_p2_label") }}</strong> {{ _("privacy.s5_p2_body") }}
</p>
<p class="text-gray-700">
For full details on the cookies we set, their names, duration, and purpose, please visit our
<a href="/cookies" class="text-blue-600 hover:underline">Cookie Policy</a>.
{{ _("privacy.s5_p3_pre") }}
<a href="/cookies" class="text-blue-600 hover:underline">{{ _("privacy.s5_cookie_link") }}</a>.
</p>
</div>
<div id="third-parties" class="bg-white shadow rounded-lg p-6 mb-6">
<h2 class="text-2xl font-semibold mb-4">6. Third-Party Services</h2>
<h2 class="text-2xl font-semibold mb-4">{{ _("privacy.s6_heading") }}</h2>
<p class="text-gray-700 mb-3">
<strong>OAuth Providers (Google, Dropbox, Microsoft):</strong> When you choose to authenticate via
OAuth, the respective provider processes your credentials and may share limited profile information
with us. These providers maintain their own privacy policies.
<strong>{{ _("privacy.s6_oauth_label") }}</strong> {{ _("privacy.s6_oauth_body") }}
</p>
<p class="text-gray-700 mb-3">
<strong>Cloud Storage Providers (Google Drive, Dropbox, OneDrive, Amazon S3, Nextcloud, WebDAV/SFTP/FTP):</strong>
Documents are stored in the cloud provider you configure. Your configured credentials are stored
encrypted in the application database and are used solely to perform the storage operations you request.
<strong>{{ _("privacy.s6_storage_label") }}</strong>
{{ _("privacy.s6_storage_body") }}
</p>
<p class="text-gray-700 mb-3">
<strong>AI Processing Services (OpenAI, Azure Document Intelligence, others):</strong> When you initiate
OCR or AI-based metadata extraction, document data is transmitted to the AI service you or your
administrator has configured. This transmission is governed by a data processing agreement with the
respective provider.
<strong>{{ _("privacy.s6_ai_label") }}</strong> {{ _("privacy.s6_ai_body") }}
</p>
<p class="text-gray-700">
<strong>No Sale or Sharing for Advertising:</strong> We do not sell, rent, or share your personal data
with third parties for advertising, marketing, or any purpose unrelated to providing the service.
<strong>{{ _("privacy.s6_no_sale_label") }}</strong> {{ _("privacy.s6_no_sale_body") }}
</p>
</div>
<div id="international-transfers" class="bg-white shadow rounded-lg p-6 mb-6">
<h2 class="text-2xl font-semibold mb-4">7. International Data Transfers</h2>
<h2 class="text-2xl font-semibold mb-4">{{ _("privacy.s7_heading") }}</h2>
<p class="text-gray-700 mb-3">
DocuElevate is hosted in the European Union / EEA by default. Where personal data is transferred
outside the EEA (for example to US-based AI service providers such as OpenAI), we rely on
appropriate safeguards including:
{{ _("privacy.s7_p1") }}
</p>
<ul class="list-disc list-inside text-gray-700 ml-4 mb-3">
<li class="mb-1">
<strong>Standard Contractual Clauses (SCCs)</strong> adopted by the European Commission (2021/914/EU)
for transfers to processors and controllers in third countries.
<strong>{{ _("privacy.s7_scc_label") }}</strong> {{ _("privacy.s7_scc_body") }}
</li>
<li class="mb-1">
<strong>Adequacy Decisions</strong> where the European Commission has recognised an equivalent level
of protection (e.g., United Kingdom, Switzerland, Canada (commercial organisations), Japan, South Korea).
<strong>{{ _("privacy.s7_adequacy_label") }}</strong> {{ _("privacy.s7_adequacy_body") }}
</li>
<li>
<strong>UK International Data Transfer Agreements (IDTAs)</strong> for transfers from the UK after Brexit.
<strong>{{ _("privacy.s7_idta_label") }}</strong> {{ _("privacy.s7_idta_body") }}
</li>
</ul>
<p class="text-gray-700">
You may request a copy of the relevant safeguards by contacting us at
{{ _("privacy.s7_contact") }}
<a href="mailto:docuelevate@christian-louis.de" class="text-blue-600 hover:underline">docuelevate@christian-louis.de</a>.
</p>
</div>
<div id="retention" class="bg-white shadow rounded-lg p-6 mb-6">
<h2 class="text-2xl font-semibold mb-4">8. Data Retention</h2>
<h2 class="text-2xl font-semibold mb-4">{{ _("privacy.s8_heading") }}</h2>
<p class="text-gray-700 mb-3">
We retain personal data only as long as strictly necessary to provide the DocuElevate service or to
comply with legal obligations:
{{ _("privacy.s8_p1") }}
</p>
<ul class="list-disc list-inside text-gray-700 ml-4 mb-3">
<li class="mb-1"><strong>Session data:</strong> Deleted when you log out or after session timeout.</li>
<li class="mb-1"><strong>File records and metadata:</strong> Retained for the duration of your use of the
service. You may delete individual files at any time through the application.</li>
<li class="mb-1"><strong>Audit logs:</strong> Retained for up to 90 days for security and compliance purposes.</li>
<li><strong>OAuth tokens:</strong> Stored in encrypted form and revocable at any time via your OAuth provider.</li>
<li class="mb-1"><strong>{{ _("privacy.s8_session_label") }}</strong> {{ _("privacy.s8_session_body") }}</li>
<li class="mb-1"><strong>{{ _("privacy.s8_files_label") }}</strong> {{ _("privacy.s8_files_body") }}</li>
<li class="mb-1"><strong>{{ _("privacy.s8_audit_label") }}</strong> {{ _("privacy.s8_audit_body") }}</li>
<li><strong>{{ _("privacy.s8_oauth_label") }}</strong> {{ _("privacy.s8_oauth_body") }}</li>
</ul>
<p class="text-gray-700">
To request deletion of your account and all associated personal data, please contact us at
{{ _("privacy.s8_contact") }}
<a href="mailto:docuelevate@christian-louis.de" class="text-blue-600 hover:underline">docuelevate@christian-louis.de</a>.
</p>
</div>
<div id="security" class="bg-white shadow rounded-lg p-6 mb-6">
<h2 class="text-2xl font-semibold mb-4">9. Data Security</h2>
<h2 class="text-2xl font-semibold mb-4">{{ _("privacy.s9_heading") }}</h2>
<p class="text-gray-700 mb-3">
We implement appropriate technical and organisational measures (TOMs) to protect your personal data,
including:
{{ _("privacy.s9_p1") }}
</p>
<ul class="list-disc list-inside text-gray-700 ml-4">
<li class="mb-1">Encryption of credentials and sensitive configuration at rest.</li>
<li class="mb-1">Transport Layer Security (TLS/HTTPS) for all communications.</li>
<li class="mb-1">Role-based access controls limiting access to personal data.</li>
<li class="mb-1">Regular security audits and dependency vulnerability scanning.</li>
<li>CSRF protection on all state-changing requests.</li>
<li class="mb-1">{{ _("privacy.s9_li1") }}</li>
<li class="mb-1">{{ _("privacy.s9_li2") }}</li>
<li class="mb-1">{{ _("privacy.s9_li3") }}</li>
<li class="mb-1">{{ _("privacy.s9_li4") }}</li>
<li>{{ _("privacy.s9_li5") }}</li>
</ul>
</div>
<div id="gdpr-rights" class="bg-white shadow rounded-lg p-6 mb-6">
<h2 class="text-2xl font-semibold mb-4">10. Your Rights (EU / EEA / UK / Switzerland)</h2>
<h2 class="text-2xl font-semibold mb-4">{{ _("privacy.s10_heading") }}</h2>
<p class="text-gray-700 mb-3">
Under GDPR (and the UK GDPR / Swiss nFADP equivalent), you have the following rights:
{{ _("privacy.s10_p1") }}
</p>
<ul class="list-disc list-inside text-gray-700 ml-4 mb-3">
<li class="mb-2"><strong>Right of Access (Art. 15):</strong> You may request a copy of the personal data
we hold about you.</li>
<li class="mb-2"><strong>Right to Rectification (Art. 16):</strong> You may request correction of inaccurate
or incomplete personal data.</li>
<li class="mb-2"><strong>Right to Erasure (Art. 17):</strong> You may request deletion of your personal data
where there is no overriding legitimate reason for us to retain it.</li>
<li class="mb-2"><strong>Right to Restriction (Art. 18):</strong> You may request that we temporarily halt
processing of your data in certain circumstances.</li>
<li class="mb-2"><strong>Right to Data Portability (Art. 20):</strong> You may request your data in a
structured, commonly used, machine-readable format.</li>
<li class="mb-2"><strong>Right to Object (Art. 21):</strong> You may object to processing based on
legitimate interests at any time.</li>
<li class="mb-2"><strong>Right to Withdraw Consent:</strong> Where processing is based on consent, you
may withdraw that consent at any time without affecting the lawfulness of prior processing.</li>
<li><strong>Right to Lodge a Complaint:</strong> You have the right to lodge a complaint with your
national Data Protection Authority (DPA). In Germany: Bundesbeauftragte für den Datenschutz und
die Informationsfreiheit (BfDI). In the UK: Information Commissioner's Office (ICO). In Switzerland:
Federal Data Protection and Information Commissioner (FDPIC).</li>
<li class="mb-2"><strong>{{ _("privacy.s10_access_label") }}</strong> {{ _("privacy.s10_access_body") }}</li>
<li class="mb-2"><strong>{{ _("privacy.s10_rectification_label") }}</strong> {{ _("privacy.s10_rectification_body") }}</li>
<li class="mb-2"><strong>{{ _("privacy.s10_erasure_label") }}</strong> {{ _("privacy.s10_erasure_body") }}</li>
<li class="mb-2"><strong>{{ _("privacy.s10_restriction_label") }}</strong> {{ _("privacy.s10_restriction_body") }}</li>
<li class="mb-2"><strong>{{ _("privacy.s10_portability_label") }}</strong> {{ _("privacy.s10_portability_body") }}</li>
<li class="mb-2"><strong>{{ _("privacy.s10_object_label") }}</strong> {{ _("privacy.s10_object_body") }}</li>
<li class="mb-2"><strong>{{ _("privacy.s10_withdraw_label") }}</strong> {{ _("privacy.s10_withdraw_body") }}</li>
<li><strong>{{ _("privacy.s10_complaint_label") }}</strong> {{ _("privacy.s10_complaint_body") }}</li>
</ul>
<p class="text-gray-700">
To exercise any of the above rights, contact us at
{{ _("privacy.s10_contact") }}
<a href="mailto:docuelevate@christian-louis.de" class="text-blue-600 hover:underline">docuelevate@christian-louis.de</a>.
We will respond within one calendar month (extendable by two further months for complex requests).
{{ _("privacy.s10_response") }}
</p>
</div>
<div id="us-rights" class="bg-white shadow rounded-lg p-6 mb-6">
<h2 class="text-2xl font-semibold mb-4">11. Additional Rights United States (CCPA / CPRA)</h2>
<h2 class="text-2xl font-semibold mb-4">{{ _("privacy.s11_heading") }}</h2>
<p class="text-gray-700 mb-3">
If you are a resident of California or another US state with applicable privacy legislation
(including Virginia VCDPA, Colorado CPA, Connecticut CTDPA, Utah UCPA), the following additional
disclosures apply:
{{ _("privacy.s11_p1") }}
</p>
<ul class="list-disc list-inside text-gray-700 ml-4 mb-3">
<li class="mb-2"><strong>Categories of personal information collected:</strong> Identifiers (name, email),
account authentication tokens, and document metadata that you choose to upload.</li>
<li class="mb-2"><strong>Purpose of collection:</strong> Providing, improving, and securing the
DocuElevate service. We do not sell or share personal information for cross-context behavioural
advertising.</li>
<li class="mb-2"><strong>Right to Know:</strong> You may request disclosure of the categories and
specific pieces of personal information we have collected about you.</li>
<li class="mb-2"><strong>Right to Delete:</strong> You may request deletion of personal information we
have collected, subject to certain exceptions.</li>
<li class="mb-2"><strong>Right to Correct:</strong> You may request correction of inaccurate personal
information.</li>
<li class="mb-2"><strong>Right to Opt Out of Sale / Sharing:</strong> We do not sell or share personal
information as defined by CCPA/CPRA. No opt-out mechanism is required; however, you may contact
us to confirm this.</li>
<li class="mb-2"><strong>Right to Limit Use of Sensitive Personal Information:</strong> We do not use
sensitive personal information beyond what is necessary to provide the service.</li>
<li><strong>Non-Discrimination:</strong> We will not discriminate against you for exercising any of
these rights.</li>
<li class="mb-2"><strong>{{ _("privacy.s11_categories_label") }}</strong> {{ _("privacy.s11_categories_body") }}</li>
<li class="mb-2"><strong>{{ _("privacy.s11_purpose_label") }}</strong> {{ _("privacy.s11_purpose_body") }}</li>
<li class="mb-2"><strong>{{ _("privacy.s11_know_label") }}</strong> {{ _("privacy.s11_know_body") }}</li>
<li class="mb-2"><strong>{{ _("privacy.s11_delete_label") }}</strong> {{ _("privacy.s11_delete_body") }}</li>
<li class="mb-2"><strong>{{ _("privacy.s11_correct_label") }}</strong> {{ _("privacy.s11_correct_body") }}</li>
<li class="mb-2"><strong>{{ _("privacy.s11_optout_label") }}</strong> {{ _("privacy.s11_optout_body") }}</li>
<li class="mb-2"><strong>{{ _("privacy.s11_limit_label") }}</strong> {{ _("privacy.s11_limit_body") }}</li>
<li><strong>{{ _("privacy.s11_nondiscrim_label") }}</strong> {{ _("privacy.s11_nondiscrim_body") }}</li>
</ul>
<p class="text-gray-700">
To submit a verifiable consumer request, contact us at
{{ _("privacy.s11_contact") }}
<a href="mailto:docuelevate@christian-louis.de" class="text-blue-600 hover:underline">docuelevate@christian-louis.de</a>.
We will respond within 45 days (extendable by an additional 45 days when reasonably necessary).
{{ _("privacy.s11_response") }}
</p>
</div>
<div id="canada-rights" class="bg-white shadow rounded-lg p-6 mb-6">
<h2 class="text-2xl font-semibold mb-4">12. Additional Rights Canada (PIPEDA / Québec Law 25)</h2>
<h2 class="text-2xl font-semibold mb-4">{{ _("privacy.s12_heading") }}</h2>
<p class="text-gray-700 mb-3">
If you are located in Canada, the following applies under the Personal Information Protection and
Electronic Documents Act (PIPEDA) and applicable provincial legislation (including Québec Law 25 /
Bill 64):
{{ _("privacy.s12_p1") }}
</p>
<ul class="list-disc list-inside text-gray-700 ml-4 mb-3">
<li class="mb-2">We collect, use, and disclose personal information only with your knowledge and
consent, or as permitted by law.</li>
<li class="mb-2"><strong>Right of Access:</strong> You may request access to your personal information
and information about how it has been used or disclosed.</li>
<li class="mb-2"><strong>Right to Correction:</strong> You may challenge the accuracy or completeness
of your personal information and request correction.</li>
<li class="mb-2"><strong>Right to Withdraw Consent:</strong> Subject to legal or contractual
restrictions, you may withdraw consent to the collection, use, or disclosure of your personal
information on reasonable notice.</li>
<li class="mb-2"><strong>Québec residents:</strong> Under Law 25, you have additional rights including
the right to data portability (effective September 2023) and the right to de-indexation where
personal information is disseminated online.</li>
<li class="mb-2">{{ _("privacy.s12_li1") }}</li>
<li class="mb-2"><strong>{{ _("privacy.s12_access_label") }}</strong> {{ _("privacy.s12_access_body") }}</li>
<li class="mb-2"><strong>{{ _("privacy.s12_correction_label") }}</strong> {{ _("privacy.s12_correction_body") }}</li>
<li class="mb-2"><strong>{{ _("privacy.s12_withdraw_label") }}</strong> {{ _("privacy.s12_withdraw_body") }}</li>
<li class="mb-2"><strong>{{ _("privacy.s12_quebec_label") }}</strong> {{ _("privacy.s12_quebec_body") }}</li>
</ul>
<p class="text-gray-700">
Direct privacy complaints to our Privacy Officer at
<a href="mailto:docuelevate@christian-louis.de" class="text-blue-600 hover:underline">docuelevate@christian-louis.de</a>,
or to the Office of the Privacy Commissioner of Canada.
{{ _("privacy.s12_contact") }}
<a href="mailto:docuelevate@christian-louis.de" class="text-blue-600 hover:underline">docuelevate@christian-louis.de</a>{{ _("privacy.s12_contact_post") }}
</p>
</div>
<div id="latam-rights" class="bg-white shadow rounded-lg p-6 mb-6">
<h2 class="text-2xl font-semibold mb-4">13. Additional Rights Latin America (LGPD &amp; Others)</h2>
<h2 class="text-2xl font-semibold mb-4">{{ _("privacy.s13_heading") }}</h2>
<p class="text-gray-700 mb-3">
<strong>Brazil (LGPD Lei Geral de Proteção de Dados, Law 13.709/2018):</strong>
If you are located in Brazil, you have the following rights under the LGPD:
<strong>{{ _("privacy.s13_brazil_label") }}</strong>
{{ _("privacy.s13_brazil_body") }}
</p>
<ul class="list-disc list-inside text-gray-700 ml-4 mb-3">
<li class="mb-1">Confirmation of the existence of processing and access to your data.</li>
<li class="mb-1">Correction of incomplete, inaccurate, or outdated data.</li>
<li class="mb-1">Anonymisation, blocking, or deletion of unnecessary or excessive data.</li>
<li class="mb-1">Portability of your data to another service or product provider.</li>
<li class="mb-1">Deletion of personal data processed with your consent.</li>
<li class="mb-1">Information about entities with which your data has been shared.</li>
<li class="mb-1">Information about the possibility of not consenting and the consequences of refusal.</li>
<li>Revocation of consent.</li>
<li class="mb-1">{{ _("privacy.s13_li1") }}</li>
<li class="mb-1">{{ _("privacy.s13_li2") }}</li>
<li class="mb-1">{{ _("privacy.s13_li3") }}</li>
<li class="mb-1">{{ _("privacy.s13_li4") }}</li>
<li class="mb-1">{{ _("privacy.s13_li5") }}</li>
<li class="mb-1">{{ _("privacy.s13_li6") }}</li>
<li class="mb-1">{{ _("privacy.s13_li7") }}</li>
<li>{{ _("privacy.s13_li8") }}</li>
</ul>
<p class="text-gray-700 mb-3">
<strong>Other Latin American Countries:</strong> We also recognise applicable privacy laws in Argentina
(PDPA), Mexico (LFPDPPP), Chile, Colombia (Ley 1581), and others. Users in these jurisdictions may
exercise equivalent rights as outlined under their national law by contacting us.
<strong>{{ _("privacy.s13_other_label") }}</strong> {{ _("privacy.s13_other_body") }}
</p>
<p class="text-gray-700">
Contact:
{{ _("privacy.s13_contact") }}
<a href="mailto:docuelevate@christian-louis.de" class="text-blue-600 hover:underline">docuelevate@christian-louis.de</a>
</p>
</div>
<div id="apj-rights" class="bg-white shadow rounded-lg p-6 mb-6">
<h2 class="text-2xl font-semibold mb-4">14. Additional Rights Asia-Pacific &amp; Japan</h2>
<h2 class="text-2xl font-semibold mb-4">{{ _("privacy.s14_heading") }}</h2>
<p class="text-gray-700 mb-3">
<strong>Japan (APPI Act on the Protection of Personal Information):</strong>
Japanese residents may request disclosure, correction, addition or deletion, suspension of use,
erasure, or suspension of third-party provision of their personal information held by us.
Third-party disclosures require your prior consent except where permitted by law.
<strong>{{ _("privacy.s14_japan_label") }}</strong>
{{ _("privacy.s14_japan_body") }}
</p>
<p class="text-gray-700 mb-3">
<strong>Australia (Privacy Act 1988 and Australian Privacy Principles):</strong>
Australian residents may request access to and correction of their personal information.
We will respond to access requests within 30 days. Complaints may be lodged with the
Office of the Australian Information Commissioner (OAIC).
<strong>{{ _("privacy.s14_australia_label") }}</strong>
{{ _("privacy.s14_australia_body") }}
</p>
<p class="text-gray-700 mb-3">
<strong>South Korea (PIPA Personal Information Protection Act):</strong>
Korean residents may request access, correction, deletion, and suspension of processing.
We handle personal information of Korean residents in accordance with the PIPA.
<strong>{{ _("privacy.s14_korea_label") }}</strong>
{{ _("privacy.s14_korea_body") }}
</p>
<p class="text-gray-700 mb-3">
<strong>Other APJ markets (Singapore PDPA, New Zealand Privacy Act, India DPDP Act):</strong>
We recognise the data protection rights afforded to residents of these jurisdictions under their
respective national laws. Contact us to exercise your rights.
<strong>{{ _("privacy.s14_apj_label") }}</strong>
{{ _("privacy.s14_apj_body") }}
</p>
<p class="text-gray-700">
Contact:
{{ _("privacy.s14_contact") }}
<a href="mailto:docuelevate@christian-louis.de" class="text-blue-600 hover:underline">docuelevate@christian-louis.de</a>
</p>
</div>
<div id="ukraine-rights" class="bg-white shadow rounded-lg p-6 mb-6">
<h2 class="text-2xl font-semibold mb-4">15. Additional Rights Ukraine</h2>
<h2 class="text-2xl font-semibold mb-4">{{ _("privacy.s15_heading") }}</h2>
<p class="text-gray-700 mb-3">
Users located in Ukraine are protected under the Law of Ukraine "On Personal Data Protection"
(No. 2297-VI). Your rights include access to, correction, blocking, and deletion of your personal
data, as well as the right to object to processing.
{{ _("privacy.s15_p1") }}
</p>
<p class="text-gray-700">
Contact:
{{ _("privacy.s15_contact") }}
<a href="mailto:docuelevate@christian-louis.de" class="text-blue-600 hover:underline">docuelevate@christian-louis.de</a>
</p>
</div>
<div id="updates" class="bg-white shadow rounded-lg p-6 mb-6">
<h2 class="text-2xl font-semibold mb-4">16. Updates to this Privacy Notice</h2>
<h2 class="text-2xl font-semibold mb-4">{{ _("privacy.s16_heading") }}</h2>
<p class="text-gray-700 mb-3">
We may update this notice from time to time to reflect changes in our practices or applicable laws.
The "Last Updated" date at the top of this page indicates when the notice was last revised.
Where changes are material, we will notify users via in-application notification or email where
appropriate.
{{ _("privacy.s16_p1") }}
</p>
<p class="text-gray-700 mb-3">
If you have any questions or concerns about this Privacy Notice or your personal data, please
contact us at
{{ _("privacy.s16_p2_pre") }}
<a href="mailto:docuelevate@christian-louis.de" class="text-blue-600 hover:underline">docuelevate@christian-louis.de</a>.
</p>
<p class="text-gray-700">
Please also review our <a href="/terms" class="text-blue-600 hover:underline">Terms of Service</a>,
<a href="/cookies" class="text-blue-600 hover:underline">Cookie Policy</a>, and
<a href="/license" class="text-blue-600 hover:underline">License Information</a>.
{{ _("privacy.s16_p3_pre") }} <a href="/terms" class="text-blue-600 hover:underline">{{ _("privacy.s16_terms_link") }}</a>,
<a href="/cookies" class="text-blue-600 hover:underline">{{ _("privacy.s16_cookies_link") }}</a>,
and <a href="/license" class="text-blue-600 hover:underline">{{ _("privacy.s16_license_link") }}</a>.
</p>
</div>
</div>
+44 -35
View File
@@ -1,5 +1,5 @@
{% extends "base.html" %}
{% block title %}Profile Settings DocuElevate{% endblock %}
{% block title %}{{ _("profile.page_title") }}{% endblock %}
{% block content %}
<div
@@ -12,10 +12,10 @@
<header class="mb-8">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
<i class="fas fa-user-circle text-blue-500" aria-hidden="true"></i>
Profile Settings
{{ _("profile.heading") }}
</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Manage your display name, avatar, language, and theme preferences.
{{ _("profile.subtitle") }}
</p>
</header>
@@ -36,7 +36,7 @@
type="button"
@click="banner.visible = false"
class="ml-auto"
aria-label="Dismiss"
aria-label="{{ _('profile.dismiss') }}"
style="min-height:44px; min-width:44px;"
>
<i class="fas fa-times" aria-hidden="true"></i>
@@ -49,7 +49,7 @@
aria-labelledby="avatar-heading"
>
<h2 id="avatar-heading" class="text-base font-semibold text-gray-900 dark:text-white mb-4">
<i class="fas fa-camera text-gray-400 mr-2" aria-hidden="true"></i>Profile Picture
<i class="fas fa-camera text-gray-400 mr-2" aria-hidden="true"></i>{{ _("profile.avatar_heading") }}
</h2>
<div class="flex flex-col sm:flex-row items-center gap-6">
@@ -57,7 +57,7 @@
<div class="relative flex-shrink-0">
<img
:src="avatarUrl"
alt="Your profile picture"
alt="{{ _('profile.avatar_alt') }}"
class="w-24 h-24 rounded-full object-cover border-2 border-gray-200 dark:border-gray-600"
/>
<template x-if="hasCustomAvatar">
@@ -66,8 +66,8 @@
@click="removeAvatar()"
:disabled="saving"
class="absolute -top-1 -right-1 bg-red-500 hover:bg-red-600 text-white rounded-full w-6 h-6 flex items-center justify-center shadow focus:outline-none focus:ring-2 focus:ring-red-400"
aria-label="Remove custom avatar"
title="Remove custom avatar"
aria-label="{{ _('profile.avatar_remove') }}"
title="{{ _('profile.avatar_remove') }}"
>
<i class="fas fa-times text-xs" aria-hidden="true"></i>
</button>
@@ -77,8 +77,8 @@
<!-- Upload controls -->
<div class="flex-1 space-y-3">
<p class="text-sm text-gray-600 dark:text-gray-400">
Upload a JPEG, PNG, GIF, or WebP image up to 2 MB.
If no custom picture is set, your <a href="https://gravatar.com" class="underline" target="_blank" rel="noopener noreferrer" aria-label="Gravatar (opens in new tab)">Gravatar</a> is shown.
{% set gravatar_anchor %}<a href="https://gravatar.com" class="underline" target="_blank" rel="noopener noreferrer" aria-label="{{ _('profile.gravatar_link') }} (opens in new tab)">{{ _("profile.gravatar_link") }}</a>{% endset %}
{{ _("profile.avatar_upload_hint", gravatar=gravatar_anchor) | safe }}
</p>
<label
for="avatar-input"
@@ -86,7 +86,7 @@
style="min-height:44px;"
>
<i class="fas fa-upload" aria-hidden="true"></i>
<span>Choose image</span>
<span>{{ _("profile.choose_image") }}</span>
<input
id="avatar-input"
type="file"
@@ -107,49 +107,49 @@
aria-labelledby="general-heading"
>
<h2 id="general-heading" class="text-base font-semibold text-gray-900 dark:text-white mb-4">
<i class="fas fa-id-card text-gray-400 mr-2" aria-hidden="true"></i>General Information
<i class="fas fa-id-card text-gray-400 mr-2" aria-hidden="true"></i>{{ _("profile.general_heading") }}
</h2>
<div class="space-y-4">
<!-- Display name -->
<div>
<label for="display-name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Display Name
{{ _("profile.display_name_label") }}
</label>
<input
id="display-name"
type="text"
x-model="form.display_name"
maxlength="255"
placeholder="Your name as shown in the UI"
placeholder="{{ _('profile.display_name_placeholder') }}"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm
focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
style="min-height:44px;"
aria-describedby="display-name-hint"
/>
<p id="display-name-hint" class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Leave blank to use your account username or email.
{{ _("profile.display_name_hint") }}
</p>
</div>
<!-- Contact email -->
<div>
<label for="contact-email" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Contact / Notification E-mail
{{ _("profile.contact_email_label") }}
</label>
<input
id="contact-email"
type="email"
x-model="form.contact_email"
maxlength="255"
placeholder="you@example.com"
placeholder="{{ _('profile.contact_email_placeholder') }}"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm
focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
style="min-height:44px;"
aria-describedby="contact-email-hint"
/>
<p id="contact-email-hint" class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Used for system notifications. This does not change your login e-mail.
{{ _("profile.contact_email_hint") }}
</p>
</div>
</div>
@@ -161,14 +161,14 @@
aria-labelledby="prefs-heading"
>
<h2 id="prefs-heading" class="text-base font-semibold text-gray-900 dark:text-white mb-4">
<i class="fas fa-sliders-h text-gray-400 mr-2" aria-hidden="true"></i>Preferences
<i class="fas fa-sliders-h text-gray-400 mr-2" aria-hidden="true"></i>{{ _("profile.preferences_heading") }}
</h2>
<div class="space-y-5">
<!-- Language -->
<div>
<label for="lang-select" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
<i class="fas fa-globe text-gray-400 mr-1" aria-hidden="true"></i>UI Language
<i class="fas fa-globe text-gray-400 mr-1" aria-hidden="true"></i>{{ _("profile.language_label") }}
</label>
<select
id="lang-select"
@@ -177,20 +177,20 @@
focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
style="min-height:44px;"
>
<option value="">Auto-detect (from browser)</option>
<option value="">{{ _("profile.language_auto_detect") }}</option>
{% for lang in supported_languages %}
<option value="{{ lang.code }}">{{ lang.flag }} {{ lang.native }} ({{ lang.name }})</option>
{% endfor %}
</select>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Choose your preferred interface language. "Auto-detect" follows your browser settings.
{{ _("profile.language_hint") }}
</p>
</div>
<!-- Theme -->
<fieldset>
<legend class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
<i class="fas fa-palette text-gray-400 mr-1" aria-hidden="true"></i>Colour Scheme
<i class="fas fa-palette text-gray-400 mr-1" aria-hidden="true"></i>{{ _("profile.theme_label") }}
</legend>
<div class="flex flex-wrap gap-3">
<label
@@ -202,7 +202,7 @@
style="min-height:44px; min-width:44px;"
>
<input type="radio" name="theme" value="light" x-model="form.preferred_theme" class="sr-only" />
<i class="fas fa-sun" aria-hidden="true"></i> Light
<i class="fas fa-sun" aria-hidden="true"></i> {{ _("profile.theme_light") }}
</label>
<label
class="relative flex items-center gap-2 cursor-pointer rounded-lg border px-4 py-3 text-sm transition-colors
@@ -213,7 +213,7 @@
style="min-height:44px; min-width:44px;"
>
<input type="radio" name="theme" value="dark" x-model="form.preferred_theme" class="sr-only" />
<i class="fas fa-moon" aria-hidden="true"></i> Dark
<i class="fas fa-moon" aria-hidden="true"></i> {{ _("profile.theme_dark") }}
</label>
<label
class="relative flex items-center gap-2 cursor-pointer rounded-lg border px-4 py-3 text-sm transition-colors
@@ -224,11 +224,11 @@
style="min-height:44px; min-width:44px;"
>
<input type="radio" name="theme" value="system" x-model="form.preferred_theme" class="sr-only" />
<i class="fas fa-desktop" aria-hidden="true"></i> System Default
<i class="fas fa-desktop" aria-hidden="true"></i> {{ _("profile.theme_system") }}
</label>
</div>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
"System Default" follows your device's dark-mode setting.
{{ _("profile.theme_hint") }}
</p>
</fieldset>
</div>
@@ -245,7 +245,7 @@
style="min-height:44px; min-width:44px;"
>
<i class="fas fa-save" aria-hidden="true"></i>
<span x-text="saving ? 'Saving…' : 'Save Changes'"></span>
<span x-text="saving ? '{{ _('profile.saving') }}' : '{{ _('profile.save_button') }}'"></span>
</button>
</div>
@@ -256,13 +256,13 @@
aria-labelledby="password-heading"
>
<h2 id="password-heading" class="text-base font-semibold text-gray-900 dark:text-white mb-4">
<i class="fas fa-lock text-gray-400 mr-2" aria-hidden="true"></i>Change Password
<i class="fas fa-lock text-gray-400 mr-2" aria-hidden="true"></i>{{ _("profile.password_heading") }}
</h2>
<div class="space-y-4 max-w-md">
<div>
<label for="current-password" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Current Password
{{ _("profile.current_password") }}
</label>
<input
id="current-password"
@@ -276,7 +276,7 @@
</div>
<div>
<label for="new-password" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
New Password
{{ _("profile.new_password") }}
</label>
<input
id="new-password"
@@ -288,11 +288,11 @@
style="min-height:44px;"
aria-describedby="new-pw-hint"
/>
<p id="new-pw-hint" class="mt-1 text-xs text-gray-500 dark:text-gray-400">Minimum 8 characters.</p>
<p id="new-pw-hint" class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ _("profile.new_password_hint") }}</p>
</div>
<div>
<label for="confirm-password" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Confirm New Password
{{ _("profile.confirm_password") }}
</label>
<input
id="confirm-password"
@@ -314,7 +314,7 @@
style="min-height:44px;"
>
<i class="fas fa-key" aria-hidden="true"></i>
<span x-text="pwSaving ? 'Updating…' : 'Update Password'"></span>
<span x-text="pwSaving ? '{{ _('profile.updating') }}' : '{{ _('profile.update_password') }}'"></span>
</button>
</div>
</div>
@@ -327,7 +327,7 @@
function profileSettings() {
return {
// ── State ──────────────────────────────────────────────────────────────
avatarUrl: '/static/images/avatar-placeholder.svg',
avatarUrl: '/static/images/default-avatar.svg',
hasCustomAvatar: false,
isLocalUser: false,
saving: false,
@@ -349,6 +349,8 @@ function profileSettings() {
},
// ── Init ───────────────────────────────────────────────────────────────
_initialLanguage: '',
async init() {
try {
const res = await fetch('/api/profile');
@@ -361,6 +363,7 @@ function profileSettings() {
this.form.contact_email = data.contact_email || '';
this.form.preferred_language = data.preferred_language || '';
this.form.preferred_theme = data.preferred_theme || 'system';
this._initialLanguage = this.form.preferred_language;
} catch (_e) {
// Silently ignore — user might not be logged in (rare for this page)
}
@@ -378,6 +381,7 @@ function profileSettings() {
async saveProfile() {
this.saving = true;
this._hideBanner();
const previousLang = this._initialLanguage || '';
try {
const csrfToken = this._getCSRFToken();
@@ -394,6 +398,11 @@ function profileSettings() {
this._showBanner(data.detail || 'Failed to save settings.', true);
return;
}
// If the UI language changed, reload so the page renders in the new locale
if (this.form.preferred_language !== previousLang) {
window.location.reload();
return;
}
this._showBanner('Profile settings saved successfully.', false);
// Update display name across the nav (refresh the avatar/name)
if (data.avatar_url) {
+121 -91
View File
@@ -1,5 +1,5 @@
{% extends "base.html" %}
{% block title %}System Status{% endblock %}
{% block title %}{{ _("status.page_title") }}{% endblock %}
{% block head %}
<!-- Font Awesome is loaded in base.html -->
@@ -8,25 +8,25 @@
{% block content %}
<div class="container mx-auto px-4 py-8">
<div class="mb-8">
<h1 class="text-3xl font-bold mb-2">System Status</h1>
<h1 class="text-3xl font-bold mb-2">{{ _("status.page_title") }}</h1>
<p class="text-gray-600">
This dashboard shows the status of all configured integrations and targets.
{{ _("status.dashboard_subtitle") }}
</p>
<div class="bg-blue-100 border-l-4 border-blue-500 text-blue-700 p-4 my-4" role="alert">
<p><strong>App Version:</strong> {{ app_version }}{% if release_name %} <span class="italic">"{{ release_name }}"</span>{% endif %}</p>
<p><strong>Build Date:</strong> {{ build_date }}</p>
<p><strong>Debug Mode:</strong> {{ "Enabled" if debug_enabled else "Disabled" }}</p>
<p><strong>{{ _("status.app_version") }}:</strong> {{ app_version }}{% if release_name %} <span class="italic">"{{ release_name }}"</span>{% endif %}</p>
<p><strong>{{ _("status.build_date") }}:</strong> {{ build_date }}</p>
<p><strong>{{ _("status.debug_mode") }}:</strong> {{ _("common.enabled") if debug_enabled else _("common.disabled") }}</p>
{% if last_check %}
<p><strong>Last Check:</strong> {{ last_check }}</p>
<p><strong>{{ _("status.last_check") }}:</strong> {{ last_check }}</p>
{% endif %}
{% if container_info.is_docker %}
<p><strong>Container ID:</strong> {{ container_info.id }}</p>
<p><strong>{{ _("status.container_id") }}:</strong> {{ container_info.id }}</p>
{% endif %}
{% if container_info.git_sha and container_info.git_sha != 'Unknown' %}
<p><strong>Git Commit:</strong> {{ container_info.git_sha }}</p>
<p><strong>{{ _("status.git_commit") }}:</strong> {{ container_info.git_sha }}</p>
{% endif %}
{% if container_info.runtime_info %}
<p><strong>Container Started:</strong> {{ container_info.runtime_info }}</p>
<p><strong>{{ _("status.container_started") }}:</strong> {{ container_info.runtime_info }}</p>
{% endif %}
</div>
</div>
@@ -44,11 +44,11 @@
</div>
{% if provider.configured %}
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800">
Configured
{{ _("status.configured") }}
</span>
{% else %}
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-red-100 text-red-800">
Not Configured
{{ _("status.not_configured") }}
</span>
{% endif %}
</div>
@@ -81,14 +81,14 @@
<svg class="-ml-0.5 mr-1.5 h-2 w-2 text-green-400" fill="currentColor" viewBox="0 0 8 8">
<circle cx="4" cy="4" r="3" />
</svg>
Active
{{ _("status.active") }}
</span>
{% else %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-md text-sm font-medium bg-gray-100 text-gray-800">
<svg class="-ml-0.5 mr-1.5 h-2 w-2 text-gray-400" fill="currentColor" viewBox="0 0 8 8">
<circle cx="4" cy="4" r="3" />
</svg>
Inactive
{{ _("status.inactive") }}
</span>
{% endif %}
</div>
@@ -100,7 +100,7 @@
class="view-details-btn inline-flex items-center px-2.5 py-1.5 border border-gray-300 text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
data-provider="{{ name }}"
data-details="{{ provider.details|tojson|forceescape }}">
View Details
{{ _("status.view_details") }}
</button>
{% endif %}
@@ -111,7 +111,7 @@
class="test-generic-btn inline-flex items-center px-2.5 py-1.5 border border-gray-300 text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
data-endpoint="{{ provider.test_endpoint }}"
data-method="POST">
Test Notifications
{{ _("status.test_notifications") }}
</button>
{% endif %}
@@ -121,7 +121,7 @@
class="test-generic-btn inline-flex items-center px-2.5 py-1.5 border border-gray-300 text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
data-endpoint="{{ provider.test_endpoint }}"
data-method="{{ provider.test_method|default('GET') }}">
Test {{ name }}
{{ _("status.test_provider", name=name) }}
</button>
{% endif %}
@@ -131,15 +131,15 @@
<button
class="test-provider-btn inline-flex items-center px-2.5 py-1.5 border border-gray-300 text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
data-provider="dropbox">
Test Connection
{{ _("status.test_connection") }}
</button>
<a href="/dropbox-setup" class="inline-flex items-center px-2.5 py-1.5 border border-transparent text-xs font-medium rounded text-indigo-700 bg-indigo-100 hover:bg-indigo-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
<i class="fa-solid fa-gear h-3 w-3 mr-1"></i>
Manage
<i class="fa-solid fa-gear h-3 w-3 mr-1" aria-hidden="true"></i>
{{ _("status.manage") }}
</a>
{% else %}
<a href="/dropbox-setup" class="inline-flex items-center px-2.5 py-1.5 border border-transparent text-xs font-medium rounded text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
Configure Now
{{ _("status.configure_now") }}
</a>
{% endif %}
{% elif name == "OneDrive" %}
@@ -147,15 +147,15 @@
<button
class="test-provider-btn inline-flex items-center px-2.5 py-1.5 border border-gray-300 text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
data-provider="onedrive">
Test Connection
{{ _("status.test_connection") }}
</button>
<a href="/onedrive-setup" class="inline-flex items-center px-2.5 py-1.5 border border-transparent text-xs font-medium rounded text-indigo-700 bg-indigo-100 hover:bg-indigo-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
<i class="fa-solid fa-gear h-3 w-3 mr-1"></i>
Manage
<i class="fa-solid fa-gear h-3 w-3 mr-1" aria-hidden="true"></i>
{{ _("status.manage") }}
</a>
{% else %}
<a href="/onedrive-setup" class="inline-flex items-center px-2.5 py-1.5 border border-transparent text-xs font-medium rounded text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
Configure Now
{{ _("status.configure_now") }}
</a>
{% endif %}
{% elif name == "Google Drive" %}
@@ -163,15 +163,15 @@
<button
class="test-provider-btn inline-flex items-center px-2.5 py-1.5 border border-gray-300 text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
data-provider="google_drive">
Test Connection
{{ _("status.test_connection") }}
</button>
<a href="/google-drive-setup" class="inline-flex items-center px-2.5 py-1.5 border border-transparent text-xs font-medium rounded text-indigo-700 bg-indigo-100 hover:bg-indigo-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
<i class="fa-solid fa-gear h-3 w-3 mr-1"></i>
Manage
<i class="fa-solid fa-gear h-3 w-3 mr-1" aria-hidden="true"></i>
{{ _("status.manage") }}
</a>
{% else %}
<a href="/google-drive-setup" class="inline-flex items-center px-2.5 py-1.5 border border-transparent text-xs font-medium rounded text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
Configure Now
{{ _("status.configure_now") }}
</a>
{% endif %}
{% elif name == "AI Provider" %}
@@ -179,13 +179,13 @@
<button
class="test-provider-btn inline-flex items-center px-2.5 py-1.5 border border-gray-300 text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
data-provider="ai_provider">
Test Connection
{{ _("status.test_connection") }}
</button>
<button
id="testAiExtractionBtn"
class="inline-flex items-center px-2.5 py-1.5 border border-transparent text-xs font-medium rounded text-indigo-700 bg-indigo-100 hover:bg-indigo-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
<i class="fa-solid fa-flask mr-1"></i>
Test Extraction
<i class="fa-solid fa-flask mr-1" aria-hidden="true"></i>
{{ _("status.test_extraction") }}
</button>
{% endif %}
{% elif name == "Azure AI" %}
@@ -193,20 +193,20 @@
<button
class="test-provider-btn inline-flex items-center px-2.5 py-1.5 border border-gray-300 text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
data-provider="azure">
Test Connection
{{ _("status.test_connection") }}
</button>
{% endif %}
{% elif provider.configured and name == "Paperless-ngx" %}
<a href="{{ provider.details.host }}" target="_blank" class="inline-flex items-center px-2.5 py-1.5 border border-gray-300 text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
<i class="fa-solid fa-external-link h-3 w-3 mr-1"></i>
Open
<i class="fa-solid fa-external-link h-3 w-3 mr-1" aria-hidden="true"></i>
{{ _("status.open") }}
</a>
{% elif provider.configured and name == "NextCloud" %}
{% if provider.details and provider.details.url %}
{% set nextcloud_base_url = provider.details.url.split('/remote.php')[0] %}
<a href="{{ nextcloud_base_url }}" target="_blank" class="inline-flex items-center px-2.5 py-1.5 border border-gray-300 text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
<i class="fa-solid fa-external-link h-3 w-3 mr-1"></i>
Open
<i class="fa-solid fa-external-link h-3 w-3 mr-1" aria-hidden="true"></i>
{{ _("status.open") }}
</a>
{% endif %}
{% endif %}
@@ -219,14 +219,14 @@
</div>
<div class="mt-8 bg-gray-50 p-4 rounded-lg border border-gray-200">
<h3 class="text-lg font-medium text-gray-900">Configuration Settings</h3>
<h3 class="text-lg font-medium text-gray-900">{{ _("status.config_settings") }}</h3>
<p class="text-sm text-gray-600 mt-1">
For more detailed configuration settings and environment variables, check the settings page.
{{ _("status.config_settings_desc") }}
</p>
<div class="mt-4">
<a href="/settings" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
View Detailed Configuration
{{ _("status.view_config") }}
</a>
</div>
</div>
@@ -238,15 +238,15 @@
<div id="modalIcon" class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100 mb-4">
<!-- Icon will be injected by JS -->
</div>
<h3 id="modalTitle" class="text-lg leading-6 font-medium text-gray-900">Success</h3>
<h3 id="modalTitle" class="text-lg leading-6 font-medium text-gray-900">{{ _("status.modal_default_title") }}</h3>
<div class="mt-2 px-7 py-3">
<p id="modalMessage" class="text-sm text-gray-500">
Operation completed successfully.
{{ _("status.modal_default_message") }}
</p>
</div>
<div class="items-center px-4 py-3">
<button id="modalClose" class="px-4 py-2 bg-blue-500 text-white text-base font-medium rounded-md w-full shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-300">
Close
{{ _("common.close") }}
</button>
</div>
</div>
@@ -258,12 +258,12 @@
<div class="relative top-20 mx-auto p-5 border w-11/12 md:w-2/3 lg:w-1/2 shadow-lg rounded-md bg-white">
<div class="absolute top-0 right-0 pt-4 pr-4">
<button type="button" id="closeDetailsModal" class="text-gray-400 hover:text-gray-500">
<span class="sr-only">Close</span>
<i class="fa-solid fa-xmark h-6 w-6"></i>
<span class="sr-only">{{ _("common.close") }}</span>
<i class="fa-solid fa-xmark h-6 w-6" aria-hidden="true"></i>
</button>
</div>
<div class="mt-3">
<h3 id="detailsModalTitle" class="text-lg leading-6 font-medium text-gray-900 mb-4">Provider Details</h3>
<h3 id="detailsModalTitle" class="text-lg leading-6 font-medium text-gray-900 mb-4">{{ _("status.provider_details") }}</h3>
<div class="mt-4">
<dl class="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2">
<div id="detailsContent" class="col-span-2">
@@ -273,7 +273,7 @@
</div>
<div class="mt-6 flex justify-end">
<button type="button" id="closeDetailsBtn" class="px-4 py-2 bg-indigo-600 text-white text-sm font-medium rounded-md shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-600">
Close
{{ _("common.close") }}
</button>
</div>
</div>
@@ -285,35 +285,34 @@
<div class="relative top-10 mx-auto p-5 border w-11/12 md:w-3/4 lg:w-2/3 xl:w-1/2 shadow-lg rounded-md bg-white">
<div class="absolute top-0 right-0 pt-4 pr-4">
<button type="button" id="closeAiExtractionModal" class="text-gray-400 hover:text-gray-500">
<span class="sr-only">Close</span>
<i class="fa-solid fa-xmark h-6 w-6"></i>
<span class="sr-only">{{ _("common.close") }}</span>
<i class="fa-solid fa-xmark h-6 w-6" aria-hidden="true"></i>
</button>
</div>
<div class="mt-3">
<h3 id="aiExtractionModalTitle" class="text-lg leading-6 font-medium text-gray-900 mb-1">
<i class="fa-solid fa-flask mr-2 text-indigo-600"></i>AI Extraction Test
<i class="fa-solid fa-flask mr-2 text-indigo-600" aria-hidden="true"></i>{{ _("status.ai_extraction_title") }}
</h3>
<p class="text-sm text-gray-500 mb-4">
Paste the plain-text content of a document below and run it through the configured AI provider
to inspect the raw response, extracted JSON, and tags.
{{ _("status.ai_extraction_desc") }}
</p>
<!-- Input area -->
<div id="aiExtractionInput">
<label for="aiExtractionText" class="block text-sm font-medium text-gray-700 mb-1">Document Text</label>
<label for="aiExtractionText" class="block text-sm font-medium text-gray-700 mb-1">{{ _("status.ai_extraction_label") }}</label>
<textarea
id="aiExtractionText"
rows="10"
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-mono focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Paste the plain-text content of your document here…"></textarea>
placeholder="{{ _('status.ai_extraction_placeholder') }}"></textarea>
<div class="mt-3 flex justify-end space-x-2">
<button type="button" id="cancelAiExtractionBtn"
class="px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
Cancel
{{ _("common.cancel") }}
</button>
<button type="button" id="runAiExtractionBtn"
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
<i class="fa-solid fa-play mr-2"></i>Run Extraction
<i class="fa-solid fa-play mr-2" aria-hidden="true"></i>{{ _("status.run_extraction") }}
</button>
</div>
</div>
@@ -328,30 +327,30 @@
<!-- Raw response -->
<div>
<h4 class="text-sm font-medium text-gray-900 mb-1">Raw LLM Response</h4>
<h4 class="text-sm font-medium text-gray-900 mb-1">{{ _("status.raw_llm_response") }}</h4>
<pre id="aiRawResponse" class="bg-gray-50 border border-gray-200 rounded p-3 text-xs overflow-auto max-h-48 whitespace-pre-wrap break-words"></pre>
</div>
<!-- Parsed JSON -->
<div id="aiParsedJsonSection">
<h4 class="text-sm font-medium text-gray-900 mb-1">Parsed JSON</h4>
<h4 class="text-sm font-medium text-gray-900 mb-1">{{ _("status.parsed_json_label") }}</h4>
<pre id="aiParsedJson" class="bg-gray-50 border border-gray-200 rounded p-3 text-xs overflow-auto max-h-64 whitespace-pre-wrap break-words"></pre>
</div>
<!-- Tags -->
<div id="aiTagsSection">
<h4 class="text-sm font-medium text-gray-900 mb-1">Extracted Tags</h4>
<h4 class="text-sm font-medium text-gray-900 mb-1">{{ _("status.extracted_tags") }}</h4>
<div id="aiTags" class="flex flex-wrap gap-2"></div>
</div>
<div class="flex justify-end space-x-2 pt-2">
<button type="button" id="aiExtractionBackBtn"
class="px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
<i class="fa-solid fa-arrow-left mr-1"></i>Back
<i class="fa-solid fa-arrow-left mr-1" aria-hidden="true"></i>{{ _("common.back") }}
</button>
<button type="button" id="aiExtractionCloseBtn"
class="px-4 py-2 bg-indigo-600 text-white text-sm font-medium rounded-md shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-600">
Close
{{ _("common.close") }}
</button>
</div>
</div>
@@ -363,6 +362,37 @@
{% block scripts %}
<script>
/* Translated strings for JavaScript use */
const i18nStrings = {
providerConfigDetails: {{ _("status.provider_config_details") | tojson }},
noDetails: {{ _("status.no_details") | tojson }},
settingLabel: {{ _("status.setting_label") | tojson }},
valueLabel: {{ _("status.value_label") | tojson }},
testNotificationSent: {{ _("status.test_notification_sent") | tojson }},
notificationConfigMissing: {{ _("status.notification_config_missing") | tojson }},
testNotificationFailed: {{ _("status.test_notification_failed") | tojson }},
errorTestingNotifications: {{ _("status.error_testing_notifications") | tojson }},
testSuccessful: {{ _("status.test_successful") | tojson }},
tokenValidFor: {{ _("status.token_valid_for") | tojson }},
warning: {{ _("common.warning") | tojson }},
authRequired: {{ _("status.auth_required") | tojson }},
tokenExpired: {{ _("status.token_expired") | tojson }},
testFailed: {{ _("status.test_failed") | tojson }},
connectionError: {{ _("status.connection_error") | tojson }},
connectionTestSuccessful: {{ _("status.connection_test_successful") | tojson }},
connectionTestFailed: {{ _("status.connection_test_failed") | tojson }},
errorTestingConnection: {{ _("status.error_testing_connection") | tojson }},
sending: {{ _("status.sending") | tojson }},
testing: {{ _("status.testing") | tojson }},
jsonParseIssue: {{ _("status.json_parse_issue") | tojson }},
aiExtractionFailed: {{ _("status.ai_extraction_failed") | tojson }},
errorRunningExtraction: {{ _("status.error_running_extraction") | tojson }},
running: {{ _("status.running") | tojson }},
aiEmptyResponse: {{ _("status.ai_empty_response") | tojson }},
asAccount: {{ _("status.as_account") | tojson }},
configureNow: {{ _("status.configure_now") | tojson }},
};
document.addEventListener('DOMContentLoaded', function() {
// Modal elements
const resultModal = document.getElementById('resultModal');
@@ -400,7 +430,7 @@ document.addEventListener('DOMContentLoaded', function() {
}
function showDetailsModal(providerName, details) {
detailsModalTitle.textContent = providerName + ' Configuration Details';
detailsModalTitle.textContent = i18nStrings.providerConfigDetails.replace('{name}', providerName);
// Clear previous content
detailsContent.innerHTML = '';
@@ -414,8 +444,8 @@ document.addEventListener('DOMContentLoaded', function() {
thead.className = 'bg-gray-50';
thead.innerHTML = `
<tr>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Setting</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Value</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">${i18nStrings.settingLabel}</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">${i18nStrings.valueLabel}</th>
</tr>
`;
@@ -456,7 +486,7 @@ document.addEventListener('DOMContentLoaded', function() {
table.appendChild(tbody);
detailsContent.appendChild(table);
} else {
detailsContent.innerHTML = '<p class="text-sm text-gray-500">No details available</p>';
detailsContent.innerHTML = `<p class="text-sm text-gray-500">${i18nStrings.noDetails}</p>`;
}
detailsModal.classList.remove('hidden');
@@ -510,7 +540,7 @@ document.addEventListener('DOMContentLoaded', function() {
testNotificationBtn.addEventListener('click', function() {
const originalText = this.innerHTML;
this.innerHTML = '<i class="fa-solid fa-spinner fa-spin mr-2"></i> Sending...';
this.innerHTML = `<i class="fa-solid fa-spinner fa-spin mr-2"></i> ${i18nStrings.sending}`;
this.disabled = true;
fetch('/api/diagnostic/test-notification', {
@@ -519,15 +549,15 @@ document.addEventListener('DOMContentLoaded', function() {
.then(response => response.json())
.then(data => {
if (data.status === 'success') {
showModal('success', 'Test Notification Sent', data.message);
showModal('success', i18nStrings.testNotificationSent, data.message);
} else if (data.status === 'warning') {
showModal('error', 'Notification Configuration Missing', data.message);
showModal('error', i18nStrings.notificationConfigMissing, data.message);
} else {
showModal('error', 'Test Notification Failed', data.message);
showModal('error', i18nStrings.testNotificationFailed, data.message);
}
})
.catch(error => {
showModal('error', 'Connection Error', 'Error testing notifications: ' + error.message);
showModal('error', i18nStrings.connectionError, i18nStrings.errorTestingNotifications + error.message);
})
.finally(() => {
// Always restore the button text and enable the button, regardless of success or failure
@@ -545,7 +575,7 @@ document.addEventListener('DOMContentLoaded', function() {
const endpoint = this.getAttribute('data-endpoint');
const method = this.getAttribute('data-method') || 'GET';
this.innerHTML = '<i class="fa-solid fa-spinner fa-spin mr-2"></i> Testing...';
this.innerHTML = `<i class="fa-solid fa-spinner fa-spin mr-2"></i> ${i18nStrings.testing}`;
this.disabled = true;
fetch(endpoint, {
@@ -558,28 +588,28 @@ document.addEventListener('DOMContentLoaded', function() {
if (data.token_info && data.token_info.expires_in_human) {
let message = data.message || 'Connection successful';
message += `<br><br><div class="bg-blue-50 p-3 rounded mt-2">
<span class="font-medium">Token valid for:</span> ${data.token_info.expires_in_human}
<span class="font-medium">${i18nStrings.tokenValidFor}</span> ${data.token_info.expires_in_human}
</div>`;
modalTitle.textContent = 'Test Successful';
modalTitle.textContent = i18nStrings.testSuccessful;
modalMessage.innerHTML = message;
modalIcon.innerHTML = '<i class="fa-solid fa-check text-green-600 fa-2x"></i>';
modalIcon.className = 'mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100 mb-4';
resultModal.classList.remove('hidden');
} else {
// Regular success
showModal('success', 'Test Successful', data.message + (data.account ? ' as ' + data.account : ''));
showModal('success', i18nStrings.testSuccessful, data.message + (data.account ? ' ' + i18nStrings.asAccount + ' ' + data.account : ''));
}
} else if (data.status === 'warning') {
showModal('error', 'Warning', data.message);
showModal('error', i18nStrings.warning, data.message);
} else if (data.needs_reauth) {
showModal('error', 'Authentication Required', 'Your token has expired or is invalid. Please reconfigure this connection.');
showModal('error', i18nStrings.authRequired, i18nStrings.tokenExpired);
} else {
showModal('error', 'Test Failed', data.message);
showModal('error', i18nStrings.testFailed, data.message);
}
})
.catch(error => {
showModal('error', 'Connection Error', `Error: ${error.message}`);
showModal('error', i18nStrings.connectionError, `Error: ${error.message}`);
})
.finally(() => {
// Always restore the button state
@@ -596,7 +626,7 @@ document.addEventListener('DOMContentLoaded', function() {
const provider = this.getAttribute('data-provider');
const originalText = this.textContent;
this.innerHTML = '<i class="fa-solid fa-spinner fa-spin mr-2"></i> Testing...';
this.innerHTML = `<i class="fa-solid fa-spinner fa-spin mr-2"></i> ${i18nStrings.testing}`;
this.disabled = true;
let endpoint = '';
@@ -622,35 +652,35 @@ document.addEventListener('DOMContentLoaded', function() {
// Add token expiration info if available (especially for Google Drive)
if (data.token_info && data.token_info.expires_in_human) {
message += `<br><br><div class="bg-blue-50 p-3 rounded mt-2">
<span class="font-medium">Token valid for:</span> ${data.token_info.expires_in_human}
<span class="font-medium">${i18nStrings.tokenValidFor}</span> ${data.token_info.expires_in_human}
</div>`;
// Show the message with HTML
modalTitle.textContent = 'Connection Test Successful';
modalTitle.textContent = i18nStrings.connectionTestSuccessful;
modalMessage.innerHTML = message;
modalIcon.innerHTML = '<i class="fa-solid fa-check text-green-600 fa-2x"></i>';
modalIcon.className = 'mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100 mb-4';
resultModal.classList.remove('hidden');
} else {
// Use the regular modal without HTML
showModal('success', 'Connection Test Successful', `${data.message} ${data.account ? 'as ' + data.account : ''}`);
showModal('success', i18nStrings.connectionTestSuccessful, `${data.message} ${data.account ? i18nStrings.asAccount + ' ' + data.account : ''}`);
}
} else {
if (data.needs_reauth) {
showModal('error', 'Authentication Required', 'Your token has expired or is invalid. Please reconfigure this connection.');
showModal('error', i18nStrings.authRequired, i18nStrings.tokenExpired);
// Add option to go to config page
modalClose.textContent = "Configure Now";
modalClose.textContent = i18nStrings.configureNow;
modalClose.addEventListener('click', function redirectToConfig() {
window.location.href = `/${provider.replace('_', '-')}-setup`;
modalClose.removeEventListener('click', redirectToConfig);
}, { once: true });
} else {
showModal('error', 'Connection Test Failed', data.message);
showModal('error', i18nStrings.connectionTestFailed, data.message);
}
}
})
.catch(error => {
showModal('error', 'Connection Error', 'Error testing connection: ' + error.message);
showModal('error', i18nStrings.connectionError, i18nStrings.errorTestingConnection + error.message);
})
.finally(() => {
this.textContent = originalText;
@@ -696,11 +726,11 @@ document.addEventListener('DOMContentLoaded', function() {
aiExtractionMeta.textContent = `Provider: ${data.provider || '—'} | Model: ${data.model || '—'}`;
// Raw response
aiRawResponse.textContent = data.raw_response || '(empty)';
aiRawResponse.textContent = data.raw_response || i18nStrings.aiEmptyResponse;
// Parse warning
if (data.parse_error) {
aiParseWarning.textContent = `JSON parse issue: ${data.parse_error}`;
aiParseWarning.textContent = i18nStrings.jsonParseIssue + data.parse_error;
aiParseWarning.classList.remove('hidden');
} else {
aiParseWarning.classList.add('hidden');
@@ -775,7 +805,7 @@ document.addEventListener('DOMContentLoaded', function() {
}
const originalHTML = runAiExtractionBtn.innerHTML;
runAiExtractionBtn.innerHTML = '<i class="fa-solid fa-spinner fa-spin mr-2"></i>Running…';
runAiExtractionBtn.innerHTML = `<i class="fa-solid fa-spinner fa-spin mr-2"></i>${i18nStrings.running}`;
runAiExtractionBtn.disabled = true;
cancelAiExtractionBtn.disabled = true;
@@ -790,12 +820,12 @@ document.addEventListener('DOMContentLoaded', function() {
showAiExtractionResults(data);
} else {
closeAiExtractionModalFn();
showModal('error', 'AI Extraction Failed', data.message || 'Unknown error');
showModal('error', i18nStrings.aiExtractionFailed, data.message || 'Unknown error');
}
})
.catch(error => {
closeAiExtractionModalFn();
showModal('error', 'Connection Error', 'Error running extraction: ' + error.message);
showModal('error', i18nStrings.connectionError, i18nStrings.errorRunningExtraction + error.message);
})
.finally(() => {
runAiExtractionBtn.innerHTML = originalHTML;
+39 -40
View File
@@ -1,5 +1,5 @@
{% extends "base.html" %}
{% block title %}My Subscription DocuElevate{% endblock %}
{% block title %}{{ _("subscription.page_title") }}{% endblock %}
{% block content %}
<div class="container mx-auto px-4 py-8 max-w-4xl">
@@ -8,9 +8,9 @@
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900 flex items-center gap-3">
<i class="fas fa-layer-group text-indigo-500" aria-hidden="true"></i>
My Subscription
{{ _("subscription.heading") }}
</h1>
<p class="text-gray-500 text-sm mt-1">Your current plan, usage and available upgrades.</p>
<p class="text-gray-500 text-sm mt-1">{{ _("subscription.subtitle") }}</p>
</div>
<!-- Flash messages -->
@@ -21,11 +21,10 @@
<div class="bg-blue-50 border border-blue-200 rounded-xl p-6 flex items-start gap-4">
<i class="fas fa-info-circle text-blue-500 text-2xl flex-shrink-0 mt-0.5" aria-hidden="true"></i>
<div>
<p class="font-semibold text-blue-800">Multi-user mode is not enabled.</p>
<p class="font-semibold text-blue-800">{{ _("subscription.single_user_title") }}</p>
<p class="text-blue-700 text-sm mt-1">
Subscription tiers apply when multi-user mode is active. Your instance currently runs in
single-user mode with no processing limits.
An admin can enable multi-user mode via <a href="/settings" class="underline hover:text-blue-900">Settings</a>.
{% set settings_link %}<a href="/settings" class="underline hover:text-blue-900">{{ _("nav.settings") }}</a>{% endset %}
{{ _("subscription.single_user_body", settings_link=settings_link) | safe }}
</p>
</div>
</div>
@@ -37,17 +36,17 @@
<div class="bg-amber-50 border border-amber-300 rounded-xl p-4 mb-6 flex items-start gap-3">
<i class="fas fa-clock text-amber-500 text-xl flex-shrink-0 mt-0.5" aria-hidden="true"></i>
<div class="flex-1">
<p class="font-semibold text-amber-800">Pending plan change scheduled</p>
<p class="font-semibold text-amber-800">{{ _("subscription.pending_title") }}</p>
<p class="text-amber-700 text-sm mt-1">
Your plan will change to <strong>{{ pending_tier.name }}</strong>
on <strong>{% if pending_date %}{{ pending_date.strftime('%B') }} {{ pending_date.day }}, {{ pending_date.year }}{% endif %}</strong>.
You keep all current plan benefits until then.
{{ _("subscription.pending_will_change") }} <strong>{{ pending_tier.name }}</strong>
{{ _("subscription.pending_on") }} <strong>{% if pending_date %}{{ pending_date.strftime('%B') }} {{ pending_date.day }}, {{ pending_date.year }}{% endif %}</strong>.
{{ _("subscription.pending_benefits") }}
</p>
</div>
<button type="button" onclick="cancelPendingChange()"
class="ml-auto flex-shrink-0 text-sm text-amber-700 underline hover:text-amber-900 font-medium"
aria-label="Cancel pending plan change">
Cancel change
aria-label="{{ _('subscription.cancel_pending') }}">
{{ _("subscription.cancel_pending") }}
</button>
</div>
{% endif %}
@@ -68,18 +67,18 @@
{% else %}fa-bolt text-purple-600{% endif %}
text-2xl" aria-hidden="true"></i>
</div>
<p class="text-xs font-semibold text-gray-400 uppercase tracking-widest">Current Plan</p>
<p class="text-xs font-semibold text-gray-400 uppercase tracking-widest">{{ _("subscription.current_plan") }}</p>
<p class="text-2xl font-extrabold text-gray-900 mt-1">{{ tier.name }}</p>
<p class="text-gray-500 text-sm mt-1">{{ tier.tagline }}</p>
{% if tier.price_monthly > 0 %}
<p class="mt-3 text-lg font-bold text-indigo-600">${{ tier.price_monthly }}<span class="text-sm font-normal text-gray-400">/month</span></p>
<p class="mt-3 text-lg font-bold text-indigo-600">${{ tier.price_monthly }}<span class="text-sm font-normal text-gray-400">{{ _("subscription.per_month") }}</span></p>
{% else %}
<p class="mt-3 text-lg font-bold text-gray-500">Free</p>
<p class="mt-3 text-lg font-bold text-gray-500">{{ _("subscription.free") }}</p>
{% endif %}
{% if period_start %}
<p class="text-xs text-gray-400 mt-2">
<i class="fas fa-calendar-alt mr-1" aria-hidden="true"></i>
Since {{ period_start.strftime('%b') }} {{ period_start.day }}, {{ period_start.year }}
{{ _("subscription.since") }} {{ period_start.strftime('%b') }} {{ period_start.day }}, {{ period_start.year }}
</p>
{% endif %}
</div>
@@ -87,13 +86,13 @@
<!-- Usage this period -->
{% if usage %}
<div class="md:col-span-2 bg-white rounded-xl shadow p-6">
<h2 class="text-sm font-semibold text-gray-500 uppercase tracking-widest mb-4">Usage</h2>
<h2 class="text-sm font-semibold text-gray-500 uppercase tracking-widest mb-4">{{ _("subscription.usage_heading") }}</h2>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<!-- Lifetime -->
<div class="text-center">
<p class="text-3xl font-extrabold text-gray-900">{{ usage.lifetime }}</p>
<p class="text-xs text-gray-500 mt-1">Total files (lifetime)</p>
<p class="text-xs text-gray-500 mt-1">{{ _("subscription.lifetime_files") }}</p>
{% if tier.lifetime_file_limit > 0 %}
<div class="mt-2">
{% set lifetime_pct = ((usage.lifetime / tier.lifetime_file_limit) * 100) | int %}
@@ -106,14 +105,14 @@
<p class="text-xs text-gray-400 mt-1">{{ usage.lifetime }} / {{ tier.lifetime_file_limit }}</p>
</div>
{% else %}
<p class="text-xs text-green-600 mt-1">Unlimited</p>
<p class="text-xs text-green-600 mt-1">{{ _("subscription.unlimited") }}</p>
{% endif %}
</div>
<!-- Today -->
<div class="text-center">
<p class="text-3xl font-extrabold text-gray-900">{{ usage.today }}</p>
<p class="text-xs text-gray-500 mt-1">Files today</p>
<p class="text-xs text-gray-500 mt-1">{{ _("subscription.today_files") }}</p>
{% if tier.daily_upload_limit > 0 %}
<div class="mt-2">
{% set today_pct = ((usage.today / tier.daily_upload_limit) * 100) | int %}
@@ -123,17 +122,17 @@
{% if today_pct >= 90 %}bg-red-500{% elif today_pct >= 70 %}bg-yellow-500{% else %}bg-blue-500{% endif %}"
style="width: {{ [today_pct, 100] | min }}%"></div>
</div>
<p class="text-xs text-gray-400 mt-1">{{ usage.today }} / {{ tier.daily_upload_limit }} today</p>
<p class="text-xs text-gray-400 mt-1">{{ usage.today }} / {{ tier.daily_upload_limit }} {{ _("subscription.today_label") }}</p>
</div>
{% else %}
<p class="text-xs text-green-600 mt-1">Unlimited</p>
<p class="text-xs text-green-600 mt-1">{{ _("subscription.unlimited") }}</p>
{% endif %}
</div>
<!-- This month -->
<div class="text-center">
<p class="text-3xl font-extrabold text-gray-900">{{ usage.month }}</p>
<p class="text-xs text-gray-500 mt-1">Files this month</p>
<p class="text-xs text-gray-500 mt-1">{{ _("subscription.month_files") }}</p>
{% if tier.monthly_upload_limit > 0 %}
<div class="mt-2">
{% set month_pct = ((usage.month / tier.monthly_upload_limit) * 100) | int %}
@@ -143,10 +142,10 @@
{% if month_pct >= 90 %}bg-red-500{% elif month_pct >= 70 %}bg-yellow-500{% else %}bg-indigo-500{% endif %}"
style="width: {{ [month_pct, 100] | min }}%"></div>
</div>
<p class="text-xs text-gray-400 mt-1">{{ usage.month }} / {{ tier.monthly_upload_limit }} this month</p>
<p class="text-xs text-gray-400 mt-1">{{ usage.month }} / {{ tier.monthly_upload_limit }} {{ _("subscription.this_month_label") }}</p>
</div>
{% else %}
<p class="text-xs text-green-600 mt-1">Unlimited</p>
<p class="text-xs text-green-600 mt-1">{{ _("subscription.unlimited") }}</p>
{% endif %}
</div>
@@ -160,7 +159,7 @@
<div class="bg-white rounded-xl shadow p-6 mb-8">
<h2 class="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
<i class="fas fa-list-check text-green-500" aria-hidden="true"></i>
What's included in your plan
{{ _("subscription.features_heading") }}
</h2>
<ul class="grid grid-cols-1 sm:grid-cols-2 gap-2 text-sm text-gray-600">
{% for feature in tier.features %}
@@ -176,7 +175,7 @@
<div class="mb-8">
<h2 class="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
<i class="fas fa-layer-group text-indigo-500" aria-hidden="true"></i>
Available Plans
{{ _("subscription.available_plans_heading") }}
</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{% for t in all_tiers %}
@@ -192,11 +191,11 @@
{% if is_current %}
<span class="absolute top-3 right-3 text-xs bg-indigo-100 text-indigo-700 font-semibold rounded-full px-2 py-0.5">
Current
{{ _("subscription.current_badge") }}
</span>
{% elif is_pending %}
<span class="absolute top-3 right-3 text-xs bg-amber-100 text-amber-700 font-semibold rounded-full px-2 py-0.5">
Pending
{{ _("subscription.pending_badge") }}
</span>
{% elif t.badge %}
<span class="absolute top-3 right-3 text-xs bg-indigo-100 text-indigo-700 font-semibold rounded-full px-2 py-0.5">
@@ -208,9 +207,9 @@
<p class="font-bold text-gray-900 pr-16">{{ t.name }}</p>
<p class="text-gray-500 text-xs mt-1 mb-3">{{ t.tagline }}</p>
{% if t.price_monthly > 0 %}
<p class="text-xl font-extrabold text-gray-900">${{ t.price_monthly }}<span class="text-sm font-normal text-gray-400">/mo</span></p>
<p class="text-xl font-extrabold text-gray-900">${{ t.price_monthly }}<span class="text-sm font-normal text-gray-400">{{ _("subscription.per_mo") }}</span></p>
{% else %}
<p class="text-xl font-extrabold text-gray-500">Free</p>
<p class="text-xl font-extrabold text-gray-500">{{ _("subscription.free") }}</p>
{% endif %}
<ul class="mt-3 space-y-1 text-xs text-gray-600">
{% for feature in t.features[:3] %}
@@ -220,7 +219,7 @@
</li>
{% endfor %}
{% if t.features | length > 3 %}
<li class="text-indigo-500 font-medium">+ {{ (t.features | length) - 3 }} more</li>
<li class="text-indigo-500 font-medium">+ {{ (t.features | length) - 3 }} {{ _("subscription.more_features_label") }}</li>
{% endif %}
</ul>
</div>
@@ -228,14 +227,14 @@
<div class="mt-4">
{% if is_current and not pending_tier_id %}
<span class="block text-center py-2 px-4 rounded-lg bg-gray-100 text-gray-400 text-sm font-semibold cursor-default">
Your current plan
{{ _("subscription.current_plan_cta") }}
</span>
{% elif is_current and pending_tier_id %}
<!-- Keep current plan (cancel pending downgrade) -->
<button type="button"
onclick="changePlan('{{ t.id }}')"
class="block w-full text-center py-2 px-4 rounded-lg bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-semibold transition">
Keep {{ t.name }}
{{ _("subscription.keep_plan_prefix") }} {{ t.name }}
</button>
{% elif is_upgrade %}
{% if t.id == 'business' %}
@@ -247,21 +246,21 @@
<button type="button"
onclick="changePlan('{{ t.id }}')"
class="block w-full text-center py-2 px-4 rounded-lg bg-green-600 hover:bg-green-700 text-white text-sm font-semibold transition">
Upgrade to {{ t.name }}
{{ _("subscription.upgrade_to_prefix") }} {{ t.name }}
</button>
{% endif %}
{% elif is_pending %}
<button type="button"
onclick="cancelPendingChange()"
class="block w-full text-center py-2 px-4 rounded-lg bg-amber-100 hover:bg-amber-200 text-amber-800 text-sm font-semibold transition">
Cancel scheduled change
{{ _("subscription.cancel_scheduled") }}
</button>
{% else %}
<!-- Downgrade -->
<button type="button"
onclick="changePlan('{{ t.id }}')"
class="block w-full text-center py-2 px-4 rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-700 text-sm font-semibold transition">
Downgrade to {{ t.name }}
{{ _("subscription.downgrade_to_prefix") }} {{ t.name }}
</button>
{% endif %}
</div>
@@ -270,7 +269,7 @@
</div>
<p class="text-xs text-gray-400 mt-3">
<i class="fas fa-info-circle mr-1" aria-hidden="true"></i>
Upgrades take effect immediately. Downgrades are scheduled for the end of your current billing period.
{{ _("subscription.upgrade_info") }}
</p>
</div>
@@ -279,7 +278,7 @@
<!-- Back link -->
<div class="mt-8">
<a href="/" class="text-sm text-indigo-600 hover:text-indigo-800 font-medium flex items-center gap-1">
<i class="fas fa-arrow-left text-xs" aria-hidden="true"></i> Back to Dashboard
<i class="fas fa-arrow-left text-xs" aria-hidden="true"></i> {{ _("subscription.back_to_dashboard") }}
</a>
</div>
+23 -22
View File
@@ -1,65 +1,66 @@
{% extends "base.html" %}
{% block title %}Terms of Service - DocuElevate{% endblock %}
{% block title %}{{ _("terms.page_title") }}{% endblock %}
{% block content %}
<div class="container mx-auto px-4 py-8 max-w-4xl">
<h1 class="text-4xl font-bold mb-6">Terms of Service</h1>
<p class="text-sm text-gray-500 mb-8">Last Updated: {{ current_date|default('May 14, 2024') }}</p>
<h1 class="text-4xl font-bold mb-6">{{ _("terms.heading") }}</h1>
<p class="text-sm text-gray-500 mb-8">{{ _("terms.last_updated") }} {{ current_date|default('May 14, 2024') }}</p>
<div class="bg-white shadow rounded-lg p-6 mb-6">
<h2 class="text-2xl font-semibold mb-4">1. Acceptance of Terms</h2>
<h2 class="text-2xl font-semibold mb-4">{{ _("terms.s1_heading") }}</h2>
<p class="text-gray-700 mb-3">
By accessing or using DocuElevate, you agree to be bound by these Terms of Service. If you do not agree to these terms, please do not use this service.
{{ _("terms.s1_p1") }}
</p>
</div>
<div class="bg-white shadow rounded-lg p-6 mb-6">
<h2 class="text-2xl font-semibold mb-4">2. Description of Service</h2>
<h2 class="text-2xl font-semibold mb-4">{{ _("terms.s2_heading") }}</h2>
<p class="text-gray-700 mb-3">
DocuElevate provides document processing, OCR, metadata extraction, and storage services. We reserve the right to modify or discontinue any aspect of the service at any time.
{{ _("terms.s2_p1") }}
</p>
</div>
<div class="bg-white shadow rounded-lg p-6 mb-6">
<h2 class="text-2xl font-semibold mb-4">3. User Responsibilities</h2>
<h2 class="text-2xl font-semibold mb-4">{{ _("terms.s3_heading") }}</h2>
<p class="text-gray-700 mb-3">
You are responsible for:
{{ _("terms.s3_p1") }}
</p>
<ul class="list-disc list-inside text-gray-700 ml-4 mb-3">
<li>All content you upload to DocuElevate</li>
<li>Ensuring you have proper rights to upload and process documents</li>
<li>Maintaining the confidentiality of your account credentials</li>
<li>Any activity that occurs under your account</li>
<li>{{ _("terms.s3_li1") }}</li>
<li>{{ _("terms.s3_li2") }}</li>
<li>{{ _("terms.s3_li3") }}</li>
<li>{{ _("terms.s3_li4") }}</li>
</ul>
<p class="text-gray-700 mb-3">
By using our service, you also agree to our <a href="/privacy" class="text-blue-600 hover:underline">Privacy Policy</a> and <a href="/cookies" class="text-blue-600 hover:underline">Cookie Policy</a>.
{{ _("terms.s3_p2_pre") }} <a href="/privacy" class="text-blue-600 hover:underline">{{ _("terms.privacy_link") }}</a>
{{ _("terms.s3_p2_and") }} <a href="/cookies" class="text-blue-600 hover:underline">{{ _("terms.cookie_link") }}</a>{{ _("terms.s3_p2_post") }}
</p>
</div>
<div class="bg-white shadow rounded-lg p-6 mb-6">
<h2 class="text-2xl font-semibold mb-4">4. Intellectual Property Rights</h2>
<h2 class="text-2xl font-semibold mb-4">{{ _("terms.s4_heading") }}</h2>
<p class="text-gray-700 mb-3">
DocuElevate respects intellectual property rights. Users may not upload content that infringes on the intellectual property rights of others.
{{ _("terms.s4_p1") }}
</p>
</div>
<div class="bg-white shadow rounded-lg p-6 mb-6">
<h2 class="text-2xl font-semibold mb-4">5. Limitation of Liability</h2>
<h2 class="text-2xl font-semibold mb-4">{{ _("terms.s5_heading") }}</h2>
<p class="text-gray-700 mb-3">
DocuElevate provides the service "as is" without warranties of any kind. We shall not be liable for any direct, indirect, incidental, special, consequential, or punitive damages resulting from your use of or inability to use the service.
{{ _("terms.s5_p1") }}
</p>
</div>
<div class="bg-white shadow rounded-lg p-6">
<h2 class="text-2xl font-semibold mb-4">6. Governing Law</h2>
<h2 class="text-2xl font-semibold mb-4">{{ _("terms.s6_heading") }}</h2>
<p class="text-gray-700 mb-3">
These Terms shall be governed by the laws of Germany, without regard to its conflict of law provisions.
{{ _("terms.s6_p1") }}
</p>
<p class="text-gray-700 mb-3">
If you have any questions about these Terms, please contact us at <a href="mailto:docuelevate@christian-louis.de" class="text-blue-600 hover:underline">docuelevate@christian-louis.de</a>.
{{ _("terms.s6_p2_pre") }} <a href="mailto:docuelevate@christian-louis.de" class="text-blue-600 hover:underline">docuelevate@christian-louis.de</a>{{ _("terms.s6_p2_post") }}
</p>
<p class="text-gray-700">
For information about how we use cookies, please see our <a href="/cookies" class="text-blue-600 hover:underline">Cookie Policy</a>. For licensing information, please refer to our <a href="/license" class="text-blue-600 hover:underline">License Information</a>.
{{ _("terms.s6_p3_pre") }} <a href="/cookies" class="text-blue-600 hover:underline">{{ _("terms.cookie_link") }}</a>{{ _("terms.s6_p3_mid") }} <a href="/license" class="text-blue-600 hover:underline">{{ _("terms.license_link") }}</a>{{ _("terms.s6_p3_post") }}
</p>
</div>
</div>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+806
View File
@@ -41,6 +41,280 @@
"about.story_heading": "Our Story",
"about.story_p1": "DocuElevate was created with one goal in mind: to simplify and streamline document management for everyone, whether youre a small startup or a large enterprise.",
"about.story_p2": "We harness the power of pluggable AI providers (OpenAI, Anthropic Claude, Google Gemini, Ollama, OpenRouter, Portkey, and more) for metadata extraction and text refinement, integrate seamlessly with Dropbox, Nextcloud, and Paperless NGX for storage and indexing, leverage Azure Document Intelligence for OCR, and even use Gotenberg for file-to-PDF conversions.",
"admin_plans.aria_delete_plan": "Delete {name}",
"admin_plans.aria_edit_plan": "Edit {name}",
"admin_plans.aria_feature_n": "Feature {n}",
"admin_plans.aria_move_down": "Move {name} down",
"admin_plans.aria_move_up": "Move {name} up",
"admin_plans.aria_remove_feature_n": "Remove feature {n}",
"admin_plans.btn_add_feature": "Add Feature",
"admin_plans.btn_add_plan": "Add Plan",
"admin_plans.btn_cancel": "Cancel",
"admin_plans.btn_create": "Create Plan",
"admin_plans.btn_delete": "Delete",
"admin_plans.btn_edit": "Edit",
"admin_plans.btn_restore_defaults": "Restore Defaults",
"admin_plans.btn_restore_defaults_title": "Restore all four default plans (only if no plans exist yet)",
"admin_plans.btn_restoring": "Restoring\u2026",
"admin_plans.btn_save_changes": "Save Changes",
"admin_plans.btn_save_order": "Save Order",
"admin_plans.btn_saving": "Saving\u2026",
"admin_plans.btn_stripe_setup": "Stripe Setup",
"admin_plans.btn_stripe_setup_title": "Open the Stripe Setup Wizard to configure API keys and sync plans",
"admin_plans.col_actions": "Actions",
"admin_plans.col_active": "Active",
"admin_plans.col_monthly": "Monthly",
"admin_plans.col_monthly_limit": "Monthly Limit",
"admin_plans.col_order": "Order",
"admin_plans.col_overage_pct": "Overage %",
"admin_plans.col_plan": "Plan",
"admin_plans.col_yearly": "Yearly",
"admin_plans.coming_soon": "Coming soon",
"admin_plans.featured_badge": "Featured",
"admin_plans.field_active": "Active",
"admin_plans.field_allow_overage": "Allow Overage Billing",
"admin_plans.field_api_access": "API Access",
"admin_plans.field_badge_text": "Badge Text",
"admin_plans.field_buffer": "Buffer:",
"admin_plans.field_cta_text": "CTA Button Text",
"admin_plans.field_docs_month": "Docs / Month",
"admin_plans.field_featured": "Featured / Highlighted",
"admin_plans.field_lifetime_docs": "Lifetime Docs",
"admin_plans.field_mailboxes": "Email Mailboxes",
"admin_plans.field_max_file_size": "Max File Size (MB)",
"admin_plans.field_name": "Name",
"admin_plans.field_ocr_pages": "OCR Pages / Month",
"admin_plans.field_overage_doc_price": "Overage price / doc ($)",
"admin_plans.field_overage_ocr_price": "Overage price / OCR page ($)",
"admin_plans.field_plan_id": "Plan ID",
"admin_plans.field_price_monthly": "Monthly Price ($)",
"admin_plans.field_price_yearly": "Yearly Price ($)",
"admin_plans.field_sort_order": "Sort Order",
"admin_plans.field_storage_dests": "Storage Destinations",
"admin_plans.field_stripe_monthly": "Stripe Price ID (monthly)",
"admin_plans.field_stripe_yearly": "Stripe Price ID (yearly)",
"admin_plans.field_tagline": "Tagline",
"admin_plans.field_trial_days": "Trial Days",
"admin_plans.free_label": "Free",
"admin_plans.heading": "Plan Designer",
"admin_plans.hint_features": "These bullet points appear on the pricing page card for this plan.",
"admin_plans.hint_plan_id": "Lowercase slug, cannot be changed after creation.",
"admin_plans.hint_zero_unlimited": "Enter 0 for unlimited.",
"admin_plans.js_delete_confirm": "Delete plan \"{id}\"? This cannot be undone.",
"admin_plans.js_delete_failed": "Delete failed",
"admin_plans.js_failed_load": "Failed to load plans",
"admin_plans.js_order_saved": "Order saved!",
"admin_plans.js_plan_created": "Plan created!",
"admin_plans.js_plan_deleted": "Plan \"{id}\" deleted.",
"admin_plans.js_plan_updated": "Plan updated!",
"admin_plans.js_reorder_failed": "Reorder failed",
"admin_plans.js_save_failed": "Save failed",
"admin_plans.js_seed_confirm": "Seed the four default plans? This is a no-op if plans already exist.",
"admin_plans.js_seed_failed": "Seed failed",
"admin_plans.js_yearly_enter": "Enter yearly price to show savings",
"admin_plans.js_yearly_save": "Save {pct}% vs monthly",
"admin_plans.loading": "Loading plans\u2026",
"admin_plans.modal_close_aria": "Close modal",
"admin_plans.modal_create_title": "Add Plan",
"admin_plans.modal_edit_title_prefix": "Edit Plan: ",
"admin_plans.no_plans_intro": "No plans yet. Click",
"admin_plans.no_plans_suffix": "to seed the four built-in plans.",
"admin_plans.overage_0pct": "0% (exact)",
"admin_plans.overage_100pct": "100%",
"admin_plans.overage_50pct": "50%",
"admin_plans.overage_announce": "\u2192 announce",
"admin_plans.overage_buffer_body_prefix": "The overage buffer is",
"admin_plans.overage_buffer_body_suffix": "We advertise X docs/month but only enforce at",
"admin_plans.overage_buffer_formula": "X \u00d7 (1 + buffer%)",
"admin_plans.overage_buffer_invisible": "invisible to users",
"admin_plans.overage_buffer_tail": "docs. For example, a 150-doc/month plan with a 20% buffer enforces at 180 docs. This prevents hard cutoffs at the exact announced limit, giving users a graceful soft landing.",
"admin_plans.overage_buffer_title": "About the Overage Buffer",
"admin_plans.overage_docs": "docs,",
"admin_plans.overage_docs_end": "docs",
"admin_plans.overage_enforce_at": "enforce at",
"admin_plans.page_title": "Plan Designer \u2014 DocuElevate Admin",
"admin_plans.section_basic_info": "Basic Info",
"admin_plans.section_display": "Display",
"admin_plans.section_features": "Features List",
"admin_plans.section_overage": "Overage Designer",
"admin_plans.section_pricing": "Pricing",
"admin_plans.section_stripe": "Stripe Integration",
"admin_plans.section_volume": "Volume Limits",
"admin_plans.status_active": "Active",
"admin_plans.status_inactive": "Inactive",
"admin_plans.stripe_desc_after": "to auto-create them. Free plans do not need Stripe Price IDs.",
"admin_plans.stripe_desc_before": "Enter the Stripe Price IDs for this plan, or use the",
"admin_plans.stripe_wizard_aria": "Open Stripe Setup Wizard in a new tab",
"admin_plans.stripe_wizard_link": "Stripe Wizard",
"admin_plans.stripe_wizard_text": "Stripe Setup Wizard",
"admin_plans.subheading": "Manage subscription plans shown on the public pricing page.",
"admin_plans.table_aria_label": "Subscription plans",
"admin_files.admin_only_badge": "Admin Only",
"admin_files.aria_breadcrumb": "Breadcrumb",
"admin_files.badge_delta_detected": "Delta detected",
"admin_files.badge_duplicate": "dup",
"admin_files.badge_in_db": "in DB",
"admin_files.badge_on_disk": "on disk",
"admin_files.breadcrumb_workdir": "workdir",
"admin_files.btn_download": "Download",
"admin_files.col_actions": "Actions",
"admin_files.col_db": "DB",
"admin_files.col_health": "Health",
"admin_files.col_id": "ID",
"admin_files.col_ingested": "Ingested",
"admin_files.col_local_filename": "local_filename",
"admin_files.col_missing_paths": "Missing paths",
"admin_files.col_modified": "Modified",
"admin_files.col_name": "Name",
"admin_files.col_original_file_path": "original_file_path",
"admin_files.col_original_filename": "Original Filename",
"admin_files.col_path_relative": "Path (relative to workdir)",
"admin_files.col_processed_file_path": "processed_file_path",
"admin_files.col_size": "Size",
"admin_files.delta_detected_detail": "Found {orphan_count} orphan file(s) on disk with no DB record, and {ghost_count} DB record(s) with missing files on disk.",
"admin_files.delta_detected_title": "Delta detected.",
"admin_files.empty_database": "No file records found in the database.",
"admin_files.empty_directory": "This directory is empty.",
"admin_files.ghost_records_desc": "(in DB, file(s) missing on disk)",
"admin_files.ghost_records_heading": "Ghost records",
"admin_files.heading": "File Manager",
"admin_files.health_missing": "Missing",
"admin_files.health_ok": "OK",
"admin_files.legend_file_exists": "File exists on disk",
"admin_files.legend_file_missing": "File missing from disk",
"admin_files.legend_found_in_db": "Found in DB",
"admin_files.legend_not_in_db": "Not in DB (orphan)",
"admin_files.legend_path_not_set": "Path not set",
"admin_files.no_delta": "No delta found \u2014 filesystem and database are in sync.",
"admin_files.no_ghost_records": "No ghost records found.",
"admin_files.no_orphan_files": "No orphan files found.",
"admin_files.orphan_files_desc": "(on disk, no DB record)",
"admin_files.orphan_files_heading": "Orphan files",
"admin_files.page_title": "File Manager \u2013 Admin",
"admin_files.status_in_db": "In DB",
"admin_files.status_orphan": "Orphan",
"admin_files.tab_database": "Database Records",
"admin_files.tab_filesystem": "Filesystem",
"admin_files.tab_reconcile": "Reconcile",
"admin_users.add_user_profile_btn": "Add User Profile",
"admin_users.admin_only_badge": "Admin Only",
"admin_users.btn_password": "Password",
"admin_users.btn_reset": "Reset",
"admin_users.col_display_name": "Display Name",
"admin_users.col_documents": "Documents",
"admin_users.col_email": "Email",
"admin_users.col_last_upload": "Last Upload",
"admin_users.col_plan": "Plan",
"admin_users.col_role": "Role",
"admin_users.col_upload_limit": "Upload Limit",
"admin_users.col_user_id": "User ID",
"admin_users.col_username": "Username",
"admin_users.create_local_account_btn": "Create Local Account",
"admin_users.create_local_title": "Create Local Account",
"admin_users.delete_account_btn": "Delete Account",
"admin_users.delete_local_confirm": "Are you sure you want to delete the account for",
"admin_users.delete_local_title": "Delete Local Account",
"admin_users.delete_local_warning": "This cannot be undone. Documents owned by this user are not deleted.",
"admin_users.delete_profile_btn": "Delete Profile",
"admin_users.delete_profile_confirm": "Are you sure you want to delete the profile for",
"admin_users.delete_profile_title": "Delete User Profile",
"admin_users.delete_profile_warning": "This only removes the admin-managed profile record. Documents owned by this user are not deleted.",
"admin_users.deleting": "Deleting…",
"admin_users.edit_local_title": "Edit Local Account",
"admin_users.filter_placeholder": "Filter by user ID…",
"admin_users.global_default": "global default",
"admin_users.heading": "User Management",
"admin_users.js_account_created": "Account created",
"admin_users.js_account_created_msg": "Local account for \"{username}\" was created successfully.",
"admin_users.js_account_deleted_msg": "Account for \"{username}\" has been removed.",
"admin_users.js_account_updated": "Account has been updated.",
"admin_users.js_delete_failed": "Delete failed",
"admin_users.js_deleted": "Deleted",
"admin_users.js_email_not_sent": "Email not sent",
"admin_users.js_email_sent": "Email sent",
"admin_users.js_email_sent_msg": "Password reset email sent to \"{email}\".",
"admin_users.js_failed": "Failed",
"admin_users.js_failed_create": "Failed to create account.",
"admin_users.js_failed_load_local": "Failed to load local users",
"admin_users.js_failed_load_users": "Failed to load users",
"admin_users.js_failed_set_password": "Failed to set password.",
"admin_users.js_failed_update": "Failed to update account.",
"admin_users.js_network_error": "Network error",
"admin_users.js_password_set": "Password set",
"admin_users.js_password_set_msg": "Password for \"{username}\" has been updated.",
"admin_users.js_profile_deleted": "Profile for \"{id}\" has been removed.",
"admin_users.js_profile_saved": "Profile for \"{id}\" has been saved.",
"admin_users.js_save_failed": "Save failed",
"admin_users.js_saved": "Saved",
"admin_users.js_smtp_not_configured": "SMTP is not configured.",
"admin_users.js_updated": "Updated",
"admin_users.loading_users": "Loading users…",
"admin_users.local_account_active": "Account active",
"admin_users.local_accounts_heading": "Local User Accounts",
"admin_users.local_accounts_subheading": "Email/password accounts created directly on this server.",
"admin_users.local_admin_privileges": "Grant admin privileges",
"admin_users.local_admin_privileges_short": "Admin privileges",
"admin_users.local_create_btn": "Create Account",
"admin_users.local_create_one": "Create one.",
"admin_users.local_creating": "Creating…",
"admin_users.local_display_name_optional": "(optional)",
"admin_users.local_loading": "Loading…",
"admin_users.local_no_accounts": "No local accounts yet.",
"admin_users.local_password_hint": "Minimum 8 characters.",
"admin_users.local_saving": "Saving…",
"admin_users.local_username_hint": "364 characters. Letters, numbers, hyphens and underscores only.",
"admin_users.modal_add_title": "Add User Profile",
"admin_users.modal_billing_cycle_label": "Billing Cycle",
"admin_users.modal_billing_monthly": "Monthly",
"admin_users.modal_billing_yearly": "Yearly",
"admin_users.modal_block_hint": "(prevents new document uploads)",
"admin_users.modal_block_label": "Block this user",
"admin_users.modal_close_aria": "Close dialog",
"admin_users.modal_complimentary_hint": "(user keeps tier benefits but is never billed — set automatically for admin accounts)",
"admin_users.modal_complimentary_label": "Complimentary plan",
"admin_users.modal_daily_limit_hint": "(leave empty to use global default)",
"admin_users.modal_daily_limit_label": "Daily Upload Limit",
"admin_users.modal_daily_limit_placeholder": "e.g. 50 (0 = unlimited)",
"admin_users.modal_display_name_label": "Display Name",
"admin_users.modal_display_name_placeholder": "Alice Smith (optional)",
"admin_users.modal_edit_title": "Edit User Profile",
"admin_users.modal_notes_label": "Admin Notes",
"admin_users.modal_notes_placeholder": "Internal notes visible only to admins…",
"admin_users.modal_period_start_hint": "Annual carry-over calculates from this date. Leave blank for monthly enforcement.",
"admin_users.modal_period_start_label": "Subscription Period Start",
"admin_users.modal_plan_business": "Business — $7.99/mo (300/mo, unlimited mailboxes)",
"admin_users.modal_plan_free": "Free — 25 lifetime files",
"admin_users.modal_plan_hint": "Sets the quota limits for this user. Limits are enforced on upload.",
"admin_users.modal_plan_label": "Subscription Plan",
"admin_users.modal_plan_professional": "Professional — $5.99/mo (150/mo, 3 mailboxes)",
"admin_users.modal_plan_starter": "Starter — $2.99/mo (50/mo, 1 mailbox)",
"admin_users.modal_save_changes": "Save Changes",
"admin_users.modal_saving": "Saving…",
"admin_users.modal_user_id_hint": "The stable identifier that matches owner_id in documents.",
"admin_users.modal_user_id_label": "User ID",
"admin_users.modal_user_id_placeholder": "user@example.com or OAuth sub",
"admin_users.new_account_btn": "New Account",
"admin_users.new_password_label": "New Password",
"admin_users.no_users_add_hint": "Upload some documents or add a profile above.",
"admin_users.no_users_found": "No users found.",
"admin_users.no_users_search_hint": "Try a different search term.",
"admin_users.page_title": "User Management Admin DocuElevate",
"admin_users.pagination_page_of": "of",
"admin_users.per_day": "/ day",
"admin_users.role_admin": "Admin",
"admin_users.role_user": "User",
"admin_users.search_users_label": "Search users",
"admin_users.set_password_btn": "Set Password",
"admin_users.set_password_desc": "The user should change this password after logging in.",
"admin_users.set_password_desc_pre": "Set a new password directly for",
"admin_users.set_password_title": "Set Temporary Password",
"admin_users.setting": "Setting…",
"admin_users.status_blocked": "Blocked",
"admin_users.status_unverified": "Unverified",
"admin_users.subheading": "Manage user profiles, per-user upload limits, and document ownership.",
"admin_users.total_count_users": "{count} users",
"admin_users.total_no_users": "No users",
"admin_users.total_one_user": "1 user",
"api_tokens.col_created": "Created",
"api_tokens.col_last_ip": "Last IP",
"api_tokens.col_last_used": "Last Used",
@@ -73,6 +347,19 @@
"api_tokens.usage_intro_pre": "Use your API token in the",
"api_tokens.your_tokens": "Your Tokens",
"app.name": "DocuElevate",
"attribution.heading": "Third-Party Software Attributions",
"attribution.intro": "DocuElevate uses several open source libraries and tools. We are grateful to the developers of these projects for their contributions to open source software.",
"attribution.page_title": "DocuElevate - Third-Party Attributions",
"attribution.paramiko_lgpl_note": "Note: This library is licensed under the GNU Lesser General Public License v2.1 (LGPL-2.1)",
"attribution.section_docker": "Docker Images",
"attribution.section_frontend": "Frontend Dependencies",
"attribution.section_python": "Python Dependencies",
"attribution.special_lgpl_link": "here",
"attribution.special_lgpl_post": ".",
"attribution.special_lgpl_pre": "A copy of the LGPL license can be found",
"attribution.special_source_post": ".",
"attribution.special_source_pre": "This software includes Paramiko, which is licensed under LGPL. The source code for Paramiko is available at",
"attribution.special_title": "Special Attribution:",
"audit.col_ip": "IP",
"audit.col_resource": "Resource",
"audit.col_timestamp": "Timestamp",
@@ -187,6 +474,7 @@
"common.actions": "Actions",
"common.active": "Active",
"common.all": "All",
"common.avatar": "Avatar",
"common.back": "Back",
"common.cancel": "Cancel",
"common.close": "Close",
@@ -218,6 +506,7 @@
"common.no": "No",
"common.none": "None",
"common.page": "Page",
"common.paused": "Paused",
"common.pending": "Pending",
"common.prev_page": "Previous page",
"common.previous": "Previous",
@@ -234,7 +523,10 @@
"common.submit": "Submit",
"common.success": "Success",
"common.tags": "Tags",
"common.test": "Test",
"common.test_connection": "Test Connection",
"common.type": "Type",
"common.update": "Update",
"common.updated": "Updated",
"common.upload": "Upload",
"common.view": "View",
@@ -247,6 +539,41 @@
"cookie.notice_label": "Cookie notice",
"cookie.policy_link": "Cookie Policy",
"cookie.privacy_link": "Privacy Notice",
"cookie_policy.heading": "Cookie Policy",
"cookie_policy.last_updated": "Last Updated:",
"cookie_policy.page_title": "Cookie Policy - DocuElevate",
"cookie_policy.s1_heading": "What Are Cookies",
"cookie_policy.s1_p1": "Cookies are small text files that are stored on your computer or mobile device when you visit a website. They are widely used to make websites work more efficiently and provide information to the website owners.",
"cookie_policy.s2_heading": "How We Use Cookies",
"cookie_policy.s2_li1_body": "To identify you when you sign in and maintain your session while you use the application.",
"cookie_policy.s2_li1_label": "Authentication & Session Management:",
"cookie_policy.s2_p1_post": "for the following purpose:",
"cookie_policy.s2_p1_pre": "DocuElevate uses",
"cookie_policy.s2_p1_strong": "only strictly necessary session cookies",
"cookie_policy.s2_p2": "These cookies are mandatory for the proper functioning of our service. Without these cookies, you would be required to log in repeatedly during your browsing session.",
"cookie_policy.s2_p3": "Because these cookies are strictly necessary for the service to function, they are exempt from prior-consent requirements under the EU ePrivacy Directive (Art. 5(3)) and equivalent national implementations. We do not set any optional, analytics, advertising, or tracking cookies.",
"cookie_policy.s3_col_duration": "Duration",
"cookie_policy.s3_col_name": "Name",
"cookie_policy.s3_col_purpose": "Purpose",
"cookie_policy.s3_col_type": "Type",
"cookie_policy.s3_heading": "Cookie Details",
"cookie_policy.s3_row1_duration": "Session (deleted on browser close or logout)",
"cookie_policy.s3_row1_purpose": "Maintains your authenticated session; required for login to function.",
"cookie_policy.s3_row1_type": "Strictly Necessary",
"cookie_policy.s3_row2_duration": "Persistent (browser localStorage)",
"cookie_policy.s3_row2_purpose": "Stores your acknowledgement of the cookie notice so it is not shown repeatedly (stored in localStorage, not a cookie).",
"cookie_policy.s3_row2_type": "Strictly Necessary",
"cookie_policy.s4_heading": "No Third-Party Cookies",
"cookie_policy.s4_p1": "DocuElevate does not use any third-party cookies, tracking cookies, advertising cookies, or analytics cookies. We respect your privacy and only implement the minimum cookies required for our service to function.",
"cookie_policy.s4_p2_pre": "For more information about how we handle your data, please see our",
"cookie_policy.s4_privacy_link": "Privacy Notice",
"cookie_policy.s5_heading": "Managing Cookies",
"cookie_policy.s5_p1": "Most web browsers allow you to control cookies through their settings. However, blocking or deleting our session cookies will prevent DocuElevate from functioning, as user authentication relies on these cookies.",
"cookie_policy.s5_p2": "You may also clear the cookie notice acknowledgement stored in your browser's localStorage at any time via your browser's developer tools (Application → Local Storage).",
"cookie_policy.s5_p3_and": "and",
"cookie_policy.s5_p3_pre": "This Cookie Policy is part of and incorporated into our",
"cookie_policy.s5_privacy_link": "Privacy Notice",
"cookie_policy.s5_terms_link": "Terms of Service",
"credentials.col_action": "Action",
"credentials.col_credential": "Credential",
"credentials.col_source": "Source",
@@ -446,15 +773,31 @@
"help.faq_4_a": "Pipelines let you chain processing steps OCR, AI extraction, format conversion and route the result to one or more destinations. Create and manage them from the Pipelines page.",
"help.faq_4_q": "How do processing pipelines work?",
"help.faq_5_a": "DocuElevate encrypts credentials at rest, communicates over TLS, and never stores your documents longer than necessary. See the Privacy Notice for full details.",
"help.faq_5_a_post": " for full details.",
"help.faq_5_a_pre": "DocuElevate encrypts credentials at rest, communicates over TLS, and never stores your documents longer than necessary. See the ",
"help.faq_5_q": "Is my data secure?",
"help.faq_heading": "Frequently Asked Questions",
"help.getting_started": "Getting Started",
"help.heading": "Help Center",
"help.ingestion_curl": "cURL / REST API",
"help.ingestion_curl_desc": "Use the REST API to push documents programmatically. Authenticate with a Bearer token and POST files to the upload endpoint.",
"help.ingestion_heading": "Data Ingestion Tools",
"help.ingestion_mobile": "Mobile Apps",
"help.ingestion_mobile_desc": "Use any mobile scanning app that supports custom HTTP upload destinations to send photos and scans to DocuElevate from your phone or tablet.",
"help.ingestion_scanners": "Network Scanners",
"help.ingestion_scanners_desc": "Point any network scanner or multifunction printer at DocuElevate's upload endpoint. Scanned documents land directly in your processing queue.",
"help.ingestion_watched_folders": "Watched Folders",
"help.ingestion_watched_folders_desc": "Configure a local or network folder to be monitored. Any file placed in a watched folder is automatically uploaded and processed by DocuElevate.",
"help.ingestion_zapier": "Zapier / n8n / Make",
"help.ingestion_zapier_desc": "Integrate DocuElevate into your automation workflows with Zapier, n8n, or Make. Use the REST API actions to trigger document uploads from any event.",
"help.meta_description": "Get help with DocuElevate find guides on uploading documents, connecting cloud storage, setting up pipelines, and more.",
"help.og_description": "Get help with DocuElevate find guides on uploading documents, connecting cloud storage, setting up pipelines, and more.",
"help.page_title": "Help Center",
"help.privacy_notice": "Privacy Notice",
"help.quickstart_heading": "Quick Start",
"help.quickstart_storage": "Connect Storage",
"help.quickstart_storage_desc": "Head to Settings and link your cloud accounts. Processed documents are automatically routed to every destination you configure.",
"help.quickstart_storage_desc_full": "Head to Integrations and link your cloud storage accounts. DocuElevate supports Dropbox, Google Drive, OneDrive, Amazon S3, Nextcloud, and more. Processed documents are automatically routed to every destination you configure.",
"help.quickstart_upload": "Upload Documents",
"help.quickstart_upload_desc": "Drag & drop files onto the Upload page or click Choose File. DocuElevate converts images and office documents to PDF, runs OCR, and extracts metadata automatically.",
"help.quickstart_workflows": "Automate Workflows",
@@ -466,6 +809,7 @@
"help.sources_rest_api_desc": "Integrate programmatically by POST-ing files to /api/upload. Ideal for scripts, watched folders, scanners, or third-party tools like Zapier and n8n.",
"help.sources_scanner": "Scanner & Mobile",
"help.sources_scanner_desc": "Point network scanners at DocuElevate's upload endpoint or use any mobile scanning app that supports custom HTTP destinations.",
"help.sources_scanner_desc_full": "Configure your network scanner or multifunction printer to send scans directly to DocuElevate. Use the /api/upload endpoint as the destination. Mobile scanning apps with custom upload destinations are also supported.",
"help.sources_web_upload": "Web Upload",
"help.sources_web_upload_desc": "The fastest way to get started. Open the Upload page, drop one or more files, and DocuElevate takes care of the rest. Supported formats include PDF, JPEG, PNG, TIFF, DOCX, XLSX, and more.",
"help.subheading": "Everything you need to get the most out of DocuElevate. Browse topics below or search for what you need.",
@@ -473,7 +817,9 @@
"help.support_admin_message": "Contact your administrator for support information.",
"help.support_description": "Cant find what youre looking for? Our support team is here to help.",
"help.support_heading": "Contact Support",
"help.support_live_chat": "Live Chat available click the chat bubble to start a conversation.",
"help.support_ticket_button": "Submit a Ticket",
"help.support_ticket_desc": "Fill in the form below and our support team will get back to you as soon as possible.",
"help.support_ticket_heading": "Open a Support Ticket",
"help.title": "Help Center",
"help.workflows_creating": "Creating a Pipeline",
@@ -481,15 +827,43 @@
"help.workflows_heading": "Workflows & Pipelines",
"help.workflows_step_1": "Convert to PDF",
"help.workflows_step_1_create": "Go to Pipelines in the main menu.",
"help.workflows_step_1_full": "Convert to PDF all incoming files are normalised to a consistent PDF format.",
"help.workflows_step_2": "OCR extract text",
"help.workflows_step_2_create": "Click New Pipeline and give it a name.",
"help.workflows_step_2_full": "OCR extract selectable text from scanned pages using Azure Document Intelligence or the built-in engine.",
"help.workflows_step_3": "AI metadata extraction",
"help.workflows_step_3_create": "Add the processing steps you need.",
"help.workflows_step_3_full": "AI metadata extraction classify document type and pull key fields such as amounts, dates, and names using OpenAI.",
"help.workflows_step_4": "Deliver to one or more destinations",
"help.workflows_step_4_create": "Choose one or more delivery destinations.",
"help.workflows_step_5_create": "Save new documents will be processed through this pipeline automatically.",
"help.workflows_typical_steps": "Typical Steps",
"help.workflows_what_is": "What is a Pipeline?",
"imprint.business_registration_heading": "Business Registration",
"imprint.business_registration_vat": "VAT Identification Number according to §27a Value Added Tax Act:",
"imprint.contact_heading": "Contact Information",
"imprint.dispute_heading": "Online Dispute Resolution",
"imprint.dispute_p1": "The European Commission provides a platform for online dispute resolution (OS):",
"imprint.dispute_p2": "We are not willing or obligated to participate in dispute resolution proceedings before a consumer arbitration board.",
"imprint.heading": "Imprint",
"imprint.legal_copyright": "All content on this website is protected by copyright. Any use outside the limits of copyright law requires the written consent of the respective author or creator.",
"imprint.legal_heading": "Legal Notices",
"imprint.legal_liability": "Despite careful content control, we assume no liability for the content of external links. The operators of the linked pages are solely responsible for their content.",
"imprint.page_title": "Imprint - DocuElevate",
"imprint.policies_cookie_desc": "Information about cookies we use",
"imprint.policies_cookie_label": "Cookie Policy",
"imprint.policies_heading": "Related Policies",
"imprint.policies_intro": "Our service is governed by the following policies:",
"imprint.policies_license_desc": "How our software is licensed",
"imprint.policies_license_label": "License Information",
"imprint.policies_privacy_desc": "How we handle your data",
"imprint.policies_privacy_label": "Privacy Policy",
"imprint.policies_terms_desc": "Rules for using DocuElevate",
"imprint.policies_terms_label": "Terms of Service",
"imprint.provider_heading": "Service Provider",
"imprint.responsible_content_heading": "Responsible for Content",
"imprint.responsible_content_rstv": "According to § 55 Abs. 2 RStV:",
"imprint.subtitle": "Information according to § 5 TMG (German Telemedia Act)",
"index.badge_intelligent": "Intelligent Document Processing",
"index.button_browse_files": "Browse Files",
"index.button_upload": "Upload",
@@ -535,6 +909,7 @@
"index.page_title_dashboard": "Dashboard",
"index.page_title_public": "Intelligent Document Processing",
"index.platform_overview": "Platform overview",
"index.processing_stats": "Processing Stats",
"index.quick_actions": "Quick Actions",
"index.quick_documents": "My Documents",
"index.quick_documents_desc": "Browse your processed files",
@@ -553,9 +928,13 @@
"index.stat_active_integrations": "Active Integrations",
"index.stat_active_users": "Active users",
"index.stat_files_month": "Files this month",
"index.stat_files_ocr": "Files with OCR",
"index.stat_files_today": "Files today",
"index.stat_storage_targets": "Storage Targets",
"index.stat_this_month": "This month",
"index.stat_today": "Today",
"index.stat_total_files": "Total files",
"index.stat_total_processed": "Total processed",
"index.tier_plan": "Plan",
"index.tier_upgrade": "Upgrade",
"index.tier_view_details": "View full details",
@@ -570,20 +949,95 @@
"index.usage_my_usage": "My usage",
"index.usage_today": "Files today",
"index.usage_unlimited": "Unlimited",
"integrations.access_key_label": "Access Key ID",
"integrations.active_label": "Active",
"integrations.add_button": "Add Integration",
"integrations.add_first_button": "Add Your First Integration",
"integrations.api_token_label": "API Token",
"integrations.authorize_btn": "Authorize",
"integrations.authorize_reauth": "Re-Authorize",
"integrations.bucket_label": "Bucket",
"integrations.configure": "Configure",
"integrations.connect": "Connect",
"integrations.connected": "Connected",
"integrations.connection_failed": "Connection failed",
"integrations.connection_success": "Connection successful",
"integrations.delete_confirm_are_you_sure": "Are you sure you want to delete",
"integrations.delete_confirm_title": "Delete Integration?",
"integrations.delete_confirm_undone": "This action cannot be undone.",
"integrations.delete_emails_label": "Delete emails after processing",
"integrations.delete_files_label": "Delete files after processing",
"integrations.destinations_quota_label": "Storage Destinations:",
"integrations.destinations_section": "Destinations",
"integrations.direction_destination": "Destination (storage)",
"integrations.direction_label": "Direction",
"integrations.direction_placeholder": "— Select —",
"integrations.direction_source": "Source (ingestion)",
"integrations.disconnect": "Disconnect",
"integrations.email_settings": "Email Forward Settings",
"integrations.empty_state": "No integrations configured",
"integrations.endpoint_label": "Endpoint URL",
"integrations.endpoint_optional": "(optional, for S3-compatible)",
"integrations.folder_label": "Folder",
"integrations.folder_optional": "(optional)",
"integrations.folder_path_label": "Folder Path",
"integrations.folder_prefix_label": "Folder Prefix",
"integrations.generic_settings_hint": "Configuration for this integration type can be provided as JSON after creation via the API.",
"integrations.gmail_hint": "Gmail labels & star: when enabled, processed emails are starred and tagged with an \"Ingested\" label in Gmail. Only applies to Gmail servers.",
"integrations.gmail_labels": "Apply Gmail labels & star",
"integrations.host_label": "Host",
"integrations.imap_settings": "IMAP Settings",
"integrations.loading": "Loading integrations…",
"integrations.modal_close": "Close modal",
"integrations.modal_title_add": "Add Integration",
"integrations.modal_title_edit": "Edit Integration",
"integrations.name_label": "Label",
"integrations.name_placeholder": "e.g. Work Gmail, S3 Archive",
"integrations.nextcloud_settings": "Nextcloud Settings",
"integrations.nextcloud_url_label": "Nextcloud URL",
"integrations.no_integrations_body": "Add your first integration to connect ingestion sources (like IMAP) and storage destinations (like S3, Dropbox, or Google Drive).",
"integrations.not_connected": "Not Connected",
"integrations.oauth_folder_label": "Folder",
"integrations.oauth_hint": "Save this integration first, then use the Authorize button to complete the OAuth flow. Your credentials will be stored securely in your personal integration record.",
"integrations.page_title": "Integrations",
"integrations.paperless_settings": "Paperless NGX Settings",
"integrations.password_label": "Password",
"integrations.paused": "Paused",
"integrations.plan_label": "Plan:",
"integrations.port_label": "Port",
"integrations.quota_not_available": "(not available)",
"integrations.quota_unlimited": "/ unlimited",
"integrations.recipient_label": "Recipient Email",
"integrations.region_label": "Region",
"integrations.remote_path_label": "Remote Path",
"integrations.s3_prefix_label": "Prefix (folder path)",
"integrations.s3_settings": "S3 Settings",
"integrations.secret_key_label": "Secret Access Key",
"integrations.show_password": "Show password",
"integrations.show_secrets": "Show secrets",
"integrations.show_token": "Show token",
"integrations.source_local": "Local Filesystem",
"integrations.source_type_label": "Source Type",
"integrations.sources_quota_label": "Mailbox Sources:",
"integrations.sources_section": "Sources",
"integrations.subtitle": "Manage your ingestion sources and storage destinations in one place.",
"integrations.title": "Integrations",
"integrations.type_label": "Integration Type",
"integrations.type_placeholder": "— Select direction first —",
"integrations.upgrade_plan": "Upgrade Plan",
"integrations.use_ssl": "Use SSL",
"integrations.username_label": "Username",
"integrations.watch_folder_settings": "Watch Folder Settings",
"integrations.webdav_settings": "WebDAV Settings",
"integrations.webdav_url_label": "WebDAV URL",
"integrations.webhook_heading": "Webhook Ingestion",
"integrations.webhook_how_heading": "How Webhook Ingestion Works",
"integrations.webhook_how_text": "A webhook integration allows external systems to push documents directly into DocuElevate via the REST API. Instead of DocuElevate polling for new files (like IMAP), your application sends files to DocuElevate using an HTTP request with an API token for authentication.",
"integrations.webhook_ideal_text": "This is ideal for CI/CD pipelines, automation scripts, scanner integrations, or any system that generates documents and needs to send them for processing.",
"integrations.webhook_quick_start": "Quick Start",
"integrations.webhook_security_tip": "Create a dedicated API token for each integration and revoke it immediately if compromised. Tokens can be managed on the API Tokens page.",
"integrations.webhook_step1": "Go to {api_tokens_link} and create a personal token.",
"integrations.webhook_upload_with_token": "Use the token to upload documents via the API:",
"language.bg": "Български",
"language.ca": "Català",
"language.change_success": "Language changed to {language}",
@@ -607,10 +1061,12 @@
"language.lv": "Latviešu",
"language.nb": "Norsk",
"language.nl": "Nederlands",
"language.no_results": "No languages found",
"language.pl": "Polski",
"language.pt": "Português",
"language.ro": "Română",
"language.ru": "Русский",
"language.search_placeholder": "Search languages…",
"language.selector": "Language",
"language.selector_label": "Select language",
"language.sk": "Slovenčina",
@@ -619,7 +1075,22 @@
"language.tr": "Türkçe",
"language.uk": "Українська",
"language.zh": "中文",
"license.apache_description": "DocuElevate is distributed under the Apache License 2.0, which is a permissive open-source software license that allows you to use, modify, distribute, and contribute to the project.",
"license.apache_heading": "Apache License 2.0",
"license.heading": "License Information",
"license.page_title": "License Information - DocuElevate",
"license.related_about_link": "About page",
"license.related_and": "and",
"license.related_heading": "Related Information",
"license.related_p1_post": "for information about using the DocuElevate service.",
"license.related_p1_pre": "While this license governs the use of our software, please also review our",
"license.related_p2_post": ".",
"license.related_p2_pre": "For more information about DocuElevate, please visit the",
"license.related_privacy_link": "Privacy Policy",
"license.related_terms_link": "Terms of Service",
"nav.about": "About",
"nav.account_menu": "Account menu",
"nav.account_menu_for": "Account menu for {name}",
"nav.admin": "Admin",
"nav.admin.audit_logs": "Audit Logs",
"nav.admin.backups": "Backups",
@@ -638,6 +1109,7 @@
"nav.duplicates": "Duplicates",
"nav.file_manager": "File Manager",
"nav.files": "Files",
"nav.get_started": "Get Started",
"nav.help": "Help",
"nav.help_center": "Help Center",
"nav.imap": "Email Import",
@@ -646,18 +1118,21 @@
"nav.login": "Log In",
"nav.logout": "Log Out",
"nav.main_navigation": "Main navigation",
"nav.my_subscription": "My Subscription",
"nav.notifications": "Notifications",
"nav.open_main_menu": "Open main menu",
"nav.pipelines": "Pipelines",
"nav.plan_designer": "Plan Designer",
"nav.pricing": "Pricing",
"nav.profile": "Profile",
"nav.profile_settings": "Profile Settings",
"nav.queue": "Queue",
"nav.queue_monitor": "Queue Monitor",
"nav.scheduled_jobs": "Scheduled Jobs",
"nav.search": "Search",
"nav.settings": "Settings",
"nav.shared_links": "Shared Links",
"nav.sign_out": "Sign Out",
"nav.signup": "Sign Up",
"nav.similarity": "Similarity",
"nav.skip_to_content": "Skip to main content",
@@ -713,6 +1188,220 @@
"pipelines.system_label": "System",
"pipelines.system_pipeline_label": "System pipeline (visible to all users)",
"pipelines.title": "Processing Pipelines",
"privacy.heading": "DocuElevate Privacy Notice",
"privacy.last_updated": "Last Updated:",
"privacy.page_title": "Privacy Notice - DocuElevate",
"privacy.s10_access_body": "You may request a copy of the personal data we hold about you.",
"privacy.s10_access_label": "Right of Access (Art. 15):",
"privacy.s10_complaint_body": "You have the right to lodge a complaint with your national Data Protection Authority (DPA). In Germany: Bundesbeauftragte für den Datenschutz und die Informationsfreiheit (BfDI). In the UK: Information Commissioner's Office (ICO). In Switzerland: Federal Data Protection and Information Commissioner (FDPIC).",
"privacy.s10_complaint_label": "Right to Lodge a Complaint:",
"privacy.s10_contact": "To exercise any of the above rights, contact us at",
"privacy.s10_erasure_body": "You may request deletion of your personal data where there is no overriding legitimate reason for us to retain it.",
"privacy.s10_erasure_label": "Right to Erasure (Art. 17):",
"privacy.s10_heading": "10. Your Rights (EU / EEA / UK / Switzerland)",
"privacy.s10_object_body": "You may object to processing based on legitimate interests at any time.",
"privacy.s10_object_label": "Right to Object (Art. 21):",
"privacy.s10_p1": "Under GDPR (and the UK GDPR / Swiss nFADP equivalent), you have the following rights:",
"privacy.s10_portability_body": "You may request your data in a structured, commonly used, machine-readable format.",
"privacy.s10_portability_label": "Right to Data Portability (Art. 20):",
"privacy.s10_rectification_body": "You may request correction of inaccurate or incomplete personal data.",
"privacy.s10_rectification_label": "Right to Rectification (Art. 16):",
"privacy.s10_response": "We will respond within one calendar month (extendable by two further months for complex requests).",
"privacy.s10_restriction_body": "You may request that we temporarily halt processing of your data in certain circumstances.",
"privacy.s10_restriction_label": "Right to Restriction (Art. 18):",
"privacy.s10_withdraw_body": "Where processing is based on consent, you may withdraw that consent at any time without affecting the lawfulness of prior processing.",
"privacy.s10_withdraw_label": "Right to Withdraw Consent:",
"privacy.s11_categories_body": "Identifiers (name, email), account authentication tokens, and document metadata that you choose to upload.",
"privacy.s11_categories_label": "Categories of personal information collected:",
"privacy.s11_contact": "To submit a verifiable consumer request, contact us at",
"privacy.s11_correct_body": "You may request correction of inaccurate personal information.",
"privacy.s11_correct_label": "Right to Correct:",
"privacy.s11_delete_body": "You may request deletion of personal information we have collected, subject to certain exceptions.",
"privacy.s11_delete_label": "Right to Delete:",
"privacy.s11_heading": "11. Additional Rights United States (CCPA / CPRA)",
"privacy.s11_know_body": "You may request disclosure of the categories and specific pieces of personal information we have collected about you.",
"privacy.s11_know_label": "Right to Know:",
"privacy.s11_limit_body": "We do not use sensitive personal information beyond what is necessary to provide the service.",
"privacy.s11_limit_label": "Right to Limit Use of Sensitive Personal Information:",
"privacy.s11_nondiscrim_body": "We will not discriminate against you for exercising any of these rights.",
"privacy.s11_nondiscrim_label": "Non-Discrimination:",
"privacy.s11_optout_body": "We do not sell or share personal information as defined by CCPA/CPRA. No opt-out mechanism is required; however, you may contact us to confirm this.",
"privacy.s11_optout_label": "Right to Opt Out of Sale / Sharing:",
"privacy.s11_p1": "If you are a resident of California or another US state with applicable privacy legislation (including Virginia VCDPA, Colorado CPA, Connecticut CTDPA, Utah UCPA), the following additional disclosures apply:",
"privacy.s11_purpose_body": "Providing, improving, and securing the DocuElevate service. We do not sell or share personal information for cross-context behavioural advertising.",
"privacy.s11_purpose_label": "Purpose of collection:",
"privacy.s11_response": "We will respond within 45 days (extendable by an additional 45 days when reasonably necessary).",
"privacy.s12_access_body": "You may request access to your personal information and information about how it has been used or disclosed.",
"privacy.s12_access_label": "Right of Access:",
"privacy.s12_contact": "Direct privacy complaints to our Privacy Officer at",
"privacy.s12_contact_post": ", or to the Office of the Privacy Commissioner of Canada.",
"privacy.s12_correction_body": "You may challenge the accuracy or completeness of your personal information and request correction.",
"privacy.s12_correction_label": "Right to Correction:",
"privacy.s12_heading": "12. Additional Rights Canada (PIPEDA / Québec Law 25)",
"privacy.s12_li1": "We collect, use, and disclose personal information only with your knowledge and consent, or as permitted by law.",
"privacy.s12_p1": "If you are located in Canada, the following applies under the Personal Information Protection and Electronic Documents Act (PIPEDA) and applicable provincial legislation (including Québec Law 25 / Bill 64):",
"privacy.s12_quebec_body": "Under Law 25, you have additional rights including the right to data portability (effective September 2023) and the right to de-indexation where personal information is disseminated online.",
"privacy.s12_quebec_label": "Québec residents:",
"privacy.s12_withdraw_body": "Subject to legal or contractual restrictions, you may withdraw consent to the collection, use, or disclosure of your personal information on reasonable notice.",
"privacy.s12_withdraw_label": "Right to Withdraw Consent:",
"privacy.s13_brazil_body": "If you are located in Brazil, you have the following rights under the LGPD:",
"privacy.s13_brazil_label": "Brazil (LGPD Lei Geral de Proteção de Dados, Law 13.709/2018):",
"privacy.s13_contact": "Contact:",
"privacy.s13_heading": "13. Additional Rights Latin America (LGPD & Others)",
"privacy.s13_li1": "Confirmation of the existence of processing and access to your data.",
"privacy.s13_li2": "Correction of incomplete, inaccurate, or outdated data.",
"privacy.s13_li3": "Anonymisation, blocking, or deletion of unnecessary or excessive data.",
"privacy.s13_li4": "Portability of your data to another service or product provider.",
"privacy.s13_li5": "Deletion of personal data processed with your consent.",
"privacy.s13_li6": "Information about entities with which your data has been shared.",
"privacy.s13_li7": "Information about the possibility of not consenting and the consequences of refusal.",
"privacy.s13_li8": "Revocation of consent.",
"privacy.s13_other_body": "We also recognise applicable privacy laws in Argentina (PDPA), Mexico (LFPDPPP), Chile, Colombia (Ley 1581), and others. Users in these jurisdictions may exercise equivalent rights as outlined under their national law by contacting us.",
"privacy.s13_other_label": "Other Latin American Countries:",
"privacy.s14_apj_body": "We recognise the data protection rights afforded to residents of these jurisdictions under their respective national laws. Contact us to exercise your rights.",
"privacy.s14_apj_label": "Other APJ markets (Singapore PDPA, New Zealand Privacy Act, India DPDP Act):",
"privacy.s14_australia_body": "Australian residents may request access to and correction of their personal information. We will respond to access requests within 30 days. Complaints may be lodged with the Office of the Australian Information Commissioner (OAIC).",
"privacy.s14_australia_label": "Australia (Privacy Act 1988 and Australian Privacy Principles):",
"privacy.s14_contact": "Contact:",
"privacy.s14_heading": "14. Additional Rights Asia-Pacific & Japan",
"privacy.s14_japan_body": "Japanese residents may request disclosure, correction, addition or deletion, suspension of use, erasure, or suspension of third-party provision of their personal information held by us. Third-party disclosures require your prior consent except where permitted by law.",
"privacy.s14_japan_label": "Japan (APPI Act on the Protection of Personal Information):",
"privacy.s14_korea_body": "Korean residents may request access, correction, deletion, and suspension of processing. We handle personal information of Korean residents in accordance with the PIPA.",
"privacy.s14_korea_label": "South Korea (PIPA Personal Information Protection Act):",
"privacy.s15_contact": "Contact:",
"privacy.s15_heading": "15. Additional Rights Ukraine",
"privacy.s15_p1": "Users located in Ukraine are protected under the Law of Ukraine \"On Personal Data Protection\" (No. 2297-VI). Your rights include access to, correction, blocking, and deletion of your personal data, as well as the right to object to processing.",
"privacy.s16_cookies_link": "Cookie Policy",
"privacy.s16_heading": "16. Updates to this Privacy Notice",
"privacy.s16_license_link": "License Information",
"privacy.s16_p1": "We may update this notice from time to time to reflect changes in our practices or applicable laws. The \"Last Updated\" date at the top of this page indicates when the notice was last revised. Where changes are material, we will notify users via in-application notification or email where appropriate.",
"privacy.s16_p2_pre": "If you have any questions or concerns about this Privacy Notice or your personal data, please contact us at",
"privacy.s16_p3_pre": "Please also review our",
"privacy.s16_terms_link": "Terms of Service",
"privacy.s1_address": "Alter Steinweg 3, 20459 Hamburg, Germany",
"privacy.s1_company": "Christian Louis IT Beratung",
"privacy.s1_contact_label": "Contact Email:",
"privacy.s1_heading": "1. Data Controller",
"privacy.s1_p1": "The controller responsible for processing your personal data under the EU General Data Protection Regulation (GDPR) and equivalent privacy laws worldwide is:",
"privacy.s1_p3": "For all privacy-related requests (access, deletion, rectification, opt-out, or complaints), please contact us at the email address above. We will respond within 30 days (or the period prescribed by applicable law).",
"privacy.s2_heading": "2. Scope of this Privacy Notice",
"privacy.s2_p1_pre": "This notice applies to the DocuElevate web application, hosted at",
"privacy.s2_p2": "It covers all users globally, including those in the European Union (EU), European Economic Area (EEA), Germany, United Kingdom (UK), Switzerland, Ukraine, United States (US), Canada, Latin America (Latam), Asia-Pacific, and Japan. Market-specific disclosures are provided in dedicated sections below.",
"privacy.s3_audit_body": "We maintain limited audit logs (action type, timestamp, user identifier) to ensure service integrity and security. These logs do not include document content.",
"privacy.s3_audit_label": "Audit Logs:",
"privacy.s3_auth_body": "We use OAuth 2.0 (Google, Dropbox, Microsoft/OneDrive) and optional local authentication. Through OAuth, we may receive your name, email address, and profile picture.",
"privacy.s3_auth_label": "User Authentication:",
"privacy.s3_doc_body": "Documents you upload are processed for OCR (optical character recognition), metadata extraction, and storage to your chosen cloud provider. Document content is processed only for the purpose you initiate and is not stored beyond what is operationally necessary.",
"privacy.s3_doc_label": "Document Processing:",
"privacy.s3_heading": "3. Data Collection & Purposes",
"privacy.s3_legal_body": "Our primary legal bases for processing are:",
"privacy.s3_legal_label": "Legal Basis (GDPR Art. 6):",
"privacy.s3_li1": "(1)(b) Performance of a contract: to provide the DocuElevate service you have requested.",
"privacy.s3_li2": "(1)(c) Legal obligation: to comply with applicable laws and regulations.",
"privacy.s3_li3": "(1)(f) Legitimate interests: ensuring the security of the service and preventing fraud.",
"privacy.s4_heading": "4. Data Minimization & Purpose Limitation",
"privacy.s4_li1": "We collect only the minimum personal data required to operate the service.",
"privacy.s4_li2": "Document content is processed strictly for the purpose you initiate (OCR, storage, metadata extraction). We do not use your documents to train AI models or for any secondary purpose.",
"privacy.s4_li3": "No advertising, behavioural tracking, or profiling is performed.",
"privacy.s4_li4": "No tracking cookies or analytics scripts are loaded.",
"privacy.s4_li5": "Third-party AI services (e.g., OpenAI, Azure Document Intelligence) are invoked only when you initiate document processing, and data is transmitted under data processing agreements.",
"privacy.s4_p1": "DocuElevate is designed with data minimization as a core principle (GDPR Art. 5(1)(c)):",
"privacy.s5_cookie_link": "Cookie Policy",
"privacy.s5_heading": "5. Use of Cookies & Similar Technologies",
"privacy.s5_p1_post": "to maintain your authenticated session. These cookies are essential for the service to function and are exempt from prior-consent requirements under the EU ePrivacy Directive (Art. 5(3)) and equivalent national laws.",
"privacy.s5_p1_pre": "DocuElevate uses",
"privacy.s5_p1_strong": "only strictly necessary session cookies",
"privacy.s5_p2_body": "analytics cookies, advertising cookies, tracking pixels, or any third-party cookies that would require your consent.",
"privacy.s5_p2_label": "We do not use:",
"privacy.s5_p3_pre": "For full details on the cookies we set, their names, duration, and purpose, please visit our",
"privacy.s6_ai_body": "When you initiate OCR or AI-based metadata extraction, document data is transmitted to the AI service you or your administrator has configured. This transmission is governed by a data processing agreement with the respective provider.",
"privacy.s6_ai_label": "AI Processing Services (OpenAI, Azure Document Intelligence, others):",
"privacy.s6_heading": "6. Third-Party Services",
"privacy.s6_no_sale_body": "We do not sell, rent, or share your personal data with third parties for advertising, marketing, or any purpose unrelated to providing the service.",
"privacy.s6_no_sale_label": "No Sale or Sharing for Advertising:",
"privacy.s6_oauth_body": "When you choose to authenticate via OAuth, the respective provider processes your credentials and may share limited profile information with us. These providers maintain their own privacy policies.",
"privacy.s6_oauth_label": "OAuth Providers (Google, Dropbox, Microsoft):",
"privacy.s6_storage_body": "Documents are stored in the cloud provider you configure. Your configured credentials are stored encrypted in the application database and are used solely to perform the storage operations you request.",
"privacy.s6_storage_label": "Cloud Storage Providers (Google Drive, Dropbox, OneDrive, Amazon S3, Nextcloud, WebDAV/SFTP/FTP):",
"privacy.s7_adequacy_body": "where the European Commission has recognised an equivalent level of protection (e.g., United Kingdom, Switzerland, Canada (commercial organisations), Japan, South Korea).",
"privacy.s7_adequacy_label": "Adequacy Decisions",
"privacy.s7_contact": "You may request a copy of the relevant safeguards by contacting us at",
"privacy.s7_heading": "7. International Data Transfers",
"privacy.s7_idta_body": "for transfers from the UK after Brexit.",
"privacy.s7_idta_label": "UK International Data Transfer Agreements (IDTAs)",
"privacy.s7_p1": "DocuElevate is hosted in the European Union / EEA by default. Where personal data is transferred outside the EEA (for example to US-based AI service providers such as OpenAI), we rely on appropriate safeguards including:",
"privacy.s7_scc_body": "adopted by the European Commission (2021/914/EU) for transfers to processors and controllers in third countries.",
"privacy.s7_scc_label": "Standard Contractual Clauses (SCCs)",
"privacy.s8_audit_body": "Retained for up to 90 days for security and compliance purposes.",
"privacy.s8_audit_label": "Audit logs:",
"privacy.s8_contact": "To request deletion of your account and all associated personal data, please contact us at",
"privacy.s8_files_body": "Retained for the duration of your use of the service. You may delete individual files at any time through the application.",
"privacy.s8_files_label": "File records and metadata:",
"privacy.s8_heading": "8. Data Retention",
"privacy.s8_oauth_body": "Stored in encrypted form and revocable at any time via your OAuth provider.",
"privacy.s8_oauth_label": "OAuth tokens:",
"privacy.s8_p1": "We retain personal data only as long as strictly necessary to provide the DocuElevate service or to comply with legal obligations:",
"privacy.s8_session_body": "Deleted when you log out or after session timeout.",
"privacy.s8_session_label": "Session data:",
"privacy.s9_heading": "9. Data Security",
"privacy.s9_li1": "Encryption of credentials and sensitive configuration at rest.",
"privacy.s9_li2": "Transport Layer Security (TLS/HTTPS) for all communications.",
"privacy.s9_li3": "Role-based access controls limiting access to personal data.",
"privacy.s9_li4": "Regular security audits and dependency vulnerability scanning.",
"privacy.s9_li5": "CSRF protection on all state-changing requests.",
"privacy.s9_p1": "We implement appropriate technical and organisational measures (TOMs) to protect your personal data, including:",
"privacy.toc_1": "Data Controller",
"privacy.toc_10": "Your Rights (EU / EEA / UK / Switzerland)",
"privacy.toc_11": "Additional Rights United States (CCPA/CPRA)",
"privacy.toc_12": "Additional Rights Canada (PIPEDA / Law 25)",
"privacy.toc_13": "Additional Rights Latin America (LGPD & others)",
"privacy.toc_14": "Additional Rights Asia-Pacific & Japan",
"privacy.toc_15": "Additional Rights Ukraine",
"privacy.toc_16": "Updates to this Privacy Notice",
"privacy.toc_2": "Scope of this Privacy Notice",
"privacy.toc_3": "Data Collection & Purposes",
"privacy.toc_4": "Data Minimization & Purpose Limitation",
"privacy.toc_5": "Use of Cookies & Similar Technologies",
"privacy.toc_6": "Third-Party Services",
"privacy.toc_7": "International Data Transfers",
"privacy.toc_8": "Data Retention",
"privacy.toc_9": "Data Security",
"privacy.toc_heading": "Contents",
"profile.avatar_alt": "Your profile picture",
"profile.avatar_heading": "Profile Picture",
"profile.avatar_remove": "Remove custom avatar",
"profile.avatar_upload_hint": "Upload a JPEG, PNG, GIF, or WebP image up to 2 MB. If no custom picture is set, your {gravatar} is shown.",
"profile.choose_image": "Choose image…",
"profile.confirm_password": "Confirm New Password",
"profile.contact_email_hint": "Used for system notifications. This does not change your login e-mail.",
"profile.contact_email_label": "Contact / Notification E-mail",
"profile.contact_email_placeholder": "you@example.com",
"profile.current_password": "Current Password",
"profile.dismiss": "Dismiss",
"profile.display_name_hint": "Leave blank to use your account username or email.",
"profile.display_name_label": "Display Name",
"profile.display_name_placeholder": "Your name as shown in the UI",
"profile.general_heading": "General Information",
"profile.gravatar_link": "Gravatar",
"profile.heading": "Profile Settings",
"profile.language_auto_detect": "Auto-detect (from browser)",
"profile.language_hint": "Choose your preferred interface language. “Auto-detect” follows your browser settings.",
"profile.language_label": "UI Language",
"profile.new_password": "New Password",
"profile.new_password_hint": "Minimum 8 characters.",
"profile.page_title": "Profile Settings DocuElevate",
"profile.password_heading": "Change Password",
"profile.preferences_heading": "Preferences",
"profile.save_button": "Save Changes",
"profile.saving": "Saving…",
"profile.subtitle": "Manage your display name, avatar, language, and theme preferences.",
"profile.theme_dark": "Dark",
"profile.theme_hint": "“System Default” follows your devices dark-mode setting.",
"profile.theme_label": "Colour Scheme",
"profile.theme_light": "Light",
"profile.theme_system": "System Default",
"profile.update_password": "Update Password",
"profile.updating": "Updating…",
"queue.active_tasks": "Active Tasks",
"queue.auto_refresh_1": "Auto-refreshes every",
"queue.auto_refresh_2": "seconds",
@@ -890,14 +1579,131 @@
"similarity.subtitle": "Pairs of documents with high semantic similarity, ranked by score.",
"similarity.trigger_aria": "Trigger embedding computation for all files missing embeddings",
"similarity.trigger_now": "trigger it now",
"status.active": "Active",
"status.ai_empty_response": "(empty)",
"status.ai_extraction_desc": "Paste the plain-text content of a document below and run it through the configured AI provider to inspect the raw response, extracted JSON, and tags.",
"status.ai_extraction_failed": "AI Extraction Failed",
"status.ai_extraction_label": "Document Text",
"status.ai_extraction_placeholder": "Paste the plain-text content of your document here…",
"status.ai_extraction_title": "AI Extraction Test",
"status.app_version": "App Version",
"status.as_account": "as",
"status.auth_required": "Authentication Required",
"status.build_date": "Build Date",
"status.config_settings": "Configuration Settings",
"status.config_settings_desc": "For more detailed configuration settings and environment variables, check the settings page.",
"status.configure_now": "Configure Now",
"status.configured": "Configured",
"status.connection_error": "Connection Error",
"status.connection_test_failed": "Connection Test Failed",
"status.connection_test_successful": "Connection Test Successful",
"status.container_id": "Container ID",
"status.container_started": "Container Started",
"status.dashboard_subtitle": "This dashboard shows the status of all configured integrations and targets.",
"status.debug_mode": "Debug Mode",
"status.error_running_extraction": "Error running extraction: ",
"status.error_testing_connection": "Error testing connection: ",
"status.error_testing_notifications": "Error testing notifications: ",
"status.extracted_tags": "Extracted Tags",
"status.git_commit": "Git Commit",
"status.inactive": "Inactive",
"status.json_parse_issue": "JSON parse issue: ",
"status.last_check": "Last Check",
"status.manage": "Manage",
"status.modal_default_message": "Operation completed successfully.",
"status.modal_default_title": "Success",
"status.no_details": "No details available",
"status.not_configured": "Not Configured",
"status.notification_config_missing": "Notification Configuration Missing",
"status.open": "Open",
"status.page_title": "System Status",
"status.parsed_json_label": "Parsed JSON",
"status.provider_config_details": "{name} Configuration Details",
"status.provider_details": "Provider Details",
"status.raw_llm_response": "Raw LLM Response",
"status.run_extraction": "Run Extraction",
"status.running": "Running…",
"status.sending": "Sending...",
"status.setting_label": "Setting",
"status.test_connection": "Test Connection",
"status.test_extraction": "Test Extraction",
"status.test_failed": "Test Failed",
"status.test_notification_failed": "Test Notification Failed",
"status.test_notification_sent": "Test Notification Sent",
"status.test_notifications": "Test Notifications",
"status.test_provider": "Test {name}",
"status.test_successful": "Test Successful",
"status.testing": "Testing...",
"status.token_expired": "Your token has expired or is invalid. Please reconfigure this connection.",
"status.token_valid_for": "Token valid for:",
"status.value_label": "Value",
"status.view_config": "View Detailed Configuration",
"status.view_details": "View Details",
"subscription.available_plans_heading": "Available Plans",
"subscription.back_to_dashboard": "Back to Dashboard",
"subscription.cancel_pending": "Cancel change",
"subscription.cancel_scheduled": "Cancel scheduled change",
"subscription.contact_sales": "Contact Sales",
"subscription.current_badge": "Current",
"subscription.current_plan": "Current Plan",
"subscription.current_plan_cta": "Your current plan",
"subscription.downgrade_to_prefix": "Downgrade to",
"subscription.features_heading": "Whats included in your plan",
"subscription.free": "Free",
"subscription.heading": "My Subscription",
"subscription.keep_plan_prefix": "Keep",
"subscription.lifetime_files": "Total files (lifetime)",
"subscription.month_files": "Files this month",
"subscription.more_features_label": "more",
"subscription.page_title": "My Subscription DocuElevate",
"subscription.pending_badge": "Pending",
"subscription.pending_benefits": "You keep all current plan benefits until then.",
"subscription.pending_on": "on",
"subscription.pending_title": "Pending plan change scheduled",
"subscription.pending_will_change": "Your plan will change to",
"subscription.per_mo": "/mo",
"subscription.per_month": "/month",
"subscription.since": "Since",
"subscription.single_user_body": "Subscription tiers apply when multi-user mode is active. Your instance currently runs in single-user mode with no processing limits. An admin can enable multi-user mode via {settings_link}.",
"subscription.single_user_title": "Multi-user mode is not enabled.",
"subscription.subtitle": "Your current plan, usage and available upgrades.",
"subscription.this_month_label": "this month",
"subscription.today_files": "Files today",
"subscription.today_label": "today",
"subscription.unlimited": "Unlimited",
"subscription.upgrade_info": "Upgrades take effect immediately. Downgrades are scheduled for the end of your current billing period.",
"subscription.upgrade_to_prefix": "Upgrade to",
"subscription.usage_heading": "Usage",
"terms.cookie_link": "Cookie Policy",
"terms.heading": "Terms of Service",
"terms.last_updated": "Last Updated:",
"terms.license_link": "License Information",
"terms.page_title": "Terms of Service - DocuElevate",
"terms.privacy_link": "Privacy Policy",
"terms.s1_heading": "1. Acceptance of Terms",
"terms.s1_p1": "By accessing or using DocuElevate, you agree to be bound by these Terms of Service. If you do not agree to these terms, please do not use this service.",
"terms.s2_heading": "2. Description of Service",
"terms.s2_p1": "DocuElevate provides document processing, OCR, metadata extraction, and storage services. We reserve the right to modify or discontinue any aspect of the service at any time.",
"terms.s3_heading": "3. User Responsibilities",
"terms.s3_li1": "All content you upload to DocuElevate",
"terms.s3_li2": "Ensuring you have proper rights to upload and process documents",
"terms.s3_li3": "Maintaining the confidentiality of your account credentials",
"terms.s3_li4": "Any activity that occurs under your account",
"terms.s3_p1": "You are responsible for:",
"terms.s3_p2_and": "and",
"terms.s3_p2_post": ".",
"terms.s3_p2_pre": "By using our service, you also agree to our",
"terms.s4_heading": "4. Intellectual Property Rights",
"terms.s4_p1": "DocuElevate respects intellectual property rights. Users may not upload content that infringes on the intellectual property rights of others.",
"terms.s5_heading": "5. Limitation of Liability",
"terms.s5_p1": "DocuElevate provides the service \"as is\" without warranties of any kind. We shall not be liable for any direct, indirect, incidental, special, consequential, or punitive damages resulting from your use of or inability to use the service.",
"terms.s6_heading": "6. Governing Law",
"terms.s6_p1": "These Terms shall be governed by the laws of Germany, without regard to its conflict of law provisions.",
"terms.s6_p2_post": ".",
"terms.s6_p2_pre": "If you have any questions about these Terms, please contact us at",
"terms.s6_p3_mid": ". For licensing information, please refer to our",
"terms.s6_p3_post": ".",
"terms.s6_p3_pre": "For information about how we use cookies, please see our",
"upload.browse_button": "Browse Files",
"upload.button_processing": "Processing...",
"upload.camera_button": "Take Photo / Scan Document",
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-929
View File
@@ -1,929 +0,0 @@
{
"about.creator_heading": "Meet the Creator",
"about.creator_name": "Christian Krakau-Louis",
"about.creator_pre": "DocuElevate is passionately developed by",
"about.features_admin_api": "Powerful REST API for programmatic access",
"about.features_admin_config": "Highly configurable via environment variables",
"about.features_admin_docker": "Docker-ready for easy deployment and scaling",
"about.features_admin_heading": "Administration",
"about.features_admin_oauth2": "OAuth2 authentication support with Authentik",
"about.features_automation_background": "Background processing with Redis and Celery",
"about.features_automation_gmail": "Gmail and generic email account integration",
"about.features_automation_heading": "Automation",
"about.features_automation_imap": "IMAP inbox polling from multiple sources",
"about.features_automation_ingestion": "Automated document ingestion from various inputs",
"about.features_heading": "Key Features",
"about.features_integration_cloud": "Cloud storage: Dropbox, Google Drive, OneDrive, Amazon S3",
"about.features_integration_heading": "Integration & Storage",
"about.features_integration_multi_dest": "Multi-destination support (store documents in multiple locations)",
"about.features_integration_protocols": "Transfer protocols: FTP, SFTP, Email forwarding",
"about.features_integration_selfhosted": "Self-hosted options: Nextcloud, Paperless NGX, WebDAV",
"about.features_processing_classification": "Intelligent document classification and date extraction",
"about.features_processing_heading": "Document Processing",
"about.features_processing_metadata": "Automated metadata extraction using a pluggable AI provider",
"about.features_processing_ocr": "OCR powered by Azure Document Intelligence",
"about.features_processing_pdf": "PDF conversion for various file formats via Gotenberg",
"about.features_processing_upload": "Simple and secure file uploads with drag & drop support",
"about.github_logo_alt": "GitHub Logo",
"about.heading": "About DocuElevate",
"about.intro_post": " your modern, intelligent solution for document processing! Weve built DocuElevate to completely transform the way you handle your documents from upload to extraction, from processing to storage.",
"about.intro_pre": "Welcome to ",
"about.involved_description": "Want to dive into the code, contribute ideas, or learn more about DocuElevate?",
"about.involved_docs": "Read the Documentation",
"about.involved_github": "View DocuElevate on GitHub",
"about.involved_heading": "Get Involved",
"about.involved_website": "Visit DocuElevate Website",
"about.legal_description": "We care about your privacy and data security. Please review our:",
"about.legal_heading": "Privacy & Legal",
"about.legal_license": "License Information",
"about.legal_privacy": "Privacy Notice",
"about.page_title": "About DocuElevate",
"about.story_heading": "Our Story",
"about.story_p1": "DocuElevate was created with one goal in mind: to simplify and streamline document management for everyone, whether youre a small startup or a large enterprise.",
"about.story_p2": "We harness the power of pluggable AI providers (OpenAI, Anthropic Claude, Google Gemini, Ollama, OpenRouter, Portkey, and more) for metadata extraction and text refinement, integrate seamlessly with Dropbox, Nextcloud, and Paperless NGX for storage and indexing, leverage Azure Document Intelligence for OCR, and even use Gotenberg for file-to-PDF conversions.",
"api_tokens.col_created": "Created",
"api_tokens.col_last_ip": "Last IP",
"api_tokens.col_last_used": "Last Used",
"api_tokens.col_name": "Name",
"api_tokens.col_prefix": "Token Prefix",
"api_tokens.copy_to_clipboard": "Copy token to clipboard",
"api_tokens.copy_upload_example": "Copy upload example to clipboard",
"api_tokens.copy_warning_1": "Copy this token now — it will",
"api_tokens.copy_warning_2": "not be shown again",
"api_tokens.create_heading": "Create New Token",
"api_tokens.create_token": "Create Token",
"api_tokens.creating": "Creating…",
"api_tokens.heading": "API Tokens",
"api_tokens.intro": "Create personal API tokens to interact with the DocuElevate API programmatically. Use tokens for webhook uploads, CI/CD pipelines, or any script that needs to upload or retrieve documents.",
"api_tokens.loading_tokens": "Loading tokens…",
"api_tokens.never": "Never",
"api_tokens.no_tokens_heading": "No API tokens yet",
"api_tokens.no_tokens_help": "Create your first token above to get started.",
"api_tokens.page_title": "API Tokens DocuElevate",
"api_tokens.revoke": "Revoke",
"api_tokens.revoke_prefix": "Revoke token",
"api_tokens.status_active": "Active",
"api_tokens.status_revoked": "Revoked",
"api_tokens.table_aria": "API Tokens",
"api_tokens.token_created": "Token created successfully!",
"api_tokens.token_name_label": "Token name",
"api_tokens.token_name_placeholder": "e.g. CI Pipeline, Webhook Upload, My Script",
"api_tokens.usage_heading": "Usage Example",
"api_tokens.usage_intro_post": "header with any API request:",
"api_tokens.usage_intro_pre": "Use your API token in the",
"api_tokens.your_tokens": "Your Tokens",
"app.name": "DocuElevate",
"audit.col_ip": "IP",
"audit.col_resource": "Resource",
"audit.col_timestamp": "Timestamp",
"audit.critical": "Critical",
"audit.filter_action": "Action",
"audit.filter_all_actions": "All actions",
"audit.filter_all_users": "All users",
"audit.filter_resource_placeholder": "e.g. document, user",
"audit.filter_resource_type": "Resource Type",
"audit.filter_severity": "Severity",
"audit.filter_user": "User",
"audit.filters_section_label": "Audit log filters",
"audit.next": "Next",
"audit.next_label": "Next page",
"audit.no_events": "No audit events recorded yet.",
"audit.no_events_hint": "Significant actions (logins, document operations, settings changes) will appear here.",
"audit.page_title": "Audit Logs - DocuElevate",
"audit.pagination_label": "Audit log pagination",
"audit.prev": "Prev",
"audit.prev_label": "Previous page",
"audit.refresh_label": "Refresh audit logs",
"audit.siem_disabled_title": "SIEM forwarding is disabled",
"audit.siem_enabled_title": "Events are being forwarded to ",
"audit.siem_off": "SIEM: Off",
"audit.subtitle": "Comprehensive, append-only record of all significant actions.",
"audit.table_label": "Audit log events",
"audit.title": "Audit Logs",
"auth.confirm_password": "Confirm Password",
"auth.create_account": "Create account",
"auth.display_name_label": "Display Name",
"auth.email_label": "Email",
"auth.forgot_password": "Forgot Password?",
"auth.forgot_username": "Forgot username?",
"auth.login": "Log In",
"auth.login_title": "Log In",
"auth.logo_alt": "DocuElevate Logo",
"auth.logout": "Log Out",
"auth.my_account": "My Account",
"auth.no_account": "Don't have an account?",
"auth.or_continue_with": "Or continue with",
"auth.password_label": "Password",
"auth.profile": "Profile",
"auth.remember_me": "Remember me",
"auth.return_home": "Return to Home",
"auth.sign_in": "Sign in",
"auth.sign_in_sso": "Sign in with SSO",
"auth.sign_in_with": "Sign in with",
"auth.sign_in_with_username": "Sign in with username",
"auth.signup": "Sign Up",
"auth.signup_title": "Sign Up",
"auth.username_label": "Username",
"auth.username_or_email": "Username or Email",
"auth.verify_email_back_sign_in": "Back to sign in",
"auth.verify_email_expiry": "The link expires in 24 hours. If you dont see the email, check your spam folder.",
"auth.verify_email_heading": "Check your inbox",
"auth.verify_email_message": "Weve sent you a verification email. Please click the link in the email to activate your account.",
"auth.verify_email_page_title": "DocuElevate - Verify Your Email",
"auth.verify_email_resend_aria_label": "Email address for resend",
"auth.verify_email_resend_button": "Resend verification email",
"auth.verify_email_resend_label": "Email address",
"auth.verify_email_resend_placeholder": "Enter your email address",
"auth.verify_email_resend_prompt": "Didnt receive it?",
"backup.archives_heading": "Backup Archives",
"backup.backup_now": "Backup Now",
"backup.clean_up": "Clean Up",
"backup.cleanup_title": "Run retention cleanup now",
"backup.col_created": "Created",
"backup.col_filename": "Filename",
"backup.col_size": "Size",
"backup.col_storage": "Storage",
"backup.col_type": "Type",
"backup.config_auto_backup": "Auto-backup",
"backup.config_remote_dest": "Remote destination",
"backup.config_retention": "Retention",
"backup.config_total_size": "Total local size",
"backup.confirm_btn": "Confirm",
"backup.confirm_title": "Confirm action",
"backup.heading": "Backup Management",
"backup.local_only": "Local only",
"backup.no_backups": "No backups yet.",
"backup.no_backups_hint": "Click Backup Now to create your first backup.",
"backup.page_title": "Backup Management",
"backup.records_label": "records",
"backup.restore_btn": "Restore",
"backup.restore_desc_mid": "backup archive to restore the database.",
"backup.restore_desc_pre": "Upload a",
"backup.restore_desc_warning": "This will overwrite all current data.",
"backup.restore_file_label": "Backup archive (.db.gz)",
"backup.restore_heading": "Restore from File",
"backup.restoring": "Restoring…",
"backup.retention_daily_detail": " kept for 3 weeks",
"backup.retention_heading": "Retention policy",
"backup.retention_hourly_detail": " kept for 4 days",
"backup.retention_note": "Backups beyond these limits are automatically pruned after each new backup is created.",
"backup.retention_weekly_detail": " kept for ~3 months",
"backup.snapshots": "snapshots",
"backup.status_ok": "ok",
"backup.storage_local": "local",
"backup.subtitle": "Database backups are created automatically: hourly (4 days), daily (3 weeks), weekly (13 weeks).",
"backup.table_aria": "Backup archives",
"backup.trigger_daily": "Backup Now (Daily)",
"backup.trigger_hourly": "Backup Now (Hourly)",
"backup.trigger_weekly": "Backup Now (Weekly)",
"backup.type_daily": "Daily",
"backup.type_hourly": "Hourly",
"backup.type_weekly": "Weekly",
"billing.go_to_dashboard": "Go to dashboard",
"billing.manage_subscription": "Manage subscription",
"billing.success_heading": "You're all set!",
"billing.success_message": "Your subscription has been activated. Thank you for choosing DocuElevate!",
"billing.success_page_title": "DocuElevate - Subscription Activated",
"common.actions": "Actions",
"common.active": "Active",
"common.all": "All",
"common.back": "Back",
"common.cancel": "Cancel",
"common.close": "Close",
"common.col_pending": "Pending",
"common.completed": "Completed",
"common.confirm": "Confirm",
"common.copied": "Copied!",
"common.copy": "Copy",
"common.create": "Create",
"common.created": "Created",
"common.date": "Date",
"common.delete": "Delete",
"common.description": "Description",
"common.details": "Details",
"common.disabled": "Disabled",
"common.download": "Download",
"common.duplicate": "Duplicate",
"common.edit": "Edit",
"common.enabled": "Enabled",
"common.error": "Error",
"common.failed": "Failed",
"common.filter": "Filter",
"common.inactive": "Inactive",
"common.info": "Info",
"common.loading": "Loading...",
"common.name": "Name",
"common.next": "Next",
"common.next_page": "Next page",
"common.no": "No",
"common.none": "None",
"common.page": "Page",
"common.pending": "Pending",
"common.prev_page": "Previous page",
"common.previous": "Previous",
"common.processing": "Processing",
"common.refresh": "Refresh",
"common.reset": "Reset",
"common.retry": "Retry",
"common.save": "Save",
"common.search": "Search",
"common.select": "Select",
"common.select_placeholder": "— select —",
"common.size": "Size",
"common.status": "Status",
"common.submit": "Submit",
"common.success": "Success",
"common.tags": "Tags",
"common.type": "Type",
"common.updated": "Updated",
"common.upload": "Upload",
"common.view": "View",
"common.warning": "Warning",
"common.yes": "Yes",
"cookie.accept": "Got it",
"cookie.learn_more": "Learn more",
"cookie.message": "This website uses cookies to improve your experience.",
"cookie.notice": "DocuElevate uses only essential session cookies required for authentication and service operation. No tracking or analytics cookies are used.",
"cookie.notice_label": "Cookie notice",
"cookie.policy_link": "Cookie Policy",
"cookie.privacy_link": "Privacy Notice",
"credentials.col_action": "Action",
"credentials.col_credential": "Credential",
"credentials.col_source": "Source",
"credentials.configured": "Configured",
"credentials.edit_in_settings": "Edit in Settings",
"credentials.legend_db": "Value stored in the database (encrypted at rest; overrides environment variable)",
"credentials.legend_env_after": " file",
"credentials.legend_env_before": "Value from environment variable or ",
"credentials.legend_missing": "Credential not set — integration will not work",
"credentials.legend_restart": "Restart required when this credential is rotated",
"credentials.legend_title": "Legend",
"credentials.manage_settings": "Manage Settings",
"credentials.not_configured": "Not Configured",
"credentials.page_title": "Credential Audit - DocuElevate",
"credentials.raw_json": "Raw JSON (API)",
"credentials.restart_title": "Restart required after rotating this credential",
"credentials.source_db_title": "Stored in database (encrypted)",
"credentials.source_env_title": "From environment variable",
"credentials.status_missing": "Missing",
"credentials.subtitle": "Overview of all sensitive credentials used by DocuElevate. No secret values are shown here — only whether each credential is configured and where it comes from.",
"credentials.table_for": "Credentials for",
"credentials.title": "Credential Audit",
"credentials.total_credentials": "Total Credentials",
"dashboard.active_integrations": "Active Integrations",
"dashboard.files_this_month": "Files This Month",
"dashboard.files_today": "Files Today",
"dashboard.ocr_processed": "OCR Processed",
"dashboard.quick_actions": "Quick Actions",
"dashboard.recent_activity": "Recent Activity",
"dashboard.storage_targets": "Storage Targets",
"dashboard.title": "Dashboard",
"dashboard.total_files": "Total Files",
"dashboard.welcome": "Welcome to DocuElevate",
"duplicates.file_id_label": "File ID",
"duplicates.file_id_placeholder": "e.g. 42",
"duplicates.find_btn": "Find",
"duplicates.found_files": "duplicate file(s) total.",
"duplicates.found_groups": "duplicate group(s) with",
"duplicates.found_prefix": "Found",
"duplicates.group_aria": "Duplicate group",
"duplicates.heading": "Duplicate Documents",
"duplicates.how_it_works_heading": "How near-duplicate detection works",
"duplicates.max_results_label": "Max results",
"duplicates.near_dup_desc": "Select a document to check whether any other files contain the same (or very similar) content — even if they were scanned at different times and have different SHA-256 hashes.",
"duplicates.near_dup_heading": "Find Near-Duplicates for a Document",
"duplicates.no_exact_heading": "No exact duplicates found",
"duplicates.page_title": "Duplicate Documents - DocuElevate",
"duplicates.pagination_aria": "Pagination",
"duplicates.role_duplicate": "Duplicate",
"duplicates.role_original": "Original",
"duplicates.tab_exact": "Exact Duplicates",
"duplicates.tab_near": "Near-Duplicate Finder",
"duplicates.tabs_aria": "Duplicate detection tabs",
"duplicates.threshold_label": "Similarity threshold",
"duplicates.view_dup_aria": "View duplicate file",
"duplicates.view_original_aria": "View original file",
"error.404_code": "404",
"error.404_heading": "Oops, we couldnt find that page!",
"error.404_home": "Return Home",
"error.404_message": "It seems DocuElevate has misplaced the document you were looking for. Dont worry weve got your back.",
"error.404_title": "404 - Not Found",
"error.500_code": "500",
"error.500_debug_info": "Show Debug Info",
"error.500_description": "Our servers encountered a mishap and need a moment.",
"error.500_heading": "Oops! Something Went Wrong.",
"error.500_home": "Go Home",
"error.500_img_alt": "Illustration of a server error",
"error.500_message1": "Our servers encountered a mishap and need a moment. Maybe the hamsters spilled their coffee?",
"error.500_message2": "We're already on it—sorting files, rebooting servers, and recalibrating flux capacitors.",
"error.500_title": "Server Error - DocuElevate",
"error.forbidden": "Forbidden",
"error.forbidden_message": "You do not have permission to access this page.",
"error.not_found": "Page not found",
"error.not_found_message": "The page you are looking for does not exist.",
"error.server_error": "Internal Server Error",
"error.server_error_message": "Something went wrong. Please try again later.",
"error.unauthorized": "Unauthorized",
"error.unauthorized_message": "You need to log in to access this page.",
"files.action_delete": "Delete file",
"files.action_details": "View details",
"files.action_preview": "Quick preview",
"files.bulk_clear_selection": "Clear Selection",
"files.bulk_cloud_ocr": "Re-run Cloud OCR",
"files.bulk_delete": "Delete Selected",
"files.bulk_download": "Download as ZIP",
"files.bulk_reprocess": "Reprocess Selected",
"files.delete_modal_cancel": "Cancel",
"files.delete_modal_confirm": "Delete",
"files.delete_modal_message": "Are you sure you want to delete this file?",
"files.delete_modal_title": "Confirm Deletion",
"files.document_title": "Document Title",
"files.drop_overlay_hint": "Supports PDF, Office docs, images, HTML, Markdown, and more folders are processed recursively",
"files.drop_overlay_title": "Drop files or folders anywhere to upload",
"files.error_hint": "This might be due to a configuration or import issue. Please check the server logs.",
"files.file_size": "File Size",
"files.filename": "Filename",
"files.files_selected": "files selected",
"files.filter_all_providers": "All Providers",
"files.filter_all_statuses": "All Statuses",
"files.filter_all_types": "All Types",
"files.filter_apply": "Apply Filters",
"files.filter_clear": "Clear",
"files.filter_date_from": "Date From",
"files.filter_date_from_aria": "Filter from date",
"files.filter_date_to": "Date To",
"files.filter_date_to_aria": "Filter to date",
"files.filter_form_aria": "Filter files",
"files.filter_mime_type": "MIME Type",
"files.filter_ocr_all": "All Files",
"files.filter_ocr_good": "Good quality",
"files.filter_ocr_poor": "Poor quality",
"files.filter_ocr_quality": "OCR Quality",
"files.filter_ocr_quality_aria": "Filter by OCR quality score",
"files.filter_ocr_unchecked": "Not yet assessed",
"files.filter_search_label": "Search Filename",
"files.filter_search_placeholder": "Enter filename...",
"files.filter_storage_provider": "Storage Provider",
"files.filter_tags_aria": "Filter by tags (comma-separated)",
"files.filter_tags_placeholder": "e.g. invoice,amazon",
"files.fulltext_search_label": "Full-Text Search (OCR text, metadata, tags)",
"files.fulltext_search_placeholder": "Search document content, sender, tags, type...",
"files.no_files": "No files found",
"files.ocr_status": "OCR Status",
"files.page_title": "File Records",
"files.pagination_files": "files",
"files.pagination_first": "First",
"files.pagination_last": "Last",
"files.pagination_nav_aria": "File list pagination",
"files.pagination_next": "Next",
"files.pagination_of": "of",
"files.pagination_previous": "Previous",
"files.pagination_showing": "Showing",
"files.preview_modal_close": "Close preview",
"files.preview_modal_title": "Preview",
"files.queue_banner_items": "item(s) are currently queued or being processed. Files will appear here once processing completes.",
"files.queue_banner_link": "View Queue",
"files.saved_searches_empty": "No saved searches yet",
"files.saved_searches_error": "Could not load saved searches",
"files.saved_searches_label": "Saved Searches",
"files.saved_searches_save": "Save Current",
"files.saved_searches_save_aria": "Save current filters as a saved search",
"files.search_results_empty": "No results found.",
"files.search_results_title": "Search Results",
"files.status_duplicate": "Duplicate",
"files.table_actions": "Actions",
"files.table_aria": "File records",
"files.table_created_at": "Created At",
"files.table_empty": "No files found",
"files.table_id": "ID",
"files.table_mime_type": "MIME Type",
"files.table_original_filename": "Original Filename",
"files.table_select_all": "Select all files on this page",
"files.tags": "Tags",
"files.title": "Files",
"files.upload_modal_aria": "Upload progress",
"files.upload_modal_close": "Close upload progress",
"files.upload_modal_header": "Uploading Files",
"files.uploaded": "Uploaded",
"footer.about": "About",
"footer.attribution": "Attribution",
"footer.attributions": "Attributions",
"footer.cookies": "Cookies",
"footer.copyright": "DocuElevate {year}",
"footer.imprint": "Imprint",
"footer.license": "License",
"footer.navigation": "Footer navigation",
"footer.privacy": "Privacy",
"footer.terms": "Terms",
"footer.version": "Version {version}",
"help.destinations_dropbox": "Dropbox",
"help.destinations_dropbox_desc": "OAuth-linked. Files land in your chosen folder.",
"help.destinations_email": "Email Forwarding",
"help.destinations_email_desc": "Processed files sent as SMTP attachments.",
"help.destinations_google_drive": "Google Drive",
"help.destinations_google_drive_desc": "Service account or OAuth. Supports shared drives.",
"help.destinations_heading": "Destinations Where Documents Go",
"help.destinations_nextcloud": "Nextcloud / WebDAV",
"help.destinations_nextcloud_desc": "Self-hosted cloud storage via WebDAV.",
"help.destinations_onedrive": "OneDrive",
"help.destinations_onedrive_desc": "Microsoft Graph API integration.",
"help.destinations_paperless": "Paperless-ngx",
"help.destinations_paperless_desc": "Push documents straight into Paperless for archival.",
"help.destinations_s3": "Amazon S3",
"help.destinations_s3_desc": "Any S3-compatible bucket (AWS, MinIO, Wasabi).",
"help.destinations_sftp": "SFTP / FTP",
"help.destinations_sftp_desc": "Secure file transfer to any server.",
"help.destinations_webhook": "Webhook",
"help.destinations_webhook_desc": "POST metadata to any external endpoint.",
"help.documentation": "Documentation",
"help.faq": "Frequently Asked Questions",
"help.faq_1_a": "Navigate to the Upload page, drag-and-drop your files or click Choose File. DocuElevate converts images and office documents to PDF, runs OCR, and extracts metadata automatically.",
"help.faq_1_q": "How do I upload documents?",
"help.faq_2_a": "PDF, JPEG, PNG, TIFF, BMP, GIF, DOCX, XLSX, PPTX, ODT, ODS, TXT, RTF, and HTML. Non-PDF files are automatically converted to PDF before processing.",
"help.faq_2_q": "Which file formats are supported?",
"help.faq_3_a": "Yes. Go to Email Ingestion, add an IMAP account, and DocuElevate will poll for new messages and process attachments automatically.",
"help.faq_3_q": "Can I ingest documents from email?",
"help.faq_4_a": "Pipelines let you chain processing steps OCR, AI extraction, format conversion and route the result to one or more destinations. Create and manage them from the Pipelines page.",
"help.faq_4_q": "How do processing pipelines work?",
"help.faq_5_a": "DocuElevate encrypts credentials at rest, communicates over TLS, and never stores your documents longer than necessary. See the Privacy Notice for full details.",
"help.faq_5_q": "Is my data secure?",
"help.faq_heading": "Frequently Asked Questions",
"help.getting_started": "Getting Started",
"help.heading": "Help Center",
"help.page_title": "Help Center",
"help.privacy_notice": "Privacy Notice",
"help.quickstart_heading": "Quick Start",
"help.quickstart_storage": "Connect Storage",
"help.quickstart_storage_desc": "Head to Settings and link your cloud accounts. Processed documents are automatically routed to every destination you configure.",
"help.quickstart_upload": "Upload Documents",
"help.quickstart_upload_desc": "Drag & drop files onto the Upload page or click Choose File. DocuElevate converts images and office documents to PDF, runs OCR, and extracts metadata automatically.",
"help.quickstart_workflows": "Automate Workflows",
"help.quickstart_workflows_desc": "Create Pipelines to define multi-step processing and routing rules. Combine OCR, AI extraction, format conversion, and delivery in a single flow.",
"help.sources_email_ingestion": "Email Ingestion (IMAP)",
"help.sources_email_ingestion_desc": "Forward documents to a dedicated mailbox. Under Email Ingestion, add one or more IMAP accounts. DocuElevate polls for new messages and processes attachments automatically.",
"help.sources_heading": "Sources Getting Documents In",
"help.sources_rest_api": "REST API",
"help.sources_rest_api_desc": "Integrate programmatically by POST-ing files to /api/upload. Ideal for scripts, watched folders, scanners, or third-party tools like Zapier and n8n.",
"help.sources_scanner": "Scanner & Mobile",
"help.sources_scanner_desc": "Point network scanners at DocuElevate's upload endpoint or use any mobile scanning app that supports custom HTTP destinations.",
"help.sources_web_upload": "Web Upload",
"help.sources_web_upload_desc": "The fastest way to get started. Open the Upload page, drop one or more files, and DocuElevate takes care of the rest. Supported formats include PDF, JPEG, PNG, TIFF, DOCX, XLSX, and more.",
"help.subheading": "Everything you need to get the most out of DocuElevate. Browse topics below or search for what you need.",
"help.support": "Support",
"help.support_admin_message": "Contact your administrator for support information.",
"help.support_description": "Cant find what youre looking for? Our support team is here to help.",
"help.support_heading": "Contact Support",
"help.support_ticket_button": "Submit a Ticket",
"help.support_ticket_heading": "Open a Support Ticket",
"help.title": "Help Center",
"help.workflows_creating": "Creating a Pipeline",
"help.workflows_definition": "A Pipeline is a series of processing steps that run automatically whenever a document is ingested. Each step can transform, enrich, or route the document.",
"help.workflows_heading": "Workflows & Pipelines",
"help.workflows_step_1": "Convert to PDF",
"help.workflows_step_1_create": "Go to Pipelines in the main menu.",
"help.workflows_step_2": "OCR extract text",
"help.workflows_step_2_create": "Click New Pipeline and give it a name.",
"help.workflows_step_3": "AI metadata extraction",
"help.workflows_step_3_create": "Add the processing steps you need.",
"help.workflows_step_4": "Deliver to one or more destinations",
"help.workflows_step_4_create": "Choose one or more delivery destinations.",
"help.workflows_step_5_create": "Save new documents will be processed through this pipeline automatically.",
"help.workflows_typical_steps": "Typical Steps",
"help.workflows_what_is": "What is a Pipeline?",
"index.badge_intelligent": "Intelligent Document Processing",
"index.button_browse_files": "Browse Files",
"index.button_upload": "Upload",
"index.capabilities_cloud": "Cloud storage: Dropbox, OneDrive, Google Drive, NextCloud",
"index.capabilities_ingestion": "Email & URL-based document ingestion",
"index.capabilities_ocr": "OCR & metadata extraction with AI",
"index.capabilities_paperless": "Paperless-ngx integration for document management",
"index.capabilities_title": "Capabilities",
"index.capabilities_workflows": "Automated classification & routing workflows",
"index.cta_description": "Join teams already automating their document processing with DocuElevate.",
"index.cta_heading": "Ready to elevate your document workflow?",
"index.cta_pricing": "See pricing",
"index.cta_signup": "Create a free account",
"index.dashboard_subtitle": "Intelligent document processing & management",
"index.dashboard_subtitle_teams": "Intelligent document processing & management — built for teams",
"index.feature_ai": "AI Metadata Extraction",
"index.feature_ai_desc": "OpenAI, Claude, Gemini, and other pluggable AI providers classify documents and pull out key fields like dates, amounts, and subjects.",
"index.feature_cloud": "Multi-Cloud Storage",
"index.feature_cloud_desc": "Route processed files to Dropbox, Google Drive, OneDrive, Amazon S3, Nextcloud, Paperless NGX, WebDAV, FTP/SFTP, and more.",
"index.feature_email": "Email & IMAP Ingestion",
"index.feature_email_desc": "Automatically pull documents from Gmail or any IMAP mailbox — no manual uploads needed.",
"index.feature_ocr": "OCR & Text Extraction",
"index.feature_ocr_desc": "Azure Document Intelligence converts scanned PDFs and images into fully searchable text automatically.",
"index.feature_pipelines": "Custom Pipelines",
"index.feature_pipelines_desc": "Build processing pipelines with configurable steps — OCR, AI extraction, format conversion, and storage routing in any order.",
"index.feature_search": "Full-Text Search",
"index.feature_search_desc": "Instantly find any document by content, metadata, or tags across your entire archive.",
"index.feature_section_title": "Everything you need for smart document workflows",
"index.getting_started": "Getting Started",
"index.getting_started_1": "Configure integrations via System Status",
"index.getting_started_2": "Upload your first document",
"index.getting_started_3": "Review results in Files",
"index.getting_started_learn": "Learn more about DocuElevate",
"index.hero_description": "DocuElevate ingests your documents, runs OCR, extracts metadata with AI, and routes files to Dropbox, Google Drive, OneDrive, S3, Nextcloud, and more — all in one seamless pipeline.",
"index.hero_heading": "From upload to insight — automatically.",
"index.hero_login": "Log In",
"index.hero_pricing": "View Plans & Pricing",
"index.hero_signup": "Get Started — its free",
"index.integrations_active": "Active integrations",
"index.integrations_storage": "Storage targets",
"index.integrations_title": "Integrations",
"index.integrations_view_status": "View system status",
"index.page_title_dashboard": "Dashboard",
"index.page_title_public": "Intelligent Document Processing",
"index.platform_overview": "Platform overview",
"index.quick_actions": "Quick Actions",
"index.quick_documents": "My Documents",
"index.quick_documents_desc": "Browse your processed files",
"index.quick_search": "Search",
"index.quick_search_desc": "Full-text search across documents",
"index.quick_subscription": "My Subscription",
"index.quick_subscription_desc": "View plan & usage details",
"index.quick_system_status": "System Status",
"index.quick_system_status_desc": "Check integration health",
"index.quick_upload": "Upload Document",
"index.quick_upload_desc": "Process a new file",
"index.quick_view_files": "View All Files",
"index.quick_view_files_desc": "Browse processed documents",
"index.single_user_heading": "DocuElevate Dashboard",
"index.single_user_subtitle": "Intelligent document processing & management",
"index.stat_active_integrations": "Active Integrations",
"index.stat_active_users": "Active users",
"index.stat_files_month": "Files this month",
"index.stat_files_today": "Files today",
"index.stat_storage_targets": "Storage Targets",
"index.stat_total_files": "Total files",
"index.tier_plan": "Plan",
"index.tier_upgrade": "Upgrade",
"index.tier_view_details": "View full details",
"index.upgrade_daily_limits": "Higher daily & monthly limits",
"index.upgrade_description": "Unlock more documents, more destinations and priority support.",
"index.upgrade_destinations": "More storage destinations",
"index.upgrade_ocr_pages": "More OCR pages",
"index.upgrade_plan": "Upgrade your plan",
"index.upgrade_view_pricing": "View plans & pricing",
"index.usage_lifetime": "Lifetime files",
"index.usage_month": "Files this month",
"index.usage_my_usage": "My usage",
"index.usage_today": "Files today",
"index.usage_unlimited": "Unlimited",
"integrations.configure": "Configure",
"integrations.connect": "Connect",
"integrations.connected": "Connected",
"integrations.disconnect": "Disconnect",
"integrations.empty_state": "No integrations configured",
"integrations.folder_label": "Folder",
"integrations.host_label": "Host",
"integrations.imap_settings": "IMAP Settings",
"integrations.not_connected": "Not Connected",
"integrations.page_title": "Integrations",
"integrations.password_label": "Password",
"integrations.port_label": "Port",
"integrations.title": "Integrations",
"integrations.username_label": "Username",
"language.bg": "Български",
"language.ca": "Català",
"language.change_success": "Language changed to {language}",
"language.changed": "Language changed to {language}",
"language.cs": "Čeština",
"language.da": "Dansk",
"language.de": "Deutsch",
"language.el": "Ελληνικά",
"language.en": "English",
"language.es": "Español",
"language.et": "Eesti",
"language.fi": "Suomi",
"language.fr": "Français",
"language.ga": "Gaeilge",
"language.hr": "Hrvatski",
"language.hu": "Magyar",
"language.is": "Íslenska",
"language.it": "Italiano",
"language.lb": "Lëtzebuergesch",
"language.lt": "Lietuvių",
"language.lv": "Latviešu",
"language.nb": "Norsk",
"language.nl": "Nederlands",
"language.pl": "Polski",
"language.pt": "Português",
"language.ro": "Română",
"language.ru": "Русский",
"language.selector": "Language",
"language.selector_label": "Select language",
"language.sk": "Slovenčina",
"language.sl": "Slovenščina",
"language.sv": "Svenska",
"language.tr": "Türkçe",
"language.uk": "Українська",
"language.zh": "中文",
"nav.about": "About",
"nav.admin": "Admin",
"nav.admin.audit_logs": "Audit Logs",
"nav.admin.backups": "Backups",
"nav.admin.plans": "Plans",
"nav.admin.scheduled_jobs": "Scheduled Jobs",
"nav.admin.users": "Users",
"nav.admin_actions": "Admin actions",
"nav.admin_menu": "Admin menu",
"nav.api_docs": "API Docs",
"nav.api_tokens": "API Tokens",
"nav.backup_restore": "Backup & Restore",
"nav.credentials": "Credentials",
"nav.dark_mode": "Dark Mode",
"nav.dashboard": "Dashboard",
"nav.developer_docs": "Developer Docs",
"nav.duplicates": "Duplicates",
"nav.file_manager": "File Manager",
"nav.files": "Files",
"nav.help": "Help",
"nav.help_center": "Help Center",
"nav.imap": "Email Import",
"nav.integrations": "Integrations",
"nav.light_mode": "Light Mode",
"nav.login": "Log In",
"nav.logout": "Log Out",
"nav.main_navigation": "Main navigation",
"nav.notifications": "Notifications",
"nav.open_main_menu": "Open main menu",
"nav.pipelines": "Pipelines",
"nav.plan_designer": "Plan Designer",
"nav.pricing": "Pricing",
"nav.profile": "Profile",
"nav.queue": "Queue",
"nav.queue_monitor": "Queue Monitor",
"nav.scheduled_jobs": "Scheduled Jobs",
"nav.search": "Search",
"nav.settings": "Settings",
"nav.shared_links": "Shared Links",
"nav.signup": "Sign Up",
"nav.similarity": "Similarity",
"nav.skip_to_content": "Skip to main content",
"nav.status": "Status",
"nav.subscription": "Subscription",
"nav.toggle_dark_mode": "Toggle dark mode",
"nav.toggle_nav": "Toggle navigation menu",
"nav.upload": "Upload",
"nav.users": "Users",
"nav.version": "Version Info",
"notifications.filter_all": "All",
"notifications.filter_read": "Read only",
"notifications.filter_unread": "Unread only",
"notifications.manage_desc": "Manage your notification inbox, targets, and event preferences",
"notifications.mark_all_read": "Mark All as Read",
"notifications.mark_all_read_btn": "Mark all read",
"notifications.mark_read": "Mark as Read",
"notifications.no_notifications": "No notifications",
"notifications.page_title": "Notifications",
"notifications.tab_inbox": "Inbox",
"notifications.tab_settings": "Settings",
"notifications.title": "Notifications",
"notifications.unread_count": "{count} unread notifications",
"pipelines.active_label": "Active",
"pipelines.add_step_btn": "Add Step",
"pipelines.create": "Create Pipeline",
"pipelines.custom_label_label": "Custom Label",
"pipelines.default_label": "Default",
"pipelines.description_label": "Description",
"pipelines.description_placeholder": "Optional description",
"pipelines.disabled_label": "Disabled",
"pipelines.edit": "Edit Pipeline",
"pipelines.empty_state": "No pipelines yet",
"pipelines.empty_state_hint": "Create your first pipeline to define custom document processing workflows.",
"pipelines.enabled_label": "Enabled",
"pipelines.force_cloud_ocr_label": "Force cloud OCR (skip local text extraction)",
"pipelines.inactive_label": "Inactive",
"pipelines.loading": "Loading pipelines…",
"pipelines.name_placeholder": "My pipeline",
"pipelines.new_pipeline_btn": "New Pipeline",
"pipelines.no_steps": "No steps defined. Add a step to start building your pipeline.",
"pipelines.ocr_auto": "Auto (use system default)",
"pipelines.ocr_language_hint": "Overrides the global language setting for Tesseract/EasyOCR. Azure and Mistral auto-detect the language.",
"pipelines.ocr_language_label": "OCR Language",
"pipelines.optional_suffix": "(optional)",
"pipelines.page_title": "Processing Pipelines",
"pipelines.set_default": "Set as my default pipeline",
"pipelines.step_label_placeholder": "Override the default step name",
"pipelines.step_type_label": "Step Type",
"pipelines.subtitle_intro": "Define and manage custom document processing workflows.",
"pipelines.subtitle_system_post": "badge and are visible to all users.",
"pipelines.subtitle_system_pre": "System pipelines (created by admins) are shown with a",
"pipelines.system_label": "System",
"pipelines.system_pipeline_label": "System pipeline (visible to all users)",
"pipelines.title": "Processing Pipelines",
"queue.active_tasks": "Active Tasks",
"queue.auto_refresh_1": "Auto-refreshes every",
"queue.auto_refresh_2": "seconds",
"queue.col_arguments": "Arguments",
"queue.col_current_step": "Current Step",
"queue.col_file": "File",
"queue.col_files": "Files",
"queue.col_queue": "Queue",
"queue.col_state": "State",
"queue.col_task": "Task",
"queue.col_task_id": "Task ID",
"queue.files_processing": "Files Processing",
"queue.heading": "Queue Monitor",
"queue.last_updated": "Last updated:",
"queue.loading_stats": "Loading queue statistics…",
"queue.no_active_tasks": "No active tasks",
"queue.no_data": "No data",
"queue.no_files_processing": "No files currently processing",
"queue.page_title": "Queue Monitor",
"queue.processing_pipeline": "Processing Pipeline",
"queue.queued_tasks": "Queued Tasks",
"queue.recently_processing": "Recently Processing Files",
"queue.redis_queues": "Redis Queues",
"queue.subtitle": "Real-time view of the document processing pipeline and Celery task queues.",
"queue.workers_online": "Workers Online",
"search.button": "Search",
"search.error_message": "Search is temporarily unavailable. Please try again in a moment.",
"search.filter_aria_clear": "Clear all filters",
"search.filter_clear_button": "Clear Filters",
"search.filter_date_from": "Date From",
"search.filter_date_to": "Date To",
"search.filter_document_type": "Document Type",
"search.filter_document_type_placeholder": "e.g. Invoice",
"search.filter_language": "Language",
"search.filter_language_placeholder": "e.g. de",
"search.filter_sender": "Sender",
"search.filter_sender_placeholder": "e.g. ACME Corp",
"search.filter_tags": "Tags",
"search.filter_tags_placeholder": "e.g. amazon",
"search.filter_text_quality": "Text Quality",
"search.filter_text_quality_all": "All",
"search.filter_text_quality_high": "High",
"search.filter_text_quality_low": "Low",
"search.filter_text_quality_medium": "Medium",
"search.filter_text_quality_no_text": "No text",
"search.heading": "Document Search",
"search.input_aria_label": "Search documents",
"search.input_placeholder": "Search documents by content, sender, tags, type...",
"search.loading_indicator": "Searching…",
"search.no_results": "No results found",
"search.page_title": "Search Documents",
"search.placeholder": "Search by filename, content, tags...",
"search.result_empty": "No documents found matching your query.",
"search.results_count": "{count} results found",
"search.saved_aria_save": "Save current search",
"search.saved_button": "Save Current",
"search.saved_empty": "No saved searches yet",
"search.saved_error": "Could not load saved searches",
"search.saved_label": "Saved Searches",
"search.saved_loading": "Loading...",
"search.title": "Search Documents",
"settings.audit_log_btn": "Audit Log",
"settings.autocomplete_hint": "Type to search known values, or enter any custom value.",
"settings.autocomplete_no_matches": "No matches — you can still type a custom value",
"settings.autocomplete_placeholder": "Type to search or enter a value…",
"settings.boolean_enable_prefix": "Enable",
"settings.categories_aria": "Settings categories",
"settings.categories_heading": "Categories",
"settings.db_wizard_btn": "DB Wizard",
"settings.effective_value_label": "Effective value:",
"settings.encrypted_suffix": "= encrypted at rest",
"settings.encrypted_title": "Value is encrypted at rest in database",
"settings.export_btn": "Export",
"settings.export_db_only": "DB settings only",
"settings.export_full_config": "Full effective config",
"settings.jump_to_category": "Jump to category…",
"settings.manage_config_prefix": "Manage configuration. Priority:",
"settings.model_picker_hint": "Pick from the list or type any model name supported by your provider.",
"settings.model_picker_placeholder": "Select a common model or type a custom name…",
"settings.no_results_clear": "clear the search",
"settings.no_results_heading": "No settings found",
"settings.no_results_hint": "Try a different search term or",
"settings.page_heading": "Application Settings",
"settings.required_label": "(required)",
"settings.reset_confirm": "Are you sure you want to reset this setting?",
"settings.restart_required_suffix": "= restart required",
"settings.revert_btn": "Remove from DB",
"settings.revert_title": "Remove DB override and revert to environment variable or default",
"settings.reverting": "Reverting…",
"settings.save_all_btn": "Save All Changes",
"settings.save_error": "Failed to save setting",
"settings.save_setting_title": "Save this setting",
"settings.save_success": "Setting saved successfully",
"settings.saving_state": "Saving…",
"settings.search_aria_label": "Search settings",
"settings.search_clear_aria": "Clear search",
"settings.search_placeholder": "Search settings by name, key, or description…",
"settings.settings_count_suffix": "settings",
"settings.source_db_badge": "DB",
"settings.source_default_badge": "DEFAULT",
"settings.source_env_badge": "ENV",
"settings.title": "Settings",
"settings.toggle_visibility_aria": "Toggle password visibility",
"settings.toggle_visibility_title": "Show/hide value",
"settings.user_autocomplete_empty": "No matching users found",
"settings.user_autocomplete_hint": "Type to search existing users by name, or enter any identifier manually.",
"settings.user_autocomplete_placeholder": "Start typing to search users…",
"settings.wizard_btn": "Wizard",
"shared.col_expiry": "Expiry",
"shared.col_file_label": "File / Label",
"shared.col_link": "Link",
"shared.col_views": "Views",
"shared.copy_link_aria": "Copy link to clipboard",
"shared.copy_link_title": "Copy link",
"shared.create_btn": "Create Link",
"shared.create_heading": "Create New Shared Link",
"shared.creating": "Creating…",
"shared.expiry_12h": "12 hours",
"shared.expiry_14d": "14 days",
"shared.expiry_1h": "1 hour",
"shared.expiry_24h": "24 hours (1 day)",
"shared.expiry_30d": "30 days",
"shared.expiry_3d": "3 days",
"shared.expiry_6h": "6 hours",
"shared.expiry_7d": "7 days",
"shared.expiry_label": "Expiry",
"shared.expiry_never": "Never",
"shared.file_id_help_post": "page or in the document detail URL.",
"shared.file_id_help_pre": "Find the file ID on the",
"shared.file_id_label": "File ID",
"shared.file_id_placeholder": "e.g. 42",
"shared.files_link": "Files",
"shared.heading": "Shared Links",
"shared.label_label": "Label",
"shared.label_placeholder": "e.g. Shared with Bob",
"shared.link_created": "Shared link created!",
"shared.link_created_help": "Copy and send this link to the recipient.",
"shared.links_count_aria": "number of links",
"shared.max_downloads_label": "Max downloads",
"shared.no_links": "No shared links yet. Create one above to get started.",
"shared.open_in_new_tab": "Open in new tab",
"shared.open_link_aria": "Open shared link",
"shared.optional": "(optional)",
"shared.page_title": "Shared Links DocuElevate",
"shared.password_label": "Password",
"shared.password_placeholder": "Leave blank for no password",
"shared.password_protected": "Password protected",
"shared.refresh_aria": "Refresh shared links list",
"shared.revoke_aria_prefix": "Revoke shared link for",
"shared.revoke_btn": "Revoke",
"shared.status_active": "Active",
"shared.status_expired": "Expired",
"shared.status_limit_reached": "Limit reached",
"shared.status_revoked": "Revoked",
"shared.subtitle": "Share documents with anyone via a time-limited or view-limited link. Recipients do not need a DocuElevate account. Links can be password-protected and revoked at any time.",
"shared.table_aria": "Shared links",
"shared.unlimited_placeholder": "Unlimited",
"shared.your_links": "Your Shared Links",
"similarity.backfill_auto": "The background task will compute them automatically every 5 minutes, or you can",
"similarity.files_missing_text": "file(s) have OCR text but no embedding yet.",
"similarity.find_pairs_btn": "Find Pairs",
"similarity.heading": "Document Similarity",
"similarity.load_error": "Failed to load pairs.",
"similarity.min_similarity_label": "Min. similarity",
"similarity.no_pairs_detail": "No document pairs exceed the similarity threshold.",
"similarity.no_pairs_heading": "No similar pairs found",
"similarity.page_title": "Document Similarity - DocuElevate",
"similarity.pagination_aria": "Similarity pairs pagination",
"similarity.per_page_label": "Per page",
"similarity.scanning": "Scanning for similar document pairs…",
"similarity.stat_embedding_model": "Embedding Model",
"similarity.stat_missing_embedding": "Missing Embedding",
"similarity.stat_total_files": "Total Files",
"similarity.stat_with_embedding": "With Embedding",
"similarity.subtitle": "Pairs of documents with high semantic similarity, ranked by score.",
"similarity.trigger_aria": "Trigger embedding computation for all files missing embeddings",
"similarity.trigger_now": "trigger it now",
"status.app_version": "App Version",
"status.build_date": "Build Date",
"status.container_id": "Container ID",
"status.git_commit": "Git Commit",
"status.last_check": "Last Check",
"status.page_title": "System Status",
"status.setting_label": "Setting",
"status.value_label": "Value",
"upload.browse_button": "Browse Files",
"upload.button_processing": "Processing...",
"upload.camera_button": "Take Photo / Scan Document",
"upload.download_button": "Download and Process",
"upload.downloading": "Downloading file from URL...",
"upload.drag_drop": "Drag & drop files here or click to browse",
"upload.drop_hint_desktop": "Drag & drop files or folders here, or click to select files.",
"upload.drop_hint_mobile": "Tap to select files or use the camera button below.",
"upload.drop_zone_aria": "File upload area. Drag and drop files here, or press Enter to browse files.",
"upload.error": "Upload failed",
"upload.error_invalid_url": "Invalid URL format",
"upload.error_url_required": "Please enter a URL",
"upload.file_size_hint": "Maximum size: 500 MB per file",
"upload.file_types": "Allowed types: PDF, Office documents (Word, Excel, PowerPoint, etc.), Images",
"upload.filename_description": "Leave empty to use filename from URL",
"upload.filename_label": "Filename (optional)",
"upload.filename_placeholder": "my-document.pdf",
"upload.max_size": "Maximum file size: {size}",
"upload.page_title": "Upload Files",
"upload.section_device": "Upload from Device",
"upload.section_url": "Upload from URL",
"upload.select_file": "Select File",
"upload.success": "File uploaded successfully",
"upload.title": "Upload Document",
"upload.uploading": "Uploading...",
"upload.url_description": "Enter a direct link to a file (PDF, Office documents, or images)",
"upload.url_label": "File URL",
"upload.url_placeholder": "https://example.com/document.pdf"
}

Some files were not shown because too many files have changed in this diff Show More