diora-web/templates/accounts/settings.html
mrwnslz cec07dd208
Some checks failed
Test / e2e (push) Waiting to run
Build and push Docker image / build (push) Successful in 15s
Test / test (push) Has been cancelled
Bücher: „Eingang“ – eine Cloud-Quelle bringt ihre neuen Bücher von selbst in die Bibliothek (Gerät holt, verschlüsselt, lädt hoch; Server merkt sich, was übernommen ist); sw v147
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 18:12:39 +02:00

317 lines
15 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 title %}Einstellungen — diora{% endblock %}
{% block content %}
<div class="settings-container">
<h1 class="settings-title">Einstellungen</h1>
{% if lastfm_error %}
<div class="message message-error">{{ lastfm_error }}</div>
{% endif %}
<!-- Last.fm section -->
<section class="settings-section">
<h2>Last.fm</h2>
{% if has_lastfm %}
<div class="lastfm-connected">
<p class="connected-status">
Verbunden als <strong>{{ profile.lastfm_username }}</strong>
</p>
<form method="post" class="settings-form" id="scrobble-form">
{% csrf_token %}
<label class="checkbox-label">
<input type="checkbox" name="lastfm_scrobble" id="lastfm_scrobble"
{% if profile.lastfm_scrobble %}checked{% endif %}
onchange="document.getElementById('scrobble-form').submit()">
Titel an Last.fm scrobbeln
</label>
</form>
<form method="post" action="{% url 'lastfm_disconnect' %}" class="inline-form" style="margin-top: 1rem;">
{% csrf_token %}
<button type="submit" class="btn btn-danger">Last.fm trennen</button>
</form>
</div>
{% else %}
<p class="lastfm-description">
Verbinde dein Last.fm-Konto, damit gehörte Titel automatisch gescrobbelt werden.
</p>
<a href="{% url 'lastfm_connect' %}" class="btn btn-lastfm">Last.fm verbinden</a>
{% endif %}
</section>
<!-- Background section -->
<section class="settings-section">
<h2>Hintergrund</h2>
{% if request.user.profile.background_encrypted %}
<p class="lastfm-description">Ein verschlüsselter Hintergrund ist gesetzt. <span class="muted">(Keine Vorschau – er wird erst auf der Hauptseite im Browser entschlüsselt.)</span></p>
<form method="post" action="{% url 'delete_background' %}" class="inline-form">
{% csrf_token %}
<button type="submit" class="btn btn-danger">Hintergrund entfernen</button>
</form>
<p style="margin-top:12px;">Neues Bild hochladen, um ihn zu ersetzen:</p>
{% elif request.user.profile.background_image_data %}
<p>
<img src="{{ request.user.profile.background_image_data }}" class="bg-preview" alt="Dein Hintergrund">
</p>
<form method="post" action="{% url 'delete_background' %}" class="inline-form">
{% csrf_token %}
<button type="submit" class="btn btn-danger">Hintergrund entfernen</button>
</form>
<p style="margin-top:12px;">Neues Bild hochladen, um ihn zu ersetzen (wird verschlüsselt gespeichert):</p>
{% else %}
<p class="lastfm-description">Eigenes Hintergrundbild hochladen (JPG, PNG oder WebP, höchstens 5 MB). Es wird Ende-zu-Ende-verschlüsselt gespeichert.</p>
{% endif %}
<div style="margin-top:8px; display:flex; align-items:center; gap:10px;">
<label class="btn" for="bg-upload-input">Datei auswählen</label>
<input type="file" id="bg-upload-input" accept=".jpg,.jpeg,.png,.webp" style="display:none;" onchange="uploadBackground(this)">
<span id="bg-upload-status" style="font-size:0.85rem; color:#888;"></span>
</div>
</section>
<!-- Account section -->
<section class="settings-section">
<h2>Konto</h2>
<p>Angemeldet als <strong>{{ request.user.username }}</strong></p>
<details {% if password_form_open %}open{% endif %} style="margin-top:1rem;">
<summary class="btn" style="display:inline-block;cursor:pointer;">Passwort ändern</summary>
<form id="pw-change-form" method="post" action="{% url 'change_password' %}" style="margin-top:1rem; display:flex; flex-direction:column; gap:0.6rem; max-width:320px;">
{% csrf_token %}
{% for field in password_form %}
<div>
<label style="display:block; font-size:0.85rem; margin-bottom:2px;">{{ field.label }}</label>
{{ field }}
{% if field.errors %}
<div class="message message-error" style="margin-top:4px; padding:4px 8px; font-size:0.8rem;">{{ field.errors|join:", " }}</div>
{% endif %}
</div>
{% endfor %}
{% if password_form.non_field_errors %}
<div class="message message-error" style="padding:4px 8px; font-size:0.8rem;">{{ password_form.non_field_errors|join:", " }}</div>
{% endif %}
<div id="pw-change-status" style="font-size:0.85rem; color:#888;"></div>
<div><button type="submit" class="btn">Speichern</button></div>
</form>
</details>
<form method="post" action="{% url 'logout' %}" class="inline-form" style="margin-top:1rem;">
{% csrf_token %}
<button type="submit" class="btn">Abmelden</button>
</form>
</section>
<!-- API token section -->
<section class="settings-section">
<h2>API-Zugriff für lokale Apps</h2>
<p class="lastfm-description">
Mit einem persönlichen Token können eigene Apps auf deine diora-Daten zugreifen (Bücher,
Lesefortschritt, Notizen, Podcasts, Radio) — ohne dein Passwort zu teilen. Token als
<code>Authorization: Bearer &lt;token&gt;</code>-Header mitschicken, z. B. gegen
<code>GET /api/sync/</code> für einen kompletten Datenabzug.
</p>
{% if api_token %}
<p style="margin-top:0.75rem;">
<code style="user-select:all; word-break:break-all; background:#1a1a1a; padding:4px 8px; border-radius:4px; display:inline-block;">{{ api_token.token }}</code>
</p>
<form method="post" action="{% url 'regenerate_api_token' %}" class="inline-form" style="margin-top:0.75rem;"
onsubmit="return confirm('Neues Token generieren? Bereits verbundene Apps können sich dann nicht mehr anmelden.');">
{% csrf_token %}
<button type="submit" class="btn btn-danger">Token neu generieren</button>
</form>
{% else %}
<form method="post" action="{% url 'regenerate_api_token' %}" class="inline-form" style="margin-top:0.75rem;">
{% csrf_token %}
<button type="submit" class="btn">Token generieren</button>
</form>
{% endif %}
</section>
<!-- Cloud connections (WebDAV / Nextcloud) for the ebook import -->
<section class="settings-section">
<h2>Cloud-Verbindungen für Bücher</h2>
<p class="lastfm-description">
Verbinde einen WebDAV-Server — Nextcloud, ownCloud, Synology oder was auch immer WebDAV
spricht — und importiere <code>.epub</code>- und <code>.pdf</code>-Dateien direkt daraus in
deine Bibliothek. Die Datei läuft dabei nur durch den Server hindurch; verschlüsselt wird sie
wie immer erst in deinem Browser, gespeichert wird ausschließlich der Geheimtext.
</p>
<p class="lastfm-description">
Lege dafür bitte ein <strong>App-Passwort</strong> an (bei Nextcloud unter
Einstellungen → Sicherheit) statt dein Konto-Passwort einzutragen — es wird serverseitig
gespeichert und lässt sich jederzeit einzeln widerrufen.
</p>
{% if webdav_sources %}
<ul class="webdav-list">
{% for source in webdav_sources %}
<li class="webdav-item">
<div class="webdav-item-info">
<strong>{{ source.label }}</strong>{% if source.inbox %} <span class="muted">· Eingang</span>{% endif %}
<span class="muted">{{ source.base_url }}{% if source.root_path %} · /{{ source.root_path }}{% endif %}</span>
</div>
<div class="webdav-item-actions">
<button type="button" class="btn" aria-expanded="false"
onclick="const f = document.getElementById('webdav-edit-{{ source.pk }}'); f.hidden = !f.hidden; this.setAttribute('aria-expanded', String(!f.hidden));">Bearbeiten</button>
<form method="post" action="{% url 'webdav_test' source.pk %}" class="inline-form">
{% csrf_token %}
<button type="submit" class="btn">Testen</button>
</form>
<form method="post" action="{% url 'webdav_delete' source.pk %}" class="inline-form"
onsubmit="return confirm('Verbindung „{{ source.label|escapejs }}“ entfernen?');">
{% csrf_token %}
<button type="submit" class="btn btn-danger">Entfernen</button>
</form>
</div>
<form method="post" action="{% url 'webdav_edit' source.pk %}" class="webdav-form webdav-edit"
id="webdav-edit-{{ source.pk }}" hidden>
{% csrf_token %}
<label class="webdav-field">
<span>Name</span>
<input type="text" name="label" required value="{{ source.label }}">
</label>
<label class="webdav-field">
<span>Server-URL</span>
<input type="url" name="base_url" required value="{{ source.base_url }}">
</label>
<label class="webdav-field">
<span>Benutzername</span>
<input type="text" name="username" autocomplete="off" value="{{ source.username }}">
</label>
<label class="webdav-field">
<span>App-Passwort <span class="muted">(leer lassen = unverändert)</span></span>
<input type="password" name="password" autocomplete="new-password">
</label>
<label class="webdav-field">
<span>Unterordner <span class="muted">(optional, Import startet dort)</span></span>
<input type="text" name="root_path" value="{{ source.root_path }}" placeholder="Buecher">
</label>
<label class="webdav-field webdav-check">
<input type="checkbox" name="inbox" value="1"{% if source.inbox %} checked{% endif %}>
<span>Eingang: neue Bücher hier kommen von selbst in die Bibliothek
<span class="muted">(beim Öffnen der Bücher; der Ordner bleibt unverändert)</span></span>
</label>
<button type="submit" class="btn">Speichern</button>
</form>
</li>
{% endfor %}
</ul>
{% endif %}
<form method="post" action="{% url 'webdav_add' %}" class="webdav-form">
{% csrf_token %}
<label class="webdav-field">
<span>Name</span>
<input type="text" name="label" required placeholder="Meine Nextcloud">
</label>
<label class="webdav-field">
<span>Server-URL</span>
<input type="url" name="base_url" required placeholder="https://cloud.example.com">
</label>
<label class="webdav-field">
<span>Benutzername</span>
<input type="text" name="username" autocomplete="off" placeholder="dein-login">
</label>
<label class="webdav-field">
<span>App-Passwort</span>
<input type="password" name="password" autocomplete="new-password">
</label>
<label class="webdav-field">
<span>Unterordner <span class="muted">(optional)</span></span>
<input type="text" name="root_path" placeholder="Buecher">
</label>
<label class="webdav-field webdav-check">
<input type="checkbox" name="inbox" value="1">
<span>Eingang: neue Bücher hier kommen von selbst in die Bibliothek</span>
</label>
<p class="lastfm-description" style="margin:0;">
Bei Nextcloud/ownCloud genügt die Server-Adresse — der WebDAV-Pfad wird aus dem
Benutzernamen ergänzt. Andere Server brauchen die vollständige WebDAV-URL.
</p>
<button type="submit" class="btn">Verbindung hinzufügen</button>
</form>
</section>
</div>
{% endblock %}
{% block extra_js %}
<style>
.bg-preview { max-width: 240px; max-height: 135px; object-fit: cover; border-radius: 4px; border: 1px solid #333; }
</style>
<script>
function getCsrfToken() {
return document.cookie.split('; ').find(r => r.startsWith('csrftoken='))?.split('=')[1] || '';
}
// Minimal crypto helpers for settings page (duplicated from app.js — settings page doesn't load app.js)
function _bytesToBase64(buf) {
const bytes = new Uint8Array(buf);
let str = '';
for (const b of bytes) str += String.fromCharCode(b);
return btoa(str);
}
function _bytesToHex(buf) {
return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('');
}
function _base64ToBytes(b64) {
const str = atob(b64);
const buf = new Uint8Array(str.length);
for (let i = 0; i < str.length; i++) buf[i] = str.charCodeAt(i);
return buf;
}
async function _getOrCreateEncKey() {
const userId = {{ request.user.id }};
const storageKey = `diora_enc_key_${userId}`;
const stored = localStorage.getItem(storageKey);
if (stored) {
try {
const raw = _base64ToBytes(stored);
return crypto.subtle.importKey('raw', raw, {name: 'AES-GCM'}, false, ['encrypt', 'decrypt']);
} catch (e) {}
}
const key = await crypto.subtle.generateKey({name: 'AES-GCM', length: 256}, true, ['encrypt', 'decrypt']);
const raw = await crypto.subtle.exportKey('raw', key);
localStorage.setItem(storageKey, _bytesToBase64(raw));
return key;
}
document.getElementById('pw-change-form')?.addEventListener('submit', function(e) {
const ok = confirm('Nach dem Ändern des Passworts können alle hochgeladenen Bücher nicht mehr geöffnet werden und müssen erneut hochgeladen werden. Fortfahren?');
if (!ok) e.preventDefault();
});
async function uploadBackground(input) {
const file = input.files[0];
if (!file) return;
const status = document.getElementById('bg-upload-status');
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];
if (!allowedTypes.includes(file.type)) {
status.textContent = 'Nur JPEG-, PNG- oder WebP-Bilder sind erlaubt.';
return;
}
if (file.size > 5 * 1024 * 1024) {
status.textContent = 'Das Bild darf höchstens 5 MB groß sein.';
return;
}
status.textContent = 'Verschlüssele …';
try {
const key = await _getOrCreateEncKey();
const buf = await file.arrayBuffer();
const iv = crypto.getRandomValues(new Uint8Array(12));
const ct = await crypto.subtle.encrypt({name: 'AES-GCM', iv}, key, buf);
status.textContent = 'Lade hoch …';
const res = await fetch('/accounts/background/upload/', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
body: JSON.stringify({iv: _bytesToHex(iv), ciphertext: _bytesToBase64(ct), mime_type: file.type, file_size: file.size}),
});
const data = await res.json();
if (data.ok) { location.reload(); }
else { status.textContent = data.error === 'only jpeg, png, or webp allowed' ? 'Nur JPEG, PNG oder WebP erlaubt.'
: data.error === 'file too large (max 5 MB)' ? 'Datei zu groß (höchstens 5 MB).' : (data.error || 'Hochladen fehlgeschlagen'); }
} catch (e) {
status.textContent = 'Hochladen fehlgeschlagen: ' + e.message;
}
input.value = '';
}
</script>
{% endblock %}