The face from the provider, and whose business a password is

The password tab was hidden for accounts with no password, which is not the
same as accounts that sign in through the provider — an account linked to one
kept its password and therefore kept the tab. That is precisely the account
the person setting this up is using. It goes by "signs in over there" now; the
password it kept is the emergency door, set by an administrator.

And the picture. The provider sends one, the server fetches it at each
sign-in and stores it encrypted in a table beside the users rather than in
them, and serves it back through the API — so the page never talks to the
provider, no third-party cookie is needed for a face to appear, and it
survives the provider being down like everything else here. Fetched once per
account into an object URL, since an img tag cannot carry a bearer token.

The suite's provider now serves a picture too, and the check asks the browser
whether it actually decoded rather than whether the element exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-21 18:30:58 +02:00
1 parent 40bdf81aa2
commit a2b3f91e00
12 files changed
+325 -22

No files matched your search

+1
View File
@@ -999,6 +999,7 @@ export default function App() {
<Sidebar
T={T}
user={user}
dark={dark}
onToggleDark={() => setDark(!dark)}
isMobile={isMobile}
+33
View File
@@ -427,6 +427,7 @@ export async function restoreSession(): Promise<User | Partial<User> | null> {
export async function logout(): Promise<void> {
api.forgetToken();
forgetAvatars();
await db.clearAll();
}
@@ -1461,6 +1462,38 @@ export async function fetchAttachmentBlob(id: string): Promise<Blob> {
return api.getBlob(`/attachments/${id}`);
}
/* ─── Profile pictures ───
*
* From the identity provider, stored on our own server and served from there,
* so nothing on the page talks to the provider and a picture keeps working when
* it is unreachable. It comes through the API with a bearer token, which an
* `<img src>` cannot carry — so the bytes are fetched once and turned into an
* object URL, exactly as an attached image is.
*
* Cached by account for the life of the page, since it is drawn in two places
* and changes only at the next sign-in.
*/
const avatarUrls = new Map<string, Promise<string | null>>();
export function avatarUrl(userId: string): Promise<string | null> {
const known = avatarUrls.get(userId);
if (known) return known;
const pending = api
.getBlob(`/users/${userId}/avatar`)
.then((blob) => URL.createObjectURL(blob))
.catch(() => null);
avatarUrls.set(userId, pending);
return pending;
}
/** Forgets them, so signing out does not leave one person's face for the next. */
export function forgetAvatars(): void {
for (const pending of avatarUrls.values()) {
pending.then((url) => url && URL.revokeObjectURL(url)).catch(() => {});
}
avatarUrls.clear();
}
/* ─── PDFs ───
*
* A PDF rides on the attachment machinery — encrypted at rest, named by content
+23 -14
View File
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from "react";
import * as sync from "../syncEngine";
import { I } from "./icons.jsx";
import { Avatar } from "./Avatar.jsx";
import { useBackdropClose } from "./backdrop";
/* ═══════ ACCOUNT MENU ═══════ */
@@ -176,17 +177,20 @@ export function AccountMenu({ user, onClose, onLogout, onUserUpdate, onOpenAdmin
borderBottom: `1px solid ${T.border}`,
}}
>
<h3
style={{
margin: 0,
fontSize: 17,
fontWeight: 600,
color: T.text,
fontFamily: "'Newsreader',Georgia,serif",
}}
>
Account
</h3>
<div style={{ display: "flex", alignItems: "center", gap: 10, minWidth: 0 }}>
<Avatar user={user} size={28} />
<h3
style={{
margin: 0,
fontSize: 17,
fontWeight: 600,
color: T.text,
fontFamily: "'Newsreader',Georgia,serif",
}}
>
Account
</h3>
</div>
<button
onClick={onClose}
style={{
@@ -226,7 +230,12 @@ export function AccountMenu({ user, onClose, onLogout, onUserUpdate, onOpenAdmin
>
Username
</button>
{!viaProvider && (
{/* Not shown to an account that signs in through the provider — even
one that still has a password here. Passwords for those accounts
are an administrator's business: the one that exists is the way
back in if the provider is unreachable, not a second thing for its
owner to keep. */}
{!linked && (
<button
onClick={() => {
setTab("password");
@@ -312,7 +321,7 @@ export function AccountMenu({ user, onClose, onLogout, onUserUpdate, onOpenAdmin
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."}
: " The password here still works as a way in, and is changed by an administrator."}
</div>
)}
@@ -457,7 +466,7 @@ export function AccountMenu({ user, onClose, onLogout, onUserUpdate, onOpenAdmin
</>
)}
{tab === "password" && (
{tab === "password" && !linked && (
<>
<label style={{ fontSize: 12, color: T.muted, marginBottom: 4, display: "block" }}>
Current Password
+5
View File
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from "react";
import * as sync from "../syncEngine";
import { I } from "./icons.jsx";
import { Avatar } from "./Avatar.jsx";
import { FONT_SERIF, FONT_UI } from "./theme";
import { useBackdropClose } from "./backdrop";
@@ -263,6 +264,10 @@ export function AdminPanel({ currentUser, onClose, dark }) {
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
{/* The picture the provider has of them, when it has one. It
says at a glance which rows are people who came from over
there, which the badge says in words. */}
<Avatar user={u} size={30} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: "flex", alignItems: "center", gap: 7 }}>
<span style={{ fontSize: 14, fontWeight: 600, color: T.text }}>{u.username}</span>
+50
View File
@@ -0,0 +1,50 @@
import { useEffect, useState } from "react";
import * as sync from "../syncEngine";
/**
* The picture an account wears.
*
* It comes from the identity provider and is stored on our own server, so it is
* fetched through the API with a bearer token — which an `<img src>` cannot
* carry. The bytes are turned into an object URL once per account and per page,
* the same way an attached image is drawn.
*
* Nothing is rendered for an account without one. This deliberately does *not*
* fall back to initials in a coloured circle: the places that use it already
* have something to draw — the person icon in the sidebar, a name in the
* administration list — and replacing those for everybody would be a redesign
* on the back of a feature that only some accounts have.
*/
export function Avatar({ user, size = 24, style = {} }) {
const [url, setUrl] = useState(null);
const [broken, setBroken] = useState(false);
useEffect(() => {
setBroken(false);
if (!user?.id || !user?.hasAvatar) {
setUrl(null);
return undefined;
}
let alive = true;
sync.avatarUrl(user.id).then((u) => alive && setUrl(u));
return () => {
alive = false;
};
}, [user?.id, user?.hasAvatar]);
if (!url || broken) return null;
return (
<img
data-avatar={user.id}
src={url}
alt=""
width={size}
height={size}
// A picture from elsewhere is any shape and any proportion; the frame is
// ours, and the picture is cropped into it rather than stretched.
onError={() => setBroken(true)}
style={{ width: size, height: size, borderRadius: "50%", objectFit: "cover", flexShrink: 0, ...style }}
/>
);
}
+6 -1
View File
@@ -2,6 +2,7 @@ import { useState } from "react";
import { DEFAULT_FOLDERS, coverBackground } from "./constants.jsx";
import { I } from "./icons.jsx";
import { Avatar } from "./Avatar.jsx";
import { FONT_UI } from "./theme";
const SYNC_LABELS = {
@@ -128,6 +129,7 @@ function InstallRow({ T, canInstall, reason, onInstall }) {
export function Sidebar({
T,
user,
dark,
onToggleDark,
isMobile,
@@ -217,7 +219,10 @@ export function Sidebar({
{dark ? I.sun : I.moon}
</button>
<button onClick={onOpenAccount} title="Account" style={iconButton(T)}>
{I.user}
{/* The picture instead of the person icon, for an account that has
one. Not initials for the rest: the icon is what this button
has always been, and only some accounts bring a face. */}
{user?.hasAvatar ? <Avatar user={user} size={20} /> : I.user}
</button>
</div>
</div>