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
4 changes: 4 additions & 0 deletions .github/scripts/http-e2e.sh
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,11 @@ case "$group" in
export NEXT_PUBLIC_CHAT_DISABLED=true
export SIM_LINKS=true
export PEOPLE=true
export CRON_SECRET=people-directory-sync-local-e2e-secret
start_browser_install people chromium
start_app scim 3017 SCIM record-http-status
wait_for_browsers
export PEOPLE_E2E_SCREENSHOT_DIR="$report_dir/people-ui-report.json.artifacts"
SCIM_E2E_BASE_URL="$local_app_url" \
SCIM_E2E_DATABASE_URL="$DATABASE_URL" \
SCIM_E2E_AUTH_SECRET="$BETTER_AUTH_SECRET" \
Expand Down
1 change: 1 addition & 0 deletions apps/docs/content/docs/search/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"find-content",
"ask-questions",
"sim-links",
"!people",
"troubleshooting",
"---Connect sources---",
"connect-your-account",
Expand Down
86 changes: 86 additions & 0 deletions apps/docs/content/docs/search/people.mdx.draft
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
---
title: People
description: Find colleagues, explore reporting lines, and keep your directory in sync
---

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.

## Explore the org chart

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.

- Use **Find a person** to jump to someone by name, title, or email.
- Select a name in the reporting path to move up the chain. Select the organization name to browse top-level teams.
- 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.
- 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.

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.

## Find and edit a profile

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.

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.

## Keep People in sync

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.

| Source | How to connect |
| --- | --- |
| **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. |
| **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`. |
| **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. |

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.

### Who appears

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.

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.

### Check a sync or recover from a failure

**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.

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.

SCIM user changes queue a refresh for the next scheduler pass, including changes that arrive while a refresh is running. Sim reads locally provisioned profiles together and publishes their reporting lines in one update. When SCIM deactivates or removes someone, their directory-managed profile is hidden immediately.

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. The status shows when Sim will retry automatically. After correcting a source error you can select **Retry sync** immediately. For a provider cooldown, the button becomes available when the cooldown ends.

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.

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.

## Run a one-time import

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.

| Source | What you need |
| --- | --- |
| **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`. |
| **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. |
| **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. |
| **Spreadsheet** | A CSV, TSV, or Excel file with an email column and at least one profile field. Use **Template** for the supported column names. |

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.

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.

## Prepare a spreadsheet

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.

```csv
email,title,department,manager_email
alina@example.com,Engineering Manager,Engineering,
rowan@example.com,Product Engineer,Engineering,alina@example.com
```

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.

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.

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.
16 changes: 16 additions & 0 deletions apps/sim/app/api/cron/people-directory-sync/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { type NextRequest, NextResponse } from 'next/server'
import { sweepPeopleDirectoriesContract } from '@/lib/api/contracts/people-sync-cron'
import { parseRequest } from '@/lib/api/server'
import { verifyCronAuth } from '@/lib/auth/internal'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { syncDueDirectories } from '@/lib/people/sync/runner'

export const maxDuration = 300

export const GET = withRouteHandler(async (request: NextRequest) => {
const authError = verifyCronAuth(request, 'People directory synchronization')
if (authError) return authError
const parsed = await parseRequest(sweepPeopleDirectoriesContract, request, {})
if (!parsed.success) return parsed.response
return NextResponse.json(await syncDueDirectories())
})
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,12 @@ export const GET = defineInternalJsonRoute({
operation: getPerson.operation,
useCase: getPerson,
mapInput: ({ params }) => ({ organizationId: params.id, memberId: params.memberId }),
present: ({ person, chain, reports }) => ({ person: presentPerson(person), chain, reports }),
present: ({ person, chain, reports, totalReports }) => ({
person: presentPerson(person),
chain,
reports,
totalReports,
}),
})

export const PUT = defineInternalJsonRoute({
Expand Down
21 changes: 21 additions & 0 deletions apps/sim/app/api/organizations/[id]/people/chart/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { getPeopleChartContract } from '@/lib/api/contracts/people'
import { presentPerson } from '@/lib/api/server/people-presenters'
import {
defineInternalJsonRoute,
internalOrchestrationErrorPolicy,
internalRateLimits,
internalSessionAuth,
} from '@/lib/api/server/routes'
import { getPeopleChart } from '@/lib/people/application/use-cases'

export const GET = defineInternalJsonRoute({
contract: getPeopleChartContract,
operation: getPeopleChart.operation,
useCase: getPeopleChart,
auth: internalSessionAuth,
rateLimit: internalRateLimits.user({ bucketName: 'people' }),
errorPolicy: internalOrchestrationErrorPolicy,
staticResponseHeaders: { 'Cache-Control': 'private, no-store' },
mapInput: ({ params, query }) => ({ organizationId: params.id, ...query }),
present: ({ person, ...branch }) => ({ ...branch, person: presentPerson(person) }),
})
26 changes: 24 additions & 2 deletions apps/sim/app/api/organizations/[id]/people/departments/route.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
import { listDepartmentsContract } from '@/lib/api/contracts/people'
import { cursorRoute, cursorScopeKey } from '@/lib/api/cursor-binding'
import { departmentFilterKey } from '@/lib/api/server/people-presenters'
import {
defineInternalJsonRoute,
internalOrchestrationErrorPolicy,
internalRateLimits,
internalSessionAuth,
} from '@/lib/api/server/routes'
import { listDepartments } from '@/lib/people/application/use-cases'
import { cursorSortKey, decodeOffsetCursor, encodeOffsetCursor } from '@/app/api/v2/lib/response'

const SORT = cursorSortKey('department', 'asc')

function scope(id: string, search?: string) {
return cursorScopeKey(cursorRoute(listDepartmentsContract, { id }), { search })
}

export const GET = defineInternalJsonRoute({
contract: listDepartmentsContract,
Expand All @@ -15,6 +24,19 @@ export const GET = defineInternalJsonRoute({
rateLimit: internalRateLimits.user({ bucketName: 'people' }),
errorPolicy: internalOrchestrationErrorPolicy,
staticResponseHeaders: { 'Cache-Control': 'private, no-store' },
mapInput: ({ params }) => ({ organizationId: params.id }),
present: (departments) => ({ departments }),
mapInput: ({ params, query }) => ({
organizationId: params.id,
search: query.search,
offset: decodeOffsetCursor(query.cursor, SORT, scope(params.id, query.search)),
}),
present: ({ data, nextOffset }, { input }) => ({
departments: data.map((department) => ({
...department,
departmentKey: departmentFilterKey(department.name),
})),
nextCursor:
nextOffset === null
? null
: encodeOffsetCursor(SORT, scope(input.organizationId, input.search), nextOffset),
}),
})
30 changes: 25 additions & 5 deletions apps/sim/app/api/organizations/[id]/people/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,19 @@ import { listPeople } from '@/lib/people/application/use-cases'
import { readSortedCursor, writeSortedCursor } from '@/app/api/v2/lib/response'

/** A cursor resumes only the list it came from: the same organization, search, and department. */
function cursorScope(id: string, search: string | undefined, department: string | undefined) {
return cursorScopeKey(cursorRoute(listPeopleContract, { id }), { search, department })
function cursorScope(
id: string,
search: string | undefined,
department: string | undefined,
departmentKey: string | undefined,
scope?: 'roots' | 'members'
) {
return cursorScopeKey(cursorRoute(listPeopleContract, { id }), {
search,
department,
departmentKey,
scope,
})
}

export const GET = defineInternalJsonRoute({
Expand All @@ -27,20 +38,29 @@ export const GET = defineInternalJsonRoute({
organizationId: params.id,
search: query.search,
department: query.department,
departmentKey: query.departmentKey,
scope: query.scope,
cursorKeys: readSortedCursor(
query.cursor,
'name',
'asc',
cursorScope(params.id, query.search, query.department)
cursorScope(params.id, query.search, query.department, query.departmentKey, query.scope)
),
}),
present: ({ data, nextCursorKeys }, { input }) => ({
present: ({ data, nextCursorKeys, selectedDepartment }, { input }) => ({
people: data.map(presentPerson),
selectedDepartment,
nextCursor: writeSortedCursor(
nextCursorKeys,
'name',
'asc',
cursorScope(input.organizationId, input.search, input.department)
cursorScope(
input.organizationId,
input.search,
input.department,
input.departmentKey,
input.scope
)
),
}),
})
54 changes: 54 additions & 0 deletions apps/sim/app/api/organizations/[id]/people/sync/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import {
configurePeopleSyncContract,
disconnectPeopleSyncContract,
getPeopleSyncContract,
} from '@/lib/api/contracts/people-sync'
import {
defineInternalJsonRoute,
internalOrchestrationErrorPolicy,
internalRateLimits,
internalSessionAuth,
} from '@/lib/api/server/routes'
import {
configurePeopleSync,
disconnectPeopleSync,
getPeopleSync,
} from '@/lib/people/application/directory-sync'

export const maxDuration = 180

const common = {
auth: internalSessionAuth,
errorPolicy: internalOrchestrationErrorPolicy,
staticResponseHeaders: { 'Cache-Control': 'private, no-store' },
} as const

export const GET = defineInternalJsonRoute({
...common,
contract: getPeopleSyncContract,
operation: getPeopleSync.operation,
useCase: getPeopleSync,
rateLimit: internalRateLimits.user({ bucketName: 'people-sync-status' }),
mapInput: ({ params }) => ({ organizationId: params.id }),
})

export const PUT = defineInternalJsonRoute({
...common,
contract: configurePeopleSyncContract,
operation: configurePeopleSync.operation,
useCase: configurePeopleSync,
rateLimit: internalRateLimits.user({
bucketName: 'people-sync-configure',
config: { maxTokens: 5, refillRate: 2, refillIntervalMs: 60000 },
}),
mapInput: ({ params, body }) => ({ organizationId: params.id, config: body }),
})

export const DELETE = defineInternalJsonRoute({
...common,
contract: disconnectPeopleSyncContract,
operation: disconnectPeopleSync.operation,
useCase: disconnectPeopleSync,
rateLimit: internalRateLimits.user({ bucketName: 'people-sync-disconnect' }),
mapInput: ({ params }) => ({ organizationId: params.id }),
})
24 changes: 24 additions & 0 deletions apps/sim/app/api/organizations/[id]/people/sync/run/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { runPeopleSyncContract } from '@/lib/api/contracts/people-sync'
import {
defineInternalJsonRoute,
internalOrchestrationErrorPolicy,
internalRateLimits,
internalSessionAuth,
} from '@/lib/api/server/routes'
import { runPeopleSync } from '@/lib/people/application/directory-sync'

export const maxDuration = 180

export const POST = defineInternalJsonRoute({
contract: runPeopleSyncContract,
operation: runPeopleSync.operation,
useCase: runPeopleSync,
auth: internalSessionAuth,
errorPolicy: internalOrchestrationErrorPolicy,
staticResponseHeaders: { 'Cache-Control': 'private, no-store' },
rateLimit: internalRateLimits.user({
bucketName: 'people-sync-run',
config: { maxTokens: 5, refillRate: 2, refillIntervalMs: 60000 },
}),
mapInput: ({ params }) => ({ organizationId: params.id }),
})
8 changes: 7 additions & 1 deletion apps/sim/app/o/[organizationId]/people/[memberId]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { Suspense } from 'react'
import type { Metadata } from 'next'
import { notFound, redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
import { organizationRoutes } from '@/lib/navigation/paths'
import { isPeopleEnabled } from '@/lib/people/feature-flag'
import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect'
import PageLoading from '@/app/o/[organizationId]/people/[memberId]/loading'
import { Person } from '@/app/o/[organizationId]/people/[memberId]/person'

export const metadata: Metadata = { title: 'People' }
Expand All @@ -23,5 +25,9 @@ export default async function PersonPage({ params }: PersonPageProps) {
})
)
if (!(await isPeopleEnabled(organizationId))) notFound()
return <Person organizationId={organizationId} memberId={memberId} />
return (
<Suspense fallback={<PageLoading />}>
<Person organizationId={organizationId} memberId={memberId} />
</Suspense>
)
}
Loading
Loading