Compare commits

..

1 Commits

Author SHA1 Message Date
copilot-swe-agent[bot] 0f518c0958 Initial plan 2026-03-13 11:26:46 +00:00
161 changed files with 42540 additions and 188649 deletions
-7
View File
@@ -163,13 +163,6 @@ 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
@@ -1,4 +0,0 @@
## 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-15T21:39:26Z
2026-03-13T08:33:59Z
-574
View File
@@ -10,580 +10,6 @@ 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 @@
237af31
3c7f088
+97 -203
View File
@@ -24,154 +24,121 @@
</div>
<div align="center">
<a href="https://www.docuelevate.org"><img src="frontend/static/hero.png" alt="DocuElevate Hero" width="80%" /></a>
<a href="https://www.docuelevate.org"><img src="frontend/static/hero.png" alt="DocuElevate Logo" width="80%" /></a>
</div>
## Overview
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.
DocuElevate automates the handling, extraction, and processing of documents using a variety of services, including:
**Key capabilities:**
- **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.
- **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)
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.
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.
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
## Screenshots
<div align="center">
<img src="docs/upload-view.png" alt="DocuElevate Upload Interface" width="80%" />
<p><em>Upload interface — drag-and-drop file upload with real-time progress</em></p>
<p><em>Upload interface for adding new documents</em></p>
<img src="docs/files-view.png" alt="DocuElevate Files View" width="80%" />
<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>
<p><em>Files view with processed documents and metadata</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
## Workflow Process
DocuElevate follows a streamlined document processing workflow:
<div align="center">
<img src="docs/workflow-diagram.png" alt="DocuElevate Workflow" width="90%" />
</div>
### 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 |
### 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
### Processing Pipeline
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.
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
### 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
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 |
Users can choose to send documents to any combination of these destinations through configuration settings or manual selection.
## Features
### 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
- **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 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
## Frameworks Used
### 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 |
- **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.
## Quick Start
For detailed installation and deployment instructions, see the [Deployment Guide](docs/DeploymentGuide.md).
For detailed installation and deployment instructions, please refer to the [Deployment Guide](docs/DeploymentGuide.md).
```bash
# Clone the repository
@@ -180,96 +147,20 @@ cd DocuElevate
# Configure environment variables
cp .env.demo .env
# Edit .env with your settings (see Configuration Guide for all options)
# Edit .env with your settings
# Run with Docker Compose
docker compose up -d
docker-compose up -d
```
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 |
The API will be available at **`http://localhost:8000`**, and the API documentation is available at **`http://localhost:8000/docs`**.
## Development & Testing
### Running Tests
DocuElevate includes comprehensive test coverage. To run tests:
```bash
# Install development dependencies
pip install -r requirements-dev.txt
@@ -284,21 +175,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, see the [Contributing Guide](CONTRIBUTING.md#running-tests).
For detailed testing information, including integration tests with Docker and authentication testing, see the [Contributing Guide](CONTRIBUTING.md#running-tests).
### Contributing
We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for:
- Code style guidelines (Ruff for formatting and linting)
- Code style guidelines
- 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
@@ -323,10 +214,13 @@ 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 |
| Dropbox SDK | MIT |
| puremagic | MIT |
| filetype | MIT |
| Dropbox | MIT |
| Azure AI Document Intelligence | MIT |
| Authlib | BSD |
| Starlette | BSD |
@@ -335,15 +229,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 (py) | BSD |
| Gotenberg Client | MIT |
| Meilisearch | MIT |
| Redis | BSD |
| Gotenberg | 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.145.2
Build Date: 2026-03-15T21:39:26Z
Git Commit: 237af31f5fe598cfc3c08f2bbba79b3d0925787e
Git Short SHA: 237af31
Version: 0.129.0
Build Date: 2026-03-13T08:33:59Z
Git Commit: 3c7f0888de98fa7cf4a89c4210768c4f138093ae
Git Short SHA: 3c7f088
Git Branch: main
Commit Date: 2026-03-15T22:39:04+01:00
Build Timestamp: 2026-03-15T21:39:26Z
Commit Date: 2026-03-13T09:33:37+01:00
Build Timestamp: 2026-03-13T08:33:59Z
==============================
+1 -1
View File
@@ -1 +1 @@
0.145.2
0.129.0
+12 -14
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 Annotated, Any
from typing import Any
from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy.orm import Session
@@ -20,22 +20,20 @@ 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: 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,
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"),
) -> dict[str, Any]:
"""Return audit log entries with optional filtering and pagination.
@@ -73,7 +71,7 @@ async def list_audit_logs(
@require_login
async def list_distinct_actions(
request: Request,
db: DbSession,
db: Session = Depends(get_db),
) -> list[str]:
"""Return the distinct action values present in the audit log."""
from app.models import AuditLog
@@ -86,7 +84,7 @@ async def list_distinct_actions(
@require_login
async def list_distinct_users(
request: Request,
db: DbSession,
db: Session = Depends(get_db),
) -> list[str]:
"""Return the distinct user values present in the audit log."""
from app.models import AuditLog
+9 -4
View File
@@ -564,6 +564,7 @@ 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)
@@ -573,10 +574,14 @@ 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:
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:
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"}
try:
import base64
+2 -22
View File
@@ -18,7 +18,7 @@ import logging
from hashlib import md5
from typing import Annotated
from fastapi import APIRouter, Depends, File, HTTPException, Request, Response, UploadFile, status
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
@@ -156,9 +156,7 @@ async def get_profile(request: Request, db: DbSession) -> ProfileResponse:
@router.patch("", response_model=ProfileResponse)
@require_login
async def update_profile(
body: ProfileUpdateRequest, request: Request, response: Response, db: DbSession
) -> ProfileResponse:
async def update_profile(body: ProfileUpdateRequest, request: Request, 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)
@@ -173,24 +171,6 @@ async def update_profile(
)
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()
+32 -1
View File
@@ -2,6 +2,7 @@
API endpoint for processing files from URLs
"""
import ipaddress
import logging
import mimetypes
import os
@@ -18,7 +19,6 @@ 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,6 +42,37 @@ 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).
+4 -175
View File
@@ -4,13 +4,12 @@ import logging
import pathlib
from datetime import datetime, timezone
from functools import wraps
from urllib.parse import urlencode, urlparse
from urllib.parse import 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
@@ -255,38 +254,6 @@ 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",
{
@@ -440,12 +407,6 @@ 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:
@@ -607,19 +568,6 @@ 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:
@@ -677,98 +625,6 @@ 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.
@@ -803,13 +659,8 @@ 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(
(func.lower(_LocalUser.username) == username_lower) | (func.lower(_LocalUser.email) == username_lower)
)
.first()
db.query(_LocalUser).filter((_LocalUser.username == username) | (_LocalUser.email == username)).first()
)
logger.debug(
"[AUTH] LocalUser lookup: username=%r found=%s",
@@ -843,17 +694,6 @@ 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")
@@ -878,13 +718,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 or "").lower() == settings.admin_username.lower() if admin_configured else False,
username == settings.admin_username if admin_configured else False,
settings.multi_user_enabled,
)
if (
settings.admin_username
and settings.admin_password
and (username or "").lower() == settings.admin_username.lower()
and username == settings.admin_username
and password == settings.admin_password
):
admin_user_data = {
@@ -899,17 +739,6 @@ 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:
+2 -8
View File
@@ -210,10 +210,8 @@ 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:
quoted_idx = preparer.quote(index["name"])
conn.execute(text(f"DROP INDEX IF EXISTS {quoted_idx}"))
conn.execute(text(f"DROP INDEX IF EXISTS {index['name']}"))
logger.info("Migration complete: unique index on 'filehash' removed")
except Exception as exc:
logger.warning(f"Skipping filehash unique index drop: {exc}")
@@ -265,16 +263,12 @@ 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]:
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})"))
conn.execute(text(f"CREATE INDEX IF NOT EXISTS {idx_name} ON {table} ({column})"))
logger.info("Performance indexes ensured")
-2
View File
@@ -261,14 +261,12 @@ _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)
+191 -192
View File
@@ -1,192 +1,191 @@
#!/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 {}
#!/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 {}
+1 -2
View File
@@ -85,8 +85,7 @@ 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
quoted_table = conn.dialect.identifier_preparer.quote(table_name)
row = conn.execute(text(f"SELECT COUNT(*) FROM {quoted_table}")).fetchone() # noqa: S608
row = conn.execute(text(f'SELECT COUNT(*) FROM "{table_name}"')).fetchone() # noqa: S608
count = row[0] if row else 0
result.append({"name": table_name, "row_count": count})
total += count
-5
View File
@@ -8,11 +8,6 @@ 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:
"""
+14 -237
View File
@@ -2,7 +2,7 @@
Provides a JSON-based translation system for the DocuElevate UI with:
* **77 supported languages** covering European, Asian, Middle-Eastern, African, and other languages
* **31 supported languages** covering all major European languages plus ZH
* 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,8 +42,7 @@ 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 Bokmål", "native": "Norsk bokmål", "flag": "🇳🇴"},
{"code": "no", "name": "Norwegian", "native": "Norsk", "flag": "🇳🇴"},
{"code": "nb", "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": "🇫🇮"},
@@ -51,12 +50,6 @@ 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": "🇨🇿"},
@@ -70,64 +63,16 @@ 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": "🇱🇹"},
{"code": "sr", "name": "Serbian", "native": "Српски", "flag": "🇷🇸"},
# --- Tier 4: Non-EU European, Middle Eastern & African ---
# --- Tier 4: Non-EU European & Other ---
{"code": "tr", "name": "Turkish", "native": "Türkçe", "flag": "🇹🇷"},
{"code": "uk", "name": "Ukrainian", "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": "ru", "name": "Russian", "native": "Русский", "flag": "🇷🇺"},
{"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
# ---------------------------------------------------------------------------
@@ -298,10 +243,14 @@ def detect_language(request: Request) -> str:
return DEFAULT_LANGUAGE
def _parse_accept_language_entries(header: str) -> list[tuple[float, str]]:
"""Parse an ``Accept-Language`` header into quality-sorted ``(q, tag)`` pairs."""
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.
"""
if not header:
return []
return None
entries: list[tuple[float, str]] = []
for raw_part in header.split(","):
@@ -319,17 +268,11 @@ def _parse_accept_language_entries(header: str) -> list[tuple[float, str]]:
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
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):
for _quality, tag in entries:
# Try exact match first (e.g., "de", "zh")
code = tag.split("-")[0]
if code in SUPPORTED_LANGUAGE_CODES:
return code
@@ -337,45 +280,6 @@ 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)
# ---------------------------------------------------------------------------
@@ -599,133 +503,6 @@ _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
@@ -1,34 +0,0 @@
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
+1 -50
View File
@@ -11,15 +11,13 @@ from sqlalchemy.orm import Session # noqa: F401
from app.auth import require_login # noqa: F401
from app.config import settings
from app.database import SessionLocal, get_db # noqa: F401
from app.models import UserProfile
from app.database import get_db # noqa: F401
from app.utils.i18n import (
SUPPORTED_LANGUAGES,
detect_language,
format_date,
format_datetime,
format_number,
get_suggested_languages,
translate,
)
@@ -48,41 +46,6 @@ 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)
@@ -107,22 +70,10 @@ 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)
+23 -135
View File
@@ -1,97 +1,27 @@
# Internationalization (i18n) & Localization (l10n) Guide
DocuElevate supports **77 languages** for its web UI, with automatic browser
DocuElevate supports **10 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 | 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 |
| 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 |
> **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.
> **Tier 1** languages (European priority) have complete, manually-reviewed
> translations. **Tier 2** languages have complete translations but may
> receive less frequent updates.
## How Language Is Detected
@@ -141,54 +71,15 @@ 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
├── 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
├── es.json # Spanish
├── it.json # Italian
├── 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
├── 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
├── nl.json # Dutch
├── pl.json # Polish
├── zh.json # Chinese
── ru.json # Russian
```
Each file is a flat key-value dictionary with dot-notation namespacing:
@@ -252,11 +143,8 @@ 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. 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.
2. Add translations for all other languages in their respective files
3. Use `{{ _("your.new.key") }}` in templates
### AI Fallback Translation
+6 -59
View File
@@ -20,7 +20,7 @@ DocuElevate includes a native mobile application for iOS and Android built with
### Prerequisites
- Node.js 20.19.4 or later (use [nvm](https://github.com/nvm-sh/nvm): `nvm install` inside `mobile/` reads `.nvmrc` automatically)
- Node.js 18 or later
- [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,8 +53,6 @@ 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
@@ -64,25 +62,11 @@ 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 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.
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`).
### Auto-generated Mobile Token
@@ -242,43 +226,6 @@ 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://`).
+39 -197
View File
@@ -2,8 +2,6 @@
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
@@ -16,7 +14,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.
@@ -32,18 +30,6 @@ 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
@@ -53,51 +39,41 @@ This document provides solutions to common problems encountered when using DocuE
#### Possible Solutions
1. **Check file size limits**
- 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
- Default maximum file size is 100MB
- Adjust `client_max_body_size` in your reverse proxy configuration
2. **Verify storage space**
```bash
df -h
```
Ensure there's sufficient disk space on the workdir volume.
Ensure there's sufficient disk space available.
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 OCR services
- API errors related to Azure services
#### Possible Solutions
1. **Verify the configured OCR provider**
Check which provider is set via the `OCR_PROVIDER` environment variable (defaults to Azure Document Intelligence).
1. **Verify API credentials**
Check the Azure Document Intelligence API key and endpoint in your `.env` file.
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**
2. **Check document quality**
- Ensure documents are clearly scanned
- Try preprocessing images to improve quality before upload
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.
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.
### Email Integration Problems
@@ -108,226 +84,92 @@ This document provides solutions to common problems encountered when using DocuE
#### Possible Solutions
1. **Verify IMAP settings**
Check host, port, username, and password for `IMAP1_*` / `IMAP2_*` in your configuration.
Check host, port, username, and password in your configuration.
2. **Test IMAP connectivity**
```bash
docker compose exec api python -c "import imaplib; m = imaplib.IMAP4_SSL('mail.example.com', 993); print('OK')"
telnet mail.example.com 993
```
Ensure the IMAP server is accessible from the container.
Ensure the IMAP server is accessible.
3. **Check for app-specific passwords**
For Gmail and some providers, you must use app-specific passwords instead of your account password.
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.
4. **Check firewall settings**
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`).
Ensure your server can make outbound connections to the mail server.
### Storage Integration Issues
#### Symptoms
- Files aren't appearing in configured storage destinations
- Files aren't appearing in Dropbox/Nextcloud/Paperless
- Authentication errors in logs
- API rate limiting errors
#### Possible Solutions
1. **Verify API credentials**
Double-check all API keys, tokens, and secrets for the relevant service.
Double-check all API keys, tokens, and secrets.
2. **Check access permissions**
Ensure the application has write permissions to the specified folders/buckets.
Ensure the application has write permissions to the specified folders.
3. **Refresh tokens**
For OAuth-based services like Dropbox, Google Drive, and OneDrive, try re-authorizing through the integration setup pages.
For OAuth-based services like Dropbox, try generating new refresh tokens.
4. **Examine detailed logs**
```bash
docker compose logs worker | grep -i "upload_to"
docker-compose logs worker | grep -i dropbox
```
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.
Look for specific error messages related to the service.
## 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 PostgreSQL (recommended for production):
```bash
docker compose exec api python -c "from app.database import engine; print(engine.url)"
```
(For SQLite databases)
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 OAuth/OIDC configuration**
Check client ID, client secret, and configuration URL for your identity provider.
#### Possible Solutions
1. **Verify Authentik configuration**
Check client ID, client secret, and configuration URL.
2. **Check callback URLs**
Ensure the redirect URIs are correctly configured in your OAuth provider. The callback URL is typically `https://your-domain/auth/callback`.
Ensure the redirect URIs are correctly configured in your OAuth provider.
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=200
docker-compose logs --tail=100
```
2. **Check the status page** at `/status` in the web UI for an overview of all service connections.
2. **Open an issue** on the [GitHub repository](https://github.com/christianlouis/document-processor/issues)
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
3. **Contact the developer** via the information provided on the About page
-9
View File
@@ -63,15 +63,6 @@ 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', `${((window.__i18n?.accountMenuFor) || 'Account menu for {name}').replace('{name}', displayName)}`);
btn.setAttribute('aria-label', `Account menu for ${displayName}`);
const avatar = document.createElement('img');
avatar.src = data.picture;
avatar.alt = window.__i18n.avatar || 'Avatar';
avatar.alt = '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', window.__i18n.accountMenu || 'Account menu');
menu.setAttribute('aria-label', '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', window.__i18n.profileSettings || 'Profile Settings', 'text-gray-700')
_makeMenuLink('/profile', 'fas fa-user-circle text-blue-400', 'Profile Settings', 'text-gray-700')
);
linksDiv.appendChild(
_makeMenuLink('/subscription', 'fas fa-layer-group text-indigo-400', window.__i18n.mySubscription || 'My Subscription', 'text-gray-700')
_makeMenuLink('/subscription', 'fas fa-layer-group text-indigo-400', 'My Subscription', 'text-gray-700')
);
linksDiv.appendChild(
_makeMenuLink('/api-tokens', 'fas fa-key text-yellow-500', window.__i18n.apiTokens || 'API Tokens', 'text-gray-700')
_makeMenuLink('/api-tokens', 'fas fa-key text-yellow-500', 'API Tokens', 'text-gray-700')
);
linksDiv.appendChild(
_makeMenuLink('/shared-links', 'fas fa-share-alt text-blue-400', window.__i18n.sharedLinks || 'Shared Links', 'text-gray-700')
_makeMenuLink('/shared-links', 'fas fa-share-alt text-blue-400', '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', window.__i18n.signOut || 'Sign Out', 'text-red-600')
_makeMenuLink('/logout', 'fas fa-sign-out-alt text-red-400', '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 = window.__i18n.avatar || 'Avatar';
mAvatar.alt = '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(window.__i18n.profileSettings || 'Profile Settings'));
profileLink.appendChild(document.createTextNode('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(window.__i18n.mySubscription || 'My Subscription'));
subLink.appendChild(document.createTextNode('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(window.__i18n.apiTokens || 'API Tokens'));
tokensLink.appendChild(document.createTextNode('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(window.__i18n.sharedLinks || 'Shared Links'));
sharedLinksLink.appendChild(document.createTextNode('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(window.__i18n.signOut || 'Sign Out'));
logoutLink.appendChild(document.createTextNode('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 = window.__i18n.logIn || 'Log In';
loginLink.textContent = '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 ? (window.__i18n.signUp || 'Sign Up') : (window.__i18n.getStarted || 'Get Started');
startLink.textContent = allowSignup ? 'Sign Up' : '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(window.__i18n.logIn || 'Log In'));
loginLink.appendChild(document.createTextNode('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 ? (window.__i18n.signUp || 'Sign Up') : (window.__i18n.getStarted || 'Get Started')));
startLink.appendChild(document.createTextNode(allowSignup ? 'Sign Up' : 'Get Started'));
mobileAuthSection.appendChild(startLink);
}
}
+102 -137
View File
@@ -1,6 +1,6 @@
{% extends "base.html" %}
{% block title %}{{ _("admin_plans.page_title") }}{% endblock %}
{% block title %}Plan Designer — DocuElevate Admin{% endblock %}
{% block content %}
<main id="main-content" class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"
@@ -10,41 +10,43 @@
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<div>
<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>
<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>
</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="{{ _('admin_plans.btn_stripe_setup_title') }}"
title="Open the Stripe Setup Wizard to configure API keys and sync plans"
>
<i class="fab fa-stripe mr-2" aria-hidden="true"></i> {{ _("admin_plans.btn_stripe_setup") }}
<i class="fab fa-stripe mr-2" aria-hidden="true"></i> 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="{{ _('admin_plans.btn_restore_defaults_title') }}"
title="Restore all four default plans (only if no plans exist yet)"
>
<i class="fas fa-undo mr-2 text-gray-400" aria-hidden="true"></i>
<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>
<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>
</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> {{ _("admin_plans.btn_add_plan") }}
<i class="fas fa-plus mr-2" aria-hidden="true"></i> 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> {{ _("admin_plans.overage_buffer_title") }}</p>
<p class="font-semibold mb-1"><i class="fas fa-info-circle mr-1" aria-hidden="true"></i> About the Overage Buffer</p>
<p>
{{ _("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") }}
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.
</p>
</div>
@@ -61,29 +63,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">{{ _("admin_plans.loading") }}</p>
<p class="mt-2 text-sm">Loading plans</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="{{ _('admin_plans.table_aria_label') }}">
<table class="min-w-full divide-y divide-gray-200" aria-label="Subscription plans">
<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">{{ _("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>
<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>
</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">
{{ _("admin_plans.no_plans_intro") }} <strong>{{ _("admin_plans.btn_restore_defaults") }}</strong> {{ _("admin_plans.no_plans_suffix") }}
No plans yet. Click <strong>Restore Defaults</strong> to seed the four built-in plans.
</td>
</tr>
</template>
@@ -95,13 +97,13 @@
@click="moveUp(idx)"
:disabled="idx === 0"
class="p-1 rounded hover:bg-gray-200 disabled:opacity-30"
:aria-label="i18n.ariaMoveUp.replace('{name}', plan.name)"
:aria-label="'Move ' + plan.name + ' up'"
><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="i18n.ariaMoveDown.replace('{name}', plan.name)"
:aria-label="'Move ' + plan.name + ' down'"
><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>
@@ -113,13 +115,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> <span x-text="i18n.featuredBadge"></span></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> Featured</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 ? i18n.freeLabel : '$' + plan.price_monthly.toFixed(2)"></span>
<span x-text="plan.price_monthly === 0 ? 'Free' : '$' + 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>
@@ -134,7 +136,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 ? i18n.statusActive : i18n.statusInactive"
x-text="plan.is_active ? 'Active' : 'Inactive'"
></span>
</td>
<td class="px-4 py-3 text-right">
@@ -142,16 +144,16 @@
<button
@click="openEditModal(plan)"
class="text-sm text-indigo-600 hover:text-indigo-800 font-medium"
:aria-label="i18n.ariaEditPlan.replace('{name}', plan.name)"
:aria-label="'Edit ' + plan.name"
>
<i class="fas fa-pencil-alt" aria-hidden="true"></i> <span x-text="i18n.btnEdit"></span>
<i class="fas fa-pencil-alt" aria-hidden="true"></i> Edit
</button>
<button
@click="deletePlan(plan.plan_id)"
class="text-sm text-red-500 hover:text-red-700 font-medium"
:aria-label="i18n.ariaDeletePlan.replace('{name}', plan.name)"
:aria-label="'Delete ' + plan.name"
>
<i class="fas fa-trash-alt" aria-hidden="true"></i> <span x-text="i18n.btnDelete"></span>
<i class="fas fa-trash-alt" aria-hidden="true"></i> Delete
</button>
</div>
</td>
@@ -169,8 +171,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">{{ _("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>
<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>
</button>
</div>
@@ -193,8 +195,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 ? 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') }}">
<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">
<i class="fas fa-times" aria-hidden="true"></i>
</button>
</div>
@@ -203,66 +205,66 @@
<!-- Basic Info -->
<div class="px-6 py-5 space-y-4">
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wider">{{ _("admin_plans.section_basic_info") }}</h3>
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wider">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">{{ _("admin_plans.field_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">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">{{ _("admin_plans.field_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">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">{{ _("admin_plans.hint_plan_id") }}</p>
<p class="text-xs text-gray-400 mt-0.5">Lowercase slug, cannot be changed after creation.</p>
</div>
</div>
<div>
<label for="f-tagline" class="block text-sm font-medium text-gray-700 mb-1">{{ _("admin_plans.field_tagline") }}</label>
<label for="f-tagline" class="block text-sm font-medium text-gray-700 mb-1">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">{{ _("admin_plans.field_sort_order") }}</label>
<label for="f-sort-order" class="block text-sm font-medium text-gray-700 mb-1">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">{{ _("admin_plans.field_active") }}</label>
<label for="f-active" class="text-sm font-medium text-gray-700">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">{{ _("admin_plans.field_featured") }}</label>
<label for="f-highlighted" class="text-sm font-medium text-gray-700">Featured / Highlighted</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">{{ _("admin_plans.section_pricing") }}</h3>
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wider">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">{{ _("admin_plans.field_price_monthly") }}</label>
<label for="f-price-monthly" class="block text-sm font-medium text-gray-700 mb-1">Monthly Price ($)</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">{{ _("admin_plans.field_price_yearly") }}</label>
<label for="f-price-yearly" class="block text-sm font-medium text-gray-700 mb-1">Yearly Price ($)</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 ? i18n.yearlySave.replace('{pct}', yearlySavingPct.toFixed(0)) : i18n.yearlyEnter">
x-text="yearlySavingPct > 0 ? 'Save ' + yearlySavingPct.toFixed(0) + '% vs monthly' : 'Enter yearly price to show savings'">
</p>
</div>
<div>
<label for="f-trial-days" class="block text-sm font-medium text-gray-700 mb-1">{{ _("admin_plans.field_trial_days") }}</label>
<label for="f-trial-days" class="block text-sm font-medium text-gray-700 mb-1">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>
@@ -272,21 +274,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">{{ _("admin_plans.section_stripe") }}</h3>
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wider">Stripe Integration</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="{{ _('admin_plans.stripe_wizard_aria') }}"
><i class="fab fa-stripe mr-1" aria-hidden="true"></i>{{ _("admin_plans.stripe_wizard_link") }}</a>
aria-label="Open Stripe Setup Wizard in a new tab"
><i class="fab fa-stripe mr-1" aria-hidden="true"></i>Stripe Wizard</a>
</div>
<p class="text-xs text-gray-500">
{{ _("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") }}
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.
</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">
{{ _("admin_plans.field_stripe_monthly") }}
Stripe Price ID (monthly)
</label>
<input id="f-stripe-monthly" type="text" x-model="form.stripe_price_id_monthly"
placeholder="price_1OtAbc…"
@@ -294,7 +296,7 @@
</div>
<div>
<label for="f-stripe-yearly" class="block text-sm font-medium text-gray-700 mb-1">
{{ _("admin_plans.field_stripe_yearly") }}
Stripe Price ID (yearly)
</label>
<input id="f-stripe-yearly" type="text" x-model="form.stripe_price_id_yearly"
placeholder="price_1OtAbc… (optional)"
@@ -305,36 +307,36 @@
<!-- Volume Limits -->
<div class="px-6 py-5 space-y-4">
<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>
<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>
<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">{{ _("admin_plans.field_docs_month") }}</label>
<label for="f-monthly-limit" class="block text-sm font-medium text-gray-700 mb-1">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">{{ _("admin_plans.field_lifetime_docs") }}</label>
<label for="f-lifetime-limit" class="block text-sm font-medium text-gray-700 mb-1">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">{{ _("admin_plans.field_ocr_pages") }}</label>
<label for="f-ocr-pages" class="block text-sm font-medium text-gray-700 mb-1">OCR Pages / Month</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">{{ _("admin_plans.field_storage_dests") }}</label>
<label for="f-dests" class="block text-sm font-medium text-gray-700 mb-1">Storage Destinations</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">{{ _("admin_plans.field_mailboxes") }}</label>
<label for="f-mailboxes" class="block text-sm font-medium text-gray-700 mb-1">Email 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">{{ _("admin_plans.field_max_file_size") }}</label>
<label for="f-filesize" class="block text-sm font-medium text-gray-700 mb-1">Max File Size (MB)</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>
@@ -343,15 +345,15 @@
<!-- Overage Designer -->
<div class="px-6 py-5 space-y-4">
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wider">{{ _("admin_plans.section_overage") }}</h3>
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wider">Overage Designer</h3>
<div>
<label for="f-overage-pct" class="block text-sm font-medium text-gray-700 mb-2">
{{ _("admin_plans.field_buffer") }}
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">
{{ _("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") }}
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
</span>
</template>
</label>
@@ -366,27 +368,27 @@
aria-valuemax="100"
/>
<div class="flex justify-between text-xs text-gray-400 mt-1">
<span>{{ _("admin_plans.overage_0pct") }}</span>
<span>{{ _("admin_plans.overage_50pct") }}</span>
<span>{{ _("admin_plans.overage_100pct") }}</span>
<span>0% (exact)</span>
<span>50%</span>
<span>100%</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">{{ _("admin_plans.field_allow_overage") }}</label>
<label class="block text-sm font-medium text-gray-700 mb-1">Allow Overage Billing</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">{{ _("admin_plans.coming_soon") }}</span>
<span class="text-xs text-gray-400">Coming soon</span>
</div>
</div>
<div class="relative">
<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') }}"
<label class="block text-sm font-medium text-gray-700 mb-1">Overage price / doc ($)</label>
<input type="number" disabled placeholder="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">{{ _("admin_plans.field_overage_ocr_price") }}</label>
<input type="number" disabled placeholder="{{ _('admin_plans.coming_soon') }}"
<label class="block text-sm font-medium text-gray-700 mb-1">Overage price / OCR page ($)</label>
<input type="number" disabled placeholder="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>
@@ -394,18 +396,18 @@
<!-- Features -->
<div class="px-6 py-5 space-y-3">
<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>
<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>
<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="i18n.ariaFeatureN.replace('{n}', i + 1)"
:aria-label="'Feature ' + (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="i18n.ariaRemoveFeatureN.replace('{n}', i + 1)"
:aria-label="'Remove feature ' + (i+1)"
class="text-red-400 hover:text-red-600 px-2 py-2">
<i class="fas fa-times" aria-hidden="true"></i>
</button>
@@ -413,21 +415,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> {{ _("admin_plans.btn_add_feature") }}
<i class="fas fa-plus mr-1" aria-hidden="true"></i> 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">{{ _("admin_plans.section_display") }}</h3>
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wider">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">{{ _("admin_plans.field_cta_text") }}</label>
<label for="f-cta" class="block text-sm font-medium text-gray-700 mb-1">CTA Button 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">{{ _("admin_plans.field_badge_text") }}</label>
<label for="f-badge" class="block text-sm font-medium text-gray-700 mb-1">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" />
@@ -436,7 +438,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">{{ _("admin_plans.field_api_access") }}</label>
<label for="f-api" class="text-sm font-medium text-gray-700">API Access</label>
</div>
</div>
@@ -447,15 +449,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"
>
{{ _("admin_plans.btn_cancel") }}
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 ? 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>
<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>
</button>
</div>
@@ -465,46 +467,9 @@
</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,
@@ -559,7 +524,7 @@ function planDesigner() {
this.errorMsg = '';
try {
const resp = await fetch('/api/plans/admin');
if (!resp.ok) throw new Error(i18n.jsFailedLoad);
if (!resp.ok) throw new Error('Failed to load plans');
const data = await resp.json();
this.plans = data.plans || [];
} catch (e) {
@@ -603,9 +568,9 @@ function planDesigner() {
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({ detail: resp.statusText }));
throw new Error(err.detail || i18n.jsSaveFailed);
throw new Error(err.detail || 'Save failed');
}
this.successMsg = this.isCreate ? i18n.jsPlanCreated : i18n.jsPlanUpdated;
this.successMsg = this.isCreate ? 'Plan created!' : 'Plan updated!';
this.modalOpen = false;
await this.loadPlans();
} catch (e) {
@@ -616,15 +581,15 @@ function planDesigner() {
},
async deletePlan(planId) {
if (!confirm(i18n.jsDeleteConfirm.replace('{id}', planId))) return;
if (!confirm(`Delete plan "${planId}"? This cannot be undone.`)) 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 || i18n.jsDeleteFailed);
throw new Error(err.detail || 'Delete failed');
}
this.successMsg = i18n.jsPlanDeleted.replace('{id}', planId);
this.successMsg = `Plan "${planId}" deleted.`;
await this.loadPlans();
} catch (e) {
this.errorMsg = e.message;
@@ -632,12 +597,12 @@ function planDesigner() {
},
async seedDefaults() {
if (!confirm(i18n.jsSeedConfirm)) return;
if (!confirm('Seed the four default plans? This is a no-op if plans already exist.')) return;
this.seeding = true;
this.errorMsg = '';
try {
const resp = await fetch('/api/plans/seed', { method: 'POST' });
if (!resp.ok) throw new Error(i18n.jsSeedFailed);
if (!resp.ok) throw new Error('Seed failed');
const data = await resp.json();
this.successMsg = data.message;
await this.loadPlans();
@@ -670,8 +635,8 @@ function planDesigner() {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ order }),
});
if (!resp.ok) throw new Error(i18n.jsReorderFailed);
this.successMsg = i18n.jsOrderSaved;
if (!resp.ok) throw new Error('Reorder failed');
this.successMsg = 'Order saved!';
this.orderDirty = false;
} catch (e) {
this.errorMsg = e.message;
+157 -193
View File
@@ -1,5 +1,5 @@
{% extends "base.html" %}
{% block title %}{{ _("admin_users.page_title") }}{% endblock %}
{% block title %}User Management Admin DocuElevate{% 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>
{{ _("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>
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>
</h1>
<p class="text-gray-500 text-sm mt-1">
{{ _("admin_users.subheading") }}
Manage user profiles, per-user upload limits, and document ownership.
</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> {{ _("admin_users.add_user_profile_btn") }}
<i class="fas fa-user-plus mr-2" aria-hidden="true"></i> Add User Profile
</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> {{ _("admin_users.create_local_account_btn") }}
<i class="fas fa-user-lock mr-2" aria-hidden="true"></i> Create Local Account
</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">{{ _("admin_users.search_users_label") }}</label>
<label for="userSearch" class="sr-only">Search users</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="{{ _('admin_users.filter_placeholder') }}"
placeholder="Filter by user ID…"
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="{{ _('admin_users.heading') }}">
<table class="min-w-full divide-y divide-gray-200" aria-label="User list">
<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">{{ _("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>
<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>
</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> {{ _("admin_users.loading_users") }}
<i class="fas fa-spinner fa-spin mr-2" aria-hidden="true"></i> 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>
{{ _("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>
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>
</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 + ' {{ _("admin_users.per_day") }}'"></span>
x-text="user.daily_upload_limit + ' / day'"></span>
<span x-show="user.daily_upload_limit === null || user.daily_upload_limit === undefined"
class="text-gray-400 text-xs">{{ _("admin_users.global_default") }}</span>
class="text-gray-400 text-xs">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 ? '{{ _("admin_users.status_blocked") }}' : '{{ _("common.active") }}'"></span>
<span x-text="user.is_blocked ? 'Blocked' : '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="`{{ _('common.edit') }} ${user.user_id}`"
:aria-label="`Edit profile for ${user.user_id}`"
>
<i class="fas fa-edit mr-1" aria-hidden="true"></i> {{ _("common.edit") }}
<i class="fas fa-edit mr-1" aria-hidden="true"></i> 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="`{{ _('common.delete') }} ${user.user_id}`"
:aria-label="`Delete profile for ${user.user_id}`"
>
<i class="fas fa-trash mr-1" aria-hidden="true"></i> {{ _("common.delete") }}
<i class="fas fa-trash mr-1" aria-hidden="true"></i> 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> {{ _("common.previous") }}
<i class="fas fa-chevron-left mr-1" aria-hidden="true"></i> Previous
</button>
<span class="text-sm text-gray-500">
{{ _("common.page") }} <span x-text="currentPage"></span> {{ _("admin_users.pagination_page_of") }} <span x-text="pages"></span>
Page <span x-text="currentPage"></span> 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"
>
{{ _("common.next") }} <i class="fas fa-chevron-right ml-1" aria-hidden="true"></i>
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="{{ _('admin_users.modal_close_aria') }}"
aria-label="Close dialog"
>
<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">{{ _("admin_users.modal_user_id_label") }}</label>
<label for="modal-user-id" class="block text-sm font-medium text-gray-700 mb-1">User ID</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="{{ _('admin_users.modal_user_id_placeholder') }}"
placeholder="user@example.com or OAuth sub"
required
aria-required="true"
autocomplete="off"
/>
<p class="text-xs text-gray-400 mt-1">{{ _("admin_users.modal_user_id_hint") }}</p>
<p class="text-xs text-gray-400 mt-1">The stable identifier that matches <code>owner_id</code> in documents.</p>
</div>
<!-- Display name -->
<div>
<label for="modal-display-name" class="block text-sm font-medium text-gray-700 mb-1">{{ _("admin_users.modal_display_name_label") }}</label>
<label for="modal-display-name" class="block text-sm font-medium text-gray-700 mb-1">Display Name</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="{{ _('admin_users.modal_display_name_placeholder') }}"
placeholder="Alice Smith (optional)"
/>
</div>
<!-- Daily upload limit -->
<div>
<label for="modal-limit" class="block text-sm font-medium text-gray-700 mb-1">
{{ _("admin_users.modal_daily_limit_label") }}
<span class="ml-1 text-xs font-normal text-gray-400">{{ _("admin_users.modal_daily_limit_hint") }}</span>
Daily Upload Limit
<span class="ml-1 text-xs font-normal text-gray-400">(leave empty to use global default)</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="{{ _('admin_users.modal_daily_limit_placeholder') }}"
placeholder="e.g. 50 (0 = unlimited)"
/>
</div>
<!-- Notes -->
<div>
<label for="modal-notes" class="block text-sm font-medium text-gray-700 mb-1">{{ _("admin_users.modal_notes_label") }}</label>
<label for="modal-notes" class="block text-sm font-medium text-gray-700 mb-1">Admin Notes</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="{{ _('admin_users.modal_notes_placeholder') }}"
placeholder="Internal notes visible only to admins…"
></textarea>
</div>
@@ -336,50 +336,50 @@
></span>
</button>
<span id="modal-blocked-label" class="text-sm font-medium text-gray-700">
{{ _("admin_users.modal_block_label") }}
<span class="text-xs text-gray-400 font-normal">{{ _("admin_users.modal_block_hint") }}</span>
Block this user
<span class="text-xs text-gray-400 font-normal">(prevents new document uploads)</span>
</span>
</div>
<!-- Subscription tier -->
<div>
<label for="modal-tier" class="block text-sm font-medium text-gray-700 mb-1">
{{ _("admin_users.modal_plan_label") }}
Subscription Plan
</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">{{ _("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>
<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>
</select>
<p class="text-xs text-gray-400 mt-1">
{{ _("admin_users.modal_plan_hint") }}
Sets the quota limits for this user. Limits are enforced on upload.
</p>
</div>
<!-- Billing cycle -->
<div>
<label for="modal-billing-cycle" class="block text-sm font-medium text-gray-700 mb-1">
{{ _("admin_users.modal_billing_cycle_label") }}
Billing Cycle
</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">{{ _("admin_users.modal_billing_monthly") }}</option>
<option value="yearly">{{ _("admin_users.modal_billing_yearly") }}</option>
<option value="monthly">Monthly</option>
<option value="yearly">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">
{{ _("admin_users.modal_period_start_label") }}
Subscription Period Start
</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">
{{ _("admin_users.modal_period_start_hint") }}
Annual carry-over calculates from this date. Leave blank for monthly enforcement.
</p>
</div>
@@ -409,8 +409,8 @@
></span>
</button>
<span id="modal-complimentary-label" class="text-sm font-medium text-gray-700">
{{ _("admin_users.modal_complimentary_label") }}
<span class="text-xs text-gray-400 font-normal">{{ _("admin_users.modal_complimentary_hint") }}</span>
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>
</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"
>
{{ _("common.cancel") }}
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 ? '{{ _('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>
<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>
</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>
{{ _("admin_users.local_accounts_heading") }}
Local User Accounts
</h2>
<p class="text-sm text-gray-500 mt-0.5">
{{ _("admin_users.local_accounts_subheading") }}
Email/password accounts created directly on this server.
</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> {{ _("admin_users.new_account_btn") }}
<i class="fas fa-plus mr-1" aria-hidden="true"></i> New Account
</button>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200" aria-label="{{ _('admin_users.local_accounts_heading') }}">
<table class="min-w-full divide-y divide-gray-200" aria-label="Local user accounts">
<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">{{ _("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>
<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>
</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> {{ _("admin_users.local_loading") }}
<i class="fas fa-spinner fa-spin mr-2" aria-hidden="true"></i> 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">
{{ _("admin_users.local_no_accounts") }}
<button type="button" @click="openCreateLocalUserModal()" class="text-green-600 hover:underline ml-1">{{ _("admin_users.local_create_one") }}</button>
No local accounts yet.
<button type="button" @click="openCreateLocalUserModal()" class="text-green-600 hover:underline ml-1">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 ? '{{ _("common.active") }}' : '{{ _("admin_users.status_unverified") }}'"
x-text="lu.is_active ? 'Active' : '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_users.role_admin") }}' : '{{ _("admin_users.role_user") }}'"
x-text="lu.is_admin ? 'Admin' : '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="`{{ _('common.edit') }} ${lu.username}`"
:aria-label="`Edit account for ${lu.username}`"
>
<i class="fas fa-edit mr-1" aria-hidden="true"></i> {{ _("common.edit") }}
<i class="fas fa-edit mr-1" aria-hidden="true"></i> 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="`{{ _('admin_users.btn_password') }} ${lu.username}`"
:aria-label="`Set password for ${lu.username}`"
>
<i class="fas fa-key mr-1" aria-hidden="true"></i> {{ _("admin_users.btn_password") }}
<i class="fas fa-key mr-1" aria-hidden="true"></i> 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="`{{ _('admin_users.btn_reset') }} ${lu.username}`"
title="{{ _('admin_users.btn_reset') }}"
:aria-label="`Send password reset email to ${lu.username}`"
title="Send password reset email"
>
<i class="fas fa-envelope mr-1" aria-hidden="true"></i> {{ _("admin_users.btn_reset") }}
<i class="fas fa-envelope mr-1" aria-hidden="true"></i> 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="`{{ _('common.delete') }} ${lu.username}`"
:aria-label="`Delete account for ${lu.username}`"
>
<i class="fas fa-trash mr-1" aria-hidden="true"></i> {{ _("common.delete") }}
<i class="fas fa-trash mr-1" aria-hidden="true"></i> 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">{{ _("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">
<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">
<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">{{ _("admin_users.col_email") }} <span aria-hidden="true" class="text-red-500">*</span></label>
<label for="lu-email" class="block text-sm font-medium text-gray-700">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">{{ _("admin_users.col_username") }} <span aria-hidden="true" class="text-red-500">*</span></label>
<label for="lu-username" class="block text-sm font-medium text-gray-700">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">{{ _("admin_users.local_username_hint") }}</p>
<p id="lu-username-hint" class="mt-1 text-xs text-gray-500">364 characters. Letters, numbers, hyphens and underscores only.</p>
</div>
<div>
<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>
<label for="lu-display-name" class="block text-sm font-medium text-gray-700">Display Name <span class="text-gray-400">(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">{{ _("auth.password_label") }} <span aria-hidden="true" class="text-red-500">*</span></label>
<label for="lu-password" class="block text-sm font-medium text-gray-700">Password <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">{{ _("admin_users.local_password_hint") }}</p>
<p id="lu-password-hint" class="mt-1 text-xs text-gray-500">Minimum 8 characters.</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">{{ _("admin_users.local_admin_privileges") }}</label>
<label for="lu-is-admin" class="text-sm text-gray-700">Grant 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">
{{ _("common.cancel") }}
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">{{ _("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>
<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>
</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">{{ _("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">
<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">
<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">{{ _("admin_users.col_email") }} <span aria-hidden="true" class="text-red-500">*</span></label>
<label for="elu-email" class="block text-sm font-medium text-gray-700">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">{{ _("admin_users.col_display_name") }} <span class="text-gray-400">{{ _("admin_users.local_display_name_optional") }}</span></label>
<label for="elu-display-name" class="block text-sm font-medium text-gray-700">Display Name <span class="text-gray-400">(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_users.local_admin_privileges_short") }}</label>
<label for="elu-is-admin" class="text-sm text-gray-700">Admin privileges</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">{{ _("admin_users.local_account_active") }}</label>
<label for="elu-is-active" class="text-sm text-gray-700">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">
{{ _("common.cancel") }}
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">{{ _("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>
<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>
</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">{{ _("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">
<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">
<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">
{{ _("admin_users.set_password_desc_pre") }} <strong class="font-mono" x-text="setPasswordModal.username"></strong>.
{{ _("admin_users.set_password_desc") }}
Set a new password directly for <strong class="font-mono" x-text="setPasswordModal.username"></strong>.
The user should change this password after logging in.
</p>
<div>
<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>
<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>
<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">{{ _("admin_users.local_password_hint") }}</p>
<p id="sp-password-hint" class="mt-1 text-xs text-gray-500">Minimum 8 characters.</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">
{{ _("common.cancel") }}
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">{{ _("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>
<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>
</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">{{ _("admin_users.delete_local_title") }}</h2>
<h2 id="delete-local-user-title" class="text-lg font-semibold text-gray-900">Delete Local Account</h2>
</div>
<div class="px-6 py-5">
<p class="text-sm text-gray-700">
{{ _("admin_users.delete_local_confirm") }}
Are you sure you want to delete the account for
<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">{{ _("admin_users.delete_local_warning") }}</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>
</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">
{{ _("common.cancel") }}
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">{{ _("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>
<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>
</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">{{ _("admin_users.delete_profile_title") }}</h2>
<h2 id="delete-modal-title" class="text-lg font-semibold text-gray-900">Delete User Profile</h2>
</div>
<div class="px-6 py-5">
<p class="text-sm text-gray-700">
{{ _("admin_users.delete_profile_confirm") }}
Are you sure you want to delete the profile for
<strong class="font-mono" x-text="deleteModal.user_id"></strong>?
</p>
<p class="text-sm text-gray-500 mt-2">
{{ _("admin_users.delete_profile_warning") }}
This only removes the admin-managed profile record. Documents owned by this user are <strong>not</strong> deleted.
</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"
>
{{ _("common.cancel") }}
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">{{ _("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>
<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>
</button>
</div>
</div>
@@ -814,43 +814,8 @@
</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: [],
@@ -922,9 +887,8 @@ function adminUsersApp() {
get totalLabel() {
if (this.loading) return '';
if (this.total === 0) return i18n.noUsers;
if (this.total === 1) return i18n.oneUser;
return i18n.countUsers.replace('{count}', this.total);
if (this.total === 0) return 'No users';
return `${this.total} user${this.total !== 1 ? 's' : ''}`;
},
async init() {
@@ -944,7 +908,7 @@ function adminUsersApp() {
const resp = await fetch(`/api/admin/users/?${params}`);
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
this.showAlert('error', i18n.failedLoadUsers, err.detail || resp.statusText);
this.showAlert('error', 'Failed to load users', err.detail || resp.statusText);
return;
}
const data = await resp.json();
@@ -952,7 +916,7 @@ function adminUsersApp() {
this.total = data.total;
this.pages = data.pages;
} catch (e) {
this.showAlert('error', i18n.networkError, e.message);
this.showAlert('error', 'Network error', e.message);
} finally {
this.loading = false;
}
@@ -960,14 +924,14 @@ function adminUsersApp() {
openCreateModal() {
this.isCreate = true;
this.modalTitle = i18n.modalAddTitle;
this.modalTitle = 'Add User Profile';
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 = i18n.modalEditTitle;
this.modalTitle = 'Edit User Profile';
this.form = {
user_id: user.user_id,
display_name: user.display_name || '',
@@ -1006,14 +970,14 @@ function adminUsersApp() {
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
this.showAlert('error', i18n.saveFailed, err.detail || resp.statusText);
this.showAlert('error', 'Save failed', err.detail || resp.statusText);
return;
}
this.modalOpen = false;
this.showAlert('success', i18n.saved, i18n.profileSaved.replace('{id}', this.form.user_id));
this.showAlert('success', 'Saved', `Profile for "${this.form.user_id}" has been saved.`);
await this.fetchUsers(this.currentPage);
} catch (e) {
this.showAlert('error', i18n.networkError, e.message);
this.showAlert('error', 'Network error', e.message);
} finally {
this.saving = false;
}
@@ -1037,15 +1001,15 @@ function adminUsersApp() {
});
if (resp.status === 204) {
this.deleteModal.open = false;
this.showAlert('success', i18n.deleted, i18n.profileDeleted.replace('{id}', this.deleteModal.user_id));
this.showAlert('success', 'Deleted', `Profile for "${this.deleteModal.user_id}" has been removed.`);
await this.fetchUsers(this.currentPage);
} else {
const err = await resp.json().catch(() => ({}));
this.showAlert('error', i18n.deleteFailed, err.detail || resp.statusText);
this.showAlert('error', 'Delete failed', err.detail || resp.statusText);
this.deleteModal.open = false;
}
} catch (e) {
this.showAlert('error', i18n.networkError, e.message);
this.showAlert('error', 'Network error', e.message);
this.deleteModal.open = false;
} finally {
this.deleteModal.deleting = false;
@@ -1080,12 +1044,12 @@ function adminUsersApp() {
headers: { 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || '' },
});
if (!resp.ok) {
this.showAlert('error', i18n.failedLoadLocal, resp.statusText);
this.showAlert('error', 'Failed to load local users', resp.statusText);
return;
}
this.localUsers = await resp.json();
} catch (e) {
this.showAlert('error', i18n.networkError, e.message);
this.showAlert('error', 'Network error', e.message);
} finally {
this.localUsersLoading = false;
}
@@ -1112,14 +1076,14 @@ function adminUsersApp() {
});
if (resp.ok) {
this.localUserModal.open = false;
this.showAlert('success', i18n.accountCreated, i18n.accountCreatedMsg.replace('{username}', this.localUserModal.form.username));
this.showAlert('success', 'Account created', `Local account for "${this.localUserModal.form.username}" was created successfully.`);
await this.fetchLocalUsers();
} else {
const err = await resp.json().catch(() => ({}));
this.localUserModal.error = err.detail || i18n.failedCreate;
this.localUserModal.error = err.detail || 'Failed to create account.';
}
} catch (e) {
this.localUserModal.error = i18n.networkError + ': ' + e.message;
this.localUserModal.error = 'Network error: ' + e.message;
} finally {
this.localUserModal.saving = false;
}
@@ -1165,14 +1129,14 @@ function adminUsersApp() {
});
if (resp.ok) {
this.editLocalUserModal.open = false;
this.showAlert('success', i18n.updated, i18n.accountUpdated);
this.showAlert('success', 'Updated', `Account has been updated.`);
await this.fetchLocalUsers();
} else {
const err = await resp.json().catch(() => ({}));
this.editLocalUserModal.error = err.detail || i18n.failedUpdate;
this.editLocalUserModal.error = err.detail || 'Failed to update account.';
}
} catch (e) {
this.editLocalUserModal.error = i18n.networkError + ': ' + e.message;
this.editLocalUserModal.error = 'Network error: ' + e.message;
} finally {
this.editLocalUserModal.saving = false;
}
@@ -1201,13 +1165,13 @@ function adminUsersApp() {
});
if (resp.ok) {
this.setPasswordModal.open = false;
this.showAlert('success', i18n.passwordSet, i18n.passwordSetMsg.replace('{username}', this.setPasswordModal.username));
this.showAlert('success', 'Password set', `Password for "${this.setPasswordModal.username}" has been updated.`);
} else {
const err = await resp.json().catch(() => ({}));
this.setPasswordModal.error = err.detail || i18n.failedSetPassword;
this.setPasswordModal.error = err.detail || 'Failed to set password.';
}
} catch (e) {
this.setPasswordModal.error = i18n.networkError + ': ' + e.message;
this.setPasswordModal.error = 'Network error: ' + e.message;
} finally {
this.setPasswordModal.saving = false;
}
@@ -1223,14 +1187,14 @@ function adminUsersApp() {
});
const data = await resp.json().catch(() => ({}));
if (resp.ok && data.sent) {
this.showAlert('success', i18n.emailSent, i18n.emailSentMsg.replace('{email}', lu.email));
this.showAlert('success', 'Email sent', `Password reset email sent to "${lu.email}".`);
} else if (resp.ok && !data.sent) {
this.showAlert('error', i18n.emailNotSent, data.reason || i18n.smtpNotConfigured);
this.showAlert('error', 'Email not sent', data.reason || 'SMTP is not configured.');
} else {
this.showAlert('error', i18n.failed, data.detail || resp.statusText);
this.showAlert('error', 'Failed', data.detail || resp.statusText);
}
} catch (e) {
this.showAlert('error', i18n.networkError, e.message);
this.showAlert('error', 'Network error', e.message);
}
},
@@ -1243,15 +1207,15 @@ function adminUsersApp() {
});
if (resp.status === 204) {
this.deleteLocalUserModal.open = false;
this.showAlert('success', i18n.deleted, i18n.accountDeletedMsg.replace('{username}', this.deleteLocalUserModal.username));
this.showAlert('success', 'Deleted', `Account for "${this.deleteLocalUserModal.username}" has been removed.`);
await this.fetchLocalUsers();
} else {
const err = await resp.json().catch(() => ({}));
this.showAlert('error', i18n.deleteFailed, err.detail || resp.statusText);
this.showAlert('error', 'Delete failed', err.detail || resp.statusText);
this.deleteLocalUserModal.open = false;
}
} catch (e) {
this.showAlert('error', i18n.networkError, e.message);
this.showAlert('error', 'Network error', e.message);
this.deleteLocalUserModal.open = false;
} finally {
this.deleteLocalUserModal.deleting = false;
+14 -14
View File
@@ -1,40 +1,40 @@
{% extends "base.html" %}
{% block title %}{{ _("attribution.page_title") }}{% endblock %}
{% block title %}DocuElevate - Third-Party Attributions{% endblock %}
{% block content %}
<div class="container mx-auto px-4 py-8">
<h1 class="text-2xl font-bold mb-4">{{ _("attribution.heading") }}</h1>
<h1 class="text-2xl font-bold mb-4">Third-Party Software Attributions</h1>
<div class="bg-white shadow-md rounded-lg p-6 mb-6">
<p class="mb-4">
{{ _("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.
</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" aria-hidden="true">
<svg class="h-5 w-5 text-yellow-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<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>{{ _("attribution.special_title") }}</strong>
{{ _("attribution.special_source_pre") }}
<strong>Special Attribution:</strong> This software includes Paramiko, which is licensed under LGPL. The source code for Paramiko is available at
<a href="https://github.com/paramiko/paramiko" class="font-medium underline text-yellow-700 hover:text-yellow-600">
https://github.com/paramiko/paramiko
</a>{{ _("attribution.special_source_post") }}
{{ _("attribution.special_lgpl_pre") }}
</a>.
A copy of the LGPL license can be found
<a href="/static/licenses/lgpl.txt" class="font-medium underline text-yellow-700 hover:text-yellow-600">
{{ _("attribution.special_lgpl_link") }}
</a>{{ _("attribution.special_lgpl_post") }}
here
</a>.
</p>
</div>
</div>
</div>
<h2 class="text-xl font-semibold mb-2">{{ _("attribution.section_python") }}</h2>
<h2 class="text-xl font-semibold mb-2">Python Dependencies</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">{{ _("attribution.paramiko_lgpl_note") }}</span>
<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>
</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">{{ _("attribution.section_docker") }}</h2>
<h2 class="text-xl font-semibold mb-2">Docker Images</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">{{ _("attribution.section_frontend") }}</h2>
<h2 class="text-xl font-semibold mb-2">Frontend Dependencies</h2>
<ul class="list-disc pl-5 mb-4">
<li class="mb-2">
<span class="font-semibold">Tailwind CSS</span> (MIT License)<br>
+20 -84
View File
@@ -216,31 +216,11 @@
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,
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 = ''">
<div x-data="{ langOpen: false }" class="relative">
<button
@click="langOpen = !langOpen; if (langOpen) $nextTick(() => { if ($refs.langSearch) $refs.langSearch.focus(); })"
@click.outside="langOpen = false; search = ''"
@click="langOpen = !langOpen"
@click.outside="langOpen = false"
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') }}"
@@ -248,10 +228,7 @@
:aria-expanded="langOpen"
aria-haspopup="true"
>
<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>
<i class="fas fa-globe"></i>
</button>
<div
x-show="langOpen"
@@ -261,49 +238,25 @@
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-56 bg-white dark:bg-gray-800 rounded-md shadow-lg ring-1 ring-black ring-opacity-5 z-50"
class="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg ring-1 ring-black ring-opacity-5 z-50"
role="menu"
aria-label="{{ _('language.selector') }}"
>
<!-- 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 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 %}
</div>
</div>
</div>
@@ -548,23 +501,6 @@
})();
</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>
+29 -30
View File
@@ -1,58 +1,58 @@
{% extends "base.html" %}
{% block title %}{{ _("cookie_policy.page_title") }}{% endblock %}
{% block title %}Cookie Policy - DocuElevate{% 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.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>
<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>
<div class="bg-white shadow rounded-lg p-6 mb-6">
<h2 class="text-2xl font-semibold mb-4">{{ _("cookie_policy.s1_heading") }}</h2>
<h2 class="text-2xl font-semibold mb-4">What Are Cookies</h2>
<p class="text-gray-700 mb-3">
{{ _("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.
</p>
</div>
<div class="bg-white shadow rounded-lg p-6 mb-6">
<h2 class="text-2xl font-semibold mb-4">{{ _("cookie_policy.s2_heading") }}</h2>
<h2 class="text-2xl font-semibold mb-4">How We Use Cookies</h2>
<p class="text-gray-700 mb-3">
{{ _("cookie_policy.s2_p1_pre") }} <strong>{{ _("cookie_policy.s2_p1_strong") }}</strong> {{ _("cookie_policy.s2_p1_post") }}
DocuElevate uses <strong>only strictly necessary session cookies</strong> for the following purpose:
</p>
<ul class="list-disc list-inside text-gray-700 ml-4 mb-3">
<li><strong>{{ _("cookie_policy.s2_li1_label") }}</strong> {{ _("cookie_policy.s2_li1_body") }}</li>
<li><strong>Authentication &amp; Session Management:</strong> To identify you when you sign in and maintain your session while you use the application.</li>
</ul>
<p class="text-gray-700 mb-3">
{{ _("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.
</p>
<p class="text-gray-700">
{{ _("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.
</p>
</div>
<div class="bg-white shadow rounded-lg p-6 mb-6">
<h2 class="text-2xl font-semibold mb-4">{{ _("cookie_policy.s3_heading") }}</h2>
<h2 class="text-2xl font-semibold mb-4">Cookie Details</h2>
<div class="overflow-x-auto">
<table class="min-w-full text-sm text-gray-700 border border-gray-200 rounded" aria-label="{{ _('cookie_policy.s3_heading') }}">
<table class="min-w-full text-sm text-gray-700 border border-gray-200 rounded">
<thead class="bg-gray-50">
<tr>
<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>
<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>
</tr>
</thead>
<tbody>
<tr class="border-b">
<td class="px-4 py-2 font-mono">session</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>
<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>
</tr>
<tr>
<td class="px-4 py-2 font-mono">cookieNoticeDismissed</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>
<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>
</tr>
</tbody>
</table>
@@ -60,26 +60,25 @@
</div>
<div class="bg-white shadow rounded-lg p-6 mb-6">
<h2 class="text-2xl font-semibold mb-4">{{ _("cookie_policy.s4_heading") }}</h2>
<h2 class="text-2xl font-semibold mb-4">No Third-Party Cookies</h2>
<p class="text-gray-700 mb-3">
{{ _("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.
</p>
<p class="text-gray-700">
{{ _("cookie_policy.s4_p2_pre") }} <a href="/privacy" class="text-blue-600 hover:underline">{{ _("cookie_policy.s4_privacy_link") }}</a>.
For more information about how we handle your data, please see our <a href="/privacy" class="text-blue-600 hover:underline">Privacy Notice</a>.
</p>
</div>
<div class="bg-white shadow rounded-lg p-6 mb-6">
<h2 class="text-2xl font-semibold mb-4">{{ _("cookie_policy.s5_heading") }}</h2>
<h2 class="text-2xl font-semibold mb-4">Managing Cookies</h2>
<p class="text-gray-700 mb-3">
{{ _("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.
</p>
<p class="text-gray-700 mb-3">
{{ _("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 &rarr; Local Storage).
</p>
<p class="text-gray-700">
{{ _("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>.
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>.
</p>
</div>
</div>
+65 -64
View File
@@ -1,5 +1,5 @@
{% extends "base.html" %}
{% block title %}{{ _("admin_files.page_title") }}{% endblock %}
{% block title %}File Manager Admin{% 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" 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>
<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>
</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" aria-hidden="true"></i>
{% if total_disk is none %}…{% else %}{{ total_disk }}{% endif %} {{ _("admin_files.badge_on_disk") }}
<i class="fas fa-hdd text-xs"></i>
{% if total_disk is none %}…{% else %}{{ total_disk }}{% endif %} 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" aria-hidden="true"></i> {{ total_db }} {{ _("admin_files.badge_in_db") }}
<i class="fas fa-database text-xs"></i> {{ total_db }} 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" aria-hidden="true"></i> {{ _("admin_files.badge_delta_detected") }}
<i class="fas fa-triangle-exclamation text-xs"></i> 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" aria-hidden="true"></i>{{ _("admin_files.tab_filesystem") }}
<i class="fas fa-folder-tree mr-1.5"></i>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" aria-hidden="true"></i>{{ _("admin_files.tab_database") }}
<i class="fas fa-database mr-1.5"></i>Database Records
</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" aria-hidden="true"></i>{{ _("admin_files.tab_reconcile") }}
<i class="fas fa-code-compare mr-1.5"></i>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> {{ _("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>
<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>
</div>
<!-- Breadcrumb -->
<nav class="flex mb-4 text-sm" aria-label="{{ _("admin_files.aria_breadcrumb") }}">
<nav class="flex mb-4 text-sm" aria-label="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" aria-hidden="true"></i> {{ _("admin_files.breadcrumb_workdir") }}
<i class="fas fa-home"></i> 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" aria-hidden="true"></i>
<i class="fas fa-chevron-right text-gray-300 mx-1 text-xs"></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">{{ _("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>
<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>
</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" aria-hidden="true"></i></td>
<td class="px-4 py-3 text-yellow-400"><i class="fas fa-folder"></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" aria-hidden="true"></i> ..
<i class="fas fa-level-up-alt text-xs"></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 }}" aria-hidden="true"></i></td>
<td class="px-4 py-3"><i class="{{ entry.icon }}"></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> {{ _("admin_files.status_in_db") }}
<span class="h-1.5 w-1.5 rounded-full bg-green-500"></span> 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> {{ _("admin_files.status_orphan") }}
<span class="h-1.5 w-1.5 rounded-full bg-amber-500"></span> 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" aria-hidden="true"></i> {{ _("admin_files.btn_download") }}
<i class="fas fa-download text-xs"></i> 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" aria-hidden="true"></i>
{{ _("admin_files.empty_directory") }}
<i class="fas fa-folder-open text-gray-200 text-3xl mb-2 block"></i>
This directory is empty.
</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> {{ _("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>
<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>
</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">{{ _("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>
<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>
</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">{{ _("admin_files.badge_duplicate") }}</span>
<span class="text-xs text-purple-600 bg-purple-50 border border-purple-200 rounded px-1 mr-1">dup</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" aria-hidden="true"></i> {{ _("admin_files.health_ok") }}
<i class="fas fa-check text-xs"></i> 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" aria-hidden="true"></i> {{ _("admin_files.health_missing") }}
<i class="fas fa-triangle-exclamation text-xs"></i> 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" aria-hidden="true"></i>
{{ _("admin_files.empty_database") }}
<i class="fas fa-database text-4xl mb-3 block text-gray-200"></i>
No file records found in the database.
</div>
{% endif %}
{% endif %}
@@ -254,16 +254,17 @@
{% 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" aria-hidden="true"></i>
<i class="fas fa-triangle-exclamation text-amber-500 mt-0.5"></i>
<div class="text-sm text-amber-800">
<strong>{{ _("admin_files.delta_detected_title") }}</strong>
{{ _("admin_files.delta_detected_detail", orphan_count=(orphan_files | length), ghost_count=(ghost_records | length)) }}
<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.
</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" aria-hidden="true"></i>
<span class="text-sm text-green-800 font-medium">{{ _("admin_files.no_delta") }}</span>
<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>
</div>
{% endif %}
@@ -271,7 +272,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>
{{ _("admin_files.orphan_files_heading") }} <span class="text-gray-400 font-normal text-sm ml-1">{{ _("admin_files.orphan_files_desc") }}</span>
Orphan files <span class="text-gray-400 font-normal text-sm ml-1">(on disk, no DB record)</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 %}
@@ -280,23 +281,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">{{ _("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>
<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>
</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 }}" aria-hidden="true"></i></td>
<td class="px-4 py-3"><i class="{{ f.icon }}"></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" aria-hidden="true"></i> {{ _("admin_files.btn_download") }}
<i class="fas fa-download text-xs"></i> Download
</a>
</td>
</tr>
@@ -305,7 +306,7 @@
</table>
</div>
{% else %}
<p class="text-sm text-gray-400 italic">{{ _("admin_files.no_orphan_files") }}</p>
<p class="text-sm text-gray-400 italic">No orphan files found.</p>
{% endif %}
</div>
@@ -313,7 +314,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>
{{ _("admin_files.ghost_records_heading") }} <span class="text-gray-400 font-normal text-sm ml-1">{{ _("admin_files.ghost_records_desc") }}</span>
Ghost records <span class="text-gray-400 font-normal text-sm ml-1">(in DB, file(s) missing on disk)</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 %}
@@ -321,10 +322,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">{{ _("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>
<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>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@@ -351,7 +352,7 @@
</table>
</div>
{% else %}
<p class="text-sm text-gray-400 italic">{{ _("admin_files.no_ghost_records") }}</p>
<p class="text-sm text-gray-400 italic">No ghost records found.</p>
{% endif %}
</div>
{% endif %}
+21 -21
View File
@@ -1,13 +1,13 @@
{% extends "base.html" %}
{% block title %}{{ _("imprint.page_title") }}{% endblock %}
{% block title %}Imprint - DocuElevate{% endblock %}
{% block content %}
<div class="container mx-auto px-4 py-8 max-w-4xl">
<h1 class="text-4xl font-bold mb-6">{{ _("imprint.heading") }}</h1>
<p class="text-sm text-gray-500 mb-8">{{ _("imprint.subtitle") }}</p>
<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>
<div class="bg-white shadow rounded-lg p-6 mb-6">
<h2 class="text-2xl font-semibold mb-4">{{ _("imprint.provider_heading") }}</h2>
<h2 class="text-2xl font-semibold mb-4">Service Provider</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">{{ _("imprint.contact_heading") }}</h2>
<h2 class="text-2xl font-semibold mb-4">Contact Information</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">{{ _("imprint.business_registration_heading") }}</h2>
<h2 class="text-2xl font-semibold mb-4">Business Registration</h2>
<p class="text-gray-700 mb-3">
{{ _("imprint.business_registration_vat") }} DE202899017
VAT Identification Number according to §27a Value Added Tax Act: DE202899017
</p>
</div>
<div class="bg-white shadow rounded-lg p-6 mb-6">
<h2 class="text-2xl font-semibold mb-4">{{ _("imprint.responsible_content_heading") }}</h2>
<h2 class="text-2xl font-semibold mb-4">Responsible for Content</h2>
<p class="text-gray-700 mb-3">
{{ _("imprint.responsible_content_rstv") }}<br>
According to § 55 Abs. 2 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">{{ _("imprint.legal_heading") }}</h2>
<h2 class="text-2xl font-semibold mb-4">Legal Notices</h2>
<p class="text-gray-700 mb-3">
{{ _("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.
</p>
<p class="text-gray-700 mb-3">
{{ _("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.
</p>
</div>
<div class="bg-white shadow rounded-lg p-6 mb-6">
<h2 class="text-2xl font-semibold mb-4">{{ _("imprint.dispute_heading") }}</h2>
<h2 class="text-2xl font-semibold mb-4">Online Dispute Resolution</h2>
<p class="text-gray-700 mb-3">
{{ _("imprint.dispute_p1") }}
The European Commission provides a platform for online dispute resolution (OS):
<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">
{{ _("imprint.dispute_p2") }}
We are not willing or obligated to participate in dispute resolution proceedings before a consumer arbitration board.
</p>
</div>
<div class="bg-white shadow rounded-lg p-6">
<h2 class="text-2xl font-semibold mb-4">{{ _("imprint.policies_heading") }}</h2>
<h2 class="text-2xl font-semibold mb-4">Related Policies</h2>
<p class="text-gray-700 mb-3">
{{ _("imprint.policies_intro") }}
Our service is governed by the following policies:
</p>
<ul class="list-disc list-inside text-gray-700 ml-4 mb-3">
<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>
<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>
</ul>
</div>
</div>
+125 -117
View File
@@ -1,5 +1,5 @@
{% extends "base.html" %}
{% block title %}{{ _("integrations.page_title") }} DocuElevate{% endblock %}
{% block title %}Integrations 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.page_title") }}
Integrations
</h1>
<p class="text-gray-500 dark:text-gray-400 text-sm mt-1">
{{ _("integrations.subtitle") }}
Manage your ingestion sources and storage destinations in one place.
</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> {{ _("integrations.add_button") }}
<i class="fas fa-plus mr-2" aria-hidden="true"></i> Add Integration
</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">
{{ _("integrations.sources_quota_label") }}
Mailbox Sources:
<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"> {{ _("integrations.quota_unlimited") }}</span>
<span class="text-gray-400"> / unlimited</span>
</template>
<template x-if="quota.max_sources === 0">
<span class="text-gray-400 ml-1">{{ _("integrations.quota_not_available") }}</span>
<span class="text-gray-400 ml-1">(not available)</span>
</template>
</p>
<p class="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
{{ _("integrations.plan_label") }} <span x-text="quota.tier_name"></span>
Plan: <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> {{ _("integrations.upgrade_plan") }}
<i class="fas fa-arrow-up mr-1" aria-hidden="true"></i> 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">
{{ _("integrations.destinations_quota_label") }}
Storage Destinations:
<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"> {{ _("integrations.quota_unlimited") }}</span>
<span class="text-gray-400"> / unlimited</span>
</template>
</p>
<p class="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
{{ _("integrations.plan_label") }} <span x-text="quota.tier_name"></span>
Plan: <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> {{ _("integrations.upgrade_plan") }}
<i class="fas fa-arrow-up mr-1" aria-hidden="true"></i> 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>{{ _("integrations.loading") }}</p>
<p>Loading integrations</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">{{ _("integrations.empty_state") }}</h2>
<h2 class="text-xl font-semibold text-gray-700 dark:text-gray-300 mb-2">No integrations configured</h2>
<p class="text-gray-500 dark:text-gray-400 mb-6 max-w-md mx-auto">
{{ _("integrations.no_integrations_body") }}
Add your first integration to connect ingestion sources (like IMAP) and storage destinations (like S3, Dropbox, or Google Drive).
</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> {{ _("integrations.add_first_button") }}
<i class="fas fa-plus mr-2" aria-hidden="true"></i> Add Your First Integration
</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> {{ _("integrations.sources_section") }}
<i class="fas fa-inbox text-blue-500" aria-hidden="true"></i> Sources
</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 ? '{{ _('common.active') }}' : '{{ _('integrations.paused') }}'"
x-text="intg.is_active ? 'Active' : '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 ? '{{ _('integrations.authorize_reauth') }}' : '{{ _('integrations.authorize_btn') }}'"></span>
<span x-text="intg.has_credentials ? 'Re-Authorize' : 'Authorize'"></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>
{{ _("common.test") }}
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>{{ _("common.edit") }}
<i class="fas fa-edit mr-1" aria-hidden="true"></i>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>{{ _("common.delete") }}
<i class="fas fa-trash-alt mr-1" aria-hidden="true"></i>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> {{ _("integrations.destinations_section") }}
<i class="fas fa-hdd text-green-500" aria-hidden="true"></i> Destinations
</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 ? '{{ _('common.active') }}' : '{{ _('integrations.paused') }}'"
x-text="intg.is_active ? 'Active' : '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 ? '{{ _('integrations.authorize_reauth') }}' : '{{ _('integrations.authorize_btn') }}'"></span>
<span x-text="intg.has_credentials ? 'Re-Authorize' : 'Authorize'"></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>
{{ _("common.test") }}
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>{{ _("common.edit") }}
<i class="fas fa-edit mr-1" aria-hidden="true"></i>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>{{ _("common.delete") }}
<i class="fas fa-trash-alt mr-1" aria-hidden="true"></i>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 ? '{{ _('integrations.modal_title_edit') }}' : '{{ _('integrations.modal_title_add') }}'"
x-text="editingIntegration ? 'Edit Integration' : 'Add Integration'"
></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="{{ _('integrations.modal_close') }}"
aria-label="Close modal"
>
<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">
{{ _("integrations.direction_label") }} <span class="text-red-500" aria-hidden="true">*</span>
Direction <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="">{{ _("integrations.direction_placeholder") }}</option>
<option value="SOURCE">{{ _("integrations.direction_source") }}</option>
<option value="DESTINATION">{{ _("integrations.direction_destination") }}</option>
<option value="">— Select —</option>
<option value="SOURCE">Source (ingestion)</option>
<option value="DESTINATION">Destination (storage)</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">
{{ _("integrations.type_label") }} <span class="text-red-500" aria-hidden="true">*</span>
Integration Type <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="">{{ _("integrations.type_placeholder") }}</option>
<option value="">— Select direction first —</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">
{{ _("integrations.name_label") }} <span class="text-red-500" aria-hidden="true">*</span>
Label <span class="text-red-500" aria-hidden="true">*</span>
</label>
<input
id="intg-name"
type="text"
x-model="form.name"
placeholder="{{ _('integrations.name_placeholder') }}"
placeholder="e.g. Work Gmail, S3 Archive"
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">{{ _("integrations.imap_settings") }}</p>
<p class="text-xs font-semibold text-gray-400 uppercase tracking-wider">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">{{ _("integrations.host_label") }} <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">Host <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">{{ _("integrations.port_label") }}</label>
<label for="imap-port" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Port</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">{{ _("integrations.username_label") }} <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">Username <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">{{ _("integrations.password_label") }} <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">Password <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" />
{{ _("integrations.use_ssl") }}
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" />
{{ _("integrations.delete_emails_label") }}
Delete emails after processing
</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" />
{{ _("integrations.gmail_labels") }}
Apply Gmail labels &amp; star
</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" />
{{ _("integrations.show_password") }}
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>
{{ _("integrations.gmail_hint") }}
Gmail labels &amp; star: when enabled, processed emails are starred and tagged with an "Ingested" label in Gmail. Only applies to Gmail servers.
</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">{{ _("integrations.s3_settings") }}</p>
<p class="text-xs font-semibold text-gray-400 uppercase tracking-wider">S3 Settings</p>
<div>
<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>
<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>
<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">{{ _("integrations.region_label") }}</label>
<label for="s3-region" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Region</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">{{ _("integrations.folder_prefix_label") }}</label>
<label for="s3-prefix" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Folder Prefix</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">{{ _("integrations.endpoint_label") }} <span class="text-gray-400">{{ _("integrations.endpoint_optional") }}</span></label>
<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>
<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">{{ _("integrations.access_key_label") }} <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">Access Key ID <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">{{ _("integrations.secret_key_label") }} <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">Secret Access Key <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" />
{{ _("integrations.show_secrets") }}
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">{{ _("integrations.webdav_url_label") }} <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">URL <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">{{ _("integrations.folder_path_label") }}</label>
<label for="webdav-folder" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Folder Path</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">{{ _("integrations.username_label") }} <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">Username <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">{{ _("integrations.password_label") }} <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">Password <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" />
{{ _("integrations.show_password") }}
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">{{ _("integrations.host_label") }} <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">Host <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">{{ _("integrations.port_label") }}</label>
<label for="ftp-port" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Port</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">{{ _("integrations.remote_path_label") }}</label>
<label for="ftp-folder" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Remote Path</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">{{ _("integrations.username_label") }} <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">Username <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">{{ _("integrations.password_label") }} <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">Password <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" />
{{ _("integrations.show_password") }}
Show password
</label>
</div>
</template>
@@ -594,12 +594,13 @@
<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">{{ _("integrations.oauth_folder_label") }} <span class="text-gray-400">{{ _("integrations.folder_optional") }}</span></label>
<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>
<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>
{{ _("integrations.oauth_hint") }}
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.
</div>
</div>
</template>
@@ -607,9 +608,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">{{ _("integrations.email_settings") }}</p>
<p class="text-xs font-semibold text-gray-400 uppercase tracking-wider">Email Forward Settings</p>
<div>
<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>
<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>
<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>
@@ -618,11 +619,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">{{ _("integrations.watch_folder_settings") }}</p>
<p class="text-xs font-semibold text-gray-400 uppercase tracking-wider">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">{{ _("integrations.source_type_label") }} <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">Source Type <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">{{ _("integrations.source_local") }}</option>
<option value="local">Local Filesystem</option>
<option value="s3">Amazon S3</option>
<option value="dropbox">Dropbox</option>
<option value="google_drive">Google Drive</option>
@@ -635,7 +636,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">{{ _("integrations.folder_path_label") }} <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">Folder Path <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>
@@ -645,29 +646,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">{{ _("integrations.bucket_label") }} <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">Bucket <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">{{ _("integrations.region_label") }}</label>
<label for="wf-s3-region" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Region</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">{{ _("integrations.s3_prefix_label") }}</label>
<label for="wf-s3-prefix" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Prefix (folder path)</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">{{ _("integrations.endpoint_label") }} <span class="text-xs text-gray-400">{{ _("integrations.endpoint_optional") }}</span></label>
<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>
<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">{{ _("integrations.access_key_label") }} <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">Access Key ID <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">{{ _("integrations.secret_key_label") }} <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">Secret Access Key <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>
@@ -678,7 +679,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">{{ _("integrations.folder_path_label") }} <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">Folder Path <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>
@@ -716,7 +717,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">{{ _("integrations.folder_path_label") }} <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">Folder Path <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>
@@ -740,20 +741,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">{{ _("integrations.nextcloud_url_label") }} <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">Nextcloud URL <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">{{ _("integrations.folder_path_label") }} <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">Folder Path <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">{{ _("integrations.username_label") }} <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">Username <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">{{ _("integrations.password_label") }} <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">Password <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>
@@ -764,20 +765,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">{{ _("integrations.webdav_url_label") }} <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">WebDAV URL <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">{{ _("integrations.folder_path_label") }} <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">Folder Path <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">{{ _("integrations.username_label") }} <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">Username <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">{{ _("integrations.password_label") }} <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">Password <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>
@@ -788,12 +789,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" />
{{ _("integrations.delete_files_label") }}
Delete files after processing
</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" />
{{ _("integrations.show_secrets") }}
Show secrets
</label>
</template>
</div>
@@ -803,18 +804,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">{{ _("integrations.paperless_settings") }}</p>
<p class="text-xs font-semibold text-gray-400 uppercase tracking-wider">Paperless NGX 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">{{ _("integrations.api_token_label") }} <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">API Token <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" />
{{ _("integrations.show_token") }}
Show token
</label>
</div>
</template>
@@ -823,33 +824,38 @@
<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> {{ _("integrations.webhook_heading") }}
<i class="fas fa-bolt mr-1" aria-hidden="true"></i> Webhook Ingestion
</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>
{{ _("integrations.webhook_how_heading") }}
How Webhook Ingestion Works
</h4>
<p class="text-sm text-blue-700 dark:text-blue-300 leading-relaxed">
{{ _("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), <strong>your
application sends files to DocuElevate</strong> using an HTTP request with an API token for
authentication.
</p>
<p class="text-sm text-blue-700 dark:text-blue-300 leading-relaxed mt-2">
{{ _("integrations.webhook_ideal_text") }}
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.
</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>
{{ _("integrations.webhook_quick_start") }}
Quick Start
</h4>
<ol class="text-sm text-gray-600 dark:text-gray-400 space-y-2 list-decimal list-inside">
<li>
{% 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 }}
Go to <a href="/api-tokens" class="text-indigo-600 hover:underline font-medium">API Tokens</a>
and create a personal token.
</li>
<li>{{ _("integrations.webhook_upload_with_token") }}</li>
<li>Use the token to upload documents via the API:</li>
</ol>
<div class="mt-3 relative">
@@ -895,7 +901,9 @@ 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>
{{ _("integrations.webhook_security_tip") }}
<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.
</p>
</div>
</div>
@@ -906,7 +914,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">
{{ _("integrations.generic_settings_hint") }}
Configuration for this integration type can be provided as JSON after creation via the API.
</p>
</div>
</template>
@@ -915,7 +923,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" />
{{ _("integrations.active_label") }}
Active
</label>
</div>
@@ -926,7 +934,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 ? '{{ _('integrations.connection_success') }}' : '{{ _('integrations.connection_failed') }}'"></p>
<p class="font-medium" x-text="testResult.success ? 'Connection successful' : 'Connection failed'"></p>
<p class="text-xs mt-0.5" x-text="testResult.message"></p>
</div>
</template>
@@ -954,7 +962,7 @@ print(response.json())</code></pre>
<template x-if="!testing">
<i class="fas fa-plug mr-2" aria-hidden="true"></i>
</template>
{{ _("common.test_connection") }}
Test Connection
</button>
<div class="flex gap-2">
<button
@@ -963,7 +971,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;"
>
{{ _("common.cancel") }}
Cancel
</button>
<button
type="submit"
@@ -974,7 +982,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 ? '{{ _('common.update') }}' : '{{ _('common.save') }}'"></span>
<span x-text="editingIntegration ? 'Update' : 'Save'"></span>
</button>
</div>
</div>
@@ -995,12 +1003,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">
{{ _("integrations.delete_confirm_title") }}
Delete Integration?
</h2>
<p class="text-sm text-gray-600 dark:text-gray-300 mb-4">
{{ _("integrations.delete_confirm_are_you_sure") }}
Are you sure you want to delete
<strong x-text="integrationToDelete ? integrationToDelete.name : ''"></strong>?
{{ _("integrations.delete_confirm_undone") }}
This action cannot be undone.
</p>
<div class="flex justify-end gap-2">
<button
@@ -1009,7 +1017,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;"
>
{{ _("common.cancel") }}
Cancel
</button>
<button
type="button"
@@ -1021,7 +1029,7 @@ print(response.json())</code></pre>
<template x-if="deleting">
<i class="fas fa-spinner fa-spin mr-2" aria-hidden="true"></i>
</template>
{{ _("common.delete") }}
Delete
</button>
</div>
</div>
+13 -12
View File
@@ -1,35 +1,36 @@
{% extends "base.html" %}
{% block title %}{{ _("license.page_title") }}{% endblock %}
{% block title %}DocuElevate - License{% endblock %}
{% block content %}
<div class="container mx-auto px-4 py-8">
<h1 class="text-2xl font-bold mb-4">{{ _("license.heading") }}</h1>
<h1 class="text-2xl font-bold mb-4">License Information</h1>
<div class="bg-white shadow-md rounded-lg p-6 mb-6">
<h2 class="text-xl font-semibold mb-4">{{ _("license.apache_heading") }}</h2>
<h2 class="text-xl font-semibold mb-4">Apache License 2.0</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">
{{ _("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.
</p>
</div>
<div class="bg-white shadow-md rounded-lg p-6">
<h2 class="text-xl font-semibold mb-4">{{ _("license.related_heading") }}</h2>
<h2 class="text-xl font-semibold mb-4">Related Information</h2>
<p class="mb-3 text-gray-600">
{{ _("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") }}
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.
</p>
<p class="text-gray-600">
{{ _("license.related_p2_pre") }}
<a href="/about" class="text-blue-600 hover:underline">{{ _("license.related_about_link") }}</a>{{ _("license.related_p2_post") }}
For more information about DocuElevate, please visit the
<a href="/about" class="text-blue-600 hover:underline">About page</a>.
</p>
</div>
</div>
+212 -140
View File
@@ -1,316 +1,388 @@
{% extends "base.html" %}
{% block title %}{{ _("privacy.page_title") }}{% endblock %}
{% block title %}Privacy Notice - DocuElevate{% endblock %}
{% block content %}
<div class="container mx-auto px-4 py-8 max-w-4xl">
<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>
<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>
<!-- 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">{{ _("privacy.toc_heading") }}</h2>
<h2 class="text-lg font-semibold mb-2 text-blue-800">Contents</h2>
<ol class="list-decimal list-inside text-blue-700 space-y-1 text-sm">
<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>
<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>
</ol>
</div>
<div id="controller" class="bg-white shadow rounded-lg p-6 mb-6">
<h2 class="text-2xl font-semibold mb-4">{{ _("privacy.s1_heading") }}</h2>
<h2 class="text-2xl font-semibold mb-4">1. Data Controller</h2>
<p class="text-gray-700 mb-3">
{{ _("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:
</p>
<p class="text-gray-700 mb-3">
<strong>{{ _("privacy.s1_company") }}</strong><br />
{{ _("privacy.s1_address") }}
<strong>Christian Louis IT Beratung</strong><br />
Alter Steinweg 3, 20459 Hamburg, Germany
</p>
<p class="text-gray-700 mb-3">
{{ _("privacy.s1_contact_label") }} <a href="mailto:docuelevate@christian-louis.de" class="text-blue-600 hover:underline">docuelevate@christian-louis.de</a>
Contact Email: <a href="mailto:docuelevate@christian-louis.de" class="text-blue-600 hover:underline">docuelevate@christian-louis.de</a>
</p>
<p class="text-gray-700">
{{ _("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).
</p>
</div>
<div id="scope" class="bg-white shadow rounded-lg p-6 mb-6">
<h2 class="text-2xl font-semibold mb-4">{{ _("privacy.s2_heading") }}</h2>
<h2 class="text-2xl font-semibold mb-4">2. Scope of this Privacy Notice</h2>
<p class="text-gray-700 mb-3">
{{ _("privacy.s2_p1_pre") }}
This notice applies to the DocuElevate web application, hosted at
<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">
{{ _("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.
</p>
</div>
<div id="data-collection" class="bg-white shadow rounded-lg p-6 mb-6">
<h2 class="text-2xl font-semibold mb-4">{{ _("privacy.s3_heading") }}</h2>
<h2 class="text-2xl font-semibold mb-4">3. Data Collection &amp; Purposes</h2>
<p class="text-gray-700 mb-3">
<strong>{{ _("privacy.s3_auth_label") }}</strong> {{ _("privacy.s3_auth_body") }}
<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.
</p>
<p class="text-gray-700 mb-3">
<strong>{{ _("privacy.s3_doc_label") }}</strong> {{ _("privacy.s3_doc_body") }}
<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.
</p>
<p class="text-gray-700 mb-3">
<strong>{{ _("privacy.s3_audit_label") }}</strong> {{ _("privacy.s3_audit_body") }}
<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.
</p>
<p class="text-gray-700 mb-3">
<strong>{{ _("privacy.s3_legal_label") }}</strong> {{ _("privacy.s3_legal_body") }}
<strong>Legal Basis (GDPR Art. 6):</strong> Our primary legal bases for processing are:
</p>
<ul class="list-disc list-inside text-gray-700 ml-4 mb-3">
<li class="mb-1">{{ _("privacy.s3_li1") }}</li>
<li class="mb-1">{{ _("privacy.s3_li2") }}</li>
<li>{{ _("privacy.s3_li3") }}</li>
<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>
</ul>
</div>
<div id="data-minimization" class="bg-white shadow rounded-lg p-6 mb-6">
<h2 class="text-2xl font-semibold mb-4">{{ _("privacy.s4_heading") }}</h2>
<h2 class="text-2xl font-semibold mb-4">4. Data Minimization &amp; Purpose Limitation</h2>
<p class="text-gray-700 mb-3">
{{ _("privacy.s4_p1") }}
DocuElevate is designed with data minimization as a core principle (GDPR Art. 5(1)(c)):
</p>
<ul class="list-disc list-inside text-gray-700 ml-4 mb-3">
<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>
<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>
</ul>
</div>
<div id="cookies" class="bg-white shadow rounded-lg p-6 mb-6">
<h2 class="text-2xl font-semibold mb-4">{{ _("privacy.s5_heading") }}</h2>
<h2 class="text-2xl font-semibold mb-4">5. Use of Cookies &amp; Similar Technologies</h2>
<p class="text-gray-700 mb-3">
{{ _("privacy.s5_p1_pre") }} <strong>{{ _("privacy.s5_p1_strong") }}</strong> {{ _("privacy.s5_p1_post") }}
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.
</p>
<p class="text-gray-700 mb-3">
<strong>{{ _("privacy.s5_p2_label") }}</strong> {{ _("privacy.s5_p2_body") }}
<strong>We do not use:</strong> analytics cookies, advertising cookies, tracking pixels, or any
third-party cookies that would require your consent.
</p>
<p class="text-gray-700">
{{ _("privacy.s5_p3_pre") }}
<a href="/cookies" class="text-blue-600 hover:underline">{{ _("privacy.s5_cookie_link") }}</a>.
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>.
</p>
</div>
<div id="third-parties" class="bg-white shadow rounded-lg p-6 mb-6">
<h2 class="text-2xl font-semibold mb-4">{{ _("privacy.s6_heading") }}</h2>
<h2 class="text-2xl font-semibold mb-4">6. Third-Party Services</h2>
<p class="text-gray-700 mb-3">
<strong>{{ _("privacy.s6_oauth_label") }}</strong> {{ _("privacy.s6_oauth_body") }}
<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.
</p>
<p class="text-gray-700 mb-3">
<strong>{{ _("privacy.s6_storage_label") }}</strong>
{{ _("privacy.s6_storage_body") }}
<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.
</p>
<p class="text-gray-700 mb-3">
<strong>{{ _("privacy.s6_ai_label") }}</strong> {{ _("privacy.s6_ai_body") }}
<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.
</p>
<p class="text-gray-700">
<strong>{{ _("privacy.s6_no_sale_label") }}</strong> {{ _("privacy.s6_no_sale_body") }}
<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.
</p>
</div>
<div id="international-transfers" class="bg-white shadow rounded-lg p-6 mb-6">
<h2 class="text-2xl font-semibold mb-4">{{ _("privacy.s7_heading") }}</h2>
<h2 class="text-2xl font-semibold mb-4">7. International Data Transfers</h2>
<p class="text-gray-700 mb-3">
{{ _("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:
</p>
<ul class="list-disc list-inside text-gray-700 ml-4 mb-3">
<li class="mb-1">
<strong>{{ _("privacy.s7_scc_label") }}</strong> {{ _("privacy.s7_scc_body") }}
<strong>Standard Contractual Clauses (SCCs)</strong> adopted by the European Commission (2021/914/EU)
for transfers to processors and controllers in third countries.
</li>
<li class="mb-1">
<strong>{{ _("privacy.s7_adequacy_label") }}</strong> {{ _("privacy.s7_adequacy_body") }}
<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).
</li>
<li>
<strong>{{ _("privacy.s7_idta_label") }}</strong> {{ _("privacy.s7_idta_body") }}
<strong>UK International Data Transfer Agreements (IDTAs)</strong> for transfers from the UK after Brexit.
</li>
</ul>
<p class="text-gray-700">
{{ _("privacy.s7_contact") }}
You may request a copy of the relevant safeguards by contacting us at
<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">{{ _("privacy.s8_heading") }}</h2>
<h2 class="text-2xl font-semibold mb-4">8. Data Retention</h2>
<p class="text-gray-700 mb-3">
{{ _("privacy.s8_p1") }}
We retain personal data only as long as strictly necessary to provide the DocuElevate service or to
comply with legal obligations:
</p>
<ul class="list-disc list-inside text-gray-700 ml-4 mb-3">
<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>
<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>
</ul>
<p class="text-gray-700">
{{ _("privacy.s8_contact") }}
To request deletion of your account and all associated personal data, please contact us at
<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">{{ _("privacy.s9_heading") }}</h2>
<h2 class="text-2xl font-semibold mb-4">9. Data Security</h2>
<p class="text-gray-700 mb-3">
{{ _("privacy.s9_p1") }}
We implement appropriate technical and organisational measures (TOMs) to protect your personal data,
including:
</p>
<ul class="list-disc list-inside text-gray-700 ml-4">
<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>
<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>
</ul>
</div>
<div id="gdpr-rights" class="bg-white shadow rounded-lg p-6 mb-6">
<h2 class="text-2xl font-semibold mb-4">{{ _("privacy.s10_heading") }}</h2>
<h2 class="text-2xl font-semibold mb-4">10. Your Rights (EU / EEA / UK / Switzerland)</h2>
<p class="text-gray-700 mb-3">
{{ _("privacy.s10_p1") }}
Under GDPR (and the UK GDPR / Swiss nFADP equivalent), you have the following rights:
</p>
<ul class="list-disc list-inside text-gray-700 ml-4 mb-3">
<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>
<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>
</ul>
<p class="text-gray-700">
{{ _("privacy.s10_contact") }}
To exercise any of the above rights, contact us at
<a href="mailto:docuelevate@christian-louis.de" class="text-blue-600 hover:underline">docuelevate@christian-louis.de</a>.
{{ _("privacy.s10_response") }}
We will respond within one calendar month (extendable by two further months for complex requests).
</p>
</div>
<div id="us-rights" class="bg-white shadow rounded-lg p-6 mb-6">
<h2 class="text-2xl font-semibold mb-4">{{ _("privacy.s11_heading") }}</h2>
<h2 class="text-2xl font-semibold mb-4">11. Additional Rights United States (CCPA / CPRA)</h2>
<p class="text-gray-700 mb-3">
{{ _("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:
</p>
<ul class="list-disc list-inside text-gray-700 ml-4 mb-3">
<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>
<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>
</ul>
<p class="text-gray-700">
{{ _("privacy.s11_contact") }}
To submit a verifiable consumer request, contact us at
<a href="mailto:docuelevate@christian-louis.de" class="text-blue-600 hover:underline">docuelevate@christian-louis.de</a>.
{{ _("privacy.s11_response") }}
We will respond within 45 days (extendable by an additional 45 days when reasonably necessary).
</p>
</div>
<div id="canada-rights" class="bg-white shadow rounded-lg p-6 mb-6">
<h2 class="text-2xl font-semibold mb-4">{{ _("privacy.s12_heading") }}</h2>
<h2 class="text-2xl font-semibold mb-4">12. Additional Rights Canada (PIPEDA / Québec Law 25)</h2>
<p class="text-gray-700 mb-3">
{{ _("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):
</p>
<ul class="list-disc list-inside text-gray-700 ml-4 mb-3">
<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>
<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>
</ul>
<p class="text-gray-700">
{{ _("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") }}
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.
</p>
</div>
<div id="latam-rights" class="bg-white shadow rounded-lg p-6 mb-6">
<h2 class="text-2xl font-semibold mb-4">{{ _("privacy.s13_heading") }}</h2>
<h2 class="text-2xl font-semibold mb-4">13. Additional Rights Latin America (LGPD &amp; Others)</h2>
<p class="text-gray-700 mb-3">
<strong>{{ _("privacy.s13_brazil_label") }}</strong>
{{ _("privacy.s13_brazil_body") }}
<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:
</p>
<ul class="list-disc list-inside text-gray-700 ml-4 mb-3">
<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>
<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>
</ul>
<p class="text-gray-700 mb-3">
<strong>{{ _("privacy.s13_other_label") }}</strong> {{ _("privacy.s13_other_body") }}
<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.
</p>
<p class="text-gray-700">
{{ _("privacy.s13_contact") }}
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">{{ _("privacy.s14_heading") }}</h2>
<h2 class="text-2xl font-semibold mb-4">14. Additional Rights Asia-Pacific &amp; Japan</h2>
<p class="text-gray-700 mb-3">
<strong>{{ _("privacy.s14_japan_label") }}</strong>
{{ _("privacy.s14_japan_body") }}
<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.
</p>
<p class="text-gray-700 mb-3">
<strong>{{ _("privacy.s14_australia_label") }}</strong>
{{ _("privacy.s14_australia_body") }}
<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).
</p>
<p class="text-gray-700 mb-3">
<strong>{{ _("privacy.s14_korea_label") }}</strong>
{{ _("privacy.s14_korea_body") }}
<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.
</p>
<p class="text-gray-700 mb-3">
<strong>{{ _("privacy.s14_apj_label") }}</strong>
{{ _("privacy.s14_apj_body") }}
<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.
</p>
<p class="text-gray-700">
{{ _("privacy.s14_contact") }}
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">{{ _("privacy.s15_heading") }}</h2>
<h2 class="text-2xl font-semibold mb-4">15. Additional Rights Ukraine</h2>
<p class="text-gray-700 mb-3">
{{ _("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.
</p>
<p class="text-gray-700">
{{ _("privacy.s15_contact") }}
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">{{ _("privacy.s16_heading") }}</h2>
<h2 class="text-2xl font-semibold mb-4">16. Updates to this Privacy Notice</h2>
<p class="text-gray-700 mb-3">
{{ _("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.
</p>
<p class="text-gray-700 mb-3">
{{ _("privacy.s16_p2_pre") }}
If you have any questions or concerns about this Privacy Notice or your personal data, please
contact us at
<a href="mailto:docuelevate@christian-louis.de" class="text-blue-600 hover:underline">docuelevate@christian-louis.de</a>.
</p>
<p class="text-gray-700">
{{ _("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>.
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>.
</p>
</div>
</div>
+35 -44
View File
@@ -1,5 +1,5 @@
{% extends "base.html" %}
{% block title %}{{ _("profile.page_title") }}{% endblock %}
{% block title %}Profile Settings DocuElevate{% 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.heading") }}
Profile Settings
</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ _("profile.subtitle") }}
Manage your display name, avatar, language, and theme preferences.
</p>
</header>
@@ -36,7 +36,7 @@
type="button"
@click="banner.visible = false"
class="ml-auto"
aria-label="{{ _('profile.dismiss') }}"
aria-label="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.avatar_heading") }}
<i class="fas fa-camera text-gray-400 mr-2" aria-hidden="true"></i>Profile Picture
</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="{{ _('profile.avatar_alt') }}"
alt="Your profile picture"
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="{{ _('profile.avatar_remove') }}"
title="{{ _('profile.avatar_remove') }}"
aria-label="Remove custom avatar"
title="Remove custom avatar"
>
<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">
{% 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 }}
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.
</p>
<label
for="avatar-input"
@@ -86,7 +86,7 @@
style="min-height:44px;"
>
<i class="fas fa-upload" aria-hidden="true"></i>
<span>{{ _("profile.choose_image") }}</span>
<span>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>{{ _("profile.general_heading") }}
<i class="fas fa-id-card text-gray-400 mr-2" aria-hidden="true"></i>General Information
</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">
{{ _("profile.display_name_label") }}
Display Name
</label>
<input
id="display-name"
type="text"
x-model="form.display_name"
maxlength="255"
placeholder="{{ _('profile.display_name_placeholder') }}"
placeholder="Your name as shown in the UI"
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">
{{ _("profile.display_name_hint") }}
Leave blank to use your account username or email.
</p>
</div>
<!-- Contact email -->
<div>
<label for="contact-email" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{{ _("profile.contact_email_label") }}
Contact / Notification E-mail
</label>
<input
id="contact-email"
type="email"
x-model="form.contact_email"
maxlength="255"
placeholder="{{ _('profile.contact_email_placeholder') }}"
placeholder="you@example.com"
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">
{{ _("profile.contact_email_hint") }}
Used for system notifications. This does not change your login e-mail.
</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>{{ _("profile.preferences_heading") }}
<i class="fas fa-sliders-h text-gray-400 mr-2" aria-hidden="true"></i>Preferences
</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>{{ _("profile.language_label") }}
<i class="fas fa-globe text-gray-400 mr-1" aria-hidden="true"></i>UI Language
</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="">{{ _("profile.language_auto_detect") }}</option>
<option value="">Auto-detect (from browser)</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">
{{ _("profile.language_hint") }}
Choose your preferred interface language. "Auto-detect" follows your browser settings.
</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>{{ _("profile.theme_label") }}
<i class="fas fa-palette text-gray-400 mr-1" aria-hidden="true"></i>Colour Scheme
</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> {{ _("profile.theme_light") }}
<i class="fas fa-sun" aria-hidden="true"></i> 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> {{ _("profile.theme_dark") }}
<i class="fas fa-moon" aria-hidden="true"></i> 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> {{ _("profile.theme_system") }}
<i class="fas fa-desktop" aria-hidden="true"></i> System Default
</label>
</div>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
{{ _("profile.theme_hint") }}
"System Default" follows your device's dark-mode setting.
</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 ? '{{ _('profile.saving') }}' : '{{ _('profile.save_button') }}'"></span>
<span x-text="saving ? 'Saving…' : 'Save Changes'"></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>{{ _("profile.password_heading") }}
<i class="fas fa-lock text-gray-400 mr-2" aria-hidden="true"></i>Change Password
</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">
{{ _("profile.current_password") }}
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">
{{ _("profile.new_password") }}
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">{{ _("profile.new_password_hint") }}</p>
<p id="new-pw-hint" class="mt-1 text-xs text-gray-500 dark:text-gray-400">Minimum 8 characters.</p>
</div>
<div>
<label for="confirm-password" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{{ _("profile.confirm_password") }}
Confirm New 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 ? '{{ _('profile.updating') }}' : '{{ _('profile.update_password') }}'"></span>
<span x-text="pwSaving ? 'Updating…' : 'Update Password'"></span>
</button>
</div>
</div>
@@ -327,7 +327,7 @@
function profileSettings() {
return {
// ── State ──────────────────────────────────────────────────────────────
avatarUrl: '/static/images/default-avatar.svg',
avatarUrl: '/static/images/avatar-placeholder.svg',
hasCustomAvatar: false,
isLocalUser: false,
saving: false,
@@ -349,8 +349,6 @@ function profileSettings() {
},
// ── Init ───────────────────────────────────────────────────────────────
_initialLanguage: '',
async init() {
try {
const res = await fetch('/api/profile');
@@ -363,7 +361,6 @@ 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)
}
@@ -381,7 +378,6 @@ function profileSettings() {
async saveProfile() {
this.saving = true;
this._hideBanner();
const previousLang = this._initialLanguage || '';
try {
const csrfToken = this._getCSRFToken();
@@ -398,11 +394,6 @@ 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) {
+91 -121
View File
@@ -1,5 +1,5 @@
{% extends "base.html" %}
{% block title %}{{ _("status.page_title") }}{% endblock %}
{% block title %}System Status{% 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">{{ _("status.page_title") }}</h1>
<h1 class="text-3xl font-bold mb-2">System Status</h1>
<p class="text-gray-600">
{{ _("status.dashboard_subtitle") }}
This dashboard shows the status of all configured integrations and targets.
</p>
<div class="bg-blue-100 border-l-4 border-blue-500 text-blue-700 p-4 my-4" role="alert">
<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>
<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>
{% if last_check %}
<p><strong>{{ _("status.last_check") }}:</strong> {{ last_check }}</p>
<p><strong>Last Check:</strong> {{ last_check }}</p>
{% endif %}
{% if container_info.is_docker %}
<p><strong>{{ _("status.container_id") }}:</strong> {{ container_info.id }}</p>
<p><strong>Container ID:</strong> {{ container_info.id }}</p>
{% endif %}
{% if container_info.git_sha and container_info.git_sha != 'Unknown' %}
<p><strong>{{ _("status.git_commit") }}:</strong> {{ container_info.git_sha }}</p>
<p><strong>Git Commit:</strong> {{ container_info.git_sha }}</p>
{% endif %}
{% if container_info.runtime_info %}
<p><strong>{{ _("status.container_started") }}:</strong> {{ container_info.runtime_info }}</p>
<p><strong>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">
{{ _("status.configured") }}
Configured
</span>
{% else %}
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-red-100 text-red-800">
{{ _("status.not_configured") }}
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>
{{ _("status.active") }}
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>
{{ _("status.inactive") }}
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 }}">
{{ _("status.view_details") }}
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">
{{ _("status.test_notifications") }}
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') }}">
{{ _("status.test_provider", name=name) }}
Test {{ 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">
{{ _("status.test_connection") }}
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" aria-hidden="true"></i>
{{ _("status.manage") }}
<i class="fa-solid fa-gear h-3 w-3 mr-1"></i>
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">
{{ _("status.configure_now") }}
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">
{{ _("status.test_connection") }}
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" aria-hidden="true"></i>
{{ _("status.manage") }}
<i class="fa-solid fa-gear h-3 w-3 mr-1"></i>
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">
{{ _("status.configure_now") }}
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">
{{ _("status.test_connection") }}
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" aria-hidden="true"></i>
{{ _("status.manage") }}
<i class="fa-solid fa-gear h-3 w-3 mr-1"></i>
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">
{{ _("status.configure_now") }}
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">
{{ _("status.test_connection") }}
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" aria-hidden="true"></i>
{{ _("status.test_extraction") }}
<i class="fa-solid fa-flask mr-1"></i>
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">
{{ _("status.test_connection") }}
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" aria-hidden="true"></i>
{{ _("status.open") }}
<i class="fa-solid fa-external-link h-3 w-3 mr-1"></i>
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" aria-hidden="true"></i>
{{ _("status.open") }}
<i class="fa-solid fa-external-link h-3 w-3 mr-1"></i>
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">{{ _("status.config_settings") }}</h3>
<h3 class="text-lg font-medium text-gray-900">Configuration Settings</h3>
<p class="text-sm text-gray-600 mt-1">
{{ _("status.config_settings_desc") }}
For more detailed configuration settings and environment variables, check the settings page.
</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">
{{ _("status.view_config") }}
View Detailed Configuration
</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">{{ _("status.modal_default_title") }}</h3>
<h3 id="modalTitle" class="text-lg leading-6 font-medium text-gray-900">Success</h3>
<div class="mt-2 px-7 py-3">
<p id="modalMessage" class="text-sm text-gray-500">
{{ _("status.modal_default_message") }}
Operation completed successfully.
</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">
{{ _("common.close") }}
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">{{ _("common.close") }}</span>
<i class="fa-solid fa-xmark h-6 w-6" aria-hidden="true"></i>
<span class="sr-only">Close</span>
<i class="fa-solid fa-xmark h-6 w-6"></i>
</button>
</div>
<div class="mt-3">
<h3 id="detailsModalTitle" class="text-lg leading-6 font-medium text-gray-900 mb-4">{{ _("status.provider_details") }}</h3>
<h3 id="detailsModalTitle" class="text-lg leading-6 font-medium text-gray-900 mb-4">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">
{{ _("common.close") }}
Close
</button>
</div>
</div>
@@ -285,34 +285,35 @@
<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">{{ _("common.close") }}</span>
<i class="fa-solid fa-xmark h-6 w-6" aria-hidden="true"></i>
<span class="sr-only">Close</span>
<i class="fa-solid fa-xmark h-6 w-6"></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" aria-hidden="true"></i>{{ _("status.ai_extraction_title") }}
<i class="fa-solid fa-flask mr-2 text-indigo-600"></i>AI Extraction Test
</h3>
<p class="text-sm text-gray-500 mb-4">
{{ _("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.
</p>
<!-- Input area -->
<div id="aiExtractionInput">
<label for="aiExtractionText" class="block text-sm font-medium text-gray-700 mb-1">{{ _("status.ai_extraction_label") }}</label>
<label for="aiExtractionText" class="block text-sm font-medium text-gray-700 mb-1">Document Text</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="{{ _('status.ai_extraction_placeholder') }}"></textarea>
placeholder="Paste the plain-text content of your document here…"></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">
{{ _("common.cancel") }}
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" aria-hidden="true"></i>{{ _("status.run_extraction") }}
<i class="fa-solid fa-play mr-2"></i>Run Extraction
</button>
</div>
</div>
@@ -327,30 +328,30 @@
<!-- Raw response -->
<div>
<h4 class="text-sm font-medium text-gray-900 mb-1">{{ _("status.raw_llm_response") }}</h4>
<h4 class="text-sm font-medium text-gray-900 mb-1">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">{{ _("status.parsed_json_label") }}</h4>
<h4 class="text-sm font-medium text-gray-900 mb-1">Parsed JSON</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">{{ _("status.extracted_tags") }}</h4>
<h4 class="text-sm font-medium text-gray-900 mb-1">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" aria-hidden="true"></i>{{ _("common.back") }}
<i class="fa-solid fa-arrow-left mr-1"></i>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">
{{ _("common.close") }}
Close
</button>
</div>
</div>
@@ -362,37 +363,6 @@
{% 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');
@@ -430,7 +400,7 @@ document.addEventListener('DOMContentLoaded', function() {
}
function showDetailsModal(providerName, details) {
detailsModalTitle.textContent = i18nStrings.providerConfigDetails.replace('{name}', providerName);
detailsModalTitle.textContent = providerName + ' Configuration Details';
// Clear previous content
detailsContent.innerHTML = '';
@@ -444,8 +414,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">${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>
<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>
</tr>
`;
@@ -486,7 +456,7 @@ document.addEventListener('DOMContentLoaded', function() {
table.appendChild(tbody);
detailsContent.appendChild(table);
} else {
detailsContent.innerHTML = `<p class="text-sm text-gray-500">${i18nStrings.noDetails}</p>`;
detailsContent.innerHTML = '<p class="text-sm text-gray-500">No details available</p>';
}
detailsModal.classList.remove('hidden');
@@ -540,7 +510,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> ${i18nStrings.sending}`;
this.innerHTML = '<i class="fa-solid fa-spinner fa-spin mr-2"></i> Sending...';
this.disabled = true;
fetch('/api/diagnostic/test-notification', {
@@ -549,15 +519,15 @@ document.addEventListener('DOMContentLoaded', function() {
.then(response => response.json())
.then(data => {
if (data.status === 'success') {
showModal('success', i18nStrings.testNotificationSent, data.message);
showModal('success', 'Test Notification Sent', data.message);
} else if (data.status === 'warning') {
showModal('error', i18nStrings.notificationConfigMissing, data.message);
showModal('error', 'Notification Configuration Missing', data.message);
} else {
showModal('error', i18nStrings.testNotificationFailed, data.message);
showModal('error', 'Test Notification Failed', data.message);
}
})
.catch(error => {
showModal('error', i18nStrings.connectionError, i18nStrings.errorTestingNotifications + error.message);
showModal('error', 'Connection Error', 'Error testing notifications: ' + error.message);
})
.finally(() => {
// Always restore the button text and enable the button, regardless of success or failure
@@ -575,7 +545,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> ${i18nStrings.testing}`;
this.innerHTML = '<i class="fa-solid fa-spinner fa-spin mr-2"></i> Testing...';
this.disabled = true;
fetch(endpoint, {
@@ -588,28 +558,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">${i18nStrings.tokenValidFor}</span> ${data.token_info.expires_in_human}
<span class="font-medium">Token valid for:</span> ${data.token_info.expires_in_human}
</div>`;
modalTitle.textContent = i18nStrings.testSuccessful;
modalTitle.textContent = 'Test Successful';
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', i18nStrings.testSuccessful, data.message + (data.account ? ' ' + i18nStrings.asAccount + ' ' + data.account : ''));
showModal('success', 'Test Successful', data.message + (data.account ? ' as ' + data.account : ''));
}
} else if (data.status === 'warning') {
showModal('error', i18nStrings.warning, data.message);
showModal('error', 'Warning', data.message);
} else if (data.needs_reauth) {
showModal('error', i18nStrings.authRequired, i18nStrings.tokenExpired);
showModal('error', 'Authentication Required', 'Your token has expired or is invalid. Please reconfigure this connection.');
} else {
showModal('error', i18nStrings.testFailed, data.message);
showModal('error', 'Test Failed', data.message);
}
})
.catch(error => {
showModal('error', i18nStrings.connectionError, `Error: ${error.message}`);
showModal('error', 'Connection Error', `Error: ${error.message}`);
})
.finally(() => {
// Always restore the button state
@@ -626,7 +596,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> ${i18nStrings.testing}`;
this.innerHTML = '<i class="fa-solid fa-spinner fa-spin mr-2"></i> Testing...';
this.disabled = true;
let endpoint = '';
@@ -652,35 +622,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">${i18nStrings.tokenValidFor}</span> ${data.token_info.expires_in_human}
<span class="font-medium">Token valid for:</span> ${data.token_info.expires_in_human}
</div>`;
// Show the message with HTML
modalTitle.textContent = i18nStrings.connectionTestSuccessful;
modalTitle.textContent = 'Connection Test Successful';
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', i18nStrings.connectionTestSuccessful, `${data.message} ${data.account ? i18nStrings.asAccount + ' ' + data.account : ''}`);
showModal('success', 'Connection Test Successful', `${data.message} ${data.account ? 'as ' + data.account : ''}`);
}
} else {
if (data.needs_reauth) {
showModal('error', i18nStrings.authRequired, i18nStrings.tokenExpired);
showModal('error', 'Authentication Required', 'Your token has expired or is invalid. Please reconfigure this connection.');
// Add option to go to config page
modalClose.textContent = i18nStrings.configureNow;
modalClose.textContent = "Configure Now";
modalClose.addEventListener('click', function redirectToConfig() {
window.location.href = `/${provider.replace('_', '-')}-setup`;
modalClose.removeEventListener('click', redirectToConfig);
}, { once: true });
} else {
showModal('error', i18nStrings.connectionTestFailed, data.message);
showModal('error', 'Connection Test Failed', data.message);
}
}
})
.catch(error => {
showModal('error', i18nStrings.connectionError, i18nStrings.errorTestingConnection + error.message);
showModal('error', 'Connection Error', 'Error testing connection: ' + error.message);
})
.finally(() => {
this.textContent = originalText;
@@ -726,11 +696,11 @@ document.addEventListener('DOMContentLoaded', function() {
aiExtractionMeta.textContent = `Provider: ${data.provider || '—'} | Model: ${data.model || '—'}`;
// Raw response
aiRawResponse.textContent = data.raw_response || i18nStrings.aiEmptyResponse;
aiRawResponse.textContent = data.raw_response || '(empty)';
// Parse warning
if (data.parse_error) {
aiParseWarning.textContent = i18nStrings.jsonParseIssue + data.parse_error;
aiParseWarning.textContent = `JSON parse issue: ${data.parse_error}`;
aiParseWarning.classList.remove('hidden');
} else {
aiParseWarning.classList.add('hidden');
@@ -805,7 +775,7 @@ document.addEventListener('DOMContentLoaded', function() {
}
const originalHTML = runAiExtractionBtn.innerHTML;
runAiExtractionBtn.innerHTML = `<i class="fa-solid fa-spinner fa-spin mr-2"></i>${i18nStrings.running}`;
runAiExtractionBtn.innerHTML = '<i class="fa-solid fa-spinner fa-spin mr-2"></i>Running…';
runAiExtractionBtn.disabled = true;
cancelAiExtractionBtn.disabled = true;
@@ -820,12 +790,12 @@ document.addEventListener('DOMContentLoaded', function() {
showAiExtractionResults(data);
} else {
closeAiExtractionModalFn();
showModal('error', i18nStrings.aiExtractionFailed, data.message || 'Unknown error');
showModal('error', 'AI Extraction Failed', data.message || 'Unknown error');
}
})
.catch(error => {
closeAiExtractionModalFn();
showModal('error', i18nStrings.connectionError, i18nStrings.errorRunningExtraction + error.message);
showModal('error', 'Connection Error', 'Error running extraction: ' + error.message);
})
.finally(() => {
runAiExtractionBtn.innerHTML = originalHTML;
+40 -39
View File
@@ -1,5 +1,5 @@
{% extends "base.html" %}
{% block title %}{{ _("subscription.page_title") }}{% endblock %}
{% block title %}My Subscription DocuElevate{% 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>
{{ _("subscription.heading") }}
My Subscription
</h1>
<p class="text-gray-500 text-sm mt-1">{{ _("subscription.subtitle") }}</p>
<p class="text-gray-500 text-sm mt-1">Your current plan, usage and available upgrades.</p>
</div>
<!-- Flash messages -->
@@ -21,10 +21,11 @@
<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">{{ _("subscription.single_user_title") }}</p>
<p class="font-semibold text-blue-800">Multi-user mode is not enabled.</p>
<p class="text-blue-700 text-sm mt-1">
{% 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 }}
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>.
</p>
</div>
</div>
@@ -36,17 +37,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">{{ _("subscription.pending_title") }}</p>
<p class="font-semibold text-amber-800">Pending plan change scheduled</p>
<p class="text-amber-700 text-sm mt-1">
{{ _("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") }}
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.
</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="{{ _('subscription.cancel_pending') }}">
{{ _("subscription.cancel_pending") }}
aria-label="Cancel pending plan change">
Cancel change
</button>
</div>
{% endif %}
@@ -67,18 +68,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">{{ _("subscription.current_plan") }}</p>
<p class="text-xs font-semibold text-gray-400 uppercase tracking-widest">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">{{ _("subscription.per_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">/month</span></p>
{% else %}
<p class="mt-3 text-lg font-bold text-gray-500">{{ _("subscription.free") }}</p>
<p class="mt-3 text-lg font-bold text-gray-500">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>
{{ _("subscription.since") }} {{ period_start.strftime('%b') }} {{ period_start.day }}, {{ period_start.year }}
Since {{ period_start.strftime('%b') }} {{ period_start.day }}, {{ period_start.year }}
</p>
{% endif %}
</div>
@@ -86,13 +87,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">{{ _("subscription.usage_heading") }}</h2>
<h2 class="text-sm font-semibold text-gray-500 uppercase tracking-widest mb-4">Usage</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">{{ _("subscription.lifetime_files") }}</p>
<p class="text-xs text-gray-500 mt-1">Total files (lifetime)</p>
{% if tier.lifetime_file_limit > 0 %}
<div class="mt-2">
{% set lifetime_pct = ((usage.lifetime / tier.lifetime_file_limit) * 100) | int %}
@@ -105,14 +106,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">{{ _("subscription.unlimited") }}</p>
<p class="text-xs text-green-600 mt-1">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">{{ _("subscription.today_files") }}</p>
<p class="text-xs text-gray-500 mt-1">Files today</p>
{% if tier.daily_upload_limit > 0 %}
<div class="mt-2">
{% set today_pct = ((usage.today / tier.daily_upload_limit) * 100) | int %}
@@ -122,17 +123,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 }} {{ _("subscription.today_label") }}</p>
<p class="text-xs text-gray-400 mt-1">{{ usage.today }} / {{ tier.daily_upload_limit }} today</p>
</div>
{% else %}
<p class="text-xs text-green-600 mt-1">{{ _("subscription.unlimited") }}</p>
<p class="text-xs text-green-600 mt-1">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">{{ _("subscription.month_files") }}</p>
<p class="text-xs text-gray-500 mt-1">Files this month</p>
{% if tier.monthly_upload_limit > 0 %}
<div class="mt-2">
{% set month_pct = ((usage.month / tier.monthly_upload_limit) * 100) | int %}
@@ -142,10 +143,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 }} {{ _("subscription.this_month_label") }}</p>
<p class="text-xs text-gray-400 mt-1">{{ usage.month }} / {{ tier.monthly_upload_limit }} this month</p>
</div>
{% else %}
<p class="text-xs text-green-600 mt-1">{{ _("subscription.unlimited") }}</p>
<p class="text-xs text-green-600 mt-1">Unlimited</p>
{% endif %}
</div>
@@ -159,7 +160,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>
{{ _("subscription.features_heading") }}
What's included in your plan
</h2>
<ul class="grid grid-cols-1 sm:grid-cols-2 gap-2 text-sm text-gray-600">
{% for feature in tier.features %}
@@ -175,7 +176,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>
{{ _("subscription.available_plans_heading") }}
Available Plans
</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{% for t in all_tiers %}
@@ -191,11 +192,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">
{{ _("subscription.current_badge") }}
Current
</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">
{{ _("subscription.pending_badge") }}
Pending
</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">
@@ -207,9 +208,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">{{ _("subscription.per_mo") }}</span></p>
<p class="text-xl font-extrabold text-gray-900">${{ t.price_monthly }}<span class="text-sm font-normal text-gray-400">/mo</span></p>
{% else %}
<p class="text-xl font-extrabold text-gray-500">{{ _("subscription.free") }}</p>
<p class="text-xl font-extrabold text-gray-500">Free</p>
{% endif %}
<ul class="mt-3 space-y-1 text-xs text-gray-600">
{% for feature in t.features[:3] %}
@@ -219,7 +220,7 @@
</li>
{% endfor %}
{% if t.features | length > 3 %}
<li class="text-indigo-500 font-medium">+ {{ (t.features | length) - 3 }} {{ _("subscription.more_features_label") }}</li>
<li class="text-indigo-500 font-medium">+ {{ (t.features | length) - 3 }} more</li>
{% endif %}
</ul>
</div>
@@ -227,14 +228,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">
{{ _("subscription.current_plan_cta") }}
Your current plan
</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">
{{ _("subscription.keep_plan_prefix") }} {{ t.name }}
Keep {{ t.name }}
</button>
{% elif is_upgrade %}
{% if t.id == 'business' %}
@@ -246,21 +247,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">
{{ _("subscription.upgrade_to_prefix") }} {{ t.name }}
Upgrade to {{ 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">
{{ _("subscription.cancel_scheduled") }}
Cancel scheduled change
</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">
{{ _("subscription.downgrade_to_prefix") }} {{ t.name }}
Downgrade to {{ t.name }}
</button>
{% endif %}
</div>
@@ -269,7 +270,7 @@
</div>
<p class="text-xs text-gray-400 mt-3">
<i class="fas fa-info-circle mr-1" aria-hidden="true"></i>
{{ _("subscription.upgrade_info") }}
Upgrades take effect immediately. Downgrades are scheduled for the end of your current billing period.
</p>
</div>
@@ -278,7 +279,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> {{ _("subscription.back_to_dashboard") }}
<i class="fas fa-arrow-left text-xs" aria-hidden="true"></i> Back to Dashboard
</a>
</div>
+22 -23
View File
@@ -1,66 +1,65 @@
{% extends "base.html" %}
{% block title %}{{ _("terms.page_title") }}{% endblock %}
{% block title %}Terms of Service - DocuElevate{% endblock %}
{% block content %}
<div class="container mx-auto px-4 py-8 max-w-4xl">
<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>
<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>
<div class="bg-white shadow rounded-lg p-6 mb-6">
<h2 class="text-2xl font-semibold mb-4">{{ _("terms.s1_heading") }}</h2>
<h2 class="text-2xl font-semibold mb-4">1. Acceptance of Terms</h2>
<p class="text-gray-700 mb-3">
{{ _("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.
</p>
</div>
<div class="bg-white shadow rounded-lg p-6 mb-6">
<h2 class="text-2xl font-semibold mb-4">{{ _("terms.s2_heading") }}</h2>
<h2 class="text-2xl font-semibold mb-4">2. Description of Service</h2>
<p class="text-gray-700 mb-3">
{{ _("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.
</p>
</div>
<div class="bg-white shadow rounded-lg p-6 mb-6">
<h2 class="text-2xl font-semibold mb-4">{{ _("terms.s3_heading") }}</h2>
<h2 class="text-2xl font-semibold mb-4">3. User Responsibilities</h2>
<p class="text-gray-700 mb-3">
{{ _("terms.s3_p1") }}
You are responsible for:
</p>
<ul class="list-disc list-inside text-gray-700 ml-4 mb-3">
<li>{{ _("terms.s3_li1") }}</li>
<li>{{ _("terms.s3_li2") }}</li>
<li>{{ _("terms.s3_li3") }}</li>
<li>{{ _("terms.s3_li4") }}</li>
<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>
</ul>
<p class="text-gray-700 mb-3">
{{ _("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") }}
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>.
</p>
</div>
<div class="bg-white shadow rounded-lg p-6 mb-6">
<h2 class="text-2xl font-semibold mb-4">{{ _("terms.s4_heading") }}</h2>
<h2 class="text-2xl font-semibold mb-4">4. Intellectual Property Rights</h2>
<p class="text-gray-700 mb-3">
{{ _("terms.s4_p1") }}
DocuElevate respects intellectual property rights. Users may not upload content that infringes on the intellectual property rights of others.
</p>
</div>
<div class="bg-white shadow rounded-lg p-6 mb-6">
<h2 class="text-2xl font-semibold mb-4">{{ _("terms.s5_heading") }}</h2>
<h2 class="text-2xl font-semibold mb-4">5. Limitation of Liability</h2>
<p class="text-gray-700 mb-3">
{{ _("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.
</p>
</div>
<div class="bg-white shadow rounded-lg p-6">
<h2 class="text-2xl font-semibold mb-4">{{ _("terms.s6_heading") }}</h2>
<h2 class="text-2xl font-semibold mb-4">6. Governing Law</h2>
<p class="text-gray-700 mb-3">
{{ _("terms.s6_p1") }}
These Terms shall be governed by the laws of Germany, without regard to its conflict of law provisions.
</p>
<p class="text-gray-700 mb-3">
{{ _("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") }}
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>.
</p>
<p class="text-gray-700">
{{ _("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") }}
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>.
</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,280 +41,6 @@
"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",
@@ -347,19 +73,6 @@
"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",
@@ -474,7 +187,6 @@
"common.actions": "Actions",
"common.active": "Active",
"common.all": "All",
"common.avatar": "Avatar",
"common.back": "Back",
"common.cancel": "Cancel",
"common.close": "Close",
@@ -506,7 +218,6 @@
"common.no": "No",
"common.none": "None",
"common.page": "Page",
"common.paused": "Paused",
"common.pending": "Pending",
"common.prev_page": "Previous page",
"common.previous": "Previous",
@@ -523,10 +234,7 @@
"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",
@@ -539,41 +247,6 @@
"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",
@@ -773,31 +446,15 @@
"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",
@@ -809,7 +466,6 @@
"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.",
@@ -817,9 +473,7 @@
"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",
@@ -827,43 +481,15 @@
"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",
@@ -909,7 +535,6 @@
"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",
@@ -928,13 +553,9 @@
"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",
@@ -949,95 +570,20 @@
"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}",
@@ -1061,12 +607,10 @@
"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",
@@ -1075,22 +619,7 @@
"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",
@@ -1109,7 +638,6 @@
"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",
@@ -1118,21 +646,18 @@
"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",
@@ -1188,220 +713,6 @@
"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",
@@ -1579,131 +890,14 @@
"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
@@ -0,0 +1,929 @@
{
"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