- Consent giving UI introduced

- Possibility to view the document version the consent is given to
- Blocking functionality is no valid consent
This commit is contained in:
Josako
2025-10-15 18:35:28 +02:00
parent 3ea3a06de6
commit eeb76d57b7
22 changed files with 803 additions and 126 deletions

View File

@@ -0,0 +1,30 @@
/* Consent Viewer specific styles */
/* Ensure markdown text aligns left by default */
#consent-document-viewer .markdown-body,
.markdown-body {
text-align: left;
}
/* Make the viewer pane scrollable with a responsive max height */
#consent-document-viewer {
max-height: 60vh;
overflow: auto;
}
/* Optional readability improvements */
#consent-document-viewer .markdown-body {
line-height: 1.6;
}
#consent-document-viewer .markdown-body pre,
#consent-document-viewer .markdown-body code {
white-space: pre-wrap;
}
/* Optional: keep the viewer header visible while scrolling */
#consent-viewer-section .card-header {
position: sticky;
top: 0;
z-index: 1;
background: #fff;
}

View File

@@ -0,0 +1,53 @@
// Centralized consent viewer JS
// Attaches a single delegated click handler to load consent markdown fragments
(function() {
function initConsentViewer() {
const viewerSection = document.getElementById('consent-viewer-section');
const viewer = document.getElementById('consent-document-viewer');
const vType = document.getElementById('viewer-type');
const vVer = document.getElementById('viewer-version');
const loading = document.getElementById('viewer-loading');
if (!viewerSection || !viewer) {
// Page without consent viewer; do nothing
return;
}
document.addEventListener('click', async function(e) {
const btn = e.target.closest('.btn-view-consent');
if (!btn) return;
e.preventDefault();
const url = btn.getAttribute('data-url');
const type = btn.getAttribute('data-consent-type') || '';
const version = btn.getAttribute('data-version') || '';
if (!url) {
console.warn('Consent viewer: data-url missing on button');
return;
}
try {
viewerSection.style.display = 'block';
if (vType) vType.textContent = type;
if (vVer) vVer.textContent = version;
if (loading) loading.style.display = 'block';
viewer.innerHTML = '';
const resp = await fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest' } });
const html = await resp.text();
viewer.innerHTML = html;
} catch (e) {
viewer.innerHTML = '<div class="alert alert-danger">Failed to load document.</div>';
} finally {
if (loading) loading.style.display = 'none';
viewerSection.scrollIntoView({ behavior: 'smooth' });
}
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initConsentViewer);
} else {
initConsentViewer();
}
})();

View File

@@ -14,6 +14,8 @@
<!-- Gebundelde CSS (bevat nu al je CSS) -->
<link href="{{ asset_url('dist/main.css') }}" rel="stylesheet" />
<!-- Consent viewer specific styles -->
<link href="{{ prefixed_url_for('static', filename='assets/css/eveai-consent-viewer.css') }}" rel="stylesheet" />
<base href="/admin/">
</head>

View File

@@ -73,6 +73,9 @@
{'name': 'Consent Versions', 'url': 'user/consent_versions', 'roles': ['Super User']},
{'name': 'Tenant Overview', 'url': 'user/tenant_overview', 'roles': ['Super User', 'Partner Admin', 'Tenant Admin']},
{'name': 'Edit Tenant', 'url': 'user/tenant/' ~ session['tenant'].get('id'), 'roles': ['Super User', 'Partner Admin', 'Tenant Admin']},
{'name': 'Tenant Consents', 'url': 'user/consent/tenant', 'roles': ['Tenant Admin']},
{'name': 'Consent Renewal', 'url': 'user/consent/tenant_renewal', 'roles': ['Super User', 'Partner Admin', 'Tenant Admin']},
{'name': 'Consents Overview', 'url': 'user/tenants/' ~ session['tenant'].get('id') ~ '/consents', 'roles': ['Super User', 'Partner Admin']},
{'name': 'Tenant Partner Services', 'url': 'user/tenant_partner_services', 'roles': ['Super User', 'Partner Admin', 'Tenant Admin']},
{'name': 'Tenant Makes', 'url': 'user/tenant_makes', 'roles': ['Super User', 'Partner Admin', 'Tenant Admin']},
{'name': 'Tenant Projects', 'url': 'user/tenant_projects', 'roles': ['Super User', 'Partner Admin', 'Tenant Admin']},

View File

@@ -0,0 +1,13 @@
{% extends 'base.html' %}
{% block title %}Consent Renewal{% endblock %}
{% block content_title %}Consent Renewal{% endblock %}
{% block content_description %}Consent renewal process{% endblock %}
{% block content %}
<div class="container">
<div class="alert alert-info">This page will guide you through the consent renewal process.</div>
<p>Please navigate to the Tenant Consents page to renew the required consents.</p>
<a href="{{ prefixed_url_for('user_bp.tenant_consent') }}" class="btn btn-primary">Go to Tenant Consents</a>
</div>
{% endblock %}

View File

@@ -0,0 +1,15 @@
{% extends 'base.html' %}
{% block title %}Consent Required{% endblock %}
{% block content_title %}Consent Required{% endblock %}
{% block content_description %}Access is restricted until required consents are provided{% endblock %}
{% block content %}
<div class="container">
<div class="alert alert-danger">You currently do not have access because required consents are missing or expired.</div>
<p>Please contact your Tenant Admin or Management Partner to review and accept the latest Data Privacy Agreement and Terms & Conditions.</p>
{% if current_user.has_roles('Tenant Admin', 'Partner Admin', 'Super User') %}
<a href="{{ prefixed_url_for('user_bp.tenant_consent') }}" class="btn btn-primary">Go to Tenant Consents</a>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,4 @@
{# HTML fragment to render consent markdown content #}
<div class="markdown-body">
{{ markdown_content | markdown }}
</div>

View File

@@ -0,0 +1,64 @@
{% extends 'base.html' %}
{% from "macros.html" import debug_to_console %}
{% block title %}Tenant Consents{% endblock %}
{% block content_title %}Tenant Consents{% endblock %}
{% block content_description %}Please consent if required before continuing{% endblock %}
{% block content %}
<div class="container">
{% if statuses %}
<div class="row">
{% for s in statuses %}
<div class="col-12 col-md-6 mb-4">
<div class="card h-100">
<div class="card-body">
<h5 class="card-title">{{ s.consent_type }}</h5>
<p class="card-text">
Status: <span class="badge {% if s.status == 'CONSENTED' %}bg-success{% elif s.status == 'RENEWAL_REQUIRED' %}bg-warning text-dark{% else %}bg-danger{% endif %}">{{ s.status }}</span>
</p>
<p class="card-text small text-muted">Active version: {{ s.active_version or 'n/a' }}, Last accepted: {{ s.last_version or 'n/a' }}</p>
<div class="d-flex gap-2">
<button type="button" class="btn btn-outline-secondary btn-view-consent"
data-consent-type="{{ s.consent_type }}" data-version="{{ s.active_version }}"
data-url="{{ prefixed_url_for('user_bp.view_consent_markdown', consent_type=s.consent_type, version=s.active_version) }}"
{% if not s.active_version %}disabled{% endif %}>
Bekijk document
</button>
{% if s.status != 'CONSENTED' %}
<form method="post" action="{{ prefixed_url_for('user_bp.accept_tenant_consent', tenant_id=tenant_id, consent_type=s.consent_type) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-primary">I agree</button>
</form>
{% else %}
<span class="text-success align-self-center">Up to date</span>
{% endif %}
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="alert alert-info">No consent status information available.</div>
{% endif %}
<!-- Consent document viewer moved into main content -->
<div class="container mt-4" id="consent-viewer-section" style="display:none;">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<div>
<strong>Document viewer:</strong>
<span id="viewer-type"></span>
<span class="text-muted">version</span>
<span id="viewer-version"></span>
</div>
<div id="viewer-loading" class="text-muted" style="display:none;">Loading...</div>
</div>
<div class="card-body">
<div id="consent-document-viewer" class="markdown-body"></div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,60 @@
{% extends 'base.html' %}
{% block title %}Consent Renewal{% endblock %}
{% block content_title %}Consent Renewal{% endblock %}
{% block content_description %}Renew consents that require attention{% endblock %}
{% block content %}
<div class="container">
{% if statuses and statuses|length > 0 %}
<div class="alert alert-warning">Some consents need renewal. Please review and accept the latest version.</div>
<div class="row">
{% for s in statuses %}
<div class="col-12 col-md-6 mb-4">
<div class="card h-100">
<div class="card-body">
<h5 class="card-title">{{ s.consent_type }}</h5>
<p class="card-text">
Status: <span class="badge {% if s.status == 'RENEWAL_REQUIRED' %}bg-warning text-dark{% else %}bg-danger{% endif %}">{{ s.status }}</span>
</p>
<p class="card-text small text-muted">Active version: {{ s.active_version or 'n/a' }}, Last accepted: {{ s.last_version or 'n/a' }}</p>
<div class="d-flex gap-2">
<button type="button" class="btn btn-outline-secondary btn-view-consent"
data-consent-type="{{ s.consent_type }}" data-version="{{ s.active_version }}"
data-url="{{ prefixed_url_for('user_bp.view_consent_markdown', consent_type=s.consent_type, version=s.active_version) }}"
{% if not s.active_version %}disabled{% endif %}>
Bekijk document
</button>
<form method="post" action="{{ prefixed_url_for('user_bp.accept_tenant_consent', tenant_id=tenant_id, consent_type=s.consent_type) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-primary">Renew and accept</button>
</form>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="alert alert-success">All consents are up to date.</div>
{% endif %}
<!-- Consent document viewer moved into main content -->
<div class="container mt-4" id="consent-viewer-section" style="display:none;">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<div>
<strong>Document viewer:</strong>
<span id="viewer-type"></span>
<span class="text-muted">version</span>
<span id="viewer-version"></span>
</div>
<div id="viewer-loading" class="text-muted" style="display:none;">Loading...</div>
</div>
<div class="card-body">
<div id="consent-document-viewer" class="markdown-body"></div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,59 @@
{% extends 'base.html' %}
{% block title %}Tenant Consents Overview{% endblock %}
{% block content_title %}Tenant Consents Overview{% endblock %}
{% block content_description %}Manage consents for this tenant{% endblock %}
{% block content %}
<div class="container">
<div class="row">
{% for s in statuses %}
<div class="col-12 col-md-6 mb-4">
<div class="card h-100">
<div class="card-body">
<h5 class="card-title">{{ s.consent_type }}</h5>
<p class="card-text">
Status: <span class="badge {% if s.status == 'CONSENTED' %}bg-success{% elif s.status == 'RENEWAL_REQUIRED' %}bg-warning text-dark{% else %}bg-danger{% endif %}">{{ s.status }}</span>
</p>
<p class="card-text small text-muted">Active version: {{ s.active_version or 'n/a' }}, Last accepted: {{ s.last_version or 'n/a' }}</p>
<div class="d-flex gap-2">
<button type="button" class="btn btn-outline-secondary btn-view-consent"
data-consent-type="{{ s.consent_type }}" data-version="{{ s.active_version }}"
data-url="{{ prefixed_url_for('user_bp.view_consent_markdown', consent_type=s.consent_type, version=s.active_version) }}"
{% if not s.active_version %}disabled{% endif %}>
Bekijk document
</button>
{% if s.status != 'CONSENTED' %}
<form method="post" action="{{ prefixed_url_for('user_bp.accept_tenant_consent', tenant_id=tenant_id, consent_type=s.consent_type) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-primary">Accept on behalf</button>
</form>
{% else %}
<span class="text-success align-self-center">Up to date</span>
{% endif %}
</div>
</div>
</div>
</div>
{% endfor %}
</div>
<!-- Consent document viewer moved into main content -->
<div class="container mt-4" id="consent-viewer-section" style="display:none;">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<div>
<strong>Document viewer:</strong>
<span id="viewer-type"></span>
<span class="text-muted">version</span>
<span id="viewer-version"></span>
</div>
<div id="viewer-loading" class="text-muted" style="display:none;">Loading...</div>
</div>
<div class="card-body">
<div id="consent-document-viewer" class="markdown-body"></div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -135,14 +135,14 @@ def view_content(content_type):
titles = {
'changelog': 'Release Notes',
'terms': 'Terms & Conditions',
'dpadpa': 'Data Privacy Agreement',
'dpa': 'Data Privacy Agreement',
# Voeg andere types toe indien nodig
}
descriptions = {
'changelog': 'EveAI Release Notes',
'terms': "Terms & Conditions for using AskEveAI's Evie",
'dpadpa': "Data Privacy Agreement for AskEveAI's Evie",
'dpa': "Data Privacy Agreement for AskEveAI's Evie",
# Voeg andere types toe indien nodig
}

View File

@@ -231,6 +231,3 @@ class EditConsentVersionForm(FlaskForm):
consent_valid_from = DateField('Consent Valid From', id='form-control datepicker', validators=[DataRequired()])
consent_valid_to = DateField('Consent Valid To', id='form-control datepicker', validators=[Optional()])
class TenantConsentForm(FlaskForm):

View File

@@ -10,7 +10,7 @@ from wtforms import BooleanField
from common.models.user import User, Tenant, Role, TenantDomain, TenantProject, PartnerTenant, TenantMake, \
ConsentVersion
from common.extensions import db, security, minio_client, simple_encryption, cache_manager
from common.extensions import db, security, minio_client, simple_encryption, cache_manager, content_manager
from common.utils.dynamic_field_utils import create_default_config_from_type_config
from common.utils.security_utils import send_confirmation_email, send_reset_email
from config.type_defs.service_types import SERVICE_TYPES
@@ -31,36 +31,12 @@ from eveai_app.views.list_views.user_list_views import get_tenants_list_view, ge
get_tenant_domains_list_view, get_tenant_projects_list_view, get_tenant_makes_list_view, \
get_tenant_partner_services_list_view, get_consent_versions_list_view
from eveai_app.views.list_views.list_view_utils import render_list_view
from common.services.user.consent_service import ConsentService
from common.models.user import ConsentStatus
user_bp = Blueprint('user_bp', __name__, url_prefix='/user')
# --- Consent flow placeholder views ---
@user_bp.route('/consent/tenant', methods=['GET'])
@roles_accepted('Super User', 'Partner Admin', 'Tenant Admin')
def tenant_consent():
# Placeholder view; UI can be implemented in templates
return render_template('user/tenant_consent.html') if current_app.jinja_env.loader else "Tenant Consent"
@user_bp.route('/consent/no_access', methods=['GET'])
@roles_accepted('Super User', 'Partner Admin', 'Tenant Admin')
def no_consent():
return render_template('user/no_consent.html') if current_app.jinja_env.loader else "Consent required - contact your admin"
@user_bp.route('/consent/tenant_renewal', methods=['GET'])
@roles_accepted('Super User', 'Partner Admin', 'Tenant Admin')
def tenant_consent_renewal():
return render_template('user/tenant_consent_renewal.html') if current_app.jinja_env.loader else "Tenant Consent Renewal"
@user_bp.route('/consent/renewal', methods=['GET'])
@roles_accepted('Super User', 'Partner Admin', 'Tenant Admin')
def consent_renewal():
return render_template('user/consent_renewal.html') if current_app.jinja_env.loader else "Consent renewal in progress"
@user_bp.before_request
def log_before_request():
current_app.logger.debug(f'Before request: {request.path} =====================================')
@@ -723,6 +699,7 @@ def tenant_partner_services():
return render_list_view('list_view.html', **config)
# Consent Version Management ----------------------------------------------------------------------
@user_bp.route('/consent_versions', methods=['GET', 'POST'])
@roles_accepted('Super User')
def consent_versions():
@@ -805,13 +782,138 @@ def edit_consent_version(consent_version_id):
return render_template('user/edit_consent_version.html', form=form, consent_version_id=consent_version_id)
@user_bp.route('/tenant_consent', methods=['GET', 'POST'])
# Tenant Consent Management -----------------------------------------------------------------------
@user_bp.route('/consent/tenant', methods=['GET'])
@roles_accepted('Tenant Admin')
def tenant_consent():
dpa_consent = BooleanField("DPA Consent", default=False)
t_c_consent = BooleanField("T&C Consent", default=False)
# Overview for current session tenant
tenant_id = session.get('tenant', {}).get('id') or getattr(current_user, 'tenant_id', None)
if not tenant_id:
flash('No tenant context.', 'danger')
return redirect(prefixed_url_for('user_bp.tenants', for_redirect=True))
types = ConsentService.get_required_consent_types()
statuses = [ConsentService.evaluate_type_status(tenant_id, t) for t in types]
if current_app.jinja_env.loader:
return render_template('user/tenant_consent.html', statuses=statuses, tenant_id=tenant_id)
# Fallback text if no templates
lines = [f"{s.consent_type}: {s.status} (active={s.active_version}, last={s.last_version})" for s in statuses]
return "\n".join(lines)
@user_bp.route('/consent/no_access', methods=['GET'])
def no_consent():
return render_template('user/no_consent.html') if current_app.jinja_env.loader else "Consent required - contact your admin"
@user_bp.route('/consent/tenant_renewal', methods=['GET'])
@roles_accepted('Super User', 'Partner Admin', 'Tenant Admin')
def tenant_consent_renewal():
# Show renewal statuses only
tenant_id = session.get('tenant', {}).get('id') or getattr(current_user, 'tenant_id', None)
types = ConsentService.get_required_consent_types()
statuses = [s for s in [ConsentService.evaluate_type_status(tenant_id, t) for t in types] if s.status != ConsentStatus.CONSENTED]
if current_app.jinja_env.loader:
return render_template('user/tenant_consent_renewal.html', statuses=statuses, tenant_id=tenant_id)
return "\n".join([f"{s.consent_type}: {s.status}" for s in statuses])
@user_bp.route('/consent/renewal', methods=['GET'])
@roles_accepted('Super User', 'Partner Admin', 'Tenant Admin')
def consent_renewal():
return render_template('user/consent_renewal.html') if current_app.jinja_env.loader else "Consent renewal in progress"
@user_bp.route('/tenants/<int:tenant_id>/consents', methods=['GET'])
@roles_accepted('Super User', 'Partner Admin')
def view_tenant_consents(tenant_id: int):
# Authorization: Tenant Admin for own tenant or Management Partner
allowed, mode, _, _ = ConsentService.can_consent_on_behalf(tenant_id)
if not (allowed or current_user.has_roles('Super User')):
flash('Not authorized to view consents for this tenant', 'danger')
return redirect(prefixed_url_for('user_bp.tenants', for_redirect=True))
types = ConsentService.get_required_consent_types()
statuses = [ConsentService.evaluate_type_status(tenant_id, t) for t in types]
if current_app.jinja_env.loader:
return render_template('user/tenant_consents_overview.html', statuses=statuses, tenant_id=tenant_id)
return "\n".join([f"{s.consent_type}: {s.status}" for s in statuses])
@user_bp.route('/tenants/<int:tenant_id>/consents/<string:consent_type>/accept', methods=['POST'])
@roles_accepted('Super User', 'Partner Admin', 'Tenant Admin')
def accept_tenant_consent(tenant_id: int, consent_type: str):
try:
tc = ConsentService.record_consent(tenant_id, consent_type)
flash(f"Consent for {consent_type} recorded (version {tc.consent_version})", 'success')
except PermissionError:
flash('Not authorized to accept this consent for the tenant', 'danger')
except Exception as e:
current_app.logger.error(f"Failed to record consent: {e}")
flash('Failed to record consent', 'danger')
if current_user.has_roles('Tenant Admin'):
return redirect(prefixed_url_for('user_bp.tenant_consent', for_redirect=True))
else:
return redirect(prefixed_url_for('user_bp.view_tenant_consents', tenant_id=tenant_id, for_redirect=True))
@user_bp.route('/consents/<path:consent_type>/<string:version>/view', methods=['GET'])
@roles_accepted('Super User', 'Partner Admin', 'Tenant Admin')
def view_consent_markdown(consent_type: str, version: str):
"""Render the consent markdown for a given type and version as an HTML fragment, using content_manager."""
try:
current_app.logger.debug(f"Rendering markdown for {consent_type} version {version}")
# Validate type against config
valid_types = set(ConsentService.get_required_consent_types())
if consent_type not in valid_types:
for t in valid_types:
if t.lower() == consent_type.lower():
consent_type = t
break
if consent_type not in valid_types:
current_app.logger.warning(f"Unknown consent type requested for view: {consent_type}")
return (render_template('user/partials/consent_markdown_fragment.html', markdown_content=f"Unknown consent type: {consent_type}"), 404)
# Version must exist in ConsentVersion for the type
cv = ConsentVersion.query.filter_by(consent_type=consent_type, consent_version=version).first()
if not cv:
current_app.logger.warning(f"Unknown consent version requested: type={consent_type}, version={version}")
return (render_template('user/partials/consent_markdown_fragment.html', markdown_content=f"Document not found for version {version}"), 404)
# Map consent type to content_manager content_type
type_map = {
'Data Privacy Agreement': 'dpa',
'Terms & Conditions': 'terms',
}
content_type = type_map.get(consent_type)
if not content_type:
current_app.logger.warning(f"No content_type mapping for consent type {consent_type}")
return (render_template('user/partials/consent_markdown_fragment.html', markdown_content=f"Unknown content mapping for {consent_type}"), 404)
# Parse major.minor and patch from version (e.g., 1.2.3 -> 1.2 and 1.2.3)
parts = version.split('.')
current_app.logger.debug(f"Parts of version {version}: {parts}")
if len(parts) < 3:
current_app.logger.warning(f"Version does not follow a.b.c pattern: {version}")
major_minor = '.'.join(parts[:2]) if len(parts) >= 2 else version
patch = ''
else:
major_minor = '.'.join(parts[:2])
patch = parts[2]
current_app.logger.debug(f"Retrieving markdown for {consent_type}/{content_type} for major.minor={major_minor} and patch={patch} ")
# Use content_manager to read content
content_data = content_manager.read_content(content_type, major_minor, patch)
if not content_data or not content_data.get('content'):
markdown_content = f"# Document not found\nThe consent document for {consent_type} version {version} could not be located."
status = 404
else:
markdown_content = content_data['content']
status = 200
return render_template('user/partials/consent_markdown_fragment.html', markdown_content=markdown_content), status
except Exception as e:
current_app.logger.error(f"Error in view_consent_markdown: {e}")
return (render_template('user/partials/consent_markdown_fragment.html', markdown_content="Unexpected error rendering document."), 500)
def reset_uniquifier(user):