Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 4 additions & 8 deletions apps/sim/app/api/invitations/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,19 +63,16 @@ export const GET = withRouteHandler(
}

/**
* Disclosure-only: a preview failure must never block viewing or
* accepting the invitation itself — but it also must not read as
* "nothing moves", so failures are flagged for the client to show a
* generic migration notice. Expired-but-still-pending rows get no
* preview — acceptance deterministically rejects them.
* Supplies the disclosure token acceptance is checked against, so a preview
* failure must never block viewing or accepting the invitation itself — the
* accept path simply runs without the guard. Expired-but-still-pending rows
* get no preview; acceptance deterministically rejects them.
*/
let joinPreview = null
let joinPreviewUnavailable = false
if (isInvitee && inv.status === 'pending' && !isInvitationExpired(inv)) {
try {
joinPreview = await getInvitationJoinPreview(session.user.id, inv)
} catch (previewError) {
joinPreviewUnavailable = true
logger.warn('Failed to compute invitation join preview', {
invitationId: id,
error: previewError,
Expand All @@ -85,7 +82,6 @@ export const GET = withRouteHandler(

return NextResponse.json({
joinPreview,
joinPreviewUnavailable,
invitation: {
id: inv.id,
kind: inv.kind,
Expand Down
12 changes: 10 additions & 2 deletions apps/sim/app/api/pinned-items/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { db, pinnedItem } from '@sim/db'
import { createLogger } from '@sim/logger'
import { getPostgresErrorCode } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { and, eq } from 'drizzle-orm'
import { and, eq, ne } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import {
createPinnedItemContract,
Expand Down Expand Up @@ -59,7 +59,15 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
and(
eq(pinnedItem.userId, session.user.id),
eq(pinnedItem.workspaceId, workspaceId),
resourceType ? eq(pinnedItem.resourceType, resourceType) : undefined
/**
* A `workspace` pin stores `workspaceId === resourceId`, so it would otherwise
* appear in this workspace's unscoped listing as a resource *inside* itself.
* It is read from the workspace-list payload instead, so it is excluded here
* rather than left for a future unscoped caller to mistake for a real resource.
*/
resourceType
? eq(pinnedItem.resourceType, resourceType)
: ne(pinnedItem.resourceType, 'workspace')
)
)

Expand Down
10 changes: 8 additions & 2 deletions apps/sim/app/api/workspaces/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,16 @@ export const GET = withRouteHandler(async (request: Request) => {
activeOrganizationId,
scope,
})
const { lastActiveWorkspaceId, creationPolicy } = payload
const { lastActiveWorkspaceId, pinnedWorkspaceIds, creationPolicy } = payload

if (scope === 'active' && payload.workspaces.length === 0) {
if (!creationPolicy.canCreate) {
return NextResponse.json({ workspaces: [], lastActiveWorkspaceId, creationPolicy })
return NextResponse.json({
workspaces: [],
lastActiveWorkspaceId,
pinnedWorkspaceIds,
creationPolicy,
})
}

let defaultWorkspace: Awaited<ReturnType<typeof createDefaultWorkspace>>
Expand Down Expand Up @@ -100,6 +105,7 @@ export const GET = withRouteHandler(async (request: Request) => {
return NextResponse.json({
workspaces: [defaultWorkspace],
lastActiveWorkspaceId,
pinnedWorkspaceIds,
creationPolicy: refreshedCreationPolicy,
})
}
Expand Down
52 changes: 4 additions & 48 deletions apps/sim/app/invite/[id]/invite.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

import { useEffect, useState } from 'react'
import { createLogger } from '@sim/logger'
import { isOrgAdminRole } from '@sim/platform-authz/workspace'
import { getErrorMessage } from '@sim/utils/errors'
import { formatQuotedNameList } from '@sim/utils/string'
import { useQueryClient } from '@tanstack/react-query'
Expand All @@ -11,11 +10,6 @@ import { ApiClientError } from '@/lib/api/client/errors'
import { requestJson } from '@/lib/api/client/request'
import { acceptInvitationContract } from '@/lib/api/contracts/invitations'
import { client, useSession } from '@/lib/auth/auth-client'
import {
buildMembershipNotice,
buildWorkspaceMigrationNotice,
MAX_LISTED_WORKSPACE_NAMES,
} from '@/lib/invitations/disclosure-copy'
import { InviteLayout, InviteStatusCard } from '@/app/invite/components'
import { useInvitationDetails } from '@/hooks/queries/invitations'
import { organizationKeys } from '@/hooks/queries/organization'
Expand All @@ -25,6 +19,9 @@ import { workspaceKeys } from '@/hooks/queries/workspace'

const logger = createLogger('InviteById')

/** Workspace names listed in the invitation title before collapsing into an "and N more" tail. */
const MAX_LISTED_WORKSPACE_NAMES = 3

function runBestEffortCacheRefresh(cache: string, refresh: () => Promise<unknown>): void {
void Promise.resolve()
.then(refresh)
Expand Down Expand Up @@ -233,7 +230,6 @@ export default function Invite() {
})
const invitation = invitationQuery.data?.invitation ?? null
const joinPreview = invitationQuery.data?.joinPreview ?? null
const joinPreviewUnavailable = invitationQuery.data?.joinPreviewUnavailable === true
const isLoading = Boolean(session?.user) && invitationQuery.isPending

const fetchError = invitationQuery.error
Expand Down Expand Up @@ -491,53 +487,13 @@ export default function Invite() {
}

const isOrg = invitation?.kind === 'organization'
/**
* Prefer the preview's organization (the one acceptance will really join)
* over the invitation's stamped name — a granted workspace may have moved
* organizations since the invite was sent.
*/
const organizationLabel =
joinPreview?.organizationName || invitation?.organizationName || 'the organization'
/**
* When the server could not compute the preview, fall back to a generic
* migration notice for membership invites — a missing preview must never
* read as "nothing moves".
*/
const migrationNotice = buildWorkspaceMigrationNotice({
joinPreview,
joinPreviewUnavailable,
membershipIntent: invitation?.membershipIntent,
organizationLabel,
})
/**
* Only disclosed when the invitation actually carries organization standing —
* a personal-workspace invite has no seat or membership to explain.
*/
const membershipNotice = buildMembershipNotice({
joinPreview,
membershipIntent: invitation?.membershipIntent,
isOrganizationAdminRole: Boolean(invitation?.role && isOrgAdminRole(invitation.role)),
organizationLabel,
/**
* A personal-workspace invite has no organization id and no organization
* name yet — acceptance creates one by converting the billed owner's Pro to
* Team — so a `will-join` outcome is the authoritative signal that a
* membership and seat are involved. Gating on the ids alone silenced the
* disclosure for exactly the case that creates the membership.
*/
isOrganizationScoped: Boolean(
invitation?.organizationId ||
joinPreview?.organizationName ||
joinPreview?.outcome === 'will-join'
),
})

return (
<InviteLayout>
<InviteStatusCard
type='invitation'
title={isOrg ? 'Organization Invitation' : 'Workspace Invitation'}
description={`You've been invited to join ${displayName}.${membershipNotice}${migrationNotice}`}
description={`You've been invited to join ${displayName}.`}
icon={isOrg ? 'users' : 'mail'}
actions={[
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,9 @@

import { Chip, ChipModal, ChipModalBody, ChipModalFooter, ChipModalHeader, toast } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { isOrgAdminRole } from '@sim/platform-authz/workspace'
import { getErrorMessage } from '@sim/utils/errors'
import { useRouter } from 'next/navigation'
import type { MyInvitation } from '@/lib/api/contracts/invitations'
import {
buildMembershipNotice,
buildWorkspaceMigrationNotice,
} from '@/lib/invitations/disclosure-copy'
import { getInvitationErrorMessage } from '@/lib/invitations/error-messages'
import {
useAcceptMyInvitation,
Expand Down Expand Up @@ -43,37 +38,6 @@ function invitationSubLabel(inv: MyInvitation): string {
return detail ? `${invitedBy} · ${detail}` : invitedBy
}

/**
* What accepting will do to the invitee's own account: the seat/membership
* consequence and any workspaces that will move into the organization. Built
* from the same copy as the emailed `/invite` page so the two accept surfaces
* can never disclose different outcomes.
*/
function invitationDisclosure(inv: MyInvitation): string {
const organizationLabel =
inv.joinPreview?.organizationName ?? inv.organizationName ?? 'the organization'
const membership = buildMembershipNotice({
joinPreview: inv.joinPreview,
membershipIntent: inv.membershipIntent,
isOrganizationAdminRole: isOrgAdminRole(inv.role),
organizationLabel,
/** A `will-join` outcome also covers a personal-workspace invite, which has
* no organization id or name until acceptance creates one. */
isOrganizationScoped: Boolean(
inv.organizationId ||
inv.joinPreview?.organizationName ||
inv.joinPreview?.outcome === 'will-join'
),
})
const migration = buildWorkspaceMigrationNotice({
joinPreview: inv.joinPreview,
joinPreviewUnavailable: inv.joinPreview === null,
membershipIntent: inv.membershipIntent,
organizationLabel,
})
return `${membership}${migration}`.trim()
}

interface ViewInvitationsModalProps {
open: boolean
onOpenChange: (open: boolean) => void
Expand Down Expand Up @@ -133,38 +97,32 @@ export function ViewInvitationsModal({ open, onOpenChange }: ViewInvitationsModa
{!invitations || invitations.length === 0 ? (
<p className='px-2 text-[var(--text-muted)] text-sm'>No pending invitations.</p>
) : (
invitations.map((inv) => {
const disclosure = invitationDisclosure(inv)
return (
<div key={inv.id} className='flex items-start gap-2 px-2'>
<div className='min-w-0 flex-1'>
<p className='truncate text-[var(--text-body)] text-sm'>{invitationLabel(inv)}</p>
<p className='truncate text-[var(--text-muted)] text-caption'>
{invitationSubLabel(inv)}
</p>
{disclosure ? (
<p className='mt-1 text-[var(--text-muted)] text-caption'>{disclosure}</p>
) : null}
</div>
<Chip
variant='primary'
disabled={isBusy}
onClick={() => void handleAccept(inv)}
className='flex-shrink-0'
>
Accept
</Chip>
<Chip
disabled={isBusy}
onClick={() => void handleDecline(inv)}
aria-label={`Decline invitation to ${invitationLabel(inv)}`}
className='flex-shrink-0'
>
Decline
</Chip>
invitations.map((inv) => (
<div key={inv.id} className='flex items-center gap-2 px-2'>
<div className='min-w-0 flex-1'>
<p className='truncate text-[var(--text-body)] text-sm'>{invitationLabel(inv)}</p>
<p className='truncate text-[var(--text-muted)] text-caption'>
{invitationSubLabel(inv)}
</p>
</div>
)
})
<Chip
variant='primary'
disabled={isBusy}
onClick={() => void handleAccept(inv)}
className='flex-shrink-0'
>
Accept
</Chip>
<Chip
disabled={isBusy}
onClick={() => void handleDecline(inv)}
aria-label={`Decline invitation to ${invitationLabel(inv)}`}
className='flex-shrink-0'
>
Decline
</Chip>
</div>
))
)}
</ChipModalBody>
<ChipModalFooter
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,17 +61,22 @@ import { WorkspaceHeader } from '@/app/workspace/[workspaceId]/w/components/side
const ACTIVE_BG = 'bg-[var(--surface-active)]'

/**
* Above `WORKSPACE_SEARCH_THRESHOLD` (3), so the searchable/keyboard list renders.
* At `WORKSPACE_SEARCH_THRESHOLD` (6), so the searchable/keyboard list renders.
* The current workspace is deliberately NOT first: the highlight is seeded to row 0 on
* open, so a current workspace sitting at row 0 would mask the double-mark this guards.
*/
const WORKSPACES = [
{ id: 'ws-rvt', name: 'RVT' },
{ id: 'ws-emir', name: "Emir's Workspace" },
{ id: 'ws-acme', name: 'Acme' },
{ id: 'ws-initech', name: 'Initech' },
{ id: 'ws-umbrella', name: 'Umbrella' },
{ id: 'ws-globex', name: 'Globex' },
] as unknown as Parameters<typeof WorkspaceHeader>[0]['workspaces']

/** Pinning reorders the list; these assertions are about the highlight, not the order. */
const NO_PINS: ReadonlySet<string> = new Set()

let container: HTMLDivElement
let root: Root

Expand All @@ -86,6 +91,8 @@ function render() {
activeWorkspace={{ name: "Emir's Workspace" }}
workspaceId='ws-emir'
workspaces={WORKSPACES}
pinnedWorkspaceIds={NO_PINS}
onToggleWorkspacePin={() => {}}
isWorkspacesLoading={false}
isCreatingWorkspace={false}
isWorkspaceMenuOpen
Expand Down
Loading
Loading