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:
1 parent
40bdf81aa2
commit
a2b3f91e00
12 files changed
+325
-22
No files matched your search
@@ -999,6 +999,7 @@ export default function App() {
|
||||
|
||||
<Sidebar
|
||||
T={T}
|
||||
user={user}
|
||||
dark={dark}
|
||||
onToggleDark={() => setDark(!dark)}
|
||||
isMobile={isMobile}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user