mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-07 16:03:21 +00:00
fix: resolve fork family/picker for superadmin visiting a non-member workspace (#10023)
* fix: populate fork base picker for superadmin visiting a non-member workspace Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: resolve fork family for superadmin across sidebar picker and scope header Extract the superadmin-visited-workspace fallback into a shared useForkableWorkspaces composable and apply it to WorkspaceFamilyPicker and WorkspaceScopeHeader so the sidebar fork picker and its fork-count trigger resolve the family for a superadmin viewing a non-member workspace. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: resolve superadmin-visited workspace name in the scope trigger chip The sidebar scope trigger next to the fork picker read $userWorkspaces directly, so a superadmin viewing a non-member workspace saw its raw id instead of the resolved name/family. Thread the folded-in forkable list into WorkspaceScopeTrigger, and trim the now-duplicated per-site rationale comments to a pointer at the composable. 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:
co-authored by
Claude Opus 4.8
parent
c537d45e49
commit
368fd2d9e4
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { userWorkspaces } from '$lib/stores'
|
||||
import { userWorkspaces, type UserWorkspace } from '$lib/stores'
|
||||
import { findWorkspaceRoot } from '$lib/utils/workspaceHierarchy'
|
||||
import { forkAccentStyle } from '$lib/utils/forkColor'
|
||||
import { Badge, Button } from '$lib/components/common'
|
||||
@@ -24,6 +24,7 @@
|
||||
interactive = true,
|
||||
menuItems = undefined,
|
||||
disableTitle = false,
|
||||
forkableWorkspaces,
|
||||
class: className = ''
|
||||
}: {
|
||||
workspaceId?: string
|
||||
@@ -55,19 +56,23 @@
|
||||
// Suppress the chip's native `title` so it doesn't double-pop when a
|
||||
// consumer wraps the chip in a richer hover tooltip (e.g. NameIdTooltip).
|
||||
disableTitle?: boolean
|
||||
// Pre-resolved workspace list from a consumer that folded in a
|
||||
// superadmin-visited workspace (see useForkableWorkspaces), so the chip
|
||||
// resolves its name/family for a workspace absent from `$userWorkspaces`.
|
||||
// Omitted consumers fall back to the member-only list.
|
||||
forkableWorkspaces?: UserWorkspace[]
|
||||
class?: string
|
||||
} = $props()
|
||||
|
||||
const root = $derived(findWorkspaceRoot(workspaceId, $userWorkspaces))
|
||||
const currentWs = $derived(
|
||||
workspaceId ? $userWorkspaces.find((w) => w.id === workspaceId) : undefined
|
||||
)
|
||||
const workspaces = $derived(forkableWorkspaces ?? $userWorkspaces)
|
||||
const root = $derived(findWorkspaceRoot(workspaceId, workspaces))
|
||||
const currentWs = $derived(workspaceId ? workspaces.find((w) => w.id === workspaceId) : undefined)
|
||||
const isFork = $derived(!!currentWs && !!root && currentWs.id !== root.id)
|
||||
const showFork = $derived(!!pendingFork || isFork)
|
||||
const name = $derived(pendingFork?.name ?? currentWs?.name ?? workspaceId ?? 'Pick workspace')
|
||||
const parentId = $derived(pendingFork?.parent_workspace_id ?? currentWs?.parent_workspace_id)
|
||||
const parentName = $derived(
|
||||
parentId ? ($userWorkspaces.find((w) => w.id === parentId)?.name ?? parentId) : undefined
|
||||
parentId ? (workspaces.find((w) => w.id === parentId)?.name ?? parentId) : undefined
|
||||
)
|
||||
// The parent segment only appears for a fork of a fork — forking off the
|
||||
// root is the default and doesn't need spelling out.
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
import {
|
||||
enterpriseLicense,
|
||||
isPremiumStore,
|
||||
superadmin,
|
||||
userStore,
|
||||
userWorkspaces,
|
||||
workspaceStore
|
||||
workspaceStore,
|
||||
type UserWorkspace
|
||||
} from '$lib/stores'
|
||||
import {
|
||||
findWorkspaceDescendants,
|
||||
@@ -13,6 +15,7 @@
|
||||
findWorkspaceRoot,
|
||||
buildWorkspaceHierarchy
|
||||
} from '$lib/utils/workspaceHierarchy'
|
||||
import { useForkableWorkspaces } from '$lib/utils/useForkableWorkspaces.svelte'
|
||||
import { canCreateFork } from '$lib/utils/editInFork'
|
||||
import { forkAccentStyle } from '$lib/utils/forkColor'
|
||||
import { getUserExt } from '$lib/user'
|
||||
@@ -65,6 +68,10 @@
|
||||
// and target href.
|
||||
settingsHref,
|
||||
settingsLabel,
|
||||
// Pre-resolved forkable list from a parent that already computed it (e.g.
|
||||
// WorkspaceScopeHeader), so the superadmin lookup isn't duplicated. Omitted
|
||||
// by standalone consumers, which then resolve it themselves.
|
||||
forkableWorkspaces: forkableWorkspacesProp,
|
||||
trigger
|
||||
}: {
|
||||
selectedId?: string
|
||||
@@ -77,18 +84,29 @@
|
||||
class?: string
|
||||
settingsHref?: string
|
||||
settingsLabel?: string
|
||||
forkableWorkspaces?: UserWorkspace[]
|
||||
trigger: Snippet<[{ open: boolean }]>
|
||||
} = $props()
|
||||
|
||||
const WM_FORK_PREFIX = 'wm-fork-'
|
||||
|
||||
const effectiveId = $derived(selectedId ?? $workspaceStore ?? undefined)
|
||||
const root = $derived(findWorkspaceRoot(effectiveId, $userWorkspaces))
|
||||
const forks = $derived(root ? findWorkspaceDescendants(root.id, $userWorkspaces) : [])
|
||||
// Resolve the family (see useForkableWorkspaces); skip the lookup when a parent already supplied it.
|
||||
const ownForkable = useForkableWorkspaces({
|
||||
workspaces: () => $userWorkspaces,
|
||||
currentWorkspaceId: () => effectiveId,
|
||||
isSuperadmin: () => !!$superadmin,
|
||||
enabled: () => forkableWorkspacesProp === undefined
|
||||
})
|
||||
const forkableWorkspaces = $derived(forkableWorkspacesProp ?? ownForkable.current)
|
||||
const root = $derived(findWorkspaceRoot(effectiveId, forkableWorkspaces))
|
||||
const forks = $derived(root ? findWorkspaceDescendants(root.id, forkableWorkspaces) : [])
|
||||
|
||||
// The family's canonical dev workspace, if any — still used for gating (a forking-locked root can be
|
||||
// forked via its dev) and as a selectable base with a "dev" badge.
|
||||
const devOfRoot = $derived(root ? findCanonicalDevWorkspace(root.id, $userWorkspaces) : undefined)
|
||||
const devOfRoot = $derived(
|
||||
root ? findCanonicalDevWorkspace(root.id, forkableWorkspaces) : undefined
|
||||
)
|
||||
const createForkLabel = 'Create new fork…'
|
||||
// Candidate bases ("targets") for a new fork: the root plus every fork/dev in the family, so a fork
|
||||
// can itself be the base — i.e. a fork of a fork. Root first, matching the list order below.
|
||||
@@ -108,7 +126,7 @@
|
||||
// forks of forks under their parent the same way. `forks` is a DFS of descendants (parent before
|
||||
// child), so indenting each row by its depth nests it under its parent.
|
||||
const familyDepths = $derived(
|
||||
new Map(buildWorkspaceHierarchy($userWorkspaces).map((h) => [h.workspace.id, h.depth]))
|
||||
new Map(buildWorkspaceHierarchy(forkableWorkspaces).map((h) => [h.workspace.id, h.depth]))
|
||||
)
|
||||
// Extra left padding (on top of the row's base px-3) to nest a workspace one step per depth level,
|
||||
// matching the sidebar menu's `depth * 16px`.
|
||||
|
||||
@@ -13,19 +13,28 @@
|
||||
import WorkspaceFamilyPicker from '$lib/components/sessions/WorkspaceFamilyPicker.svelte'
|
||||
import WorkspaceScopeTrigger from '$lib/components/WorkspaceScopeTrigger.svelte'
|
||||
import { findWorkspaceRoot, findWorkspaceDescendants } from '$lib/utils/workspaceHierarchy'
|
||||
import { useForkableWorkspaces } from '$lib/utils/useForkableWorkspaces.svelte'
|
||||
|
||||
let { isCollapsed = false }: { isCollapsed?: boolean } = $props()
|
||||
|
||||
const effectiveId = $derived($workspaceStore ?? undefined)
|
||||
// Resolve once here and pass to the picker + trigger below, avoiding a duplicate lookup (see
|
||||
// useForkableWorkspaces).
|
||||
const forkable = useForkableWorkspaces({
|
||||
workspaces: () => $userWorkspaces,
|
||||
currentWorkspaceId: () => effectiveId,
|
||||
isSuperadmin: () => !!$superadmin
|
||||
})
|
||||
const forkableWorkspaces = $derived(forkable.current)
|
||||
const currentWs = $derived(
|
||||
effectiveId ? $userWorkspaces.find((w) => w.id === effectiveId) : undefined
|
||||
effectiveId ? forkableWorkspaces.find((w) => w.id === effectiveId) : undefined
|
||||
)
|
||||
|
||||
// Fork count surfaces the family's size right on the trigger, hinting that
|
||||
// the muted "root" chip is also the entry point to its forks.
|
||||
const forkCount = $derived.by(() => {
|
||||
const root = findWorkspaceRoot(effectiveId, $userWorkspaces)
|
||||
return root ? findWorkspaceDescendants(root.id, $userWorkspaces).length : 0
|
||||
const root = findWorkspaceRoot(effectiveId, forkableWorkspaces)
|
||||
return root ? findWorkspaceDescendants(root.id, forkableWorkspaces).length : 0
|
||||
})
|
||||
const rootLabel = $derived(`${forkCount} fork${forkCount === 1 ? '' : 's'}`)
|
||||
|
||||
@@ -50,6 +59,7 @@
|
||||
<div class="flex items-center min-w-0 px-2 {isCollapsed ? 'justify-center' : ''} py-0.5 rounded-md">
|
||||
<WorkspaceFamilyPicker
|
||||
selectedId={effectiveId}
|
||||
{forkableWorkspaces}
|
||||
onPick={switchWorkspaceDirect}
|
||||
onRequestCreateFork={openForkModal}
|
||||
{settingsHref}
|
||||
@@ -59,6 +69,7 @@
|
||||
{#snippet trigger()}
|
||||
<WorkspaceScopeTrigger
|
||||
workspaceId={effectiveId}
|
||||
{forkableWorkspaces}
|
||||
{isCollapsed}
|
||||
{rootLabel}
|
||||
wrap
|
||||
|
||||
@@ -14,12 +14,13 @@
|
||||
import { validateUsername } from '$lib/utils'
|
||||
import { logoutWithRedirect } from '$lib/logoutKit'
|
||||
import { page } from '$app/state'
|
||||
import { usersWorkspaceStore, userWorkspaces, workspaceStore } from '$lib/stores'
|
||||
import { superadmin, usersWorkspaceStore, userWorkspaces, workspaceStore } from '$lib/stores'
|
||||
import {
|
||||
workspaceIsFork,
|
||||
findWorkspaceRoot,
|
||||
findWorkspaceDescendants
|
||||
} from '$lib/utils/workspaceHierarchy'
|
||||
import { useForkableWorkspaces } from '$lib/utils/useForkableWorkspaces.svelte'
|
||||
import { resource } from 'runed'
|
||||
import { Badge, Button } from '$lib/components/common'
|
||||
import { devBadgeText } from '$lib/utils/devWorkspaceLabel'
|
||||
@@ -83,10 +84,19 @@
|
||||
// a fork here (rather than the root) yields a fork of a fork.
|
||||
let baseWorkspaceId = $state<string | undefined>(undefined)
|
||||
|
||||
// Base list for forking, folding in a superadmin-visited workspace (see useForkableWorkspaces).
|
||||
const forkable = useForkableWorkspaces({
|
||||
workspaces: () => $userWorkspaces,
|
||||
currentWorkspaceId: () => $workspaceStore,
|
||||
isSuperadmin: () => !!$superadmin,
|
||||
enabled: () => isFork
|
||||
})
|
||||
let forkableWorkspaces = $derived(forkable.current)
|
||||
|
||||
// Base candidates are the current workspace's family: its root first, then every fork/dev under it.
|
||||
let familyRoot = $derived(findWorkspaceRoot($workspaceStore, $userWorkspaces))
|
||||
let familyRoot = $derived(findWorkspaceRoot($workspaceStore, forkableWorkspaces))
|
||||
let baseCandidates = $derived(
|
||||
familyRoot ? [familyRoot, ...findWorkspaceDescendants(familyRoot.id, $userWorkspaces)] : []
|
||||
familyRoot ? [familyRoot, ...findWorkspaceDescendants(familyRoot.id, forkableWorkspaces)] : []
|
||||
)
|
||||
let baseItems = $derived(
|
||||
baseCandidates.map((w) => ({
|
||||
@@ -112,13 +122,13 @@
|
||||
|
||||
// The dev-workspace option is only offered when forking a root workspace that doesn't already
|
||||
// have one: a workspace gets at most one dev, and dev workspaces don't nest (a dev of a dev).
|
||||
let baseWorkspaceEntry = $derived($userWorkspaces.find((w) => w.id === baseWorkspaceId))
|
||||
let baseWorkspaceEntry = $derived(forkableWorkspaces.find((w) => w.id === baseWorkspaceId))
|
||||
// Require the base workspace to be loaded before treating it as a root: a missing entry must
|
||||
// not read as root (it would offer invalid dev creation while the workspace list is still loading).
|
||||
// `workspaceIsFork` (prefix OR parent) also excludes an orphaned `wm-fork-` workspace, whose parent
|
||||
// FK was set null — it has no parent but is still a fork, so it can't host a dev workspace.
|
||||
let currentIsRoot = $derived(
|
||||
!!baseWorkspaceEntry && !workspaceIsFork(baseWorkspaceId, $userWorkspaces)
|
||||
!!baseWorkspaceEntry && !workspaceIsFork(baseWorkspaceId, forkableWorkspaces)
|
||||
)
|
||||
// Ask the server whether a dev already exists: the caller may not be a member of this prod's dev,
|
||||
// so the client workspace list can't see it and would offer an invalid "create dev" action.
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { resource } from 'runed'
|
||||
import { WorkspaceService } from '$lib/gen'
|
||||
import type { UserWorkspace } from '$lib/stores'
|
||||
|
||||
/**
|
||||
* `$userWorkspaces` only lists workspaces the user is a *member* of. A superadmin can navigate into a
|
||||
* workspace they don't belong to, which leaves fork surfaces (the base picker, the family picker)
|
||||
* unable to resolve that workspace's family — so it appears empty. This composable fetches the
|
||||
* visited-but-unlisted workspace's metadata as superadmin and appends it, so the family root resolves
|
||||
* and can be forked. Its wider family (ancestors/siblings the caller can't see) stays unreachable
|
||||
* client-side, so only the visited workspace itself is added.
|
||||
*/
|
||||
export function useForkableWorkspaces(args: {
|
||||
workspaces: () => UserWorkspace[]
|
||||
currentWorkspaceId: () => string | undefined
|
||||
isSuperadmin: () => boolean
|
||||
// Skip the lookup entirely when the fork surface isn't in play (defaults to always enabled).
|
||||
enabled?: () => boolean
|
||||
}) {
|
||||
const missing = resource(
|
||||
() => {
|
||||
const id = args.currentWorkspaceId()
|
||||
const enabled = args.enabled?.() ?? true
|
||||
return enabled && args.isSuperadmin() && id && !args.workspaces().some((w) => w.id === id)
|
||||
? id
|
||||
: undefined
|
||||
},
|
||||
async (ws) =>
|
||||
ws ? await WorkspaceService.getWorkspaceAsSuperAdmin({ workspace: ws }) : undefined
|
||||
)
|
||||
return {
|
||||
get current(): UserWorkspace[] {
|
||||
const base = args.workspaces()
|
||||
const w = missing.current
|
||||
// Drop the fetched entry once membership catches up, so the id is never duplicated.
|
||||
if (!w || base.some((b) => b.id === w.id)) return base
|
||||
return [
|
||||
...base,
|
||||
{
|
||||
id: w.id,
|
||||
name: w.name,
|
||||
username: '',
|
||||
color: w.color ?? undefined,
|
||||
parent_workspace_id: w.parent_workspace_id,
|
||||
disabled: false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user