fix: show the fork banner to a superadmin who is not a workspace member (#10465)

* fix: show the fork banner to a superadmin who is not a workspace member

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

* fix: scope the non-member workspace cache to the current workspace

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

* fix: make the non-member workspace cache own exactly one workspace

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

* fix: drop the non-member workspace cache when no workspace is open

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-08-03 17:13:12 +00:00
committed by GitHub
parent 4f03aa91a7
commit 3cef678b5f
11 changed files with 463 additions and 52 deletions
@@ -0,0 +1,70 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n workspace.id AS \"id!\",\n workspace.name AS \"name!\",\n workspace.owner AS \"owner!\",\n workspace.deleted AS \"deleted!\",\n workspace.premium AS \"premium!\",\n workspace_settings.color AS \"color\",\n workspace.parent_workspace_id AS \"parent_workspace_id\",\n workspace.is_dev_workspace AS \"is_dev_workspace!\",\n workspace.dev_workspace_label AS \"dev_workspace_label\"\n FROM workspace\n LEFT JOIN workspace_settings ON workspace.id = workspace_settings.workspace_id\n WHERE workspace.id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id!",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "name!",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "owner!",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "deleted!",
"type_info": "Bool"
},
{
"ordinal": 4,
"name": "premium!",
"type_info": "Bool"
},
{
"ordinal": 5,
"name": "color",
"type_info": "Varchar"
},
{
"ordinal": 6,
"name": "parent_workspace_id",
"type_info": "Varchar"
},
{
"ordinal": 7,
"name": "is_dev_workspace!",
"type_info": "Bool"
},
{
"ordinal": 8,
"name": "dev_workspace_label",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
false,
false,
false,
true,
true,
false,
true
]
},
"hash": "1c472a4740a41ae8985f865e15b0c4edb4d5af2281c22955121cb794428c3cde"
}
@@ -0,0 +1,70 @@
{
"db_name": "PostgreSQL",
"query": "SELECT workspace.id, workspace.name, workspace.owner, workspace.deleted, workspace.premium, workspace_settings.color, workspace.parent_workspace_id, workspace.is_dev_workspace, workspace.dev_workspace_label\n FROM workspace\n LEFT JOIN workspace_settings ON workspace.id = workspace_settings.workspace_id\n JOIN usr ON usr.workspace_id = workspace.id\n WHERE usr.email = $1 AND workspace.deleted = false",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "owner",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "deleted",
"type_info": "Bool"
},
{
"ordinal": 4,
"name": "premium",
"type_info": "Bool"
},
{
"ordinal": 5,
"name": "color",
"type_info": "Varchar"
},
{
"ordinal": 6,
"name": "parent_workspace_id",
"type_info": "Varchar"
},
{
"ordinal": 7,
"name": "is_dev_workspace",
"type_info": "Bool"
},
{
"ordinal": 8,
"name": "dev_workspace_label",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
false,
false,
false,
true,
true,
false,
true
]
},
"hash": "4f266373d5acdbcedc81e8ed3eb1fb95861ef17ca5570ef06b9903f04e9adb5e"
}
@@ -0,0 +1,71 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n workspace.id AS \"id!\",\n workspace.name AS \"name!\",\n workspace.owner AS \"owner!\",\n workspace.deleted AS \"deleted!\",\n workspace.premium AS \"premium!\",\n workspace_settings.color AS \"color\",\n workspace.parent_workspace_id AS \"parent_workspace_id\",\n workspace.is_dev_workspace AS \"is_dev_workspace!\",\n workspace.dev_workspace_label AS \"dev_workspace_label\"\n FROM workspace\n LEFT JOIN workspace_settings ON workspace.id = workspace_settings.workspace_id\n LIMIT $1 OFFSET $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id!",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "name!",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "owner!",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "deleted!",
"type_info": "Bool"
},
{
"ordinal": 4,
"name": "premium!",
"type_info": "Bool"
},
{
"ordinal": 5,
"name": "color",
"type_info": "Varchar"
},
{
"ordinal": 6,
"name": "parent_workspace_id",
"type_info": "Varchar"
},
{
"ordinal": 7,
"name": "is_dev_workspace!",
"type_info": "Bool"
},
{
"ordinal": 8,
"name": "dev_workspace_label",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Int8",
"Int8"
]
},
"nullable": [
false,
false,
false,
false,
false,
true,
true,
false,
true
]
},
"hash": "8c33cbe3dc0a7539835fab61f981f5117da38c3410ad6e34ad67f5441deaf89e"
}
@@ -247,6 +247,8 @@ struct Workspace {
premium: bool,
color: Option<String>,
parent_workspace_id: Option<String>,
is_dev_workspace: bool,
dev_workspace_label: Option<String>,
}
#[derive(FromRow, Serialize, Debug)]
@@ -749,7 +751,7 @@ async fn list_workspaces(
let mut tx = user_db.begin(&authed).await?;
let workspaces = sqlx::query_as!(
Workspace,
"SELECT workspace.id, workspace.name, workspace.owner, workspace.deleted, workspace.premium, workspace_settings.color, workspace.parent_workspace_id
"SELECT workspace.id, workspace.name, workspace.owner, workspace.deleted, workspace.premium, workspace_settings.color, workspace.parent_workspace_id, workspace.is_dev_workspace, workspace.dev_workspace_label
FROM workspace
LEFT JOIN workspace_settings ON workspace.id = workspace_settings.workspace_id
JOIN usr ON usr.workspace_id = workspace.id
@@ -4827,7 +4829,9 @@ async fn get_workspace_as_superadmin(
workspace.deleted AS \"deleted!\",
workspace.premium AS \"premium!\",
workspace_settings.color AS \"color\",
workspace.parent_workspace_id AS \"parent_workspace_id\"
workspace.parent_workspace_id AS \"parent_workspace_id\",
workspace.is_dev_workspace AS \"is_dev_workspace!\",
workspace.dev_workspace_label AS \"dev_workspace_label\"
FROM workspace
LEFT JOIN workspace_settings ON workspace.id = workspace_settings.workspace_id
WHERE workspace.id = $1",
@@ -4861,7 +4865,9 @@ async fn list_workspaces_as_super_admin(
workspace.deleted AS \"deleted!\",
workspace.premium AS \"premium!\",
workspace_settings.color AS \"color\",
workspace.parent_workspace_id AS \"parent_workspace_id\"
workspace.parent_workspace_id AS \"parent_workspace_id\",
workspace.is_dev_workspace AS \"is_dev_workspace!\",
workspace.dev_workspace_label AS \"dev_workspace_label\"
FROM workspace
LEFT JOIN workspace_settings ON workspace.id = workspace_settings.workspace_id
LIMIT $1 OFFSET $2",
@@ -5966,7 +5972,14 @@ async fn clone_scripts(
.execute(&mut **tx)
.await?;
clear_orphaned_compat_address(tx, "script", "hash", source_workspace_id, target_workspace_id).await?;
clear_orphaned_compat_address(
tx,
"script",
"hash",
source_workspace_id,
target_workspace_id,
)
.await?;
Ok(())
}
@@ -6204,7 +6217,8 @@ async fn clone_flows(
.execute(&mut **tx)
.await?;
clear_orphaned_compat_address(tx, "flow", "path", source_workspace_id, target_workspace_id).await?;
clear_orphaned_compat_address(tx, "flow", "path", source_workspace_id, target_workspace_id)
.await?;
// Then clone flow versions
let flow_versions = sqlx::query!(
+9
View File
@@ -30923,6 +30923,15 @@ components:
parent_workspace_id:
type: string
nullable: true
deleted:
type: boolean
description: "Archived (soft-deleted) workspace"
is_dev_workspace:
type: boolean
dev_workspace_label:
type: string
nullable: true
description: "Cosmetic display label of the dev workspace ('dev' | 'staging'); null defaults to 'dev'"
required:
- id
- name
@@ -167,15 +167,21 @@
stays aligned with it instead of bleeding to the viewport edges. -->
<div class="w-full text-xs max-w-7xl mx-auto px-4 sm:px-8 pt-2">
<div class="bg-blue-50 dark:bg-blue-900 rounded-md px-4 py-2">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<GitFork class="w-4 h-4 text-accent" />
<div class="text-sm">
<!-- The summary wraps inside its own column while the CTA keeps its width and
stays on the first line: laid out as one non-wrapping row, the summary is
long enough on a laptop-width viewport to push the button out of the
banner instead of getting shorter. -->
<div class="flex items-center justify-between gap-x-3">
<div class="flex items-center flex-wrap gap-x-3 gap-y-1 min-w-0">
<GitFork class="w-4 h-4 text-accent shrink-0" />
<div class="text-sm min-w-0">
<span class="font-medium text-blue-900 dark:text-blue-100">
{isDevWorkspace
? `${devLabelWord(currentWorkspaceData?.dev_workspace_label)} workspace of`
: 'Fork of'}
<b>{parentWorkspaceData?.name}</b> ({parentWorkspaceId})
<b>{parentWorkspaceData?.name}</b
>{#if parentWorkspaceData?.name !== parentWorkspaceId}
({parentWorkspaceId}){/if}
</span>
</div>
@@ -186,7 +192,7 @@
{error}
</span>
{:else if comparison}
<div class="flex items-center gap-4 text-xs">
<div class="flex items-center flex-wrap gap-x-4 gap-y-1 text-xs min-w-0">
{#if comparison.summary.total_diffs > 0}
<span class="text-blue-700 dark:text-blue-100">
{forkAheadBehindMessage(
@@ -194,9 +200,14 @@
comparison.summary.total_behind
)}
<span class="font-semibold underline">{parentWorkspaceId}</span> over {comparison
.summary.total_diffs} items:
.summary.total_diffs} items<span class="hidden lg:inline">:</span>
</span>
<div class="flex items-center gap-2">
<!-- The per-kind breakdown is the first thing to go on a narrow
viewport: the item total above already sizes the change set, and
the compare page carries the detail. -->
<div
class="hidden lg:flex items-center flex-wrap gap-x-2 gap-y-1 whitespace-nowrap"
>
{#if comparison.summary.scripts_changed > 0}
<span class="text-blue-700 dark:text-blue-100">
{comparison.summary.scripts_changed} script{comparison.summary
@@ -272,17 +283,23 @@
{#if ciTestTotal > 0}
-
{#if ciTestFailing > 0}
<div class="flex items-center gap-1 text-red-600 dark:text-red-400">
<div
class="flex items-center gap-1 text-red-600 dark:text-red-400 whitespace-nowrap"
>
<CircleX class="w-3 h-3" />
<span>CI: {ciTestFailing} failing</span>
</div>
{:else if ciTestRunning > 0}
<div class="flex items-center gap-1 text-yellow-600 dark:text-yellow-400">
<div
class="flex items-center gap-1 text-yellow-600 dark:text-yellow-400 whitespace-nowrap"
>
<Loader2 class="w-3 h-3 animate-spin" />
<span>CI: {ciTestRunning} running</span>
</div>
{:else}
<div class="flex items-center gap-1 text-green-600 dark:text-green-400">
<div
class="flex items-center gap-1 text-green-600 dark:text-green-400 whitespace-nowrap"
>
<CircleCheck class="w-3 h-3" />
<span>CI: {ciTestPassing} passing</span>
</div>
@@ -291,7 +308,9 @@
{#if comparison.summary.conflicts > 0}
-
<div class="flex items-center gap-1 text-orange-600 dark:text-orange-400">
<div
class="flex items-center gap-1 text-orange-600 dark:text-orange-400 whitespace-nowrap"
>
<AlertTriangle class="w-3 h-3" />
<span
>{comparison.summary.conflicts} conflict{comparison.summary.conflicts !== 1
@@ -316,7 +335,7 @@
{/if}
</div>
<div class="flex items-center gap-2">
<div class="flex items-center gap-2 shrink-0">
<Button
variant="default"
unifiedSize="sm"
@@ -33,9 +33,9 @@
stays aligned with it instead of bleeding to the viewport edges. -->
<div class="w-full text-xs max-w-7xl mx-auto px-4 sm:px-8 pt-2">
<div class="bg-blue-50 dark:bg-blue-900 rounded-md px-4 py-2">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<Pencil class="w-4 h-4 text-accent" />
<div class="flex flex-wrap items-center justify-between gap-x-3 gap-y-2">
<div class="flex items-center gap-3 min-w-0">
<Pencil class="w-4 h-4 text-accent shrink-0" />
<span class="text-xs font-medium text-blue-900 dark:text-blue-100">
This workspace has {draftCount} draft{draftCount !== 1 ? 's' : ''}
</span>
@@ -0,0 +1,70 @@
import { describe, expect, it, beforeEach } from 'vitest'
import { get } from 'svelte/store'
import {
clearNonMemberWorkspaces,
nonMemberWorkspaces,
setNonMemberWorkspaces,
superadmin,
userWorkspaces,
usersWorkspaceStore
} from './stores'
import type { Workspace } from './gen'
function workspace(id: string, extra: Partial<Workspace> = {}): Workspace {
return { id, name: id, owner: 'admin@windmill.dev', ...extra } as Workspace
}
beforeEach(() => {
usersWorkspaceStore.set(undefined)
superadmin.set(undefined)
nonMemberWorkspaces.set(undefined)
})
describe('nonMemberWorkspaces', () => {
it('merges the resolved workspaces into userWorkspaces with their fork lineage', () => {
setNonMemberWorkspaces('dev', [
workspace('dev', { parent_workspace_id: 'prod', is_dev_workspace: true }),
workspace('prod')
])
expect(get(userWorkspaces)).toMatchObject([
{ id: 'dev', parent_workspace_id: 'prod', is_dev_workspace: true },
{ id: 'prod' }
])
})
it('never offers an archived workspace, and still marks it resolved', () => {
setNonMemberWorkspaces('archived-fork', [workspace('archived-fork', { deleted: true })])
expect(get(userWorkspaces)).toEqual([])
// Without this the layout re-fetches the archived workspace on every effect pass.
expect(get(nonMemberWorkspaces)?.forWorkspace).toBe('archived-fork')
})
it('replaces the previous workspace set rather than accumulating across switches', () => {
setNonMemberWorkspaces('fork-a', [workspace('fork-a')])
setNonMemberWorkspaces('fork-b', [workspace('fork-b')])
expect(get(userWorkspaces).map((w) => w.id)).toEqual(['fork-b'])
})
it('does not duplicate a workspace the user is a member of', () => {
usersWorkspaceStore.set({
email: 'admin@windmill.dev',
workspaces: [{ id: 'dev', name: 'dev', username: 'admin', color: '', disabled: false }]
} as any)
setNonMemberWorkspaces('dev', [workspace('dev')])
expect(get(userWorkspaces).map((w) => w.id)).toEqual(['dev'])
})
// The layout effect that fills this store also reads it, and its member path — every
// ordinary user, every pass — clears it. So the empty state must be one Svelte won't
// re-notify for: an object value always notifies, however equal, and the effect would
// then invalidate itself forever.
it('does not notify when clearing an already empty store', () => {
let notifications = 0
const unsubscribe = userWorkspaces.subscribe(() => notifications++)
notifications = 0
clearNonMemberWorkspaces()
clearNonMemberWorkspaces()
expect(notifications).toBe(0)
unsubscribe()
})
})
+2
View File
@@ -4,6 +4,7 @@ import {
workspaceStore,
userStore,
usersWorkspaceStore,
nonMemberWorkspaces,
superadmin,
devopsRole,
clearWorkspaceFromStorage
@@ -36,6 +37,7 @@ export function clearStores(): void {
userStore.set(undefined)
workspaceStore.set(undefined)
usersWorkspaceStore.set(undefined)
nonMemberWorkspaces.set(undefined)
refreshSuperadmin.cancel()
superadmin.set(undefined)
devopsRole.set(undefined)
+63 -18
View File
@@ -1,5 +1,5 @@
import { BROWSER } from 'esm-env'
import { derived, type Readable, writable } from 'svelte/store'
import { derived, get, type Readable, writable } from 'svelte/store'
import type { IntrospectionQuery } from 'graphql'
import {
@@ -93,25 +93,70 @@ export const lspTokenStore = writable<string | undefined>(undefined)
export const hubBaseUrlStore = writable<string>(DEFAULT_HUB_BASE_URL)
export const wsBaseUrlStore = writable<string | undefined>(undefined)
export const disableHubStore = writable<boolean>(false)
// What a superadmin standing in a workspace they are not a member of needs to see it as a
// fork: the workspace itself and its parent. `listUserWorkspaces` is membership-gated, so
// `$userWorkspaces` would miss them and every fork lookup would read the workspace as a
// parentless root. Owned by exactly one workspace (`forWorkspace`) — held past a switch,
// these would go on presenting themselves as memberships (picker, workspace-family lookups).
export const nonMemberWorkspaces = writable<
{ forWorkspace: string; workspaces: UserWorkspace[] } | undefined
>(undefined)
/**
* Records what was resolved for `forWorkspace`, dropping archived workspaces —
* `userWorkspaces` must never offer one. An archived workspace therefore resolves to an
* empty set, which still marks it as resolved and stops it being fetched again.
*/
export function setNonMemberWorkspaces(forWorkspace: string, workspaces: Workspace[]): void {
nonMemberWorkspaces.set({
forWorkspace,
workspaces: workspaces
.filter((w) => !w.deleted)
.map((w) => ({
id: w.id,
name: w.name,
// No `usr` row here, hence no per-workspace username — same stand-in as the
// synthetic `admins` entry below.
username: 'superadmin',
color: w.color,
parent_workspace_id: w.parent_workspace_id,
is_dev_workspace: w.is_dev_workspace,
dev_workspace_label: w.dev_workspace_label,
disabled: false
}))
})
}
export function clearNonMemberWorkspaces(): void {
// A Svelte store notifies on every write of an object value, so clearing an already
// empty store is not free: the layout effect that fills this one also reads it, and
// would re-run itself forever.
if (get(nonMemberWorkspaces) != undefined) {
nonMemberWorkspaces.set(undefined)
}
}
export const userWorkspaces: Readable<Array<UserWorkspace>> = derived(
[usersWorkspaceStore, superadmin],
([store, superadmin]) => {
[usersWorkspaceStore, superadmin, nonMemberWorkspaces],
([store, superadmin, nonMember]) => {
const originalWorkspaces = store?.workspaces ?? []
if (superadmin) {
return [
...originalWorkspaces.filter((x) => x.id != 'admins'),
{
id: 'admins',
name: 'Admins',
username: 'superadmin',
color: undefined,
operator_settings: undefined,
disabled: false
}
]
} else {
return originalWorkspaces
}
const workspaces = superadmin
? [
...originalWorkspaces.filter((x) => x.id != 'admins'),
{
id: 'admins',
name: 'Admins',
username: 'superadmin',
color: undefined,
operator_settings: undefined,
disabled: false
}
]
: originalWorkspaces
const extra = (nonMember?.workspaces ?? []).filter(
(w) => !workspaces.some((x) => x.id === w.id)
)
return extra.length > 0 ? [...workspaces, ...extra] : workspaces
}
)
@@ -29,6 +29,7 @@
userStore,
workspaceStore,
userWorkspaces,
usersWorkspaceStore,
type UserExt,
defaultScripts,
hubBaseUrlStore,
@@ -39,7 +40,11 @@
whitelabelNameStore,
globalDbManagerDrawer,
globalForkModal,
globalS3FilePickerExplorer
globalS3FilePickerExplorer,
nonMemberWorkspaces,
setNonMemberWorkspaces,
clearNonMemberWorkspaces,
type UserWorkspace
} from '$lib/stores'
import CenteredModal from '$lib/components/CenteredModal.svelte'
import { afterNavigate, beforeNavigate } from '$app/navigation'
@@ -702,34 +707,70 @@
$effect(() => {
const ws = $workspaceStore
const list = $userWorkspaces
const memberships = $usersWorkspaceStore?.workspaces
const resolvedFor = $nonMemberWorkspaces?.forWorkspace
const isSuperadmin = $superadmin
untrack(() => void recordCurrentForkParent(ws, list, isSuperadmin))
untrack(() => void resolveCurrentWorkspace(ws, list, memberships, resolvedFor, isSuperadmin))
})
// A superadmin can open a fork they aren't a member of, including a prefixless
// dev workspace. The membership-gated list omits it, so `recordForkParent` can't
// see its parent — fetch it directly so the deleted-fork recovery still works.
async function recordCurrentForkParent(
// A superadmin can open a fork they aren't a member of, including a prefixless dev
// workspace, and only `getWorkspaceAsSuperAdmin` will hand back its lineage (see
// `nonMemberWorkspaces`). Membership is decided against the raw list, not
// `$userWorkspaces`: that one already carries what this resolves, so checking it
// would clear and re-fetch the entry on every pass.
async function resolveCurrentWorkspace(
ws: string | undefined,
list: typeof $userWorkspaces,
memberships: UserWorkspace[] | undefined,
resolvedFor: string | undefined,
isSuperadmin: string | false | undefined
): Promise<void> {
if (!ws) return
if (list.some((w) => w.id === ws)) {
recordForkParent(ws, list)
// Leaving every workspace (deleting the one you were in) has to drop the cache too:
// the other two paths below only fire once another workspace is open, and until then
// the workspace picker would keep offering the one that just went away.
if (!ws) {
clearNonMemberWorkspaces()
return
}
if (!isSuperadmin) return
if (list.some((w) => w.id === ws)) {
recordForkParent(ws, list)
}
// Absence from a list that hasn't arrived yet says nothing about membership.
if (memberships == undefined || resolvedFor === ws) return
if (memberships.some((w) => w.id === ws)) {
clearNonMemberWorkspaces()
return
}
// This effect re-runs while a fetch is in flight, and no such pass can see the
// result yet.
if (!isSuperadmin || resolvingWorkspace === ws) return
resolvingWorkspace = ws
try {
const workspace = await WorkspaceService.getWorkspaceAsSuperAdmin({ workspace: ws })
if (workspace.parent_workspace_id) {
rememberForkParent(ws, workspace.parent_workspace_id)
const parentId = workspace.parent_workspace_id
// The fork UI names the parent, which a superadmin is just as likely not to be
// a member of. One hop only: nothing above the parent is displayed.
const parent =
parentId && !memberships.some((w) => w.id === parentId)
? await WorkspaceService.getWorkspaceAsSuperAdmin({ workspace: parentId })
: undefined
// A switch during the fetch already resolved (or cleared) the store for the
// workspace now open; this answer describes the one we left.
if ($workspaceStore !== ws) return
setNonMemberWorkspaces(ws, parent ? [workspace, parent] : [workspace])
if (parentId) {
rememberForkParent(ws, parentId)
}
} catch {
// Best-effort: if we can't resolve the parent, recovery falls back to the
// Best-effort: if we can't resolve the workspace, recovery falls back to the
// workspace picker rather than the parent redirect.
} finally {
if (resolvingWorkspace === ws) {
resolvingWorkspace = undefined
}
}
}
let resolvingWorkspace: string | undefined = undefined
$effect(() => {
$workspaceStore && untrack(() => onLoad())
})