mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-18 16:02:10 +00:00
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:
+40
@@ -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"
|
||||
}
|
||||
+23
@@ -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"
|
||||
}
|
||||
+22
@@ -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"
|
||||
}
|
||||
+34
@@ -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 @@
|
||||
14315067c083d3361512de621b12e41dbe3b017d
|
||||
da5189cf69a453de3855057f41be0d84e5910707
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user