Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 16de54f66b | |||
| 084171395d | |||
| 958b195e79 | |||
| c5ef1ec50c | |||
| ef897f660d | |||
| 6cb9feacab | |||
| 76c0e91500 | |||
| 0c7ea6748d | |||
| 78077fa8c7 | |||
| 242846aa9c | |||
| 868613ac49 | |||
| 33a0e49acd | |||
| 14b3031e63 | |||
| 1d7df13c94 | |||
| ce4bca0186 | |||
| 4b07e996ad | |||
| 720c9c11b0 | |||
| 48a303d498 |
@@ -200,3 +200,6 @@ cython_debug/
|
|||||||
# Build metadata files - generated at build time
|
# Build metadata files - generated at build time
|
||||||
GIT_SHA
|
GIT_SHA
|
||||||
RUNTIME_INFO
|
RUNTIME_INFO
|
||||||
|
|
||||||
|
# Frontend build tooling
|
||||||
|
frontend/node_modules/
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
2026-03-22T17:37:50Z
|
2026-03-22T18:47:07Z
|
||||||
|
|||||||
@@ -10,6 +10,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
<!-- version list -->
|
<!-- version list -->
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
|
||||||
|
## v0.172.1 (2026-03-22)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Social login providers now work from DB config without restart
|
||||||
|
([`0c7ea67`](https://github.com/christianlouis/DocuElevate/commit/0c7ea6748da554c80ef9af1b709c08aba49174e6))
|
||||||
|
|
||||||
|
|
||||||
|
## v0.172.0 (2026-03-22)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **ui**: Migrate Tailwind CSS from v2 CDN to v3 Play CDN (interim step)
|
||||||
|
([`1d7df13`](https://github.com/christianlouis/DocuElevate/commit/1d7df13c943cc9138dc3ed514f9ab81d861bfbac))
|
||||||
|
|
||||||
|
- **ui**: Replace Tailwind CSS CDN with compiled v3 production build
|
||||||
|
([`14b3031`](https://github.com/christianlouis/DocuElevate/commit/14b3031e63e8645c4048dd73a594e9a53a919c17))
|
||||||
|
|
||||||
|
|
||||||
|
## v0.171.3 (2026-03-22)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **ui**: Add missing opening script tag in base.html Sentry block
|
||||||
|
([`425472c`](https://github.com/christianlouis/DocuElevate/commit/425472c839b3564c20a29b6e983fa6b9e7d6cf9c))
|
||||||
|
|
||||||
|
|
||||||
## v0.171.2 (2026-03-22)
|
## v0.171.2 (2026-03-22)
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|||||||
+17
-2
@@ -27,7 +27,20 @@ RUN pip install --no-cache-dir -r requirements.txt \
|
|||||||
&& find /opt/venv -type f -name "*.pyc" -delete \
|
&& find /opt/venv -type f -name "*.pyc" -delete \
|
||||||
&& find /opt/venv -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
|
&& find /opt/venv -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
|
||||||
|
|
||||||
# ── Stage 2: Documentation builder ──────────────────────────────────────────
|
# ── Stage 2: Frontend asset builder (Tailwind CSS) ──────────────────────────
|
||||||
|
FROM node:20-alpine AS frontend-builder
|
||||||
|
|
||||||
|
WORKDIR /frontend
|
||||||
|
|
||||||
|
# Install dependencies first (layer-cached unless package.json/lockfile changes)
|
||||||
|
COPY frontend/package.json frontend/package-lock.json ./
|
||||||
|
RUN npm ci --omit=dev
|
||||||
|
|
||||||
|
# Copy source files and compile Tailwind CSS
|
||||||
|
COPY frontend/ ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ── Stage 3: Documentation builder ──────────────────────────────────────────
|
||||||
FROM python:3.14.3-slim AS docs-builder
|
FROM python:3.14.3-slim AS docs-builder
|
||||||
|
|
||||||
WORKDIR /docs
|
WORKDIR /docs
|
||||||
@@ -43,7 +56,7 @@ COPY mkdocs.yml /docs/mkdocs.yml
|
|||||||
# Build the static documentation site
|
# Build the static documentation site
|
||||||
RUN mkdocs build --config-file /docs/mkdocs.yml --site-dir /docs/docs_build
|
RUN mkdocs build --config-file /docs/mkdocs.yml --site-dir /docs/docs_build
|
||||||
|
|
||||||
# ── Stage 3: Runtime image ───────────────────────────────────────────────────
|
# ── Stage 4: Runtime image ───────────────────────────────────────────────────
|
||||||
FROM python:3.14.3-slim
|
FROM python:3.14.3-slim
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -68,6 +81,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
# Copy application code
|
# Copy application code
|
||||||
COPY ./app /app/app
|
COPY ./app /app/app
|
||||||
COPY ./frontend /app/frontend
|
COPY ./frontend /app/frontend
|
||||||
|
# Overlay compiled Tailwind CSS from the frontend build stage
|
||||||
|
COPY --from=frontend-builder /frontend/static/styles.css /app/frontend/static/styles.css
|
||||||
COPY ./migrations /app/migrations
|
COPY ./migrations /app/migrations
|
||||||
COPY ./alembic.ini /app/alembic.ini
|
COPY ./alembic.ini /app/alembic.ini
|
||||||
COPY ./LICENSE /app/LICENSE
|
COPY ./LICENSE /app/LICENSE
|
||||||
|
|||||||
+6
-6
@@ -1,10 +1,10 @@
|
|||||||
DocuElevate Build Information
|
DocuElevate Build Information
|
||||||
==============================
|
==============================
|
||||||
Version: 0.171.2
|
Version: 0.172.1
|
||||||
Build Date: 2026-03-22T17:37:50Z
|
Build Date: 2026-03-22T18:47:07Z
|
||||||
Git Commit: 3e1b35293006b76737c7d2edfc50a14b003ef266
|
Git Commit: 76c0e91500963fac4e8d4a43123340a7cc64731f
|
||||||
Git Short SHA: 3e1b352
|
Git Short SHA: 76c0e91
|
||||||
Git Branch: main
|
Git Branch: main
|
||||||
Commit Date: 2026-03-22T18:37:25+01:00
|
Commit Date: 2026-03-22T19:46:48+01:00
|
||||||
Build Timestamp: 2026-03-22T17:37:50Z
|
Build Timestamp: 2026-03-22T18:47:07Z
|
||||||
==============================
|
==============================
|
||||||
|
|||||||
+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")
|
||||||
|
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()
|
||||||
|
|
||||||
|
|||||||
+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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+57
-12
@@ -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",
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -792,7 +792,7 @@ SECURITY_HEADER_CSP_VALUE="default-src 'self'; script-src 'self'; style-src 'sel
|
|||||||
SECURITY_HEADER_CSP_VALUE="default-src 'self'; script-src 'self' https://cdn.example.com; style-src 'self' 'unsafe-inline';"
|
SECURITY_HEADER_CSP_VALUE="default-src 'self'; script-src 'self' https://cdn.example.com; style-src 'self' 'unsafe-inline';"
|
||||||
```
|
```
|
||||||
|
|
||||||
**Note:** The default policy includes `'unsafe-inline'` for compatibility with Tailwind CSS and inline JavaScript. For stricter security, use nonces or hashes.
|
**Note:** The default policy includes `'unsafe-inline'` for compatibility with inline JavaScript. Tailwind CSS v3 is compiled at build time into a static file served from `'self'`, so no external style CDN is needed.
|
||||||
|
|
||||||
#### X-Frame-Options
|
#### X-Frame-Options
|
||||||
|
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ Recommended headers to configure at the proxy level:
|
|||||||
|
|
||||||
#### Content-Security-Policy Notes
|
#### Content-Security-Policy Notes
|
||||||
|
|
||||||
DocuElevate's frontend uses Tailwind CSS loaded from CDN in development mode. In production, ensure your CSP allows loading scripts and styles from your configured static file origin. A starting point:
|
DocuElevate's frontend uses Tailwind CSS v3 compiled at Docker build time. No external CDN requests are needed for CSS. In production, your CSP does not need to allow any external style sources beyond your own static file origin. A starting point:
|
||||||
|
|
||||||
```
|
```
|
||||||
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;
|
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;
|
||||||
|
|||||||
@@ -0,0 +1,329 @@
|
|||||||
|
/* frontend/input.css
|
||||||
|
* Tailwind CSS v3 source file.
|
||||||
|
* Edit this file (not static/styles.css) — the compiled output is
|
||||||
|
* generated by running: npm run build (inside the frontend/ directory)
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ── Tailwind layers ──────────────────────────────────────────────────────── */
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
/* ── Custom utilities ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/* =============================================================
|
||||||
|
ACCESSIBILITY
|
||||||
|
Skip-to-content link, focus indicators, and screen-reader-only
|
||||||
|
utility class following WCAG 2.1 Level AA requirements.
|
||||||
|
============================================================= */
|
||||||
|
|
||||||
|
/* Skip-to-content link: visible only on keyboard focus */
|
||||||
|
.skip-link {
|
||||||
|
position: absolute;
|
||||||
|
left: -9999px;
|
||||||
|
top: auto;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
z-index: 9999;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
background-color: #1d4ed8;
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 0 0 0.375rem 0;
|
||||||
|
}
|
||||||
|
.skip-link:focus {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
outline: 2px solid #2563eb;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Enhanced focus-visible indicators for keyboard navigation (WCAG 2.4.7) */
|
||||||
|
a:focus-visible,
|
||||||
|
button:focus-visible,
|
||||||
|
input:focus-visible,
|
||||||
|
select:focus-visible,
|
||||||
|
textarea:focus-visible,
|
||||||
|
[tabindex]:focus-visible {
|
||||||
|
outline: 2px solid #2563eb;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Screen-reader-only utility (visually hidden, accessible to AT) */
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
/* Your global overrides can go here if needed */
|
||||||
|
}
|
||||||
|
.material-symbols-light--folder-managed-outline {
|
||||||
|
display: inline-block;
|
||||||
|
width: 96px;
|
||||||
|
height: 96px;
|
||||||
|
--svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23000' d='m17.212 20.404l-.108-.885q-.57-.125-.938-.33q-.368-.204-.7-.577l-.835.334l-.539-.815l.689-.577q-.165-.531-.165-1.035t.165-1.034l-.689-.577l.539-.816l.835.335q.332-.393.7-.588q.369-.195.938-.32l.108-.885h1l.107.885q.57.125.938.32t.7.588l.835-.335l.539.816l-.689.576q.166.531.166 1.035t-.166 1.035l.689.577l-.539.815l-.834-.335q-.333.373-.701.578q-.369.205-.938.33l-.107.885zm.5-1.731q.882 0 1.518-.635q.636-.636.636-1.519t-.636-1.518t-1.518-.636t-1.519.636t-.635 1.518t.635 1.519t1.518.635M4 18V6v4.435V10zm.616 1q-.691 0-1.153-.462T3 17.384V6.616q0-.691.463-1.153T4.615 5h4.981l2 2h7.789q.69 0 1.153.463T21 8.616v2.294q-.238-.152-.479-.265q-.24-.112-.521-.21v-1.82q0-.269-.173-.442T19.385 8h-8.19l-2-2h-4.58q-.269 0-.442.173T4 6.616v10.769q0 .269.173.442t.443.173h6.748q.055.275.131.515t.186.485z'/%3E%3C/svg%3E");
|
||||||
|
background-color: currentColor;
|
||||||
|
-webkit-mask-image: var(--svg);
|
||||||
|
mask-image: var(--svg);
|
||||||
|
-webkit-mask-repeat: no-repeat;
|
||||||
|
mask-repeat: no-repeat;
|
||||||
|
-webkit-mask-size: 100% 100%;
|
||||||
|
mask-size: 100% 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure pagination wraps properly on small screens */
|
||||||
|
.pagination {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.pagination-buttons {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure filter items stack on very small screens */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.filter-group {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.filter-item {
|
||||||
|
min-width: unset;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =============================================================
|
||||||
|
DARK MODE
|
||||||
|
Activated by "dark" class on <html> element.
|
||||||
|
Toggled by the navbar button; preference stored in localStorage.
|
||||||
|
Falls back to the server-side ui_default_color_scheme setting,
|
||||||
|
then to the OS prefers-color-scheme media query.
|
||||||
|
WCAG AA contrast ratios verified for all text/background pairs.
|
||||||
|
============================================================= */
|
||||||
|
|
||||||
|
/* Tell the browser we support both colour schemes */
|
||||||
|
html { color-scheme: light; }
|
||||||
|
html.dark { color-scheme: dark; }
|
||||||
|
|
||||||
|
/* ---- Base / Body ---- */
|
||||||
|
html.dark body { background-color: #111827; color: #e5e7eb; }
|
||||||
|
html.dark .bg-gray-50 { background-color: #111827; }
|
||||||
|
html.dark .bg-white { background-color: #1f2937; }
|
||||||
|
html.dark .bg-gray-100 { background-color: #374151; }
|
||||||
|
html.dark .bg-gray-200 { background-color: #4b5563; }
|
||||||
|
|
||||||
|
/* ---- Text colours ---- */
|
||||||
|
html.dark .text-gray-900 { color: #f9fafb; }
|
||||||
|
html.dark .text-gray-800 { color: #f3f4f6; }
|
||||||
|
html.dark .text-gray-700 { color: #e5e7eb; }
|
||||||
|
html.dark .text-gray-600 { color: #d1d5db; }
|
||||||
|
html.dark .text-gray-500 { color: #9ca3af; }
|
||||||
|
html.dark .text-gray-400 { color: #9ca3af; }
|
||||||
|
html.dark .text-black { color: #f9fafb; }
|
||||||
|
|
||||||
|
/* ---- Borders ---- */
|
||||||
|
html.dark .border-gray-100 { border-color: #374151; }
|
||||||
|
html.dark .border-gray-200 { border-color: #374151; }
|
||||||
|
html.dark .border-gray-300 { border-color: #4b5563; }
|
||||||
|
html.dark .border-gray-400 { border-color: #6b7280; }
|
||||||
|
html.dark .divide-gray-200 > :not([hidden]) ~ :not([hidden]) { border-color: #374151; }
|
||||||
|
html.dark .divide-gray-100 > :not([hidden]) ~ :not([hidden]) { border-color: #374151; }
|
||||||
|
html.dark .divide-y > :not([hidden]) ~ :not([hidden]) { border-color: #374151; }
|
||||||
|
|
||||||
|
/* ---- Hover states ---- */
|
||||||
|
html.dark .hover\:bg-gray-50:hover { background-color: #374151; }
|
||||||
|
html.dark .hover\:bg-gray-100:hover { background-color: #4b5563; }
|
||||||
|
html.dark .hover\:text-gray-900:hover { color: #f9fafb; }
|
||||||
|
html.dark .hover\:text-gray-700:hover { color: #e5e7eb; }
|
||||||
|
|
||||||
|
/* ---- Shadows (softened for dark mode) ---- */
|
||||||
|
html.dark .shadow,
|
||||||
|
html.dark .shadow-md,
|
||||||
|
html.dark .shadow-sm,
|
||||||
|
html.dark .shadow-lg {
|
||||||
|
box-shadow: 0 1px 3px 0 rgba(0,0,0,0.6), 0 1px 2px 0 rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Alert / info-banner backgrounds ---- */
|
||||||
|
html.dark .bg-blue-50 { background-color: #1e3a5f; }
|
||||||
|
html.dark .bg-green-50 { background-color: #052e16; }
|
||||||
|
html.dark .bg-red-50 { background-color: #450a0a; }
|
||||||
|
html.dark .bg-yellow-50 { background-color: #451a03; }
|
||||||
|
html.dark .bg-indigo-50 { background-color: #1e1b4b; }
|
||||||
|
html.dark .bg-orange-50 { background-color: #431407; }
|
||||||
|
|
||||||
|
/* ---- Badge / pill backgrounds ---- */
|
||||||
|
html.dark .bg-blue-100 { background-color: #1e3a5f; }
|
||||||
|
html.dark .bg-green-100 { background-color: #052e16; }
|
||||||
|
html.dark .bg-red-100 { background-color: #450a0a; }
|
||||||
|
html.dark .bg-yellow-100 { background-color: #451a03; }
|
||||||
|
html.dark .bg-indigo-100 { background-color: #431407; }
|
||||||
|
html.dark .bg-orange-100 { background-color: #431407; }
|
||||||
|
html.dark .bg-purple-100 { background-color: #2e1065; }
|
||||||
|
|
||||||
|
/* ---- Status / badge text colours ---- */
|
||||||
|
html.dark .text-blue-700 { color: #93c5fd; }
|
||||||
|
html.dark .text-blue-800 { color: #bfdbfe; }
|
||||||
|
html.dark .text-green-700 { color: #86efac; }
|
||||||
|
html.dark .text-green-800 { color: #bbf7d0; }
|
||||||
|
html.dark .text-red-700 { color: #fca5a5; }
|
||||||
|
html.dark .text-red-800 { color: #fecaca; }
|
||||||
|
html.dark .text-yellow-700 { color: #fcd34d; }
|
||||||
|
html.dark .text-yellow-800 { color: #fde68a; }
|
||||||
|
html.dark .text-indigo-700 { color: #a5b4fc; }
|
||||||
|
html.dark .text-indigo-800 { color: #c7d2fe; }
|
||||||
|
html.dark .text-orange-700 { color: #fdba74; }
|
||||||
|
html.dark .text-orange-800 { color: #fed7aa; }
|
||||||
|
html.dark .text-purple-700 { color: #d8b4fe; }
|
||||||
|
html.dark .text-purple-800 { color: #e9d5ff; }
|
||||||
|
|
||||||
|
/* ---- Dropdown / popup menus ---- */
|
||||||
|
html.dark .bg-white.rounded-md.shadow-lg { background-color: #1f2937; }
|
||||||
|
html.dark .ring-black { --tw-ring-color: rgba(0,0,0,0.5); }
|
||||||
|
|
||||||
|
/* ---- Form inputs / selects / textareas ---- */
|
||||||
|
html.dark input:not([type="checkbox"]):not([type="radio"]):not([type="range"]),
|
||||||
|
html.dark select,
|
||||||
|
html.dark textarea {
|
||||||
|
background-color: #374151;
|
||||||
|
border-color: #4b5563;
|
||||||
|
color: #e5e7eb;
|
||||||
|
}
|
||||||
|
html.dark input::placeholder,
|
||||||
|
html.dark textarea::placeholder {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
html.dark input:focus:not([type="checkbox"]):not([type="radio"]):not([type="range"]),
|
||||||
|
html.dark select:focus,
|
||||||
|
html.dark textarea:focus {
|
||||||
|
border-color: #60a5fa;
|
||||||
|
outline-color: #60a5fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Table rows ---- */
|
||||||
|
html.dark thead,
|
||||||
|
html.dark .bg-gray-50 thead { background-color: #1f2937; }
|
||||||
|
html.dark thead th { color: #9ca3af; }
|
||||||
|
html.dark tbody tr:hover { background-color: #374151; }
|
||||||
|
|
||||||
|
/* ---- Code / pre ---- */
|
||||||
|
html.dark pre,
|
||||||
|
html.dark code { background-color: #111827; color: #d1d5db; }
|
||||||
|
|
||||||
|
/* ---- Dark-mode toggle button icon colour ---- */
|
||||||
|
html.dark #darkModeToggle { color: #fbbf24; }
|
||||||
|
html.dark #darkModeToggle:hover { background-color: #374151; }
|
||||||
|
|
||||||
|
/* ---- Dark-mode skip-link ---- */
|
||||||
|
html.dark .skip-link { background-color: #2563eb; }
|
||||||
|
html.dark .skip-link:focus { outline-color: #60a5fa; }
|
||||||
|
|
||||||
|
/* ---- Dark-mode focus-visible indicators ---- */
|
||||||
|
html.dark a:focus-visible,
|
||||||
|
html.dark button:focus-visible,
|
||||||
|
html.dark input:focus-visible,
|
||||||
|
html.dark select:focus-visible,
|
||||||
|
html.dark textarea:focus-visible,
|
||||||
|
html.dark [tabindex]:focus-visible {
|
||||||
|
outline-color: #60a5fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Scrollbar (WebKit browsers) ---- */
|
||||||
|
html.dark ::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||||
|
html.dark ::-webkit-scrollbar-track { background: #1f2937; }
|
||||||
|
html.dark ::-webkit-scrollbar-thumb { background: #4b5563; border-radius: 4px; }
|
||||||
|
html.dark ::-webkit-scrollbar-thumb:hover { background: #6b7280; }
|
||||||
|
|
||||||
|
/* ---- Settings page: sidebar active state (dark) ---- */
|
||||||
|
html.dark .bg-blue-50 { background-color: #1e3a5f; }
|
||||||
|
|
||||||
|
/* =============================================================
|
||||||
|
DOC-TOGGLE – cross-browser toggle switch
|
||||||
|
Implemented with custom CSS pseudo-elements so the appearance
|
||||||
|
is consistent across all browsers regardless of Tailwind version.
|
||||||
|
Usage:
|
||||||
|
<label class="doc-toggle">
|
||||||
|
<input type="checkbox" class="sr-only" onchange="...">
|
||||||
|
<span class="doc-toggle-track" aria-hidden="true"></span>
|
||||||
|
<span class="ml-3 ...">Label text</span>
|
||||||
|
</label>
|
||||||
|
============================================================= */
|
||||||
|
|
||||||
|
.doc-toggle {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-toggle-track {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
width: 44px;
|
||||||
|
min-width: 44px;
|
||||||
|
height: 24px;
|
||||||
|
background-color: #e5e7eb; /* gray-200 */
|
||||||
|
border-radius: 9999px;
|
||||||
|
transition: background-color 0.2s ease-in-out;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-toggle-track::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
background-color: #ffffff;
|
||||||
|
border: 1px solid #d1d5db; /* gray-300 */
|
||||||
|
border-radius: 9999px;
|
||||||
|
transition: transform 0.2s ease-in-out, border-color 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-toggle input[type="checkbox"]:checked + .doc-toggle-track {
|
||||||
|
background-color: #4f46e5; /* indigo-600 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-toggle input[type="checkbox"]:checked + .doc-toggle-track::after {
|
||||||
|
transform: translateX(20px);
|
||||||
|
border-color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-toggle input[type="checkbox"]:focus-visible + .doc-toggle-track {
|
||||||
|
box-shadow: 0 0 0 2px #ffffff, 0 0 0 4px #6366f1; /* ring-2 ring-indigo-500 with offset */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode overrides */
|
||||||
|
html.dark .doc-toggle-track {
|
||||||
|
background-color: #374151; /* gray-700 */
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .doc-toggle-track::after {
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-color: #4b5563; /* gray-600 */
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .doc-toggle input[type="checkbox"]:checked + .doc-toggle-track {
|
||||||
|
background-color: #4f46e5; /* indigo-600 */
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .doc-toggle input[type="checkbox"]:checked + .doc-toggle-track::after {
|
||||||
|
border-color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .doc-toggle input[type="checkbox"]:focus-visible + .doc-toggle-track {
|
||||||
|
box-shadow: 0 0 0 2px #111827, 0 0 0 4px #6366f1; /* dark background offset */
|
||||||
|
}
|
||||||
Generated
+1017
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "docuelevate-frontend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Frontend asset compilation for DocuElevate",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tailwindcss -i input.css -o static/styles.css --minify",
|
||||||
|
"watch": "tailwindcss -i input.css -o static/styles.css --watch"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"tailwindcss": "^3.4.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-317
File diff suppressed because one or more lines are too long
@@ -0,0 +1,12 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
content: [
|
||||||
|
'./templates/**/*.html',
|
||||||
|
'./static/js/**/*.js',
|
||||||
|
],
|
||||||
|
darkMode: 'class',
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
@@ -21,8 +21,6 @@
|
|||||||
<!-- Alpine.js moved to head for earlier loading -->
|
<!-- Alpine.js moved to head for earlier loading -->
|
||||||
<script src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
|
<script src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
|
||||||
{% block head_css %}
|
{% block head_css %}
|
||||||
<!-- Tailwind CSS and other CSS -->
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
|
|
||||||
<!-- Font Awesome -->
|
<!-- Font Awesome -->
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
|
||||||
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
|
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{{ _("billing.success_page_title") }}</title>
|
<title>{{ _("billing.success_page_title") }}</title>
|
||||||
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
|
<link rel="stylesheet" href="/static/styles.css" />
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
|
||||||
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
|
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
|
||||||
crossorigin="anonymous" referrerpolicy="no-referrer" />
|
crossorigin="anonymous" referrerpolicy="no-referrer" />
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>DocuElevate - Forgot Password</title>
|
<title>DocuElevate - Forgot Password</title>
|
||||||
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
|
<link rel="stylesheet" href="/static/styles.css" />
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
|
||||||
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
|
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
|
||||||
crossorigin="anonymous" referrerpolicy="no-referrer" />
|
crossorigin="anonymous" referrerpolicy="no-referrer" />
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>DocuElevate - Forgot Username</title>
|
<title>DocuElevate - Forgot Username</title>
|
||||||
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
|
<link rel="stylesheet" href="/static/styles.css" />
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
|
||||||
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
|
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
|
||||||
crossorigin="anonymous" referrerpolicy="no-referrer" />
|
crossorigin="anonymous" referrerpolicy="no-referrer" />
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{{ _("app.name") }} - {{ _("auth.login_title") }}</title>
|
<title>{{ _("app.name") }} - {{ _("auth.login_title") }}</title>
|
||||||
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
|
<link rel="stylesheet" href="/static/styles.css" />
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
|
||||||
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
|
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
|
||||||
crossorigin="anonymous" referrerpolicy="no-referrer" />
|
crossorigin="anonymous" referrerpolicy="no-referrer" />
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>DocuElevate - Reset Password</title>
|
<title>DocuElevate - Reset Password</title>
|
||||||
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
|
<link rel="stylesheet" href="/static/styles.css" />
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
|
||||||
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
|
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
|
||||||
crossorigin="anonymous" referrerpolicy="no-referrer" />
|
crossorigin="anonymous" referrerpolicy="no-referrer" />
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<title>Shared Document – DocuElevate</title>
|
<title>Shared Document – DocuElevate</title>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<!-- Tailwind CSS -->
|
<!-- Tailwind CSS v3 (compiled) -->
|
||||||
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet" />
|
<link rel="stylesheet" href="/static/styles.css" />
|
||||||
<!-- Font Awesome -->
|
<!-- Font Awesome -->
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
|
||||||
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
|
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>DocuElevate - Create Account</title>
|
<title>DocuElevate - Create Account</title>
|
||||||
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
|
<link rel="stylesheet" href="/static/styles.css" />
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
|
||||||
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
|
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
|
||||||
crossorigin="anonymous" referrerpolicy="no-referrer" />
|
crossorigin="anonymous" referrerpolicy="no-referrer" />
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{{ _("auth.verify_email_page_title") }}</title>
|
<title>{{ _("auth.verify_email_page_title") }}</title>
|
||||||
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
|
<link rel="stylesheet" href="/static/styles.css" />
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
|
||||||
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
|
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
|
||||||
crossorigin="anonymous" referrerpolicy="no-referrer" />
|
crossorigin="anonymous" referrerpolicy="no-referrer" />
|
||||||
|
|||||||
+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:
|
||||||
|
|||||||
Reference in New Issue
Block a user