mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-20 16:03:21 +00:00
fix: let devops users reach instance pages without a workspace (#11876)
* fix: let devops users reach instance pages without a workspace Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: hide workspace-bound worker actions when no workspace is selected Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: keep the page header bar in the no-workspace instance view Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
adae5b32a3
commit
a3a4fa5038
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Pages a devops user can open without belonging to any workspace. Devops grants no workspace
|
||||
* access, so such a page renders with `$workspaceStore` and `$userStore` unset and must hide
|
||||
* whatever needs them (a workspace-scoped call or a link into a workspace) rather than break.
|
||||
*/
|
||||
const INSTANCE_ROUTES = ['/workers', '/service_logs']
|
||||
|
||||
export function isInstanceRoute(pathname: string): boolean {
|
||||
return INSTANCE_ROUTES.some((r) => pathname === r || pathname.startsWith(r + '/'))
|
||||
}
|
||||
@@ -79,8 +79,11 @@
|
||||
Home,
|
||||
Play,
|
||||
Search,
|
||||
WandSparkles
|
||||
WandSparkles,
|
||||
ArrowLeft
|
||||
} from 'lucide-svelte'
|
||||
import { Button } from '$lib/components/common'
|
||||
import { isInstanceRoute } from '$lib/instanceRoutes'
|
||||
import { getUserExt } from '$lib/user'
|
||||
import { confirmPendingLoginMethod } from '$lib/lastLoginMethod'
|
||||
import { deepEqual } from 'fast-equals'
|
||||
@@ -796,6 +799,11 @@
|
||||
}
|
||||
|
||||
let devOnly = $derived(page.url.pathname.startsWith('/scripts/dev'))
|
||||
// A devops user who belongs to no workspace has no `$userStore`, and would otherwise wait on
|
||||
// "Loading user..." forever on the instance pages their role exists for.
|
||||
let instanceOnlyView = $derived(
|
||||
!$workspaceStore && !!$devopsRole && isInstanceRoute(page.url.pathname)
|
||||
)
|
||||
|
||||
async function loadDefaultScripts(workspace: string, user: UserExt | undefined) {
|
||||
if (!user?.operator) {
|
||||
@@ -1566,6 +1574,27 @@ outside this layout, and their shortcut must reopen the URL that is their creden
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{:else if instanceOnlyView}
|
||||
<!-- No workspace means no sidebar: everything in it is workspace-scoped. The header bar
|
||||
stays, without its workspace part: pages register their toolbars there. -->
|
||||
<div class="flex flex-col h-screen w-full bg-surface">
|
||||
<div class="flex items-center pl-4 border-b shrink-0">
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="sm"
|
||||
href="/user/workspaces"
|
||||
startIcon={{ icon: ArrowLeft }}
|
||||
>
|
||||
Workspaces
|
||||
</Button>
|
||||
<div class="flex-1 min-w-0">
|
||||
<PageHeaderBar navHidden />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 min-h-0 overflow-auto">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<CenteredModal title="Loading user..." loading={true}></CenteredModal>
|
||||
{/if}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import { UserService, type WorkspaceInvite, WorkspaceService } from '$lib/gen'
|
||||
import {
|
||||
superadmin,
|
||||
devopsRole,
|
||||
usersWorkspaceStore,
|
||||
userWorkspaces,
|
||||
workspaceStore,
|
||||
@@ -30,7 +31,9 @@
|
||||
Search,
|
||||
ChevronsDownUp,
|
||||
ChevronsUpDown,
|
||||
LogOut
|
||||
LogOut,
|
||||
Logs,
|
||||
ServerCog
|
||||
} from 'lucide-svelte'
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
import { isValidLogoutRedirect, toSameOriginRelativePath } from '$lib/logoutRedirect'
|
||||
@@ -169,17 +172,17 @@
|
||||
let workspacesLoaded = $derived.by(
|
||||
() => workspaces !== undefined && $usersWorkspaceStore !== undefined
|
||||
)
|
||||
// Not for a superadmin: this page is also where they reach the instance settings and the
|
||||
// list-all toggle, and standing the picker down takes both away — a superadmin with no
|
||||
// membership of their own has business here besides creating a workspace. Waiting for the
|
||||
// store to answer rather than reading `!$superadmin`, which is true while `globalWhoami`
|
||||
// is still in flight.
|
||||
// Not for a superadmin or devops user: this page is also where they reach the instance
|
||||
// settings, the list-all toggle and the instance pages, and standing the picker down takes
|
||||
// those away — they have business here besides creating a workspace. `$devopsRole` is truthy
|
||||
// for a superadmin too. Waiting for the store to answer rather than reading `!$devopsRole`,
|
||||
// which is true while `globalWhoami` is still in flight.
|
||||
let nothingToChoose = $derived(
|
||||
workspacesLoaded &&
|
||||
invitesLoaded &&
|
||||
createWorkspace &&
|
||||
$superadmin !== undefined &&
|
||||
!$superadmin &&
|
||||
$devopsRole !== undefined &&
|
||||
!$devopsRole &&
|
||||
!list_all_as_super_admin &&
|
||||
allWorkspaces.length === 0 &&
|
||||
invites.length === 0
|
||||
@@ -206,11 +209,11 @@
|
||||
let showCreate = $state(false)
|
||||
$effect(() => {
|
||||
if (nothingToChoose) showCreate = true
|
||||
// Except for a superadmin, whom `nothingToChoose` excludes — so this only ever undoes a
|
||||
// latch that should not have happened: one taken on a stale `false` before the forced
|
||||
// `refreshSuperadmin` above answered. Without it that superadmin would be stuck on the
|
||||
// create form, instance settings and the list-all toggle gone with it, until a reload.
|
||||
else if ($superadmin) showCreate = false
|
||||
// Except for a superadmin or devops user, whom `nothingToChoose` excludes — so this only
|
||||
// ever undoes a latch that should not have happened: one taken on a stale `false` before
|
||||
// the forced `refreshSuperadmin` above answered. Without it they would be stuck on the
|
||||
// create form, with what they came here for gone with it, until a reload.
|
||||
else if ($devopsRole) showCreate = false
|
||||
})
|
||||
|
||||
async function speakFriendAndEnterWorkspace(workspaceId: string) {
|
||||
@@ -518,6 +521,20 @@
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- A superadmin reaches these from the `admins` workspace; devops alone grants no
|
||||
workspace, so without this row the role's pages have no way in. -->
|
||||
{#if !showCreate && $devopsRole && !$superadmin}
|
||||
<h2 class="text-sm font-semibold text-emphasis mt-10 mb-2">Instance</h2>
|
||||
<div class="flex items-center flex-wrap gap-2">
|
||||
<Button variant="default" unifiedSize="sm" href="/workers" startIcon={{ icon: ServerCog }}>
|
||||
Workers
|
||||
</Button>
|
||||
<Button variant="default" unifiedSize="sm" href="/service_logs" startIcon={{ icon: Logs }}>
|
||||
Service logs
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Settings are for someone who lives here; a user with no workspace yet has one thing
|
||||
to do, so this row stands down for the create state. Logging out is in this menu as
|
||||
well as on the subtitle line, which is the only one of the two the create state has. -->
|
||||
|
||||
@@ -602,9 +602,12 @@
|
||||
if (!config || !config.worker_tags || config.worker_tags.length === 0) cols += 1 // Worker tags
|
||||
if ((!config || config?.dedicated_worker == undefined) && ($superadmin || $devopsRole))
|
||||
cols += 2 // Last job, Occupancy rate
|
||||
if ($superadmin || $devopsRole) cols += 1 // Repl
|
||||
if (replAvailable) cols += 1 // Repl
|
||||
return cols
|
||||
})
|
||||
// The REPL runs its commands as jobs in the selected workspace, which a devops user who
|
||||
// belongs to no workspace does not have (see `isInstanceRoute`).
|
||||
let replAvailable = $derived(($superadmin || $devopsRole) && !!$workspaceStore)
|
||||
</script>
|
||||
|
||||
{#if $superadmin || $devopsRole}
|
||||
@@ -1022,7 +1025,7 @@
|
||||
<Cell head>Limits</Cell>
|
||||
<Cell head>Version</Cell>
|
||||
<Cell head>Status</Cell>
|
||||
{#if $superadmin || $devopsRole}
|
||||
{#if replAvailable}
|
||||
<Cell head>
|
||||
Repl
|
||||
<Tooltip>
|
||||
@@ -1152,13 +1155,17 @@
|
||||
<Cell class="text-secondary">{jobs_executed}</Cell>
|
||||
{#if (!config || config?.dedicated_worker == undefined) && ($superadmin || $devopsRole)}
|
||||
<Cell class="text-secondary">
|
||||
{#if last_job_id}
|
||||
<!-- The run page switches to the job's workspace, which strands a
|
||||
devops user who belongs to none of them. -->
|
||||
{#if last_job_id && $workspaceStore}
|
||||
<a href={`/run/${last_job_id}?workspace=${last_job_workspace_id}`}>
|
||||
View last job
|
||||
<ExternalLink size={12} class="inline-block" />
|
||||
</a>
|
||||
<br />
|
||||
{last_job_workspace_id}
|
||||
{:else if last_job_id}
|
||||
{last_job_workspace_id}
|
||||
{/if}
|
||||
</Cell>
|
||||
<Cell class="text-secondary">
|
||||
@@ -1277,7 +1284,7 @@
|
||||
<div class="text-2xs text-hint">{pingText}</div>
|
||||
</div>
|
||||
</Cell>
|
||||
{#if $superadmin || $devopsRole}
|
||||
{#if replAvailable}
|
||||
<Cell class="text-secondary">
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
import { onDestroy, onMount } from 'svelte'
|
||||
|
||||
import { refreshSuperadmin } from '$lib/refreshUser'
|
||||
import { isInstanceRoute } from '$lib/instanceRoutes'
|
||||
// import EditorTheme from '$lib/components/EditorTheme.svelte'
|
||||
import { computeDrift } from '$lib/forLater'
|
||||
import { setLicense } from '$lib/enterpriseUtils'
|
||||
@@ -155,7 +156,8 @@
|
||||
// The hub import wizard asks for the destination itself, and may end in a
|
||||
// workspace that does not exist yet — bouncing it to the picker would
|
||||
// force the very choice it exists to make.
|
||||
!page.url.pathname.startsWith('/projects/import')
|
||||
!page.url.pathname.startsWith('/projects/import') &&
|
||||
!((user.devops || user.super_admin) && isInstanceRoute(page.url.pathname))
|
||||
) {
|
||||
goto(`/user/workspaces?rd=${encodeURIComponent(page.url.href.replace(page.url.origin, ''))}`)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user