feat(caldav): app-specific passwords so MFA accounts can use CalDAV

CalDAV clients send only user+password over Basic Auth and can't provide a TOTP
code, so account passwords would bypass 2FA. Add revocable app passwords:

- models: AppPassword table (bcrypt hash, label, last_used); auto-created via
  create_all
- profile_router: GET/POST/DELETE /profile/app-passwords (plaintext shown once)
- dav_router: Basic Auth accepts any app password; the account password is
  accepted only when 2FA is disabled
- frontend: "App-Passwörter (CalDAV)" section in the profile modal (create/show-
  once/copy/revoke) + i18n (de/en); login hint now says app password

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-01 13:15:35 +02:00
parent fb32f0424f
commit f662163185
8 changed files with 213 additions and 9 deletions

View File

@@ -4144,6 +4144,50 @@ function bindProfileModal() {
document.getElementById('2fa-disable-pw').value = '';
} catch (e) { showToast(e.message, true); }
};
// ── App passwords (CalDAV) ──
const appPwList = document.getElementById('app-pw-list');
document.getElementById('app-pw-new').classList.add('hidden');
document.getElementById('app-pw-new-value').textContent = '';
async function loadAppPasswords() {
try {
const rows = await api.get('/profile/app-passwords');
appPwList.innerHTML = rows.length
? rows.map(r => `<div class="app-pw-item">
<span class="app-pw-name">${escHtml(r.label)}</span>
<span class="app-pw-meta">${r.last_used_at ? t('app_pw_last_used') + ' ' + new Date(r.last_used_at).toLocaleDateString() : t('app_pw_never_used')}</span>
<button class="btn btn-ghost btn-sm app-pw-del" data-id="${r.id}">${t('app_pw_revoke')}</button>
</div>`).join('')
: `<p class="text-muted">${t('app_pw_none')}</p>`;
} catch (e) { /* ignore */ }
}
loadAppPasswords();
document.getElementById('app-pw-create-btn').onclick = async () => {
const label = document.getElementById('app-pw-label').value.trim() || 'CalDAV';
try {
const res = await api.post('/profile/app-passwords', { label });
document.getElementById('app-pw-new-value').textContent = res.password;
document.getElementById('app-pw-new').classList.remove('hidden');
document.getElementById('app-pw-label').value = '';
loadAppPasswords();
} catch (e) { showToast(e.message, true); }
};
document.getElementById('app-pw-copy').onclick = () => {
const v = document.getElementById('app-pw-new-value').textContent;
if (v) navigator.clipboard.writeText(v).then(() => showToast(t('app_pw_copied')));
};
appPwList.onclick = async (e) => {
const btn = e.target.closest('.app-pw-del');
if (!btn) return;
if (!confirm(t('app_pw_revoke_confirm'))) return;
try {
await api.delete(`/profile/app-passwords/${btn.dataset.id}`);
loadAppPasswords();
} catch (err) { showToast(err.message, true); }
};
}
function updateTopbarAvatar(hasAvatar) {