Compare commits

...

49 Commits

Author SHA1 Message Date
google-labs-jules[bot] 16de54f66b 🧹 [code health] Remove unused TokenRevokeRequest model
🎯 What: The code health issue addressed (removing unused TokenRevokeRequest model) was already resolved in the codebase.
💡 Why: How this improves maintainability (removing dead code).
 Verification: I confirmed by searching the repository and inspecting the files that the code does not exist.
 Result: The codebase is already in the desired state.

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-23 14:27:10 +00:00
github-actions[bot] 084171395d docs(changelog): update changelog [skip ci] 2026-03-23 12:17:55 +00:00
Christian Krakau-Louis 958b195e79 Merge pull request #806 from christianlouis/alert-autofix-400
Potential fix for code scanning alert no. 400: Clear-text logging of sensitive information
2026-03-23 13:17:29 +01:00
Christian Krakau-Louis c5ef1ec50c Potential fix for code scanning alert no. 400: Clear-text logging of sensitive information
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-03-23 13:17:19 +01:00
github-actions[bot] ef897f660d chore(release): update build metadata files [skip ci] 2026-03-22 18:47:10 +00:00
semantic-release 6cb9feacab 0.172.1
Automatically generated by python-semantic-release
2026-03-22 18:47:07 +00:00
Christian Krakau-Louis 76c0e91500 Merge pull request #804 from christianlouis/copilot/enable-qr-code-upload
fix: social login providers load from DB without restart; connections page shows accurate linked status
2026-03-22 19:46:48 +01:00
copilot-swe-agent[bot] 0c7ea6748d fix: social login providers now work from DB config without restart
- Extract all OAuth registration into _setup_social_providers() with a
  _register_oauth_client() helper that clears the authlib _clients cache
  so credentials can change without a restart
- Add refresh_social_providers() public function called after every
  settings reload (lifespan startup + settings_sync live reload)
- Fix connections page linked status to use _get_effective() (DB-aware)
  instead of the stale startup-time SOCIAL_PROVIDERS dict
- Fix oauth_configured template variable similarly
- Add tests: DB-driven linked status, stale-provider clearing,
  register_oauth_client cache-clear, refresh function coverage

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
Agent-Logs-Url: https://github.com/christianlouis/DocuElevate/sessions/ef15910f-fd25-469a-814b-9e1fb40659c9
2026-03-22 18:42:18 +00:00
github-actions[bot] 78077fa8c7 chore(release): update build metadata files [skip ci] 2026-03-22 18:24:56 +00:00
semantic-release 242846aa9c 0.172.0
Automatically generated by python-semantic-release
2026-03-22 18:24:52 +00:00
Christian Krakau-Louis 868613ac49 Merge pull request #802 from christianlouis/copilot/migrate-tailscale-to-3-x
feat(ui): migrate Tailwind CSS from v2 CDN to compiled v3 production build
2026-03-22 19:24:31 +01:00
copilot-swe-agent[bot] 33a0e49acd Initial plan 2026-03-22 18:21:40 +00:00
copilot-swe-agent[bot] 14b3031e63 feat(ui): replace Tailwind CSS CDN with compiled v3 production build
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
Agent-Logs-Url: https://github.com/christianlouis/DocuElevate/sessions/85d2244d-170a-48d3-8f6b-b4c124a49ed9
2026-03-22 18:07:32 +00:00
copilot-swe-agent[bot] 1d7df13c94 feat(ui): migrate Tailwind CSS from v2 CDN to v3 Play CDN (interim step)
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
Agent-Logs-Url: https://github.com/christianlouis/DocuElevate/sessions/85d2244d-170a-48d3-8f6b-b4c124a49ed9
2026-03-22 17:50:11 +00:00
github-actions[bot] ce4bca0186 chore(release): update build metadata files [skip ci] 2026-03-22 17:46:30 +00:00
semantic-release 4b07e996ad 0.171.3
Automatically generated by python-semantic-release
2026-03-22 17:46:27 +00:00
Christian Krakau-Louis 720c9c11b0 Merge pull request #803 from christianlouis/copilot/fix-html-lint-errors
fix(ui): add missing opening `<script>` tag in base.html Sentry init block
2026-03-22 18:46:06 +01:00
copilot-swe-agent[bot] 425472c839 fix(ui): add missing opening script tag in base.html Sentry block
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
Agent-Logs-Url: https://github.com/christianlouis/DocuElevate/sessions/3780cab7-11b6-470a-935a-57d63bf1d36b
2026-03-22 17:42:54 +00:00
copilot-swe-agent[bot] 55afa4981b Initial plan 2026-03-22 17:41:40 +00:00
github-actions[bot] 63f7b62fc0 chore(release): update build metadata files [skip ci] 2026-03-22 17:37:52 +00:00
copilot-swe-agent[bot] 48a303d498 Initial plan 2026-03-22 17:37:51 +00:00
semantic-release 8f1fe79411 0.171.2
Automatically generated by python-semantic-release
2026-03-22 17:37:50 +00:00
Christian Krakau-Louis 3e1b352930 Merge pull request #801 from christianlouis/copilot/fix-toggle-not-working
fix(ui): replace broken Tailwind v3-only peer toggles on admin connections page
2026-03-22 18:37:25 +01:00
copilot-swe-agent[bot] 46772fc746 fix(ui): fix greyed-out toggle switches on admin connections page
The toggles used Tailwind CSS v3 JIT pseudo-element utilities
(after:content-[''], peer-checked:after:translate-x-full, etc.)
that are not available in Tailwind v2.2.19 CDN.

Added .doc-toggle / .doc-toggle-track CSS classes to styles.css
using native CSS ::after pseudo-elements and adjacent-sibling
selectors — works across all Tailwind versions and browsers.
Updated all three toggle instances in admin_connections.html
(SSO auto-login, QR login, and JS-created service settings toggles).

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
Agent-Logs-Url: https://github.com/christianlouis/DocuElevate/sessions/64581c3a-6c34-4bb6-bb6b-6331945fed04
2026-03-22 17:35:22 +00:00
github-actions[bot] 25d32a9006 docs(changelog): update changelog [skip ci] 2026-03-22 17:29:38 +00:00
Christian Krakau-Louis 8c6a02885d Update Sentry script to version 10.45.0 2026-03-22 18:29:18 +01:00
copilot-swe-agent[bot] 899cc56638 Initial plan 2026-03-22 17:25:56 +00:00
github-actions[bot] 9822ba583d docs(changelog): update changelog [skip ci] 2026-03-22 17:21:40 +00:00
Christian Krakau-Louis 2288b89cd7 Merge pull request #800 from christianlouis/copilot/add-sentry-bundle
chore: upgrade Sentry Browser SDK CDN bundle from v9 to v10
2026-03-22 18:21:17 +01:00
copilot-swe-agent[bot] 3d0bdf7836 chore: upgrade Sentry Browser SDK CDN bundle from v9.x.x to v10.x.x
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
Agent-Logs-Url: https://github.com/christianlouis/DocuElevate/sessions/583f7047-bc1e-4bcd-8a1a-350857626370
2026-03-22 17:17:54 +00:00
copilot-swe-agent[bot] 41844c4b60 Initial plan 2026-03-22 17:11:17 +00:00
github-actions[bot] 94aa2ebe57 chore(release): update build metadata files [skip ci] 2026-03-22 16:43:37 +00:00
semantic-release be97a757a3 0.171.1
Automatically generated by python-semantic-release
2026-03-22 16:43:34 +00:00
Christian Krakau-Louis a5df6dc9cb Merge pull request #799 from christianlouis/copilot/fix-greyed-out-toggles
fix(admin): fix greyed-out QR login toggle on /admin/connections
2026-03-22 17:43:12 +01:00
copilot-swe-agent[bot] d4cc44a72f fix(admin): fix greyed-out QR login toggle on admin connections page
- Add `qr_login_enabled` boolean config field (default True) to app/config.py
- Add `qr_login_enabled` entry to settings metadata in app/utils/settings_service.py
- Fix app/views/settings.py to use `qr_login_enabled` directly instead of
  deriving enablement status from qr_login_challenge_ttl_seconds (integer TTL)
- Fix admin_connections.html: remove hardcoded `disabled` attribute from the
  Mobile Phone Upload toggle and wire up onchange handler so toggling actually
  persists the setting via toggleSetting('qr_login_enabled', this.checked)
- Gate all three QR auth API endpoints on settings.qr_login_enabled so the
  feature is actually disabled when the toggle is turned off

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
Agent-Logs-Url: https://github.com/christianlouis/DocuElevate/sessions/8421cdb2-d92d-4b83-9cda-c44983d35173
2026-03-22 16:06:55 +00:00
copilot-swe-agent[bot] 61dee5ba52 Initial plan 2026-03-22 15:55:18 +00:00
github-actions[bot] 5f94e64734 chore(release): update build metadata files [skip ci] 2026-03-22 15:51:56 +00:00
semantic-release 9be03d8690 0.171.0
Automatically generated by python-semantic-release
2026-03-22 15:51:52 +00:00
Christian Krakau-Louis 5c5b3ac054 Merge pull request #798 from christianlouis/copilot/add-file-owner-display-and-claim-option
feat(ui): show file owner and add claim ownership on file view pages
2026-03-22 16:51:29 +01:00
copilot-swe-agent[bot] 9458055661 feat(ui): show file owner, add claim ownership on file summary, detail, and annotations pages
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
Agent-Logs-Url: https://github.com/christianlouis/DocuElevate/sessions/0ce1d83e-500c-473f-bbb6-ccf510ba953a
2026-03-22 15:46:30 +00:00
copilot-swe-agent[bot] bb116dcdd3 Initial plan 2026-03-22 15:28:24 +00:00
github-actions[bot] 725bf98352 chore(release): update build metadata files [skip ci] 2026-03-22 15:24:16 +00:00
semantic-release 962495ba8c 0.170.0
Automatically generated by python-semantic-release
2026-03-22 15:24:13 +00:00
Christian Krakau-Louis 3843bce596 Merge pull request #795 from christianlouis/copilot/fix-annotations-saving
feat(ui): Connect EmbedPDF viewer to annotations panel with bidirectional sync
2026-03-22 16:23:45 +01:00
github-actions[bot] 2df92ce469 chore(release): update build metadata files [skip ci] 2026-03-22 15:22:18 +00:00
semantic-release b25aaf879f 0.169.1
Automatically generated by python-semantic-release
2026-03-22 15:22:16 +00:00
Christian Krakau-Louis 4120a502df Merge pull request #796 from christianlouis/copilot/fix-dropbox-authentication-toggle
fix: 405 on settings PUT + shared OAuth credentials for Google & Microsoft
2026-03-22 16:21:49 +01:00
copilot-swe-agent[bot] 9c98a8438a feat(ui): integrate EmbedPDF viewer with annotations panel for bidirectional sync
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
Agent-Logs-Url: https://github.com/christianlouis/DocuElevate/sessions/3eb56099-1bc7-456d-a23d-1cee2176cf8a
2026-03-22 15:08:12 +00:00
copilot-swe-agent[bot] 49b816c878 Initial plan 2026-03-22 14:49:52 +00:00
42 changed files with 2497 additions and 458 deletions
+3
View File
@@ -200,3 +200,6 @@ cython_debug/
# Build metadata files - generated at build time
GIT_SHA
RUNTIME_INFO
# Frontend build tooling
frontend/node_modules/
+1 -1
View File
@@ -1 +1 @@
2026-03-22T14:46:09Z
2026-03-22T18:47:07Z
+105
View File
@@ -10,6 +10,111 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
<!-- version list -->
## Unreleased
## v0.172.1 (2026-03-22)
### Bug Fixes
- Social login providers now work from DB config without restart
([`0c7ea67`](https://github.com/christianlouis/DocuElevate/commit/0c7ea6748da554c80ef9af1b709c08aba49174e6))
## v0.172.0 (2026-03-22)
### Features
- **ui**: Migrate Tailwind CSS from v2 CDN to v3 Play CDN (interim step)
([`1d7df13`](https://github.com/christianlouis/DocuElevate/commit/1d7df13c943cc9138dc3ed514f9ab81d861bfbac))
- **ui**: Replace Tailwind CSS CDN with compiled v3 production build
([`14b3031`](https://github.com/christianlouis/DocuElevate/commit/14b3031e63e8645c4048dd73a594e9a53a919c17))
## v0.171.3 (2026-03-22)
### Bug Fixes
- **ui**: Add missing opening script tag in base.html Sentry block
([`425472c`](https://github.com/christianlouis/DocuElevate/commit/425472c839b3564c20a29b6e983fa6b9e7d6cf9c))
## v0.171.2 (2026-03-22)
### Bug Fixes
- **ui**: Fix greyed-out toggle switches on admin connections page
([`46772fc`](https://github.com/christianlouis/DocuElevate/commit/46772fc7461f9f8333b399b301ec969f5caa4c1e))
### Chores
- Upgrade Sentry Browser SDK CDN bundle from v9.x.x to v10.x.x
([`3d0bdf7`](https://github.com/christianlouis/DocuElevate/commit/3d0bdf783649019d631fa0b1156db66e5e3269b4))
### Documentation
- **changelog**: Update changelog [skip ci]
([`25d32a9`](https://github.com/christianlouis/DocuElevate/commit/25d32a9006161b433dc6bbac3810ab560e5e5847))
- **changelog**: Update changelog [skip ci]
([`9822ba5`](https://github.com/christianlouis/DocuElevate/commit/9822ba583d076671692699cd9856d8fbc7d0218d))
## Unreleased
### Chores
- Upgrade Sentry Browser SDK CDN bundle from v9.x.x to v10.x.x
([`3d0bdf7`](https://github.com/christianlouis/DocuElevate/commit/3d0bdf783649019d631fa0b1156db66e5e3269b4))
### Documentation
- **changelog**: Update changelog [skip ci]
([`9822ba5`](https://github.com/christianlouis/DocuElevate/commit/9822ba583d076671692699cd9856d8fbc7d0218d))
## Unreleased
### Chores
- Upgrade Sentry Browser SDK CDN bundle from v9.x.x to v10.x.x
([`3d0bdf7`](https://github.com/christianlouis/DocuElevate/commit/3d0bdf783649019d631fa0b1156db66e5e3269b4))
## v0.171.1 (2026-03-22)
### Bug Fixes
- **admin**: Fix greyed-out QR login toggle on admin connections page
([`d4cc44a`](https://github.com/christianlouis/DocuElevate/commit/d4cc44a72f7821360ffce1c9743efb85dbc65f22))
## v0.171.0 (2026-03-22)
### Features
- **ui**: Show file owner, add claim ownership on file summary, detail, and annotations pages
([`9458055`](https://github.com/christianlouis/DocuElevate/commit/9458055661e5458256b51cfe1965b0607d6a478e))
## v0.170.0 (2026-03-22)
### Features
- **ui**: Integrate EmbedPDF viewer with annotations panel for bidirectional sync
([`9c98a84`](https://github.com/christianlouis/DocuElevate/commit/9c98a8438ab81c70cc497191449378b914775731))
## v0.169.1 (2026-03-22)
### Bug Fixes
- **api**: Add PUT /api/settings/{key} endpoint and shared credentials for Google/Microsoft social
login
([`7d6128d`](https://github.com/christianlouis/DocuElevate/commit/7d6128d78f7782d4a687b51220747714ab59df6d))
## v0.169.0 (2026-03-22)
### Bug Fixes
+17 -2
View File
@@ -27,7 +27,20 @@ RUN pip install --no-cache-dir -r requirements.txt \
&& find /opt/venv -type f -name "*.pyc" -delete \
&& find /opt/venv -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
# ── Stage 2: Documentation builder ──────────────────────────────────────────
# ── Stage 2: Frontend asset builder (Tailwind CSS) ──────────────────────────
FROM node:20-alpine AS frontend-builder
WORKDIR /frontend
# Install dependencies first (layer-cached unless package.json/lockfile changes)
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci --omit=dev
# Copy source files and compile Tailwind CSS
COPY frontend/ ./
RUN npm run build
# ── Stage 3: Documentation builder ──────────────────────────────────────────
FROM python:3.14.3-slim AS docs-builder
WORKDIR /docs
@@ -43,7 +56,7 @@ COPY mkdocs.yml /docs/mkdocs.yml
# Build the static documentation site
RUN mkdocs build --config-file /docs/mkdocs.yml --site-dir /docs/docs_build
# ── Stage 3: Runtime image ───────────────────────────────────────────────────
# ── Stage 4: Runtime image ───────────────────────────────────────────────────
FROM python:3.14.3-slim
WORKDIR /app
@@ -68,6 +81,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
# Copy application code
COPY ./app /app/app
COPY ./frontend /app/frontend
# Overlay compiled Tailwind CSS from the frontend build stage
COPY --from=frontend-builder /frontend/static/styles.css /app/frontend/static/styles.css
COPY ./migrations /app/migrations
COPY ./alembic.ini /app/alembic.ini
COPY ./LICENSE /app/LICENSE
+1 -1
View File
@@ -1 +1 @@
4a35aab
76c0e91
+6 -6
View File
@@ -1,10 +1,10 @@
DocuElevate Build Information
==============================
Version: 0.169.0
Build Date: 2026-03-22T14:46:09Z
Git Commit: 4a35aabdaa92dfa55d1f3a332691df81e56f025a
Git Short SHA: 4a35aab
Version: 0.172.1
Build Date: 2026-03-22T18:47:07Z
Git Commit: 76c0e91500963fac4e8d4a43123340a7cc64731f
Git Short SHA: 76c0e91
Git Branch: main
Commit Date: 2026-03-22T15:45:45+01:00
Build Timestamp: 2026-03-22T14:46:09Z
Commit Date: 2026-03-22T19:46:48+01:00
Build Timestamp: 2026-03-22T18:47:07Z
==============================
+1 -1
View File
@@ -1 +1 @@
0.169.0
0.172.1
+16
View File
@@ -31,6 +31,7 @@ from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.auth import require_login
from app.config import settings
from app.database import get_db
from app.middleware.audit_log import get_client_ip
from app.utils.session_manager import (
@@ -151,6 +152,11 @@ async def create_challenge(
displayed to the user. The mobile app scans this QR code and
calls the ``/claim`` endpoint.
"""
if not settings.qr_login_enabled:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="QR login feature is currently disabled. Please contact your administrator to enable it.",
)
ip = get_client_ip(request)
challenge = create_qr_challenge(db, owner_id, ip_address=ip)
@@ -187,6 +193,11 @@ async def poll_challenge_status(
The web UI calls this endpoint every few seconds to check if the
mobile app has scanned the QR code and claimed the challenge.
"""
if not settings.qr_login_enabled:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="QR login feature is currently disabled. Please contact your administrator to enable it.",
)
result = get_challenge_status(db, challenge_id, owner_id)
if not result:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Challenge not found")
@@ -206,6 +217,11 @@ async def claim_challenge(
serves as proof that the user authorized this login from their web
session.
"""
if not settings.qr_login_enabled:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="QR login feature is currently disabled. Please contact your administrator to enable it.",
)
ip = get_client_ip(request)
result = claim_qr_challenge(db, body.challenge_token, device_name=body.device_name, ip_address=ip)
+219 -147
View File
@@ -45,78 +45,27 @@ OAUTH_PROVIDER_NAME = "Single Sign-On"
# Social login providers that are enabled and registered
SOCIAL_PROVIDERS: dict[str, dict[str, str]] = {}
if AUTH_ENABLED and settings.authentik_client_id and settings.authentik_client_secret:
oauth.register(
name="authentik",
client_id=settings.authentik_client_id,
client_secret=settings.authentik_client_secret,
server_metadata_url=settings.authentik_config_url,
client_kwargs={"scope": "openid profile email"},
)
OAUTH_CONFIGURED = True
OAUTH_PROVIDER_NAME = settings.oauth_provider_name or "Authentik SSO"
# --- Social Login Providers ---------------------------------------------------
if AUTH_ENABLED and settings.social_auth_google_enabled:
# Determine which credentials to use for Google social login
_google_client_id = settings.social_auth_google_client_id
_google_client_secret = settings.social_auth_google_client_secret
if settings.social_auth_google_use_global_credentials and not (_google_client_id and _google_client_secret):
_google_client_id = settings.google_drive_client_id
_google_client_secret = settings.google_drive_client_secret
# ---------------------------------------------------------------------------
# Helpers for dynamic (re-)registration of OAuth providers
# ---------------------------------------------------------------------------
if _google_client_id and _google_client_secret:
oauth.register(
name="google",
client_id=_google_client_id,
client_secret=_google_client_secret,
server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
client_kwargs={"scope": "openid profile email"},
)
SOCIAL_PROVIDERS["google"] = {"name": "Google", "icon": "fab fa-google", "color": "red"}
logger.info("Social login provider registered: Google")
else:
logger.warning("SOCIAL_AUTH_GOOGLE_ENABLED=true but client ID/secret not configured")
if AUTH_ENABLED and settings.social_auth_microsoft_enabled:
# Determine which credentials to use for Microsoft social login
_microsoft_client_id = settings.social_auth_microsoft_client_id
_microsoft_client_secret = settings.social_auth_microsoft_client_secret
if settings.social_auth_microsoft_use_global_credentials and not (
_microsoft_client_id and _microsoft_client_secret
):
_microsoft_client_id = settings.onedrive_client_id
_microsoft_client_secret = settings.onedrive_client_secret
def _register_oauth_client(name: str, **kwargs: object) -> None:
"""Register (or re-register) an authlib OAuth client, clearing any cached instance.
if _microsoft_client_id and _microsoft_client_secret:
tenant = settings.social_auth_microsoft_tenant or "common"
oauth.register(
name="microsoft",
client_id=_microsoft_client_id,
client_secret=_microsoft_client_secret,
server_metadata_url=f"https://login.microsoftonline.com/{tenant}/v2.0/.well-known/openid-configuration",
client_kwargs={"scope": "openid profile email"},
)
SOCIAL_PROVIDERS["microsoft"] = {"name": "Microsoft", "icon": "fab fa-microsoft", "color": "blue"}
logger.info("Social login provider registered: Microsoft (tenant=%s)", tenant)
else:
logger.warning("SOCIAL_AUTH_MICROSOFT_ENABLED=true but client ID/secret not configured")
authlib caches the constructed client object in ``oauth._clients`` after the
first ``register()`` call. Subsequent ``register()`` calls overwrite the
registry entry but the stale cached client is still returned by
``create_client()`` / ``__getattr__``. Popping the name from ``_clients``
before re-registering ensures the new credentials are picked up immediately.
if AUTH_ENABLED and settings.social_auth_apple_enabled:
if settings.social_auth_apple_client_id and settings.social_auth_apple_team_id:
oauth.register(
name="apple",
client_id=settings.social_auth_apple_client_id,
server_metadata_url="https://appleid.apple.com/.well-known/openid-configuration",
client_kwargs={
"scope": "openid name email",
"response_mode": "form_post",
},
)
SOCIAL_PROVIDERS["apple"] = {"name": "Apple", "icon": "fab fa-apple", "color": "gray"}
logger.info("Social login provider registered: Apple")
else:
logger.warning("SOCIAL_AUTH_APPLE_ENABLED=true but client ID/team ID not configured")
Args:
name: Provider name (e.g. ``"google"``, ``"github"``).
**kwargs: Keyword arguments forwarded verbatim to ``oauth.register()``.
"""
oauth._clients.pop(name, None)
oauth.register(name, **kwargs)
def _dropbox_userinfo_compliance_fix(client, user_cls, token, data):
@@ -145,92 +94,215 @@ def _dropbox_userinfo_compliance_fix(client, user_cls, token, data):
return data
if AUTH_ENABLED and settings.social_auth_dropbox_enabled:
# Determine which credentials to use for Dropbox social login
_dropbox_client_id = settings.social_auth_dropbox_client_id
_dropbox_client_secret = settings.social_auth_dropbox_client_secret
if settings.social_auth_dropbox_use_global_credentials and not (_dropbox_client_id and _dropbox_client_secret):
_dropbox_client_id = settings.dropbox_app_key
_dropbox_client_secret = settings.dropbox_app_secret
def _setup_social_providers() -> None:
"""Register all configured OAuth / social-login providers from current settings.
if _dropbox_client_id and _dropbox_client_secret:
oauth.register(
name="dropbox",
client_id=_dropbox_client_id,
client_secret=_dropbox_client_secret,
authorize_url="https://www.dropbox.com/oauth2/authorize",
access_token_url="https://api.dropboxapi.com/oauth2/token",
userinfo_endpoint="https://api.dropboxapi.com/2/users/get_current_account",
userinfo_compliance_fix=_dropbox_userinfo_compliance_fix,
client_kwargs={
"token_endpoint_auth_method": "client_secret_post",
"token_access_type": "offline",
},
)
SOCIAL_PROVIDERS["dropbox"] = {"name": "Dropbox", "icon": "fab fa-dropbox", "color": "blue"}
logger.info("Social login provider registered: Dropbox")
else:
logger.warning("SOCIAL_AUTH_DROPBOX_ENABLED=true but client ID/secret not configured")
This function is **idempotent**: it clears ``SOCIAL_PROVIDERS``,
``OAUTH_CONFIGURED``, and ``OAUTH_PROVIDER_NAME`` before rebuilding them,
and calls :func:`_register_oauth_client` (which also clears the authlib
client cache) so that credential changes in the database are reflected
without an application restart.
if AUTH_ENABLED and settings.social_auth_github_enabled:
if settings.social_auth_github_client_id and settings.social_auth_github_client_secret:
oauth.register(
name="github",
client_id=settings.social_auth_github_client_id,
client_secret=settings.social_auth_github_client_secret,
authorize_url="https://github.com/login/oauth/authorize",
access_token_url="https://github.com/login/oauth/access_token",
userinfo_endpoint="https://api.github.com/user",
client_kwargs={"scope": "read:user user:email"},
)
SOCIAL_PROVIDERS["github"] = {"name": "GitHub", "icon": "fab fa-github", "color": "gray"}
logger.info("Social login provider registered: GitHub")
else:
logger.warning("SOCIAL_AUTH_GITHUB_ENABLED=true but client ID/secret not configured")
Can safely be called multiple times, e.g. after a settings reload.
"""
global OAUTH_CONFIGURED, OAUTH_PROVIDER_NAME
if AUTH_ENABLED and settings.social_auth_keycloak_enabled:
_kc_server = settings.social_auth_keycloak_server_url
_kc_realm = settings.social_auth_keycloak_realm
if (
settings.social_auth_keycloak_client_id
and settings.social_auth_keycloak_client_secret
and _kc_server
and _kc_realm
):
_kc_base = f"{_kc_server.rstrip('/')}/realms/{_kc_realm}"
oauth.register(
name="keycloak",
client_id=settings.social_auth_keycloak_client_id,
client_secret=settings.social_auth_keycloak_client_secret,
server_metadata_url=f"{_kc_base}/.well-known/openid-configuration",
SOCIAL_PROVIDERS.clear()
OAUTH_CONFIGURED = False
OAUTH_PROVIDER_NAME = "Single Sign-On"
if not AUTH_ENABLED:
return
# --- Authentik / OIDC ---
if settings.authentik_client_id and settings.authentik_client_secret:
_register_oauth_client(
"authentik",
client_id=settings.authentik_client_id,
client_secret=settings.authentik_client_secret,
server_metadata_url=settings.authentik_config_url,
client_kwargs={"scope": "openid profile email"},
)
SOCIAL_PROVIDERS["keycloak"] = {"name": "Keycloak", "icon": "fas fa-key", "color": "gray"}
logger.info("Social login provider registered: Keycloak (realm=%s)", _kc_realm)
else:
logger.warning("SOCIAL_AUTH_KEYCLOAK_ENABLED=true but required settings not configured")
OAUTH_CONFIGURED = True
OAUTH_PROVIDER_NAME = settings.oauth_provider_name or "Authentik SSO"
if AUTH_ENABLED and settings.social_auth_generic_oauth2_enabled:
if (
settings.social_auth_generic_oauth2_client_id
and settings.social_auth_generic_oauth2_client_secret
and settings.social_auth_generic_oauth2_authorize_url
and settings.social_auth_generic_oauth2_token_url
):
oauth.register(
name="generic_oauth2",
client_id=settings.social_auth_generic_oauth2_client_id,
client_secret=settings.social_auth_generic_oauth2_client_secret,
authorize_url=settings.social_auth_generic_oauth2_authorize_url,
access_token_url=settings.social_auth_generic_oauth2_token_url,
userinfo_endpoint=settings.social_auth_generic_oauth2_userinfo_url,
client_kwargs={"scope": settings.social_auth_generic_oauth2_scope},
)
_generic_name = settings.social_auth_generic_oauth2_name or "OAuth2"
SOCIAL_PROVIDERS["generic_oauth2"] = {"name": _generic_name, "icon": "fas fa-sign-in-alt", "color": "indigo"}
logger.info("Social login provider registered: Generic OAuth2 (%s)", _generic_name)
else:
logger.warning("SOCIAL_AUTH_GENERIC_OAUTH2_ENABLED=true but required settings not configured")
# --- Social Login Providers ---
# Google
if settings.social_auth_google_enabled:
_google_client_id = settings.social_auth_google_client_id
_google_client_secret = settings.social_auth_google_client_secret
if settings.social_auth_google_use_global_credentials and not (_google_client_id and _google_client_secret):
_google_client_id = settings.google_drive_client_id
_google_client_secret = settings.google_drive_client_secret
if _google_client_id and _google_client_secret:
_register_oauth_client(
"google",
client_id=_google_client_id,
client_secret=_google_client_secret,
server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
client_kwargs={"scope": "openid profile email"},
)
SOCIAL_PROVIDERS["google"] = {"name": "Google", "icon": "fab fa-google", "color": "red"}
logger.info("Social login provider registered: Google")
else:
logger.warning("SOCIAL_AUTH_GOOGLE_ENABLED=true but client ID/secret not configured")
# Microsoft
if settings.social_auth_microsoft_enabled:
_microsoft_client_id = settings.social_auth_microsoft_client_id
_microsoft_client_secret = settings.social_auth_microsoft_client_secret
if settings.social_auth_microsoft_use_global_credentials and not (
_microsoft_client_id and _microsoft_client_secret
):
_microsoft_client_id = settings.onedrive_client_id
_microsoft_client_secret = settings.onedrive_client_secret
if _microsoft_client_id and _microsoft_client_secret:
tenant = settings.social_auth_microsoft_tenant or "common"
_register_oauth_client(
"microsoft",
client_id=_microsoft_client_id,
client_secret=_microsoft_client_secret,
server_metadata_url=f"https://login.microsoftonline.com/{tenant}/v2.0/.well-known/openid-configuration",
client_kwargs={"scope": "openid profile email"},
)
SOCIAL_PROVIDERS["microsoft"] = {"name": "Microsoft", "icon": "fab fa-microsoft", "color": "blue"}
logger.info("Social login provider registered: Microsoft (tenant=%s)", tenant)
else:
logger.warning("SOCIAL_AUTH_MICROSOFT_ENABLED=true but client ID/secret not configured")
# Apple
if settings.social_auth_apple_enabled:
if settings.social_auth_apple_client_id and settings.social_auth_apple_team_id:
_register_oauth_client(
"apple",
client_id=settings.social_auth_apple_client_id,
server_metadata_url="https://appleid.apple.com/.well-known/openid-configuration",
client_kwargs={
"scope": "openid name email",
"response_mode": "form_post",
},
)
SOCIAL_PROVIDERS["apple"] = {"name": "Apple", "icon": "fab fa-apple", "color": "gray"}
logger.info("Social login provider registered: Apple")
else:
logger.warning("SOCIAL_AUTH_APPLE_ENABLED=true but client ID/team ID not configured")
# Dropbox
if settings.social_auth_dropbox_enabled:
_dropbox_client_id = settings.social_auth_dropbox_client_id
_dropbox_client_secret = settings.social_auth_dropbox_client_secret
if settings.social_auth_dropbox_use_global_credentials and not (_dropbox_client_id and _dropbox_client_secret):
_dropbox_client_id = settings.dropbox_app_key
_dropbox_client_secret = settings.dropbox_app_secret
if _dropbox_client_id and _dropbox_client_secret:
_register_oauth_client(
"dropbox",
client_id=_dropbox_client_id,
client_secret=_dropbox_client_secret,
authorize_url="https://www.dropbox.com/oauth2/authorize",
access_token_url="https://api.dropboxapi.com/oauth2/token",
userinfo_endpoint="https://api.dropboxapi.com/2/users/get_current_account",
userinfo_compliance_fix=_dropbox_userinfo_compliance_fix,
client_kwargs={
"token_endpoint_auth_method": "client_secret_post",
"token_access_type": "offline",
},
)
SOCIAL_PROVIDERS["dropbox"] = {"name": "Dropbox", "icon": "fab fa-dropbox", "color": "blue"}
logger.info("Social login provider registered: Dropbox")
else:
logger.warning("SOCIAL_AUTH_DROPBOX_ENABLED=true but client ID/secret not configured")
# GitHub
if settings.social_auth_github_enabled:
if settings.social_auth_github_client_id and settings.social_auth_github_client_secret:
_register_oauth_client(
"github",
client_id=settings.social_auth_github_client_id,
client_secret=settings.social_auth_github_client_secret,
authorize_url="https://github.com/login/oauth/authorize",
access_token_url="https://github.com/login/oauth/access_token",
userinfo_endpoint="https://api.github.com/user",
client_kwargs={"scope": "read:user user:email"},
)
SOCIAL_PROVIDERS["github"] = {"name": "GitHub", "icon": "fab fa-github", "color": "gray"}
logger.info("Social login provider registered: GitHub")
else:
logger.warning("SOCIAL_AUTH_GITHUB_ENABLED=true but client ID/secret not configured")
# Keycloak
if settings.social_auth_keycloak_enabled:
_kc_server = settings.social_auth_keycloak_server_url
_kc_realm = settings.social_auth_keycloak_realm
if (
settings.social_auth_keycloak_client_id
and settings.social_auth_keycloak_client_secret
and _kc_server
and _kc_realm
):
_kc_base = f"{_kc_server.rstrip('/')}/realms/{_kc_realm}"
_register_oauth_client(
"keycloak",
client_id=settings.social_auth_keycloak_client_id,
client_secret=settings.social_auth_keycloak_client_secret,
server_metadata_url=f"{_kc_base}/.well-known/openid-configuration",
client_kwargs={"scope": "openid profile email"},
)
SOCIAL_PROVIDERS["keycloak"] = {"name": "Keycloak", "icon": "fas fa-key", "color": "gray"}
logger.info("Social login provider registered: Keycloak (realm=%s)", _kc_realm)
else:
logger.warning("SOCIAL_AUTH_KEYCLOAK_ENABLED=true but required settings not configured")
# Generic OAuth2
if settings.social_auth_generic_oauth2_enabled:
if (
settings.social_auth_generic_oauth2_client_id
and settings.social_auth_generic_oauth2_client_secret
and settings.social_auth_generic_oauth2_authorize_url
and settings.social_auth_generic_oauth2_token_url
):
_register_oauth_client(
"generic_oauth2",
client_id=settings.social_auth_generic_oauth2_client_id,
client_secret=settings.social_auth_generic_oauth2_client_secret,
authorize_url=settings.social_auth_generic_oauth2_authorize_url,
access_token_url=settings.social_auth_generic_oauth2_token_url,
userinfo_endpoint=settings.social_auth_generic_oauth2_userinfo_url,
client_kwargs={"scope": settings.social_auth_generic_oauth2_scope},
)
_generic_name = settings.social_auth_generic_oauth2_name or "OAuth2"
SOCIAL_PROVIDERS["generic_oauth2"] = {
"name": _generic_name,
"icon": "fas fa-sign-in-alt",
"color": "indigo",
}
logger.info("Social login provider registered: Generic OAuth2")
else:
logger.warning("SOCIAL_AUTH_GENERIC_OAUTH2_ENABLED=true but required settings not configured")
def refresh_social_providers() -> None:
"""Re-register all OAuth providers from the *current* settings object.
Call this after loading or reloading settings from the database so that
providers configured (or updated) through the admin UI take effect
immediately — **no application restart required**.
This function is safe to call multiple times and is idempotent.
"""
logger.info("Refreshing social login provider registrations from current settings")
_setup_social_providers()
# Perform the initial registration from environment / default settings at
# import time. The lifespan hook and settings_sync will call
# refresh_social_providers() again after DB settings are loaded so that
# any providers configured only in the database are also active.
_setup_social_providers()
router = APIRouter()
+4
View File
@@ -244,6 +244,10 @@ class Settings(BaseSettings):
"Useful for admin-configured non-standard durations."
),
)
qr_login_enabled: bool = Field(
default=True,
description="Enable QR code-based login for mobile device authentication (default: True).",
)
qr_login_challenge_ttl_seconds: int = Field(
default=120,
description="Time-to-live in seconds for QR login challenges (default: 2 minutes).",
+12
View File
@@ -189,6 +189,18 @@ async def lifespan(app: FastAPI):
finally:
db.close()
# Re-register OAuth / social-login providers now that DB settings are
# loaded. auth.py runs its initial registration at import time (before
# the lifespan runs), so providers that are only configured in the
# database would not be registered yet. Calling refresh here ensures
# they are active immediately on startup without any manual restart.
try:
from app.auth import refresh_social_providers
refresh_social_providers()
except Exception as e:
logging.warning(f"Could not refresh social login providers on startup: {e}")
# Initialize Sentry after DB settings are loaded so that values configured
# via the database UI (e.g. SENTRY_DSN) are respected in addition to env vars.
init_sentry()
+8
View File
@@ -206,6 +206,14 @@ SETTING_METADATA = {
"required": False,
"restart_required": True,
},
"qr_login_enabled": {
"category": "Authentication",
"description": "Enable QR code-based login for mobile device authentication.",
"type": "boolean",
"sensitive": False,
"required": False,
"restart_required": False,
},
"qr_login_challenge_ttl_seconds": {
"category": "Authentication",
"description": "Time-to-live in seconds for QR login challenges (default 120).",
+10
View File
@@ -71,6 +71,16 @@ def notify_settings_updated() -> None:
except Exception as exc:
logger.warning(f"Could not reload in-process settings: {exc}")
# Re-register OAuth / social-login providers so that any provider whose
# credentials were just saved (or updated) in the database is active
# immediately on the login page — no restart required.
try:
from app.auth import refresh_social_providers
refresh_social_providers()
except Exception as exc:
logger.warning(f"Could not refresh social login providers after settings update: {exc}")
# Re-check OCR language availability in the background whenever settings
# are updated. This ensures that if a user changes tesseract_language or
# easyocr_languages via the UI, the new language data is downloaded without
+44 -12
View File
@@ -19,6 +19,43 @@ router = APIRouter()
_FILE_NOT_FOUND = "File not found"
def _resolve_owner_context(request: Request, file_record, db: Session) -> dict:
"""Return owner display info and the current user's effective role.
Returns a dict with:
- ``current_user_role``: one of "owner" / "editor" / "viewer" / None
- ``owner_display``: human-readable owner string (display_name or user_id)
- ``multi_user_enabled``: whether multi-user mode is active
"""
from app.config import settings
from app.models import UserProfile
from app.utils.user_scope import get_current_owner_id, get_file_role
multi_user_enabled = settings.multi_user_enabled
current_owner_id = get_current_owner_id(request)
user_session = request.session.get("user")
is_admin = isinstance(user_session, dict) and bool(user_session.get("is_admin"))
if is_admin:
current_user_role: str | None = "owner"
else:
current_user_role = get_file_role(file_record, current_owner_id, db)
# Build a human-readable owner label
if file_record.owner_id:
profile = db.query(UserProfile).filter(UserProfile.user_id == file_record.owner_id).first()
owner_display: str | None = profile.display_name if profile and profile.display_name else file_record.owner_id
else:
owner_display = None # No owner (unowned)
return {
"current_user_role": current_user_role,
"owner_display": owner_display,
"multi_user_enabled": multi_user_enabled,
}
@router.get("/files")
@require_login
def files_page(
@@ -268,6 +305,7 @@ def file_summary_page(request: Request, file_id: int, db: Session = Depends(get_
step_summary = None
pipeline_info = _resolve_pipeline(db, file_record)
owner_ctx = _resolve_owner_context(request, file_record, db)
return templates.TemplateResponse(
"file_summary.html",
@@ -279,6 +317,7 @@ def file_summary_page(request: Request, file_id: int, db: Session = Depends(get_
"processed_file_exists": processed_file_exists,
"step_summary": step_summary,
"pipeline_info": pipeline_info,
**owner_ctx,
},
)
except Exception as e:
@@ -353,6 +392,7 @@ def file_view_page(request: Request, file_id: int, db: Session = Depends(get_db)
# Resolve the pipeline assigned to this file (explicit or system default)
pipeline_info = _resolve_pipeline(db, file_record)
owner_ctx = _resolve_owner_context(request, file_record, db)
return templates.TemplateResponse(
"file_view.html",
@@ -364,6 +404,7 @@ def file_view_page(request: Request, file_id: int, db: Session = Depends(get_db)
"processed_file_exists": processed_file_exists,
"step_summary": step_summary,
"pipeline_info": pipeline_info,
**owner_ctx,
},
)
except Exception as e:
@@ -497,17 +538,8 @@ def file_annotations_page(request: Request, file_id: int, db: Session = Depends(
mime = file_record.mime_type or ""
is_pdf = mime == "application/pdf" or (file_record.original_filename or "").lower().endswith(".pdf")
# Determine the current user's role on this file
from app.utils.user_scope import get_current_owner_id, get_file_role
current_owner_id = get_current_owner_id(request)
user_session = request.session.get("user")
is_admin = isinstance(user_session, dict) and bool(user_session.get("is_admin"))
if is_admin:
current_user_role: str | None = "owner"
else:
current_user_role = get_file_role(file_record, current_owner_id, db)
# None means no access — the template will not show owner-only UI
# Determine the current user's role on this file (and owner display info)
owner_ctx = _resolve_owner_context(request, file_record, db)
return templates.TemplateResponse(
"file_annotations.html",
@@ -517,7 +549,7 @@ def file_annotations_page(request: Request, file_id: int, db: Session = Depends(
"original_file_exists": original_file_exists,
"processed_file_exists": processed_file_exists,
"is_pdf": is_pdf,
"current_user_role": current_user_role,
**owner_ctx,
},
)
except Exception as e:
+58 -13
View File
@@ -206,8 +206,6 @@ async def connections_page(request: Request, db: Session = Depends(get_db)):
SSO settings, and service integrations through a wizard-like interface.
"""
try:
from app.auth import OAUTH_CONFIGURED, SOCIAL_PROVIDERS
db_settings = get_all_settings_from_db(db)
def _get_effective(key: str):
@@ -227,13 +225,14 @@ async def connections_page(request: Request, db: Session = Depends(get_db)):
services = []
# --- SSO (Authentik / OIDC) ---
_oidc_linked = bool(_get_effective("authentik_client_id") and _get_effective("authentik_client_secret"))
services.append(
{
"key": "oidc",
"name": settings.oauth_provider_name or "Single Sign-On",
"name": _get_effective("oauth_provider_name") or "Single Sign-On",
"icon": "fas fa-lock",
"type": "SSO",
"linked": OAUTH_CONFIGURED,
"linked": _oidc_linked,
"description": "OpenID Connect SSO provider",
"settings_keys": [
"authentik_client_id",
@@ -245,13 +244,23 @@ async def connections_page(request: Request, db: Session = Depends(get_db)):
)
# --- Google ---
_google_id = _get_effective("social_auth_google_client_id")
_google_secret = _get_effective("social_auth_google_client_secret")
if _is_truthy(_get_effective("social_auth_google_use_global_credentials")) and not (
_google_id and _google_secret
):
_google_id = _google_id or _get_effective("google_drive_client_id")
_google_secret = _google_secret or _get_effective("google_drive_client_secret")
_google_linked = bool(
_is_truthy(_get_effective("social_auth_google_enabled")) and _google_id and _google_secret
)
services.append(
{
"key": "google",
"name": "Google",
"icon": "fab fa-google",
"type": "Sign-in authentication",
"linked": "google" in SOCIAL_PROVIDERS,
"linked": _google_linked,
"description": "Sign-in authentication",
"settings_keys": [
"social_auth_google_enabled",
@@ -263,13 +272,18 @@ async def connections_page(request: Request, db: Session = Depends(get_db)):
)
# --- GitHub ---
_github_linked = bool(
_is_truthy(_get_effective("social_auth_github_enabled"))
and _get_effective("social_auth_github_client_id")
and _get_effective("social_auth_github_client_secret")
)
services.append(
{
"key": "github",
"name": "GitHub",
"icon": "fab fa-github",
"type": "Sign-in authentication",
"linked": "github" in SOCIAL_PROVIDERS,
"linked": _github_linked,
"description": "Sign-in authentication",
"settings_keys": [
"social_auth_github_enabled",
@@ -280,13 +294,19 @@ async def connections_page(request: Request, db: Session = Depends(get_db)):
)
# --- Microsoft ---
_ms_id = _get_effective("social_auth_microsoft_client_id")
_ms_secret = _get_effective("social_auth_microsoft_client_secret")
if _is_truthy(_get_effective("social_auth_microsoft_use_global_credentials")) and not (_ms_id and _ms_secret):
_ms_id = _ms_id or _get_effective("onedrive_client_id")
_ms_secret = _ms_secret or _get_effective("onedrive_client_secret")
_microsoft_linked = bool(_is_truthy(_get_effective("social_auth_microsoft_enabled")) and _ms_id and _ms_secret)
services.append(
{
"key": "microsoft",
"name": "Microsoft",
"icon": "fab fa-microsoft",
"type": "Sign-in authentication",
"linked": "microsoft" in SOCIAL_PROVIDERS,
"linked": _microsoft_linked,
"description": "Sign-in authentication",
"settings_keys": [
"social_auth_microsoft_enabled",
@@ -299,13 +319,18 @@ async def connections_page(request: Request, db: Session = Depends(get_db)):
)
# --- Apple ---
_apple_linked = bool(
_is_truthy(_get_effective("social_auth_apple_enabled"))
and _get_effective("social_auth_apple_client_id")
and _get_effective("social_auth_apple_team_id")
)
services.append(
{
"key": "apple",
"name": "Apple",
"icon": "fab fa-apple",
"type": "Sign-in authentication",
"linked": "apple" in SOCIAL_PROVIDERS,
"linked": _apple_linked,
"description": "Sign-in authentication",
"settings_keys": [
"social_auth_apple_enabled",
@@ -318,13 +343,19 @@ async def connections_page(request: Request, db: Session = Depends(get_db)):
)
# --- Dropbox ---
_dbx_id = _get_effective("social_auth_dropbox_client_id")
_dbx_secret = _get_effective("social_auth_dropbox_client_secret")
if _is_truthy(_get_effective("social_auth_dropbox_use_global_credentials")) and not (_dbx_id and _dbx_secret):
_dbx_id = _dbx_id or _get_effective("dropbox_app_key")
_dbx_secret = _dbx_secret or _get_effective("dropbox_app_secret")
_dropbox_linked = bool(_is_truthy(_get_effective("social_auth_dropbox_enabled")) and _dbx_id and _dbx_secret)
services.append(
{
"key": "dropbox",
"name": "Dropbox",
"icon": "fab fa-dropbox",
"type": "Sign-in authentication",
"linked": "dropbox" in SOCIAL_PROVIDERS,
"linked": _dropbox_linked,
"description": "Sign-in authentication",
"settings_keys": [
"social_auth_dropbox_enabled",
@@ -336,13 +367,20 @@ async def connections_page(request: Request, db: Session = Depends(get_db)):
)
# --- Keycloak ---
_keycloak_linked = bool(
_is_truthy(_get_effective("social_auth_keycloak_enabled"))
and _get_effective("social_auth_keycloak_client_id")
and _get_effective("social_auth_keycloak_client_secret")
and _get_effective("social_auth_keycloak_server_url")
and _get_effective("social_auth_keycloak_realm")
)
services.append(
{
"key": "keycloak",
"name": "Keycloak",
"icon": "fas fa-key",
"type": "SSO",
"linked": "keycloak" in SOCIAL_PROVIDERS,
"linked": _keycloak_linked,
"description": "SSO",
"settings_keys": [
"social_auth_keycloak_enabled",
@@ -355,13 +393,20 @@ async def connections_page(request: Request, db: Session = Depends(get_db)):
)
# --- Generic OAuth2 ---
_generic_oauth2_linked = bool(
_is_truthy(_get_effective("social_auth_generic_oauth2_enabled"))
and _get_effective("social_auth_generic_oauth2_client_id")
and _get_effective("social_auth_generic_oauth2_client_secret")
and _get_effective("social_auth_generic_oauth2_authorize_url")
and _get_effective("social_auth_generic_oauth2_token_url")
)
services.append(
{
"key": "generic_oauth2",
"name": "Generic OAuth2",
"icon": "fas fa-sign-in-alt",
"type": "SSO",
"linked": "generic_oauth2" in SOCIAL_PROVIDERS,
"linked": _generic_oauth2_linked,
"description": "SSO",
"settings_keys": [
"social_auth_generic_oauth2_enabled",
@@ -464,7 +509,7 @@ async def connections_page(request: Request, db: Session = Depends(get_db)):
# Feature toggles
sso_auto_login = _is_truthy(_get_effective("sso_auto_login"))
qr_login_enabled = _is_truthy(_get_effective("qr_login_challenge_ttl_seconds"))
qr_login_enabled = _is_truthy(_get_effective("qr_login_enabled"))
frontend_url_configured = bool(_get_effective("public_base_url"))
return templates.TemplateResponse(
@@ -474,7 +519,7 @@ async def connections_page(request: Request, db: Session = Depends(get_db)):
"services": services,
"service_settings": service_settings,
"sso_auto_login": sso_auto_login,
"oauth_configured": OAUTH_CONFIGURED,
"oauth_configured": _oidc_linked,
"qr_login_enabled": qr_login_enabled,
"frontend_url_configured": frontend_url_configured,
"app_version": settings.version,
+1 -1
View File
@@ -792,7 +792,7 @@ SECURITY_HEADER_CSP_VALUE="default-src 'self'; script-src 'self'; style-src 'sel
SECURITY_HEADER_CSP_VALUE="default-src 'self'; script-src 'self' https://cdn.example.com; style-src 'self' 'unsafe-inline';"
```
**Note:** The default policy includes `'unsafe-inline'` for compatibility with Tailwind CSS and inline JavaScript. For stricter security, use nonces or hashes.
**Note:** The default policy includes `'unsafe-inline'` for compatibility with inline JavaScript. Tailwind CSS v3 is compiled at build time into a static file served from `'self'`, so no external style CDN is needed.
#### X-Frame-Options
+1 -1
View File
@@ -157,7 +157,7 @@ Recommended headers to configure at the proxy level:
#### Content-Security-Policy Notes
DocuElevate's frontend uses Tailwind CSS loaded from CDN in development mode. In production, ensure your CSP allows loading scripts and styles from your configured static file origin. A starting point:
DocuElevate's frontend uses Tailwind CSS v3 compiled at Docker build time. No external CDN requests are needed for CSS. In production, your CSP does not need to allow any external style sources beyond your own static file origin. A starting point:
```
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;
+1 -1
View File
@@ -181,7 +181,7 @@ Simply leave `SENTRY_DSN` unset (or set it to an empty string). Neither the Pyt
## SDK Version
- **Server:** DocuElevate uses [`sentry-sdk`](https://pypi.org/project/sentry-sdk/) `>=2.20.0,<3.0.0` with the `fastapi`, `celery`, and `sqlalchemy` extras.
- **Browser:** The `bundle.tracing.replay.min.js` bundle is loaded from the official Sentry CDN (`browser.sentry-cdn.com`). The version pin is in `frontend/templates/base.html`.
- **Browser:** The `bundle.tracing.replay.min.js` bundle from the Sentry Browser SDK **v10** is loaded from the official Sentry CDN (`browser.sentry-cdn.com`). The version pin is in `frontend/templates/base.html`.
---
+329
View File
@@ -0,0 +1,329 @@
/* frontend/input.css
* Tailwind CSS v3 source file.
* Edit this file (not static/styles.css) the compiled output is
* generated by running: npm run build (inside the frontend/ directory)
*/
/* ── Tailwind layers ──────────────────────────────────────────────────────── */
@tailwind base;
@tailwind components;
@tailwind utilities;
/* ── Custom utilities ─────────────────────────────────────────────────────── */
/* =============================================================
ACCESSIBILITY
Skip-to-content link, focus indicators, and screen-reader-only
utility class following WCAG 2.1 Level AA requirements.
============================================================= */
/* Skip-to-content link: visible only on keyboard focus */
.skip-link {
position: absolute;
left: -9999px;
top: auto;
width: 1px;
height: 1px;
overflow: hidden;
z-index: 9999;
padding: 0.75rem 1.5rem;
background-color: #1d4ed8;
color: #ffffff;
font-weight: 600;
text-decoration: none;
border-radius: 0 0 0.375rem 0;
}
.skip-link:focus {
position: fixed;
top: 0;
left: 0;
width: auto;
height: auto;
outline: 2px solid #2563eb;
outline-offset: 2px;
}
/* Enhanced focus-visible indicators for keyboard navigation (WCAG 2.4.7) */
a:focus-visible,
button:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible,
[tabindex]:focus-visible {
outline: 2px solid #2563eb;
outline-offset: 2px;
}
/* Screen-reader-only utility (visually hidden, accessible to AT) */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
body {
/* Your global overrides can go here if needed */
}
.material-symbols-light--folder-managed-outline {
display: inline-block;
width: 96px;
height: 96px;
--svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23000' d='m17.212 20.404l-.108-.885q-.57-.125-.938-.33q-.368-.204-.7-.577l-.835.334l-.539-.815l.689-.577q-.165-.531-.165-1.035t.165-1.034l-.689-.577l.539-.816l.835.335q.332-.393.7-.588q.369-.195.938-.32l.108-.885h1l.107.885q.57.125.938.32t.7.588l.835-.335l.539.816l-.689.576q.166.531.166 1.035t-.166 1.035l.689.577l-.539.815l-.834-.335q-.333.373-.701.578q-.369.205-.938.33l-.107.885zm.5-1.731q.882 0 1.518-.635q.636-.636.636-1.519t-.636-1.518t-1.518-.636t-1.519.636t-.635 1.518t.635 1.519t1.518.635M4 18V6v4.435V10zm.616 1q-.691 0-1.153-.462T3 17.384V6.616q0-.691.463-1.153T4.615 5h4.981l2 2h7.789q.69 0 1.153.463T21 8.616v2.294q-.238-.152-.479-.265q-.24-.112-.521-.21v-1.82q0-.269-.173-.442T19.385 8h-8.19l-2-2h-4.58q-.269 0-.442.173T4 6.616v10.769q0 .269.173.442t.443.173h6.748q.055.275.131.515t.186.485z'/%3E%3C/svg%3E");
background-color: currentColor;
-webkit-mask-image: var(--svg);
mask-image: var(--svg);
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
-webkit-mask-size: 100% 100%;
mask-size: 100% 100%;
}
/* Ensure pagination wraps properly on small screens */
.pagination {
flex-wrap: wrap;
gap: 0.5rem;
}
.pagination-buttons {
flex-wrap: wrap;
}
/* Ensure filter items stack on very small screens */
@media (max-width: 480px) {
.filter-group {
flex-direction: column;
}
.filter-item {
min-width: unset;
width: 100%;
}
}
/* =============================================================
DARK MODE
Activated by "dark" class on <html> element.
Toggled by the navbar button; preference stored in localStorage.
Falls back to the server-side ui_default_color_scheme setting,
then to the OS prefers-color-scheme media query.
WCAG AA contrast ratios verified for all text/background pairs.
============================================================= */
/* Tell the browser we support both colour schemes */
html { color-scheme: light; }
html.dark { color-scheme: dark; }
/* ---- Base / Body ---- */
html.dark body { background-color: #111827; color: #e5e7eb; }
html.dark .bg-gray-50 { background-color: #111827; }
html.dark .bg-white { background-color: #1f2937; }
html.dark .bg-gray-100 { background-color: #374151; }
html.dark .bg-gray-200 { background-color: #4b5563; }
/* ---- Text colours ---- */
html.dark .text-gray-900 { color: #f9fafb; }
html.dark .text-gray-800 { color: #f3f4f6; }
html.dark .text-gray-700 { color: #e5e7eb; }
html.dark .text-gray-600 { color: #d1d5db; }
html.dark .text-gray-500 { color: #9ca3af; }
html.dark .text-gray-400 { color: #9ca3af; }
html.dark .text-black { color: #f9fafb; }
/* ---- Borders ---- */
html.dark .border-gray-100 { border-color: #374151; }
html.dark .border-gray-200 { border-color: #374151; }
html.dark .border-gray-300 { border-color: #4b5563; }
html.dark .border-gray-400 { border-color: #6b7280; }
html.dark .divide-gray-200 > :not([hidden]) ~ :not([hidden]) { border-color: #374151; }
html.dark .divide-gray-100 > :not([hidden]) ~ :not([hidden]) { border-color: #374151; }
html.dark .divide-y > :not([hidden]) ~ :not([hidden]) { border-color: #374151; }
/* ---- Hover states ---- */
html.dark .hover\:bg-gray-50:hover { background-color: #374151; }
html.dark .hover\:bg-gray-100:hover { background-color: #4b5563; }
html.dark .hover\:text-gray-900:hover { color: #f9fafb; }
html.dark .hover\:text-gray-700:hover { color: #e5e7eb; }
/* ---- Shadows (softened for dark mode) ---- */
html.dark .shadow,
html.dark .shadow-md,
html.dark .shadow-sm,
html.dark .shadow-lg {
box-shadow: 0 1px 3px 0 rgba(0,0,0,0.6), 0 1px 2px 0 rgba(0,0,0,0.4);
}
/* ---- Alert / info-banner backgrounds ---- */
html.dark .bg-blue-50 { background-color: #1e3a5f; }
html.dark .bg-green-50 { background-color: #052e16; }
html.dark .bg-red-50 { background-color: #450a0a; }
html.dark .bg-yellow-50 { background-color: #451a03; }
html.dark .bg-indigo-50 { background-color: #1e1b4b; }
html.dark .bg-orange-50 { background-color: #431407; }
/* ---- Badge / pill backgrounds ---- */
html.dark .bg-blue-100 { background-color: #1e3a5f; }
html.dark .bg-green-100 { background-color: #052e16; }
html.dark .bg-red-100 { background-color: #450a0a; }
html.dark .bg-yellow-100 { background-color: #451a03; }
html.dark .bg-indigo-100 { background-color: #431407; }
html.dark .bg-orange-100 { background-color: #431407; }
html.dark .bg-purple-100 { background-color: #2e1065; }
/* ---- Status / badge text colours ---- */
html.dark .text-blue-700 { color: #93c5fd; }
html.dark .text-blue-800 { color: #bfdbfe; }
html.dark .text-green-700 { color: #86efac; }
html.dark .text-green-800 { color: #bbf7d0; }
html.dark .text-red-700 { color: #fca5a5; }
html.dark .text-red-800 { color: #fecaca; }
html.dark .text-yellow-700 { color: #fcd34d; }
html.dark .text-yellow-800 { color: #fde68a; }
html.dark .text-indigo-700 { color: #a5b4fc; }
html.dark .text-indigo-800 { color: #c7d2fe; }
html.dark .text-orange-700 { color: #fdba74; }
html.dark .text-orange-800 { color: #fed7aa; }
html.dark .text-purple-700 { color: #d8b4fe; }
html.dark .text-purple-800 { color: #e9d5ff; }
/* ---- Dropdown / popup menus ---- */
html.dark .bg-white.rounded-md.shadow-lg { background-color: #1f2937; }
html.dark .ring-black { --tw-ring-color: rgba(0,0,0,0.5); }
/* ---- Form inputs / selects / textareas ---- */
html.dark input:not([type="checkbox"]):not([type="radio"]):not([type="range"]),
html.dark select,
html.dark textarea {
background-color: #374151;
border-color: #4b5563;
color: #e5e7eb;
}
html.dark input::placeholder,
html.dark textarea::placeholder {
color: #9ca3af;
}
html.dark input:focus:not([type="checkbox"]):not([type="radio"]):not([type="range"]),
html.dark select:focus,
html.dark textarea:focus {
border-color: #60a5fa;
outline-color: #60a5fa;
}
/* ---- Table rows ---- */
html.dark thead,
html.dark .bg-gray-50 thead { background-color: #1f2937; }
html.dark thead th { color: #9ca3af; }
html.dark tbody tr:hover { background-color: #374151; }
/* ---- Code / pre ---- */
html.dark pre,
html.dark code { background-color: #111827; color: #d1d5db; }
/* ---- Dark-mode toggle button icon colour ---- */
html.dark #darkModeToggle { color: #fbbf24; }
html.dark #darkModeToggle:hover { background-color: #374151; }
/* ---- Dark-mode skip-link ---- */
html.dark .skip-link { background-color: #2563eb; }
html.dark .skip-link:focus { outline-color: #60a5fa; }
/* ---- Dark-mode focus-visible indicators ---- */
html.dark a:focus-visible,
html.dark button:focus-visible,
html.dark input:focus-visible,
html.dark select:focus-visible,
html.dark textarea:focus-visible,
html.dark [tabindex]:focus-visible {
outline-color: #60a5fa;
}
/* ---- Scrollbar (WebKit browsers) ---- */
html.dark ::-webkit-scrollbar { width: 8px; height: 8px; }
html.dark ::-webkit-scrollbar-track { background: #1f2937; }
html.dark ::-webkit-scrollbar-thumb { background: #4b5563; border-radius: 4px; }
html.dark ::-webkit-scrollbar-thumb:hover { background: #6b7280; }
/* ---- Settings page: sidebar active state (dark) ---- */
html.dark .bg-blue-50 { background-color: #1e3a5f; }
/* =============================================================
DOC-TOGGLE cross-browser toggle switch
Implemented with custom CSS pseudo-elements so the appearance
is consistent across all browsers regardless of Tailwind version.
Usage:
<label class="doc-toggle">
<input type="checkbox" class="sr-only" onchange="...">
<span class="doc-toggle-track" aria-hidden="true"></span>
<span class="ml-3 ...">Label text</span>
</label>
============================================================= */
.doc-toggle {
position: relative;
display: inline-flex;
align-items: center;
cursor: pointer;
}
.doc-toggle-track {
position: relative;
display: inline-block;
width: 44px;
min-width: 44px;
height: 24px;
background-color: #e5e7eb; /* gray-200 */
border-radius: 9999px;
transition: background-color 0.2s ease-in-out;
flex-shrink: 0;
}
.doc-toggle-track::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 20px;
height: 20px;
background-color: #ffffff;
border: 1px solid #d1d5db; /* gray-300 */
border-radius: 9999px;
transition: transform 0.2s ease-in-out, border-color 0.2s ease-in-out;
}
.doc-toggle input[type="checkbox"]:checked + .doc-toggle-track {
background-color: #4f46e5; /* indigo-600 */
}
.doc-toggle input[type="checkbox"]:checked + .doc-toggle-track::after {
transform: translateX(20px);
border-color: #ffffff;
}
.doc-toggle input[type="checkbox"]:focus-visible + .doc-toggle-track {
box-shadow: 0 0 0 2px #ffffff, 0 0 0 4px #6366f1; /* ring-2 ring-indigo-500 with offset */
}
/* Dark mode overrides */
html.dark .doc-toggle-track {
background-color: #374151; /* gray-700 */
}
html.dark .doc-toggle-track::after {
background-color: #ffffff;
border-color: #4b5563; /* gray-600 */
}
html.dark .doc-toggle input[type="checkbox"]:checked + .doc-toggle-track {
background-color: #4f46e5; /* indigo-600 */
}
html.dark .doc-toggle input[type="checkbox"]:checked + .doc-toggle-track::after {
border-color: #ffffff;
}
html.dark .doc-toggle input[type="checkbox"]:focus-visible + .doc-toggle-track {
box-shadow: 0 0 0 2px #111827, 0 0 0 4px #6366f1; /* dark background offset */
}
+1017
View File
File diff suppressed because it is too large Load Diff
+12
View File
@@ -0,0 +1,12 @@
{
"name": "docuelevate-frontend",
"version": "1.0.0",
"description": "Frontend asset compilation for DocuElevate",
"scripts": {
"build": "tailwindcss -i input.css -o static/styles.css --minify",
"watch": "tailwindcss -i input.css -o static/styles.css --watch"
},
"devDependencies": {
"tailwindcss": "^3.4.0"
}
}
+14 -2
View File
@@ -24,6 +24,10 @@
_i18n = i18n || {};
_loadAnnotations();
// Expose reload function so the EmbedPDF viewer init script can refresh the
// list after auto-saving an annotation created inside the viewer.
window._reloadAnnotations = _loadAnnotations;
var form = document.getElementById('annotation-form');
if (form) {
form.addEventListener('submit', function (e) {
@@ -81,10 +85,18 @@
typeBadge.className = 'annotation-type annotation-type--' + ann.annotation_type;
typeBadge.textContent = _i18n['type_' + ann.annotation_type] || ann.annotation_type;
var pageInfo = document.createElement('span');
pageInfo.className = 'annotation-page';
var pageInfo = document.createElement('button');
pageInfo.type = 'button';
pageInfo.className = 'annotation-page annotation-page--link';
pageInfo.setAttribute('aria-label', (_i18n.go_to_page || 'Go to page') + ' ' + ann.page);
pageInfo.title = (_i18n.go_to_page || 'Go to page') + ' ' + ann.page;
pageInfo.innerHTML = '<i class="fas fa-file-alt" aria-hidden="true"></i> ' +
(_i18n.page || 'Page') + ' ' + ann.page;
pageInfo.addEventListener('click', function () {
if (typeof window._embedpdfScrollToPage === 'function') {
window._embedpdfScrollToPage(ann.page);
}
});
header.appendChild(typeBadge);
if (ann.color) {
+44
View File
@@ -0,0 +1,44 @@
/**
* claim.js Claim-ownership UI helper for unowned documents.
*
* Usage: call initClaimOwnership(fileId, i18n) after DOMContentLoaded.
* The i18n object must contain:
* confirm, success, failed
*/
function initClaimOwnership(fileId, i18n) {
var btn = document.getElementById('claim-btn');
var msg = document.getElementById('claim-msg');
if (!btn) return;
btn.addEventListener('click', function () {
if (!confirm(i18n.confirm)) return;
btn.disabled = true;
fetch('/api/files/' + fileId + '/claim', { method: 'POST' })
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, data: d }; }); })
.then(function (result) {
if (result.ok || (result.data && result.data.status === 'already_owned')) {
if (msg) {
msg.textContent = i18n.success;
msg.style.color = '#059669';
msg.style.display = msg.tagName === 'SPAN' ? 'inline' : 'block';
}
setTimeout(function () { location.reload(); }, 1200);
} else {
if (msg) {
msg.textContent = (result.data && result.data.detail) || i18n.failed;
msg.style.color = '#dc2626';
msg.style.display = msg.tagName === 'SPAN' ? 'inline' : 'block';
}
btn.disabled = false;
}
})
.catch(function () {
if (msg) {
msg.textContent = i18n.failed;
msg.style.color = '#dc2626';
msg.style.display = msg.tagName === 'SPAN' ? 'inline' : 'block';
}
btn.disabled = false;
});
});
}
File diff suppressed because one or more lines are too long
+12
View File
@@ -0,0 +1,12 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./templates/**/*.html',
'./static/js/**/*.js',
],
darkMode: 'class',
theme: {
extend: {},
},
plugins: [],
}
+14 -13
View File
@@ -18,11 +18,11 @@
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{ _("connections.sso_auto_login_description") }}</p>
</div>
<div>
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" id="sso-auto-login-toggle" class="sr-only peer"
<label class="doc-toggle">
<input type="checkbox" id="sso-auto-login-toggle" class="sr-only"
{% if sso_auto_login %}checked{% endif %}
onchange="toggleSetting('sso_auto_login', this.checked)">
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-indigo-500 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-indigo-600" style="min-width:44px; min-height:24px;"></div>
<span class="doc-toggle-track" aria-hidden="true"></span>
<span class="ml-3 text-sm font-medium text-gray-700 dark:text-gray-300">{{ _("connections.sso_auto_login") }}</span>
</label>
</div>
@@ -44,10 +44,11 @@
{% endif %}
</div>
<div>
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" id="qr-upload-toggle" class="sr-only peer"
{% if qr_login_enabled %}checked{% endif %} disabled>
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-indigo-500 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-indigo-600" style="min-width:44px; min-height:24px;"></div>
<label class="doc-toggle">
<input type="checkbox" id="qr-upload-toggle" class="sr-only"
{% if qr_login_enabled %}checked{% endif %}
onchange="toggleSetting('qr_login_enabled', this.checked)">
<span class="doc-toggle-track" aria-hidden="true"></span>
<span class="ml-3 text-sm font-medium text-gray-700 dark:text-gray-300">{{ _("connections.qr_code_enabled") }}</span>
</label>
</div>
@@ -199,24 +200,24 @@ function openServiceModal(serviceKey) {
}
if (meta.type === 'boolean') {
// Styled Tailwind toggle switch (matches the page-level toggles)
// Styled toggle switch using .doc-toggle CSS class (compatible with Tailwind v2 CDN).
const toggleWrapper = document.createElement('label');
toggleWrapper.className = 'relative inline-flex items-center cursor-pointer';
toggleWrapper.className = 'doc-toggle';
toggleWrapper.setAttribute('aria-label', field.key.replace(/_/g, ' ').replace(/\b\w/g, function(c) { return c.toUpperCase(); }));
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.id = 'field-' + field.key;
checkbox.name = field.key;
checkbox.className = 'sr-only peer';
checkbox.className = 'sr-only';
const val = field.value;
if (val === true || val === 'true' || val === '1' || val === 'True') {
checkbox.checked = true;
}
const slider = document.createElement('div');
slider.className = "w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-indigo-500 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-indigo-600";
slider.style.cssText = 'min-width:44px; min-height:24px;';
const slider = document.createElement('span');
slider.className = 'doc-toggle-track';
slider.setAttribute('aria-hidden', 'true');
toggleWrapper.appendChild(checkbox);
toggleWrapper.appendChild(slider);
+4 -4
View File
@@ -21,8 +21,6 @@
<!-- Alpine.js moved to head for earlier loading -->
<script src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
{% block head_css %}
<!-- Tailwind CSS and other CSS -->
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
@@ -41,8 +39,10 @@
upgrading the SDK.
──────────────────────────────────────────────────────────────────────── #}
{% if sentry_dsn %}
<script src="https://browser.sentry-cdn.com/9.x.x/bundle.tracing.replay.min.js"
crossorigin="anonymous"></script>
<script src="https://browser.sentry-cdn.com/10.45.0/bundle.tracing.replay.feedback.logs.metrics.min.js"
integrity="sha384-TCY3xw5Ej940LIWfS6PwhCCBl7lvEsxBpHy+BirF+EycSQUvXbfZsgsLi0oU18yZ"
crossorigin="anonymous"
></script>
<script>
if (window.Sentry) {
Sentry.init({
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ _("billing.success_page_title") }}</title>
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
<link rel="stylesheet" href="/static/styles.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
crossorigin="anonymous" referrerpolicy="no-referrer" />
+130 -3
View File
@@ -380,6 +380,23 @@
font-size: 0.75rem;
color: #718096;
}
.annotation-page--link {
background: none;
border: none;
padding: 0;
cursor: pointer;
font-size: 0.75rem;
color: #718096;
text-decoration: none;
min-height: 0;
}
.annotation-page--link:hover {
color: #3182ce;
text-decoration: underline;
}
.dark .annotation-page--link:hover {
color: #63b3ed;
}
.annotation-content {
color: #4a5568;
font-size: 0.9375rem;
@@ -569,6 +586,23 @@
Comments &amp; Annotations
</div>
<div class="annotations-subtitle">{{ file.original_filename }}</div>
{% if multi_user_enabled %}
<div class="annotations-subtitle" style="margin-top:0.25rem;">
<i class="fas fa-user" aria-hidden="true" style="margin-right:0.25rem;"></i>
{{ _("file.owner_label") }}: <strong>{{ owner_display or _("file.owner_unowned") }}</strong>
{% if file.owner_id is none %}
&mdash;
<button
id="claim-btn"
aria-label="{{ _('file.claim_ownership') }}"
style="background:#10b981;color:#fff;border:none;border-radius:0.375rem;padding:0.25rem 0.75rem;font-size:0.8rem;font-weight:600;cursor:pointer;"
>
<i class="fas fa-user-check" aria-hidden="true"></i> {{ _("file.claim_ownership") }}
</button>
<span id="claim-msg" style="font-size:0.8rem;margin-left:0.5rem;display:none;" role="alert"></span>
{% endif %}
</div>
{% endif %}
</div>
</div>
@@ -750,6 +784,7 @@
delete_confirm: {{ _("annotations.delete_confirm") | tojson }},
page: {{ _("annotations.page") | tojson }},
color: {{ _("annotations.color") | tojson }},
go_to_page: {{ _("annotations.go_to_page") | tojson }},
type_note: {{ _("annotations.type_note") | tojson }},
type_highlight: {{ _("annotations.type_highlight") | tojson }},
type_underline: {{ _("annotations.type_underline") | tojson }},
@@ -801,18 +836,110 @@
const viewerEl = document.getElementById('embedpdf-viewer');
if (viewerEl) {
const fileId = {{ file.id | tojson }};
{% if processed_file_exists %}
const pdfUrl = '/api/files/{{ file.id }}/preview?version=processed';
const pdfUrl = '/api/files/' + fileId + '/preview?version=processed';
{% else %}
const pdfUrl = '/api/files/{{ file.id }}/preview?version=original';
const pdfUrl = '/api/files/' + fileId + '/preview?version=original';
{% endif %}
EmbedPDF.init({
const viewer = EmbedPDF.init({
type: 'container',
target: viewerEl,
src: pdfUrl,
});
if (viewer) {
viewer.registry.then(function (registry) {
// ── Page sync: viewer page change → update annotation form ──────────
var scrollPlugin = registry.getPlugin('scroll');
if (scrollPlugin) {
var scroll = scrollPlugin.provides();
scroll.onPageChange(function (event) {
var pageInput = document.getElementById('annotation-page-input');
if (pageInput) {
pageInput.value = String(event.pageNumber);
}
});
// Expose scrollToPage so the annotations panel can navigate the viewer
window._embedpdfScrollToPage = function (pageNumber) {
scroll.scrollToPage({ pageNumber: pageNumber });
};
}
// ── Auto-save: viewer annotation events → DocuElevate API ───────────
var annotationPlugin = registry.getPlugin('annotation');
if (annotationPlugin) {
var annotation = annotationPlugin.provides();
annotation.onAnnotationEvent(function (event) {
if (event.type !== 'create') return;
var ann = event.annotation;
var pageIndex = typeof ann.pageIndex === 'number' ? ann.pageIndex
: (typeof event.pageIndex === 'number' ? event.pageIndex : 0);
var page = pageIndex + 1;
var rect = ann.rect || { x: 0, y: 0, width: 0, height: 0 };
var color = ann.strokeColor || ann.color || undefined;
var content = (ann.contents || '').trim();
// Map PDF annotation subtypes to DocuElevate annotation types
var typeMap = {
highlight: 'highlight',
underline: 'underline',
strikeout: 'strikethrough',
squiggly: 'underline',
text: 'note',
freetext: 'note',
ink: 'note',
square: 'note',
circle: 'note',
};
var annType = typeMap[String(ann.type).toLowerCase()] || 'note';
if (!content) {
var typeLabel = annType.charAt(0).toUpperCase() + annType.slice(1);
content = typeLabel + ' \u2014 p.' + page;
}
var payload = {
page: page,
x: rect.x || 0,
y: rect.y || 0,
width: rect.width || 0,
height: rect.height || 0,
annotation_type: annType,
content: content,
};
if (color) {
payload.color = color;
}
fetch('/api/files/' + fileId + '/annotations', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
.then(function (r) {
if (r.ok && typeof window._reloadAnnotations === 'function') {
window._reloadAnnotations();
}
})
.catch(function (err) {
console.error('Failed to save viewer annotation:', err);
});
});
}
}).catch(function () {});
}
}
</script>
{% endif %}
{% if multi_user_enabled and file and file.owner_id is none %}
<script src="{{ url_for('static', path='js/claim.js') }}" defer></script>
<script>
document.addEventListener('DOMContentLoaded', function () {
initClaimOwnership({{ file.id | tojson }}, {
confirm: {{ _("file.claim_ownership_confirm") | tojson }},
success: {{ _("file.claim_ownership_success") | tojson }},
failed: {{ _("file.claim_ownership_failed") | tojson }}
});
});
</script>
{% endif %}
{% endblock %}
+30
View File
@@ -165,6 +165,12 @@
{% if file.document_title %}
<div class="info-row"><span class="info-key">Document Title</span><span class="info-val">{{ file.document_title }}</span></div>
{% endif %}
{% if multi_user_enabled %}
<div class="info-row">
<span class="info-key">{{ _("file.owner_label") }}</span>
<span class="info-val">{{ owner_display or _("file.owner_unowned") }}</span>
</div>
{% endif %}
</div>
<!-- ── Quick actions ── -->
@@ -184,7 +190,18 @@
<a href="/files/{{ file.id }}/detail" class="action-btn btn-secondary">
<i class="fas fa-eye" aria-hidden="true"></i> View Detail
</a>
{% if multi_user_enabled and file.owner_id is none %}
<button
class="action-btn btn-primary"
id="claim-btn"
aria-label="{{ _('file.claim_ownership') }}"
style="background:#10b981;"
>
<i class="fas fa-user-check" aria-hidden="true"></i> {{ _("file.claim_ownership") }}
</button>
{% endif %}
</div>
<p id="claim-msg" style="margin-top:0.5rem;font-size:0.875rem;display:none;" role="alert"></p>
</div>
{% else %}
@@ -193,4 +210,17 @@
{% endif %}
</div>
{% if multi_user_enabled and file and file.owner_id is none %}
<script src="{{ url_for('static', path='js/claim.js') }}" defer></script>
<script>
document.addEventListener('DOMContentLoaded', function () {
initClaimOwnership({{ file.id | tojson }}, {
confirm: {{ _("file.claim_ownership_confirm") | tojson }},
success: {{ _("file.claim_ownership_success") | tojson }},
failed: {{ _("file.claim_ownership_failed") | tojson }}
});
});
</script>
{% endif %}
{% endblock %}
+27
View File
@@ -316,6 +316,12 @@
{% endif %}
</span>
</div>
{% if multi_user_enabled %}
<div class="info-row">
<span class="info-key">{{ _("file.owner_label") }}</span>
<span class="info-val">{{ owner_display or _("file.owner_unowned") }}</span>
</div>
{% endif %}
</div>
<!-- Actions -->
@@ -344,7 +350,18 @@
<a href="/shared-links?file_id={{ file.id }}" class="action-btn btn-secondary">
<i class="fas fa-share-alt" aria-hidden="true"></i> Share
</a>
{% if multi_user_enabled and file.owner_id is none %}
<button
class="action-btn btn-primary"
id="claim-btn"
aria-label="{{ _('file.claim_ownership') }}"
style="background:#10b981;border:none;cursor:pointer;"
>
<i class="fas fa-user-check" aria-hidden="true"></i> {{ _("file.claim_ownership") }}
</button>
{% endif %}
</div>
<p id="claim-msg" style="margin-top:0.5rem;font-size:0.875rem;display:none;" role="alert"></p>
</div>
</div>
@@ -937,6 +954,16 @@
pdfInit('/api/files/{{ file.id }}/preview?version={{ pv }}');
{% endif %}
{% endif %}
{% if multi_user_enabled and file and file.owner_id is none %}
initClaimOwnership({{ file.id | tojson }}, {
confirm: {{ _("file.claim_ownership_confirm") | tojson }},
success: {{ _("file.claim_ownership_success") | tojson }},
failed: {{ _("file.claim_ownership_failed") | tojson }}
});
{% endif %}
});
</script>
{% if multi_user_enabled and file and file.owner_id is none %}
<script src="{{ url_for('static', path='js/claim.js') }}" defer></script>
{% endif %}
{% endblock %}
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DocuElevate - Forgot Password</title>
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
<link rel="stylesheet" href="/static/styles.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
crossorigin="anonymous" referrerpolicy="no-referrer" />
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DocuElevate - Forgot Username</title>
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
<link rel="stylesheet" href="/static/styles.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
crossorigin="anonymous" referrerpolicy="no-referrer" />
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ _("app.name") }} - {{ _("auth.login_title") }}</title>
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
<link rel="stylesheet" href="/static/styles.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
crossorigin="anonymous" referrerpolicy="no-referrer" />
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DocuElevate - Reset Password</title>
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
<link rel="stylesheet" href="/static/styles.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
crossorigin="anonymous" referrerpolicy="no-referrer" />
+2 -2
View File
@@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<title>Shared Document DocuElevate</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- Tailwind CSS -->
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet" />
<!-- Tailwind CSS v3 (compiled) -->
<link rel="stylesheet" href="/static/styles.css" />
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DocuElevate - Create Account</title>
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
<link rel="stylesheet" href="/static/styles.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
crossorigin="anonymous" referrerpolicy="no-referrer" />
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ _("auth.verify_email_page_title") }}</title>
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
<link rel="stylesheet" href="/static/styles.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
crossorigin="anonymous" referrerpolicy="no-referrer" />
+7
View File
@@ -321,6 +321,7 @@
"annotations.delete_confirm": "Are you sure you want to delete this annotation?",
"annotations.deleted": "Annotation deleted",
"annotations.empty": "No annotations yet",
"annotations.go_to_page": "Go to page",
"annotations.heading": "Annotations",
"annotations.page": "Page",
"annotations.save": "Save",
@@ -1756,6 +1757,12 @@
"sharing.role_viewer": "Viewer",
"sharing.user_id_label": "User ID or email",
"sharing.user_id_placeholder": "e.g. alice@example.com",
"file.owner_label": "Owner",
"file.owner_unowned": "Unowned",
"file.claim_ownership": "Claim Ownership",
"file.claim_ownership_confirm": "Claim this document as yours? You will become the owner and can manage sharing.",
"file.claim_ownership_success": "You are now the owner of this document.",
"file.claim_ownership_failed": "Could not claim ownership. The document may already have an owner.",
"similarity.backfill_auto": "The background task will compute them automatically every 5 minutes, or you can",
"similarity.files_missing_text": "file(s) have OCR text but no embedding yet.",
"similarity.find_pairs_btn": "Find Pairs",
+43
View File
@@ -156,6 +156,49 @@ class TestCommentsUIRendering:
assert 'id="embedpdf-viewer"' in html
assert "@embedpdf/snippet" in html
def test_embedpdf_init_subscribes_to_page_change(self, client: TestClient, db_session, tmp_path):
"""The EmbedPDF init script should subscribe to page change events to sync the form."""
f = _create_file(db_session, tmp_path)
resp = client.get(f"/files/{f.id}/annotations")
assert resp.status_code == 200
html = resp.text
# Verifies the viewer registry is awaited and scroll plugin is used
assert "viewer.registry" in html
assert "onPageChange" in html
assert "annotation-page-input" in html
def test_embedpdf_init_exposes_scroll_function(self, client: TestClient, db_session, tmp_path):
"""The EmbedPDF init script must expose _embedpdfScrollToPage for the annotations panel."""
f = _create_file(db_session, tmp_path)
resp = client.get(f"/files/{f.id}/annotations")
assert resp.status_code == 200
assert "_embedpdfScrollToPage" in resp.text
assert "scrollToPage" in resp.text
def test_embedpdf_init_saves_viewer_annotations(self, client: TestClient, db_session, tmp_path):
"""The EmbedPDF init script should capture annotation events and POST to the API."""
f = _create_file(db_session, tmp_path)
resp = client.get(f"/files/{f.id}/annotations")
assert resp.status_code == 200
html = resp.text
assert "onAnnotationEvent" in html
# Verifies the POST target is the annotations API for this file
assert "/api/files/" in html and "/annotations" in html
def test_embedpdf_init_reloads_annotation_list(self, client: TestClient, db_session, tmp_path):
"""After auto-saving a viewer annotation, the panel list should be refreshed."""
f = _create_file(db_session, tmp_path)
resp = client.get(f"/files/{f.id}/annotations")
assert resp.status_code == 200
assert "_reloadAnnotations" in resp.text
def test_annotations_page_has_go_to_page_i18n(self, client: TestClient, db_session, tmp_path):
"""The annotations i18n bundle should include the go_to_page key."""
f = _create_file(db_session, tmp_path)
resp = client.get(f"/files/{f.id}/annotations")
assert resp.status_code == 200
assert "go_to_page" in resp.text
def test_summary_page_renders(self, client: TestClient, db_session, tmp_path):
"""The summary page at /files/{id} should render correctly."""
f = _create_file(db_session, tmp_path)
+169 -2
View File
@@ -268,8 +268,6 @@ class TestConnectionsPageRoute:
patch("app.views.settings.get_all_settings_from_db", return_value={}),
patch("app.views.settings.templates") as mock_templates,
patch("app.views.settings.SETTING_METADATA", {}),
patch("app.auth.OAUTH_CONFIGURED", False),
patch("app.auth.SOCIAL_PROVIDERS", {}),
patch("app.views.settings.get_setting_metadata", return_value={}),
):
mock_templates.TemplateResponse.return_value = "response"
@@ -296,6 +294,175 @@ class TestConnectionsPageRoute:
assert "smtp" in service_keys
assert "telegram" in service_keys
@pytest.mark.asyncio
async def test_connections_page_linked_status_from_db(self):
"""Linked status is derived from DB/effective settings, not SOCIAL_PROVIDERS."""
from app.views.settings import connections_page
mock_request = MagicMock()
mock_request.session = {"user": {"is_admin": True}}
mock_db = MagicMock()
# Simulate GitHub configured only in DB (not in SOCIAL_PROVIDERS yet)
db_values = {
"social_auth_github_enabled": "true",
"social_auth_github_client_id": "gh-id",
"social_auth_github_client_secret": "gh-secret",
}
with (
patch("app.views.settings.get_all_settings_from_db", return_value=db_values),
patch("app.views.settings.templates") as mock_templates,
patch("app.views.settings.SETTING_METADATA", {}),
patch("app.views.settings.get_setting_metadata", return_value={}),
):
mock_templates.TemplateResponse.return_value = "response"
await connections_page(mock_request, db=mock_db)
context = mock_templates.TemplateResponse.call_args[0][1]
services_by_key = {s["key"]: s for s in context["services"]}
# GitHub should be linked because DB values say so
assert services_by_key["github"]["linked"] is True
@pytest.mark.asyncio
async def test_connections_page_unlinked_when_credentials_missing(self):
"""Provider is unlinked when enabled=true but credentials are absent."""
from app.views.settings import connections_page
mock_request = MagicMock()
mock_request.session = {"user": {"is_admin": True}}
mock_db = MagicMock()
# enabled but no credentials
db_values = {"social_auth_github_enabled": "true"}
with (
patch("app.views.settings.get_all_settings_from_db", return_value=db_values),
patch("app.views.settings.templates") as mock_templates,
patch("app.views.settings.SETTING_METADATA", {}),
patch("app.views.settings.get_setting_metadata", return_value={}),
):
mock_templates.TemplateResponse.return_value = "response"
await connections_page(mock_request, db=mock_db)
context = mock_templates.TemplateResponse.call_args[0][1]
services_by_key = {s["key"]: s for s in context["services"]}
assert services_by_key["github"]["linked"] is False
@pytest.mark.asyncio
async def test_connections_page_oidc_linked_from_db(self):
"""OIDC linked status derives from DB effective settings."""
from app.views.settings import connections_page
mock_request = MagicMock()
mock_request.session = {"user": {"is_admin": True}}
mock_db = MagicMock()
db_values = {
"authentik_client_id": "my-client-id",
"authentik_client_secret": "my-secret",
"oauth_provider_name": "My SSO",
}
with (
patch("app.views.settings.get_all_settings_from_db", return_value=db_values),
patch("app.views.settings.templates") as mock_templates,
patch("app.views.settings.SETTING_METADATA", {}),
patch("app.views.settings.get_setting_metadata", return_value={}),
):
mock_templates.TemplateResponse.return_value = "response"
await connections_page(mock_request, db=mock_db)
context = mock_templates.TemplateResponse.call_args[0][1]
services_by_key = {s["key"]: s for s in context["services"]}
assert services_by_key["oidc"]["linked"] is True
assert services_by_key["oidc"]["name"] == "My SSO"
# oauth_configured template var should also reflect the DB state
assert context["oauth_configured"] is True
@pytest.mark.unit
class TestRefreshSocialProviders:
"""Tests for the refresh_social_providers() mechanism."""
def test_refresh_social_providers_exists(self):
"""refresh_social_providers is importable from app.auth."""
from app.auth import refresh_social_providers
assert callable(refresh_social_providers)
def test_refresh_social_providers_clears_and_repopulates(self):
"""After refresh, SOCIAL_PROVIDERS reflects current settings."""
import app.auth as auth_module
with (
patch.object(auth_module, "AUTH_ENABLED", True),
patch.object(auth_module, "settings") as mock_settings,
):
mock_settings.authentik_client_id = None
mock_settings.authentik_client_secret = None
mock_settings.social_auth_google_enabled = True
mock_settings.social_auth_google_client_id = "gid"
mock_settings.social_auth_google_client_secret = "gsecret"
mock_settings.social_auth_google_use_global_credentials = False
# All other providers disabled
for attr in (
"social_auth_microsoft_enabled",
"social_auth_apple_enabled",
"social_auth_dropbox_enabled",
"social_auth_github_enabled",
"social_auth_keycloak_enabled",
"social_auth_generic_oauth2_enabled",
):
setattr(mock_settings, attr, False)
with patch.object(auth_module, "_register_oauth_client"):
auth_module._setup_social_providers()
assert "google" in auth_module.SOCIAL_PROVIDERS
assert auth_module.OAUTH_CONFIGURED is False
def test_refresh_clears_previous_providers(self):
"""Providers removed from settings are cleared after refresh."""
import app.auth as auth_module
# Pre-populate with a stale entry
auth_module.SOCIAL_PROVIDERS["stale_provider"] = {"name": "Stale", "icon": "", "color": ""}
with (
patch.object(auth_module, "AUTH_ENABLED", True),
patch.object(auth_module, "settings") as mock_settings,
):
mock_settings.authentik_client_id = None
mock_settings.authentik_client_secret = None
for attr in (
"social_auth_google_enabled",
"social_auth_microsoft_enabled",
"social_auth_apple_enabled",
"social_auth_dropbox_enabled",
"social_auth_github_enabled",
"social_auth_keycloak_enabled",
"social_auth_generic_oauth2_enabled",
):
setattr(mock_settings, attr, False)
with patch.object(auth_module, "_register_oauth_client"):
auth_module._setup_social_providers()
assert "stale_provider" not in auth_module.SOCIAL_PROVIDERS
def test_register_oauth_client_clears_cache(self):
"""_register_oauth_client removes the cached client before re-registering."""
import app.auth as auth_module
# Inject a fake cached client
auth_module.oauth._clients["test_provider"] = object()
with patch.object(auth_module.oauth, "register"):
auth_module._register_oauth_client("test_provider", client_id="x", client_secret="y")
assert "test_provider" not in auth_module.oauth._clients
@pytest.mark.unit
class TestTranslationKeys:
+126
View File
@@ -6,6 +6,7 @@ Target: Bring coverage from 8.77% to 70%+
"""
import json
import uuid
from datetime import datetime, timedelta
from unittest.mock import Mock, patch
@@ -1981,3 +1982,128 @@ class TestPipelineInfoInViews:
assert response.status_code == 200
assert b"Standard" in response.content
# ---------------------------------------------------------------------------
# Owner display and claim ownership tests
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestOwnerDisplayAndClaim:
"""Tests that owner info and claim button appear correctly on file views."""
def _make_file(self, db_session, owner_id=None) -> FileRecord:
file_rec = FileRecord(
filehash=uuid.uuid4().hex,
original_filename="doc.pdf",
local_filename="/tmp/doc.pdf",
file_size=512,
mime_type="application/pdf",
owner_id=owner_id,
)
db_session.add(file_rec)
db_session.commit()
db_session.refresh(file_rec)
return file_rec
# ── /files/{id} (file_summary.html) ──────────────────────────────────
def test_summary_shows_owner_when_multi_user_enabled(self, client, db_session):
"""Owner ID is rendered in file summary when multi-user mode is on."""
file_rec = self._make_file(db_session, owner_id="alice@example.com")
with patch("app.config.settings.multi_user_enabled", True):
response = client.get(f"/files/{file_rec.id}")
assert response.status_code == 200
assert b"alice@example.com" in response.content
def test_summary_shows_unowned_label_for_unowned_file(self, client, db_session):
"""'Unowned' label is rendered in file summary for files without an owner."""
file_rec = self._make_file(db_session, owner_id=None)
with patch("app.config.settings.multi_user_enabled", True):
response = client.get(f"/files/{file_rec.id}")
assert response.status_code == 200
assert b"Unowned" in response.content
def test_summary_shows_claim_button_for_unowned_file(self, client, db_session):
"""Claim Ownership button appears on file summary for an unowned file."""
file_rec = self._make_file(db_session, owner_id=None)
with patch("app.config.settings.multi_user_enabled", True):
response = client.get(f"/files/{file_rec.id}")
assert response.status_code == 200
assert b"Claim Ownership" in response.content
def test_summary_no_claim_button_when_owned(self, client, db_session):
"""No Claim Ownership button when the file already has an owner."""
file_rec = self._make_file(db_session, owner_id="bob@example.com")
with patch("app.config.settings.multi_user_enabled", True):
response = client.get(f"/files/{file_rec.id}")
assert response.status_code == 200
assert b"Claim Ownership" not in response.content
def test_summary_no_owner_row_in_single_user_mode(self, client, db_session):
"""Owner row is hidden in single-user mode."""
file_rec = self._make_file(db_session, owner_id=None)
with patch("app.config.settings.multi_user_enabled", False):
response = client.get(f"/files/{file_rec.id}")
assert response.status_code == 200
# Claim button and Unowned label should not appear in single-user mode
assert b"Claim Ownership" not in response.content
# ── /files/{id}/detail (file_view.html) ──────────────────────────────
def test_detail_shows_owner_when_multi_user_enabled(self, client, db_session):
"""Owner ID is rendered in file detail view when multi-user mode is on."""
file_rec = self._make_file(db_session, owner_id="charlie@example.com")
with patch("app.config.settings.multi_user_enabled", True):
response = client.get(f"/files/{file_rec.id}/detail")
assert response.status_code == 200
assert b"charlie@example.com" in response.content
def test_detail_shows_claim_button_for_unowned_file(self, client, db_session):
"""Claim Ownership button appears in file detail view for an unowned file."""
file_rec = self._make_file(db_session, owner_id=None)
with patch("app.config.settings.multi_user_enabled", True):
response = client.get(f"/files/{file_rec.id}/detail")
assert response.status_code == 200
assert b"Claim Ownership" in response.content
# ── /files/{id}/annotations (file_annotations.html) ──────────────────
def test_annotations_shows_owner_info(self, client, db_session):
"""Owner info is rendered on the annotations page in multi-user mode."""
file_rec = self._make_file(db_session, owner_id="dave@example.com")
with patch("app.config.settings.multi_user_enabled", True):
response = client.get(f"/files/{file_rec.id}/annotations")
assert response.status_code == 200
assert b"dave@example.com" in response.content
def test_annotations_shows_claim_button_for_unowned_file(self, client, db_session):
"""Claim Ownership button appears on annotations page for unowned file."""
file_rec = self._make_file(db_session, owner_id=None)
with patch("app.config.settings.multi_user_enabled", True):
response = client.get(f"/files/{file_rec.id}/annotations")
assert response.status_code == 200
assert b"Claim Ownership" in response.content
def test_annotations_no_claim_button_when_owned(self, client, db_session):
"""No Claim Ownership button on annotations page when file has an owner."""
file_rec = self._make_file(db_session, owner_id="eve@example.com")
with patch("app.config.settings.multi_user_enabled", True):
response = client.get(f"/files/{file_rec.id}/annotations")
assert response.status_code == 200
assert b"Claim Ownership" not in response.content
def test_display_name_used_when_profile_exists(self, client, db_session):
"""UserProfile.display_name overrides raw user_id in the owner display."""
from app.models import UserProfile
file_rec = self._make_file(db_session, owner_id="frank@example.com")
profile = UserProfile(user_id="frank@example.com", display_name="Frank Lastname")
db_session.add(profile)
db_session.commit()
with patch("app.config.settings.multi_user_enabled", True):
response = client.get(f"/files/{file_rec.id}")
assert response.status_code == 200
assert b"Frank Lastname" in response.content