diff --git a/frontend/index.html b/frontend/index.html
index f0e76c5..06432f8 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -806,6 +806,14 @@
+
+
+
Single Sign-On
+
Verknüpfte Konten, mit denen du dich ohne Passwort anmelden kannst.
+
+
+
Privatsphäre
Wie private Termine für andere Gruppenmitglieder erscheinen
diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js
index 9ef4e9a..41f62bb 100644
--- a/frontend/js/calendar.js
+++ b/frontend/js/calendar.js
@@ -3398,6 +3398,7 @@ function populateSettings() {
if (dh) dh.checked = !!p.directory_hidden;
}).catch(() => {});
initAppPasswords();
+ initSSOIdentities();
// Show users nav button only for admins
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 = `
${t('sso_none_linked')}
`;
+ section.classList.remove('hidden');
+ return;
+ }
+
+ list.innerHTML = identities.map(i => `
+ ${escHtml(i.name || i.provider)}
+ ${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')}
+
+
`).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 ──
function initAppPasswords() {
const appPwList = document.getElementById('app-pw-list');
diff --git a/frontend/js/i18n.js b/frontend/js/i18n.js
index d500c72..d00b716 100644
--- a/frontend/js/i18n.js
+++ b/frontend/js/i18n.js
@@ -39,6 +39,12 @@ const translations = {
sso_err_discovery_failed: 'Anmeldedienst antwortet nicht korrekt',
sso_err_setup_required: 'Bitte zuerst das Administrator-Konto einrichten',
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',
// Topbar
@@ -846,6 +852,12 @@ const translations = {
sso_err_discovery_failed: 'Sign-in service returned an invalid response',
sso_err_setup_required: 'Please set up the administrator account first',
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',
// Topbar
diff --git a/frontend/js/version.js b/frontend/js/version.js
index c8e2cb0..d8186c2 100644
--- a/frontend/js/version.js
+++ b/frontend/js/version.js
@@ -1,2 +1,2 @@
// Increment APP_VERSION with every code change
-export const APP_VERSION = 'v92';
+export const APP_VERSION = 'v93';
diff --git a/frontend/sw.js b/frontend/sw.js
index 836c5d7..6c3e4c4 100644
--- a/frontend/sw.js
+++ b/frontend/sw.js
@@ -7,7 +7,7 @@
// the entry HTML / version files). New releases take effect on the next
// reload, no manual SW unregister required.
-const CACHE_VERSION = 'calendarr-v38';
+const CACHE_VERSION = 'calendarr-v39';
const OFFLINE_SHELL = ['/', '/index.html'];
self.addEventListener('install', event => {