Skip to content

Commit 1e1c830

Browse files
committed
fix(people): consistent profile reads, capability-aware navigation, unambiguous email matching, stricter dates, guarded workbook parsing
1 parent ab0b4a1 commit 1e1c830

10 files changed

Lines changed: 94 additions & 31 deletions

File tree

‎apps/sim/app/o/[organizationId]/layout.tsx‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { isDashboardsEnabled } from '@/lib/dashboards/feature-flag'
1212
import { isMothershipModelSelectorEnabled } from '@/lib/mothership/feature-flags'
1313
import { organizationRoutes, WORKSPACE_SETTINGS_PATH } from '@/lib/navigation/paths'
1414
import { getOrganizationSurfaceContext } from '@/lib/organizations/surface'
15-
import { isPeopleEnabled } from '@/lib/people/feature-flag'
15+
import { canBrowsePeople } from '@/lib/people/feature-flag'
1616
import { isProjectsEnabled } from '@/lib/projects/rollout.server'
1717
import { isSimLinksEnabled } from '@/lib/sim-links/feature-flag'
1818
import { isWorkflowTestsEnabled } from '@/lib/workflow-tests/feature-flag'
@@ -65,7 +65,8 @@ export default async function OrganizationLayout({
6565
if (!context) {
6666
return <OrganizationAccessDenied />
6767
}
68-
if (!context.mothershipAvailable && !context.searchAccess.memberScoped)
68+
const peopleEnabled = await canBrowsePeople(organizationId, context.viewer.role)
69+
if (!context.mothershipAvailable && !context.searchAccess.memberScoped && !peopleEnabled)
6970
redirect(WORKSPACE_SETTINGS_PATH)
7071

7172
const [
@@ -78,7 +79,6 @@ export default async function OrganizationLayout({
7879
changelogEnabled,
7980
projectsEnabled,
8081
simLinksEnabled,
81-
peopleEnabled,
8282
] = await Promise.all([
8383
prefetchOrganizationSidebar(
8484
queryClient,
@@ -97,7 +97,6 @@ export default async function OrganizationLayout({
9797
isChangelogEnabled(organizationId),
9898
isProjectsEnabled(),
9999
isSimLinksEnabled(organizationId),
100-
isPeopleEnabled(organizationId),
101100
])
102101
const initialSidebarCollapsed = cookieStore.get('sidebar_collapsed')?.value === '1'
103102

‎apps/sim/app/o/[organizationId]/page.tsx‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { notFound, redirect } from 'next/navigation'
22
import { getSession } from '@/lib/auth'
33
import { organizationRoutes, WORKSPACE_SETTINGS_PATH } from '@/lib/navigation/paths'
44
import { getOrganizationSurfaceContext } from '@/lib/organizations/surface'
5+
import { canBrowsePeople } from '@/lib/people/feature-flag'
56
import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect'
67

78
export default async function OrganizationPage({
@@ -17,11 +18,11 @@ export default async function OrganizationPage({
1718
}
1819
const context = await getOrganizationSurfaceContext(organizationId, session.user.id)
1920
if (!context) notFound()
21+
if (context.mothershipAvailable && context.canBuild) redirect(routes.home)
22+
if (context.searchAccess.memberScoped) redirect(routes.search)
2023
redirect(
21-
context.mothershipAvailable && context.canBuild
22-
? routes.home
23-
: context.searchAccess.memberScoped
24-
? routes.search
25-
: WORKSPACE_SETTINGS_PATH
24+
(await canBrowsePeople(organizationId, context.viewer.role))
25+
? routes.people
26+
: WORKSPACE_SETTINGS_PATH
2627
)
2728
}

‎apps/sim/app/o/[organizationId]/people/people-import-modal.tsx‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
} from '@sim/emcn'
1616
import { getErrorMessage } from '@sim/utils/errors'
1717
import { PEOPLE_IMPORT_MAX_ROWS, type PeopleImportResult } from '@/lib/api/contracts/people'
18+
import { assertOoxmlPreviewWithinLimits } from '@/lib/file-parsers/ooxml-preview-guard'
1819
import { saveBlob } from '@/lib/uploads/client/download'
1920
import { useImportPeople } from '@/hooks/queries/people'
2021

@@ -38,8 +39,10 @@ interface PeopleSheet {
3839
*/
3940
async function readPeopleSheet(file: File): Promise<PeopleSheet> {
4041
if (file.size > MAX_FILE_BYTES) throw new Error('Choose a file under 5 MB')
42+
const data = new Uint8Array(await file.arrayBuffer())
43+
if (/\.xlsx$/i.test(file.name)) await assertOoxmlPreviewWithinLimits(data)
4144
const XLSX = await import('xlsx')
42-
const workbook = XLSX.read(await file.arrayBuffer(), {
45+
const workbook = XLSX.read(data, {
4346
type: 'array',
4447
raw: /\.(csv|tsv)$/i.test(file.name),
4548
dense: true,
@@ -91,7 +94,11 @@ function plural(count: number, noun: string): string {
9194
}
9295

9396
/** The preview's issues in file order, with a count of any past the ones listed. */
94-
function ImportIssueList({ result }: { result: PeopleImportResult }) {
97+
interface ImportIssueListProps {
98+
result: PeopleImportResult
99+
}
100+
101+
function ImportIssueList({ result }: ImportIssueListProps) {
95102
const listRef = useRef<HTMLUListElement>(null)
96103
const edges = useScrollEdges(listRef)
97104
const unlisted = result.skipped + result.warnings - result.issues.length
@@ -108,9 +115,9 @@ function ImportIssueList({ result }: { result: PeopleImportResult }) {
108115
<li key={`${issue.row}-${issue.message}`} className='py-0.5'>
109116
<span className='text-[var(--text-muted)]'>Row {issue.row}</span>{' '}
110117
<span
111-
className={
118+
className={cn(
112119
issue.severity === 'skipped' ? 'text-[var(--text-error)]' : 'text-[var(--text-body)]'
113-
}
120+
)}
114121
>
115122
{issue.email ? `${issue.email}: ` : ''}
116123
{issue.message}

‎apps/sim/lib/people/application/use-cases.ts‎

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -56,16 +56,23 @@ export const listDepartments = defineAuthorizedOrganizationUseCase({
5656
repository.listDepartments(input.organizationId),
5757
})
5858

59-
/** One person with the managers above them and the people who report to them. */
59+
/**
60+
* One person with the managers above them and the people who report to them, read from one
61+
* snapshot so a reporting-line edit made meanwhile can't leave the three disagreeing.
62+
*/
6063
export const getPerson = defineAuthorizedOrganizationUseCase({
6164
operation: peopleOperations.get,
6265
authorizeResource: authorizeDirectory,
6366
async execute({ input }: OrganizationUseCaseContext<PersonInput>) {
64-
const [person, chain, reports] = await Promise.all([
65-
repository.getPerson(db, input.organizationId, input.memberId),
66-
repository.getReportingChain(input.organizationId, input.memberId),
67-
repository.listDirectReports(input.organizationId, input.memberId),
68-
])
67+
const { organizationId, memberId } = input
68+
const { person, chain, reports } = await db.transaction(
69+
async (tx) => ({
70+
person: await repository.getPerson(tx, organizationId, memberId),
71+
chain: await repository.getReportingChain(tx, organizationId, memberId),
72+
reports: await repository.listDirectReports(tx, organizationId, memberId),
73+
}),
74+
{ isolationLevel: 'repeatable read', accessMode: 'read only' }
75+
)
6976
if (!person) throw new OrchestrationError('not_found', 'Person not found')
7077
return { person, chain, reports }
7178
},

‎apps/sim/lib/people/feature-flag.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import { isOrgAdminRole } from '@sim/platform-authz/workspace'
12
import { isFeatureEnabled } from '@/lib/core/config/feature-flags'
23
import { OrchestrationError } from '@/lib/core/orchestration/types'
4+
import { isOrganizationCapabilityWithheld } from '@/lib/permission-groups/capability-assertions'
35

46
export function isPeopleEnabled(organizationId: string): Promise<boolean> {
57
return isFeatureEnabled('people', { orgId: organizationId })
@@ -10,3 +12,16 @@ export async function requirePeopleEnabled(organizationId: string): Promise<void
1012
throw new OrchestrationError('forbidden', 'People is not enabled for this organization')
1113
}
1214
}
15+
16+
/**
17+
* Whether the People page is on for `organizationId` and its directory open to a member with
18+
* `role`: administrators always see it; other members unless their permission group withholds
19+
* `organization.member_directory`, the capability its reads enforce.
20+
*/
21+
export async function canBrowsePeople(organizationId: string, role: string): Promise<boolean> {
22+
if (!(await isPeopleEnabled(organizationId))) return false
23+
return (
24+
isOrgAdminRole(role) ||
25+
!(await isOrganizationCapabilityWithheld(organizationId, 'organization.member_directory'))
26+
)
27+
}

‎apps/sim/lib/people/import.test.ts‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,7 @@ describe('planPeopleImport', () => {
215215
['2021/3/4', '2021-03-04'],
216216
['3/4/2021', '2021-03-04'],
217217
['2021-03-04T09:00:00Z', '2021-03-04'],
218+
['2021-03-04 09:00', '2021-03-04'],
218219
])('reads the start date %s', (value, startDate) => {
219220
const result = plan(
220221
['email', 'start date'],
@@ -223,7 +224,7 @@ describe('planPeopleImport', () => {
223224
expect(result.changes).toEqual([{ memberId: 'm-ada', profile: profile({ startDate }) }])
224225
})
225226

226-
it.each(['2021-02-30', '13/45/2021', '04.03.2021', 'March 4', '1/2/21'])(
227+
it.each(['2021-02-30', '13/45/2021', '04.03.2021', 'March 4', '1/2/21', '2021-03-04Tsoon'])(
227228
'skips the row with the unreadable start date %s instead of guessing',
228229
(value) => {
229230
const result = plan(
@@ -338,6 +339,23 @@ describe('planPeopleImport', () => {
338339
])
339340
})
340341

342+
it('refuses an address two members share once case and spaces are ignored', () => {
343+
const twin = { memberId: 'm-twin', email: ' ADA@example.com', profile: null, active: true }
344+
const result = plan(
345+
['email', 'title', 'manager email'],
346+
[
347+
{ email: 'ada@example.com', title: 'A', 'manager email': '' },
348+
{ email: 'bo@example.com', title: 'B', 'manager email': 'Ada@Example.com' },
349+
],
350+
[ada, twin, bo]
351+
)
352+
expect(result.changes).toEqual([{ memberId: 'm-bo', profile: profile({ title: 'B' }) }])
353+
expect(result.issues).toEqual([
354+
expect.objectContaining({ row: 2, severity: 'skipped' }),
355+
expect.objectContaining({ row: 3, severity: 'warning' }),
356+
])
357+
})
358+
341359
it('combines location parts, leaving out the blank ones', () => {
342360
const result = plan(
343361
['email', 'city', 'state', 'country'],

‎apps/sim/lib/people/import.ts‎

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { normalizeEmail } from '@sim/utils/string'
12
import { z } from 'zod'
23
import {
34
type MemberProfileFields,
@@ -179,9 +180,17 @@ export function planPeopleImport({
179180
rows: readonly Record<string, string>[]
180181
members: readonly PeopleImportMember[]
181182
}): PeopleImportPlan {
182-
const byEmail = new Map(
183-
members.filter((member) => member.active).map((member) => [member.email.toLowerCase(), member])
184-
)
183+
const byEmail = new Map<string, PeopleImportMember | 'ambiguous'>()
184+
for (const member of members) {
185+
if (!member.active) continue
186+
const key = normalizeEmail(member.email)
187+
byEmail.set(key, byEmail.has(key) ? 'ambiguous' : member)
188+
}
189+
/** The one active member an address names; `undefined` when none or several do. */
190+
const memberWith = (email: string) => {
191+
const found = byEmail.get(email)
192+
return found === 'ambiguous' ? undefined : found
193+
}
185194
const managers = new Map(
186195
members.map((member) => [member.memberId, member.profile?.managerMemberId ?? null])
187196
)
@@ -193,14 +202,17 @@ export function planPeopleImport({
193202
rows.forEach((row, index) => {
194203
const rowNumber = index + 2
195204
if (Object.values(row).every((value) => !value?.trim())) return
196-
const email = cell(row, columns.email).toLowerCase()
205+
const email = normalizeEmail(cell(row, columns.email))
197206
const skip = (message: string) =>
198207
issues.push({ row: rowNumber, email: email || null, severity: 'skipped', message })
199208

200209
if (!emailSchema.safeParse(email).success) return skip('Enter a valid email address')
201210
const earlierRow = acceptedRowByEmail.get(email)
202211
if (earlierRow !== undefined) return skip(`Already listed in row ${earlierRow}`)
203-
const member = byEmail.get(email)
212+
if (byEmail.get(email) === 'ambiguous') {
213+
return skip('More than one member has this email, so the row was not applied')
214+
}
215+
const member = memberWith(email)
204216
if (!member) return skip('Not a member of this organization')
205217

206218
const fields: Partial<MemberProfileFields> = {}
@@ -228,8 +240,8 @@ export function planPeopleImport({
228240

229241
let warning: string | null = null
230242
if (columns.managerEmail !== undefined) {
231-
const managerEmail = cell(row, columns.managerEmail).toLowerCase()
232-
const manager = managerEmail ? byEmail.get(managerEmail) : null
243+
const managerEmail = normalizeEmail(cell(row, columns.managerEmail))
244+
const manager = managerEmail ? memberWith(managerEmail) : null
233245
if (manager?.memberId === member.memberId) return skip("A person can't be their own manager")
234246
if (!managerEmail) {
235247
fields.managerMemberId = null

‎apps/sim/lib/people/repository.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -176,10 +176,11 @@ export async function getPerson(
176176
* ends at a manager who is suspended or outside the organization, and never revisits anyone.
177177
*/
178178
export async function getReportingChain(
179+
executor: DbOrTx,
179180
organizationId: string,
180181
memberId: string
181182
): Promise<PersonSummary[]> {
182-
const rows = await db.execute<PersonSummary>(sql`
183+
const rows = await executor.execute<PersonSummary>(sql`
183184
WITH RECURSIVE chain(member_id, depth, path) AS (
184185
SELECT m.id, 1, ARRAY[p.member_id, m.id]
185186
FROM member_profile p
@@ -206,10 +207,11 @@ export async function getReportingChain(
206207

207208
/** The active people who report to `memberId`, by name. */
208209
export async function listDirectReports(
210+
executor: DbOrTx,
209211
organizationId: string,
210212
memberId: string
211213
): Promise<PersonSummary[]> {
212-
return db
214+
return executor
213215
.select(summaryColumns)
214216
.from(member)
215217
.innerJoin(user, eq(user.id, member.userId))

‎apps/sim/lib/people/validation.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ import { z } from 'zod'
22

33
export const PROFILE_TEXT_MAX_LENGTH = 200
44

5-
const ISO_DATE = /^(\d{4})[-/](\d{1,2})[-/](\d{1,2})(?:T.*)?$/
5+
/** A date, optionally with a time of day and offset, which are dropped. */
6+
const ISO_DATE =
7+
/^(\d{4})[-/](\d{1,2})[-/](\d{1,2})(?:[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?$/
68
const US_DATE = /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/
79

810
function calendarDate(year: number, month: number, day: number): string | null {

‎apps/sim/scripts/test-people-e2e.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -320,7 +320,7 @@ async function run() {
320320
from audit_log where resource_id = ${orgId} and action in ${sql(Object.keys(expected))}
321321
group by action`
322322
counts = Object.fromEntries(rows.map((row) => [row.action, row.count]))
323-
if (counts['org_people.imported'] === expected['org_people.imported']) break
323+
if (Object.entries(expected).every(([action, count]) => counts[action] === count)) break
324324
await sleep(250)
325325
}
326326
assert.deepEqual(counts, expected, 'Previews are not audited; each apply and edit is')

0 commit comments

Comments
 (0)