Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions docs/features/site-shell.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion server/collab/socket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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,
},
Expand Down
7 changes: 4 additions & 3 deletions src/__tests__/server/collabRelayIntegration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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,
Expand All @@ -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!))
}
})

Expand Down Expand Up @@ -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,
Expand Down
17 changes: 17 additions & 0 deletions src/__tests__/server/collabSocket.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
10 changes: 10 additions & 0 deletions src/__tests__/users/displayName.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
3 changes: 2 additions & 1 deletion src/admin/pages/account/tabs/ProfileTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 ||
Expand Down
3 changes: 2 additions & 1 deletion src/admin/pages/dashboard/widgets/ActivityWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -187,7 +188,7 @@ export function ActivityWidget({ span, editing }: DashboardWidgetRendererProps)
<UserAvatar
user={r.actor}
size={AVATAR_SIZE}
alt={`Avatar for ${r.actor.displayName || r.actor.email}`}
alt={`Avatar for ${userDisplayLabel(r.actor)}`}
/>
) : (
<span className={styles.feedSystemAvatar} title="System" aria-hidden="true">
Expand Down
6 changes: 5 additions & 1 deletion src/admin/pages/site/canvas/CanvasRoot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 })),
Expand Down Expand Up @@ -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,
}
Expand Down
5 changes: 3 additions & 2 deletions src/admin/pages/users/tabs/UsersTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -330,7 +331,7 @@ export function UsersTab({ data, canManageUsers }: UsersTabProps) {
<DataTableBody>
{users.map((user) => {
const owner = isOwnerUser(user)
const label = displayUserName(user)
const label = userDisplayLabel(user)
return (
<DataTableRow key={user.id} aria-label={`User ${user.email}`}>
<DataTableCell>
Expand Down
5 changes: 3 additions & 2 deletions src/admin/pages/users/utils/audit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>, key: string): string | null {
const value = metadata[key]
Expand Down Expand Up @@ -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, CmsCurrentUser>): string {
Expand Down
4 changes: 0 additions & 4 deletions src/admin/pages/users/utils/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}
Expand Down
3 changes: 2 additions & 1 deletion src/admin/shared/AccountMenuButton/AccountMenuButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -54,7 +55,7 @@ export function AccountMenuButton(): ReactNode {
const [status, setStatus] = useState<{ tone: 'info' | 'error'; message: string } | null>(null)
const triggerRef = useRef<HTMLButtonElement>(null)

const displayName = user.displayName.trim() || user.email
const displayName = userDisplayLabel(user)
const roleLabel = user.role.name

function close(): void {
Expand Down
5 changes: 3 additions & 2 deletions src/admin/shared/UserAvatar/UserAvatar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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() ?? '?'
}
Expand All @@ -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
Expand Down
11 changes: 11 additions & 0 deletions src/core/users/displayName.ts
Original file line number Diff line number Diff line change
@@ -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
}