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:
@@ -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) {
|
||||
|
||||
@@ -124,7 +124,18 @@ const translations = {
|
||||
caldav_rotate_confirm: 'Neuen Token erzeugen? Die bisherige URL wird ungültig und bestehende Abos müssen mit der neuen URL neu eingerichtet werden.',
|
||||
caldav_hint: 'Jeder mit dieser URL kann diesen Kalender abonnieren und bearbeiten – kein Login nötig. Über CalDAV-fähige Clients (Apple Kalender, Thunderbird, DAVx5) einbinden.',
|
||||
caldav_login_url: 'CalDAV-URL (mit Login):',
|
||||
caldav_login_hint: 'Alternativ im Client ein „CalDAV-Konto" mit dieser Server-URL sowie deinem Benutzernamen und Passwort hinzufügen – dann werden alle deine veröffentlichten Kalender gefunden.',
|
||||
caldav_login_hint: 'Alternativ im Client ein „CalDAV-Konto" mit dieser Server-URL sowie deinem Benutzernamen und App-Passwort hinzufügen – dann werden alle deine veröffentlichten Kalender gefunden.',
|
||||
app_pw_title: 'App-Passwörter (CalDAV)',
|
||||
app_pw_desc: 'Eigene Passwörter für CalDAV-Clients. Bei aktivem 2FA nötig, da Apps keinen 2FA-Code eingeben können. Jederzeit widerrufbar.',
|
||||
app_pw_label_ph: 'Name (z.B. iPhone)',
|
||||
app_pw_create: 'Erstellen',
|
||||
app_pw_new_label: 'Neues App-Passwort (nur jetzt sichtbar):',
|
||||
app_pw_last_used: 'zuletzt',
|
||||
app_pw_never_used: 'noch nie genutzt',
|
||||
app_pw_revoke: 'Widerrufen',
|
||||
app_pw_none: 'Noch keine App-Passwörter.',
|
||||
app_pw_copied: 'App-Passwort kopiert',
|
||||
app_pw_revoke_confirm: 'Dieses App-Passwort widerrufen? Clients, die es nutzen, verlieren den Zugriff.',
|
||||
share: 'Teilen',
|
||||
import: 'Importieren',
|
||||
export: 'Exportieren',
|
||||
@@ -428,7 +439,18 @@ const translations = {
|
||||
caldav_rotate_confirm: 'Generate a new token? The current URL will stop working and existing subscriptions must be re-added with the new URL.',
|
||||
caldav_hint: 'Anyone with this URL can subscribe to and edit this calendar — no login required. Add it in a CalDAV-capable client (Apple Calendar, Thunderbird, DAVx5).',
|
||||
caldav_login_url: 'CalDAV URL (with login):',
|
||||
caldav_login_hint: 'Alternatively add a "CalDAV account" in your client using this server URL plus your username and password — it will discover all your published calendars.',
|
||||
caldav_login_hint: 'Alternatively add a "CalDAV account" in your client using this server URL plus your username and app password — it will discover all your published calendars.',
|
||||
app_pw_title: 'App passwords (CalDAV)',
|
||||
app_pw_desc: "Dedicated passwords for CalDAV clients. Required when 2FA is on, since apps can't enter a 2FA code. Revocable anytime.",
|
||||
app_pw_label_ph: 'Name (e.g. iPhone)',
|
||||
app_pw_create: 'Create',
|
||||
app_pw_new_label: 'New app password (shown only now):',
|
||||
app_pw_last_used: 'last used',
|
||||
app_pw_never_used: 'never used',
|
||||
app_pw_revoke: 'Revoke',
|
||||
app_pw_none: 'No app passwords yet.',
|
||||
app_pw_copied: 'App password copied',
|
||||
app_pw_revoke_confirm: 'Revoke this app password? Clients using it will lose access.',
|
||||
share: 'Share',
|
||||
import: 'Import',
|
||||
export: 'Export',
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Increment APP_VERSION with every code change
|
||||
export const APP_VERSION = 'v67';
|
||||
export const APP_VERSION = 'v68';
|
||||
|
||||
Reference in New Issue
Block a user