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:
Ruben Fiszel
2026-10-19 13:09:03 +02:00
committed by GitHub
co-authored by Claude Opus 5.5
parent adae5b32a3
commit a3a4fa5038
5 changed files with 84 additions and 19 deletions
+10
View File
@@ -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"
+3 -1
View File
@@ -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, ''))}`)
}