From 40bdf81aa21d149d0e1dc39bb766cf1a7050dd03 Mon Sep 17 00:00:00 2001 From: marc Date: Tue, 18 Aug 2026 17:47:42 +0200 Subject: [PATCH] Say which accounts belong to the provider, and to whom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Has an identity at the provider" and "has no password here" are two different questions, and the interface was answering the second while being asked the first: an account that existed before and was linked — the usual case for whoever sets this up — carried no mark at all in the administration list. The server now reports both. The list marks every linked account, says which of them have no password, and offers to give one rather than to reset something that is not there. The account panel names the provider it signs in through, which is also the missing explanation for the password tab that disappeared. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 19 ++++++++++++ backend/server.js | 11 +++++-- e2e/oidc.mjs | 52 +++++++++++++++++++++++++++++++++ frontend/src/types.ts | 11 ++++--- frontend/src/ui/AccountMenu.jsx | 35 ++++++++++++++++++++-- frontend/src/ui/AdminPanel.jsx | 34 +++++++++++++++++---- 6 files changed, 147 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index baaadc3..710275e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,25 @@ a rebuild is not bit-identical. --- +## 2026-08-18 — Which accounts belong to the provider + +**Images** — web `sha256:5fb51ed4…`, server `sha256:ce1f4649…` + +- **The administration list marks every account the provider can sign in**, not + only the ones it created. Those are two different things and the badge had been + hung on the wrong one: an account that already existed and was *linked* — which + is what happens to the account you were using when you first sign in this way — + showed nothing at all. The row says which it is: an account with no password + here says so, and the button offers to **give** it one rather than to reset + something that does not exist, with a line explaining that this adds a second + way in and takes nothing away. +- **The account panel says how you get in.** The password tab is gone for an + account that has none — it was already, and the server refuses that route too — + but nothing said why. It now names the provider, and tells an account that has + both that the password still works. + +--- + ## 2026-08-18 — A second way in **Images** — web `sha256:232982cb…`, server `sha256:5ed577b1…` diff --git a/backend/server.js b/backend/server.js index ef8c167..bb2bdf9 100644 --- a/backend/server.js +++ b/backend/server.js @@ -278,9 +278,14 @@ const publicUser = (row) => ({ displayName: row.display_name, isAdmin: !!row.is_admin, preferences: readPreferences(row.preferences), - /* Whether this account has a password at all. The interface needs it to stop - * offering "change password" to somebody who signs in through the provider, - * and the administration list to show which accounts those are. */ + /* Two different things, and the interface needs both. + * + * `linkedToProvider` — this account has an identity at the provider and can + * sign in through it. `signsInWithProvider` — it has *no password here at + * all*, which is true of accounts the provider created and not of an existing + * account that was later linked. The first decides whether to say so; the + * second decides whether there is a password to offer changing. */ + linkedToProvider: !!row.oidc_sub, signsInWithProvider: isProviderOnly(row), }); diff --git a/e2e/oidc.mjs b/e2e/oidc.mjs index 227ef6b..63e7d81 100644 --- a/e2e/oidc.mjs +++ b/e2e/oidc.mjs @@ -99,6 +99,54 @@ try { check(!!created, "an account was made for them", newcomer); check(created?.isAdmin === false, "and it is not an administrator"); check(created?.signsInWithProvider === true, "and has no password of its own"); + check(created?.linkedToProvider === true, "and is marked as belonging to the provider"); + + /* ── what the two people involved see ── + * The one signing in: no password tab, because there is no password behind + * it. The administrator: which accounts these are, without asking anybody. */ + await page.getByTitle("Account").click(); + await page.waitForTimeout(900); + const tabs = await page.locator("button").allInnerTexts(); + check( + !(await page.getByRole("button", { name: "Password", exact: true }).count()), + "the account panel offers no password to change", + tabs.join(" | ").slice(0, 120) + ); + check( + (await page.locator("[data-account-sso]").count()) === 1, + "and says which provider it signs in through instead" + ); + check( + (await page.locator("[data-account-sso]").innerText()).includes("Test IdP"), + "by name", + await page.locator("[data-account-sso]").innerText() + ); + await page.keyboard.press("Escape"); + await page.waitForTimeout(500); + + await adminPage.getByTitle("Account").click(); + await adminPage.waitForTimeout(700); + await adminPage.getByRole("button", { name: "Administration" }).click(); + await adminPage.waitForTimeout(1500); + const ssoRow = adminPage.locator(`text=${newcomer}`).locator("xpath=ancestor::div[1]"); + check( + (await adminPage.locator("[data-user-sso]").count()) >= 1, + "the administrator sees which accounts come from the provider", + `${await adminPage.locator("[data-user-sso]").count()} marked` + ); + check( + (await ssoRow.innerText()).includes("SSO"), + "on the row of the account itself", + (await ssoRow.innerText()).replace(/\n/g, " ").slice(0, 100) + ); + check( + (await adminPage.locator("body").innerText()).includes("Give a password"), + "and is offered to give it a password rather than to reset one" + ); + await adminPage.keyboard.press("Escape"); + await adminPage.waitForTimeout(400); + await adminPage.keyboard.press("Escape"); + await adminPage.waitForTimeout(600); /* The address is cleaned, and what was in it was a ticket, not the token. */ const returned = seen.find((u) => u.includes("oidc=")); @@ -163,6 +211,10 @@ try { linked[0]?.signsInWithProvider === false, "and it keeps the password it already had — one way in does not close the other" ); + check( + linked[0]?.linkedToProvider === true, + "while still being shown as one that can use the provider" + ); await second.context().close(); /* ── the ways it must fail ── */ diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 17c6f38..743a35f 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -175,11 +175,14 @@ export interface User { isAdmin?: boolean; /** Interface settings, kept on the account so they follow you to the phone. */ preferences?: UserPreferences; + /** True when this account has an identity at the provider and can sign in with it. */ + linkedToProvider?: boolean; /** - * True when this account has no password of its own and signs in through the - * identity provider. The interface uses it to stop offering a password to - * change; an administrator can still set one, which is the way back in if the - * provider is ever unreachable. + * True when this account has no password of its own — the provider made it. + * The interface uses this to stop offering a password to change; an + * administrator can still set one, which is the way back in if the provider is + * ever unreachable. An account that had a password first and was later linked + * keeps it, and is `linkedToProvider` without being this. */ signsInWithProvider?: boolean; /** diff --git a/frontend/src/ui/AccountMenu.jsx b/frontend/src/ui/AccountMenu.jsx index edcf36d..7e7abfa 100644 --- a/frontend/src/ui/AccountMenu.jsx +++ b/frontend/src/ui/AccountMenu.jsx @@ -24,10 +24,24 @@ export function AccountMenu({ user, onClose, onLogout, onUserUpdate, onOpenAdmin const chooseFont = (noteFont) => choosePreference({ noteFont }); const chooseChapterView = (chapterView) => choosePreference({ chapterView }); const [tab, setTab] = useState("profile"); // profile | username | password - /* An account made by the identity provider has no password here at all, so - the tab for changing one would lead to a form that can only fail. The - server refuses it too — this is the half that says why. */ + /* Two different things. `viaProvider` — this account has no password here at + all, so the tab for changing one would lead to a form that can only fail; + the server refuses it too, and this is the half that says why. `linked` — + it can sign in through the provider, which is also true of an account that + had a password first and kept it. */ const viaProvider = !!user?.signsInWithProvider; + const linked = !!user?.linkedToProvider; + /* Only to put a name to it. Asked here rather than carried on the account, + because it is a server setting and not a property of the person. */ + const [providerName, setProviderName] = useState(""); + useEffect(() => { + if (!linked) return; + let alive = true; + sync.authMethods().then((m) => alive && setProviderName(m?.oidc?.label || "")); + return () => { + alive = false; + }; + }, [linked]); const [displayName, setDisplayName] = useState(user?.displayName || ""); const [newUsername, setNewUsername] = useState(user?.username || ""); const [usernamePass, setUsernamePass] = useState(""); @@ -287,6 +301,21 @@ export function AccountMenu({ user, onClose, onLogout, onUserUpdate, onOpenAdmin {saving ? "Saving..." : "Save Changes"} + {/* How this account gets in. Shown because it explains the tab + that is missing beside it, and because "my password does not + work" is what an account with no password feels like. */} + {linked && ( +
+ Signed in through {providerName || "your identity provider"}. + {viaProvider + ? " This account has no password here; an administrator can give it one if the provider is ever unreachable." + : " The password here still works as well."} +
+ )} + {/* Kept on the account rather than in this browser: a choice about how your own notes are set should follow you to the phone, not be made again on every device. The export is untouched — this diff --git a/frontend/src/ui/AdminPanel.jsx b/frontend/src/ui/AdminPanel.jsx index 0ed3977..407dd1b 100644 --- a/frontend/src/ui/AdminPanel.jsx +++ b/frontend/src/ui/AdminPanel.jsx @@ -281,11 +281,19 @@ export function AdminPanel({ currentUser, onClose, dark }) { ADMIN )} - {/* Which accounts have no password here at all. Setting one - for such an account is deliberately still possible: it is - the way back in if the provider ever goes away. */} - {u.signsInWithProvider && ( + {/* Which accounts can sign in through the provider — an + account created by it and one that merely linked to it + both wear this, because from here they are the same + kind of thing. Whether there is also a password is a + second question, answered on the line below. */} + {u.linkedToProvider && ( {u.displayName} · {u.noteCount} note{u.noteCount === 1 ? "" : "s"} · since{" "} {fmtDate(u.createdAt)} + {/* Said here rather than only in the badge: this is the + account whose owner has nothing to type on the sign-in + screen, which is exactly what an administrator needs to + know before the provider is ever unreachable. */} + {u.signsInWithProvider && <> · no password here} {!u.isSelf && (