Files
Assetmanager/app/templates/settings_privacy.html
T

375 lines
23 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "base.html" %}
{% block content %}
<div class="toolbar privacy-toolbar">
<div>
<h1>{{ t('privacy.title') }}</h1>
<p class="muted">{{ t('privacy.intro') }}</p>
</div>
<div class="toolbar-actions">
<a class="button button-secondary" href="/settings/privacy/export.txt">{{ t('privacy.export_text') }}</a>
<a class="button button-secondary" href="/settings/privacy/export.html">{{ t('privacy.export_html') }}</a>
<a class="button" href="/settings/privacy/export.xlsx">{{ t('privacy.export_excel') }}</a>
</div>
</div>
<div class="notice warning privacy-phase-notice">
<strong>{{ t('privacy.phase2_title') }}</strong>
{{ t('privacy.phase2_help') }}
</div>
{% if not can_edit %}<div class="notice info"><strong>{{ t('privacy.read_only_title') }}</strong> {{ t('privacy.read_only_help') }}</div>{% endif %}
<form method="post" action="/settings/privacy" class="settings-form privacy-settings-form" data-privacy-readonly="{{ 'false' if can_edit else 'true' }}">
{% set activity = privacy_settings.processing_activity %}
<section class="settings-section">
<h2>{{ t('privacy.processing_activity') }}</h2>
<div class="form-grid two-columns">
<label>{{ t('privacy.activity_title') }}<input type="text" name="title" value="{{ activity.title }}" required></label>
<label>{{ t('privacy.document_version') }}<input type="text" name="document_version" value="{{ activity.document_version }}"></label>
<label>{{ t('privacy.controller') }}<input type="text" name="controller" value="{{ activity.controller }}"></label>
<label>{{ t('privacy.responsible_department') }}<input type="text" name="responsible_department" value="{{ activity.responsible_department }}"></label>
<label>{{ t('privacy.responsible_people') }}<input type="text" name="responsible_people" value="{{ activity.responsible_people }}"></label>
<label>{{ t('privacy.data_protection_contact') }}<input type="text" name="data_protection_contact" value="{{ activity.data_protection_contact }}"></label>
</div>
<label>{{ t('privacy.legal_basis') }}<textarea name="legal_basis" rows="3">{{ activity.legal_basis }}</textarea></label>
<label>{{ t('privacy.data_subject_groups') }}<textarea name="data_subject_groups" rows="3">{{ activity.data_subject_groups }}</textarea></label>
</section>
<section class="settings-section">
<div class="settings-section-heading">
<div><h2>{{ t('privacy.purposes') }}</h2><p class="muted">{{ t('privacy.purposes_help') }}</p></div>
<button type="button" class="button-secondary" data-add-list-row="purposes-list">{{ t('common.add', 'Add') }}</button>
</div>
<div id="purposes-list" class="repeatable-list">
{% for value in activity.purposes %}
<div class="repeatable-row"><input type="text" name="purposes" value="{{ value }}"><button type="button" class="button-danger button-small" data-remove-row>×</button></div>
{% endfor %}
</div>
</section>
<section class="settings-section">
<h2>{{ t('privacy.recipients_transfers') }}</h2>
<div class="form-grid two-columns">
<label>{{ t('privacy.internal_recipients') }}<textarea name="internal_recipients" rows="3">{{ activity.internal_recipients }}</textarea></label>
<label>{{ t('privacy.external_recipients') }}<textarea name="external_recipients" rows="3">{{ activity.external_recipients }}</textarea></label>
</div>
<label class="checkbox-label"><input class="checkbox" type="checkbox" name="third_country_transfer" {% if activity.third_country_transfer %}checked{% endif %}> {{ t('privacy.third_country_transfer') }}</label>
<label>{{ t('privacy.third_country_details') }}<textarea name="third_country_details" rows="3">{{ activity.third_country_details }}</textarea></label>
</section>
<section class="settings-section">
<h2>{{ t('privacy.systems_backups') }}</h2>
<label>{{ t('privacy.systems') }}<textarea name="systems" rows="3">{{ activity.systems }}</textarea></label>
<div class="form-grid two-columns">
<label>{{ t('privacy.backup_system') }}<input type="text" name="backup_system" value="{{ activity.backup_system }}"></label>
<label>{{ t('privacy.backup_retention_days') }}<input type="number" min="0" max="36500" name="backup_retention_days" value="{{ activity.backup_retention_days if activity.backup_retention_days is not none else '' }}"></label>
</div>
<label>{{ t('privacy.restore_deletion_procedure') }}<textarea name="restore_deletion_procedure" rows="3">{{ activity.restore_deletion_procedure }}</textarea></label>
</section>
<section class="settings-section privacy-category-section">
<div class="settings-section-heading">
<div><h2>{{ t('privacy.data_categories') }}</h2><p class="muted">{{ t('privacy.data_categories_tabs_help') }}</p></div>
{% if can_edit %}<button type="button" class="button-secondary" id="add-privacy-category">{{ t('privacy.add_category') }}</button>{% endif %}
</div>
<div class="privacy-category-workbook" data-category-workbook>
<div class="privacy-category-tabs" role="tablist" aria-label="{{ t('privacy.data_categories') }}" data-category-tabs>
{% for category in privacy_settings.categories %}
<button type="button" class="privacy-category-tab{% if loop.first %} active{% endif %}" role="tab" aria-selected="{{ 'true' if loop.first else 'false' }}" aria-controls="privacy-category-panel-{{ loop.index0 }}" data-category-tab>
<span data-category-tab-title>{{ category.name }}</span>{% if category.is_system %}<span class="privacy-system-badge" title="{{ t('privacy.system_category_help') }}">🔒</span>{% endif %}
</button>
{% endfor %}
</div>
<div id="privacy-category-list" class="privacy-category-panels" data-category-panels>
{% for category in privacy_settings.categories %}
<section id="privacy-category-panel-{{ loop.index0 }}" class="privacy-category-panel{% if loop.first %} active{% endif %}" role="tabpanel" {% if not loop.first %}hidden{% endif %} data-category-panel>
<input type="hidden" name="category_key" value="{{ category.key }}">
<div class="form-grid two-columns">
<label>{{ t('privacy.category_name') }}<input type="text" name="category_name" value="{{ category.name }}" data-category-name></label>
<label>{{ t('privacy.system') }}<input type="text" name="category_system" value="{{ category.system }}"></label>
</div>
<label>{{ t('privacy.data_subjects') }}<textarea name="category_data_subjects" rows="2">{{ category.data_subjects }}</textarea></label>
<label>{{ t('privacy.personal_data') }}<textarea name="category_personal_data" rows="3">{{ category.personal_data }}</textarea></label>
<label>{{ t('privacy.purpose') }}<textarea name="category_purpose" rows="2">{{ category.purpose }}</textarea></label>
<div class="form-grid three-columns">
<label>{{ t('privacy.retention_type') }}
<select name="category_retention_type">
{% for value, key in [
('days','privacy.retention_days_type'),
('manual_parent_delete','privacy.retention_manual_parent'),
('overwrite_parent_delete','privacy.retention_overwrite_parent'),
('external_days','privacy.retention_external_days'),
('external_manual','privacy.retention_external_manual'),
('external_overwrite_parent_delete','privacy.retention_external_overwrite'),
('not_stored','privacy.retention_not_stored'),
('manual','privacy.retention_manual')
] %}<option value="{{ value }}" {% if category.retention_type == value %}selected{% endif %}>{{ t(key) }}</option>{% endfor %}
</select>
</label>
<label>{{ t('privacy.retention_days') }}<input type="number" min="0" max="36500" name="category_retention_days" value="{{ category.retention_days if category.retention_days is not none else '' }}"></label>
<label>{{ t('privacy.automatic_possible') }}
<select name="category_automatic_possible"><option value="0" {% if not category.automatic_possible %}selected{% endif %}>{{ t('common.no') }}</option><option value="1" {% if category.automatic_possible %}selected{% endif %}>{{ t('common.yes') }}</option></select>
</label>
</div>
<label>{{ t('privacy.retention_trigger') }}<textarea name="category_retention_trigger" rows="2">{{ category.retention_trigger }}</textarea></label>
<label>{{ t('privacy.manual_procedure') }}<textarea name="category_manual_procedure" rows="3">{{ category.manual_procedure }}</textarea></label>
<label>{{ t('privacy.backup_handling') }}<textarea name="category_backup_handling" rows="2">{{ category.backup_handling }}</textarea></label>
<div class="form-grid two-columns">
<label>{{ t('privacy.responsible') }}<input type="text" name="category_responsible" value="{{ category.responsible }}"></label>
<label>{{ t('privacy.notes') }}<textarea name="category_notes" rows="2">{{ category.notes }}</textarea></label>
</div>
<div class="privacy-retention-actions">
{% if category.is_system %}<span class="muted privacy-system-note">🔒 {{ t('privacy.system_category_help') }}</span>{% endif %}
{% if can_edit and category.key in implemented_retention_keys %}
<button type="button" class="button-secondary" data-retention-check-url="/settings/privacy/retention/{{ category.key }}/check">{{ t('privacy.run_check') }}</button>
<button type="button" class="button-danger" data-retention-delete-url="/settings/privacy/retention/{{ category.key }}/delete" data-category-name="{{ category.name }}">{{ t('privacy.run_manual_deletion') }}</button>
{% elif category.automatic_possible %}
<span class="muted">{{ t('privacy.handler_not_implemented') }}</span>
{% endif %}
{% if can_edit and not category.is_system %}<button type="button" class="button-danger" data-remove-category>{{ t('privacy.remove_category') }}</button>{% endif %}
</div>
{% set preview = preview_results.get(category.key) %}
{% if preview %}
<div class="notice info privacy-preview-result">
<strong>{{ t('privacy.check_result') }}</strong>
<span>{{ t('privacy.matched_records') }}: {{ preview.matched_records or 0 }}</span>
<span>{{ t('privacy.approx_size') }}: {{ ((preview.approx_bytes or 0) / 1024 / 1024)|round(2) }} MB</span>
<span>{{ t('privacy.cutoff') }}: {{ preview.cutoff_utc or '-' }}</span>
{% if not preview.supported %}<span>{{ preview.reason }}</span>{% endif %}
</div>
{% endif %}
</section>
{% endfor %}
</div>
</div>
</section>
<section class="settings-section">
<div class="settings-section-heading">
<div><h2>{{ t('privacy.toms') }}</h2><p class="muted">{{ t('privacy.toms_help') }}</p></div>
<button type="button" class="button-secondary" data-add-list-row="toms-list">{{ t('common.add', 'Add') }}</button>
</div>
<div id="toms-list" class="repeatable-list">
{% for value in activity.toms %}
<div class="repeatable-row"><textarea name="toms" rows="2">{{ value }}</textarea><button type="button" class="button-danger button-small" data-remove-row>×</button></div>
{% endfor %}
</div>
</section>
<section class="settings-section">
<h2>{{ t('privacy.review') }}</h2>
<div class="form-grid three-columns">
<label>{{ t('privacy.review_interval_months') }}<input type="number" min="1" max="120" name="review_interval_months" value="{{ activity.review_interval_months }}"></label>
<label>{{ t('privacy.last_reviewed_at') }}<input type="date" name="last_reviewed_at" value="{{ activity.last_reviewed_at }}"></label>
<label>{{ t('privacy.approved_by') }}<input type="text" name="approved_by" value="{{ activity.approved_by }}"></label>
</div>
</section>
{% if can_edit %}<div class="form-actions sticky-form-actions"><button type="submit" class="button-save">{{ t('common.save') }}</button></div>{% endif %}
</form>
<section class="settings-section">
<h2>{{ t('privacy.policy_audit') }}</h2>
<p class="muted">{{ t('privacy.policy_audit_help') }}</p>
<div class="table-wrap"><table class="data-table" data-table-tools="true"><thead><tr><th>{{ t('common.date') }}</th><th>{{ t('privacy.action') }}</th><th>{{ t('privacy.changed_by') }}</th><th>{{ t('privacy.details') }}</th></tr></thead><tbody>
{% for row in privacy_audit %}<tr><td data-local-datetime="{{ row.timestamp_utc }}">{{ row.timestamp_utc }}</td><td>{{ row.action }}</td><td>{{ row.changed_by }}</td><td><code>{{ row.details }}</code></td></tr>{% else %}<tr><td colspan="4" class="muted">{{ t('privacy.no_audit_entries') }}</td></tr>{% endfor %}
</tbody></table></div>
</section>
<section class="settings-section">
<h2>{{ t('privacy.deletion_audit') }}</h2>
<p class="muted">{{ t('privacy.deletion_audit_help') }}</p>
<div class="table-wrap"><table class="data-table" data-table-tools="true"><thead><tr><th>{{ t('common.date') }}</th><th>{{ t('privacy.run_id') }}</th><th>{{ t('privacy.action') }}</th><th>{{ t('privacy.category_name') }}</th><th>{{ t('privacy.changed_by') }}</th><th>{{ t('privacy.details') }}</th></tr></thead><tbody>
{% for row in deletion_audit %}<tr><td data-local-datetime="{{ row.timestamp_utc }}">{{ row.timestamp_utc }}</td><td><code>{{ row.run_id }}</code></td><td>{{ row.action }}</td><td>{{ row.category_key }}</td><td>{{ row.changed_by }}</td><td><code>{{ row.result }}</code></td></tr>{% else %}<tr><td colspan="6" class="muted">{{ t('privacy.no_deletion_entries') }}</td></tr>{% endfor %}
</tbody></table></div>
</section>
<template id="privacy-category-template">
<section class="privacy-category-panel" role="tabpanel" hidden data-category-panel>
<input type="hidden" name="category_key" value="">
<div class="form-grid two-columns">
<label>{{ t('privacy.category_name') }}<input type="text" name="category_name" value="" data-category-name></label>
<label>{{ t('privacy.system') }}<input type="text" name="category_system" value=""></label>
</div>
<label>{{ t('privacy.data_subjects') }}<textarea name="category_data_subjects" rows="2"></textarea></label>
<label>{{ t('privacy.personal_data') }}<textarea name="category_personal_data" rows="3"></textarea></label>
<label>{{ t('privacy.purpose') }}<textarea name="category_purpose" rows="2"></textarea></label>
<div class="form-grid three-columns">
<label>{{ t('privacy.retention_type') }}
<select name="category_retention_type">
<option value="days">{{ t('privacy.retention_days_type') }}</option>
<option value="manual_parent_delete">{{ t('privacy.retention_manual_parent') }}</option>
<option value="overwrite_parent_delete">{{ t('privacy.retention_overwrite_parent') }}</option>
<option value="external_days">{{ t('privacy.retention_external_days') }}</option>
<option value="external_manual">{{ t('privacy.retention_external_manual') }}</option>
<option value="external_overwrite_parent_delete">{{ t('privacy.retention_external_overwrite') }}</option>
<option value="not_stored">{{ t('privacy.retention_not_stored') }}</option>
<option value="manual" selected>{{ t('privacy.retention_manual') }}</option>
</select>
</label>
<label>{{ t('privacy.retention_days') }}<input type="number" min="0" max="36500" name="category_retention_days"></label>
<label>{{ t('privacy.automatic_possible') }}<select name="category_automatic_possible"><option value="0">{{ t('common.no') }}</option><option value="1">{{ t('common.yes') }}</option></select></label>
</div>
<label>{{ t('privacy.retention_trigger') }}<textarea name="category_retention_trigger" rows="2"></textarea></label>
<label>{{ t('privacy.manual_procedure') }}<textarea name="category_manual_procedure" rows="3"></textarea></label>
<label>{{ t('privacy.backup_handling') }}<textarea name="category_backup_handling" rows="2"></textarea></label>
<div class="form-grid two-columns"><label>{{ t('privacy.responsible') }}<input type="text" name="category_responsible"></label><label>{{ t('privacy.notes') }}<textarea name="category_notes" rows="2"></textarea></label></div>
<div class="form-actions"><button type="button" class="button-danger" data-remove-category>{{ t('privacy.remove_category') }}</button></div>
</section>
</template>
<script>
(() => {
const privacyForm = document.querySelector('.privacy-settings-form');
const readOnly = privacyForm?.dataset.privacyReadonly === 'true';
if (readOnly) {
privacyForm.querySelectorAll('input, textarea').forEach(control => {
if (control.type === 'hidden') return;
control.readOnly = true;
control.setAttribute('aria-readonly', 'true');
});
privacyForm.querySelectorAll('select').forEach(control => {
control.disabled = true;
});
privacyForm.querySelectorAll('button:not([data-category-tab])').forEach(control => {
control.disabled = true;
});
}
document.addEventListener('click', event => {
const checkButton = event.target.closest('[data-retention-check-url]');
const deleteButton = event.target.closest('[data-retention-delete-url]');
const url = checkButton?.dataset.retentionCheckUrl || deleteButton?.dataset.retentionDeleteUrl;
if (!url) return;
event.preventDefault();
let confirmation = '';
if (deleteButton) {
const categoryName = deleteButton.dataset.categoryName || '';
confirmation = window.prompt({{ t('privacy.delete_confirmation_prompt')|tojson }}.replace('{category}', categoryName), '');
if (confirmation === null) return;
}
const form = document.createElement('form');
form.method = 'post';
form.action = url;
if (deleteButton) {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'confirmation';
input.value = confirmation;
form.appendChild(input);
}
document.body.appendChild(form);
form.submit();
});
const workbook = document.querySelector('[data-category-workbook]');
const tabs = workbook?.querySelector('[data-category-tabs]');
const panels = workbook?.querySelector('[data-category-panels]');
const newCategoryLabel = {{ t('privacy.new_category')|tojson }};
let nextPanelId = document.querySelectorAll('[data-category-panel]').length;
const activate = panel => {
if (!panel || !tabs || !panels) return;
panels.querySelectorAll('[data-category-panel]').forEach(item => {
const active = item === panel;
item.classList.toggle('active', active);
item.hidden = !active;
});
tabs.querySelectorAll('[data-category-tab]').forEach(tab => {
const active = tab.getAttribute('aria-controls') === panel.id;
tab.classList.toggle('active', active);
tab.setAttribute('aria-selected', active ? 'true' : 'false');
tab.tabIndex = active ? 0 : -1;
});
tabs.querySelector(`[aria-controls="${CSS.escape(panel.id)}"]`)?.scrollIntoView({block: 'nearest', inline: 'nearest'});
};
const createTab = (panel, title) => {
if (!tabs) return null;
const tab = document.createElement('button');
tab.type = 'button';
tab.className = 'privacy-category-tab';
tab.setAttribute('role', 'tab');
tab.setAttribute('aria-selected', 'false');
tab.setAttribute('aria-controls', panel.id);
tab.tabIndex = -1;
const label = document.createElement('span');
label.dataset.categoryTabTitle = '';
label.textContent = title || newCategoryLabel;
tab.appendChild(label);
tabs.appendChild(tab);
return tab;
};
const bindPanel = panel => {
if (!panel || panel.dataset.bound === 'true') return;
panel.dataset.bound = 'true';
const nameInput = panel.querySelector('[data-category-name]');
const tab = tabs?.querySelector(`[aria-controls="${CSS.escape(panel.id)}"]`);
nameInput?.addEventListener('input', () => {
const label = tab?.querySelector('[data-category-tab-title]');
if (label) label.textContent = nameInput.value.trim() || newCategoryLabel;
});
panel.querySelector('[data-remove-category]')?.addEventListener('click', () => {
const allPanels = [...panels.querySelectorAll('[data-category-panel]')];
const index = allPanels.indexOf(panel);
const fallback = allPanels[index + 1] || allPanels[index - 1] || null;
tab?.remove();
panel.remove();
if (fallback) activate(fallback);
});
};
tabs?.addEventListener('click', event => {
const tab = event.target.closest('[data-category-tab]');
if (!tab) return;
activate(document.getElementById(tab.getAttribute('aria-controls')));
});
tabs?.addEventListener('keydown', event => {
const tab = event.target.closest('[data-category-tab]');
if (!tab || !['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
const allTabs = [...tabs.querySelectorAll('[data-category-tab]')];
let index = allTabs.indexOf(tab);
if (event.key === 'ArrowLeft') index = (index - 1 + allTabs.length) % allTabs.length;
if (event.key === 'ArrowRight') index = (index + 1) % allTabs.length;
if (event.key === 'Home') index = 0;
if (event.key === 'End') index = allTabs.length - 1;
event.preventDefault();
allTabs[index]?.focus();
allTabs[index]?.click();
});
document.querySelectorAll('[data-category-panel]').forEach(bindPanel);
const initiallyActive = document.querySelector('[data-category-panel].active') || document.querySelector('[data-category-panel]');
if (initiallyActive) activate(initiallyActive);
const bindRemoveRows = root => root.querySelectorAll('[data-remove-row]').forEach(button => button.onclick = () => button.closest('.repeatable-row')?.remove());
bindRemoveRows(document);
document.querySelectorAll('[data-add-list-row]').forEach(button => button.addEventListener('click', () => {
const list = document.getElementById(button.dataset.addListRow);
if (!list) return;
const name = list.id === 'purposes-list' ? 'purposes' : 'toms';
const row = document.createElement('div');
row.className = 'repeatable-row';
row.innerHTML = name === 'purposes'
? `<input type="text" name="${name}"><button type="button" class="button-danger button-small" data-remove-row>×</button>`
: `<textarea name="${name}" rows="2"></textarea><button type="button" class="button-danger button-small" data-remove-row>×</button>`;
list.appendChild(row);
bindRemoveRows(row);
}));
document.getElementById('add-privacy-category')?.addEventListener('click', () => {
const template = document.getElementById('privacy-category-template');
if (!template || !panels || !tabs) return;
const fragment = template.content.cloneNode(true);
const panel = fragment.querySelector('[data-category-panel]');
panel.id = `privacy-category-panel-${nextPanelId++}`;
panels.appendChild(fragment);
const inserted = panels.lastElementChild;
createTab(inserted, newCategoryLabel);
bindPanel(inserted);
activate(inserted);
inserted.querySelector('[data-category-name]')?.focus();
});
})();
</script>
{% endblock %}