snapshot: preserve totp account security baseline

This commit is contained in:
Md Bayazid Bostame
2026-03-27 02:46:40 +01:00
parent 358a71230d
commit c679488437
18 changed files with 1723 additions and 786 deletions

View File

@@ -180,12 +180,100 @@
<span>{% trans "Aktualisieren Sie Ihr Passwort direkt im Konto." %}</span>
</a>
<div class="account-action-card{% if account_user_profile.totp_enabled %}{% else %} account-action-card-muted{% endif %}">
<strong>{% trans "TOTP" %}</strong>
<span>
{% if account_user_profile.totp_enabled %}
{% trans "Zweiter Faktor ist aktiv und wird bei der Anmeldung geprüft." %}
{% else %}
{% trans "Standardmäßig deaktiviert. Kann hier jederzeit aktiviert werden." %}
{% endif %}
</span>
</div>
<div class="account-action-card account-action-card-muted">
<strong>{% trans "Sitzung" %}</strong>
<span>{% trans "Sie können sich jederzeit sicher vom aktuellen Gerät abmelden." %}</span>
</div>
</div>
<div class="account-totp-card">
<div class="account-panel-head">
<div>
<h3>{% trans "Zwei-Faktor-Authentifizierung" %}</h3>
<p>{% trans "Aktivieren Sie TOTP mit einer Authenticator-App. Standardmäßig bleibt es ausgeschaltet." %}</p>
</div>
<span class="account-chip{% if not account_user_profile.totp_enabled %} account-chip-muted{% endif %}">
{% if account_user_profile.totp_enabled %}
{% trans "Aktiv" %}
{% else %}
{% trans "Aus" %}
{% endif %}
</span>
</div>
{% if account_user_profile.totp_enabled %}
<div class="account-detail-grid">
<div class="account-detail">
<span>{% trans "Status" %}</span>
<strong>{% trans "TOTP ist aktiviert." %}</strong>
</div>
<div class="account-detail">
<span>{% trans "Bestätigt am" %}</span>
<strong>{% if account_user_profile.totp_confirmed_at %}{{ account_user_profile.totp_confirmed_at|date:"d.m.Y H:i" }}{% else %}-{% endif %}</strong>
</div>
</div>
<form class="account-totp-form" method="post">
{% csrf_token %}
<input type="hidden" name="account_form" value="totp_disable" />
<div class="account-form-grid">
{% for field in totp_disable_form %}
<div class="account-form-field{% if field.errors %} has-error{% endif %}">
<label for="{{ field.id_for_label }}">{{ field.label }}</label>
{{ field }}
{% if field.errors %}
<div class="account-form-error">{{ field.errors|join:", " }}</div>
{% endif %}
</div>
{% endfor %}
</div>
<div class="account-inline-actions">
<button class="btn btn-secondary" type="submit">{% trans "TOTP deaktivieren" %}</button>
</div>
</form>
{% else %}
<div class="account-detail-grid">
<div class="account-detail">
<span>{% trans "Manueller Schlüssel" %}</span>
<strong class="account-secret">{{ totp_pending_secret }}</strong>
</div>
<div class="account-detail">
<span>{% trans "Setup-Link" %}</span>
<strong class="account-secret">{{ totp_otpauth_uri }}</strong>
</div>
</div>
<p class="mini">{% trans "Wenn Ihre App keinen QR-Code scannen kann, tragen Sie den Schlüssel oder den otpauth-Link manuell ein." %}</p>
<form class="account-totp-form" method="post">
{% csrf_token %}
<input type="hidden" name="account_form" value="totp_enable" />
<div class="account-form-grid">
{% for field in totp_enable_form %}
<div class="account-form-field{% if field.errors %} has-error{% endif %}">
<label for="{{ field.id_for_label }}">{{ field.label }}</label>
{{ field }}
{% if field.errors %}
<div class="account-form-error">{{ field.errors|join:", " }}</div>
{% endif %}
</div>
{% endfor %}
</div>
<div class="account-inline-actions">
<button class="btn btn-primary" type="submit">{% trans "TOTP aktivieren" %}</button>
</div>
</form>
{% endif %}
</div>
<div class="account-actions">
<a class="btn btn-primary" href="{% url 'password_change' %}">{% trans "Passwort ändern" %}</a>
<form method="post" action="{% url 'logout' %}">

View File

@@ -1,6 +1,7 @@
{% extends 'workflows/base_shell.html' %}
{% load static i18n %}
{% trans "Ungespeicherte Änderungen" as dirty_state_label %}
{% trans "Sortierung" as sort_label %}
{% block title %}{% trans "App Registry" %}{% endblock %}
@@ -40,150 +41,184 @@
<label for="app-registry-section">{% trans "Bereich" %}</label>
<select id="app-registry-section">
<option value="all">{% trans "Alle" %}</option>
<option value="apps">{% trans "Apps" %}</option>
<option value="platform_apps">{% trans "Platform Apps" %}</option>
<option value="admin_apps">{% trans "Admin Apps" %}</option>
<option value="app">{% trans "Apps" %}</option>
<option value="platform">{% trans "Platform Apps" %}</option>
<option value="admin">{% trans "Admin Apps" %}</option>
</select>
</div>
</section>
<div class="app-registry-cards">
{% for row in rows %}
<details class="app-registry-card{% if not row.config.is_enabled %} is-disabled{% endif %}" data-app-card
data-key="{{ row.config.key|lower }}"
data-title="{{ row.definition.title|lower }}"
data-enabled="{% if row.config.is_enabled %}1{% else %}0{% endif %}"
data-platform-only="{% if not row.config.visible_to_super_admin and not row.config.visible_to_admin and not row.config.visible_to_it_staff and not row.config.visible_to_staff %}1{% else %}0{% endif %}"
data-section="{{ row.config.section }}">
<summary class="app-registry-summary">
<div class="app-registry-summary-main">
<div class="app-registry-card-title-row">
<h2>{{ row.definition.title }}</h2>
{% if row.config.is_enabled %}
<span class="badge sent">{% trans "Aktiv" %}</span>
<div class="hint" id="app-registry-reorder-hint">{% trans "Für eine verlässliche Reihenfolge bitte ohne aktive Filter umsortieren." %}</div>
<div class="app-registry-groups">
{% for section_key, section_label in section_choices %}
<section class="app-registry-group" data-app-group="{{ section_key }}">
<div class="app-registry-group-head">
<div>
<h2>{{ section_label }}</h2>
<p class="mini">
{% if section_key == 'platform' %}
{% trans "Produktweite Steuerung und nur für die Platform sichtbare Oberflächen." %}
{% elif section_key == 'admin' %}
{% trans "Administrative Apps für Kundenrollen mit erhöhter Verantwortung." %}
{% else %}
<span class="badge cancelled">{% trans "Deaktiviert" %}</span>
{% trans "Operative Apps, die im täglichen Einsatz auf der Landing Page erscheinen." %}
{% endif %}
</div>
<div class="mini">{{ row.config.key }}</div>
<p class="app-registry-card-copy">{{ row.definition.description }}</p>
<p class="mini">{% trans "Empfohlener Standardzugriff:" %} {{ row.default_visibility_summary }}</p>
</p>
</div>
<div class="app-registry-summary-meta">
<span class="badge scheduled">
{% if row.config.section == 'platform_apps' %}
{% trans "Platform Apps" %}
{% elif row.config.section == 'admin_apps' %}
{% trans "Admin Apps" %}
{% else %}
{% trans "Apps" %}
{% endif %}
</span>
<span class="badge">{% trans "Sortierung" %}: {{ row.config.sort_order }}</span>
{% if not row.config.visible_to_super_admin and not row.config.visible_to_admin and not row.config.visible_to_it_staff and not row.config.visible_to_staff %}
<span class="badge paused">{% trans "Platform only" %}</span>
{% elif row.config.visible_to_super_admin and row.config.visible_to_admin and row.config.visible_to_it_staff and row.config.visible_to_staff %}
<span class="badge sent">{% trans "Alle Firmenrollen" %}</span>
{% else %}
<span class="badge scheduled">
{% if row.config.visible_to_super_admin %}{% trans "Super Admin" %}{% endif %}
{% if row.config.visible_to_admin %}{% if row.config.visible_to_super_admin %} + {% endif %}{% trans "Admin" %}{% endif %}
{% if row.config.visible_to_it_staff %}{% if row.config.visible_to_super_admin or row.config.visible_to_admin %} + {% endif %}{% trans "IT Staff" %}{% endif %}
{% if row.config.visible_to_staff %}{% if row.config.visible_to_super_admin or row.config.visible_to_admin or row.config.visible_to_it_staff %} + {% endif %}{% trans "Staff" %}{% endif %}
</span>
{% endif %}
</div>
</summary>
<div class="app-registry-card-grid">
<section class="app-registry-panel">
<h3>{% trans "Verfügbarkeit" %}</h3>
<div class="check-row app-registry-checks">
<label>
<input type="checkbox" name="is_enabled__{{ row.config.key }}" {% if row.config.is_enabled %}checked{% endif %} />
<span>{% trans "App aktiviert" %}</span>
</label>
</div>
<p class="hint">{% trans "Deaktivierte Apps erscheinen nicht auf der Landing Page, selbst wenn Rollen sie sehen dürften." %}</p>
</section>
<section class="app-registry-panel">
<h3>{% trans "Sichtbarkeit nach Rolle" %}</h3>
<div class="check-row app-registry-checks">
<label>
<input type="checkbox" name="visible_to_super_admin__{{ row.config.key }}" {% if row.config.visible_to_super_admin %}checked{% endif %} />
<span>{% trans "Super Admin" %}</span>
</label>
<label>
<input type="checkbox" name="visible_to_admin__{{ row.config.key }}" {% if row.config.visible_to_admin %}checked{% endif %} />
<span>{% trans "Admin" %}</span>
</label>
<label>
<input type="checkbox" name="visible_to_it_staff__{{ row.config.key }}" {% if row.config.visible_to_it_staff %}checked{% endif %} />
<span>{% trans "IT Staff" %}</span>
</label>
<label>
<input type="checkbox" name="visible_to_staff__{{ row.config.key }}" {% if row.config.visible_to_staff %}checked{% endif %} />
<span>{% trans "Staff" %}</span>
</label>
</div>
<p class="hint">{% trans "Wenn keine Firmenrolle aktiv ist, bleibt die App nur für die Platform sichtbar." %}</p>
</section>
<section class="app-registry-panel">
<h3>{% trans "Platzierung" %}</h3>
<div class="grid">
<div class="field">
<label for="section__{{ row.config.key }}">{% trans "Bereich" %}</label>
<select id="section__{{ row.config.key }}" name="section__{{ row.config.key }}">
{% for value, label in section_choices %}
<option value="{{ value }}"{% if row.config.section == value %} selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="field">
<label for="sort_order__{{ row.config.key }}">{% trans "Reihenfolge" %}</label>
<input id="sort_order__{{ row.config.key }}" type="number" name="sort_order__{{ row.config.key }}" value="{{ row.config.sort_order }}" min="0" step="1" />
</div>
</div>
</section>
<section class="app-registry-panel app-registry-copy-panel">
<h3>{% trans "Bezeichnungen & Texte" %}</h3>
<div class="grid lang-pairs">
<div class="lang-block">
<h4>{% trans "Deutsch" %}</h4>
<div class="field">
<label for="title_override__{{ row.config.key }}">{% trans "Titel" %}</label>
<input id="title_override__{{ row.config.key }}" type="text" name="title_override__{{ row.config.key }}" value="{{ row.config.title_override }}" placeholder="{{ row.definition.title }}" />
</div>
<div class="field">
<label for="description_override__{{ row.config.key }}">{% trans "Beschreibung" %}</label>
<textarea id="description_override__{{ row.config.key }}" name="description_override__{{ row.config.key }}" rows="3" placeholder="{{ row.definition.description }}">{{ row.config.description_override }}</textarea>
</div>
<div class="field">
<label for="action_label_override__{{ row.config.key }}">{% trans "Aktionslabel" %}</label>
<input id="action_label_override__{{ row.config.key }}" type="text" name="action_label_override__{{ row.config.key }}" value="{{ row.config.action_label_override }}" placeholder="{{ row.definition.action_label }}" />
</div>
</div>
<div class="lang-block">
<h4>{% trans "English" %}</h4>
<div class="field">
<label for="title_override_en__{{ row.config.key }}">{% trans "Title" %}</label>
<input id="title_override_en__{{ row.config.key }}" type="text" name="title_override_en__{{ row.config.key }}" value="{{ row.config.title_override_en }}" placeholder="{{ row.definition.title }}" />
</div>
<div class="field">
<label for="description_override_en__{{ row.config.key }}">{% trans "Description" %}</label>
<textarea id="description_override_en__{{ row.config.key }}" name="description_override_en__{{ row.config.key }}" rows="3" placeholder="{{ row.definition.description }}">{{ row.config.description_override_en }}</textarea>
</div>
<div class="field">
<label for="action_label_override_en__{{ row.config.key }}">{% trans "Action label" %}</label>
<input id="action_label_override_en__{{ row.config.key }}" type="text" name="action_label_override_en__{{ row.config.key }}" value="{{ row.config.action_label_override_en }}" placeholder="{{ row.definition.action_label }}" />
</div>
</div>
</div>
</section>
<span class="badge scheduled" data-app-group-count>{{ section_label }}</span>
</div>
</details>
<div class="app-registry-group-body" data-app-group-body="{{ section_key }}">
{% for row in rows %}
{% if row.config.section == section_key %}
<details class="app-registry-card{% if not row.config.is_enabled %} is-disabled{% endif %}" data-app-card draggable="true"
data-key="{{ row.config.key|lower }}"
data-title="{{ row.definition.title|lower }}"
data-enabled="{% if row.config.is_enabled %}1{% else %}0{% endif %}"
data-platform-only="{% if not row.config.visible_to_super_admin and not row.config.visible_to_admin and not row.config.visible_to_it_staff and not row.config.visible_to_staff %}1{% else %}0{% endif %}"
data-section="{{ row.config.section }}">
<summary class="app-registry-summary">
<span class="app-registry-drag-handle" title="{% trans 'Ziehen zum Umordnen' %}" aria-hidden="true">⋮⋮</span>
<div class="app-registry-summary-main">
<div class="app-registry-card-title-row">
<h2>{{ row.definition.title }}</h2>
{% if row.config.is_enabled %}
<span class="badge sent">{% trans "Aktiv" %}</span>
{% else %}
<span class="badge cancelled">{% trans "Deaktiviert" %}</span>
{% endif %}
</div>
<div class="mini">{{ row.config.key }}</div>
<p class="app-registry-card-copy">{{ row.definition.description }}</p>
<p class="mini">{% trans "Empfohlener Standardzugriff:" %} {{ row.default_visibility_summary }}</p>
</div>
<div class="app-registry-summary-meta">
<span class="badge scheduled">
{% if row.config.section == 'platform' %}
{% trans "Platform Apps" %}
{% elif row.config.section == 'admin' %}
{% trans "Admin Apps" %}
{% else %}
{% trans "Apps" %}
{% endif %}
</span>
<span class="badge" data-sort-badge>{% trans "Sortierung" %}: {{ row.config.sort_order }}</span>
{% if not row.config.visible_to_super_admin and not row.config.visible_to_admin and not row.config.visible_to_it_staff and not row.config.visible_to_staff %}
<span class="badge paused">{% trans "Platform only" %}</span>
{% elif row.config.visible_to_super_admin and row.config.visible_to_admin and row.config.visible_to_it_staff and row.config.visible_to_staff %}
<span class="badge sent">{% trans "Alle Firmenrollen" %}</span>
{% else %}
<span class="badge scheduled">
{% if row.config.visible_to_super_admin %}{% trans "Super Admin" %}{% endif %}
{% if row.config.visible_to_admin %}{% if row.config.visible_to_super_admin %} + {% endif %}{% trans "Admin" %}{% endif %}
{% if row.config.visible_to_it_staff %}{% if row.config.visible_to_super_admin or row.config.visible_to_admin %} + {% endif %}{% trans "IT Staff" %}{% endif %}
{% if row.config.visible_to_staff %}{% if row.config.visible_to_super_admin or row.config.visible_to_admin or row.config.visible_to_it_staff %} + {% endif %}{% trans "Staff" %}{% endif %}
</span>
{% endif %}
</div>
</summary>
<div class="app-registry-card-grid">
<section class="app-registry-panel">
<h3>{% trans "Verfügbarkeit" %}</h3>
<div class="check-row app-registry-checks">
<label>
<input type="checkbox" name="is_enabled__{{ row.config.key }}" {% if row.config.is_enabled %}checked{% endif %} />
<span>{% trans "App aktiviert" %}</span>
</label>
</div>
<p class="hint">{% trans "Deaktivierte Apps erscheinen nicht auf der Landing Page, selbst wenn Rollen sie sehen dürften." %}</p>
</section>
<section class="app-registry-panel">
<h3>{% trans "Sichtbarkeit nach Rolle" %}</h3>
<div class="check-row app-registry-checks">
<label>
<input type="checkbox" name="visible_to_super_admin__{{ row.config.key }}" {% if row.config.visible_to_super_admin %}checked{% endif %} />
<span>{% trans "Super Admin" %}</span>
</label>
<label>
<input type="checkbox" name="visible_to_admin__{{ row.config.key }}" {% if row.config.visible_to_admin %}checked{% endif %} />
<span>{% trans "Admin" %}</span>
</label>
<label>
<input type="checkbox" name="visible_to_it_staff__{{ row.config.key }}" {% if row.config.visible_to_it_staff %}checked{% endif %} />
<span>{% trans "IT Staff" %}</span>
</label>
<label>
<input type="checkbox" name="visible_to_staff__{{ row.config.key }}" {% if row.config.visible_to_staff %}checked{% endif %} />
<span>{% trans "Staff" %}</span>
</label>
</div>
<p class="hint">{% trans "Wenn keine Firmenrolle aktiv ist, bleibt die App nur für die Platform sichtbar." %}</p>
</section>
<section class="app-registry-panel">
<h3>{% trans "Platzierung" %}</h3>
<div class="grid">
<div class="field">
<label for="section__{{ row.config.key }}">{% trans "Bereich" %}</label>
<select id="section__{{ row.config.key }}" name="section__{{ row.config.key }}">
{% for value, label in section_choices %}
<option value="{{ value }}"{% if row.config.section == value %} selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="field">
<label for="sort_order__{{ row.config.key }}">{% trans "Reihenfolge" %}</label>
<input
id="sort_order__{{ row.config.key }}"
type="number"
name="sort_order__{{ row.config.key }}"
value="{{ row.config.sort_order }}"
min="0"
step="1"
data-sort-order-input
/>
<div class="hint">{% trans "Wird per Drag-and-drop und Bereichswechsel dynamisch neu nummeriert." %}</div>
</div>
</div>
</section>
<section class="app-registry-panel app-registry-copy-panel">
<h3>{% trans "Bezeichnungen & Texte" %}</h3>
<div class="grid lang-pairs">
<div class="lang-block">
<h4>{% trans "Deutsch" %}</h4>
<div class="field">
<label for="title_override__{{ row.config.key }}">{% trans "Titel" %}</label>
<input id="title_override__{{ row.config.key }}" type="text" name="title_override__{{ row.config.key }}" value="{{ row.config.title_override }}" placeholder="{{ row.definition.title }}" />
</div>
<div class="field">
<label for="description_override__{{ row.config.key }}">{% trans "Beschreibung" %}</label>
<textarea id="description_override__{{ row.config.key }}" name="description_override__{{ row.config.key }}" rows="3" placeholder="{{ row.definition.description }}">{{ row.config.description_override }}</textarea>
</div>
<div class="field">
<label for="action_label_override__{{ row.config.key }}">{% trans "Aktionslabel" %}</label>
<input id="action_label_override__{{ row.config.key }}" type="text" name="action_label_override__{{ row.config.key }}" value="{{ row.config.action_label_override }}" placeholder="{{ row.definition.action_label }}" />
</div>
</div>
<div class="lang-block">
<h4>{% trans "English" %}</h4>
<div class="field">
<label for="title_override_en__{{ row.config.key }}">{% trans "Title" %}</label>
<input id="title_override_en__{{ row.config.key }}" type="text" name="title_override_en__{{ row.config.key }}" value="{{ row.config.title_override_en }}" placeholder="{{ row.definition.title }}" />
</div>
<div class="field">
<label for="description_override_en__{{ row.config.key }}">{% trans "Description" %}</label>
<textarea id="description_override_en__{{ row.config.key }}" name="description_override_en__{{ row.config.key }}" rows="3" placeholder="{{ row.definition.description }}">{{ row.config.description_override_en }}</textarea>
</div>
<div class="field">
<label for="action_label_override_en__{{ row.config.key }}">{% trans "Action label" %}</label>
<input id="action_label_override_en__{{ row.config.key }}" type="text" name="action_label_override_en__{{ row.config.key }}" value="{{ row.config.action_label_override_en }}" placeholder="{{ row.definition.action_label }}" />
</div>
</div>
</div>
</section>
</div>
</details>
{% endif %}
{% endfor %}
</div>
</section>
{% endfor %}
</div>
<div class="app-registry-savebar">
@@ -204,13 +239,46 @@
const stateSelect = document.getElementById('app-registry-state');
const sectionSelect = document.getElementById('app-registry-section');
const cards = Array.from(document.querySelectorAll('[data-app-card]'));
const groups = Array.from(document.querySelectorAll('[data-app-group]'));
const groupBodies = Array.from(document.querySelectorAll('[data-app-group-body]'));
const form = document.querySelector('form[action$="app-registry/save/"], form[action*="save_portal_app_registry"]') || document.querySelector('.stack-form');
const dirtyState = document.getElementById('app-registry-dirty-state');
const reorderHint = document.getElementById('app-registry-reorder-hint');
const SECTION_ORDER = ['app', 'platform', 'admin'];
let draggedCard = null;
function cardsInDomOrder() {
return groupBodies.flatMap((body) => Array.from(body.querySelectorAll('[data-app-card]')));
}
function syncGroupContainers() {
cards.forEach((card) => {
const sectionField = card.querySelector('select[name^="section__"]');
const targetSection = sectionField ? sectionField.value : card.dataset.section;
const targetBody = document.querySelector(`[data-app-group-body="${targetSection}"]`);
if (targetBody && card.parentElement !== targetBody) {
targetBody.appendChild(card);
}
card.dataset.section = targetSection;
});
}
function updateGroupVisibility() {
groups.forEach((group) => {
const visibleCards = group.querySelectorAll('[data-app-card]:not([hidden])').length;
group.hidden = visibleCards === 0;
const countBadge = group.querySelector('[data-app-group-count]');
if (countBadge) {
countBadge.textContent = String(visibleCards);
}
});
}
function applyFilters() {
const query = (searchInput?.value || '').trim().toLowerCase();
const state = stateSelect?.value || 'all';
const section = sectionSelect?.value || 'all';
const filterActive = Boolean(query) || state !== 'all' || section !== 'all';
cards.forEach((card) => {
const matchesQuery = !query || card.dataset.key.includes(query) || card.dataset.title.includes(query);
@@ -221,20 +289,116 @@
(state === 'platform_only' && card.dataset.platformOnly === '1');
const matchesSection = section === 'all' || card.dataset.section === section;
card.hidden = !(matchesQuery && matchesState && matchesSection);
card.draggable = !filterActive;
card.classList.toggle('drag-disabled', filterActive);
});
if (reorderHint) {
reorderHint.hidden = !filterActive;
}
updateGroupVisibility();
}
function normalizeSortOrders() {
const grouped = new Map();
const orderedCards = cardsInDomOrder();
SECTION_ORDER.forEach((key) => grouped.set(key, []));
orderedCards.forEach((card) => {
const sectionField = card.querySelector('select[name^="section__"]');
const sortInput = card.querySelector('[data-sort-order-input]');
const sectionKey = sectionField ? sectionField.value : card.dataset.section;
const sortValue = sortInput ? parseInt(sortInput.value || '0', 10) : 0;
if (!grouped.has(sectionKey)) grouped.set(sectionKey, []);
grouped.get(sectionKey).push({
card,
sortInput,
sortValue: Number.isNaN(sortValue) ? 0 : sortValue,
title: (card.dataset.title || '').toLowerCase(),
});
});
grouped.forEach((items, sectionKey) => {
items
.sort((a, b) => {
const domOrder = orderedCards.indexOf(a.card) - orderedCards.indexOf(b.card);
return (a.sortValue - b.sortValue) || domOrder || a.title.localeCompare(b.title);
})
.forEach((item, index) => {
if (item.sortInput) item.sortInput.value = index;
item.card.dataset.section = sectionKey;
const badge = item.card.querySelector('[data-sort-badge]');
if (badge) badge.textContent = `{{ sort_label|escapejs }}: ${index}`;
});
});
}
function markDirty() {
if (!dirtyState) return;
dirtyState.textContent = "{{ dirty_state_label|escapejs }}";
function closestCardAfterPointer(container, clientY) {
const siblingCards = Array.from(container.querySelectorAll('[data-app-card]:not(.is-dragging)'));
return siblingCards.find((card) => {
const rect = card.getBoundingClientRect();
return clientY < rect.top + rect.height / 2;
}) || null;
}
searchInput?.addEventListener('input', applyFilters);
stateSelect?.addEventListener('change', applyFilters);
sectionSelect?.addEventListener('change', applyFilters);
form?.addEventListener('input', markDirty);
form?.addEventListener('change', markDirty);
function markDirty() {
if (dirtyState) {
dirtyState.textContent = '{{ dirty_state_label|escapejs }}';
}
}
if (form) {
form.addEventListener('change', (event) => {
if (event.target.matches('select[name^="section__"], [data-sort-order-input]')) {
syncGroupContainers();
normalizeSortOrders();
}
markDirty();
applyFilters();
});
form.addEventListener('input', () => {
markDirty();
});
}
cards.forEach((card) => {
card.addEventListener('dragstart', () => {
if (card.classList.contains('drag-disabled')) return;
draggedCard = card;
card.classList.add('is-dragging');
});
card.addEventListener('dragend', () => {
card.classList.remove('is-dragging');
draggedCard = null;
normalizeSortOrders();
markDirty();
applyFilters();
});
});
groupBodies.forEach((body) => {
body.addEventListener('dragover', (event) => {
if (!draggedCard || draggedCard.classList.contains('drag-disabled')) return;
event.preventDefault();
const nextCard = closestCardAfterPointer(body, event.clientY);
if (nextCard) {
body.insertBefore(draggedCard, nextCard);
} else {
body.appendChild(draggedCard);
}
});
});
[searchInput, stateSelect, sectionSelect].forEach((control) => {
if (!control) return;
control.addEventListener('input', applyFilters);
control.addEventListener('change', applyFilters);
});
syncGroupContainers();
normalizeSortOrders();
applyFilters();
})();
</script>

View File

@@ -26,12 +26,16 @@
<div class="app-alert app-alert-error" role="alert" aria-live="assertive">
<div class="app-alert-body">
<strong>{% trans "Anmeldung fehlgeschlagen" %}</strong><br />
<span>{% trans "Benutzername oder Passwort sind nicht korrekt. Bitte versuchen Sie es erneut." %}</span>
<span>{% trans "Anmeldedaten oder TOTP-Code sind nicht korrekt. Bitte versuchen Sie es erneut." %}</span>
</div>
</div>
{% endif %}
<div class="field{% if form.errors %} has-error{% endif %}">{{ form.username.label_tag }}{{ form.username }}</div>
<div class="field{% if form.errors %} has-error{% endif %}">{{ form.password.label_tag }}{{ form.password }}</div>
<div class="field{% if form.username.errors or form.errors %} has-error{% endif %}">{{ form.username.label_tag }}{{ form.username }}</div>
<div class="field{% if form.password.errors or form.errors %} has-error{% endif %}">{{ form.password.label_tag }}{{ form.password }}</div>
<div class="field{% if form.otp_code.errors %} has-error{% endif %}">
{{ form.otp_code.label_tag }}{{ form.otp_code }}
<div class="mini">{% trans "Nur erforderlich, wenn TOTP für Ihr Konto aktiviert ist." %}</div>
</div>
<button class="btn btn-primary" type="submit">{% trans "Anmelden" %}</button>
</form>
</div>

View File

@@ -15,71 +15,66 @@
{% include 'workflows/includes/messages.html' %}
<section class="card">
<form method="post" action="{% url 'save_portal_branding' %}" enctype="multipart/form-data" class="stack-form">
{% csrf_token %}
<div class="branding-sections">
<section class="branding-block">
<div class="branding-block-head">
<h2>{% trans "Identität" %}</h2>
<p>{% trans "Titel, Firmenname und zentrale Spracheinstellungen." %}</p>
<div class="branding-sections">
{% for section in branding_sections %}
<section class="branding-block branding-inline-block" data-branding-section="{{ section.key }}">
<div class="branding-block-head branding-inline-head">
<div>
<h2>{{ section.title }}</h2>
<p>{{ section.subtitle }}</p>
</div>
<div class="grid two">
<div class="field">
<label for="{{ form.portal_title.id_for_label }}">{{ form.portal_title.label }}</label>
{{ form.portal_title }}
</div>
<div class="field">
<label for="{{ form.company_name.id_for_label }}">{{ form.company_name.label }}</label>
{{ form.company_name }}
</div>
<div class="field">
<label for="{{ form.company_domain.id_for_label }}">{{ form.company_domain.label }}</label>
{{ form.company_domain }}
<div class="hint">{% trans "Wird für E-Mail-Vorschläge und Domain-bezogene Standardtexte verwendet, z. B. tub.co." %}</div>
</div>
<div class="field">
<label for="{{ form.default_language.id_for_label }}">{{ form.default_language.label }}</label>
{{ form.default_language }}
</div>
<div class="field field-full">
<label for="{{ form.login_subtitle.id_for_label }}">{{ form.login_subtitle.label }}</label>
{{ form.login_subtitle }}
</div>
</div>
</section>
<button
class="btn btn-secondary branding-inline-trigger"
type="button"
data-branding-edit-toggle="{{ section.key }}"
aria-expanded="{% if editing_branding_section == section.key %}true{% else %}false{% endif %}"
>
{% trans "Bearbeiten" %}
</button>
</div>
<section class="branding-block">
<div class="branding-block-head">
<h2>{% trans "Farben & Erscheinungsbild" %}</h2>
<p>{% trans "Zentrale visuelle Markenwerte und Browser-Icon." %}</p>
<div class="branding-inline-view{% if editing_branding_section == section.key %} is-hidden{% endif %}" data-branding-edit-view="{{ section.key }}">
{% if section.key == 'legal' %}
<div class="grid two lang-pairs">
<div class="lang-block">
<h3>{% trans "Deutsch" %}</h3>
{% for row in section.rows|slice:":2" %}
<div class="field{% if row.is_full %} field-full{% endif %}">
<label>{{ row.label }}</label>
<div class="branding-inline-value">{{ row.value|default:"-" }}</div>
</div>
{% endfor %}
</div>
<div class="lang-block">
<h3>{% trans "English" %}</h3>
{% for row in section.rows|slice:"2:" %}
<div class="field{% if row.is_full %} field-full{% endif %}">
<label>{{ row.label }}</label>
<div class="branding-inline-value">{{ row.value|default:"-" }}</div>
</div>
{% endfor %}
</div>
</div>
{% else %}
<div class="grid two">
<div class="field">
<label for="{{ form.primary_color.id_for_label }}">{{ form.primary_color.label }}</label>
{{ form.primary_color }}
</div>
<div class="field">
<label for="{{ form.secondary_color.id_for_label }}">{{ form.secondary_color.label }}</label>
{{ form.secondary_color }}
</div>
<div class="field">
<label for="{{ form.logo_image.id_for_label }}">{{ form.logo_image.label }}</label>
{{ form.logo_image }}
<div class="hint">{% trans "Erlaubte Formate: SVG, PNG, JPG, JPEG, WEBP. Maximal 5 MB." %}</div>
{% for error in form.logo_image.errors %}<div class="hint">{{ error }}</div>{% endfor %}
{% if branding.logo_image %}
<div class="hint">{% trans "Aktuelles Logo:" %} <a href="{{ branding.logo_image.url }}" target="_blank" rel="noopener">{% trans "öffnen" %}</a></div>
{% endif %}
</div>
<div class="field">
<label for="{{ form.favicon_image.id_for_label }}">{{ form.favicon_image.label }}</label>
{{ form.favicon_image }}
<div class="hint">{% trans "Erlaubte Formate: ICO, PNG, SVG, WEBP. Maximal 2 MB." %}</div>
{% for error in form.favicon_image.errors %}<div class="hint">{{ error }}</div>{% endfor %}
{% if branding.favicon_image %}
<div class="hint">{% trans "Aktuelles Favicon:" %} <a href="{{ branding.favicon_image.url }}" target="_blank" rel="noopener">{% trans "öffnen" %}</a></div>
{% endif %}
{% for row in section.rows %}
<div class="field{% if row.is_full %} field-full{% endif %}">
<label>{{ row.label }}</label>
<div class="branding-inline-value">
{% if row.is_file %}
{% if row.value %}
<a href="{{ row.value.url }}" target="_blank" rel="noopener">{% trans "öffnen" %}</a>
{% else %}
-
{% endif %}
{% else %}
{{ row.value|default:"-" }}
{% endif %}
</div>
{% if row.hint %}<div class="hint">{{ row.hint }}</div>{% endif %}
</div>
{% endfor %}
{% if section.key == 'appearance' %}
<div class="field field-full">
<div class="branding-preview" id="branding-preview" data-default-logo="{{ portal_logo_url }}">
<div class="branding-preview-shell">
@@ -101,78 +96,121 @@
</div>
</div>
</div>
{% endif %}
</div>
</section>
{% endif %}
</div>
<section class="branding-block">
<div class="branding-block-head">
<h2>{% trans "Kommunikation" %}</h2>
<p>{% trans "Absender, Support und PDF-Branding für ausgehende Kommunikation." %}</p>
</div>
<div class="grid two">
<div class="field">
<label for="{{ form.support_email.id_for_label }}">{{ form.support_email.label }}</label>
{{ form.support_email }}
</div>
<div class="field">
<label for="{{ form.sender_display_name.id_for_label }}">{{ form.sender_display_name.label }}</label>
{{ form.sender_display_name }}
<div class="hint">{% trans "Wird für ausgehende System-E-Mails als Anzeigename verwendet." %}</div>
</div>
<div class="field field-full">
<label for="{{ form.pdf_letterhead.id_for_label }}">{{ form.pdf_letterhead.label }}</label>
{{ form.pdf_letterhead }}
<div class="hint">{% trans "Erlaubtes Format: PDF. Maximal 10 MB." %}</div>
{% for error in form.pdf_letterhead.errors %}<div class="hint">{{ error }}</div>{% endfor %}
{% if branding.pdf_letterhead %}
<div class="hint">{% trans "Aktueller Briefkopf:" %} <a href="{{ branding.pdf_letterhead.url }}" target="_blank" rel="noopener">{% trans "öffnen" %}</a></div>
{% endif %}
</div>
</div>
</section>
<form method="post" action="{% url 'save_portal_branding' %}" enctype="multipart/form-data" class="branding-inline-form{% if editing_branding_section != section.key %} is-hidden{% endif %}" data-branding-edit-form="{{ section.key }}">
{% csrf_token %}
<input type="hidden" name="section_key" value="{{ section.key }}" />
<section class="branding-block">
<div class="branding-block-head">
<h2>{% trans "Footer & Rechtliches" %}</h2>
<p>{% trans "Gemeinsame Footer-Texte und rechtliche Hinweise für die Shell." %}</p>
</div>
{% if section.key == 'legal' %}
<div class="grid two lang-pairs">
<div class="lang-block">
<h3>{% trans "Deutsch" %}</h3>
<div class="field">
<label for="{{ form.footer_text.id_for_label }}">{{ form.footer_text.label }}</label>
{{ form.footer_text }}
</div>
<div class="field">
<label for="{{ form.legal_notice.id_for_label }}">{{ form.legal_notice.label }}</label>
{{ form.legal_notice }}
{% for row in section.rows|slice:":2" %}
<div class="field{% if row.is_full %} field-full{% endif %}{% if row.bound_field.errors %} has-error{% endif %}">
<label for="{{ row.bound_field.id_for_label }}">{{ row.label }}</label>
{{ row.bound_field }}
{% if row.bound_field.errors %}<div class="branding-inline-error">{{ row.bound_field.errors|join:", " }}</div>{% endif %}
</div>
{% endfor %}
</div>
<div class="lang-block">
<h3>{% trans "English" %}</h3>
<div class="field">
<label for="{{ form.footer_text_en.id_for_label }}">{{ form.footer_text_en.label }}</label>
{{ form.footer_text_en }}
</div>
<div class="field">
<label for="{{ form.legal_notice_en.id_for_label }}">{{ form.legal_notice_en.label }}</label>
{{ form.legal_notice_en }}
{% for row in section.rows|slice:"2:" %}
<div class="field{% if row.is_full %} field-full{% endif %}{% if row.bound_field.errors %} has-error{% endif %}">
<label for="{{ row.bound_field.id_for_label }}">{{ row.label }}</label>
{{ row.bound_field }}
{% if row.bound_field.errors %}<div class="branding-inline-error">{{ row.bound_field.errors|join:", " }}</div>{% endif %}
</div>
{% endfor %}
</div>
</div>
</section>
</div>
{% else %}
<div class="grid two">
{% for row in section.rows %}
<div class="field{% if row.is_full %} field-full{% endif %}{% if row.bound_field.errors %} has-error{% endif %}">
<label for="{{ row.bound_field.id_for_label }}">{{ row.label }}</label>
{{ row.bound_field }}
{% if row.hint %}<div class="hint">{{ row.hint }}</div>{% endif %}
{% if row.is_file and row.value %}
<div class="hint">
{% if row.name == 'logo_image' %}{% trans "Aktuelles Logo:" %}
{% elif row.name == 'favicon_image' %}{% trans "Aktuelles Favicon:" %}
{% elif row.name == 'pdf_letterhead' %}{% trans "Aktueller Briefkopf:" %}
{% endif %}
<a href="{{ row.value.url }}" target="_blank" rel="noopener">{% trans "öffnen" %}</a>
</div>
{% endif %}
{% if row.bound_field.errors %}<div class="branding-inline-error">{{ row.bound_field.errors|join:", " }}</div>{% endif %}
</div>
{% endfor %}
{% if section.key == 'appearance' %}
<div class="field field-full">
<div class="branding-preview" id="branding-preview" data-default-logo="{{ portal_logo_url }}">
<div class="branding-preview-shell">
<div class="branding-preview-header">
<img class="branding-preview-logo" id="branding-preview-logo" src="{{ portal_logo_url }}" alt="{{ portal_company_name }} Logo" />
<div class="branding-preview-copy">
<strong id="branding-preview-company">{{ branding.company_name }}</strong>
<span id="branding-preview-title">{{ branding.portal_title }}</span>
</div>
</div>
<div class="branding-preview-band">
<span class="branding-preview-chip" id="branding-preview-primary">{% trans "Primärfarbe" %}</span>
<span class="branding-preview-chip branding-preview-chip-secondary" id="branding-preview-secondary">{% trans "Sekundärfarbe" %}</span>
</div>
<div class="branding-preview-footer">
<div class="branding-preview-footer-main" id="branding-preview-footer">{{ branding.footer_text|default:branding.portal_title }}</div>
<div class="branding-preview-footer-legal" id="branding-preview-legal">{{ branding.legal_notice }}</div>
</div>
</div>
</div>
</div>
{% endif %}
</div>
{% endif %}
<div class="branding-inline-actions">
<button class="btn btn-primary" type="submit">{% trans "Speichern" %}</button>
<button class="btn btn-secondary" type="button" data-branding-edit-cancel="{{ section.key }}">{% trans "Abbrechen" %}</button>
</div>
</form>
</section>
{% endfor %}
<div class="toolbar" style="margin-top:1.25rem;">
<div class="hint">{% trans "Die aktuell gesetzte Deployment-Branding bleibt erhalten, bis hier Werte geändert oder Dateien hochgeladen werden." %}</div>
<button class="btn btn-primary" type="submit">{% trans "Branding speichern" %}</button>
</div>
</form>
</div>
</section>
{% endblock %}
{% block extra_scripts %}
<script>
(() => {
function setBrandingMode(key, editing) {
const view = document.querySelector('[data-branding-edit-view="' + key + '"]');
const form = document.querySelector('[data-branding-edit-form="' + key + '"]');
const toggle = document.querySelector('[data-branding-edit-toggle="' + key + '"]');
if (!view || !form || !toggle) return;
view.classList.toggle('is-hidden', editing);
form.classList.toggle('is-hidden', !editing);
toggle.setAttribute('aria-expanded', editing ? 'true' : 'false');
}
document.querySelectorAll('[data-branding-edit-toggle]').forEach((button) => {
const key = button.getAttribute('data-branding-edit-toggle');
button.addEventListener('click', () => setBrandingMode(key, true));
});
document.querySelectorAll('[data-branding-edit-cancel]').forEach((button) => {
const key = button.getAttribute('data-branding-edit-cancel');
button.addEventListener('click', () => setBrandingMode(key, false));
});
const byId = (id) => document.getElementById(id);
const title = byId('{{ form.portal_title.id_for_label }}');
const company = byId('{{ form.company_name.id_for_label }}');

View File

@@ -15,106 +15,75 @@
{% include 'workflows/includes/messages.html' %}
<section class="branding-sections">
<form method="post" action="{% url 'save_portal_company_config' %}" class="stack-form">
{% csrf_token %}
<section class="branding-block">
<div class="branding-block-head">
<h2>{% trans "Firmenprofil" %}</h2>
<p>{% trans "Rechtlicher Name und zentrale Stammdaten der Firma." %}</p>
{% for section in company_config_sections %}
<section class="branding-block company-inline-block" data-company-section="{{ section.key }}">
<div class="branding-block-head company-inline-head">
<div>
<h2>{{ section.title }}</h2>
<p>{{ section.subtitle }}</p>
</div>
<div class="grid">
<div class="field">
<label for="{{ form.legal_company_name.id_for_label }}">{{ form.legal_company_name.label }}</label>
{{ form.legal_company_name }}
</div>
<div class="field">
<label for="{{ form.phone_number.id_for_label }}">{{ form.phone_number.label }}</label>
{{ form.phone_number }}
</div>
<div class="field">
<label for="{{ form.website_url.id_for_label }}">{{ form.website_url.label }}</label>
{{ form.website_url }}
</div>
<div class="field">
<label for="{{ form.country.id_for_label }}">{{ form.country.label }}</label>
{{ form.country }}
</div>
</div>
</section>
<section class="branding-block">
<div class="branding-block-head">
<h2>{% trans "Adresse & Register" %}</h2>
<p>{% trans "Anschrift sowie optionale Register- und Steuerangaben." %}</p>
</div>
<div class="grid">
<div class="field field-full">
<label for="{{ form.street_address.id_for_label }}">{{ form.street_address.label }}</label>
{{ form.street_address }}
</div>
<div class="field">
<label for="{{ form.postal_code.id_for_label }}">{{ form.postal_code.label }}</label>
{{ form.postal_code }}
</div>
<div class="field">
<label for="{{ form.city.id_for_label }}">{{ form.city.label }}</label>
{{ form.city }}
</div>
<div class="field">
<label for="{{ form.registration_number.id_for_label }}">{{ form.registration_number.label }}</label>
{{ form.registration_number }}
</div>
<div class="field">
<label for="{{ form.vat_id.id_for_label }}">{{ form.vat_id.label }}</label>
{{ form.vat_id }}
</div>
</div>
</section>
<section class="branding-block">
<div class="branding-block-head">
<h2>{% trans "Kontaktpunkte" %}</h2>
<p>{% trans "Zentrale Ansprechpartner für HR, IT und Operations." %}</p>
</div>
<div class="grid">
<div class="field">
<label for="{{ form.hr_contact_email.id_for_label }}">{{ form.hr_contact_email.label }}</label>
{{ form.hr_contact_email }}
</div>
<div class="field">
<label for="{{ form.it_contact_email.id_for_label }}">{{ form.it_contact_email.label }}</label>
{{ form.it_contact_email }}
</div>
<div class="field">
<label for="{{ form.operations_contact_email.id_for_label }}">{{ form.operations_contact_email.label }}</label>
{{ form.operations_contact_email }}
</div>
</div>
</section>
<section class="branding-block">
<div class="branding-block-head">
<h2>{% trans "Recht & Öffentlichkeit" %}</h2>
<p>{% trans "Öffentliche Links für Website, Impressum und Datenschutz." %}</p>
</div>
<div class="grid">
<div class="field">
<label for="{{ form.imprint_url.id_for_label }}">{{ form.imprint_url.label }}</label>
{{ form.imprint_url }}
</div>
<div class="field">
<label for="{{ form.privacy_url.id_for_label }}">{{ form.privacy_url.label }}</label>
{{ form.privacy_url }}
</div>
</div>
<div class="hint">{% trans "Diese Links können später im Portal-Footer oder in öffentlichen Seiten verwendet werden." %}</div>
</section>
<div class="toolbar" style="margin-top:1rem;">
<div class="hint">{% trans "Diese Ebene ist bewusst von Branding getrennt: Hier geht es um strukturierte Firmendaten, nicht um visuelle Gestaltung." %}</div>
<button class="btn btn-primary" type="submit">{% trans "Firmenkonfiguration speichern" %}</button>
<button class="btn btn-secondary company-inline-trigger" type="button" data-company-edit-toggle="{{ section.key }}" aria-expanded="{% if editing_company_section == section.key %}true{% else %}false{% endif %}">{% trans "Bearbeiten" %}</button>
</div>
</form>
<div class="company-inline-view{% if editing_company_section == section.key %} is-hidden{% endif %}" data-company-edit-view="{{ section.key }}">
<div class="grid">
{% for row in section.rows %}
<div class="field{% if row.name == 'street_address' %} field-full{% endif %}">
<label>{{ row.label }}</label>
<div class="company-inline-value">{{ row.value|default:"-" }}</div>
</div>
{% endfor %}
</div>
{% if section.hint %}<div class="hint">{{ section.hint }}</div>{% endif %}
</div>
<form method="post" action="{% url 'save_portal_company_config' %}" class="company-inline-form{% if editing_company_section != section.key %} is-hidden{% endif %}" data-company-edit-form="{{ section.key }}">
{% csrf_token %}
<input type="hidden" name="section_key" value="{{ section.key }}" />
<div class="grid">
{% for row in section.rows %}
<div class="field{% if row.name == 'street_address' %} field-full{% endif %}{% if row.bound_field.errors %} has-error{% endif %}">
<label for="{{ row.bound_field.id_for_label }}">{{ row.label }}</label>
{{ row.bound_field }}
{% if row.bound_field.errors %}<div class="company-inline-error">{{ row.bound_field.errors|join:", " }}</div>{% endif %}
</div>
{% endfor %}
</div>
{% if section.hint %}<div class="hint">{{ section.hint }}</div>{% endif %}
<div class="company-inline-actions">
<button class="btn btn-primary" type="submit">{% trans "Speichern" %}</button>
<button class="btn btn-secondary" type="button" data-company-edit-cancel="{{ section.key }}">{% trans "Abbrechen" %}</button>
</div>
</form>
</section>
{% endfor %}
<div class="toolbar" style="margin-top:1rem;">
<div class="hint">{% trans "Diese Ebene ist bewusst von Branding getrennt: Hier geht es um strukturierte Firmendaten, nicht um visuelle Gestaltung." %}</div>
</div>
</section>
{% endblock %}
{% block extra_scripts %}
<script>
(function () {
function setMode(key, editing) {
var view = document.querySelector('[data-company-edit-view="' + key + '"]');
var form = document.querySelector('[data-company-edit-form="' + key + '"]');
var toggle = document.querySelector('[data-company-edit-toggle="' + key + '"]');
if (!view || !form || !toggle) return;
view.classList.toggle('is-hidden', editing);
form.classList.toggle('is-hidden', !editing);
toggle.setAttribute('aria-expanded', editing ? 'true' : 'false');
}
document.querySelectorAll('[data-company-edit-toggle]').forEach(function (button) {
var key = button.getAttribute('data-company-edit-toggle');
button.addEventListener('click', function () { setMode(key, true); });
});
document.querySelectorAll('[data-company-edit-cancel]').forEach(function (button) {
var key = button.getAttribute('data-company-edit-cancel');
button.addEventListener('click', function () { setMode(key, false); });
});
}());
</script>
{% endblock %}

View File

@@ -3,15 +3,15 @@
{% block title %}{{ portal_title }}{% endblock %}
{% block shell_header %}
{% include 'workflows/includes/app_header.html' with header_show_lang=1 header_inside_shell=1 %}
{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="{% static 'workflows/css/home.css' %}" />
{% endblock %}
{% block shell_body %}
{% include 'workflows/includes/app_header.html' with header_show_lang=1 %}
<div class="hero">
<div class="hero-grid">
<div class="hero-card">