mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-18 16:02:10 +00:00
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:
+70
@@ -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"
|
||||
}
|
||||
+70
@@ -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"
|
||||
}
|
||||
+71
@@ -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!(
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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())
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user