feat(oidc): verknuepfte SSO-Konten in den Einstellungen

Die Endpunkte gab es schon, die Oberflaeche fehlte — es war schlicht nicht
sichtbar, ob und womit ein Konto verknuepft ist.

Unter Einstellungen > Profil, zwischen App-Passwoertern und Privatsphaere,
steht jetzt "Single Sign-On" mit den verknuepften Konten (Anbieter, E-Mail,
letzte Nutzung) und je einem Knopf zum Aufheben. Ohne Verknuepfung erscheint
ein Hinweis, wie man eine anlegt; ist auf dem Server ueberhaupt kein Provider
konfiguriert, bleibt der ganze Abschnitt unsichtbar.

Bei einem per SSO angelegten Konto ist die letzte Verknuepfung nicht
loesbar — es hat kein brauchbares Passwort und waere danach dauerhaft
unerreichbar. Der Knopf ist in dem Fall deaktiviert und erklaert sich per
Tooltip; der Server lehnt es zusaetzlich ab.

Im Browser durchgespielt: verknuepfen, Eintrag erscheint mit Anbieter und
Datum, aufheben, Bestaetigung, leerer Zustand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-10 06:10:43 +02:00
co-authored by Claude Opus 5
parent 725b61c8cb
commit a977ee3c2b
5 changed files with 81 additions and 2 deletions
+8
View File
@@ -806,6 +806,14 @@
</div> </div>
<div id="app-pw-list"></div> <div id="app-pw-list"></div>
<!-- Single sign-on: hidden unless the server has a provider or the
account already holds a linked identity. -->
<div id="sso-section" class="hidden">
<h4 class="panel-title" style="margin-top:24px" data-i18n="sso_link_title">Single Sign-On</h4>
<p class="panel-desc" data-i18n="sso_link_desc">Verknüpfte Konten, mit denen du dich ohne Passwort anmelden kannst.</p>
<div id="sso-identity-list"></div>
</div>
<h4 class="panel-title" style="margin-top:24px" data-i18n="settings_privacy">Privatsphäre</h4> <h4 class="panel-title" style="margin-top:24px" data-i18n="settings_privacy">Privatsphäre</h4>
<p class="panel-desc" data-i18n="settings_private_visibility_desc">Wie private Termine für andere Gruppenmitglieder erscheinen</p> <p class="panel-desc" data-i18n="settings_private_visibility_desc">Wie private Termine für andere Gruppenmitglieder erscheinen</p>
<div class="form-group"> <div class="form-group">
+59
View File
@@ -3398,6 +3398,7 @@ function populateSettings() {
if (dh) dh.checked = !!p.directory_hidden; if (dh) dh.checked = !!p.directory_hidden;
}).catch(() => {}); }).catch(() => {});
initAppPasswords(); initAppPasswords();
initSSOIdentities();
// Show users nav button only for admins // Show users nav button only for admins
const user = JSON.parse(localStorage.getItem('user') || '{}'); const user = JSON.parse(localStorage.getItem('user') || '{}');
@@ -4940,6 +4941,64 @@ function bindProfileModal() {
}; };
} }
// ── Linked SSO accounts — lives in Settings → Profile ──
async function initSSOIdentities() {
const section = document.getElementById('sso-section');
const list = document.getElementById('sso-identity-list');
if (!section || !list) return;
let data;
try {
data = await api.oidcIdentities();
} catch (e) {
section.classList.add('hidden');
return;
}
if (!data) return;
const identities = data.identities || [];
// An SSO-provisioned account cannot fall back to a password, so its last
// link must stay — otherwise the user locks themselves out for good.
const isSSOOnly = data.auth_source === 'sso';
if (!identities.length) {
// Nothing linked: only worth showing if SSO is available at all.
let enabled = false;
try {
const cfg = await api.oidcProviders();
enabled = !!(cfg && cfg.enabled);
} catch (e) { /* treat as unavailable */ }
if (!enabled) { section.classList.add('hidden'); return; }
list.innerHTML = `<p class="text-muted">${t('sso_none_linked')}</p>`;
section.classList.remove('hidden');
return;
}
list.innerHTML = identities.map(i => `<div class="app-pw-item">
<span class="app-pw-name">${escHtml(i.name || i.provider)}</span>
<span class="app-pw-meta">${i.email ? escHtml(i.email) + ' · ' : ''}${
i.last_login_at
? t('sso_last_login') + ' ' + new Date(i.last_login_at).toLocaleDateString(getLocale())
: t('sso_never_used')}</span>
<button class="btn btn-ghost btn-sm sso-unlink" data-id="${i.id}"
${isSSOOnly && identities.length === 1 ? 'disabled title="' + escHtml(t('sso_last_identity_hint')) + '"' : ''}
>${t('sso_unlink')}</button>
</div>`).join('');
section.classList.remove('hidden');
list.querySelectorAll('.sso-unlink').forEach(btn => {
btn.onclick = async () => {
try {
await api.oidcUnlink(btn.dataset.id);
showToast(t('sso_unlinked'));
initSSOIdentities();
} catch (e) {
showToast(e.message === 'oidc_last_identity' ? t('sso_last_identity_hint') : e.message, true);
}
};
});
}
// ── App passwords (CalDAV) — lives in Settings → Profile ── // ── App passwords (CalDAV) — lives in Settings → Profile ──
function initAppPasswords() { function initAppPasswords() {
const appPwList = document.getElementById('app-pw-list'); const appPwList = document.getElementById('app-pw-list');
+12
View File
@@ -39,6 +39,12 @@ const translations = {
sso_err_discovery_failed: 'Anmeldedienst antwortet nicht korrekt', sso_err_discovery_failed: 'Anmeldedienst antwortet nicht korrekt',
sso_err_setup_required: 'Bitte zuerst das Administrator-Konto einrichten', sso_err_setup_required: 'Bitte zuerst das Administrator-Konto einrichten',
sso_link_title: 'Single Sign-On', sso_link_title: 'Single Sign-On',
sso_link_desc: 'Verknüpfte Konten, mit denen du dich ohne Passwort anmelden kannst.',
sso_none_linked: 'Noch kein Konto verknüpft. Melde dich einmal über den SSO-Knopf auf der Anmeldeseite an.',
sso_last_login: 'Zuletzt',
sso_never_used: 'Noch nicht benutzt',
sso_unlinked: 'Verknüpfung aufgehoben',
sso_last_identity_hint: 'Letzte Verknüpfung — dieses Konto hat kein Passwort und wäre danach nicht mehr erreichbar.',
sso_unlink: 'Verknüpfung aufheben', sso_unlink: 'Verknüpfung aufheben',
// Topbar // Topbar
@@ -846,6 +852,12 @@ const translations = {
sso_err_discovery_failed: 'Sign-in service returned an invalid response', sso_err_discovery_failed: 'Sign-in service returned an invalid response',
sso_err_setup_required: 'Please set up the administrator account first', sso_err_setup_required: 'Please set up the administrator account first',
sso_link_title: 'Single sign-on', sso_link_title: 'Single sign-on',
sso_link_desc: 'Linked accounts you can sign in with, without a password.',
sso_none_linked: 'No account linked yet. Sign in once via the SSO button on the login screen.',
sso_last_login: 'Last used',
sso_never_used: 'Not used yet',
sso_unlinked: 'Link removed',
sso_last_identity_hint: 'Last link — this account has no password and would become unreachable.',
sso_unlink: 'Unlink', sso_unlink: 'Unlink',
// Topbar // Topbar
+1 -1
View File
@@ -1,2 +1,2 @@
// Increment APP_VERSION with every code change // Increment APP_VERSION with every code change
export const APP_VERSION = 'v92'; export const APP_VERSION = 'v93';
+1 -1
View File
@@ -7,7 +7,7 @@
// the entry HTML / version files). New releases take effect on the next // the entry HTML / version files). New releases take effect on the next
// reload, no manual SW unregister required. // reload, no manual SW unregister required.
const CACHE_VERSION = 'calendarr-v38'; const CACHE_VERSION = 'calendarr-v39';
const OFFLINE_SHELL = ['/', '/index.html']; const OFFLINE_SHELL = ['/', '/index.html'];
self.addEventListener('install', event => { self.addEventListener('install', event => {