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 && (