Say which accounts belong to the provider, and to whom

"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 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-18 17:47:42 +02:00
1 parent cd70a8c8bb
commit 40bdf81aa2
6 files changed
+147 -15

No files matched your search

+7 -4
View File
@@ -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;
/**
+32 -3
View File
@@ -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"}
</button>
{/* 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 && (
<div
data-account-sso
style={{ marginTop: 18, fontSize: 11.5, color: T.muted, lineHeight: 1.55 }}
>
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."}
</div>
)}
{/* 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
+29 -5
View File
@@ -281,11 +281,19 @@ export function AdminPanel({ currentUser, onClose, dark }) {
ADMIN
</span>
)}
{/* 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 && (
<span
data-user-sso
title={
u.signsInWithProvider
? "Signs in through the identity provider, and has no password here"
: "Can sign in through the identity provider, and has a password here as well"
}
style={{
fontSize: 10,
fontWeight: 700,
@@ -304,6 +312,11 @@ export function AdminPanel({ currentUser, onClose, dark }) {
<div style={{ fontSize: 11, color: T.muted, marginTop: 3 }}>
{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</>}
</div>
</div>
<button
@@ -314,7 +327,7 @@ export function AdminPanel({ currentUser, onClose, dark }) {
}}
style={{ ...btn("ghost"), whiteSpace: "nowrap" }}
>
Reset password
{u.signsInWithProvider ? "Give a password" : "Reset password"}
</button>
{!u.isSelf && (
<button
@@ -345,6 +358,17 @@ export function AdminPanel({ currentUser, onClose, dark }) {
{resetFor === u.id && (
<div style={{ marginTop: 10, paddingTop: 10, borderTop: `1px solid ${T.border}` }}>
{/* The way back in. Worth saying, because on an account with
no password this reads like an odd thing to be offered —
and because it adds a way in rather than replacing the
one the provider gives. */}
{u.signsInWithProvider && (
<div style={{ fontSize: 11.5, color: T.muted, marginBottom: 8, lineHeight: 1.5 }}>
{u.username} signs in through the identity provider and has no password here. Giving
one adds a second way in — useful the day the provider cannot be reached — and does
not take the first one away.
</div>
)}
<input
type="password"
value={resetPass}