- Correctie reset password en confirm email adress by adapting the prefixed_url_for to use config setting

- Adaptation of DPA and T&Cs
- Refer to privacy statement as DPA, not a privacy statement
- Startup of enforcing signed DPA and T&Cs
- Adaptation of eveai_chat_client to ensure we retrieve correct DPA & T&Cs
This commit is contained in:
Josako
2025-10-13 14:28:09 +02:00
parent 83272a4e2a
commit 37819cd7e5
35 changed files with 5350 additions and 241 deletions

View File

@@ -1,4 +1,5 @@
from datetime import date
from enum import Enum
from common.extensions import db
from flask_security import UserMixin, RoleMixin
@@ -121,7 +122,6 @@ class User(db.Model, UserMixin):
def has_roles(self, *args):
return any(role.name in args for role in self.roles)
class TenantDomain(db.Model):
__bind_key__ = 'public'
__table_args__ = {'schema': 'public'}
@@ -311,6 +311,49 @@ class PartnerTenant(db.Model):
updated_by = db.Column(db.Integer, db.ForeignKey('public.user.id'), nullable=True)
class TenantConsent(db.Model):
__bind_key__ = 'public'
__table_args__ = {'schema': 'public'}
id = db.Column(db.Integer, primary_key=True)
tenant_id = db.Column(db.Integer, db.ForeignKey('public.tenant.id'), nullable=False)
partner_id = db.Column(db.Integer, db.ForeignKey('public.partner.id'), nullable=False)
partner_service_id = db.Column(db.Integer, db.ForeignKey('public.partner_service.id'), nullable=False)
user_id = db.Column(db.Integer, db.ForeignKey('public.user.id'), nullable=False)
consent_type = db.Column(db.String(50), nullable=False)
consent_date = db.Column(db.DateTime, nullable=False, server_default=db.func.now())
consent_version = db.Column(db.String(20), nullable=False, default="1.0.0")
consent_data = db.Column(db.JSON, nullable=False)
# Tracking
created_at = db.Column(db.DateTime, nullable=False, server_default=db.func.now())
created_by = db.Column(db.Integer, db.ForeignKey('public.user.id'), nullable=True)
updated_at = db.Column(db.DateTime, nullable=False, server_default=db.func.now(), onupdate=db.func.now())
updated_by = db.Column(db.Integer, db.ForeignKey('public.user.id'), nullable=True)
class ConsentVersion(db.Model):
__bind_key__ = 'public'
__table_args__ = {'schema': 'public'}
id = db.Column(db.Integer, primary_key=True)
consent_type = db.Column(db.String(50), nullable=False)
consent_version = db.Column(db.String(20), nullable=False)
consent_valid_from = db.Column(db.DateTime, nullable=False, server_default=db.func.now())
consent_valid_to = db.Column(db.DateTime, nullable=True)
# Tracking
created_at = db.Column(db.DateTime, nullable=False, server_default=db.func.now())
created_by = db.Column(db.Integer, db.ForeignKey('public.user.id'), nullable=True)
updated_at = db.Column(db.DateTime, nullable=False, server_default=db.func.now(), onupdate=db.func.now())
updated_by = db.Column(db.Integer, db.ForeignKey('public.user.id'), nullable=True)
class ConsentStatus(Enum):
CONSENTED = 'CONSENTED'
NOT_CONSENTED = 'NOT_CONSENTED'
RENEWAL_REQUIRED = 'RENEWAL_REQUIRED'
CONSENT_EXPIRED = 'CONSENT_EXPIRED'
UNKNOWN_CONSENT_VERSION = 'UNKNOWN_CONSENT_VERSION'
class SpecialistMagicLinkTenant(db.Model):
__bind_key__ = 'public'
__table_args__ = {'schema': 'public'}

View File

@@ -1,10 +1,12 @@
from typing import Dict, List
from flask import session, current_app
from sqlalchemy import desc
from sqlalchemy.exc import SQLAlchemyError
from common.extensions import db, cache_manager
from common.models.user import Partner, PartnerTenant, PartnerService, Tenant
from common.models.user import Partner, PartnerTenant, PartnerService, Tenant, TenantConsent, ConsentStatus, \
ConsentVersion
from common.utils.eveai_exceptions import EveAINoManagementPartnerService
from common.utils.model_logging_utils import set_logging_information
from datetime import datetime as dt, timezone as tz
@@ -172,4 +174,30 @@ class TenantServices:
except Exception as e:
current_app.logger.error(f"Error checking specialist type access: {str(e)}")
return False
return False
@staticmethod
def get_consent_status(tenant_id: int) -> ConsentStatus:
cts = current_app.config.get("CONSENT_TYPES")
status = ConsentStatus.CONSENTED
for ct in cts:
consent = (TenantConsent.query.filter_by(tenant_id=tenant_id, consent_type=ct)
.order_by(desc(TenantConsent.id))
.first())
if not consent:
status = ConsentStatus.NOT_CONSENTED
break
cv = ConsentVersion.query.filter_by(consent_type=ct, consent_version=consent.consent_version).first()
if not cv:
current_app.logger.error(f"Consent version {consent.consent_version} not found checking tenant {tenant_id}")
status = ConsentStatus.UNKNOWN_CONSENT_VERSION
break
if cv.consent_valid_to:
if cv.consent_valid_to.date() >= dt.now(tz.utc).date():
status = ConsentStatus.RENEWAL_REQUIRED
break
else:
status = ConsentStatus.NOT_CONSENTED
break
return status

View File

@@ -1,14 +1,39 @@
from flask import request, url_for
from flask import request, url_for, current_app
from urllib.parse import urlsplit, urlunsplit
import re
VISIBLE_PREFIXES = ('/admin', '/api', '/chat-client')
def _normalize_prefix(raw_prefix: str) -> str:
"""Normalize config prefix to internal form '/admin' or '' if not set."""
if not raw_prefix:
return ''
s = str(raw_prefix).strip()
if not s:
return ''
# remove leading/trailing slashes, then add single leading slash
s = s.strip('/')
if not s:
return ''
return f"/{s}"
def _get_config_prefix() -> str:
"""Return normalized prefix from config EVEAI_APP_PREFIX (config-first)."""
try:
cfg_val = (current_app.config.get('EVEAI_APP_PREFIX') if current_app else None)
return _normalize_prefix(cfg_val)
except Exception:
return ''
def _derive_visible_prefix():
# 1) Edge-provided header (beste en meest expliciete bron)
xfp = request.headers.get('X-Forwarded-Prefix')
if xfp and any(xfp.startswith(p) for p in VISIBLE_PREFIXES):
return xfp.rstrip('/')
current_app.logger.debug(f"X-Forwarded-Prefix: {xfp}")
if xfp and any(str(xfp).startswith(p) for p in VISIBLE_PREFIXES):
return str(xfp).rstrip('/')
# 2) Referer fallback: haal het top-level segment uit de Referer path
ref = request.headers.get('Referer') or ''
@@ -24,13 +49,31 @@ def _derive_visible_prefix():
return ''
def _visible_prefix_for_runtime() -> str:
"""Decide which prefix to use at runtime.
Priority: config EVEAI_APP_PREFIX; optional dynamic fallback if enabled.
"""
cfg_prefix = _get_config_prefix()
if cfg_prefix:
current_app.logger.debug(f"prefixed_url_for: using config prefix: {cfg_prefix}")
return cfg_prefix
# Optional dynamic fallback
use_fallback = bool(current_app.config.get('EVEAI_USE_DYNAMIC_PREFIX_FALLBACK', False)) if current_app else False
if use_fallback:
dyn = _derive_visible_prefix()
current_app.logger.debug(f"prefixed_url_for: using dynamic fallback prefix: {dyn}")
return dyn
current_app.logger.debug("prefixed_url_for: no prefix configured, no fallback enabled")
return ''
def prefixed_url_for(endpoint, **values):
"""
Gedrag:
- Default (_external=False, for_redirect=False): retourneer relatief pad (zonder leading '/')
voor templates/JS. De dynamische <base> zorgt voor correcte resolutie onder het zichtbare prefix.
- _external=True: bouw absolute URL (schema/host). Als X-Forwarded-Prefix aanwezig is,
prefixeer de path daarmee (handig voor e-mails/deeplinks).
- _external=True: bouw absolute URL (schema/host). Pad wordt geprefixt met config prefix (indien gezet),
of optioneel met dynamische fallback wanneer geactiveerd.
- for_redirect=True: geef root-absoluut pad inclusief zichtbaar top-prefix, geschikt
voor HTTP Location headers. Backwards compat: _as_location=True wordt behandeld als for_redirect.
"""
@@ -46,16 +89,20 @@ def prefixed_url_for(endpoint, **values):
if external:
scheme = request.headers.get('X-Forwarded-Proto', request.scheme)
host = request.headers.get('Host', request.host)
xfp = request.headers.get('X-Forwarded-Prefix', '') or ''
new_path = (xfp.rstrip('/') + path) if (xfp and not path.startswith(xfp)) else path
visible_prefix = _visible_prefix_for_runtime()
new_path = (visible_prefix.rstrip('/') + path) if (visible_prefix and not path.startswith(visible_prefix)) else path
current_app.logger.debug(f"prefixed_url_for external: {scheme}://{host}{new_path}")
return urlunsplit((scheme, host, new_path, query, fragment))
if for_redirect:
visible_prefix = _derive_visible_prefix()
visible_prefix = _visible_prefix_for_runtime()
if visible_prefix and not path.startswith(visible_prefix):
return f"{visible_prefix}{path}"
# root-absoluut pad, zonder prefix als onbekend
composed = f"{visible_prefix}{path}"
current_app.logger.debug(f"prefixed_url_for redirect: {composed}")
return composed
current_app.logger.debug(f"prefixed_url_for redirect (no prefix): {path}")
return path
# Default: relatief pad
return path[1:] if path.startswith('/') else path
# Default: relatief pad (zonder leading '/')
rel = path[1:] if path.startswith('/') else path
return rel

View File

@@ -36,7 +36,7 @@ def send_confirmation_email(user):
try:
send_email(user.email, f"{user.first_name} {user.last_name}", "Confirm your email", html)
current_app.logger.info(f'Confirmation email sent to {user.email}')
current_app.logger.info(f'Confirmation email sent to {user.email} with url: {confirm_url}')
except Exception as e:
current_app.logger.error(f'Failed to send confirmation email to {user.email}. Error: {str(e)}')
raise
@@ -51,7 +51,7 @@ def send_reset_email(user):
try:
send_email(user.email, f"{user.first_name} {user.last_name}", subject, html)
current_app.logger.info(f'Reset email sent to {user.email}')
current_app.logger.info(f'Reset email sent to {user.email} with url: {reset_url}')
except Exception as e:
current_app.logger.error(f'Failed to send reset email to {user.email}. Error: {str(e)}')
raise