feat(github-app): hide cloud-only UI on self-managed + admin assignment UI (#9299)

* feat(github-app): hide cloud-only UI on self-managed + admin assignment UI

Two related UX fixes for the GitHub App self-managed (GHES) integration:

1. On self-managed instances, the per-installation Export button and the
   "Import installation from other instance" section in the workspace UI both
   hide. Both round-trip a JWT carrying only {installation_id, account_id} with
   no github_base_url, so they would produce broken cloud-style installs on a
   self-managed instance. The previous Export attempt also failed with
   "No JWT token received from server" because self-managed installs store an
   empty JWT by design.

2. New "Workspace assignments" panel in instance settings (GhesAppSettings.svelte)
   that auto-discovers installations of the configured GHES App and lets the
   super-admin assign them to specific workspaces. Workspace users without
   GitHub permissions no longer need to install the App themselves — the admin
   provisions the link from instance settings. Admin-provisioned installs show a
   "Provisioned by admin" badge in the workspace UI and can only be removed by
   the super-admin from instance settings.

Backend support is in the EE companion PR
windmill-labs/windmill-ee-private#588.

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

* chore: update ee-repo-ref to da5189cf69a453de3855057f41be0d84e5910707

This commit updates the EE repository reference after PR #588 was merged in windmill-ee-private.

Previous ee-repo-ref: d959b83ce413ad531e9cc28e0f8199cdecb73a31

New ee-repo-ref: da5189cf69a453de3855057f41be0d84e5910707

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
This commit is contained in:
Alexander Petric
2026-05-22 17:01:15 -04:00
committed by GitHub
parent daab561ec0
commit dcee8cc0d3
9 changed files with 542 additions and 44 deletions
@@ -0,0 +1,40 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n (elem->>'installation_id')::bigint as installation_id,\n elem->>'account_id' as account_id,\n elem->>'github_base_url' as github_base_url,\n COALESCE((elem->>'provisioned_by_admin')::bool, false) as \"provisioned_by_admin!\"\n FROM workspace_settings,\n LATERAL jsonb_array_elements(git_app_installations) AS elem\n WHERE workspace_id = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "installation_id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "account_id",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "github_base_url",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "provisioned_by_admin!",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null,
null,
null,
null
]
},
"hash": "14bc9dd1d02a3d121297509beacc27f3c29d1b3877c1f2e7c206f0e36ef18701"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT COALESCE((elem->>'provisioned_by_admin')::bool, false) as \"is_admin!\"\n FROM workspace_settings,\n LATERAL jsonb_array_elements(git_app_installations) AS elem\n WHERE workspace_id = $1\n AND (elem->>'installation_id')::bigint = $2\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "is_admin!",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text",
"Int8"
]
},
"nullable": [
null
]
},
"hash": "2f166b5575a614b028c3130fc5089353bef40f1cccf31b7775d0e9a800425f4d"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS(SELECT 1 FROM workspace_settings WHERE workspace_id = $1)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exists",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "3c42a56d0ffe39ad217f2ee603431637bcb22c6e713a21bdb83204de9cf383d7"
}
@@ -0,0 +1,34 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n workspace_id,\n (elem->>'installation_id')::bigint as \"installation_id!\",\n COALESCE((elem->>'provisioned_by_admin')::bool, false) as \"provisioned_by_admin!\"\n FROM workspace_settings,\n LATERAL jsonb_array_elements(git_app_installations) AS elem\n WHERE (elem->>'installation_id')::bigint = ANY($1)\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "workspace_id",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "installation_id!",
"type_info": "Int8"
},
{
"ordinal": 2,
"name": "provisioned_by_admin!",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Int8Array"
]
},
"nullable": [
false,
null,
null
]
},
"hash": "f5e98ff83301b89f33e4454ae944da1977030cf2db9dadd372188902bb23062f"
}
+1 -1
View File
@@ -1 +1 @@
14315067c083d3361512de621b12e41dbe3b017d
da5189cf69a453de3855057f41be0d84e5910707
+105
View File
@@ -2574,6 +2574,104 @@ paths:
- app_slug
- client_id
/github_app/ghes/discover:
get:
summary: Discover GHES App installations
description: |
Lists every installation the configured self-managed GitHub App can see,
annotated with the workspaces in this Windmill instance the
installation is currently assigned to. Super-admin only.
operationId: discoverGhesInstallations
tags:
- Git Sync
responses:
"200":
description: Discovered installations
content:
application/json:
schema:
type: array
items:
type: object
required:
- installation_id
- account_id
- assigned_workspaces
properties:
installation_id:
type: integer
format: int64
account_id:
type: string
description: GitHub login of the installation's account (org or user)
assigned_workspaces:
type: array
items:
type: object
required:
- workspace_id
- provisioned_by_admin
properties:
workspace_id:
type: string
provisioned_by_admin:
type: boolean
/github_app/ghes/assign:
post:
summary: Assign GHES installation to a workspace
description: |
Assigns a discovered GHES App installation to a workspace. The resulting
installation is marked as admin-provisioned, so workspace admins cannot
remove it. Super-admin only.
operationId: assignGhesInstallation
tags:
- Git Sync
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- workspace_id
- installation_id
properties:
workspace_id:
type: string
installation_id:
type: integer
format: int64
responses:
"200":
description: Installation assigned
/github_app/ghes/assign/{workspace_id}/{installation_id}:
delete:
summary: Unassign GHES installation from a workspace
description: |
Removes an installation (admin-provisioned or otherwise) from a
workspace. Super-admin only. Does not affect the installation on the
GitHub side.
operationId: unassignGhesInstallation
tags:
- Git Sync
parameters:
- name: workspace_id
in: path
required: true
schema:
type: string
- name: installation_id
in: path
required: true
schema:
type: integer
format: int64
responses:
"200":
description: Installation unassigned
/users/accept_invite:
post:
summary: accept invite to workspace
@@ -27823,6 +27921,13 @@ components:
error:
type: string
description: Error message if token retrieval failed
github_base_url:
type: string
nullable: true
description: Set for self-managed (GHES) installs. Cloud installs omit this field.
provisioned_by_admin:
type: boolean
description: True when the installation was assigned by the instance super-admin from instance settings. Workspace admins cannot remove these.
required:
- installation_id
- account_id
@@ -295,13 +295,21 @@
{#each githubState.workspaceGithubInstallations as installation (`current-${installation.installation_id}-${installation.workspace_id}`)}
<tr class="border-t border-gray-200 dark:border-gray-700">
<td class="py-2">
<div class="flex items-center gap-1">
<div class="flex items-center gap-1 flex-wrap">
{#if installation.error}
<span title={installation.error}>
<AlertTriangle class="w-4 h-4 text-yellow-500" />
</span>
{/if}
{installation.account_id}
{#if installation.provisioned_by_admin}
<span
class="text-2xs px-1.5 py-0.5 rounded bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200"
title="Assigned by the instance super-admin from instance settings. Only the super-admin can remove it."
>
Provisioned by admin
</span>
{/if}
</div>
</td>
<td class="py-2">
@@ -310,34 +318,41 @@
</td>
<td class="py-2 text-primary">
{#if installation.error}
<span class="text-yellow-600 dark:text-yellow-400 text-xs" title={installation.error}>Token error</span>
<span
class="text-yellow-600 dark:text-yellow-400 text-xs"
title={installation.error}>Token error</span
>
{:else}
{installation.repositories.length} repos
{/if}
</td>
<td class="py-2 text-right">
<div class="flex justify-end gap-1">
<Button
size="xs2"
variant="accent"
title="Export installation to other instance"
startIcon={{ icon: Download }}
on:click={() =>
handleExportInstallation(installation.installation_id)}
>
Export
</Button>
<Button
size="xs2"
variant="default"
destructive
title="Remove installation from workspace"
startIcon={{ icon: Minus }}
on:click={() =>
handleDeleteInstallation(installation.installation_id)}
>
Remove
</Button>
{#if !installation.github_base_url}
<Button
size="xs2"
variant="accent"
title="Export installation to other instance"
startIcon={{ icon: Download }}
on:click={() =>
handleExportInstallation(installation.installation_id)}
>
Export
</Button>
{/if}
{#if !installation.provisioned_by_admin}
<Button
size="xs2"
variant="default"
destructive
title="Remove installation from workspace"
startIcon={{ icon: Minus }}
on:click={() =>
handleDeleteInstallation(installation.installation_id)}
>
Remove
</Button>
{/if}
</div>
</td>
</tr>
@@ -381,7 +396,10 @@
</td>
<td class="py-2 text-primary">
{#if installation.error}
<span class="text-yellow-600 dark:text-yellow-400 text-xs" title={installation.error}>Token error</span>
<span
class="text-yellow-600 dark:text-yellow-400 text-xs"
title={installation.error}>Token error</span
>
{:else}
{installation.repositories.length} repos
{/if}
@@ -414,26 +432,28 @@
</div>
</div>
<div class="mt-4 flex flex-col gap-2">
<p class="text-sm font-semibold text-secondary"
>Import installation from other instance:</p
>
<div class="flex gap-2">
<input
type="text"
placeholder="Paste JWT token here"
bind:value={githubState.importJwt}
class="flex-1"
/>
<Button
variant="accent"
on:click={handleImportInstallation}
disabled={!githubState.importJwt}
{#if !githubState.isGhesSelfManaged}
<div class="mt-4 flex flex-col gap-2">
<p class="text-sm font-semibold text-secondary"
>Import installation from other instance:</p
>
Import
</Button>
<div class="flex gap-2">
<input
type="text"
placeholder="Paste JWT token here"
bind:value={githubState.importJwt}
class="flex-1"
/>
<Button
variant="accent"
on:click={handleImportInstallation}
disabled={!githubState.importJwt}
>
Import
</Button>
</div>
</div>
</div>
{/if}
</div>
</div>
{/snippet}
@@ -1,6 +1,12 @@
<script lang="ts">
import TextInput from '../text_input/TextInput.svelte'
import Toggle from '../Toggle.svelte'
import { Button } from '$lib/components/common'
import Select from '../select/Select.svelte'
import { Loader2, RotateCw, X } from 'lucide-svelte'
import { GitSyncService, WorkspaceService, type Workspace } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import Tooltip from '../Tooltip.svelte'
import type { Writable } from 'svelte/store'
interface Props {
@@ -17,6 +23,126 @@
let selfManaged = $derived(!!$values['github_enterprise_app'].self_managed)
let fieldsDisabled = $derived(disabled || !selfManaged)
// --- Workspace assignments ---
type Discovered = {
installation_id: number
account_id: string
assigned_workspaces: Array<{ workspace_id: string; provisioned_by_admin: boolean }>
}
let discovered: Discovered[] = $state([])
let workspaces: Workspace[] = $state([])
let loadingDiscovery = $state(false)
let discoveryError: string | undefined = $state(undefined)
// True once we've attempted at least one discovery call. Prevents the
// auto-prefetch $effect from re-firing when a successful refresh
// legitimately returns zero installations.
let hasDiscovered = $state(false)
// installation_id -> workspace_id picked in the row's dropdown
let pickedWorkspace: Record<number, string | undefined> = $state({})
// installation_id -> in-flight assign/unassign (so the row spinner shows)
let busyInstallation: Record<number, boolean> = $state({})
async function loadWorkspaces() {
try {
const perPage = 100
const all: Workspace[] = []
let page = 1
// Defensive cap; an instance with >10k workspaces is implausible.
while (page <= 100) {
const batch = await WorkspaceService.listWorkspacesAsSuperAdmin({ page, perPage })
all.push(...batch)
if (batch.length < perPage) break
page++
}
workspaces = all
} catch (err: any) {
console.error('Failed to list workspaces:', err)
sendUserToast(
`Failed to load workspaces for assignment: ${err?.body?.error?.message || err?.message || 'unknown error'}`,
true
)
}
}
async function refreshDiscovery() {
loadingDiscovery = true
discoveryError = undefined
hasDiscovered = true
try {
discovered = await GitSyncService.discoverGhesInstallations()
} catch (err: any) {
discoveryError = err?.body?.error?.message || err?.message || 'Failed to load installations'
discovered = []
} finally {
loadingDiscovery = false
}
}
function workspaceOptions(install: Discovered) {
const assignedIds = new Set(install.assigned_workspaces.map((a) => a.workspace_id))
return workspaces
.filter((w) => !assignedIds.has(w.id))
.map((w) => ({ label: `${w.name} (${w.id})`, value: w.id }))
}
async function assign(installation_id: number) {
const workspace_id = pickedWorkspace[installation_id]
if (!workspace_id) return
busyInstallation[installation_id] = true
try {
await GitSyncService.assignGhesInstallation({
requestBody: { workspace_id, installation_id }
})
sendUserToast(`Assigned installation to workspace ${workspace_id}`, false)
pickedWorkspace[installation_id] = undefined
await refreshDiscovery()
} catch (err: any) {
sendUserToast(
`Failed to assign installation: ${err?.body?.error?.message || err?.message || 'unknown error'}`,
true
)
} finally {
busyInstallation[installation_id] = false
}
}
async function unassign(installation_id: number, workspace_id: string) {
busyInstallation[installation_id] = true
try {
await GitSyncService.unassignGhesInstallation({
workspaceId: workspace_id,
installationId: installation_id
})
sendUserToast(`Unassigned installation from workspace ${workspace_id}`, false)
await refreshDiscovery()
} catch (err: any) {
sendUserToast(
`Failed to unassign installation: ${err?.body?.error?.message || err?.message || 'unknown error'}`,
true
)
} finally {
busyInstallation[installation_id] = false
}
}
let assignmentsReady = $derived(
selfManaged &&
!!$values['github_enterprise_app'].base_url &&
!!$values['github_enterprise_app'].app_id &&
!!$values['github_enterprise_app'].private_key
)
$effect(() => {
if (assignmentsReady && workspaces.length === 0) {
loadWorkspaces()
}
if (assignmentsReady && !hasDiscovered) {
refreshDiscovery()
}
})
</script>
<div class="space-y-6">
@@ -185,4 +311,121 @@
bind:value={$values['github_enterprise_app'].private_key}
></textarea>
</div>
{#if assignmentsReady}
<div class="border-t border-gray-200 dark:border-gray-700 pt-4">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold text-emphasis">Workspace assignments</h3>
<Button
size="xs"
variant="default"
on:click={refreshDiscovery}
disabled={loadingDiscovery}
startIcon={{
icon: loadingDiscovery ? Loader2 : RotateCw,
classes: loadingDiscovery ? 'animate-spin' : ''
}}
>
Refresh
</Button>
</div>
<p class="text-xs text-secondary mt-1">
Assign installations of the configured GitHub App to specific workspaces so workspace users
don't need GitHub permissions to set up sync. Click <strong>Refresh</strong> to load installations
the App can see (save the config above first if you haven't).
</p>
{#if discoveryError}
<p class="text-xs text-red-600 dark:text-red-400 mt-2">{discoveryError}</p>
{:else if loadingDiscovery && discovered.length === 0}
<div class="mt-2"><Loader2 class="w-4 h-4 animate-spin" /></div>
{:else if discovered.length === 0}
<p class="text-xs text-secondary mt-2">
The configured GitHub App has no installations yet. Install it on a GitHub account, then
click <strong>Refresh</strong>.
</p>
{:else}
<table class="w-full text-sm mt-3">
<thead>
<tr class="text-left text-xs text-secondary">
<th class="pb-2">
GitHub account
<Tooltip>
The GitHub organization or user the App is installed on (e.g.
<code>windmill-labs</code>). A GitHub App installation is always scoped to exactly
one account.
</Tooltip>
</th>
<th class="pb-2">Installation ID</th>
<th class="pb-2">Assigned to</th>
<th class="pb-2"></th>
</tr>
</thead>
<tbody>
{#each discovered as install (install.installation_id)}
<tr class="border-t border-gray-200 dark:border-gray-700 align-top">
<td class="py-2 pr-2">{install.account_id}</td>
<td class="py-2 pr-2 font-mono text-xs">{install.installation_id}</td>
<td class="py-2 pr-2">
{#if install.assigned_workspaces.length === 0}
<span class="text-xs text-tertiary"></span>
{:else}
<div class="flex flex-wrap gap-1">
{#each install.assigned_workspaces as assignment (assignment.workspace_id)}
<span
class="inline-flex items-center gap-1 text-2xs px-1.5 py-0.5 rounded {assignment.provisioned_by_admin
? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'
: 'bg-surface-secondary text-secondary'}"
title={assignment.provisioned_by_admin
? 'Provisioned from instance settings'
: 'Added by the workspace itself'}
>
{assignment.workspace_id}
<button
type="button"
class="hover:text-red-600 dark:hover:text-red-400 disabled:opacity-50"
disabled={busyInstallation[install.installation_id]}
title={assignment.provisioned_by_admin
? 'Unassign from this workspace'
: 'Force-remove from this workspace'}
onclick={() =>
unassign(install.installation_id, assignment.workspace_id)}
>
<X class="w-3 h-3" />
</button>
</span>
{/each}
</div>
{/if}
</td>
<td class="py-2">
<div class="flex items-center gap-1 justify-end">
<div class="w-56">
<Select
items={workspaceOptions(install)}
bind:value={pickedWorkspace[install.installation_id]}
placeholder="Pick a workspace…"
size="sm"
clearable
noItemsMsg="No more workspaces to assign"
/>
</div>
<Button
size="xs"
variant="accent"
disabled={!pickedWorkspace[install.installation_id] ||
busyInstallation[install.installation_id]}
on:click={() => assign(install.installation_id)}
>
Assign
</Button>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
{/if}
</div>
+12 -1
View File
@@ -16,6 +16,13 @@ export interface GitHubAppState {
installationCheckInterval: number | undefined
isCheckingInstallation: boolean
importJwt: string
/**
* True when the instance has a self-managed (GHES) GitHub App configured.
* Used to hide cloud-only UI like the Export/Import buttons, since those
* JWTs carry no `github_base_url` and would round-trip into broken
* github.com-pointed installs.
*/
isGhesSelfManaged: boolean
}
export interface GitHubRepository {
@@ -99,7 +106,8 @@ export function createGitHubAppState(): GitHubAppState {
githubInstallationUrl: undefined,
installationCheckInterval: undefined,
isCheckingInstallation: false,
importJwt: ''
importJwt: '',
isGhesSelfManaged: false
}
}
@@ -137,6 +145,7 @@ export async function loadGithubInstallations(
try {
const ghesConfig: GetGhesConfigResponse = await GitSyncService.getGhesConfig()
if (ghesConfig?.base_url && ghesConfig?.app_slug) {
state.isGhesSelfManaged = true
const ghesBaseUrl = ghesConfig.base_url.replace(/\/$/, '')
// GHES (self-hosted) uses /github-apps/, github.com and GHE Cloud (*.ghe.com) use /apps/
const hostname = new URL(ghesBaseUrl).hostname
@@ -149,10 +158,12 @@ export async function loadGithubInstallations(
: ghesConfig.app_slug
state.githubInstallationUrl = `${ghesBaseUrl}/${appsPath}/${appPath}/installations/new?state=${stateParam}`
} else {
state.isGhesSelfManaged = false
state.githubInstallationUrl = `https://github.com/apps/windmill-sync-helper/installations/new?state=${stateParam}`
}
} catch {
// No GHES config — use default github.com URL
state.isGhesSelfManaged = false
state.githubInstallationUrl = `https://github.com/apps/windmill-sync-helper/installations/new?state=${stateParam}`
}
} catch (err) {