Skip to content
Merged
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
9 changes: 9 additions & 0 deletions server/db/migrations-pg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1147,4 +1147,13 @@ export const pgMigrations: Migration[] = [
alter table collab_documents add column generation text not null default '';
`,
},
{
// See migrations-sqlite.ts:024 — clears display names that are just the
// account's email address, because author bindings render them publicly.
id: '024_clear_email_display_names',
sql: `
update users set display_name = ''
where trim(lower(display_name)) = trim(lower(email));
`,
},
]
13 changes: 13 additions & 0 deletions server/db/migrations-sqlite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1211,4 +1211,17 @@ export const sqliteMigrations: Migration[] = [
alter table collab_documents add column generation text not null default '';
`,
},
{
// `display_name` is rendered on PUBLIC pages through author bindings, and
// setup used to default it to the account's email address — so every
// install created before this has an address sitting in a public field.
// Clear it only where it is exactly the address; a name somebody actually
// chose is left alone. Admin surfaces already fall back to the email for
// their own display, so an empty value costs nothing there.
id: '024_clear_email_display_names',
sql: `
update users set display_name = ''
where trim(lower(display_name)) = trim(lower(email));
`,
},
]
6 changes: 5 additions & 1 deletion server/handlers/cms/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,16 @@ export async function handleSetupRoutes(req: Request, db: DbClient): Promise<Res
siteName: Type.String(),
email: Type.String(),
password: Type.String(),
// Optional: the owner's public name. Left empty, author bindings render
// nothing rather than the email address.
displayName: Type.Optional(Type.String()),
})
const body = await readValidatedBody(req, SetupBodySchema)
if (!body) return badRequest('Invalid request body')
const siteName = body.siteName.trim()
const email = body.email.trim().toLowerCase()
const password = body.password.trim()
const displayName = body.displayName?.trim() ?? ''

if (!siteName) return badRequest('Missing siteName')
if (!email.includes('@')) return badRequest('Invalid email')
Expand All @@ -70,7 +74,7 @@ export async function handleSetupRoutes(req: Request, db: DbClient): Promise<Res
const owner = await createUser(tx, {
id: nanoid(),
email,
displayName: email,
displayName,
passwordHash: await hashPassword(password),
roleId: 'owner',
allowOwnerRole: true,
Expand Down
9 changes: 6 additions & 3 deletions server/publish/loopPrefetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,12 +79,15 @@ export function publishedDataRowToLoopItem(row: PublishedDataRow): LoopItem {
versionNumber: row.versionNumber,
tableId: row.tableId,
tableSlug: row.tableSlug,
// People
author,
// People. `author` and `publishedBy` are the DISPLAY NAME, not the
// reference object — a binding lands in public HTML, and a non-scalar
// there used to be JSON-stringified, publishing display name, role slug
// and role name together. Every people key here is now a leaf.
author: author?.displayName ?? null,
authorName: author?.displayName ?? null,
authorRoleSlug: author?.roleSlug ?? null,
authorRoleName: author?.roleName ?? null,
publishedBy,
publishedBy: publishedBy?.displayName ?? null,
publishedByName: publishedBy?.displayName ?? null,
publishedByRoleSlug: publishedBy?.roleSlug ?? null,
publishedByRoleName: publishedBy?.roleName ?? null,
Expand Down
11 changes: 9 additions & 2 deletions server/repositories/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,13 @@ export async function createUser(
const email = input.email.trim()
const emailNormalized = normalizeEmail(email)
if (!emailNormalized.includes('@')) throw new UserMutationError('Invalid email')
const displayName = input.displayName.trim() || email
// Empty means empty. `display_name` is rendered on PUBLIC pages through
// author bindings, so defaulting it to the email address publishes the
// address the moment anyone binds an author field. Admin surfaces already
// fall back to the email for their own display (UserAvatar,
// AccountMenuButton, ContentSettingsPanel, ActivityWidget) — that fallback
// is authenticated-only and stays.
const displayName = input.displayName.trim()
const id = input.id ?? nanoid()
const status = input.status ?? 'active'
if (input.roleId === 'owner' && input.allowOwnerRole !== true) {
Expand Down Expand Up @@ -318,9 +324,10 @@ export async function updateUser(
const email = input.email === undefined ? current.email : input.email.trim()
const emailNormalized = normalizeEmail(email)
if (!emailNormalized.includes('@')) throw new UserMutationError('Invalid email')
// Clearing the field is allowed and means "no public name" — see createUser.
const displayName = input.displayName === undefined
? current.displayName
: input.displayName.trim() || email
: input.displayName.trim()
const status = input.status ?? current.status
const roleId = input.roleId ?? current.role.id
const passwordHash = input.passwordHash ?? current.passwordHash
Expand Down
8 changes: 7 additions & 1 deletion src/__tests__/loops/dataRowsSource.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,16 @@ import { DataRowsSource } from '@core/loops/sources/dataRows'

describe('data.rows loop source', () => {
it('offers author display fields without exposing user ids as binding fields', () => {
// `author` is declared, because it is the name an author reaches for and
// an undeclared key is exactly how a binding to a non-field goes unnoticed.
expect(DataRowsSource.fields).toContainEqual({
id: 'authorName',
id: 'author',
label: 'Author name',
})
expect(DataRowsSource.fields).toContainEqual({
id: 'authorName',
label: 'Author name (alias)',
})
expect(DataRowsSource.fields).toContainEqual({
id: 'authorRoleName',
label: 'Author role',
Expand Down
57 changes: 57 additions & 0 deletions src/__tests__/publisher/authorBindingLeak.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/**
* A binding must never publish internal shape.
*
* `{currentEntry.author}` used to resolve to the `PublicDataUserReference`
* object, and the token interpolator JSON-stringified any non-scalar "so
* mistakes stay visible" — an author-facing debug affordance running in the
* public publish path. A single template binding therefore published
* `{"displayName":"owner@example.com","roleSlug":"owner","roleName":"Owner"}`
* into live HTML, and into every screenshot taken of it.
*
* Two independent guards keep that shut, and both are asserted here: the
* people keys are leaf strings, and a non-scalar value renders as nothing
* rather than as JSON.
*/
import { describe, expect, it } from 'bun:test'
import { interpolateTokens } from '@core/templates/tokenInterpolation'
import type { TemplateContext } from '@core/templates/contextFrames'

function contextWithEntry(fields: Record<string, unknown>): TemplateContext {
return { entryStack: [{ id: 'row_1', fields }] } as unknown as TemplateContext
}

describe('author bindings never publish internal shape', () => {
it('renders the display name for {currentEntry.author}', () => {
const ctx = contextWithEntry({ author: 'Rosa Ferrandis' })
expect(interpolateTokens('By {currentEntry.author}', ctx)).toBe('By Rosa Ferrandis')
})

it('renders nothing for a binding that resolves to an object', () => {
const ctx = contextWithEntry({
author: { displayName: 'owner@example.com', roleSlug: 'owner', roleName: 'Owner' },
})
const out = interpolateTokens('By {currentEntry.author}', ctx)

expect(out).toBe('By ')
expect(out).not.toContain('@')
expect(out).not.toContain('roleSlug')
expect(out).not.toContain('displayName')
})

it('renders nothing for a binding that resolves to an array', () => {
const ctx = contextWithEntry({ tags: ['a', 'b'] })
expect(interpolateTokens('{currentEntry.tags}', ctx)).toBe('')
})

it('still honours an author-supplied fallback when the value is non-scalar', () => {
const ctx = contextWithEntry({ author: { displayName: 'owner@example.com' } })
expect(interpolateTokens('{currentEntry.author|Anonymous}', ctx)).toBe('Anonymous')
})

it('leaves scalar bindings untouched', () => {
const ctx = contextWithEntry({ title: 'El inventario', year: 2025, pardoned: false })
expect(interpolateTokens('{currentEntry.title} ({currentEntry.year})', ctx))
.toBe('El inventario (2025)')
expect(interpolateTokens('{currentEntry.pardoned}', ctx)).toBe('false')
})
})
18 changes: 8 additions & 10 deletions src/__tests__/server/loopPrefetch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,24 +49,22 @@ describe('loopPrefetch', () => {
createdAt: '2026-05-01T10:02:00.000Z',
})

// Every people key is a LEAF. `author` used to be the reference object,
// so a template binding it published `{"displayName":…,"roleSlug":…}`
// straight into the page.
expect(item.fields).toMatchObject({
author: {
displayName: 'Author Name',
roleSlug: 'editor',
roleName: 'Editor',
},
author: 'Author Name',
authorName: 'Author Name',
authorRoleName: 'Editor',
authorRoleSlug: 'editor',
publishedBy: {
displayName: 'Publisher Name',
roleSlug: 'admin',
roleName: 'Admin',
},
publishedBy: 'Publisher Name',
publishedByName: 'Publisher Name',
publishedByRoleName: 'Admin',
publishedByRoleSlug: 'admin',
})
for (const key of ['author', 'publishedBy']) {
expect(typeof item.fields[key]).not.toBe('object')
}
expect('authorUserId' in item.fields).toBe(false)
expect('authorId' in item.fields).toBe(false)
expect('publishedByUserId' in item.fields).toBe(false)
Expand Down
72 changes: 72 additions & 0 deletions src/__tests__/server/setupDisplayName.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/**
* A display name is never silently the account's email address.
*
* `display_name` is rendered on PUBLIC pages through author bindings, and both
* setup and `createUser` used to default it to the email. So a fresh install
* had the owner's address sitting in a publicly-bindable field before anyone
* had written a line of content, and the only way to change it was the account
* page after the fact.
*
* Setup now takes an optional `displayName`, and an unset name stays empty.
*/
import { describe, expect, it } from 'bun:test'
import type { DbClient } from '../../../server/db'
import { handleCmsRequest } from '../../../server/handlers/cms'
import { createUser, findUserByEmail } from '../../../server/repositories/users'
import { createTestDb } from '../helpers/createTestDb'

const EMAIL = 'owner@example.com'
const PASSWORD = 'long-enough-password'

async function runSetup(db: DbClient, body: Record<string, unknown>): Promise<Response> {
return await handleCmsRequest(
new Request('http://localhost/admin/api/cms/setup', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ siteName: 'Test', email: EMAIL, password: PASSWORD, ...body }),
}),
db,
)
}

describe('setup display name', () => {
it('leaves the display name empty when none is given', async () => {
const { db } = await createTestDb()
expect((await runSetup(db, {})).status).toBe(201)

const owner = await findUserByEmail(db, EMAIL)
expect(owner?.displayName).toBe('')
expect(owner?.displayName).not.toContain('@')
})

it('stores the display name given at setup', async () => {
const { db } = await createTestDb()
expect((await runSetup(db, { displayName: 'Rosa Ferrandis' })).status).toBe(201)

expect((await findUserByEmail(db, EMAIL))?.displayName).toBe('Rosa Ferrandis')
})

it('trims a whitespace-only display name to empty rather than the email', async () => {
const { db } = await createTestDb()
expect((await runSetup(db, { displayName: ' ' })).status).toBe(201)

expect((await findUserByEmail(db, EMAIL))?.displayName).toBe('')
})
})

describe('createUser display name', () => {
it('does not fall back to the email address', async () => {
const { db } = await createTestDb()
await runSetup(db, {})

const created = await createUser(db, {
email: 'editor@example.com',
displayName: '',
passwordHash: 'x',
roleId: 'admin',
})

expect(created.displayName).toBe('')
expect(created.displayName).not.toContain('@')
})
})
6 changes: 6 additions & 0 deletions src/admin/preauth/AdminPreAuthForm.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@
font-weight: 600;
}

/* Secondary note inside a field label — lighter than the label it follows. */
.hint {
color: var(--text-subtle);
font-weight: 400;
}

@keyframes instaticAdminSpin {
to {
transform: rotate(360deg);
Expand Down
40 changes: 29 additions & 11 deletions src/admin/preauth/AdminPreAuthForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,13 +68,15 @@ export function AdminPreAuthForm({
onAuthenticated,
}: AdminPreAuthFormProps) {
const [siteName, setSiteName] = useState('My Site')
const [displayName, setDisplayName] = useState('')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [mfaCode, setMfaCode] = useState('')
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(initialError)

const siteNameId = useId()
const displayNameId = useId()
const emailId = useId()
const passwordId = useId()
const mfaCodeId = useId()
Expand All @@ -86,7 +88,7 @@ export function AdminPreAuthForm({
return
}
await runAuthAction(async () => {
await setupCms({ siteName, email, password })
await setupCms({ siteName, email, password, displayName })
await loginCms({ email, password })
onAuthenticated(await getCurrentCmsUser())
}, 'Setup failed', setSubmitting, setError)
Expand Down Expand Up @@ -167,16 +169,32 @@ export function AdminPreAuthForm({
/>
</label>
) : phase === 'setup' && (
<label className={styles.field} htmlFor={siteNameId}>
<span>Site name</span>
<Input
id={siteNameId}
value={siteName}
onChange={(event) => setSiteName(event.target.value)}
required
autoComplete="organization"
/>
</label>
<>
<label className={styles.field} htmlFor={siteNameId}>
<span>Site name</span>
<Input
id={siteNameId}
value={siteName}
onChange={(event) => setSiteName(event.target.value)}
required
autoComplete="organization"
/>
</label>

{/* Optional, and public: this is what author bindings render on
published pages. Left blank they render nothing — which is
why it is offered here rather than only on the account page. */}
<label className={styles.field} htmlFor={displayNameId}>
<span>Your name <span className={styles.hint}>optional, shown on published pages</span></span>
<Input
id={displayNameId}
value={displayName}
onChange={(event) => setDisplayName(event.target.value)}
autoComplete="name"
data-testid="admin-setup-display-name"
/>
</label>
</>
)}

{phase !== 'mfa' && (
Expand Down
11 changes: 9 additions & 2 deletions src/core/data/publicDataUser.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
/**
* Public-facing user reference attached to published data rows.
*
* Strips internal-only ids and email addresses, retaining only the bits the
* publisher and frontend templates need to render bylines safely.
* Drops internal-only ids and the `email` field, retaining only the bits the
* publisher and frontend templates need to render bylines.
*
* It does NOT guarantee the result is free of email addresses: `displayName`
* carries whatever the account has set, and setup used to default that to the
* address. Two things keep it out of public HTML now — a display name is empty
* until someone sets one (`createUser`), and the binding frames expose the
* display name as a leaf string rather than this object, because a non-scalar
* binding used to be serialised wholesale into the page.
*/

interface PublicDataUserReference {
Expand Down
Loading
Loading