diff --git a/docs/features/site-shell.md b/docs/features/site-shell.md
index c0bcc632c..2c34a4270 100644
--- a/docs/features/site-shell.md
+++ b/docs/features/site-shell.md
@@ -601,8 +601,15 @@ inbound frame names the lineage, then sends them as one update — so a row
created and placed inside the bind round trip is never refused. Two more socket-level defenses: per-frame payload caps (64 KB
awareness / 4 MB sync, plus the transport `maxPayloadLength`) drop oversized
frames before any decode work, and every awareness frame is decoded and
-checked against the session — a state claiming another user's identity
-(`state.user.id !== session user`) is dropped, so presence can't be spoofed.
+checked against the session — a state whose identity (id, name, avatar,
+Gravatar hash) differs from the session's is dropped, so presence can't be
+spoofed. The session name is `userDisplayLabel(user)`
+(`src/core/users/displayName.ts`): the display name, or the email address
+when the admin has none ("Your name" is optional at setup). The editor
+publishes the same label, so collaborators see the **email address** of an
+admin without a display name. Before, such an admin published no presence
+at all: no cursor, no selection ring, and no "someone else is editing"
+guard.
**Client transport** (`src/admin/pages/site/collab/collabProvider.ts`): one
socket, every bound doc multiplexed; local transactions send updates the
diff --git a/server/collab/socket.ts b/server/collab/socket.ts
index 790b99203..56d933b8b 100644
--- a/server/collab/socket.ts
+++ b/server/collab/socket.ts
@@ -54,6 +54,7 @@ import { originAllowed } from '../auth/security'
import type { DbClient } from '../db/client'
import { jsonResponse } from '../http'
import { BranchGoneError } from './relayBranches'
+import { userDisplayLabel } from '@core/users/displayName'
import type { CollabRelay, RelayDoc } from './relay'
export { SITE_SOCKET_PATH }
@@ -215,7 +216,7 @@ export async function handleCollabSocketUpgrade(
userId: user.id,
identity: {
id: user.id,
- name: user.displayName,
+ name: userDisplayLabel(user),
avatarUrl: user.avatarUrl,
gravatarHash: user.gravatarHash,
},
diff --git a/src/__tests__/server/collabRelayIntegration.test.ts b/src/__tests__/server/collabRelayIntegration.test.ts
index d11c1f848..b0a02c894 100644
--- a/src/__tests__/server/collabRelayIntegration.test.ts
+++ b/src/__tests__/server/collabRelayIntegration.test.ts
@@ -14,6 +14,7 @@
* - `runPublishFlush` drains the persist debounce, so publish bakes the
* edit an admin made seconds earlier instead of losing it.
*/
+import { userDisplayLabel } from '@core/users/displayName'
import { afterEach, describe, expect, it } from 'bun:test'
import * as Y from 'yjs'
import * as awarenessProtocol from 'y-protocols/awareness'
@@ -349,7 +350,7 @@ describe('collab relay integration (real server, real sockets)', () => {
const viewerUserId = viewerUser!.id
const realIdentity = {
id: viewerUserId,
- name: viewerUser!.displayName,
+ name: userDisplayLabel(viewerUser!),
color: peerColor(viewerUserId),
avatarUrl: viewerUser!.avatarUrl,
gravatarHash: viewerUser!.gravatarHash,
@@ -369,7 +370,7 @@ describe('collab relay integration (real server, real sockets)', () => {
for (const [, state] of owner.awareness.getStates()) {
const s = state as { user?: { id?: string; name?: string } }
expect(s.user?.id).not.toBe('someone-else')
- if (s.user?.id === viewerUserId) expect(s.user?.name).toBe(viewerUser!.displayName)
+ if (s.user?.id === viewerUserId) expect(s.user?.name).toBe(userDisplayLabel(viewerUser!))
}
})
@@ -431,7 +432,7 @@ describe('collab relay integration (real server, real sockets)', () => {
const user = (await findUserByEmail(stack.harness.db, email))!
return {
id: user.id,
- name: user.displayName,
+ name: userDisplayLabel(user),
color: peerColor(user.id),
avatarUrl: user.avatarUrl,
gravatarHash: user.gravatarHash,
diff --git a/src/__tests__/server/collabSocket.test.ts b/src/__tests__/server/collabSocket.test.ts
index a027ff774..0496ab4a7 100644
--- a/src/__tests__/server/collabSocket.test.ts
+++ b/src/__tests__/server/collabSocket.test.ts
@@ -115,6 +115,23 @@ describe('collab socket — upgrade gating', () => {
expect(readOnlyServer.data()?.fullSiteWriter).toBe(false)
})
+ // "Your name" is optional: without a display name the session identity (the
+ // only presence name the relay forwards) must still be non-empty and match
+ // what the editor publishes (userDisplayLabel) — otherwise that admin's
+ // presence never reaches anyone.
+ it('names an admin without a display name by email in the presence identity', async () => {
+ const { harness } = await setup()
+ const nameless = await harness.createRoleUser({
+ name: 'Nameless',
+ slug: 'site-nameless',
+ displayName: '',
+ capabilities: ['site.read'],
+ })
+ const server = fakeServer(true)
+ expect(await handleCollabSocketUpgrade(socketRequest({ cookie: nameless.cookie }), harness.db, server)).toBeNull()
+ expect(server.data()?.identity.name).toBe(nameless.email)
+ })
+
it('returns 426 when the request is not an upgradable WebSocket handshake', async () => {
const { harness, cookie } = await setup()
const res = await handleCollabSocketUpgrade(socketRequest({ cookie }), harness.db, fakeServer(false))
diff --git a/src/__tests__/users/displayName.test.ts b/src/__tests__/users/displayName.test.ts
new file mode 100644
index 000000000..9e860084a
--- /dev/null
+++ b/src/__tests__/users/displayName.test.ts
@@ -0,0 +1,10 @@
+import { describe, it, expect } from 'bun:test'
+import { userDisplayLabel } from '@core/users/displayName'
+
+describe('userDisplayLabel', () => {
+ it('uses the trimmed display name, else the email', () => {
+ expect(userDisplayLabel({ displayName: ' Alex ', email: 'n@x.de' })).toBe('Alex')
+ expect(userDisplayLabel({ displayName: ' ', email: 'n@x.de' })).toBe('n@x.de')
+ expect(userDisplayLabel({ displayName: '', email: 'n@x.de' })).toBe('n@x.de')
+ })
+})
diff --git a/src/admin/pages/account/tabs/ProfileTab.tsx b/src/admin/pages/account/tabs/ProfileTab.tsx
index 887138335..6c9861c69 100644
--- a/src/admin/pages/account/tabs/ProfileTab.tsx
+++ b/src/admin/pages/account/tabs/ProfileTab.tsx
@@ -23,6 +23,7 @@ import { UserAvatar } from '@admin/shared/UserAvatar'
import styles from '../AccountPage.module.css'
import { getErrorMessage } from '@core/utils/errorMessage'
import { isStepUpCancelled } from './securityErrors'
+import { userDisplayLabel } from '@core/users/displayName'
interface ProfileTabProps {
user: CmsCurrentUser
@@ -86,7 +87,7 @@ export function ProfileTab({ user }: ProfileTabProps) {
const displayNameId = useId()
const emailId = useId()
- const displayName = user.displayName.trim() || user.email
+ const displayName = userDisplayLabel(user)
const hasUploadedAvatar = user.avatarUrl !== null
const profileDirty =
profileForm.displayName.trim() !== user.displayName ||
diff --git a/src/admin/pages/dashboard/widgets/ActivityWidget.tsx b/src/admin/pages/dashboard/widgets/ActivityWidget.tsx
index e486b112e..9fdc06e14 100644
--- a/src/admin/pages/dashboard/widgets/ActivityWidget.tsx
+++ b/src/admin/pages/dashboard/widgets/ActivityWidget.tsx
@@ -20,6 +20,7 @@ import {
type DashboardActivityEntry,
} from '../hooks/useDashboardStats'
import styles from './widgets.module.css'
+import { userDisplayLabel } from '@core/users/displayName'
/**
* Diameter of the actor avatar in the feed row. Picked to fit the
@@ -187,7 +188,7 @@ export function ActivityWidget({ span, editing }: DashboardWidgetRendererProps)
) : (
diff --git a/src/admin/pages/site/canvas/CanvasRoot.tsx b/src/admin/pages/site/canvas/CanvasRoot.tsx
index 0bf9dc8b1..dc4c59f59 100644
--- a/src/admin/pages/site/canvas/CanvasRoot.tsx
+++ b/src/admin/pages/site/canvas/CanvasRoot.tsx
@@ -58,6 +58,7 @@ import { useConfirmDelete } from '@admin/shared/dialogs/ConfirmDeleteDialog'
import { useEditorPreference, readEditorSelectPreference } from '@site/preferences/editorPreferences'
import { useTemplatePreviewContext } from '@site/hooks/useTemplatePreviewContext'
import styles from './CanvasRoot.module.css'
+import { userDisplayLabel } from '@core/users/displayName'
const VisualComponentModeControl = lazy(() =>
import('./VisualComponentModeControl').then((module) => ({ default: module.default })),
@@ -92,7 +93,10 @@ export function CanvasRoot({ editable = true }: CanvasRootProps) {
currentUser
? {
id: currentUser.id,
- name: currentUser.displayName,
+ // Never empty: an admin without a display name would otherwise
+ // publish no presence at all (peers saw no cursor, and the
+ // "someone else is editing" guards never fired).
+ name: userDisplayLabel(currentUser),
avatarUrl: currentUser.avatarUrl,
gravatarHash: currentUser.gravatarHash,
}
diff --git a/src/admin/pages/users/tabs/UsersTab.tsx b/src/admin/pages/users/tabs/UsersTab.tsx
index e89b96656..e9bb51071 100644
--- a/src/admin/pages/users/tabs/UsersTab.tsx
+++ b/src/admin/pages/users/tabs/UsersTab.tsx
@@ -40,7 +40,8 @@ import { UserAvatar } from '@admin/shared/UserAvatar'
import { Badge } from '../components/Badge'
import { RowActionMenu } from '../components/RowActionMenu'
import { UserDialog } from '../components/UserDialog'
-import { displayUserName, formatDateTime, isOwnerUser, statusLabel } from '../utils/format'
+import { userDisplayLabel } from '@core/users/displayName'
+import { formatDateTime, isOwnerUser, statusLabel } from '../utils/format'
import {
emptyUserForm,
type UserDialogMode,
@@ -330,7 +331,7 @@ export function UsersTab({ data, canManageUsers }: UsersTabProps) {
{users.map((user) => {
const owner = isOwnerUser(user)
- const label = displayUserName(user)
+ const label = userDisplayLabel(user)
return (
diff --git a/src/admin/pages/users/utils/audit.ts b/src/admin/pages/users/utils/audit.ts
index cc6a3c9ba..ad00b3ce2 100644
--- a/src/admin/pages/users/utils/audit.ts
+++ b/src/admin/pages/users/utils/audit.ts
@@ -9,7 +9,8 @@
* sentence-case title rendering.
*/
import type { CmsAuditEvent, CmsCurrentUser, CmsRole } from '@core/persistence'
-import { displayUserName, statusLabel } from './format'
+import { userDisplayLabel } from '@core/users/displayName'
+import { statusLabel } from './format'
function metadataString(metadata: Record, key: string): string | null {
const value = metadata[key]
@@ -52,7 +53,7 @@ function auditUserLabel(
): string | null {
if (!userId) return fallback
const user = usersById.get(userId)
- return user ? displayUserName(user) : fallback ?? userId
+ return user ? userDisplayLabel(user) : fallback ?? userId
}
export function auditActor(event: CmsAuditEvent, usersById: Map): string {
diff --git a/src/admin/pages/users/utils/format.ts b/src/admin/pages/users/utils/format.ts
index 11626d66e..11efe986c 100644
--- a/src/admin/pages/users/utils/format.ts
+++ b/src/admin/pages/users/utils/format.ts
@@ -11,10 +11,6 @@ export function isOwnerUser(user: CmsCurrentUser): boolean {
return user.role.slug === 'owner'
}
-export function displayUserName(user: CmsCurrentUser): string {
- return user.displayName.trim() || user.email
-}
-
export function statusLabel(status: CmsCurrentUser['status']): string {
return status === 'active' ? 'Active' : 'Suspended'
}
diff --git a/src/admin/shared/AccountMenuButton/AccountMenuButton.tsx b/src/admin/shared/AccountMenuButton/AccountMenuButton.tsx
index 116407e4e..2ed0eb875 100644
--- a/src/admin/shared/AccountMenuButton/AccountMenuButton.tsx
+++ b/src/admin/shared/AccountMenuButton/AccountMenuButton.tsx
@@ -42,6 +42,7 @@ import { UserAvatar } from '@admin/shared/UserAvatar'
import { logoutAllOtherCmsSessions, logoutCms } from '@core/persistence'
import styles from './AccountMenuButton.module.css'
import { getErrorMessage } from '@core/utils/errorMessage'
+import { userDisplayLabel } from '@core/users/displayName'
const ACCOUNT_ROUTE = '/admin/account'
@@ -54,7 +55,7 @@ export function AccountMenuButton(): ReactNode {
const [status, setStatus] = useState<{ tone: 'info' | 'error'; message: string } | null>(null)
const triggerRef = useRef(null)
- const displayName = user.displayName.trim() || user.email
+ const displayName = userDisplayLabel(user)
const roleLabel = user.role.name
function close(): void {
diff --git a/src/admin/shared/UserAvatar/UserAvatar.tsx b/src/admin/shared/UserAvatar/UserAvatar.tsx
index 06251b4eb..e534af80b 100644
--- a/src/admin/shared/UserAvatar/UserAvatar.tsx
+++ b/src/admin/shared/UserAvatar/UserAvatar.tsx
@@ -20,6 +20,7 @@ import { useState, type CSSProperties, type ReactNode } from 'react'
import type { CmsCurrentUser } from '@core/persistence'
import { resolveAvatarUrl } from '@core/users/avatar'
import { cn } from '@ui/cn'
+import { userDisplayLabel } from '@core/users/displayName'
import styles from './UserAvatar.module.css'
interface UserAvatarProps {
@@ -36,7 +37,7 @@ interface UserAvatarProps {
}
function deriveInitial(user: { displayName: string; email: string }): string {
- const source = (user.displayName.trim() || user.email).trim()
+ const source = userDisplayLabel(user).trim()
if (!source) return '?'
return source[0]?.toUpperCase() ?? '?'
}
@@ -45,7 +46,7 @@ export function UserAvatar({ user, size, alt, className }: UserAvatarProps): Rea
const [imageFailed, setImageFailed] = useState(false)
const url = resolveAvatarUrl(user, { size })
const showImage = url !== null && !imageFailed
- const displayName = user.displayName.trim() || user.email
+ const displayName = userDisplayLabel(user)
const altText = alt === null ? '' : alt ?? `Avatar for ${displayName}`
const style: CSSProperties = { '--avatar-size': `${size}px` } as CSSProperties
diff --git a/src/core/users/displayName.ts b/src/core/users/displayName.ts
new file mode 100644
index 000000000..2ef14583d
--- /dev/null
+++ b/src/core/users/displayName.ts
@@ -0,0 +1,11 @@
+/**
+ * The name shown for an admin: the display name, or — since "Your name" is
+ * optional at setup and in the profile — the email address.
+ *
+ * Shared by the admin UI and the collab relay: the relay only forwards
+ * presence whose `user.name` equals the session identity it derives with this
+ * same function, so both sides must agree.
+ */
+export function userDisplayLabel(user: { displayName: string; email: string }): string {
+ return user.displayName.trim() || user.email
+}