Skip to content

Commit 2fc98fb

Browse files
committed
feat(workspaces): add recency-based workspace switching and redirect
1 parent cd7e413 commit 2fc98fb

File tree

14 files changed

+14994
-226
lines changed

14 files changed

+14994
-226
lines changed

apps/sim/app/api/users/me/settings/route.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ const SettingsSchema = z.object({
2828
errorNotificationsEnabled: z.boolean().optional(),
2929
snapToGridSize: z.number().min(0).max(50).optional(),
3030
showActionBar: z.boolean().optional(),
31+
lastActiveWorkspaceId: z.string().optional(),
3132
})
3233

3334
const defaultSettings = {
@@ -41,6 +42,7 @@ const defaultSettings = {
4142
errorNotificationsEnabled: true,
4243
snapToGridSize: 0,
4344
showActionBar: true,
45+
lastActiveWorkspaceId: null,
4446
}
4547

4648
export async function GET() {
@@ -76,6 +78,7 @@ export async function GET() {
7678
errorNotificationsEnabled: userSettings.errorNotificationsEnabled ?? true,
7779
snapToGridSize: userSettings.snapToGridSize ?? 0,
7880
showActionBar: userSettings.showActionBar ?? true,
81+
lastActiveWorkspaceId: userSettings.lastActiveWorkspaceId ?? null,
7982
},
8083
},
8184
{ status: 200 }

apps/sim/app/api/workspaces/[id]/route.ts

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -267,17 +267,34 @@ export async function DELETE(
267267
}
268268

269269
try {
270+
const [[workspaceRecord], totalWorkspaces] = await Promise.all([
271+
db
272+
.select({ name: workspace.name })
273+
.from(workspace)
274+
.where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt)))
275+
.limit(1),
276+
db
277+
.select({ id: permissions.entityId })
278+
.from(permissions)
279+
.innerJoin(workspace, eq(permissions.entityId, workspace.id))
280+
.where(
281+
and(
282+
eq(permissions.userId, session.user.id),
283+
eq(permissions.entityType, 'workspace'),
284+
isNull(workspace.archivedAt)
285+
)
286+
),
287+
])
288+
289+
/** Counts all workspace memberships (any role), not just admin — prevents the user from reaching a zero-workspace state. */
290+
if (totalWorkspaces.length <= 1) {
291+
return NextResponse.json({ error: 'Cannot delete the only workspace' }, { status: 400 })
292+
}
293+
270294
logger.info(
271295
`Deleting workspace ${workspaceId} for user ${session.user.id}, deleteTemplates: ${deleteTemplates}`
272296
)
273297

274-
// Fetch workspace name before deletion for audit logging
275-
const [workspaceRecord] = await db
276-
.select({ name: workspace.name })
277-
.from(workspace)
278-
.where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt)))
279-
.limit(1)
280-
281298
const workspaceWorkflows = await db
282299
.select({ id: workflow.id })
283300
.from(workflow)

apps/sim/app/api/workspaces/route.ts

Lines changed: 41 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { db } from '@sim/db'
2-
import { permissions, workflow, workspace } from '@sim/db/schema'
2+
import { permissions, settings, workflow, workspace } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
44
import { and, desc, eq, isNull, sql } from 'drizzle-orm'
55
import { NextResponse } from 'next/server'
@@ -38,36 +38,47 @@ export async function GET(request: Request) {
3838
return NextResponse.json({ error: 'Invalid scope' }, { status: 400 })
3939
}
4040

41-
const userWorkspaces = await db
42-
.select({
43-
workspace: workspace,
44-
permissionType: permissions.permissionType,
45-
})
46-
.from(permissions)
47-
.innerJoin(workspace, eq(permissions.entityId, workspace.id))
48-
.where(
49-
scope === 'all'
50-
? and(eq(permissions.userId, session.user.id), eq(permissions.entityType, 'workspace'))
51-
: scope === 'archived'
52-
? and(
53-
eq(permissions.userId, session.user.id),
54-
eq(permissions.entityType, 'workspace'),
55-
sql`${workspace.archivedAt} IS NOT NULL`
56-
)
57-
: and(
58-
eq(permissions.userId, session.user.id),
59-
eq(permissions.entityType, 'workspace'),
60-
isNull(workspace.archivedAt)
61-
)
62-
)
63-
.orderBy(desc(workspace.createdAt))
41+
const settingsQuery = db
42+
.select({ lastActiveWorkspaceId: settings.lastActiveWorkspaceId })
43+
.from(settings)
44+
.where(eq(settings.userId, session.user.id))
45+
.limit(1)
46+
47+
const [userWorkspaces, userSettings] = await Promise.all([
48+
db
49+
.select({
50+
workspace: workspace,
51+
permissionType: permissions.permissionType,
52+
})
53+
.from(permissions)
54+
.innerJoin(workspace, eq(permissions.entityId, workspace.id))
55+
.where(
56+
scope === 'all'
57+
? and(eq(permissions.userId, session.user.id), eq(permissions.entityType, 'workspace'))
58+
: scope === 'archived'
59+
? and(
60+
eq(permissions.userId, session.user.id),
61+
eq(permissions.entityType, 'workspace'),
62+
sql`${workspace.archivedAt} IS NOT NULL`
63+
)
64+
: and(
65+
eq(permissions.userId, session.user.id),
66+
eq(permissions.entityType, 'workspace'),
67+
isNull(workspace.archivedAt)
68+
)
69+
)
70+
.orderBy(desc(workspace.createdAt)),
71+
settingsQuery,
72+
])
73+
74+
const lastActiveWorkspaceId = userSettings[0]?.lastActiveWorkspaceId ?? null
6475

6576
if (scope === 'active' && userWorkspaces.length === 0) {
6677
const defaultWorkspace = await createDefaultWorkspace(session.user.id, session.user.name)
6778

6879
await migrateExistingWorkflows(session.user.id, defaultWorkspace.id)
6980

70-
return NextResponse.json({ workspaces: [defaultWorkspace] })
81+
return NextResponse.json({ workspaces: [defaultWorkspace], lastActiveWorkspaceId })
7182
}
7283

7384
if (scope === 'active') {
@@ -77,12 +88,15 @@ export async function GET(request: Request) {
7788
const workspacesWithPermissions = userWorkspaces.map(
7889
({ workspace: workspaceDetails, permissionType }) => ({
7990
...workspaceDetails,
80-
role: permissionType === 'admin' ? 'owner' : 'member', // Map admin to owner for compatibility
91+
role: permissionType === 'admin' ? 'owner' : 'member',
8192
permissions: permissionType,
8293
})
8394
)
8495

85-
return NextResponse.json({ workspaces: workspacesWithPermissions })
96+
return NextResponse.json({
97+
workspaces: workspacesWithPermissions,
98+
lastActiveWorkspaceId,
99+
})
86100
}
87101

88102
// POST /api/workspaces - Create a new workspace

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx

Lines changed: 17 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -28,20 +28,12 @@ import { DeleteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/
2828
import { CreateWorkspaceModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/create-workspace-modal/create-workspace-modal'
2929
import { InviteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal'
3030
import { useSubscriptionData } from '@/hooks/queries/subscription'
31+
import type { Workspace } from '@/hooks/queries/workspace'
3132
import { usePermissionConfig } from '@/hooks/use-permission-config'
3233
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
3334

3435
const logger = createLogger('WorkspaceHeader')
3536

36-
interface Workspace {
37-
id: string
38-
name: string
39-
color?: string
40-
ownerId: string
41-
role?: string
42-
permissions?: 'admin' | 'write' | 'read' | null
43-
}
44-
4537
interface WorkspaceHeaderProps {
4638
/** The active workspace object */
4739
activeWorkspace?: { name: string } | null
@@ -65,6 +57,8 @@ interface WorkspaceHeaderProps {
6557
onRenameWorkspace: (workspaceId: string, newName: string) => Promise<void>
6658
/** Callback to delete the workspace */
6759
onDeleteWorkspace: (workspaceId: string) => Promise<void>
60+
/** Whether workspace deletion is in progress */
61+
isDeletingWorkspace: boolean
6862
/** Callback to duplicate the workspace */
6963
onDuplicateWorkspace: (workspaceId: string, workspaceName: string) => Promise<void>
7064
/** Callback to export the workspace */
@@ -77,6 +71,8 @@ interface WorkspaceHeaderProps {
7771
onColorChange?: (workspaceId: string, color: string) => Promise<void>
7872
/** Callback to leave the workspace */
7973
onLeaveWorkspace?: (workspaceId: string) => Promise<void>
74+
/** Whether workspace leave is in progress */
75+
isLeavingWorkspace: boolean
8076
/** Current user's session ID for owner check */
8177
sessionUserId?: string
8278
/** Whether the sidebar is collapsed */
@@ -98,22 +94,22 @@ export function WorkspaceHeader({
9894
onCreateWorkspace,
9995
onRenameWorkspace,
10096
onDeleteWorkspace,
97+
isDeletingWorkspace,
10198
onDuplicateWorkspace,
10299
onExportWorkspace,
103100
onImportWorkspace,
104101
isImportingWorkspace,
105102
onColorChange,
106103
onLeaveWorkspace,
104+
isLeavingWorkspace,
107105
sessionUserId,
108106
isCollapsed = false,
109107
}: WorkspaceHeaderProps) {
110108
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)
111109
const [isInviteModalOpen, setIsInviteModalOpen] = useState(false)
112110
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
113-
const [isDeleting, setIsDeleting] = useState(false)
114111
const [deleteTarget, setDeleteTarget] = useState<Workspace | null>(null)
115112
const [isLeaveModalOpen, setIsLeaveModalOpen] = useState(false)
116-
const [isLeaving, setIsLeaving] = useState(false)
117113
const [leaveTarget, setLeaveTarget] = useState<Workspace | null>(null)
118114
const [editingWorkspaceId, setEditingWorkspaceId] = useState<string | null>(null)
119115
const [editingName, setEditingName] = useState('')
@@ -296,32 +292,26 @@ export function WorkspaceHeader({
296292
const handleLeaveWorkspace = async () => {
297293
if (!leaveTarget || !onLeaveWorkspace) return
298294

299-
setIsLeaving(true)
300295
try {
301296
await onLeaveWorkspace(leaveTarget.id)
302297
setIsLeaveModalOpen(false)
303298
setLeaveTarget(null)
304299
} catch (error) {
305300
logger.error('Error leaving workspace:', error)
306-
} finally {
307-
setIsLeaving(false)
308301
}
309302
}
310303

311304
/**
312-
* Handle delete workspace
305+
* Handle delete workspace after confirmation
313306
*/
314307
const handleDeleteWorkspace = async () => {
315-
setIsDeleting(true)
316308
try {
317309
const targetId = deleteTarget?.id || workspaceId
318310
await onDeleteWorkspace(targetId)
319311
setIsDeleteModalOpen(false)
320312
setDeleteTarget(null)
321313
} catch (error) {
322314
logger.error('Error deleting workspace:', error)
323-
} finally {
324-
setIsDeleting(false)
325315
}
326316
}
327317

@@ -638,7 +628,7 @@ export function WorkspaceHeader({
638628
disableRename={!contextCanAdmin}
639629
disableDuplicate={!contextCanEdit}
640630
disableExport={!contextCanAdmin}
641-
disableDelete={!contextCanAdmin}
631+
disableDelete={!contextCanAdmin || workspaces.length <= 1}
642632
disableColorChange={!contextCanAdmin}
643633
/>
644634
)
@@ -666,7 +656,7 @@ export function WorkspaceHeader({
666656
isOpen={isDeleteModalOpen}
667657
onClose={() => setIsDeleteModalOpen(false)}
668658
onConfirm={handleDeleteWorkspace}
669-
isDeleting={isDeleting}
659+
isDeleting={isDeletingWorkspace}
670660
itemType='workspace'
671661
itemName={deleteTarget?.name || activeWorkspaceFull?.name || activeWorkspace?.name}
672662
/>
@@ -686,12 +676,16 @@ export function WorkspaceHeader({
686676
<Button
687677
variant='default'
688678
onClick={() => setIsLeaveModalOpen(false)}
689-
disabled={isLeaving}
679+
disabled={isLeavingWorkspace}
690680
>
691681
Cancel
692682
</Button>
693-
<Button variant='destructive' onClick={handleLeaveWorkspace} disabled={isLeaving}>
694-
{isLeaving ? 'Leaving...' : 'Leave Workspace'}
683+
<Button
684+
variant='destructive'
685+
onClick={handleLeaveWorkspace}
686+
disabled={isLeavingWorkspace}
687+
>
688+
{isLeavingWorkspace ? 'Leaving...' : 'Leave Workspace'}
695689
</Button>
696690
</ModalFooter>
697691
</ModalContent>

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,4 @@ export { useSidebarResize } from './use-sidebar-resize'
1717
export { useTaskSelection } from './use-task-selection'
1818
export { useWorkflowOperations } from './use-workflow-operations'
1919
export { useWorkflowSelection } from './use-workflow-selection'
20-
export { useWorkspaceManagement, type Workspace } from './use-workspace-management'
20+
export { useWorkspaceManagement } from './use-workspace-management'

0 commit comments

Comments
 (0)