Skip to content

Commit 878abcc

Browse files
committed
feat(people): add a synced organization chart
1 parent fcb03df commit 878abcc

85 files changed

Lines changed: 39843 additions & 690 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/scripts/http-e2e.sh‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,11 @@ case "$group" in
164164
export NEXT_PUBLIC_CHAT_DISABLED=true
165165
export SIM_LINKS=true
166166
export PEOPLE=true
167+
export CRON_SECRET=people-directory-sync-local-e2e-secret
168+
start_browser_install people chromium
167169
start_app scim 3017 SCIM record-http-status
170+
wait_for_browsers
171+
export PEOPLE_E2E_SCREENSHOT_DIR="$report_dir/people-ui-report.json.artifacts"
168172
SCIM_E2E_BASE_URL="$local_app_url" \
169173
SCIM_E2E_DATABASE_URL="$DATABASE_URL" \
170174
SCIM_E2E_AUTH_SECRET="$BETTER_AUTH_SECRET" \

‎apps/docs/content/docs/search/meta.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"find-content",
77
"ask-questions",
88
"sim-links",
9+
"!people",
910
"troubleshooting",
1011
"---Connect sources---",
1112
"connect-your-account",
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
---
2+
title: People
3+
description: Find colleagues, explore reporting lines, and keep your directory in sync
4+
---
5+
6+
Open **People** in the organization sidebar to find a colleague or see who reports to whom. People appears when your organization has the feature enabled and you have access to its member directory. Organization owners and admins can also edit and import profiles.
7+
8+
## Explore the org chart
9+
10+
The chart opens at **My position**. Your manager appears above you and your direct reports appear below you. Select another person’s card to center the chart on them. To see their peers, select their manager and browse that manager’s direct reports.
11+
12+
- Use **Find a person** to jump to someone by name, title, or email.
13+
- Select a name in the reporting path to move up the chain. Select the organization name to browse top-level teams.
14+
- Use the report arrows to page through a large team. Use the zoom controls or **Fit chart** to bring the current branch back into view.
15+
- Select the profile arrow on a card to open that person’s details. **View in org chart** centers the chart on them. To share that position with another organization member, copy the page URL.
16+
17+
When no reporting lines have been recorded, an admin can connect a directory, import people, or add a manager to a profile. If just one person has no reporting lines, their profile remains available, and **Explore a team** opens a connected branch.
18+
19+
## Find and edit a profile
20+
21+
Switch to **Directory** to search by name, email, title, department, or location. Use **Filter → Department** to narrow the list. Select a row to open the person’s profile, including their email, department, location, start date, managers, and direct reports.
22+
23+
Profiles managed by a directory show their source. Change their information in that directory. To update a manually managed profile as an admin, select **Edit**, change the fields, and select **Save**. To change a reporting line, choose an active member of the same organization as the manager. Sim rejects self-reporting and reporting loops. Suspended members are hidden from the directory and chart.
24+
25+
## Keep People in sync
26+
27+
An organization admin can open **People → Directory sync** and connect one authoritative directory. Use sync when you want future hires, team moves, manager changes, and departures to appear automatically.
28+
29+
| Source | How to connect |
30+
| --- | --- |
31+
| **SCIM provisioning** | Enable your organization's [SCIM provisioning connection](/platform/enterprise/scim), then select it in People. Map core `title`, enterprise `department`, and enterprise `manager.value`. The manager value is the manager's SCIM resource ID in the same connection. |
32+
| **Okta** | Enter your Okta organization domain and an API token that can read all intended users. Sim reads active users and resolves managers using the Okta user ID in `profile.managerId`. |
33+
| **Microsoft Entra ID** | In your Microsoft commercial-cloud tenant, register an application, grant Microsoft Graph the **application** permission `User.Read.All`, and obtain admin consent. Enter the tenant ID, application (client) ID, and client secret **value**. Sim obtains a fresh access token for each sync and reads direct reports to build the chart. |
34+
35+
Select **Connect and sync** to run the first refresh. Sim stores the Okta token or Entra client secret encrypted. Use **Update credentials** to rotate them for the same directory.
36+
37+
### Who appears
38+
39+
Okta and Entra can include colleagues who have never signed in to Sim. Directory-only people have profiles and reporting lines, including links to managers who also have no account. Appearing in People grants no login, membership, workspace access, or seat. If someone later joins this organization with the same email address, People shows a single profile. Suspended Sim members are hidden. SCIM uses the people already provisioned through the connected Sim provisioning service. Its account provisioning and deprovisioning rules still apply.
40+
41+
The directory controls the name, title, department, and reporting line of the people it supplies. Okta and Entra also supply location. SCIM uses an address marked as a work address when one is available. Correct these fields in the source. Removing a manager upstream clears the source reporting line. An active person whose manager is absent from the directory appears without that manager. When an entry leaves the source, its directory-only profile disappears. An active Sim member can still appear with their manually recorded profile.
42+
43+
### Check a sync or recover from a failure
44+
45+
**Directory sync** shows when the last refresh succeeded and when the next one is due. Okta and Entra refresh hourly. A large directory can take several scheduler passes. The status shows the number of users read and whether Sim is reading people, resolving reporting lines, or publishing. **Sync now** starts a refresh. **Continue sync** processes more of an unfinished refresh. The scheduler checks every five minutes.
46+
47+
People keeps the last published chart visible until the full replacement passes validation. If a provider asks Sim to slow down, the status shows when the next attempt is allowed. Sim keeps its place and resumes from the last completed page. A refresh still unfinished after seven days restarts from the source on its next eligible run. An empty directory removes its entries only after a successful refresh.
48+
49+
SCIM user changes queue a refresh for the next scheduler pass. Sim reads the organization’s locally provisioned profiles together and publishes their reporting lines in one update.
50+
51+
After a failure, check the status for the cause. Correct invalid or duplicate email addresses and reporting loops in the source. Check credentials and directory permissions for an access failure, then select **Retry sync** when available.
52+
53+
To stop syncing, select **Disconnect**. This removes directory-only profiles and source-managed details. Existing Sim members and their manual profile data remain. Disconnect the current source before changing providers or tenants.
54+
55+
For self-hosted deployments, enable the cron service in Helm or Docker Compose. If you manage schedules yourself, call `GET /api/cron/people-directory-sync` every five minutes with your configured `CRON_SECRET` as a bearer token.
56+
57+
## Run a one-time import
58+
59+
Open **People → Import** and choose a source. Sim matches people to existing active members of this organization by email. Importing updates profile details and reporting lines. Membership and workspace access are managed separately. Use **Directory sync** to include people without Sim accounts or keep future changes up to date.
60+
61+
| Source | What you need |
62+
| --- | --- |
63+
| **Okta** | Your Okta organization domain, such as `example.okta.com`, and an API token with permission to read users. Sim reads active users and resolves managers from `profile.managerId`. |
64+
| **Microsoft Entra ID** | A Microsoft Graph access token for a work or school account in the Microsoft commercial cloud, with delegated `User.Read.All` permission. Sim reads enabled users and their managers. |
65+
| **SCIM provisioning** | An active [provisioning connection](/platform/enterprise/scim) with accounts already provisioned to Sim. Map core `title`, enterprise `department`, and enterprise `manager.value`. The manager value must be the manager’s SCIM User resource ID in the same connection. |
66+
| **Spreadsheet** | A CSV, TSV, or Excel file with an email column and at least one profile field. Use **Template** for the supported column names. |
67+
68+
Select **Preview import**, or choose a spreadsheet to preview it. Review the counts and warnings before selecting **Import**. Skipped rows make no changes. If a provider supplies the same email for two accounts, Sim skips both. If a manager cannot be matched, or a proposed reporting line would create a loop, Sim keeps the current manager and can still update other fields.
69+
70+
Okta and Entra tokens are used for the current import. If Okta, Entra, or provisioned SCIM profile data changes between preview and import, Sim asks you to preview again. Run another import when you want to bring in later directory changes. Missing or empty provider fields preserve the values already in Sim. To clear an old manager, edit the profile or use a spreadsheet.
71+
72+
## Prepare a spreadsheet
73+
74+
Put the headers in the first row of the first worksheet. For example, this file updates two members and sets Alina as Rowan’s manager. Replace the sample emails with your members’ addresses.
75+
76+
```csv
77+
email,title,department,manager_email
78+
alina@example.com,Engineering Manager,Engineering,
79+
rowan@example.com,Product Engineer,Engineering,alina@example.com
80+
```
81+
82+
Include only the columns you intend to update. A blank cell in an included column clears the existing value. In the example, the blank manager cell removes Alina’s manager. Omit the entire column to preserve that field for every row.
83+
84+
The importer also recognizes `mail` and `userPrincipalName` from Entra and `email` and `login` from Okta. It falls back to the sign-in address when the primary email cell is blank. Use `YYYY-MM-DD` for a start date.
85+
86+
Files can be up to 5 MiB, with at most 100 named columns and 10,000 data rows. The 10,000-row limit applies to one-time imports. Use **Directory sync** for a larger provider directory, or split a spreadsheet into smaller files and preview each one separately.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { type NextRequest, NextResponse } from 'next/server'
2+
import { sweepPeopleDirectoriesContract } from '@/lib/api/contracts/people-sync-cron'
3+
import { parseRequest } from '@/lib/api/server'
4+
import { verifyCronAuth } from '@/lib/auth/internal'
5+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
6+
import { syncDueDirectories } from '@/lib/people/sync/runner'
7+
8+
export const maxDuration = 300
9+
10+
export const GET = withRouteHandler(async (request: NextRequest) => {
11+
const authError = verifyCronAuth(request, 'People directory synchronization')
12+
if (authError) return authError
13+
const parsed = await parseRequest(sweepPeopleDirectoriesContract, request, {})
14+
if (!parsed.success) return parsed.response
15+
return NextResponse.json(await syncDueDirectories())
16+
})

‎apps/sim/app/api/organizations/[id]/people/[memberId]/route.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,12 @@ export const GET = defineInternalJsonRoute({
2121
operation: getPerson.operation,
2222
useCase: getPerson,
2323
mapInput: ({ params }) => ({ organizationId: params.id, memberId: params.memberId }),
24-
present: ({ person, chain, reports }) => ({ person: presentPerson(person), chain, reports }),
24+
present: ({ person, chain, reports, totalReports }) => ({
25+
person: presentPerson(person),
26+
chain,
27+
reports,
28+
totalReports,
29+
}),
2530
})
2631

2732
export const PUT = defineInternalJsonRoute({
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { getPeopleChartContract } from '@/lib/api/contracts/people'
2+
import { presentPerson } from '@/lib/api/server/people-presenters'
3+
import {
4+
defineInternalJsonRoute,
5+
internalOrchestrationErrorPolicy,
6+
internalRateLimits,
7+
internalSessionAuth,
8+
} from '@/lib/api/server/routes'
9+
import { getPeopleChart } from '@/lib/people/application/use-cases'
10+
11+
export const GET = defineInternalJsonRoute({
12+
contract: getPeopleChartContract,
13+
operation: getPeopleChart.operation,
14+
useCase: getPeopleChart,
15+
auth: internalSessionAuth,
16+
rateLimit: internalRateLimits.user({ bucketName: 'people' }),
17+
errorPolicy: internalOrchestrationErrorPolicy,
18+
staticResponseHeaders: { 'Cache-Control': 'private, no-store' },
19+
mapInput: ({ params, query }) => ({ organizationId: params.id, ...query }),
20+
present: ({ person, ...branch }) => ({ ...branch, person: presentPerson(person) }),
21+
})
Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
11
import { listDepartmentsContract } from '@/lib/api/contracts/people'
2+
import { cursorRoute, cursorScopeKey } from '@/lib/api/cursor-binding'
23
import {
34
defineInternalJsonRoute,
45
internalOrchestrationErrorPolicy,
56
internalRateLimits,
67
internalSessionAuth,
78
} from '@/lib/api/server/routes'
89
import { listDepartments } from '@/lib/people/application/use-cases'
10+
import { readSortedCursor, writeSortedCursor } from '@/app/api/v2/lib/response'
11+
12+
function scope(id: string, search?: string) {
13+
return cursorScopeKey(cursorRoute(listDepartmentsContract, { id }), { search })
14+
}
915

1016
export const GET = defineInternalJsonRoute({
1117
contract: listDepartmentsContract,
@@ -15,6 +21,18 @@ export const GET = defineInternalJsonRoute({
1521
rateLimit: internalRateLimits.user({ bucketName: 'people' }),
1622
errorPolicy: internalOrchestrationErrorPolicy,
1723
staticResponseHeaders: { 'Cache-Control': 'private, no-store' },
18-
mapInput: ({ params }) => ({ organizationId: params.id }),
19-
present: (departments) => ({ departments }),
24+
mapInput: ({ params, query }) => ({
25+
organizationId: params.id,
26+
search: query.search,
27+
cursorKeys: readSortedCursor(query.cursor, 'department', 'asc', scope(params.id, query.search)),
28+
}),
29+
present: ({ data, nextCursorKeys }, { input }) => ({
30+
departments: data,
31+
nextCursor: writeSortedCursor(
32+
nextCursorKeys,
33+
'department',
34+
'asc',
35+
scope(input.organizationId, input.search)
36+
),
37+
}),
2038
})

‎apps/sim/app/api/organizations/[id]/people/route.ts‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,13 @@ import { listPeople } from '@/lib/people/application/use-cases'
1111
import { readSortedCursor, writeSortedCursor } from '@/app/api/v2/lib/response'
1212

1313
/** A cursor resumes only the list it came from: the same organization, search, and department. */
14-
function cursorScope(id: string, search: string | undefined, department: string | undefined) {
15-
return cursorScopeKey(cursorRoute(listPeopleContract, { id }), { search, department })
14+
function cursorScope(
15+
id: string,
16+
search: string | undefined,
17+
department: string | undefined,
18+
scope?: 'roots' | 'members'
19+
) {
20+
return cursorScopeKey(cursorRoute(listPeopleContract, { id }), { search, department, scope })
1621
}
1722

1823
export const GET = defineInternalJsonRoute({
@@ -27,11 +32,12 @@ export const GET = defineInternalJsonRoute({
2732
organizationId: params.id,
2833
search: query.search,
2934
department: query.department,
35+
scope: query.scope,
3036
cursorKeys: readSortedCursor(
3137
query.cursor,
3238
'name',
3339
'asc',
34-
cursorScope(params.id, query.search, query.department)
40+
cursorScope(params.id, query.search, query.department, query.scope)
3541
),
3642
}),
3743
present: ({ data, nextCursorKeys }, { input }) => ({
@@ -40,7 +46,7 @@ export const GET = defineInternalJsonRoute({
4046
nextCursorKeys,
4147
'name',
4248
'asc',
43-
cursorScope(input.organizationId, input.search, input.department)
49+
cursorScope(input.organizationId, input.search, input.department, input.scope)
4450
),
4551
}),
4652
})
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import {
2+
configurePeopleSyncContract,
3+
disconnectPeopleSyncContract,
4+
getPeopleSyncContract,
5+
} from '@/lib/api/contracts/people-sync'
6+
import {
7+
defineInternalJsonRoute,
8+
internalOrchestrationErrorPolicy,
9+
internalRateLimits,
10+
internalSessionAuth,
11+
} from '@/lib/api/server/routes'
12+
import {
13+
configurePeopleSync,
14+
disconnectPeopleSync,
15+
getPeopleSync,
16+
} from '@/lib/people/application/directory-sync'
17+
18+
export const maxDuration = 180
19+
20+
const common = {
21+
auth: internalSessionAuth,
22+
errorPolicy: internalOrchestrationErrorPolicy,
23+
staticResponseHeaders: { 'Cache-Control': 'private, no-store' },
24+
} as const
25+
26+
export const GET = defineInternalJsonRoute({
27+
...common,
28+
contract: getPeopleSyncContract,
29+
operation: getPeopleSync.operation,
30+
useCase: getPeopleSync,
31+
rateLimit: internalRateLimits.user({ bucketName: 'people-sync-status' }),
32+
mapInput: ({ params }) => ({ organizationId: params.id }),
33+
})
34+
35+
export const PUT = defineInternalJsonRoute({
36+
...common,
37+
contract: configurePeopleSyncContract,
38+
operation: configurePeopleSync.operation,
39+
useCase: configurePeopleSync,
40+
rateLimit: internalRateLimits.user({
41+
bucketName: 'people-sync-configure',
42+
config: { maxTokens: 5, refillRate: 2, refillIntervalMs: 60000 },
43+
}),
44+
mapInput: ({ params, body }) => ({ organizationId: params.id, config: body }),
45+
})
46+
47+
export const DELETE = defineInternalJsonRoute({
48+
...common,
49+
contract: disconnectPeopleSyncContract,
50+
operation: disconnectPeopleSync.operation,
51+
useCase: disconnectPeopleSync,
52+
rateLimit: internalRateLimits.user({ bucketName: 'people-sync-disconnect' }),
53+
mapInput: ({ params }) => ({ organizationId: params.id }),
54+
})
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { runPeopleSyncContract } from '@/lib/api/contracts/people-sync'
2+
import {
3+
defineInternalJsonRoute,
4+
internalOrchestrationErrorPolicy,
5+
internalRateLimits,
6+
internalSessionAuth,
7+
} from '@/lib/api/server/routes'
8+
import { runPeopleSync } from '@/lib/people/application/directory-sync'
9+
10+
export const maxDuration = 180
11+
12+
export const POST = defineInternalJsonRoute({
13+
contract: runPeopleSyncContract,
14+
operation: runPeopleSync.operation,
15+
useCase: runPeopleSync,
16+
auth: internalSessionAuth,
17+
errorPolicy: internalOrchestrationErrorPolicy,
18+
staticResponseHeaders: { 'Cache-Control': 'private, no-store' },
19+
rateLimit: internalRateLimits.user({
20+
bucketName: 'people-sync-run',
21+
config: { maxTokens: 5, refillRate: 2, refillIntervalMs: 60000 },
22+
}),
23+
mapInput: ({ params }) => ({ organizationId: params.id }),
24+
})

0 commit comments

Comments
 (0)