fix(forks): show workspace settings link in sidebar for fork creators (#10216)

* fix(forks): show workspace settings link in sidebar for fork creators

PR #10166 grants a fork's creator developer-management access to the
workspace settings page, but every sidebar entry point to
/workspace_settings stayed gated on is_admin || superadmin, so a
non-admin fork creator could only reach the page by typing the URL.

Widen the four sidebar guards (SettingsMenu, SidebarContent,
WorkspaceMenu, WorkspaceScopeHeader) to also admit fork owners, via a
shared isForkOwner() helper mirroring the fork-owner check already on
the settings page and the backend authorize_fork_owner_add_user grant.

Fixes WIN-2210

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(forks): check fork ownership against the settings entry's target workspace

In session mode SettingsMenu's entry targets workspaceSettingsTarget,
which can differ from the active workspace, but the fork-owner guard was
checked against the active workspace — hiding the entry when the session
targets the user's fork, or exposing a dead link when only the active
workspace is theirs. Check ownership against the workspace the entry
actually points at.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-07-20 20:39:48 +02:00
committed by GitHub
parent 87be041c09
commit 11bb37d7ca
5 changed files with 50 additions and 11 deletions
@@ -29,7 +29,7 @@
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import ConfirmationModal from '../common/confirmationModal/ConfirmationModal.svelte'
import DeleteForkedWorkspaceModal from './DeleteForkedWorkspaceModal.svelte'
import { workspaceIsFork } from '$lib/utils/workspaceHierarchy'
import { workspaceIsFork, isForkOwner } from '$lib/utils/workspaceHierarchy'
import DarkModeObserver from '../DarkModeObserver.svelte'
import DiscordIcon from '../icons/brands/Discord.svelte'
import MenuLink from './MenuLink.svelte'
@@ -69,9 +69,6 @@
} = $props()
const currentWs = $derived($userWorkspaces?.find((w) => w.id === $workspaceStore))
const canManageWorkspace = $derived($userStore?.is_admin || $superadmin)
// Fork/dev workspaces are detected by their parent link, not the `wm-fork-` id prefix.
const currentWsIsFork = $derived(workspaceIsFork($workspaceStore, $userWorkspaces ?? []))
const settingsTargetWs = $derived(
workspaceSettingsTarget
@@ -79,6 +76,17 @@
: undefined
)
// The fork-owner grant must be checked against the workspace the settings entry
// actually points at — the session target when in session mode, else the active
// workspace — otherwise ownership of the wrong workspace could hide the entry or
// expose a dead link to a fork the user doesn't own.
const settingsWs = $derived(workspaceSettingsTarget ? settingsTargetWs : currentWs)
const canManageWorkspace = $derived(
$userStore?.is_admin || $superadmin || isForkOwner(settingsWs, $userStore?.email)
)
// Fork/dev workspaces are detected by their parent link, not the `wm-fork-` id prefix.
const currentWsIsFork = $derived(workspaceIsFork($workspaceStore, $userWorkspaces ?? []))
let leaveWorkspaceModal = $state(false)
let deleteForkModal = $state<DeleteForkedWorkspaceModal>()
@@ -4,6 +4,7 @@
superadmin,
usedTriggerKinds,
userStore,
userWorkspaces,
workspaceStore,
isCriticalAlertsUIOpen,
enterpriseLicense,
@@ -11,6 +12,7 @@
tutorialsToDo,
skippedAll
} from '$lib/stores'
import { isForkOwner } from '$lib/utils/workspaceHierarchy'
import { syncTutorialsTodos } from '$lib/tutorialUtils'
import { SIDEBAR_SHOW_SCHEDULES } from '$lib/consts'
import {
@@ -400,6 +402,12 @@
return !page.url.pathname.includes(link.href) && !$usedTriggerKinds.includes(link.kind)
})
)
// Admins, superadmins, and fork creators reach workspace settings (the latter
// for the fork members screen; see isForkOwner / backend authorize_fork_owner_add_user).
const currentWs = $derived($userWorkspaces?.find((w) => w.id === $workspaceStore))
const canManageWorkspace = $derived(
$userStore?.is_admin || $superadmin || isForkOwner(currentWs, $userStore?.email)
)
let secondaryMenuLinks = $derived([
// {
// label: 'Workspace',
@@ -422,7 +430,7 @@
aiDescription: 'Button to navigate to account settings',
faIcon: undefined
},
...($userStore?.is_admin || $superadmin
...(canManageWorkspace
? [
{
label: 'Workspace',
@@ -30,7 +30,7 @@
import { workspaceAIClients } from '../copilot/lib'
import { twMerge } from 'tailwind-merge'
import type { MenubarBuilders } from '@melt-ui/svelte'
import { buildWorkspaceHierarchy } from '$lib/utils/workspaceHierarchy'
import { buildWorkspaceHierarchy, isForkOwner } from '$lib/utils/workspaceHierarchy'
import { canCreateFork } from '$lib/utils/editInFork'
import { getContrastTextColor } from '$lib/utils'
import { workspaceRootId } from '$lib/components/sessions/sessionScope.svelte'
@@ -167,7 +167,9 @@
// The active workspace itself (fork included) — names the settings entry.
const activeWorkspace = $derived($userWorkspaces?.find((w) => w.id === $workspaceStore))
const canManageWorkspace = $derived($userStore?.is_admin || $superadmin)
const canManageWorkspace = $derived(
$userStore?.is_admin || $superadmin || isForkOwner(activeWorkspace, $userStore?.email)
)
// font-normal is explicit: href-less MenuItems render as <button>, which the
// global stylesheet makes semibold, unlike the <a> the href entries get.
@@ -12,7 +12,11 @@
import { workspaceAIClients } from '$lib/components/copilot/lib'
import WorkspaceFamilyPicker from '$lib/components/sessions/WorkspaceFamilyPicker.svelte'
import WorkspaceScopeTrigger from '$lib/components/WorkspaceScopeTrigger.svelte'
import { findWorkspaceRoot, findWorkspaceDescendants } from '$lib/utils/workspaceHierarchy'
import {
findWorkspaceRoot,
findWorkspaceDescendants,
isForkOwner
} from '$lib/utils/workspaceHierarchy'
import { useForkableWorkspaces } from '$lib/utils/useForkableWorkspaces.svelte'
let { isCollapsed = false }: { isCollapsed?: boolean } = $props()
@@ -38,9 +42,11 @@
})
const rootLabel = $derived(`${forkCount} fork${forkCount === 1 ? '' : 's'}`)
// Settings link at the bottom of the picker — admin/superadmin only, scoped
// to the active workspace (fork or root).
const canManageWorkspace = $derived($userStore?.is_admin || $superadmin)
// Settings link at the bottom of the picker — admins, superadmins, and fork
// creators, scoped to the active workspace (fork or root).
const canManageWorkspace = $derived(
$userStore?.is_admin || $superadmin || isForkOwner(currentWs, $userStore?.email)
)
const settingsHref = $derived(canManageWorkspace ? `${base}/workspace_settings` : undefined)
const settingsLabel = $derived(`${currentWs?.name ?? effectiveId ?? 'Workspace'} settings`)
@@ -138,6 +138,21 @@ export function workspaceIsFork(
return allWorkspaces.find((w) => w.id === workspaceId)?.parent_workspace_id != null
}
/**
* Whether `userEmail` is the creator of a fork workspace. The fork creator gets workspace-settings
* access (the fork members screen) even when they are not an admin of it: forking as an ordinary
* developer copies their parent `usr` row, leaving them otherwise unable to bring collaborators in.
* Mirrors the backend `authorize_fork_owner_add_user` grant.
*/
export function isForkOwner(
workspace: UserWorkspace | undefined,
userEmail: string | null | undefined
): boolean {
return (
Boolean(workspace?.parent_workspace_id) && !!userEmail && workspace?.created_by === userEmail
)
}
/**
* The canonical dev workspace of a prod workspace, if any (at most one per prod). Used to redirect
* edits from a locked prod workspace into its dev workspace. Disabled dev workspaces are excluded: