fix(cloud): EP-3b the audit log names a workspace's new owner from the team roster when the control plane cannot

This commit is contained in:
Neil
2026-11-13 05:03:17 -08:00
parent ad4b171255
commit b38ffd1f17
5 changed files with 277 additions and 12 deletions
@@ -0,0 +1,202 @@
// @vitest-environment happy-dom
// EP-3b: handing a workspace to a teammate must name them, even when the control plane cannot.
// It only knows people who have used Orca Cloud in the team; the team roster knows everyone.
import { cleanup, render, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAppStore } from '@/store'
import type { CloudAuditEvent } from '../../../../../shared/cloud-api/cloud-api-types'
import type { OrcaProfileAuthStatus } from '../../../../../shared/orca-profiles'
import { EnterpriseAuditLog } from './EnterpriseAuditLog'
import { FIXTURE_NOW, fixtureAuditProps } from './enterprise-audit.fixtures'
import { auditEventSentence, unnamedPersonIn } from './enterprise-audit-copy'
const ROSTER = {
status: 'ok' as const,
roster: {
viewerRole: 'owner' as const,
canManageMembers: true,
members: [
{
userId: 'u_ada',
email: 'ada@acme.dev',
displayName: 'Ada Lovelace',
role: 'owner' as const
},
{
userId: 'u_member',
email: 'member@acme.dev',
displayName: 'Orca Staging Member',
role: 'member' as const
},
{ userId: 'u_noname', email: 'quiet@acme.dev', displayName: '', role: 'member' as const }
],
pendingInvites: []
}
}
const SIGNED_IN: OrcaProfileAuthStatus = {
activeProfileId: 'p1',
configured: true,
state: 'connected',
persistence: 'encrypted',
cloud: {
cloudProfileId: 'cp',
userId: 'u_ada',
email: 'ada@acme.dev',
activeOrgId: 'org_ep3b',
linkedAt: 1
},
organizations: [{ orgId: 'org_ep3b', name: 'Acme', role: 'owner' }]
}
function ownerChange(detail: Record<string, unknown>, id = 'own_1'): CloudAuditEvent {
return {
id,
at: FIXTURE_NOW,
category: 'workspaces',
action: 'workspace.owner_changed',
actor: { kind: 'person', userId: 'u_ada', name: 'Ada Lovelace', via: null },
target: { type: 'workspace', id: 'sbx_1', label: 'endpass-own' },
detail
}
}
const listed = (events: CloudAuditEvent[]) =>
fixtureAuditProps({
log: {
status: 'ok',
events,
hasMore: false,
loadingMore: false,
moreError: null,
refreshing: false
}
})
const sentences = (container: HTMLElement): string[] =>
[...container.querySelectorAll('ul[aria-label="Audit log"] > li')].map(
(row) => row.children[0]?.textContent ?? ''
)
let orgMembersList: ReturnType<typeof vi.fn>
beforeEach(() => {
orgMembersList = vi.fn(async () => ROSTER)
vi.stubGlobal('api', { orcaProfiles: { orgMembersList } })
useAppStore.setState({ orcaProfileAuthStatus: SIGNED_IN })
})
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
useAppStore.setState({ orcaProfileAuthStatus: null })
})
describe('the new owner of a workspace', () => {
it('is named from the team roster when the control plane sent no name', async () => {
// What dev sent on n46: the ids, no toUserName (the new owner had never used Orca Cloud).
const { container } = render(
<EnterpriseAuditLog
{...listed([ownerChange({ fromUserId: 'u_ada', toUserId: 'u_member' })])}
/>
)
// Before the roster answers: the sentence that needs no name, never an id.
expect(sentences(container)).toEqual(['Changed the owner of workspace “endpass-own”'])
await waitFor(() =>
expect(sentences(container)).toEqual([
'Made Orca Staging Member the owner of workspace “endpass-own”'
])
)
expect(orgMembersList).toHaveBeenCalledWith({ orgId: 'org_ep3b' })
expect(container.textContent).not.toContain('u_member')
})
it('falls back to the email, then to the no-name sentence, and never shows a raw id', async () => {
const { container } = render(
<EnterpriseAuditLog
{...listed([
ownerChange({ fromUserId: 'u_ada', toUserId: 'u_noname' }, 'own_1'),
ownerChange({ fromUserId: 'u_ada', toUserId: 'u_gone' }, 'own_2')
])}
/>
)
await waitFor(() =>
expect(sentences(container)).toEqual([
'Made quiet@acme.dev the owner of workspace “endpass-own”',
'Changed the owner of workspace “endpass-own”'
])
)
expect(container.textContent).not.toMatch(/u_noname|u_gone/)
})
it("keeps the control plane's name when it sent one, and does not read the roster for it", async () => {
const { container } = render(
<EnterpriseAuditLog
{...listed([
ownerChange(
{ fromUserId: 'u_ada', toUserId: 'u_member', toUserName: 'Grace Hopper' },
'own_1'
),
// Took it themselves, and a new owner who has left: neither needs a name looked up.
ownerChange({ fromUserId: 'u_bob', toUserId: 'u_ada' }, 'own_2'),
ownerChange(
{
fromUserId: 'u_ada',
toUserId: 'u_left',
toUserName: 'Linus Park',
toUserFormer: true
},
'own_3'
)
])}
/>
)
expect(sentences(container)).toEqual([
'Made Grace Hopper the owner of workspace “endpass-own”',
'Took ownership of workspace “endpass-own”',
'Made a former member the owner of workspace “endpass-own”'
])
await Promise.resolve()
expect(orgMembersList).not.toHaveBeenCalled()
})
it('names the member a role change is about the same way', async () => {
const roleChange: CloudAuditEvent = {
id: 'role_1',
at: FIXTURE_NOW,
category: 'members',
action: 'member.role_changed',
actor: { kind: 'person', userId: 'u_ada', name: 'Ada Lovelace', via: null },
target: { type: 'member', id: 'u_member', label: null },
detail: { role: 'admin' }
}
const { container } = render(<EnterpriseAuditLog {...listed([roleChange])} />)
await waitFor(() =>
expect(sentences(container)).toEqual(['Changed the role of Orca Staging Member'])
)
// A label from the control plane wins, and needs no roster read.
expect(
unnamedPersonIn({ ...roleChange, target: { type: 'member', id: 'u_member', label: 'Bo' } })
).toBeNull()
})
it('reads the roster only for rows that point at someone unnamed', () => {
expect(unnamedPersonIn(ownerChange({ fromUserId: 'u_ada', toUserId: 'u_member' }))).toBe(
'u_member'
)
expect(
unnamedPersonIn(ownerChange({ toUserId: 'u_member', toUserName: 'Grace Hopper' }))
).toBeNull()
expect(unnamedPersonIn(ownerChange({ toUserId: 'u_ada' }))).toBeNull()
expect(
unnamedPersonIn({
...ownerChange({ toUserId: 'u_member' }),
action: 'workspace.made_private'
})
).toBeNull()
// The pure sentence takes any resolver.
expect(
auditEventSentence(ownerChange({ toUserId: 'u_member' }), undefined, undefined, () => 'Bo')
).toBe('Made Bo the owner of workspace “endpass-own”')
})
})
@@ -27,7 +27,9 @@ import {
} from '../cloud-api/cloud-api-parts'
import { SettingsGroup } from '../section-layout/SettingsGroup'
import { EnterpriseUpsell, EnterpriseUpsellLine, type EnterpriseContact } from './EnterpriseUpsell'
import { auditCategoryLabel, auditEventSentence } from './enterprise-audit-copy'
import { auditCategoryLabel, auditEventSentence, unnamedPersonIn } from './enterprise-audit-copy'
import { useAppStore } from '@/store'
import { useOrgRoster } from '../../billing/team/use-org-roster'
export type AuditLogFilters = { category: CloudAuditCategory | null; actor: string | null }
@@ -214,10 +216,28 @@ function Filters({
)
}
/**
* Names teammates from the team roster (the directory the Team page uses), for sentences that
* point at someone the control plane could not name. Read only when a row on screen needs it.
*/
function useRosterNames(events: readonly CloudAuditEvent[]): (userId: string) => string | null {
const orgId = useAppStore((s) => s.orcaProfileAuthStatus?.cloud?.activeOrgId ?? null)
const needed = events.some((event) => unnamedPersonIn(event) !== null)
const roster = useOrgRoster(needed ? orgId : null)
const members = roster.result?.status === 'ok' ? roster.result.roster.members : null
return (userId) => {
const member = members?.find((candidate) => candidate.userId === userId)
return member?.displayName?.trim() || member?.email || null
}
}
const NO_EVENTS: readonly CloudAuditEvent[] = []
export function EnterpriseAuditLog(props: EnterpriseAuditLogProps): React.JSX.Element {
const t = useTranslate()
const locale = useIntlLocale()
const { log, filters, exportState } = props
const nameOf = useRosterNames(log.status === 'ok' ? log.events : NO_EVENTS)
const filtered = filters.category !== null || filters.actor !== null
const canExport = log.status === 'ok' && log.events.length > 0
return (
@@ -347,7 +367,7 @@ export function EnterpriseAuditLog(props: EnterpriseAuditLogProps): React.JSX.El
className="flex items-center gap-3 py-2 text-xs"
>
<span className="min-w-0 flex-1 truncate text-foreground" title={event.action}>
{auditEventSentence(event, t, locale)}
{auditEventSentence(event, t, locale, nameOf)}
</span>
<span className="w-28 shrink-0 truncate text-muted-foreground">
{auditCategoryLabel(event.category, t)}
@@ -16,8 +16,15 @@ export type ActionCopy = {
t: Translate,
detail: Detail,
locale: string | undefined,
/** The target's name when known, and who did it. */
about: { name: string | null; actorUserId: string | null }
/**
* The target's name when known, who did it, and a way to name another teammate the event
* points at by id (from the team roster) when the control plane could not.
*/
about: {
name: string | null
actorUserId: string | null
nameOf: (userId: string) => string | null
}
) => string | null
}
@@ -173,9 +173,22 @@ const ACTIONS: Record<string, ActionCopy> = {
}
/** The name to put in the sentence: what the control plane labelled the target, else a recorded name. */
function targetName(event: CloudAuditEvent): string | null {
/** A teammate the row is about whom the control plane could not name (see unnamedPersonIn). */
function unnamedMemberTarget(event: CloudAuditEvent): string | null {
const target = event.target
return target?.type === 'member' && target.id && !target.label?.trim() ? target.id : null
}
function targetName(
event: CloudAuditEvent,
nameOf: (userId: string) => string | null
): string | null {
const recorded = event.detail?.name
const name = event.target?.label ?? (typeof recorded === 'string' ? recorded : null)
const member = unnamedMemberTarget(event)
const name =
event.target?.label ??
(typeof recorded === 'string' ? recorded : null) ??
(member ? nameOf(member) : null)
const clean = name?.trim()
return clean ? clean.slice(0, 80) : null
}
@@ -184,15 +197,17 @@ function targetName(event: CloudAuditEvent): string | null {
export function auditEventSentence(
event: CloudAuditEvent,
t: Translate = translate,
locale?: string
locale?: string,
/** Names a teammate by id from the team roster; the control plane only knows people who have used Orca Cloud. */
nameOf: (userId: string) => string | null = () => null
): string {
const copy = ACTIONS[event.action]
if (!copy) {
return event.action
}
const name = targetName(event)
const name = targetName(event, nameOf)
const detailed = event.detail
? copy.detailed?.(t, event.detail, locale, { name, actorUserId: event.actor.userId })
? copy.detailed?.(t, event.detail, locale, { name, actorUserId: event.actor.userId, nameOf })
: null
if (detailed) {
return detailed
@@ -204,3 +219,17 @@ export function auditEventSentence(
export function isKnownAuditAction(action: string): boolean {
return action in ACTIONS
}
/**
* The teammate a row names in its sentence whom the control plane could not name (it only knows
* people who have used Orca Cloud in this team), so the roster is read only when a row needs it.
*/
export function unnamedPersonIn(event: CloudAuditEvent): string | null {
if (event.action !== 'workspace.owner_changed') {
// A role change or removal whose member the control plane could not name.
return unnamedMemberTarget(event)
}
const owner = event.detail?.toUserId
const named = typeof event.detail?.toUserName === 'string' && event.detail.toUserName.trim()
return typeof owner === 'string' && !named && owner !== event.actor.userId ? owner : null
}
@@ -82,7 +82,7 @@ export const WORKSPACE_AUDIT_ACTIONS: Record<string, ActionCopy> = {
),
// Says who got it: themselves, a teammate by name, or someone who has since left. With no
// name to give, the plain sentences above are used. The previous owner is left out.
detailed: (t, detail, _locale, { name, actorUserId }) => {
detailed: (t, detail, _locale, { name, actorUserId, nameOf }) => {
const owner = typeof detail.toUserId === 'string' ? detail.toUserId : null
if (owner && owner === actorUserId) {
return name
@@ -107,8 +107,15 @@ export const WORKSPACE_AUDIT_ACTIONS: Record<string, ActionCopy> = {
'Made a former member the owner of a workspace'
)
}
const person =
typeof detail.toUserName === 'string' ? detail.toUserName.trim().slice(0, 80) : ''
// The control plane names people who have used Orca Cloud in this team; a teammate who has
// not is named from the team roster, like the person column.
const person = (
(typeof detail.toUserName === 'string' ? detail.toUserName : '') ||
(owner ? nameOf(owner) : null) ||
''
)
.trim()
.slice(0, 80)
if (!person) {
return null
}