Merge branch 'main' into refactor/audit-logs-annotated-b008-6104828608171783200
This commit is contained in:
@@ -7,6 +7,24 @@ GOTENBERG_URL=http://gotenberg:3000
|
||||
ALLOW_FILE_DELETE=true # Allow deletion of file records
|
||||
COMPLIANCE_ENABLED=true # Enable compliance templates dashboard (GDPR, HIPAA, SOC 2)
|
||||
|
||||
# **Logging**
|
||||
# LOG_LEVEL controls the Python root-logger level.
|
||||
# Accepted values: DEBUG, INFO, WARNING, ERROR, CRITICAL (default: INFO).
|
||||
# When DEBUG=true and LOG_LEVEL is not set, the level is automatically lowered to DEBUG.
|
||||
# LOG_LEVEL=INFO
|
||||
# DEBUG=false
|
||||
|
||||
# Log output format: "text" (human-readable, default) or "json" (structured JSON lines).
|
||||
# Use "json" when shipping logs to Grafana Loki, Splunk, ELK, Datadog, or any SIEM.
|
||||
# LOG_FORMAT=text
|
||||
|
||||
# Forward application logs to a syslog receiver (in addition to stdout).
|
||||
# Useful for traditional (non-container) deployments and centralised SIEM ingestion.
|
||||
# LOG_SYSLOG_ENABLED=false
|
||||
# LOG_SYSLOG_HOST=localhost
|
||||
# LOG_SYSLOG_PORT=514
|
||||
# LOG_SYSLOG_PROTOCOL=udp # udp | tcp
|
||||
|
||||
# **UI / Appearance**
|
||||
# Default colour scheme: system (follow OS), light, or dark
|
||||
# Individual users can always override with the navbar dark-mode toggle.
|
||||
|
||||
@@ -163,6 +163,13 @@ pytest --tb=short -q
|
||||
- Keep JavaScript minimal - prefer server-side rendering
|
||||
- Follow existing template structure and patterns
|
||||
|
||||
### Internationalization (i18n) & Localization (l10n)
|
||||
- **Always** use the `_("key")` helper in Jinja2 templates and `translate("key", locale)` in Python for every user-visible string — never hardcode UI text.
|
||||
- **Only add new keys to `frontend/translations/en.json`** — that is the one and only file you must touch when introducing new UI strings.
|
||||
- Do **not** manually edit any non-English translation file (`de.json`, `fr.json`, etc.). An external automation script syncs all other language files from `en.json` automatically.
|
||||
- Key naming convention: `<section>.<descriptor>` in snake_case, e.g. `language.search_placeholder`, `nav.help`, `common.cancel`.
|
||||
- The `test_all_languages_have_same_keys` check has been intentionally removed — key completeness across locales is enforced by the external sync script, not by the test suite.
|
||||
|
||||
### Testing
|
||||
- Write tests in `tests/` directory, mirroring `app/` structure
|
||||
- Use pytest markers: `@pytest.mark.unit`, `@pytest.mark.integration`, etc.
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
## 2024-05-24 - SSRF in WebDAV connection test
|
||||
**Vulnerability:** The `_test_webdav_connection` function had a custom SSRF check that failed to resolve DNS names, allowing attackers to bypass the check by providing a domain that resolves to an internal IP (e.g., `127.0.0.1`).
|
||||
**Learning:** DNS resolution is required for robust SSRF protection when validating URLs provided by users.
|
||||
**Prevention:** Use a centralized `is_private_ip` function (now in `app/utils/network.py`) that resolves the hostname to its IPs and checks if any are private.
|
||||
+1
-1
@@ -1 +1 @@
|
||||
2026-03-13T14:54:25Z
|
||||
2026-03-16T10:45:13Z
|
||||
|
||||
+743
@@ -10,6 +10,749 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
<!-- version list -->
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Code Style
|
||||
|
||||
- Apply ruff auto-fix
|
||||
([`46c4031`](https://github.com/christianlouis/DocuElevate/commit/46c403127641c1b5c729ff69ab5505efa5c9b54d))
|
||||
|
||||
### Documentation
|
||||
|
||||
- **changelog**: Update changelog [skip ci]
|
||||
([`5734df2`](https://github.com/christianlouis/DocuElevate/commit/5734df2d5046158a23b987fdc4235d3f3f6b4042))
|
||||
|
||||
|
||||
## Unreleased
|
||||
|
||||
|
||||
## v0.147.1 (2026-03-16)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Resolve test failures and mypy errors on main
|
||||
([`c7ff177`](https://github.com/christianlouis/DocuElevate/commit/c7ff177e179b60a4b6adba7f5296522e6da4391f))
|
||||
|
||||
### Documentation
|
||||
|
||||
- **changelog**: Update changelog [skip ci]
|
||||
([`dd7c8f0`](https://github.com/christianlouis/DocuElevate/commit/dd7c8f0342f00b80e83394d146e6bc51a3688687))
|
||||
|
||||
- **changelog**: Update changelog [skip ci]
|
||||
([`4d4706e`](https://github.com/christianlouis/DocuElevate/commit/4d4706e078d661682326262000e70d7e56fb6d23))
|
||||
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Documentation
|
||||
|
||||
- **changelog**: Update changelog [skip ci]
|
||||
([`4d4706e`](https://github.com/christianlouis/DocuElevate/commit/4d4706e078d661682326262000e70d7e56fb6d23))
|
||||
|
||||
|
||||
## Unreleased
|
||||
|
||||
|
||||
## v0.147.0 (2026-03-16)
|
||||
|
||||
### Code Style
|
||||
|
||||
- Apply ruff auto-fix
|
||||
([`80a0ddc`](https://github.com/christianlouis/DocuElevate/commit/80a0ddcbfcd89c156d6cce65bb6275bdf35c061e))
|
||||
|
||||
### Documentation
|
||||
|
||||
- **changelog**: Update changelog [skip ci]
|
||||
([`5e986cb`](https://github.com/christianlouis/DocuElevate/commit/5e986cb6d66c313753c7ce9cf69137c36b354ae8))
|
||||
|
||||
- **changelog**: Update changelog [skip ci]
|
||||
([`8f8e11c`](https://github.com/christianlouis/DocuElevate/commit/8f8e11cffe5a6aaae47c9111bc6a95cb88ef6cd8))
|
||||
|
||||
### Features
|
||||
|
||||
- **config**: Add JSON structured logging and syslog forwarding for application logs
|
||||
([`6da1e6f`](https://github.com/christianlouis/DocuElevate/commit/6da1e6fd816fea1dc0fa37c9ad42ce8cafed8c73))
|
||||
|
||||
- **config**: Add LOG_LEVEL setting and configure root logging at startup
|
||||
([`18c49c6`](https://github.com/christianlouis/DocuElevate/commit/18c49c6b2d214fddefa2f954d57b0358fb4b5f72))
|
||||
|
||||
### Refactoring
|
||||
|
||||
- **main**: Move JSON formatter imports to module level per code review
|
||||
([`df1fa51`](https://github.com/christianlouis/DocuElevate/commit/df1fa51800fb62862271fce4e0fff691f62c3333))
|
||||
|
||||
### Testing
|
||||
|
||||
- Add 500 error test for saved search deletion
|
||||
([`ae9ed6e`](https://github.com/christianlouis/DocuElevate/commit/ae9ed6e9a703f8a80880d0b1cb421de3cf024cd3))
|
||||
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Code Style
|
||||
|
||||
- Apply ruff auto-fix
|
||||
([`80a0ddc`](https://github.com/christianlouis/DocuElevate/commit/80a0ddcbfcd89c156d6cce65bb6275bdf35c061e))
|
||||
|
||||
### Documentation
|
||||
|
||||
- **changelog**: Update changelog [skip ci]
|
||||
([`8f8e11c`](https://github.com/christianlouis/DocuElevate/commit/8f8e11cffe5a6aaae47c9111bc6a95cb88ef6cd8))
|
||||
|
||||
### Testing
|
||||
|
||||
- Add 500 error test for saved search deletion
|
||||
([`ae9ed6e`](https://github.com/christianlouis/DocuElevate/commit/ae9ed6e9a703f8a80880d0b1cb421de3cf024cd3))
|
||||
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Code Style
|
||||
|
||||
- Apply ruff auto-fix
|
||||
([`80a0ddc`](https://github.com/christianlouis/DocuElevate/commit/80a0ddcbfcd89c156d6cce65bb6275bdf35c061e))
|
||||
|
||||
### Testing
|
||||
|
||||
- Add 500 error test for saved search deletion
|
||||
([`ae9ed6e`](https://github.com/christianlouis/DocuElevate/commit/ae9ed6e9a703f8a80880d0b1cb421de3cf024cd3))
|
||||
|
||||
|
||||
## v0.146.0 (2026-03-16)
|
||||
|
||||
### Code Style
|
||||
|
||||
- Apply ruff auto-fix
|
||||
([`522cefa`](https://github.com/christianlouis/DocuElevate/commit/522cefad935508fed32e194984726b0a10c99654))
|
||||
|
||||
- Apply ruff auto-fix
|
||||
([`c76e513`](https://github.com/christianlouis/DocuElevate/commit/c76e51391b14eccbbe74f34ffadc89a477b58a64))
|
||||
|
||||
- Apply ruff auto-fix
|
||||
([`cc2a07b`](https://github.com/christianlouis/DocuElevate/commit/cc2a07b090883f3cf2affea8a5e254a4163ce966))
|
||||
|
||||
- Apply ruff auto-fix
|
||||
([`8bb6457`](https://github.com/christianlouis/DocuElevate/commit/8bb6457c65c177a13fd15de7a7b55e24eb39477e))
|
||||
|
||||
- Apply ruff auto-fix
|
||||
([`275a5ad`](https://github.com/christianlouis/DocuElevate/commit/275a5ad6fa887ce3aa05d50787bd5da435735f26))
|
||||
|
||||
- Apply ruff auto-fix
|
||||
([`ac35c5e`](https://github.com/christianlouis/DocuElevate/commit/ac35c5e6fa84f2b38cd333de75f202f13bf4c461))
|
||||
|
||||
- Apply ruff auto-fix
|
||||
([`705c801`](https://github.com/christianlouis/DocuElevate/commit/705c801158394e7d6466f82485f8d872860653bb))
|
||||
|
||||
- Apply ruff auto-fix
|
||||
([`0425d46`](https://github.com/christianlouis/DocuElevate/commit/0425d46c4440191cd410434ba64c1bc9cdb53cbb))
|
||||
|
||||
- Apply ruff auto-fix
|
||||
([`b4118f6`](https://github.com/christianlouis/DocuElevate/commit/b4118f61622a35461af2c220d8a948ecb795e65b))
|
||||
|
||||
- Format app/database.py to fix CI failure
|
||||
([`fbd4f83`](https://github.com/christianlouis/DocuElevate/commit/fbd4f837301deb208c7303553bca8facbae85ba6))
|
||||
|
||||
### Features
|
||||
|
||||
- Extract embedded PDF metadata using pypdf in upload_to_email
|
||||
([`726e4df`](https://github.com/christianlouis/DocuElevate/commit/726e4dfdc47507dd08047a27f02c137ed3e7ecaf))
|
||||
|
||||
- Extract embedded PDF metadata using pypdf in upload_to_email
|
||||
([`df64aec`](https://github.com/christianlouis/DocuElevate/commit/df64aece2c19215cf762b2ac62d476888cd21527))
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
- Optimize dropbox token refresh by replacing blocking requests with httpx
|
||||
([`84c6e1c`](https://github.com/christianlouis/DocuElevate/commit/84c6e1c5dd1a427c7f015e7461df59b889e66154))
|
||||
|
||||
- **api**: Optimize reorder_plans to prevent N+1 queries
|
||||
([`d8372c6`](https://github.com/christianlouis/DocuElevate/commit/d8372c6fb83b09ce8d61bc8a870c921372a9d27a))
|
||||
|
||||
- **duplicates**: Fix N+1 query in group listing
|
||||
([`e4e3ac4`](https://github.com/christianlouis/DocuElevate/commit/e4e3ac40771bdd8459cc0876d9d49be441df51a4))
|
||||
|
||||
### Testing
|
||||
|
||||
- Add missing error tests for updating saved searches
|
||||
([`d18c05c`](https://github.com/christianlouis/DocuElevate/commit/d18c05c36dc4324dd24e20e5344bca9c038bbf73))
|
||||
|
||||
- Improve coverage for notify_settings_updated error handling
|
||||
([`d8906ae`](https://github.com/christianlouis/DocuElevate/commit/d8906aece0045aed9dcd0f0cf1891970c7cb00e8))
|
||||
|
||||
|
||||
## v0.145.3 (2026-03-16)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **tests**: Resolve ruff import sorting issue in benchmark test
|
||||
([`fa9b037`](https://github.com/christianlouis/DocuElevate/commit/fa9b037d5a2a67f9115a1bddf0f98ba9020cefd6))
|
||||
|
||||
### Code Style
|
||||
|
||||
- Apply ruff auto-fix
|
||||
([`c1657a0`](https://github.com/christianlouis/DocuElevate/commit/c1657a01a77ca6b914bc21a20a42aeb924de8254))
|
||||
|
||||
- Apply ruff auto-fix
|
||||
([`6f5f4d9`](https://github.com/christianlouis/DocuElevate/commit/6f5f4d9d4948208f65fc5572c86a08642efe54b6))
|
||||
|
||||
- Apply ruff auto-fix
|
||||
([`2cfbea2`](https://github.com/christianlouis/DocuElevate/commit/2cfbea29a9211abfc13b74dea0a841fe5a2546b9))
|
||||
|
||||
### Documentation
|
||||
|
||||
- **changelog**: Update changelog [skip ci]
|
||||
([`fa36ec6`](https://github.com/christianlouis/DocuElevate/commit/fa36ec69876b919a33a7471bb837c2a6b69c2a50))
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
- **api**: Fix n+1 query issue in user notification preferences update
|
||||
([`fe20e02`](https://github.com/christianlouis/DocuElevate/commit/fe20e02f78c3c6236b07f50edb66599b8a9c7ed2))
|
||||
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Code Style
|
||||
|
||||
- Apply ruff auto-fix
|
||||
([`2cfbea2`](https://github.com/christianlouis/DocuElevate/commit/2cfbea29a9211abfc13b74dea0a841fe5a2546b9))
|
||||
|
||||
|
||||
## 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
|
||||
|
||||
@@ -24,121 +24,154 @@
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
<a href="https://www.docuelevate.org"><img src="frontend/static/hero.png" alt="DocuElevate Logo" width="80%" /></a>
|
||||
<a href="https://www.docuelevate.org"><img src="frontend/static/hero.png" alt="DocuElevate Hero" width="80%" /></a>
|
||||
</div>
|
||||
|
||||
## Overview
|
||||
|
||||
DocuElevate automates the handling, extraction, and processing of documents using a variety of services, including:
|
||||
DocuElevate is an intelligent document processing system that automates the ingestion, OCR, AI-powered metadata extraction, and distribution of documents. It supports a wide range of AI providers, OCR engines, and cloud storage destinations out of the box.
|
||||
|
||||
- **AI Provider** (pluggable – OpenAI, Anthropic, Gemini, Ollama, OpenRouter, Portkey, and more) for metadata extraction and text refinement.
|
||||
- **Dropbox**, **Nextcloud**, and **Google Drive** for file storage and uploads.
|
||||
- **Paperless NGX** for document indexing and management.
|
||||
- **Azure Document Intelligence** for OCR on PDFs.
|
||||
- **Gotenberg** for file-to-PDF conversions.
|
||||
- **Authentik** for authentication and user management.
|
||||
**Key capabilities:**
|
||||
|
||||
It is designed for flexibility and configurability through environment variables, making it easily customizable for different workflows. The system can fetch documents from multiple IMAP mailboxes, process them (OCR, metadata extraction, PDF conversion), and store them in the desired destinations.
|
||||
- **AI-Powered Metadata Extraction** — pluggable AI providers including OpenAI, Anthropic Claude, Google Gemini, Ollama (local), OpenRouter, Portkey, and Azure OpenAI via LiteLLM
|
||||
- **Multi-Engine OCR** — Azure Document Intelligence, Tesseract, EasyOCR, Mistral OCR, Google Cloud Document AI, and AWS Textract with configurable merge strategies
|
||||
- **12 Storage Destinations** — Dropbox, Google Drive, OneDrive, Amazon S3, Nextcloud, WebDAV, FTP, SFTP, iCloud Drive, Email (SMTP), Paperless-ngx, and Rclone
|
||||
- **Multi-Channel Ingestion** — web upload, browser extension, mobile app, CLI, REST API, IMAP email, and watched folders (local, cloud, FTP/SFTP)
|
||||
- **Processing Pipelines** — customizable multi-step workflows with conditional routing rules
|
||||
- **Full-Text Search** — powered by Meilisearch for instant document discovery
|
||||
- **Multi-User with SSO** — local accounts, OAuth2/OIDC (Authentik), and social login (Google, Microsoft, Apple, Dropbox)
|
||||
|
||||
The project includes a **UI** for uploading and managing files, and an API documentation page is available at `/docs` (powered by **FastAPI**).
|
||||
|
||||
## Documentation Index
|
||||
|
||||
- [User Guide](docs/UserGuide.md) - How to use DocuElevate
|
||||
- [Browser Extension Guide](docs/BrowserExtension.md) - Install and use the browser extension
|
||||
- [API Documentation](docs/API.md) - API reference
|
||||
- [Deployment Guide](docs/DeploymentGuide.md) - How to deploy DocuElevate
|
||||
- [Configuration Guide](docs/ConfigurationGuide.md) - Available configuration options
|
||||
- [Build Metadata](docs/BuildMetadata.md) - Automated version and build information
|
||||
- [CI/CD Tools Guide](docs/CIToolsGuide.md) - CI/CD pipeline and tool documentation
|
||||
- [CI Workflow Guide](docs/CIWorkflow.md) - Detailed workflow documentation
|
||||
- [Development Guide](CONTRIBUTING.md) - How to contribute to DocuElevate
|
||||
- [Troubleshooting](docs/Troubleshooting.md) - Common issues and solutions
|
||||
The project ships with a web UI, a REST + GraphQL API, a CLI tool, a native mobile app (iOS & Android), a browser extension, and Helm charts for Kubernetes deployment.
|
||||
|
||||
## Screenshots
|
||||
|
||||
<div align="center">
|
||||
<img src="docs/upload-view.png" alt="DocuElevate Upload Interface" width="80%" />
|
||||
<p><em>Upload interface for adding new documents</em></p>
|
||||
<p><em>Upload interface — drag-and-drop file upload with real-time progress</em></p>
|
||||
|
||||
<img src="docs/files-view.png" alt="DocuElevate Files View" width="80%" />
|
||||
<p><em>Files view with processed documents and metadata</em></p>
|
||||
<p><em>Files view — processed documents with AI-extracted metadata</em></p>
|
||||
|
||||
<img src="docs/status-view.png" alt="DocuElevate Status View" width="80%" />
|
||||
<p><em>Status view — system health and service monitoring</em></p>
|
||||
</div>
|
||||
|
||||
> **Note:** Screenshots may not reflect the very latest UI. For the most current look, visit [docuelevate.org](https://www.docuelevate.org).
|
||||
|
||||
## Workflow Process
|
||||
|
||||
DocuElevate follows a streamlined document processing workflow:
|
||||
## Workflow
|
||||
|
||||
<div align="center">
|
||||
<img src="docs/workflow-diagram.png" alt="DocuElevate Workflow" width="90%" />
|
||||
</div>
|
||||
|
||||
### Document Ingestion
|
||||
Documents enter DocuElevate through four possible channels:
|
||||
1. **Web Upload**: Users manually upload files via the web interface
|
||||
2. **Browser Extension**: Send files directly from your browser with one click
|
||||
3. **Email Attachments**: Automatic polling of configured IMAP mailboxes (supports multiple accounts)
|
||||
4. **API**: Direct programmatic uploads via the REST API
|
||||
### Ingestion
|
||||
|
||||
Documents enter DocuElevate through multiple channels:
|
||||
|
||||
| Channel | Description |
|
||||
|---------|-------------|
|
||||
| **Web Upload** | Drag-and-drop interface with real-time progress (up to 1 GB per file) |
|
||||
| **Browser Extension** | Clip web pages or send files from Chrome, Firefox, or Edge |
|
||||
| **Mobile App** | Capture documents with the device camera or upload from the photo library |
|
||||
| **CLI** | Batch uploads and scripted workflows via the `docuelevate` command-line tool |
|
||||
| **REST API** | Programmatic uploads with full API-token authentication |
|
||||
| **Email (IMAP)** | Automatic polling of multiple mailboxes with attachment filtering |
|
||||
| **Watched Folders** | Monitor local paths, FTP, SFTP, S3, Dropbox, Google Drive, OneDrive, Nextcloud, or WebDAV for new files |
|
||||
|
||||
### Processing Pipeline
|
||||
Every document goes through the following steps:
|
||||
1. **PDF Conversion**: Non-PDF files are converted to PDF format using Gotenberg
|
||||
2. **OCR Processing**: Azure Document Intelligence extracts text from images/scans
|
||||
3. **Metadata Extraction**: The configured AI provider analyzes document content to identify:
|
||||
- Document type (invoice, receipt, contract, etc.)
|
||||
- Key entities (dates, names, amounts, account numbers)
|
||||
- Important data points specific to the document type
|
||||
4. **Enrichment**: Metadata is attached to the document in a structured format
|
||||
|
||||
Each document passes through a configurable set of steps:
|
||||
|
||||
1. **PDF Conversion** — Non-PDF files are converted using Gotenberg, with optional PDF/A archival conversion
|
||||
2. **OCR** — Text extraction via one or more OCR engines (Azure, Tesseract, EasyOCR, Mistral, Google Document AI, AWS Textract) with configurable merge strategies
|
||||
3. **AI Metadata Extraction** — The configured AI provider classifies the document and extracts structured metadata (type, dates, amounts, entities)
|
||||
4. **Enrichment** — Metadata is embedded into the PDF and stored alongside the document
|
||||
5. **Embedding Generation** — Vector embeddings for similarity search and duplicate detection
|
||||
|
||||
Steps can be customized using **Pipelines** and **Routing Rules** for conditional processing.
|
||||
|
||||
### Distribution
|
||||
Processed documents with their metadata can be automatically sent to:
|
||||
- **Dropbox**: For cloud storage and sharing
|
||||
- **Nextcloud**: For self-hosted file storage
|
||||
- **Google Drive**: For Google Workspace integration
|
||||
- **Paperless-NGX**: For advanced document management with search capabilities
|
||||
|
||||
Users can choose to send documents to any combination of these destinations through configuration settings or manual selection.
|
||||
Processed documents are distributed to any combination of configured destinations:
|
||||
|
||||
| Destination | Type |
|
||||
|------------|------|
|
||||
| **Dropbox** | Cloud storage |
|
||||
| **Google Drive** | Cloud storage |
|
||||
| **OneDrive** | Cloud storage |
|
||||
| **Amazon S3** | Object storage |
|
||||
| **Nextcloud** | Self-hosted cloud |
|
||||
| **WebDAV** | Protocol-based |
|
||||
| **FTP / SFTP** | File transfer |
|
||||
| **iCloud Drive** | Apple cloud |
|
||||
| **Email (SMTP)** | Send as attachment |
|
||||
| **Paperless-ngx** | Document management system |
|
||||
| **Rclone** | 70+ cloud providers via Rclone |
|
||||
|
||||
## Features
|
||||
|
||||
- **Intuitive File Upload**:
|
||||
- Drag-and-drop file upload on both Upload and Files pages—upload anywhere on the Files page
|
||||
- Real-time upload progress with validation
|
||||
- Support for PDF, Office documents, images, and more (up to 500MB per file)
|
||||
- **Browser Extension**:
|
||||
- Send files directly from your browser to DocuElevate with one click
|
||||
- Compatible with Chrome, Firefox, Edge, and other Chromium-based browsers
|
||||
- Context menu integration for quick access
|
||||
- See [Browser Extension Guide](docs/BrowserExtension.md) for installation and usage
|
||||
- **Document Upload & Storage**:
|
||||
- Manual uploads (via API or UI) to Dropbox, Nextcloud, Google Drive, or Paperless
|
||||
- **OCR Processing (Azure)**:
|
||||
- Extract text from scanned PDFs using Azure Document Intelligence
|
||||
- **Metadata Extraction (AI Provider)**:
|
||||
- Use any supported AI provider (OpenAI, Anthropic, Gemini, Ollama, etc.) to classify, label, or otherwise enrich the text with structured metadata
|
||||
- **PDF Conversion (Gotenberg)**:
|
||||
- Convert non-PDF attachments (e.g., Word docs, images) into PDFs
|
||||
- **Document Management (Paperless NGX)**:
|
||||
- Store processed documents and metadata in a Paperless NGX instance
|
||||
- **IMAP Integration**:
|
||||
- Fetch documents from multiple mailboxes (including Gmail) and automatically enqueue them for processing
|
||||
- **Authentication**:
|
||||
- Secure access to the system using **Authentik** for OAuth2-based login
|
||||
### Document Processing
|
||||
- **Multi-engine OCR** with quality checks and configurable merge strategies (AI merge, longest, primary)
|
||||
- **AI metadata extraction** using any supported provider (OpenAI, Anthropic, Gemini, Ollama, OpenRouter, Portkey, Azure OpenAI)
|
||||
- **PDF conversion** via Gotenberg with optional PDF/A archival format
|
||||
- **Duplicate detection** — exact (SHA-256) and near-duplicate (content similarity with vector embeddings)
|
||||
- **Customizable pipelines** — define multi-step processing workflows with conditional routing rules
|
||||
|
||||
## Frameworks Used
|
||||
### Document Management
|
||||
- **Full-text search** powered by Meilisearch with saved searches
|
||||
- **File detail view** with metadata, text preview, processing history, and similarity analysis
|
||||
- **Shared links** for public document access with expiration controls
|
||||
- **Bulk operations** — reprocess, delete, or reassign documents in batch
|
||||
|
||||
- **FastAPI**: High-performance web framework for APIs.
|
||||
- **Celery**: Task queue for asynchronous processing.
|
||||
- **Redis**: Message broker and result backend.
|
||||
- **SQLAlchemy**: ORM for database interactions.
|
||||
- **Tailwind CSS**: Utility-first CSS framework.
|
||||
- **Docker**: Containerization for easy deployment.
|
||||
### Multi-Channel Ingestion
|
||||
- **Web UI** — drag-and-drop upload with real-time progress
|
||||
- **Browser extension** — clip web pages or send files from Chrome, Firefox, Edge ([guide](docs/BrowserExtension.md))
|
||||
- **Mobile app** — iOS and Android with camera capture, push notifications, and SSO ([guide](docs/MobileApp.md))
|
||||
- **CLI tool** — batch uploads, downloads, search, and API-token management ([guide](docs/CLIGuide.md))
|
||||
- **REST API & GraphQL** — full programmatic access with Swagger documentation at `/docs`
|
||||
- **IMAP email** — poll multiple mailboxes with attachment filtering and auto-processing
|
||||
- **Watched folders** — local filesystem, FTP, SFTP, and cloud storage providers
|
||||
|
||||
### Administration
|
||||
- **Multi-user mode** with per-user document isolation and ownership
|
||||
- **Subscription & billing** — Stripe integration with configurable plans and quotas
|
||||
- **Scheduled jobs** — IMAP polling, watched folder scans, automated backups, uptime monitoring
|
||||
- **Audit logging** with SIEM integration support
|
||||
- **Compliance templates** — GDPR, HIPAA, SOC 2
|
||||
- **Admin dashboard** — user management, queue monitoring, credential management, backup/restore
|
||||
|
||||
### Authentication & Security
|
||||
- **Local accounts** with self-service registration and password reset
|
||||
- **OAuth2/OIDC** via Authentik or any OIDC provider
|
||||
- **Social login** — Google, Microsoft, Apple, Dropbox
|
||||
- **API tokens** for CLI, mobile, and automation access
|
||||
- **Security headers** — HSTS, CSP, X-Frame-Options, X-Content-Type-Options
|
||||
- **Rate limiting** with configurable per-endpoint controls
|
||||
|
||||
### Notifications
|
||||
- **100+ notification backends** via Apprise — Discord, Telegram, Slack, Microsoft Teams, Email, webhooks, and more
|
||||
- **Configurable events** — task failures, credential issues, file processed, user signup, payment issues
|
||||
- **In-app notification inbox** with per-user preferences
|
||||
- **Webhooks** — push events to external systems with HMAC signature verification and retry
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Component | Technology |
|
||||
|-----------|-----------|
|
||||
| **Backend** | FastAPI, Celery, Redis, SQLAlchemy, Alembic |
|
||||
| **Frontend** | Jinja2, Tailwind CSS |
|
||||
| **Search** | Meilisearch |
|
||||
| **Mobile** | React Native (Expo) — iOS & Android |
|
||||
| **AI** | LiteLLM (OpenAI, Anthropic, Gemini, Ollama, OpenRouter, Portkey) |
|
||||
| **OCR** | Azure Document Intelligence, Tesseract, EasyOCR, Mistral, Google Doc AI, AWS Textract |
|
||||
| **PDF** | Gotenberg, pypdf |
|
||||
| **Auth** | Authlib (OAuth2/OIDC), MSAL, social providers |
|
||||
| **Infrastructure** | Docker, Docker Compose, Helm/Kubernetes |
|
||||
| **Docs** | MkDocs Material |
|
||||
|
||||
## Quick Start
|
||||
|
||||
For detailed installation and deployment instructions, please refer to the [Deployment Guide](docs/DeploymentGuide.md).
|
||||
For detailed installation and deployment instructions, see the [Deployment Guide](docs/DeploymentGuide.md).
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
@@ -147,20 +180,96 @@ cd DocuElevate
|
||||
|
||||
# Configure environment variables
|
||||
cp .env.demo .env
|
||||
# Edit .env with your settings
|
||||
# Edit .env with your settings (see Configuration Guide for all options)
|
||||
|
||||
# Run with Docker Compose
|
||||
docker-compose up -d
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
The API will be available at **`http://localhost:8000`**, and the API documentation is available at **`http://localhost:8000/docs`**.
|
||||
The web UI is available at **`http://localhost:8000`** and the interactive API documentation at **`http://localhost:8000/docs`**.
|
||||
|
||||
### Kubernetes / Helm
|
||||
|
||||
```bash
|
||||
helm repo add docuelevate https://christianlouis.github.io/DocuElevate
|
||||
helm install docuelevate docuelevate/docuelevate -f values.yaml
|
||||
```
|
||||
|
||||
See the [Kubernetes Deployment Guide](docs/KubernetesDeployment.md) for full details.
|
||||
|
||||
## Documentation
|
||||
|
||||
### Getting Started
|
||||
|
||||
| Guide | Description |
|
||||
|-------|-------------|
|
||||
| [Setup Wizard](docs/SetupWizard.md) | Interactive first-run setup |
|
||||
| [User Guide](docs/UserGuide.md) | How to use DocuElevate |
|
||||
| [Browser Extension](docs/BrowserExtension.md) | Install and use the browser extension |
|
||||
| [Mobile App](docs/MobileApp.md) | iOS and Android mobile app |
|
||||
| [CLI Guide](docs/CLIGuide.md) | Command-line tool for automation |
|
||||
|
||||
### How-To Guides
|
||||
|
||||
| Guide | Description |
|
||||
|-------|-------------|
|
||||
| [How-To Overview](docs/HowToGuides.md) | Index of all how-to guides |
|
||||
| [Email Ingestion](docs/howto/EmailIngestion.md) | Set up IMAP email polling |
|
||||
| [Watched Folder](docs/howto/WatchedFolderSetup.md) | Monitor local or remote folders |
|
||||
| [Mobile Scanning](docs/howto/MobileScanning.md) | Scan documents with your phone |
|
||||
|
||||
### Reference
|
||||
|
||||
| Guide | Description |
|
||||
|-------|-------------|
|
||||
| [API Documentation](docs/API.md) | REST & GraphQL API reference |
|
||||
| [Configuration Guide](docs/ConfigurationGuide.md) | All environment variables |
|
||||
| [Configuration Master](docs/ConfigurationMaster.md) | Configuration overview |
|
||||
| [Settings Management](docs/SettingsManagement.md) | Runtime settings UI |
|
||||
|
||||
### Deployment & Operations
|
||||
|
||||
| Guide | Description |
|
||||
|-------|-------------|
|
||||
| [Deployment Guide](docs/DeploymentGuide.md) | Docker Compose deployment |
|
||||
| [Kubernetes / Helm](docs/KubernetesDeployment.md) | Kubernetes deployment with Helm charts |
|
||||
| [Production Readiness](docs/ProductionReadiness.md) | Checklist for production environments |
|
||||
| [Database Configuration](docs/DatabaseConfiguration.md) | Database setup and migration |
|
||||
| [Backup & Restore](docs/ConfigurationGuide.md#backup--restore) | Automated backup configuration |
|
||||
|
||||
### Storage Integration Setup
|
||||
|
||||
| Guide | Description |
|
||||
|-------|-------------|
|
||||
| [Dropbox](docs/DropboxSetup.md) | Dropbox OAuth setup |
|
||||
| [Google Drive](docs/GoogleDriveSetup.md) | Google Drive service account / OAuth |
|
||||
| [OneDrive](docs/OneDriveSetup.md) | Microsoft OneDrive setup |
|
||||
| [Amazon S3](docs/AmazonS3Setup.md) | S3 bucket configuration |
|
||||
| [Authentication](docs/AuthenticationSetup.md) | OAuth2, OIDC, and social login |
|
||||
| [Notifications](docs/NotificationsSetup.md) | Notification backend setup |
|
||||
|
||||
### Security & Compliance
|
||||
|
||||
| Guide | Description |
|
||||
|-------|-------------|
|
||||
| [Credential Rotation](docs/CredentialRotationGuide.md) | Rotate secrets safely |
|
||||
| [Licensing Compliance](docs/LicensingCompliance.md) | Dependency licenses |
|
||||
| [Privacy & GDPR](docs/PrivacyCompliance.md) | Privacy compliance |
|
||||
|
||||
### Development
|
||||
|
||||
| Guide | Description |
|
||||
|-------|-------------|
|
||||
| [Contributing](CONTRIBUTING.md) | Code style, commits, and PR process |
|
||||
| [Troubleshooting](docs/Troubleshooting.md) | Common issues and solutions |
|
||||
| [Configuration Troubleshooting](docs/ConfigurationTroubleshooting.md) | Configuration-specific issues |
|
||||
| [Build Metadata](docs/BuildMetadata.md) | Version and build information |
|
||||
| [Internationalization](docs/InternationalizationGuide.md) | Translation and localization |
|
||||
|
||||
## Development & Testing
|
||||
|
||||
### Running Tests
|
||||
|
||||
DocuElevate includes comprehensive test coverage. To run tests:
|
||||
|
||||
```bash
|
||||
# Install development dependencies
|
||||
pip install -r requirements-dev.txt
|
||||
@@ -175,21 +284,21 @@ pytest --cov=app --cov-report=term-missing
|
||||
pytest -m unit
|
||||
```
|
||||
|
||||
Tests are automatically configured with the necessary environment variables - **no manual setup required!**
|
||||
Tests are automatically configured with the necessary environment variables — **no manual setup required!**
|
||||
|
||||
For detailed testing information, including integration tests with Docker and authentication testing, see the [Contributing Guide](CONTRIBUTING.md#running-tests).
|
||||
For detailed testing information, see the [Contributing Guide](CONTRIBUTING.md#running-tests).
|
||||
|
||||
### Contributing
|
||||
|
||||
We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for:
|
||||
- Code style guidelines
|
||||
- Code style guidelines (Ruff for formatting and linting)
|
||||
- Commit message format (Conventional Commits)
|
||||
- Testing requirements
|
||||
- Pull request process
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the Apache License 2.0 - see the [LICENSE](LICENSE) file for details.
|
||||
This project is licensed under the Apache License 2.0 — see the [LICENSE](LICENSE) file for details.
|
||||
|
||||
## Third-Party Software
|
||||
|
||||
@@ -214,13 +323,10 @@ The following is a summary of the licenses used by our direct dependencies:
|
||||
| Uvicorn | BSD |
|
||||
| SQLAlchemy | MIT |
|
||||
| Pydantic | MIT |
|
||||
| openai | MIT |
|
||||
| litellm | MIT |
|
||||
| pypdf | BSD |
|
||||
| Requests | Apache 2.0 |
|
||||
| puremagic | MIT |
|
||||
| filetype | MIT |
|
||||
| Dropbox | MIT |
|
||||
| Dropbox SDK | MIT |
|
||||
| Azure AI Document Intelligence | MIT |
|
||||
| Authlib | BSD |
|
||||
| Starlette | BSD |
|
||||
@@ -229,15 +335,15 @@ The following is a summary of the licenses used by our direct dependencies:
|
||||
| Microsoft Graph Core | MIT |
|
||||
| MSAL | MIT |
|
||||
| Boto3 | Apache 2.0 |
|
||||
| Paramiko | LGPL-2.1|
|
||||
| Paramiko | LGPL-2.1 |
|
||||
| Apprise | MIT |
|
||||
| Redis | BSD |
|
||||
| Gotenberg | MIT |
|
||||
| Redis (py) | BSD |
|
||||
| Gotenberg Client | MIT |
|
||||
| Meilisearch | MIT |
|
||||
|
||||
For a comprehensive list of all dependencies and their licenses, run:
|
||||
|
||||
```
|
||||
```bash
|
||||
pip install pip-licenses
|
||||
pip-licenses
|
||||
|
||||
```
|
||||
|
||||
+6
-6
@@ -1,10 +1,10 @@
|
||||
DocuElevate Build Information
|
||||
==============================
|
||||
Version: 0.131.0
|
||||
Build Date: 2026-03-13T14:54:25Z
|
||||
Git Commit: a47db951295c5760e66cef1741771137ccc1b666
|
||||
Git Short SHA: a47db95
|
||||
Version: 0.147.1
|
||||
Build Date: 2026-03-16T10:45:13Z
|
||||
Git Commit: fd15c3666547405bb0a3af37e98be4727ff635bb
|
||||
Git Short SHA: fd15c36
|
||||
Git Branch: main
|
||||
Commit Date: 2026-03-13T15:54:02+01:00
|
||||
Build Timestamp: 2026-03-13T14:54:25Z
|
||||
Commit Date: 2026-03-16T11:44:51+01:00
|
||||
Build Timestamp: 2026-03-16T10:45:13Z
|
||||
==============================
|
||||
|
||||
+49
-46
@@ -6,7 +6,7 @@ import logging
|
||||
import os
|
||||
from typing import Annotated, Optional
|
||||
|
||||
import requests
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -132,57 +132,60 @@ async def test_dropbox_token(request: Request):
|
||||
"message": "Dropbox credentials are not fully configured",
|
||||
}
|
||||
|
||||
# Check token validity by getting current account info
|
||||
headers = {"Authorization": f"Bearer {settings.dropbox_refresh_token}"}
|
||||
response = requests.post(
|
||||
"https://api.dropboxapi.com/2/users/get_current_account",
|
||||
headers=headers,
|
||||
timeout=settings.http_request_timeout,
|
||||
)
|
||||
|
||||
# If token is invalid, try refreshing it
|
||||
if response.status_code == 401:
|
||||
logger.info("Dropbox access token invalid or expired, trying to refresh")
|
||||
|
||||
# Get a new access token using the refresh token
|
||||
refresh_url = "https://api.dropbox.com/oauth2/token"
|
||||
refresh_data = {
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": settings.dropbox_refresh_token,
|
||||
"client_id": settings.dropbox_app_key,
|
||||
"client_secret": settings.dropbox_app_secret,
|
||||
}
|
||||
|
||||
refresh_response = requests.post(refresh_url, data=refresh_data, timeout=settings.http_request_timeout)
|
||||
|
||||
if refresh_response.status_code != 200:
|
||||
logger.error(f"Failed to refresh Dropbox token: {refresh_response.text}")
|
||||
return {
|
||||
"status": "error",
|
||||
"message": "Refresh token has expired or is invalid",
|
||||
"needs_reauth": True,
|
||||
}
|
||||
|
||||
token_info = refresh_response.json()
|
||||
access_token = token_info.get("access_token")
|
||||
|
||||
# Try again with the new access token
|
||||
headers = {"Authorization": f"Bearer {access_token}"}
|
||||
response = requests.post(
|
||||
async with httpx.AsyncClient() as client:
|
||||
# Check token validity by getting current account info
|
||||
headers = {"Authorization": f"Bearer {settings.dropbox_refresh_token}"}
|
||||
response = await client.post(
|
||||
"https://api.dropboxapi.com/2/users/get_current_account",
|
||||
headers=headers,
|
||||
timeout=settings.http_request_timeout,
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.error(f"Dropbox token test failed: {response.status_code} {response.text}")
|
||||
return {
|
||||
"status": "error",
|
||||
"message": f"Token validation failed with status {response.status_code}: {response.text}",
|
||||
}
|
||||
# If token is invalid, try refreshing it
|
||||
if response.status_code == 401:
|
||||
logger.info("Dropbox access token invalid or expired, trying to refresh")
|
||||
|
||||
# Get account info
|
||||
account_info = response.json()
|
||||
# Get a new access token using the refresh token
|
||||
refresh_url = "https://api.dropbox.com/oauth2/token"
|
||||
refresh_data = {
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": settings.dropbox_refresh_token,
|
||||
"client_id": settings.dropbox_app_key,
|
||||
"client_secret": settings.dropbox_app_secret,
|
||||
}
|
||||
|
||||
refresh_response = await client.post(
|
||||
refresh_url, data=refresh_data, timeout=settings.http_request_timeout
|
||||
)
|
||||
|
||||
if refresh_response.status_code != 200:
|
||||
logger.error(f"Failed to refresh Dropbox token: {refresh_response.text}")
|
||||
return {
|
||||
"status": "error",
|
||||
"message": "Refresh token has expired or is invalid",
|
||||
"needs_reauth": True,
|
||||
}
|
||||
|
||||
token_info = refresh_response.json()
|
||||
access_token = token_info.get("access_token")
|
||||
|
||||
# Try again with the new access token
|
||||
headers = {"Authorization": f"Bearer {access_token}"}
|
||||
response = await client.post(
|
||||
"https://api.dropboxapi.com/2/users/get_current_account",
|
||||
headers=headers,
|
||||
timeout=settings.http_request_timeout,
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.error(f"Dropbox token test failed: {response.status_code} {response.text}")
|
||||
return {
|
||||
"status": "error",
|
||||
"message": f"Token validation failed with status {response.status_code}: {response.text}",
|
||||
}
|
||||
|
||||
# Get account info
|
||||
account_info = response.json()
|
||||
account_email = account_info.get("email", "Unknown account")
|
||||
account_name = account_info.get("name", {}).get("display_name", "Unknown user")
|
||||
|
||||
|
||||
+28
-23
@@ -73,33 +73,38 @@ def list_duplicate_groups(
|
||||
groups = []
|
||||
total_duplicate_files = 0
|
||||
|
||||
for filehash in dup_hashes:
|
||||
# Find the original (non-duplicate) record with this hash
|
||||
original = (
|
||||
db.query(FileRecord)
|
||||
.filter(FileRecord.filehash == filehash, FileRecord.is_duplicate.is_(False))
|
||||
.order_by(FileRecord.id.asc())
|
||||
.first()
|
||||
if dup_hashes:
|
||||
# Fetch all matching files (both original and duplicates) in a single batch query
|
||||
all_records = (
|
||||
db.query(FileRecord).filter(FileRecord.filehash.in_(dup_hashes)).order_by(FileRecord.id.asc()).all()
|
||||
)
|
||||
|
||||
# Find all duplicate records for this hash
|
||||
duplicates = (
|
||||
db.query(FileRecord)
|
||||
.filter(FileRecord.filehash == filehash, FileRecord.is_duplicate.is_(True))
|
||||
.order_by(FileRecord.id.asc())
|
||||
.all()
|
||||
)
|
||||
# Group records by hash
|
||||
originals_by_hash = {}
|
||||
duplicates_by_hash = {h: [] for h in dup_hashes}
|
||||
|
||||
total_duplicate_files += len(duplicates)
|
||||
for record in all_records:
|
||||
h = record.filehash
|
||||
if not record.is_duplicate:
|
||||
# Store only the first original record per hash, matching the old .first() behaviour
|
||||
if h not in originals_by_hash:
|
||||
originals_by_hash[h] = record
|
||||
else:
|
||||
duplicates_by_hash[h].append(record)
|
||||
total_duplicate_files += 1
|
||||
|
||||
groups.append(
|
||||
{
|
||||
"filehash": filehash,
|
||||
"original": _file_record_to_dict(original) if original else None,
|
||||
"duplicates": [_file_record_to_dict(d) for d in duplicates],
|
||||
"duplicate_count": len(duplicates),
|
||||
}
|
||||
)
|
||||
for filehash in dup_hashes:
|
||||
original = originals_by_hash.get(filehash)
|
||||
duplicates = duplicates_by_hash.get(filehash, [])
|
||||
|
||||
groups.append(
|
||||
{
|
||||
"filehash": filehash,
|
||||
"original": _file_record_to_dict(original) if original else None,
|
||||
"duplicates": [_file_record_to_dict(d) for d in duplicates],
|
||||
"duplicate_count": len(duplicates),
|
||||
}
|
||||
)
|
||||
|
||||
total_pages = (total_groups + per_page - 1) // per_page if total_groups > 0 else 1
|
||||
|
||||
|
||||
+4
-3
@@ -11,6 +11,7 @@ import zipfile
|
||||
from datetime import datetime, timezone
|
||||
from typing import Annotated, List, Optional
|
||||
|
||||
import aiofiles
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy import asc, desc
|
||||
@@ -1277,7 +1278,7 @@ async def ui_upload(request: Request, db: DbSession, file: UploadFile = File(...
|
||||
# enforcing the size limit during the read so memory usage stays bounded.
|
||||
try:
|
||||
written_size = 0
|
||||
with open(target_path, "wb") as f:
|
||||
async with aiofiles.open(target_path, "wb") as f:
|
||||
chunk_size = 65536 # 64 KB chunks
|
||||
while True:
|
||||
chunk = await file.read(chunk_size)
|
||||
@@ -1286,14 +1287,14 @@ async def ui_upload(request: Request, db: DbSession, file: UploadFile = File(...
|
||||
written_size += len(chunk)
|
||||
if written_size > max_size:
|
||||
# Exceeded limit mid-stream; clean up and reject
|
||||
f.close()
|
||||
await f.close()
|
||||
os.remove(target_path)
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail=f"File too large: exceeded {max_size} bytes during upload. "
|
||||
f"See SECURITY_AUDIT.md for configuration details.",
|
||||
)
|
||||
f.write(chunk)
|
||||
await f.write(chunk)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
|
||||
@@ -363,7 +363,7 @@ def format_time_remaining(time_delta):
|
||||
|
||||
@router.post("/google-drive/save-settings")
|
||||
@require_login
|
||||
async def save_dropbox_settings(
|
||||
async def save_google_drive_settings(
|
||||
request: Request,
|
||||
refresh_token: Annotated[str, Form(...)],
|
||||
client_id: Annotated[Optional[str], Form()] = None,
|
||||
|
||||
@@ -564,7 +564,6 @@ def _test_webdav_connection(config: dict[str, Any] | None, credentials: dict[str
|
||||
return {"success": False, "message": "Missing required field: url"}
|
||||
|
||||
# Only allow http/https to prevent file:// or other custom scheme attacks
|
||||
import ipaddress
|
||||
from urllib.parse import urlparse
|
||||
|
||||
parsed = urlparse(url)
|
||||
@@ -574,14 +573,10 @@ def _test_webdav_connection(config: dict[str, Any] | None, credentials: dict[str
|
||||
# Block requests to private/internal IPs to prevent SSRF
|
||||
hostname = parsed.hostname or ""
|
||||
if hostname:
|
||||
try:
|
||||
addr = ipaddress.ip_address(hostname)
|
||||
if addr.is_private or addr.is_loopback or addr.is_link_local:
|
||||
return {"success": False, "message": "URLs pointing to internal or private networks are not allowed"}
|
||||
except ValueError:
|
||||
# Hostname is not an IP literal — allow DNS names through
|
||||
if hostname in ("localhost", "localhost.localdomain"):
|
||||
return {"success": False, "message": "URLs pointing to localhost are not allowed"}
|
||||
from app.utils.network import is_private_ip
|
||||
|
||||
if is_private_ip(hostname):
|
||||
return {"success": False, "message": "URLs pointing to internal or private networks are not allowed"}
|
||||
|
||||
try:
|
||||
import base64
|
||||
|
||||
@@ -452,17 +452,16 @@ async def update_preferences(
|
||||
)
|
||||
|
||||
try:
|
||||
# Pre-fetch existing preferences for this user to avoid N+1 queries
|
||||
existing_prefs = (
|
||||
db.query(UserNotificationPreference).filter(UserNotificationPreference.owner_id == owner_id).all()
|
||||
)
|
||||
|
||||
# Build a fast lookup dictionary keyed by (event_type, channel_type, target_id)
|
||||
prefs_dict = {(pref.event_type, pref.channel_type, pref.target_id): pref for pref in existing_prefs}
|
||||
|
||||
for item in body.preferences:
|
||||
existing = (
|
||||
db.query(UserNotificationPreference)
|
||||
.filter(
|
||||
UserNotificationPreference.owner_id == owner_id,
|
||||
UserNotificationPreference.event_type == item.event_type,
|
||||
UserNotificationPreference.channel_type == item.channel_type,
|
||||
UserNotificationPreference.target_id == item.target_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
existing = prefs_dict.get((item.event_type, item.channel_type, item.target_id))
|
||||
if existing:
|
||||
existing.is_enabled = item.is_enabled
|
||||
else:
|
||||
|
||||
+19
-93
@@ -3,7 +3,6 @@ OneDrive API endpoints
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Annotated, Optional
|
||||
|
||||
@@ -14,6 +13,7 @@ from sqlalchemy.orm import Session
|
||||
from app.auth import require_login
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
from app.utils.env_utils import update_env_file
|
||||
from app.utils.oauth_helper import exchange_oauth_token
|
||||
from app.utils.settings_service import save_setting_to_db
|
||||
from app.utils.settings_sync import notify_settings_updated
|
||||
@@ -115,32 +115,7 @@ async def test_onedrive_token(request: Request):
|
||||
settings.onedrive_refresh_token = new_refresh_token
|
||||
|
||||
# Also try to update .env file if it exists
|
||||
try:
|
||||
env_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), ".env")
|
||||
if os.path.exists(env_path):
|
||||
with open(env_path, "r") as f:
|
||||
env_lines = f.readlines()
|
||||
|
||||
updated_lines = []
|
||||
updated = False
|
||||
|
||||
for line in env_lines:
|
||||
if line.startswith("ONEDRIVE_REFRESH_TOKEN="):
|
||||
updated_lines.append(f"ONEDRIVE_REFRESH_TOKEN={new_refresh_token}\n")
|
||||
updated = True
|
||||
else:
|
||||
updated_lines.append(line)
|
||||
|
||||
if not updated:
|
||||
updated_lines.append(f"ONEDRIVE_REFRESH_TOKEN={new_refresh_token}\n")
|
||||
|
||||
with open(env_path, "w") as f:
|
||||
f.writelines(updated_lines)
|
||||
|
||||
logger.info("Updated refresh token in .env file")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to update refresh token in .env file: {e}")
|
||||
update_env_file({"ONEDRIVE_REFRESH_TOKEN": new_refresh_token})
|
||||
|
||||
# Persist the rotated refresh token to the database
|
||||
try:
|
||||
@@ -246,75 +221,26 @@ async def save_onedrive_settings(
|
||||
user.get("preferred_username") or user.get("username") or user.get("email") or user.get("id") or "wizard"
|
||||
)
|
||||
|
||||
# Best-effort .env file write
|
||||
try:
|
||||
env_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), ".env")
|
||||
if not os.path.exists(env_path):
|
||||
logger.warning(f".env file not found at {env_path}, skipping file write")
|
||||
else:
|
||||
logger.info(f"Updating OneDrive settings in {env_path}")
|
||||
# Build settings dictionary mapped to database/memory keys
|
||||
onedrive_settings = {
|
||||
"onedrive_refresh_token": refresh_token,
|
||||
"onedrive_client_id": client_id,
|
||||
"onedrive_client_secret": client_secret,
|
||||
"onedrive_tenant_id": tenant_id,
|
||||
"onedrive_folder_path": folder_path,
|
||||
}
|
||||
|
||||
with open(env_path, "r") as f:
|
||||
env_lines = f.readlines()
|
||||
# Filter out None values
|
||||
onedrive_settings = {k: v for k, v in onedrive_settings.items() if v is not None}
|
||||
|
||||
onedrive_settings = {"ONEDRIVE_REFRESH_TOKEN": refresh_token}
|
||||
if client_id:
|
||||
onedrive_settings["ONEDRIVE_CLIENT_ID"] = client_id
|
||||
if client_secret:
|
||||
onedrive_settings["ONEDRIVE_CLIENT_SECRET"] = client_secret
|
||||
if tenant_id:
|
||||
onedrive_settings["ONEDRIVE_TENANT_ID"] = tenant_id
|
||||
if folder_path:
|
||||
onedrive_settings["ONEDRIVE_FOLDER_PATH"] = folder_path
|
||||
# Best-effort .env file write using the new utility
|
||||
env_settings = {k.upper(): v for k, v in onedrive_settings.items()}
|
||||
update_env_file(env_settings)
|
||||
|
||||
updated = set()
|
||||
new_env_lines = []
|
||||
for line in env_lines:
|
||||
stripped_line = line.rstrip()
|
||||
is_updated = False
|
||||
for key, value in onedrive_settings.items():
|
||||
if stripped_line.startswith(f"{key}=") or stripped_line.startswith(f"# {key}="):
|
||||
new_env_lines.append(f"{key}={value}")
|
||||
updated.add(key)
|
||||
is_updated = True
|
||||
break
|
||||
if not is_updated:
|
||||
new_env_lines.append(stripped_line)
|
||||
|
||||
for key, value in onedrive_settings.items():
|
||||
if key not in updated:
|
||||
new_env_lines.append(f"{key}={value}")
|
||||
|
||||
with open(env_path, "w") as f:
|
||||
f.write("\n".join(new_env_lines) + "\n")
|
||||
|
||||
logger.info("Successfully updated OneDrive settings in .env file")
|
||||
except Exception as env_err:
|
||||
logger.warning(f"Failed to write .env file (non-fatal): {env_err}")
|
||||
|
||||
# Update the settings in memory
|
||||
if refresh_token:
|
||||
settings.onedrive_refresh_token = refresh_token
|
||||
if client_id:
|
||||
settings.onedrive_client_id = client_id
|
||||
if client_secret:
|
||||
settings.onedrive_client_secret = client_secret
|
||||
if tenant_id:
|
||||
settings.onedrive_tenant_id = tenant_id
|
||||
if folder_path:
|
||||
settings.onedrive_folder_path = folder_path
|
||||
|
||||
# Persist to database (primary)
|
||||
if refresh_token:
|
||||
save_setting_to_db(db, "onedrive_refresh_token", refresh_token, changed_by=changed_by)
|
||||
if client_id:
|
||||
save_setting_to_db(db, "onedrive_client_id", client_id, changed_by=changed_by)
|
||||
if client_secret:
|
||||
save_setting_to_db(db, "onedrive_client_secret", client_secret, changed_by=changed_by)
|
||||
if tenant_id:
|
||||
save_setting_to_db(db, "onedrive_tenant_id", tenant_id, changed_by=changed_by)
|
||||
if folder_path:
|
||||
save_setting_to_db(db, "onedrive_folder_path", folder_path, changed_by=changed_by)
|
||||
# Update in-memory settings and persist to database dynamically
|
||||
for key, value in onedrive_settings.items():
|
||||
setattr(settings, key, value)
|
||||
save_setting_to_db(db, key, value, changed_by=changed_by)
|
||||
|
||||
notify_settings_updated()
|
||||
|
||||
|
||||
+11
-2
@@ -196,11 +196,20 @@ def seed_plans(db: DbSession, _admin: AdminUser) -> dict[str, Any]:
|
||||
def reorder_plans(body: ReorderBody, db: DbSession, _admin: AdminUser) -> dict[str, Any]:
|
||||
"""Update sort_order for each plan_id in *body.order* (position = index in list)."""
|
||||
updated = 0
|
||||
for sort_order, plan_id in enumerate(body.order):
|
||||
plan = db.query(SubscriptionPlan).filter(SubscriptionPlan.plan_id == plan_id).first()
|
||||
|
||||
# Fetch all requested plans in a single query to avoid N+1
|
||||
plan_ids = body.order
|
||||
plans = db.query(SubscriptionPlan).filter(SubscriptionPlan.plan_id.in_(plan_ids)).all()
|
||||
|
||||
# Build a map for fast O(1) lookup
|
||||
plan_map = {p.plan_id: p for p in plans}
|
||||
|
||||
for sort_order, plan_id in enumerate(plan_ids):
|
||||
plan = plan_map.get(plan_id)
|
||||
if plan:
|
||||
plan.sort_order = sort_order
|
||||
updated += 1
|
||||
|
||||
try:
|
||||
db.commit()
|
||||
except Exception:
|
||||
|
||||
+22
-2
@@ -18,7 +18,7 @@ import logging
|
||||
from hashlib import md5
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Request, Response, UploadFile, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -156,7 +156,9 @@ async def get_profile(request: Request, db: DbSession) -> ProfileResponse:
|
||||
|
||||
@router.patch("", response_model=ProfileResponse)
|
||||
@require_login
|
||||
async def update_profile(body: ProfileUpdateRequest, request: Request, db: DbSession) -> ProfileResponse:
|
||||
async def update_profile(
|
||||
body: ProfileUpdateRequest, request: Request, response: Response, db: DbSession
|
||||
) -> ProfileResponse:
|
||||
"""Update the current user's editable profile settings."""
|
||||
user_id = _get_user_id(request)
|
||||
profile = _get_or_create_profile(db, user_id)
|
||||
@@ -171,6 +173,24 @@ async def update_profile(body: ProfileUpdateRequest, request: Request, db: DbSes
|
||||
)
|
||||
profile.preferred_language = lang or None # type: ignore[assignment]
|
||||
|
||||
# Keep session and cookie in sync so detect_language() picks up
|
||||
# the new preference immediately (without a DB round-trip).
|
||||
if hasattr(request, "session"):
|
||||
if lang:
|
||||
request.session["preferred_language"] = lang
|
||||
else:
|
||||
request.session.pop("preferred_language", None)
|
||||
if lang:
|
||||
response.set_cookie(
|
||||
key="docuelevate_lang",
|
||||
value=lang,
|
||||
max_age=30 * 24 * 60 * 60,
|
||||
httponly=False,
|
||||
samesite="lax",
|
||||
)
|
||||
else:
|
||||
response.delete_cookie(key="docuelevate_lang")
|
||||
|
||||
# Validate theme
|
||||
if body.preferred_theme is not None:
|
||||
theme = body.preferred_theme.lower().strip()
|
||||
|
||||
@@ -313,16 +313,18 @@ async def list_shared_links(
|
||||
active_only: bool = Query(False, description="When true, only return active (non-revoked) links"),
|
||||
) -> list[dict[str, Any]]:
|
||||
"""List all shared links created by the authenticated user."""
|
||||
q = db.query(SharedLink).filter(SharedLink.owner_id == owner_id)
|
||||
q = (
|
||||
db.query(SharedLink, FileRecord.original_filename)
|
||||
.outerjoin(FileRecord, SharedLink.file_id == FileRecord.id)
|
||||
.filter(SharedLink.owner_id == owner_id)
|
||||
)
|
||||
if active_only:
|
||||
q = q.filter(SharedLink.is_active.is_(True))
|
||||
links = q.order_by(SharedLink.created_at.desc()).all()
|
||||
links_with_filenames = q.order_by(SharedLink.created_at.desc()).all()
|
||||
|
||||
base_url = str(request.base_url).rstrip("/")
|
||||
result = []
|
||||
for link in links:
|
||||
file_record = db.query(FileRecord).filter(FileRecord.id == link.file_id).first()
|
||||
filename = file_record.original_filename if file_record else None
|
||||
for link, filename in links_with_filenames:
|
||||
result.append(_link_to_dict(link, base_url, filename))
|
||||
return result
|
||||
|
||||
|
||||
+1
-32
@@ -2,7 +2,6 @@
|
||||
API endpoint for processing files from URLs
|
||||
"""
|
||||
|
||||
import ipaddress
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
@@ -19,6 +18,7 @@ from app.config import settings
|
||||
from app.tasks.process_document import process_document
|
||||
from app.utils.allowed_types import ALLOWED_MIME_TYPES
|
||||
from app.utils.filename_utils import sanitize_filename
|
||||
from app.utils.network import is_private_ip
|
||||
|
||||
# Set up logging
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -42,37 +42,6 @@ class URLUploadRequest(BaseModel):
|
||||
return v
|
||||
|
||||
|
||||
def is_private_ip(hostname: str) -> bool:
|
||||
"""
|
||||
Check if a hostname resolves to a private/internal IP address.
|
||||
Protects against SSRF attacks by blocking access to internal networks.
|
||||
"""
|
||||
try:
|
||||
# Try to parse as IP address directly
|
||||
ip = ipaddress.ip_address(hostname)
|
||||
return ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved
|
||||
except ValueError:
|
||||
# Not a direct IP, try to resolve hostname
|
||||
try:
|
||||
import socket
|
||||
|
||||
# Get all IP addresses for this hostname
|
||||
addr_info = socket.getaddrinfo(hostname, None)
|
||||
for info in addr_info:
|
||||
ip_str = info[4][0]
|
||||
ip = ipaddress.ip_address(ip_str)
|
||||
# Block if ANY resolved IP is private/internal
|
||||
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
|
||||
return True
|
||||
return False
|
||||
except (socket.gaierror, socket.error):
|
||||
# Cannot resolve - allow for testing/development
|
||||
# In production, DNS should work properly
|
||||
# Log this for debugging
|
||||
logger.warning(f"Could not resolve hostname: {hostname}")
|
||||
return False # Changed from True to False to allow external domains in tests
|
||||
|
||||
|
||||
def validate_url_safety(url: str) -> None:
|
||||
"""
|
||||
Validate that URL is safe to fetch (SSRF protection).
|
||||
|
||||
+256
-6
@@ -4,12 +4,13 @@ import logging
|
||||
import pathlib
|
||||
from datetime import datetime, timezone
|
||||
from functools import wraps
|
||||
from urllib.parse import urlparse
|
||||
from urllib.parse import urlencode, urlparse
|
||||
|
||||
from authlib.integrations.starlette_client import OAuth
|
||||
from fastapi import APIRouter, Depends, Request, status
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
from starlette.responses import RedirectResponse
|
||||
|
||||
@@ -124,8 +125,17 @@ def get_current_user(request: Request):
|
||||
# Check for Bearer token auth first (API tokens)
|
||||
api_user = getattr(request.state, "api_token_user", None)
|
||||
if isinstance(api_user, dict):
|
||||
logger.debug("[AUTH] get_current_user: resolved from API token (user_id=%s)", api_user.get("id"))
|
||||
return api_user
|
||||
return request.session.get("user")
|
||||
session_user = request.session.get("user")
|
||||
if session_user:
|
||||
logger.debug(
|
||||
"[AUTH] get_current_user: resolved from session (user=%s)",
|
||||
session_user.get("preferred_username") or session_user.get("email") or session_user.get("id"),
|
||||
)
|
||||
else:
|
||||
logger.debug("[AUTH] get_current_user: no user in session or API token")
|
||||
return session_user
|
||||
|
||||
|
||||
def _resolve_bearer_user(request: Request, db: Session) -> dict | None:
|
||||
@@ -140,10 +150,12 @@ def _resolve_bearer_user(request: Request, db: Session) -> dict | None:
|
||||
"""
|
||||
auth_header = request.headers.get("authorization", "")
|
||||
if not isinstance(auth_header, str) or not auth_header.startswith("Bearer "):
|
||||
logger.debug("[AUTH] _resolve_bearer_user: no Bearer token in Authorization header")
|
||||
return None
|
||||
|
||||
raw_token = auth_header[7:]
|
||||
if not raw_token or not isinstance(raw_token, str):
|
||||
logger.debug("[AUTH] _resolve_bearer_user: empty or invalid token after 'Bearer ' prefix")
|
||||
return None
|
||||
|
||||
from app.api.api_tokens import hash_token
|
||||
@@ -152,8 +164,15 @@ def _resolve_bearer_user(request: Request, db: Session) -> dict | None:
|
||||
token_hash = hash_token(raw_token)
|
||||
db_token = db.query(ApiToken).filter(ApiToken.token_hash == token_hash, ApiToken.is_active.is_(True)).first()
|
||||
if db_token is None:
|
||||
logger.debug("[AUTH] _resolve_bearer_user: no active API token matched the provided hash")
|
||||
return None
|
||||
|
||||
logger.debug(
|
||||
"[AUTH] _resolve_bearer_user: matched API token id=%s owner=%s",
|
||||
db_token.id,
|
||||
db_token.owner_id,
|
||||
)
|
||||
|
||||
# Update usage tracking
|
||||
try:
|
||||
db_token.last_used_at = datetime.now(timezone.utc)
|
||||
@@ -204,16 +223,18 @@ def require_login(func):
|
||||
|
||||
@wraps(func)
|
||||
async def wrapper(request: Request, *args, **kwargs):
|
||||
url_path = urlparse(str(request.url)).path
|
||||
# Check session auth first
|
||||
if request.session.get("user"):
|
||||
logger.debug("[AUTH] require_login: session auth OK for %s", url_path)
|
||||
if inspect.iscoroutinefunction(func):
|
||||
return await func(*args, request=request, **kwargs)
|
||||
else:
|
||||
return func(*args, request=request, **kwargs)
|
||||
|
||||
# Fall back to Bearer token auth for API endpoints
|
||||
url_path = urlparse(str(request.url)).path
|
||||
if url_path.startswith("/api/"):
|
||||
logger.debug("[AUTH] require_login: no session, trying Bearer token for %s", url_path)
|
||||
try:
|
||||
from app.database import SessionLocal
|
||||
|
||||
@@ -227,17 +248,22 @@ def require_login(func):
|
||||
|
||||
if api_user:
|
||||
request.state.api_token_user = api_user
|
||||
logger.debug(
|
||||
"[AUTH] require_login: Bearer token auth OK for %s (user=%s)", url_path, api_user.get("id")
|
||||
)
|
||||
if inspect.iscoroutinefunction(func):
|
||||
return await func(*args, request=request, **kwargs)
|
||||
else:
|
||||
return func(*args, request=request, **kwargs)
|
||||
|
||||
logger.debug("[AUTH] require_login: no valid auth for API endpoint %s — returning 401", url_path)
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
content={"error": "Not authenticated"},
|
||||
)
|
||||
|
||||
# Non-API endpoint with no session — redirect to login
|
||||
logger.debug("[AUTH] require_login: no session for %s — redirecting to /login", url_path)
|
||||
request.session["redirect_after_login"] = str(request.url)
|
||||
return RedirectResponse(url="/login", status_code=status.HTTP_302_FOUND)
|
||||
|
||||
@@ -254,6 +280,38 @@ def get_gravatar_url(email):
|
||||
|
||||
async def login(request: Request):
|
||||
"""Show login page with appropriate authentication options."""
|
||||
# Persist the mobile deep-link redirect URI in the session so it survives
|
||||
# the OAuth provider round-trip and is available when auth completes.
|
||||
# Accepted schemes:
|
||||
# • "docuelevate://" — production / EAS builds (custom app scheme)
|
||||
# • "exp://" — Expo Go development client
|
||||
# Only custom (non-HTTP) schemes are accepted to prevent open-redirect abuse.
|
||||
_MOBILE_ALLOWED_SCHEMES = ("docuelevate://", "exp://")
|
||||
if request.query_params.get("mobile") == "1":
|
||||
redirect_uri = request.query_params.get("redirect_uri", "")
|
||||
logger.debug(
|
||||
"[MOBILE] Login page opened with mobile=1: redirect_uri=%r client_ip=%s",
|
||||
redirect_uri,
|
||||
get_client_ip(request),
|
||||
)
|
||||
if any(redirect_uri.startswith(s) for s in _MOBILE_ALLOWED_SCHEMES):
|
||||
request.session["mobile_redirect_uri"] = redirect_uri
|
||||
logger.info(
|
||||
"[MOBILE] Mobile redirect URI stored in session: %r",
|
||||
redirect_uri,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"[MOBILE] Rejected redirect_uri with disallowed scheme: %r (allowed: %s)",
|
||||
redirect_uri,
|
||||
", ".join(_MOBILE_ALLOWED_SCHEMES),
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
"[MOBILE] Login page opened without mobile=1 (standard browser flow) client_ip=%s",
|
||||
get_client_ip(request),
|
||||
)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"login.html",
|
||||
{
|
||||
@@ -274,9 +332,15 @@ async def login(request: Request):
|
||||
async def oauth_login(request: Request):
|
||||
"""Handle OAuth login flow"""
|
||||
if not OAUTH_CONFIGURED:
|
||||
logger.debug("[AUTH] oauth_login: OAuth not configured — redirecting to /login")
|
||||
return RedirectResponse(url="/login?error=OAuth+not+configured", status_code=status.HTTP_302_FOUND)
|
||||
|
||||
redirect_uri = request.url_for("oauth_callback")
|
||||
logger.debug(
|
||||
"[AUTH] oauth_login: initiating Authentik OAuth redirect_uri=%s session_keys=%s",
|
||||
redirect_uri,
|
||||
list(request.session.keys()),
|
||||
)
|
||||
return await oauth.authentik.authorize_redirect(request, redirect_uri)
|
||||
|
||||
|
||||
@@ -291,13 +355,23 @@ async def social_login(request: Request, provider: str):
|
||||
A redirect to the provider's authorization page, or back to /login on error.
|
||||
"""
|
||||
if provider not in SOCIAL_PROVIDERS:
|
||||
logger.debug(
|
||||
"[AUTH] social_login: unknown provider=%r (registered=%s)", provider, list(SOCIAL_PROVIDERS.keys())
|
||||
)
|
||||
return RedirectResponse(url="/login?error=Unknown+social+provider", status_code=status.HTTP_302_FOUND)
|
||||
|
||||
redirect_uri = request.url_for("social_callback", provider=provider)
|
||||
oauth_client = getattr(oauth, provider, None)
|
||||
if oauth_client is None:
|
||||
logger.debug("[AUTH] social_login: provider=%r registered but OAuth client not configured", provider)
|
||||
return RedirectResponse(url="/login?error=Provider+not+configured", status_code=status.HTTP_302_FOUND)
|
||||
|
||||
logger.debug(
|
||||
"[AUTH] social_login: initiating %s OAuth, redirect_uri=%s session_keys=%s",
|
||||
provider,
|
||||
redirect_uri,
|
||||
list(request.session.keys()),
|
||||
)
|
||||
return await oauth_client.authorize_redirect(request, redirect_uri)
|
||||
|
||||
|
||||
@@ -356,27 +430,39 @@ async def social_callback(request: Request, provider: str, db: Session = Depends
|
||||
A redirect to the user's original destination or the upload page.
|
||||
"""
|
||||
if provider not in SOCIAL_PROVIDERS:
|
||||
logger.debug("[AUTH] social_callback: unknown provider=%r", provider)
|
||||
return RedirectResponse(url="/login?error=Unknown+social+provider", status_code=status.HTTP_302_FOUND)
|
||||
|
||||
oauth_client = getattr(oauth, provider, None)
|
||||
if oauth_client is None:
|
||||
logger.debug("[AUTH] social_callback: provider=%r not configured", provider)
|
||||
return RedirectResponse(url="/login?error=Provider+not+configured", status_code=status.HTTP_302_FOUND)
|
||||
|
||||
try:
|
||||
logger.debug("[AUTH] social_callback: exchanging auth code for provider=%s", provider)
|
||||
token = await oauth_client.authorize_access_token(request)
|
||||
|
||||
# Try standard OIDC userinfo first, fall back to token-embedded userinfo
|
||||
raw_userinfo = token.get("userinfo")
|
||||
if not raw_userinfo:
|
||||
logger.debug("[AUTH] social_callback: no userinfo in token, fetching from userinfo endpoint")
|
||||
try:
|
||||
resp = await oauth_client.userinfo(token=token)
|
||||
raw_userinfo = resp if isinstance(resp, dict) else resp.json() if hasattr(resp, "json") else {}
|
||||
except Exception:
|
||||
logger.debug("[AUTH] social_callback: userinfo endpoint failed, using empty dict", exc_info=True)
|
||||
raw_userinfo = {}
|
||||
|
||||
user_data = _normalize_social_userinfo(provider, token, raw_userinfo)
|
||||
logger.debug(
|
||||
"[AUTH] social_callback: normalized user_data email=%s sub=%s provider=%s",
|
||||
user_data.get("email"),
|
||||
user_data.get("sub"),
|
||||
provider,
|
||||
)
|
||||
|
||||
if not user_data.get("email"):
|
||||
logger.debug("[AUTH] social_callback: no email in user_data — aborting")
|
||||
return RedirectResponse(
|
||||
url="/login?error=Could+not+retrieve+email+from+provider",
|
||||
status_code=status.HTTP_302_FOUND,
|
||||
@@ -407,17 +493,31 @@ 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.
|
||||
logger.debug(
|
||||
"[MOBILE] social_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] social_callback: returning mobile redirect response for provider=%s", provider)
|
||||
return mobile_resp
|
||||
|
||||
if user_id:
|
||||
profile = db.query(_UserProfile).filter(_UserProfile.user_id == user_id).first()
|
||||
if profile and not profile.onboarding_completed:
|
||||
logger.debug("[AUTH] social_callback: user=%s needs onboarding, redirecting", user_id)
|
||||
post_onboarding = request.session.pop("redirect_after_login", "/upload")
|
||||
request.session["post_onboarding_redirect"] = post_onboarding
|
||||
return RedirectResponse(url="/onboarding", status_code=status.HTTP_302_FOUND)
|
||||
|
||||
redirect_url = request.session.pop("redirect_after_login", "/upload")
|
||||
logger.debug("[AUTH] social_callback: login complete, redirecting to %s", redirect_url)
|
||||
return RedirectResponse(url=redirect_url, status_code=status.HTTP_302_FOUND)
|
||||
except Exception as e:
|
||||
logger.warning("[SECURITY] SOCIAL_LOGIN_FAILURE provider=%s error=%s", provider, type(e).__name__)
|
||||
logger.debug("[AUTH] social_callback: full exception for provider=%s", provider, exc_info=True)
|
||||
return RedirectResponse(
|
||||
url="/login?error=Social+login+failed.+Please+try+again.", status_code=status.HTTP_302_FOUND
|
||||
)
|
||||
@@ -522,15 +622,23 @@ def _ensure_user_profile(db: Session, user_data: dict, is_admin: bool = False) -
|
||||
async def oauth_callback(request: Request, db: Session = Depends(get_db)):
|
||||
"""Handle OAuth callback from provider"""
|
||||
try:
|
||||
logger.debug("[AUTH] oauth_callback: exchanging authorization code for token")
|
||||
token = await oauth.authentik.authorize_access_token(request)
|
||||
userinfo = token.get("userinfo")
|
||||
if not userinfo:
|
||||
logger.debug("[AUTH] oauth_callback: no userinfo in token response — aborting")
|
||||
return RedirectResponse(
|
||||
url="/login?error=Failed+to+retrieve+user+information", status_code=status.HTTP_302_FOUND
|
||||
)
|
||||
|
||||
# Store user info in session
|
||||
user_data = dict(userinfo)
|
||||
logger.debug(
|
||||
"[AUTH] oauth_callback: received userinfo email=%s sub=%s groups=%s",
|
||||
user_data.get("email"),
|
||||
user_data.get("sub"),
|
||||
user_data.get("groups", []),
|
||||
)
|
||||
|
||||
# Add Gravatar picture if no picture is provided
|
||||
if not user_data.get("picture") and user_data.get("email"):
|
||||
@@ -545,6 +653,12 @@ async def oauth_callback(request: Request, db: Session = Depends(get_db)):
|
||||
groups = user_data.get("groups", [])
|
||||
admin_group = (settings.admin_group_name or "admin").strip().lower()
|
||||
is_admin = admin_group in [group.lower() for group in groups]
|
||||
logger.debug(
|
||||
"[AUTH] oauth_callback: admin group check — looking for %r in %s → is_admin=%s",
|
||||
admin_group,
|
||||
[g.lower() for g in groups],
|
||||
is_admin,
|
||||
)
|
||||
|
||||
# Set is_admin flag (defaults to False for OAuth users unless they're in admin group)
|
||||
user_data["is_admin"] = is_admin
|
||||
@@ -568,18 +682,34 @@ 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:
|
||||
logger.debug("[AUTH] oauth_callback: user=%s needs onboarding, redirecting", user_id)
|
||||
post_onboarding = request.session.pop("redirect_after_login", "/upload")
|
||||
request.session["post_onboarding_redirect"] = post_onboarding
|
||||
return RedirectResponse(url="/onboarding", status_code=status.HTTP_302_FOUND)
|
||||
|
||||
# Redirect to original destination or default
|
||||
redirect_url = request.session.pop("redirect_after_login", "/upload")
|
||||
logger.debug("[AUTH] oauth_callback: login complete, redirecting to %s", redirect_url)
|
||||
return RedirectResponse(url=redirect_url, status_code=status.HTTP_302_FOUND)
|
||||
except Exception as e:
|
||||
logger.warning(f"[SECURITY] OAUTH_LOGIN_FAILURE error={type(e).__name__}")
|
||||
logger.debug("[AUTH] oauth_callback: full exception details", exc_info=True)
|
||||
return RedirectResponse(url=f"/login?error=Authentication+failed:+{str(e)}", status_code=status.HTTP_302_FOUND)
|
||||
|
||||
|
||||
@@ -625,6 +755,98 @@ def _record_login_event(
|
||||
logger.debug("Failed to write login audit event for user=%s", username, exc_info=True)
|
||||
|
||||
|
||||
def _create_mobile_redirect(request: Request, db: Session) -> RedirectResponse | None:
|
||||
"""Generate a mobile API token and return a redirect to the mobile app.
|
||||
|
||||
If ``mobile_redirect_uri`` is stored in the session (set when the login
|
||||
page was opened with ``?mobile=1&redirect_uri=docuelevate://...``), this
|
||||
function creates a long-lived API token, appends it as a ``?token=``
|
||||
query parameter to the redirect URI, and returns the redirect so that
|
||||
``WebBrowser.openAuthSessionAsync`` in the Expo app intercepts the
|
||||
deep link and stores the token.
|
||||
|
||||
Returns ``None`` when the request is not part of a mobile SSO flow.
|
||||
|
||||
Args:
|
||||
request: The current FastAPI request. The ``user`` dict must already
|
||||
be stored in ``request.session`` before calling this function.
|
||||
db: Active database session used to persist the new API token.
|
||||
|
||||
Returns:
|
||||
A ``RedirectResponse`` to the deep-link URI with ``?token=<plaintext>``,
|
||||
or ``None`` if no mobile redirect URI is pending.
|
||||
"""
|
||||
mobile_redirect_uri = request.session.pop("mobile_redirect_uri", None)
|
||||
if not mobile_redirect_uri:
|
||||
logger.debug("[MOBILE] _create_mobile_redirect: no mobile_redirect_uri in session — skipping mobile flow")
|
||||
return None
|
||||
|
||||
logger.info(
|
||||
"[MOBILE] _create_mobile_redirect: mobile flow detected, redirect_uri=%r",
|
||||
mobile_redirect_uri,
|
||||
)
|
||||
|
||||
user = request.session.get("user") or {}
|
||||
owner_id = user.get("sub") or user.get("preferred_username") or user.get("email") or user.get("id")
|
||||
logger.debug(
|
||||
"[MOBILE] Resolving owner_id from session user: sub=%r preferred_username=%r email=%r id=%r → owner_id=%r",
|
||||
user.get("sub"),
|
||||
user.get("preferred_username"),
|
||||
user.get("email"),
|
||||
user.get("id"),
|
||||
owner_id,
|
||||
)
|
||||
if not owner_id:
|
||||
logger.warning("Mobile SSO redirect requested but no owner_id could be resolved from session")
|
||||
return None
|
||||
|
||||
# Lazy imports to avoid circular dependency via app.api.__init__
|
||||
from app.api.api_tokens import generate_api_token, hash_token
|
||||
from app.models import ApiToken as _ApiToken
|
||||
|
||||
plaintext = generate_api_token()
|
||||
token_hash_value = hash_token(plaintext)
|
||||
prefix = plaintext[:12]
|
||||
|
||||
db_token = _ApiToken(
|
||||
owner_id=owner_id,
|
||||
name="Mobile App",
|
||||
token_hash=token_hash_value,
|
||||
token_prefix=prefix,
|
||||
)
|
||||
try:
|
||||
db.add(db_token)
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
logger.exception("Failed to create mobile API token for owner_id=%s", owner_id)
|
||||
return None
|
||||
|
||||
# Safely append the token as a query parameter, preserving any existing params.
|
||||
separator = "&" if "?" in mobile_redirect_uri else "?"
|
||||
redirect_url = f"{mobile_redirect_uri}{separator}{urlencode({'token': plaintext})}"
|
||||
|
||||
# Log the full redirect URL at DEBUG so it's visible when debug logging is enabled.
|
||||
# At INFO level, log a sanitised version (scheme + host only, token prefix only)
|
||||
# so the plaintext token is never written to persistent info logs.
|
||||
parsed = urlparse(redirect_url)
|
||||
existing_params = f"&{parsed.query.replace(f'token={plaintext}', '')}" if parsed.query else ""
|
||||
sanitised_url = (
|
||||
f"{parsed.scheme}://{parsed.netloc}{parsed.path}?token={prefix}…[redacted]{existing_params.rstrip('&')}"
|
||||
)
|
||||
logger.info(
|
||||
"[MOBILE] MOBILE_SSO_TOKEN_ISSUED owner=%s token_id=%s redirect_target=%s",
|
||||
owner_id,
|
||||
db_token.id,
|
||||
sanitised_url,
|
||||
)
|
||||
logger.debug(
|
||||
"[MOBILE] Full redirect URL being sent to client: %s",
|
||||
redirect_url,
|
||||
)
|
||||
return RedirectResponse(url=redirect_url, status_code=status.HTTP_302_FOUND)
|
||||
|
||||
|
||||
async def auth(request: Request, db: Session = Depends(get_db)):
|
||||
"""Handle local username/password authentication.
|
||||
|
||||
@@ -659,8 +881,13 @@ async def auth(request: Request, db: Session = Depends(get_db)):
|
||||
)
|
||||
return RedirectResponse(url="/login?error=Invalid+username+or+password", status_code=302)
|
||||
|
||||
username_lower = username.lower()
|
||||
local_user = (
|
||||
db.query(_LocalUser).filter((_LocalUser.username == username) | (_LocalUser.email == username)).first()
|
||||
db.query(_LocalUser)
|
||||
.filter(
|
||||
(func.lower(_LocalUser.username) == username_lower) | (func.lower(_LocalUser.email) == username_lower)
|
||||
)
|
||||
.first()
|
||||
)
|
||||
logger.debug(
|
||||
"[AUTH] LocalUser lookup: username=%r found=%s",
|
||||
@@ -694,6 +921,17 @@ async def auth(request: Request, db: Session = Depends(get_db)):
|
||||
logger.info("[SECURITY] LOCAL_LOGIN_SUCCESS user=%s", local_user.email)
|
||||
_record_login_event(db, request, local_user.email, success=True)
|
||||
_ensure_user_profile(db, user_data, is_admin=bool(local_user.is_admin))
|
||||
|
||||
# Mobile app flow: issue an inline API token and redirect back to the app.
|
||||
logger.debug(
|
||||
"[MOBILE] local auth: checking for mobile redirect (session has mobile_redirect_uri=%s)",
|
||||
"mobile_redirect_uri" in request.session,
|
||||
)
|
||||
mobile_resp = _create_mobile_redirect(request, db)
|
||||
if mobile_resp:
|
||||
logger.info("[MOBILE] local auth: returning mobile redirect response")
|
||||
return mobile_resp
|
||||
|
||||
profile = db.query(_UserProfile).filter(_UserProfile.user_id == local_user.email).first()
|
||||
if profile and not profile.onboarding_completed:
|
||||
post_onboarding = request.session.pop("redirect_after_login", "/upload")
|
||||
@@ -718,13 +956,13 @@ async def auth(request: Request, db: Session = Depends(get_db)):
|
||||
logger.debug(
|
||||
"[AUTH] Admin credential check: admin_configured=%s username_match=%s multi_user_enabled=%s",
|
||||
admin_configured,
|
||||
username == settings.admin_username if admin_configured else False,
|
||||
(username or "").lower() == settings.admin_username.lower() if admin_configured else False,
|
||||
settings.multi_user_enabled,
|
||||
)
|
||||
if (
|
||||
settings.admin_username
|
||||
and settings.admin_password
|
||||
and username == settings.admin_username
|
||||
and (username or "").lower() == settings.admin_username.lower()
|
||||
and password == settings.admin_password
|
||||
):
|
||||
admin_user_data = {
|
||||
@@ -739,6 +977,17 @@ async def auth(request: Request, db: Session = Depends(get_db)):
|
||||
logger.info("[SECURITY] LOCAL_LOGIN_SUCCESS user=%s", username)
|
||||
_record_login_event(db, request, username, success=True)
|
||||
_ensure_user_profile(db, admin_user_data, is_admin=True)
|
||||
|
||||
# Mobile app flow: issue an inline API token and redirect back to the app.
|
||||
logger.debug(
|
||||
"[MOBILE] admin auth: checking for mobile redirect (session has mobile_redirect_uri=%s)",
|
||||
"mobile_redirect_uri" in request.session,
|
||||
)
|
||||
mobile_resp = _create_mobile_redirect(request, db)
|
||||
if mobile_resp:
|
||||
logger.info("[MOBILE] admin auth: returning mobile redirect response")
|
||||
return mobile_resp
|
||||
|
||||
redirect_url = request.session.pop("redirect_after_login", "/upload")
|
||||
return RedirectResponse(url=redirect_url, status_code=302)
|
||||
else:
|
||||
@@ -760,6 +1009,7 @@ async def logout(request: Request, db: Session = Depends(get_db)):
|
||||
username = "unknown"
|
||||
if isinstance(user, dict):
|
||||
username = user.get("preferred_username") or user.get("email") or "unknown"
|
||||
logger.debug("[AUTH] logout: clearing session for user=%s client_ip=%s", username, get_client_ip(request))
|
||||
logger.info(f"[SECURITY] LOGOUT user={username}")
|
||||
try:
|
||||
from app.utils.audit_service import record_event
|
||||
|
||||
@@ -48,6 +48,51 @@ class Settings(BaseSettings):
|
||||
workdir: str
|
||||
debug: bool = False # Default to False
|
||||
|
||||
# Logging level for the application. Accepts standard Python level names:
|
||||
# DEBUG, INFO, WARNING, ERROR, CRITICAL. When *debug* is True and
|
||||
# *log_level* has not been explicitly set, the effective level is forced to
|
||||
# DEBUG so that all ``logger.debug()`` calls produce output.
|
||||
log_level: str = Field(
|
||||
default="INFO",
|
||||
description=(
|
||||
"Python logging level for the application root logger. "
|
||||
"Accepts: DEBUG, INFO, WARNING, ERROR, CRITICAL. "
|
||||
"When DEBUG=True and LOG_LEVEL is not explicitly set, "
|
||||
"the effective level is automatically lowered to DEBUG."
|
||||
),
|
||||
)
|
||||
|
||||
# Log output format. ``text`` is the human-readable default.
|
||||
# ``json`` emits one JSON object per line, ideal for log collectors
|
||||
# (Promtail, Fluentd, Filebeat, Datadog agent) and SIEM ingestion.
|
||||
log_format: str = Field(
|
||||
default="text",
|
||||
description=(
|
||||
"Log output format: 'text' (human-readable, default) or "
|
||||
"'json' (structured JSON lines for SIEM / log aggregation)."
|
||||
),
|
||||
)
|
||||
|
||||
# Optional syslog forwarding for application logs (not just audit events).
|
||||
# When enabled, a Python SysLogHandler is added to the root logger so that
|
||||
# every log message is also sent to the configured syslog receiver.
|
||||
log_syslog_enabled: bool = Field(
|
||||
default=False,
|
||||
description="Forward application logs to a syslog receiver in addition to stdout.",
|
||||
)
|
||||
log_syslog_host: str = Field(
|
||||
default="localhost",
|
||||
description="Hostname or IP of the syslog receiver for application logs.",
|
||||
)
|
||||
log_syslog_port: int = Field(
|
||||
default=514,
|
||||
description="Port of the syslog receiver for application logs.",
|
||||
)
|
||||
log_syslog_protocol: str = Field(
|
||||
default="udp",
|
||||
description="Protocol for syslog transport: 'udp' or 'tcp'.",
|
||||
)
|
||||
|
||||
# Making Dropbox optional
|
||||
dropbox_enabled: bool = Field(
|
||||
default=True,
|
||||
|
||||
+9
-2
@@ -210,8 +210,10 @@ def _run_schema_migrations(engine: Any) -> None:
|
||||
if unique_filehash_indexes:
|
||||
logger.info("Migrating files: dropping unique index on 'filehash'")
|
||||
with engine.begin() as conn:
|
||||
preparer = conn.dialect.identifier_preparer
|
||||
for index in unique_filehash_indexes:
|
||||
conn.execute(text(f"DROP INDEX IF EXISTS {index['name']}"))
|
||||
quoted_idx = preparer.quote(index["name"])
|
||||
conn.execute(text(f"DROP INDEX IF EXISTS {quoted_idx}"))
|
||||
logger.info("Migration complete: unique index on 'filehash' removed")
|
||||
except Exception as exc:
|
||||
logger.warning(f"Skipping filehash unique index drop: {exc}")
|
||||
@@ -263,12 +265,17 @@ def _ensure_indexes(engine: Any, inspector: Any) -> None:
|
||||
table_names = inspector.get_table_names()
|
||||
columns_by_table: dict[str, set[str]] = {}
|
||||
with engine.begin() as conn:
|
||||
preparer = conn.dialect.identifier_preparer
|
||||
for idx_name, table, column in _PERF_INDEXES:
|
||||
if table in table_names:
|
||||
if table not in columns_by_table:
|
||||
columns_by_table[table] = {col["name"] for col in inspector.get_columns(table)}
|
||||
if column in columns_by_table[table]:
|
||||
conn.execute(text(f"CREATE INDEX IF NOT EXISTS {idx_name} ON {table} ({column})"))
|
||||
# SECURITY: Quoted identifiers to prevent SQL injection during index creation
|
||||
quoted_idx = preparer.quote(idx_name)
|
||||
quoted_table = preparer.quote(table)
|
||||
quoted_col = preparer.quote(column)
|
||||
conn.execute(text(f"CREATE INDEX IF NOT EXISTS {quoted_idx} ON {quoted_table} ({quoted_col})"))
|
||||
|
||||
logger.info("Performance indexes ensured")
|
||||
|
||||
|
||||
+113
@@ -1,8 +1,11 @@
|
||||
#!/usr/bin/env python3
|
||||
import json as _json_mod
|
||||
import logging
|
||||
import os
|
||||
import pathlib
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime as _dt
|
||||
from datetime import timezone as _tz
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request, status
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
@@ -36,6 +39,114 @@ from app.views import router as frontend_router
|
||||
# Explicitly include the files router
|
||||
from app.views.files import router as files_router
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configure Python root logging level early so that *all* loggers (including
|
||||
# those already created via ``logging.getLogger(__name__)`` in other modules)
|
||||
# respect the configured level.
|
||||
#
|
||||
# Standard behaviour (matches Django, Flask, 12-factor conventions):
|
||||
# • ``LOG_LEVEL`` env var takes precedence when explicitly set.
|
||||
# • When ``DEBUG=True`` and ``LOG_LEVEL`` is **not** set, the effective
|
||||
# level is automatically lowered to ``DEBUG``.
|
||||
# • Default (neither flag set): ``INFO``.
|
||||
#
|
||||
# ``LOG_FORMAT=json`` enables structured JSON lines on stdout, suitable for
|
||||
# Promtail, Fluentd, Filebeat, Datadog, Splunk UF, or any log collector.
|
||||
#
|
||||
# ``LOG_SYSLOG_ENABLED=true`` adds a Python SysLogHandler so that every log
|
||||
# message is also forwarded to the configured syslog receiver — useful for
|
||||
# traditional (non-container) deployments and centralised SIEM ingestion.
|
||||
#
|
||||
# Noisy third-party loggers (httpx, httpcore, authlib, etc.) are pinned to
|
||||
# WARNING when the app-level is DEBUG to keep output useful.
|
||||
# ---------------------------------------------------------------------------
|
||||
_explicit_log_level = os.environ.get("LOG_LEVEL")
|
||||
if settings.debug and _explicit_log_level is None:
|
||||
_effective_level = "DEBUG"
|
||||
else:
|
||||
_effective_level = settings.log_level.upper()
|
||||
|
||||
_effective_level_int = getattr(logging, _effective_level, logging.INFO)
|
||||
|
||||
|
||||
class _JsonFormatter(logging.Formatter):
|
||||
"""Emit one JSON object per log line for machine consumption.
|
||||
|
||||
Fields emitted: ``timestamp``, ``level``, ``logger``, ``message``,
|
||||
``module``, ``funcName``, ``lineno``, and — when present — ``exc_info``.
|
||||
Compatible with Grafana Loki, Splunk, ELK, Datadog, and most SIEM tools.
|
||||
"""
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
log_entry: dict = {
|
||||
"timestamp": _dt.fromtimestamp(record.created, tz=_tz.utc).isoformat(),
|
||||
"level": record.levelname,
|
||||
"logger": record.name,
|
||||
"message": record.getMessage(),
|
||||
"module": record.module,
|
||||
"funcName": record.funcName,
|
||||
"lineno": record.lineno,
|
||||
}
|
||||
if record.exc_info and record.exc_info[1] is not None:
|
||||
log_entry["exc_info"] = self.formatException(record.exc_info)
|
||||
return _json_mod.dumps(log_entry, default=str)
|
||||
|
||||
|
||||
# Choose formatter based on LOG_FORMAT setting
|
||||
if settings.log_format.lower() == "json":
|
||||
_handler = logging.StreamHandler()
|
||||
_handler.setFormatter(_JsonFormatter())
|
||||
logging.root.handlers = [_handler]
|
||||
logging.root.setLevel(_effective_level_int)
|
||||
else:
|
||||
logging.basicConfig(
|
||||
level=_effective_level_int,
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
force=True,
|
||||
)
|
||||
|
||||
# Optional: forward application logs to a syslog receiver
|
||||
if settings.log_syslog_enabled:
|
||||
import logging.handlers as _lh
|
||||
import socket as _socket
|
||||
|
||||
_proto = settings.log_syslog_protocol.lower()
|
||||
_socktype = _socket.SOCK_STREAM if _proto == "tcp" else _socket.SOCK_DGRAM
|
||||
_syslog_handler = _lh.SysLogHandler(
|
||||
address=(settings.log_syslog_host, settings.log_syslog_port),
|
||||
socktype=_socktype,
|
||||
)
|
||||
_syslog_handler.setLevel(_effective_level_int)
|
||||
# Use the same formatter as stdout (text or JSON)
|
||||
if settings.log_format.lower() == "json":
|
||||
_syslog_handler.setFormatter(_JsonFormatter())
|
||||
else:
|
||||
_syslog_handler.setFormatter(logging.Formatter("%(name)s - %(levelname)s - %(message)s"))
|
||||
logging.root.addHandler(_syslog_handler)
|
||||
|
||||
# Keep noisy third-party loggers quiet at DEBUG level
|
||||
if _effective_level_int <= logging.DEBUG:
|
||||
for _noisy in (
|
||||
"httpx",
|
||||
"httpcore",
|
||||
"authlib",
|
||||
"urllib3",
|
||||
"hpack",
|
||||
"multipart",
|
||||
"watchfiles",
|
||||
):
|
||||
logging.getLogger(_noisy).setLevel(logging.WARNING)
|
||||
|
||||
_startup_logger = logging.getLogger(__name__)
|
||||
_startup_logger.info(
|
||||
"Root logging level set to %s (debug=%s, format=%s, syslog=%s)",
|
||||
_effective_level,
|
||||
settings.debug,
|
||||
settings.log_format,
|
||||
settings.log_syslog_enabled,
|
||||
)
|
||||
|
||||
# Load configuration from .env for the session key
|
||||
config = Config(".env")
|
||||
# Use settings.session_secret which has proper validation
|
||||
@@ -261,12 +372,14 @@ _error_templates = Jinja2Templates(directory=str(_error_templates_dir))
|
||||
# Register the i18n translate helper as a global so error templates can use {{ _("key") }}.
|
||||
# Error pages use the default language (English); request-specific locale is not needed here.
|
||||
from app.utils.i18n import SUPPORTED_LANGUAGES as _SUPPORTED_LANGUAGES # noqa: E402
|
||||
from app.utils.i18n import get_suggested_languages as _get_suggested_languages # noqa: E402
|
||||
from app.utils.i18n import translate as _translate_fn # noqa: E402
|
||||
|
||||
_error_templates.env.globals["_"] = lambda key, **kwargs: _translate_fn(key, "en", **kwargs)
|
||||
_error_templates.env.globals["min"] = min
|
||||
_error_templates.env.globals["max"] = max
|
||||
_error_templates.env.globals["supported_languages"] = _SUPPORTED_LANGUAGES
|
||||
_error_templates.env.globals["suggested_languages"] = _get_suggested_languages("en", "")
|
||||
|
||||
|
||||
@app.exception_handler(HTTPException)
|
||||
|
||||
@@ -1,191 +1,192 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
|
||||
# Import the shared Celery instance
|
||||
from app.celery_app import celery
|
||||
from app.config import settings
|
||||
from app.database import SessionLocal
|
||||
from app.models import FileRecord
|
||||
from app.tasks.embed_metadata_into_pdf import embed_metadata_into_pdf
|
||||
from app.tasks.retry_config import BaseTaskWithRetry
|
||||
from app.utils import log_task_progress
|
||||
from app.utils.ai_provider import get_ai_provider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def extract_json_from_text(text):
|
||||
"""
|
||||
Try to extract a JSON object from the text.
|
||||
- First, check for a JSON block inside triple backticks.
|
||||
- If not found, try to extract text from the first '{' to the last '}'.
|
||||
"""
|
||||
pattern = r"```(?:json)?\s*(\{.*?\})\s*```"
|
||||
match = re.search(pattern, text, re.DOTALL)
|
||||
if match:
|
||||
return match.group(1)
|
||||
else:
|
||||
start = text.find("{")
|
||||
end = text.rfind("}")
|
||||
if start != -1 and end != -1 and end > start:
|
||||
return text[start : end + 1]
|
||||
return None
|
||||
|
||||
|
||||
@celery.task(base=BaseTaskWithRetry, bind=True)
|
||||
def extract_metadata_with_gpt(self, filename: str, cleaned_text: str, file_id: int = None):
|
||||
"""
|
||||
Uses OpenAI to classify document metadata.
|
||||
|
||||
Args:
|
||||
filename: Can be either a basename (e.g., "file.pdf") or a full path (e.g., "/workdir/processed/file.pdf")
|
||||
cleaned_text: The extracted text from the document
|
||||
file_id: Optional file ID for tracking
|
||||
"""
|
||||
task_id = self.request.id
|
||||
logger.info(f"[{task_id}] Starting metadata extraction for: {filename}")
|
||||
log_task_progress(
|
||||
task_id,
|
||||
"extract_metadata_with_gpt",
|
||||
"in_progress",
|
||||
f"Extracting metadata for {os.path.basename(filename)}",
|
||||
file_id=file_id,
|
||||
)
|
||||
|
||||
# Get file_id from database if not provided
|
||||
if file_id is None:
|
||||
tmp_dir = os.path.join(settings.workdir, "tmp")
|
||||
# Handle both basename and full path
|
||||
if os.path.isabs(filename):
|
||||
file_path = filename
|
||||
else:
|
||||
file_path = os.path.join(tmp_dir, filename)
|
||||
if os.path.exists(file_path):
|
||||
with SessionLocal() as db:
|
||||
file_record = db.query(FileRecord).filter_by(local_filename=file_path).first()
|
||||
if file_record:
|
||||
file_id = file_record.id
|
||||
|
||||
prompt = (
|
||||
"You are a specialized document analyzer trained to extract structured metadata from documents.\n"
|
||||
"Your task is to analyze the given text and return a well-structured JSON object.\n\n"
|
||||
"Extract and return the following fields:\n"
|
||||
"1. **filename**: Machine-readable filename "
|
||||
"(YYYY-MM-DD_DescriptiveTitle, use only letters, numbers, periods, and underscores).\n"
|
||||
'2. **empfaenger**: The recipient, or "Unknown" if not found.\n'
|
||||
'3. **absender**: The sender, or "Unknown" if not found.\n'
|
||||
"4. **correspondent**: The entity or company that issued the document "
|
||||
'(shortest possible name, e.g., "Amazon" instead of "Amazon EU SARL, German branch").\n'
|
||||
"5. **kommunikationsart**: One of [Behoerdlicher_Brief, Rechnung, Kontoauszug, Vertrag, "
|
||||
"Quittung, Privater_Brief, Einladung, Gewerbliche_Korrespondenz, Newsletter, Werbung, Sonstiges].\n"
|
||||
"6. **kommunikationskategorie**: One of [Amtliche_Postbehoerdliche_Dokumente, "
|
||||
"Finanz_und_Vertragsdokumente, Geschaeftliche_Kommunikation, "
|
||||
"Private_Korrespondenz, Sonstige_Informationen].\n"
|
||||
"7. **document_type**: Precise classification (e.g., Invoice, Contract, Information, Unknown).\n"
|
||||
"8. **tags**: A list of up to 4 relevant thematic keywords.\n"
|
||||
'9. **language**: Detected document language (ISO 639-1 code, e.g., "de" or "en").\n'
|
||||
"10. **title**: A human-readable title summarizing the document content.\n"
|
||||
"11. **confidence_score**: A numeric value (0-100) indicating the confidence level "
|
||||
"of the extracted metadata.\n"
|
||||
"12. **reference_number**: Extracted invoice/order/reference number if available.\n"
|
||||
"13. **monetary_amounts**: A list of key monetary values detected in the document.\n\n"
|
||||
"### Important Rules:\n"
|
||||
"- **OCR Correction**: Assume the text has been corrected for OCR errors.\n"
|
||||
"- **Tagging**: Max 4 tags, avoiding generic or overly specific terms.\n"
|
||||
"- **Title**: Concise, no addresses, and contains key identifying features.\n"
|
||||
"- **Date Selection**: Use the most relevant date if multiple are found.\n"
|
||||
"- **Output Language**: Maintain the document's original language.\n\n"
|
||||
f"Extracted text:\n{cleaned_text}\n\n"
|
||||
"Return only valid JSON with no additional commentary.\n"
|
||||
)
|
||||
|
||||
try:
|
||||
logger.info(f"[{task_id}] Sending classification request for {filename}...")
|
||||
log_task_progress(task_id, "call_ai_provider", "in_progress", "Calling AI provider API", file_id=file_id)
|
||||
provider = get_ai_provider()
|
||||
model = settings.ai_model or settings.openai_model
|
||||
content = provider.chat_completion(
|
||||
messages=[
|
||||
{"role": "system", "content": "You are an intelligent document classifier."},
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
model=model,
|
||||
temperature=0,
|
||||
)
|
||||
|
||||
logger.info(f"[{task_id}] Raw classification response for {filename}: {content[:200]}...")
|
||||
log_task_progress(
|
||||
task_id,
|
||||
"call_ai_provider",
|
||||
"success",
|
||||
"Received AI provider response",
|
||||
file_id=file_id,
|
||||
detail=f"Raw classification response:\n{content}",
|
||||
)
|
||||
|
||||
json_text = extract_json_from_text(content)
|
||||
if not json_text:
|
||||
logger.error(f"[{task_id}] Could not find valid JSON in GPT response for {filename}.")
|
||||
log_task_progress(
|
||||
task_id,
|
||||
"extract_metadata_with_gpt",
|
||||
"failure",
|
||||
"Invalid JSON in response",
|
||||
file_id=file_id,
|
||||
detail=f"Could not parse valid JSON from GPT response.\nRaw response:\n{content}",
|
||||
)
|
||||
return {}
|
||||
|
||||
metadata = json.loads(json_text)
|
||||
|
||||
# SECURITY: Validate filename format from GPT to prevent path traversal
|
||||
# The prompt requests filenames with only letters, numbers, periods, and underscores
|
||||
# Enforce this constraint to prevent malicious filenames
|
||||
suggested_filename = metadata.get("filename", "")
|
||||
if suggested_filename:
|
||||
# Check if filename contains only safe characters AND explicitly check for ".."
|
||||
# Defense in depth: While the regex [\w\-\. ]+ already excludes / and \,
|
||||
# we explicitly reject ".." to guard against:
|
||||
# 1. Potential locale-specific \w behavior
|
||||
# 2. Files literally named ".." which are valid but problematic
|
||||
# 3. Future code changes that might relax the regex
|
||||
if not re.match(r"^[\w\-\. ]+$", suggested_filename) or ".." in suggested_filename:
|
||||
logger.warning(f"[{task_id}] Invalid filename format from GPT: '{suggested_filename}', using fallback")
|
||||
# Reset to empty to trigger fallback to original filename
|
||||
metadata["filename"] = ""
|
||||
|
||||
logger.info(f"[{task_id}] Extracted metadata: {metadata}")
|
||||
log_task_progress(
|
||||
task_id,
|
||||
"parse_metadata",
|
||||
"success",
|
||||
f"Parsed metadata: {list(metadata.keys())}",
|
||||
file_id=file_id,
|
||||
detail=f"Extracted metadata:\n{json.dumps(metadata, ensure_ascii=False, indent=2)}",
|
||||
)
|
||||
|
||||
# Trigger the next step: embedding metadata into the PDF
|
||||
# Pass the filename (can be basename or full path) so embed_metadata_into_pdf can find the file on disk
|
||||
logger.info(f"[{task_id}] Queueing metadata embedding task")
|
||||
log_task_progress(
|
||||
task_id, "extract_metadata_with_gpt", "success", "Metadata extracted, queuing embed task", file_id=file_id
|
||||
)
|
||||
embed_metadata_into_pdf.delay(filename, cleaned_text, metadata, file_id)
|
||||
|
||||
return {"s3_file": os.path.basename(filename), "metadata": metadata}
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"[{task_id}] AI provider classification failed for {filename}: {e}")
|
||||
log_task_progress(
|
||||
task_id,
|
||||
"extract_metadata_with_gpt",
|
||||
"failure",
|
||||
f"Exception: {str(e)}",
|
||||
file_id=file_id,
|
||||
detail=f"AI provider classification failed for {filename}.\nException: {str(e)}",
|
||||
)
|
||||
return {}
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
|
||||
# Import the shared Celery instance
|
||||
from app.celery_app import celery
|
||||
from app.config import settings
|
||||
from app.database import SessionLocal
|
||||
from app.models import FileRecord
|
||||
from app.tasks.embed_metadata_into_pdf import embed_metadata_into_pdf
|
||||
from app.tasks.retry_config import BaseTaskWithRetry
|
||||
from app.utils import log_task_progress
|
||||
from app.utils.ai_provider import get_ai_provider
|
||||
from app.utils.filename_utils import VALID_FILENAME_RE
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def extract_json_from_text(text):
|
||||
"""
|
||||
Try to extract a JSON object from the text.
|
||||
- First, check for a JSON block inside triple backticks.
|
||||
- If not found, try to extract text from the first '{' to the last '}'.
|
||||
"""
|
||||
pattern = r"```(?:json)?\s*(\{.*?\})\s*```"
|
||||
match = re.search(pattern, text, re.DOTALL)
|
||||
if match:
|
||||
return match.group(1)
|
||||
else:
|
||||
start = text.find("{")
|
||||
end = text.rfind("}")
|
||||
if start != -1 and end != -1 and end > start:
|
||||
return text[start : end + 1]
|
||||
return None
|
||||
|
||||
|
||||
@celery.task(base=BaseTaskWithRetry, bind=True)
|
||||
def extract_metadata_with_gpt(self, filename: str, cleaned_text: str, file_id: int = None):
|
||||
"""
|
||||
Uses OpenAI to classify document metadata.
|
||||
|
||||
Args:
|
||||
filename: Can be either a basename (e.g., "file.pdf") or a full path (e.g., "/workdir/processed/file.pdf")
|
||||
cleaned_text: The extracted text from the document
|
||||
file_id: Optional file ID for tracking
|
||||
"""
|
||||
task_id = self.request.id
|
||||
logger.info(f"[{task_id}] Starting metadata extraction for: {filename}")
|
||||
log_task_progress(
|
||||
task_id,
|
||||
"extract_metadata_with_gpt",
|
||||
"in_progress",
|
||||
f"Extracting metadata for {os.path.basename(filename)}",
|
||||
file_id=file_id,
|
||||
)
|
||||
|
||||
# Get file_id from database if not provided
|
||||
if file_id is None:
|
||||
tmp_dir = os.path.join(settings.workdir, "tmp")
|
||||
# Handle both basename and full path
|
||||
if os.path.isabs(filename):
|
||||
file_path = filename
|
||||
else:
|
||||
file_path = os.path.join(tmp_dir, filename)
|
||||
if os.path.exists(file_path):
|
||||
with SessionLocal() as db:
|
||||
file_record = db.query(FileRecord).filter_by(local_filename=file_path).first()
|
||||
if file_record:
|
||||
file_id = file_record.id
|
||||
|
||||
prompt = (
|
||||
"You are a specialized document analyzer trained to extract structured metadata from documents.\n"
|
||||
"Your task is to analyze the given text and return a well-structured JSON object.\n\n"
|
||||
"Extract and return the following fields:\n"
|
||||
"1. **filename**: Machine-readable filename "
|
||||
"(YYYY-MM-DD_DescriptiveTitle, use only letters, numbers, spaces, dashes, periods, and underscores).\n"
|
||||
'2. **empfaenger**: The recipient, or "Unknown" if not found.\n'
|
||||
'3. **absender**: The sender, or "Unknown" if not found.\n'
|
||||
"4. **correspondent**: The entity or company that issued the document "
|
||||
'(shortest possible name, e.g., "Amazon" instead of "Amazon EU SARL, German branch").\n'
|
||||
"5. **kommunikationsart**: One of [Behoerdlicher_Brief, Rechnung, Kontoauszug, Vertrag, "
|
||||
"Quittung, Privater_Brief, Einladung, Gewerbliche_Korrespondenz, Newsletter, Werbung, Sonstiges].\n"
|
||||
"6. **kommunikationskategorie**: One of [Amtliche_Postbehoerdliche_Dokumente, "
|
||||
"Finanz_und_Vertragsdokumente, Geschaeftliche_Kommunikation, "
|
||||
"Private_Korrespondenz, Sonstige_Informationen].\n"
|
||||
"7. **document_type**: Precise classification (e.g., Invoice, Contract, Information, Unknown).\n"
|
||||
"8. **tags**: A list of up to 4 relevant thematic keywords.\n"
|
||||
'9. **language**: Detected document language (ISO 639-1 code, e.g., "de" or "en").\n'
|
||||
"10. **title**: A human-readable title summarizing the document content.\n"
|
||||
"11. **confidence_score**: A numeric value (0-100) indicating the confidence level "
|
||||
"of the extracted metadata.\n"
|
||||
"12. **reference_number**: Extracted invoice/order/reference number if available.\n"
|
||||
"13. **monetary_amounts**: A list of key monetary values detected in the document.\n\n"
|
||||
"### Important Rules:\n"
|
||||
"- **OCR Correction**: Assume the text has been corrected for OCR errors.\n"
|
||||
"- **Tagging**: Max 4 tags, avoiding generic or overly specific terms.\n"
|
||||
"- **Title**: Concise, no addresses, and contains key identifying features.\n"
|
||||
"- **Date Selection**: Use the most relevant date if multiple are found.\n"
|
||||
"- **Output Language**: Maintain the document's original language.\n\n"
|
||||
f"Extracted text:\n{cleaned_text}\n\n"
|
||||
"Return only valid JSON with no additional commentary.\n"
|
||||
)
|
||||
|
||||
try:
|
||||
logger.info(f"[{task_id}] Sending classification request for {filename}...")
|
||||
log_task_progress(task_id, "call_ai_provider", "in_progress", "Calling AI provider API", file_id=file_id)
|
||||
provider = get_ai_provider()
|
||||
model = settings.ai_model or settings.openai_model
|
||||
content = provider.chat_completion(
|
||||
messages=[
|
||||
{"role": "system", "content": "You are an intelligent document classifier."},
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
model=model,
|
||||
temperature=0,
|
||||
)
|
||||
|
||||
logger.info(f"[{task_id}] Raw classification response for {filename}: {content[:200]}...")
|
||||
log_task_progress(
|
||||
task_id,
|
||||
"call_ai_provider",
|
||||
"success",
|
||||
"Received AI provider response",
|
||||
file_id=file_id,
|
||||
detail=f"Raw classification response:\n{content}",
|
||||
)
|
||||
|
||||
json_text = extract_json_from_text(content)
|
||||
if not json_text:
|
||||
logger.error(f"[{task_id}] Could not find valid JSON in GPT response for {filename}.")
|
||||
log_task_progress(
|
||||
task_id,
|
||||
"extract_metadata_with_gpt",
|
||||
"failure",
|
||||
"Invalid JSON in response",
|
||||
file_id=file_id,
|
||||
detail=f"Could not parse valid JSON from GPT response.\nRaw response:\n{content}",
|
||||
)
|
||||
return {}
|
||||
|
||||
metadata = json.loads(json_text)
|
||||
|
||||
# SECURITY: Validate filename format from GPT to prevent path traversal
|
||||
# The prompt requests filenames with only letters, numbers, periods, and underscores
|
||||
# Enforce this constraint to prevent malicious filenames
|
||||
suggested_filename = metadata.get("filename", "")
|
||||
if suggested_filename:
|
||||
# Check if filename contains only safe characters AND explicitly check for ".."
|
||||
# Defense in depth: While the regex VALID_FILENAME_PATTERN already excludes / and \,
|
||||
# we explicitly reject ".." to guard against:
|
||||
# 1. Potential locale-specific \w behavior
|
||||
# 2. Files literally named ".." which are valid but problematic
|
||||
# 3. Future code changes that might relax the regex
|
||||
if not VALID_FILENAME_RE.match(suggested_filename) or ".." in suggested_filename:
|
||||
logger.warning(f"[{task_id}] Invalid filename format from GPT: '{suggested_filename}', using fallback")
|
||||
# Reset to empty to trigger fallback to original filename
|
||||
metadata["filename"] = ""
|
||||
|
||||
logger.info(f"[{task_id}] Extracted metadata: {metadata}")
|
||||
log_task_progress(
|
||||
task_id,
|
||||
"parse_metadata",
|
||||
"success",
|
||||
f"Parsed metadata: {list(metadata.keys())}",
|
||||
file_id=file_id,
|
||||
detail=f"Extracted metadata:\n{json.dumps(metadata, ensure_ascii=False, indent=2)}",
|
||||
)
|
||||
|
||||
# Trigger the next step: embedding metadata into the PDF
|
||||
# Pass the filename (can be basename or full path) so embed_metadata_into_pdf can find the file on disk
|
||||
logger.info(f"[{task_id}] Queueing metadata embedding task")
|
||||
log_task_progress(
|
||||
task_id, "extract_metadata_with_gpt", "success", "Metadata extracted, queuing embed task", file_id=file_id
|
||||
)
|
||||
embed_metadata_into_pdf.delay(filename, cleaned_text, metadata, file_id)
|
||||
|
||||
return {"s3_file": os.path.basename(filename), "metadata": metadata}
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"[{task_id}] AI provider classification failed for {filename}: {e}")
|
||||
log_task_progress(
|
||||
task_id,
|
||||
"extract_metadata_with_gpt",
|
||||
"failure",
|
||||
f"Exception: {str(e)}",
|
||||
file_id=file_id,
|
||||
detail=f"AI provider classification failed for {filename}.\nException: {str(e)}",
|
||||
)
|
||||
return {}
|
||||
|
||||
@@ -11,6 +11,7 @@ from email.mime.image import MIMEImage
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
|
||||
import pypdf
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
|
||||
from app.celery_app import celery
|
||||
@@ -80,8 +81,22 @@ def extract_metadata_from_file(file_path):
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load metadata from JSON file: {str(e)}")
|
||||
|
||||
# TODO: For PDF files, try to extract embedded metadata using PyPDF2
|
||||
# This would require additional dependencies, so for now we'll just check for external JSON
|
||||
# Try to extract embedded metadata from PDF
|
||||
if file_path.lower().endswith(".pdf") and os.path.exists(file_path):
|
||||
try:
|
||||
with open(file_path, "rb") as f:
|
||||
pdf_reader = pypdf.PdfReader(f)
|
||||
pdf_metadata = pdf_reader.metadata
|
||||
if pdf_metadata:
|
||||
# Convert metadata to a standard dictionary
|
||||
for key, value in pdf_metadata.items():
|
||||
# Remove the leading slash from PDF metadata keys (e.g., '/Title' -> 'Title')
|
||||
clean_key = key[1:] if key.startswith("/") else key
|
||||
metadata[clean_key] = str(value)
|
||||
|
||||
logger.info(f"Extracted embedded metadata from PDF: {file_path}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to extract metadata from PDF {file_path}: {str(e)}")
|
||||
|
||||
return metadata
|
||||
|
||||
|
||||
@@ -55,12 +55,12 @@ def upload_with_rclone(self, file_path: str, destination: str):
|
||||
|
||||
try:
|
||||
# Ensure the remote path exists (create folders if needed)
|
||||
mkdir_cmd = ["rclone", "mkdir", "--config", rclone_config_path, destination]
|
||||
mkdir_cmd = ["rclone", "mkdir", "--config", rclone_config_path, "--", destination]
|
||||
|
||||
subprocess.run(mkdir_cmd, check=True, capture_output=True) # noqa: S603
|
||||
|
||||
# Construct the upload command
|
||||
upload_cmd = ["rclone", "copy", "--config", rclone_config_path, file_path, destination, "--progress"]
|
||||
upload_cmd = ["rclone", "copy", "--config", rclone_config_path, "--progress", "--", file_path, destination]
|
||||
|
||||
log_task_progress(task_id, "rclone_upload", "in_progress", f"Executing rclone copy to {destination}")
|
||||
|
||||
@@ -71,7 +71,7 @@ def upload_with_rclone(self, file_path: str, destination: str):
|
||||
if result.returncode == 0:
|
||||
# Try to get a public link if possible
|
||||
try:
|
||||
link_cmd = ["rclone", "link", "--config", rclone_config_path, f"{destination}/{filename}"]
|
||||
link_cmd = ["rclone", "link", "--config", rclone_config_path, "--", f"{destination}/{filename}"]
|
||||
link_result = subprocess.run(link_cmd, capture_output=True, text=True, check=False) # noqa: S603
|
||||
public_url = link_result.stdout.strip() if link_result.returncode == 0 else None
|
||||
except (subprocess.SubprocessError, OSError) as e:
|
||||
|
||||
@@ -12,6 +12,7 @@ The utility:
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import MetaData, create_engine, inspect, text
|
||||
@@ -84,8 +85,12 @@ def preview_migration(source_url: str) -> dict[str, Any]:
|
||||
total = 0
|
||||
with src_engine.connect() as conn:
|
||||
for table_name in tables:
|
||||
if not re.match(r"^[a-zA-Z0-9_]+$", table_name):
|
||||
logger.warning(f"Skipping table with invalid name format: {table_name}")
|
||||
continue
|
||||
# table_name is safe — sourced from inspect().get_table_names(), not user input
|
||||
row = conn.execute(text(f'SELECT COUNT(*) FROM "{table_name}"')).fetchone() # noqa: S608
|
||||
quoted_table = conn.dialect.identifier_preparer.quote(table_name)
|
||||
row = conn.execute(text(f"SELECT COUNT(*) FROM {quoted_table}")).fetchone() # noqa: S608
|
||||
count = row[0] if row else 0
|
||||
result.append({"name": table_name, "row_count": count})
|
||||
total += count
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import logging
|
||||
import os
|
||||
from typing import Dict
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def update_env_file(settings_to_update: Dict[str, str]) -> bool:
|
||||
"""
|
||||
Updates the .env file with the given settings (best-effort).
|
||||
Creates or modifies existing keys.
|
||||
|
||||
Args:
|
||||
settings_to_update: A dictionary mapping uppercase env var names to their new string values.
|
||||
|
||||
Returns:
|
||||
True if the file was successfully updated, False otherwise.
|
||||
"""
|
||||
try:
|
||||
env_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), ".env")
|
||||
if not os.path.exists(env_path):
|
||||
logger.warning(f".env file not found at {env_path}, skipping file write")
|
||||
return False
|
||||
|
||||
logger.info(f"Updating settings in {env_path}")
|
||||
|
||||
with open(env_path, "r") as f:
|
||||
env_lines = f.readlines()
|
||||
|
||||
updated = set()
|
||||
new_env_lines = []
|
||||
for line in env_lines:
|
||||
stripped_line = line.rstrip()
|
||||
is_updated = False
|
||||
for key, value in settings_to_update.items():
|
||||
if stripped_line.startswith(f"{key}=") or stripped_line.startswith(f"# {key}="):
|
||||
new_env_lines.append(f"{key}={value}")
|
||||
updated.add(key)
|
||||
is_updated = True
|
||||
break
|
||||
if not is_updated:
|
||||
new_env_lines.append(stripped_line)
|
||||
|
||||
for key, value in settings_to_update.items():
|
||||
if key not in updated:
|
||||
new_env_lines.append(f"{key}={value}")
|
||||
|
||||
with open(env_path, "w") as f:
|
||||
f.write("\n".join(new_env_lines) + "\n")
|
||||
|
||||
logger.info("Successfully updated settings in .env file")
|
||||
return True
|
||||
except Exception as env_err:
|
||||
logger.warning(f"Failed to write .env file (non-fatal): {env_err}")
|
||||
return False
|
||||
@@ -8,6 +8,11 @@ from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Pattern for valid filenames (alphanumeric, dash, underscore, period, and space)
|
||||
# Used for validating GPT-provided filenames and other inputs
|
||||
VALID_FILENAME_PATTERN = r"^[\w\-\. ]+$"
|
||||
VALID_FILENAME_RE = re.compile(VALID_FILENAME_PATTERN)
|
||||
|
||||
|
||||
def get_unique_filename(original_path: str, check_exists_func: Callable[[str], bool] | None = None) -> str:
|
||||
"""
|
||||
|
||||
+91
-15
@@ -2,7 +2,7 @@
|
||||
|
||||
Provides a JSON-based translation system for the DocuElevate UI with:
|
||||
|
||||
* **49 supported languages** covering European, Asian, Middle-Eastern, and other languages
|
||||
* **77 supported languages** covering European, Asian, Middle-Eastern, African, and other languages
|
||||
* Browser ``Accept-Language`` detection with cookie & user-profile persistence
|
||||
* AI-powered fallback translation via the configured LLM provider
|
||||
* Locale-aware date, number, and file-size formatting helpers
|
||||
@@ -52,10 +52,10 @@ SUPPORTED_LANGUAGES: list[dict[str, str]] = [
|
||||
{"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": "Frisian", "native": "Frysk", "flag": "🇳🇱"},
|
||||
{"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": "West Flemish", "native": "West-Vlams", "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": "🇵🇱"},
|
||||
@@ -74,25 +74,60 @@ SUPPORTED_LANGUAGES: list[dict[str, str]] = [
|
||||
# --- Tier 4: Non-EU European, Middle Eastern & African ---
|
||||
{"code": "tr", "name": "Turkish", "native": "Türkçe", "flag": "🇹🇷"},
|
||||
{"code": "uk", "name": "Ukrainian", "native": "Українська", "flag": "🇺🇦"},
|
||||
{"code": "ru", "name": "Russian", "native": "Русский", "flag": "🇷🇺"},
|
||||
{"code": "he", "name": "Hebrew", "native": "עברית", "flag": "🇮🇱"},
|
||||
{"code": "ar", "name": "Arabic", "native": "العربية", "flag": "🇸🇦"},
|
||||
{"code": "fa", "name": "Persian", "native": "فارسی", "flag": "🇮🇷"},
|
||||
{"code": "af", "name": "Afrikaans", "native": "Afrikaans", "flag": "🇿🇦"},
|
||||
# --- Tier 5: Asian languages ---
|
||||
{"code": "zh", "name": "Chinese", "native": "中文", "flag": "🇨🇳"},
|
||||
{"code": "zh-TW", "name": "Traditional Chinese", "native": "繁體中文", "flag": "🇹🇼"},
|
||||
{"code": "ja", "name": "Japanese", "native": "日本語", "flag": "🇯🇵"},
|
||||
{"code": "ko", "name": "Korean", "native": "한국어", "flag": "🇰🇷"},
|
||||
{"code": "vi", "name": "Vietnamese", "native": "Tiếng Việt", "flag": "🇻🇳"},
|
||||
{"code": "pa", "name": "Punjabi", "native": "ਪੰਜਾਬੀ", "flag": "🇮🇳"},
|
||||
{"code": "kn", "name": "Kannada", "native": "ಕನ್ನಡ", "flag": "🇮🇳"},
|
||||
# --- Tier 6: Constructed & other languages ---
|
||||
{"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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -263,14 +298,10 @@ def detect_language(request: Request) -> str:
|
||||
return DEFAULT_LANGUAGE
|
||||
|
||||
|
||||
def _parse_accept_language(header: str) -> str | None:
|
||||
"""Extract the best matching language from an ``Accept-Language`` header.
|
||||
|
||||
Parses quality values and returns the highest-priority match among
|
||||
:data:`SUPPORTED_LANGUAGE_CODES`, or ``None`` if nothing matches.
|
||||
"""
|
||||
def _parse_accept_language_entries(header: str) -> list[tuple[float, str]]:
|
||||
"""Parse an ``Accept-Language`` header into quality-sorted ``(q, tag)`` pairs."""
|
||||
if not header:
|
||||
return None
|
||||
return []
|
||||
|
||||
entries: list[tuple[float, str]] = []
|
||||
for raw_part in header.split(","):
|
||||
@@ -288,11 +319,17 @@ def _parse_accept_language(header: str) -> str | None:
|
||||
quality = 1.0
|
||||
entries.append((quality, lang_tag.strip().lower()))
|
||||
|
||||
# Sort by quality descending
|
||||
entries.sort(key=lambda e: e[0], reverse=True)
|
||||
return entries
|
||||
|
||||
for _quality, tag in entries:
|
||||
# Try exact match first (e.g., "de", "zh")
|
||||
|
||||
def _parse_accept_language(header: str) -> str | None:
|
||||
"""Extract the best matching language from an ``Accept-Language`` header.
|
||||
|
||||
Parses quality values and returns the highest-priority match among
|
||||
:data:`SUPPORTED_LANGUAGE_CODES`, or ``None`` if nothing matches.
|
||||
"""
|
||||
for _quality, tag in _parse_accept_language_entries(header):
|
||||
code = tag.split("-")[0]
|
||||
if code in SUPPORTED_LANGUAGE_CODES:
|
||||
return code
|
||||
@@ -300,6 +337,45 @@ def _parse_accept_language(header: str) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
# Maximum number of languages shown in the compact nav-bar dropdown
|
||||
_SUGGESTED_LANGUAGES_MAX = 6
|
||||
|
||||
|
||||
def get_suggested_languages(current_locale: str, accept_language_header: str = "") -> list[dict[str, str]]:
|
||||
"""Return up to :data:`_SUGGESTED_LANGUAGES_MAX` suggested languages for the compact picker.
|
||||
|
||||
Selection priority:
|
||||
1. The currently active language (always included first).
|
||||
2. Languages listed in the browser's ``Accept-Language`` header.
|
||||
3. Popular global languages (by estimated speaker count) as fillers.
|
||||
|
||||
The resulting list is de-duplicated and capped at
|
||||
:data:`_SUGGESTED_LANGUAGES_MAX` entries.
|
||||
"""
|
||||
candidates: list[str] = []
|
||||
|
||||
# 1. Active locale first
|
||||
if current_locale in SUPPORTED_LANGUAGE_CODES:
|
||||
candidates.append(current_locale)
|
||||
|
||||
# 2. Browser preferences
|
||||
for _quality, tag in _parse_accept_language_entries(accept_language_header):
|
||||
if len(candidates) >= _SUGGESTED_LANGUAGES_MAX:
|
||||
break
|
||||
code = tag.split("-")[0]
|
||||
if code in SUPPORTED_LANGUAGE_CODES and code not in candidates:
|
||||
candidates.append(code)
|
||||
|
||||
# 3. Popular language fillers
|
||||
for code in _POPULAR_LANGUAGE_CODES:
|
||||
if len(candidates) >= _SUGGESTED_LANGUAGES_MAX:
|
||||
break
|
||||
if code not in candidates and code in SUPPORTED_LANGUAGE_CODES:
|
||||
candidates.append(code)
|
||||
|
||||
return [_LANG_CODE_MAP[c] for c in candidates[:_SUGGESTED_LANGUAGES_MAX] if c in _LANG_CODE_MAP]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Localization helpers (l10n)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import ipaddress
|
||||
import logging
|
||||
import socket
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def is_private_ip(hostname: str) -> bool:
|
||||
"""
|
||||
Check if a hostname resolves to a private/internal IP address.
|
||||
Protects against SSRF attacks by blocking access to internal networks.
|
||||
"""
|
||||
try:
|
||||
# Try to parse as IP address directly
|
||||
ip = ipaddress.ip_address(hostname)
|
||||
return ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved
|
||||
except ValueError:
|
||||
# Not a direct IP, try to resolve hostname
|
||||
try:
|
||||
# Get all IP addresses for this hostname
|
||||
addr_info = socket.getaddrinfo(hostname, None)
|
||||
for info in addr_info:
|
||||
ip_str = info[4][0]
|
||||
ip = ipaddress.ip_address(ip_str)
|
||||
# Block if ANY resolved IP is private/internal
|
||||
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
|
||||
return True
|
||||
return False
|
||||
except (socket.gaierror, socket.error):
|
||||
# Cannot resolve - allow for testing/development
|
||||
# In production, DNS should work properly
|
||||
# Log this for debugging
|
||||
logger.warning(f"Could not resolve hostname: {hostname}")
|
||||
return False # Changed from True to False to allow external domains in tests
|
||||
@@ -2628,6 +2628,63 @@ SETTING_METADATA = {
|
||||
"required": False,
|
||||
"restart_required": False,
|
||||
},
|
||||
# Logging
|
||||
"log_level": {
|
||||
"category": "Observability",
|
||||
"description": (
|
||||
"Python logging level for the application root logger. "
|
||||
"Accepts: DEBUG, INFO, WARNING, ERROR, CRITICAL. "
|
||||
"When DEBUG=True and LOG_LEVEL is not explicitly set, "
|
||||
"the effective level is automatically lowered to DEBUG."
|
||||
),
|
||||
"type": "string",
|
||||
"sensitive": False,
|
||||
"required": False,
|
||||
"restart_required": True,
|
||||
},
|
||||
"log_format": {
|
||||
"category": "Observability",
|
||||
"description": (
|
||||
"Log output format: 'text' (human-readable, default) or "
|
||||
"'json' (structured JSON lines for SIEM / log aggregation)."
|
||||
),
|
||||
"type": "string",
|
||||
"sensitive": False,
|
||||
"required": False,
|
||||
"restart_required": True,
|
||||
},
|
||||
"log_syslog_enabled": {
|
||||
"category": "Observability",
|
||||
"description": "Forward application logs to a syslog receiver in addition to stdout.",
|
||||
"type": "boolean",
|
||||
"sensitive": False,
|
||||
"required": False,
|
||||
"restart_required": True,
|
||||
},
|
||||
"log_syslog_host": {
|
||||
"category": "Observability",
|
||||
"description": "Hostname or IP of the syslog receiver for application logs.",
|
||||
"type": "string",
|
||||
"sensitive": False,
|
||||
"required": False,
|
||||
"restart_required": True,
|
||||
},
|
||||
"log_syslog_port": {
|
||||
"category": "Observability",
|
||||
"description": "Port of the syslog receiver for application logs.",
|
||||
"type": "integer",
|
||||
"sensitive": False,
|
||||
"required": False,
|
||||
"restart_required": True,
|
||||
},
|
||||
"log_syslog_protocol": {
|
||||
"category": "Observability",
|
||||
"description": "Protocol for syslog transport: 'udp' or 'tcp'.",
|
||||
"type": "string",
|
||||
"sensitive": False,
|
||||
"required": False,
|
||||
"restart_required": True,
|
||||
},
|
||||
# Observability – Sentry
|
||||
"sentry_dsn": {
|
||||
"category": "Observability",
|
||||
|
||||
+50
-1
@@ -11,13 +11,15 @@ from sqlalchemy.orm import Session # noqa: F401
|
||||
|
||||
from app.auth import require_login # noqa: F401
|
||||
from app.config import settings
|
||||
from app.database import get_db # noqa: F401
|
||||
from app.database import SessionLocal, get_db # noqa: F401
|
||||
from app.models import UserProfile
|
||||
from app.utils.i18n import (
|
||||
SUPPORTED_LANGUAGES,
|
||||
detect_language,
|
||||
format_date,
|
||||
format_datetime,
|
||||
format_number,
|
||||
get_suggested_languages,
|
||||
translate,
|
||||
)
|
||||
|
||||
@@ -46,6 +48,41 @@ templates.env.globals["_"] = lambda key, **kwargs: translate(key, "en", **kwargs
|
||||
original_template_response = templates.TemplateResponse
|
||||
|
||||
|
||||
def _hydrate_language_from_db(request: Request, session_user: object) -> None:
|
||||
"""Load the user's preferred language from the DB into the session.
|
||||
|
||||
Called once per session when ``preferred_language`` is not yet in the
|
||||
session. A lightweight DB query fetches the stored preference so that
|
||||
:func:`detect_language` picks it up from the session on all subsequent
|
||||
requests without further DB access.
|
||||
"""
|
||||
from app.utils.i18n import SUPPORTED_LANGUAGE_CODES
|
||||
|
||||
user_id: str | None = None
|
||||
if isinstance(session_user, dict):
|
||||
user_id = (
|
||||
session_user.get("sub")
|
||||
or session_user.get("preferred_username")
|
||||
or session_user.get("email")
|
||||
or session_user.get("id")
|
||||
)
|
||||
elif isinstance(session_user, str):
|
||||
user_id = session_user
|
||||
|
||||
if not user_id:
|
||||
return
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
profile = db.query(UserProfile).filter(UserProfile.user_id == user_id).first()
|
||||
if profile and profile.preferred_language and profile.preferred_language in SUPPORTED_LANGUAGE_CODES:
|
||||
request.session["preferred_language"] = profile.preferred_language
|
||||
except Exception: # noqa: BLE001 — intentionally broad; DB may be temporarily unavailable
|
||||
logger.debug("Could not hydrate language preference for user_id=%s", user_id)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _inject_global_context(ctx: dict) -> None:
|
||||
"""Inject shared global variables into every template context dict."""
|
||||
ctx.setdefault("version", settings.version)
|
||||
@@ -70,10 +107,22 @@ def _inject_global_context(ctx: dict) -> None:
|
||||
# When auth is disabled every visitor is effectively "logged in"
|
||||
ctx.setdefault("is_logged_in", not getattr(settings, "auth_enabled", True) or session_user is not None)
|
||||
|
||||
# --- Hydrate session language from DB (once per session) ---
|
||||
# If the session doesn't have a preferred_language yet but the user
|
||||
# is logged in, load the stored preference from the database so that
|
||||
# detect_language() picks it up from the session on this and all
|
||||
# subsequent requests.
|
||||
if hasattr(req, "session") and "preferred_language" not in req.session and session_user is not None:
|
||||
_hydrate_language_from_db(req, session_user)
|
||||
|
||||
# --- i18n: detect language and register template helpers ---
|
||||
current_locale = detect_language(req)
|
||||
ctx.setdefault("current_locale", current_locale)
|
||||
|
||||
# Smart language suggestions for the compact nav-bar dropdown (5-7 languages)
|
||||
accept_header = req.headers.get("accept-language", "") if hasattr(req, "headers") else ""
|
||||
ctx.setdefault("suggested_languages", get_suggested_languages(current_locale, accept_header))
|
||||
|
||||
def _translate(key: str, **kwargs: object) -> str:
|
||||
return translate(key, current_locale, **kwargs)
|
||||
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import time
|
||||
import os
|
||||
import sys
|
||||
import asyncio
|
||||
|
||||
# Mock settings before app imports to bypass validation
|
||||
os.environ["DATABASE_URL"] = "sqlite:///:memory:"
|
||||
os.environ["REDIS_URL"] = "redis://localhost:6379"
|
||||
os.environ["OPENAI_API_KEY"] = "mock_key"
|
||||
os.environ["WORKDIR"] = "/tmp/workdir"
|
||||
os.environ["AZURE_AI_KEY"] = "mock"
|
||||
os.environ["AZURE_REGION"] = "mock"
|
||||
os.environ["AZURE_ENDPOINT"] = "http://mock"
|
||||
os.environ["GOTENBERG_URL"] = "http://mock"
|
||||
os.environ["SESSION_SECRET"] = "mock_secret_mock_secret_mock_secret_mock_secret"
|
||||
|
||||
# Ensure app package is accessible
|
||||
sys.path.insert(0, os.path.abspath(os.path.dirname(__file__)))
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from app.database import Base
|
||||
from app.models import FileRecord
|
||||
from app.api.duplicates import list_duplicate_groups
|
||||
|
||||
# Mocking Request object
|
||||
class MockRequest:
|
||||
def __init__(self):
|
||||
self.session = {"user": {"username": "testuser"}}
|
||||
self.state = type('State', (), {'user': {"username": "testuser"}})()
|
||||
|
||||
class MockURL:
|
||||
def include_query_params(self, **kwargs):
|
||||
return f"http://testserver/api/duplicates?page={kwargs.get('page')}"
|
||||
url = MockURL()
|
||||
|
||||
def setup_db():
|
||||
engine = create_engine('sqlite:///:memory:')
|
||||
Base.metadata.create_all(engine)
|
||||
Session = sessionmaker(bind=engine)
|
||||
db = Session()
|
||||
return db
|
||||
|
||||
def populate_data(db, num_groups, duplicates_per_group):
|
||||
for i in range(num_groups):
|
||||
filehash = f"hash_{i}"
|
||||
# Original
|
||||
original = FileRecord(
|
||||
filehash=filehash,
|
||||
local_filename=f"orig_{i}.txt",
|
||||
file_size=100,
|
||||
is_duplicate=False
|
||||
)
|
||||
db.add(original)
|
||||
# Duplicates
|
||||
for j in range(duplicates_per_group):
|
||||
dup = FileRecord(
|
||||
filehash=filehash,
|
||||
local_filename=f"dup_{i}_{j}.txt",
|
||||
file_size=100,
|
||||
is_duplicate=True
|
||||
)
|
||||
db.add(dup)
|
||||
db.commit()
|
||||
|
||||
async def run_benchmark(db):
|
||||
request = MockRequest()
|
||||
start_time = time.time()
|
||||
|
||||
# Run the function we want to benchmark
|
||||
result = list_duplicate_groups(request=request, db=db, page=1, per_page=500)
|
||||
if asyncio.iscoroutine(result):
|
||||
result = await result
|
||||
|
||||
end_time = time.time()
|
||||
return end_time - start_time, result
|
||||
|
||||
async def main():
|
||||
db = setup_db()
|
||||
# 500 groups, each with 20 duplicates = 10500 records total
|
||||
print("Populating data...")
|
||||
populate_data(db, 500, 20)
|
||||
print("Data populated. Running baseline benchmark...")
|
||||
|
||||
# Warmup
|
||||
result = list_duplicate_groups(request=MockRequest(), db=db, page=1, per_page=500)
|
||||
if asyncio.iscoroutine(result):
|
||||
await result
|
||||
|
||||
# Benchmark
|
||||
total_time = 0
|
||||
iterations = 10
|
||||
for _ in range(iterations):
|
||||
time_taken, _ = await run_benchmark(db)
|
||||
total_time += time_taken
|
||||
|
||||
avg_time = total_time / iterations
|
||||
print(f"Average time over {iterations} iterations: {avg_time:.4f} seconds")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,50 @@
|
||||
import json
|
||||
import time
|
||||
import pytest
|
||||
from app.database import get_db
|
||||
from app.models import UserNotificationTarget, UserNotificationPreference
|
||||
from app.main import app
|
||||
from tests.test_notifications_api import _make_client, _OWNER, _cleanup
|
||||
import statistics
|
||||
|
||||
def run_benchmark(notif_engine, notif_session, client, items_count, iterations=5):
|
||||
# Setup
|
||||
target = UserNotificationTarget(
|
||||
owner_id=_OWNER,
|
||||
channel_type="webhook",
|
||||
name="My Webhook",
|
||||
config=json.dumps({"url": "https://x.com"}),
|
||||
)
|
||||
notif_session.add(target)
|
||||
notif_session.commit()
|
||||
notif_session.refresh(target)
|
||||
|
||||
# Generate big payload
|
||||
preferences = []
|
||||
for i in range(items_count):
|
||||
preferences.append({
|
||||
"event_type": f"event.type.{i}",
|
||||
"channel_type": "webhook",
|
||||
"is_enabled": True,
|
||||
"target_id": target.id,
|
||||
})
|
||||
|
||||
payload = {"preferences": preferences}
|
||||
|
||||
# Warm up
|
||||
client.put("/api/user-notifications/preferences", json=payload)
|
||||
|
||||
times = []
|
||||
for _ in range(iterations):
|
||||
# Alter the values a bit so it's a real update
|
||||
for p in payload["preferences"]:
|
||||
p["is_enabled"] = not p["is_enabled"]
|
||||
|
||||
start = time.time()
|
||||
resp = client.put("/api/user-notifications/preferences", json=payload)
|
||||
end = time.time()
|
||||
|
||||
assert resp.status_code == 200
|
||||
times.append(end - start)
|
||||
|
||||
return statistics.mean(times)
|
||||
@@ -457,6 +457,87 @@ default overage buffer applied across all plans.
|
||||
|
||||
DocuElevate supports HTTP security headers to improve browser-side security. **These headers are disabled by default** since most deployments use a reverse proxy (Traefik, Nginx, etc.) that already adds them. Enable only if deploying directly without a reverse proxy. See [Deployment Guide - Security Headers](DeploymentGuide.md#security-headers) for detailed configuration examples.
|
||||
|
||||
### Application Logging
|
||||
|
||||
DocuElevate uses Python's standard `logging` module. Two environment variables control log verbosity:
|
||||
|
||||
| **Variable** | **Description** | **Default** |
|
||||
|-------------|----------------|-------------|
|
||||
| `LOG_LEVEL` | Root logger level. Accepts standard Python level names: `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL`. | `INFO` |
|
||||
| `DEBUG` | Enable debug mode. When `true` **and** `LOG_LEVEL` is **not** explicitly set, the effective log level is automatically lowered to `DEBUG`. | `false` |
|
||||
|
||||
**Precedence rules (standard behaviour):**
|
||||
|
||||
1. If `LOG_LEVEL` is explicitly set, it always wins — regardless of `DEBUG`.
|
||||
2. If only `DEBUG=true` is set (no `LOG_LEVEL`), the effective level becomes `DEBUG`.
|
||||
3. If neither is set, the default level is `INFO`.
|
||||
|
||||
```bash
|
||||
# Typical production (default)
|
||||
# LOG_LEVEL=INFO
|
||||
|
||||
# Quick debug mode — sets level to DEBUG automatically
|
||||
DEBUG=true
|
||||
|
||||
# Explicit level override (DEBUG flag is ignored for level selection)
|
||||
LOG_LEVEL=WARNING
|
||||
```
|
||||
|
||||
> **Tip:** At `DEBUG` level, noisy third-party libraries (httpx, authlib, urllib3, etc.) are automatically pinned to `WARNING` so that application debug output remains readable.
|
||||
|
||||
#### Structured JSON Logging
|
||||
|
||||
Set `LOG_FORMAT=json` to emit structured JSON lines on stdout — one JSON object per log message. This is the standard format for log collectors and SIEM tools:
|
||||
|
||||
| **Variable** | **Description** | **Default** |
|
||||
|-------------|----------------|-------------|
|
||||
| `LOG_FORMAT` | Log output format: `text` (human-readable) or `json` (structured JSON lines). | `text` |
|
||||
|
||||
Each JSON log line contains: `timestamp` (ISO 8601), `level`, `logger`, `message`, `module`, `funcName`, `lineno`, and `exc_info` (when an exception is logged).
|
||||
|
||||
```bash
|
||||
# Enable JSON logging for SIEM / log aggregation
|
||||
LOG_FORMAT=json
|
||||
```
|
||||
|
||||
**Example JSON output:**
|
||||
```json
|
||||
{"timestamp": "2025-03-16T09:18:05.192000+00:00", "level": "INFO", "logger": "app.auth", "message": "[SECURITY] OAUTH_LOGIN_SUCCESS user=alice@example.com admin=False", "module": "auth", "funcName": "oauth_callback", "lineno": 654}
|
||||
```
|
||||
|
||||
**Compatible with:**
|
||||
- **Grafana Loki** — Promtail scrapes JSON from Docker stdout
|
||||
- **Splunk** — Universal Forwarder or HEC with JSON sourcetype
|
||||
- **ELK / OpenSearch** — Filebeat with JSON codec
|
||||
- **Datadog** — Agent auto-parses JSON logs
|
||||
- **Fluentd / Vector** — JSON input plugin
|
||||
- **Docker log drivers** — `--log-driver=json-file` (default) preserves structure
|
||||
|
||||
#### Syslog Forwarding (Application Logs)
|
||||
|
||||
For traditional (non-container) deployments, application logs can be forwarded directly to a syslog receiver. This is **separate** from audit-log SIEM forwarding (see below) — it sends _every_ Python log message, not just audit events.
|
||||
|
||||
| **Variable** | **Description** | **Default** |
|
||||
|-------------|----------------|-------------|
|
||||
| `LOG_SYSLOG_ENABLED` | Forward application logs to a syslog receiver in addition to stdout. | `false` |
|
||||
| `LOG_SYSLOG_HOST` | Hostname or IP of the syslog receiver. | `localhost` |
|
||||
| `LOG_SYSLOG_PORT` | Port of the syslog receiver. | `514` |
|
||||
| `LOG_SYSLOG_PROTOCOL` | Protocol: `udp` or `tcp`. | `udp` |
|
||||
|
||||
```bash
|
||||
# Forward all application logs to syslog
|
||||
LOG_SYSLOG_ENABLED=true
|
||||
LOG_SYSLOG_HOST=syslog.internal.example.com
|
||||
LOG_SYSLOG_PORT=514
|
||||
LOG_SYSLOG_PROTOCOL=udp
|
||||
|
||||
# Combine with JSON format for structured syslog messages
|
||||
LOG_FORMAT=json
|
||||
LOG_SYSLOG_ENABLED=true
|
||||
```
|
||||
|
||||
> **Note:** When `LOG_FORMAT=json`, syslog messages are also sent as JSON. When `LOG_FORMAT=text`, syslog messages use the standard `name - level - message` format.
|
||||
|
||||
### Audit Logging
|
||||
|
||||
DocuElevate provides comprehensive audit logging that records significant actions (logins, document CRUD, settings changes) to an append-only database table. Every entry captures the timestamp, user, action, resource, client IP, and optional JSON details.
|
||||
|
||||
@@ -1,67 +1,97 @@
|
||||
# Internationalization (i18n) & Localization (l10n) Guide
|
||||
|
||||
DocuElevate supports **49 languages** for its web UI, with automatic browser
|
||||
DocuElevate supports **77 languages** for its web UI, with automatic browser
|
||||
language detection, user-preference persistence, and an AI-powered fallback
|
||||
translator for strings that haven't been manually translated yet.
|
||||
|
||||
## Supported Languages
|
||||
|
||||
| Code | Language | Native Name | 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` | Frisian | Frysk | 🇳🇱 | Tier 2 |
|
||||
| `gl` | Galician | Galego | 🇪🇸 | Tier 2 |
|
||||
| `li` | Limburgish | Limburgs | 🇳🇱 | Tier 2 |
|
||||
| `vls` | West 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 |
|
||||
| `ru` | Russian | Русский | 🇷🇺 | Tier 4 |
|
||||
| `he` | Hebrew | עברית | 🇮🇱 | Tier 4 |
|
||||
| `ar` | Arabic | العربية | 🇸🇦 | Tier 4 |
|
||||
| `fa` | Persian | فارسی | 🇮🇷 | Tier 4 |
|
||||
| `af` | Afrikaans | Afrikaans | 🇿🇦 | Tier 4 |
|
||||
| `zh` | 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 |
|
||||
| `eo` | Esperanto | Esperanto | 🌍 | Tier 6 |
|
||||
| Code | Language | Native Name | Flag | Priority |
|
||||
|---------|--------------------|--------------------|------|----------|
|
||||
| `en` | English | English | 🇬🇧 | Tier 1 |
|
||||
| `de` | German | Deutsch | 🇩🇪 | Tier 1 |
|
||||
| `fr` | French | Français | 🇫🇷 | Tier 1 |
|
||||
| `es` | Spanish | Español | 🇪🇸 | Tier 1 |
|
||||
| `it` | Italian | Italiano | 🇮🇹 | Tier 1 |
|
||||
| `pt` | Portuguese | Português | 🇵🇹 | Tier 1 |
|
||||
| `nl` | Dutch | Nederlands | 🇳🇱 | Tier 2 |
|
||||
| `nb` | Norwegian Bokmål | Norsk bokmål | 🇳🇴 | Tier 2 |
|
||||
| `no` | Norwegian | Norsk | 🇳🇴 | Tier 2 |
|
||||
| `da` | Danish | Dansk | 🇩🇰 | Tier 2 |
|
||||
| `sv` | Swedish | Svenska | 🇸🇪 | Tier 2 |
|
||||
| `fi` | Finnish | Suomi | 🇫🇮 | Tier 2 |
|
||||
| `is` | Icelandic | Íslenska | 🇮🇸 | Tier 2 |
|
||||
| `ga` | Irish | Gaeilge | 🇮🇪 | Tier 2 |
|
||||
| `lb` | Luxembourgish | Lëtzebuergesch | 🇱🇺 | Tier 2 |
|
||||
| `ca` | Catalan | Català | 🏴 | Tier 2 |
|
||||
| `cy` | Welsh | Cymraeg | 🏴 | Tier 2 |
|
||||
| `fy` | Western Frisian | Frysk | 🇳🇱 | Tier 2 |
|
||||
| `gl` | Galician | Galego | 🇪🇸 | Tier 2 |
|
||||
| `li` | Limburgish | Limburgs | 🇳🇱 | Tier 2 |
|
||||
| `vls` | Flemish | West-Vlams | 🇧🇪 | Tier 2 |
|
||||
| `nds` | Low German | Plattdüütsch | 🇩🇪 | Tier 2 |
|
||||
| `pl` | Polish | Polski | 🇵🇱 | Tier 3 |
|
||||
| `cs` | Czech | Čeština | 🇨🇿 | Tier 3 |
|
||||
| `sk` | Slovak | Slovenčina | 🇸🇰 | Tier 3 |
|
||||
| `hu` | Hungarian | Magyar | 🇭🇺 | Tier 3 |
|
||||
| `sl` | Slovenian | Slovenščina | 🇸🇮 | Tier 3 |
|
||||
| `hr` | Croatian | Hrvatski | 🇭🇷 | Tier 3 |
|
||||
| `ro` | Romanian | Română | 🇷🇴 | Tier 3 |
|
||||
| `bg` | Bulgarian | Български | 🇧🇬 | Tier 3 |
|
||||
| `el` | Greek | Ελληνικά | 🇬🇷 | Tier 3 |
|
||||
| `et` | Estonian | Eesti | 🇪🇪 | Tier 3 |
|
||||
| `lv` | Latvian | Latviešu | 🇱🇻 | Tier 3 |
|
||||
| `lt` | Lithuanian | Lietuvių | 🇱🇹 | Tier 3 |
|
||||
| `sr` | Serbian | Српски | 🇷🇸 | Tier 3 |
|
||||
| `tr` | Turkish | Türkçe | 🇹🇷 | Tier 4 |
|
||||
| `uk` | Ukrainian | Українська | 🇺🇦 | Tier 4 |
|
||||
| `he` | Hebrew | עברית | 🇮🇱 | Tier 4 |
|
||||
| `ar` | Arabic | العربية | 🇸🇦 | Tier 4 |
|
||||
| `fa` | Persian | فارسی | 🇮🇷 | Tier 4 |
|
||||
| `af` | Afrikaans | Afrikaans | 🇿🇦 | Tier 4 |
|
||||
| `zh` | Chinese | 中文 | 🇨🇳 | Tier 5 |
|
||||
| `zh-TW` | Traditional Chinese | 繁體中文 | 🇹🇼 | Tier 5 |
|
||||
| `ja` | Japanese | 日本語 | 🇯🇵 | Tier 5 |
|
||||
| `ko` | Korean | 한국어 | 🇰🇷 | Tier 5 |
|
||||
| `vi` | Vietnamese | Tiếng Việt | 🇻🇳 | Tier 5 |
|
||||
| `pa` | Punjabi | ਪੰਜਾਬੀ | 🇮🇳 | Tier 5 |
|
||||
| `kn` | Kannada | ಕನ್ನಡ | 🇮🇳 | Tier 5 |
|
||||
| `hi` | Hindi | हिन्दी | 🇮🇳 | Tier 5 |
|
||||
| `bn` | Bengali | বাংলা | 🇧🇩 | Tier 5 |
|
||||
| `gu` | Gujarati | ગુજરાતી | 🇮🇳 | Tier 5 |
|
||||
| `ml` | Malayalam | മലയാളം | 🇮🇳 | Tier 5 |
|
||||
| `mr` | Marathi | मराठी | 🇮🇳 | Tier 5 |
|
||||
| `ta` | Tamil | தமிழ் | 🇮🇳 | Tier 5 |
|
||||
| `te` | Telugu | తెలుగు | 🇮🇳 | Tier 5 |
|
||||
| `ur` | Urdu | اردو | 🇵🇰 | Tier 5 |
|
||||
| `si` | Sinhala | සිංහල | 🇱🇰 | Tier 5 |
|
||||
| `ne` | Nepali | नेपाली | 🇳🇵 | Tier 5 |
|
||||
| `th` | Thai | ไทย | 🇹🇭 | Tier 5 |
|
||||
| `km` | Khmer | ខ្មែរ | 🇰🇭 | Tier 5 |
|
||||
| `id` | Indonesian | Bahasa Indonesia | 🇮🇩 | Tier 5 |
|
||||
| `ms` | Malay | Bahasa Melayu | 🇲🇾 | Tier 5 |
|
||||
| `jv` | Javanese | Basa Jawa | 🇮🇩 | Tier 5 |
|
||||
| `tl` | Tagalog | Filipino | 🇵🇭 | Tier 5 |
|
||||
| `mn` | Mongolian | Монгол | 🇲🇳 | Tier 5 |
|
||||
| `kk` | Kazakh | Қазақ тілі | 🇰🇿 | Tier 5 |
|
||||
| `uz` | Uzbek | Oʻzbekcha | 🇺🇿 | Tier 5 |
|
||||
| `az` | Azerbaijani | Azərbaycan dili | 🇦🇿 | Tier 5 |
|
||||
| `hy` | Armenian | Հայերեն | 🇦🇲 | Tier 5 |
|
||||
| `ka` | Georgian | ქართული | 🇬🇪 | Tier 5 |
|
||||
| `sw` | Swahili | Kiswahili | 🇰🇪 | Tier 6 |
|
||||
| `am` | Amharic | አማርኛ | 🇪🇹 | Tier 6 |
|
||||
| `ha` | Hausa | Hausa | 🇳🇬 | Tier 6 |
|
||||
| `yo` | Yoruba | Yorùbá | 🇳🇬 | Tier 6 |
|
||||
| `ig` | Igbo | Igbo | 🇳🇬 | Tier 6 |
|
||||
| `zu` | Zulu | isiZulu | 🇿🇦 | Tier 6 |
|
||||
| `eo` | Esperanto | Esperanto | 🌍 | Tier 7 |
|
||||
|
||||
> **Tier 1** languages (major European) have complete, manually-reviewed
|
||||
> translations. **Tier 2–3** languages have complete translations but may
|
||||
> receive less frequent updates. **Tier 4–5** cover Non-EU European, Middle
|
||||
> Eastern, and Asian languages. **Tier 6** covers constructed languages.
|
||||
> receive less frequent updates. **Tier 4** covers Non-EU European, Middle
|
||||
> Eastern, and South African (Afrikaans) languages. **Tier 5** covers Asian
|
||||
> and Central Asian languages. **Tier 6** covers African languages. **Tier 7**
|
||||
> covers constructed languages.
|
||||
|
||||
## How Language Is Detected
|
||||
|
||||
@@ -222,8 +252,11 @@ format_number(1234.56, "en") # → "1,234.56"
|
||||
### Adding a New Translation Key
|
||||
|
||||
1. Add the key and English text to `frontend/translations/en.json`
|
||||
2. Add translations for all other languages in their respective files
|
||||
3. Use `{{ _("your.new.key") }}` in templates
|
||||
2. Use `{{ _("your.new.key") }}` in templates or `translate("your.new.key", locale)` in Python
|
||||
|
||||
That's it. An external automation script picks up new keys in `en.json` and propagates
|
||||
translations to all other language files. You never need to touch the non-English JSON
|
||||
files manually — the translate-and-sync pipeline takes care of it.
|
||||
|
||||
### AI Fallback Translation
|
||||
|
||||
|
||||
+59
-6
@@ -20,7 +20,7 @@ DocuElevate includes a native mobile application for iOS and Android built with
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 18 or later
|
||||
- Node.js 20.19.4 or later (use [nvm](https://github.com/nvm-sh/nvm): `nvm install` inside `mobile/` reads `.nvmrc` automatically)
|
||||
- [Expo CLI](https://docs.expo.dev/get-started/installation/): `npm install -g @expo/cli`
|
||||
- [Expo Go](https://expo.dev/client) app on your iOS or Android device (for development)
|
||||
- A running DocuElevate server reachable from your device
|
||||
@@ -53,6 +53,8 @@ eas build --platform ios
|
||||
eas build --platform android
|
||||
```
|
||||
|
||||
> **Note:** The mobile app uses `expo-build-properties` with `buildReactNativeFromSource: true` for iOS builds. This is required for Expo SDK 54 (React Native 0.81) compatibility — some native modules still use legacy bridge APIs (`RCTBridge`, `RCTViewManager`, etc.) that are no longer included in the default precompiled XCFrameworks. Building React Native from source makes these headers available, at the cost of slightly longer iOS build times.
|
||||
|
||||
See the [EAS Build documentation](https://docs.expo.dev/build/introduction/) for full setup instructions.
|
||||
|
||||
## Authentication
|
||||
@@ -62,11 +64,25 @@ See the [EAS Build documentation](https://docs.expo.dev/build/introduction/) for
|
||||
The mobile app uses the server's existing OAuth2/SSO setup:
|
||||
|
||||
1. User enters the DocuElevate server URL on the login screen.
|
||||
2. The app opens `<server>/login?mobile=1&redirect_uri=docuelevate://callback` in the **system browser** (Safari / Chrome).
|
||||
3. The user authenticates via SSO or local credentials.
|
||||
4. The server redirects back to `docuelevate://callback`.
|
||||
5. The app calls `POST /api/mobile/generate-token` to exchange the session for a **long-lived API token**.
|
||||
6. The token is stored securely in the device's keychain (`expo-secure-store`).
|
||||
2. The app opens `<server>/login?mobile=1&redirect_uri=docuelevate://callback` in the **system browser** (Safari / Chrome Custom Tabs).
|
||||
3. The server stores `docuelevate://callback` in the browser session and presents the login page.
|
||||
4. The user authenticates via SSO or local credentials.
|
||||
5. After successful authentication the server mints a long-lived API token and redirects the browser to `docuelevate://callback?token=<token>`.
|
||||
6. `WebBrowser.openAuthSessionAsync` intercepts the `docuelevate://` deep link and returns the URL to the app.
|
||||
7. The app extracts the token from the URL and stores it securely in the device's keychain (`expo-secure-store`).
|
||||
|
||||
> **Security note:** The `redirect_uri` is validated server-side; only URIs with the `docuelevate://` custom scheme (production) or the `exp://` scheme (Expo Go development) are accepted, preventing open-redirect attacks.
|
||||
|
||||
### Testing in Expo Go
|
||||
|
||||
When developing with **Expo Go** the app does not have the `docuelevate://` custom URL scheme registered. The auth flow adapts automatically:
|
||||
|
||||
1. `Linking.createURL('callback')` returns an `exp://` URI pointing at the local dev server (e.g. `exp://192.168.1.5:8081/--/callback`).
|
||||
2. This URI is sent to the server as `redirect_uri`; the server accepts it alongside the production `docuelevate://` scheme.
|
||||
3. After successful authentication the server redirects back to the `exp://` URI.
|
||||
4. `WebBrowser.openAuthSessionAsync` intercepts the deep link and the Expo Go app receives the token.
|
||||
|
||||
No extra configuration is needed — just run `npx expo start` and scan the QR code with the **Expo Go** app.
|
||||
|
||||
### Auto-generated Mobile Token
|
||||
|
||||
@@ -226,6 +242,43 @@ mobile/
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Session expired Local session" during iOS build
|
||||
|
||||
EAS stores an Apple ID session locally (in `~/.expo/`) to manage code-signing certificates and provisioning profiles. This session expires after a few weeks.
|
||||
|
||||
**To fix:**
|
||||
|
||||
1. **Refresh the session** by running `eas credentials` and re-authenticating with your Apple ID.
|
||||
2. **Recommended for automation:** Replace the Apple ID session with an [App Store Connect API Key](https://docs.expo.dev/app-signing/app-credentials/#app-store-connect-api-key). API keys do not expire and work fully non-interactively:
|
||||
- Create a key at [appstoreconnect.apple.com → Users → Integrations → Keys](https://appstoreconnect.apple.com/access/integrations/api)
|
||||
- Download the `.p8` file and note the **Key ID** and **Issuer ID**
|
||||
- Run `eas credentials` → iOS → *Add an App Store Connect API key*
|
||||
- Upload the `.p8` file when prompted
|
||||
|
||||
Once an API key is configured in EAS, automated builds (including CI and EAS Cloud Workflows) will no longer prompt for a password.
|
||||
|
||||
### Node.js deprecation warning `[DEP0169]` during EAS build
|
||||
|
||||
```
|
||||
(node:XXXXX) [DEP0169] DeprecationWarning: `url.parse()` behavior is not standardized…
|
||||
```
|
||||
|
||||
This warning is emitted by **EAS CLI** (an external tool) when it runs on **Node.js 22 or later**, which deprecates `url.parse()`. It does not indicate a problem in the DocuElevate mobile app itself and will not cause a build failure on its own.
|
||||
|
||||
The `eas.json` build profiles already include `"NODE_NO_WARNINGS": "1"` in their `env` sections to suppress this warning during EAS Cloud builds. For local builds with a system Node.js ≥ 22, suppress it by running:
|
||||
|
||||
```bash
|
||||
NODE_NO_WARNINGS=1 eas build --platform ios
|
||||
```
|
||||
|
||||
or by activating the project's pinned Node.js version first:
|
||||
|
||||
```bash
|
||||
cd mobile
|
||||
nvm use # reads .nvmrc → Node 20.19.4 (no deprecation warning)
|
||||
eas build --platform ios
|
||||
```
|
||||
|
||||
### "Authentication was cancelled or failed"
|
||||
|
||||
- Ensure the server URL is correct (including `https://`).
|
||||
|
||||
+197
-39
@@ -2,6 +2,8 @@
|
||||
|
||||
This document provides solutions to common problems encountered when using DocuElevate.
|
||||
|
||||
> **Tip:** For configuration-specific issues, see also the [Configuration Troubleshooting](ConfigurationTroubleshooting.md) guide.
|
||||
|
||||
## Common Issues
|
||||
|
||||
### Application Won't Start
|
||||
@@ -14,7 +16,7 @@ This document provides solutions to common problems encountered when using DocuE
|
||||
#### Possible Solutions
|
||||
1. **Check environment variables**
|
||||
```bash
|
||||
docker-compose config
|
||||
docker compose config
|
||||
```
|
||||
Ensure all required variables are set properly in your `.env` file.
|
||||
|
||||
@@ -30,6 +32,18 @@ This document provides solutions to common problems encountered when using DocuE
|
||||
```
|
||||
Ensure the port isn't already in use by another application.
|
||||
|
||||
4. **Check Redis connectivity**
|
||||
```bash
|
||||
docker compose logs redis
|
||||
```
|
||||
Ensure Redis is running — both the API server and Celery worker depend on it.
|
||||
|
||||
5. **Check database migrations**
|
||||
```bash
|
||||
docker compose exec api alembic upgrade head
|
||||
```
|
||||
Ensure the database schema is up-to-date.
|
||||
|
||||
### Document Upload Fails
|
||||
|
||||
#### Symptoms
|
||||
@@ -39,41 +53,51 @@ This document provides solutions to common problems encountered when using DocuE
|
||||
|
||||
#### Possible Solutions
|
||||
1. **Check file size limits**
|
||||
- Default maximum file size is 100MB
|
||||
- Adjust `client_max_body_size` in your reverse proxy configuration
|
||||
- Default maximum file size is 1 GB (`MAX_UPLOAD_SIZE`)
|
||||
- Individual file limit: `MAX_SINGLE_FILE_SIZE` (default: same as `MAX_UPLOAD_SIZE`)
|
||||
- If using a reverse proxy, adjust `client_max_body_size` (Nginx) or equivalent
|
||||
|
||||
2. **Verify storage space**
|
||||
```bash
|
||||
df -h
|
||||
```
|
||||
Ensure there's sufficient disk space available.
|
||||
Ensure there's sufficient disk space on the workdir volume.
|
||||
|
||||
3. **Check worker process**
|
||||
```bash
|
||||
docker-compose logs worker
|
||||
docker compose logs worker
|
||||
```
|
||||
Verify the Celery worker is running and processing tasks.
|
||||
|
||||
4. **Check upload quota**
|
||||
If multi-user mode and subscriptions are enabled, verify the user hasn't exceeded their daily upload limit (`DEFAULT_DAILY_UPLOAD_LIMIT`).
|
||||
|
||||
### OCR or Text Extraction Issues
|
||||
|
||||
#### Symptoms
|
||||
- Documents upload but text isn't extracted
|
||||
- Poor quality text extraction
|
||||
- API errors related to Azure services
|
||||
- API errors related to OCR services
|
||||
|
||||
#### Possible Solutions
|
||||
1. **Verify API credentials**
|
||||
Check the Azure Document Intelligence API key and endpoint in your `.env` file.
|
||||
1. **Verify the configured OCR provider**
|
||||
Check which provider is set via the `OCR_PROVIDER` environment variable (defaults to Azure Document Intelligence).
|
||||
|
||||
2. **Check document quality**
|
||||
2. **Verify API credentials**
|
||||
Check the credentials for your configured OCR provider in your `.env` file:
|
||||
- **Azure**: `AZURE_DI_KEY` and `AZURE_DI_ENDPOINT`
|
||||
- **Tesseract**: No credentials required (local), but ensure `TESSERACT_LANGUAGES` is set
|
||||
- **EasyOCR**: No credentials required (local)
|
||||
- **Mistral**: `MISTRAL_OCR_API_KEY`
|
||||
- **Google Document AI**: `GOOGLE_DOCAI_PROJECT_ID`, `GOOGLE_DOCAI_LOCATION`, `GOOGLE_DOCAI_PROCESSOR_ID`
|
||||
- **AWS Textract**: `AWS_TEXTRACT_ACCESS_KEY_ID`, `AWS_TEXTRACT_SECRET_ACCESS_KEY`, `AWS_TEXTRACT_REGION`
|
||||
|
||||
3. **Check document quality**
|
||||
- Ensure documents are clearly scanned
|
||||
- Try preprocessing images to improve quality before upload
|
||||
|
||||
3. **Test API connectivity**
|
||||
```bash
|
||||
curl -X GET -H "Ocp-Apim-Subscription-Key: YOUR_KEY" "YOUR_ENDPOINT"
|
||||
```
|
||||
Ensure the API is accessible from your server.
|
||||
4. **Try multi-provider OCR**
|
||||
Configure `OCR_PROVIDERS` (comma-separated list) with a merge strategy (`OCR_MERGE_STRATEGY`: `ai_merge`, `longest`, or `primary`) for better results.
|
||||
|
||||
### Email Integration Problems
|
||||
|
||||
@@ -84,92 +108,226 @@ This document provides solutions to common problems encountered when using DocuE
|
||||
|
||||
#### Possible Solutions
|
||||
1. **Verify IMAP settings**
|
||||
Check host, port, username, and password in your configuration.
|
||||
Check host, port, username, and password for `IMAP1_*` / `IMAP2_*` in your configuration.
|
||||
|
||||
2. **Test IMAP connectivity**
|
||||
```bash
|
||||
telnet mail.example.com 993
|
||||
docker compose exec api python -c "import imaplib; m = imaplib.IMAP4_SSL('mail.example.com', 993); print('OK')"
|
||||
```
|
||||
Ensure the IMAP server is accessible.
|
||||
Ensure the IMAP server is accessible from the container.
|
||||
|
||||
3. **Enable less secure apps**
|
||||
For Gmail and some providers, you may need to enable access for less secure apps or use app-specific passwords.
|
||||
3. **Check for app-specific passwords**
|
||||
For Gmail and some providers, you must use app-specific passwords instead of your account password.
|
||||
|
||||
4. **Check firewall settings**
|
||||
Ensure your server can make outbound connections to the mail server.
|
||||
Ensure your server can make outbound connections to the mail server on port 993 (IMAP SSL).
|
||||
|
||||
5. **Check attachment filter**
|
||||
If only certain attachments are expected, verify `IMAP_ATTACHMENT_FILTER` is set correctly (`documents_only` or `all`).
|
||||
|
||||
### Storage Integration Issues
|
||||
|
||||
#### Symptoms
|
||||
- Files aren't appearing in Dropbox/Nextcloud/Paperless
|
||||
- Files aren't appearing in configured storage destinations
|
||||
- Authentication errors in logs
|
||||
- API rate limiting errors
|
||||
|
||||
#### Possible Solutions
|
||||
1. **Verify API credentials**
|
||||
Double-check all API keys, tokens, and secrets.
|
||||
Double-check all API keys, tokens, and secrets for the relevant service.
|
||||
|
||||
2. **Check access permissions**
|
||||
Ensure the application has write permissions to the specified folders.
|
||||
Ensure the application has write permissions to the specified folders/buckets.
|
||||
|
||||
3. **Refresh tokens**
|
||||
For OAuth-based services like Dropbox, try generating new refresh tokens.
|
||||
For OAuth-based services like Dropbox, Google Drive, and OneDrive, try re-authorizing through the integration setup pages.
|
||||
|
||||
4. **Examine detailed logs**
|
||||
```bash
|
||||
docker-compose logs worker | grep -i dropbox
|
||||
docker compose logs worker | grep -i "upload_to"
|
||||
```
|
||||
Look for specific error messages related to the service.
|
||||
Look for specific error messages related to the storage service.
|
||||
|
||||
5. **Check integration status**
|
||||
Visit the **Integrations** page in the web UI to verify the connection status of each configured storage backend.
|
||||
|
||||
## Search Issues
|
||||
|
||||
### Symptoms
|
||||
- Search returns no results or incomplete results
|
||||
- Search page shows an error
|
||||
|
||||
### Possible Solutions
|
||||
1. **Check Meilisearch is running**
|
||||
```bash
|
||||
docker compose logs meilisearch
|
||||
```
|
||||
Ensure the Meilisearch container is healthy and accepting connections.
|
||||
|
||||
2. **Verify Meilisearch URL**
|
||||
Check `MEILISEARCH_URL` in your `.env` file (default: `http://meilisearch:7700`).
|
||||
|
||||
3. **Rebuild the search index**
|
||||
If documents are missing from search results, reprocessing them will re-index their content.
|
||||
|
||||
## Pipeline & Routing Issues
|
||||
|
||||
### Symptoms
|
||||
- Documents are not processed according to pipeline steps
|
||||
- Routing rules don't match expected documents
|
||||
|
||||
### Possible Solutions
|
||||
1. **Verify pipeline assignment**
|
||||
On the file detail page, check which pipeline (if any) is assigned. The system pipeline applies to all documents by default.
|
||||
|
||||
2. **Test routing rules**
|
||||
Use the **Evaluate** button on the Routing Rules page to test whether a rule matches a specific document.
|
||||
|
||||
3. **Check step ordering**
|
||||
Pipeline steps execute in order — ensure OCR comes before metadata extraction if the AI step depends on extracted text.
|
||||
|
||||
## Database Issues
|
||||
|
||||
#### Symptoms
|
||||
### Symptoms
|
||||
- Application errors related to database connections
|
||||
- Missing or corrupt data
|
||||
- Slow performance
|
||||
|
||||
#### Possible Solutions
|
||||
### Possible Solutions
|
||||
1. **Check database connection string**
|
||||
Verify the `DATABASE_URL` variable in your `.env` file.
|
||||
|
||||
2. **Inspect database integrity**
|
||||
For SQLite:
|
||||
```bash
|
||||
sqlite3 database.db "PRAGMA integrity_check;"
|
||||
```
|
||||
(For SQLite databases)
|
||||
For PostgreSQL (recommended for production):
|
||||
```bash
|
||||
docker compose exec api python -c "from app.database import engine; print(engine.url)"
|
||||
```
|
||||
|
||||
3. **Perform database migrations**
|
||||
```bash
|
||||
docker-compose exec api alembic upgrade head
|
||||
docker compose exec api alembic upgrade head
|
||||
```
|
||||
Ensure the database schema is up-to-date.
|
||||
|
||||
4. **Consider PostgreSQL for production**
|
||||
SQLite is suitable for small deployments, but PostgreSQL is recommended for multi-user production environments. See the [Database Configuration Guide](DatabaseConfiguration.md).
|
||||
|
||||
## Authentication Problems
|
||||
|
||||
#### Symptoms
|
||||
### Symptoms
|
||||
- Unable to log in
|
||||
- Redirect loops during authentication
|
||||
- OAuth errors
|
||||
|
||||
#### Possible Solutions
|
||||
1. **Verify Authentik configuration**
|
||||
Check client ID, client secret, and configuration URL.
|
||||
### Possible Solutions
|
||||
1. **Verify OAuth/OIDC configuration**
|
||||
Check client ID, client secret, and configuration URL for your identity provider.
|
||||
|
||||
2. **Check callback URLs**
|
||||
Ensure the redirect URIs are correctly configured in your OAuth provider.
|
||||
Ensure the redirect URIs are correctly configured in your OAuth provider. The callback URL is typically `https://your-domain/auth/callback`.
|
||||
|
||||
3. **Clear browser cookies and cache**
|
||||
Authentication issues can sometimes be resolved by clearing browser data.
|
||||
|
||||
4. **Check social login credentials**
|
||||
If using social login (Google, Microsoft, Apple, Dropbox), verify the corresponding `SOCIAL_AUTH_*` environment variables.
|
||||
|
||||
5. **Verify `EXTERNAL_HOSTNAME`**
|
||||
The `EXTERNAL_HOSTNAME` setting must match the domain users access DocuElevate from — OAuth redirect URLs depend on it.
|
||||
|
||||
## Mobile App Issues
|
||||
|
||||
### Symptoms
|
||||
- Can't connect to DocuElevate from the mobile app
|
||||
- Push notifications not received
|
||||
- Login fails
|
||||
|
||||
### Possible Solutions
|
||||
1. **Verify the server URL**
|
||||
Ensure the mobile app is configured with the correct DocuElevate server URL (including `https://`).
|
||||
|
||||
2. **Check API token**
|
||||
Generate a fresh API token from the web UI (Profile → API Tokens) and enter it in the mobile app settings.
|
||||
|
||||
3. **Check network connectivity**
|
||||
The mobile device must be able to reach your DocuElevate server. If using a private network, ensure VPN is connected.
|
||||
|
||||
4. **Push notifications**
|
||||
Push notifications require a valid Expo push token. Check the app settings and ensure notifications are enabled at the OS level.
|
||||
|
||||
See the [Mobile App Guide](MobileApp.md) for detailed setup instructions.
|
||||
|
||||
## CLI Issues
|
||||
|
||||
### Symptoms
|
||||
- CLI commands fail with connection errors
|
||||
- Authentication rejected
|
||||
|
||||
### Possible Solutions
|
||||
1. **Verify URL and token**
|
||||
```bash
|
||||
docuelevate --url https://your-instance --token de_xxx list
|
||||
```
|
||||
Ensure the URL is correct (include the scheme) and the API token is valid.
|
||||
|
||||
2. **Check environment variables**
|
||||
The CLI reads `DOCUELEVATE_URL` and `DOCUELEVATE_API_TOKEN` from the environment. Verify they are exported.
|
||||
|
||||
3. **Test API directly**
|
||||
```bash
|
||||
curl -H "Authorization: Bearer de_xxx" https://your-instance/api/files
|
||||
```
|
||||
If this fails, the issue is with the server, not the CLI.
|
||||
|
||||
See the [CLI Guide](CLIGuide.md) for detailed usage.
|
||||
|
||||
## Performance Issues
|
||||
|
||||
### Symptoms
|
||||
- Slow document processing
|
||||
- High memory usage
|
||||
- Queue backing up
|
||||
|
||||
### Possible Solutions
|
||||
1. **Check worker concurrency**
|
||||
The Celery worker processes tasks in parallel. If the queue is backing up, consider scaling workers or adjusting concurrency.
|
||||
|
||||
2. **Enable batch throttling**
|
||||
Set `PROCESSALL_THROTTLE_THRESHOLD` and `PROCESSALL_THROTTLE_DELAY` to prevent overwhelming external APIs.
|
||||
|
||||
3. **Monitor the queue**
|
||||
Visit the **Admin → Queue** page to see pending, active, and failed tasks.
|
||||
|
||||
4. **Use PostgreSQL**
|
||||
SQLite can become a bottleneck under load. Migrate to PostgreSQL for better concurrent performance. See the [Database Configuration Guide](DatabaseConfiguration.md).
|
||||
|
||||
5. **Check Redis memory**
|
||||
```bash
|
||||
docker compose exec redis redis-cli info memory
|
||||
```
|
||||
Ensure Redis has sufficient memory for the task queue and cache.
|
||||
|
||||
## Getting Additional Help
|
||||
|
||||
If you continue to experience issues after trying these solutions:
|
||||
|
||||
1. **Check the logs** for detailed error messages
|
||||
1. **Check the logs** for detailed error messages:
|
||||
```bash
|
||||
docker-compose logs --tail=100
|
||||
docker compose logs --tail=200
|
||||
```
|
||||
|
||||
2. **Open an issue** on the [GitHub repository](https://github.com/christianlouis/document-processor/issues)
|
||||
2. **Check the status page** at `/status` in the web UI for an overview of all service connections.
|
||||
|
||||
3. **Contact the developer** via the information provided on the About page
|
||||
3. **Open an issue** on the [GitHub repository](https://github.com/christianlouis/DocuElevate/issues) with:
|
||||
- A description of the problem
|
||||
- Relevant log output
|
||||
- Your DocuElevate version (shown on the About page or in the `VERSION` file)
|
||||
|
||||
4. **Consult additional documentation**:
|
||||
- [Configuration Guide](ConfigurationGuide.md) — All environment variables
|
||||
- [Configuration Troubleshooting](ConfigurationTroubleshooting.md) — Configuration-specific issues
|
||||
- [Deployment Guide](DeploymentGuide.md) — Infrastructure and deployment
|
||||
|
||||
@@ -63,6 +63,15 @@ DocuElevate features a simple navigation system with the following main sections
|
||||
- **Search**: Dedicated full-text search across all document content
|
||||
- **About**: Information about DocuElevate
|
||||
|
||||
### Other Ways to Use DocuElevate
|
||||
|
||||
Beyond the web interface, DocuElevate is available through several additional clients:
|
||||
|
||||
- **Mobile App (iOS & Android)** — Capture documents with your phone camera or upload from your photo library. See the [Mobile App Guide](MobileApp.md) for setup and usage.
|
||||
- **Browser Extension** — Clip web pages or send files to DocuElevate directly from Chrome, Firefox, or Edge. See the [Browser Extension Guide](BrowserExtension.md) for installation.
|
||||
- **CLI Tool** — Upload, download, search, and manage documents from the command line or scripts. See the [CLI Guide](CLIGuide.md) for details.
|
||||
- **REST & GraphQL API** — Full programmatic access for automation and integrations. See the [API Documentation](API.md).
|
||||
|
||||
## Uploading Documents
|
||||
|
||||
DocuElevate provides multiple convenient ways to upload documents to the system.
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import re
|
||||
|
||||
with open("tests/test_api_saved_searches.py", "r") as f:
|
||||
content = f.read()
|
||||
|
||||
# We need to mock get_current_user in app.api.saved_searches (which is imported from app.auth)
|
||||
# because saved searches uses `_get_user_id` which calls `get_current_user(request)`.
|
||||
# But `_get_user_id` is NOT a dependency injected via `Depends`!
|
||||
# Let's verify `app/api/saved_searches.py` uses `Depends` or just calls it.
|
||||
|
||||
# In `app/api/saved_searches.py`:
|
||||
# def _get_user_id(request: Request) -> str:
|
||||
# user = get_current_user(request)
|
||||
# if user:
|
||||
# return user.get("preferred_username") ...
|
||||
# It's called directly inside the routes: `user_id = _get_user_id(request)`
|
||||
# It doesn't use `Depends(_get_user_id)`.
|
||||
# Ah! But earlier I saw `_get_user_id` wasn't mocked properly. Let's use patch to mock `_get_user_id`.
|
||||
|
||||
# Wait, `TestClient` can be given an active session, but `app.auth.get_current_user` uses `request.session.get("user")` or Bearer token.
|
||||
# Is `AUTH_ENABLED` false? The test env has `os.environ["AUTH_ENABLED"] = "False"` in `tests/conftest.py`.
|
||||
# If `AUTH_ENABLED` is false, `require_login` is a no-op, and `_get_user_id` falls back to "anonymous".
|
||||
# Actually, `_get_user_id` returns "anonymous" if `get_current_user(request)` is None.
|
||||
# If `_OWNER` is "test_user@example.com", we should probably just patch `_get_user_id`.
|
||||
|
||||
replacement = """def _make_client(int_engine, owner_id: str = _OWNER):
|
||||
\"\"\"Return a TestClient with *owner_id* injected as the authenticated user.\"\"\"
|
||||
from app.main import app
|
||||
from unittest.mock import patch
|
||||
|
||||
def override_db():
|
||||
Session = sessionmaker(bind=int_engine)
|
||||
session = Session()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
app.dependency_overrides[get_db] = override_db
|
||||
with patch("app.api.saved_searches._get_user_id", return_value=owner_id):
|
||||
with TestClient(app, base_url="http://localhost", raise_server_exceptions=False) as client:
|
||||
yield client
|
||||
app.dependency_overrides.clear()"""
|
||||
|
||||
content = re.sub(
|
||||
r"def _make_client\(int_engine, owner_id: str = _OWNER\):.*?(?=@pytest\.fixture\(\)\ndef int_client\(int_engine\):)",
|
||||
replacement + "\n\n\n",
|
||||
content,
|
||||
flags=re.DOTALL
|
||||
)
|
||||
|
||||
with open("tests/test_api_saved_searches.py", "w") as f:
|
||||
f.write(content)
|
||||
@@ -165,11 +165,11 @@ function _makeMenuLink(href, iconClass, label, extraClasses = '') {
|
||||
'flex items-center gap-2 px-2 py-1 rounded-md text-gray-700 hover:text-gray-900 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500';
|
||||
btn.setAttribute('aria-haspopup', 'true');
|
||||
btn.setAttribute('aria-expanded', 'false');
|
||||
btn.setAttribute('aria-label', `Account menu for ${displayName}`);
|
||||
btn.setAttribute('aria-label', `${((window.__i18n?.accountMenuFor) || 'Account menu for {name}').replace('{name}', displayName)}`);
|
||||
|
||||
const avatar = document.createElement('img');
|
||||
avatar.src = data.picture;
|
||||
avatar.alt = 'Avatar';
|
||||
avatar.alt = window.__i18n.avatar || 'Avatar';
|
||||
avatar.className = 'w-8 h-8 rounded-full';
|
||||
|
||||
const nameSpan = document.createElement('span');
|
||||
@@ -189,7 +189,7 @@ function _makeMenuLink(href, iconClass, label, extraClasses = '') {
|
||||
menu.className =
|
||||
'absolute right-0 mt-2 w-52 bg-white rounded-md shadow-lg ring-1 ring-black ring-opacity-5 z-50 hidden';
|
||||
menu.setAttribute('role', 'menu');
|
||||
menu.setAttribute('aria-label', 'Account menu');
|
||||
menu.setAttribute('aria-label', window.__i18n.accountMenu || 'Account menu');
|
||||
|
||||
// User info header
|
||||
const header = document.createElement('div');
|
||||
@@ -207,16 +207,16 @@ function _makeMenuLink(href, iconClass, label, extraClasses = '') {
|
||||
const linksDiv = document.createElement('div');
|
||||
linksDiv.className = 'py-1';
|
||||
linksDiv.appendChild(
|
||||
_makeMenuLink('/profile', 'fas fa-user-circle text-blue-400', 'Profile Settings', 'text-gray-700')
|
||||
_makeMenuLink('/profile', 'fas fa-user-circle text-blue-400', window.__i18n.profileSettings || 'Profile Settings', 'text-gray-700')
|
||||
);
|
||||
linksDiv.appendChild(
|
||||
_makeMenuLink('/subscription', 'fas fa-layer-group text-indigo-400', 'My Subscription', 'text-gray-700')
|
||||
_makeMenuLink('/subscription', 'fas fa-layer-group text-indigo-400', window.__i18n.mySubscription || 'My Subscription', 'text-gray-700')
|
||||
);
|
||||
linksDiv.appendChild(
|
||||
_makeMenuLink('/api-tokens', 'fas fa-key text-yellow-500', 'API Tokens', 'text-gray-700')
|
||||
_makeMenuLink('/api-tokens', 'fas fa-key text-yellow-500', window.__i18n.apiTokens || 'API Tokens', 'text-gray-700')
|
||||
);
|
||||
linksDiv.appendChild(
|
||||
_makeMenuLink('/shared-links', 'fas fa-share-alt text-blue-400', 'Shared Links', 'text-gray-700')
|
||||
_makeMenuLink('/shared-links', 'fas fa-share-alt text-blue-400', window.__i18n.sharedLinks || 'Shared Links', 'text-gray-700')
|
||||
);
|
||||
|
||||
// Divider + Sign Out
|
||||
@@ -225,7 +225,7 @@ function _makeMenuLink(href, iconClass, label, extraClasses = '') {
|
||||
const signOutDiv = document.createElement('div');
|
||||
signOutDiv.className = 'py-1';
|
||||
signOutDiv.appendChild(
|
||||
_makeMenuLink('/logout', 'fas fa-sign-out-alt text-red-400', 'Sign Out', 'text-red-600')
|
||||
_makeMenuLink('/logout', 'fas fa-sign-out-alt text-red-400', window.__i18n.signOut || 'Sign Out', 'text-red-600')
|
||||
);
|
||||
|
||||
menu.appendChild(header);
|
||||
@@ -259,7 +259,7 @@ function _makeMenuLink(href, iconClass, label, extraClasses = '') {
|
||||
userRow.className = 'flex items-center gap-3 px-3 py-3';
|
||||
const mAvatar = document.createElement('img');
|
||||
mAvatar.src = data.picture;
|
||||
mAvatar.alt = 'Avatar';
|
||||
mAvatar.alt = window.__i18n.avatar || 'Avatar';
|
||||
mAvatar.className = 'w-8 h-8 rounded-full flex-shrink-0';
|
||||
const mUserInfo = document.createElement('div');
|
||||
mUserInfo.className = 'min-w-0';
|
||||
@@ -284,7 +284,7 @@ function _makeMenuLink(href, iconClass, label, extraClasses = '') {
|
||||
profileIcon.className = 'fas fa-user-circle mr-2 text-blue-400';
|
||||
profileIcon.setAttribute('aria-hidden', 'true');
|
||||
profileLink.appendChild(profileIcon);
|
||||
profileLink.appendChild(document.createTextNode('Profile Settings'));
|
||||
profileLink.appendChild(document.createTextNode(window.__i18n.profileSettings || 'Profile Settings'));
|
||||
mobileAuthSection.appendChild(profileLink);
|
||||
|
||||
// Subscription link
|
||||
@@ -296,7 +296,7 @@ function _makeMenuLink(href, iconClass, label, extraClasses = '') {
|
||||
subIcon.className = 'fas fa-layer-group mr-2 text-indigo-400';
|
||||
subIcon.setAttribute('aria-hidden', 'true');
|
||||
subLink.appendChild(subIcon);
|
||||
subLink.appendChild(document.createTextNode('My Subscription'));
|
||||
subLink.appendChild(document.createTextNode(window.__i18n.mySubscription || 'My Subscription'));
|
||||
mobileAuthSection.appendChild(subLink);
|
||||
|
||||
// API Tokens link
|
||||
@@ -308,7 +308,7 @@ function _makeMenuLink(href, iconClass, label, extraClasses = '') {
|
||||
tokensIcon.className = 'fas fa-key mr-2 text-yellow-500';
|
||||
tokensIcon.setAttribute('aria-hidden', 'true');
|
||||
tokensLink.appendChild(tokensIcon);
|
||||
tokensLink.appendChild(document.createTextNode('API Tokens'));
|
||||
tokensLink.appendChild(document.createTextNode(window.__i18n.apiTokens || 'API Tokens'));
|
||||
mobileAuthSection.appendChild(tokensLink);
|
||||
|
||||
// Shared Links link
|
||||
@@ -320,7 +320,7 @@ function _makeMenuLink(href, iconClass, label, extraClasses = '') {
|
||||
sharedLinksIcon.className = 'fas fa-share-alt mr-2 text-blue-400';
|
||||
sharedLinksIcon.setAttribute('aria-hidden', 'true');
|
||||
sharedLinksLink.appendChild(sharedLinksIcon);
|
||||
sharedLinksLink.appendChild(document.createTextNode('Shared Links'));
|
||||
sharedLinksLink.appendChild(document.createTextNode(window.__i18n.sharedLinks || 'Shared Links'));
|
||||
mobileAuthSection.appendChild(sharedLinksLink);
|
||||
|
||||
// Logout link
|
||||
@@ -332,7 +332,7 @@ function _makeMenuLink(href, iconClass, label, extraClasses = '') {
|
||||
logoutIcon.className = 'fas fa-sign-out-alt mr-2';
|
||||
logoutIcon.setAttribute('aria-hidden', 'true');
|
||||
logoutLink.appendChild(logoutIcon);
|
||||
logoutLink.appendChild(document.createTextNode('Sign Out'));
|
||||
logoutLink.appendChild(document.createTextNode(window.__i18n.signOut || 'Sign Out'));
|
||||
mobileAuthSection.appendChild(logoutLink);
|
||||
}
|
||||
} else {
|
||||
@@ -367,7 +367,7 @@ function _renderLoggedOutAuth(authSection, mobileAuthSection) {
|
||||
loginLink.href = '/login';
|
||||
loginLink.className =
|
||||
'px-3 py-1.5 rounded-md text-sm font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-100 border border-gray-300';
|
||||
loginLink.textContent = 'Log In';
|
||||
loginLink.textContent = window.__i18n.logIn || 'Log In';
|
||||
row.appendChild(loginLink);
|
||||
|
||||
if (multiUser) {
|
||||
@@ -375,7 +375,7 @@ function _renderLoggedOutAuth(authSection, mobileAuthSection) {
|
||||
startLink.href = startHref;
|
||||
startLink.className =
|
||||
'px-3 py-1.5 rounded-md text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500';
|
||||
startLink.textContent = allowSignup ? 'Sign Up' : 'Get Started';
|
||||
startLink.textContent = allowSignup ? (window.__i18n.signUp || 'Sign Up') : (window.__i18n.getStarted || 'Get Started');
|
||||
row.appendChild(startLink);
|
||||
}
|
||||
|
||||
@@ -393,7 +393,7 @@ function _renderLoggedOutAuth(authSection, mobileAuthSection) {
|
||||
loginIcon.className = 'fas fa-sign-in-alt mr-2';
|
||||
loginIcon.setAttribute('aria-hidden', 'true');
|
||||
loginLink.appendChild(loginIcon);
|
||||
loginLink.appendChild(document.createTextNode('Log In'));
|
||||
loginLink.appendChild(document.createTextNode(window.__i18n.logIn || 'Log In'));
|
||||
mobileAuthSection.appendChild(loginLink);
|
||||
|
||||
if (multiUser) {
|
||||
@@ -405,7 +405,7 @@ function _renderLoggedOutAuth(authSection, mobileAuthSection) {
|
||||
startIcon.className = 'fas fa-arrow-right mr-2';
|
||||
startIcon.setAttribute('aria-hidden', 'true');
|
||||
startLink.appendChild(startIcon);
|
||||
startLink.appendChild(document.createTextNode(allowSignup ? 'Sign Up' : 'Get Started'));
|
||||
startLink.appendChild(document.createTextNode(allowSignup ? (window.__i18n.signUp || 'Sign Up') : (window.__i18n.getStarted || 'Get Started')));
|
||||
mobileAuthSection.appendChild(startLink);
|
||||
}
|
||||
}
|
||||
|
||||
+137
-102
@@ -1,6 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Plan Designer — DocuElevate Admin{% endblock %}
|
||||
{% block title %}{{ _("admin_plans.page_title") }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main id="main-content" class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"
|
||||
@@ -10,43 +10,41 @@
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">Plan Designer</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">Manage subscription plans shown on the public pricing page.</p>
|
||||
<h1 class="text-2xl font-bold text-gray-900">{{ _("admin_plans.heading") }}</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">{{ _("admin_plans.subheading") }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="/admin/stripe-wizard"
|
||||
class="inline-flex items-center px-4 py-2 border border-indigo-300 rounded-md text-sm font-medium text-indigo-700 bg-indigo-50 hover:bg-indigo-100 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-indigo-400"
|
||||
title="Open the Stripe Setup Wizard to configure API keys and sync plans"
|
||||
title="{{ _('admin_plans.btn_stripe_setup_title') }}"
|
||||
>
|
||||
<i class="fab fa-stripe mr-2" aria-hidden="true"></i> Stripe Setup
|
||||
<i class="fab fa-stripe mr-2" aria-hidden="true"></i> {{ _("admin_plans.btn_stripe_setup") }}
|
||||
</a>
|
||||
<button
|
||||
@click="seedDefaults()"
|
||||
:disabled="seeding"
|
||||
class="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-indigo-400 disabled:opacity-50"
|
||||
title="Restore all four default plans (only if no plans exist yet)"
|
||||
title="{{ _('admin_plans.btn_restore_defaults_title') }}"
|
||||
>
|
||||
<i class="fas fa-undo mr-2 text-gray-400" aria-hidden="true"></i>
|
||||
<span x-show="!seeding">Restore Defaults</span>
|
||||
<span x-show="seeding"><i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i> Restoring…</span>
|
||||
<span x-show="!seeding">{{ _("admin_plans.btn_restore_defaults") }}</span>
|
||||
<span x-show="seeding"><i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i> {{ _("admin_plans.btn_restoring") }}</span>
|
||||
</button>
|
||||
<button
|
||||
@click="openCreateModal()"
|
||||
class="inline-flex items-center px-4 py-2 rounded-md text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-indigo-500"
|
||||
>
|
||||
<i class="fas fa-plus mr-2" aria-hidden="true"></i> Add Plan
|
||||
<i class="fas fa-plus mr-2" aria-hidden="true"></i> {{ _("admin_plans.btn_add_plan") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Overage callout -->
|
||||
<div class="mb-6 rounded-lg border border-indigo-200 bg-indigo-50 p-4 text-sm text-indigo-800" role="note">
|
||||
<p class="font-semibold mb-1"><i class="fas fa-info-circle mr-1" aria-hidden="true"></i> About the Overage Buffer</p>
|
||||
<p class="font-semibold mb-1"><i class="fas fa-info-circle mr-1" aria-hidden="true"></i> {{ _("admin_plans.overage_buffer_title") }}</p>
|
||||
<p>
|
||||
The overage buffer is <strong>invisible to users</strong>. We advertise X docs/month but only enforce
|
||||
at <strong>X × (1 + buffer%)</strong> docs. For example, a 150-doc/month plan with a 20% buffer
|
||||
enforces at 180 docs. This prevents hard cutoffs at the exact announced limit, giving users a
|
||||
graceful soft landing.
|
||||
{{ _("admin_plans.overage_buffer_body_prefix") }} <strong>{{ _("admin_plans.overage_buffer_invisible") }}</strong>. {{ _("admin_plans.overage_buffer_body_suffix") }}
|
||||
<strong>{{ _("admin_plans.overage_buffer_formula") }}</strong> {{ _("admin_plans.overage_buffer_tail") }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -63,29 +61,29 @@
|
||||
<!-- Loading -->
|
||||
<div x-show="loading" class="text-center py-12 text-gray-400">
|
||||
<i class="fas fa-spinner fa-spin text-2xl" aria-hidden="true"></i>
|
||||
<p class="mt-2 text-sm">Loading plans…</p>
|
||||
<p class="mt-2 text-sm">{{ _("admin_plans.loading") }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Plans table -->
|
||||
<div x-show="!loading" class="bg-white shadow-sm rounded-lg overflow-hidden border border-gray-200">
|
||||
<table class="min-w-full divide-y divide-gray-200" aria-label="Subscription plans">
|
||||
<table class="min-w-full divide-y divide-gray-200" aria-label="{{ _('admin_plans.table_aria_label') }}">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Order</th>
|
||||
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Plan</th>
|
||||
<th scope="col" class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Monthly</th>
|
||||
<th scope="col" class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Yearly</th>
|
||||
<th scope="col" class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Monthly Limit</th>
|
||||
<th scope="col" class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Overage %</th>
|
||||
<th scope="col" class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">Active</th>
|
||||
<th scope="col" class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ _("admin_plans.col_order") }}</th>
|
||||
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ _("admin_plans.col_plan") }}</th>
|
||||
<th scope="col" class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">{{ _("admin_plans.col_monthly") }}</th>
|
||||
<th scope="col" class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">{{ _("admin_plans.col_yearly") }}</th>
|
||||
<th scope="col" class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">{{ _("admin_plans.col_monthly_limit") }}</th>
|
||||
<th scope="col" class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">{{ _("admin_plans.col_overage_pct") }}</th>
|
||||
<th scope="col" class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">{{ _("admin_plans.col_active") }}</th>
|
||||
<th scope="col" class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">{{ _("admin_plans.col_actions") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-100">
|
||||
<template x-if="plans.length === 0">
|
||||
<tr>
|
||||
<td colspan="8" class="px-4 py-8 text-center text-sm text-gray-400">
|
||||
No plans yet. Click <strong>Restore Defaults</strong> to seed the four built-in plans.
|
||||
{{ _("admin_plans.no_plans_intro") }} <strong>{{ _("admin_plans.btn_restore_defaults") }}</strong> {{ _("admin_plans.no_plans_suffix") }}
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
@@ -97,13 +95,13 @@
|
||||
@click="moveUp(idx)"
|
||||
:disabled="idx === 0"
|
||||
class="p-1 rounded hover:bg-gray-200 disabled:opacity-30"
|
||||
:aria-label="'Move ' + plan.name + ' up'"
|
||||
:aria-label="i18n.ariaMoveUp.replace('{name}', plan.name)"
|
||||
><i class="fas fa-chevron-up text-xs" aria-hidden="true"></i></button>
|
||||
<button
|
||||
@click="moveDown(idx)"
|
||||
:disabled="idx === plans.length - 1"
|
||||
class="p-1 rounded hover:bg-gray-200 disabled:opacity-30"
|
||||
:aria-label="'Move ' + plan.name + ' down'"
|
||||
:aria-label="i18n.ariaMoveDown.replace('{name}', plan.name)"
|
||||
><i class="fas fa-chevron-down text-xs" aria-hidden="true"></i></button>
|
||||
<span x-text="idx + 1" class="w-5 text-center text-xs text-gray-400"></span>
|
||||
</div>
|
||||
@@ -115,13 +113,13 @@
|
||||
<span class="text-xs px-2 py-0.5 rounded-full bg-indigo-100 text-indigo-700 font-medium" x-text="plan.badge_text"></span>
|
||||
</template>
|
||||
<template x-if="plan.is_highlighted">
|
||||
<span class="text-xs px-2 py-0.5 rounded-full bg-yellow-100 text-yellow-700 font-medium"><i class="fas fa-star" aria-hidden="true"></i> Featured</span>
|
||||
<span class="text-xs px-2 py-0.5 rounded-full bg-yellow-100 text-yellow-700 font-medium"><i class="fas fa-star" aria-hidden="true"></i> <span x-text="i18n.featuredBadge"></span></span>
|
||||
</template>
|
||||
</div>
|
||||
<div class="text-xs text-gray-400 mt-0.5" x-text="plan.plan_id"></div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right text-sm text-gray-700">
|
||||
<span x-text="plan.price_monthly === 0 ? 'Free' : '$' + plan.price_monthly.toFixed(2)"></span>
|
||||
<span x-text="plan.price_monthly === 0 ? i18n.freeLabel : '$' + plan.price_monthly.toFixed(2)"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right text-sm text-gray-700">
|
||||
<span x-text="plan.price_yearly === 0 ? '—' : '$' + plan.price_yearly.toFixed(2)"></span>
|
||||
@@ -136,7 +134,7 @@
|
||||
<span
|
||||
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium"
|
||||
:class="plan.is_active ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'"
|
||||
x-text="plan.is_active ? 'Active' : 'Inactive'"
|
||||
x-text="plan.is_active ? i18n.statusActive : i18n.statusInactive"
|
||||
></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
@@ -144,16 +142,16 @@
|
||||
<button
|
||||
@click="openEditModal(plan)"
|
||||
class="text-sm text-indigo-600 hover:text-indigo-800 font-medium"
|
||||
:aria-label="'Edit ' + plan.name"
|
||||
:aria-label="i18n.ariaEditPlan.replace('{name}', plan.name)"
|
||||
>
|
||||
<i class="fas fa-pencil-alt" aria-hidden="true"></i> Edit
|
||||
<i class="fas fa-pencil-alt" aria-hidden="true"></i> <span x-text="i18n.btnEdit"></span>
|
||||
</button>
|
||||
<button
|
||||
@click="deletePlan(plan.plan_id)"
|
||||
class="text-sm text-red-500 hover:text-red-700 font-medium"
|
||||
:aria-label="'Delete ' + plan.name"
|
||||
:aria-label="i18n.ariaDeletePlan.replace('{name}', plan.name)"
|
||||
>
|
||||
<i class="fas fa-trash-alt" aria-hidden="true"></i> Delete
|
||||
<i class="fas fa-trash-alt" aria-hidden="true"></i> <span x-text="i18n.btnDelete"></span>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
@@ -171,8 +169,8 @@
|
||||
class="inline-flex items-center px-4 py-2 rounded-md text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-indigo-500 disabled:opacity-50"
|
||||
>
|
||||
<i class="fas fa-save mr-2" aria-hidden="true"></i>
|
||||
<span x-show="!saving">Save Order</span>
|
||||
<span x-show="saving"><i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i> Saving…</span>
|
||||
<span x-show="!saving">{{ _("admin_plans.btn_save_order") }}</span>
|
||||
<span x-show="saving"><i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i> {{ _("admin_plans.btn_saving") }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -195,8 +193,8 @@
|
||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-2xl my-auto" @click.outside="modalOpen = false">
|
||||
<!-- Modal header -->
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b">
|
||||
<h2 id="modal-title" class="text-lg font-semibold text-gray-900" x-text="isCreate ? 'Add Plan' : 'Edit Plan: ' + form.name"></h2>
|
||||
<button @click="modalOpen = false" class="text-gray-400 hover:text-gray-600" aria-label="Close modal">
|
||||
<h2 id="modal-title" class="text-lg font-semibold text-gray-900" x-text="isCreate ? i18n.modalCreateTitle : i18n.modalEditTitlePrefix + form.name"></h2>
|
||||
<button @click="modalOpen = false" class="text-gray-400 hover:text-gray-600" aria-label="{{ _('admin_plans.modal_close_aria') }}">
|
||||
<i class="fas fa-times" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
@@ -205,66 +203,66 @@
|
||||
|
||||
<!-- Basic Info -->
|
||||
<div class="px-6 py-5 space-y-4">
|
||||
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wider">Basic Info</h3>
|
||||
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wider">{{ _("admin_plans.section_basic_info") }}</h3>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="f-name" class="block text-sm font-medium text-gray-700 mb-1">Name <span class="text-red-500" aria-hidden="true">*</span></label>
|
||||
<label for="f-name" class="block text-sm font-medium text-gray-700 mb-1">{{ _("admin_plans.field_name") }} <span class="text-red-500" aria-hidden="true">*</span></label>
|
||||
<input id="f-name" type="text" x-model="form.name" required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="f-plan-id" class="block text-sm font-medium text-gray-700 mb-1">Plan ID <span class="text-red-500" aria-hidden="true">*</span></label>
|
||||
<label for="f-plan-id" class="block text-sm font-medium text-gray-700 mb-1">{{ _("admin_plans.field_plan_id") }} <span class="text-red-500" aria-hidden="true">*</span></label>
|
||||
<input id="f-plan-id" type="text" x-model="form.plan_id" :readonly="!isCreate"
|
||||
:class="!isCreate ? 'bg-gray-50 text-gray-400 cursor-not-allowed' : ''"
|
||||
required pattern="[a-z0-9_-]+" title="Lowercase letters, numbers, _ and - only"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400" />
|
||||
<p class="text-xs text-gray-400 mt-0.5">Lowercase slug, cannot be changed after creation.</p>
|
||||
<p class="text-xs text-gray-400 mt-0.5">{{ _("admin_plans.hint_plan_id") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="f-tagline" class="block text-sm font-medium text-gray-700 mb-1">Tagline</label>
|
||||
<label for="f-tagline" class="block text-sm font-medium text-gray-700 mb-1">{{ _("admin_plans.field_tagline") }}</label>
|
||||
<input id="f-tagline" type="text" x-model="form.tagline" maxlength="255"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400" />
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label for="f-sort-order" class="block text-sm font-medium text-gray-700 mb-1">Sort Order</label>
|
||||
<label for="f-sort-order" class="block text-sm font-medium text-gray-700 mb-1">{{ _("admin_plans.field_sort_order") }}</label>
|
||||
<input id="f-sort-order" type="number" x-model.number="form.sort_order" min="0"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400" />
|
||||
</div>
|
||||
<div class="flex items-center gap-3 pt-6">
|
||||
<input id="f-active" type="checkbox" x-model="form.is_active"
|
||||
class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" />
|
||||
<label for="f-active" class="text-sm font-medium text-gray-700">Active</label>
|
||||
<label for="f-active" class="text-sm font-medium text-gray-700">{{ _("admin_plans.field_active") }}</label>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 pt-6">
|
||||
<input id="f-highlighted" type="checkbox" x-model="form.is_highlighted"
|
||||
class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" />
|
||||
<label for="f-highlighted" class="text-sm font-medium text-gray-700">Featured / Highlighted</label>
|
||||
<label for="f-highlighted" class="text-sm font-medium text-gray-700">{{ _("admin_plans.field_featured") }}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pricing -->
|
||||
<div class="px-6 py-5 space-y-4">
|
||||
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wider">Pricing</h3>
|
||||
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wider">{{ _("admin_plans.section_pricing") }}</h3>
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label for="f-price-monthly" class="block text-sm font-medium text-gray-700 mb-1">Monthly Price ($)</label>
|
||||
<label for="f-price-monthly" class="block text-sm font-medium text-gray-700 mb-1">{{ _("admin_plans.field_price_monthly") }}</label>
|
||||
<input id="f-price-monthly" type="number" x-model.number="form.price_monthly" min="0" step="0.01"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="f-price-yearly" class="block text-sm font-medium text-gray-700 mb-1">Yearly Price ($)</label>
|
||||
<label for="f-price-yearly" class="block text-sm font-medium text-gray-700 mb-1">{{ _("admin_plans.field_price_yearly") }}</label>
|
||||
<input id="f-price-yearly" type="number" x-model.number="form.price_yearly" min="0" step="0.01"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400" />
|
||||
<p class="text-xs mt-0.5"
|
||||
:class="yearlySavingPct > 0 ? 'text-green-600 font-medium' : 'text-gray-400'"
|
||||
x-text="yearlySavingPct > 0 ? 'Save ' + yearlySavingPct.toFixed(0) + '% vs monthly' : 'Enter yearly price to show savings'">
|
||||
x-text="yearlySavingPct > 0 ? i18n.yearlySave.replace('{pct}', yearlySavingPct.toFixed(0)) : i18n.yearlyEnter">
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="f-trial-days" class="block text-sm font-medium text-gray-700 mb-1">Trial Days</label>
|
||||
<label for="f-trial-days" class="block text-sm font-medium text-gray-700 mb-1">{{ _("admin_plans.field_trial_days") }}</label>
|
||||
<input id="f-trial-days" type="number" x-model.number="form.trial_days" min="0"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400" />
|
||||
</div>
|
||||
@@ -274,21 +272,21 @@
|
||||
<!-- Stripe Integration -->
|
||||
<div class="px-6 py-5 space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wider">Stripe Integration</h3>
|
||||
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wider">{{ _("admin_plans.section_stripe") }}</h3>
|
||||
<a href="/admin/stripe-wizard" target="_blank"
|
||||
class="text-xs text-indigo-600 hover:text-indigo-800 font-medium focus:outline-none focus:ring-1 focus:ring-indigo-400 rounded"
|
||||
aria-label="Open Stripe Setup Wizard in a new tab"
|
||||
><i class="fab fa-stripe mr-1" aria-hidden="true"></i>Stripe Wizard</a>
|
||||
aria-label="{{ _('admin_plans.stripe_wizard_aria') }}"
|
||||
><i class="fab fa-stripe mr-1" aria-hidden="true"></i>{{ _("admin_plans.stripe_wizard_link") }}</a>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500">
|
||||
Enter the Stripe Price IDs for this plan, or use the
|
||||
<a href="/admin/stripe-wizard" target="_blank" class="text-indigo-600 hover:text-indigo-800 underline">Stripe Setup Wizard</a>
|
||||
to auto-create them. Free plans do not need Stripe Price IDs.
|
||||
{{ _("admin_plans.stripe_desc_before") }}
|
||||
<a href="/admin/stripe-wizard" target="_blank" class="text-indigo-600 hover:text-indigo-800 underline">{{ _("admin_plans.stripe_wizard_text") }}</a>
|
||||
{{ _("admin_plans.stripe_desc_after") }}
|
||||
</p>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="f-stripe-monthly" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Stripe Price ID (monthly)
|
||||
{{ _("admin_plans.field_stripe_monthly") }}
|
||||
</label>
|
||||
<input id="f-stripe-monthly" type="text" x-model="form.stripe_price_id_monthly"
|
||||
placeholder="price_1OtAbc…"
|
||||
@@ -296,7 +294,7 @@
|
||||
</div>
|
||||
<div>
|
||||
<label for="f-stripe-yearly" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Stripe Price ID (yearly)
|
||||
{{ _("admin_plans.field_stripe_yearly") }}
|
||||
</label>
|
||||
<input id="f-stripe-yearly" type="text" x-model="form.stripe_price_id_yearly"
|
||||
placeholder="price_1OtAbc… (optional)"
|
||||
@@ -307,36 +305,36 @@
|
||||
|
||||
<!-- Volume Limits -->
|
||||
<div class="px-6 py-5 space-y-4">
|
||||
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wider">Volume Limits</h3>
|
||||
<p class="text-xs text-gray-500">Enter <strong>0</strong> for unlimited.</p>
|
||||
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wider">{{ _("admin_plans.section_volume") }}</h3>
|
||||
<p class="text-xs text-gray-500">{{ _("admin_plans.hint_zero_unlimited") }}</p>
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label for="f-monthly-limit" class="block text-sm font-medium text-gray-700 mb-1">Docs / Month</label>
|
||||
<label for="f-monthly-limit" class="block text-sm font-medium text-gray-700 mb-1">{{ _("admin_plans.field_docs_month") }}</label>
|
||||
<input id="f-monthly-limit" type="number" x-model.number="form.monthly_upload_limit" min="0"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="f-lifetime-limit" class="block text-sm font-medium text-gray-700 mb-1">Lifetime Docs</label>
|
||||
<label for="f-lifetime-limit" class="block text-sm font-medium text-gray-700 mb-1">{{ _("admin_plans.field_lifetime_docs") }}</label>
|
||||
<input id="f-lifetime-limit" type="number" x-model.number="form.lifetime_file_limit" min="0"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="f-ocr-pages" class="block text-sm font-medium text-gray-700 mb-1">OCR Pages / Month</label>
|
||||
<label for="f-ocr-pages" class="block text-sm font-medium text-gray-700 mb-1">{{ _("admin_plans.field_ocr_pages") }}</label>
|
||||
<input id="f-ocr-pages" type="number" x-model.number="form.max_ocr_pages_monthly" min="0"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="f-dests" class="block text-sm font-medium text-gray-700 mb-1">Storage Destinations</label>
|
||||
<label for="f-dests" class="block text-sm font-medium text-gray-700 mb-1">{{ _("admin_plans.field_storage_dests") }}</label>
|
||||
<input id="f-dests" type="number" x-model.number="form.max_storage_destinations" min="0"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="f-mailboxes" class="block text-sm font-medium text-gray-700 mb-1">Email Mailboxes</label>
|
||||
<label for="f-mailboxes" class="block text-sm font-medium text-gray-700 mb-1">{{ _("admin_plans.field_mailboxes") }}</label>
|
||||
<input id="f-mailboxes" type="number" x-model.number="form.max_mailboxes" min="0"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="f-filesize" class="block text-sm font-medium text-gray-700 mb-1">Max File Size (MB)</label>
|
||||
<label for="f-filesize" class="block text-sm font-medium text-gray-700 mb-1">{{ _("admin_plans.field_max_file_size") }}</label>
|
||||
<input id="f-filesize" type="number" x-model.number="form.max_file_size_mb" min="0"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400" />
|
||||
</div>
|
||||
@@ -345,15 +343,15 @@
|
||||
|
||||
<!-- Overage Designer -->
|
||||
<div class="px-6 py-5 space-y-4">
|
||||
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wider">Overage Designer</h3>
|
||||
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wider">{{ _("admin_plans.section_overage") }}</h3>
|
||||
<div>
|
||||
<label for="f-overage-pct" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Buffer:
|
||||
{{ _("admin_plans.field_buffer") }}
|
||||
<span class="text-indigo-700 font-semibold" x-text="form.overage_percent + '%'"></span>
|
||||
<template x-if="form.monthly_upload_limit > 0">
|
||||
<span class="text-gray-500 font-normal ml-2">
|
||||
→ announce <strong x-text="form.monthly_upload_limit"></strong> docs,
|
||||
enforce at <strong class="text-indigo-700" x-text="Math.round(form.monthly_upload_limit * (1 + form.overage_percent / 100))"></strong> docs
|
||||
{{ _("admin_plans.overage_announce") }} <strong x-text="form.monthly_upload_limit"></strong> {{ _("admin_plans.overage_docs") }}
|
||||
{{ _("admin_plans.overage_enforce_at") }} <strong class="text-indigo-700" x-text="Math.round(form.monthly_upload_limit * (1 + form.overage_percent / 100))"></strong> {{ _("admin_plans.overage_docs_end") }}
|
||||
</span>
|
||||
</template>
|
||||
</label>
|
||||
@@ -368,27 +366,27 @@
|
||||
aria-valuemax="100"
|
||||
/>
|
||||
<div class="flex justify-between text-xs text-gray-400 mt-1">
|
||||
<span>0% (exact)</span>
|
||||
<span>50%</span>
|
||||
<span>100%</span>
|
||||
<span>{{ _("admin_plans.overage_0pct") }}</span>
|
||||
<span>{{ _("admin_plans.overage_50pct") }}</span>
|
||||
<span>{{ _("admin_plans.overage_100pct") }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4 opacity-60">
|
||||
<div class="relative">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Allow Overage Billing</label>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{ _("admin_plans.field_allow_overage") }}</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="checkbox" disabled class="h-4 w-4 rounded border-gray-300 text-indigo-600" />
|
||||
<span class="text-xs text-gray-400">Coming soon</span>
|
||||
<span class="text-xs text-gray-400">{{ _("admin_plans.coming_soon") }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Overage price / doc ($)</label>
|
||||
<input type="number" disabled placeholder="Coming soon"
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{ _("admin_plans.field_overage_doc_price") }}</label>
|
||||
<input type="number" disabled placeholder="{{ _('admin_plans.coming_soon') }}"
|
||||
class="w-full px-3 py-2 border border-gray-200 rounded-md text-sm bg-gray-50 text-gray-400 cursor-not-allowed" />
|
||||
</div>
|
||||
<div class="relative">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Overage price / OCR page ($)</label>
|
||||
<input type="number" disabled placeholder="Coming soon"
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{ _("admin_plans.field_overage_ocr_price") }}</label>
|
||||
<input type="number" disabled placeholder="{{ _('admin_plans.coming_soon') }}"
|
||||
class="w-full px-3 py-2 border border-gray-200 rounded-md text-sm bg-gray-50 text-gray-400 cursor-not-allowed" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -396,18 +394,18 @@
|
||||
|
||||
<!-- Features -->
|
||||
<div class="px-6 py-5 space-y-3">
|
||||
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wider">Features List</h3>
|
||||
<p class="text-xs text-gray-500">These bullet points appear on the pricing page card for this plan.</p>
|
||||
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wider">{{ _("admin_plans.section_features") }}</h3>
|
||||
<p class="text-xs text-gray-500">{{ _("admin_plans.hint_features") }}</p>
|
||||
<template x-for="(feat, i) in form.features" :key="i">
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
x-model="form.features[i]"
|
||||
:aria-label="'Feature ' + (i+1)"
|
||||
:aria-label="i18n.ariaFeatureN.replace('{n}', i + 1)"
|
||||
class="flex-1 px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
|
||||
/>
|
||||
<button type="button" @click="form.features.splice(i, 1)"
|
||||
:aria-label="'Remove feature ' + (i+1)"
|
||||
:aria-label="i18n.ariaRemoveFeatureN.replace('{n}', i + 1)"
|
||||
class="text-red-400 hover:text-red-600 px-2 py-2">
|
||||
<i class="fas fa-times" aria-hidden="true"></i>
|
||||
</button>
|
||||
@@ -415,21 +413,21 @@
|
||||
</template>
|
||||
<button type="button" @click="form.features.push('')"
|
||||
class="inline-flex items-center text-sm text-indigo-600 hover:text-indigo-800 font-medium">
|
||||
<i class="fas fa-plus mr-1" aria-hidden="true"></i> Add Feature
|
||||
<i class="fas fa-plus mr-1" aria-hidden="true"></i> {{ _("admin_plans.btn_add_feature") }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Display -->
|
||||
<div class="px-6 py-5 space-y-4">
|
||||
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wider">Display</h3>
|
||||
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wider">{{ _("admin_plans.section_display") }}</h3>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="f-cta" class="block text-sm font-medium text-gray-700 mb-1">CTA Button Text</label>
|
||||
<label for="f-cta" class="block text-sm font-medium text-gray-700 mb-1">{{ _("admin_plans.field_cta_text") }}</label>
|
||||
<input id="f-cta" type="text" x-model="form.cta_text" maxlength="100"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="f-badge" class="block text-sm font-medium text-gray-700 mb-1">Badge Text</label>
|
||||
<label for="f-badge" class="block text-sm font-medium text-gray-700 mb-1">{{ _("admin_plans.field_badge_text") }}</label>
|
||||
<input id="f-badge" type="text" x-model="form.badge_text" maxlength="50"
|
||||
placeholder="e.g. Most Popular"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400" />
|
||||
@@ -438,7 +436,7 @@
|
||||
<div class="flex items-center gap-3">
|
||||
<input id="f-api" type="checkbox" x-model="form.api_access"
|
||||
class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" />
|
||||
<label for="f-api" class="text-sm font-medium text-gray-700">API Access</label>
|
||||
<label for="f-api" class="text-sm font-medium text-gray-700">{{ _("admin_plans.field_api_access") }}</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -449,15 +447,15 @@
|
||||
@click="modalOpen = false"
|
||||
class="px-4 py-2 text-sm font-medium border border-gray-300 rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-gray-400"
|
||||
>
|
||||
Cancel
|
||||
{{ _("admin_plans.btn_cancel") }}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="saving"
|
||||
class="px-4 py-2 text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span x-show="!saving" x-text="isCreate ? 'Create Plan' : 'Save Changes'"></span>
|
||||
<span x-show="saving"><i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i> Saving…</span>
|
||||
<span x-show="!saving" x-text="isCreate ? i18n.btnCreate : i18n.btnSaveChanges"></span>
|
||||
<span x-show="saving"><i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i> <span x-text="i18n.btnSaving"></span></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -467,9 +465,46 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script>
|
||||
window.__i18nAdminPlans = {
|
||||
modalCreateTitle: {{ _("admin_plans.modal_create_title") | tojson }},
|
||||
modalEditTitlePrefix: {{ _("admin_plans.modal_edit_title_prefix") | tojson }},
|
||||
statusActive: {{ _("admin_plans.status_active") | tojson }},
|
||||
statusInactive: {{ _("admin_plans.status_inactive") | tojson }},
|
||||
featuredBadge: {{ _("admin_plans.featured_badge") | tojson }},
|
||||
freeLabel: {{ _("admin_plans.free_label") | tojson }},
|
||||
btnEdit: {{ _("admin_plans.btn_edit") | tojson }},
|
||||
btnDelete: {{ _("admin_plans.btn_delete") | tojson }},
|
||||
btnCreate: {{ _("admin_plans.btn_create") | tojson }},
|
||||
btnSaveChanges: {{ _("admin_plans.btn_save_changes") | tojson }},
|
||||
btnSaving: {{ _("admin_plans.btn_saving") | tojson }},
|
||||
ariaMoveUp: {{ _("admin_plans.aria_move_up") | tojson }},
|
||||
ariaMoveDown: {{ _("admin_plans.aria_move_down") | tojson }},
|
||||
ariaEditPlan: {{ _("admin_plans.aria_edit_plan") | tojson }},
|
||||
ariaDeletePlan: {{ _("admin_plans.aria_delete_plan") | tojson }},
|
||||
ariaFeatureN: {{ _("admin_plans.aria_feature_n") | tojson }},
|
||||
ariaRemoveFeatureN: {{ _("admin_plans.aria_remove_feature_n") | tojson }},
|
||||
yearlySave: {{ _("admin_plans.js_yearly_save") | tojson }},
|
||||
yearlyEnter: {{ _("admin_plans.js_yearly_enter") | tojson }},
|
||||
jsFailedLoad: {{ _("admin_plans.js_failed_load") | tojson }},
|
||||
jsPlanCreated: {{ _("admin_plans.js_plan_created") | tojson }},
|
||||
jsPlanUpdated: {{ _("admin_plans.js_plan_updated") | tojson }},
|
||||
jsDeleteConfirm: {{ _("admin_plans.js_delete_confirm") | tojson }},
|
||||
jsDeleteFailed: {{ _("admin_plans.js_delete_failed") | tojson }},
|
||||
jsPlanDeleted: {{ _("admin_plans.js_plan_deleted") | tojson }},
|
||||
jsSeedConfirm: {{ _("admin_plans.js_seed_confirm") | tojson }},
|
||||
jsSeedFailed: {{ _("admin_plans.js_seed_failed") | tojson }},
|
||||
jsReorderFailed: {{ _("admin_plans.js_reorder_failed") | tojson }},
|
||||
jsOrderSaved: {{ _("admin_plans.js_order_saved") | tojson }},
|
||||
jsSaveFailed: {{ _("admin_plans.js_save_failed") | tojson }},
|
||||
};
|
||||
</script>
|
||||
|
||||
<script>
|
||||
function planDesigner() {
|
||||
const i18n = window.__i18nAdminPlans;
|
||||
return {
|
||||
i18n,
|
||||
plans: [],
|
||||
loading: true,
|
||||
saving: false,
|
||||
@@ -524,7 +559,7 @@ function planDesigner() {
|
||||
this.errorMsg = '';
|
||||
try {
|
||||
const resp = await fetch('/api/plans/admin');
|
||||
if (!resp.ok) throw new Error('Failed to load plans');
|
||||
if (!resp.ok) throw new Error(i18n.jsFailedLoad);
|
||||
const data = await resp.json();
|
||||
this.plans = data.plans || [];
|
||||
} catch (e) {
|
||||
@@ -568,9 +603,9 @@ function planDesigner() {
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({ detail: resp.statusText }));
|
||||
throw new Error(err.detail || 'Save failed');
|
||||
throw new Error(err.detail || i18n.jsSaveFailed);
|
||||
}
|
||||
this.successMsg = this.isCreate ? 'Plan created!' : 'Plan updated!';
|
||||
this.successMsg = this.isCreate ? i18n.jsPlanCreated : i18n.jsPlanUpdated;
|
||||
this.modalOpen = false;
|
||||
await this.loadPlans();
|
||||
} catch (e) {
|
||||
@@ -581,15 +616,15 @@ function planDesigner() {
|
||||
},
|
||||
|
||||
async deletePlan(planId) {
|
||||
if (!confirm(`Delete plan "${planId}"? This cannot be undone.`)) return;
|
||||
if (!confirm(i18n.jsDeleteConfirm.replace('{id}', planId))) return;
|
||||
this.errorMsg = '';
|
||||
try {
|
||||
const resp = await fetch(`/api/plans/${encodeURIComponent(planId)}`, { method: 'DELETE' });
|
||||
if (!resp.ok && resp.status !== 204) {
|
||||
const err = await resp.json().catch(() => ({ detail: resp.statusText }));
|
||||
throw new Error(err.detail || 'Delete failed');
|
||||
throw new Error(err.detail || i18n.jsDeleteFailed);
|
||||
}
|
||||
this.successMsg = `Plan "${planId}" deleted.`;
|
||||
this.successMsg = i18n.jsPlanDeleted.replace('{id}', planId);
|
||||
await this.loadPlans();
|
||||
} catch (e) {
|
||||
this.errorMsg = e.message;
|
||||
@@ -597,12 +632,12 @@ function planDesigner() {
|
||||
},
|
||||
|
||||
async seedDefaults() {
|
||||
if (!confirm('Seed the four default plans? This is a no-op if plans already exist.')) return;
|
||||
if (!confirm(i18n.jsSeedConfirm)) return;
|
||||
this.seeding = true;
|
||||
this.errorMsg = '';
|
||||
try {
|
||||
const resp = await fetch('/api/plans/seed', { method: 'POST' });
|
||||
if (!resp.ok) throw new Error('Seed failed');
|
||||
if (!resp.ok) throw new Error(i18n.jsSeedFailed);
|
||||
const data = await resp.json();
|
||||
this.successMsg = data.message;
|
||||
await this.loadPlans();
|
||||
@@ -635,8 +670,8 @@ function planDesigner() {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ order }),
|
||||
});
|
||||
if (!resp.ok) throw new Error('Reorder failed');
|
||||
this.successMsg = 'Order saved!';
|
||||
if (!resp.ok) throw new Error(i18n.jsReorderFailed);
|
||||
this.successMsg = i18n.jsOrderSaved;
|
||||
this.orderDirty = false;
|
||||
} catch (e) {
|
||||
this.errorMsg = e.message;
|
||||
|
||||
+193
-157
@@ -1,5 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}User Management – Admin – DocuElevate{% endblock %}
|
||||
{% block title %}{{ _("admin_users.page_title") }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mx-auto px-4 py-8" x-data="adminUsersApp()">
|
||||
@@ -9,11 +9,11 @@
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900 flex items-center gap-2">
|
||||
<i class="fas fa-users text-blue-500" aria-hidden="true"></i>
|
||||
User Management
|
||||
<span class="text-xs font-semibold text-red-600 bg-red-50 border border-red-200 rounded px-2 py-0.5 ml-1">Admin Only</span>
|
||||
{{ _("admin_users.heading") }}
|
||||
<span class="text-xs font-semibold text-red-600 bg-red-50 border border-red-200 rounded px-2 py-0.5 ml-1">{{ _("admin_users.admin_only_badge") }}</span>
|
||||
</h1>
|
||||
<p class="text-gray-500 text-sm mt-1">
|
||||
Manage user profiles, per-user upload limits, and document ownership.
|
||||
{{ _("admin_users.subheading") }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -21,14 +21,14 @@
|
||||
@click="openCreateModal()"
|
||||
class="inline-flex items-center px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
<i class="fas fa-user-plus mr-2" aria-hidden="true"></i> Add User Profile
|
||||
<i class="fas fa-user-plus mr-2" aria-hidden="true"></i> {{ _("admin_users.add_user_profile_btn") }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="openCreateLocalUserModal()"
|
||||
class="inline-flex items-center px-4 py-2 bg-green-600 hover:bg-green-700 text-white text-sm font-medium rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500"
|
||||
>
|
||||
<i class="fas fa-user-lock mr-2" aria-hidden="true"></i> Create Local Account
|
||||
<i class="fas fa-user-lock mr-2" aria-hidden="true"></i> {{ _("admin_users.create_local_account_btn") }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
<!-- ── Search & pagination controls ──────────────────────────────────────── -->
|
||||
<div class="mb-4 flex flex-col sm:flex-row gap-3 items-start sm:items-center justify-between">
|
||||
<div class="flex-1 max-w-sm">
|
||||
<label for="userSearch" class="sr-only">Search users</label>
|
||||
<label for="userSearch" class="sr-only">{{ _("admin_users.search_users_label") }}</label>
|
||||
<div class="relative">
|
||||
<span class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-search text-gray-400 text-sm" aria-hidden="true"></i>
|
||||
@@ -58,7 +58,7 @@
|
||||
type="search"
|
||||
x-model="search"
|
||||
@input.debounce.300ms="fetchUsers(1)"
|
||||
placeholder="Filter by user ID…"
|
||||
placeholder="{{ _('admin_users.filter_placeholder') }}"
|
||||
class="w-full pl-9 pr-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-400"
|
||||
/>
|
||||
</div>
|
||||
@@ -68,24 +68,24 @@
|
||||
|
||||
<!-- ── Table ──────────────────────────────────────────────────────────────── -->
|
||||
<div class="bg-white shadow rounded-lg overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200" aria-label="User list">
|
||||
<table class="min-w-full divide-y divide-gray-200" aria-label="{{ _('admin_users.heading') }}">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">User ID</th>
|
||||
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Display Name</th>
|
||||
<th scope="col" class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">Documents</th>
|
||||
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Last Upload</th>
|
||||
<th scope="col" class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">Plan</th>
|
||||
<th scope="col" class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">Upload Limit</th>
|
||||
<th scope="col" class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
||||
<th scope="col" class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ _("admin_users.col_user_id") }}</th>
|
||||
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ _("admin_users.col_display_name") }}</th>
|
||||
<th scope="col" class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">{{ _("admin_users.col_documents") }}</th>
|
||||
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ _("admin_users.col_last_upload") }}</th>
|
||||
<th scope="col" class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">{{ _("admin_users.col_plan") }}</th>
|
||||
<th scope="col" class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">{{ _("admin_users.col_upload_limit") }}</th>
|
||||
<th scope="col" class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">{{ _("common.status") }}</th>
|
||||
<th scope="col" class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">{{ _("common.actions") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<template x-if="loading">
|
||||
<tr>
|
||||
<td colspan="8" class="px-4 py-8 text-center text-gray-400">
|
||||
<i class="fas fa-spinner fa-spin mr-2" aria-hidden="true"></i> Loading users…
|
||||
<i class="fas fa-spinner fa-spin mr-2" aria-hidden="true"></i> {{ _("admin_users.loading_users") }}
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
@@ -93,9 +93,9 @@
|
||||
<tr>
|
||||
<td colspan="8" class="px-4 py-8 text-center text-gray-400">
|
||||
<i class="fas fa-users-slash text-3xl block mb-2" aria-hidden="true"></i>
|
||||
No users found.
|
||||
<span x-show="search"> Try a different search term.</span>
|
||||
<span x-show="!search"> Upload some documents or add a profile above.</span>
|
||||
{{ _("admin_users.no_users_found") }}
|
||||
<span x-show="search"> {{ _("admin_users.no_users_search_hint") }}</span>
|
||||
<span x-show="!search"> {{ _("admin_users.no_users_add_hint") }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
@@ -159,9 +159,9 @@
|
||||
<td class="px-4 py-3 text-sm text-center text-gray-700">
|
||||
<span x-show="user.daily_upload_limit !== null && user.daily_upload_limit !== undefined"
|
||||
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-indigo-100 text-indigo-700"
|
||||
x-text="user.daily_upload_limit + ' / day'"></span>
|
||||
x-text="user.daily_upload_limit + ' {{ _("admin_users.per_day") }}'"></span>
|
||||
<span x-show="user.daily_upload_limit === null || user.daily_upload_limit === undefined"
|
||||
class="text-gray-400 text-xs">global default</span>
|
||||
class="text-gray-400 text-xs">{{ _("admin_users.global_default") }}</span>
|
||||
</td>
|
||||
<!-- Status -->
|
||||
<td class="px-4 py-3 text-sm text-center">
|
||||
@@ -172,7 +172,7 @@
|
||||
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium"
|
||||
>
|
||||
<i :class="user.is_blocked ? 'fas fa-ban' : 'fas fa-check-circle'" aria-hidden="true"></i>
|
||||
<span x-text="user.is_blocked ? 'Blocked' : 'Active'"></span>
|
||||
<span x-text="user.is_blocked ? '{{ _("admin_users.status_blocked") }}' : '{{ _("common.active") }}'"></span>
|
||||
</span>
|
||||
</td>
|
||||
<!-- Actions -->
|
||||
@@ -181,18 +181,18 @@
|
||||
type="button"
|
||||
@click="openEditModal(user)"
|
||||
class="inline-flex items-center px-2.5 py-1.5 text-xs font-medium rounded border border-gray-300 text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-blue-500 mr-1"
|
||||
:aria-label="`Edit profile for ${user.user_id}`"
|
||||
:aria-label="`{{ _('common.edit') }} ${user.user_id}`"
|
||||
>
|
||||
<i class="fas fa-edit mr-1" aria-hidden="true"></i> Edit
|
||||
<i class="fas fa-edit mr-1" aria-hidden="true"></i> {{ _("common.edit") }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="confirmDelete(user)"
|
||||
x-show="user.profile_id !== null"
|
||||
class="inline-flex items-center px-2.5 py-1.5 text-xs font-medium rounded border border-red-300 text-red-600 bg-white hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-red-400"
|
||||
:aria-label="`Delete profile for ${user.user_id}`"
|
||||
:aria-label="`{{ _('common.delete') }} ${user.user_id}`"
|
||||
>
|
||||
<i class="fas fa-trash mr-1" aria-hidden="true"></i> Delete
|
||||
<i class="fas fa-trash mr-1" aria-hidden="true"></i> {{ _("common.delete") }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -209,10 +209,10 @@
|
||||
:disabled="currentPage <= 1"
|
||||
class="px-3 py-1.5 text-sm border border-gray-300 rounded hover:bg-gray-50 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<i class="fas fa-chevron-left mr-1" aria-hidden="true"></i> Previous
|
||||
<i class="fas fa-chevron-left mr-1" aria-hidden="true"></i> {{ _("common.previous") }}
|
||||
</button>
|
||||
<span class="text-sm text-gray-500">
|
||||
Page <span x-text="currentPage"></span> of <span x-text="pages"></span>
|
||||
{{ _("common.page") }} <span x-text="currentPage"></span> {{ _("admin_users.pagination_page_of") }} <span x-text="pages"></span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
@@ -220,7 +220,7 @@
|
||||
:disabled="currentPage >= pages"
|
||||
class="px-3 py-1.5 text-sm border border-gray-300 rounded hover:bg-gray-50 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
Next <i class="fas fa-chevron-right ml-1" aria-hidden="true"></i>
|
||||
{{ _("common.next") }} <i class="fas fa-chevron-right ml-1" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -250,7 +250,7 @@
|
||||
type="button"
|
||||
@click="modalOpen = false"
|
||||
class="text-gray-400 hover:text-gray-600 focus:outline-none"
|
||||
aria-label="Close dialog"
|
||||
aria-label="{{ _('admin_users.modal_close_aria') }}"
|
||||
>
|
||||
<i class="fas fa-times" aria-hidden="true"></i>
|
||||
</button>
|
||||
@@ -261,7 +261,7 @@
|
||||
|
||||
<!-- User ID (read-only when editing) -->
|
||||
<div>
|
||||
<label for="modal-user-id" class="block text-sm font-medium text-gray-700 mb-1">User ID</label>
|
||||
<label for="modal-user-id" class="block text-sm font-medium text-gray-700 mb-1">{{ _("admin_users.modal_user_id_label") }}</label>
|
||||
<input
|
||||
id="modal-user-id"
|
||||
type="text"
|
||||
@@ -269,32 +269,32 @@
|
||||
:readonly="!isCreate"
|
||||
:class="!isCreate ? 'bg-gray-100 cursor-not-allowed' : ''"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-400"
|
||||
placeholder="user@example.com or OAuth sub"
|
||||
placeholder="{{ _('admin_users.modal_user_id_placeholder') }}"
|
||||
required
|
||||
aria-required="true"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<p class="text-xs text-gray-400 mt-1">The stable identifier that matches <code>owner_id</code> in documents.</p>
|
||||
<p class="text-xs text-gray-400 mt-1">{{ _("admin_users.modal_user_id_hint") }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Display name -->
|
||||
<div>
|
||||
<label for="modal-display-name" class="block text-sm font-medium text-gray-700 mb-1">Display Name</label>
|
||||
<label for="modal-display-name" class="block text-sm font-medium text-gray-700 mb-1">{{ _("admin_users.modal_display_name_label") }}</label>
|
||||
<input
|
||||
id="modal-display-name"
|
||||
type="text"
|
||||
x-model="form.display_name"
|
||||
maxlength="255"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-400"
|
||||
placeholder="Alice Smith (optional)"
|
||||
placeholder="{{ _('admin_users.modal_display_name_placeholder') }}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Daily upload limit -->
|
||||
<div>
|
||||
<label for="modal-limit" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Daily Upload Limit
|
||||
<span class="ml-1 text-xs font-normal text-gray-400">(leave empty to use global default)</span>
|
||||
{{ _("admin_users.modal_daily_limit_label") }}
|
||||
<span class="ml-1 text-xs font-normal text-gray-400">{{ _("admin_users.modal_daily_limit_hint") }}</span>
|
||||
</label>
|
||||
<input
|
||||
id="modal-limit"
|
||||
@@ -302,20 +302,20 @@
|
||||
x-model.number="form.daily_upload_limit"
|
||||
min="0"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-400"
|
||||
placeholder="e.g. 50 (0 = unlimited)"
|
||||
placeholder="{{ _('admin_users.modal_daily_limit_placeholder') }}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Notes -->
|
||||
<div>
|
||||
<label for="modal-notes" class="block text-sm font-medium text-gray-700 mb-1">Admin Notes</label>
|
||||
<label for="modal-notes" class="block text-sm font-medium text-gray-700 mb-1">{{ _("admin_users.modal_notes_label") }}</label>
|
||||
<textarea
|
||||
id="modal-notes"
|
||||
x-model="form.notes"
|
||||
rows="3"
|
||||
maxlength="4096"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-400 resize-y"
|
||||
placeholder="Internal notes visible only to admins…"
|
||||
placeholder="{{ _('admin_users.modal_notes_placeholder') }}"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
@@ -336,50 +336,50 @@
|
||||
></span>
|
||||
</button>
|
||||
<span id="modal-blocked-label" class="text-sm font-medium text-gray-700">
|
||||
Block this user
|
||||
<span class="text-xs text-gray-400 font-normal">(prevents new document uploads)</span>
|
||||
{{ _("admin_users.modal_block_label") }}
|
||||
<span class="text-xs text-gray-400 font-normal">{{ _("admin_users.modal_block_hint") }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Subscription tier -->
|
||||
<div>
|
||||
<label for="modal-tier" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Subscription Plan
|
||||
{{ _("admin_users.modal_plan_label") }}
|
||||
</label>
|
||||
<select
|
||||
id="modal-tier"
|
||||
x-model="form.subscription_tier"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-400 bg-white"
|
||||
>
|
||||
<option value="free">Free — 25 lifetime files</option>
|
||||
<option value="starter">Starter — $2.99/mo (50/mo, 1 mailbox)</option>
|
||||
<option value="professional">Professional — $5.99/mo (150/mo, 3 mailboxes)</option>
|
||||
<option value="business">Business — $7.99/mo (300/mo, unlimited mailboxes)</option>
|
||||
<option value="free">{{ _("admin_users.modal_plan_free") }}</option>
|
||||
<option value="starter">{{ _("admin_users.modal_plan_starter") }}</option>
|
||||
<option value="professional">{{ _("admin_users.modal_plan_professional") }}</option>
|
||||
<option value="business">{{ _("admin_users.modal_plan_business") }}</option>
|
||||
</select>
|
||||
<p class="text-xs text-gray-400 mt-1">
|
||||
Sets the quota limits for this user. Limits are enforced on upload.
|
||||
{{ _("admin_users.modal_plan_hint") }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Billing cycle -->
|
||||
<div>
|
||||
<label for="modal-billing-cycle" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Billing Cycle
|
||||
{{ _("admin_users.modal_billing_cycle_label") }}
|
||||
</label>
|
||||
<select
|
||||
id="modal-billing-cycle"
|
||||
x-model="form.subscription_billing_cycle"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-400 bg-white"
|
||||
>
|
||||
<option value="monthly">Monthly</option>
|
||||
<option value="yearly">Yearly</option>
|
||||
<option value="monthly">{{ _("admin_users.modal_billing_monthly") }}</option>
|
||||
<option value="yearly">{{ _("admin_users.modal_billing_yearly") }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Subscription period start (only shown for yearly) -->
|
||||
<div x-show="form.subscription_billing_cycle === 'yearly'" x-cloak>
|
||||
<label for="modal-period-start" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Subscription Period Start
|
||||
{{ _("admin_users.modal_period_start_label") }}
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
@@ -388,7 +388,7 @@
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-400"
|
||||
/>
|
||||
<p class="text-xs text-gray-400 mt-1">
|
||||
Annual carry-over calculates from this date. Leave blank for monthly enforcement.
|
||||
{{ _("admin_users.modal_period_start_hint") }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -409,8 +409,8 @@
|
||||
></span>
|
||||
</button>
|
||||
<span id="modal-complimentary-label" class="text-sm font-medium text-gray-700">
|
||||
Complimentary plan
|
||||
<span class="text-xs text-gray-400 font-normal">(user keeps tier benefits but is never billed — set automatically for admin accounts)</span>
|
||||
{{ _("admin_users.modal_complimentary_label") }}
|
||||
<span class="text-xs text-gray-400 font-normal">{{ _("admin_users.modal_complimentary_hint") }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -423,15 +423,15 @@
|
||||
@click="modalOpen = false"
|
||||
class="px-4 py-2 text-sm font-medium border border-gray-300 rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-gray-400"
|
||||
>
|
||||
Cancel
|
||||
{{ _("common.cancel") }}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="saving"
|
||||
class="px-4 py-2 text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span x-show="!saving" x-text="isCreate ? 'Create' : 'Save Changes'"></span>
|
||||
<span x-show="saving"><i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i> Saving…</span>
|
||||
<span x-show="!saving" x-text="isCreate ? '{{ _('common.create') }}' : '{{ _('admin_users.modal_save_changes') }}'"></span>
|
||||
<span x-show="saving"><i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i> {{ _("admin_users.modal_saving") }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -444,10 +444,10 @@
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
||||
<i class="fas fa-user-lock text-green-600" aria-hidden="true"></i>
|
||||
Local User Accounts
|
||||
{{ _("admin_users.local_accounts_heading") }}
|
||||
</h2>
|
||||
<p class="text-sm text-gray-500 mt-0.5">
|
||||
Email/password accounts created directly on this server.
|
||||
{{ _("admin_users.local_accounts_subheading") }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -455,35 +455,35 @@
|
||||
@click="openCreateLocalUserModal()"
|
||||
class="inline-flex items-center px-3 py-1.5 bg-green-600 hover:bg-green-700 text-white text-sm font-medium rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500"
|
||||
>
|
||||
<i class="fas fa-plus mr-1" aria-hidden="true"></i> New Account
|
||||
<i class="fas fa-plus mr-1" aria-hidden="true"></i> {{ _("admin_users.new_account_btn") }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200" aria-label="Local user accounts">
|
||||
<table class="min-w-full divide-y divide-gray-200" aria-label="{{ _('admin_users.local_accounts_heading') }}">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Username</th>
|
||||
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Email</th>
|
||||
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Display Name</th>
|
||||
<th scope="col" class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
||||
<th scope="col" class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">Role</th>
|
||||
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Created</th>
|
||||
<th scope="col" class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ _("admin_users.col_username") }}</th>
|
||||
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ _("admin_users.col_email") }}</th>
|
||||
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ _("admin_users.col_display_name") }}</th>
|
||||
<th scope="col" class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">{{ _("common.status") }}</th>
|
||||
<th scope="col" class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">{{ _("admin_users.col_role") }}</th>
|
||||
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ _("common.created") }}</th>
|
||||
<th scope="col" class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">{{ _("common.actions") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<template x-if="localUsersLoading">
|
||||
<tr>
|
||||
<td colspan="7" class="px-4 py-6 text-center text-gray-400">
|
||||
<i class="fas fa-spinner fa-spin mr-2" aria-hidden="true"></i> Loading…
|
||||
<i class="fas fa-spinner fa-spin mr-2" aria-hidden="true"></i> {{ _("admin_users.local_loading") }}
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<template x-if="!localUsersLoading && localUsers.length === 0">
|
||||
<tr>
|
||||
<td colspan="7" class="px-4 py-6 text-center text-gray-400">
|
||||
No local accounts yet.
|
||||
<button type="button" @click="openCreateLocalUserModal()" class="text-green-600 hover:underline ml-1">Create one.</button>
|
||||
{{ _("admin_users.local_no_accounts") }}
|
||||
<button type="button" @click="openCreateLocalUserModal()" class="text-green-600 hover:underline ml-1">{{ _("admin_users.local_create_one") }}</button>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
@@ -496,14 +496,14 @@
|
||||
<span
|
||||
:class="lu.is_active ? 'bg-green-100 text-green-800' : 'bg-yellow-100 text-yellow-800'"
|
||||
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
|
||||
x-text="lu.is_active ? 'Active' : 'Unverified'"
|
||||
x-text="lu.is_active ? '{{ _("common.active") }}' : '{{ _("admin_users.status_unverified") }}'"
|
||||
></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-center">
|
||||
<span
|
||||
:class="lu.is_admin ? 'bg-red-100 text-red-700' : 'bg-gray-100 text-gray-600'"
|
||||
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
|
||||
x-text="lu.is_admin ? 'Admin' : 'User'"
|
||||
x-text="lu.is_admin ? '{{ _("admin_users.role_admin") }}' : '{{ _("admin_users.role_user") }}'"
|
||||
></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-500 whitespace-nowrap" x-text="lu.created_at ? formatDate(lu.created_at) : '—'"></td>
|
||||
@@ -512,34 +512,34 @@
|
||||
type="button"
|
||||
@click="openEditLocalUserModal(lu)"
|
||||
class="inline-flex items-center px-2.5 py-1.5 text-xs font-medium rounded border border-gray-300 text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-blue-500 mr-1"
|
||||
:aria-label="`Edit account for ${lu.username}`"
|
||||
:aria-label="`{{ _('common.edit') }} ${lu.username}`"
|
||||
>
|
||||
<i class="fas fa-edit mr-1" aria-hidden="true"></i> Edit
|
||||
<i class="fas fa-edit mr-1" aria-hidden="true"></i> {{ _("common.edit") }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="openSetPasswordModal(lu)"
|
||||
class="inline-flex items-center px-2.5 py-1.5 text-xs font-medium rounded border border-yellow-300 text-yellow-700 bg-white hover:bg-yellow-50 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-yellow-400 mr-1"
|
||||
:aria-label="`Set password for ${lu.username}`"
|
||||
:aria-label="`{{ _('admin_users.btn_password') }} ${lu.username}`"
|
||||
>
|
||||
<i class="fas fa-key mr-1" aria-hidden="true"></i> Password
|
||||
<i class="fas fa-key mr-1" aria-hidden="true"></i> {{ _("admin_users.btn_password") }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="sendPasswordReset(lu)"
|
||||
class="inline-flex items-center px-2.5 py-1.5 text-xs font-medium rounded border border-indigo-300 text-indigo-600 bg-white hover:bg-indigo-50 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-indigo-400 mr-1"
|
||||
:aria-label="`Send password reset email to ${lu.username}`"
|
||||
title="Send password reset email"
|
||||
:aria-label="`{{ _('admin_users.btn_reset') }} ${lu.username}`"
|
||||
title="{{ _('admin_users.btn_reset') }}"
|
||||
>
|
||||
<i class="fas fa-envelope mr-1" aria-hidden="true"></i> Reset
|
||||
<i class="fas fa-envelope mr-1" aria-hidden="true"></i> {{ _("admin_users.btn_reset") }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="confirmDeleteLocalUser(lu)"
|
||||
class="inline-flex items-center px-2.5 py-1.5 text-xs font-medium rounded border border-red-300 text-red-600 bg-white hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-red-400"
|
||||
:aria-label="`Delete account for ${lu.username}`"
|
||||
:aria-label="`{{ _('common.delete') }} ${lu.username}`"
|
||||
>
|
||||
<i class="fas fa-trash mr-1" aria-hidden="true"></i> Delete
|
||||
<i class="fas fa-trash mr-1" aria-hidden="true"></i> {{ _("common.delete") }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -562,44 +562,44 @@
|
||||
>
|
||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-lg" @click.outside="localUserModal.open = false">
|
||||
<div class="px-6 py-4 border-b flex items-center justify-between">
|
||||
<h2 id="create-local-user-title" class="text-lg font-semibold text-gray-900">Create Local Account</h2>
|
||||
<button type="button" @click="localUserModal.open = false" aria-label="Close" class="text-gray-400 hover:text-gray-600">
|
||||
<h2 id="create-local-user-title" class="text-lg font-semibold text-gray-900">{{ _("admin_users.create_local_title") }}</h2>
|
||||
<button type="button" @click="localUserModal.open = false" aria-label="{{ _('common.close') }}" class="text-gray-400 hover:text-gray-600">
|
||||
<i class="fas fa-times" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
<form @submit.prevent="submitCreateLocalUser" class="px-6 py-5 space-y-4">
|
||||
<div>
|
||||
<label for="lu-email" class="block text-sm font-medium text-gray-700">Email <span aria-hidden="true" class="text-red-500">*</span></label>
|
||||
<label for="lu-email" class="block text-sm font-medium text-gray-700">{{ _("admin_users.col_email") }} <span aria-hidden="true" class="text-red-500">*</span></label>
|
||||
<input type="email" id="lu-email" x-model="localUserModal.form.email" required autocomplete="off"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-green-500 focus:ring focus:ring-green-500 focus:ring-opacity-50 text-sm"
|
||||
style="min-height:40px;" aria-required="true">
|
||||
</div>
|
||||
<div>
|
||||
<label for="lu-username" class="block text-sm font-medium text-gray-700">Username <span aria-hidden="true" class="text-red-500">*</span></label>
|
||||
<label for="lu-username" class="block text-sm font-medium text-gray-700">{{ _("admin_users.col_username") }} <span aria-hidden="true" class="text-red-500">*</span></label>
|
||||
<input type="text" id="lu-username" x-model="localUserModal.form.username" required autocomplete="off"
|
||||
pattern="^[a-zA-Z0-9_-]+$" minlength="3" maxlength="64"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-green-500 focus:ring focus:ring-green-500 focus:ring-opacity-50 text-sm"
|
||||
style="min-height:40px;" aria-required="true" aria-describedby="lu-username-hint">
|
||||
<p id="lu-username-hint" class="mt-1 text-xs text-gray-500">3–64 characters. Letters, numbers, hyphens and underscores only.</p>
|
||||
<p id="lu-username-hint" class="mt-1 text-xs text-gray-500">{{ _("admin_users.local_username_hint") }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="lu-display-name" class="block text-sm font-medium text-gray-700">Display Name <span class="text-gray-400">(optional)</span></label>
|
||||
<label for="lu-display-name" class="block text-sm font-medium text-gray-700">{{ _("admin_users.col_display_name") }} <span class="text-gray-400">{{ _("admin_users.local_display_name_optional") }}</span></label>
|
||||
<input type="text" id="lu-display-name" x-model="localUserModal.form.display_name" autocomplete="off" maxlength="255"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-green-500 focus:ring focus:ring-green-500 focus:ring-opacity-50 text-sm"
|
||||
style="min-height:40px;">
|
||||
</div>
|
||||
<div>
|
||||
<label for="lu-password" class="block text-sm font-medium text-gray-700">Password <span aria-hidden="true" class="text-red-500">*</span></label>
|
||||
<label for="lu-password" class="block text-sm font-medium text-gray-700">{{ _("auth.password_label") }} <span aria-hidden="true" class="text-red-500">*</span></label>
|
||||
<input type="password" id="lu-password" x-model="localUserModal.form.password" required autocomplete="new-password"
|
||||
minlength="8" maxlength="128"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-green-500 focus:ring focus:ring-green-500 focus:ring-opacity-50 text-sm"
|
||||
style="min-height:40px;" aria-required="true" aria-describedby="lu-password-hint">
|
||||
<p id="lu-password-hint" class="mt-1 text-xs text-gray-500">Minimum 8 characters.</p>
|
||||
<p id="lu-password-hint" class="mt-1 text-xs text-gray-500">{{ _("admin_users.local_password_hint") }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="checkbox" id="lu-is-admin" x-model="localUserModal.form.is_admin"
|
||||
class="h-4 w-4 rounded border-gray-300 text-green-600 focus:ring-green-500">
|
||||
<label for="lu-is-admin" class="text-sm text-gray-700">Grant admin privileges</label>
|
||||
<label for="lu-is-admin" class="text-sm text-gray-700">{{ _("admin_users.local_admin_privileges") }}</label>
|
||||
</div>
|
||||
<div x-show="localUserModal.error" x-cloak
|
||||
class="bg-red-50 border-l-4 border-red-500 text-red-700 p-3 rounded text-sm"
|
||||
@@ -608,12 +608,12 @@
|
||||
<div class="flex justify-end gap-3 pt-2">
|
||||
<button type="button" @click="localUserModal.open = false"
|
||||
class="px-4 py-2 text-sm font-medium border border-gray-300 rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
||||
Cancel
|
||||
{{ _("common.cancel") }}
|
||||
</button>
|
||||
<button type="submit" :disabled="localUserModal.saving"
|
||||
class="px-4 py-2 text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-green-500 disabled:opacity-50">
|
||||
<span x-show="!localUserModal.saving">Create Account</span>
|
||||
<span x-show="localUserModal.saving" x-cloak><i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i> Creating…</span>
|
||||
<span x-show="!localUserModal.saving">{{ _("admin_users.local_create_btn") }}</span>
|
||||
<span x-show="localUserModal.saving" x-cloak><i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i> {{ _("admin_users.local_creating") }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -633,20 +633,20 @@
|
||||
>
|
||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-lg" @click.outside="editLocalUserModal.open = false">
|
||||
<div class="px-6 py-4 border-b flex items-center justify-between">
|
||||
<h2 id="edit-local-user-title" class="text-lg font-semibold text-gray-900">Edit Local Account</h2>
|
||||
<button type="button" @click="editLocalUserModal.open = false" aria-label="Close" class="text-gray-400 hover:text-gray-600">
|
||||
<h2 id="edit-local-user-title" class="text-lg font-semibold text-gray-900">{{ _("admin_users.edit_local_title") }}</h2>
|
||||
<button type="button" @click="editLocalUserModal.open = false" aria-label="{{ _('common.close') }}" class="text-gray-400 hover:text-gray-600">
|
||||
<i class="fas fa-times" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
<form @submit.prevent="submitEditLocalUser" class="px-6 py-5 space-y-4">
|
||||
<div>
|
||||
<label for="elu-email" class="block text-sm font-medium text-gray-700">Email <span aria-hidden="true" class="text-red-500">*</span></label>
|
||||
<label for="elu-email" class="block text-sm font-medium text-gray-700">{{ _("admin_users.col_email") }} <span aria-hidden="true" class="text-red-500">*</span></label>
|
||||
<input type="email" id="elu-email" x-model="editLocalUserModal.form.email" required autocomplete="off"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-500 focus:ring-opacity-50 text-sm"
|
||||
style="min-height:40px;" aria-required="true">
|
||||
</div>
|
||||
<div>
|
||||
<label for="elu-display-name" class="block text-sm font-medium text-gray-700">Display Name <span class="text-gray-400">(optional)</span></label>
|
||||
<label for="elu-display-name" class="block text-sm font-medium text-gray-700">{{ _("admin_users.col_display_name") }} <span class="text-gray-400">{{ _("admin_users.local_display_name_optional") }}</span></label>
|
||||
<input type="text" id="elu-display-name" x-model="editLocalUserModal.form.display_name" autocomplete="off" maxlength="255"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-500 focus:ring-opacity-50 text-sm"
|
||||
style="min-height:40px;">
|
||||
@@ -654,12 +654,12 @@
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="checkbox" id="elu-is-admin" x-model="editLocalUserModal.form.is_admin"
|
||||
class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500">
|
||||
<label for="elu-is-admin" class="text-sm text-gray-700">Admin privileges</label>
|
||||
<label for="elu-is-admin" class="text-sm text-gray-700">{{ _("admin_users.local_admin_privileges_short") }}</label>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="checkbox" id="elu-is-active" x-model="editLocalUserModal.form.is_active"
|
||||
class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500">
|
||||
<label for="elu-is-active" class="text-sm text-gray-700">Account active</label>
|
||||
<label for="elu-is-active" class="text-sm text-gray-700">{{ _("admin_users.local_account_active") }}</label>
|
||||
</div>
|
||||
<div x-show="editLocalUserModal.error" x-cloak
|
||||
class="bg-red-50 border-l-4 border-red-500 text-red-700 p-3 rounded text-sm"
|
||||
@@ -668,12 +668,12 @@
|
||||
<div class="flex justify-end gap-3 pt-2">
|
||||
<button type="button" @click="editLocalUserModal.open = false"
|
||||
class="px-4 py-2 text-sm font-medium border border-gray-300 rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
||||
Cancel
|
||||
{{ _("common.cancel") }}
|
||||
</button>
|
||||
<button type="submit" :disabled="editLocalUserModal.saving"
|
||||
class="px-4 py-2 text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-blue-500 disabled:opacity-50">
|
||||
<span x-show="!editLocalUserModal.saving">Save Changes</span>
|
||||
<span x-show="editLocalUserModal.saving" x-cloak><i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i> Saving…</span>
|
||||
<span x-show="!editLocalUserModal.saving">{{ _("admin_users.modal_save_changes") }}</span>
|
||||
<span x-show="editLocalUserModal.saving" x-cloak><i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i> {{ _("admin_users.local_saving") }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -693,23 +693,23 @@
|
||||
>
|
||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-md" @click.outside="setPasswordModal.open = false">
|
||||
<div class="px-6 py-4 border-b flex items-center justify-between">
|
||||
<h2 id="set-password-title" class="text-lg font-semibold text-gray-900">Set Temporary Password</h2>
|
||||
<button type="button" @click="setPasswordModal.open = false" aria-label="Close" class="text-gray-400 hover:text-gray-600">
|
||||
<h2 id="set-password-title" class="text-lg font-semibold text-gray-900">{{ _("admin_users.set_password_title") }}</h2>
|
||||
<button type="button" @click="setPasswordModal.open = false" aria-label="{{ _('common.close') }}" class="text-gray-400 hover:text-gray-600">
|
||||
<i class="fas fa-times" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
<form @submit.prevent="submitSetPassword" class="px-6 py-5 space-y-4">
|
||||
<p class="text-sm text-gray-600">
|
||||
Set a new password directly for <strong class="font-mono" x-text="setPasswordModal.username"></strong>.
|
||||
The user should change this password after logging in.
|
||||
{{ _("admin_users.set_password_desc_pre") }} <strong class="font-mono" x-text="setPasswordModal.username"></strong>.
|
||||
{{ _("admin_users.set_password_desc") }}
|
||||
</p>
|
||||
<div>
|
||||
<label for="sp-password" class="block text-sm font-medium text-gray-700">New Password <span aria-hidden="true" class="text-red-500">*</span></label>
|
||||
<label for="sp-password" class="block text-sm font-medium text-gray-700">{{ _("admin_users.new_password_label") }} <span aria-hidden="true" class="text-red-500">*</span></label>
|
||||
<input type="password" id="sp-password" x-model="setPasswordModal.password" required autocomplete="new-password"
|
||||
minlength="8" maxlength="128"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-yellow-500 focus:ring focus:ring-yellow-500 focus:ring-opacity-50 text-sm"
|
||||
style="min-height:40px;" aria-required="true" aria-describedby="sp-password-hint">
|
||||
<p id="sp-password-hint" class="mt-1 text-xs text-gray-500">Minimum 8 characters.</p>
|
||||
<p id="sp-password-hint" class="mt-1 text-xs text-gray-500">{{ _("admin_users.local_password_hint") }}</p>
|
||||
</div>
|
||||
<div x-show="setPasswordModal.error" x-cloak
|
||||
class="bg-red-50 border-l-4 border-red-500 text-red-700 p-3 rounded text-sm"
|
||||
@@ -718,12 +718,12 @@
|
||||
<div class="flex justify-end gap-3 pt-2">
|
||||
<button type="button" @click="setPasswordModal.open = false"
|
||||
class="px-4 py-2 text-sm font-medium border border-gray-300 rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
||||
Cancel
|
||||
{{ _("common.cancel") }}
|
||||
</button>
|
||||
<button type="submit" :disabled="setPasswordModal.saving"
|
||||
class="px-4 py-2 text-sm font-medium rounded-md text-white bg-yellow-600 hover:bg-yellow-700 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-yellow-500 disabled:opacity-50">
|
||||
<span x-show="!setPasswordModal.saving">Set Password</span>
|
||||
<span x-show="setPasswordModal.saving" x-cloak><i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i> Setting…</span>
|
||||
<span x-show="!setPasswordModal.saving">{{ _("admin_users.set_password_btn") }}</span>
|
||||
<span x-show="setPasswordModal.saving" x-cloak><i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i> {{ _("admin_users.setting") }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -743,25 +743,25 @@
|
||||
>
|
||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-md" @click.outside="deleteLocalUserModal.open = false">
|
||||
<div class="px-6 py-4 border-b">
|
||||
<h2 id="delete-local-user-title" class="text-lg font-semibold text-gray-900">Delete Local Account</h2>
|
||||
<h2 id="delete-local-user-title" class="text-lg font-semibold text-gray-900">{{ _("admin_users.delete_local_title") }}</h2>
|
||||
</div>
|
||||
<div class="px-6 py-5">
|
||||
<p class="text-sm text-gray-700">
|
||||
Are you sure you want to delete the account for
|
||||
{{ _("admin_users.delete_local_confirm") }}
|
||||
<strong class="font-mono" x-text="deleteLocalUserModal.username"></strong>
|
||||
(<span class="font-mono" x-text="deleteLocalUserModal.email"></span>)?
|
||||
</p>
|
||||
<p class="text-sm text-gray-500 mt-2">This cannot be undone. Documents owned by this user are <strong>not</strong> deleted.</p>
|
||||
<p class="text-sm text-gray-500 mt-2">{{ _("admin_users.delete_local_warning") }}</p>
|
||||
</div>
|
||||
<div class="px-6 py-4 border-t flex justify-end gap-3">
|
||||
<button type="button" @click="deleteLocalUserModal.open = false"
|
||||
class="px-4 py-2 text-sm font-medium border border-gray-300 rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
||||
Cancel
|
||||
{{ _("common.cancel") }}
|
||||
</button>
|
||||
<button type="button" @click="executeDeleteLocalUser()" :disabled="deleteLocalUserModal.deleting"
|
||||
class="px-4 py-2 text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 disabled:opacity-50">
|
||||
<span x-show="!deleteLocalUserModal.deleting">Delete Account</span>
|
||||
<span x-show="deleteLocalUserModal.deleting" x-cloak><i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i> Deleting…</span>
|
||||
<span x-show="!deleteLocalUserModal.deleting">{{ _("admin_users.delete_account_btn") }}</span>
|
||||
<span x-show="deleteLocalUserModal.deleting" x-cloak><i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i> {{ _("admin_users.deleting") }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -780,15 +780,15 @@
|
||||
>
|
||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-md" @click.outside="deleteModal.open = false">
|
||||
<div class="px-6 py-4 border-b">
|
||||
<h2 id="delete-modal-title" class="text-lg font-semibold text-gray-900">Delete User Profile</h2>
|
||||
<h2 id="delete-modal-title" class="text-lg font-semibold text-gray-900">{{ _("admin_users.delete_profile_title") }}</h2>
|
||||
</div>
|
||||
<div class="px-6 py-5">
|
||||
<p class="text-sm text-gray-700">
|
||||
Are you sure you want to delete the profile for
|
||||
{{ _("admin_users.delete_profile_confirm") }}
|
||||
<strong class="font-mono" x-text="deleteModal.user_id"></strong>?
|
||||
</p>
|
||||
<p class="text-sm text-gray-500 mt-2">
|
||||
This only removes the admin-managed profile record. Documents owned by this user are <strong>not</strong> deleted.
|
||||
{{ _("admin_users.delete_profile_warning") }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="px-6 py-4 border-t flex justify-end gap-3">
|
||||
@@ -797,7 +797,7 @@
|
||||
@click="deleteModal.open = false"
|
||||
class="px-4 py-2 text-sm font-medium border border-gray-300 rounded-md text-gray-700 bg-white hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
{{ _("common.cancel") }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -805,8 +805,8 @@
|
||||
:disabled="deleteModal.deleting"
|
||||
class="px-4 py-2 text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 disabled:opacity-50"
|
||||
>
|
||||
<span x-show="!deleteModal.deleting">Delete Profile</span>
|
||||
<span x-show="deleteModal.deleting"><i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i> Deleting…</span>
|
||||
<span x-show="!deleteModal.deleting">{{ _("admin_users.delete_profile_btn") }}</span>
|
||||
<span x-show="deleteModal.deleting"><i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i> {{ _("admin_users.deleting") }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -814,8 +814,43 @@
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window.__i18nAdminUsers = {
|
||||
noUsers: {{ _("admin_users.total_no_users") | tojson }},
|
||||
oneUser: {{ _("admin_users.total_one_user") | tojson }},
|
||||
countUsers: {{ _("admin_users.total_count_users") | tojson }},
|
||||
modalAddTitle: {{ _("admin_users.modal_add_title") | tojson }},
|
||||
modalEditTitle: {{ _("admin_users.modal_edit_title") | tojson }},
|
||||
failedLoadUsers: {{ _("admin_users.js_failed_load_users") | tojson }},
|
||||
networkError: {{ _("admin_users.js_network_error") | tojson }},
|
||||
saveFailed: {{ _("admin_users.js_save_failed") | tojson }},
|
||||
saved: {{ _("admin_users.js_saved") | tojson }},
|
||||
profileSaved: {{ _("admin_users.js_profile_saved") | tojson }},
|
||||
deleteFailed: {{ _("admin_users.js_delete_failed") | tojson }},
|
||||
deleted: {{ _("admin_users.js_deleted") | tojson }},
|
||||
profileDeleted: {{ _("admin_users.js_profile_deleted") | tojson }},
|
||||
failedLoadLocal: {{ _("admin_users.js_failed_load_local") | tojson }},
|
||||
accountCreated: {{ _("admin_users.js_account_created") | tojson }},
|
||||
accountCreatedMsg: {{ _("admin_users.js_account_created_msg") | tojson }},
|
||||
failedCreate: {{ _("admin_users.js_failed_create") | tojson }},
|
||||
accountDeletedMsg: {{ _("admin_users.js_account_deleted_msg") | tojson }},
|
||||
updated: {{ _("admin_users.js_updated") | tojson }},
|
||||
accountUpdated: {{ _("admin_users.js_account_updated") | tojson }},
|
||||
failedUpdate: {{ _("admin_users.js_failed_update") | tojson }},
|
||||
passwordSet: {{ _("admin_users.js_password_set") | tojson }},
|
||||
passwordSetMsg: {{ _("admin_users.js_password_set_msg") | tojson }},
|
||||
failedSetPassword: {{ _("admin_users.js_failed_set_password") | tojson }},
|
||||
emailSent: {{ _("admin_users.js_email_sent") | tojson }},
|
||||
emailSentMsg: {{ _("admin_users.js_email_sent_msg") | tojson }},
|
||||
emailNotSent: {{ _("admin_users.js_email_not_sent") | tojson }},
|
||||
smtpNotConfigured: {{ _("admin_users.js_smtp_not_configured") | tojson }},
|
||||
failed: {{ _("admin_users.js_failed") | tojson }},
|
||||
};
|
||||
</script>
|
||||
|
||||
<script>
|
||||
function adminUsersApp() {
|
||||
const i18n = window.__i18nAdminUsers;
|
||||
return {
|
||||
// State
|
||||
users: [],
|
||||
@@ -887,8 +922,9 @@ function adminUsersApp() {
|
||||
|
||||
get totalLabel() {
|
||||
if (this.loading) return '';
|
||||
if (this.total === 0) return 'No users';
|
||||
return `${this.total} user${this.total !== 1 ? 's' : ''}`;
|
||||
if (this.total === 0) return i18n.noUsers;
|
||||
if (this.total === 1) return i18n.oneUser;
|
||||
return i18n.countUsers.replace('{count}', this.total);
|
||||
},
|
||||
|
||||
async init() {
|
||||
@@ -908,7 +944,7 @@ function adminUsersApp() {
|
||||
const resp = await fetch(`/api/admin/users/?${params}`);
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
this.showAlert('error', 'Failed to load users', err.detail || resp.statusText);
|
||||
this.showAlert('error', i18n.failedLoadUsers, err.detail || resp.statusText);
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
@@ -916,7 +952,7 @@ function adminUsersApp() {
|
||||
this.total = data.total;
|
||||
this.pages = data.pages;
|
||||
} catch (e) {
|
||||
this.showAlert('error', 'Network error', e.message);
|
||||
this.showAlert('error', i18n.networkError, e.message);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
@@ -924,14 +960,14 @@ function adminUsersApp() {
|
||||
|
||||
openCreateModal() {
|
||||
this.isCreate = true;
|
||||
this.modalTitle = 'Add User Profile';
|
||||
this.modalTitle = i18n.modalAddTitle;
|
||||
this.form = { user_id: '', display_name: '', daily_upload_limit: null, notes: '', is_blocked: false, subscription_tier: 'free', subscription_billing_cycle: 'monthly', subscription_period_start: null, is_complimentary: false };
|
||||
this.modalOpen = true;
|
||||
},
|
||||
|
||||
openEditModal(user) {
|
||||
this.isCreate = false;
|
||||
this.modalTitle = 'Edit User Profile';
|
||||
this.modalTitle = i18n.modalEditTitle;
|
||||
this.form = {
|
||||
user_id: user.user_id,
|
||||
display_name: user.display_name || '',
|
||||
@@ -970,14 +1006,14 @@ function adminUsersApp() {
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
this.showAlert('error', 'Save failed', err.detail || resp.statusText);
|
||||
this.showAlert('error', i18n.saveFailed, err.detail || resp.statusText);
|
||||
return;
|
||||
}
|
||||
this.modalOpen = false;
|
||||
this.showAlert('success', 'Saved', `Profile for "${this.form.user_id}" has been saved.`);
|
||||
this.showAlert('success', i18n.saved, i18n.profileSaved.replace('{id}', this.form.user_id));
|
||||
await this.fetchUsers(this.currentPage);
|
||||
} catch (e) {
|
||||
this.showAlert('error', 'Network error', e.message);
|
||||
this.showAlert('error', i18n.networkError, e.message);
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
@@ -1001,15 +1037,15 @@ function adminUsersApp() {
|
||||
});
|
||||
if (resp.status === 204) {
|
||||
this.deleteModal.open = false;
|
||||
this.showAlert('success', 'Deleted', `Profile for "${this.deleteModal.user_id}" has been removed.`);
|
||||
this.showAlert('success', i18n.deleted, i18n.profileDeleted.replace('{id}', this.deleteModal.user_id));
|
||||
await this.fetchUsers(this.currentPage);
|
||||
} else {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
this.showAlert('error', 'Delete failed', err.detail || resp.statusText);
|
||||
this.showAlert('error', i18n.deleteFailed, err.detail || resp.statusText);
|
||||
this.deleteModal.open = false;
|
||||
}
|
||||
} catch (e) {
|
||||
this.showAlert('error', 'Network error', e.message);
|
||||
this.showAlert('error', i18n.networkError, e.message);
|
||||
this.deleteModal.open = false;
|
||||
} finally {
|
||||
this.deleteModal.deleting = false;
|
||||
@@ -1044,12 +1080,12 @@ function adminUsersApp() {
|
||||
headers: { 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || '' },
|
||||
});
|
||||
if (!resp.ok) {
|
||||
this.showAlert('error', 'Failed to load local users', resp.statusText);
|
||||
this.showAlert('error', i18n.failedLoadLocal, resp.statusText);
|
||||
return;
|
||||
}
|
||||
this.localUsers = await resp.json();
|
||||
} catch (e) {
|
||||
this.showAlert('error', 'Network error', e.message);
|
||||
this.showAlert('error', i18n.networkError, e.message);
|
||||
} finally {
|
||||
this.localUsersLoading = false;
|
||||
}
|
||||
@@ -1076,14 +1112,14 @@ function adminUsersApp() {
|
||||
});
|
||||
if (resp.ok) {
|
||||
this.localUserModal.open = false;
|
||||
this.showAlert('success', 'Account created', `Local account for "${this.localUserModal.form.username}" was created successfully.`);
|
||||
this.showAlert('success', i18n.accountCreated, i18n.accountCreatedMsg.replace('{username}', this.localUserModal.form.username));
|
||||
await this.fetchLocalUsers();
|
||||
} else {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
this.localUserModal.error = err.detail || 'Failed to create account.';
|
||||
this.localUserModal.error = err.detail || i18n.failedCreate;
|
||||
}
|
||||
} catch (e) {
|
||||
this.localUserModal.error = 'Network error: ' + e.message;
|
||||
this.localUserModal.error = i18n.networkError + ': ' + e.message;
|
||||
} finally {
|
||||
this.localUserModal.saving = false;
|
||||
}
|
||||
@@ -1129,14 +1165,14 @@ function adminUsersApp() {
|
||||
});
|
||||
if (resp.ok) {
|
||||
this.editLocalUserModal.open = false;
|
||||
this.showAlert('success', 'Updated', `Account has been updated.`);
|
||||
this.showAlert('success', i18n.updated, i18n.accountUpdated);
|
||||
await this.fetchLocalUsers();
|
||||
} else {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
this.editLocalUserModal.error = err.detail || 'Failed to update account.';
|
||||
this.editLocalUserModal.error = err.detail || i18n.failedUpdate;
|
||||
}
|
||||
} catch (e) {
|
||||
this.editLocalUserModal.error = 'Network error: ' + e.message;
|
||||
this.editLocalUserModal.error = i18n.networkError + ': ' + e.message;
|
||||
} finally {
|
||||
this.editLocalUserModal.saving = false;
|
||||
}
|
||||
@@ -1165,13 +1201,13 @@ function adminUsersApp() {
|
||||
});
|
||||
if (resp.ok) {
|
||||
this.setPasswordModal.open = false;
|
||||
this.showAlert('success', 'Password set', `Password for "${this.setPasswordModal.username}" has been updated.`);
|
||||
this.showAlert('success', i18n.passwordSet, i18n.passwordSetMsg.replace('{username}', this.setPasswordModal.username));
|
||||
} else {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
this.setPasswordModal.error = err.detail || 'Failed to set password.';
|
||||
this.setPasswordModal.error = err.detail || i18n.failedSetPassword;
|
||||
}
|
||||
} catch (e) {
|
||||
this.setPasswordModal.error = 'Network error: ' + e.message;
|
||||
this.setPasswordModal.error = i18n.networkError + ': ' + e.message;
|
||||
} finally {
|
||||
this.setPasswordModal.saving = false;
|
||||
}
|
||||
@@ -1187,14 +1223,14 @@ function adminUsersApp() {
|
||||
});
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
if (resp.ok && data.sent) {
|
||||
this.showAlert('success', 'Email sent', `Password reset email sent to "${lu.email}".`);
|
||||
this.showAlert('success', i18n.emailSent, i18n.emailSentMsg.replace('{email}', lu.email));
|
||||
} else if (resp.ok && !data.sent) {
|
||||
this.showAlert('error', 'Email not sent', data.reason || 'SMTP is not configured.');
|
||||
this.showAlert('error', i18n.emailNotSent, data.reason || i18n.smtpNotConfigured);
|
||||
} else {
|
||||
this.showAlert('error', 'Failed', data.detail || resp.statusText);
|
||||
this.showAlert('error', i18n.failed, data.detail || resp.statusText);
|
||||
}
|
||||
} catch (e) {
|
||||
this.showAlert('error', 'Network error', e.message);
|
||||
this.showAlert('error', i18n.networkError, e.message);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1207,15 +1243,15 @@ function adminUsersApp() {
|
||||
});
|
||||
if (resp.status === 204) {
|
||||
this.deleteLocalUserModal.open = false;
|
||||
this.showAlert('success', 'Deleted', `Account for "${this.deleteLocalUserModal.username}" has been removed.`);
|
||||
this.showAlert('success', i18n.deleted, i18n.accountDeletedMsg.replace('{username}', this.deleteLocalUserModal.username));
|
||||
await this.fetchLocalUsers();
|
||||
} else {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
this.showAlert('error', 'Delete failed', err.detail || resp.statusText);
|
||||
this.showAlert('error', i18n.deleteFailed, err.detail || resp.statusText);
|
||||
this.deleteLocalUserModal.open = false;
|
||||
}
|
||||
} catch (e) {
|
||||
this.showAlert('error', 'Network error', e.message);
|
||||
this.showAlert('error', i18n.networkError, e.message);
|
||||
this.deleteLocalUserModal.open = false;
|
||||
} finally {
|
||||
this.deleteLocalUserModal.deleting = false;
|
||||
|
||||
@@ -1,40 +1,40 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}DocuElevate - Third-Party Attributions{% endblock %}
|
||||
{% block title %}{{ _("attribution.page_title") }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<h1 class="text-2xl font-bold mb-4">Third-Party Software Attributions</h1>
|
||||
<h1 class="text-2xl font-bold mb-4">{{ _("attribution.heading") }}</h1>
|
||||
|
||||
<div class="bg-white shadow-md rounded-lg p-6 mb-6">
|
||||
<p class="mb-4">
|
||||
DocuElevate uses several open source libraries and tools. We are grateful to the
|
||||
developers of these projects for their contributions to open source software.
|
||||
{{ _("attribution.intro") }}
|
||||
</p>
|
||||
|
||||
<div class="bg-yellow-50 border-l-4 border-yellow-400 p-4 mb-6">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-5 w-5 text-yellow-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<svg class="h-5 w-5 text-yellow-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-yellow-700">
|
||||
<strong>Special Attribution:</strong> This software includes Paramiko, which is licensed under LGPL. The source code for Paramiko is available at
|
||||
<strong>{{ _("attribution.special_title") }}</strong>
|
||||
{{ _("attribution.special_source_pre") }}
|
||||
<a href="https://github.com/paramiko/paramiko" class="font-medium underline text-yellow-700 hover:text-yellow-600">
|
||||
https://github.com/paramiko/paramiko
|
||||
</a>.
|
||||
A copy of the LGPL license can be found
|
||||
</a>{{ _("attribution.special_source_post") }}
|
||||
{{ _("attribution.special_lgpl_pre") }}
|
||||
<a href="/static/licenses/lgpl.txt" class="font-medium underline text-yellow-700 hover:text-yellow-600">
|
||||
here
|
||||
</a>.
|
||||
{{ _("attribution.special_lgpl_link") }}
|
||||
</a>{{ _("attribution.special_lgpl_post") }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="text-xl font-semibold mb-2">Python Dependencies</h2>
|
||||
<h2 class="text-xl font-semibold mb-2">{{ _("attribution.section_python") }}</h2>
|
||||
<ul class="list-disc pl-5 mb-4">
|
||||
<li class="mb-2">
|
||||
<span class="font-semibold">FastAPI</span> (MIT License)<br>
|
||||
@@ -130,7 +130,7 @@
|
||||
<span class="font-semibold">Paramiko</span> (LGPL-2.1 License)<br>
|
||||
Copyright (c) 2003-2009 Robey Pointer<br>
|
||||
<a href="https://github.com/paramiko/paramiko" class="text-blue-600 hover:underline">https://github.com/paramiko/paramiko</a><br>
|
||||
<span class="text-sm text-gray-600">Note: This library is licensed under the GNU Lesser General Public License v2.1 (LGPL-2.1)</span>
|
||||
<span class="text-sm text-gray-600">{{ _("attribution.paramiko_lgpl_note") }}</span>
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<span class="font-semibold">Apprise</span> (MIT License)<br>
|
||||
@@ -139,7 +139,7 @@
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2 class="text-xl font-semibold mb-2">Docker Images</h2>
|
||||
<h2 class="text-xl font-semibold mb-2">{{ _("attribution.section_docker") }}</h2>
|
||||
<ul class="list-disc pl-5 mb-4">
|
||||
<li class="mb-2">
|
||||
<span class="font-semibold">Redis</span> (BSD License)<br>
|
||||
@@ -153,7 +153,7 @@
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2 class="text-xl font-semibold mb-2">Frontend Dependencies</h2>
|
||||
<h2 class="text-xl font-semibold mb-2">{{ _("attribution.section_frontend") }}</h2>
|
||||
<ul class="list-disc pl-5 mb-4">
|
||||
<li class="mb-2">
|
||||
<span class="font-semibold">Tailwind CSS</span> (MIT License)<br>
|
||||
|
||||
@@ -216,11 +216,31 @@
|
||||
aria-live="polite"></span>
|
||||
</a>
|
||||
|
||||
<!-- Language selector data (kept outside the HTML attribute to avoid quote conflicts with tojson) -->
|
||||
<script>
|
||||
window.__langSuggested = {{ suggested_languages | tojson }};
|
||||
window.__langAll = {{ supported_languages | tojson }};
|
||||
</script>
|
||||
|
||||
<!-- Language selector dropdown -->
|
||||
<div x-data="{ langOpen: false }" class="relative">
|
||||
<div x-data="{
|
||||
langOpen: false,
|
||||
search: '',
|
||||
suggested: window.__langSuggested,
|
||||
all: window.__langAll,
|
||||
get filtered() {
|
||||
if (!this.search.trim()) return this.suggested;
|
||||
const q = this.search.trim().toLowerCase();
|
||||
return this.all.filter(l =>
|
||||
l.native.toLowerCase().includes(q) ||
|
||||
l.name.toLowerCase().includes(q) ||
|
||||
l.code.toLowerCase().startsWith(q)
|
||||
);
|
||||
}
|
||||
}" class="relative" @keydown.escape="langOpen = false; search = ''">
|
||||
<button
|
||||
@click="langOpen = !langOpen"
|
||||
@click.outside="langOpen = false"
|
||||
@click="langOpen = !langOpen; if (langOpen) $nextTick(() => { if ($refs.langSearch) $refs.langSearch.focus(); })"
|
||||
@click.outside="langOpen = false; search = ''"
|
||||
type="button"
|
||||
class="p-1.5 rounded-md text-gray-500 hover:text-gray-700 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500"
|
||||
aria-label="{{ _('language.selector') }}"
|
||||
@@ -228,7 +248,10 @@
|
||||
:aria-expanded="langOpen"
|
||||
aria-haspopup="true"
|
||||
>
|
||||
<i class="fas fa-globe"></i>
|
||||
<span class="text-base leading-none" aria-hidden="true">
|
||||
{% set cur_lang = suggested_languages | selectattr("code", "equalto", current_locale) | list %}
|
||||
{% if cur_lang %}{{ cur_lang[0].flag }}{% else %}🌐{% endif %}
|
||||
</span>
|
||||
</button>
|
||||
<div
|
||||
x-show="langOpen"
|
||||
@@ -238,25 +261,49 @@
|
||||
x-transition:leave="transition ease-in duration-75 transform"
|
||||
x-transition:leave-start="opacity-100 scale-100"
|
||||
x-transition:leave-end="opacity-0 scale-95"
|
||||
class="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg ring-1 ring-black ring-opacity-5 z-50"
|
||||
class="absolute right-0 mt-2 w-56 bg-white dark:bg-gray-800 rounded-md shadow-lg ring-1 ring-black ring-opacity-5 z-50"
|
||||
role="menu"
|
||||
aria-label="{{ _('language.selector') }}"
|
||||
>
|
||||
<div class="py-1">
|
||||
{% for lang in supported_languages %}
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
class="flex items-center w-full px-4 py-2 text-sm text-left hover:bg-gray-100 {% if current_locale == lang.code %}bg-blue-50 text-blue-700 font-medium{% else %}text-gray-700{% endif %}"
|
||||
onclick="setLanguage('{{ lang.code }}')"
|
||||
>
|
||||
<span class="mr-2">{{ lang.flag }}</span>
|
||||
<span>{{ lang.native }}</span>
|
||||
{% if current_locale == lang.code %}
|
||||
<i class="fas fa-check ml-auto text-blue-500" aria-hidden="true"></i>
|
||||
{% endif %}
|
||||
</button>
|
||||
{% endfor %}
|
||||
<!-- Search input -->
|
||||
<div class="px-3 pt-2 pb-1">
|
||||
<div class="relative">
|
||||
<i class="fas fa-magnifying-glass absolute left-2 top-1/2 -translate-y-1/2 text-gray-400 text-xs pointer-events-none" aria-hidden="true"></i>
|
||||
<input
|
||||
x-ref="langSearch"
|
||||
x-model="search"
|
||||
type="search"
|
||||
placeholder="{{ _('language.search_placeholder') }}"
|
||||
class="w-full pl-6 pr-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-1 focus:ring-blue-400 dark:bg-gray-700 dark:text-white"
|
||||
aria-label="{{ _('language.search_placeholder') }}"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Language list -->
|
||||
<div class="py-1 max-h-56 overflow-y-auto" role="none">
|
||||
<template x-for="lang in filtered" :key="lang.code">
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
:class="lang.code === '{{ current_locale }}' ? 'bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 font-medium' : 'text-gray-700 dark:text-gray-200'"
|
||||
class="flex items-center w-full px-4 py-2 text-sm text-left hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
:onclick="`setLanguage('${lang.code}')`"
|
||||
>
|
||||
<span class="mr-2 text-base" x-text="lang.flag"></span>
|
||||
<span x-text="lang.native"></span>
|
||||
<i x-show="lang.code === '{{ current_locale }}'" class="fas fa-check ml-auto text-blue-500" aria-hidden="true"></i>
|
||||
</button>
|
||||
</template>
|
||||
<p
|
||||
x-show="filtered.length === 0"
|
||||
class="px-4 py-2 text-sm text-gray-400 dark:text-gray-500"
|
||||
>{{ _('language.no_results') }}</p>
|
||||
</div>
|
||||
<!-- Footer hint -->
|
||||
<div x-show="!search.trim()" class="px-3 py-1.5 text-xs text-gray-400 dark:text-gray-500 border-t border-gray-100 dark:border-gray-700 flex items-center justify-between">
|
||||
<span x-text="`${suggested.length} / {{ supported_languages | length }}`"></span>
|
||||
<button type="button" @click="$refs.langSearch.focus(); $refs.langSearch.select()" class="text-blue-500 hover:underline">{{ _('common.search') }}…</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -501,6 +548,23 @@
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- i18n strings for common.js (user menu) -->
|
||||
<script>
|
||||
window.__i18n = {
|
||||
accountMenu: {{ _("nav.account_menu") | tojson }},
|
||||
accountMenuFor: {{ _("nav.account_menu_for") | tojson }},
|
||||
avatar: {{ _("common.avatar") | tojson }},
|
||||
profileSettings: {{ _("nav.profile_settings") | tojson }},
|
||||
mySubscription: {{ _("nav.my_subscription") | tojson }},
|
||||
apiTokens: {{ _("nav.api_tokens") | tojson }},
|
||||
sharedLinks: {{ _("nav.shared_links") | tojson }},
|
||||
signOut: {{ _("nav.sign_out") | tojson }},
|
||||
logIn: {{ _("nav.login") | tojson }},
|
||||
signUp: {{ _("nav.signup") | tojson }},
|
||||
getStarted: {{ _("nav.get_started") | tojson }},
|
||||
};
|
||||
</script>
|
||||
|
||||
<!-- Common JS (shared) -->
|
||||
<script src="/static/js/common.js"></script>
|
||||
|
||||
|
||||
@@ -1,58 +1,58 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Cookie Policy - DocuElevate{% endblock %}
|
||||
{% block title %}{{ _("cookie_policy.page_title") }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mx-auto px-4 py-8 max-w-4xl">
|
||||
<h1 class="text-4xl font-bold mb-6">Cookie Policy</h1>
|
||||
<p class="text-sm text-gray-500 mb-8">Last Updated: {{ build_date|default(current_date|default('May 14, 2024')) }}</p>
|
||||
<h1 class="text-4xl font-bold mb-6">{{ _("cookie_policy.heading") }}</h1>
|
||||
<p class="text-sm text-gray-500 mb-8">{{ _("cookie_policy.last_updated") }} {{ build_date|default(current_date|default('May 14, 2024')) }}</p>
|
||||
|
||||
<div class="bg-white shadow rounded-lg p-6 mb-6">
|
||||
<h2 class="text-2xl font-semibold mb-4">What Are Cookies</h2>
|
||||
<h2 class="text-2xl font-semibold mb-4">{{ _("cookie_policy.s1_heading") }}</h2>
|
||||
<p class="text-gray-700 mb-3">
|
||||
Cookies are small text files that are stored on your computer or mobile device when you visit a website. They are widely used to make websites work more efficiently and provide information to the website owners.
|
||||
{{ _("cookie_policy.s1_p1") }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white shadow rounded-lg p-6 mb-6">
|
||||
<h2 class="text-2xl font-semibold mb-4">How We Use Cookies</h2>
|
||||
<h2 class="text-2xl font-semibold mb-4">{{ _("cookie_policy.s2_heading") }}</h2>
|
||||
<p class="text-gray-700 mb-3">
|
||||
DocuElevate uses <strong>only strictly necessary session cookies</strong> for the following purpose:
|
||||
{{ _("cookie_policy.s2_p1_pre") }} <strong>{{ _("cookie_policy.s2_p1_strong") }}</strong> {{ _("cookie_policy.s2_p1_post") }}
|
||||
</p>
|
||||
<ul class="list-disc list-inside text-gray-700 ml-4 mb-3">
|
||||
<li><strong>Authentication & Session Management:</strong> To identify you when you sign in and maintain your session while you use the application.</li>
|
||||
<li><strong>{{ _("cookie_policy.s2_li1_label") }}</strong> {{ _("cookie_policy.s2_li1_body") }}</li>
|
||||
</ul>
|
||||
<p class="text-gray-700 mb-3">
|
||||
These cookies are mandatory for the proper functioning of our service. Without these cookies, you would be required to log in repeatedly during your browsing session.
|
||||
{{ _("cookie_policy.s2_p2") }}
|
||||
</p>
|
||||
<p class="text-gray-700">
|
||||
Because these cookies are strictly necessary for the service to function, they are exempt from prior-consent requirements under the EU ePrivacy Directive (Art. 5(3)) and equivalent national implementations. We do not set any optional, analytics, advertising, or tracking cookies.
|
||||
{{ _("cookie_policy.s2_p3") }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white shadow rounded-lg p-6 mb-6">
|
||||
<h2 class="text-2xl font-semibold mb-4">Cookie Details</h2>
|
||||
<h2 class="text-2xl font-semibold mb-4">{{ _("cookie_policy.s3_heading") }}</h2>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full text-sm text-gray-700 border border-gray-200 rounded">
|
||||
<table class="min-w-full text-sm text-gray-700 border border-gray-200 rounded" aria-label="{{ _('cookie_policy.s3_heading') }}">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-4 py-2 text-left font-semibold border-b">Name</th>
|
||||
<th class="px-4 py-2 text-left font-semibold border-b">Type</th>
|
||||
<th class="px-4 py-2 text-left font-semibold border-b">Purpose</th>
|
||||
<th class="px-4 py-2 text-left font-semibold border-b">Duration</th>
|
||||
<th class="px-4 py-2 text-left font-semibold border-b" scope="col">{{ _("cookie_policy.s3_col_name") }}</th>
|
||||
<th class="px-4 py-2 text-left font-semibold border-b" scope="col">{{ _("cookie_policy.s3_col_type") }}</th>
|
||||
<th class="px-4 py-2 text-left font-semibold border-b" scope="col">{{ _("cookie_policy.s3_col_purpose") }}</th>
|
||||
<th class="px-4 py-2 text-left font-semibold border-b" scope="col">{{ _("cookie_policy.s3_col_duration") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr class="border-b">
|
||||
<td class="px-4 py-2 font-mono">session</td>
|
||||
<td class="px-4 py-2">Strictly Necessary</td>
|
||||
<td class="px-4 py-2">Maintains your authenticated session; required for login to function.</td>
|
||||
<td class="px-4 py-2">Session (deleted on browser close or logout)</td>
|
||||
<td class="px-4 py-2">{{ _("cookie_policy.s3_row1_type") }}</td>
|
||||
<td class="px-4 py-2">{{ _("cookie_policy.s3_row1_purpose") }}</td>
|
||||
<td class="px-4 py-2">{{ _("cookie_policy.s3_row1_duration") }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="px-4 py-2 font-mono">cookieNoticeDismissed</td>
|
||||
<td class="px-4 py-2">Strictly Necessary</td>
|
||||
<td class="px-4 py-2">Stores your acknowledgement of the cookie notice so it is not shown repeatedly (stored in localStorage, not a cookie).</td>
|
||||
<td class="px-4 py-2">Persistent (browser localStorage)</td>
|
||||
<td class="px-4 py-2">{{ _("cookie_policy.s3_row2_type") }}</td>
|
||||
<td class="px-4 py-2">{{ _("cookie_policy.s3_row2_purpose") }}</td>
|
||||
<td class="px-4 py-2">{{ _("cookie_policy.s3_row2_duration") }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -60,25 +60,26 @@
|
||||
</div>
|
||||
|
||||
<div class="bg-white shadow rounded-lg p-6 mb-6">
|
||||
<h2 class="text-2xl font-semibold mb-4">No Third-Party Cookies</h2>
|
||||
<h2 class="text-2xl font-semibold mb-4">{{ _("cookie_policy.s4_heading") }}</h2>
|
||||
<p class="text-gray-700 mb-3">
|
||||
DocuElevate does not use any third-party cookies, tracking cookies, advertising cookies, or analytics cookies. We respect your privacy and only implement the minimum cookies required for our service to function.
|
||||
{{ _("cookie_policy.s4_p1") }}
|
||||
</p>
|
||||
<p class="text-gray-700">
|
||||
For more information about how we handle your data, please see our <a href="/privacy" class="text-blue-600 hover:underline">Privacy Notice</a>.
|
||||
{{ _("cookie_policy.s4_p2_pre") }} <a href="/privacy" class="text-blue-600 hover:underline">{{ _("cookie_policy.s4_privacy_link") }}</a>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white shadow rounded-lg p-6 mb-6">
|
||||
<h2 class="text-2xl font-semibold mb-4">Managing Cookies</h2>
|
||||
<h2 class="text-2xl font-semibold mb-4">{{ _("cookie_policy.s5_heading") }}</h2>
|
||||
<p class="text-gray-700 mb-3">
|
||||
Most web browsers allow you to control cookies through their settings. However, blocking or deleting our session cookies will prevent DocuElevate from functioning, as user authentication relies on these cookies.
|
||||
{{ _("cookie_policy.s5_p1") }}
|
||||
</p>
|
||||
<p class="text-gray-700 mb-3">
|
||||
You may also clear the cookie notice acknowledgement stored in your browser's localStorage at any time via your browser's developer tools (Application → Local Storage).
|
||||
{{ _("cookie_policy.s5_p2") }}
|
||||
</p>
|
||||
<p class="text-gray-700">
|
||||
This Cookie Policy is part of and incorporated into our <a href="/terms" class="text-blue-600 hover:underline">Terms of Service</a> and <a href="/privacy" class="text-blue-600 hover:underline">Privacy Notice</a>.
|
||||
{{ _("cookie_policy.s5_p3_pre") }} <a href="/terms" class="text-blue-600 hover:underline">{{ _("cookie_policy.s5_terms_link") }}</a>
|
||||
{{ _("cookie_policy.s5_p3_and") }} <a href="/privacy" class="text-blue-600 hover:underline">{{ _("cookie_policy.s5_privacy_link") }}</a>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}File Manager – Admin{% endblock %}
|
||||
{% block title %}{{ _("admin_files.page_title") }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
@@ -8,9 +8,9 @@
|
||||
<div class="mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900 flex items-center gap-2">
|
||||
<i class="fas fa-folder-open text-blue-500"></i>
|
||||
File Manager
|
||||
<span class="text-xs font-semibold text-red-600 bg-red-50 border border-red-200 rounded px-2 py-0.5 ml-1">Admin Only</span>
|
||||
<i class="fas fa-folder-open text-blue-500" aria-hidden="true"></i>
|
||||
{{ _("admin_files.heading") }}
|
||||
<span class="text-xs font-semibold text-red-600 bg-red-50 border border-red-200 rounded px-2 py-0.5 ml-1">{{ _("admin_files.admin_only_badge") }}</span>
|
||||
</h1>
|
||||
<p class="text-gray-400 text-sm mt-1">
|
||||
<code class="bg-gray-100 rounded px-1 text-xs">{{ workdir }}</code>
|
||||
@@ -19,15 +19,15 @@
|
||||
<!-- Summary badges -->
|
||||
<div class="flex gap-3 text-sm flex-wrap">
|
||||
<span class="inline-flex items-center gap-1.5 bg-blue-50 border border-blue-200 text-blue-700 rounded-full px-3 py-1">
|
||||
<i class="fas fa-hdd text-xs"></i>
|
||||
{% if total_disk is none %}…{% else %}{{ total_disk }}{% endif %} on disk
|
||||
<i class="fas fa-hdd text-xs" aria-hidden="true"></i>
|
||||
{% if total_disk is none %}…{% else %}{{ total_disk }}{% endif %} {{ _("admin_files.badge_on_disk") }}
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1.5 bg-green-50 border border-green-200 text-green-700 rounded-full px-3 py-1">
|
||||
<i class="fas fa-database text-xs"></i> {{ total_db }} in DB
|
||||
<i class="fas fa-database text-xs" aria-hidden="true"></i> {{ total_db }} {{ _("admin_files.badge_in_db") }}
|
||||
</span>
|
||||
{% if total_disk is not none and total_disk != total_db %}
|
||||
<span class="inline-flex items-center gap-1.5 bg-amber-50 border border-amber-300 text-amber-700 rounded-full px-3 py-1 font-medium">
|
||||
<i class="fas fa-triangle-exclamation text-xs"></i> Delta detected
|
||||
<i class="fas fa-triangle-exclamation text-xs" aria-hidden="true"></i> {{ _("admin_files.badge_delta_detected") }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -38,17 +38,17 @@
|
||||
<a href="?view=filesystem"
|
||||
class="tab-link px-5 py-2.5 text-sm font-medium rounded-t border-b-2 transition
|
||||
{% if view == 'filesystem' %}border-blue-600 text-blue-600 bg-blue-50{% else %}border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300{% endif %}">
|
||||
<i class="fas fa-folder-tree mr-1.5"></i>Filesystem
|
||||
<i class="fas fa-folder-tree mr-1.5" aria-hidden="true"></i>{{ _("admin_files.tab_filesystem") }}
|
||||
</a>
|
||||
<a href="?view=database"
|
||||
class="tab-link px-5 py-2.5 text-sm font-medium rounded-t border-b-2 transition
|
||||
{% if view == 'database' %}border-green-600 text-green-600 bg-green-50{% else %}border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300{% endif %}">
|
||||
<i class="fas fa-database mr-1.5"></i>Database Records
|
||||
<i class="fas fa-database mr-1.5" aria-hidden="true"></i>{{ _("admin_files.tab_database") }}
|
||||
</a>
|
||||
<a href="?view=reconcile"
|
||||
class="tab-link px-5 py-2.5 text-sm font-medium rounded-t border-b-2 transition
|
||||
{% if view == 'reconcile' %}border-amber-500 text-amber-600 bg-amber-50{% else %}border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300{% endif %}">
|
||||
<i class="fas fa-code-compare mr-1.5"></i>Reconcile
|
||||
<i class="fas fa-code-compare mr-1.5" aria-hidden="true"></i>{{ _("admin_files.tab_reconcile") }}
|
||||
{% if total_disk != total_db %}
|
||||
<span class="ml-1 bg-amber-500 text-white rounded-full text-xs px-1.5 py-0.5">!</span>
|
||||
{% endif %}
|
||||
@@ -62,21 +62,21 @@
|
||||
|
||||
<!-- Legend -->
|
||||
<div class="flex flex-wrap gap-4 text-xs text-gray-500 mb-4">
|
||||
<span class="flex items-center gap-1"><span class="h-2.5 w-2.5 rounded-full bg-green-400 inline-block"></span> Found in DB</span>
|
||||
<span class="flex items-center gap-1"><span class="h-2.5 w-2.5 rounded-full bg-amber-400 inline-block"></span> Not in DB (orphan)</span>
|
||||
<span class="flex items-center gap-1"><span class="h-2.5 w-2.5 rounded-full bg-green-400 inline-block"></span> {{ _("admin_files.legend_found_in_db") }}</span>
|
||||
<span class="flex items-center gap-1"><span class="h-2.5 w-2.5 rounded-full bg-amber-400 inline-block"></span> {{ _("admin_files.legend_not_in_db") }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Breadcrumb -->
|
||||
<nav class="flex mb-4 text-sm" aria-label="Breadcrumb">
|
||||
<nav class="flex mb-4 text-sm" aria-label="{{ _("admin_files.aria_breadcrumb") }}">
|
||||
<ol class="inline-flex items-center space-x-1 flex-wrap">
|
||||
<li>
|
||||
<a href="?view=filesystem" class="text-blue-600 hover:underline flex items-center gap-1">
|
||||
<i class="fas fa-home"></i> workdir
|
||||
<i class="fas fa-home" aria-hidden="true"></i> {{ _("admin_files.breadcrumb_workdir") }}
|
||||
</a>
|
||||
</li>
|
||||
{% for crumb in breadcrumbs %}
|
||||
<li class="flex items-center">
|
||||
<i class="fas fa-chevron-right text-gray-300 mx-1 text-xs"></i>
|
||||
<i class="fas fa-chevron-right text-gray-300 mx-1 text-xs" aria-hidden="true"></i>
|
||||
{% if loop.last %}
|
||||
<span class="text-gray-700 font-medium">{{ crumb.name }}</span>
|
||||
{% else %}
|
||||
@@ -93,22 +93,22 @@
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left w-8"></th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-600 uppercase tracking-wider text-xs">Name</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-600 uppercase tracking-wider text-xs hidden sm:table-cell">Size</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-600 uppercase tracking-wider text-xs hidden md:table-cell">Modified</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-600 uppercase tracking-wider text-xs">DB</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-600 uppercase tracking-wider text-xs">Actions</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-600 uppercase tracking-wider text-xs">{{ _("admin_files.col_name") }}</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-600 uppercase tracking-wider text-xs hidden sm:table-cell">{{ _("admin_files.col_size") }}</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-600 uppercase tracking-wider text-xs hidden md:table-cell">{{ _("admin_files.col_modified") }}</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-600 uppercase tracking-wider text-xs">{{ _("admin_files.col_db") }}</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-600 uppercase tracking-wider text-xs">{{ _("admin_files.col_actions") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
|
||||
{% if current_path %}
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-4 py-3 text-yellow-400"><i class="fas fa-folder"></i></td>
|
||||
<td class="px-4 py-3 text-yellow-400"><i class="fas fa-folder" aria-hidden="true"></i></td>
|
||||
<td class="px-4 py-3 font-medium text-gray-900" colspan="4">
|
||||
<a href="?view=filesystem{% if parent_path %}&path={{ parent_path | urlencode }}{% endif %}"
|
||||
class="text-blue-600 hover:underline flex items-center gap-1">
|
||||
<i class="fas fa-level-up-alt text-xs"></i> ..
|
||||
<i class="fas fa-level-up-alt text-xs" aria-hidden="true"></i> ..
|
||||
</a>
|
||||
</td>
|
||||
<td></td>
|
||||
@@ -118,7 +118,7 @@
|
||||
{% if fs_entries %}
|
||||
{% for entry in fs_entries %}
|
||||
<tr class="hover:bg-gray-50 {% if entry.db_status == 'orphan' %}bg-amber-50{% endif %}">
|
||||
<td class="px-4 py-3"><i class="{{ entry.icon }}"></i></td>
|
||||
<td class="px-4 py-3"><i class="{{ entry.icon }}" aria-hidden="true"></i></td>
|
||||
<td class="px-4 py-3 font-medium text-gray-900 max-w-xs truncate">
|
||||
{% if entry.is_dir %}
|
||||
<a href="?view=filesystem&path={{ entry.rel_path | urlencode }}" class="text-blue-600 hover:underline">
|
||||
@@ -133,11 +133,11 @@
|
||||
<td class="px-4 py-3">
|
||||
{% if entry.db_status == 'in_db' %}
|
||||
<span class="inline-flex items-center gap-1 text-xs text-green-700 bg-green-50 border border-green-200 rounded-full px-2 py-0.5">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-green-500"></span> In DB
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-green-500"></span> {{ _("admin_files.status_in_db") }}
|
||||
</span>
|
||||
{% elif entry.db_status == 'orphan' %}
|
||||
<span class="inline-flex items-center gap-1 text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded-full px-2 py-0.5">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-amber-500"></span> Orphan
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-amber-500"></span> {{ _("admin_files.status_orphan") }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
@@ -145,7 +145,7 @@
|
||||
{% if not entry.is_dir %}
|
||||
<a href="/admin/files/download?path={{ entry.rel_path | urlencode }}"
|
||||
class="inline-flex items-center gap-1 px-2.5 py-1 border border-gray-200 text-xs font-medium rounded text-gray-600 bg-white hover:bg-gray-50">
|
||||
<i class="fas fa-download text-xs"></i> Download
|
||||
<i class="fas fa-download text-xs" aria-hidden="true"></i> {{ _("admin_files.btn_download") }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
@@ -154,8 +154,8 @@
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="6" class="px-6 py-10 text-center text-sm text-gray-400">
|
||||
<i class="fas fa-folder-open text-gray-200 text-3xl mb-2 block"></i>
|
||||
This directory is empty.
|
||||
<i class="fas fa-folder-open text-gray-200 text-3xl mb-2 block" aria-hidden="true"></i>
|
||||
{{ _("admin_files.empty_directory") }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
@@ -171,9 +171,9 @@
|
||||
|
||||
<!-- Legend -->
|
||||
<div class="flex flex-wrap gap-4 text-xs text-gray-500 mb-4">
|
||||
<span class="flex items-center gap-1"><span class="h-2.5 w-2.5 rounded-full bg-green-400 inline-block"></span> File exists on disk</span>
|
||||
<span class="flex items-center gap-1"><span class="h-2.5 w-2.5 rounded-full bg-red-400 inline-block"></span> File missing from disk</span>
|
||||
<span class="flex items-center gap-1"><span class="h-2.5 w-2.5 rounded-full bg-gray-300 inline-block"></span> Path not set</span>
|
||||
<span class="flex items-center gap-1"><span class="h-2.5 w-2.5 rounded-full bg-green-400 inline-block"></span> {{ _("admin_files.legend_file_exists") }}</span>
|
||||
<span class="flex items-center gap-1"><span class="h-2.5 w-2.5 rounded-full bg-red-400 inline-block"></span> {{ _("admin_files.legend_file_missing") }}</span>
|
||||
<span class="flex items-center gap-1"><span class="h-2.5 w-2.5 rounded-full bg-gray-300 inline-block"></span> {{ _("admin_files.legend_path_not_set") }}</span>
|
||||
</div>
|
||||
|
||||
{% if db_records %}
|
||||
@@ -181,14 +181,14 @@
|
||||
<table class="min-w-full divide-y divide-gray-200 text-sm">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs">ID</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs">Original Filename</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs hidden md:table-cell">Size</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs hidden lg:table-cell">Ingested</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs">local_filename</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs">original_file_path</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs">processed_file_path</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs">Health</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs">{{ _("admin_files.col_id") }}</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs">{{ _("admin_files.col_original_filename") }}</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs hidden md:table-cell">{{ _("admin_files.col_size") }}</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs hidden lg:table-cell">{{ _("admin_files.col_ingested") }}</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs">{{ _("admin_files.col_local_filename") }}</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs">{{ _("admin_files.col_original_file_path") }}</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs">{{ _("admin_files.col_processed_file_path") }}</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs">{{ _("admin_files.col_health") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
@@ -197,7 +197,7 @@
|
||||
<td class="px-4 py-3 text-gray-400 font-mono text-xs">{{ rec.id }}</td>
|
||||
<td class="px-4 py-3 font-medium text-gray-800 max-w-xs truncate" title="{{ rec.original_filename }}">
|
||||
{% if rec.is_duplicate %}
|
||||
<span class="text-xs text-purple-600 bg-purple-50 border border-purple-200 rounded px-1 mr-1">dup</span>
|
||||
<span class="text-xs text-purple-600 bg-purple-50 border border-purple-200 rounded px-1 mr-1">{{ _("admin_files.badge_duplicate") }}</span>
|
||||
{% endif %}
|
||||
{{ rec.original_filename }}
|
||||
</td>
|
||||
@@ -226,11 +226,11 @@
|
||||
<td class="px-4 py-3">
|
||||
{% if rec.health == 'ok' %}
|
||||
<span class="inline-flex items-center gap-1 text-xs text-green-700 bg-green-50 border border-green-200 rounded-full px-2 py-0.5">
|
||||
<i class="fas fa-check text-xs"></i> OK
|
||||
<i class="fas fa-check text-xs" aria-hidden="true"></i> {{ _("admin_files.health_ok") }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="inline-flex items-center gap-1 text-xs text-red-700 bg-red-50 border border-red-200 rounded-full px-2 py-0.5">
|
||||
<i class="fas fa-triangle-exclamation text-xs"></i> Missing
|
||||
<i class="fas fa-triangle-exclamation text-xs" aria-hidden="true"></i> {{ _("admin_files.health_missing") }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
@@ -241,8 +241,8 @@
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="bg-white rounded-lg shadow p-10 text-center text-gray-400">
|
||||
<i class="fas fa-database text-4xl mb-3 block text-gray-200"></i>
|
||||
No file records found in the database.
|
||||
<i class="fas fa-database text-4xl mb-3 block text-gray-200" aria-hidden="true"></i>
|
||||
{{ _("admin_files.empty_database") }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
@@ -254,17 +254,16 @@
|
||||
|
||||
{% if orphan_files or ghost_records %}
|
||||
<div class="bg-amber-50 border border-amber-300 rounded-lg px-5 py-4 mb-6 flex items-start gap-3">
|
||||
<i class="fas fa-triangle-exclamation text-amber-500 mt-0.5"></i>
|
||||
<i class="fas fa-triangle-exclamation text-amber-500 mt-0.5" aria-hidden="true"></i>
|
||||
<div class="text-sm text-amber-800">
|
||||
<strong>Delta detected.</strong>
|
||||
Found <strong>{{ orphan_files | length }}</strong> orphan file(s) on disk with no DB record,
|
||||
and <strong>{{ ghost_records | length }}</strong> DB record(s) with missing files on disk.
|
||||
<strong>{{ _("admin_files.delta_detected_title") }}</strong>
|
||||
{{ _("admin_files.delta_detected_detail", orphan_count=(orphan_files | length), ghost_count=(ghost_records | length)) }}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="bg-green-50 border border-green-300 rounded-lg px-5 py-4 mb-6 flex items-center gap-3">
|
||||
<i class="fas fa-circle-check text-green-500"></i>
|
||||
<span class="text-sm text-green-800 font-medium">No delta found — filesystem and database are in sync.</span>
|
||||
<i class="fas fa-circle-check text-green-500" aria-hidden="true"></i>
|
||||
<span class="text-sm text-green-800 font-medium">{{ _("admin_files.no_delta") }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -272,7 +271,7 @@
|
||||
<div class="mb-8">
|
||||
<h2 class="text-base font-semibold text-gray-800 mb-3 flex items-center gap-2">
|
||||
<span class="h-3 w-3 rounded-full bg-amber-400 inline-block"></span>
|
||||
Orphan files <span class="text-gray-400 font-normal text-sm ml-1">(on disk, no DB record)</span>
|
||||
{{ _("admin_files.orphan_files_heading") }} <span class="text-gray-400 font-normal text-sm ml-1">{{ _("admin_files.orphan_files_desc") }}</span>
|
||||
<span class="text-xs bg-amber-100 text-amber-700 border border-amber-200 rounded-full px-2 py-0.5 ml-auto">{{ orphan_files | length }}</span>
|
||||
</h2>
|
||||
{% if orphan_files %}
|
||||
@@ -281,23 +280,23 @@
|
||||
<thead class="bg-amber-50">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left w-8"></th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs">Path (relative to workdir)</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs hidden sm:table-cell">Size</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs hidden md:table-cell">Modified</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs">Actions</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs">{{ _("admin_files.col_path_relative") }}</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs hidden sm:table-cell">{{ _("admin_files.col_size") }}</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs hidden md:table-cell">{{ _("admin_files.col_modified") }}</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs">{{ _("admin_files.col_actions") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
{% for f in orphan_files %}
|
||||
<tr class="hover:bg-amber-50">
|
||||
<td class="px-4 py-3"><i class="{{ f.icon }}"></i></td>
|
||||
<td class="px-4 py-3"><i class="{{ f.icon }}" aria-hidden="true"></i></td>
|
||||
<td class="px-4 py-3 font-mono text-xs text-amber-800 max-w-xs truncate" title="{{ f.rel_path }}">{{ f.rel_path }}</td>
|
||||
<td class="px-4 py-3 text-gray-500 hidden sm:table-cell">{{ f.size }}</td>
|
||||
<td class="px-4 py-3 text-gray-400 text-xs hidden md:table-cell">{{ f.modified }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<a href="/admin/files/download?path={{ f.rel_path | urlencode }}"
|
||||
class="inline-flex items-center gap-1 px-2.5 py-1 border border-gray-200 text-xs font-medium rounded text-gray-600 bg-white hover:bg-gray-50">
|
||||
<i class="fas fa-download text-xs"></i> Download
|
||||
<i class="fas fa-download text-xs" aria-hidden="true"></i> {{ _("admin_files.btn_download") }}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -306,7 +305,7 @@
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-sm text-gray-400 italic">No orphan files found.</p>
|
||||
<p class="text-sm text-gray-400 italic">{{ _("admin_files.no_orphan_files") }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -314,7 +313,7 @@
|
||||
<div>
|
||||
<h2 class="text-base font-semibold text-gray-800 mb-3 flex items-center gap-2">
|
||||
<span class="h-3 w-3 rounded-full bg-red-400 inline-block"></span>
|
||||
Ghost records <span class="text-gray-400 font-normal text-sm ml-1">(in DB, file(s) missing on disk)</span>
|
||||
{{ _("admin_files.ghost_records_heading") }} <span class="text-gray-400 font-normal text-sm ml-1">{{ _("admin_files.ghost_records_desc") }}</span>
|
||||
<span class="text-xs bg-red-100 text-red-700 border border-red-200 rounded-full px-2 py-0.5 ml-auto">{{ ghost_records | length }}</span>
|
||||
</h2>
|
||||
{% if ghost_records %}
|
||||
@@ -322,10 +321,10 @@
|
||||
<table class="min-w-full divide-y divide-gray-200 text-sm">
|
||||
<thead class="bg-red-50">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs">ID</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs">Original Filename</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs hidden md:table-cell">Ingested</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs">Missing paths</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs">{{ _("admin_files.col_id") }}</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs">{{ _("admin_files.col_original_filename") }}</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs hidden md:table-cell">{{ _("admin_files.col_ingested") }}</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wider text-xs">{{ _("admin_files.col_missing_paths") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
@@ -352,7 +351,7 @@
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-sm text-gray-400 italic">No ghost records found.</p>
|
||||
<p class="text-sm text-gray-400 italic">{{ _("admin_files.no_ghost_records") }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Imprint - DocuElevate{% endblock %}
|
||||
{% block title %}{{ _("imprint.page_title") }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mx-auto px-4 py-8 max-w-4xl">
|
||||
<h1 class="text-4xl font-bold mb-6">Imprint</h1>
|
||||
<p class="text-sm text-gray-500 mb-8">Information according to § 5 TMG (German Telemedia Act)</p>
|
||||
<h1 class="text-4xl font-bold mb-6">{{ _("imprint.heading") }}</h1>
|
||||
<p class="text-sm text-gray-500 mb-8">{{ _("imprint.subtitle") }}</p>
|
||||
|
||||
<div class="bg-white shadow rounded-lg p-6 mb-6">
|
||||
<h2 class="text-2xl font-semibold mb-4">Service Provider</h2>
|
||||
<h2 class="text-2xl font-semibold mb-4">{{ _("imprint.provider_heading") }}</h2>
|
||||
<p class="text-gray-700 mb-3">
|
||||
<strong>Christian Louis IT Beratung</strong><br>
|
||||
Alter Steinweg 3<br>
|
||||
@@ -17,7 +17,7 @@
|
||||
</div>
|
||||
|
||||
<div class="bg-white shadow rounded-lg p-6 mb-6">
|
||||
<h2 class="text-2xl font-semibold mb-4">Contact Information</h2>
|
||||
<h2 class="text-2xl font-semibold mb-4">{{ _("imprint.contact_heading") }}</h2>
|
||||
<p class="text-gray-700 mb-3">
|
||||
Fax: +49 40 97074609<br>
|
||||
Email: christianlouis@gmail.com<br>
|
||||
@@ -26,16 +26,16 @@
|
||||
</div>
|
||||
|
||||
<div class="bg-white shadow rounded-lg p-6 mb-6">
|
||||
<h2 class="text-2xl font-semibold mb-4">Business Registration</h2>
|
||||
<h2 class="text-2xl font-semibold mb-4">{{ _("imprint.business_registration_heading") }}</h2>
|
||||
<p class="text-gray-700 mb-3">
|
||||
VAT Identification Number according to §27a Value Added Tax Act: DE202899017
|
||||
{{ _("imprint.business_registration_vat") }} DE202899017
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white shadow rounded-lg p-6 mb-6">
|
||||
<h2 class="text-2xl font-semibold mb-4">Responsible for Content</h2>
|
||||
<h2 class="text-2xl font-semibold mb-4">{{ _("imprint.responsible_content_heading") }}</h2>
|
||||
<p class="text-gray-700 mb-3">
|
||||
According to § 55 Abs. 2 RStV:<br>
|
||||
{{ _("imprint.responsible_content_rstv") }}<br>
|
||||
Christian Krakau-Louis<br>
|
||||
Alter Steinweg 3<br>
|
||||
20459 Hamburg<br>
|
||||
@@ -44,36 +44,36 @@
|
||||
</div>
|
||||
|
||||
<div class="bg-white shadow rounded-lg p-6 mb-6">
|
||||
<h2 class="text-2xl font-semibold mb-4">Legal Notices</h2>
|
||||
<h2 class="text-2xl font-semibold mb-4">{{ _("imprint.legal_heading") }}</h2>
|
||||
<p class="text-gray-700 mb-3">
|
||||
Despite careful content control, we assume no liability for the content of external links. The operators of the linked pages are solely responsible for their content.
|
||||
{{ _("imprint.legal_liability") }}
|
||||
</p>
|
||||
<p class="text-gray-700 mb-3">
|
||||
All content on this website is protected by copyright. Any use outside the limits of copyright law requires the written consent of the respective author or creator.
|
||||
{{ _("imprint.legal_copyright") }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white shadow rounded-lg p-6 mb-6">
|
||||
<h2 class="text-2xl font-semibold mb-4">Online Dispute Resolution</h2>
|
||||
<h2 class="text-2xl font-semibold mb-4">{{ _("imprint.dispute_heading") }}</h2>
|
||||
<p class="text-gray-700 mb-3">
|
||||
The European Commission provides a platform for online dispute resolution (OS):
|
||||
{{ _("imprint.dispute_p1") }}
|
||||
<a href="https://ec.europa.eu/consumers/odr/" target="_blank" rel="noopener noreferrer" class="text-blue-600 hover:underline">https://ec.europa.eu/consumers/odr/</a>
|
||||
</p>
|
||||
<p class="text-gray-700 mb-3">
|
||||
We are not willing or obligated to participate in dispute resolution proceedings before a consumer arbitration board.
|
||||
{{ _("imprint.dispute_p2") }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white shadow rounded-lg p-6">
|
||||
<h2 class="text-2xl font-semibold mb-4">Related Policies</h2>
|
||||
<h2 class="text-2xl font-semibold mb-4">{{ _("imprint.policies_heading") }}</h2>
|
||||
<p class="text-gray-700 mb-3">
|
||||
Our service is governed by the following policies:
|
||||
{{ _("imprint.policies_intro") }}
|
||||
</p>
|
||||
<ul class="list-disc list-inside text-gray-700 ml-4 mb-3">
|
||||
<li><a href="/terms" class="text-blue-600 hover:underline">Terms of Service</a> - Rules for using DocuElevate</li>
|
||||
<li><a href="/privacy" class="text-blue-600 hover:underline">Privacy Policy</a> - How we handle your data</li>
|
||||
<li><a href="/cookies" class="text-blue-600 hover:underline">Cookie Policy</a> - Information about cookies we use</li>
|
||||
<li><a href="/license" class="text-blue-600 hover:underline">License Information</a> - How our software is licensed</li>
|
||||
<li><a href="/terms" class="text-blue-600 hover:underline">{{ _("imprint.policies_terms_label") }}</a> - {{ _("imprint.policies_terms_desc") }}</li>
|
||||
<li><a href="/privacy" class="text-blue-600 hover:underline">{{ _("imprint.policies_privacy_label") }}</a> - {{ _("imprint.policies_privacy_desc") }}</li>
|
||||
<li><a href="/cookies" class="text-blue-600 hover:underline">{{ _("imprint.policies_cookie_label") }}</a> - {{ _("imprint.policies_cookie_desc") }}</li>
|
||||
<li><a href="/license" class="text-blue-600 hover:underline">{{ _("imprint.policies_license_label") }}</a> - {{ _("imprint.policies_license_desc") }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Integrations – DocuElevate{% endblock %}
|
||||
{% block title %}{{ _("integrations.page_title") }} – DocuElevate{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div
|
||||
@@ -13,10 +13,10 @@
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<i class="fas fa-plug text-indigo-500" aria-hidden="true"></i>
|
||||
Integrations
|
||||
{{ _("integrations.page_title") }}
|
||||
</h1>
|
||||
<p class="text-gray-500 dark:text-gray-400 text-sm mt-1">
|
||||
Manage your ingestion sources and storage destinations in one place.
|
||||
{{ _("integrations.subtitle") }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -27,7 +27,7 @@
|
||||
style="min-height:44px;"
|
||||
:aria-disabled="!canAddAny"
|
||||
>
|
||||
<i class="fas fa-plus mr-2" aria-hidden="true"></i> Add Integration
|
||||
<i class="fas fa-plus mr-2" aria-hidden="true"></i> {{ _("integrations.add_button") }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -41,20 +41,20 @@
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-gray-700 dark:text-gray-200">
|
||||
Mailbox Sources:
|
||||
{{ _("integrations.sources_quota_label") }}
|
||||
<strong x-text="quota.src_count"></strong>
|
||||
<template x-if="quota.max_sources !== null && quota.max_sources > 0">
|
||||
<span> / <strong x-text="quota.max_sources"></strong></span>
|
||||
</template>
|
||||
<template x-if="quota.max_sources === null">
|
||||
<span class="text-gray-400"> / unlimited</span>
|
||||
<span class="text-gray-400"> {{ _("integrations.quota_unlimited") }}</span>
|
||||
</template>
|
||||
<template x-if="quota.max_sources === 0">
|
||||
<span class="text-gray-400 ml-1">(not available)</span>
|
||||
<span class="text-gray-400 ml-1">{{ _("integrations.quota_not_available") }}</span>
|
||||
</template>
|
||||
</p>
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
|
||||
Plan: <span x-text="quota.tier_name"></span>
|
||||
{{ _("integrations.plan_label") }} <span x-text="quota.tier_name"></span>
|
||||
</p>
|
||||
</div>
|
||||
<!-- Mini usage bar -->
|
||||
@@ -77,7 +77,7 @@
|
||||
</div>
|
||||
<template x-if="quota.max_sources !== null && quota.src_count >= quota.max_sources && quota.max_sources >= 0">
|
||||
<a href="/subscription" class="inline-flex items-center text-xs text-indigo-600 hover:text-indigo-800 mt-2">
|
||||
<i class="fas fa-arrow-up mr-1" aria-hidden="true"></i> Upgrade Plan
|
||||
<i class="fas fa-arrow-up mr-1" aria-hidden="true"></i> {{ _("integrations.upgrade_plan") }}
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
@@ -90,17 +90,17 @@
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-gray-700 dark:text-gray-200">
|
||||
Storage Destinations:
|
||||
{{ _("integrations.destinations_quota_label") }}
|
||||
<strong x-text="quota.dest_count"></strong>
|
||||
<template x-if="quota.max_destinations !== null">
|
||||
<span> / <strong x-text="quota.max_destinations"></strong></span>
|
||||
</template>
|
||||
<template x-if="quota.max_destinations === null">
|
||||
<span class="text-gray-400"> / unlimited</span>
|
||||
<span class="text-gray-400"> {{ _("integrations.quota_unlimited") }}</span>
|
||||
</template>
|
||||
</p>
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
|
||||
Plan: <span x-text="quota.tier_name"></span>
|
||||
{{ _("integrations.plan_label") }} <span x-text="quota.tier_name"></span>
|
||||
</p>
|
||||
</div>
|
||||
<!-- Mini usage bar -->
|
||||
@@ -123,7 +123,7 @@
|
||||
</div>
|
||||
<template x-if="quota.max_destinations !== null && quota.dest_count >= quota.max_destinations">
|
||||
<a href="/subscription" class="inline-flex items-center text-xs text-indigo-600 hover:text-indigo-800 mt-2">
|
||||
<i class="fas fa-arrow-up mr-1" aria-hidden="true"></i> Upgrade Plan
|
||||
<i class="fas fa-arrow-up mr-1" aria-hidden="true"></i> {{ _("integrations.upgrade_plan") }}
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
@@ -151,7 +151,7 @@
|
||||
<template x-if="loading">
|
||||
<div class="text-center py-12 text-gray-400">
|
||||
<i class="fas fa-spinner fa-spin text-3xl mb-3" aria-hidden="true"></i>
|
||||
<p>Loading integrations…</p>
|
||||
<p>{{ _("integrations.loading") }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -159,9 +159,9 @@
|
||||
<template x-if="!loading && integrations.length === 0">
|
||||
<div class="text-center py-16 bg-white dark:bg-gray-800 rounded-lg shadow">
|
||||
<i class="fas fa-plug text-5xl text-gray-300 mb-4" aria-hidden="true"></i>
|
||||
<h2 class="text-xl font-semibold text-gray-700 dark:text-gray-300 mb-2">No integrations configured</h2>
|
||||
<h2 class="text-xl font-semibold text-gray-700 dark:text-gray-300 mb-2">{{ _("integrations.empty_state") }}</h2>
|
||||
<p class="text-gray-500 dark:text-gray-400 mb-6 max-w-md mx-auto">
|
||||
Add your first integration to connect ingestion sources (like IMAP) and storage destinations (like S3, Dropbox, or Google Drive).
|
||||
{{ _("integrations.no_integrations_body") }}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
@@ -170,7 +170,7 @@
|
||||
class="inline-flex items-center px-5 py-2.5 bg-indigo-600 hover:bg-indigo-700 disabled:bg-gray-300 disabled:cursor-not-allowed text-white text-sm font-medium rounded-md"
|
||||
style="min-height:44px;"
|
||||
>
|
||||
<i class="fas fa-plus mr-2" aria-hidden="true"></i> Add Your First Integration
|
||||
<i class="fas fa-plus mr-2" aria-hidden="true"></i> {{ _("integrations.add_first_button") }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -179,7 +179,7 @@
|
||||
<template x-if="!loading && sources.length > 0">
|
||||
<section class="mb-8">
|
||||
<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-3 flex items-center gap-2">
|
||||
<i class="fas fa-inbox text-blue-500" aria-hidden="true"></i> Sources
|
||||
<i class="fas fa-inbox text-blue-500" aria-hidden="true"></i> {{ _("integrations.sources_section") }}
|
||||
</h2>
|
||||
<div class="space-y-3">
|
||||
<template x-for="intg in sources" :key="intg.id">
|
||||
@@ -196,7 +196,7 @@
|
||||
<span
|
||||
class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
|
||||
:class="intg.is_active ? 'bg-green-50 text-green-700 dark:bg-green-900 dark:text-green-300' : 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400'"
|
||||
x-text="intg.is_active ? 'Active' : 'Paused'"
|
||||
x-text="intg.is_active ? '{{ _('common.active') }}' : '{{ _('integrations.paused') }}'"
|
||||
></span>
|
||||
</p>
|
||||
<template x-if="intg.last_error">
|
||||
@@ -216,7 +216,7 @@
|
||||
:aria-label="`${intg.has_credentials ? 'Re-authorize' : 'Authorize'} ${intg.name}`"
|
||||
>
|
||||
<i class="fas fa-key mr-1" aria-hidden="true"></i>
|
||||
<span x-text="intg.has_credentials ? 'Re-Authorize' : 'Authorize'"></span>
|
||||
<span x-text="intg.has_credentials ? '{{ _('integrations.authorize_reauth') }}' : '{{ _('integrations.authorize_btn') }}'"></span>
|
||||
</a>
|
||||
</template>
|
||||
<button
|
||||
@@ -233,7 +233,7 @@
|
||||
<template x-if="testingId !== intg.id">
|
||||
<i class="fas fa-plug mr-1" aria-hidden="true"></i>
|
||||
</template>
|
||||
Test
|
||||
{{ _("common.test") }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -242,7 +242,7 @@
|
||||
style="min-height:36px;"
|
||||
:aria-label="`Edit ${intg.name}`"
|
||||
>
|
||||
<i class="fas fa-edit mr-1" aria-hidden="true"></i>Edit
|
||||
<i class="fas fa-edit mr-1" aria-hidden="true"></i>{{ _("common.edit") }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -251,7 +251,7 @@
|
||||
style="min-height:36px;"
|
||||
:aria-label="`Delete ${intg.name}`"
|
||||
>
|
||||
<i class="fas fa-trash-alt mr-1" aria-hidden="true"></i>Delete
|
||||
<i class="fas fa-trash-alt mr-1" aria-hidden="true"></i>{{ _("common.delete") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -265,7 +265,7 @@
|
||||
<template x-if="!loading && destinations.length > 0">
|
||||
<section class="mb-8">
|
||||
<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-3 flex items-center gap-2">
|
||||
<i class="fas fa-hdd text-green-500" aria-hidden="true"></i> Destinations
|
||||
<i class="fas fa-hdd text-green-500" aria-hidden="true"></i> {{ _("integrations.destinations_section") }}
|
||||
</h2>
|
||||
<div class="space-y-3">
|
||||
<template x-for="intg in destinations" :key="intg.id">
|
||||
@@ -282,7 +282,7 @@
|
||||
<span
|
||||
class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
|
||||
:class="intg.is_active ? 'bg-green-50 text-green-700 dark:bg-green-900 dark:text-green-300' : 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400'"
|
||||
x-text="intg.is_active ? 'Active' : 'Paused'"
|
||||
x-text="intg.is_active ? '{{ _('common.active') }}' : '{{ _('integrations.paused') }}'"
|
||||
></span>
|
||||
</p>
|
||||
<template x-if="intg.last_error">
|
||||
@@ -302,7 +302,7 @@
|
||||
:aria-label="`${intg.has_credentials ? 'Re-authorize' : 'Authorize'} ${intg.name}`"
|
||||
>
|
||||
<i class="fas fa-key mr-1" aria-hidden="true"></i>
|
||||
<span x-text="intg.has_credentials ? 'Re-Authorize' : 'Authorize'"></span>
|
||||
<span x-text="intg.has_credentials ? '{{ _('integrations.authorize_reauth') }}' : '{{ _('integrations.authorize_btn') }}'"></span>
|
||||
</a>
|
||||
</template>
|
||||
<button
|
||||
@@ -319,7 +319,7 @@
|
||||
<template x-if="testingId !== intg.id">
|
||||
<i class="fas fa-plug mr-1" aria-hidden="true"></i>
|
||||
</template>
|
||||
Test
|
||||
{{ _("common.test") }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -328,7 +328,7 @@
|
||||
style="min-height:36px;"
|
||||
:aria-label="`Edit ${intg.name}`"
|
||||
>
|
||||
<i class="fas fa-edit mr-1" aria-hidden="true"></i>Edit
|
||||
<i class="fas fa-edit mr-1" aria-hidden="true"></i>{{ _("common.edit") }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -337,7 +337,7 @@
|
||||
style="min-height:36px;"
|
||||
:aria-label="`Delete ${intg.name}`"
|
||||
>
|
||||
<i class="fas fa-trash-alt mr-1" aria-hidden="true"></i>Delete
|
||||
<i class="fas fa-trash-alt mr-1" aria-hidden="true"></i>{{ _("common.delete") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -371,13 +371,13 @@
|
||||
<h2
|
||||
:id="editingIntegration ? 'modal-title-edit' : 'modal-title-create'"
|
||||
class="text-lg font-semibold text-gray-900 dark:text-white"
|
||||
x-text="editingIntegration ? 'Edit Integration' : 'Add Integration'"
|
||||
x-text="editingIntegration ? '{{ _('integrations.modal_title_edit') }}' : '{{ _('integrations.modal_title_add') }}'"
|
||||
></h2>
|
||||
<button
|
||||
type="button"
|
||||
@click="closeModal()"
|
||||
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 focus:outline-none focus:ring-2 focus:ring-indigo-500 rounded"
|
||||
aria-label="Close modal"
|
||||
aria-label="{{ _('integrations.modal_close') }}"
|
||||
>
|
||||
<i class="fas fa-times text-xl" aria-hidden="true"></i>
|
||||
</button>
|
||||
@@ -388,7 +388,7 @@
|
||||
<!-- Direction -->
|
||||
<div>
|
||||
<label for="intg-direction" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">
|
||||
Direction <span class="text-red-500" aria-hidden="true">*</span>
|
||||
{{ _("integrations.direction_label") }} <span class="text-red-500" aria-hidden="true">*</span>
|
||||
</label>
|
||||
<select
|
||||
id="intg-direction"
|
||||
@@ -399,16 +399,16 @@
|
||||
aria-required="true"
|
||||
@change="onDirectionChange()"
|
||||
>
|
||||
<option value="">— Select —</option>
|
||||
<option value="SOURCE">Source (ingestion)</option>
|
||||
<option value="DESTINATION">Destination (storage)</option>
|
||||
<option value="">{{ _("integrations.direction_placeholder") }}</option>
|
||||
<option value="SOURCE">{{ _("integrations.direction_source") }}</option>
|
||||
<option value="DESTINATION">{{ _("integrations.direction_destination") }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Integration type -->
|
||||
<div>
|
||||
<label for="intg-type" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">
|
||||
Integration Type <span class="text-red-500" aria-hidden="true">*</span>
|
||||
{{ _("integrations.type_label") }} <span class="text-red-500" aria-hidden="true">*</span>
|
||||
</label>
|
||||
<select
|
||||
id="intg-type"
|
||||
@@ -419,7 +419,7 @@
|
||||
aria-required="true"
|
||||
@change="onTypeChange()"
|
||||
>
|
||||
<option value="">— Select direction first —</option>
|
||||
<option value="">{{ _("integrations.type_placeholder") }}</option>
|
||||
<template x-for="t in availableTypes" :key="t">
|
||||
<option :value="t" x-text="typeLabel(t)"></option>
|
||||
</template>
|
||||
@@ -429,13 +429,13 @@
|
||||
<!-- Name -->
|
||||
<div>
|
||||
<label for="intg-name" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">
|
||||
Label <span class="text-red-500" aria-hidden="true">*</span>
|
||||
{{ _("integrations.name_label") }} <span class="text-red-500" aria-hidden="true">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="intg-name"
|
||||
type="text"
|
||||
x-model="form.name"
|
||||
placeholder="e.g. Work Gmail, S3 Archive"
|
||||
placeholder="{{ _('integrations.name_placeholder') }}"
|
||||
required
|
||||
maxlength="255"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm"
|
||||
@@ -448,46 +448,46 @@
|
||||
<!-- IMAP fields -->
|
||||
<template x-if="form.integration_type === 'IMAP'">
|
||||
<div class="space-y-3 border-t border-gray-200 dark:border-gray-700 pt-3">
|
||||
<p class="text-xs font-semibold text-gray-400 uppercase tracking-wider">IMAP Settings</p>
|
||||
<p class="text-xs font-semibold text-gray-400 uppercase tracking-wider">{{ _("integrations.imap_settings") }}</p>
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<div class="col-span-2">
|
||||
<label for="imap-host" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Host <span class="text-red-500" aria-hidden="true">*</span></label>
|
||||
<label for="imap-host" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.host_label") }} <span class="text-red-500" aria-hidden="true">*</span></label>
|
||||
<input id="imap-host" type="text" x-model="form.config.host" placeholder="imap.example.com" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" aria-required="true" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="imap-port" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Port</label>
|
||||
<label for="imap-port" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.port_label") }}</label>
|
||||
<input id="imap-port" type="number" x-model.number="form.config.port" min="1" max="65535" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="imap-username" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Username <span class="text-red-500" aria-hidden="true">*</span></label>
|
||||
<label for="imap-username" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.username_label") }} <span class="text-red-500" aria-hidden="true">*</span></label>
|
||||
<input id="imap-username" type="text" x-model="form.config.username" placeholder="user@example.com" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" aria-required="true" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="imap-password" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Password <span class="text-red-500" aria-hidden="true">*</span></label>
|
||||
<label for="imap-password" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.password_label") }} <span class="text-red-500" aria-hidden="true">*</span></label>
|
||||
<input id="imap-password" :type="showPassword ? 'text' : 'password'" x-model="form.credentials.password" :placeholder="editingIntegration ? '(unchanged if blank)' : ''" :required="!editingIntegration" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" />
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-200 cursor-pointer">
|
||||
<input type="checkbox" x-model="form.config.use_ssl" class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" />
|
||||
Use SSL
|
||||
{{ _("integrations.use_ssl") }}
|
||||
</label>
|
||||
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-200 cursor-pointer">
|
||||
<input type="checkbox" x-model="form.config.delete_after_process" class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" />
|
||||
Delete emails after processing
|
||||
{{ _("integrations.delete_emails_label") }}
|
||||
</label>
|
||||
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-200 cursor-pointer">
|
||||
<input type="checkbox" x-model="form.config.gmail_apply_labels" class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" />
|
||||
Apply Gmail labels & star
|
||||
{{ _("integrations.gmail_labels") }}
|
||||
</label>
|
||||
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-200 cursor-pointer">
|
||||
<input type="checkbox" x-model="showPassword" class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" />
|
||||
Show password
|
||||
{{ _("integrations.show_password") }}
|
||||
</label>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
<i class="fas fa-info-circle mr-1" aria-hidden="true"></i>
|
||||
Gmail labels & star: when enabled, processed emails are starred and tagged with an "Ingested" label in Gmail. Only applies to Gmail servers.
|
||||
{{ _("integrations.gmail_hint") }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -495,36 +495,36 @@
|
||||
<!-- S3 fields -->
|
||||
<template x-if="form.integration_type === 'S3'">
|
||||
<div class="space-y-3 border-t border-gray-200 dark:border-gray-700 pt-3">
|
||||
<p class="text-xs font-semibold text-gray-400 uppercase tracking-wider">S3 Settings</p>
|
||||
<p class="text-xs font-semibold text-gray-400 uppercase tracking-wider">{{ _("integrations.s3_settings") }}</p>
|
||||
<div>
|
||||
<label for="s3-bucket" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Bucket <span class="text-red-500" aria-hidden="true">*</span></label>
|
||||
<label for="s3-bucket" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.bucket_label") }} <span class="text-red-500" aria-hidden="true">*</span></label>
|
||||
<input id="s3-bucket" type="text" x-model="form.config.bucket" placeholder="my-bucket" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" aria-required="true" />
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label for="s3-region" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Region</label>
|
||||
<label for="s3-region" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.region_label") }}</label>
|
||||
<input id="s3-region" type="text" x-model="form.config.region" placeholder="us-east-1" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="s3-prefix" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Folder Prefix</label>
|
||||
<label for="s3-prefix" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.folder_prefix_label") }}</label>
|
||||
<input id="s3-prefix" type="text" x-model="form.config.folder_prefix" placeholder="documents/" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="s3-endpoint" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Endpoint URL <span class="text-gray-400">(optional, for S3-compatible)</span></label>
|
||||
<label for="s3-endpoint" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.endpoint_label") }} <span class="text-gray-400">{{ _("integrations.endpoint_optional") }}</span></label>
|
||||
<input id="s3-endpoint" type="url" x-model="form.config.endpoint_url" placeholder="https://s3.example.com" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="s3-key" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Access Key ID <span class="text-red-500" aria-hidden="true">*</span></label>
|
||||
<label for="s3-key" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.access_key_label") }} <span class="text-red-500" aria-hidden="true">*</span></label>
|
||||
<input id="s3-key" type="text" x-model="form.credentials.access_key_id" :placeholder="editingIntegration ? '(unchanged if blank)' : ''" :required="!editingIntegration" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="s3-secret" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Secret Access Key <span class="text-red-500" aria-hidden="true">*</span></label>
|
||||
<label for="s3-secret" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.secret_key_label") }} <span class="text-red-500" aria-hidden="true">*</span></label>
|
||||
<input id="s3-secret" :type="showPassword ? 'text' : 'password'" x-model="form.credentials.secret_access_key" :placeholder="editingIntegration ? '(unchanged if blank)' : ''" :required="!editingIntegration" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" />
|
||||
</div>
|
||||
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-200 cursor-pointer">
|
||||
<input type="checkbox" x-model="showPassword" class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" />
|
||||
Show secrets
|
||||
{{ _("integrations.show_secrets") }}
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
@@ -534,24 +534,24 @@
|
||||
<div class="space-y-3 border-t border-gray-200 dark:border-gray-700 pt-3">
|
||||
<p class="text-xs font-semibold text-gray-400 uppercase tracking-wider" x-text="form.integration_type === 'NEXTCLOUD' ? 'Nextcloud Settings' : 'WebDAV Settings'"></p>
|
||||
<div>
|
||||
<label for="webdav-url" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">URL <span class="text-red-500" aria-hidden="true">*</span></label>
|
||||
<label for="webdav-url" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.webdav_url_label") }} <span class="text-red-500" aria-hidden="true">*</span></label>
|
||||
<input id="webdav-url" type="url" x-model="form.config.url" placeholder="https://cloud.example.com/remote.php/dav/files/user/" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" aria-required="true" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="webdav-folder" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Folder Path</label>
|
||||
<label for="webdav-folder" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.folder_path_label") }}</label>
|
||||
<input id="webdav-folder" type="text" x-model="form.config.folder" placeholder="/Documents" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="webdav-user" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Username <span class="text-red-500" aria-hidden="true">*</span></label>
|
||||
<label for="webdav-user" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.username_label") }} <span class="text-red-500" aria-hidden="true">*</span></label>
|
||||
<input id="webdav-user" type="text" x-model="form.credentials.username" :required="!editingIntegration" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="webdav-pass" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Password <span class="text-red-500" aria-hidden="true">*</span></label>
|
||||
<label for="webdav-pass" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.password_label") }} <span class="text-red-500" aria-hidden="true">*</span></label>
|
||||
<input id="webdav-pass" :type="showPassword ? 'text' : 'password'" x-model="form.credentials.password" :placeholder="editingIntegration ? '(unchanged if blank)' : ''" :required="!editingIntegration" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" />
|
||||
</div>
|
||||
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-200 cursor-pointer">
|
||||
<input type="checkbox" x-model="showPassword" class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" />
|
||||
Show password
|
||||
{{ _("integrations.show_password") }}
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
@@ -562,29 +562,29 @@
|
||||
<p class="text-xs font-semibold text-gray-400 uppercase tracking-wider" x-text="form.integration_type + ' Settings'"></p>
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<div class="col-span-2">
|
||||
<label for="ftp-host" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Host <span class="text-red-500" aria-hidden="true">*</span></label>
|
||||
<label for="ftp-host" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.host_label") }} <span class="text-red-500" aria-hidden="true">*</span></label>
|
||||
<input id="ftp-host" type="text" x-model="form.config.host" placeholder="ftp.example.com" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" aria-required="true" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="ftp-port" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Port</label>
|
||||
<label for="ftp-port" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.port_label") }}</label>
|
||||
<input id="ftp-port" type="number" x-model.number="form.config.port" :placeholder="form.integration_type === 'SFTP' ? '22' : '21'" min="1" max="65535" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="ftp-folder" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Remote Path</label>
|
||||
<label for="ftp-folder" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.remote_path_label") }}</label>
|
||||
<input id="ftp-folder" type="text" x-model="form.config.folder" placeholder="/upload" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="ftp-user" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Username <span class="text-red-500" aria-hidden="true">*</span></label>
|
||||
<label for="ftp-user" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.username_label") }} <span class="text-red-500" aria-hidden="true">*</span></label>
|
||||
<input id="ftp-user" type="text" x-model="form.credentials.username" :required="!editingIntegration" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="ftp-pass" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Password <span class="text-red-500" aria-hidden="true">*</span></label>
|
||||
<label for="ftp-pass" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.password_label") }} <span class="text-red-500" aria-hidden="true">*</span></label>
|
||||
<input id="ftp-pass" :type="showPassword ? 'text' : 'password'" x-model="form.credentials.password" :placeholder="editingIntegration ? '(unchanged if blank)' : ''" :required="!editingIntegration" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" />
|
||||
</div>
|
||||
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-200 cursor-pointer">
|
||||
<input type="checkbox" x-model="showPassword" class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" />
|
||||
Show password
|
||||
{{ _("integrations.show_password") }}
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
@@ -594,13 +594,12 @@
|
||||
<div class="space-y-3 border-t border-gray-200 dark:border-gray-700 pt-3">
|
||||
<p class="text-xs font-semibold text-gray-400 uppercase tracking-wider" x-text="typeLabel(form.integration_type) + ' Settings'"></p>
|
||||
<div>
|
||||
<label for="oauth-folder" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Folder <span class="text-gray-400">(optional)</span></label>
|
||||
<label for="oauth-folder" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.oauth_folder_label") }} <span class="text-gray-400">{{ _("integrations.folder_optional") }}</span></label>
|
||||
<input id="oauth-folder" type="text" x-model="form.config.folder" placeholder="/DocuElevate" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" />
|
||||
</div>
|
||||
<div class="bg-blue-50 dark:bg-blue-900/30 rounded-md p-3 text-sm text-blue-700 dark:text-blue-300">
|
||||
<i class="fas fa-info-circle mr-1" aria-hidden="true"></i>
|
||||
Save this integration first, then use the <strong>Authorize</strong> button to complete the OAuth flow.
|
||||
Your credentials will be stored securely in your personal integration record.
|
||||
{{ _("integrations.oauth_hint") }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -608,9 +607,9 @@
|
||||
<!-- Email (destination) fields -->
|
||||
<template x-if="form.integration_type === 'EMAIL'">
|
||||
<div class="space-y-3 border-t border-gray-200 dark:border-gray-700 pt-3">
|
||||
<p class="text-xs font-semibold text-gray-400 uppercase tracking-wider">Email Forward Settings</p>
|
||||
<p class="text-xs font-semibold text-gray-400 uppercase tracking-wider">{{ _("integrations.email_settings") }}</p>
|
||||
<div>
|
||||
<label for="email-to" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Recipient Email <span class="text-red-500" aria-hidden="true">*</span></label>
|
||||
<label for="email-to" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.recipient_label") }} <span class="text-red-500" aria-hidden="true">*</span></label>
|
||||
<input id="email-to" type="email" x-model="form.config.recipient" placeholder="archive@example.com" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" aria-required="true" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -619,11 +618,11 @@
|
||||
<!-- Watch Folder fields -->
|
||||
<template x-if="form.integration_type === 'WATCH_FOLDER'">
|
||||
<div class="space-y-3 border-t border-gray-200 dark:border-gray-700 pt-3">
|
||||
<p class="text-xs font-semibold text-gray-400 uppercase tracking-wider">Watch Folder Settings</p>
|
||||
<p class="text-xs font-semibold text-gray-400 uppercase tracking-wider">{{ _("integrations.watch_folder_settings") }}</p>
|
||||
<div>
|
||||
<label for="wf-source-type" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Source Type <span class="text-red-500" aria-hidden="true">*</span></label>
|
||||
<label for="wf-source-type" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.source_type_label") }} <span class="text-red-500" aria-hidden="true">*</span></label>
|
||||
<select id="wf-source-type" x-model="form.config.source_type" @change="onWatchFolderSourceTypeChange()" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" aria-required="true">
|
||||
<option value="local">Local Filesystem</option>
|
||||
<option value="local">{{ _("integrations.source_local") }}</option>
|
||||
<option value="s3">Amazon S3</option>
|
||||
<option value="dropbox">Dropbox</option>
|
||||
<option value="google_drive">Google Drive</option>
|
||||
@@ -636,7 +635,7 @@
|
||||
<!-- Local filesystem fields -->
|
||||
<template x-if="form.config.source_type === 'local'">
|
||||
<div>
|
||||
<label for="wf-path" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Folder Path <span class="text-red-500" aria-hidden="true">*</span></label>
|
||||
<label for="wf-path" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.folder_path_label") }} <span class="text-red-500" aria-hidden="true">*</span></label>
|
||||
<input id="wf-path" type="text" x-model="form.config.folder_path" placeholder="/data/inbox" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" aria-required="true" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -646,29 +645,29 @@
|
||||
<div class="space-y-3">
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label for="wf-s3-bucket" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Bucket <span class="text-red-500" aria-hidden="true">*</span></label>
|
||||
<label for="wf-s3-bucket" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.bucket_label") }} <span class="text-red-500" aria-hidden="true">*</span></label>
|
||||
<input id="wf-s3-bucket" type="text" x-model="form.config.bucket" placeholder="my-bucket" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" aria-required="true" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="wf-s3-region" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Region</label>
|
||||
<label for="wf-s3-region" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.region_label") }}</label>
|
||||
<input id="wf-s3-region" type="text" x-model="form.config.region" placeholder="us-east-1" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="wf-s3-prefix" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Prefix (folder path)</label>
|
||||
<label for="wf-s3-prefix" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.s3_prefix_label") }}</label>
|
||||
<input id="wf-s3-prefix" type="text" x-model="form.config.prefix" placeholder="inbox/" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="wf-s3-endpoint" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Endpoint URL <span class="text-xs text-gray-400">(optional, for S3-compatible)</span></label>
|
||||
<label for="wf-s3-endpoint" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.endpoint_label") }} <span class="text-xs text-gray-400">{{ _("integrations.endpoint_optional") }}</span></label>
|
||||
<input id="wf-s3-endpoint" type="text" x-model="form.config.endpoint_url" placeholder="https://s3.example.com" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" />
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label for="wf-s3-ak" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Access Key ID <span class="text-red-500" aria-hidden="true">*</span></label>
|
||||
<label for="wf-s3-ak" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.access_key_label") }} <span class="text-red-500" aria-hidden="true">*</span></label>
|
||||
<input id="wf-s3-ak" type="text" x-model="form.credentials.access_key_id" :required="!editingIntegration" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" aria-required="true" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="wf-s3-sk" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Secret Access Key <span class="text-red-500" aria-hidden="true">*</span></label>
|
||||
<label for="wf-s3-sk" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.secret_key_label") }} <span class="text-red-500" aria-hidden="true">*</span></label>
|
||||
<input id="wf-s3-sk" :type="showPassword ? 'text' : 'password'" x-model="form.credentials.secret_access_key" :placeholder="editingIntegration ? '(unchanged if blank)' : ''" :required="!editingIntegration" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -679,7 +678,7 @@
|
||||
<template x-if="form.config.source_type === 'dropbox'">
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label for="wf-dbx-folder" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Folder Path <span class="text-red-500" aria-hidden="true">*</span></label>
|
||||
<label for="wf-dbx-folder" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.folder_path_label") }} <span class="text-red-500" aria-hidden="true">*</span></label>
|
||||
<input id="wf-dbx-folder" type="text" x-model="form.config.folder_path" placeholder="/Inbox/Scanner" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" aria-required="true" />
|
||||
</div>
|
||||
<div>
|
||||
@@ -717,7 +716,7 @@
|
||||
<template x-if="form.config.source_type === 'onedrive'">
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label for="wf-od-folder" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Folder Path <span class="text-red-500" aria-hidden="true">*</span></label>
|
||||
<label for="wf-od-folder" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.folder_path_label") }} <span class="text-red-500" aria-hidden="true">*</span></label>
|
||||
<input id="wf-od-folder" type="text" x-model="form.config.folder_path" placeholder="/Documents/Inbox" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" aria-required="true" />
|
||||
</div>
|
||||
<div>
|
||||
@@ -741,20 +740,20 @@
|
||||
<template x-if="form.config.source_type === 'nextcloud'">
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label for="wf-nc-url" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Nextcloud URL <span class="text-red-500" aria-hidden="true">*</span></label>
|
||||
<label for="wf-nc-url" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.nextcloud_url_label") }} <span class="text-red-500" aria-hidden="true">*</span></label>
|
||||
<input id="wf-nc-url" type="url" x-model="form.config.url" placeholder="https://cloud.example.com" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" aria-required="true" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="wf-nc-folder" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Folder Path <span class="text-red-500" aria-hidden="true">*</span></label>
|
||||
<label for="wf-nc-folder" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.folder_path_label") }} <span class="text-red-500" aria-hidden="true">*</span></label>
|
||||
<input id="wf-nc-folder" type="text" x-model="form.config.folder_path" placeholder="/Documents/Inbox" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" aria-required="true" />
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label for="wf-nc-user" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Username <span class="text-red-500" aria-hidden="true">*</span></label>
|
||||
<label for="wf-nc-user" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.username_label") }} <span class="text-red-500" aria-hidden="true">*</span></label>
|
||||
<input id="wf-nc-user" type="text" x-model="form.credentials.username" :required="!editingIntegration" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" aria-required="true" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="wf-nc-pass" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Password <span class="text-red-500" aria-hidden="true">*</span></label>
|
||||
<label for="wf-nc-pass" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.password_label") }} <span class="text-red-500" aria-hidden="true">*</span></label>
|
||||
<input id="wf-nc-pass" :type="showPassword ? 'text' : 'password'" x-model="form.credentials.password" :placeholder="editingIntegration ? '(unchanged if blank)' : ''" :required="!editingIntegration" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -765,20 +764,20 @@
|
||||
<template x-if="form.config.source_type === 'webdav'">
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label for="wf-dav-url" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">WebDAV URL <span class="text-red-500" aria-hidden="true">*</span></label>
|
||||
<label for="wf-dav-url" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.webdav_url_label") }} <span class="text-red-500" aria-hidden="true">*</span></label>
|
||||
<input id="wf-dav-url" type="url" x-model="form.config.url" placeholder="https://webdav.example.com/dav/" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" aria-required="true" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="wf-dav-folder" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Folder Path <span class="text-red-500" aria-hidden="true">*</span></label>
|
||||
<label for="wf-dav-folder" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.folder_path_label") }} <span class="text-red-500" aria-hidden="true">*</span></label>
|
||||
<input id="wf-dav-folder" type="text" x-model="form.config.folder_path" placeholder="/remote.php/webdav/Inbox" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" aria-required="true" />
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label for="wf-dav-user" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Username <span class="text-red-500" aria-hidden="true">*</span></label>
|
||||
<label for="wf-dav-user" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.username_label") }} <span class="text-red-500" aria-hidden="true">*</span></label>
|
||||
<input id="wf-dav-user" type="text" x-model="form.credentials.username" :required="!editingIntegration" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" aria-required="true" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="wf-dav-pass" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Password <span class="text-red-500" aria-hidden="true">*</span></label>
|
||||
<label for="wf-dav-pass" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.password_label") }} <span class="text-red-500" aria-hidden="true">*</span></label>
|
||||
<input id="wf-dav-pass" :type="showPassword ? 'text' : 'password'" x-model="form.credentials.password" :placeholder="editingIntegration ? '(unchanged if blank)' : ''" :required="!editingIntegration" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -789,12 +788,12 @@
|
||||
<div class="flex flex-wrap items-center gap-4 pt-2">
|
||||
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-200 cursor-pointer">
|
||||
<input type="checkbox" x-model="form.config.delete_after_process" class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" />
|
||||
Delete files after processing
|
||||
{{ _("integrations.delete_files_label") }}
|
||||
</label>
|
||||
<template x-if="form.config.source_type !== 'local'">
|
||||
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-200 cursor-pointer">
|
||||
<input type="checkbox" x-model="showPassword" class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" />
|
||||
Show secrets
|
||||
{{ _("integrations.show_secrets") }}
|
||||
</label>
|
||||
</template>
|
||||
</div>
|
||||
@@ -804,18 +803,18 @@
|
||||
<!-- Paperless fields -->
|
||||
<template x-if="form.integration_type === 'PAPERLESS'">
|
||||
<div class="space-y-3 border-t border-gray-200 dark:border-gray-700 pt-3">
|
||||
<p class="text-xs font-semibold text-gray-400 uppercase tracking-wider">Paperless NGX Settings</p>
|
||||
<p class="text-xs font-semibold text-gray-400 uppercase tracking-wider">{{ _("integrations.paperless_settings") }}</p>
|
||||
<div>
|
||||
<label for="paperless-url" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">URL <span class="text-red-500" aria-hidden="true">*</span></label>
|
||||
<input id="paperless-url" type="url" x-model="form.config.url" placeholder="https://paperless.example.com" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" aria-required="true" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="paperless-token" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">API Token <span class="text-red-500" aria-hidden="true">*</span></label>
|
||||
<label for="paperless-token" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.api_token_label") }} <span class="text-red-500" aria-hidden="true">*</span></label>
|
||||
<input id="paperless-token" :type="showPassword ? 'text' : 'password'" x-model="form.credentials.api_token" :placeholder="editingIntegration ? '(unchanged if blank)' : ''" :required="!editingIntegration" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" />
|
||||
</div>
|
||||
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-200 cursor-pointer">
|
||||
<input type="checkbox" x-model="showPassword" class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" />
|
||||
Show token
|
||||
{{ _("integrations.show_token") }}
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
@@ -824,38 +823,33 @@
|
||||
<template x-if="form.integration_type === 'WEBHOOK'">
|
||||
<div class="space-y-4 border-t border-gray-200 dark:border-gray-700 pt-3">
|
||||
<p class="text-xs font-semibold text-purple-500 uppercase tracking-wider">
|
||||
<i class="fas fa-bolt mr-1" aria-hidden="true"></i> Webhook Ingestion
|
||||
<i class="fas fa-bolt mr-1" aria-hidden="true"></i> {{ _("integrations.webhook_heading") }}
|
||||
</p>
|
||||
|
||||
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<h4 class="text-sm font-semibold text-blue-800 dark:text-blue-200 mb-2">
|
||||
<i class="fas fa-info-circle mr-1" aria-hidden="true"></i>
|
||||
How Webhook Ingestion Works
|
||||
{{ _("integrations.webhook_how_heading") }}
|
||||
</h4>
|
||||
<p class="text-sm text-blue-700 dark:text-blue-300 leading-relaxed">
|
||||
A webhook integration allows external systems to push documents directly into DocuElevate
|
||||
via the REST API. Instead of DocuElevate polling for new files (like IMAP), <strong>your
|
||||
application sends files to DocuElevate</strong> using an HTTP request with an API token for
|
||||
authentication.
|
||||
{{ _("integrations.webhook_how_text") }}
|
||||
</p>
|
||||
<p class="text-sm text-blue-700 dark:text-blue-300 leading-relaxed mt-2">
|
||||
This is ideal for <strong>CI/CD pipelines</strong>, <strong>automation scripts</strong>,
|
||||
<strong>scanner integrations</strong>, or any system that generates documents and needs to
|
||||
send them for processing.
|
||||
{{ _("integrations.webhook_ideal_text") }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 dark:bg-gray-750 rounded-lg p-4">
|
||||
<h4 class="text-sm font-semibold text-gray-800 dark:text-gray-200 mb-2">
|
||||
<i class="fas fa-terminal mr-1" aria-hidden="true"></i>
|
||||
Quick Start
|
||||
{{ _("integrations.webhook_quick_start") }}
|
||||
</h4>
|
||||
<ol class="text-sm text-gray-600 dark:text-gray-400 space-y-2 list-decimal list-inside">
|
||||
<li>
|
||||
Go to <a href="/api-tokens" class="text-indigo-600 hover:underline font-medium">API Tokens</a>
|
||||
and create a personal token.
|
||||
{% set api_tokens_link %}<a href="/api-tokens" class="text-indigo-600 hover:underline font-medium">{{ _("nav.api_tokens") }}</a>{% endset %}
|
||||
{{ _("integrations.webhook_step1", api_tokens_link=api_tokens_link) | safe }}
|
||||
</li>
|
||||
<li>Use the token to upload documents via the API:</li>
|
||||
<li>{{ _("integrations.webhook_upload_with_token") }}</li>
|
||||
</ol>
|
||||
|
||||
<div class="mt-3 relative">
|
||||
@@ -901,9 +895,7 @@ print(response.json())</code></pre>
|
||||
<div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-3">
|
||||
<p class="text-xs text-yellow-800 dark:text-yellow-300">
|
||||
<i class="fas fa-shield-alt mr-1" aria-hidden="true"></i>
|
||||
<strong>Security tip:</strong> Create a dedicated API token for each integration and
|
||||
revoke it immediately if compromised. Tokens can be managed on the
|
||||
<a href="/api-tokens" class="underline font-medium">API Tokens</a> page.
|
||||
{{ _("integrations.webhook_security_tip") }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -914,7 +906,7 @@ print(response.json())</code></pre>
|
||||
<div class="space-y-3 border-t border-gray-200 dark:border-gray-700 pt-3">
|
||||
<p class="text-xs font-semibold text-gray-400 uppercase tracking-wider" x-text="form.integration_type + ' Settings'"></p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Configuration for this integration type can be provided as JSON after creation via the API.
|
||||
{{ _("integrations.generic_settings_hint") }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -923,7 +915,7 @@ print(response.json())</code></pre>
|
||||
<div class="flex items-center gap-2 pt-2">
|
||||
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-200 cursor-pointer">
|
||||
<input type="checkbox" x-model="form.is_active" class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" />
|
||||
Active
|
||||
{{ _("integrations.active_label") }}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -934,7 +926,7 @@ print(response.json())</code></pre>
|
||||
:class="testResult.success ? 'bg-green-50 border-green-400 text-green-800 dark:bg-opacity-10' : 'bg-red-50 border-red-400 text-red-800 dark:bg-opacity-10'"
|
||||
role="status"
|
||||
>
|
||||
<p class="font-medium" x-text="testResult.success ? 'Connection successful' : 'Connection failed'"></p>
|
||||
<p class="font-medium" x-text="testResult.success ? '{{ _('integrations.connection_success') }}' : '{{ _('integrations.connection_failed') }}'"></p>
|
||||
<p class="text-xs mt-0.5" x-text="testResult.message"></p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -962,7 +954,7 @@ print(response.json())</code></pre>
|
||||
<template x-if="!testing">
|
||||
<i class="fas fa-plug mr-2" aria-hidden="true"></i>
|
||||
</template>
|
||||
Test Connection
|
||||
{{ _("common.test_connection") }}
|
||||
</button>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
@@ -971,7 +963,7 @@ print(response.json())</code></pre>
|
||||
class="px-4 py-2 text-sm font-medium rounded border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-500"
|
||||
style="min-height:40px;"
|
||||
>
|
||||
Cancel
|
||||
{{ _("common.cancel") }}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
@@ -982,7 +974,7 @@ print(response.json())</code></pre>
|
||||
<template x-if="saving">
|
||||
<i class="fas fa-spinner fa-spin mr-2" aria-hidden="true"></i>
|
||||
</template>
|
||||
<span x-text="editingIntegration ? 'Update' : 'Save'"></span>
|
||||
<span x-text="editingIntegration ? '{{ _('common.update') }}' : '{{ _('common.save') }}'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1003,12 +995,12 @@ print(response.json())</code></pre>
|
||||
>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-sm p-6" @click.stop>
|
||||
<h2 id="delete-modal-title" class="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||
Delete Integration?
|
||||
{{ _("integrations.delete_confirm_title") }}
|
||||
</h2>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-300 mb-4">
|
||||
Are you sure you want to delete
|
||||
{{ _("integrations.delete_confirm_are_you_sure") }}
|
||||
<strong x-text="integrationToDelete ? integrationToDelete.name : ''"></strong>?
|
||||
This action cannot be undone.
|
||||
{{ _("integrations.delete_confirm_undone") }}
|
||||
</p>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
@@ -1017,7 +1009,7 @@ print(response.json())</code></pre>
|
||||
class="px-4 py-2 text-sm font-medium rounded border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-500"
|
||||
style="min-height:40px;"
|
||||
>
|
||||
Cancel
|
||||
{{ _("common.cancel") }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -1029,7 +1021,7 @@ print(response.json())</code></pre>
|
||||
<template x-if="deleting">
|
||||
<i class="fas fa-spinner fa-spin mr-2" aria-hidden="true"></i>
|
||||
</template>
|
||||
Delete
|
||||
{{ _("common.delete") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,36 +1,35 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}DocuElevate - License{% endblock %}
|
||||
{% block title %}{{ _("license.page_title") }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<h1 class="text-2xl font-bold mb-4">License Information</h1>
|
||||
<h1 class="text-2xl font-bold mb-4">{{ _("license.heading") }}</h1>
|
||||
|
||||
<div class="bg-white shadow-md rounded-lg p-6 mb-6">
|
||||
<h2 class="text-xl font-semibold mb-4">Apache License 2.0</h2>
|
||||
<h2 class="text-xl font-semibold mb-4">{{ _("license.apache_heading") }}</h2>
|
||||
|
||||
<div class="prose">
|
||||
<pre class="whitespace-pre-wrap text-sm font-mono bg-gray-50 p-4 rounded border overflow-auto max-h-96 mb-4">{{ license_text }}</pre>
|
||||
</div>
|
||||
|
||||
<p class="mt-4 text-gray-600">
|
||||
DocuElevate is distributed under the Apache License 2.0, which is a permissive
|
||||
open-source software license that allows you to use, modify, distribute, and
|
||||
contribute to the project.
|
||||
{{ _("license.apache_description") }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white shadow-md rounded-lg p-6">
|
||||
<h2 class="text-xl font-semibold mb-4">Related Information</h2>
|
||||
<h2 class="text-xl font-semibold mb-4">{{ _("license.related_heading") }}</h2>
|
||||
<p class="mb-3 text-gray-600">
|
||||
While this license governs the use of our software, please also review our
|
||||
<a href="/terms" class="text-blue-600 hover:underline">Terms of Service</a> and
|
||||
<a href="/privacy" class="text-blue-600 hover:underline">Privacy Policy</a> for
|
||||
information about using the DocuElevate service.
|
||||
{{ _("license.related_p1_pre") }}
|
||||
<a href="/terms" class="text-blue-600 hover:underline">{{ _("license.related_terms_link") }}</a>
|
||||
{{ _("license.related_and") }}
|
||||
<a href="/privacy" class="text-blue-600 hover:underline">{{ _("license.related_privacy_link") }}</a>
|
||||
{{ _("license.related_p1_post") }}
|
||||
</p>
|
||||
<p class="text-gray-600">
|
||||
For more information about DocuElevate, please visit the
|
||||
<a href="/about" class="text-blue-600 hover:underline">About page</a>.
|
||||
{{ _("license.related_p2_pre") }}
|
||||
<a href="/about" class="text-blue-600 hover:underline">{{ _("license.related_about_link") }}</a>{{ _("license.related_p2_post") }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+140
-212
@@ -1,388 +1,316 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Privacy Notice - DocuElevate{% endblock %}
|
||||
{% block title %}{{ _("privacy.page_title") }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mx-auto px-4 py-8 max-w-4xl">
|
||||
<h1 class="text-4xl font-bold mb-6">DocuElevate – Privacy Notice</h1>
|
||||
<p class="text-sm text-gray-500 mb-8">Last Updated: {{ build_date|default(current_date|default('April 7, 2024')) }}</p>
|
||||
<h1 class="text-4xl font-bold mb-6">{{ _("privacy.heading") }}</h1>
|
||||
<p class="text-sm text-gray-500 mb-8">{{ _("privacy.last_updated") }} {{ build_date|default(current_date|default('April 7, 2024')) }}</p>
|
||||
|
||||
<!-- Table of Contents -->
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
|
||||
<h2 class="text-lg font-semibold mb-2 text-blue-800">Contents</h2>
|
||||
<h2 class="text-lg font-semibold mb-2 text-blue-800">{{ _("privacy.toc_heading") }}</h2>
|
||||
<ol class="list-decimal list-inside text-blue-700 space-y-1 text-sm">
|
||||
<li><a href="#controller" class="hover:underline">Data Controller</a></li>
|
||||
<li><a href="#scope" class="hover:underline">Scope of this Privacy Notice</a></li>
|
||||
<li><a href="#data-collection" class="hover:underline">Data Collection & Purposes</a></li>
|
||||
<li><a href="#data-minimization" class="hover:underline">Data Minimization & Purpose Limitation</a></li>
|
||||
<li><a href="#cookies" class="hover:underline">Use of Cookies & 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 & others)</a></li>
|
||||
<li><a href="#apj-rights" class="hover:underline">Additional Rights – Asia-Pacific & Japan</a></li>
|
||||
<li><a href="#ukraine-rights" class="hover:underline">Additional Rights – Ukraine</a></li>
|
||||
<li><a href="#updates" class="hover:underline">Updates to this Privacy Notice</a></li>
|
||||
<li><a href="#controller" class="hover:underline">{{ _("privacy.toc_1") }}</a></li>
|
||||
<li><a href="#scope" class="hover:underline">{{ _("privacy.toc_2") }}</a></li>
|
||||
<li><a href="#data-collection" class="hover:underline">{{ _("privacy.toc_3") }}</a></li>
|
||||
<li><a href="#data-minimization" class="hover:underline">{{ _("privacy.toc_4") }}</a></li>
|
||||
<li><a href="#cookies" class="hover:underline">{{ _("privacy.toc_5") }}</a></li>
|
||||
<li><a href="#third-parties" class="hover:underline">{{ _("privacy.toc_6") }}</a></li>
|
||||
<li><a href="#international-transfers" class="hover:underline">{{ _("privacy.toc_7") }}</a></li>
|
||||
<li><a href="#retention" class="hover:underline">{{ _("privacy.toc_8") }}</a></li>
|
||||
<li><a href="#security" class="hover:underline">{{ _("privacy.toc_9") }}</a></li>
|
||||
<li><a href="#gdpr-rights" class="hover:underline">{{ _("privacy.toc_10") }}</a></li>
|
||||
<li><a href="#us-rights" class="hover:underline">{{ _("privacy.toc_11") }}</a></li>
|
||||
<li><a href="#canada-rights" class="hover:underline">{{ _("privacy.toc_12") }}</a></li>
|
||||
<li><a href="#latam-rights" class="hover:underline">{{ _("privacy.toc_13") }}</a></li>
|
||||
<li><a href="#apj-rights" class="hover:underline">{{ _("privacy.toc_14") }}</a></li>
|
||||
<li><a href="#ukraine-rights" class="hover:underline">{{ _("privacy.toc_15") }}</a></li>
|
||||
<li><a href="#updates" class="hover:underline">{{ _("privacy.toc_16") }}</a></li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div id="controller" class="bg-white shadow rounded-lg p-6 mb-6">
|
||||
<h2 class="text-2xl font-semibold mb-4">1. Data Controller</h2>
|
||||
<h2 class="text-2xl font-semibold mb-4">{{ _("privacy.s1_heading") }}</h2>
|
||||
<p class="text-gray-700 mb-3">
|
||||
The controller responsible for processing your personal data under the EU General Data Protection Regulation (GDPR) and equivalent privacy laws worldwide is:
|
||||
{{ _("privacy.s1_p1") }}
|
||||
</p>
|
||||
<p class="text-gray-700 mb-3">
|
||||
<strong>Christian Louis IT Beratung</strong><br />
|
||||
Alter Steinweg 3, 20459 Hamburg, Germany
|
||||
<strong>{{ _("privacy.s1_company") }}</strong><br />
|
||||
{{ _("privacy.s1_address") }}
|
||||
</p>
|
||||
<p class="text-gray-700 mb-3">
|
||||
Contact Email: <a href="mailto:docuelevate@christian-louis.de" class="text-blue-600 hover:underline">docuelevate@christian-louis.de</a>
|
||||
{{ _("privacy.s1_contact_label") }} <a href="mailto:docuelevate@christian-louis.de" class="text-blue-600 hover:underline">docuelevate@christian-louis.de</a>
|
||||
</p>
|
||||
<p class="text-gray-700">
|
||||
For all privacy-related requests (access, deletion, rectification, opt-out, or complaints), please contact us at the email address above.
|
||||
We will respond within 30 days (or the period prescribed by applicable law).
|
||||
{{ _("privacy.s1_p3") }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="scope" class="bg-white shadow rounded-lg p-6 mb-6">
|
||||
<h2 class="text-2xl font-semibold mb-4">2. Scope of this Privacy Notice</h2>
|
||||
<h2 class="text-2xl font-semibold mb-4">{{ _("privacy.s2_heading") }}</h2>
|
||||
<p class="text-gray-700 mb-3">
|
||||
This notice applies to the DocuElevate web application, hosted at
|
||||
{{ _("privacy.s2_p1_pre") }}
|
||||
<a href="{{ request.url.scheme }}://{{ request.url.netloc }}/" class="text-blue-600 hover:underline" target="_blank">{{ request.url.scheme }}://{{ request.url.netloc }}/</a>.
|
||||
</p>
|
||||
<p class="text-gray-700">
|
||||
It covers all users globally, including those in the European Union (EU), European Economic Area (EEA),
|
||||
Germany, United Kingdom (UK), Switzerland, Ukraine, United States (US), Canada, Latin America (Latam),
|
||||
Asia-Pacific, and Japan. Market-specific disclosures are provided in dedicated sections below.
|
||||
{{ _("privacy.s2_p2") }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="data-collection" class="bg-white shadow rounded-lg p-6 mb-6">
|
||||
<h2 class="text-2xl font-semibold mb-4">3. Data Collection & Purposes</h2>
|
||||
<h2 class="text-2xl font-semibold mb-4">{{ _("privacy.s3_heading") }}</h2>
|
||||
<p class="text-gray-700 mb-3">
|
||||
<strong>User Authentication:</strong> We use OAuth 2.0 (Google, Dropbox, Microsoft/OneDrive) and optional
|
||||
local authentication. Through OAuth, we may receive your name, email address, and profile picture.
|
||||
<strong>{{ _("privacy.s3_auth_label") }}</strong> {{ _("privacy.s3_auth_body") }}
|
||||
</p>
|
||||
<p class="text-gray-700 mb-3">
|
||||
<strong>Document Processing:</strong> Documents you upload are processed for OCR (optical character recognition),
|
||||
metadata extraction, and storage to your chosen cloud provider. Document content is processed only for the
|
||||
purpose you initiate and is not stored beyond what is operationally necessary.
|
||||
<strong>{{ _("privacy.s3_doc_label") }}</strong> {{ _("privacy.s3_doc_body") }}
|
||||
</p>
|
||||
<p class="text-gray-700 mb-3">
|
||||
<strong>Audit Logs:</strong> We maintain limited audit logs (action type, timestamp, user identifier) to
|
||||
ensure service integrity and security. These logs do not include document content.
|
||||
<strong>{{ _("privacy.s3_audit_label") }}</strong> {{ _("privacy.s3_audit_body") }}
|
||||
</p>
|
||||
<p class="text-gray-700 mb-3">
|
||||
<strong>Legal Basis (GDPR Art. 6):</strong> Our primary legal bases for processing are:
|
||||
<strong>{{ _("privacy.s3_legal_label") }}</strong> {{ _("privacy.s3_legal_body") }}
|
||||
</p>
|
||||
<ul class="list-disc list-inside text-gray-700 ml-4 mb-3">
|
||||
<li class="mb-1">(1)(b) Performance of a contract: to provide the DocuElevate service you have requested.</li>
|
||||
<li class="mb-1">(1)(c) Legal obligation: to comply with applicable laws and regulations.</li>
|
||||
<li>(1)(f) Legitimate interests: ensuring the security of the service and preventing fraud.</li>
|
||||
<li class="mb-1">{{ _("privacy.s3_li1") }}</li>
|
||||
<li class="mb-1">{{ _("privacy.s3_li2") }}</li>
|
||||
<li>{{ _("privacy.s3_li3") }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div id="data-minimization" class="bg-white shadow rounded-lg p-6 mb-6">
|
||||
<h2 class="text-2xl font-semibold mb-4">4. Data Minimization & Purpose Limitation</h2>
|
||||
<h2 class="text-2xl font-semibold mb-4">{{ _("privacy.s4_heading") }}</h2>
|
||||
<p class="text-gray-700 mb-3">
|
||||
DocuElevate is designed with data minimization as a core principle (GDPR Art. 5(1)(c)):
|
||||
{{ _("privacy.s4_p1") }}
|
||||
</p>
|
||||
<ul class="list-disc list-inside text-gray-700 ml-4 mb-3">
|
||||
<li class="mb-1">We collect only the minimum personal data required to operate the service.</li>
|
||||
<li class="mb-1">Document content is processed strictly for the purpose you initiate (OCR, storage, metadata extraction).
|
||||
We do not use your documents to train AI models or for any secondary purpose.</li>
|
||||
<li class="mb-1">No advertising, behavioural tracking, or profiling is performed.</li>
|
||||
<li class="mb-1">No tracking cookies or analytics scripts are loaded.</li>
|
||||
<li>Third-party AI services (e.g., OpenAI, Azure Document Intelligence) are invoked only when you initiate
|
||||
document processing, and data is transmitted under data processing agreements.</li>
|
||||
<li class="mb-1">{{ _("privacy.s4_li1") }}</li>
|
||||
<li class="mb-1">{{ _("privacy.s4_li2") }}</li>
|
||||
<li class="mb-1">{{ _("privacy.s4_li3") }}</li>
|
||||
<li class="mb-1">{{ _("privacy.s4_li4") }}</li>
|
||||
<li>{{ _("privacy.s4_li5") }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div id="cookies" class="bg-white shadow rounded-lg p-6 mb-6">
|
||||
<h2 class="text-2xl font-semibold mb-4">5. Use of Cookies & Similar Technologies</h2>
|
||||
<h2 class="text-2xl font-semibold mb-4">{{ _("privacy.s5_heading") }}</h2>
|
||||
<p class="text-gray-700 mb-3">
|
||||
DocuElevate uses <strong>only strictly necessary session cookies</strong> to maintain your authenticated
|
||||
session. These cookies are essential for the service to function and are exempt from prior-consent
|
||||
requirements under the EU ePrivacy Directive (Art. 5(3)) and equivalent national laws.
|
||||
{{ _("privacy.s5_p1_pre") }} <strong>{{ _("privacy.s5_p1_strong") }}</strong> {{ _("privacy.s5_p1_post") }}
|
||||
</p>
|
||||
<p class="text-gray-700 mb-3">
|
||||
<strong>We do not use:</strong> analytics cookies, advertising cookies, tracking pixels, or any
|
||||
third-party cookies that would require your consent.
|
||||
<strong>{{ _("privacy.s5_p2_label") }}</strong> {{ _("privacy.s5_p2_body") }}
|
||||
</p>
|
||||
<p class="text-gray-700">
|
||||
For full details on the cookies we set, their names, duration, and purpose, please visit our
|
||||
<a href="/cookies" class="text-blue-600 hover:underline">Cookie Policy</a>.
|
||||
{{ _("privacy.s5_p3_pre") }}
|
||||
<a href="/cookies" class="text-blue-600 hover:underline">{{ _("privacy.s5_cookie_link") }}</a>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="third-parties" class="bg-white shadow rounded-lg p-6 mb-6">
|
||||
<h2 class="text-2xl font-semibold mb-4">6. Third-Party Services</h2>
|
||||
<h2 class="text-2xl font-semibold mb-4">{{ _("privacy.s6_heading") }}</h2>
|
||||
<p class="text-gray-700 mb-3">
|
||||
<strong>OAuth Providers (Google, Dropbox, Microsoft):</strong> When you choose to authenticate via
|
||||
OAuth, the respective provider processes your credentials and may share limited profile information
|
||||
with us. These providers maintain their own privacy policies.
|
||||
<strong>{{ _("privacy.s6_oauth_label") }}</strong> {{ _("privacy.s6_oauth_body") }}
|
||||
</p>
|
||||
<p class="text-gray-700 mb-3">
|
||||
<strong>Cloud Storage Providers (Google Drive, Dropbox, OneDrive, Amazon S3, Nextcloud, WebDAV/SFTP/FTP):</strong>
|
||||
Documents are stored in the cloud provider you configure. Your configured credentials are stored
|
||||
encrypted in the application database and are used solely to perform the storage operations you request.
|
||||
<strong>{{ _("privacy.s6_storage_label") }}</strong>
|
||||
{{ _("privacy.s6_storage_body") }}
|
||||
</p>
|
||||
<p class="text-gray-700 mb-3">
|
||||
<strong>AI Processing Services (OpenAI, Azure Document Intelligence, others):</strong> When you initiate
|
||||
OCR or AI-based metadata extraction, document data is transmitted to the AI service you or your
|
||||
administrator has configured. This transmission is governed by a data processing agreement with the
|
||||
respective provider.
|
||||
<strong>{{ _("privacy.s6_ai_label") }}</strong> {{ _("privacy.s6_ai_body") }}
|
||||
</p>
|
||||
<p class="text-gray-700">
|
||||
<strong>No Sale or Sharing for Advertising:</strong> We do not sell, rent, or share your personal data
|
||||
with third parties for advertising, marketing, or any purpose unrelated to providing the service.
|
||||
<strong>{{ _("privacy.s6_no_sale_label") }}</strong> {{ _("privacy.s6_no_sale_body") }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="international-transfers" class="bg-white shadow rounded-lg p-6 mb-6">
|
||||
<h2 class="text-2xl font-semibold mb-4">7. International Data Transfers</h2>
|
||||
<h2 class="text-2xl font-semibold mb-4">{{ _("privacy.s7_heading") }}</h2>
|
||||
<p class="text-gray-700 mb-3">
|
||||
DocuElevate is hosted in the European Union / EEA by default. Where personal data is transferred
|
||||
outside the EEA (for example to US-based AI service providers such as OpenAI), we rely on
|
||||
appropriate safeguards including:
|
||||
{{ _("privacy.s7_p1") }}
|
||||
</p>
|
||||
<ul class="list-disc list-inside text-gray-700 ml-4 mb-3">
|
||||
<li class="mb-1">
|
||||
<strong>Standard Contractual Clauses (SCCs)</strong> adopted by the European Commission (2021/914/EU)
|
||||
for transfers to processors and controllers in third countries.
|
||||
<strong>{{ _("privacy.s7_scc_label") }}</strong> {{ _("privacy.s7_scc_body") }}
|
||||
</li>
|
||||
<li class="mb-1">
|
||||
<strong>Adequacy Decisions</strong> where the European Commission has recognised an equivalent level
|
||||
of protection (e.g., United Kingdom, Switzerland, Canada (commercial organisations), Japan, South Korea).
|
||||
<strong>{{ _("privacy.s7_adequacy_label") }}</strong> {{ _("privacy.s7_adequacy_body") }}
|
||||
</li>
|
||||
<li>
|
||||
<strong>UK International Data Transfer Agreements (IDTAs)</strong> for transfers from the UK after Brexit.
|
||||
<strong>{{ _("privacy.s7_idta_label") }}</strong> {{ _("privacy.s7_idta_body") }}
|
||||
</li>
|
||||
</ul>
|
||||
<p class="text-gray-700">
|
||||
You may request a copy of the relevant safeguards by contacting us at
|
||||
{{ _("privacy.s7_contact") }}
|
||||
<a href="mailto:docuelevate@christian-louis.de" class="text-blue-600 hover:underline">docuelevate@christian-louis.de</a>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="retention" class="bg-white shadow rounded-lg p-6 mb-6">
|
||||
<h2 class="text-2xl font-semibold mb-4">8. Data Retention</h2>
|
||||
<h2 class="text-2xl font-semibold mb-4">{{ _("privacy.s8_heading") }}</h2>
|
||||
<p class="text-gray-700 mb-3">
|
||||
We retain personal data only as long as strictly necessary to provide the DocuElevate service or to
|
||||
comply with legal obligations:
|
||||
{{ _("privacy.s8_p1") }}
|
||||
</p>
|
||||
<ul class="list-disc list-inside text-gray-700 ml-4 mb-3">
|
||||
<li class="mb-1"><strong>Session data:</strong> Deleted when you log out or after session timeout.</li>
|
||||
<li class="mb-1"><strong>File records and metadata:</strong> Retained for the duration of your use of the
|
||||
service. You may delete individual files at any time through the application.</li>
|
||||
<li class="mb-1"><strong>Audit logs:</strong> Retained for up to 90 days for security and compliance purposes.</li>
|
||||
<li><strong>OAuth tokens:</strong> Stored in encrypted form and revocable at any time via your OAuth provider.</li>
|
||||
<li class="mb-1"><strong>{{ _("privacy.s8_session_label") }}</strong> {{ _("privacy.s8_session_body") }}</li>
|
||||
<li class="mb-1"><strong>{{ _("privacy.s8_files_label") }}</strong> {{ _("privacy.s8_files_body") }}</li>
|
||||
<li class="mb-1"><strong>{{ _("privacy.s8_audit_label") }}</strong> {{ _("privacy.s8_audit_body") }}</li>
|
||||
<li><strong>{{ _("privacy.s8_oauth_label") }}</strong> {{ _("privacy.s8_oauth_body") }}</li>
|
||||
</ul>
|
||||
<p class="text-gray-700">
|
||||
To request deletion of your account and all associated personal data, please contact us at
|
||||
{{ _("privacy.s8_contact") }}
|
||||
<a href="mailto:docuelevate@christian-louis.de" class="text-blue-600 hover:underline">docuelevate@christian-louis.de</a>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="security" class="bg-white shadow rounded-lg p-6 mb-6">
|
||||
<h2 class="text-2xl font-semibold mb-4">9. Data Security</h2>
|
||||
<h2 class="text-2xl font-semibold mb-4">{{ _("privacy.s9_heading") }}</h2>
|
||||
<p class="text-gray-700 mb-3">
|
||||
We implement appropriate technical and organisational measures (TOMs) to protect your personal data,
|
||||
including:
|
||||
{{ _("privacy.s9_p1") }}
|
||||
</p>
|
||||
<ul class="list-disc list-inside text-gray-700 ml-4">
|
||||
<li class="mb-1">Encryption of credentials and sensitive configuration at rest.</li>
|
||||
<li class="mb-1">Transport Layer Security (TLS/HTTPS) for all communications.</li>
|
||||
<li class="mb-1">Role-based access controls limiting access to personal data.</li>
|
||||
<li class="mb-1">Regular security audits and dependency vulnerability scanning.</li>
|
||||
<li>CSRF protection on all state-changing requests.</li>
|
||||
<li class="mb-1">{{ _("privacy.s9_li1") }}</li>
|
||||
<li class="mb-1">{{ _("privacy.s9_li2") }}</li>
|
||||
<li class="mb-1">{{ _("privacy.s9_li3") }}</li>
|
||||
<li class="mb-1">{{ _("privacy.s9_li4") }}</li>
|
||||
<li>{{ _("privacy.s9_li5") }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div id="gdpr-rights" class="bg-white shadow rounded-lg p-6 mb-6">
|
||||
<h2 class="text-2xl font-semibold mb-4">10. Your Rights (EU / EEA / UK / Switzerland)</h2>
|
||||
<h2 class="text-2xl font-semibold mb-4">{{ _("privacy.s10_heading") }}</h2>
|
||||
<p class="text-gray-700 mb-3">
|
||||
Under GDPR (and the UK GDPR / Swiss nFADP equivalent), you have the following rights:
|
||||
{{ _("privacy.s10_p1") }}
|
||||
</p>
|
||||
<ul class="list-disc list-inside text-gray-700 ml-4 mb-3">
|
||||
<li class="mb-2"><strong>Right of Access (Art. 15):</strong> You may request a copy of the personal data
|
||||
we hold about you.</li>
|
||||
<li class="mb-2"><strong>Right to Rectification (Art. 16):</strong> You may request correction of inaccurate
|
||||
or incomplete personal data.</li>
|
||||
<li class="mb-2"><strong>Right to Erasure (Art. 17):</strong> You may request deletion of your personal data
|
||||
where there is no overriding legitimate reason for us to retain it.</li>
|
||||
<li class="mb-2"><strong>Right to Restriction (Art. 18):</strong> You may request that we temporarily halt
|
||||
processing of your data in certain circumstances.</li>
|
||||
<li class="mb-2"><strong>Right to Data Portability (Art. 20):</strong> You may request your data in a
|
||||
structured, commonly used, machine-readable format.</li>
|
||||
<li class="mb-2"><strong>Right to Object (Art. 21):</strong> You may object to processing based on
|
||||
legitimate interests at any time.</li>
|
||||
<li class="mb-2"><strong>Right to Withdraw Consent:</strong> Where processing is based on consent, you
|
||||
may withdraw that consent at any time without affecting the lawfulness of prior processing.</li>
|
||||
<li><strong>Right to Lodge a Complaint:</strong> You have the right to lodge a complaint with your
|
||||
national Data Protection Authority (DPA). In Germany: Bundesbeauftragte für den Datenschutz und
|
||||
die Informationsfreiheit (BfDI). In the UK: Information Commissioner's Office (ICO). In Switzerland:
|
||||
Federal Data Protection and Information Commissioner (FDPIC).</li>
|
||||
<li class="mb-2"><strong>{{ _("privacy.s10_access_label") }}</strong> {{ _("privacy.s10_access_body") }}</li>
|
||||
<li class="mb-2"><strong>{{ _("privacy.s10_rectification_label") }}</strong> {{ _("privacy.s10_rectification_body") }}</li>
|
||||
<li class="mb-2"><strong>{{ _("privacy.s10_erasure_label") }}</strong> {{ _("privacy.s10_erasure_body") }}</li>
|
||||
<li class="mb-2"><strong>{{ _("privacy.s10_restriction_label") }}</strong> {{ _("privacy.s10_restriction_body") }}</li>
|
||||
<li class="mb-2"><strong>{{ _("privacy.s10_portability_label") }}</strong> {{ _("privacy.s10_portability_body") }}</li>
|
||||
<li class="mb-2"><strong>{{ _("privacy.s10_object_label") }}</strong> {{ _("privacy.s10_object_body") }}</li>
|
||||
<li class="mb-2"><strong>{{ _("privacy.s10_withdraw_label") }}</strong> {{ _("privacy.s10_withdraw_body") }}</li>
|
||||
<li><strong>{{ _("privacy.s10_complaint_label") }}</strong> {{ _("privacy.s10_complaint_body") }}</li>
|
||||
</ul>
|
||||
<p class="text-gray-700">
|
||||
To exercise any of the above rights, contact us at
|
||||
{{ _("privacy.s10_contact") }}
|
||||
<a href="mailto:docuelevate@christian-louis.de" class="text-blue-600 hover:underline">docuelevate@christian-louis.de</a>.
|
||||
We will respond within one calendar month (extendable by two further months for complex requests).
|
||||
{{ _("privacy.s10_response") }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="us-rights" class="bg-white shadow rounded-lg p-6 mb-6">
|
||||
<h2 class="text-2xl font-semibold mb-4">11. Additional Rights – United States (CCPA / CPRA)</h2>
|
||||
<h2 class="text-2xl font-semibold mb-4">{{ _("privacy.s11_heading") }}</h2>
|
||||
<p class="text-gray-700 mb-3">
|
||||
If you are a resident of California or another US state with applicable privacy legislation
|
||||
(including Virginia VCDPA, Colorado CPA, Connecticut CTDPA, Utah UCPA), the following additional
|
||||
disclosures apply:
|
||||
{{ _("privacy.s11_p1") }}
|
||||
</p>
|
||||
<ul class="list-disc list-inside text-gray-700 ml-4 mb-3">
|
||||
<li class="mb-2"><strong>Categories of personal information collected:</strong> Identifiers (name, email),
|
||||
account authentication tokens, and document metadata that you choose to upload.</li>
|
||||
<li class="mb-2"><strong>Purpose of collection:</strong> Providing, improving, and securing the
|
||||
DocuElevate service. We do not sell or share personal information for cross-context behavioural
|
||||
advertising.</li>
|
||||
<li class="mb-2"><strong>Right to Know:</strong> You may request disclosure of the categories and
|
||||
specific pieces of personal information we have collected about you.</li>
|
||||
<li class="mb-2"><strong>Right to Delete:</strong> You may request deletion of personal information we
|
||||
have collected, subject to certain exceptions.</li>
|
||||
<li class="mb-2"><strong>Right to Correct:</strong> You may request correction of inaccurate personal
|
||||
information.</li>
|
||||
<li class="mb-2"><strong>Right to Opt Out of Sale / Sharing:</strong> We do not sell or share personal
|
||||
information as defined by CCPA/CPRA. No opt-out mechanism is required; however, you may contact
|
||||
us to confirm this.</li>
|
||||
<li class="mb-2"><strong>Right to Limit Use of Sensitive Personal Information:</strong> We do not use
|
||||
sensitive personal information beyond what is necessary to provide the service.</li>
|
||||
<li><strong>Non-Discrimination:</strong> We will not discriminate against you for exercising any of
|
||||
these rights.</li>
|
||||
<li class="mb-2"><strong>{{ _("privacy.s11_categories_label") }}</strong> {{ _("privacy.s11_categories_body") }}</li>
|
||||
<li class="mb-2"><strong>{{ _("privacy.s11_purpose_label") }}</strong> {{ _("privacy.s11_purpose_body") }}</li>
|
||||
<li class="mb-2"><strong>{{ _("privacy.s11_know_label") }}</strong> {{ _("privacy.s11_know_body") }}</li>
|
||||
<li class="mb-2"><strong>{{ _("privacy.s11_delete_label") }}</strong> {{ _("privacy.s11_delete_body") }}</li>
|
||||
<li class="mb-2"><strong>{{ _("privacy.s11_correct_label") }}</strong> {{ _("privacy.s11_correct_body") }}</li>
|
||||
<li class="mb-2"><strong>{{ _("privacy.s11_optout_label") }}</strong> {{ _("privacy.s11_optout_body") }}</li>
|
||||
<li class="mb-2"><strong>{{ _("privacy.s11_limit_label") }}</strong> {{ _("privacy.s11_limit_body") }}</li>
|
||||
<li><strong>{{ _("privacy.s11_nondiscrim_label") }}</strong> {{ _("privacy.s11_nondiscrim_body") }}</li>
|
||||
</ul>
|
||||
<p class="text-gray-700">
|
||||
To submit a verifiable consumer request, contact us at
|
||||
{{ _("privacy.s11_contact") }}
|
||||
<a href="mailto:docuelevate@christian-louis.de" class="text-blue-600 hover:underline">docuelevate@christian-louis.de</a>.
|
||||
We will respond within 45 days (extendable by an additional 45 days when reasonably necessary).
|
||||
{{ _("privacy.s11_response") }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="canada-rights" class="bg-white shadow rounded-lg p-6 mb-6">
|
||||
<h2 class="text-2xl font-semibold mb-4">12. Additional Rights – Canada (PIPEDA / Québec Law 25)</h2>
|
||||
<h2 class="text-2xl font-semibold mb-4">{{ _("privacy.s12_heading") }}</h2>
|
||||
<p class="text-gray-700 mb-3">
|
||||
If you are located in Canada, the following applies under the Personal Information Protection and
|
||||
Electronic Documents Act (PIPEDA) and applicable provincial legislation (including Québec Law 25 /
|
||||
Bill 64):
|
||||
{{ _("privacy.s12_p1") }}
|
||||
</p>
|
||||
<ul class="list-disc list-inside text-gray-700 ml-4 mb-3">
|
||||
<li class="mb-2">We collect, use, and disclose personal information only with your knowledge and
|
||||
consent, or as permitted by law.</li>
|
||||
<li class="mb-2"><strong>Right of Access:</strong> You may request access to your personal information
|
||||
and information about how it has been used or disclosed.</li>
|
||||
<li class="mb-2"><strong>Right to Correction:</strong> You may challenge the accuracy or completeness
|
||||
of your personal information and request correction.</li>
|
||||
<li class="mb-2"><strong>Right to Withdraw Consent:</strong> Subject to legal or contractual
|
||||
restrictions, you may withdraw consent to the collection, use, or disclosure of your personal
|
||||
information on reasonable notice.</li>
|
||||
<li class="mb-2"><strong>Québec residents:</strong> Under Law 25, you have additional rights including
|
||||
the right to data portability (effective September 2023) and the right to de-indexation where
|
||||
personal information is disseminated online.</li>
|
||||
<li class="mb-2">{{ _("privacy.s12_li1") }}</li>
|
||||
<li class="mb-2"><strong>{{ _("privacy.s12_access_label") }}</strong> {{ _("privacy.s12_access_body") }}</li>
|
||||
<li class="mb-2"><strong>{{ _("privacy.s12_correction_label") }}</strong> {{ _("privacy.s12_correction_body") }}</li>
|
||||
<li class="mb-2"><strong>{{ _("privacy.s12_withdraw_label") }}</strong> {{ _("privacy.s12_withdraw_body") }}</li>
|
||||
<li class="mb-2"><strong>{{ _("privacy.s12_quebec_label") }}</strong> {{ _("privacy.s12_quebec_body") }}</li>
|
||||
</ul>
|
||||
<p class="text-gray-700">
|
||||
Direct privacy complaints to our Privacy Officer at
|
||||
<a href="mailto:docuelevate@christian-louis.de" class="text-blue-600 hover:underline">docuelevate@christian-louis.de</a>,
|
||||
or to the Office of the Privacy Commissioner of Canada.
|
||||
{{ _("privacy.s12_contact") }}
|
||||
<a href="mailto:docuelevate@christian-louis.de" class="text-blue-600 hover:underline">docuelevate@christian-louis.de</a>{{ _("privacy.s12_contact_post") }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="latam-rights" class="bg-white shadow rounded-lg p-6 mb-6">
|
||||
<h2 class="text-2xl font-semibold mb-4">13. Additional Rights – Latin America (LGPD & Others)</h2>
|
||||
<h2 class="text-2xl font-semibold mb-4">{{ _("privacy.s13_heading") }}</h2>
|
||||
<p class="text-gray-700 mb-3">
|
||||
<strong>Brazil (LGPD – Lei Geral de Proteção de Dados, Law 13.709/2018):</strong>
|
||||
If you are located in Brazil, you have the following rights under the LGPD:
|
||||
<strong>{{ _("privacy.s13_brazil_label") }}</strong>
|
||||
{{ _("privacy.s13_brazil_body") }}
|
||||
</p>
|
||||
<ul class="list-disc list-inside text-gray-700 ml-4 mb-3">
|
||||
<li class="mb-1">Confirmation of the existence of processing and access to your data.</li>
|
||||
<li class="mb-1">Correction of incomplete, inaccurate, or outdated data.</li>
|
||||
<li class="mb-1">Anonymisation, blocking, or deletion of unnecessary or excessive data.</li>
|
||||
<li class="mb-1">Portability of your data to another service or product provider.</li>
|
||||
<li class="mb-1">Deletion of personal data processed with your consent.</li>
|
||||
<li class="mb-1">Information about entities with which your data has been shared.</li>
|
||||
<li class="mb-1">Information about the possibility of not consenting and the consequences of refusal.</li>
|
||||
<li>Revocation of consent.</li>
|
||||
<li class="mb-1">{{ _("privacy.s13_li1") }}</li>
|
||||
<li class="mb-1">{{ _("privacy.s13_li2") }}</li>
|
||||
<li class="mb-1">{{ _("privacy.s13_li3") }}</li>
|
||||
<li class="mb-1">{{ _("privacy.s13_li4") }}</li>
|
||||
<li class="mb-1">{{ _("privacy.s13_li5") }}</li>
|
||||
<li class="mb-1">{{ _("privacy.s13_li6") }}</li>
|
||||
<li class="mb-1">{{ _("privacy.s13_li7") }}</li>
|
||||
<li>{{ _("privacy.s13_li8") }}</li>
|
||||
</ul>
|
||||
<p class="text-gray-700 mb-3">
|
||||
<strong>Other Latin American Countries:</strong> We also recognise applicable privacy laws in Argentina
|
||||
(PDPA), Mexico (LFPDPPP), Chile, Colombia (Ley 1581), and others. Users in these jurisdictions may
|
||||
exercise equivalent rights as outlined under their national law by contacting us.
|
||||
<strong>{{ _("privacy.s13_other_label") }}</strong> {{ _("privacy.s13_other_body") }}
|
||||
</p>
|
||||
<p class="text-gray-700">
|
||||
Contact:
|
||||
{{ _("privacy.s13_contact") }}
|
||||
<a href="mailto:docuelevate@christian-louis.de" class="text-blue-600 hover:underline">docuelevate@christian-louis.de</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="apj-rights" class="bg-white shadow rounded-lg p-6 mb-6">
|
||||
<h2 class="text-2xl font-semibold mb-4">14. Additional Rights – Asia-Pacific & Japan</h2>
|
||||
<h2 class="text-2xl font-semibold mb-4">{{ _("privacy.s14_heading") }}</h2>
|
||||
<p class="text-gray-700 mb-3">
|
||||
<strong>Japan (APPI – Act on the Protection of Personal Information):</strong>
|
||||
Japanese residents may request disclosure, correction, addition or deletion, suspension of use,
|
||||
erasure, or suspension of third-party provision of their personal information held by us.
|
||||
Third-party disclosures require your prior consent except where permitted by law.
|
||||
<strong>{{ _("privacy.s14_japan_label") }}</strong>
|
||||
{{ _("privacy.s14_japan_body") }}
|
||||
</p>
|
||||
<p class="text-gray-700 mb-3">
|
||||
<strong>Australia (Privacy Act 1988 and Australian Privacy Principles):</strong>
|
||||
Australian residents may request access to and correction of their personal information.
|
||||
We will respond to access requests within 30 days. Complaints may be lodged with the
|
||||
Office of the Australian Information Commissioner (OAIC).
|
||||
<strong>{{ _("privacy.s14_australia_label") }}</strong>
|
||||
{{ _("privacy.s14_australia_body") }}
|
||||
</p>
|
||||
<p class="text-gray-700 mb-3">
|
||||
<strong>South Korea (PIPA – Personal Information Protection Act):</strong>
|
||||
Korean residents may request access, correction, deletion, and suspension of processing.
|
||||
We handle personal information of Korean residents in accordance with the PIPA.
|
||||
<strong>{{ _("privacy.s14_korea_label") }}</strong>
|
||||
{{ _("privacy.s14_korea_body") }}
|
||||
</p>
|
||||
<p class="text-gray-700 mb-3">
|
||||
<strong>Other APJ markets (Singapore PDPA, New Zealand Privacy Act, India DPDP Act):</strong>
|
||||
We recognise the data protection rights afforded to residents of these jurisdictions under their
|
||||
respective national laws. Contact us to exercise your rights.
|
||||
<strong>{{ _("privacy.s14_apj_label") }}</strong>
|
||||
{{ _("privacy.s14_apj_body") }}
|
||||
</p>
|
||||
<p class="text-gray-700">
|
||||
Contact:
|
||||
{{ _("privacy.s14_contact") }}
|
||||
<a href="mailto:docuelevate@christian-louis.de" class="text-blue-600 hover:underline">docuelevate@christian-louis.de</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="ukraine-rights" class="bg-white shadow rounded-lg p-6 mb-6">
|
||||
<h2 class="text-2xl font-semibold mb-4">15. Additional Rights – Ukraine</h2>
|
||||
<h2 class="text-2xl font-semibold mb-4">{{ _("privacy.s15_heading") }}</h2>
|
||||
<p class="text-gray-700 mb-3">
|
||||
Users located in Ukraine are protected under the Law of Ukraine "On Personal Data Protection"
|
||||
(No. 2297-VI). Your rights include access to, correction, blocking, and deletion of your personal
|
||||
data, as well as the right to object to processing.
|
||||
{{ _("privacy.s15_p1") }}
|
||||
</p>
|
||||
<p class="text-gray-700">
|
||||
Contact:
|
||||
{{ _("privacy.s15_contact") }}
|
||||
<a href="mailto:docuelevate@christian-louis.de" class="text-blue-600 hover:underline">docuelevate@christian-louis.de</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="updates" class="bg-white shadow rounded-lg p-6 mb-6">
|
||||
<h2 class="text-2xl font-semibold mb-4">16. Updates to this Privacy Notice</h2>
|
||||
<h2 class="text-2xl font-semibold mb-4">{{ _("privacy.s16_heading") }}</h2>
|
||||
<p class="text-gray-700 mb-3">
|
||||
We may update this notice from time to time to reflect changes in our practices or applicable laws.
|
||||
The "Last Updated" date at the top of this page indicates when the notice was last revised.
|
||||
Where changes are material, we will notify users via in-application notification or email where
|
||||
appropriate.
|
||||
{{ _("privacy.s16_p1") }}
|
||||
</p>
|
||||
<p class="text-gray-700 mb-3">
|
||||
If you have any questions or concerns about this Privacy Notice or your personal data, please
|
||||
contact us at
|
||||
{{ _("privacy.s16_p2_pre") }}
|
||||
<a href="mailto:docuelevate@christian-louis.de" class="text-blue-600 hover:underline">docuelevate@christian-louis.de</a>.
|
||||
</p>
|
||||
<p class="text-gray-700">
|
||||
Please also review our <a href="/terms" class="text-blue-600 hover:underline">Terms of Service</a>,
|
||||
<a href="/cookies" class="text-blue-600 hover:underline">Cookie Policy</a>, and
|
||||
<a href="/license" class="text-blue-600 hover:underline">License Information</a>.
|
||||
{{ _("privacy.s16_p3_pre") }} <a href="/terms" class="text-blue-600 hover:underline">{{ _("privacy.s16_terms_link") }}</a>,
|
||||
<a href="/cookies" class="text-blue-600 hover:underline">{{ _("privacy.s16_cookies_link") }}</a>,
|
||||
and <a href="/license" class="text-blue-600 hover:underline">{{ _("privacy.s16_license_link") }}</a>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Profile Settings – DocuElevate{% endblock %}
|
||||
{% block title %}{{ _("profile.page_title") }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div
|
||||
@@ -12,10 +12,10 @@
|
||||
<header class="mb-8">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<i class="fas fa-user-circle text-blue-500" aria-hidden="true"></i>
|
||||
Profile Settings
|
||||
{{ _("profile.heading") }}
|
||||
</h1>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Manage your display name, avatar, language, and theme preferences.
|
||||
{{ _("profile.subtitle") }}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
type="button"
|
||||
@click="banner.visible = false"
|
||||
class="ml-auto"
|
||||
aria-label="Dismiss"
|
||||
aria-label="{{ _('profile.dismiss') }}"
|
||||
style="min-height:44px; min-width:44px;"
|
||||
>
|
||||
<i class="fas fa-times" aria-hidden="true"></i>
|
||||
@@ -49,7 +49,7 @@
|
||||
aria-labelledby="avatar-heading"
|
||||
>
|
||||
<h2 id="avatar-heading" class="text-base font-semibold text-gray-900 dark:text-white mb-4">
|
||||
<i class="fas fa-camera text-gray-400 mr-2" aria-hidden="true"></i>Profile Picture
|
||||
<i class="fas fa-camera text-gray-400 mr-2" aria-hidden="true"></i>{{ _("profile.avatar_heading") }}
|
||||
</h2>
|
||||
|
||||
<div class="flex flex-col sm:flex-row items-center gap-6">
|
||||
@@ -57,7 +57,7 @@
|
||||
<div class="relative flex-shrink-0">
|
||||
<img
|
||||
:src="avatarUrl"
|
||||
alt="Your profile picture"
|
||||
alt="{{ _('profile.avatar_alt') }}"
|
||||
class="w-24 h-24 rounded-full object-cover border-2 border-gray-200 dark:border-gray-600"
|
||||
/>
|
||||
<template x-if="hasCustomAvatar">
|
||||
@@ -66,8 +66,8 @@
|
||||
@click="removeAvatar()"
|
||||
:disabled="saving"
|
||||
class="absolute -top-1 -right-1 bg-red-500 hover:bg-red-600 text-white rounded-full w-6 h-6 flex items-center justify-center shadow focus:outline-none focus:ring-2 focus:ring-red-400"
|
||||
aria-label="Remove custom avatar"
|
||||
title="Remove custom avatar"
|
||||
aria-label="{{ _('profile.avatar_remove') }}"
|
||||
title="{{ _('profile.avatar_remove') }}"
|
||||
>
|
||||
<i class="fas fa-times text-xs" aria-hidden="true"></i>
|
||||
</button>
|
||||
@@ -77,8 +77,8 @@
|
||||
<!-- Upload controls -->
|
||||
<div class="flex-1 space-y-3">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Upload a JPEG, PNG, GIF, or WebP image up to 2 MB.
|
||||
If no custom picture is set, your <a href="https://gravatar.com" class="underline" target="_blank" rel="noopener noreferrer" aria-label="Gravatar (opens in new tab)">Gravatar</a> is shown.
|
||||
{% set gravatar_anchor %}<a href="https://gravatar.com" class="underline" target="_blank" rel="noopener noreferrer" aria-label="{{ _('profile.gravatar_link') }} (opens in new tab)">{{ _("profile.gravatar_link") }}</a>{% endset %}
|
||||
{{ _("profile.avatar_upload_hint", gravatar=gravatar_anchor) | safe }}
|
||||
</p>
|
||||
<label
|
||||
for="avatar-input"
|
||||
@@ -86,7 +86,7 @@
|
||||
style="min-height:44px;"
|
||||
>
|
||||
<i class="fas fa-upload" aria-hidden="true"></i>
|
||||
<span>Choose image…</span>
|
||||
<span>{{ _("profile.choose_image") }}</span>
|
||||
<input
|
||||
id="avatar-input"
|
||||
type="file"
|
||||
@@ -107,49 +107,49 @@
|
||||
aria-labelledby="general-heading"
|
||||
>
|
||||
<h2 id="general-heading" class="text-base font-semibold text-gray-900 dark:text-white mb-4">
|
||||
<i class="fas fa-id-card text-gray-400 mr-2" aria-hidden="true"></i>General Information
|
||||
<i class="fas fa-id-card text-gray-400 mr-2" aria-hidden="true"></i>{{ _("profile.general_heading") }}
|
||||
</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- Display name -->
|
||||
<div>
|
||||
<label for="display-name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Display Name
|
||||
{{ _("profile.display_name_label") }}
|
||||
</label>
|
||||
<input
|
||||
id="display-name"
|
||||
type="text"
|
||||
x-model="form.display_name"
|
||||
maxlength="255"
|
||||
placeholder="Your name as shown in the UI"
|
||||
placeholder="{{ _('profile.display_name_placeholder') }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
|
||||
style="min-height:44px;"
|
||||
aria-describedby="display-name-hint"
|
||||
/>
|
||||
<p id="display-name-hint" class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Leave blank to use your account username or email.
|
||||
{{ _("profile.display_name_hint") }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Contact email -->
|
||||
<div>
|
||||
<label for="contact-email" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Contact / Notification E-mail
|
||||
{{ _("profile.contact_email_label") }}
|
||||
</label>
|
||||
<input
|
||||
id="contact-email"
|
||||
type="email"
|
||||
x-model="form.contact_email"
|
||||
maxlength="255"
|
||||
placeholder="you@example.com"
|
||||
placeholder="{{ _('profile.contact_email_placeholder') }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
|
||||
style="min-height:44px;"
|
||||
aria-describedby="contact-email-hint"
|
||||
/>
|
||||
<p id="contact-email-hint" class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Used for system notifications. This does not change your login e-mail.
|
||||
{{ _("profile.contact_email_hint") }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -161,14 +161,14 @@
|
||||
aria-labelledby="prefs-heading"
|
||||
>
|
||||
<h2 id="prefs-heading" class="text-base font-semibold text-gray-900 dark:text-white mb-4">
|
||||
<i class="fas fa-sliders-h text-gray-400 mr-2" aria-hidden="true"></i>Preferences
|
||||
<i class="fas fa-sliders-h text-gray-400 mr-2" aria-hidden="true"></i>{{ _("profile.preferences_heading") }}
|
||||
</h2>
|
||||
|
||||
<div class="space-y-5">
|
||||
<!-- Language -->
|
||||
<div>
|
||||
<label for="lang-select" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
<i class="fas fa-globe text-gray-400 mr-1" aria-hidden="true"></i>UI Language
|
||||
<i class="fas fa-globe text-gray-400 mr-1" aria-hidden="true"></i>{{ _("profile.language_label") }}
|
||||
</label>
|
||||
<select
|
||||
id="lang-select"
|
||||
@@ -177,20 +177,20 @@
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
|
||||
style="min-height:44px;"
|
||||
>
|
||||
<option value="">Auto-detect (from browser)</option>
|
||||
<option value="">{{ _("profile.language_auto_detect") }}</option>
|
||||
{% for lang in supported_languages %}
|
||||
<option value="{{ lang.code }}">{{ lang.flag }} {{ lang.native }} ({{ lang.name }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Choose your preferred interface language. "Auto-detect" follows your browser settings.
|
||||
{{ _("profile.language_hint") }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Theme -->
|
||||
<fieldset>
|
||||
<legend class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
<i class="fas fa-palette text-gray-400 mr-1" aria-hidden="true"></i>Colour Scheme
|
||||
<i class="fas fa-palette text-gray-400 mr-1" aria-hidden="true"></i>{{ _("profile.theme_label") }}
|
||||
</legend>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<label
|
||||
@@ -202,7 +202,7 @@
|
||||
style="min-height:44px; min-width:44px;"
|
||||
>
|
||||
<input type="radio" name="theme" value="light" x-model="form.preferred_theme" class="sr-only" />
|
||||
<i class="fas fa-sun" aria-hidden="true"></i> Light
|
||||
<i class="fas fa-sun" aria-hidden="true"></i> {{ _("profile.theme_light") }}
|
||||
</label>
|
||||
<label
|
||||
class="relative flex items-center gap-2 cursor-pointer rounded-lg border px-4 py-3 text-sm transition-colors
|
||||
@@ -213,7 +213,7 @@
|
||||
style="min-height:44px; min-width:44px;"
|
||||
>
|
||||
<input type="radio" name="theme" value="dark" x-model="form.preferred_theme" class="sr-only" />
|
||||
<i class="fas fa-moon" aria-hidden="true"></i> Dark
|
||||
<i class="fas fa-moon" aria-hidden="true"></i> {{ _("profile.theme_dark") }}
|
||||
</label>
|
||||
<label
|
||||
class="relative flex items-center gap-2 cursor-pointer rounded-lg border px-4 py-3 text-sm transition-colors
|
||||
@@ -224,11 +224,11 @@
|
||||
style="min-height:44px; min-width:44px;"
|
||||
>
|
||||
<input type="radio" name="theme" value="system" x-model="form.preferred_theme" class="sr-only" />
|
||||
<i class="fas fa-desktop" aria-hidden="true"></i> System Default
|
||||
<i class="fas fa-desktop" aria-hidden="true"></i> {{ _("profile.theme_system") }}
|
||||
</label>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
"System Default" follows your device's dark-mode setting.
|
||||
{{ _("profile.theme_hint") }}
|
||||
</p>
|
||||
</fieldset>
|
||||
</div>
|
||||
@@ -245,7 +245,7 @@
|
||||
style="min-height:44px; min-width:44px;"
|
||||
>
|
||||
<i class="fas fa-save" aria-hidden="true"></i>
|
||||
<span x-text="saving ? 'Saving…' : 'Save Changes'"></span>
|
||||
<span x-text="saving ? '{{ _('profile.saving') }}' : '{{ _('profile.save_button') }}'"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -256,13 +256,13 @@
|
||||
aria-labelledby="password-heading"
|
||||
>
|
||||
<h2 id="password-heading" class="text-base font-semibold text-gray-900 dark:text-white mb-4">
|
||||
<i class="fas fa-lock text-gray-400 mr-2" aria-hidden="true"></i>Change Password
|
||||
<i class="fas fa-lock text-gray-400 mr-2" aria-hidden="true"></i>{{ _("profile.password_heading") }}
|
||||
</h2>
|
||||
|
||||
<div class="space-y-4 max-w-md">
|
||||
<div>
|
||||
<label for="current-password" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Current Password
|
||||
{{ _("profile.current_password") }}
|
||||
</label>
|
||||
<input
|
||||
id="current-password"
|
||||
@@ -276,7 +276,7 @@
|
||||
</div>
|
||||
<div>
|
||||
<label for="new-password" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
New Password
|
||||
{{ _("profile.new_password") }}
|
||||
</label>
|
||||
<input
|
||||
id="new-password"
|
||||
@@ -288,11 +288,11 @@
|
||||
style="min-height:44px;"
|
||||
aria-describedby="new-pw-hint"
|
||||
/>
|
||||
<p id="new-pw-hint" class="mt-1 text-xs text-gray-500 dark:text-gray-400">Minimum 8 characters.</p>
|
||||
<p id="new-pw-hint" class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ _("profile.new_password_hint") }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="confirm-password" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Confirm New Password
|
||||
{{ _("profile.confirm_password") }}
|
||||
</label>
|
||||
<input
|
||||
id="confirm-password"
|
||||
@@ -314,7 +314,7 @@
|
||||
style="min-height:44px;"
|
||||
>
|
||||
<i class="fas fa-key" aria-hidden="true"></i>
|
||||
<span x-text="pwSaving ? 'Updating…' : 'Update Password'"></span>
|
||||
<span x-text="pwSaving ? '{{ _('profile.updating') }}' : '{{ _('profile.update_password') }}'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -327,7 +327,7 @@
|
||||
function profileSettings() {
|
||||
return {
|
||||
// ── State ──────────────────────────────────────────────────────────────
|
||||
avatarUrl: '/static/images/avatar-placeholder.svg',
|
||||
avatarUrl: '/static/images/default-avatar.svg',
|
||||
hasCustomAvatar: false,
|
||||
isLocalUser: false,
|
||||
saving: false,
|
||||
@@ -349,6 +349,8 @@ function profileSettings() {
|
||||
},
|
||||
|
||||
// ── Init ───────────────────────────────────────────────────────────────
|
||||
_initialLanguage: '',
|
||||
|
||||
async init() {
|
||||
try {
|
||||
const res = await fetch('/api/profile');
|
||||
@@ -361,6 +363,7 @@ function profileSettings() {
|
||||
this.form.contact_email = data.contact_email || '';
|
||||
this.form.preferred_language = data.preferred_language || '';
|
||||
this.form.preferred_theme = data.preferred_theme || 'system';
|
||||
this._initialLanguage = this.form.preferred_language;
|
||||
} catch (_e) {
|
||||
// Silently ignore — user might not be logged in (rare for this page)
|
||||
}
|
||||
@@ -378,6 +381,7 @@ function profileSettings() {
|
||||
async saveProfile() {
|
||||
this.saving = true;
|
||||
this._hideBanner();
|
||||
const previousLang = this._initialLanguage || '';
|
||||
try {
|
||||
const csrfToken = this._getCSRFToken();
|
||||
|
||||
@@ -394,6 +398,11 @@ function profileSettings() {
|
||||
this._showBanner(data.detail || 'Failed to save settings.', true);
|
||||
return;
|
||||
}
|
||||
// If the UI language changed, reload so the page renders in the new locale
|
||||
if (this.form.preferred_language !== previousLang) {
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
this._showBanner('Profile settings saved successfully.', false);
|
||||
// Update display name across the nav (refresh the avatar/name)
|
||||
if (data.avatar_url) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}System Status{% endblock %}
|
||||
{% block title %}{{ _("status.page_title") }}{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<!-- Font Awesome is loaded in base.html -->
|
||||
@@ -8,25 +8,25 @@
|
||||
{% block content %}
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold mb-2">System Status</h1>
|
||||
<h1 class="text-3xl font-bold mb-2">{{ _("status.page_title") }}</h1>
|
||||
<p class="text-gray-600">
|
||||
This dashboard shows the status of all configured integrations and targets.
|
||||
{{ _("status.dashboard_subtitle") }}
|
||||
</p>
|
||||
<div class="bg-blue-100 border-l-4 border-blue-500 text-blue-700 p-4 my-4" role="alert">
|
||||
<p><strong>App Version:</strong> {{ app_version }}{% if release_name %} <span class="italic">"{{ release_name }}"</span>{% endif %}</p>
|
||||
<p><strong>Build Date:</strong> {{ build_date }}</p>
|
||||
<p><strong>Debug Mode:</strong> {{ "Enabled" if debug_enabled else "Disabled" }}</p>
|
||||
<p><strong>{{ _("status.app_version") }}:</strong> {{ app_version }}{% if release_name %} <span class="italic">"{{ release_name }}"</span>{% endif %}</p>
|
||||
<p><strong>{{ _("status.build_date") }}:</strong> {{ build_date }}</p>
|
||||
<p><strong>{{ _("status.debug_mode") }}:</strong> {{ _("common.enabled") if debug_enabled else _("common.disabled") }}</p>
|
||||
{% if last_check %}
|
||||
<p><strong>Last Check:</strong> {{ last_check }}</p>
|
||||
<p><strong>{{ _("status.last_check") }}:</strong> {{ last_check }}</p>
|
||||
{% endif %}
|
||||
{% if container_info.is_docker %}
|
||||
<p><strong>Container ID:</strong> {{ container_info.id }}</p>
|
||||
<p><strong>{{ _("status.container_id") }}:</strong> {{ container_info.id }}</p>
|
||||
{% endif %}
|
||||
{% if container_info.git_sha and container_info.git_sha != 'Unknown' %}
|
||||
<p><strong>Git Commit:</strong> {{ container_info.git_sha }}</p>
|
||||
<p><strong>{{ _("status.git_commit") }}:</strong> {{ container_info.git_sha }}</p>
|
||||
{% endif %}
|
||||
{% if container_info.runtime_info %}
|
||||
<p><strong>Container Started:</strong> {{ container_info.runtime_info }}</p>
|
||||
<p><strong>{{ _("status.container_started") }}:</strong> {{ container_info.runtime_info }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -44,11 +44,11 @@
|
||||
</div>
|
||||
{% if provider.configured %}
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800">
|
||||
Configured
|
||||
{{ _("status.configured") }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-red-100 text-red-800">
|
||||
Not Configured
|
||||
{{ _("status.not_configured") }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -81,14 +81,14 @@
|
||||
<svg class="-ml-0.5 mr-1.5 h-2 w-2 text-green-400" fill="currentColor" viewBox="0 0 8 8">
|
||||
<circle cx="4" cy="4" r="3" />
|
||||
</svg>
|
||||
Active
|
||||
{{ _("status.active") }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-md text-sm font-medium bg-gray-100 text-gray-800">
|
||||
<svg class="-ml-0.5 mr-1.5 h-2 w-2 text-gray-400" fill="currentColor" viewBox="0 0 8 8">
|
||||
<circle cx="4" cy="4" r="3" />
|
||||
</svg>
|
||||
Inactive
|
||||
{{ _("status.inactive") }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -100,7 +100,7 @@
|
||||
class="view-details-btn inline-flex items-center px-2.5 py-1.5 border border-gray-300 text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
data-provider="{{ name }}"
|
||||
data-details="{{ provider.details|tojson|forceescape }}">
|
||||
View Details
|
||||
{{ _("status.view_details") }}
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
@@ -111,7 +111,7 @@
|
||||
class="test-generic-btn inline-flex items-center px-2.5 py-1.5 border border-gray-300 text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
data-endpoint="{{ provider.test_endpoint }}"
|
||||
data-method="POST">
|
||||
Test Notifications
|
||||
{{ _("status.test_notifications") }}
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
@@ -121,7 +121,7 @@
|
||||
class="test-generic-btn inline-flex items-center px-2.5 py-1.5 border border-gray-300 text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
data-endpoint="{{ provider.test_endpoint }}"
|
||||
data-method="{{ provider.test_method|default('GET') }}">
|
||||
Test {{ name }}
|
||||
{{ _("status.test_provider", name=name) }}
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
@@ -131,15 +131,15 @@
|
||||
<button
|
||||
class="test-provider-btn inline-flex items-center px-2.5 py-1.5 border border-gray-300 text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
data-provider="dropbox">
|
||||
Test Connection
|
||||
{{ _("status.test_connection") }}
|
||||
</button>
|
||||
<a href="/dropbox-setup" class="inline-flex items-center px-2.5 py-1.5 border border-transparent text-xs font-medium rounded text-indigo-700 bg-indigo-100 hover:bg-indigo-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||
<i class="fa-solid fa-gear h-3 w-3 mr-1"></i>
|
||||
Manage
|
||||
<i class="fa-solid fa-gear h-3 w-3 mr-1" aria-hidden="true"></i>
|
||||
{{ _("status.manage") }}
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="/dropbox-setup" class="inline-flex items-center px-2.5 py-1.5 border border-transparent text-xs font-medium rounded text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||
Configure Now
|
||||
{{ _("status.configure_now") }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% elif name == "OneDrive" %}
|
||||
@@ -147,15 +147,15 @@
|
||||
<button
|
||||
class="test-provider-btn inline-flex items-center px-2.5 py-1.5 border border-gray-300 text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
data-provider="onedrive">
|
||||
Test Connection
|
||||
{{ _("status.test_connection") }}
|
||||
</button>
|
||||
<a href="/onedrive-setup" class="inline-flex items-center px-2.5 py-1.5 border border-transparent text-xs font-medium rounded text-indigo-700 bg-indigo-100 hover:bg-indigo-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||
<i class="fa-solid fa-gear h-3 w-3 mr-1"></i>
|
||||
Manage
|
||||
<i class="fa-solid fa-gear h-3 w-3 mr-1" aria-hidden="true"></i>
|
||||
{{ _("status.manage") }}
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="/onedrive-setup" class="inline-flex items-center px-2.5 py-1.5 border border-transparent text-xs font-medium rounded text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||
Configure Now
|
||||
{{ _("status.configure_now") }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% elif name == "Google Drive" %}
|
||||
@@ -163,15 +163,15 @@
|
||||
<button
|
||||
class="test-provider-btn inline-flex items-center px-2.5 py-1.5 border border-gray-300 text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
data-provider="google_drive">
|
||||
Test Connection
|
||||
{{ _("status.test_connection") }}
|
||||
</button>
|
||||
<a href="/google-drive-setup" class="inline-flex items-center px-2.5 py-1.5 border border-transparent text-xs font-medium rounded text-indigo-700 bg-indigo-100 hover:bg-indigo-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||
<i class="fa-solid fa-gear h-3 w-3 mr-1"></i>
|
||||
Manage
|
||||
<i class="fa-solid fa-gear h-3 w-3 mr-1" aria-hidden="true"></i>
|
||||
{{ _("status.manage") }}
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="/google-drive-setup" class="inline-flex items-center px-2.5 py-1.5 border border-transparent text-xs font-medium rounded text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||
Configure Now
|
||||
{{ _("status.configure_now") }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% elif name == "AI Provider" %}
|
||||
@@ -179,13 +179,13 @@
|
||||
<button
|
||||
class="test-provider-btn inline-flex items-center px-2.5 py-1.5 border border-gray-300 text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
data-provider="ai_provider">
|
||||
Test Connection
|
||||
{{ _("status.test_connection") }}
|
||||
</button>
|
||||
<button
|
||||
id="testAiExtractionBtn"
|
||||
class="inline-flex items-center px-2.5 py-1.5 border border-transparent text-xs font-medium rounded text-indigo-700 bg-indigo-100 hover:bg-indigo-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||
<i class="fa-solid fa-flask mr-1"></i>
|
||||
Test Extraction
|
||||
<i class="fa-solid fa-flask mr-1" aria-hidden="true"></i>
|
||||
{{ _("status.test_extraction") }}
|
||||
</button>
|
||||
{% endif %}
|
||||
{% elif name == "Azure AI" %}
|
||||
@@ -193,20 +193,20 @@
|
||||
<button
|
||||
class="test-provider-btn inline-flex items-center px-2.5 py-1.5 border border-gray-300 text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
data-provider="azure">
|
||||
Test Connection
|
||||
{{ _("status.test_connection") }}
|
||||
</button>
|
||||
{% endif %}
|
||||
{% elif provider.configured and name == "Paperless-ngx" %}
|
||||
<a href="{{ provider.details.host }}" target="_blank" class="inline-flex items-center px-2.5 py-1.5 border border-gray-300 text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||
<i class="fa-solid fa-external-link h-3 w-3 mr-1"></i>
|
||||
Open
|
||||
<i class="fa-solid fa-external-link h-3 w-3 mr-1" aria-hidden="true"></i>
|
||||
{{ _("status.open") }}
|
||||
</a>
|
||||
{% elif provider.configured and name == "NextCloud" %}
|
||||
{% if provider.details and provider.details.url %}
|
||||
{% set nextcloud_base_url = provider.details.url.split('/remote.php')[0] %}
|
||||
<a href="{{ nextcloud_base_url }}" target="_blank" class="inline-flex items-center px-2.5 py-1.5 border border-gray-300 text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||
<i class="fa-solid fa-external-link h-3 w-3 mr-1"></i>
|
||||
Open
|
||||
<i class="fa-solid fa-external-link h-3 w-3 mr-1" aria-hidden="true"></i>
|
||||
{{ _("status.open") }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
@@ -219,14 +219,14 @@
|
||||
</div>
|
||||
|
||||
<div class="mt-8 bg-gray-50 p-4 rounded-lg border border-gray-200">
|
||||
<h3 class="text-lg font-medium text-gray-900">Configuration Settings</h3>
|
||||
<h3 class="text-lg font-medium text-gray-900">{{ _("status.config_settings") }}</h3>
|
||||
<p class="text-sm text-gray-600 mt-1">
|
||||
For more detailed configuration settings and environment variables, check the settings page.
|
||||
{{ _("status.config_settings_desc") }}
|
||||
</p>
|
||||
|
||||
<div class="mt-4">
|
||||
<a href="/settings" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||
View Detailed Configuration
|
||||
{{ _("status.view_config") }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -238,15 +238,15 @@
|
||||
<div id="modalIcon" class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100 mb-4">
|
||||
<!-- Icon will be injected by JS -->
|
||||
</div>
|
||||
<h3 id="modalTitle" class="text-lg leading-6 font-medium text-gray-900">Success</h3>
|
||||
<h3 id="modalTitle" class="text-lg leading-6 font-medium text-gray-900">{{ _("status.modal_default_title") }}</h3>
|
||||
<div class="mt-2 px-7 py-3">
|
||||
<p id="modalMessage" class="text-sm text-gray-500">
|
||||
Operation completed successfully.
|
||||
{{ _("status.modal_default_message") }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="items-center px-4 py-3">
|
||||
<button id="modalClose" class="px-4 py-2 bg-blue-500 text-white text-base font-medium rounded-md w-full shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-300">
|
||||
Close
|
||||
{{ _("common.close") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -258,12 +258,12 @@
|
||||
<div class="relative top-20 mx-auto p-5 border w-11/12 md:w-2/3 lg:w-1/2 shadow-lg rounded-md bg-white">
|
||||
<div class="absolute top-0 right-0 pt-4 pr-4">
|
||||
<button type="button" id="closeDetailsModal" class="text-gray-400 hover:text-gray-500">
|
||||
<span class="sr-only">Close</span>
|
||||
<i class="fa-solid fa-xmark h-6 w-6"></i>
|
||||
<span class="sr-only">{{ _("common.close") }}</span>
|
||||
<i class="fa-solid fa-xmark h-6 w-6" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<h3 id="detailsModalTitle" class="text-lg leading-6 font-medium text-gray-900 mb-4">Provider Details</h3>
|
||||
<h3 id="detailsModalTitle" class="text-lg leading-6 font-medium text-gray-900 mb-4">{{ _("status.provider_details") }}</h3>
|
||||
<div class="mt-4">
|
||||
<dl class="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2">
|
||||
<div id="detailsContent" class="col-span-2">
|
||||
@@ -273,7 +273,7 @@
|
||||
</div>
|
||||
<div class="mt-6 flex justify-end">
|
||||
<button type="button" id="closeDetailsBtn" class="px-4 py-2 bg-indigo-600 text-white text-sm font-medium rounded-md shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-600">
|
||||
Close
|
||||
{{ _("common.close") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -285,35 +285,34 @@
|
||||
<div class="relative top-10 mx-auto p-5 border w-11/12 md:w-3/4 lg:w-2/3 xl:w-1/2 shadow-lg rounded-md bg-white">
|
||||
<div class="absolute top-0 right-0 pt-4 pr-4">
|
||||
<button type="button" id="closeAiExtractionModal" class="text-gray-400 hover:text-gray-500">
|
||||
<span class="sr-only">Close</span>
|
||||
<i class="fa-solid fa-xmark h-6 w-6"></i>
|
||||
<span class="sr-only">{{ _("common.close") }}</span>
|
||||
<i class="fa-solid fa-xmark h-6 w-6" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<h3 id="aiExtractionModalTitle" class="text-lg leading-6 font-medium text-gray-900 mb-1">
|
||||
<i class="fa-solid fa-flask mr-2 text-indigo-600"></i>AI Extraction Test
|
||||
<i class="fa-solid fa-flask mr-2 text-indigo-600" aria-hidden="true"></i>{{ _("status.ai_extraction_title") }}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500 mb-4">
|
||||
Paste the plain-text content of a document below and run it through the configured AI provider
|
||||
to inspect the raw response, extracted JSON, and tags.
|
||||
{{ _("status.ai_extraction_desc") }}
|
||||
</p>
|
||||
|
||||
<!-- Input area -->
|
||||
<div id="aiExtractionInput">
|
||||
<label for="aiExtractionText" class="block text-sm font-medium text-gray-700 mb-1">Document Text</label>
|
||||
<label for="aiExtractionText" class="block text-sm font-medium text-gray-700 mb-1">{{ _("status.ai_extraction_label") }}</label>
|
||||
<textarea
|
||||
id="aiExtractionText"
|
||||
rows="10"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-mono focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
|
||||
placeholder="Paste the plain-text content of your document here…"></textarea>
|
||||
placeholder="{{ _('status.ai_extraction_placeholder') }}"></textarea>
|
||||
<div class="mt-3 flex justify-end space-x-2">
|
||||
<button type="button" id="cancelAiExtractionBtn"
|
||||
class="px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||
Cancel
|
||||
{{ _("common.cancel") }}
|
||||
</button>
|
||||
<button type="button" id="runAiExtractionBtn"
|
||||
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||
<i class="fa-solid fa-play mr-2"></i>Run Extraction
|
||||
<i class="fa-solid fa-play mr-2" aria-hidden="true"></i>{{ _("status.run_extraction") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -328,30 +327,30 @@
|
||||
|
||||
<!-- Raw response -->
|
||||
<div>
|
||||
<h4 class="text-sm font-medium text-gray-900 mb-1">Raw LLM Response</h4>
|
||||
<h4 class="text-sm font-medium text-gray-900 mb-1">{{ _("status.raw_llm_response") }}</h4>
|
||||
<pre id="aiRawResponse" class="bg-gray-50 border border-gray-200 rounded p-3 text-xs overflow-auto max-h-48 whitespace-pre-wrap break-words"></pre>
|
||||
</div>
|
||||
|
||||
<!-- Parsed JSON -->
|
||||
<div id="aiParsedJsonSection">
|
||||
<h4 class="text-sm font-medium text-gray-900 mb-1">Parsed JSON</h4>
|
||||
<h4 class="text-sm font-medium text-gray-900 mb-1">{{ _("status.parsed_json_label") }}</h4>
|
||||
<pre id="aiParsedJson" class="bg-gray-50 border border-gray-200 rounded p-3 text-xs overflow-auto max-h-64 whitespace-pre-wrap break-words"></pre>
|
||||
</div>
|
||||
|
||||
<!-- Tags -->
|
||||
<div id="aiTagsSection">
|
||||
<h4 class="text-sm font-medium text-gray-900 mb-1">Extracted Tags</h4>
|
||||
<h4 class="text-sm font-medium text-gray-900 mb-1">{{ _("status.extracted_tags") }}</h4>
|
||||
<div id="aiTags" class="flex flex-wrap gap-2"></div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-2 pt-2">
|
||||
<button type="button" id="aiExtractionBackBtn"
|
||||
class="px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||
<i class="fa-solid fa-arrow-left mr-1"></i>Back
|
||||
<i class="fa-solid fa-arrow-left mr-1" aria-hidden="true"></i>{{ _("common.back") }}
|
||||
</button>
|
||||
<button type="button" id="aiExtractionCloseBtn"
|
||||
class="px-4 py-2 bg-indigo-600 text-white text-sm font-medium rounded-md shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-600">
|
||||
Close
|
||||
{{ _("common.close") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -363,6 +362,37 @@
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
/* Translated strings for JavaScript use */
|
||||
const i18nStrings = {
|
||||
providerConfigDetails: {{ _("status.provider_config_details") | tojson }},
|
||||
noDetails: {{ _("status.no_details") | tojson }},
|
||||
settingLabel: {{ _("status.setting_label") | tojson }},
|
||||
valueLabel: {{ _("status.value_label") | tojson }},
|
||||
testNotificationSent: {{ _("status.test_notification_sent") | tojson }},
|
||||
notificationConfigMissing: {{ _("status.notification_config_missing") | tojson }},
|
||||
testNotificationFailed: {{ _("status.test_notification_failed") | tojson }},
|
||||
errorTestingNotifications: {{ _("status.error_testing_notifications") | tojson }},
|
||||
testSuccessful: {{ _("status.test_successful") | tojson }},
|
||||
tokenValidFor: {{ _("status.token_valid_for") | tojson }},
|
||||
warning: {{ _("common.warning") | tojson }},
|
||||
authRequired: {{ _("status.auth_required") | tojson }},
|
||||
tokenExpired: {{ _("status.token_expired") | tojson }},
|
||||
testFailed: {{ _("status.test_failed") | tojson }},
|
||||
connectionError: {{ _("status.connection_error") | tojson }},
|
||||
connectionTestSuccessful: {{ _("status.connection_test_successful") | tojson }},
|
||||
connectionTestFailed: {{ _("status.connection_test_failed") | tojson }},
|
||||
errorTestingConnection: {{ _("status.error_testing_connection") | tojson }},
|
||||
sending: {{ _("status.sending") | tojson }},
|
||||
testing: {{ _("status.testing") | tojson }},
|
||||
jsonParseIssue: {{ _("status.json_parse_issue") | tojson }},
|
||||
aiExtractionFailed: {{ _("status.ai_extraction_failed") | tojson }},
|
||||
errorRunningExtraction: {{ _("status.error_running_extraction") | tojson }},
|
||||
running: {{ _("status.running") | tojson }},
|
||||
aiEmptyResponse: {{ _("status.ai_empty_response") | tojson }},
|
||||
asAccount: {{ _("status.as_account") | tojson }},
|
||||
configureNow: {{ _("status.configure_now") | tojson }},
|
||||
};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Modal elements
|
||||
const resultModal = document.getElementById('resultModal');
|
||||
@@ -400,7 +430,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
|
||||
function showDetailsModal(providerName, details) {
|
||||
detailsModalTitle.textContent = providerName + ' Configuration Details';
|
||||
detailsModalTitle.textContent = i18nStrings.providerConfigDetails.replace('{name}', providerName);
|
||||
|
||||
// Clear previous content
|
||||
detailsContent.innerHTML = '';
|
||||
@@ -414,8 +444,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
thead.className = 'bg-gray-50';
|
||||
thead.innerHTML = `
|
||||
<tr>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Setting</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Value</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">${i18nStrings.settingLabel}</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">${i18nStrings.valueLabel}</th>
|
||||
</tr>
|
||||
`;
|
||||
|
||||
@@ -456,7 +486,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
table.appendChild(tbody);
|
||||
detailsContent.appendChild(table);
|
||||
} else {
|
||||
detailsContent.innerHTML = '<p class="text-sm text-gray-500">No details available</p>';
|
||||
detailsContent.innerHTML = `<p class="text-sm text-gray-500">${i18nStrings.noDetails}</p>`;
|
||||
}
|
||||
|
||||
detailsModal.classList.remove('hidden');
|
||||
@@ -510,7 +540,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
testNotificationBtn.addEventListener('click', function() {
|
||||
const originalText = this.innerHTML;
|
||||
|
||||
this.innerHTML = '<i class="fa-solid fa-spinner fa-spin mr-2"></i> Sending...';
|
||||
this.innerHTML = `<i class="fa-solid fa-spinner fa-spin mr-2"></i> ${i18nStrings.sending}`;
|
||||
this.disabled = true;
|
||||
|
||||
fetch('/api/diagnostic/test-notification', {
|
||||
@@ -519,15 +549,15 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.status === 'success') {
|
||||
showModal('success', 'Test Notification Sent', data.message);
|
||||
showModal('success', i18nStrings.testNotificationSent, data.message);
|
||||
} else if (data.status === 'warning') {
|
||||
showModal('error', 'Notification Configuration Missing', data.message);
|
||||
showModal('error', i18nStrings.notificationConfigMissing, data.message);
|
||||
} else {
|
||||
showModal('error', 'Test Notification Failed', data.message);
|
||||
showModal('error', i18nStrings.testNotificationFailed, data.message);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
showModal('error', 'Connection Error', 'Error testing notifications: ' + error.message);
|
||||
showModal('error', i18nStrings.connectionError, i18nStrings.errorTestingNotifications + error.message);
|
||||
})
|
||||
.finally(() => {
|
||||
// Always restore the button text and enable the button, regardless of success or failure
|
||||
@@ -545,7 +575,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
const endpoint = this.getAttribute('data-endpoint');
|
||||
const method = this.getAttribute('data-method') || 'GET';
|
||||
|
||||
this.innerHTML = '<i class="fa-solid fa-spinner fa-spin mr-2"></i> Testing...';
|
||||
this.innerHTML = `<i class="fa-solid fa-spinner fa-spin mr-2"></i> ${i18nStrings.testing}`;
|
||||
this.disabled = true;
|
||||
|
||||
fetch(endpoint, {
|
||||
@@ -558,28 +588,28 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
if (data.token_info && data.token_info.expires_in_human) {
|
||||
let message = data.message || 'Connection successful';
|
||||
message += `<br><br><div class="bg-blue-50 p-3 rounded mt-2">
|
||||
<span class="font-medium">Token valid for:</span> ${data.token_info.expires_in_human}
|
||||
<span class="font-medium">${i18nStrings.tokenValidFor}</span> ${data.token_info.expires_in_human}
|
||||
</div>`;
|
||||
|
||||
modalTitle.textContent = 'Test Successful';
|
||||
modalTitle.textContent = i18nStrings.testSuccessful;
|
||||
modalMessage.innerHTML = message;
|
||||
modalIcon.innerHTML = '<i class="fa-solid fa-check text-green-600 fa-2x"></i>';
|
||||
modalIcon.className = 'mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100 mb-4';
|
||||
resultModal.classList.remove('hidden');
|
||||
} else {
|
||||
// Regular success
|
||||
showModal('success', 'Test Successful', data.message + (data.account ? ' as ' + data.account : ''));
|
||||
showModal('success', i18nStrings.testSuccessful, data.message + (data.account ? ' ' + i18nStrings.asAccount + ' ' + data.account : ''));
|
||||
}
|
||||
} else if (data.status === 'warning') {
|
||||
showModal('error', 'Warning', data.message);
|
||||
showModal('error', i18nStrings.warning, data.message);
|
||||
} else if (data.needs_reauth) {
|
||||
showModal('error', 'Authentication Required', 'Your token has expired or is invalid. Please reconfigure this connection.');
|
||||
showModal('error', i18nStrings.authRequired, i18nStrings.tokenExpired);
|
||||
} else {
|
||||
showModal('error', 'Test Failed', data.message);
|
||||
showModal('error', i18nStrings.testFailed, data.message);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
showModal('error', 'Connection Error', `Error: ${error.message}`);
|
||||
showModal('error', i18nStrings.connectionError, `Error: ${error.message}`);
|
||||
})
|
||||
.finally(() => {
|
||||
// Always restore the button state
|
||||
@@ -596,7 +626,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
const provider = this.getAttribute('data-provider');
|
||||
const originalText = this.textContent;
|
||||
|
||||
this.innerHTML = '<i class="fa-solid fa-spinner fa-spin mr-2"></i> Testing...';
|
||||
this.innerHTML = `<i class="fa-solid fa-spinner fa-spin mr-2"></i> ${i18nStrings.testing}`;
|
||||
this.disabled = true;
|
||||
|
||||
let endpoint = '';
|
||||
@@ -622,35 +652,35 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
// Add token expiration info if available (especially for Google Drive)
|
||||
if (data.token_info && data.token_info.expires_in_human) {
|
||||
message += `<br><br><div class="bg-blue-50 p-3 rounded mt-2">
|
||||
<span class="font-medium">Token valid for:</span> ${data.token_info.expires_in_human}
|
||||
<span class="font-medium">${i18nStrings.tokenValidFor}</span> ${data.token_info.expires_in_human}
|
||||
</div>`;
|
||||
|
||||
// Show the message with HTML
|
||||
modalTitle.textContent = 'Connection Test Successful';
|
||||
modalTitle.textContent = i18nStrings.connectionTestSuccessful;
|
||||
modalMessage.innerHTML = message;
|
||||
modalIcon.innerHTML = '<i class="fa-solid fa-check text-green-600 fa-2x"></i>';
|
||||
modalIcon.className = 'mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100 mb-4';
|
||||
resultModal.classList.remove('hidden');
|
||||
} else {
|
||||
// Use the regular modal without HTML
|
||||
showModal('success', 'Connection Test Successful', `${data.message} ${data.account ? 'as ' + data.account : ''}`);
|
||||
showModal('success', i18nStrings.connectionTestSuccessful, `${data.message} ${data.account ? i18nStrings.asAccount + ' ' + data.account : ''}`);
|
||||
}
|
||||
} else {
|
||||
if (data.needs_reauth) {
|
||||
showModal('error', 'Authentication Required', 'Your token has expired or is invalid. Please reconfigure this connection.');
|
||||
showModal('error', i18nStrings.authRequired, i18nStrings.tokenExpired);
|
||||
// Add option to go to config page
|
||||
modalClose.textContent = "Configure Now";
|
||||
modalClose.textContent = i18nStrings.configureNow;
|
||||
modalClose.addEventListener('click', function redirectToConfig() {
|
||||
window.location.href = `/${provider.replace('_', '-')}-setup`;
|
||||
modalClose.removeEventListener('click', redirectToConfig);
|
||||
}, { once: true });
|
||||
} else {
|
||||
showModal('error', 'Connection Test Failed', data.message);
|
||||
showModal('error', i18nStrings.connectionTestFailed, data.message);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
showModal('error', 'Connection Error', 'Error testing connection: ' + error.message);
|
||||
showModal('error', i18nStrings.connectionError, i18nStrings.errorTestingConnection + error.message);
|
||||
})
|
||||
.finally(() => {
|
||||
this.textContent = originalText;
|
||||
@@ -696,11 +726,11 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
aiExtractionMeta.textContent = `Provider: ${data.provider || '—'} | Model: ${data.model || '—'}`;
|
||||
|
||||
// Raw response
|
||||
aiRawResponse.textContent = data.raw_response || '(empty)';
|
||||
aiRawResponse.textContent = data.raw_response || i18nStrings.aiEmptyResponse;
|
||||
|
||||
// Parse warning
|
||||
if (data.parse_error) {
|
||||
aiParseWarning.textContent = `JSON parse issue: ${data.parse_error}`;
|
||||
aiParseWarning.textContent = i18nStrings.jsonParseIssue + data.parse_error;
|
||||
aiParseWarning.classList.remove('hidden');
|
||||
} else {
|
||||
aiParseWarning.classList.add('hidden');
|
||||
@@ -775,7 +805,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
|
||||
const originalHTML = runAiExtractionBtn.innerHTML;
|
||||
runAiExtractionBtn.innerHTML = '<i class="fa-solid fa-spinner fa-spin mr-2"></i>Running…';
|
||||
runAiExtractionBtn.innerHTML = `<i class="fa-solid fa-spinner fa-spin mr-2"></i>${i18nStrings.running}`;
|
||||
runAiExtractionBtn.disabled = true;
|
||||
cancelAiExtractionBtn.disabled = true;
|
||||
|
||||
@@ -790,12 +820,12 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
showAiExtractionResults(data);
|
||||
} else {
|
||||
closeAiExtractionModalFn();
|
||||
showModal('error', 'AI Extraction Failed', data.message || 'Unknown error');
|
||||
showModal('error', i18nStrings.aiExtractionFailed, data.message || 'Unknown error');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
closeAiExtractionModalFn();
|
||||
showModal('error', 'Connection Error', 'Error running extraction: ' + error.message);
|
||||
showModal('error', i18nStrings.connectionError, i18nStrings.errorRunningExtraction + error.message);
|
||||
})
|
||||
.finally(() => {
|
||||
runAiExtractionBtn.innerHTML = originalHTML;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}My Subscription – DocuElevate{% endblock %}
|
||||
{% block title %}{{ _("subscription.page_title") }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mx-auto px-4 py-8 max-w-4xl">
|
||||
@@ -8,9 +8,9 @@
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900 flex items-center gap-3">
|
||||
<i class="fas fa-layer-group text-indigo-500" aria-hidden="true"></i>
|
||||
My Subscription
|
||||
{{ _("subscription.heading") }}
|
||||
</h1>
|
||||
<p class="text-gray-500 text-sm mt-1">Your current plan, usage and available upgrades.</p>
|
||||
<p class="text-gray-500 text-sm mt-1">{{ _("subscription.subtitle") }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Flash messages -->
|
||||
@@ -21,11 +21,10 @@
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-xl p-6 flex items-start gap-4">
|
||||
<i class="fas fa-info-circle text-blue-500 text-2xl flex-shrink-0 mt-0.5" aria-hidden="true"></i>
|
||||
<div>
|
||||
<p class="font-semibold text-blue-800">Multi-user mode is not enabled.</p>
|
||||
<p class="font-semibold text-blue-800">{{ _("subscription.single_user_title") }}</p>
|
||||
<p class="text-blue-700 text-sm mt-1">
|
||||
Subscription tiers apply when multi-user mode is active. Your instance currently runs in
|
||||
single-user mode with no processing limits.
|
||||
An admin can enable multi-user mode via <a href="/settings" class="underline hover:text-blue-900">Settings</a>.
|
||||
{% set settings_link %}<a href="/settings" class="underline hover:text-blue-900">{{ _("nav.settings") }}</a>{% endset %}
|
||||
{{ _("subscription.single_user_body", settings_link=settings_link) | safe }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -37,17 +36,17 @@
|
||||
<div class="bg-amber-50 border border-amber-300 rounded-xl p-4 mb-6 flex items-start gap-3">
|
||||
<i class="fas fa-clock text-amber-500 text-xl flex-shrink-0 mt-0.5" aria-hidden="true"></i>
|
||||
<div class="flex-1">
|
||||
<p class="font-semibold text-amber-800">Pending plan change scheduled</p>
|
||||
<p class="font-semibold text-amber-800">{{ _("subscription.pending_title") }}</p>
|
||||
<p class="text-amber-700 text-sm mt-1">
|
||||
Your plan will change to <strong>{{ pending_tier.name }}</strong>
|
||||
on <strong>{% if pending_date %}{{ pending_date.strftime('%B') }} {{ pending_date.day }}, {{ pending_date.year }}{% endif %}</strong>.
|
||||
You keep all current plan benefits until then.
|
||||
{{ _("subscription.pending_will_change") }} <strong>{{ pending_tier.name }}</strong>
|
||||
{{ _("subscription.pending_on") }} <strong>{% if pending_date %}{{ pending_date.strftime('%B') }} {{ pending_date.day }}, {{ pending_date.year }}{% endif %}</strong>.
|
||||
{{ _("subscription.pending_benefits") }}
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" onclick="cancelPendingChange()"
|
||||
class="ml-auto flex-shrink-0 text-sm text-amber-700 underline hover:text-amber-900 font-medium"
|
||||
aria-label="Cancel pending plan change">
|
||||
Cancel change
|
||||
aria-label="{{ _('subscription.cancel_pending') }}">
|
||||
{{ _("subscription.cancel_pending") }}
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -68,18 +67,18 @@
|
||||
{% else %}fa-bolt text-purple-600{% endif %}
|
||||
text-2xl" aria-hidden="true"></i>
|
||||
</div>
|
||||
<p class="text-xs font-semibold text-gray-400 uppercase tracking-widest">Current Plan</p>
|
||||
<p class="text-xs font-semibold text-gray-400 uppercase tracking-widest">{{ _("subscription.current_plan") }}</p>
|
||||
<p class="text-2xl font-extrabold text-gray-900 mt-1">{{ tier.name }}</p>
|
||||
<p class="text-gray-500 text-sm mt-1">{{ tier.tagline }}</p>
|
||||
{% if tier.price_monthly > 0 %}
|
||||
<p class="mt-3 text-lg font-bold text-indigo-600">${{ tier.price_monthly }}<span class="text-sm font-normal text-gray-400">/month</span></p>
|
||||
<p class="mt-3 text-lg font-bold text-indigo-600">${{ tier.price_monthly }}<span class="text-sm font-normal text-gray-400">{{ _("subscription.per_month") }}</span></p>
|
||||
{% else %}
|
||||
<p class="mt-3 text-lg font-bold text-gray-500">Free</p>
|
||||
<p class="mt-3 text-lg font-bold text-gray-500">{{ _("subscription.free") }}</p>
|
||||
{% endif %}
|
||||
{% if period_start %}
|
||||
<p class="text-xs text-gray-400 mt-2">
|
||||
<i class="fas fa-calendar-alt mr-1" aria-hidden="true"></i>
|
||||
Since {{ period_start.strftime('%b') }} {{ period_start.day }}, {{ period_start.year }}
|
||||
{{ _("subscription.since") }} {{ period_start.strftime('%b') }} {{ period_start.day }}, {{ period_start.year }}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -87,13 +86,13 @@
|
||||
<!-- Usage this period -->
|
||||
{% if usage %}
|
||||
<div class="md:col-span-2 bg-white rounded-xl shadow p-6">
|
||||
<h2 class="text-sm font-semibold text-gray-500 uppercase tracking-widest mb-4">Usage</h2>
|
||||
<h2 class="text-sm font-semibold text-gray-500 uppercase tracking-widest mb-4">{{ _("subscription.usage_heading") }}</h2>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
|
||||
<!-- Lifetime -->
|
||||
<div class="text-center">
|
||||
<p class="text-3xl font-extrabold text-gray-900">{{ usage.lifetime }}</p>
|
||||
<p class="text-xs text-gray-500 mt-1">Total files (lifetime)</p>
|
||||
<p class="text-xs text-gray-500 mt-1">{{ _("subscription.lifetime_files") }}</p>
|
||||
{% if tier.lifetime_file_limit > 0 %}
|
||||
<div class="mt-2">
|
||||
{% set lifetime_pct = ((usage.lifetime / tier.lifetime_file_limit) * 100) | int %}
|
||||
@@ -106,14 +105,14 @@
|
||||
<p class="text-xs text-gray-400 mt-1">{{ usage.lifetime }} / {{ tier.lifetime_file_limit }}</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-xs text-green-600 mt-1">Unlimited</p>
|
||||
<p class="text-xs text-green-600 mt-1">{{ _("subscription.unlimited") }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Today -->
|
||||
<div class="text-center">
|
||||
<p class="text-3xl font-extrabold text-gray-900">{{ usage.today }}</p>
|
||||
<p class="text-xs text-gray-500 mt-1">Files today</p>
|
||||
<p class="text-xs text-gray-500 mt-1">{{ _("subscription.today_files") }}</p>
|
||||
{% if tier.daily_upload_limit > 0 %}
|
||||
<div class="mt-2">
|
||||
{% set today_pct = ((usage.today / tier.daily_upload_limit) * 100) | int %}
|
||||
@@ -123,17 +122,17 @@
|
||||
{% if today_pct >= 90 %}bg-red-500{% elif today_pct >= 70 %}bg-yellow-500{% else %}bg-blue-500{% endif %}"
|
||||
style="width: {{ [today_pct, 100] | min }}%"></div>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 mt-1">{{ usage.today }} / {{ tier.daily_upload_limit }} today</p>
|
||||
<p class="text-xs text-gray-400 mt-1">{{ usage.today }} / {{ tier.daily_upload_limit }} {{ _("subscription.today_label") }}</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-xs text-green-600 mt-1">Unlimited</p>
|
||||
<p class="text-xs text-green-600 mt-1">{{ _("subscription.unlimited") }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- This month -->
|
||||
<div class="text-center">
|
||||
<p class="text-3xl font-extrabold text-gray-900">{{ usage.month }}</p>
|
||||
<p class="text-xs text-gray-500 mt-1">Files this month</p>
|
||||
<p class="text-xs text-gray-500 mt-1">{{ _("subscription.month_files") }}</p>
|
||||
{% if tier.monthly_upload_limit > 0 %}
|
||||
<div class="mt-2">
|
||||
{% set month_pct = ((usage.month / tier.monthly_upload_limit) * 100) | int %}
|
||||
@@ -143,10 +142,10 @@
|
||||
{% if month_pct >= 90 %}bg-red-500{% elif month_pct >= 70 %}bg-yellow-500{% else %}bg-indigo-500{% endif %}"
|
||||
style="width: {{ [month_pct, 100] | min }}%"></div>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 mt-1">{{ usage.month }} / {{ tier.monthly_upload_limit }} this month</p>
|
||||
<p class="text-xs text-gray-400 mt-1">{{ usage.month }} / {{ tier.monthly_upload_limit }} {{ _("subscription.this_month_label") }}</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-xs text-green-600 mt-1">Unlimited</p>
|
||||
<p class="text-xs text-green-600 mt-1">{{ _("subscription.unlimited") }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -160,7 +159,7 @@
|
||||
<div class="bg-white rounded-xl shadow p-6 mb-8">
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
||||
<i class="fas fa-list-check text-green-500" aria-hidden="true"></i>
|
||||
What's included in your plan
|
||||
{{ _("subscription.features_heading") }}
|
||||
</h2>
|
||||
<ul class="grid grid-cols-1 sm:grid-cols-2 gap-2 text-sm text-gray-600">
|
||||
{% for feature in tier.features %}
|
||||
@@ -176,7 +175,7 @@
|
||||
<div class="mb-8">
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
||||
<i class="fas fa-layer-group text-indigo-500" aria-hidden="true"></i>
|
||||
Available Plans
|
||||
{{ _("subscription.available_plans_heading") }}
|
||||
</h2>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{% for t in all_tiers %}
|
||||
@@ -192,11 +191,11 @@
|
||||
|
||||
{% if is_current %}
|
||||
<span class="absolute top-3 right-3 text-xs bg-indigo-100 text-indigo-700 font-semibold rounded-full px-2 py-0.5">
|
||||
Current
|
||||
{{ _("subscription.current_badge") }}
|
||||
</span>
|
||||
{% elif is_pending %}
|
||||
<span class="absolute top-3 right-3 text-xs bg-amber-100 text-amber-700 font-semibold rounded-full px-2 py-0.5">
|
||||
Pending
|
||||
{{ _("subscription.pending_badge") }}
|
||||
</span>
|
||||
{% elif t.badge %}
|
||||
<span class="absolute top-3 right-3 text-xs bg-indigo-100 text-indigo-700 font-semibold rounded-full px-2 py-0.5">
|
||||
@@ -208,9 +207,9 @@
|
||||
<p class="font-bold text-gray-900 pr-16">{{ t.name }}</p>
|
||||
<p class="text-gray-500 text-xs mt-1 mb-3">{{ t.tagline }}</p>
|
||||
{% if t.price_monthly > 0 %}
|
||||
<p class="text-xl font-extrabold text-gray-900">${{ t.price_monthly }}<span class="text-sm font-normal text-gray-400">/mo</span></p>
|
||||
<p class="text-xl font-extrabold text-gray-900">${{ t.price_monthly }}<span class="text-sm font-normal text-gray-400">{{ _("subscription.per_mo") }}</span></p>
|
||||
{% else %}
|
||||
<p class="text-xl font-extrabold text-gray-500">Free</p>
|
||||
<p class="text-xl font-extrabold text-gray-500">{{ _("subscription.free") }}</p>
|
||||
{% endif %}
|
||||
<ul class="mt-3 space-y-1 text-xs text-gray-600">
|
||||
{% for feature in t.features[:3] %}
|
||||
@@ -220,7 +219,7 @@
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% if t.features | length > 3 %}
|
||||
<li class="text-indigo-500 font-medium">+ {{ (t.features | length) - 3 }} more</li>
|
||||
<li class="text-indigo-500 font-medium">+ {{ (t.features | length) - 3 }} {{ _("subscription.more_features_label") }}</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -228,14 +227,14 @@
|
||||
<div class="mt-4">
|
||||
{% if is_current and not pending_tier_id %}
|
||||
<span class="block text-center py-2 px-4 rounded-lg bg-gray-100 text-gray-400 text-sm font-semibold cursor-default">
|
||||
Your current plan
|
||||
{{ _("subscription.current_plan_cta") }}
|
||||
</span>
|
||||
{% elif is_current and pending_tier_id %}
|
||||
<!-- Keep current plan (cancel pending downgrade) -->
|
||||
<button type="button"
|
||||
onclick="changePlan('{{ t.id }}')"
|
||||
class="block w-full text-center py-2 px-4 rounded-lg bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-semibold transition">
|
||||
Keep {{ t.name }}
|
||||
{{ _("subscription.keep_plan_prefix") }} {{ t.name }}
|
||||
</button>
|
||||
{% elif is_upgrade %}
|
||||
{% if t.id == 'business' %}
|
||||
@@ -247,21 +246,21 @@
|
||||
<button type="button"
|
||||
onclick="changePlan('{{ t.id }}')"
|
||||
class="block w-full text-center py-2 px-4 rounded-lg bg-green-600 hover:bg-green-700 text-white text-sm font-semibold transition">
|
||||
Upgrade to {{ t.name }}
|
||||
{{ _("subscription.upgrade_to_prefix") }} {{ t.name }}
|
||||
</button>
|
||||
{% endif %}
|
||||
{% elif is_pending %}
|
||||
<button type="button"
|
||||
onclick="cancelPendingChange()"
|
||||
class="block w-full text-center py-2 px-4 rounded-lg bg-amber-100 hover:bg-amber-200 text-amber-800 text-sm font-semibold transition">
|
||||
Cancel scheduled change
|
||||
{{ _("subscription.cancel_scheduled") }}
|
||||
</button>
|
||||
{% else %}
|
||||
<!-- Downgrade -->
|
||||
<button type="button"
|
||||
onclick="changePlan('{{ t.id }}')"
|
||||
class="block w-full text-center py-2 px-4 rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-700 text-sm font-semibold transition">
|
||||
Downgrade to {{ t.name }}
|
||||
{{ _("subscription.downgrade_to_prefix") }} {{ t.name }}
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -270,7 +269,7 @@
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 mt-3">
|
||||
<i class="fas fa-info-circle mr-1" aria-hidden="true"></i>
|
||||
Upgrades take effect immediately. Downgrades are scheduled for the end of your current billing period.
|
||||
{{ _("subscription.upgrade_info") }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -279,7 +278,7 @@
|
||||
<!-- Back link -->
|
||||
<div class="mt-8">
|
||||
<a href="/" class="text-sm text-indigo-600 hover:text-indigo-800 font-medium flex items-center gap-1">
|
||||
<i class="fas fa-arrow-left text-xs" aria-hidden="true"></i> Back to Dashboard
|
||||
<i class="fas fa-arrow-left text-xs" aria-hidden="true"></i> {{ _("subscription.back_to_dashboard") }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,65 +1,66 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Terms of Service - DocuElevate{% endblock %}
|
||||
{% block title %}{{ _("terms.page_title") }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mx-auto px-4 py-8 max-w-4xl">
|
||||
<h1 class="text-4xl font-bold mb-6">Terms of Service</h1>
|
||||
<p class="text-sm text-gray-500 mb-8">Last Updated: {{ current_date|default('May 14, 2024') }}</p>
|
||||
<h1 class="text-4xl font-bold mb-6">{{ _("terms.heading") }}</h1>
|
||||
<p class="text-sm text-gray-500 mb-8">{{ _("terms.last_updated") }} {{ current_date|default('May 14, 2024') }}</p>
|
||||
|
||||
<div class="bg-white shadow rounded-lg p-6 mb-6">
|
||||
<h2 class="text-2xl font-semibold mb-4">1. Acceptance of Terms</h2>
|
||||
<h2 class="text-2xl font-semibold mb-4">{{ _("terms.s1_heading") }}</h2>
|
||||
<p class="text-gray-700 mb-3">
|
||||
By accessing or using DocuElevate, you agree to be bound by these Terms of Service. If you do not agree to these terms, please do not use this service.
|
||||
{{ _("terms.s1_p1") }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white shadow rounded-lg p-6 mb-6">
|
||||
<h2 class="text-2xl font-semibold mb-4">2. Description of Service</h2>
|
||||
<h2 class="text-2xl font-semibold mb-4">{{ _("terms.s2_heading") }}</h2>
|
||||
<p class="text-gray-700 mb-3">
|
||||
DocuElevate provides document processing, OCR, metadata extraction, and storage services. We reserve the right to modify or discontinue any aspect of the service at any time.
|
||||
{{ _("terms.s2_p1") }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white shadow rounded-lg p-6 mb-6">
|
||||
<h2 class="text-2xl font-semibold mb-4">3. User Responsibilities</h2>
|
||||
<h2 class="text-2xl font-semibold mb-4">{{ _("terms.s3_heading") }}</h2>
|
||||
<p class="text-gray-700 mb-3">
|
||||
You are responsible for:
|
||||
{{ _("terms.s3_p1") }}
|
||||
</p>
|
||||
<ul class="list-disc list-inside text-gray-700 ml-4 mb-3">
|
||||
<li>All content you upload to DocuElevate</li>
|
||||
<li>Ensuring you have proper rights to upload and process documents</li>
|
||||
<li>Maintaining the confidentiality of your account credentials</li>
|
||||
<li>Any activity that occurs under your account</li>
|
||||
<li>{{ _("terms.s3_li1") }}</li>
|
||||
<li>{{ _("terms.s3_li2") }}</li>
|
||||
<li>{{ _("terms.s3_li3") }}</li>
|
||||
<li>{{ _("terms.s3_li4") }}</li>
|
||||
</ul>
|
||||
<p class="text-gray-700 mb-3">
|
||||
By using our service, you also agree to our <a href="/privacy" class="text-blue-600 hover:underline">Privacy Policy</a> and <a href="/cookies" class="text-blue-600 hover:underline">Cookie Policy</a>.
|
||||
{{ _("terms.s3_p2_pre") }} <a href="/privacy" class="text-blue-600 hover:underline">{{ _("terms.privacy_link") }}</a>
|
||||
{{ _("terms.s3_p2_and") }} <a href="/cookies" class="text-blue-600 hover:underline">{{ _("terms.cookie_link") }}</a>{{ _("terms.s3_p2_post") }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white shadow rounded-lg p-6 mb-6">
|
||||
<h2 class="text-2xl font-semibold mb-4">4. Intellectual Property Rights</h2>
|
||||
<h2 class="text-2xl font-semibold mb-4">{{ _("terms.s4_heading") }}</h2>
|
||||
<p class="text-gray-700 mb-3">
|
||||
DocuElevate respects intellectual property rights. Users may not upload content that infringes on the intellectual property rights of others.
|
||||
{{ _("terms.s4_p1") }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white shadow rounded-lg p-6 mb-6">
|
||||
<h2 class="text-2xl font-semibold mb-4">5. Limitation of Liability</h2>
|
||||
<h2 class="text-2xl font-semibold mb-4">{{ _("terms.s5_heading") }}</h2>
|
||||
<p class="text-gray-700 mb-3">
|
||||
DocuElevate provides the service "as is" without warranties of any kind. We shall not be liable for any direct, indirect, incidental, special, consequential, or punitive damages resulting from your use of or inability to use the service.
|
||||
{{ _("terms.s5_p1") }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white shadow rounded-lg p-6">
|
||||
<h2 class="text-2xl font-semibold mb-4">6. Governing Law</h2>
|
||||
<h2 class="text-2xl font-semibold mb-4">{{ _("terms.s6_heading") }}</h2>
|
||||
<p class="text-gray-700 mb-3">
|
||||
These Terms shall be governed by the laws of Germany, without regard to its conflict of law provisions.
|
||||
{{ _("terms.s6_p1") }}
|
||||
</p>
|
||||
<p class="text-gray-700 mb-3">
|
||||
If you have any questions about these Terms, please contact us at <a href="mailto:docuelevate@christian-louis.de" class="text-blue-600 hover:underline">docuelevate@christian-louis.de</a>.
|
||||
{{ _("terms.s6_p2_pre") }} <a href="mailto:docuelevate@christian-louis.de" class="text-blue-600 hover:underline">docuelevate@christian-louis.de</a>{{ _("terms.s6_p2_post") }}
|
||||
</p>
|
||||
<p class="text-gray-700">
|
||||
For information about how we use cookies, please see our <a href="/cookies" class="text-blue-600 hover:underline">Cookie Policy</a>. For licensing information, please refer to our <a href="/license" class="text-blue-600 hover:underline">License Information</a>.
|
||||
{{ _("terms.s6_p3_pre") }} <a href="/cookies" class="text-blue-600 hover:underline">{{ _("terms.cookie_link") }}</a>{{ _("terms.s6_p3_mid") }} <a href="/license" class="text-blue-600 hover:underline">{{ _("terms.license_link") }}</a>{{ _("terms.s6_p3_post") }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+1055
-870
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1102
-917
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1093
-908
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+724
-539
File diff suppressed because it is too large
Load Diff
+1076
-891
File diff suppressed because it is too large
Load Diff
+1073
-888
File diff suppressed because it is too large
Load Diff
+1031
-846
File diff suppressed because it is too large
Load Diff
+766
-581
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1085
-900
File diff suppressed because it is too large
Load Diff
@@ -41,6 +41,280 @@
|
||||
"about.story_heading": "Our Story",
|
||||
"about.story_p1": "DocuElevate was created with one goal in mind: to simplify and streamline document management for everyone, whether you’re 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": "3–64 characters. Letters, numbers, hyphens and underscores only.",
|
||||
"admin_users.modal_add_title": "Add User Profile",
|
||||
"admin_users.modal_billing_cycle_label": "Billing Cycle",
|
||||
"admin_users.modal_billing_monthly": "Monthly",
|
||||
"admin_users.modal_billing_yearly": "Yearly",
|
||||
"admin_users.modal_block_hint": "(prevents new document uploads)",
|
||||
"admin_users.modal_block_label": "Block this user",
|
||||
"admin_users.modal_close_aria": "Close dialog",
|
||||
"admin_users.modal_complimentary_hint": "(user keeps tier benefits but is never billed — set automatically for admin accounts)",
|
||||
"admin_users.modal_complimentary_label": "Complimentary plan",
|
||||
"admin_users.modal_daily_limit_hint": "(leave empty to use global default)",
|
||||
"admin_users.modal_daily_limit_label": "Daily Upload Limit",
|
||||
"admin_users.modal_daily_limit_placeholder": "e.g. 50 (0 = unlimited)",
|
||||
"admin_users.modal_display_name_label": "Display Name",
|
||||
"admin_users.modal_display_name_placeholder": "Alice Smith (optional)",
|
||||
"admin_users.modal_edit_title": "Edit User Profile",
|
||||
"admin_users.modal_notes_label": "Admin Notes",
|
||||
"admin_users.modal_notes_placeholder": "Internal notes visible only to admins…",
|
||||
"admin_users.modal_period_start_hint": "Annual carry-over calculates from this date. Leave blank for monthly enforcement.",
|
||||
"admin_users.modal_period_start_label": "Subscription Period Start",
|
||||
"admin_users.modal_plan_business": "Business — $7.99/mo (300/mo, unlimited mailboxes)",
|
||||
"admin_users.modal_plan_free": "Free — 25 lifetime files",
|
||||
"admin_users.modal_plan_hint": "Sets the quota limits for this user. Limits are enforced on upload.",
|
||||
"admin_users.modal_plan_label": "Subscription Plan",
|
||||
"admin_users.modal_plan_professional": "Professional — $5.99/mo (150/mo, 3 mailboxes)",
|
||||
"admin_users.modal_plan_starter": "Starter — $2.99/mo (50/mo, 1 mailbox)",
|
||||
"admin_users.modal_save_changes": "Save Changes",
|
||||
"admin_users.modal_saving": "Saving…",
|
||||
"admin_users.modal_user_id_hint": "The stable identifier that matches owner_id in documents.",
|
||||
"admin_users.modal_user_id_label": "User ID",
|
||||
"admin_users.modal_user_id_placeholder": "user@example.com or OAuth sub",
|
||||
"admin_users.new_account_btn": "New Account",
|
||||
"admin_users.new_password_label": "New Password",
|
||||
"admin_users.no_users_add_hint": "Upload some documents or add a profile above.",
|
||||
"admin_users.no_users_found": "No users found.",
|
||||
"admin_users.no_users_search_hint": "Try a different search term.",
|
||||
"admin_users.page_title": "User Management – Admin – DocuElevate",
|
||||
"admin_users.pagination_page_of": "of",
|
||||
"admin_users.per_day": "/ day",
|
||||
"admin_users.role_admin": "Admin",
|
||||
"admin_users.role_user": "User",
|
||||
"admin_users.search_users_label": "Search users",
|
||||
"admin_users.set_password_btn": "Set Password",
|
||||
"admin_users.set_password_desc": "The user should change this password after logging in.",
|
||||
"admin_users.set_password_desc_pre": "Set a new password directly for",
|
||||
"admin_users.set_password_title": "Set Temporary Password",
|
||||
"admin_users.setting": "Setting…",
|
||||
"admin_users.status_blocked": "Blocked",
|
||||
"admin_users.status_unverified": "Unverified",
|
||||
"admin_users.subheading": "Manage user profiles, per-user upload limits, and document ownership.",
|
||||
"admin_users.total_count_users": "{count} users",
|
||||
"admin_users.total_no_users": "No users",
|
||||
"admin_users.total_one_user": "1 user",
|
||||
"api_tokens.col_created": "Created",
|
||||
"api_tokens.col_last_ip": "Last IP",
|
||||
"api_tokens.col_last_used": "Last Used",
|
||||
@@ -73,6 +347,19 @@
|
||||
"api_tokens.usage_intro_pre": "Use your API token in the",
|
||||
"api_tokens.your_tokens": "Your Tokens",
|
||||
"app.name": "DocuElevate",
|
||||
"attribution.heading": "Third-Party Software Attributions",
|
||||
"attribution.intro": "DocuElevate uses several open source libraries and tools. We are grateful to the developers of these projects for their contributions to open source software.",
|
||||
"attribution.page_title": "DocuElevate - Third-Party Attributions",
|
||||
"attribution.paramiko_lgpl_note": "Note: This library is licensed under the GNU Lesser General Public License v2.1 (LGPL-2.1)",
|
||||
"attribution.section_docker": "Docker Images",
|
||||
"attribution.section_frontend": "Frontend Dependencies",
|
||||
"attribution.section_python": "Python Dependencies",
|
||||
"attribution.special_lgpl_link": "here",
|
||||
"attribution.special_lgpl_post": ".",
|
||||
"attribution.special_lgpl_pre": "A copy of the LGPL license can be found",
|
||||
"attribution.special_source_post": ".",
|
||||
"attribution.special_source_pre": "This software includes Paramiko, which is licensed under LGPL. The source code for Paramiko is available at",
|
||||
"attribution.special_title": "Special Attribution:",
|
||||
"audit.col_ip": "IP",
|
||||
"audit.col_resource": "Resource",
|
||||
"audit.col_timestamp": "Timestamp",
|
||||
@@ -187,6 +474,7 @@
|
||||
"common.actions": "Actions",
|
||||
"common.active": "Active",
|
||||
"common.all": "All",
|
||||
"common.avatar": "Avatar",
|
||||
"common.back": "Back",
|
||||
"common.cancel": "Cancel",
|
||||
"common.close": "Close",
|
||||
@@ -218,6 +506,7 @@
|
||||
"common.no": "No",
|
||||
"common.none": "None",
|
||||
"common.page": "Page",
|
||||
"common.paused": "Paused",
|
||||
"common.pending": "Pending",
|
||||
"common.prev_page": "Previous page",
|
||||
"common.previous": "Previous",
|
||||
@@ -234,7 +523,10 @@
|
||||
"common.submit": "Submit",
|
||||
"common.success": "Success",
|
||||
"common.tags": "Tags",
|
||||
"common.test": "Test",
|
||||
"common.test_connection": "Test Connection",
|
||||
"common.type": "Type",
|
||||
"common.update": "Update",
|
||||
"common.updated": "Updated",
|
||||
"common.upload": "Upload",
|
||||
"common.view": "View",
|
||||
@@ -247,6 +539,41 @@
|
||||
"cookie.notice_label": "Cookie notice",
|
||||
"cookie.policy_link": "Cookie Policy",
|
||||
"cookie.privacy_link": "Privacy Notice",
|
||||
"cookie_policy.heading": "Cookie Policy",
|
||||
"cookie_policy.last_updated": "Last Updated:",
|
||||
"cookie_policy.page_title": "Cookie Policy - DocuElevate",
|
||||
"cookie_policy.s1_heading": "What Are Cookies",
|
||||
"cookie_policy.s1_p1": "Cookies are small text files that are stored on your computer or mobile device when you visit a website. They are widely used to make websites work more efficiently and provide information to the website owners.",
|
||||
"cookie_policy.s2_heading": "How We Use Cookies",
|
||||
"cookie_policy.s2_li1_body": "To identify you when you sign in and maintain your session while you use the application.",
|
||||
"cookie_policy.s2_li1_label": "Authentication & Session Management:",
|
||||
"cookie_policy.s2_p1_post": "for the following purpose:",
|
||||
"cookie_policy.s2_p1_pre": "DocuElevate uses",
|
||||
"cookie_policy.s2_p1_strong": "only strictly necessary session cookies",
|
||||
"cookie_policy.s2_p2": "These cookies are mandatory for the proper functioning of our service. Without these cookies, you would be required to log in repeatedly during your browsing session.",
|
||||
"cookie_policy.s2_p3": "Because these cookies are strictly necessary for the service to function, they are exempt from prior-consent requirements under the EU ePrivacy Directive (Art. 5(3)) and equivalent national implementations. We do not set any optional, analytics, advertising, or tracking cookies.",
|
||||
"cookie_policy.s3_col_duration": "Duration",
|
||||
"cookie_policy.s3_col_name": "Name",
|
||||
"cookie_policy.s3_col_purpose": "Purpose",
|
||||
"cookie_policy.s3_col_type": "Type",
|
||||
"cookie_policy.s3_heading": "Cookie Details",
|
||||
"cookie_policy.s3_row1_duration": "Session (deleted on browser close or logout)",
|
||||
"cookie_policy.s3_row1_purpose": "Maintains your authenticated session; required for login to function.",
|
||||
"cookie_policy.s3_row1_type": "Strictly Necessary",
|
||||
"cookie_policy.s3_row2_duration": "Persistent (browser localStorage)",
|
||||
"cookie_policy.s3_row2_purpose": "Stores your acknowledgement of the cookie notice so it is not shown repeatedly (stored in localStorage, not a cookie).",
|
||||
"cookie_policy.s3_row2_type": "Strictly Necessary",
|
||||
"cookie_policy.s4_heading": "No Third-Party Cookies",
|
||||
"cookie_policy.s4_p1": "DocuElevate does not use any third-party cookies, tracking cookies, advertising cookies, or analytics cookies. We respect your privacy and only implement the minimum cookies required for our service to function.",
|
||||
"cookie_policy.s4_p2_pre": "For more information about how we handle your data, please see our",
|
||||
"cookie_policy.s4_privacy_link": "Privacy Notice",
|
||||
"cookie_policy.s5_heading": "Managing Cookies",
|
||||
"cookie_policy.s5_p1": "Most web browsers allow you to control cookies through their settings. However, blocking or deleting our session cookies will prevent DocuElevate from functioning, as user authentication relies on these cookies.",
|
||||
"cookie_policy.s5_p2": "You may also clear the cookie notice acknowledgement stored in your browser's localStorage at any time via your browser's developer tools (Application → Local Storage).",
|
||||
"cookie_policy.s5_p3_and": "and",
|
||||
"cookie_policy.s5_p3_pre": "This Cookie Policy is part of and incorporated into our",
|
||||
"cookie_policy.s5_privacy_link": "Privacy Notice",
|
||||
"cookie_policy.s5_terms_link": "Terms of Service",
|
||||
"credentials.col_action": "Action",
|
||||
"credentials.col_credential": "Credential",
|
||||
"credentials.col_source": "Source",
|
||||
@@ -446,15 +773,31 @@
|
||||
"help.faq_4_a": "Pipelines let you chain processing steps – OCR, AI extraction, format conversion – and route the result to one or more destinations. Create and manage them from the Pipelines page.",
|
||||
"help.faq_4_q": "How do processing pipelines work?",
|
||||
"help.faq_5_a": "DocuElevate encrypts credentials at rest, communicates over TLS, and never stores your documents longer than necessary. See the Privacy Notice for full details.",
|
||||
"help.faq_5_a_post": " for full details.",
|
||||
"help.faq_5_a_pre": "DocuElevate encrypts credentials at rest, communicates over TLS, and never stores your documents longer than necessary. See the ",
|
||||
"help.faq_5_q": "Is my data secure?",
|
||||
"help.faq_heading": "Frequently Asked Questions",
|
||||
"help.getting_started": "Getting Started",
|
||||
"help.heading": "Help Center",
|
||||
"help.ingestion_curl": "cURL / REST API",
|
||||
"help.ingestion_curl_desc": "Use the REST API to push documents programmatically. Authenticate with a Bearer token and POST files to the upload endpoint.",
|
||||
"help.ingestion_heading": "Data Ingestion Tools",
|
||||
"help.ingestion_mobile": "Mobile Apps",
|
||||
"help.ingestion_mobile_desc": "Use any mobile scanning app that supports custom HTTP upload destinations to send photos and scans to DocuElevate from your phone or tablet.",
|
||||
"help.ingestion_scanners": "Network Scanners",
|
||||
"help.ingestion_scanners_desc": "Point any network scanner or multifunction printer at DocuElevate's upload endpoint. Scanned documents land directly in your processing queue.",
|
||||
"help.ingestion_watched_folders": "Watched Folders",
|
||||
"help.ingestion_watched_folders_desc": "Configure a local or network folder to be monitored. Any file placed in a watched folder is automatically uploaded and processed by DocuElevate.",
|
||||
"help.ingestion_zapier": "Zapier / n8n / Make",
|
||||
"help.ingestion_zapier_desc": "Integrate DocuElevate into your automation workflows with Zapier, n8n, or Make. Use the REST API actions to trigger document uploads from any event.",
|
||||
"help.meta_description": "Get help with DocuElevate – find guides on uploading documents, connecting cloud storage, setting up pipelines, and more.",
|
||||
"help.og_description": "Get help with DocuElevate – find guides on uploading documents, connecting cloud storage, setting up pipelines, and more.",
|
||||
"help.page_title": "Help Center",
|
||||
"help.privacy_notice": "Privacy Notice",
|
||||
"help.quickstart_heading": "Quick Start",
|
||||
"help.quickstart_storage": "Connect Storage",
|
||||
"help.quickstart_storage_desc": "Head to Settings and link your cloud accounts. Processed documents are automatically routed to every destination you configure.",
|
||||
"help.quickstart_storage_desc_full": "Head to Integrations and link your cloud storage accounts. DocuElevate supports Dropbox, Google Drive, OneDrive, Amazon S3, Nextcloud, and more. Processed documents are automatically routed to every destination you configure.",
|
||||
"help.quickstart_upload": "Upload Documents",
|
||||
"help.quickstart_upload_desc": "Drag & drop files onto the Upload page or click Choose File. DocuElevate converts images and office documents to PDF, runs OCR, and extracts metadata automatically.",
|
||||
"help.quickstart_workflows": "Automate Workflows",
|
||||
@@ -466,6 +809,7 @@
|
||||
"help.sources_rest_api_desc": "Integrate programmatically by POST-ing files to /api/upload. Ideal for scripts, watched folders, scanners, or third-party tools like Zapier and n8n.",
|
||||
"help.sources_scanner": "Scanner & Mobile",
|
||||
"help.sources_scanner_desc": "Point network scanners at DocuElevate's upload endpoint or use any mobile scanning app that supports custom HTTP destinations.",
|
||||
"help.sources_scanner_desc_full": "Configure your network scanner or multifunction printer to send scans directly to DocuElevate. Use the /api/upload endpoint as the destination. Mobile scanning apps with custom upload destinations are also supported.",
|
||||
"help.sources_web_upload": "Web Upload",
|
||||
"help.sources_web_upload_desc": "The fastest way to get started. Open the Upload page, drop one or more files, and DocuElevate takes care of the rest. Supported formats include PDF, JPEG, PNG, TIFF, DOCX, XLSX, and more.",
|
||||
"help.subheading": "Everything you need to get the most out of DocuElevate. Browse topics below or search for what you need.",
|
||||
@@ -473,7 +817,9 @@
|
||||
"help.support_admin_message": "Contact your administrator for support information.",
|
||||
"help.support_description": "Can’t find what you’re looking for? Our support team is here to help.",
|
||||
"help.support_heading": "Contact Support",
|
||||
"help.support_live_chat": "Live Chat available – click the chat bubble to start a conversation.",
|
||||
"help.support_ticket_button": "Submit a Ticket",
|
||||
"help.support_ticket_desc": "Fill in the form below and our support team will get back to you as soon as possible.",
|
||||
"help.support_ticket_heading": "Open a Support Ticket",
|
||||
"help.title": "Help Center",
|
||||
"help.workflows_creating": "Creating a Pipeline",
|
||||
@@ -481,15 +827,43 @@
|
||||
"help.workflows_heading": "Workflows & Pipelines",
|
||||
"help.workflows_step_1": "Convert to PDF",
|
||||
"help.workflows_step_1_create": "Go to Pipelines in the main menu.",
|
||||
"help.workflows_step_1_full": "Convert to PDF – all incoming files are normalised to a consistent PDF format.",
|
||||
"help.workflows_step_2": "OCR – extract text",
|
||||
"help.workflows_step_2_create": "Click New Pipeline and give it a name.",
|
||||
"help.workflows_step_2_full": "OCR – extract selectable text from scanned pages using Azure Document Intelligence or the built-in engine.",
|
||||
"help.workflows_step_3": "AI metadata extraction",
|
||||
"help.workflows_step_3_create": "Add the processing steps you need.",
|
||||
"help.workflows_step_3_full": "AI metadata extraction – classify document type and pull key fields such as amounts, dates, and names using OpenAI.",
|
||||
"help.workflows_step_4": "Deliver to one or more destinations",
|
||||
"help.workflows_step_4_create": "Choose one or more delivery destinations.",
|
||||
"help.workflows_step_5_create": "Save – new documents will be processed through this pipeline automatically.",
|
||||
"help.workflows_typical_steps": "Typical Steps",
|
||||
"help.workflows_what_is": "What is a Pipeline?",
|
||||
"imprint.business_registration_heading": "Business Registration",
|
||||
"imprint.business_registration_vat": "VAT Identification Number according to §27a Value Added Tax Act:",
|
||||
"imprint.contact_heading": "Contact Information",
|
||||
"imprint.dispute_heading": "Online Dispute Resolution",
|
||||
"imprint.dispute_p1": "The European Commission provides a platform for online dispute resolution (OS):",
|
||||
"imprint.dispute_p2": "We are not willing or obligated to participate in dispute resolution proceedings before a consumer arbitration board.",
|
||||
"imprint.heading": "Imprint",
|
||||
"imprint.legal_copyright": "All content on this website is protected by copyright. Any use outside the limits of copyright law requires the written consent of the respective author or creator.",
|
||||
"imprint.legal_heading": "Legal Notices",
|
||||
"imprint.legal_liability": "Despite careful content control, we assume no liability for the content of external links. The operators of the linked pages are solely responsible for their content.",
|
||||
"imprint.page_title": "Imprint - DocuElevate",
|
||||
"imprint.policies_cookie_desc": "Information about cookies we use",
|
||||
"imprint.policies_cookie_label": "Cookie Policy",
|
||||
"imprint.policies_heading": "Related Policies",
|
||||
"imprint.policies_intro": "Our service is governed by the following policies:",
|
||||
"imprint.policies_license_desc": "How our software is licensed",
|
||||
"imprint.policies_license_label": "License Information",
|
||||
"imprint.policies_privacy_desc": "How we handle your data",
|
||||
"imprint.policies_privacy_label": "Privacy Policy",
|
||||
"imprint.policies_terms_desc": "Rules for using DocuElevate",
|
||||
"imprint.policies_terms_label": "Terms of Service",
|
||||
"imprint.provider_heading": "Service Provider",
|
||||
"imprint.responsible_content_heading": "Responsible for Content",
|
||||
"imprint.responsible_content_rstv": "According to § 55 Abs. 2 RStV:",
|
||||
"imprint.subtitle": "Information according to § 5 TMG (German Telemedia Act)",
|
||||
"index.badge_intelligent": "Intelligent Document Processing",
|
||||
"index.button_browse_files": "Browse Files",
|
||||
"index.button_upload": "Upload",
|
||||
@@ -535,6 +909,7 @@
|
||||
"index.page_title_dashboard": "Dashboard",
|
||||
"index.page_title_public": "Intelligent Document Processing",
|
||||
"index.platform_overview": "Platform overview",
|
||||
"index.processing_stats": "Processing Stats",
|
||||
"index.quick_actions": "Quick Actions",
|
||||
"index.quick_documents": "My Documents",
|
||||
"index.quick_documents_desc": "Browse your processed files",
|
||||
@@ -553,9 +928,13 @@
|
||||
"index.stat_active_integrations": "Active Integrations",
|
||||
"index.stat_active_users": "Active users",
|
||||
"index.stat_files_month": "Files this month",
|
||||
"index.stat_files_ocr": "Files with OCR",
|
||||
"index.stat_files_today": "Files today",
|
||||
"index.stat_storage_targets": "Storage Targets",
|
||||
"index.stat_this_month": "This month",
|
||||
"index.stat_today": "Today",
|
||||
"index.stat_total_files": "Total files",
|
||||
"index.stat_total_processed": "Total processed",
|
||||
"index.tier_plan": "Plan",
|
||||
"index.tier_upgrade": "Upgrade",
|
||||
"index.tier_view_details": "View full details",
|
||||
@@ -570,20 +949,95 @@
|
||||
"index.usage_my_usage": "My usage",
|
||||
"index.usage_today": "Files today",
|
||||
"index.usage_unlimited": "Unlimited",
|
||||
"integrations.access_key_label": "Access Key ID",
|
||||
"integrations.active_label": "Active",
|
||||
"integrations.add_button": "Add Integration",
|
||||
"integrations.add_first_button": "Add Your First Integration",
|
||||
"integrations.api_token_label": "API Token",
|
||||
"integrations.authorize_btn": "Authorize",
|
||||
"integrations.authorize_reauth": "Re-Authorize",
|
||||
"integrations.bucket_label": "Bucket",
|
||||
"integrations.configure": "Configure",
|
||||
"integrations.connect": "Connect",
|
||||
"integrations.connected": "Connected",
|
||||
"integrations.connection_failed": "Connection failed",
|
||||
"integrations.connection_success": "Connection successful",
|
||||
"integrations.delete_confirm_are_you_sure": "Are you sure you want to delete",
|
||||
"integrations.delete_confirm_title": "Delete Integration?",
|
||||
"integrations.delete_confirm_undone": "This action cannot be undone.",
|
||||
"integrations.delete_emails_label": "Delete emails after processing",
|
||||
"integrations.delete_files_label": "Delete files after processing",
|
||||
"integrations.destinations_quota_label": "Storage Destinations:",
|
||||
"integrations.destinations_section": "Destinations",
|
||||
"integrations.direction_destination": "Destination (storage)",
|
||||
"integrations.direction_label": "Direction",
|
||||
"integrations.direction_placeholder": "— Select —",
|
||||
"integrations.direction_source": "Source (ingestion)",
|
||||
"integrations.disconnect": "Disconnect",
|
||||
"integrations.email_settings": "Email Forward Settings",
|
||||
"integrations.empty_state": "No integrations configured",
|
||||
"integrations.endpoint_label": "Endpoint URL",
|
||||
"integrations.endpoint_optional": "(optional, for S3-compatible)",
|
||||
"integrations.folder_label": "Folder",
|
||||
"integrations.folder_optional": "(optional)",
|
||||
"integrations.folder_path_label": "Folder Path",
|
||||
"integrations.folder_prefix_label": "Folder Prefix",
|
||||
"integrations.generic_settings_hint": "Configuration for this integration type can be provided as JSON after creation via the API.",
|
||||
"integrations.gmail_hint": "Gmail labels & star: when enabled, processed emails are starred and tagged with an \"Ingested\" label in Gmail. Only applies to Gmail servers.",
|
||||
"integrations.gmail_labels": "Apply Gmail labels & star",
|
||||
"integrations.host_label": "Host",
|
||||
"integrations.imap_settings": "IMAP Settings",
|
||||
"integrations.loading": "Loading integrations…",
|
||||
"integrations.modal_close": "Close modal",
|
||||
"integrations.modal_title_add": "Add Integration",
|
||||
"integrations.modal_title_edit": "Edit Integration",
|
||||
"integrations.name_label": "Label",
|
||||
"integrations.name_placeholder": "e.g. Work Gmail, S3 Archive",
|
||||
"integrations.nextcloud_settings": "Nextcloud Settings",
|
||||
"integrations.nextcloud_url_label": "Nextcloud URL",
|
||||
"integrations.no_integrations_body": "Add your first integration to connect ingestion sources (like IMAP) and storage destinations (like S3, Dropbox, or Google Drive).",
|
||||
"integrations.not_connected": "Not Connected",
|
||||
"integrations.oauth_folder_label": "Folder",
|
||||
"integrations.oauth_hint": "Save this integration first, then use the Authorize button to complete the OAuth flow. Your credentials will be stored securely in your personal integration record.",
|
||||
"integrations.page_title": "Integrations",
|
||||
"integrations.paperless_settings": "Paperless NGX Settings",
|
||||
"integrations.password_label": "Password",
|
||||
"integrations.paused": "Paused",
|
||||
"integrations.plan_label": "Plan:",
|
||||
"integrations.port_label": "Port",
|
||||
"integrations.quota_not_available": "(not available)",
|
||||
"integrations.quota_unlimited": "/ unlimited",
|
||||
"integrations.recipient_label": "Recipient Email",
|
||||
"integrations.region_label": "Region",
|
||||
"integrations.remote_path_label": "Remote Path",
|
||||
"integrations.s3_prefix_label": "Prefix (folder path)",
|
||||
"integrations.s3_settings": "S3 Settings",
|
||||
"integrations.secret_key_label": "Secret Access Key",
|
||||
"integrations.show_password": "Show password",
|
||||
"integrations.show_secrets": "Show secrets",
|
||||
"integrations.show_token": "Show token",
|
||||
"integrations.source_local": "Local Filesystem",
|
||||
"integrations.source_type_label": "Source Type",
|
||||
"integrations.sources_quota_label": "Mailbox Sources:",
|
||||
"integrations.sources_section": "Sources",
|
||||
"integrations.subtitle": "Manage your ingestion sources and storage destinations in one place.",
|
||||
"integrations.title": "Integrations",
|
||||
"integrations.type_label": "Integration Type",
|
||||
"integrations.type_placeholder": "— Select direction first —",
|
||||
"integrations.upgrade_plan": "Upgrade Plan",
|
||||
"integrations.use_ssl": "Use SSL",
|
||||
"integrations.username_label": "Username",
|
||||
"integrations.watch_folder_settings": "Watch Folder Settings",
|
||||
"integrations.webdav_settings": "WebDAV Settings",
|
||||
"integrations.webdav_url_label": "WebDAV URL",
|
||||
"integrations.webhook_heading": "Webhook Ingestion",
|
||||
"integrations.webhook_how_heading": "How Webhook Ingestion Works",
|
||||
"integrations.webhook_how_text": "A webhook integration allows external systems to push documents directly into DocuElevate via the REST API. Instead of DocuElevate polling for new files (like IMAP), your application sends files to DocuElevate using an HTTP request with an API token for authentication.",
|
||||
"integrations.webhook_ideal_text": "This is ideal for CI/CD pipelines, automation scripts, scanner integrations, or any system that generates documents and needs to send them for processing.",
|
||||
"integrations.webhook_quick_start": "Quick Start",
|
||||
"integrations.webhook_security_tip": "Create a dedicated API token for each integration and revoke it immediately if compromised. Tokens can be managed on the API Tokens page.",
|
||||
"integrations.webhook_step1": "Go to {api_tokens_link} and create a personal token.",
|
||||
"integrations.webhook_upload_with_token": "Use the token to upload documents via the API:",
|
||||
"language.bg": "Български",
|
||||
"language.ca": "Català",
|
||||
"language.change_success": "Language changed to {language}",
|
||||
@@ -607,10 +1061,12 @@
|
||||
"language.lv": "Latviešu",
|
||||
"language.nb": "Norsk",
|
||||
"language.nl": "Nederlands",
|
||||
"language.no_results": "No languages found",
|
||||
"language.pl": "Polski",
|
||||
"language.pt": "Português",
|
||||
"language.ro": "Română",
|
||||
"language.ru": "Русский",
|
||||
"language.search_placeholder": "Search languages…",
|
||||
"language.selector": "Language",
|
||||
"language.selector_label": "Select language",
|
||||
"language.sk": "Slovenčina",
|
||||
@@ -619,7 +1075,22 @@
|
||||
"language.tr": "Türkçe",
|
||||
"language.uk": "Українська",
|
||||
"language.zh": "中文",
|
||||
"license.apache_description": "DocuElevate is distributed under the Apache License 2.0, which is a permissive open-source software license that allows you to use, modify, distribute, and contribute to the project.",
|
||||
"license.apache_heading": "Apache License 2.0",
|
||||
"license.heading": "License Information",
|
||||
"license.page_title": "License Information - DocuElevate",
|
||||
"license.related_about_link": "About page",
|
||||
"license.related_and": "and",
|
||||
"license.related_heading": "Related Information",
|
||||
"license.related_p1_post": "for information about using the DocuElevate service.",
|
||||
"license.related_p1_pre": "While this license governs the use of our software, please also review our",
|
||||
"license.related_p2_post": ".",
|
||||
"license.related_p2_pre": "For more information about DocuElevate, please visit the",
|
||||
"license.related_privacy_link": "Privacy Policy",
|
||||
"license.related_terms_link": "Terms of Service",
|
||||
"nav.about": "About",
|
||||
"nav.account_menu": "Account menu",
|
||||
"nav.account_menu_for": "Account menu for {name}",
|
||||
"nav.admin": "Admin",
|
||||
"nav.admin.audit_logs": "Audit Logs",
|
||||
"nav.admin.backups": "Backups",
|
||||
@@ -638,6 +1109,7 @@
|
||||
"nav.duplicates": "Duplicates",
|
||||
"nav.file_manager": "File Manager",
|
||||
"nav.files": "Files",
|
||||
"nav.get_started": "Get Started",
|
||||
"nav.help": "Help",
|
||||
"nav.help_center": "Help Center",
|
||||
"nav.imap": "Email Import",
|
||||
@@ -646,18 +1118,21 @@
|
||||
"nav.login": "Log In",
|
||||
"nav.logout": "Log Out",
|
||||
"nav.main_navigation": "Main navigation",
|
||||
"nav.my_subscription": "My Subscription",
|
||||
"nav.notifications": "Notifications",
|
||||
"nav.open_main_menu": "Open main menu",
|
||||
"nav.pipelines": "Pipelines",
|
||||
"nav.plan_designer": "Plan Designer",
|
||||
"nav.pricing": "Pricing",
|
||||
"nav.profile": "Profile",
|
||||
"nav.profile_settings": "Profile Settings",
|
||||
"nav.queue": "Queue",
|
||||
"nav.queue_monitor": "Queue Monitor",
|
||||
"nav.scheduled_jobs": "Scheduled Jobs",
|
||||
"nav.search": "Search",
|
||||
"nav.settings": "Settings",
|
||||
"nav.shared_links": "Shared Links",
|
||||
"nav.sign_out": "Sign Out",
|
||||
"nav.signup": "Sign Up",
|
||||
"nav.similarity": "Similarity",
|
||||
"nav.skip_to_content": "Skip to main content",
|
||||
@@ -713,6 +1188,220 @@
|
||||
"pipelines.system_label": "System",
|
||||
"pipelines.system_pipeline_label": "System pipeline (visible to all users)",
|
||||
"pipelines.title": "Processing Pipelines",
|
||||
"privacy.heading": "DocuElevate – Privacy Notice",
|
||||
"privacy.last_updated": "Last Updated:",
|
||||
"privacy.page_title": "Privacy Notice - DocuElevate",
|
||||
"privacy.s10_access_body": "You may request a copy of the personal data we hold about you.",
|
||||
"privacy.s10_access_label": "Right of Access (Art. 15):",
|
||||
"privacy.s10_complaint_body": "You have the right to lodge a complaint with your national Data Protection Authority (DPA). In Germany: Bundesbeauftragte für den Datenschutz und die Informationsfreiheit (BfDI). In the UK: Information Commissioner's Office (ICO). In Switzerland: Federal Data Protection and Information Commissioner (FDPIC).",
|
||||
"privacy.s10_complaint_label": "Right to Lodge a Complaint:",
|
||||
"privacy.s10_contact": "To exercise any of the above rights, contact us at",
|
||||
"privacy.s10_erasure_body": "You may request deletion of your personal data where there is no overriding legitimate reason for us to retain it.",
|
||||
"privacy.s10_erasure_label": "Right to Erasure (Art. 17):",
|
||||
"privacy.s10_heading": "10. Your Rights (EU / EEA / UK / Switzerland)",
|
||||
"privacy.s10_object_body": "You may object to processing based on legitimate interests at any time.",
|
||||
"privacy.s10_object_label": "Right to Object (Art. 21):",
|
||||
"privacy.s10_p1": "Under GDPR (and the UK GDPR / Swiss nFADP equivalent), you have the following rights:",
|
||||
"privacy.s10_portability_body": "You may request your data in a structured, commonly used, machine-readable format.",
|
||||
"privacy.s10_portability_label": "Right to Data Portability (Art. 20):",
|
||||
"privacy.s10_rectification_body": "You may request correction of inaccurate or incomplete personal data.",
|
||||
"privacy.s10_rectification_label": "Right to Rectification (Art. 16):",
|
||||
"privacy.s10_response": "We will respond within one calendar month (extendable by two further months for complex requests).",
|
||||
"privacy.s10_restriction_body": "You may request that we temporarily halt processing of your data in certain circumstances.",
|
||||
"privacy.s10_restriction_label": "Right to Restriction (Art. 18):",
|
||||
"privacy.s10_withdraw_body": "Where processing is based on consent, you may withdraw that consent at any time without affecting the lawfulness of prior processing.",
|
||||
"privacy.s10_withdraw_label": "Right to Withdraw Consent:",
|
||||
"privacy.s11_categories_body": "Identifiers (name, email), account authentication tokens, and document metadata that you choose to upload.",
|
||||
"privacy.s11_categories_label": "Categories of personal information collected:",
|
||||
"privacy.s11_contact": "To submit a verifiable consumer request, contact us at",
|
||||
"privacy.s11_correct_body": "You may request correction of inaccurate personal information.",
|
||||
"privacy.s11_correct_label": "Right to Correct:",
|
||||
"privacy.s11_delete_body": "You may request deletion of personal information we have collected, subject to certain exceptions.",
|
||||
"privacy.s11_delete_label": "Right to Delete:",
|
||||
"privacy.s11_heading": "11. Additional Rights – United States (CCPA / CPRA)",
|
||||
"privacy.s11_know_body": "You may request disclosure of the categories and specific pieces of personal information we have collected about you.",
|
||||
"privacy.s11_know_label": "Right to Know:",
|
||||
"privacy.s11_limit_body": "We do not use sensitive personal information beyond what is necessary to provide the service.",
|
||||
"privacy.s11_limit_label": "Right to Limit Use of Sensitive Personal Information:",
|
||||
"privacy.s11_nondiscrim_body": "We will not discriminate against you for exercising any of these rights.",
|
||||
"privacy.s11_nondiscrim_label": "Non-Discrimination:",
|
||||
"privacy.s11_optout_body": "We do not sell or share personal information as defined by CCPA/CPRA. No opt-out mechanism is required; however, you may contact us to confirm this.",
|
||||
"privacy.s11_optout_label": "Right to Opt Out of Sale / Sharing:",
|
||||
"privacy.s11_p1": "If you are a resident of California or another US state with applicable privacy legislation (including Virginia VCDPA, Colorado CPA, Connecticut CTDPA, Utah UCPA), the following additional disclosures apply:",
|
||||
"privacy.s11_purpose_body": "Providing, improving, and securing the DocuElevate service. We do not sell or share personal information for cross-context behavioural advertising.",
|
||||
"privacy.s11_purpose_label": "Purpose of collection:",
|
||||
"privacy.s11_response": "We will respond within 45 days (extendable by an additional 45 days when reasonably necessary).",
|
||||
"privacy.s12_access_body": "You may request access to your personal information and information about how it has been used or disclosed.",
|
||||
"privacy.s12_access_label": "Right of Access:",
|
||||
"privacy.s12_contact": "Direct privacy complaints to our Privacy Officer at",
|
||||
"privacy.s12_contact_post": ", or to the Office of the Privacy Commissioner of Canada.",
|
||||
"privacy.s12_correction_body": "You may challenge the accuracy or completeness of your personal information and request correction.",
|
||||
"privacy.s12_correction_label": "Right to Correction:",
|
||||
"privacy.s12_heading": "12. Additional Rights – Canada (PIPEDA / Québec Law 25)",
|
||||
"privacy.s12_li1": "We collect, use, and disclose personal information only with your knowledge and consent, or as permitted by law.",
|
||||
"privacy.s12_p1": "If you are located in Canada, the following applies under the Personal Information Protection and Electronic Documents Act (PIPEDA) and applicable provincial legislation (including Québec Law 25 / Bill 64):",
|
||||
"privacy.s12_quebec_body": "Under Law 25, you have additional rights including the right to data portability (effective September 2023) and the right to de-indexation where personal information is disseminated online.",
|
||||
"privacy.s12_quebec_label": "Québec residents:",
|
||||
"privacy.s12_withdraw_body": "Subject to legal or contractual restrictions, you may withdraw consent to the collection, use, or disclosure of your personal information on reasonable notice.",
|
||||
"privacy.s12_withdraw_label": "Right to Withdraw Consent:",
|
||||
"privacy.s13_brazil_body": "If you are located in Brazil, you have the following rights under the LGPD:",
|
||||
"privacy.s13_brazil_label": "Brazil (LGPD – Lei Geral de Proteção de Dados, Law 13.709/2018):",
|
||||
"privacy.s13_contact": "Contact:",
|
||||
"privacy.s13_heading": "13. Additional Rights – Latin America (LGPD & Others)",
|
||||
"privacy.s13_li1": "Confirmation of the existence of processing and access to your data.",
|
||||
"privacy.s13_li2": "Correction of incomplete, inaccurate, or outdated data.",
|
||||
"privacy.s13_li3": "Anonymisation, blocking, or deletion of unnecessary or excessive data.",
|
||||
"privacy.s13_li4": "Portability of your data to another service or product provider.",
|
||||
"privacy.s13_li5": "Deletion of personal data processed with your consent.",
|
||||
"privacy.s13_li6": "Information about entities with which your data has been shared.",
|
||||
"privacy.s13_li7": "Information about the possibility of not consenting and the consequences of refusal.",
|
||||
"privacy.s13_li8": "Revocation of consent.",
|
||||
"privacy.s13_other_body": "We also recognise applicable privacy laws in Argentina (PDPA), Mexico (LFPDPPP), Chile, Colombia (Ley 1581), and others. Users in these jurisdictions may exercise equivalent rights as outlined under their national law by contacting us.",
|
||||
"privacy.s13_other_label": "Other Latin American Countries:",
|
||||
"privacy.s14_apj_body": "We recognise the data protection rights afforded to residents of these jurisdictions under their respective national laws. Contact us to exercise your rights.",
|
||||
"privacy.s14_apj_label": "Other APJ markets (Singapore PDPA, New Zealand Privacy Act, India DPDP Act):",
|
||||
"privacy.s14_australia_body": "Australian residents may request access to and correction of their personal information. We will respond to access requests within 30 days. Complaints may be lodged with the Office of the Australian Information Commissioner (OAIC).",
|
||||
"privacy.s14_australia_label": "Australia (Privacy Act 1988 and Australian Privacy Principles):",
|
||||
"privacy.s14_contact": "Contact:",
|
||||
"privacy.s14_heading": "14. Additional Rights – Asia-Pacific & Japan",
|
||||
"privacy.s14_japan_body": "Japanese residents may request disclosure, correction, addition or deletion, suspension of use, erasure, or suspension of third-party provision of their personal information held by us. Third-party disclosures require your prior consent except where permitted by law.",
|
||||
"privacy.s14_japan_label": "Japan (APPI – Act on the Protection of Personal Information):",
|
||||
"privacy.s14_korea_body": "Korean residents may request access, correction, deletion, and suspension of processing. We handle personal information of Korean residents in accordance with the PIPA.",
|
||||
"privacy.s14_korea_label": "South Korea (PIPA – Personal Information Protection Act):",
|
||||
"privacy.s15_contact": "Contact:",
|
||||
"privacy.s15_heading": "15. Additional Rights – Ukraine",
|
||||
"privacy.s15_p1": "Users located in Ukraine are protected under the Law of Ukraine \"On Personal Data Protection\" (No. 2297-VI). Your rights include access to, correction, blocking, and deletion of your personal data, as well as the right to object to processing.",
|
||||
"privacy.s16_cookies_link": "Cookie Policy",
|
||||
"privacy.s16_heading": "16. Updates to this Privacy Notice",
|
||||
"privacy.s16_license_link": "License Information",
|
||||
"privacy.s16_p1": "We may update this notice from time to time to reflect changes in our practices or applicable laws. The \"Last Updated\" date at the top of this page indicates when the notice was last revised. Where changes are material, we will notify users via in-application notification or email where appropriate.",
|
||||
"privacy.s16_p2_pre": "If you have any questions or concerns about this Privacy Notice or your personal data, please contact us at",
|
||||
"privacy.s16_p3_pre": "Please also review our",
|
||||
"privacy.s16_terms_link": "Terms of Service",
|
||||
"privacy.s1_address": "Alter Steinweg 3, 20459 Hamburg, Germany",
|
||||
"privacy.s1_company": "Christian Louis IT Beratung",
|
||||
"privacy.s1_contact_label": "Contact Email:",
|
||||
"privacy.s1_heading": "1. Data Controller",
|
||||
"privacy.s1_p1": "The controller responsible for processing your personal data under the EU General Data Protection Regulation (GDPR) and equivalent privacy laws worldwide is:",
|
||||
"privacy.s1_p3": "For all privacy-related requests (access, deletion, rectification, opt-out, or complaints), please contact us at the email address above. We will respond within 30 days (or the period prescribed by applicable law).",
|
||||
"privacy.s2_heading": "2. Scope of this Privacy Notice",
|
||||
"privacy.s2_p1_pre": "This notice applies to the DocuElevate web application, hosted at",
|
||||
"privacy.s2_p2": "It covers all users globally, including those in the European Union (EU), European Economic Area (EEA), Germany, United Kingdom (UK), Switzerland, Ukraine, United States (US), Canada, Latin America (Latam), Asia-Pacific, and Japan. Market-specific disclosures are provided in dedicated sections below.",
|
||||
"privacy.s3_audit_body": "We maintain limited audit logs (action type, timestamp, user identifier) to ensure service integrity and security. These logs do not include document content.",
|
||||
"privacy.s3_audit_label": "Audit Logs:",
|
||||
"privacy.s3_auth_body": "We use OAuth 2.0 (Google, Dropbox, Microsoft/OneDrive) and optional local authentication. Through OAuth, we may receive your name, email address, and profile picture.",
|
||||
"privacy.s3_auth_label": "User Authentication:",
|
||||
"privacy.s3_doc_body": "Documents you upload are processed for OCR (optical character recognition), metadata extraction, and storage to your chosen cloud provider. Document content is processed only for the purpose you initiate and is not stored beyond what is operationally necessary.",
|
||||
"privacy.s3_doc_label": "Document Processing:",
|
||||
"privacy.s3_heading": "3. Data Collection & Purposes",
|
||||
"privacy.s3_legal_body": "Our primary legal bases for processing are:",
|
||||
"privacy.s3_legal_label": "Legal Basis (GDPR Art. 6):",
|
||||
"privacy.s3_li1": "(1)(b) Performance of a contract: to provide the DocuElevate service you have requested.",
|
||||
"privacy.s3_li2": "(1)(c) Legal obligation: to comply with applicable laws and regulations.",
|
||||
"privacy.s3_li3": "(1)(f) Legitimate interests: ensuring the security of the service and preventing fraud.",
|
||||
"privacy.s4_heading": "4. Data Minimization & Purpose Limitation",
|
||||
"privacy.s4_li1": "We collect only the minimum personal data required to operate the service.",
|
||||
"privacy.s4_li2": "Document content is processed strictly for the purpose you initiate (OCR, storage, metadata extraction). We do not use your documents to train AI models or for any secondary purpose.",
|
||||
"privacy.s4_li3": "No advertising, behavioural tracking, or profiling is performed.",
|
||||
"privacy.s4_li4": "No tracking cookies or analytics scripts are loaded.",
|
||||
"privacy.s4_li5": "Third-party AI services (e.g., OpenAI, Azure Document Intelligence) are invoked only when you initiate document processing, and data is transmitted under data processing agreements.",
|
||||
"privacy.s4_p1": "DocuElevate is designed with data minimization as a core principle (GDPR Art. 5(1)(c)):",
|
||||
"privacy.s5_cookie_link": "Cookie Policy",
|
||||
"privacy.s5_heading": "5. Use of Cookies & Similar Technologies",
|
||||
"privacy.s5_p1_post": "to maintain your authenticated session. These cookies are essential for the service to function and are exempt from prior-consent requirements under the EU ePrivacy Directive (Art. 5(3)) and equivalent national laws.",
|
||||
"privacy.s5_p1_pre": "DocuElevate uses",
|
||||
"privacy.s5_p1_strong": "only strictly necessary session cookies",
|
||||
"privacy.s5_p2_body": "analytics cookies, advertising cookies, tracking pixels, or any third-party cookies that would require your consent.",
|
||||
"privacy.s5_p2_label": "We do not use:",
|
||||
"privacy.s5_p3_pre": "For full details on the cookies we set, their names, duration, and purpose, please visit our",
|
||||
"privacy.s6_ai_body": "When you initiate OCR or AI-based metadata extraction, document data is transmitted to the AI service you or your administrator has configured. This transmission is governed by a data processing agreement with the respective provider.",
|
||||
"privacy.s6_ai_label": "AI Processing Services (OpenAI, Azure Document Intelligence, others):",
|
||||
"privacy.s6_heading": "6. Third-Party Services",
|
||||
"privacy.s6_no_sale_body": "We do not sell, rent, or share your personal data with third parties for advertising, marketing, or any purpose unrelated to providing the service.",
|
||||
"privacy.s6_no_sale_label": "No Sale or Sharing for Advertising:",
|
||||
"privacy.s6_oauth_body": "When you choose to authenticate via OAuth, the respective provider processes your credentials and may share limited profile information with us. These providers maintain their own privacy policies.",
|
||||
"privacy.s6_oauth_label": "OAuth Providers (Google, Dropbox, Microsoft):",
|
||||
"privacy.s6_storage_body": "Documents are stored in the cloud provider you configure. Your configured credentials are stored encrypted in the application database and are used solely to perform the storage operations you request.",
|
||||
"privacy.s6_storage_label": "Cloud Storage Providers (Google Drive, Dropbox, OneDrive, Amazon S3, Nextcloud, WebDAV/SFTP/FTP):",
|
||||
"privacy.s7_adequacy_body": "where the European Commission has recognised an equivalent level of protection (e.g., United Kingdom, Switzerland, Canada (commercial organisations), Japan, South Korea).",
|
||||
"privacy.s7_adequacy_label": "Adequacy Decisions",
|
||||
"privacy.s7_contact": "You may request a copy of the relevant safeguards by contacting us at",
|
||||
"privacy.s7_heading": "7. International Data Transfers",
|
||||
"privacy.s7_idta_body": "for transfers from the UK after Brexit.",
|
||||
"privacy.s7_idta_label": "UK International Data Transfer Agreements (IDTAs)",
|
||||
"privacy.s7_p1": "DocuElevate is hosted in the European Union / EEA by default. Where personal data is transferred outside the EEA (for example to US-based AI service providers such as OpenAI), we rely on appropriate safeguards including:",
|
||||
"privacy.s7_scc_body": "adopted by the European Commission (2021/914/EU) for transfers to processors and controllers in third countries.",
|
||||
"privacy.s7_scc_label": "Standard Contractual Clauses (SCCs)",
|
||||
"privacy.s8_audit_body": "Retained for up to 90 days for security and compliance purposes.",
|
||||
"privacy.s8_audit_label": "Audit logs:",
|
||||
"privacy.s8_contact": "To request deletion of your account and all associated personal data, please contact us at",
|
||||
"privacy.s8_files_body": "Retained for the duration of your use of the service. You may delete individual files at any time through the application.",
|
||||
"privacy.s8_files_label": "File records and metadata:",
|
||||
"privacy.s8_heading": "8. Data Retention",
|
||||
"privacy.s8_oauth_body": "Stored in encrypted form and revocable at any time via your OAuth provider.",
|
||||
"privacy.s8_oauth_label": "OAuth tokens:",
|
||||
"privacy.s8_p1": "We retain personal data only as long as strictly necessary to provide the DocuElevate service or to comply with legal obligations:",
|
||||
"privacy.s8_session_body": "Deleted when you log out or after session timeout.",
|
||||
"privacy.s8_session_label": "Session data:",
|
||||
"privacy.s9_heading": "9. Data Security",
|
||||
"privacy.s9_li1": "Encryption of credentials and sensitive configuration at rest.",
|
||||
"privacy.s9_li2": "Transport Layer Security (TLS/HTTPS) for all communications.",
|
||||
"privacy.s9_li3": "Role-based access controls limiting access to personal data.",
|
||||
"privacy.s9_li4": "Regular security audits and dependency vulnerability scanning.",
|
||||
"privacy.s9_li5": "CSRF protection on all state-changing requests.",
|
||||
"privacy.s9_p1": "We implement appropriate technical and organisational measures (TOMs) to protect your personal data, including:",
|
||||
"privacy.toc_1": "Data Controller",
|
||||
"privacy.toc_10": "Your Rights (EU / EEA / UK / Switzerland)",
|
||||
"privacy.toc_11": "Additional Rights – United States (CCPA/CPRA)",
|
||||
"privacy.toc_12": "Additional Rights – Canada (PIPEDA / Law 25)",
|
||||
"privacy.toc_13": "Additional Rights – Latin America (LGPD & others)",
|
||||
"privacy.toc_14": "Additional Rights – Asia-Pacific & Japan",
|
||||
"privacy.toc_15": "Additional Rights – Ukraine",
|
||||
"privacy.toc_16": "Updates to this Privacy Notice",
|
||||
"privacy.toc_2": "Scope of this Privacy Notice",
|
||||
"privacy.toc_3": "Data Collection & Purposes",
|
||||
"privacy.toc_4": "Data Minimization & Purpose Limitation",
|
||||
"privacy.toc_5": "Use of Cookies & Similar Technologies",
|
||||
"privacy.toc_6": "Third-Party Services",
|
||||
"privacy.toc_7": "International Data Transfers",
|
||||
"privacy.toc_8": "Data Retention",
|
||||
"privacy.toc_9": "Data Security",
|
||||
"privacy.toc_heading": "Contents",
|
||||
"profile.avatar_alt": "Your profile picture",
|
||||
"profile.avatar_heading": "Profile Picture",
|
||||
"profile.avatar_remove": "Remove custom avatar",
|
||||
"profile.avatar_upload_hint": "Upload a JPEG, PNG, GIF, or WebP image up to 2 MB. If no custom picture is set, your {gravatar} is shown.",
|
||||
"profile.choose_image": "Choose image…",
|
||||
"profile.confirm_password": "Confirm New Password",
|
||||
"profile.contact_email_hint": "Used for system notifications. This does not change your login e-mail.",
|
||||
"profile.contact_email_label": "Contact / Notification E-mail",
|
||||
"profile.contact_email_placeholder": "you@example.com",
|
||||
"profile.current_password": "Current Password",
|
||||
"profile.dismiss": "Dismiss",
|
||||
"profile.display_name_hint": "Leave blank to use your account username or email.",
|
||||
"profile.display_name_label": "Display Name",
|
||||
"profile.display_name_placeholder": "Your name as shown in the UI",
|
||||
"profile.general_heading": "General Information",
|
||||
"profile.gravatar_link": "Gravatar",
|
||||
"profile.heading": "Profile Settings",
|
||||
"profile.language_auto_detect": "Auto-detect (from browser)",
|
||||
"profile.language_hint": "Choose your preferred interface language. “Auto-detect” follows your browser settings.",
|
||||
"profile.language_label": "UI Language",
|
||||
"profile.new_password": "New Password",
|
||||
"profile.new_password_hint": "Minimum 8 characters.",
|
||||
"profile.page_title": "Profile Settings – DocuElevate",
|
||||
"profile.password_heading": "Change Password",
|
||||
"profile.preferences_heading": "Preferences",
|
||||
"profile.save_button": "Save Changes",
|
||||
"profile.saving": "Saving…",
|
||||
"profile.subtitle": "Manage your display name, avatar, language, and theme preferences.",
|
||||
"profile.theme_dark": "Dark",
|
||||
"profile.theme_hint": "“System Default” follows your device’s dark-mode setting.",
|
||||
"profile.theme_label": "Colour Scheme",
|
||||
"profile.theme_light": "Light",
|
||||
"profile.theme_system": "System Default",
|
||||
"profile.update_password": "Update Password",
|
||||
"profile.updating": "Updating…",
|
||||
"queue.active_tasks": "Active Tasks",
|
||||
"queue.auto_refresh_1": "Auto-refreshes every",
|
||||
"queue.auto_refresh_2": "seconds",
|
||||
@@ -890,14 +1579,131 @@
|
||||
"similarity.subtitle": "Pairs of documents with high semantic similarity, ranked by score.",
|
||||
"similarity.trigger_aria": "Trigger embedding computation for all files missing embeddings",
|
||||
"similarity.trigger_now": "trigger it now",
|
||||
"status.active": "Active",
|
||||
"status.ai_empty_response": "(empty)",
|
||||
"status.ai_extraction_desc": "Paste the plain-text content of a document below and run it through the configured AI provider to inspect the raw response, extracted JSON, and tags.",
|
||||
"status.ai_extraction_failed": "AI Extraction Failed",
|
||||
"status.ai_extraction_label": "Document Text",
|
||||
"status.ai_extraction_placeholder": "Paste the plain-text content of your document here…",
|
||||
"status.ai_extraction_title": "AI Extraction Test",
|
||||
"status.app_version": "App Version",
|
||||
"status.as_account": "as",
|
||||
"status.auth_required": "Authentication Required",
|
||||
"status.build_date": "Build Date",
|
||||
"status.config_settings": "Configuration Settings",
|
||||
"status.config_settings_desc": "For more detailed configuration settings and environment variables, check the settings page.",
|
||||
"status.configure_now": "Configure Now",
|
||||
"status.configured": "Configured",
|
||||
"status.connection_error": "Connection Error",
|
||||
"status.connection_test_failed": "Connection Test Failed",
|
||||
"status.connection_test_successful": "Connection Test Successful",
|
||||
"status.container_id": "Container ID",
|
||||
"status.container_started": "Container Started",
|
||||
"status.dashboard_subtitle": "This dashboard shows the status of all configured integrations and targets.",
|
||||
"status.debug_mode": "Debug Mode",
|
||||
"status.error_running_extraction": "Error running extraction: ",
|
||||
"status.error_testing_connection": "Error testing connection: ",
|
||||
"status.error_testing_notifications": "Error testing notifications: ",
|
||||
"status.extracted_tags": "Extracted Tags",
|
||||
"status.git_commit": "Git Commit",
|
||||
"status.inactive": "Inactive",
|
||||
"status.json_parse_issue": "JSON parse issue: ",
|
||||
"status.last_check": "Last Check",
|
||||
"status.manage": "Manage",
|
||||
"status.modal_default_message": "Operation completed successfully.",
|
||||
"status.modal_default_title": "Success",
|
||||
"status.no_details": "No details available",
|
||||
"status.not_configured": "Not Configured",
|
||||
"status.notification_config_missing": "Notification Configuration Missing",
|
||||
"status.open": "Open",
|
||||
"status.page_title": "System Status",
|
||||
"status.parsed_json_label": "Parsed JSON",
|
||||
"status.provider_config_details": "{name} Configuration Details",
|
||||
"status.provider_details": "Provider Details",
|
||||
"status.raw_llm_response": "Raw LLM Response",
|
||||
"status.run_extraction": "Run Extraction",
|
||||
"status.running": "Running…",
|
||||
"status.sending": "Sending...",
|
||||
"status.setting_label": "Setting",
|
||||
"status.test_connection": "Test Connection",
|
||||
"status.test_extraction": "Test Extraction",
|
||||
"status.test_failed": "Test Failed",
|
||||
"status.test_notification_failed": "Test Notification Failed",
|
||||
"status.test_notification_sent": "Test Notification Sent",
|
||||
"status.test_notifications": "Test Notifications",
|
||||
"status.test_provider": "Test {name}",
|
||||
"status.test_successful": "Test Successful",
|
||||
"status.testing": "Testing...",
|
||||
"status.token_expired": "Your token has expired or is invalid. Please reconfigure this connection.",
|
||||
"status.token_valid_for": "Token valid for:",
|
||||
"status.value_label": "Value",
|
||||
"status.view_config": "View Detailed Configuration",
|
||||
"status.view_details": "View Details",
|
||||
"subscription.available_plans_heading": "Available Plans",
|
||||
"subscription.back_to_dashboard": "Back to Dashboard",
|
||||
"subscription.cancel_pending": "Cancel change",
|
||||
"subscription.cancel_scheduled": "Cancel scheduled change",
|
||||
"subscription.contact_sales": "Contact Sales",
|
||||
"subscription.current_badge": "Current",
|
||||
"subscription.current_plan": "Current Plan",
|
||||
"subscription.current_plan_cta": "Your current plan",
|
||||
"subscription.downgrade_to_prefix": "Downgrade to",
|
||||
"subscription.features_heading": "What’s 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",
|
||||
|
||||
+1090
-905
File diff suppressed because it is too large
Load Diff
+633
-448
File diff suppressed because it is too large
Load Diff
+1075
-890
File diff suppressed because it is too large
Load Diff
+1097
-912
File diff suppressed because it is too large
Load Diff
+1072
-887
File diff suppressed because it is too large
Load Diff
+584
-399
File diff suppressed because it is too large
Load Diff
+1017
-832
File diff suppressed because it is too large
Load Diff
+1067
-882
File diff suppressed because it is too large
Load Diff
+1057
-872
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
+1095
-910
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1084
-899
File diff suppressed because it is too large
Load Diff
+1058
-873
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
+1063
-878
File diff suppressed because it is too large
Load Diff
+951
-766
File diff suppressed because it is too large
Load Diff
+1095
-910
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
+1103
-918
File diff suppressed because it is too large
Load Diff
+1099
-914
File diff suppressed because it is too large
Load Diff
+1006
-821
File diff suppressed because it is too large
Load Diff
+1030
-845
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user