Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0c7ea6748d | |||
| 33a0e49acd | |||
| ce4bca0186 | |||
| 4b07e996ad | |||
| 720c9c11b0 | |||
| 425472c839 | |||
| 55afa4981b | |||
| 63f7b62fc0 | |||
| 8f1fe79411 | |||
| 3e1b352930 | |||
| 46772fc746 | |||
| 25d32a9006 | |||
| 8c6a02885d | |||
| 899cc56638 | |||
| 9822ba583d | |||
| 2288b89cd7 | |||
| 3d0bdf7836 | |||
| 41844c4b60 | |||
| 94aa2ebe57 | |||
| be97a757a3 | |||
| a5df6dc9cb | |||
| d4cc44a72f | |||
| 61dee5ba52 | |||
| 5f94e64734 | |||
| 9be03d8690 | |||
| 5c5b3ac054 | |||
| 9458055661 | |||
| bb116dcdd3 | |||
| 725bf98352 | |||
| 962495ba8c | |||
| 3843bce596 | |||
| 2df92ce469 | |||
| b25aaf879f | |||
| 4120a502df | |||
| 9c98a8438a | |||
| 49b816c878 |
+1
-1
@@ -1 +1 @@
|
|||||||
2026-03-22T14:46:09Z
|
2026-03-22T17:46:27Z
|
||||||
|
|||||||
@@ -10,6 +10,89 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
<!-- version list -->
|
<!-- version list -->
|
||||||
|
|
||||||
|
## 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)
|
## v0.169.0 (2026-03-22)
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|||||||
+6
-6
@@ -1,10 +1,10 @@
|
|||||||
DocuElevate Build Information
|
DocuElevate Build Information
|
||||||
==============================
|
==============================
|
||||||
Version: 0.169.0
|
Version: 0.171.3
|
||||||
Build Date: 2026-03-22T14:46:09Z
|
Build Date: 2026-03-22T17:46:27Z
|
||||||
Git Commit: 4a35aabdaa92dfa55d1f3a332691df81e56f025a
|
Git Commit: 720c9c11b05a7af6ac33478ecab6cc80c24c83e1
|
||||||
Git Short SHA: 4a35aab
|
Git Short SHA: 720c9c1
|
||||||
Git Branch: main
|
Git Branch: main
|
||||||
Commit Date: 2026-03-22T15:45:45+01:00
|
Commit Date: 2026-03-22T18:46:06+01:00
|
||||||
Build Timestamp: 2026-03-22T14:46:09Z
|
Build Timestamp: 2026-03-22T17:46:27Z
|
||||||
==============================
|
==============================
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ from pydantic import BaseModel, Field
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.auth import require_login
|
from app.auth import require_login
|
||||||
|
from app.config import settings
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.middleware.audit_log import get_client_ip
|
from app.middleware.audit_log import get_client_ip
|
||||||
from app.utils.session_manager import (
|
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
|
displayed to the user. The mobile app scans this QR code and
|
||||||
calls the ``/claim`` endpoint.
|
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)
|
ip = get_client_ip(request)
|
||||||
challenge = create_qr_challenge(db, owner_id, ip_address=ip)
|
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
|
The web UI calls this endpoint every few seconds to check if the
|
||||||
mobile app has scanned the QR code and claimed the challenge.
|
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)
|
result = get_challenge_status(db, challenge_id, owner_id)
|
||||||
if not result:
|
if not result:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Challenge not found")
|
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
|
serves as proof that the user authorized this login from their web
|
||||||
session.
|
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)
|
ip = get_client_ip(request)
|
||||||
result = claim_qr_challenge(db, body.challenge_token, device_name=body.device_name, ip_address=ip)
|
result = claim_qr_challenge(db, body.challenge_token, device_name=body.device_name, ip_address=ip)
|
||||||
|
|
||||||
|
|||||||
+219
-147
@@ -45,78 +45,27 @@ OAUTH_PROVIDER_NAME = "Single Sign-On"
|
|||||||
# Social login providers that are enabled and registered
|
# Social login providers that are enabled and registered
|
||||||
SOCIAL_PROVIDERS: dict[str, dict[str, str]] = {}
|
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:
|
# Helpers for dynamic (re-)registration of OAuth providers
|
||||||
# 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
|
|
||||||
|
|
||||||
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:
|
def _register_oauth_client(name: str, **kwargs: object) -> None:
|
||||||
# Determine which credentials to use for Microsoft social login
|
"""Register (or re-register) an authlib OAuth client, clearing any cached instance.
|
||||||
_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:
|
authlib caches the constructed client object in ``oauth._clients`` after the
|
||||||
tenant = settings.social_auth_microsoft_tenant or "common"
|
first ``register()`` call. Subsequent ``register()`` calls overwrite the
|
||||||
oauth.register(
|
registry entry but the stale cached client is still returned by
|
||||||
name="microsoft",
|
``create_client()`` / ``__getattr__``. Popping the name from ``_clients``
|
||||||
client_id=_microsoft_client_id,
|
before re-registering ensures the new credentials are picked up immediately.
|
||||||
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")
|
|
||||||
|
|
||||||
if AUTH_ENABLED and settings.social_auth_apple_enabled:
|
Args:
|
||||||
if settings.social_auth_apple_client_id and settings.social_auth_apple_team_id:
|
name: Provider name (e.g. ``"google"``, ``"github"``).
|
||||||
oauth.register(
|
**kwargs: Keyword arguments forwarded verbatim to ``oauth.register()``.
|
||||||
name="apple",
|
"""
|
||||||
client_id=settings.social_auth_apple_client_id,
|
oauth._clients.pop(name, None)
|
||||||
server_metadata_url="https://appleid.apple.com/.well-known/openid-configuration",
|
oauth.register(name, **kwargs)
|
||||||
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")
|
|
||||||
|
|
||||||
|
|
||||||
def _dropbox_userinfo_compliance_fix(client, user_cls, token, data):
|
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
|
return data
|
||||||
|
|
||||||
|
|
||||||
if AUTH_ENABLED and settings.social_auth_dropbox_enabled:
|
def _setup_social_providers() -> None:
|
||||||
# Determine which credentials to use for Dropbox social login
|
"""Register all configured OAuth / social-login providers from current settings.
|
||||||
_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:
|
This function is **idempotent**: it clears ``SOCIAL_PROVIDERS``,
|
||||||
oauth.register(
|
``OAUTH_CONFIGURED``, and ``OAUTH_PROVIDER_NAME`` before rebuilding them,
|
||||||
name="dropbox",
|
and calls :func:`_register_oauth_client` (which also clears the authlib
|
||||||
client_id=_dropbox_client_id,
|
client cache) so that credential changes in the database are reflected
|
||||||
client_secret=_dropbox_client_secret,
|
without an application restart.
|
||||||
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")
|
|
||||||
|
|
||||||
if AUTH_ENABLED and settings.social_auth_github_enabled:
|
Can safely be called multiple times, e.g. after a settings reload.
|
||||||
if settings.social_auth_github_client_id and settings.social_auth_github_client_secret:
|
"""
|
||||||
oauth.register(
|
global OAUTH_CONFIGURED, OAUTH_PROVIDER_NAME
|
||||||
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")
|
|
||||||
|
|
||||||
if AUTH_ENABLED and settings.social_auth_keycloak_enabled:
|
SOCIAL_PROVIDERS.clear()
|
||||||
_kc_server = settings.social_auth_keycloak_server_url
|
OAUTH_CONFIGURED = False
|
||||||
_kc_realm = settings.social_auth_keycloak_realm
|
OAUTH_PROVIDER_NAME = "Single Sign-On"
|
||||||
if (
|
|
||||||
settings.social_auth_keycloak_client_id
|
if not AUTH_ENABLED:
|
||||||
and settings.social_auth_keycloak_client_secret
|
return
|
||||||
and _kc_server
|
|
||||||
and _kc_realm
|
# --- Authentik / OIDC ---
|
||||||
):
|
if settings.authentik_client_id and settings.authentik_client_secret:
|
||||||
_kc_base = f"{_kc_server.rstrip('/')}/realms/{_kc_realm}"
|
_register_oauth_client(
|
||||||
oauth.register(
|
"authentik",
|
||||||
name="keycloak",
|
client_id=settings.authentik_client_id,
|
||||||
client_id=settings.social_auth_keycloak_client_id,
|
client_secret=settings.authentik_client_secret,
|
||||||
client_secret=settings.social_auth_keycloak_client_secret,
|
server_metadata_url=settings.authentik_config_url,
|
||||||
server_metadata_url=f"{_kc_base}/.well-known/openid-configuration",
|
|
||||||
client_kwargs={"scope": "openid profile email"},
|
client_kwargs={"scope": "openid profile email"},
|
||||||
)
|
)
|
||||||
SOCIAL_PROVIDERS["keycloak"] = {"name": "Keycloak", "icon": "fas fa-key", "color": "gray"}
|
OAUTH_CONFIGURED = True
|
||||||
logger.info("Social login provider registered: Keycloak (realm=%s)", _kc_realm)
|
OAUTH_PROVIDER_NAME = settings.oauth_provider_name or "Authentik SSO"
|
||||||
else:
|
|
||||||
logger.warning("SOCIAL_AUTH_KEYCLOAK_ENABLED=true but required settings not configured")
|
|
||||||
|
|
||||||
if AUTH_ENABLED and settings.social_auth_generic_oauth2_enabled:
|
# --- Social Login Providers ---
|
||||||
if (
|
|
||||||
settings.social_auth_generic_oauth2_client_id
|
# Google
|
||||||
and settings.social_auth_generic_oauth2_client_secret
|
if settings.social_auth_google_enabled:
|
||||||
and settings.social_auth_generic_oauth2_authorize_url
|
_google_client_id = settings.social_auth_google_client_id
|
||||||
and settings.social_auth_generic_oauth2_token_url
|
_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):
|
||||||
oauth.register(
|
_google_client_id = settings.google_drive_client_id
|
||||||
name="generic_oauth2",
|
_google_client_secret = settings.google_drive_client_secret
|
||||||
client_id=settings.social_auth_generic_oauth2_client_id,
|
|
||||||
client_secret=settings.social_auth_generic_oauth2_client_secret,
|
if _google_client_id and _google_client_secret:
|
||||||
authorize_url=settings.social_auth_generic_oauth2_authorize_url,
|
_register_oauth_client(
|
||||||
access_token_url=settings.social_auth_generic_oauth2_token_url,
|
"google",
|
||||||
userinfo_endpoint=settings.social_auth_generic_oauth2_userinfo_url,
|
client_id=_google_client_id,
|
||||||
client_kwargs={"scope": settings.social_auth_generic_oauth2_scope},
|
client_secret=_google_client_secret,
|
||||||
)
|
server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
|
||||||
_generic_name = settings.social_auth_generic_oauth2_name or "OAuth2"
|
client_kwargs={"scope": "openid profile email"},
|
||||||
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)
|
SOCIAL_PROVIDERS["google"] = {"name": "Google", "icon": "fab fa-google", "color": "red"}
|
||||||
else:
|
logger.info("Social login provider registered: Google")
|
||||||
logger.warning("SOCIAL_AUTH_GENERIC_OAUTH2_ENABLED=true but required settings not configured")
|
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 (%s)", _generic_name)
|
||||||
|
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()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|||||||
@@ -244,6 +244,10 @@ class Settings(BaseSettings):
|
|||||||
"Useful for admin-configured non-standard durations."
|
"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(
|
qr_login_challenge_ttl_seconds: int = Field(
|
||||||
default=120,
|
default=120,
|
||||||
description="Time-to-live in seconds for QR login challenges (default: 2 minutes).",
|
description="Time-to-live in seconds for QR login challenges (default: 2 minutes).",
|
||||||
|
|||||||
+12
@@ -189,6 +189,18 @@ async def lifespan(app: FastAPI):
|
|||||||
finally:
|
finally:
|
||||||
db.close()
|
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
|
# 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.
|
# via the database UI (e.g. SENTRY_DSN) are respected in addition to env vars.
|
||||||
init_sentry()
|
init_sentry()
|
||||||
|
|||||||
@@ -206,6 +206,14 @@ SETTING_METADATA = {
|
|||||||
"required": False,
|
"required": False,
|
||||||
"restart_required": True,
|
"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": {
|
"qr_login_challenge_ttl_seconds": {
|
||||||
"category": "Authentication",
|
"category": "Authentication",
|
||||||
"description": "Time-to-live in seconds for QR login challenges (default 120).",
|
"description": "Time-to-live in seconds for QR login challenges (default 120).",
|
||||||
|
|||||||
@@ -71,6 +71,16 @@ def notify_settings_updated() -> None:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning(f"Could not reload in-process settings: {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
|
# Re-check OCR language availability in the background whenever settings
|
||||||
# are updated. This ensures that if a user changes tesseract_language or
|
# are updated. This ensures that if a user changes tesseract_language or
|
||||||
# easyocr_languages via the UI, the new language data is downloaded without
|
# easyocr_languages via the UI, the new language data is downloaded without
|
||||||
|
|||||||
+44
-12
@@ -19,6 +19,43 @@ router = APIRouter()
|
|||||||
_FILE_NOT_FOUND = "File not found"
|
_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")
|
@router.get("/files")
|
||||||
@require_login
|
@require_login
|
||||||
def files_page(
|
def files_page(
|
||||||
@@ -268,6 +305,7 @@ def file_summary_page(request: Request, file_id: int, db: Session = Depends(get_
|
|||||||
step_summary = None
|
step_summary = None
|
||||||
|
|
||||||
pipeline_info = _resolve_pipeline(db, file_record)
|
pipeline_info = _resolve_pipeline(db, file_record)
|
||||||
|
owner_ctx = _resolve_owner_context(request, file_record, db)
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"file_summary.html",
|
"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,
|
"processed_file_exists": processed_file_exists,
|
||||||
"step_summary": step_summary,
|
"step_summary": step_summary,
|
||||||
"pipeline_info": pipeline_info,
|
"pipeline_info": pipeline_info,
|
||||||
|
**owner_ctx,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
except Exception as e:
|
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)
|
# Resolve the pipeline assigned to this file (explicit or system default)
|
||||||
pipeline_info = _resolve_pipeline(db, file_record)
|
pipeline_info = _resolve_pipeline(db, file_record)
|
||||||
|
owner_ctx = _resolve_owner_context(request, file_record, db)
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"file_view.html",
|
"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,
|
"processed_file_exists": processed_file_exists,
|
||||||
"step_summary": step_summary,
|
"step_summary": step_summary,
|
||||||
"pipeline_info": pipeline_info,
|
"pipeline_info": pipeline_info,
|
||||||
|
**owner_ctx,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
except Exception as e:
|
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 ""
|
mime = file_record.mime_type or ""
|
||||||
is_pdf = mime == "application/pdf" or (file_record.original_filename or "").lower().endswith(".pdf")
|
is_pdf = mime == "application/pdf" or (file_record.original_filename or "").lower().endswith(".pdf")
|
||||||
|
|
||||||
# Determine the current user's role on this file
|
# Determine the current user's role on this file (and owner display info)
|
||||||
from app.utils.user_scope import get_current_owner_id, get_file_role
|
owner_ctx = _resolve_owner_context(request, file_record, db)
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"file_annotations.html",
|
"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,
|
"original_file_exists": original_file_exists,
|
||||||
"processed_file_exists": processed_file_exists,
|
"processed_file_exists": processed_file_exists,
|
||||||
"is_pdf": is_pdf,
|
"is_pdf": is_pdf,
|
||||||
"current_user_role": current_user_role,
|
**owner_ctx,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
+58
-13
@@ -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.
|
SSO settings, and service integrations through a wizard-like interface.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from app.auth import OAUTH_CONFIGURED, SOCIAL_PROVIDERS
|
|
||||||
|
|
||||||
db_settings = get_all_settings_from_db(db)
|
db_settings = get_all_settings_from_db(db)
|
||||||
|
|
||||||
def _get_effective(key: str):
|
def _get_effective(key: str):
|
||||||
@@ -227,13 +225,14 @@ async def connections_page(request: Request, db: Session = Depends(get_db)):
|
|||||||
services = []
|
services = []
|
||||||
|
|
||||||
# --- SSO (Authentik / OIDC) ---
|
# --- SSO (Authentik / OIDC) ---
|
||||||
|
_oidc_linked = bool(_get_effective("authentik_client_id") and _get_effective("authentik_client_secret"))
|
||||||
services.append(
|
services.append(
|
||||||
{
|
{
|
||||||
"key": "oidc",
|
"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",
|
"icon": "fas fa-lock",
|
||||||
"type": "SSO",
|
"type": "SSO",
|
||||||
"linked": OAUTH_CONFIGURED,
|
"linked": _oidc_linked,
|
||||||
"description": "OpenID Connect SSO provider",
|
"description": "OpenID Connect SSO provider",
|
||||||
"settings_keys": [
|
"settings_keys": [
|
||||||
"authentik_client_id",
|
"authentik_client_id",
|
||||||
@@ -245,13 +244,23 @@ async def connections_page(request: Request, db: Session = Depends(get_db)):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# --- Google ---
|
# --- 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(
|
services.append(
|
||||||
{
|
{
|
||||||
"key": "google",
|
"key": "google",
|
||||||
"name": "Google",
|
"name": "Google",
|
||||||
"icon": "fab fa-google",
|
"icon": "fab fa-google",
|
||||||
"type": "Sign-in authentication",
|
"type": "Sign-in authentication",
|
||||||
"linked": "google" in SOCIAL_PROVIDERS,
|
"linked": _google_linked,
|
||||||
"description": "Sign-in authentication",
|
"description": "Sign-in authentication",
|
||||||
"settings_keys": [
|
"settings_keys": [
|
||||||
"social_auth_google_enabled",
|
"social_auth_google_enabled",
|
||||||
@@ -263,13 +272,18 @@ async def connections_page(request: Request, db: Session = Depends(get_db)):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# --- GitHub ---
|
# --- 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(
|
services.append(
|
||||||
{
|
{
|
||||||
"key": "github",
|
"key": "github",
|
||||||
"name": "GitHub",
|
"name": "GitHub",
|
||||||
"icon": "fab fa-github",
|
"icon": "fab fa-github",
|
||||||
"type": "Sign-in authentication",
|
"type": "Sign-in authentication",
|
||||||
"linked": "github" in SOCIAL_PROVIDERS,
|
"linked": _github_linked,
|
||||||
"description": "Sign-in authentication",
|
"description": "Sign-in authentication",
|
||||||
"settings_keys": [
|
"settings_keys": [
|
||||||
"social_auth_github_enabled",
|
"social_auth_github_enabled",
|
||||||
@@ -280,13 +294,19 @@ async def connections_page(request: Request, db: Session = Depends(get_db)):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# --- Microsoft ---
|
# --- 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(
|
services.append(
|
||||||
{
|
{
|
||||||
"key": "microsoft",
|
"key": "microsoft",
|
||||||
"name": "Microsoft",
|
"name": "Microsoft",
|
||||||
"icon": "fab fa-microsoft",
|
"icon": "fab fa-microsoft",
|
||||||
"type": "Sign-in authentication",
|
"type": "Sign-in authentication",
|
||||||
"linked": "microsoft" in SOCIAL_PROVIDERS,
|
"linked": _microsoft_linked,
|
||||||
"description": "Sign-in authentication",
|
"description": "Sign-in authentication",
|
||||||
"settings_keys": [
|
"settings_keys": [
|
||||||
"social_auth_microsoft_enabled",
|
"social_auth_microsoft_enabled",
|
||||||
@@ -299,13 +319,18 @@ async def connections_page(request: Request, db: Session = Depends(get_db)):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# --- Apple ---
|
# --- 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(
|
services.append(
|
||||||
{
|
{
|
||||||
"key": "apple",
|
"key": "apple",
|
||||||
"name": "Apple",
|
"name": "Apple",
|
||||||
"icon": "fab fa-apple",
|
"icon": "fab fa-apple",
|
||||||
"type": "Sign-in authentication",
|
"type": "Sign-in authentication",
|
||||||
"linked": "apple" in SOCIAL_PROVIDERS,
|
"linked": _apple_linked,
|
||||||
"description": "Sign-in authentication",
|
"description": "Sign-in authentication",
|
||||||
"settings_keys": [
|
"settings_keys": [
|
||||||
"social_auth_apple_enabled",
|
"social_auth_apple_enabled",
|
||||||
@@ -318,13 +343,19 @@ async def connections_page(request: Request, db: Session = Depends(get_db)):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# --- Dropbox ---
|
# --- 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(
|
services.append(
|
||||||
{
|
{
|
||||||
"key": "dropbox",
|
"key": "dropbox",
|
||||||
"name": "Dropbox",
|
"name": "Dropbox",
|
||||||
"icon": "fab fa-dropbox",
|
"icon": "fab fa-dropbox",
|
||||||
"type": "Sign-in authentication",
|
"type": "Sign-in authentication",
|
||||||
"linked": "dropbox" in SOCIAL_PROVIDERS,
|
"linked": _dropbox_linked,
|
||||||
"description": "Sign-in authentication",
|
"description": "Sign-in authentication",
|
||||||
"settings_keys": [
|
"settings_keys": [
|
||||||
"social_auth_dropbox_enabled",
|
"social_auth_dropbox_enabled",
|
||||||
@@ -336,13 +367,20 @@ async def connections_page(request: Request, db: Session = Depends(get_db)):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# --- Keycloak ---
|
# --- 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(
|
services.append(
|
||||||
{
|
{
|
||||||
"key": "keycloak",
|
"key": "keycloak",
|
||||||
"name": "Keycloak",
|
"name": "Keycloak",
|
||||||
"icon": "fas fa-key",
|
"icon": "fas fa-key",
|
||||||
"type": "SSO",
|
"type": "SSO",
|
||||||
"linked": "keycloak" in SOCIAL_PROVIDERS,
|
"linked": _keycloak_linked,
|
||||||
"description": "SSO",
|
"description": "SSO",
|
||||||
"settings_keys": [
|
"settings_keys": [
|
||||||
"social_auth_keycloak_enabled",
|
"social_auth_keycloak_enabled",
|
||||||
@@ -355,13 +393,20 @@ async def connections_page(request: Request, db: Session = Depends(get_db)):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# --- Generic OAuth2 ---
|
# --- 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(
|
services.append(
|
||||||
{
|
{
|
||||||
"key": "generic_oauth2",
|
"key": "generic_oauth2",
|
||||||
"name": "Generic OAuth2",
|
"name": "Generic OAuth2",
|
||||||
"icon": "fas fa-sign-in-alt",
|
"icon": "fas fa-sign-in-alt",
|
||||||
"type": "SSO",
|
"type": "SSO",
|
||||||
"linked": "generic_oauth2" in SOCIAL_PROVIDERS,
|
"linked": _generic_oauth2_linked,
|
||||||
"description": "SSO",
|
"description": "SSO",
|
||||||
"settings_keys": [
|
"settings_keys": [
|
||||||
"social_auth_generic_oauth2_enabled",
|
"social_auth_generic_oauth2_enabled",
|
||||||
@@ -464,7 +509,7 @@ async def connections_page(request: Request, db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
# Feature toggles
|
# Feature toggles
|
||||||
sso_auto_login = _is_truthy(_get_effective("sso_auto_login"))
|
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"))
|
frontend_url_configured = bool(_get_effective("public_base_url"))
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
@@ -474,7 +519,7 @@ async def connections_page(request: Request, db: Session = Depends(get_db)):
|
|||||||
"services": services,
|
"services": services,
|
||||||
"service_settings": service_settings,
|
"service_settings": service_settings,
|
||||||
"sso_auto_login": sso_auto_login,
|
"sso_auto_login": sso_auto_login,
|
||||||
"oauth_configured": OAUTH_CONFIGURED,
|
"oauth_configured": _oidc_linked,
|
||||||
"qr_login_enabled": qr_login_enabled,
|
"qr_login_enabled": qr_login_enabled,
|
||||||
"frontend_url_configured": frontend_url_configured,
|
"frontend_url_configured": frontend_url_configured,
|
||||||
"app_version": settings.version,
|
"app_version": settings.version,
|
||||||
|
|||||||
+1
-1
@@ -181,7 +181,7 @@ Simply leave `SENTRY_DSN` unset (or set it to an empty string). Neither the Pyt
|
|||||||
## SDK Version
|
## 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.
|
- **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`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,10 @@
|
|||||||
_i18n = i18n || {};
|
_i18n = i18n || {};
|
||||||
_loadAnnotations();
|
_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');
|
var form = document.getElementById('annotation-form');
|
||||||
if (form) {
|
if (form) {
|
||||||
form.addEventListener('submit', function (e) {
|
form.addEventListener('submit', function (e) {
|
||||||
@@ -81,10 +85,18 @@
|
|||||||
typeBadge.className = 'annotation-type annotation-type--' + ann.annotation_type;
|
typeBadge.className = 'annotation-type annotation-type--' + ann.annotation_type;
|
||||||
typeBadge.textContent = _i18n['type_' + ann.annotation_type] || ann.annotation_type;
|
typeBadge.textContent = _i18n['type_' + ann.annotation_type] || ann.annotation_type;
|
||||||
|
|
||||||
var pageInfo = document.createElement('span');
|
var pageInfo = document.createElement('button');
|
||||||
pageInfo.className = 'annotation-page';
|
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> ' +
|
pageInfo.innerHTML = '<i class="fas fa-file-alt" aria-hidden="true"></i> ' +
|
||||||
(_i18n.page || 'Page') + ' ' + ann.page;
|
(_i18n.page || 'Page') + ' ' + ann.page;
|
||||||
|
pageInfo.addEventListener('click', function () {
|
||||||
|
if (typeof window._embedpdfScrollToPage === 'function') {
|
||||||
|
window._embedpdfScrollToPage(ann.page);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
header.appendChild(typeBadge);
|
header.appendChild(typeBadge);
|
||||||
if (ann.color) {
|
if (ann.color) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -238,3 +238,81 @@ html.dark ::-webkit-scrollbar-thumb:hover { background: #6b7280; }
|
|||||||
|
|
||||||
/* ---- Settings page: sidebar active state (dark) ---- */
|
/* ---- Settings page: sidebar active state (dark) ---- */
|
||||||
html.dark .bg-blue-50 { background-color: #1e3a5f; }
|
html.dark .bg-blue-50 { background-color: #1e3a5f; }
|
||||||
|
|
||||||
|
/* =============================================================
|
||||||
|
DOC-TOGGLE – cross-browser toggle switch
|
||||||
|
Works with Tailwind v2 CDN (which lacks after:* utilities).
|
||||||
|
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 */
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,11 +18,11 @@
|
|||||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{ _("connections.sso_auto_login_description") }}</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{ _("connections.sso_auto_login_description") }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="relative inline-flex items-center cursor-pointer">
|
<label class="doc-toggle">
|
||||||
<input type="checkbox" id="sso-auto-login-toggle" class="sr-only peer"
|
<input type="checkbox" id="sso-auto-login-toggle" class="sr-only"
|
||||||
{% if sso_auto_login %}checked{% endif %}
|
{% if sso_auto_login %}checked{% endif %}
|
||||||
onchange="toggleSetting('sso_auto_login', this.checked)">
|
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>
|
<span class="ml-3 text-sm font-medium text-gray-700 dark:text-gray-300">{{ _("connections.sso_auto_login") }}</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -44,10 +44,11 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="relative inline-flex items-center cursor-pointer">
|
<label class="doc-toggle">
|
||||||
<input type="checkbox" id="qr-upload-toggle" class="sr-only peer"
|
<input type="checkbox" id="qr-upload-toggle" class="sr-only"
|
||||||
{% if qr_login_enabled %}checked{% endif %} disabled>
|
{% if qr_login_enabled %}checked{% endif %}
|
||||||
<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>
|
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>
|
<span class="ml-3 text-sm font-medium text-gray-700 dark:text-gray-300">{{ _("connections.qr_code_enabled") }}</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -199,24 +200,24 @@ function openServiceModal(serviceKey) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (meta.type === 'boolean') {
|
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');
|
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(); }));
|
toggleWrapper.setAttribute('aria-label', field.key.replace(/_/g, ' ').replace(/\b\w/g, function(c) { return c.toUpperCase(); }));
|
||||||
|
|
||||||
const checkbox = document.createElement('input');
|
const checkbox = document.createElement('input');
|
||||||
checkbox.type = 'checkbox';
|
checkbox.type = 'checkbox';
|
||||||
checkbox.id = 'field-' + field.key;
|
checkbox.id = 'field-' + field.key;
|
||||||
checkbox.name = field.key;
|
checkbox.name = field.key;
|
||||||
checkbox.className = 'sr-only peer';
|
checkbox.className = 'sr-only';
|
||||||
const val = field.value;
|
const val = field.value;
|
||||||
if (val === true || val === 'true' || val === '1' || val === 'True') {
|
if (val === true || val === 'true' || val === '1' || val === 'True') {
|
||||||
checkbox.checked = true;
|
checkbox.checked = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const slider = document.createElement('div');
|
const slider = document.createElement('span');
|
||||||
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.className = 'doc-toggle-track';
|
||||||
slider.style.cssText = 'min-width:44px; min-height:24px;';
|
slider.setAttribute('aria-hidden', 'true');
|
||||||
|
|
||||||
toggleWrapper.appendChild(checkbox);
|
toggleWrapper.appendChild(checkbox);
|
||||||
toggleWrapper.appendChild(slider);
|
toggleWrapper.appendChild(slider);
|
||||||
|
|||||||
@@ -41,8 +41,10 @@
|
|||||||
upgrading the SDK.
|
upgrading the SDK.
|
||||||
──────────────────────────────────────────────────────────────────────── #}
|
──────────────────────────────────────────────────────────────────────── #}
|
||||||
{% if sentry_dsn %}
|
{% if sentry_dsn %}
|
||||||
<script src="https://browser.sentry-cdn.com/9.x.x/bundle.tracing.replay.min.js"
|
<script src="https://browser.sentry-cdn.com/10.45.0/bundle.tracing.replay.feedback.logs.metrics.min.js"
|
||||||
crossorigin="anonymous"></script>
|
integrity="sha384-TCY3xw5Ej940LIWfS6PwhCCBl7lvEsxBpHy+BirF+EycSQUvXbfZsgsLi0oU18yZ"
|
||||||
|
crossorigin="anonymous"
|
||||||
|
></script>
|
||||||
<script>
|
<script>
|
||||||
if (window.Sentry) {
|
if (window.Sentry) {
|
||||||
Sentry.init({
|
Sentry.init({
|
||||||
|
|||||||
@@ -380,6 +380,23 @@
|
|||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: #718096;
|
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 {
|
.annotation-content {
|
||||||
color: #4a5568;
|
color: #4a5568;
|
||||||
font-size: 0.9375rem;
|
font-size: 0.9375rem;
|
||||||
@@ -569,6 +586,23 @@
|
|||||||
Comments & Annotations
|
Comments & Annotations
|
||||||
</div>
|
</div>
|
||||||
<div class="annotations-subtitle">{{ file.original_filename }}</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 %}
|
||||||
|
—
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -750,6 +784,7 @@
|
|||||||
delete_confirm: {{ _("annotations.delete_confirm") | tojson }},
|
delete_confirm: {{ _("annotations.delete_confirm") | tojson }},
|
||||||
page: {{ _("annotations.page") | tojson }},
|
page: {{ _("annotations.page") | tojson }},
|
||||||
color: {{ _("annotations.color") | tojson }},
|
color: {{ _("annotations.color") | tojson }},
|
||||||
|
go_to_page: {{ _("annotations.go_to_page") | tojson }},
|
||||||
type_note: {{ _("annotations.type_note") | tojson }},
|
type_note: {{ _("annotations.type_note") | tojson }},
|
||||||
type_highlight: {{ _("annotations.type_highlight") | tojson }},
|
type_highlight: {{ _("annotations.type_highlight") | tojson }},
|
||||||
type_underline: {{ _("annotations.type_underline") | tojson }},
|
type_underline: {{ _("annotations.type_underline") | tojson }},
|
||||||
@@ -801,18 +836,110 @@
|
|||||||
|
|
||||||
const viewerEl = document.getElementById('embedpdf-viewer');
|
const viewerEl = document.getElementById('embedpdf-viewer');
|
||||||
if (viewerEl) {
|
if (viewerEl) {
|
||||||
|
const fileId = {{ file.id | tojson }};
|
||||||
{% if processed_file_exists %}
|
{% if processed_file_exists %}
|
||||||
const pdfUrl = '/api/files/{{ file.id }}/preview?version=processed';
|
const pdfUrl = '/api/files/' + fileId + '/preview?version=processed';
|
||||||
{% else %}
|
{% else %}
|
||||||
const pdfUrl = '/api/files/{{ file.id }}/preview?version=original';
|
const pdfUrl = '/api/files/' + fileId + '/preview?version=original';
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
EmbedPDF.init({
|
const viewer = EmbedPDF.init({
|
||||||
type: 'container',
|
type: 'container',
|
||||||
target: viewerEl,
|
target: viewerEl,
|
||||||
src: pdfUrl,
|
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>
|
</script>
|
||||||
{% endif %}
|
{% 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 %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|||||||
@@ -165,6 +165,12 @@
|
|||||||
{% if file.document_title %}
|
{% if file.document_title %}
|
||||||
<div class="info-row"><span class="info-key">Document Title</span><span class="info-val">{{ file.document_title }}</span></div>
|
<div class="info-row"><span class="info-key">Document Title</span><span class="info-val">{{ file.document_title }}</span></div>
|
||||||
{% endif %}
|
{% 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>
|
</div>
|
||||||
|
|
||||||
<!-- ── Quick actions ── -->
|
<!-- ── Quick actions ── -->
|
||||||
@@ -184,7 +190,18 @@
|
|||||||
<a href="/files/{{ file.id }}/detail" class="action-btn btn-secondary">
|
<a href="/files/{{ file.id }}/detail" class="action-btn btn-secondary">
|
||||||
<i class="fas fa-eye" aria-hidden="true"></i> View Detail
|
<i class="fas fa-eye" aria-hidden="true"></i> View Detail
|
||||||
</a>
|
</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>
|
</div>
|
||||||
|
<p id="claim-msg" style="margin-top:0.5rem;font-size:0.875rem;display:none;" role="alert"></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -193,4 +210,17 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
</div>
|
</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 %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -316,6 +316,12 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Actions -->
|
<!-- Actions -->
|
||||||
@@ -344,7 +350,18 @@
|
|||||||
<a href="/shared-links?file_id={{ file.id }}" class="action-btn btn-secondary">
|
<a href="/shared-links?file_id={{ file.id }}" class="action-btn btn-secondary">
|
||||||
<i class="fas fa-share-alt" aria-hidden="true"></i> Share
|
<i class="fas fa-share-alt" aria-hidden="true"></i> Share
|
||||||
</a>
|
</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>
|
</div>
|
||||||
|
<p id="claim-msg" style="margin-top:0.5rem;font-size:0.875rem;display:none;" role="alert"></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -937,6 +954,16 @@
|
|||||||
pdfInit('/api/files/{{ file.id }}/preview?version={{ pv }}');
|
pdfInit('/api/files/{{ file.id }}/preview?version={{ pv }}');
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% 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>
|
</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 %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -321,6 +321,7 @@
|
|||||||
"annotations.delete_confirm": "Are you sure you want to delete this annotation?",
|
"annotations.delete_confirm": "Are you sure you want to delete this annotation?",
|
||||||
"annotations.deleted": "Annotation deleted",
|
"annotations.deleted": "Annotation deleted",
|
||||||
"annotations.empty": "No annotations yet",
|
"annotations.empty": "No annotations yet",
|
||||||
|
"annotations.go_to_page": "Go to page",
|
||||||
"annotations.heading": "Annotations",
|
"annotations.heading": "Annotations",
|
||||||
"annotations.page": "Page",
|
"annotations.page": "Page",
|
||||||
"annotations.save": "Save",
|
"annotations.save": "Save",
|
||||||
@@ -1756,6 +1757,12 @@
|
|||||||
"sharing.role_viewer": "Viewer",
|
"sharing.role_viewer": "Viewer",
|
||||||
"sharing.user_id_label": "User ID or email",
|
"sharing.user_id_label": "User ID or email",
|
||||||
"sharing.user_id_placeholder": "e.g. alice@example.com",
|
"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.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.files_missing_text": "file(s) have OCR text but no embedding yet.",
|
||||||
"similarity.find_pairs_btn": "Find Pairs",
|
"similarity.find_pairs_btn": "Find Pairs",
|
||||||
|
|||||||
@@ -156,6 +156,49 @@ class TestCommentsUIRendering:
|
|||||||
assert 'id="embedpdf-viewer"' in html
|
assert 'id="embedpdf-viewer"' in html
|
||||||
assert "@embedpdf/snippet" 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):
|
def test_summary_page_renders(self, client: TestClient, db_session, tmp_path):
|
||||||
"""The summary page at /files/{id} should render correctly."""
|
"""The summary page at /files/{id} should render correctly."""
|
||||||
f = _create_file(db_session, tmp_path)
|
f = _create_file(db_session, tmp_path)
|
||||||
|
|||||||
+169
-2
@@ -268,8 +268,6 @@ class TestConnectionsPageRoute:
|
|||||||
patch("app.views.settings.get_all_settings_from_db", return_value={}),
|
patch("app.views.settings.get_all_settings_from_db", return_value={}),
|
||||||
patch("app.views.settings.templates") as mock_templates,
|
patch("app.views.settings.templates") as mock_templates,
|
||||||
patch("app.views.settings.SETTING_METADATA", {}),
|
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={}),
|
patch("app.views.settings.get_setting_metadata", return_value={}),
|
||||||
):
|
):
|
||||||
mock_templates.TemplateResponse.return_value = "response"
|
mock_templates.TemplateResponse.return_value = "response"
|
||||||
@@ -296,6 +294,175 @@ class TestConnectionsPageRoute:
|
|||||||
assert "smtp" in service_keys
|
assert "smtp" in service_keys
|
||||||
assert "telegram" 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
|
@pytest.mark.unit
|
||||||
class TestTranslationKeys:
|
class TestTranslationKeys:
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ Target: Bring coverage from 8.77% to 70%+
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import uuid
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from unittest.mock import Mock, patch
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
@@ -1981,3 +1982,128 @@ class TestPipelineInfoInViews:
|
|||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert b"Standard" in response.content
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user