diff --git a/apps/sim/app/o/[organizationId]/_redesign/redesign-host.tsx b/apps/sim/app/o/[organizationId]/_redesign/redesign-host.tsx index 857f03e6182..ecac86ac933 100644 --- a/apps/sim/app/o/[organizationId]/_redesign/redesign-host.tsx +++ b/apps/sim/app/o/[organizationId]/_redesign/redesign-host.tsx @@ -18,3 +18,12 @@ interface RedesignSurfaceProps { export function RedesignSurface({ children }: RedesignSurfaceProps) { return {children} } + +/** Fills the app's content slot, and the grid stretches the surface so its canvas reaches the end. */ +export function RedesignPageSurface({ children }: RedesignSurfaceProps) { + return ( +
+ {children} +
+ ) +} diff --git a/apps/sim/app/o/[organizationId]/links/links-surface.tsx b/apps/sim/app/o/[organizationId]/links/links-surface.tsx deleted file mode 100644 index 9ea6175bb9c..00000000000 --- a/apps/sim/app/o/[organizationId]/links/links-surface.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import type { ReactNode } from 'react' -import { RedesignSurface } from '@/app/o/[organizationId]/_redesign/redesign-host' - -interface LinksSurfaceProps { - children: ReactNode -} - -/** Fills the app's content slot, and the grid stretches the surface so its canvas reaches the end. */ -export function LinksSurface({ children }: LinksSurfaceProps) { - return ( -
- {children} -
- ) -} diff --git a/apps/sim/app/o/[organizationId]/links/loading.tsx b/apps/sim/app/o/[organizationId]/links/loading.tsx index 004a9e26083..2aa2aba9ade 100644 --- a/apps/sim/app/o/[organizationId]/links/loading.tsx +++ b/apps/sim/app/o/[organizationId]/links/loading.tsx @@ -1,4 +1,4 @@ -import { LinksSurface } from '@/app/o/[organizationId]/links/links-surface' +import { RedesignPageSurface } from '@/app/o/[organizationId]/_redesign/redesign-host' import { LinkRowsSkeleton, LinksFrame, @@ -6,10 +6,10 @@ import { export default function SimLinksLoading() { return ( - + - + ) } diff --git a/apps/sim/app/o/[organizationId]/links/page.tsx b/apps/sim/app/o/[organizationId]/links/page.tsx index 77b12a2deac..b788502566e 100644 --- a/apps/sim/app/o/[organizationId]/links/page.tsx +++ b/apps/sim/app/o/[organizationId]/links/page.tsx @@ -5,7 +5,7 @@ import { getSession } from '@/lib/auth' import { organizationRoutes } from '@/lib/navigation/paths' import { isSimLinksEnabled } from '@/lib/sim-links/feature-flag' import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect' -import { LinksSurface } from '@/app/o/[organizationId]/links/links-surface' +import { RedesignPageSurface } from '@/app/o/[organizationId]/_redesign/redesign-host' import { loadSimLinksListView } from '@/app/o/[organizationId]/links/search-params' import { LinksPage } from '@/platform/app/(shell)/links/_components/links-page/links-page' import { prefetchSimLinks } from '@/platform/lib/data/prefetch' @@ -34,10 +34,10 @@ export default async function SimLinksPage({ params, searchParams }: SimLinksPag loadSimLinksListView(query) ) return ( - + - + ) } diff --git a/apps/sim/app/o/[organizationId]/people/[memberId]/loading.tsx b/apps/sim/app/o/[organizationId]/people/[memberId]/loading.tsx index 4d1e787708c..e3a7a006921 100644 --- a/apps/sim/app/o/[organizationId]/people/[memberId]/loading.tsx +++ b/apps/sim/app/o/[organizationId]/people/[memberId]/loading.tsx @@ -1,5 +1,10 @@ -import { ApplicationLoading } from '@/app/_shell/application-loading' +import { RedesignPageSurface } from '@/app/o/[organizationId]/_redesign/redesign-host' +import { PersonSkeleton } from '@/platform/app/(shell)/people/_components/person/person-skeleton' -export default function Loading() { - return +export default function PersonLoading() { + return ( + + + + ) } diff --git a/apps/sim/app/o/[organizationId]/people/[memberId]/page.tsx b/apps/sim/app/o/[organizationId]/people/[memberId]/page.tsx index 5c3144801f6..cc7373cd2aa 100644 --- a/apps/sim/app/o/[organizationId]/people/[memberId]/page.tsx +++ b/apps/sim/app/o/[organizationId]/people/[memberId]/page.tsx @@ -3,10 +3,12 @@ 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 { getOrganizationSurfaceContext } from '@/lib/organizations/surface' +import { canBrowsePeople } 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' +import { RedesignPageSurface } from '@/app/o/[organizationId]/_redesign/redesign-host' +import { PersonProfile } from '@/platform/app/(shell)/people/_components/person/person' +import { PersonSkeleton } from '@/platform/app/(shell)/people/_components/person/person-skeleton' export const metadata: Metadata = { title: 'People' } @@ -24,10 +26,13 @@ export default async function PersonPage({ params }: PersonPageProps) { isInviteFlow: false, }) ) - if (!(await isPeopleEnabled(organizationId))) notFound() + const context = await getOrganizationSurfaceContext(organizationId, session.user.id) + if (!context || !(await canBrowsePeople(organizationId, context.viewer.role))) notFound() return ( - }> - - + + }> + + + ) } diff --git a/apps/sim/app/o/[organizationId]/people/[memberId]/person-modal.tsx b/apps/sim/app/o/[organizationId]/people/[memberId]/person-modal.tsx deleted file mode 100644 index 8783105b517..00000000000 --- a/apps/sim/app/o/[organizationId]/people/[memberId]/person-modal.tsx +++ /dev/null @@ -1,148 +0,0 @@ -'use client' - -import { useState } from 'react' -import { - Chip, - ChipCombobox, - ChipDatePicker, - ChipModal, - ChipModalBody, - ChipModalError, - ChipModalField, - ChipModalFooter, - ChipModalHeader, -} from '@sim/emcn' -import type { Person, PersonBody } from '@/lib/api/contracts/people' -import { memberProfileFieldsSchema, PROFILE_TEXT_MAX_LENGTH } from '@/lib/people/validation' -import { usePeoplePicker } from '@/app/o/[organizationId]/people/use-people-picker' -import { useUpdatePerson } from '@/hooks/queries/people' - -type TextField = 'title' | 'department' | 'location' - -/** The picker's value for no manager; membership ids are never this. */ -const NO_MANAGER = 'no-manager' - -const TEXT_FIELDS: readonly { field: TextField; title: string; placeholder: string }[] = [ - { field: 'title', title: 'Title', placeholder: 'Staff Engineer' }, - { field: 'department', title: 'Department', placeholder: 'Engineering' }, - { field: 'location', title: 'Location', placeholder: 'San Francisco' }, -] - -interface PersonModalProps { - organizationId: string - person: Person - onClose: () => void -} - -/** Edits one person's profile; managers are chosen from the organization's people. */ -export function PersonModal({ organizationId, person, onClose }: PersonModalProps) { - const update = useUpdatePerson(organizationId) - const [draft, setDraft] = useState({ - title: person.title, - department: person.department, - location: person.location, - startDate: person.startDate, - managerMemberId: person.managerMemberId, - }) - const [errors, setErrors] = useState>>({}) - /** - * The chosen manager's name, kept with the choice: the search results may no longer include them. - * Only a stored manager who is suspended has an id and no name. - */ - const [managerName, setManagerName] = useState(person.manager?.name ?? null) - const { people, pickerProps } = usePeoplePicker(organizationId, 'members', person.memberId) - const managerLabel = draft.managerMemberId ? (managerName ?? 'Suspended member') : 'No manager' - - const change = (field: K, value: PersonBody[K]) => { - setDraft((current) => ({ ...current, [field]: value })) - setErrors((current) => ({ ...current, [field]: undefined })) - if (update.isError) update.reset() - } - - const save = () => { - const parsed = memberProfileFieldsSchema.safeParse(draft) - if (!parsed.success) { - setErrors( - Object.fromEntries( - parsed.error.issues.map((issue) => [String(issue.path[0]), issue.message]) - ) - ) - return - } - update.mutate({ memberId: person.memberId, body: parsed.data }, { onSuccess: onClose }) - } - - const title = `Edit ${person.name}` - return ( - !open && onClose()} - dismissDisabled={update.isPending} - srTitle={title} - > - {title} - - {TEXT_FIELDS.map(({ field, title: label, placeholder }) => ( - change(field, value || null)} - error={errors[field]} - /> - ))} - - {() => ( -
- change('startDate', value)} - placeholder='Not recorded' - disabled={update.isPending} - fullWidth - /> - {draft.startDate && ( - change('startDate', null)}> - Clear - - )} -
- )} -
- - {() => ( - { - const manager = people.find((candidate) => candidate.memberId === value) - setManagerName(manager?.name ?? null) - change('managerMemberId', manager?.memberId ?? null) - }} - overlayContent={managerLabel} - searchPlaceholder='Search people...' - emptyMessage='No one matches' - disabled={update.isPending} - className='w-full' - /> - )} - - {update.error?.message} -
- -
- ) -} diff --git a/apps/sim/app/o/[organizationId]/people/[memberId]/person.tsx b/apps/sim/app/o/[organizationId]/people/[memberId]/person.tsx deleted file mode 100644 index c4f9f04103e..00000000000 --- a/apps/sim/app/o/[organizationId]/people/[memberId]/person.tsx +++ /dev/null @@ -1,233 +0,0 @@ -'use client' - -import { useState } from 'react' -import { Avatar, Chip, ChipLink } from '@sim/emcn' -import { ArrowLeft, Workflow } from '@sim/emcn/icons' -import Link from 'next/link' -import { useQueryStates } from 'nuqs' -import { isApiClientError } from '@/lib/api/client/errors' -import type { PersonSummary } from '@/lib/api/contracts/people' -import { organizationRoutes } from '@/lib/navigation/paths' -import { PersonModal } from '@/app/o/[organizationId]/people/[memberId]/person-modal' -import { peopleParsers, serializePeopleView } from '@/app/o/[organizationId]/people/search-params' -import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider' -import { - CredentialDetailHeading, - CredentialDetailLayout, - DetailSection, -} from '@/app/workspace/[workspaceId]/components/credential-detail' -import { - SettingsEmptyState, - SettingsQueryErrorState, -} from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' -import { SettingsField } from '@/app/workspace/[workspaceId]/settings/components/settings-field' -import { - RESOURCE_LIST_STACK, - SettingsResourceRow, -} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' -import { usePerson } from '@/hooks/queries/people' - -const SOURCE_NAMES = { - scim: 'SCIM provisioning', - okta: 'Okta', - entra: 'Microsoft Entra ID', -} as const -const NOT_RECORDED =

Not recorded

- -const START_DATE_FORMAT = new Intl.DateTimeFormat(undefined, { - dateStyle: 'medium', - timeZone: 'UTC', -}) - -/** `2024-01-31` as a calendar date, with how long ago it was: "Jan 31, 2024 · 2 yrs". */ -function describeStartDate(startDate: string): string { - const start = new Date(`${startDate}T00:00:00Z`) - const now = new Date() - const months = - (now.getUTCFullYear() - start.getUTCFullYear()) * 12 + - now.getUTCMonth() - - start.getUTCMonth() - - (now.getUTCDate() < start.getUTCDate() ? 1 : 0) - const tenure = - months < 0 - ? 'starts soon' - : months < 1 - ? 'new' - : months < 12 - ? `${months} mo` - : `${Math.floor(months / 12)} yr${months >= 24 ? 's' : ''}` - return `${START_DATE_FORMAT.format(start)} · ${tenure}` -} - -interface PersonRowsProps { - organizationId: string - originQuery: string - people: readonly PersonSummary[] - empty: string -} - -function PersonRows({ organizationId, originQuery, people, empty }: PersonRowsProps) { - if (!people.length) return {empty} - const routes = organizationRoutes(organizationId) - return ( -
- {people.map((person) => ( - } - title={person.name} - description={person.title ?? person.email} - href={`${routes.person(person.memberId)}${originQuery}`} - clickLabel={`Open ${person.name}`} - navigable - /> - ))} -
- ) -} - -interface PersonProps { - organizationId: string - memberId: string -} - -/** One person's profile: what they do, where they are, and where they sit in the organization. */ -export function Person({ organizationId, memberId }: PersonProps) { - const { viewer } = useOrganizationContext() - const [origin] = useQueryStates(peopleParsers) - const query = usePerson(organizationId, memberId) - const [editing, setEditing] = useState(false) - const routes = organizationRoutes(organizationId) - const originQuery = serializePeopleView({ ...origin, view: origin.view ?? 'directory' }) - const notFound = isApiClientError(query.error) && query.error.status === 404 - const refused = isApiClientError(query.error) && query.error.status < 500 - // A transient refresh failure keeps the loaded profile and any edit open on it; a refusal does not. - const loadError = query.data && !refused ? null : query.error - const data = loadError ? undefined : query.data - - const chartHref = serializePeopleView(routes.people, { - ...origin, - view: 'chart', - focus: data?.person.memberId ?? memberId, - page: origin.focus === data?.person.memberId ? origin.page : 1, - panel: null, - }) - return ( - - People - - } - actions={ - data ? ( - <> - - View in org chart - - {viewer.isAdmin && !data.person.directorySource && ( - setEditing(true)}>Edit - )} - - ) : undefined - } - > - {loadError ? ( - notFound ? ( - - This person isn't in your organization. - - ) : ( - void query.refetch()} - variant='inline' - /> - ) - ) : !data ? ( - Loading… - ) : ( - <> - - } - title={data.person.name} - subtitle={data.person.title} - /> - -
- - - {data.person.email} - - - - {data.person.department ? ( - - {data.person.department} - - ) : ( - NOT_RECORDED - )} - - {data.person.directorySource && ( - - {SOURCE_NAMES[data.person.directorySource]} - - )} - {data.person.location ?? NOT_RECORDED} - - {data.person.startDate ? describeStartDate(data.person.startDate) : NOT_RECORDED} - -
-
- - - - - - {data.totalReports > data.reports.length && ( - Browse all {data.totalReports} reports - )} - - - )} - {editing && data && !data.person.directorySource && ( - setEditing(false)} - /> - )} -
- ) -} diff --git a/apps/sim/app/o/[organizationId]/people/chart-canvas.tsx b/apps/sim/app/o/[organizationId]/people/chart-canvas.tsx deleted file mode 100644 index f7ae966a52d..00000000000 --- a/apps/sim/app/o/[organizationId]/people/chart-canvas.tsx +++ /dev/null @@ -1,299 +0,0 @@ -'use client' - -import { type MouseEvent, useMemo } from 'react' -import { Avatar, cn, OverflowText, Tooltip } from '@sim/emcn' -import { ArrowUpRight, ChevronRight, Minus, Plus } from '@sim/emcn/icons' -import { - BLOCK_DIMENSIONS, - CONNECTION_KNOB_PEAK_PX, - useCanvasColorMode, - WorkflowBlockBorder, - type WorkflowBorderPort, -} from '@sim/workflow-renderer' -import { - BaseEdge, - type Edge, - type EdgeProps, - Handle, - type Node, - type NodeProps, - Position, - ReactFlow, - useReactFlow, - useStore, -} from '@xyflow/react' -import Link from 'next/link' -import { CanvasToolbar, CanvasToolbarButton } from '@/components/canvas-toolbar' -import type { ChartBranch, PersonSummary } from '@/lib/api/contracts/people' -import { organizationRoutes } from '@/lib/navigation/paths' - -import '@xyflow/react/dist/style.css' - -type CardData = { - person: PersonSummary - relation: 'Manager' | 'In focus' | 'Direct report' - department?: string | null - reportCount?: number - ports: WorkflowBorderPort[] - profileUrl: string - onFocus: (id: string, event: MouseEvent) => void -} -type CardNode = Node -const PORT_POSITIONS = { - top: Position.Top, - bottom: Position.Bottom, - left: Position.Left, - right: Position.Right, -} as const -const WIDTH = BLOCK_DIMENSIONS.FIXED_WIDTH -const HEIGHT = BLOCK_DIMENSIONS.MIN_HEIGHT -const LEVEL = HEIGHT + 48 -const COLUMN = WIDTH / 2 + 32 - -function PersonCard({ data }: NodeProps) { - const { person, relation, reportCount, department, onFocus, profileUrl, ports } = data - const focused = relation === 'In focus' - const contents = ( - <> - - - - - - - - - - - - {focused ? ( - <> - View profile - - - ) : ( - <> - {reportCount === undefined - ? 'Explore team' - : `${reportCount} report${reportCount === 1 ? '' : 's'}`} - - - )} - - - - ) - const actionClass = - 'nodrag nopan pointer-events-auto relative flex size-full cursor-pointer flex-col rounded-2xl bg-[var(--person-card-fill)] text-left outline-offset-2 [--person-card-fill:var(--surface-2)] hover-hover:[--person-card-fill:var(--surface-1)] focus-visible:outline-2 focus-visible:outline-[var(--brand-secondary)] [&_[data-workflow-block-border]_path]:pointer-events-none' - return ( - <> - {ports.map((port) => ( - - ))} - {focused ? ( - - {contents} - - ) : ( - <> - - - - - - - - View profile - - - )} - - ) -} - -function ReportingLine({ sourceX, sourceY, targetX, targetY }: EdgeProps) { - const direction = Math.sign(targetX - sourceX) - const radius = Math.min(9, Math.abs(targetX - sourceX), Math.abs(targetY - sourceY)) - const path = - direction === 0 - ? `M ${sourceX} ${sourceY} V ${targetY}` - : `M ${sourceX} ${sourceY} V ${targetY - radius} Q ${sourceX} ${targetY} ${sourceX + direction * radius} ${targetY} H ${targetX}` - return ( - - ) -} - -const NODE_TYPES = { person: PersonCard } -const EDGE_TYPES = { reporting: ReportingLine } -const MIN_ZOOM = 0.35 -const MAX_ZOOM = 1.6 -const FIT = { padding: 0.22, minZoom: 0.45, maxZoom: 1 } - -function ChartControls() { - const { zoomIn, zoomOut, fitView } = useReactFlow() - const zoom = useStore((state) => state.transform[2]) - return ( - void fitView(FIT)}> - void zoomOut()} - /> - = MAX_ZOOM} - onClick={() => void zoomIn()} - /> - - ) -} - -interface ChartCanvasProps { - profileQuery: string - organizationId: string - branch: ChartBranch - onFocus: CardData['onFocus'] -} - -export function ChartCanvas({ organizationId, branch, onFocus, profileQuery }: ChartCanvasProps) { - const colorMode = useCanvasColorMode() - const { person, reports, chain } = branch - const { nodes, edges } = useMemo(() => { - const routes = organizationRoutes(organizationId) - const nodes: CardNode[] = [] - const edges: Edge[] = [] - function addCard( - member: PersonSummary, - relation: CardData['relation'], - x: number, - y: number, - sides: Position[], - reportCount?: number, - department?: string | null - ) { - nodes.push({ - id: member.memberId, - type: 'person', - position: { x: x - WIDTH / 2, y }, - width: WIDTH, - height: HEIGHT, - data: { - person: member, - relation, - ports: sides.map((side) => ({ - id: side, - side, - position: 'center', - plateau: 14, - hoverAmplitude: 1, - magnetizable: false, - })), - profileUrl: `${routes.person(member.memberId)}${profileQuery}`, - reportCount, - department, - onFocus, - }, - }) - } - function connect(source: string, target: string) { - edges.push({ id: `${source}/${target}`, source, target, type: 'reporting' }) - } - const manager = chain[0] - if (manager) { - addCard(manager, 'Manager', 0, -LEVEL, [Position.Bottom]) - connect(manager.memberId, person.memberId) - } - const focusPorts = [ - ...(manager ? [Position.Top] : []), - ...(reports.length ? [Position.Bottom] : []), - ] - addCard(person, 'In focus', 0, 0, focusPorts, undefined, person.department) - reports.forEach((report, i) => { - const single = reports.length === 1 - const left = i % 2 === 0 - addCard( - report, - 'Direct report', - single ? 0 : left ? -COLUMN : COLUMN, - LEVEL + Math.floor(i / 2) * (HEIGHT + 24), - [single ? Position.Top : left ? Position.Right : Position.Left], - report.reportCount - ) - connect(person.memberId, report.memberId) - }) - return { nodes, edges } - }, [organizationId, person, chain, reports, profileQuery, onFocus]) - - return ( -
- - - -
- ) -} diff --git a/apps/sim/app/o/[organizationId]/people/chart-empty-state.tsx b/apps/sim/app/o/[organizationId]/people/chart-empty-state.tsx deleted file mode 100644 index 6125fc95cd0..00000000000 --- a/apps/sim/app/o/[organizationId]/people/chart-empty-state.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import type { MouseEvent, RefCallback } from 'react' -import { Chip, ChipLink } from '@sim/emcn' -import { ArrowUpRight, ChevronRight, RefreshCw, Upload } from '@sim/emcn/icons' -import { EmptyState } from '@/components/empty-state/empty-state' -import type { PersonSummary } from '@/lib/api/contracts/people' -import { organizationRoutes } from '@/lib/navigation/paths' -import { HAIRLINE } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/hairline' -import { usePeople } from '@/hooks/queries/people' - -function ReportingLinesGraphic() { - return ( - - ) -} - -interface ChartEmptyStateProps { - profileQuery: string - organizationId: string - person: PersonSummary - onFocus: (memberId: string, event: MouseEvent) => void - currentPersonRef: RefCallback - onImport?: () => void - onSync?: () => void -} - -export function ChartEmptyState({ - organizationId, - profileQuery, - person, - onFocus, - currentPersonRef, - onImport, - onSync, -}: ChartEmptyStateProps) { - const roots = usePeople(organizationId, '', '', { scope: 'roots' }) - const firstTeam = roots.isSuccess ? roots.data.pages[0]?.people[0] : undefined - const emptyOrganization = roots.isSuccess && !firstTeam - const routes = organizationRoutes(organizationId) - - return ( - } - title={emptyOrganization ? 'No reporting lines yet' : `No reporting lines for ${person.name}`} - description={ - emptyOrganization - ? onImport - ? 'Connect a directory, import people data, or add a manager to a profile to build your org chart.' - : 'An administrator needs to connect the company directory, such as Okta, to build the org chart.' - : 'This profile has no visible manager or direct reports.' - } - action={ -
- {firstTeam ? ( - onFocus(firstTeam.memberId, event)} - > - Explore a team - - ) : emptyOrganization && onSync ? ( - - Manage directory sync - - ) : null} - {emptyOrganization && onImport && ( - - Import people - - )} - - View profile - -
- } - /> - ) -} diff --git a/apps/sim/app/o/[organizationId]/people/columns.ts b/apps/sim/app/o/[organizationId]/people/columns.ts deleted file mode 100644 index 9a7a45f83fd..00000000000 --- a/apps/sim/app/o/[organizationId]/people/columns.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Upload } from '@sim/emcn/icons' -import type { ResourceColumn } from '@/app/workspace/[workspaceId]/components/resource/resource' - -/** Shared by the page and its loading chrome so the skeleton matches the table. */ -export const PEOPLE_COLUMNS = [ - { id: 'name', header: 'Name', widthMultiplier: 1.4 }, - { id: 'title', header: 'Title', widthMultiplier: 1.4 }, - { id: 'department', header: 'Department' }, - { id: 'location', header: 'Location' }, - { id: 'manager', header: 'Manager', widthMultiplier: 1.2 }, -] as const satisfies readonly ResourceColumn[] - -export const PEOPLE_SEARCH_PLACEHOLDER = 'Search people...' - -export const IMPORT_PEOPLE_ACTION = { text: 'Import', icon: Upload } as const diff --git a/apps/sim/app/o/[organizationId]/people/directory-sync-form.tsx b/apps/sim/app/o/[organizationId]/people/directory-sync-form.tsx deleted file mode 100644 index 9d68645cd42..00000000000 --- a/apps/sim/app/o/[organizationId]/people/directory-sync-form.tsx +++ /dev/null @@ -1,223 +0,0 @@ -import { useState } from 'react' -import { - Chip, - ChipLink, - ChipModal, - ChipModalBody, - ChipModalDescription, - ChipModalError, - ChipModalField, - ChipModalFooter, - ChipModalHeader, -} from '@sim/emcn' -import { isOrganizationSettingsSectionAvailable } from '@/components/settings/navigation' -import { - type PeopleSyncConfig, - type PeopleSyncStatus, - peopleSyncConfigSchema, -} from '@/lib/api/contracts/people-sync' -import { flattenFieldErrors } from '@/lib/api/contracts/primitives' -import { organizationRoutes } from '@/lib/navigation/paths' -import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider' -import { useScimConnection } from '@/ee/scim/hooks/scim' -import { serializeSsoSettings } from '@/ee/sso/search-params' -import type { usePeopleSyncActions } from '@/hooks/queries/people-sync' - -interface DirectorySyncFormProps { - organizationId: string - source: PeopleSyncConfig['source'] - name: string - current: PeopleSyncStatus | null - actions: ReturnType - onBack: () => void - onClose: () => void -} - -export function DirectorySyncForm({ - organizationId, - source, - name, - current, - actions, - onBack, - onClose, -}: DirectorySyncFormProps) { - const [domain, setDomain] = useState(current?.domain ?? '') - const [tenantId, setTenantId] = useState(current?.tenantId ?? '') - const [clientId, setClientId] = useState(current?.clientId ?? '') - const [secret, setSecret] = useState('') - const [fieldErrors, setFieldErrors] = useState< - Partial> - >({}) - const { settingsFeatures } = useOrganizationContext() - const provisioning = useScimConnection(organizationId, source === 'scim') - const scimReady = provisioning.isSuccess && provisioning.data.connection?.status === 'active' - const title = current ? `Update ${name} credentials` : `Connect ${name}` - const disabled = actions.pending - const canSubmit = - source === 'scim' - ? scimReady - : secret.trim() && (source === 'okta' ? domain.trim() : tenantId.trim() && clientId.trim()) - - function connect() { - if (disabled) return - const config = peopleSyncConfigSchema.safeParse( - source === 'okta' - ? { source, domain, token: secret } - : source === 'entra' - ? { source, tenantId: tenantId.trim(), clientId: clientId.trim(), clientSecret: secret } - : { source } - ) - if (!config.success) { - setFieldErrors(flattenFieldErrors(config.error)) - return - } - setFieldErrors({}) - actions.configure(config.data, onBack) - } - - return ( - !open && onClose()} srTitle={title}> - {title} - - - {source === 'okta' - ? 'Use an Okta API token with permission to list users in your organization.' - : source === 'entra' - ? 'Register an app in your Microsoft Entra tenant and grant administrator consent for the Microsoft Graph User.Read.All application permission. Use the client secret value.' - : 'Use profiles from your organization’s existing SCIM provisioning connection.'} - - {source === 'okta' && ( - <> - - -
- - Create an Okta API token - -
- - )} - {source === 'entra' && ( - <> - - - -
- - Register an application - -
- - )} - {source === 'scim' && ( - <> - - {() => ( -
-

- {provisioning.isPending - ? 'Checking provisioning…' - : provisioning.isError - ? 'Could not read the provisioning connection.' - : scimReady - ? 'Active' - : 'Enable provisioning before connecting this source.'} -

- {isOrganizationSettingsSectionAvailable('sso', settingsFeatures) && ( - - Manage provisioning - - )} - {!provisioning.isFetching && !scimReady && ( - void provisioning.refetch()}>Check again - )} -
- )} -
- - )} - {source !== 'scim' && ( - - Sim stores these credentials encrypted for scheduled syncs. - - )} - {disabled && ( - - Sync is running. You can close this window and return for the result. - - )} - {actions.error?.message} -
- -
- ) -} diff --git a/apps/sim/app/o/[organizationId]/people/directory-sync-modal.tsx b/apps/sim/app/o/[organizationId]/people/directory-sync-modal.tsx deleted file mode 100644 index 062b4589bf0..00000000000 --- a/apps/sim/app/o/[organizationId]/people/directory-sync-modal.tsx +++ /dev/null @@ -1,289 +0,0 @@ -import { useState } from 'react' -import { - Badge, - Chip, - ChipConfirmModal, - ChipModal, - ChipModalBody, - ChipModalDescription, - ChipModalError, - ChipModalField, - ChipModalFooter, - ChipModalHeader, -} from '@sim/emcn' -import type { PeopleSyncConfig, PeopleSyncStatus } from '@/lib/api/contracts/people-sync' -import { useDeploymentShape } from '@/lib/core/config/deployment-shape' -import { DirectorySyncForm } from '@/app/o/[organizationId]/people/directory-sync-form' -import { IMPORT_SOURCES } from '@/app/o/[organizationId]/people/import-source' -import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider' -import { - RESOURCE_LIST_STACK, - SettingsResourceRow, -} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' -import { - isDirectorySyncAccessError, - usePeopleSyncActions, - usePeopleSyncStatus, -} from '@/hooks/queries/people-sync' - -const SOURCES = { - okta: IMPORT_SOURCES.okta, - entra: IMPORT_SOURCES.entra, - scim: IMPORT_SOURCES.directory, -} as const -const STATES = { - scheduled: { label: 'Connected', variant: 'green' }, - syncing: { label: 'Syncing', variant: 'amber' }, - queued: { label: 'Queued', variant: 'amber' }, - failed: { label: 'Sync failed', variant: 'red' }, - paused: { label: 'Paused', variant: 'orange' }, -} as const -const dateFormat = new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }) -const PHASE_LABELS = { - users: 'Reading people', - relationships: 'Reading reporting lines', - publish: 'Validating and publishing', -} as const - -interface DirectorySyncModalProps { - organizationId: string - onClose: () => void -} - -interface SyncStatusProps { - sync: PeopleSyncStatus -} -function SyncStatus({ sync }: SyncStatusProps) { - const paused = sync.state === 'paused' - const { label, variant } = - !paused && sync.retryBlocked && sync.state !== 'syncing' - ? { label: 'Waiting to retry', variant: 'amber' as const } - : sync.progress && sync.state === 'queued' - ? { label: 'Refresh in progress', variant: 'blue' as const } - : STATES[sync.state] - const { progress } = sync - const dates = [ - ['Last attempted', sync.lastAttemptAt], - ['Last successful sync', sync.lastSuccessAt], - ...(sync.lastFailedAt ? [['Last failed sync', sync.lastFailedAt]] : []), - ...(!paused - ? [sync.retryAt ? ['Next retry', sync.retryAt] : ['Next scheduled sync', sync.nextSyncAt]] - : []), - ] as const - return ( - <> - - {() => ( -
- - {SOURCES[sync.source].name} - {sync.domain && ` · ${sync.domain}`} - - - - {label} - - -
- )} -
- {progress && !paused && ( - - {() => ( -
-

- {PHASE_LABELS[progress.phase]} · {progress.usersRead.toLocaleString()} users read -

-

- The chart updates after the full directory has been validated. -

-
- )} -
- )} - - {() => ( -
- {dates.map(([label, value]) => ( -
-
{label}
-
- {value ? : '—'} -
-
- ))} -
- )} -
- {sync.lastError && - (paused ? ( - Last sync error: {sync.lastError} - ) : ( - {sync.lastError} - ))} - - ) -} - -export function DirectorySyncModal({ organizationId, onClose }: DirectorySyncModalProps) { - const { settingsFeatures } = useOrganizationContext() - const { features, hosted } = useDeploymentShape() - const canProvision = features.scim && (!hosted || settingsFeatures.hasEnterprisePlan) - const query = usePeopleSyncStatus(organizationId) - const actions = usePeopleSyncActions(organizationId) - const [mode, setMode] = useState(null) - const status = isDirectorySyncAccessError(query.error) ? undefined : query.data - const sync = status?.sync ?? null - const activeMode = status ? mode : null - const goBack = () => { - setMode(null) - actions.reset() - } - if (activeMode === 'disconnect') - return ( - !open && goBack()} - title='Disconnect directory' - text='Disconnecting removes the profile details and reporting lines supplied by this directory. Sim keeps organization memberships and manually recorded profiles.' - confirm={{ - label: 'Disconnect', - pending: actions.pending, - pendingLabel: 'Disconnecting…', - onClick: () => actions.disconnect(goBack), - }} - > - {actions.error?.message} - - ) - if (activeMode) - return ( - - ) - const title = 'Directory sync' - const syncing = sync?.state === 'syncing' || actions.pending - const paused = sync?.state === 'paused' - const waitingToRetry = Boolean(sync?.retryBlocked) - return ( - !open && onClose()} srTitle={title}> - {title} - - {query.isPending ? ( - Loading directory connection… - ) : query.isError && !status ? ( - <> - {query.error.message} -
- void query.refetch()}> - Retry connection - -
- - ) : sync ? ( - <> - - {paused - ? 'SCIM provisioning is disabled. Re-enable it in organization settings to resume updates. Your last published chart remains available.' - : 'People updates from this directory automatically. You can also start a sync now.'} - - -
- {sync.source !== 'scim' && ( - { - actions.reset() - setMode(sync.source) - }} - > - Update credentials - - )} - { - actions.reset() - setMode('disconnect') - }} - > - Disconnect - -
- {actions.action === 'run' && !paused && ( - - Sync is running. You can close this window and return for the result. - - )} - {actions.error?.message} - - ) : ( - <> - - Connect a directory to keep profiles and reporting lines up to date. - - - {() => ( -
- {(['okta', 'entra', 'scim'] as const).map((source) => { - if (source === 'scim' && !canProvision) return null - const { name, icon: Icon } = SOURCES[source] - return ( - } - clickLabel={`Connect ${name}`} - onClick={() => setMode(source)} - navigable - /> - ) - })} -
- )} -
- - )} -
- -
- ) -} diff --git a/apps/sim/app/o/[organizationId]/people/import-source.tsx b/apps/sim/app/o/[organizationId]/people/import-source.tsx deleted file mode 100644 index b1b0d1d0872..00000000000 --- a/apps/sim/app/o/[organizationId]/people/import-source.tsx +++ /dev/null @@ -1,183 +0,0 @@ -import { Chip, ChipLink, ChipModalDescription, ChipModalField } from '@sim/emcn' -import { ShieldCheck, Table } from '@sim/emcn/icons' -import { MicrosoftIcon, OktaIcon } from '@/components/icons' -import { organizationRoutes } from '@/lib/navigation/paths' -import { - RESOURCE_LIST_STACK, - SettingsResourceRow, -} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' -import type { useScimConnection } from '@/ee/scim/hooks/scim' -import { serializeSsoSettings } from '@/ee/sso/search-params' - -export const IMPORT_SOURCES = { - okta: { name: 'Okta', description: 'Read profiles from your Okta directory', icon: OktaIcon }, - entra: { - name: 'Microsoft Entra ID', - description: 'Read profiles from Microsoft Graph', - icon: MicrosoftIcon, - }, - directory: { - name: 'SCIM provisioning', - description: 'Use directory data already provisioned to Sim', - icon: ShieldCheck, - }, - file: { name: 'Spreadsheet', description: 'Upload a CSV or Excel file', icon: Table }, -} as const - -export type ImportSource = keyof typeof IMPORT_SOURCES - -interface ImportSourcePickerProps { - canProvision: boolean - onChoose: (source: ImportSource) => void -} - -export function ImportSourcePicker({ canProvision, onChoose }: ImportSourcePickerProps) { - return ( - <> - - Choose a source for a one-time update to members’ job titles, departments, and reporting - lines. - - - {() => ( -
- {(['okta', 'entra', 'directory', 'file'] as const).map((source) => { - if (source === 'directory' && !canProvision) return null - const { name, description, icon: Icon } = IMPORT_SOURCES[source] - return ( - } - title={name} - description={description} - clickLabel={name} - onClick={() => onChoose(source)} - navigable - /> - ) - })} -
- )} -
- - ) -} - -interface ProviderFieldsProps { - source: 'okta' | 'entra' - domain: string - onDomainChange: (value: string) => void - token: string - onTokenChange: (value: string) => void - disabled: boolean -} - -export function ProviderFields({ - source, - domain, - onDomainChange, - token, - onTokenChange, - disabled, -}: ProviderFieldsProps) { - const okta = source === 'okta' - return ( - <> - - {okta - ? 'Use an Okta API token that can read users in your organization.' - : 'In Graph Explorer, sign in with your work or school account, grant delegated User.Read.All permission, and copy your access token. Microsoft commercial cloud is supported.'}{' '} - Sim uses the token for this import only. - - {okta && ( - - )} - -
- - {okta ? 'Create an Okta API token' : 'Open Graph Explorer'} - -
- - ) -} - -interface ProvisioningSourceProps { - organizationId: string - connection: ReturnType - canOpenSettings: boolean -} - -export function ProvisioningSource({ - organizationId, - connection, - canOpenSettings, -}: ProvisioningSourceProps) { - const directory = connection.data?.connection - const active = directory?.status === 'active' - return ( - <> - - {connection.isLoading - ? 'Checking your provisioning connection…' - : connection.isError - ? 'Could not load your provisioning connection.' - : !active - ? 'Enable SCIM provisioning and assign accounts in your provider to import their profiles.' - : directory.userCount === 0 - ? 'Your connection is active. Assign accounts to Sim in your identity provider, then refresh here.' - : `Your connection has ${directory.userCount} provisioned ${directory.userCount === 1 ? 'account' : 'accounts'}. Preview the profile fields and reporting lines sent by your provider.`} - -
- {canOpenSettings && !connection.isLoading && !connection.error && ( - - {active ? 'Manage provisioning' : 'Set up provisioning'} - - )} - {!connection.isLoading && !active && ( - - Provisioning guide - - )} - {!connection.isLoading && ( - void connection.refetch()}> - {connection.isError ? 'Retry connection' : 'Refresh'} - - )} -
- - ) -} diff --git a/apps/sim/app/o/[organizationId]/people/loading-status.tsx b/apps/sim/app/o/[organizationId]/people/loading-status.tsx deleted file mode 100644 index d5bcd1daf60..00000000000 --- a/apps/sim/app/o/[organizationId]/people/loading-status.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { Loader } from '@sim/emcn' - -interface LoadingStatusProps { - children: string -} - -export function LoadingStatus({ children }: LoadingStatusProps) { - return ( -
- - {children} -
- ) -} diff --git a/apps/sim/app/o/[organizationId]/people/loading.tsx b/apps/sim/app/o/[organizationId]/people/loading.tsx index 0bedf388c48..4a966f50016 100644 --- a/apps/sim/app/o/[organizationId]/people/loading.tsx +++ b/apps/sim/app/o/[organizationId]/people/loading.tsx @@ -1,14 +1,10 @@ -'use client' - -import { Users } from '@sim/emcn/icons' -import { LoadingStatus } from '@/app/o/[organizationId]/people/loading-status' -import { Resource } from '@/app/workspace/[workspaceId]/components/resource/resource' +import { RedesignPageSurface } from '@/app/o/[organizationId]/_redesign/redesign-host' +import { PeopleSkeleton } from '@/platform/app/(shell)/people/_components/people-page/people-skeleton' export default function PeopleLoading() { return ( - - - Loading people… - + + + ) } diff --git a/apps/sim/app/o/[organizationId]/people/page.tsx b/apps/sim/app/o/[organizationId]/people/page.tsx index a8927477798..dc128179cd7 100644 --- a/apps/sim/app/o/[organizationId]/people/page.tsx +++ b/apps/sim/app/o/[organizationId]/people/page.tsx @@ -3,10 +3,12 @@ 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 { getOrganizationSurfaceContext } from '@/lib/organizations/surface' +import { canBrowsePeople } from '@/lib/people/feature-flag' import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect' -import PageLoading from '@/app/o/[organizationId]/people/loading' -import { People } from '@/app/o/[organizationId]/people/people' +import { RedesignPageSurface } from '@/app/o/[organizationId]/_redesign/redesign-host' +import { PeoplePage as PeopleView } from '@/platform/app/(shell)/people/_components/people-page/people-page' +import { PeopleSkeleton } from '@/platform/app/(shell)/people/_components/people-page/people-skeleton' export const metadata: Metadata = { title: 'People' } @@ -24,10 +26,13 @@ export default async function PeoplePage({ params }: PeoplePageProps) { isInviteFlow: false, }) ) - if (!(await isPeopleEnabled(organizationId))) notFound() + const context = await getOrganizationSurfaceContext(organizationId, session.user.id) + if (!context || !(await canBrowsePeople(organizationId, context.viewer.role))) notFound() return ( - }> - - + + }> + + + ) } diff --git a/apps/sim/app/o/[organizationId]/people/people-chart.tsx b/apps/sim/app/o/[organizationId]/people/people-chart.tsx deleted file mode 100644 index d8a07255ebf..00000000000 --- a/apps/sim/app/o/[organizationId]/people/people-chart.tsx +++ /dev/null @@ -1,290 +0,0 @@ -'use client' - -import { - Fragment, - type MouseEvent, - type ReactNode, - useCallback, - useLayoutEffect, - useRef, -} from 'react' -import { - Button, - Chip, - ChipCombobox, - cn, - scrollFadeAttributes, - scrollFadeXClass, - useScrollEdges, -} from '@sim/emcn' -import { ChevronLeft, ChevronRight, Search } from '@sim/emcn/icons' -import { useQueryStates } from 'nuqs' -import { EmptyState } from '@/components/empty-state/empty-state' -import { isApiClientError } from '@/lib/api/client/errors' -import { ChartCanvas } from '@/app/o/[organizationId]/people/chart-canvas' -import { ChartEmptyState } from '@/app/o/[organizationId]/people/chart-empty-state' -import { LoadingStatus } from '@/app/o/[organizationId]/people/loading-status' -import { - peopleParsers, - peopleUrlKeys, - serializePeopleView, -} from '@/app/o/[organizationId]/people/search-params' -import { usePeoplePicker } from '@/app/o/[organizationId]/people/use-people-picker' -import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider' -import { ResourceLoadError } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state' -import { usePeopleChart } from '@/hooks/queries/people' -import { useIsMobile } from '@/hooks/use-is-mobile' - -interface PeopleChartProps { - organizationId: string - onImport?: () => void - onSync?: () => void - viewSwitch: ReactNode -} - -interface FindPersonProps { - rootsLabel?: string - organizationId: string - onSelect: (id: string) => void -} - -function FindPerson({ organizationId, onSelect, rootsLabel }: FindPersonProps) { - const { search, pickerProps } = usePeoplePicker(organizationId, rootsLabel ? 'roots' : undefined) - return ( - - - Find a person - - ) - } - overlayLabel={rootsLabel ?? 'Find a person'} - placeholder={rootsLabel ?? 'Find a person'} - searchPlaceholder={rootsLabel ? 'Search team leads…' : 'Search name, title or email…'} - className={rootsLabel ? 'max-w-48' : 'w-44 max-w-full'} - dropdownWidth={300} - align='start' - onChange={onSelect} - emptyMessage={ - rootsLabel && !search ? 'No top-level teams with reporting lines yet' : 'No matching people' - } - /> - ) -} - -interface ReportingPathProps { - children: ReactNode -} - -function ReportingPath({ children }: ReportingPathProps) { - const scrollRef = useRef(null) - const edges = useScrollEdges(scrollRef, { axis: 'x' }) - return ( - - ) -} - -export function PeopleChart({ organizationId, viewSwitch, onImport, onSync }: PeopleChartProps) { - const { organization } = useOrganizationContext() - const [origin, setView] = useQueryStates(peopleParsers, peopleUrlKeys) - const { focus, page } = origin - const size = useIsMobile() ? 1 : 4 - const chart = usePeopleChart(organizationId, focus, Math.max(1, Math.min(page, 1_000_000)), size) - const notFound = isApiClientError(chart.error) && chart.error.status === 404 - const refused = isApiClientError(chart.error) && chart.error.status < 500 - // A transient refresh failure keeps the chart on screen; a refusal replaces it. - const branch = refused ? undefined : chart.data - const profileQuery = serializePeopleView({ - ...origin, - view: 'chart', - focus: branch?.person.memberId ?? focus, - page: branch?.page ?? page, - panel: null, - }) - const currentPerson = useRef(null) - const previousReports = useRef(null) - const nextReports = useRef(null) - const pendingFocus = useRef<{ - focus: string - page: number - trigger: HTMLElement - target: 'person' | 'previous' | 'next' - } | null>(null) - const focusPerson = useCallback( - (id: string, event?: MouseEvent) => { - pendingFocus.current = - event?.detail === 0 - ? { focus: id, page: 1, trigger: event.currentTarget, target: 'person' } - : null - void setView({ focus: id, page: 1, view: 'chart' }, { history: 'push' }) - }, - [setView] - ) - const changePage = ( - value: number, - target: 'previous' | 'next', - event: MouseEvent - ) => { - pendingFocus.current = - event.detail === 0 ? { focus, page: value, trigger: event.currentTarget, target } : null - void setView({ page: value }) - } - useLayoutEffect(() => { - const pending = pendingFocus.current - if (!branch || !pending || pending.focus !== focus || pending.page !== page) return - pendingFocus.current = null - if (document.activeElement !== document.body && document.activeElement !== pending.trigger) - return - const controls = - pending.target === 'next' - ? [nextReports.current, previousReports.current] - : [previousReports.current, nextReports.current] - const target = - pending.target === 'person' - ? currentPerson.current - : (controls.find((button) => button && !button.disabled) ?? currentPerson.current) - target?.focus() - }, [branch, focus, page]) - const setCurrentPerson = useCallback((element: HTMLElement | null) => { - currentPerson.current = element - }, []) - return ( - <> -
- {viewSwitch} -
-
- -
- focusPerson('', event)} disabled={!focus && page === 1}> - My position - -
-
- {branch && !branch.chain.length && !branch.totalReports ? ( -
- -
- ) : branch ? ( - <> - -
- -
- {branch.chain - .slice() - .reverse() - .map((manager) => ( - - - focusPerson(manager.memberId, event)}> - {manager.name} - - - ))} - -

- {branch.person.name} -

-
- -
- - {branch.totalReports - ? `${branch.totalReports} direct report${branch.totalReports === 1 ? '' : 's'}` - : 'No direct reports'} - - {branch.totalReports > size && ( -
- - {size === 1 - ? branch.page - : `${(branch.page - 1) * size + 1}–${(branch.page - 1) * size + branch.reports.length}`}{' '} - of {branch.totalReports} - - - -
- )} -
- - ) : chart.error ? ( - notFound ? ( - focusPerson('', event)}>My position - ) : ( - void setView({ view: 'directory' })}>Open directory - ) - } - /> - ) : ( - void chart.refetch()} /> - ) - ) : ( - Loading org chart… - )} - - ) -} diff --git a/apps/sim/app/o/[organizationId]/people/people-directory.tsx b/apps/sim/app/o/[organizationId]/people/people-directory.tsx deleted file mode 100644 index 9f7cac3bf23..00000000000 --- a/apps/sim/app/o/[organizationId]/people/people-directory.tsx +++ /dev/null @@ -1,227 +0,0 @@ -'use client' - -import { useCallback, useMemo, useState } from 'react' -import { Avatar, Chip, ChipCombobox } from '@sim/emcn' -import Link from 'next/link' -import { useRouter } from 'next/navigation' -import { useQueryStates } from 'nuqs' -import { EmptyState } from '@/components/empty-state/empty-state' -import type { PersonSummary } from '@/lib/api/contracts/people' -import { organizationRoutes } from '@/lib/navigation/paths' -import { PEOPLE_SEARCH_MAX_LENGTH } from '@/lib/people/validation' -import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' -import { PEOPLE_COLUMNS, PEOPLE_SEARCH_PLACEHOLDER } from '@/app/o/[organizationId]/people/columns' -import { LoadingStatus } from '@/app/o/[organizationId]/people/loading-status' -import { - peopleParsers, - peopleUrlKeys, - serializePeopleView, -} from '@/app/o/[organizationId]/people/search-params' -import { - ResourceLoadError, - ResourceNoResults, -} from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state' -import { - type FilterTag, - ResourceFilterPanel, - ResourceFilterSection, - type SearchConfig, -} from '@/app/workspace/[workspaceId]/components/resource/components/resource-options' -import { - Resource, - type ResourceRow, -} from '@/app/workspace/[workspaceId]/components/resource/resource' -import { useDepartments, usePeople } from '@/hooks/queries/people' -import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' -import { useSearchFilterValue } from '@/hooks/use-search-filter-value' - -/** The filter's value for every department: stored text can't hold a NUL, so no department is this. */ -const KEY_PREFIX = '\u0000key:' -const ALL_DEPARTMENTS = '\u0000all' - -function personCell(person: PersonSummary) { - return { - icon: , - label: person.name, - } -} - -interface PeopleProps { - organizationId: string -} - -export function PeopleDirectory({ organizationId }: PeopleProps) { - const router = useRouter() - const [origin, setView] = useQueryStates(peopleParsers, peopleUrlKeys) - const { search, department, departmentKey } = origin - const setSearch = useDebouncedSearchSetter((value, options) => - setView({ view: 'directory', search: value }, options) - ) - const debouncedSearch = useSearchFilterValue(search, SEARCH_DEBOUNCE_MS) - const query = debouncedSearch.trim() - const people = usePeople(organizationId, query, department, { departmentKey }) - const profileQuery = serializePeopleView({ ...origin, view: 'directory', panel: null }) - const profileHref = useCallback( - (memberId: string) => `${organizationRoutes(organizationId).person(memberId)}${profileQuery}`, - [organizationId, profileQuery] - ) - - const [departmentSearch, setDepartmentSearch] = useState('') - const [departmentsOpen, setDepartmentsOpen] = useState(false) - const departmentTerm = useSearchFilterValue(departmentSearch, SEARCH_DEBOUNCE_MS).trim() - const departments = useDepartments(organizationId, departmentTerm, departmentsOpen) - const departmentWaiting = departmentSearch.trim() !== departmentTerm - const changingFilter = people.isPlaceholderData || query !== search.trim() - - const visiblePages = people.isRefetchError || changingFilter ? undefined : people.data?.pages - const rows: ResourceRow[] = useMemo( - () => - (visiblePages ?? []).flatMap((page) => - page.people.map((person) => ({ - id: person.memberId, - cells: { - name: { - content: ( - event.stopPropagation()} - className='flex min-w-0 items-center gap-2 rounded-sm text-[var(--text-body)] hover:underline focus-visible:outline-2 focus-visible:outline-[var(--brand-secondary)]' - > - - - {person.name} - - - ), - }, - title: { label: person.title }, - department: { label: person.department }, - location: { label: person.location }, - manager: person.manager ? personCell(person.manager) : { label: null }, - }, - })) - ), - [visiblePages, profileHref] - ) - - const searchConfig: SearchConfig = { - value: search, - maxLength: PEOPLE_SEARCH_MAX_LENGTH, - onChange: setSearch, - onClearAll: () => setSearch(''), - placeholder: PEOPLE_SEARCH_PLACEHOLDER, - } - const setDepartment = (value: string) => { - const keyed = value.startsWith(KEY_PREFIX) - void setView({ - view: 'directory', - department: keyed ? null : value, - departmentKey: keyed ? value.slice(KEY_PREFIX.length) : null, - }) - } - const hasDepartment = Boolean(departmentKey || department) - const selectedValue = departmentKey - ? `${KEY_PREFIX}${departmentKey}` - : department || ALL_DEPARTMENTS - const departmentLabel = departmentKey - ? (!people.isPlaceholderData && people.data?.pages[0]?.selectedDepartment) || - 'Selected department' - : department - const filterTags: FilterTag[] = hasDepartment - ? [{ label: `Department: ${departmentLabel}`, onRemove: () => setDepartment('') }] - : [] - - return ( - <> - - - page.departments) ?? []) - ).map(({ name, count, departmentKey: key }) => ({ - value: key ? `${KEY_PREFIX}${key}` : name, - label: `${name} (${count})`, - })), - ]} - value={selectedValue} - onChange={(value) => setDepartment(value === ALL_DEPARTMENTS ? '' : value)} - overlayContent={departmentLabel || 'All'} - overlayLabel={departmentLabel || 'All'} - searchable - searchMaxLength={PEOPLE_SEARCH_MAX_LENGTH} - searchMode='server' - onSearchChange={setDepartmentSearch} - onOpenChange={setDepartmentsOpen} - hasMore={!departmentWaiting && departments.hasNextPage} - onLoadMore={() => void departments.fetchNextPage()} - isLoadingMore={departments.isFetchingNextPage} - isLoading={departmentWaiting || departments.isPending} - error={departments.error?.message} - searchPlaceholder='Search departments...' - emptyMessage='No departments match' - className='w-full' - /> - {departments.isError && ( - void departments.refetch()} - > - Retry departments - - )} - - - ), - }} - filterTags={filterTags} - /> - router.push(profileHref(memberId))} - onLoadMore={() => void people.fetchNextPage()} - hasMore={people.hasNextPage && !changingFilter} - isLoadingMore={people.isFetchingNextPage} - isFetching={people.isFetching} - loadMoreFailed={people.isFetchNextPageError} - emptyState={ - people.isPending || changingFilter ? ( - Loading people… - ) : people.isLoadingError || people.isRefetchError ? ( - void people.refetch()} - /> - ) : !query && !hasDepartment ? ( - - ) : ( -
- - void setView({ view: 'directory', search: '', department: '', departmentKey: '' }) - } - /> -
- ) - } - /> - - ) -} diff --git a/apps/sim/app/o/[organizationId]/people/people-import-modal.tsx b/apps/sim/app/o/[organizationId]/people/people-import-modal.tsx deleted file mode 100644 index 7d6bdc7e66e..00000000000 --- a/apps/sim/app/o/[organizationId]/people/people-import-modal.tsx +++ /dev/null @@ -1,331 +0,0 @@ -'use client' - -import { useRef, useState } from 'react' -import { - ChipModal, - ChipModalBody, - ChipModalDescription, - ChipModalError, - ChipModalField, - ChipModalFooter, - ChipModalHeader, - cn, - scrollFadeAttributes, - scrollFadeClass, - toast, - useScrollEdges, -} from '@sim/emcn' -import { getErrorMessage } from '@sim/utils/errors' -import { isOrganizationSettingsSectionAvailable } from '@/components/settings/navigation' -import { isApiClientError } from '@/lib/api/client/errors' -import type { PeopleImportBody, PeopleImportResult } from '@/lib/api/contracts/people' -import { useDeploymentShape } from '@/lib/core/config/deployment-shape' -import { saveBlob } from '@/lib/uploads/client/download' -import { - IMPORT_SOURCES, - type ImportSource, - ImportSourcePicker, - ProviderFields, - ProvisioningSource, -} from '@/app/o/[organizationId]/people/import-source' -import { - type PeopleSheet, - readPeopleSheet, -} from '@/app/o/[organizationId]/people/read-people-sheet' -import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider' -import { useScimConnection } from '@/ee/scim/hooks/scim' -import { useImportPeople } from '@/hooks/queries/people' - -const ACCEPTED_FILES = '.csv,.tsv,.xlsx,.xls' -const TEMPLATE_CSV = - 'email,title,department,location,start_date,manager_email\nada@example.com,Engineer,Platform,Berlin,2024-01-31,grace@example.com\n' - -function downloadTemplate() { - saveBlob(new Blob([TEMPLATE_CSV], { type: 'text/csv' }), 'people-template.csv') -} - -function plural(count: number, noun: string): string { - return `${count} ${noun}${count === 1 ? '' : 's'}` -} - -/** The preview's issues in file order, with a count of any past the ones listed. */ -interface ImportIssueListProps { - result: PeopleImportResult - spreadsheet: boolean -} - -function ImportIssueList({ result, spreadsheet }: ImportIssueListProps) { - const listRef = useRef(null) - const edges = useScrollEdges(listRef) - const unlisted = result.skipped + result.warnings - result.issues.length - return ( -
    - {result.issues.map((issue) => ( -
  • - {spreadsheet && Row {issue.row}}{' '} - - {issue.email ? `${issue.email}: ` : ''} - {issue.message} - -
  • - ))} - {unlisted > 0 && ( -
  • and {plural(unlisted, 'more issue')}
  • - )} -
- ) -} - -interface PeopleImportModalProps { - organizationId: string - onClose: () => void -} - -export function PeopleImportModal({ organizationId, onClose }: PeopleImportModalProps) { - const [source, setSource] = useState(null) - const { settingsFeatures } = useOrganizationContext() - const { features, hosted } = useDeploymentShape() - const canProvision = features.scim && (!hosted || settingsFeatures.hasEnterprisePlan) - - if (source && (source !== 'directory' || canProvision)) { - return ( - setSource(null)} - canOpenSettings={isOrganizationSettingsSectionAvailable('sso', settingsFeatures)} - /> - ) - } - - return ( - !open && onClose()} srTitle='Import people data'> - Import people data - - - - - - ) -} - -interface SourceImportModalProps extends PeopleImportModalProps { - source: ImportSource - onBack: () => void - canOpenSettings: boolean -} - -function SourceImportModal({ - organizationId, - source, - onBack, - onClose, - canOpenSettings, -}: SourceImportModalProps) { - const connection = useScimConnection(organizationId, source === 'directory') - const importPeople = useImportPeople(organizationId) - const [domain, setDomain] = useState('') - const [token, setToken] = useState('') - const [sheet, setSheet] = useState(null) - const [reading, setReading] = useState(false) - const [error, setError] = useState(null) - const [preview, setPreview] = useState(null) - const busy = useRef(false) - const pending = reading || importPeople.isPending - const provider = source === 'okta' || source === 'entra' - const ready = - source === 'file' - ? Boolean(sheet) - : source === 'directory' - ? connection.isSuccess && - connection.data.connection?.status === 'active' && - connection.data.connection.userCount > 0 - : Boolean(token.trim() && (source !== 'okta' || domain.trim())) - const sourceName = IMPORT_SOURCES[source].name - const title = preview - ? 'Review import' - : source === 'file' - ? 'Import a spreadsheet' - : `Import from ${sourceName}` - - const submit = async (apply: boolean, file?: File) => { - if (busy.current || (apply && !preview?.updated)) return - busy.current = true - setError(null) - try { - let body: PeopleImportBody - if (source === 'file') { - let selected = sheet - if (file) { - setReading(true) - setSheet(null) - selected = await readPeopleSheet(file) - setSheet(selected) - } - if (!selected) return - body = { headers: selected.headers, rows: selected.rows, apply } - } else if (source === 'okta') { - body = { - source, - domain: domain.trim(), - token: token.trim(), - apply, - previewDigest: apply ? preview?.previewDigest : undefined, - } - } else if (source === 'entra') { - body = { - source, - token: token.trim(), - apply, - previewDigest: apply ? preview?.previewDigest : undefined, - } - } else { - body = { source, apply, previewDigest: apply ? preview?.previewDigest : undefined } - } - const result = await importPeople.submit(body) - if (apply) { - toast.success(`Updated ${plural(result.updated, 'profile')}`) - onClose() - } else { - setPreview(result) - } - } catch (cause) { - if (source !== 'file' && apply && isApiClientError(cause) && cause.status === 409) - setPreview(null) - setError(getErrorMessage(cause, 'Could not import people data')) - } finally { - busy.current = false - setReading(false) - } - } - - const goBack = () => { - if (busy.current) return - if (preview) { - setPreview(null) - setError(null) - } else { - onBack() - } - } - - const done = preview?.updated === 0 - return ( - !open && onClose()} - dismissDisabled={pending} - srTitle={title} - > - {title} - - {preview ? ( - <> - - {source === 'file' ? sheet?.fileName : sourceName} - - - {() => ( -
-

- {done - ? 'No profiles to update.' - : `${plural(preview.updated, 'profile')} will be updated.`} -

-

- {[ - `${preview.unchanged} unchanged`, - `${preview.skipped} skipped`, - plural(preview.warnings, 'warning'), - ].join(' · ')} -

- {preview.issues.length > 0 && ( - - )} -
- )} -
- {!done && ( - - {source !== 'file' - ? 'Import updates matching members’ profiles. If the directory changes, you’ll be asked to preview it again.' - : 'Import applies these profile updates to matching members.'} - - )} - - ) : provider ? ( - { - setDomain(value) - setError(null) - }} - token={token} - onTokenChange={(value) => { - setToken(value) - setError(null) - }} - disabled={pending} - /> - ) : source === 'directory' ? ( - - ) : ( - file && void submit(false, file)} - /> - )} - {error} -
- void submit(Boolean(preview)), - }} - /> -
- ) -} diff --git a/apps/sim/app/o/[organizationId]/people/people.tsx b/apps/sim/app/o/[organizationId]/people/people.tsx deleted file mode 100644 index 6e3b3ed806a..00000000000 --- a/apps/sim/app/o/[organizationId]/people/people.tsx +++ /dev/null @@ -1,99 +0,0 @@ -'use client' - -import { useState } from 'react' -import { ChipSwitch } from '@sim/emcn' -import { List, RefreshCw, Users, Workflow } from '@sim/emcn/icons' -import dynamic from 'next/dynamic' -import { useQueryStates } from 'nuqs' -import { IMPORT_PEOPLE_ACTION } from '@/app/o/[organizationId]/people/columns' -import { PeopleDirectory } from '@/app/o/[organizationId]/people/people-directory' -import { PeopleImportModal } from '@/app/o/[organizationId]/people/people-import-modal' -import { peopleParsers, peopleUrlKeys } from '@/app/o/[organizationId]/people/search-params' -import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider' -import { Resource } from '@/app/workspace/[workspaceId]/components/resource/resource' - -const PeopleChart = dynamic(() => - import('@/app/o/[organizationId]/people/people-chart').then((module) => module.PeopleChart) -) -const DirectorySyncModal = dynamic(() => - import('@/app/o/[organizationId]/people/directory-sync-modal').then( - (module) => module.DirectorySyncModal - ) -) -const VIEWS = [ - { value: 'chart', label: 'Org chart', icon: Workflow }, - { value: 'directory', label: 'Directory', icon: List }, -] as const - -interface PeopleProps { - organizationId: string -} - -export function People({ organizationId }: PeopleProps) { - const { viewer } = useOrganizationContext() - const [{ view, search, department, departmentKey, panel }, setView] = useQueryStates( - peopleParsers, - peopleUrlKeys - ) - const [importOpen, setImportOpen] = useState(false) - const openSync = () => void setView({ panel: 'sync' }, { history: 'push' }) - const currentView = view ?? (search || department || departmentKey ? 'directory' : 'chart') - - const viewSwitch = ( - void setView({ view: next }, { history: 'push' })} - /> - ) - - return ( - - setImportOpen(true), - }, - ] - : [] - } - /> - {currentView === 'chart' ? ( - setImportOpen(true) : undefined} - onSync={viewer.isAdmin ? openSync : undefined} - /> - ) : ( - <> -
{viewSwitch}
- - - )} - {importOpen && !panel && viewer.isAdmin && ( - setImportOpen(false)} /> - )} - {panel === 'sync' && viewer.isAdmin && ( - void setView({ panel: null })} - /> - )} -
- ) -} diff --git a/apps/sim/app/o/[organizationId]/people/use-people-picker.tsx b/apps/sim/app/o/[organizationId]/people/use-people-picker.tsx deleted file mode 100644 index 36b77209cff..00000000000 --- a/apps/sim/app/o/[organizationId]/people/use-people-picker.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { useState } from 'react' -import { Avatar } from '@sim/emcn' -import { PEOPLE_SEARCH_MAX_LENGTH } from '@/lib/people/validation' -import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' -import { usePeople } from '@/hooks/queries/people' -import { useDebounce } from '@/hooks/use-debounce' - -/** Shared, paginated directory search for chart navigation and editing a person's manager. */ -export function usePeoplePicker( - organizationId: string, - scope?: 'roots' | 'members', - excludeMemberId?: string -) { - const [search, setSearch] = useState('') - const [open, setOpen] = useState(false) - const term = useDebounce(search.trim(), SEARCH_DEBOUNCE_MS) - const query = usePeople(organizationId, term, '', { scope, enabled: open }) - const waiting = search.trim() !== term || query.isPlaceholderData - const people = - waiting || query.isRefetchError - ? [] - : (query.data?.pages ?? []).flatMap((page) => - page.people.filter((person) => person.memberId !== excludeMemberId) - ) - return { - search, - people, - pickerProps: { - searchable: true, - searchMaxLength: PEOPLE_SEARCH_MAX_LENGTH, - searchMode: 'server' as const, - onSearchChange: setSearch, - onOpenChange: setOpen, - options: people.map((person) => ({ - value: person.memberId, - label: [person.name, person.title, person.email].filter(Boolean).join(' · '), - iconElement: , - })), - hasMore: query.hasNextPage && !waiting, - onLoadMore: () => void query.fetchNextPage(), - isLoading: waiting || (query.isFetching && !query.isFetchingNextPage), - isLoadingMore: query.isFetchingNextPage, - error: query.error?.message, - }, - } -} diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/index.ts b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/index.ts index 514db4ca670..cd37a146c47 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/index.ts @@ -4,7 +4,6 @@ export { DocumentsEmptyState } from '@/app/workspace/[workspaceId]/components/re export { FilesEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/files-empty-state' export { KnowledgeEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/knowledge-empty-state' export { LogsEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/logs-empty-state' -export { ResourceLoadError } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/resource-load-error' export { ResourceNoResults } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/resource-no-results' export { TablesEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/tables-empty-state' export { TestsEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/tests-empty-state' diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/resource-load-error.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/resource-load-error.tsx deleted file mode 100644 index eaff2e66928..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/resource-load-error.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { Chip } from '@sim/emcn' - -interface ResourceLoadErrorProps { - message: string - onRetry: () => void -} - -/** What a resource table shows when its first page failed to load. */ -export function ResourceLoadError({ message, onRetry }: ResourceLoadErrorProps) { - return ( -
-

{message}

- Retry -
- ) -} diff --git a/apps/sim/hooks/queries/people-sync.ts b/apps/sim/hooks/queries/people-sync.ts index f5e49767251..407256f3a7c 100644 --- a/apps/sim/hooks/queries/people-sync.ts +++ b/apps/sim/hooks/queries/people-sync.ts @@ -1,5 +1,11 @@ import { useRef } from 'react' -import { type QueryClient, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { + type QueryClient, + useIsMutating, + useMutation, + useQuery, + useQueryClient, +} from '@tanstack/react-query' import { isApiClientError } from '@/lib/api/client/errors' import { requestJson } from '@/lib/api/client/request' import type { ContractJsonResponse } from '@/lib/api/contracts' @@ -19,6 +25,8 @@ const IDLE_SYNC_POLL_MS = 30_000 const syncKeys = { all: ['people-directory-sync'] as const, organization: (organizationId: string) => [...syncKeys.all, organizationId] as const, + actions: (organizationId: string) => + [...syncKeys.organization(organizationId), 'action'] as const, } type SyncResult = ContractJsonResponse @@ -34,9 +42,10 @@ function refreshPublishedPeople(client: QueryClient, organizationId: string, res } } -export function usePeopleSyncStatus(organizationId: string) { +export function usePeopleSyncStatus(organizationId: string, enabled = true) { const client = useQueryClient() return useQuery({ + enabled, queryKey: syncKeys.organization(organizationId), queryFn: async ({ signal }) => { const result = await requestJson(getPeopleSyncContract, { @@ -64,8 +73,10 @@ export function usePeopleSyncActions(organizationId: string) { const client = useQueryClient() // Only the action name may enter the mutation cache or its devtools. const nextConfig = useRef(null) - const busy = useRef(false) + const mutationKey = syncKeys.actions(organizationId) + const pending = useIsMutating({ mutationKey }) > 0 const mutation = useMutation({ + mutationKey, gcTime: 0, retry: false, networkMode: 'always', @@ -80,35 +91,40 @@ export function usePeopleSyncActions(organizationId: string) { if (!body) throw new Error('Choose a directory connection') return requestJson(configurePeopleSyncContract, { params, body }) }, - onSuccess: async (result) => { + onSuccess: async (result, action) => { const queryKey = syncKeys.organization(organizationId) await client.cancelQueries({ queryKey }) - const refreshed = refreshPublishedPeople(client, organizationId, result) + // A sync can publish while the disconnect is in flight, so its last-success time can't say + // whether the cached people still exist. + const refreshed = + action === 'disconnect' + ? client.invalidateQueries({ queryKey: peopleKeys.organization(organizationId) }) + : refreshPublishedPeople(client, organizationId, result) client.setQueryData(queryKey, result) await refreshed }, onError: () => client.invalidateQueries({ queryKey: syncKeys.organization(organizationId) }), onSettled: () => { - busy.current = false nextConfig.current = null }, }) function submit( action: 'configure' | 'run' | 'disconnect', config?: PeopleSyncConfig, - onSuccess?: () => void + onSuccess?: () => void, + onError?: (error: Error) => void ) { - if (busy.current) return - busy.current = true + if (client.isMutating({ mutationKey }) > 0) return nextConfig.current = config ?? null - mutation.mutate(action, { onSuccess }) + mutation.mutate(action, { onSuccess: () => onSuccess?.(), onError }) } return { configure: (config: PeopleSyncConfig, onSuccess: () => void) => submit('configure', config, onSuccess), run: () => submit('run'), - disconnect: (onSuccess: () => void) => submit('disconnect', undefined, onSuccess), - pending: mutation.isPending, + disconnect: (onSuccess: () => void, onError?: (error: Error) => void) => + submit('disconnect', undefined, onSuccess, onError), + pending, action: mutation.isPending ? mutation.variables : null, error: mutation.error, reset: mutation.reset, diff --git a/apps/sim/lib/people/feature-flag.ts b/apps/sim/lib/people/feature-flag.ts index 7054328e162..185d5a99553 100644 --- a/apps/sim/lib/people/feature-flag.ts +++ b/apps/sim/lib/people/feature-flag.ts @@ -1,3 +1,4 @@ +import { cache } from 'react' import { createLogger } from '@sim/logger' import { isOrgAdminRole } from '@sim/platform-authz/workspace' import { getErrorMessage } from '@sim/utils/errors' @@ -5,7 +6,7 @@ import { isFeatureEnabled } from '@/lib/core/config/feature-flags' import { OrchestrationError } from '@/lib/core/orchestration/types' import { isOrganizationCapabilityWithheld } from '@/lib/permission-groups/capability-assertions' -export function isPeopleEnabled(organizationId: string): Promise { +function isPeopleEnabled(organizationId: string): Promise { return isFeatureEnabled('people', { orgId: organizationId }) } @@ -20,10 +21,13 @@ const logger = createLogger('People') /** * Whether to show the People page to a member with `role`: it is on for `organizationId`, and its * directory is open to them — administrators always, other members unless their permission group - * withholds `organization.member_directory`, the capability its reads enforce. For navigation - * only, so a lookup that fails hides People rather than failing the page around it. + * withholds `organization.member_directory`, the capability its reads enforce. For navigation and + * the page gate, so a lookup that fails hides People rather than failing the page around it. */ -export async function canBrowsePeople(organizationId: string, role: string): Promise { +export const canBrowsePeople = cache(async function canBrowsePeople( + organizationId: string, + role: string +): Promise { try { if (!(await isPeopleEnabled(organizationId))) return false return ( @@ -37,4 +41,4 @@ export async function canBrowsePeople(organizationId: string, role: string): Pro }) return false } -} +}) diff --git a/apps/sim/app/o/[organizationId]/people/read-people-sheet.test.ts b/apps/sim/lib/people/read-sheet.test.ts similarity index 97% rename from apps/sim/app/o/[organizationId]/people/read-people-sheet.test.ts rename to apps/sim/lib/people/read-sheet.test.ts index 3babf9af531..c0d4307eb91 100644 --- a/apps/sim/app/o/[organizationId]/people/read-people-sheet.test.ts +++ b/apps/sim/lib/people/read-sheet.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import * as XLSX from 'xlsx' -import { readPeopleSheet } from '@/app/o/[organizationId]/people/read-people-sheet' +import { readPeopleSheet } from '@/lib/people/read-sheet' /** January 31, 2024 as a date serial in each of Excel's two date systems. */ const SERIAL_1900 = 45322 diff --git a/apps/sim/app/o/[organizationId]/people/read-people-sheet.ts b/apps/sim/lib/people/read-sheet.ts similarity index 100% rename from apps/sim/app/o/[organizationId]/people/read-people-sheet.ts rename to apps/sim/lib/people/read-sheet.ts diff --git a/apps/sim/app/o/[organizationId]/people/search-params.ts b/apps/sim/lib/people/search-params.ts similarity index 84% rename from apps/sim/app/o/[organizationId]/people/search-params.ts rename to apps/sim/lib/people/search-params.ts index a34714387f1..c2aacdee25f 100644 --- a/apps/sim/app/o/[organizationId]/people/search-params.ts +++ b/apps/sim/lib/people/search-params.ts @@ -7,11 +7,14 @@ import { PEOPLE_SEARCH_MAX_LENGTH, } from '@/lib/people/validation' -const boundedSearch = (value: string) => truncateAtCodePoint(value, PEOPLE_SEARCH_MAX_LENGTH, '') +export const boundedSearch = (value: string) => + truncateAtCodePoint(value, PEOPLE_SEARCH_MAX_LENGTH, '') /** `search` matches name, email, title, department, or location; `department` narrows to one. */ export const peopleParsers = { + /** Nullable: with no explicit view, the default depends on whether a filter is set. */ view: parseAsStringLiteral(['chart', 'directory'] as const), + /** Nullable: absent means no panel is open; there is no default panel. */ panel: parseAsStringLiteral(['sync'] as const), focus: createParser({ parse: (value) => (value.length > 0 && value.length <= PEOPLE_ID_MAX_LENGTH ? value : null), diff --git a/apps/sim/platform/app/(shell)/people/_components/directory-sync/connect-modal.tsx b/apps/sim/platform/app/(shell)/people/_components/directory-sync/connect-modal.tsx new file mode 100644 index 00000000000..4251f9828bd --- /dev/null +++ b/apps/sim/platform/app/(shell)/people/_components/directory-sync/connect-modal.tsx @@ -0,0 +1,185 @@ +'use client' + +import { useState } from 'react' +import Link from 'next/link' +import styles from '@/platform/app/(shell)/people/_components/directory-sync/directory-sync.module.css' +import { ExternalLink } from '@/platform/app/(shell)/people/_components/external-link/external-link' +import { + Button, + InlineNotice, + Modal, + SecretField, + TextField, +} from '@/platform/design-system/components' +import { + SYNC_SOURCE_NAMES, + type SyncDraft, + type SyncFieldErrors, + type SyncSource, + useDirectorySync, + usePeopleViewer, + useProvisioningConnection, +} from '@/platform/lib/data/people' + +const SOURCE_DESCRIPTIONS: Record = { + okta: 'Keep your org chart current with an hourly directory sync. You can disconnect at any time.', + entra: + 'Register an app in your Microsoft Entra tenant and grant administrator consent for the Microsoft Graph User.Read.All application permission. Use the client secret value.', + scim: 'Use profiles from your organization’s existing SCIM provisioning connection.', +} + +interface ConnectModalProps { + organizationId: string + source: SyncSource + onClose: () => void + onConnected: () => void +} + +/** + * Connects a directory, or replaces a connected one's credentials. Credentials stay in this + * component's state; the action hook keeps them out of the mutation cache. A status poll that fails + * keeps the draft, and one that says the viewer lost access takes the form away. + */ +export function ConnectModal({ organizationId, source, onClose, onConnected }: ConnectModalProps) { + const { canOpenProvisioningSettings, provisioningSettingsHref } = usePeopleViewer() + const directory = useDirectorySync(organizationId) + const { sync } = directory + const current = sync?.source === source ? sync : null + const provisioning = useProvisioningConnection(organizationId, source === 'scim') + const [draft, setDraft] = useState({ + domain: current?.domain ?? '', + token: '', + tenantId: current?.tenantId ?? '', + clientId: current?.clientId ?? '', + clientSecret: '', + }) + const [errors, setErrors] = useState({}) + const change = (next: Partial) => setDraft((previous) => ({ ...previous, ...next })) + const name = SYNC_SOURCE_NAMES[source] + const disabled = directory.pending + const canConnect = + source === 'scim' + ? provisioning.ready + : source === 'okta' + ? Boolean(draft.token.trim() && draft.domain.trim()) + : Boolean(draft.clientSecret.trim() && draft.tenantId.trim() && draft.clientId.trim()) + + if (directory.denied) return null + + return ( + { + setErrors(directory.connect(source, draft, onConnected) ?? {}) + return false + }} + > +

+ {SOURCE_DESCRIPTIONS[source]} +

+ {source === 'okta' && ( + <> + change({ domain })} + /> + change({ token })} + /> + + Create an Okta API token + + + )} + {source === 'entra' && ( + <> + change({ tenantId })} + /> + change({ clientId })} + /> + change({ clientSecret })} + /> + + Register an application + + + )} + {source === 'scim' && ( +
+

+ {provisioning.loading + ? 'Checking provisioning…' + : provisioning.failed + ? 'Could not read the provisioning connection.' + : provisioning.ready + ? 'Active' + : 'Enable provisioning before connecting this source.'} +

+
+ {canOpenProvisioningSettings && ( + + )} + {!provisioning.fetching && !provisioning.ready && ( + + )} +
+
+ )} + + {disabled && ( +

+ Sync is running. You can close this window and return for the result. +

+ )} + {directory.actionError && ( + + )} +
+ ) +} diff --git a/apps/sim/platform/app/(shell)/people/_components/directory-sync/directory-sources.tsx b/apps/sim/platform/app/(shell)/people/_components/directory-sync/directory-sources.tsx new file mode 100644 index 00000000000..7c26e751a9c --- /dev/null +++ b/apps/sim/platform/app/(shell)/people/_components/directory-sync/directory-sources.tsx @@ -0,0 +1,62 @@ +import styles from '@/platform/app/(shell)/people/_components/directory-sync/directory-sync.module.css' +import { Icon } from '@/platform/design-system/components' +import { SYNC_SOURCE_NAMES, type SyncSource } from '@/platform/lib/data/people' + +export const SOURCE_MARKS: Record = { okta: 'O', entra: 'E', scim: 'S' } + +const SOURCES = [ + { source: 'okta', hint: 'Sync your Okta directory' }, + { source: 'entra', hint: 'Sync your Entra directory' }, + { source: 'scim', hint: 'Use existing provisioning' }, +] as const + +interface DirectorySourcesProps { + canProvision: boolean + onConnect: (source: SyncSource) => void +} + +/** The directories an organization can connect, shared by first use and the sync page. */ +export function DirectorySources({ canProvision, onConnect }: DirectorySourcesProps) { + return ( +
+ {SOURCES.filter(({ source }) => source !== 'scim' || canProvision).map(({ source, hint }) => ( + + ))} +
+ ) +} + +interface ImportPromptProps { + onImport: () => void +} + +export function ImportPrompt({ onImport }: ImportPromptProps) { + return ( +

+ Need a one-time update? + +

+ ) +} diff --git a/apps/sim/platform/app/(shell)/people/_components/directory-sync/directory-sync-page.tsx b/apps/sim/platform/app/(shell)/people/_components/directory-sync/directory-sync-page.tsx new file mode 100644 index 00000000000..718910c8298 --- /dev/null +++ b/apps/sim/platform/app/(shell)/people/_components/directory-sync/directory-sync-page.tsx @@ -0,0 +1,303 @@ +'use client' + +import { useState } from 'react' +import { formatDateTime } from '@sim/utils/formatting' +import { + DirectorySources, + ImportPrompt, + SOURCE_MARKS, +} from '@/platform/app/(shell)/people/_components/directory-sync/directory-sources' +import styles from '@/platform/app/(shell)/people/_components/directory-sync/directory-sync.module.css' +import { PeopleStatus } from '@/platform/app/(shell)/people/_components/people-page/people-status' +import { + Button, + FailureNotice, + InlineNotice, + Modal, + StatusTag, +} from '@/platform/design-system/components' +import { + SYNC_SOURCE_NAMES, + type SyncSource, + type SyncStatus, + useDirectorySync, + usePeopleViewer, +} from '@/platform/lib/data/people' + +const PHASE_TITLES = { + users: 'Reading people', + relationships: 'Reading reporting lines', + publish: 'Validating and publishing', +} as const + +type SyncIntent = 'info' | 'success' | 'warning' | 'danger' + +/** What the card says about a connection: a heading for its state, and its tag. */ +function describeSync(sync: SyncStatus): { title: string; tag: string; intent: SyncIntent } { + if (sync.state === 'paused') + return { title: 'SCIM provisioning is disabled', tag: 'Paused', intent: 'warning' } + if (sync.retryBlocked && sync.state !== 'syncing') + return { + title: `${SYNC_SOURCE_NAMES[sync.source]} asked us to slow down`, + tag: 'Waiting to retry', + intent: 'warning', + } + if (sync.state === 'failed') + return { title: 'Couldn’t refresh the directory', tag: 'Sync failed', intent: 'danger' } + if (sync.progress?.phase === 'publish') + return { title: 'Finishing your refresh', tag: 'Publishing', intent: 'info' } + if (sync.state === 'syncing') + return { + title: sync.lastSuccessAt ? 'Refreshing your directory' : 'Reading your directory', + tag: sync.lastSuccessAt ? 'Syncing' : 'First sync', + intent: 'info', + } + if (sync.state === 'queued') + return { + title: sync.progress ? 'Refresh in progress' : 'Sync queued', + tag: sync.progress ? 'Refresh in progress' : 'Queued', + intent: 'info', + } + return { title: 'Directory is up to date', tag: 'Connected', intent: 'success' } +} + +function syncActionLabel(sync: SyncStatus, syncing: boolean): string { + if (sync.state === 'paused') return 'Sync paused' + if (syncing) return 'Sync in progress' + if (sync.retryBlocked) return 'Waiting to retry' + if (sync.state === 'failed') return 'Retry sync' + return sync.progress ? 'Continue sync' : 'Sync now' +} + +function formatInstant(value: string | null): string { + return value ? formatDateTime(new Date(value)) : '—' +} + +interface SyncCardProps { + sync: SyncStatus + syncing: boolean + running: boolean + onUpdate: () => void + onRun: () => void +} + +function SyncCard({ sync, syncing, running, onUpdate, onRun }: SyncCardProps) { + const paused = sync.state === 'paused' + const { title, tag, intent } = describeSync(sync) + const { progress } = sync + const facts: [label: string, value: string | null][] = [ + ['Last successful sync', sync.lastSuccessAt], + [syncing ? 'Current attempt started' : 'Last attempted', sync.lastAttemptAt], + ] + if (sync.lastFailedAt) facts.push(['Last failed sync', sync.lastFailedAt]) + if (!paused) + facts.push( + sync.retryAt ? ['Next retry', sync.retryAt] : ['Next scheduled sync', sync.nextSyncAt] + ) + return ( +
+
+ + + {SYNC_SOURCE_NAMES[sync.source]} + {sync.domain && ( + + {sync.domain} + + )} + + +
+
+

{title}

+ {paused && ( +

+ Re-enable provisioning in organization settings to resume. Your last published chart + remains available. +

+ )} +
+ {progress && !paused && ( + + {progress.usersRead.toLocaleString()} read + + } + /> + )} + {sync.lastError && + (paused ? ( +

+ Last sync error: {sync.lastError} +

+ ) : ( + + ))} +
+ {facts.map(([label, value]) => ( +
+
+ {label} +
+
{formatInstant(value)}
+
+ ))} +
+ {running && !paused && ( +

+ Sync is running. You can leave this page and return for the result. +

+ )} +
+ {sync.source !== 'scim' && ( + + )} +
+ +
+
+
+ ) +} + +interface DirectorySyncPageProps { + organizationId: string + onBack: () => void + onConnect: (source: SyncSource) => void + onImport: () => void +} + +/** + * The connection's status and its administration. A sync can outlast this page, so nothing here + * holds it open: the actions report through the hook's state, and status polling shows the result. + */ +export function DirectorySyncPage({ + organizationId, + onBack, + onConnect, + onImport, +}: DirectorySyncPageProps) { + const { canProvision } = usePeopleViewer() + const directory = useDirectorySync(organizationId) + const { sync } = directory + const [disconnecting, setDisconnecting] = useState(false) + const syncing = sync?.state === 'syncing' || directory.pending + return ( +
+
+ +
+
+

Directory sync

+

+ Keep people, roles, and reporting lines up to date from your identity provider. +

+
+ {directory.loading ? ( + Loading directory connection… + ) : directory.loadError ? ( + + ) : sync ? ( +
+
+ { + directory.reset() + onConnect(sync.source) + }} + onRun={directory.run} + /> + {directory.actionError && !disconnecting && ( + + )} +
+

+ Disconnect directory +

+

+ Remove synced profiles and reporting lines. Existing Sim memberships stay unchanged. +

+
+ +
+
+
+ + {disconnecting && ( + setDisconnecting(false)} + title='Disconnect directory' + confirmLabel='Disconnect' + destructive + onConfirm={directory.disconnect} + > +

+ Disconnecting removes the profile details and reporting lines supplied by this + directory. Sim keeps organization memberships and manually recorded profiles. +

+
+ )} +
+ ) : ( +
+ + +
+ )} +
+ ) +} diff --git a/apps/sim/platform/app/(shell)/people/_components/directory-sync/directory-sync.module.css b/apps/sim/platform/app/(shell)/people/_components/directory-sync/directory-sync.module.css new file mode 100644 index 00000000000..8743cb24280 --- /dev/null +++ b/apps/sim/platform/app/(shell)/people/_components/directory-sync/directory-sync.module.css @@ -0,0 +1,202 @@ +.page { + display: flex; + flex-direction: column; + min-block-size: 100%; + padding: var(--shell-home-row-start) var(--ui-page-inline-gutter) var(--ui-page-inset-block); + gap: var(--spacing-32); +} + +.back { + margin-inline-start: calc(-1 * var(--spacing-control-inline)); +} + +.heading { + display: grid; + gap: var(--spacing-8); +} + +.secondary { + color: var(--ui-secondary); +} + +.columns { + display: grid; + grid-template-columns: minmax(0, calc(2 * var(--spacing-320))) minmax( + 0, + calc(var(--spacing-320) + var(--spacing-64)) + ); + align-items: start; + gap: var(--spacing-32) var(--spacing-64); +} + +.main, +.notes { + display: flex; + flex-direction: column; + min-inline-size: 0; + gap: var(--spacing-32); +} + +.notes { + gap: var(--spacing-24); + padding-block-start: var(--spacing-8); +} + +.note { + display: grid; + gap: var(--spacing-8); +} + +.disconnect { + display: grid; + gap: var(--spacing-12); + padding-block-start: var(--spacing-8); +} + +.card { + display: flex; + flex-direction: column; + gap: var(--spacing-24); + padding: var(--spacing-24); + border: var(--spacing-stroke-default) solid var(--ui-border); + border-radius: var(--radius-12); +} + +.cardHeader { + display: flex; + align-items: center; + gap: var(--spacing-12); +} + +.cardHeader .sourceCopy { + flex: 1; +} + +.summary { + display: grid; + gap: var(--spacing-4); +} + +.mark { + display: inline-flex; + flex: none; + align-items: center; + justify-content: center; + inline-size: var(--spacing-32); + block-size: var(--spacing-32); + border: var(--spacing-stroke-default) solid var(--ui-border); + border-radius: var(--ui-control-radius); +} + +.sourceCopy { + display: grid; + min-inline-size: 0; + gap: var(--ui-stack-gap); + overflow-wrap: anywhere; +} + +.facts { + display: grid; + gap: var(--spacing-16); +} + +.fact { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: var(--spacing-4) var(--spacing-12); +} + +.cardHeader .mark { + inline-size: var(--spacing-40); + block-size: var(--spacing-40); +} + +.run { + margin-inline-start: auto; +} + +.cardFooter { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-8); + padding-block-start: var(--spacing-16); + border-block-start: var(--spacing-stroke-default) solid var(--ui-divider); +} + +.sources { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 13rem), 16rem)); + justify-content: center; + inline-size: 100%; + gap: var(--spacing-16); +} + +.source { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--spacing-16); + padding: var(--spacing-20); + border: var(--spacing-stroke-default) solid var(--ui-border); + border-radius: var(--radius-12); + background: var(--ui-canvas); + color: var(--ui-ink); + text-align: start; + cursor: pointer; +} + +.source:hover { + background: var(--ui-hover); +} + +.sourceAction { + display: flex; + align-items: center; + justify-content: space-between; + align-self: stretch; + margin-block-start: var(--spacing-4); +} + +.unconnected { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--spacing-24); +} + +.unconnected .sources { + justify-content: start; +} + +.importPrompt { + display: flex; + flex-wrap: wrap; + justify-content: center; + margin-block-start: var(--spacing-8); + gap: var(--spacing-8); + color: var(--ui-secondary); +} + +.importLink { + color: var(--ui-ink); + text-decoration: underline; + cursor: pointer; +} + +.provisioning { + display: grid; + gap: var(--ui-stack-gap); +} + +.quietActions { + display: flex; + flex-wrap: wrap; + gap: var(--ui-action-gap); +} + +@media (width < 64rem) { + .columns { + grid-template-columns: minmax(0, 1fr); + } +} diff --git a/apps/sim/platform/app/(shell)/people/_components/directory/directory.module.css b/apps/sim/platform/app/(shell)/people/_components/directory/directory.module.css new file mode 100644 index 00000000000..fa20c43d1fd --- /dev/null +++ b/apps/sim/platform/app/(shell)/people/_components/directory/directory.module.css @@ -0,0 +1,45 @@ +.directory { + /* Everything above and below the table's own scroller, so the page itself stays still. */ + --ui-table-viewport-offset: calc( + var(--shell-home-row-start) + + 2 * + var(--spacing-app-control-md) + + 2 * + var(--spacing-48) + + var(--spacing-48) + ); + display: flex; + flex-direction: column; + gap: var(--spacing-24); + padding-inline: var(--ui-page-inline-gutter); +} + +.filters { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-12); +} + +.search { + flex: 0 1 var(--spacing-320); + min-inline-size: var(--spacing-192); +} + +.department { + flex: 0 1 var(--spacing-192); + min-inline-size: var(--spacing-128); +} + +.scope { + flex: 1; + color: var(--ui-secondary); + text-align: end; + white-space: nowrap; +} + +@media (width < 48rem) { + .scope { + display: none; + } +} diff --git a/apps/sim/platform/app/(shell)/people/_components/directory/directory.tsx b/apps/sim/platform/app/(shell)/people/_components/directory/directory.tsx new file mode 100644 index 00000000000..566842df7bc --- /dev/null +++ b/apps/sim/platform/app/(shell)/people/_components/directory/directory.tsx @@ -0,0 +1,117 @@ +'use client' + +import { useMemo } from 'react' +import styles from '@/platform/app/(shell)/people/_components/directory/directory.module.css' +import { PeopleStatus } from '@/platform/app/(shell)/people/_components/people-page/people-status' +import { PersonIdentity } from '@/platform/app/(shell)/people/_components/person-identity/person-identity' +import { + Button, + DataTable, + EmptyState, + FailureNotice, + SearchableSelect, + SearchField, +} from '@/platform/design-system/components' +import { personHref, useDepartmentOptions, usePeopleDirectory } from '@/platform/lib/data/people' + +const COLUMNS = [ + { key: 'person', label: 'Person', kind: 'text', primary: true }, + { key: 'department', label: 'Department', kind: 'text', showFrom: 'compact' }, + { key: 'location', label: 'Location', kind: 'text', showFrom: 'wide' }, + { key: 'manager', label: 'Manager', kind: 'text', showFrom: 'wide' }, +] as const + +interface DirectoryCellProps { + value: string | null | undefined +} + +function DirectoryCell({ value }: DirectoryCellProps) { + return {value || '—'} +} + +interface PeopleDirectoryProps { + organizationId: string +} + +export function PeopleDirectory({ organizationId }: PeopleDirectoryProps) { + const directory = usePeopleDirectory(organizationId) + const departments = useDepartmentOptions(organizationId, directory.origin) + const { originQuery } = directory + const rows = useMemo( + () => + directory.rows.map((person) => ({ + id: person.memberId, + values: {}, + cells: { + person: , + department: , + location: , + manager: , + }, + href: personHref(organizationId, person.memberId, originQuery), + })), + [directory.rows, organizationId, originQuery] + ) + return ( +
+
+
+ +
+
+ directory.setDepartment(departments.parse(next))} + /> +
+ + People in your organization + +
+ {directory.loading ? ( + Loading people… + ) : directory.error ? ( + + ) : rows.length === 0 ? ( + !directory.query && !directory.hasDepartment ? ( + + ) : ( + Clear search} + /> + ) + ) : ( + + )} +
+ ) +} diff --git a/apps/sim/platform/app/(shell)/people/_components/external-link/external-link.tsx b/apps/sim/platform/app/(shell)/people/_components/external-link/external-link.tsx new file mode 100644 index 00000000000..6c3268689ed --- /dev/null +++ b/apps/sim/platform/app/(shell)/people/_components/external-link/external-link.tsx @@ -0,0 +1,21 @@ +import Link from 'next/link' +import { Button } from '@/platform/design-system/components' + +interface ExternalLinkProps { + href: string + children: string +} + +export function ExternalLink({ href, children }: ExternalLinkProps) { + return ( +
+ +
+ ) +} diff --git a/apps/sim/platform/app/(shell)/people/_components/import/import-modal.tsx b/apps/sim/platform/app/(shell)/people/_components/import/import-modal.tsx new file mode 100644 index 00000000000..bc5c26f15fd --- /dev/null +++ b/apps/sim/platform/app/(shell)/people/_components/import/import-modal.tsx @@ -0,0 +1,319 @@ +'use client' + +import { useState } from 'react' +import { toast } from '@sim/emcn' +import { getErrorMessage } from '@sim/utils/errors' +import Link from 'next/link' +import { ExternalLink } from '@/platform/app/(shell)/people/_components/external-link/external-link' +import styles from '@/platform/app/(shell)/people/_components/import/import.module.css' +import { + Button, + FileField, + InlineNotice, + MetricGroup, + Modal, + SecretField, + SelectField, + TextField, +} from '@/platform/design-system/components' +import { useScrollEdges } from '@/platform/design-system/components/scroll-area/use-scroll-edges' +import { + downloadImportTemplate, + type ImportBody, + isApiConflict, + type PeopleImportResult, + usePeopleImport, + usePeopleViewer, + useProvisioningConnection, +} from '@/platform/lib/data/people' +import { plural } from '@/platform/lib/plural' + +const ACCEPTED_FILES = '.csv,.tsv,.xlsx,.xls' + +const SOURCES = { + file: { name: 'Spreadsheet', description: 'Upload a CSV or Excel file' }, + okta: { name: 'Okta', description: 'Read profiles from your Okta directory' }, + entra: { name: 'Microsoft Entra ID', description: 'Read profiles from Microsoft Graph' }, + directory: { + name: 'SCIM provisioning', + description: 'Use directory data already provisioned to Sim', + }, +} as const + +type ImportSource = keyof typeof SOURCES + +const TOKEN_SOURCES = { + okta: { + instructions: 'Use an Okta API token that can read users in your organization.', + tokenLabel: 'API token', + tokenPlaceholder: 'Paste your token without SSWS', + helpHref: 'https://developer.okta.com/docs/guides/create-an-api-token/main/', + helpLabel: 'Create an Okta API token', + }, + entra: { + instructions: + 'In Graph Explorer, sign in with your work or school account, grant delegated User.Read.All permission, and copy your access token. Microsoft commercial cloud is supported.', + tokenLabel: 'Access token', + tokenPlaceholder: 'Paste your token without Bearer', + helpHref: 'https://developer.microsoft.com/en-us/graph/graph-explorer', + helpLabel: 'Open Graph Explorer', + }, +} as const + +interface IssueListProps { + result: PeopleImportResult + spreadsheet: boolean +} + +function IssueList({ result, spreadsheet }: IssueListProps) { + const unlisted = result.skipped + result.warnings - result.issues.length + const issueEdges = useScrollEdges() + return ( +
+

+ {`${plural(result.skipped + result.warnings, 'row')} need${result.skipped + result.warnings === 1 ? 's' : ''} attention`} +

+
    + {result.issues.map((issue) => ( +
  • + {spreadsheet && Row {issue.row}} + + {issue.email ? `${issue.email}: ` : ''} + {issue.message} + + + {issue.severity === 'skipped' ? 'Skipped' : 'Warning'} + +
  • + ))} + {unlisted > 0 &&
  • and {plural(unlisted, 'more issue')}
  • } +
+
+ ) +} + +interface ImportModalProps { + organizationId: string + onClose: () => void + onOpenSync: () => void +} + +/** + * One-time import: choose a source, preview, then apply exactly what was previewed. A directory + * that moved after its preview answers 409 and returns here for a new one. Tokens stay in this + * component's state and reach the request through the mutation that keeps them out of the cache. + */ +export function ImportModal({ organizationId, onClose, onOpenSync }: ImportModalProps) { + const { canProvision, canOpenProvisioningSettings, provisioningSettingsHref } = usePeopleViewer() + const importPeople = usePeopleImport(organizationId) + const [source, setSource] = useState('file') + const connection = useProvisioningConnection(organizationId, source === 'directory') + const [domain, setDomain] = useState('') + const [token, setToken] = useState('') + const [sheet, setSheet] = useState> | null>( + null + ) + const [reading, setReading] = useState(false) + const [fileError, setFileError] = useState() + const [preview, setPreview] = useState(null) + const pending = reading || importPeople.pending + const done = preview?.updated === 0 + const spreadsheet = source === 'file' + const ready = spreadsheet + ? Boolean(sheet) + : source === 'directory' + ? connection.ready && connection.userCount > 0 + : Boolean(token.trim() && (source !== 'okta' || domain.trim())) + + function requestBody(apply: boolean): ImportBody | null { + const previewDigest = apply ? preview?.previewDigest : undefined + if (source === 'file') return sheet ? { headers: sheet.headers, rows: sheet.rows, apply } : null + if (source === 'okta') + return { source, domain: domain.trim(), token: token.trim(), apply, previewDigest } + if (source === 'entra') return { source, token: token.trim(), apply, previewDigest } + return { source, apply, previewDigest } + } + + async function chooseFile(file: File) { + setFileError(undefined) + setReading(true) + setSheet(null) + try { + const selected = await importPeople.readSheet(file) + setSheet(selected) + setPreview( + await importPeople.submit({ headers: selected.headers, rows: selected.rows, apply: false }) + ) + } catch (error) { + setFileError(getErrorMessage(error, 'Could not import people data')) + } finally { + setReading(false) + } + } + + return ( + setPreview(null) : undefined} + onConfirm={ + done + ? undefined + : async () => { + const body = requestBody(Boolean(preview)) + if (!body) return + try { + const result = await importPeople.submit(body) + if (preview) { + toast.success(`Updated ${plural(result.updated, 'profile')}`) + return + } + setPreview(result) + return false + } catch (error) { + if (!spreadsheet && preview && isApiConflict(error)) setPreview(null) + throw error + } + } + } + > + {preview ? ( + <> +

+ {spreadsheet + ? `${sheet?.fileName} updates profiles for existing organization members. It does not create accounts or an ongoing connection.` + : `${SOURCES[source].name} updates profiles for existing organization members. If the directory changes, you’ll be asked to preview it again.`} +

+ + {preview.issues.length > 0 && } + {spreadsheet && ( +

+ To include people without Sim accounts and keep profiles current, connect Okta or + Microsoft Entra ID through{' '} + + . +

+ )} + + ) : ( + <> + key !== 'directory' || canProvision) + .map((key) => ({ value: key, label: SOURCES[key].name }))} + value={source} + hint={SOURCES[source].description} + disabled={pending} + onValueChange={(next) => { + setSource(next as ImportSource) + setToken('') + }} + /> + {(source === 'okta' || source === 'entra') && ( + <> +

+ {TOKEN_SOURCES[source].instructions} Sim uses the token for this import only. +

+ {source === 'okta' && ( + + )} + + + {TOKEN_SOURCES[source].helpLabel} + + + )} + {source === 'directory' && ( + <> +

+ {connection.loading + ? 'Checking your provisioning connection…' + : connection.failed + ? 'Could not load your provisioning connection.' + : !connection.active + ? 'Enable SCIM provisioning and assign accounts in your provider to import their profiles.' + : connection.userCount === 0 + ? 'Your connection is active. Assign accounts to Sim in your identity provider, then refresh here.' + : `Your connection has ${plural(connection.userCount, 'provisioned account')}. Preview the profile fields and reporting lines sent by your provider.`} +

+ {!connection.loading && ( +
+ {canOpenProvisioningSettings && !connection.failed && ( + + )} + {!connection.active && ( + + Provisioning guide + + )} + +
+ )} + + )} + {source === 'file' && ( + <> + setSheet(null)} + onFileChange={(file) => void chooseFile(file)} + disabled={pending} + error={fileError} + hint='CSV or Excel with an email column, plus any of title, department, location, start date, and manager email.' + /> + {reading && } +
+ +
+ + )} + + )} +
+ ) +} diff --git a/apps/sim/platform/app/(shell)/people/_components/import/import.module.css b/apps/sim/platform/app/(shell)/people/_components/import/import.module.css new file mode 100644 index 00000000000..d3fd69ccc23 --- /dev/null +++ b/apps/sim/platform/app/(shell)/people/_components/import/import.module.css @@ -0,0 +1,44 @@ +.secondary { + color: var(--ui-secondary); +} + +.links { + display: flex; + flex-wrap: wrap; + gap: var(--ui-action-gap); +} + +.attention { + display: grid; + gap: var(--spacing-8); +} + +.attentionTitle { + color: var(--status-warning); +} + +.issues { + display: grid; + gap: var(--spacing-8); + max-block-size: var(--spacing-192); + overflow-y: auto; + overflow-wrap: anywhere; +} + +.issue { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: var(--spacing-4) var(--spacing-12); +} + +.issueMessage { + flex: 1; + min-inline-size: var(--spacing-128); +} + +.inlineLink { + color: var(--ui-ink); + text-decoration: underline; + cursor: pointer; +} diff --git a/apps/sim/platform/app/(shell)/people/_components/org-chart/chart-canvas.tsx b/apps/sim/platform/app/(shell)/people/_components/org-chart/chart-canvas.tsx new file mode 100644 index 00000000000..62bb3a46fdb --- /dev/null +++ b/apps/sim/platform/app/(shell)/people/_components/org-chart/chart-canvas.tsx @@ -0,0 +1,295 @@ +'use client' + +import { type MouseEvent, type ReactNode, useMemo } from 'react' +import { + BaseEdge, + type Edge, + type EdgeProps, + Handle, + type Node, + type NodeProps, + Position, + ReactFlow, + ReactFlowProvider, + useReactFlow, + useStore, +} from '@xyflow/react' +import Link from 'next/link' +import styles from '@/platform/app/(shell)/people/_components/org-chart/org-chart.module.css' +import { Avatar, Icon, IconButton, OverflowText } from '@/platform/design-system/components' +import { type ChartBranch, type PersonSummary, personHref } from '@/platform/lib/data/people' +import { plural } from '@/platform/lib/plural' + +import '@xyflow/react/dist/style.css' + +type ChartFocus = (memberId: string, event: MouseEvent) => void + +interface PersonCardData extends Record { + person: PersonSummary + footer: string + focused: boolean + compact: boolean + hasManagerLine: boolean + hasReportLine: boolean + profileHref: string + onFocus: ChartFocus +} + +type PersonCardNode = Node + +const CARD_WIDTH = 224 +const CARD_HEIGHT = 184 +const COMPACT_WIDTH = 310 +const COMPACT_HEIGHT = 76 +const COLUMN_STEP = 240 +const MANAGER_GAP = 32 +const REPORT_GAP = 52 +const REPORT_BUS_DROP = 28 +const LINE_RADIUS = 8 +const MIN_ZOOM = 0.35 +const MAX_ZOOM = 1.6 +const FIT = { padding: 0.08, minZoom: 0.45, maxZoom: 1 } + +function PersonCard({ data }: NodeProps) { + const { person, footer, focused, compact, hasManagerLine, hasReportLine } = data + const copy = ( + + + {person.name} + + + + {person.title || person.email} + + + + ) + const lines = ( + <> + {hasManagerLine && } + {hasReportLine && } + + ) + const explore = { + type: 'button', + 'aria-label': `Explore ${person.name}`, + onClick: (event: MouseEvent) => data.onFocus(person.memberId, event), + } as const + if (compact) + return ( + <> + {lines} + + + ) + const body = ( + <> + + {copy} + + ) + return ( +
+ {lines} + {focused ? ( +
{body}
+ ) : ( + + )} +
+ + + {footer} + + + Profile + + +
+
+ ) +} + +function ReportingLine({ sourceX, sourceY, targetX, targetY }: EdgeProps) { + const direction = Math.sign(targetX - sourceX) + const busY = sourceY + REPORT_BUS_DROP + const path = + direction === 0 + ? `M ${sourceX} ${sourceY} V ${targetY}` + : `M ${sourceX} ${sourceY} V ${busY - LINE_RADIUS} Q ${sourceX} ${busY} ${sourceX + direction * LINE_RADIUS} ${busY} H ${targetX - direction * LINE_RADIUS} Q ${targetX} ${busY} ${targetX} ${busY + LINE_RADIUS} V ${targetY}` + return +} + +const NODE_TYPES = { person: PersonCard } +const EDGE_TYPES = { reporting: ReportingLine } + +interface ChartControlsProps { + compact: boolean +} + +function ChartControls({ compact }: ChartControlsProps) { + const { zoomIn, zoomOut, fitView } = useReactFlow() + const zoom = useStore((state) => state.transform[2]) + return ( +
+ {!compact && ( + <> + void zoomOut()} + /> + + {Math.round(zoom * 100)}% + + = MAX_ZOOM} + onClick={() => void zoomIn()} + /> +
+ ) +} + +function chartGraph( + { person, chain, reports, totalReports }: ChartBranch, + organizationId: string, + profileQuery: string, + compact: boolean, + onFocus: ChartFocus +) { + const nodes: PersonCardNode[] = [] + const edges: Edge[] = [] + const add = ( + member: PersonSummary, + footer: string, + x: number, + y: number, + lines: Pick + ) => { + const width = lines.compact ? COMPACT_WIDTH : CARD_WIDTH + nodes.push({ + id: member.memberId, + type: 'person', + position: { x: x - width / 2, y }, + width, + height: lines.compact ? COMPACT_HEIGHT : CARD_HEIGHT, + data: { + person: member, + footer, + ...lines, + profileHref: personHref(organizationId, member.memberId, profileQuery), + onFocus, + }, + }) + } + const connect = (source: string, target: string) => + edges.push({ id: `${source}/${target}`, source, target, type: 'reporting' }) + + const [manager] = chain + if (manager) { + const height = compact ? COMPACT_HEIGHT : CARD_HEIGHT + add(manager, 'Manager', 0, -(height + MANAGER_GAP), { + focused: false, + compact, + hasManagerLine: false, + hasReportLine: true, + }) + connect(manager.memberId, person.memberId) + } + add(person, totalReports ? plural(totalReports, 'report') : 'No reports', 0, 0, { + focused: true, + compact: false, + hasManagerLine: Boolean(manager), + hasReportLine: reports.length > 0, + }) + reports.forEach((report, index) => { + add( + report, + report.reportCount ? plural(report.reportCount, 'report') : 'No reports', + (index - (reports.length - 1) / 2) * COLUMN_STEP, + CARD_HEIGHT + REPORT_GAP, + { focused: false, compact: false, hasManagerLine: true, hasReportLine: false } + ) + connect(person.memberId, report.memberId) + }) + return { nodes, edges } +} + +interface ChartCanvasProps { + organizationId: string + branch: ChartBranch + profileQuery: string + compact: boolean + onFocus: ChartFocus + /** The report pager, between the canvas and its controls. */ + pager?: ReactNode +} + +/** + * The manager, the person in focus and a page of their reports, on a canvas a viewer can pan and + * zoom. Remount it per branch: the first fit is what centers each new set of cards. + */ +export function ChartCanvas({ + organizationId, + branch, + profileQuery, + compact, + onFocus, + pager, +}: ChartCanvasProps) { + const { nodes, edges } = useMemo( + () => chartGraph(branch, organizationId, profileQuery, compact, onFocus), + [branch, organizationId, profileQuery, compact, onFocus] + ) + return ( + +
+
+ +
+
+
+ {pager} + +
+
+ ) +} diff --git a/apps/sim/platform/app/(shell)/people/_components/org-chart/chart-empty-state.tsx b/apps/sim/platform/app/(shell)/people/_components/org-chart/chart-empty-state.tsx new file mode 100644 index 00000000000..d8f4b6ba9f8 --- /dev/null +++ b/apps/sim/platform/app/(shell)/people/_components/org-chart/chart-empty-state.tsx @@ -0,0 +1,69 @@ +import type { MouseEvent, RefCallback } from 'react' +import Link from 'next/link' +import { Button, EmptyState } from '@/platform/design-system/components' +import { type PersonSummary, personHref, useChartRoots } from '@/platform/lib/data/people' + +interface ChartEmptyStateProps { + organizationId: string + person: PersonSummary + profileQuery: string + onFocus: (memberId: string, event: MouseEvent) => void + onBrowseDirectory: () => void + currentPersonRef: RefCallback + /** Present for an administrator, whose connection is what a first chart waits on. */ + onSync?: () => void +} + +export function ChartEmptyState({ + organizationId, + person, + profileQuery, + onFocus, + onBrowseDirectory, + currentPersonRef, + onSync, +}: ChartEmptyStateProps) { + const { firstTeam, emptyOrganization } = useChartRoots(organizationId) + if (emptyOrganization) + return ( + + + {onSync && } + + } + /> + ) + return ( + + {firstTeam && ( + + )} + + + } + /> + ) +} diff --git a/apps/sim/platform/app/(shell)/people/_components/org-chart/org-chart.module.css b/apps/sim/platform/app/(shell)/people/_components/org-chart/org-chart.module.css new file mode 100644 index 00000000000..f9e51661c72 --- /dev/null +++ b/apps/sim/platform/app/(shell)/people/_components/org-chart/org-chart.module.css @@ -0,0 +1,272 @@ +.chart { + display: flex; + flex: 1; + flex-direction: column; + min-block-size: 0; + gap: var(--spacing-12); +} + +.toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-8) var(--spacing-16); + padding-inline: var(--ui-page-inline-gutter); +} + +.search { + flex: 0 1 calc(var(--spacing-320) - var(--spacing-32)); + min-inline-size: var(--spacing-128); +} + +.pathNav { + flex: 1; + min-inline-size: 0; +} + +.path { + display: flex; + align-items: center; + gap: var(--spacing-4); + overflow-x: auto; + scrollbar-width: none; + white-space: nowrap; + color: var(--ui-secondary); +} + +.crumb { + flex: none; + padding-inline: var(--spacing-4); + border-radius: var(--ui-control-radius); + cursor: pointer; +} + +.crumb:hover { + color: var(--ui-ink); +} + +.current { + flex: none; + padding-inline: var(--spacing-4); + color: var(--ui-ink); +} + +.canvas { + position: relative; + flex: 1; + min-block-size: calc(var(--spacing-320) + 2 * var(--spacing-80)); +} + +.flowFrame { + position: absolute; + inset: 0; +} + +.flow { + --xy-background-color: transparent; + --xy-edge-stroke: var(--ui-border); +} + +.port { + visibility: hidden; + inline-size: 0; + min-inline-size: 0; + block-size: 0; + min-block-size: 0; + border: 0; +} + +.card, +.compactCard { + position: relative; + block-size: 100%; + inline-size: 100%; + border: var(--spacing-stroke-default) solid var(--ui-border); + border-radius: var(--radius-12); + background: var(--ui-canvas); + color: var(--ui-ink); + pointer-events: auto; +} + +.card { + display: flex; + flex-direction: column; +} + +button.cardBody:hover, +.compactCard:hover { + background: var(--ui-hover); +} + +.card[data-focused] { + border-color: var(--status-info); + box-shadow: 0 0 0 var(--spacing-stroke-focus) var(--status-info-soft); +} + +.compactCard { + display: flex; + align-items: center; + gap: var(--spacing-12); + padding-inline: var(--spacing-16); + text-align: start; + cursor: pointer; +} + +.cardBody { + display: flex; + flex: 1; + flex-direction: column; + gap: var(--spacing-16); + min-block-size: 0; + padding: var(--spacing-20); + text-align: start; +} + +button.cardBody { + border-start-start-radius: inherit; + border-start-end-radius: inherit; + cursor: pointer; +} + +.names { + display: grid; + flex: 1; + grid-template-columns: minmax(0, 1fr); + min-inline-size: 0; + gap: var(--spacing-4); +} + +.secondary { + color: var(--ui-secondary); +} + +.cardFooter { + position: relative; + display: flex; + flex: none; + align-items: center; + justify-content: space-between; + min-block-size: var(--spacing-40); + padding-inline: var(--spacing-20); + border-block-start: var(--spacing-stroke-default) solid var(--ui-divider); + color: var(--ui-secondary); +} + +.cardMeta, +.profileLink { + display: inline-flex; + align-items: center; + gap: var(--spacing-8); + min-inline-size: 0; +} + +.profileLink { + gap: var(--spacing-4); + color: var(--ui-ink); + border-radius: var(--ui-control-radius); +} + +.profileLink:hover { + text-decoration: underline; +} + +.footer { + display: grid; + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); + align-items: center; + min-block-size: var(--spacing-app-control-md); + gap: var(--spacing-8); + padding: var(--spacing-8) var(--ui-page-inline-gutter) var(--spacing-16); +} + +.pager { + grid-column: 2; + display: flex; + align-items: center; + gap: var(--spacing-8); + color: var(--ui-secondary); + font-variant-numeric: tabular-nums; +} + +.pagerLabel { + white-space: nowrap; +} + +.controls { + grid-column: 3; + display: inline-flex; + align-items: center; + justify-self: end; + gap: var(--spacing-4); + padding: var(--spacing-4); + border: var(--spacing-stroke-default) solid var(--ui-border); + border-radius: var(--ui-control-radius); +} + +.zoomLevel { + min-inline-size: var(--spacing-40); + text-align: center; + font-variant-numeric: tabular-nums; +} + +.controlsRule { + block-size: var(--spacing-16); + border-inline-start: var(--spacing-stroke-default) solid var(--ui-border); +} + +.inset { + padding-inline: var(--ui-page-inline-gutter); +} + +.firstUse { + display: flex; + flex: 1; + flex-direction: column; + align-items: center; + padding: calc(var(--spacing-96) - var(--spacing-20)) var(--ui-page-inline-gutter); + gap: var(--spacing-24); + text-align: center; +} + +.firstUseHeading { + display: grid; + justify-items: center; + gap: var(--spacing-12); + margin-block-end: var(--spacing-8); +} + +.firstUseCopy { + max-inline-size: var(--container-app-panel-start); + color: var(--ui-secondary); + text-wrap: balance; +} + +.graphic { + inline-size: calc(var(--spacing-96) + var(--spacing-16)); + fill: none; + stroke: var(--ui-border); + stroke-width: var(--spacing-stroke-default); +} + +.graphic [data-root] { + fill: var(--ui-hover); +} + +@media (width < 48rem) { + .search { + flex-basis: 100%; + } + + .position { + order: 1; + } + + .pathNav { + flex-basis: 100%; + order: 2; + } + + .canvas { + min-block-size: calc(var(--spacing-320) + var(--spacing-80)); + } +} diff --git a/apps/sim/platform/app/(shell)/people/_components/org-chart/org-chart.tsx b/apps/sim/platform/app/(shell)/people/_components/org-chart/org-chart.tsx new file mode 100644 index 00000000000..7469cbbfd82 --- /dev/null +++ b/apps/sim/platform/app/(shell)/people/_components/org-chart/org-chart.tsx @@ -0,0 +1,315 @@ +'use client' + +import { Fragment, type MouseEvent, useCallback, useLayoutEffect, useMemo, useRef } from 'react' +import { + DirectorySources, + ImportPrompt, +} from '@/platform/app/(shell)/people/_components/directory-sync/directory-sources' +import { ChartCanvas } from '@/platform/app/(shell)/people/_components/org-chart/chart-canvas' +import { ChartEmptyState } from '@/platform/app/(shell)/people/_components/org-chart/chart-empty-state' +import styles from '@/platform/app/(shell)/people/_components/org-chart/org-chart.module.css' +import { PeopleStatus } from '@/platform/app/(shell)/people/_components/people-page/people-status' +import { ProfileUnavailable } from '@/platform/app/(shell)/people/_components/people-page/profile-unavailable' +import { + Avatar, + Button, + FailureNotice, + Icon, + IconButton, + SearchableSelect, + SearchCombobox, +} from '@/platform/design-system/components' +import { useScrollEdges } from '@/platform/design-system/components/scroll-area/use-scroll-edges' +import { + isRefusal, + type SyncSource, + useOrgChart, + usePeoplePicker, + usePeopleViewer, +} from '@/platform/lib/data/people' + +function ReportingLinesGraphic() { + return ( + + + + + + + ) +} + +interface FindPersonProps { + organizationId: string + onSelect: (memberId: string) => void +} + +function FindPerson({ organizationId, onSelect }: FindPersonProps) { + const picker = usePeoplePicker(organizationId) + const options = useMemo( + () => + picker.people.map((person) => ({ + value: person.memberId, + label: person.name, + leading: , + details: [person.title || person.email, person.department].filter( + (detail): detail is string => Boolean(detail) + ), + })), + [picker.people] + ) + return ( + + ) +} + +interface BrowseTeamsProps { + organizationId: string + organizationName: string + onSelect: (memberId: string) => void +} + +function BrowseTeams({ organizationId, organizationName, onSelect }: BrowseTeamsProps) { + const picker = usePeoplePicker(organizationId, 'roots') + return ( + { + if (next) onSelect(next) + }} + /> + ) +} + +type FocusTarget = 'person' | 'previous' | 'next' + +interface OrgChartProps { + organizationId: string + /** An administrator with no reporting lines and no connection: the chart offers to start one. */ + firstUse: boolean + onConnect: (source: SyncSource) => void + onImport: () => void + onSync?: () => void +} + +export function OrgChart({ organizationId, firstUse, onConnect, onImport, onSync }: OrgChartProps) { + const { organizationName, canProvision } = usePeopleViewer() + const chart = useOrgChart(organizationId) + const { branch, focus, page, size, compact, setView, profileQuery } = chart + const pathEdges = useScrollEdges('horizontal') + const currentPerson = useRef(null) + const previousReports = useRef(null) + const nextReports = useRef(null) + const pendingFocus = useRef<{ + focus: string + page: number + trigger: HTMLElement + target: FocusTarget + } | null>(null) + const focusPerson = useCallback( + (id: string, event?: MouseEvent) => { + // `detail === 0` is a keyboard click: focus follows the navigation instead of being lost. + pendingFocus.current = + event?.detail === 0 + ? { focus: id, page: 1, trigger: event.currentTarget, target: 'person' } + : null + void setView({ focus: id, page: 1, view: 'chart' }, { history: 'push' }) + }, + [setView] + ) + const changePage = ( + value: number, + target: 'previous' | 'next', + event: MouseEvent + ) => { + pendingFocus.current = + event.detail === 0 ? { focus, page: value, trigger: event.currentTarget, target } : null + void setView({ page: value }) + } + useLayoutEffect(() => { + const pending = pendingFocus.current + if (!branch || !pending || pending.focus !== focus || pending.page !== page) return + pendingFocus.current = null + const active = document.activeElement + if (active !== document.body && active !== pending.trigger) return + const pagers = [previousReports.current, nextReports.current].map((slot) => + slot?.querySelector('button') + ) + const controls = pending.target === 'next' ? pagers.reverse() : pagers + const target = + pending.target === 'person' + ? currentPerson.current + : (controls.find((button) => button && !button.disabled) ?? currentPerson.current) + target?.focus() + }, [branch, focus, page]) + const setCurrentPerson = useCallback((element: HTMLElement | null) => { + currentPerson.current = element + }, []) + const browseDirectory = () => void setView({ view: 'directory' }, { history: 'push' }) + const noLines = branch && !branch.chain.length && !branch.totalReports + + if (noLines && firstUse) + return ( +
+ +
+

Bring your organization into view

+

+ Connect your identity provider to keep people and reporting lines current, including + people who haven’t joined Sim. +

+
+ + +

+ You can also edit an existing member’s profile to add their manager. +

+
+ ) + + return ( +
+
+
+ +
+ {branch && !noLines && ( + + )} +
+ +
+
+ {noLines ? ( +
+ +
+ ) : branch ? ( + size && ( +
+ + changePage(branch.page - 1, 'previous', event)} + /> + + + {size === 1 + ? branch.page + : `${(branch.page - 1) * size + 1}–${(branch.page - 1) * size + branch.reports.length}`}{' '} + of {branch.totalReports} direct reports + + + = branch.totalReports} + onClick={(event) => changePage(branch.page + 1, 'next', event)} + /> + +
+ ) + } + /> + ) : chart.error ? ( +
+ {isRefusal(chart.error) ? ( + focusPerson('', event)}>My position + ) : ( + + ) + } + /> + ) : ( + + )} +
+ ) : ( + Loading org chart… + )} +
+ ) +} diff --git a/apps/sim/platform/app/(shell)/people/_components/people-page/people-frame.tsx b/apps/sim/platform/app/(shell)/people/_components/people-page/people-frame.tsx new file mode 100644 index 00000000000..1f707deb947 --- /dev/null +++ b/apps/sim/platform/app/(shell)/people/_components/people-page/people-frame.tsx @@ -0,0 +1,46 @@ +'use client' + +import type { ReactNode } from 'react' +import styles from '@/platform/app/(shell)/people/_components/people-page/people-page.module.css' + +type PeopleView = 'chart' | 'directory' + +const VIEWS = [ + { value: 'chart', label: 'Org chart' }, + { value: 'directory', label: 'Directory' }, +] as const + +interface PeopleFrameProps { + actions?: ReactNode + view?: PeopleView + onViewChange?: (view: PeopleView) => void + children: ReactNode +} + +/** The title, its actions and the view tabs, shared with the loading state so nothing shifts. */ +export function PeopleFrame({ actions, view, onViewChange, children }: PeopleFrameProps) { + return ( +
+
+

People

+ {actions &&
{actions}
} +
+ +
{children}
+
+ ) +} diff --git a/apps/sim/platform/app/(shell)/people/_components/people-page/people-page.module.css b/apps/sim/platform/app/(shell)/people/_components/people-page/people-page.module.css new file mode 100644 index 00000000000..ea76ea1df46 --- /dev/null +++ b/apps/sim/platform/app/(shell)/people/_components/people-page/people-page.module.css @@ -0,0 +1,70 @@ +.page { + display: flex; + flex-direction: column; + min-block-size: 100%; + padding-block-start: var(--shell-home-row-start); +} + +.header { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: var(--spacing-8) var(--spacing-16); + padding-inline: var(--ui-page-inline-gutter); +} + +.actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-8); +} + +.updated { + color: var(--ui-secondary); + margin-inline-end: var(--spacing-8); +} + +.tabs { + display: flex; + gap: var(--spacing-24); + margin-block-start: var(--spacing-24); + padding-inline: var(--ui-page-inline-gutter); + border-block-end: var(--spacing-stroke-default) solid var(--ui-divider); +} + +.tab { + margin-block-end: calc(-1 * var(--spacing-stroke-default)); + min-block-size: var(--spacing-40); + border-block-end: var(--spacing-stroke-focus) solid transparent; + color: var(--ui-secondary); + cursor: pointer; +} + +.tab:hover:enabled { + color: var(--ui-ink); +} + +.tab[aria-current="page"] { + border-block-end-color: var(--ui-ink); + color: var(--ui-ink); +} + +.tab:disabled { + cursor: default; +} + +.body { + display: flex; + flex: 1; + flex-direction: column; + min-block-size: 0; + padding-block-start: var(--spacing-20); +} + +.status { + color: var(--ui-secondary); + padding-block: var(--spacing-48); + text-align: center; +} diff --git a/apps/sim/platform/app/(shell)/people/_components/people-page/people-page.tsx b/apps/sim/platform/app/(shell)/people/_components/people-page/people-page.tsx new file mode 100644 index 00000000000..cdb9ad18ed6 --- /dev/null +++ b/apps/sim/platform/app/(shell)/people/_components/people-page/people-page.tsx @@ -0,0 +1,133 @@ +'use client' + +import { useState } from 'react' +import dynamic from 'next/dynamic' +import { PeopleDirectory } from '@/platform/app/(shell)/people/_components/directory/directory' +import { PeopleFrame } from '@/platform/app/(shell)/people/_components/people-page/people-frame' +import styles from '@/platform/app/(shell)/people/_components/people-page/people-page.module.css' +import { Button } from '@/platform/design-system/components' +import { + type SyncSource, + useChartRoots, + useDirectorySyncStatus, + usePeopleUrl, + usePeopleViewer, +} from '@/platform/lib/data/people' +import { formatRelativeTime } from '@/platform/lib/time' + +const OrgChart = dynamic(() => + import('@/platform/app/(shell)/people/_components/org-chart/org-chart').then( + (module) => module.OrgChart + ) +) +const ImportModal = dynamic(() => + import('@/platform/app/(shell)/people/_components/import/import-modal').then( + (module) => module.ImportModal + ) +) +const DirectorySyncPage = dynamic(() => + import('@/platform/app/(shell)/people/_components/directory-sync/directory-sync-page').then( + (module) => module.DirectorySyncPage + ) +) +const ConnectModal = dynamic(() => + import('@/platform/app/(shell)/people/_components/directory-sync/connect-modal').then( + (module) => module.ConnectModal + ) +) + +interface PeoplePageProps { + organizationId: string +} + +export function PeoplePage({ organizationId }: PeoplePageProps) { + const { isAdmin } = usePeopleViewer() + const [{ view, search, department, departmentKey, panel }, setView] = usePeopleUrl() + const [importing, setImporting] = useState(false) + const [connecting, setConnecting] = useState(null) + const currentView = view ?? (search || department || departmentKey ? 'directory' : 'chart') + const status = useDirectorySyncStatus(organizationId, isAdmin) + const roots = useChartRoots(organizationId, isAdmin && currentView === 'chart') + const openSync = () => void setView({ panel: 'sync' }, { history: 'push' }) + const openImport = () => setImporting(true) + const firstUse = isAdmin && roots.emptyOrganization && status.loaded && !status.sync + // First use has no header actions, so they wait for the reads that decide it rather than flash. + const showActions = + isAdmin && (currentView !== 'chart' || (roots.settled && status.settled && !firstUse)) + const lastSuccessAt = status.sync?.lastSuccessAt + const modals = ( + <> + {importing && isAdmin && ( + setImporting(false)} + onOpenSync={() => { + setImporting(false) + openSync() + }} + /> + )} + {connecting && isAdmin && ( + setConnecting(null)} + onConnected={() => { + setConnecting(null) + openSync() + }} + /> + )} + + ) + + if (panel === 'sync' && isAdmin) + return ( + <> + void setView({ panel: null }, { history: 'push' })} + onConnect={setConnecting} + onImport={openImport} + /> + {modals} + + ) + + return ( + void setView({ view: next }, { history: 'push' })} + actions={ + showActions ? ( + <> + {lastSuccessAt && ( + + Updated {formatRelativeTime(Date.parse(lastSuccessAt), Date.now())} + + )} + + + + ) : undefined + } + > + {currentView === 'chart' ? ( + + ) : ( + + )} + {modals} + + ) +} diff --git a/apps/sim/platform/app/(shell)/people/_components/people-page/people-skeleton.tsx b/apps/sim/platform/app/(shell)/people/_components/people-page/people-skeleton.tsx new file mode 100644 index 00000000000..a26e6165ef6 --- /dev/null +++ b/apps/sim/platform/app/(shell)/people/_components/people-page/people-skeleton.tsx @@ -0,0 +1,10 @@ +import { PeopleFrame } from '@/platform/app/(shell)/people/_components/people-page/people-frame' +import { PeopleStatus } from '@/platform/app/(shell)/people/_components/people-page/people-status' + +export function PeopleSkeleton() { + return ( + + Loading people… + + ) +} diff --git a/apps/sim/platform/app/(shell)/people/_components/people-page/people-status.tsx b/apps/sim/platform/app/(shell)/people/_components/people-page/people-status.tsx new file mode 100644 index 00000000000..294efbefe98 --- /dev/null +++ b/apps/sim/platform/app/(shell)/people/_components/people-page/people-status.tsx @@ -0,0 +1,14 @@ +import styles from '@/platform/app/(shell)/people/_components/people-page/people-page.module.css' + +interface PeopleStatusProps { + children: string +} + +/** The body of a People view while its first read is pending, the same size in every view. */ +export function PeopleStatus({ children }: PeopleStatusProps) { + return ( +

+ {children} +

+ ) +} diff --git a/apps/sim/platform/app/(shell)/people/_components/people-page/profile-unavailable.tsx b/apps/sim/platform/app/(shell)/people/_components/people-page/profile-unavailable.tsx new file mode 100644 index 00000000000..275f1722ae1 --- /dev/null +++ b/apps/sim/platform/app/(shell)/people/_components/people-page/profile-unavailable.tsx @@ -0,0 +1,17 @@ +import type { ReactNode } from 'react' +import { EmptyState } from '@/platform/design-system/components' + +interface ProfileUnavailableProps { + action: ReactNode +} + +/** The neutral answer for a person this viewer can't reach, whether gone or refused. */ +export function ProfileUnavailable({ action }: ProfileUnavailableProps) { + return ( + + ) +} diff --git a/apps/sim/platform/app/(shell)/people/_components/person-identity/person-identity.module.css b/apps/sim/platform/app/(shell)/people/_components/person-identity/person-identity.module.css new file mode 100644 index 00000000000..11d185b820c --- /dev/null +++ b/apps/sim/platform/app/(shell)/people/_components/person-identity/person-identity.module.css @@ -0,0 +1,17 @@ +.identity { + display: flex; + align-items: center; + min-inline-size: 0; + gap: var(--spacing-12); +} + +.copy { + display: grid; + min-inline-size: 0; + gap: var(--ui-stack-gap); + overflow-wrap: anywhere; +} + +.detail { + color: var(--ui-secondary); +} diff --git a/apps/sim/platform/app/(shell)/people/_components/person-identity/person-identity.tsx b/apps/sim/platform/app/(shell)/people/_components/person-identity/person-identity.tsx new file mode 100644 index 00000000000..a053d435883 --- /dev/null +++ b/apps/sim/platform/app/(shell)/people/_components/person-identity/person-identity.tsx @@ -0,0 +1,22 @@ +import styles from '@/platform/app/(shell)/people/_components/person-identity/person-identity.module.css' +import { Avatar } from '@/platform/design-system/components' +import type { PersonSummary } from '@/platform/lib/data/people' + +interface PersonIdentityProps { + person: Pick +} + +/** A person's avatar, name and what they do: the row that directory and profile lists share. */ +export function PersonIdentity({ person }: PersonIdentityProps) { + return ( + + + + {person.name} + + {person.title || person.email} + + + + ) +} diff --git a/apps/sim/platform/app/(shell)/people/_components/person/person-modal.tsx b/apps/sim/platform/app/(shell)/people/_components/person/person-modal.tsx new file mode 100644 index 00000000000..b754bd45ce3 --- /dev/null +++ b/apps/sim/platform/app/(shell)/people/_components/person/person-modal.tsx @@ -0,0 +1,114 @@ +'use client' + +import { useMemo, useState } from 'react' +import { DatePicker, Modal, SearchableSelect, TextField } from '@/platform/design-system/components' +import { + type Person, + type PersonDraft, + useEditPerson, + usePeoplePicker, +} from '@/platform/lib/data/people' + +/** Profile text's server bound; a longer draft would only come back refused. */ +const PROFILE_TEXT_MAX_LENGTH = 200 + +/** The picker's value for no manager; membership ids are never this. */ +const NO_MANAGER = 'no-manager' + +const TEXT_FIELDS = [ + { field: 'title', label: 'Title', placeholder: 'Staff Engineer' }, + { field: 'department', label: 'Department', placeholder: 'Engineering' }, + { field: 'location', label: 'Location', placeholder: 'San Francisco' }, +] as const + +interface PersonModalProps { + organizationId: string + person: Person + onClose: () => void +} + +/** A validation problem keeps the dialog open like a refused save, with its message on the field. */ +class FieldProblems extends Error {} + +export function PersonModal({ organizationId, person, onClose }: PersonModalProps) { + const edit = useEditPerson(organizationId, person.memberId) + const picker = usePeoplePicker(organizationId, 'members', person.memberId) + const [draft, setDraft] = useState({ + title: person.title, + department: person.department, + location: person.location, + startDate: person.startDate, + managerMemberId: person.managerMemberId, + }) + const [saving, setSaving] = useState(false) + const [errors, setErrors] = useState>>({}) + // The chosen manager's name, kept with the choice: the search results may no longer include them. + // Only a stored manager who is suspended has an id and no name. + const [managerName, setManagerName] = useState(person.manager?.name ?? null) + const managerOptions = useMemo( + () => [{ value: NO_MANAGER, label: 'No manager' }, ...picker.options], + [picker.options] + ) + const change = (field: Key, value: PersonDraft[Key]) => { + setDraft((current) => ({ ...current, [field]: value })) + setErrors((current) => ({ ...current, [field]: undefined })) + } + return ( + !open && onClose()} + title={`Edit ${person.name}`} + confirmLabel='Save' + onConfirm={async () => { + setSaving(true) + try { + const problems = await edit.save(draft) + if (problems) { + setErrors(problems) + throw new FieldProblems('Check the highlighted fields.') + } + } finally { + setSaving(false) + } + }} + > + {TEXT_FIELDS.map(({ field, label, placeholder }) => ( + change(field, value || null)} + error={errors[field]} + disabled={saving} + /> + ))} + change('startDate', value)} + error={errors.startDate} + disabled={saving} + /> + { + const manager = picker.people.find((candidate) => candidate.memberId === value) + setManagerName(manager?.name ?? null) + change('managerMemberId', manager?.memberId ?? null) + }} + /> + + ) +} diff --git a/apps/sim/platform/app/(shell)/people/_components/person/person-skeleton.tsx b/apps/sim/platform/app/(shell)/people/_components/person/person-skeleton.tsx new file mode 100644 index 00000000000..c73e5347d07 --- /dev/null +++ b/apps/sim/platform/app/(shell)/people/_components/person/person-skeleton.tsx @@ -0,0 +1,12 @@ +import { PeopleStatus } from '@/platform/app/(shell)/people/_components/people-page/people-status' +import styles from '@/platform/app/(shell)/people/_components/person/person.module.css' + +/** A profile's frame while the route loads, with room where its way back will appear. */ +export function PersonSkeleton() { + return ( +
+
+ Loading profile… +
+ ) +} diff --git a/apps/sim/platform/app/(shell)/people/_components/person/person.module.css b/apps/sim/platform/app/(shell)/people/_components/person/person.module.css new file mode 100644 index 00000000000..7cd69ffe8d5 --- /dev/null +++ b/apps/sim/platform/app/(shell)/people/_components/person/person.module.css @@ -0,0 +1,136 @@ +.page { + display: flex; + flex-direction: column; + min-block-size: 100%; + padding: var(--shell-home-row-start) var(--ui-page-inline-gutter) var(--ui-page-inset-block); + gap: var(--spacing-32); +} + +.nav, +.heading { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: var(--spacing-12); +} + +.nav { + min-block-size: var(--spacing-app-chip); +} + +.back { + margin-inline-start: calc(-1 * var(--spacing-control-inline)); +} + +.heading { + padding-block: var(--spacing-16) var(--spacing-8); +} + +.identity { + display: flex; + align-items: center; + min-inline-size: 0; + gap: var(--spacing-24); +} + +.titles { + display: grid; + min-inline-size: 0; + gap: var(--spacing-8); + overflow-wrap: anywhere; +} + +.actions { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-8); +} + +.secondary { + color: var(--ui-secondary); +} + +.columns { + display: grid; + grid-template-columns: minmax(0, var(--spacing-320)) minmax(0, 1fr); + gap: var(--spacing-32) var(--spacing-64); +} + +.about, +.facts, +.lines, +.lineSection { + display: flex; + flex-direction: column; + min-inline-size: 0; +} + +.about, +.lineSection, +.facts { + gap: var(--spacing-24); +} + +.lineSection { + gap: var(--spacing-16); +} + +.fact { + display: grid; + gap: var(--spacing-6); + overflow-wrap: anywhere; +} + +.factLink:hover { + text-decoration: underline; +} + +.managed { + display: grid; + gap: var(--spacing-8); + padding-block-start: var(--spacing-16); +} + +.managedBy { + display: flex; + align-items: center; + gap: var(--spacing-8); + color: var(--ui-secondary); +} + +.lines { + gap: var(--spacing-32); +} + +.lineHeading { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-8); +} + +.list { + display: grid; + gap: var(--ui-list-gap); + margin-inline: calc(-1 * var(--ui-row-inset)); +} + +.row { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-12); + padding: var(--spacing-12) var(--ui-row-inset); + border-radius: var(--ui-control-radius); +} + +.row:hover { + background: var(--ui-hover); +} + +@media (width < 48rem) { + .columns { + grid-template-columns: minmax(0, 1fr); + } +} diff --git a/apps/sim/platform/app/(shell)/people/_components/person/person.tsx b/apps/sim/platform/app/(shell)/people/_components/person/person.tsx new file mode 100644 index 00000000000..9789b2ffd2b --- /dev/null +++ b/apps/sim/platform/app/(shell)/people/_components/person/person.tsx @@ -0,0 +1,263 @@ +'use client' + +import { useState } from 'react' +import Link from 'next/link' +import { PeopleStatus } from '@/platform/app/(shell)/people/_components/people-page/people-status' +import { ProfileUnavailable } from '@/platform/app/(shell)/people/_components/people-page/profile-unavailable' +import styles from '@/platform/app/(shell)/people/_components/person/person.module.css' +import { PersonModal } from '@/platform/app/(shell)/people/_components/person/person-modal' +import { PersonIdentity } from '@/platform/app/(shell)/people/_components/person-identity/person-identity' +import { Avatar, Button, FailureNotice, Icon } from '@/platform/design-system/components' +import { useClipboard } from '@/platform/design-system/components/feedback/use-clipboard' +import { + isRefusal, + type Person, + type PersonSummary, + personHref, + SYNC_SOURCE_NAMES, + usePeopleViewer, + usePersonProfile, +} from '@/platform/lib/data/people' +import { formatCalendarDate } from '@/platform/lib/time' + +const NOT_PROVIDED = 'Not provided' + +/** `2024-01-31` as a calendar day, with how long ago it was: "Jan 31, 2024 · 2 yrs". */ +function describeStartDate(startDate: string): string { + const start = new Date(`${startDate}T00:00:00Z`) + const now = new Date() + const months = + (now.getUTCFullYear() - start.getUTCFullYear()) * 12 + + now.getUTCMonth() - + start.getUTCMonth() - + (now.getUTCDate() < start.getUTCDate() ? 1 : 0) + const tenure = + months < 0 + ? 'starts soon' + : months < 1 + ? 'new' + : months < 12 + ? `${months} mo` + : `${Math.floor(months / 12)} yr${months >= 24 ? 's' : ''}` + return `${formatCalendarDate(startDate, 'full')} · ${tenure}` +} + +interface AboutProps { + person: Person + departmentHref: string +} + +function About({ person, departmentHref }: AboutProps) { + const facts: { label: string; value: string; href?: string }[] = [ + { label: 'Email', value: person.email, href: `mailto:${person.email}` }, + { + label: 'Department', + value: person.department ?? NOT_PROVIDED, + href: person.department ? departmentHref : undefined, + }, + { label: 'Location', value: person.location ?? NOT_PROVIDED }, + { + label: 'Start date', + value: person.startDate ? describeStartDate(person.startDate) : NOT_PROVIDED, + }, + ] + return ( +
+

+ About +

+
+ {facts.map(({ label, value, href }) => ( +
+
+ {label} +
+
+ {href ? ( + + {value} + + ) : ( + value + )} +
+
+ ))} +
+ {person.directorySource && ( +
+

+ + Managed by {SYNC_SOURCE_NAMES[person.directorySource]} +

+

+ Profile details update from your directory. Contact an admin to make a correction. +

+
+ )} +
+ ) +} + +interface PersonRowsProps { + organizationId: string + originQuery: string + people: readonly PersonSummary[] +} + +function PersonRows({ organizationId, originQuery, people }: PersonRowsProps) { + return ( +
    + {people.map((person) => ( +
  • + + + + +
  • + ))} +
+ ) +} + +interface PersonProfileProps { + organizationId: string + memberId: string +} + +export function PersonProfile({ organizationId, memberId }: PersonProfileProps) { + const { isAdmin } = usePeopleViewer() + const profile = usePersonProfile(organizationId, memberId) + const { copied, copy } = useClipboard() + const [editing, setEditing] = useState(false) + const { data } = profile + const canEdit = isAdmin && Boolean(data) && !data?.person.directorySource + const unavailable = isRefusal(profile.error) + return ( +
+
+ {!unavailable && ( +
+ +
+ )} + {data && ( + + )} +
+ {profile.error ? ( + unavailable ? ( + }>Back to directory + } + /> + ) : ( + + ) + ) : !data ? ( + Loading profile… + ) : ( + <> +
+
+ +
+

{data.person.name}

+ {data.person.title && ( +

+ {data.person.title} +

+ )} +
+
+
+ + {canEdit && } +
+
+
+ +
+
+

+ Reports to +

+ {data.chain.length > 0 ? ( + + ) : ( +

+ {data.person.managerMemberId + ? 'Their manager is suspended.' + : 'No manager listed. This person is still part of your organization, and their reporting line will appear when it is provided.'} +

+ )} +
+
+
+

+ Direct reports +

+ {data.totalReports > 0 && ( + + {data.totalReports === 1 ? '1 person' : `${data.totalReports} people`} + + )} +
+ {data.reports.length > 0 ? ( + + ) : ( +

+ No one reports to this person. +

+ )} + {data.totalReports > data.reports.length && ( +
+ +
+ )} +
+
+
+ {editing && canEdit && ( + setEditing(false)} + /> + )} + + )} +
+ ) +} diff --git a/apps/sim/platform/design-system/catalog/icon-geometry.tsx b/apps/sim/platform/design-system/catalog/icon-geometry.tsx index e4c671ba2c4..aab76568475 100644 --- a/apps/sim/platform/design-system/catalog/icon-geometry.tsx +++ b/apps/sim/platform/design-system/catalog/icon-geometry.tsx @@ -859,6 +859,21 @@ export const glyphs = { ), }, + I133: { + label: 'users', + viewBox: '0 0 24 24', + draw: (): ReactNode => ( + <> + + + + ), + }, + I134: { + label: 'minus', + viewBox: '0 0 24 24', + draw: (): ReactNode => , + }, } as const export type IconName = keyof typeof glyphs diff --git a/apps/sim/platform/design-system/components/avatar/avatar.module.css b/apps/sim/platform/design-system/components/avatar/avatar.module.css index c6e53805809..18c02db66b3 100644 --- a/apps/sim/platform/design-system/components/avatar/avatar.module.css +++ b/apps/sim/platform/design-system/components/avatar/avatar.module.css @@ -22,6 +22,21 @@ height: var(--ui-icon-size); } +.avatar[data-size="row"] { + width: var(--spacing-36); + height: var(--spacing-36); +} + +.avatar[data-size="card"] { + width: var(--spacing-40); + height: var(--spacing-40); +} + +.avatar[data-size="hero"] { + width: var(--spacing-80); + height: var(--spacing-80); +} + .avatarImage { display: block; width: 100%; diff --git a/apps/sim/platform/design-system/components/avatar/avatar.tsx b/apps/sim/platform/design-system/components/avatar/avatar.tsx index b9eb3094637..8b064f7e938 100644 --- a/apps/sim/platform/design-system/components/avatar/avatar.tsx +++ b/apps/sim/platform/design-system/components/avatar/avatar.tsx @@ -14,11 +14,13 @@ export function AvatarImage({ src, name = '' }: AvatarImageProps) { return {name} } +const AVATAR_TYPE = { inline: 'T021', row: 'T050', card: 'T042', hero: 'T076' } as const + interface AvatarProps { name: string src?: string organization?: boolean - size?: 'inline' + size?: 'inline' | 'row' | 'card' | 'hero' } export function Avatar({ name, src, organization, size }: AvatarProps) { @@ -29,7 +31,7 @@ export function Avatar({ name, src, organization, size }: AvatarProps) { data-size={size} role='img' aria-label={name} - data-type={size ? 'T021' : organization ? 'T034' : 'T050'} + data-type={size ? AVATAR_TYPE[size] : organization ? 'T034' : 'T050'} > {src ? : Array.from(name.trim().toUpperCase())[0]} diff --git a/apps/sim/platform/design-system/components/data-display/data-display.module.css b/apps/sim/platform/design-system/components/data-display/data-display.module.css index 3ccb05689cf..67a58d8f8a6 100644 --- a/apps/sim/platform/design-system/components/data-display/data-display.module.css +++ b/apps/sim/platform/design-system/components/data-display/data-display.module.css @@ -55,6 +55,9 @@ color: var(--ui-secondary); overflow-wrap: anywhere; } +.table [data-primary] { + inline-size: 32%; +} .table [data-numeric="true"] { inline-size: calc(var(--ui-table-compact) + var(--spacing-16)); text-align: end; @@ -105,6 +108,10 @@ padding-inline: 0; text-align: end; } +.table [data-kind="action"][data-passive] { + padding-inline-end: var(--spacing-8); + pointer-events: none; +} .sort { display: inline-flex; align-items: center; diff --git a/apps/sim/platform/design-system/components/data-display/data-display.tsx b/apps/sim/platform/design-system/components/data-display/data-display.tsx index 580c8e617ad..e8e2107e038 100644 --- a/apps/sim/platform/design-system/components/data-display/data-display.tsx +++ b/apps/sim/platform/design-system/components/data-display/data-display.tsx @@ -46,6 +46,8 @@ type TableColumn = { key: string label: string showFrom?: 'compact' | 'wide' + /** The column a row is read by, wider than the columns that share the rest. */ + primary?: boolean /** Offer this column to `onSortChange`. The caller owns the order of `rows`. */ sortable?: boolean } & ( @@ -85,6 +87,8 @@ interface DataTableProps { loadMore?: LoadMoreState /** Window a long list inside a bounded scroller with a sticky header. */ virtualized?: boolean + /** Mark rows that open a destination with a trailing chevron, at every width. */ + rowChevron?: boolean } function columnFormat(column: TableColumn, locale: string) { @@ -122,6 +126,7 @@ interface TableRowViewProps { row: TableRow fields: readonly TableField[] actionColumn: boolean + rowChevron?: boolean index?: number rowRef?: Ref } @@ -130,6 +135,7 @@ const TableRowView = memo(function TableRowView({ row, fields, actionColumn, + rowChevron, index, rowRef, }: TableRowViewProps) { @@ -149,6 +155,7 @@ const TableRowView = memo(function TableRowView({ key={column.key} data-kind={column.kind} data-from={column.showFrom} + data-primary={column.primary || undefined} data-numeric={Boolean(column.format)} data-type={column.kind === 'text' ? 'T059' : 'T060'} > @@ -166,7 +173,11 @@ const TableRowView = memo(function TableRowView({ ) })} - {actionColumn && {row.action}} + {actionColumn && ( + + {row.action ?? (rowChevron && )} + + )} ) }) @@ -194,6 +205,7 @@ function TableHead({ fields, actionColumn, sort, onSortChange }: TableHeadProps) aria-sort={sortable ? ARIA_SORT[direction ?? 'none'] : undefined} data-kind={column.kind} data-from={column.showFrom} + data-primary={column.primary || undefined} data-numeric={Boolean(column.format)} data-type='T050' > @@ -236,6 +248,7 @@ interface VirtualTableProps extends TableHeadProps { title: string rows: readonly TableRow[] loadMore?: LoadMoreState + rowChevron?: boolean } function VirtualTable({ @@ -246,6 +259,7 @@ function VirtualTable({ sort, onSortChange, loadMore, + rowChevron, }: VirtualTableProps) { const viewport = useRef(null) const scrollRef = useScrollEdges('vertical', { viewport, tabStop: true }) @@ -285,6 +299,7 @@ function VirtualTable({ row={rows[item.index]} fields={fields} actionColumn={actionColumn} + rowChevron={rowChevron} index={item.index} rowRef={virtualizer.measureElement} /> @@ -328,9 +343,10 @@ export function DataTable({ onSortChange, loadMore, virtualized, + rowChevron, }: DataTableProps) { const fields = columns.map((column) => ({ ...column, format: columnFormat(column, locale) })) - const actionColumn = rows.some((row) => row.action) + const actionColumn = Boolean(rowChevron) || rows.some((row) => row.action) return (
{(!titleHidden || control) && ( @@ -349,6 +365,7 @@ export function DataTable({ sort={sort} onSortChange={onSortChange} loadMore={loadMore} + rowChevron={rowChevron} /> ) : ( <> @@ -367,6 +384,7 @@ export function DataTable({ row={row} fields={fields} actionColumn={actionColumn} + rowChevron={rowChevron} /> ))} diff --git a/apps/sim/platform/design-system/components/empty-state/empty-state.module.css b/apps/sim/platform/design-system/components/empty-state/empty-state.module.css index 38781a5e3ab..a8a57b15643 100644 --- a/apps/sim/platform/design-system/components/empty-state/empty-state.module.css +++ b/apps/sim/platform/design-system/components/empty-state/empty-state.module.css @@ -1,5 +1,5 @@ .empty, -.empty > div { +.copy { display: flex; flex-direction: column; align-items: flex-start; @@ -18,6 +18,12 @@ gap: var(--spacing-12); } +.actions { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-8); +} + .empty p { color: var(--ui-secondary); } diff --git a/apps/sim/platform/design-system/components/empty-state/empty-state.tsx b/apps/sim/platform/design-system/components/empty-state/empty-state.tsx index 9edf920d8da..049406b9e6e 100644 --- a/apps/sim/platform/design-system/components/empty-state/empty-state.tsx +++ b/apps/sim/platform/design-system/components/empty-state/empty-state.tsx @@ -17,11 +17,11 @@ export function EmptyState({ title, description, action, graphic }: EmptyStatePr {graphic} )} -
+

{title}

{description &&

{description}

}
- {action} + {action &&
{action}
}
) } diff --git a/apps/sim/platform/design-system/components/index.ts b/apps/sim/platform/design-system/components/index.ts index 4f98b66353a..a17e03fb1b1 100644 --- a/apps/sim/platform/design-system/components/index.ts +++ b/apps/sim/platform/design-system/components/index.ts @@ -38,6 +38,7 @@ export { OverflowText } from './overflow-text/overflow-text' export { Pagination } from './pagination/pagination' export { DatePicker, DateRangePicker } from './picker/date-picker' export { MultiSelect } from './picker/multi-select' +export { SearchCombobox } from './picker/search-combobox' export { SearchableSelect } from './picker/searchable-select' export { Select } from './picker/select' export { PropertyList } from './property-list/property-list' diff --git a/apps/sim/platform/design-system/components/picker/picker.module.css b/apps/sim/platform/design-system/components/picker/picker.module.css index 02726aaf6bb..222867310a0 100644 --- a/apps/sim/platform/design-system/components/picker/picker.module.css +++ b/apps/sim/platform/design-system/components/picker/picker.module.css @@ -50,6 +50,94 @@ background: var(--settings-picker-surface); } +.trigger[data-variant="text"] { + inline-size: auto; + min-block-size: var(--spacing-app-control-sm); + padding-inline: var(--spacing-4); + border-color: transparent; + background: transparent; + color: var(--ui-secondary); +} + +.trigger[data-variant="text"]:hover:enabled { + border-color: transparent; + background: var(--ui-hover); +} + +.detachedPopup { + inline-size: min(calc(var(--spacing-320) + var(--spacing-80)), var(--available-width)); +} + +.searchField { + display: flex; + align-items: center; + min-inline-size: 0; + min-block-size: var(--spacing-app-chip); + padding-inline: var(--spacing-7); + gap: var(--spacing-6); + border: var(--spacing-stroke-default) solid var(--ui-border); + border-radius: var(--radius-control); + background: var(--ui-reading); + color: var(--ui-secondary); +} + +.searchField input { + flex: 1; + min-inline-size: 0; + background: transparent; + color: var(--ui-ink); + outline: none; +} + +.searchField input::placeholder { + color: var(--ui-secondary); +} + +.results { + max-block-size: var(--spacing-320); +} + +.result { + align-items: flex-start; +} + +.resultCopy { + display: grid; + flex: 1; + min-inline-size: 0; + gap: var(--ui-stack-gap); + overflow-wrap: anywhere; +} + +.resultDetail, +.hints { + color: var(--ui-secondary); +} + +.resultEnter { + flex: none; + color: var(--ui-secondary); + visibility: hidden; +} + +.result[data-highlighted] .resultEnter { + visibility: visible; +} + +.hints { + display: flex; + justify-content: space-between; + gap: var(--spacing-12); + padding: var(--spacing-8) var(--ui-row-inset) var(--spacing-4); +} + +@media (pointer: coarse) { + .hints, + .resultEnter { + display: none; + } +} + .trigger[data-empty] .value { color: var(--ui-secondary); } diff --git a/apps/sim/platform/design-system/components/picker/search-combobox.tsx b/apps/sim/platform/design-system/components/picker/search-combobox.tsx new file mode 100644 index 00000000000..d9d239e6d2d --- /dev/null +++ b/apps/sim/platform/design-system/components/picker/search-combobox.tsx @@ -0,0 +1,134 @@ +'use client' + +import { type ReactNode, useMemo } from 'react' +import { Combobox } from '@base-ui/react/combobox' +import { Icon } from '@/platform/design-system/components/icon/icon' +import popup from '@/platform/design-system/components/menu/popup.shared.module.css' +import { usePopoverContainer } from '@/platform/design-system/components/menu/use-popover-container' +import picker from '@/platform/design-system/components/picker/picker.module.css' +import type { PickerServerSearch } from '@/platform/design-system/components/picker/search-popup' +import { LoadMoreFooter } from '@/platform/design-system/components/scroll-area/load-more-footer' +import { useScrollEdges } from '@/platform/design-system/components/scroll-area/use-scroll-edges' + +interface SearchComboboxOption { + value: string + label: string + /** Lines under the label that tell results with the same label apart. */ + details?: readonly string[] + leading?: ReactNode +} + +const OPTION_KEYS = { + getValue: (option: SearchComboboxOption) => option.value, + getLabel: (option: SearchComboboxOption) => option.label, +} + +interface SearchComboboxProps { + label: string + placeholder: string + /** Names the results above the list. */ + heading: string + emptyLabel: string + /** What Enter does to the highlighted result, in the keyboard hints. */ + enterHint: string + options: readonly SearchComboboxOption[] + search: PickerServerSearch + onSelect: (value: string) => void + /** Lets a caller read its options only once the viewer asks for them. */ + onOpenChange?: (open: boolean) => void +} + +/** A search field that stays on the page, with its server results in a popup under it. */ +export function SearchCombobox({ + label, + placeholder, + heading, + emptyLabel, + enterHint, + options, + search, + onSelect, + onOpenChange, +}: SearchComboboxProps) { + const { trigger, container, opener } = usePopoverContainer() + const scrollRef = useScrollEdges() + const items = useMemo(() => Combobox.createItems(options, OPTION_KEYS), [options]) + return ( + { + if (details.reason !== 'item-press') search.onQueryChange(query) + }} + onOpenChange={onOpenChange} + onValueChange={(next) => { + if (!next) return + search.onQueryChange('') + onSelect(next) + }} + autoHighlight + modal={false} + > +
+ + +
+ {container && ( + + + + + {heading} + + + {search.loading || search.error ? null : emptyLabel} + + + + {(option: SearchComboboxOption) => ( + + {option.leading} + + {option.label} + {option.details?.map((detail) => ( + + {detail} + + ))} + + + + )} + + + + + + + + )} +
+ ) +} diff --git a/apps/sim/platform/design-system/components/picker/search-popup.tsx b/apps/sim/platform/design-system/components/picker/search-popup.tsx index 9905d884c9f..4c3bf6b2d9e 100644 --- a/apps/sim/platform/design-system/components/picker/search-popup.tsx +++ b/apps/sim/platform/design-system/components/picker/search-popup.tsx @@ -28,11 +28,20 @@ interface SearchPopupProps { searchLabel: string container: HTMLElement search?: PickerServerSearch + /** Size the popup for its rows instead of matching its trigger's width. */ + detached?: boolean children?: ReactNode } /** The floating part of a searchable picker. Must sit inside a `Combobox.Root`. */ -export function SearchPopup({ label, searchLabel, container, search, children }: SearchPopupProps) { +export function SearchPopup({ + label, + searchLabel, + container, + search, + detached, + children, +}: SearchPopupProps) { const input = useRef(null) const queryEdges = useScrollEdges('horizontal', { viewport: input }) const scrollRef = useScrollEdges() @@ -42,7 +51,7 @@ export function SearchPopup({ label, searchLabel, container, search, children }: align='start' positionMethod='fixed' sideOffset={8} - className={popup.anchorWidth} + className={detached ? `${popup.positioner} ${picker.detachedPopup}` : popup.anchorWidth} > @@ -104,6 +108,7 @@ export function SearchableSelect({ searchLabel={searchLabel} container={selection.container} search={search} + detached={variant === 'text'} > {createLabel && ( <> diff --git a/apps/sim/platform/lib/data/people.ts b/apps/sim/platform/lib/data/people.ts new file mode 100644 index 00000000000..16702606328 --- /dev/null +++ b/apps/sim/platform/lib/data/people.ts @@ -0,0 +1,445 @@ +'use client' + +import { useMemo, useState } from 'react' +import { useQueryStates } from 'nuqs' +import { isOrganizationSettingsSectionAvailable } from '@/components/settings/navigation' +import { isApiClientError } from '@/lib/api/client/errors' +import type { + ChartBranch as ContractChartBranch, + PeopleImportResult as ContractImportResult, + Person as ContractPerson, + PersonSummary as ContractPersonSummary, + PeopleImportBody, + PersonBody, +} from '@/lib/api/contracts/people' +import { + type PeopleSyncConfig, + type PeopleSyncStatus, + peopleSyncConfigSchema, +} from '@/lib/api/contracts/people-sync' +import { flattenFieldErrors } from '@/lib/api/contracts/primitives' +import { useDeploymentShape } from '@/lib/core/config/deployment-shape' +import { organizationRoutes } from '@/lib/navigation/paths' +import { readPeopleSheet } from '@/lib/people/read-sheet' +import { + boundedSearch, + peopleParsers, + peopleUrlKeys, + serializePeopleView, +} from '@/lib/people/search-params' +import { memberProfileFieldsSchema } from '@/lib/people/validation' +import { saveBlob } from '@/lib/uploads/client/download' +import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' +import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider' +import { useScimConnection } from '@/ee/scim/hooks/scim' +import { serializeSsoSettings } from '@/ee/sso/search-params' +import { + useDepartments, + useImportPeople, + usePeople, + usePeopleChart, + usePerson, + useUpdatePerson, +} from '@/hooks/queries/people' +import { + isDirectorySyncAccessError, + usePeopleSyncActions, + usePeopleSyncStatus, +} from '@/hooks/queries/people-sync' +import { useDebounce } from '@/hooks/use-debounce' +import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' +import { useIsMobile } from '@/hooks/use-is-mobile' +import { useSearchFilterValue } from '@/hooks/use-search-filter-value' + +export type ChartBranch = ContractChartBranch +export type PeopleImportResult = ContractImportResult +export type Person = ContractPerson +export type PersonSummary = ContractPersonSummary +export type SyncStatus = PeopleSyncStatus +export type SyncSource = PeopleSyncConfig['source'] + +export const SYNC_SOURCE_NAMES: Record = { + okta: 'Okta', + entra: 'Microsoft Entra ID', + scim: 'SCIM provisioning', +} + +/** Who is viewing, and which of the page's administration they may reach. */ +export function usePeopleViewer() { + const { organization, viewer, settingsFeatures } = useOrganizationContext() + const { features, hosted } = useDeploymentShape() + return { + organizationName: organization.name, + isAdmin: viewer.isAdmin, + canProvision: features.scim && (!hosted || settingsFeatures.hasEnterprisePlan), + canOpenProvisioningSettings: isOrganizationSettingsSectionAvailable('sso', settingsFeatures), + provisioningSettingsHref: serializeSsoSettings( + organizationRoutes(organization.id).settingsSection('sso'), + { tab: 'provisioning' } + ), + } +} + +export function personHref(organizationId: string, memberId: string, originQuery = ''): string { + return `${organizationRoutes(organizationId).person(memberId)}${originQuery}` +} + +export function isApiConflict(error: unknown): boolean { + return isApiClientError(error) && error.status === 409 +} + +const REFUSAL_STATUSES = new Set([400, 401, 403, 404]) + +/** A refusal replaces what is on screen; a transient failure, including a 429, keeps it. */ +export function isRefusal(error: unknown): boolean { + return isApiClientError(error) && REFUSAL_STATUSES.has(error.status) +} + +export function usePeopleUrl() { + return useQueryStates(peopleParsers, peopleUrlKeys) +} + +type PeopleUrl = ReturnType[0] +type PeopleUrlOverrides = { [Key in keyof PeopleUrl]?: PeopleUrl[Key] | null } + +function profileOriginQuery(origin: PeopleUrl, overrides: PeopleUrlOverrides): string { + return serializePeopleView({ ...origin, panel: null, ...overrides }) +} + +function peopleViewHref( + organizationId: string, + origin: PeopleUrl, + overrides: PeopleUrlOverrides +): string { + return serializePeopleView(organizationRoutes(organizationId).people, { + ...origin, + panel: null, + ...overrides, + }) +} + +export function usePeopleDirectory(organizationId: string) { + const [origin, setView] = usePeopleUrl() + const { search, department, departmentKey } = origin + const setSearch = useDebouncedSearchSetter((value, options) => + setView({ view: 'directory', search: value }, options) + ) + const debounced = useSearchFilterValue(search, SEARCH_DEBOUNCE_MS) + const query = debounced.trim() + const people = usePeople(organizationId, query, department, { departmentKey }) + const changingFilter = people.isPlaceholderData || query !== search.trim() + const pages = people.isRefetchError || changingFilter ? undefined : people.data?.pages + const rows = useMemo(() => (pages ?? []).flatMap((page) => page.people), [pages]) + const hasDepartment = Boolean(departmentKey || department) + const departmentLabel = departmentKey + ? (!people.isPlaceholderData && people.data?.pages[0]?.selectedDepartment) || + 'Selected department' + : department + return { + origin, + search, + query, + setSearch: (value: string) => setSearch(boundedSearch(value)), + rows, + hasDepartment, + departmentLabel, + originQuery: profileOriginQuery(origin, { view: 'directory' }), + loading: people.isPending || changingFilter, + error: people.isLoadingError || people.isRefetchError ? people.error : null, + retry: () => void people.refetch(), + loadMore: { + hasMore: people.hasNextPage && !changingFilter, + loading: people.isFetchingNextPage, + error: people.isFetchNextPageError ? people.error.message : undefined, + onLoadMore: () => void people.fetchNextPage(), + }, + setDepartment: (next: { name: string; key?: string } | null) => + void setView({ + view: 'directory', + department: next && !next.key ? next.name : null, + departmentKey: next?.key ?? null, + }), + clearFilters: () => + void setView({ view: 'directory', search: '', department: '', departmentKey: '' }), + } +} + +/** The filter's option values: stored text can't hold a NUL, so no department collides with these. */ +const DEPARTMENT_KEY_PREFIX = '\u0000key:' +const ALL_DEPARTMENTS = '\u0000all' + +export function useDepartmentOptions( + organizationId: string, + selected: { department: string; departmentKey: string } +) { + const [open, setOpen] = useState(false) + const [term, setTerm] = useState('') + const debounced = useSearchFilterValue(term, SEARCH_DEBOUNCE_MS).trim() + const departments = useDepartments(organizationId, debounced, open) + const waiting = term.trim() !== debounced + const options = useMemo(() => { + const entries = waiting + ? [] + : (departments.data?.pages.flatMap((page) => page.departments) ?? []) + return [ + { value: ALL_DEPARTMENTS, label: 'All departments' }, + ...entries.map(({ name, count, departmentKey }) => ({ + value: departmentKey ? `${DEPARTMENT_KEY_PREFIX}${departmentKey}` : name, + label: `${name} (${count})`, + })), + ] + }, [waiting, departments.data]) + return { + options, + onOpenChange: setOpen, + value: selected.departmentKey + ? `${DEPARTMENT_KEY_PREFIX}${selected.departmentKey}` + : selected.department || ALL_DEPARTMENTS, + /** Maps an option back to the URL's split between a plain name and an opaque key. */ + parse: (value: string | null): { name: string; key?: string } | null => { + if (!value || value === ALL_DEPARTMENTS) return null + return value.startsWith(DEPARTMENT_KEY_PREFIX) + ? { name: '', key: value.slice(DEPARTMENT_KEY_PREFIX.length) } + : { name: value } + }, + search: { + query: term, + onQueryChange: (query: string) => setTerm(boundedSearch(query)), + hasMore: !waiting && departments.hasNextPage, + loading: waiting || departments.isFetching, + error: waiting ? undefined : departments.error?.message, + onLoadMore: () => void departments.fetchNextPage(), + onRetry: () => void departments.refetch(), + }, + } +} + +/** Paginated directory search for the chart's pickers and for choosing a manager. */ +export function usePeoplePicker( + organizationId: string, + scope?: 'roots' | 'members', + excludeMemberId?: string +) { + const [open, setOpen] = useState(false) + const [term, setTerm] = useState('') + const debounced = useDebounce(term.trim(), SEARCH_DEBOUNCE_MS) + const query = usePeople(organizationId, debounced, '', { scope, enabled: open }) + const waiting = term.trim() !== debounced || query.isPlaceholderData + const pages = waiting || query.isRefetchError ? undefined : query.data?.pages + const people = useMemo( + () => + (pages ?? []).flatMap((page) => + page.people.filter((person) => person.memberId !== excludeMemberId) + ), + [pages, excludeMemberId] + ) + const options = useMemo( + () => + people.map((person) => ({ + value: person.memberId, + label: [person.name, person.title, person.email].filter(Boolean).join(' · '), + })), + [people] + ) + return { + people, + options, + onOpenChange: setOpen, + search: { + query: term, + onQueryChange: (next: string) => setTerm(boundedSearch(next)), + hasMore: query.hasNextPage && !waiting, + loading: waiting || query.isFetching, + error: waiting ? undefined : query.error?.message, + onLoadMore: () => void query.fetchNextPage(), + onRetry: () => void query.refetch(), + }, + } +} + +const CHART_MAX_PAGE = 1_000_000 + +export function useOrgChart(organizationId: string) { + const [origin, setView] = usePeopleUrl() + const { focus, page } = origin + const compact = useIsMobile() + const size = compact ? 1 : 4 + const chart = usePeopleChart( + organizationId, + focus, + Math.max(1, Math.min(page, CHART_MAX_PAGE)), + size + ) + const branch = isRefusal(chart.error) ? undefined : chart.data + return { + setView, + focus, + page, + size, + compact, + branch, + error: chart.error, + retry: () => void chart.refetch(), + profileQuery: profileOriginQuery(origin, { + view: 'chart', + focus: branch?.person.memberId ?? focus, + page: branch?.page ?? page, + }), + } +} + +/** Whether any reporting line exists yet, and the first team to offer someone who has none. */ +export function useChartRoots(organizationId: string, enabled = true) { + const roots = usePeople(organizationId, '', '', { scope: 'roots', enabled }) + const firstTeam = roots.data?.pages[0]?.people[0] + return { + firstTeam, + emptyOrganization: Boolean(roots.data) && !firstTeam, + settled: !roots.isPending, + } +} + +export function usePersonProfile(organizationId: string, memberId: string) { + const [origin] = usePeopleUrl() + const query = usePerson(organizationId, memberId) + const loadError = query.data && !isRefusal(query.error) ? null : query.error + const data = loadError ? undefined : query.data + const originView = origin.view ?? 'directory' + return { + data, + error: loadError, + retrying: query.isFetching, + retry: () => void query.refetch(), + originView, + originQuery: profileOriginQuery(origin, { view: originView }), + chartHref: peopleViewHref(organizationId, origin, { + view: 'chart', + focus: data?.person.memberId ?? memberId, + page: origin.focus === data?.person.memberId ? origin.page : 1, + }), + departmentHref: peopleViewHref(organizationId, origin, { + view: 'directory', + search: '', + department: data?.person.departmentKey ? null : data?.person.department, + departmentKey: data?.person.departmentKey ?? null, + }), + backHref: peopleViewHref(organizationId, origin, { view: originView }), + directoryHref: peopleViewHref(organizationId, origin, { view: 'directory' }), + } +} + +export type PersonDraft = PersonBody + +export function useEditPerson(organizationId: string, memberId: string) { + const update = useUpdatePerson(organizationId) + return { + /** Field problems, or null once the update went through. A failed request rejects. */ + save: async ( + draft: PersonDraft + ): Promise> | null> => { + const parsed = memberProfileFieldsSchema.safeParse(draft) + if (!parsed.success) return flattenFieldErrors(parsed.error) + await update.mutateAsync({ memberId, body: parsed.data }) + return null + }, + } +} + +export type ImportBody = PeopleImportBody + +const IMPORT_TEMPLATE_CSV = + 'email,title,department,location,start_date,manager_email\nada@example.com,Engineer,Platform,Berlin,2024-01-31,grace@example.com\n' + +export function downloadImportTemplate() { + saveBlob(new Blob([IMPORT_TEMPLATE_CSV], { type: 'text/csv' }), 'people-template.csv') +} + +/** The import mutation that keeps provider tokens out of the mutation cache, and its sheet reader. */ +export function usePeopleImport(organizationId: string) { + const importPeople = useImportPeople(organizationId) + return { + submit: importPeople.submit, + pending: importPeople.isPending, + readSheet: readPeopleSheet, + } +} + +export function useProvisioningConnection(organizationId: string, enabled: boolean) { + const connection = useScimConnection(organizationId, enabled) + const directory = connection.data?.connection + return { + loading: connection.isLoading, + failed: connection.isError, + fetching: connection.isFetching, + active: directory?.status === 'active', + ready: connection.isSuccess && directory?.status === 'active', + userCount: directory?.userCount ?? 0, + refresh: () => void connection.refetch(), + } +} + +export interface SyncDraft { + domain: string + token: string + tenantId: string + clientId: string + clientSecret: string +} + +export type SyncFieldErrors = Partial> + +/** + * The connection as the page header reads it. Members never ask: its reads are admin-only. Reads + * only the result, so a poll starting or settling doesn't re-render the page under it. + */ +export function useDirectorySyncStatus(organizationId: string, enabled: boolean) { + const query = usePeopleSyncStatus(organizationId, enabled) + const status = isDirectorySyncAccessError(query.error) ? undefined : query.data + return { loaded: Boolean(status), settled: !query.isPending, sync: status?.sync ?? null } +} + +/** The status with the action state machine the hook keeps out of the cache. */ +export function useDirectorySync(organizationId: string) { + const query = usePeopleSyncStatus(organizationId) + const actions = usePeopleSyncActions(organizationId) + const denied = isDirectorySyncAccessError(query.error) + const status = denied ? undefined : query.data + const connect = ( + source: SyncSource, + draft: SyncDraft, + onConnected: () => void + ): SyncFieldErrors | null => { + const parsed = peopleSyncConfigSchema.safeParse( + source === 'okta' + ? { source: 'okta', domain: draft.domain, token: draft.token } + : source === 'entra' + ? { + source: 'entra', + tenantId: draft.tenantId.trim(), + clientId: draft.clientId.trim(), + clientSecret: draft.clientSecret, + } + : { source: 'scim' } + ) + if (!parsed.success) return flattenFieldErrors(parsed.error) + actions.configure(parsed.data, onConnected) + return null + } + return { + denied, + loading: query.isPending, + loaded: Boolean(status), + loadError: query.isError && !status ? query.error : null, + fetching: query.isFetching, + retry: () => void query.refetch(), + sync: status?.sync ?? null, + pending: actions.pending, + running: actions.action === 'run', + actionError: actions.error, + reset: actions.reset, + run: actions.run, + /** Settles with the request, so a dialog can hold itself open as pending. */ + disconnect: () => new Promise((resolve, reject) => actions.disconnect(resolve, reject)), + connect, + } +} diff --git a/apps/sim/scripts/test-org-redesign-e2e.ts b/apps/sim/scripts/test-org-redesign-e2e.ts index 445ecb7eeb1..0ded3ed4c11 100644 --- a/apps/sim/scripts/test-org-redesign-e2e.ts +++ b/apps/sim/scripts/test-org-redesign-e2e.ts @@ -3227,27 +3227,23 @@ async function peoplePhaseChecks() { }) }) await check( - 'a member reads that an administrator connects the directory, and no name', + 'a member reads that an organization admin connects the directory, and no name', async () => { await withSession(MEMBER, async ({ page }) => { await page.goto(orgUrl('/people')) - await expect( - page - .getByRole('radio', { name: 'Org chart' }) - .or(page.getByRole('button', { name: 'Org chart' })) - .first() - ).toBeVisible() - await expect(page.getByText(/administrator/i).first()).toBeVisible() + await expect(page.getByRole('button', { name: 'Org chart' })).toBeVisible() + await expect(page.getByText(/organization admin/i).first()).toBeVisible() await expect(page.getByRole('button', { name: 'Directory sync' })).toHaveCount(0) - await expect(page.getByRole('button', { name: 'Import people' })).toHaveCount(0) + await expect(page.getByRole('button', { name: 'Import', exact: true })).toHaveCount(0) + await expect(page.getByRole('button', { name: 'Connect Okta' })).toHaveCount(0) }) } ) await check('an admin can sync and import from the empty state', async () => { await withSession(ADMIN, async ({ page }) => { await page.goto(orgUrl('/people')) - await expect(page.getByRole('button', { name: /Directory sync/ }).first()).toBeVisible() - await expect(page.getByRole('button', { name: /Import/ }).first()).toBeVisible() + await expect(page.getByRole('button', { name: 'Connect Okta' })).toBeVisible() + await expect(page.getByRole('button', { name: 'Import profiles' })).toBeVisible() }) }) await check('the directory lists the organization', async () => { diff --git a/apps/sim/scripts/test-people-e2e.ts b/apps/sim/scripts/test-people-e2e.ts index b90de740bf1..1d52aa65a1a 100644 --- a/apps/sim/scripts/test-people-e2e.ts +++ b/apps/sim/scripts/test-people-e2e.ts @@ -566,31 +566,40 @@ async function run() { waitUntil: 'domcontentloaded', timeout: 180_000, }) - const focused = (name: string) => + const trail = page.getByRole('navigation', { name: 'Reporting path' }) + const focused = (name: string) => trail.getByRole('heading', { name, exact: true }) + const profileOf = (name: string) => page.getByRole('link', { name: `View profile for ${name}` }) + const viewTab = (name: string) => + page + .getByRole('navigation', { name: 'People view' }) + .getByRole('button', { name, exact: true }) await expect(focused('Maya Chen')).toBeVisible({ timeout: 120_000 }) await expect(page.getByRole('button', { name: 'Explore Felix Ward' })).toBeVisible() const longTitleLabel = page .getByRole('button', { name: 'Explore Amara Cole' }) .getByText(longTitle, { exact: true }) - await longTitleLabel.hover() - await expect( - page.locator('[data-native-surface-overlay]').filter({ hasText: longTitle }) - ).toBeVisible() + await longTitleLabel.hover({ position: { x: 4, y: 4 } }) + await expect(page.locator('[role=tooltip]').filter({ hasText: longTitle })).toBeVisible() await page.mouse.move(0, 0) + await expect(page.getByRole('button', { name: 'Import', exact: true })).toBeVisible() + await page.getByRole('button', { name: 'Directory sync', exact: true }).click() + await page.getByRole('heading', { name: 'Directory sync', level: 1 }).waitFor() + await page.goBack() + await expect(focused('Maya Chen')).toBeVisible() await page.screenshot({ path: join(screenshotDir, 'desktop.png') }) - const card = focused('Maya Chen') + const card = page.getByRole('button', { name: 'Explore Felix Ward' }) const cardWidth = await card.evaluate((node) => node.getBoundingClientRect().width) await page.getByRole('button', { name: 'Zoom in' }).click() await expect .poll(() => card.evaluate((node) => node.getBoundingClientRect().width)) .toBeGreaterThan(cardWidth) await page.getByRole('button', { name: 'Fit chart' }).click() - const lightCardColor = await card.evaluate((node) => getComputedStyle(node).backgroundColor) + const cardColor = () => + card.evaluate((node) => getComputedStyle(node.parentElement as Element).backgroundColor) + const lightCardColor = await cardColor() await page.emulateMedia({ colorScheme: 'dark' }) - await expect - .poll(() => card.evaluate((node) => getComputedStyle(node).backgroundColor)) - .not.toBe(lightCardColor) + await expect.poll(cardColor).not.toBe(lightCardColor) await page.screenshot({ path: join(screenshotDir, 'desktop-dark.png') }) await page.emulateMedia({ colorScheme: 'light' }) await page.getByRole('button', { name: 'Next reports' }).focus() @@ -623,8 +632,8 @@ async function run() { (text) => navigator.clipboard.writeText(text), `${boundedPaste}${'界'.repeat(4096)}` ) - await page.getByLabel('Find a person').click() - const pickerSearch = page.getByPlaceholder('Search name, title or email…') + const pickerSearch = page.getByRole('combobox', { name: 'Find a person' }) + await pickerSearch.click() await pickerSearch.focus() await pickerSearch.press('ControlOrMeta+v') await expect(pickerSearch).toHaveValue(boundedPaste, { timeout: 5_000 }) @@ -639,8 +648,6 @@ async function run() { try { await pickerSearch.fill(pagedSearch) await expect(page.getByRole('option', { name: /^Directory colleague 001/ })).toBeVisible() - const more = page.getByRole('button', { name: 'Load more', exact: true }) - await expect(more).toBeVisible({ timeout: 5_000 }) await Promise.all([ page.waitForResponse((response) => { const url = new URL(response.url()) @@ -651,14 +658,15 @@ async function run() { response.ok() ) }), - more.click(), + page + .getByRole('listbox', { name: 'Find a person' }) + .evaluate((list) => list.scrollTo(0, list.scrollHeight)), ]) - await more.scrollIntoViewIfNeeded() await page.getByRole('option', { name: /^Directory colleague 100/ }).click() await expect( page.getByText('No reporting lines for Directory colleague 100', { exact: true }) ).toBeVisible() - assert.equal(requestedPages.length, 2, 'One click loads exactly one additional page') + assert.equal(requestedPages.length, 2, 'Reaching the end loads one additional page') assert.equal(requestedPages[0], null) assert(requestedPages[1], 'Page two resumes with a cursor') await page.goBack() @@ -666,17 +674,19 @@ async function run() { } finally { await sql`delete from people_directory_sync where organization_id = ${teamOrg} and source_target = 'picker.okta.com'` } - await page.getByLabel('Find a person').click() - await page.getByPlaceholder('Search name, title or email…').fill(' Engineering ') - await page.getByText(/Ben Ortega · Design Lead/).click() - await focused('Ben Ortega').click() + await pickerSearch.click() + await pickerSearch.fill(' Engineering ') + await page.getByRole('option', { name: /^Ben Ortega.*Design Lead/ }).click() + await expect(focused('Ben Ortega')).toBeVisible() + await expect(pickerSearch).toHaveValue('') + await profileOf('Ben Ortega').click() await page.getByRole('link', { name: 'View in org chart' }).click() await expect(focused('Ben Ortega')).toBeVisible() - await page.getByLabel('Browse top-level teams').click() - await page.getByText(/Maya Chen · VP Engineering/).click() + await page.getByRole('combobox', { name: 'Browse top-level teams' }).click() + await page.getByRole('option', { name: /^Maya Chen · VP Engineering/ }).click() await expect(focused('Maya Chen')).toBeVisible() - await page.getByRole('radio', { name: 'Directory' }).click() - const directorySearch = page.getByRole('textbox', { name: 'Search people...', exact: true }) + await viewTab('Directory').click() + const directorySearch = page.getByRole('searchbox', { name: 'Search people' }) await directorySearch.focus() await directorySearch.press('ControlOrMeta+v') await expect(directorySearch).toHaveValue(boundedPaste) @@ -692,12 +702,12 @@ async function run() { 'People sent an oversized search' ) await directorySearch.fill('Amara') - const profileLink = page.getByRole('link', { name: 'Amara Cole', exact: true }) + const profileLink = page.getByRole('link', { name: /^Amara Cole/ }) await profileLink.focus() await page.keyboard.press('Enter') await page.getByRole('link', { name: 'Open Maya Chen' }).click() const backToResults = page - .getByRole('link', { name: 'People', exact: true }) + .getByRole('link', { name: 'Back to directory', exact: true }) .and(page.locator('a[href*="search=Amara"]')) await backToResults.click() await expect(directorySearch).toHaveValue('Amara') @@ -708,9 +718,9 @@ async function run() { chartOrigin.searchParams.set('search', 'Amara') chartOrigin.searchParams.set('department', 'Engineering') await page.goto(chartOrigin.href) - await focused('Maya Chen').click() + await profileOf('Maya Chen').click() await page - .getByRole('link', { name: 'People', exact: true }) + .getByRole('link', { name: 'Back to org chart', exact: true }) .and(page.locator('a[href*="department=Engineering"]')) .click() await page.waitForURL( @@ -726,8 +736,8 @@ async function run() { await page.waitForURL( (url) => url.searchParams.get('view') === 'directory' && !url.searchParams.get('search') ) - await expect(page.getByRole('radio', { name: 'Directory' })).toBeChecked() - await page.getByRole('radio', { name: 'Org chart' }).click() + await expect(viewTab('Directory')).toHaveAttribute('aria-current', 'page') + await viewTab('Org chart').click() await page.goto(new URL(`${path}?focus=${unlinked.memberId}`, baseUrl).href) await page.getByText('No reporting lines for Rae Dunn').waitFor() await page.getByRole('button', { name: 'Explore a team' }).click() @@ -741,15 +751,20 @@ async function run() { { name: 'better-auth.session_token', value: outsider.cookie, url: baseUrl.origin }, ]) await empty.goto(new URL(`/o/${outsider.orgId}/people`, baseUrl).href) - await empty.getByText('No reporting lines yet', { exact: true }).waitFor() + await empty.getByText('Bring your organization into view', { exact: true }).waitFor() await empty.screenshot({ path: join(screenshotDir, 'empty-chart.png') }) - await empty.getByRole('button', { name: 'Import people', exact: true }).click() - const picker = empty.getByRole('dialog', { name: 'Import people data' }) - await picker.getByRole('button', { name: 'Okta', exact: true }).waitFor() - await expect(picker.getByRole('button', { name: 'SCIM provisioning' })).toHaveCount(0) + await empty.getByRole('button', { name: 'Connect Okta', exact: true }).waitFor() + await expect(empty.getByRole('button', { name: 'Connect SCIM provisioning' })).toHaveCount( + 0 + ) + await empty.getByRole('button', { name: 'Import profiles', exact: true }).click() + const importDialog = empty.getByRole('dialog', { name: 'Import profiles' }) + await importDialog.getByRole('combobox', { name: 'Source' }).click() + await empty.getByRole('option', { name: 'Okta', exact: true }).waitFor() + await expect(empty.getByRole('option', { name: 'SCIM provisioning' })).toHaveCount(0) await empty.screenshot({ path: join(screenshotDir, 'import-sources.png') }) - await picker.getByRole('button', { name: 'Microsoft Entra ID' }).click() - await empty.getByRole('dialog', { name: 'Import from Microsoft Entra ID' }).waitFor() + await empty.getByRole('option', { name: 'Microsoft Entra ID' }).click() + await importDialog.getByLabel('Access token', { exact: true }).waitFor() await empty.close() const mobile = await browser.newPage({ isMobile: true, @@ -781,11 +796,11 @@ async function run() { } await mobile.getByRole('button', { name: 'Explore Amara Cole' }).tap() await mobile.getByRole('button', { name: 'Explore June Evans' }).tap() - const trail = mobile.getByRole('navigation', { name: 'Reporting path' }) - const heading = trail.getByRole('heading', { name: 'June Evans' }) + const mobileTrail = mobile.getByRole('navigation', { name: 'Reporting path' }) + const heading = mobileTrail.getByRole('heading', { name: 'June Evans' }) await heading.scrollIntoViewIfNeeded() await expect(heading).toBeInViewport() - const manager = trail.getByRole('button', { name: 'Maya Chen' }) + const manager = mobileTrail.getByRole('button', { name: 'Maya Chen' }) await manager.scrollIntoViewIfNeeded() await expect(manager).toBeInViewport() await manager.tap() @@ -824,7 +839,7 @@ async function run() { }) await page.clock.install() await page.goto(new URL(`/o/${teamOrg}/people?panel=sync`, baseUrl).href) - await page.getByRole('button', { name: 'Update credentials' }).click() + await page.getByRole('button', { name: 'Update connection' }).click() const token = page .getByRole('dialog', { name: 'Update Okta credentials' }) .getByLabel('API token', { exact: true }) diff --git a/apps/sim/scripts/test-scim-e2e.ts b/apps/sim/scripts/test-scim-e2e.ts index 146887ddb52..e099685ba5c 100644 --- a/apps/sim/scripts/test-scim-e2e.ts +++ b/apps/sim/scripts/test-scim-e2e.ts @@ -421,18 +421,16 @@ async function run() { waitUntil: 'domcontentloaded', timeout: 180_000, }) - await page.getByRole('button', { name: 'Import people', exact: true }).click() - const picker = page.getByRole('dialog', { name: 'Import people data' }) - await picker.getByRole('button', { name: 'SCIM provisioning' }).click() - await page - .getByRole('dialog', { name: 'Import from SCIM provisioning' }) - .getByRole('button', { name: 'Preview import' }) - .click() - const modal = page.getByRole('dialog', { name: 'Review import' }) - await expect(modal.getByText(/1 profile will be updated/)).toBeVisible() + await page.getByRole('button', { name: /^Import( profiles)?$/ }).click() + const importDialog = page.getByRole('dialog', { name: 'Import profiles' }) + await importDialog.getByRole('combobox', { name: 'Source' }).click() + await page.getByRole('option', { name: 'SCIM provisioning' }).click() + await importDialog.getByRole('button', { name: 'Preview import' }).click() + const modal = page.getByRole('dialog', { name: 'Review profile import' }) + await expect(modal.getByText('To update', { exact: true })).toBeVisible() await mkdir(screenshotDir, { recursive: true }) await page.screenshot({ path: join(screenshotDir, 'provisioned-import.png') }) - await modal.getByRole('button', { name: 'Import', exact: true }).click() + await modal.getByRole('button', { name: 'Import 1 profile', exact: true }).click() await expect(modal).toHaveCount(0) } finally { await browser.close() @@ -537,30 +535,33 @@ async function run() { .addCookies([ { name: 'better-auth.session_token', value: primary.cookie, url: baseUrl.origin }, ]) - await page.goto( - new URL(`/o/${primary.orgId}/people?focus=${aliceMemberId}`, baseUrl).href - ) const statusUrl = new URL(syncPath, baseUrl).href await page.route(statusUrl, (route) => route.fulfill({ status: 503, json: { error: 'Unavailable' } }) ) - await page.getByRole('button', { name: 'Directory sync', exact: true }).click() - const modal = page.getByRole('dialog', { name: 'Directory sync', exact: true }) - await modal.getByRole('button', { name: 'Retry connection' }).waitFor() + await page.goto( + new URL(`/o/${primary.orgId}/people?focus=${aliceMemberId}`, baseUrl).href + ) + const openSync = page.getByRole('button', { name: 'Directory sync', exact: true }) + await openSync.click() + await page.getByRole('heading', { name: 'Directory sync', level: 1 }).waitFor() + await page.getByRole('button', { name: 'Retry', exact: true }).waitFor() await page.unroute(statusUrl) - await expect(modal.getByText('Sync failed', { exact: true })).toBeVisible({ + await expect(page.getByText('Sync failed', { exact: true })).toBeVisible({ timeout: 60_000, }) - await expect(modal.getByText(/reporting loop/)).toBeVisible() + await expect(page.getByText(/reporting loop/)).toBeVisible() await page.screenshot({ path: join(screenshotDir, 'sync-failure.png') }) - await modal.getByRole('button', { name: 'Retry sync' }).click() - await expect(modal.getByText('Connected', { exact: true })).toBeVisible() - await expect(modal.getByText('Last successful sync')).toBeVisible() + await page.getByRole('button', { name: 'Retry sync' }).click() + await expect(page.getByText('Connected', { exact: true })).toBeVisible() + await expect(page.getByText('Last successful sync')).toBeVisible() await page.setViewportSize({ width: 390, height: 844 }) await page.screenshot({ path: join(screenshotDir, 'sync-recovered-mobile.png') }) - const dialogBox = await modal.boundingBox() - assert(dialogBox && dialogBox.x >= 0 && dialogBox.x + dialogBox.width <= 391) + assert( + await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth) + ) assert.equal(record((await chart()).manager).email, bobEmail) + await page.getByRole('button', { name: 'Back to people' }).click() await expect(page.getByText('Principal Engineer', { exact: true })).toBeAttached() await patch(token, `/Users/${aliceId}`, [ { op: 'replace', path: 'title', value: 'Staff Platform Engineer' }, @@ -569,20 +570,20 @@ async function run() { await expect(page.getByText('Staff Platform Engineer', { exact: true })).toBeAttached({ timeout: 60_000, }) - await modal.getByRole('button', { name: 'Disconnect', exact: true }).click() + await openSync.click() + await page.getByRole('button', { name: 'Disconnect SCIM provisioning' }).click() await page .getByRole('dialog', { name: 'Disconnect directory' }) .getByRole('button', { name: 'Disconnect', exact: true }) .click() - await expect( - modal.getByRole('button', { name: 'Connect SCIM provisioning' }) - ).toBeVisible() - await modal.getByRole('button', { name: 'Connect SCIM provisioning' }).click() + const connectScim = page.getByRole('button', { name: 'Connect SCIM provisioning' }) + await expect(connectScim).toBeVisible() + await connectScim.click() await page .getByRole('dialog', { name: 'Connect SCIM provisioning' }) .getByRole('button', { name: 'Connect and sync' }) .click() - await expect(modal.getByText('Connected', { exact: true })).toBeVisible() + await expect(page.getByText('Connected', { exact: true })).toBeVisible() } finally { await browser.close() }