mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-07 16:03:21 +00:00
* fix(offboarding): make global reassignment per-workspace and optional Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(offboarding): handle sole-member workspaces in workspace-level removal Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(offboarding): show close action instead of dead-end in reassign-only sole-member case Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(offboarding): prevent no-op success in global reassign-only with no reassignable workspace Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
372 lines
12 KiB
Svelte
372 lines
12 KiB
Svelte
<script lang="ts">
|
|
import { Alert, Button } from '$lib/components/common'
|
|
import { fade } from 'svelte/transition'
|
|
import { classNames } from '$lib/utils'
|
|
import { AlertTriangle, CornerDownLeft, Loader2 } from 'lucide-svelte'
|
|
import { UserService, FolderService } from '$lib/gen'
|
|
import type { WorkspaceOffboardPreview } from '$lib/gen'
|
|
import { sendUserToast } from '$lib/toast'
|
|
import Toggle from '$lib/components/Toggle.svelte'
|
|
import OffboardWorkspaceSection from './OffboardWorkspaceSection.svelte'
|
|
import { countPaths } from './offboarding-utils'
|
|
|
|
type Props = {
|
|
open: boolean
|
|
email: string
|
|
reassignOnly?: boolean
|
|
onClose: () => void
|
|
onComplete: () => void
|
|
}
|
|
|
|
let { open = $bindable(), email, reassignOnly = false, onClose, onComplete }: Props = $props()
|
|
|
|
let workspacePreviews: WorkspaceOffboardPreview[] = $state([])
|
|
let loading = $state(false)
|
|
let submitting = $state(false)
|
|
let doReassign = $state(true)
|
|
let deleteUser = $state(true)
|
|
let conflicts: string[] = $state([])
|
|
|
|
$effect(() => {
|
|
deleteUser = !reassignOnly
|
|
})
|
|
|
|
let wsConfigs: Record<
|
|
string,
|
|
{
|
|
reassign: boolean
|
|
targetKind: 'user' | 'folder'
|
|
selectedUser: string | undefined
|
|
selectedFolder: string | undefined
|
|
selectedOperator: string | undefined
|
|
users: Array<{ label: string; value: string }>
|
|
folders: Array<{ label: string; value: string }>
|
|
}
|
|
> = $state({})
|
|
|
|
let workspacesWithItems = $derived(
|
|
workspacePreviews.filter(
|
|
(wp) => countPaths(wp.preview.owned) > 0 || countPaths(wp.preview.executing_on_behalf) > 0
|
|
)
|
|
)
|
|
|
|
$effect(() => {
|
|
if (open) {
|
|
loadPreview()
|
|
}
|
|
})
|
|
|
|
async function loadPreview() {
|
|
loading = true
|
|
conflicts = []
|
|
try {
|
|
const result = await UserService.globalOffboardPreview({ email })
|
|
workspacePreviews = result.workspaces
|
|
|
|
const configPromises = result.workspaces
|
|
.filter(
|
|
(wp) => countPaths(wp.preview.owned) > 0 || countPaths(wp.preview.executing_on_behalf) > 0
|
|
)
|
|
.map(async (wp) => {
|
|
const [usernamesList, foldersList] = await Promise.all([
|
|
UserService.listUsernames({ workspace: wp.workspace_id }),
|
|
FolderService.listFolders({ workspace: wp.workspace_id })
|
|
])
|
|
const users = usernamesList
|
|
.filter((u: string) => u !== wp.username)
|
|
.map((u: string) => ({ label: u, value: u }))
|
|
return {
|
|
workspace_id: wp.workspace_id,
|
|
config: {
|
|
// Reassignment requires another workspace user to own items and back
|
|
// triggers/runnables; with no other user (e.g. single-member forks) it
|
|
// is impossible, so default off and leave items as-is.
|
|
reassign: users.length > 0,
|
|
targetKind: 'user' as const,
|
|
selectedUser: undefined as string | undefined,
|
|
selectedFolder: undefined as string | undefined,
|
|
selectedOperator: undefined as string | undefined,
|
|
users,
|
|
folders: foldersList.map((f: { name: string }) => ({
|
|
label: f.name,
|
|
value: f.name
|
|
}))
|
|
}
|
|
}
|
|
})
|
|
const configs = await Promise.all(configPromises)
|
|
for (const { workspace_id, config } of configs) {
|
|
wsConfigs[workspace_id] = config
|
|
}
|
|
} catch (e) {
|
|
sendUserToast('Failed to load offboard preview', true)
|
|
onClose()
|
|
} finally {
|
|
loading = false
|
|
}
|
|
}
|
|
|
|
function getReassignTo(wId: string): string | undefined {
|
|
const cfg = wsConfigs[wId]
|
|
if (!cfg) return undefined
|
|
return cfg.targetKind === 'user'
|
|
? cfg.selectedUser
|
|
? `u/${cfg.selectedUser}`
|
|
: undefined
|
|
: cfg.selectedFolder
|
|
? `f/${cfg.selectedFolder}`
|
|
: undefined
|
|
}
|
|
|
|
// At least one workspace can be reassigned (has another assignable user).
|
|
let anyReassignableWorkspace = $derived(
|
|
workspacesWithItems.some((wp) => (wsConfigs[wp.workspace_id]?.users.length ?? 0) > 0)
|
|
)
|
|
// At least one workspace is actually selected for reassignment.
|
|
let anyWorkspaceReassigned = $derived(
|
|
workspacesWithItems.some((wp) => wsConfigs[wp.workspace_id]?.reassign)
|
|
)
|
|
|
|
let canSubmit = $derived(
|
|
(!doReassign ||
|
|
workspacesWithItems.every((wp) => {
|
|
const cfg = wsConfigs[wp.workspace_id]
|
|
if (!cfg?.reassign) return true
|
|
const target = getReassignTo(wp.workspace_id)
|
|
if (!target) return false
|
|
if (!cfg?.selectedOperator) return false
|
|
return true
|
|
})) &&
|
|
// Reassign-only runs (no deletion) must reassign at least one workspace,
|
|
// otherwise the request is an empty no-op reported as success.
|
|
(deleteUser || anyWorkspaceReassigned)
|
|
)
|
|
|
|
async function submit() {
|
|
submitting = true
|
|
conflicts = []
|
|
try {
|
|
const reassignments: Record<string, { reassign_to: string; new_on_behalf_of_user?: string }> =
|
|
{}
|
|
if (doReassign) {
|
|
for (const wp of workspacesWithItems) {
|
|
const cfg = wsConfigs[wp.workspace_id]
|
|
if (!cfg?.reassign) continue
|
|
const target = getReassignTo(wp.workspace_id)
|
|
if (target) {
|
|
reassignments[wp.workspace_id] = {
|
|
reassign_to: target,
|
|
new_on_behalf_of_user: cfg?.selectedOperator
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const result = await UserService.offboardGlobalUser({
|
|
email,
|
|
requestBody: {
|
|
reassignments,
|
|
delete_user: deleteUser
|
|
}
|
|
})
|
|
if (result.conflicts && result.conflicts.length > 0) {
|
|
conflicts = result.conflicts
|
|
} else {
|
|
sendUserToast(
|
|
deleteUser
|
|
? `User ${email} offboarded successfully`
|
|
: `Items reassigned from ${email} successfully`
|
|
)
|
|
onComplete()
|
|
}
|
|
} catch (e) {
|
|
sendUserToast(`Offboarding failed: ${e}`, true)
|
|
} finally {
|
|
submitting = false
|
|
}
|
|
}
|
|
|
|
function fadeFast(node: HTMLElement) {
|
|
return fade(node, { duration: 100 })
|
|
}
|
|
</script>
|
|
|
|
{#if open}
|
|
<div transition:fadeFast|local class="fixed top-0 bottom-0 left-0 right-0 z-[5000]" role="dialog">
|
|
<div
|
|
class={classNames(
|
|
'fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity',
|
|
'ease-out duration-300 opacity-100'
|
|
)}
|
|
></div>
|
|
|
|
<div class="fixed inset-0 z-10 overflow-y-auto">
|
|
<div class="flex min-h-full items-center justify-center p-4">
|
|
<div
|
|
class="relative transform overflow-hidden rounded-lg bg-surface px-4 pt-5 pb-4 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl sm:p-6 max-h-[80vh] overflow-y-auto"
|
|
>
|
|
<div class="flex">
|
|
<div
|
|
class="flex h-12 w-12 items-center justify-center rounded-full bg-red-100 dark:bg-red-800/50"
|
|
>
|
|
<AlertTriangle class="text-red-500 dark:text-red-400" />
|
|
</div>
|
|
<div class="ml-4 text-left flex-1">
|
|
<h3 class="text-lg font-medium text-primary">
|
|
{reassignOnly ? 'Reassign user items globally' : 'Offboard user globally'}
|
|
</h3>
|
|
<p class="text-sm text-secondary mt-1">
|
|
{reassignOnly
|
|
? `Reassign items owned by ${email} across all workspaces`
|
|
: `Remove ${email} from instance and reassign their items`}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{#if loading}
|
|
<div class="flex items-center justify-center py-8">
|
|
<Loader2 class="animate-spin" size={24} />
|
|
<span class="ml-2 text-sm text-secondary">Loading preview...</span>
|
|
</div>
|
|
{:else}
|
|
<div class="mt-4 space-y-3">
|
|
{#if workspacesWithItems.length === 0}
|
|
<p class="text-sm text-secondary">
|
|
This user has no owned items in any workspace.
|
|
</p>
|
|
{:else}
|
|
{#if !reassignOnly}
|
|
<Toggle
|
|
bind:checked={doReassign}
|
|
size="xs"
|
|
options={{ right: 'Reassign items before removing' }}
|
|
/>
|
|
{/if}
|
|
|
|
{#if doReassign}
|
|
{#each workspacesWithItems as wp (wp.workspace_id)}
|
|
{@const cfg = wsConfigs[wp.workspace_id]}
|
|
<div class="border border-border rounded-md p-3 space-y-2">
|
|
<div class="flex items-center justify-between">
|
|
<span class="text-sm font-medium text-primary">
|
|
{wp.workspace_id}
|
|
<span class="text-secondary font-normal">({wp.username})</span>
|
|
</span>
|
|
{#if cfg}
|
|
<Toggle
|
|
bind:checked={cfg.reassign}
|
|
disabled={cfg.users.length === 0}
|
|
size="xs"
|
|
options={{ right: 'Reassign' }}
|
|
/>
|
|
{/if}
|
|
</div>
|
|
|
|
{#if cfg}
|
|
{#if cfg.users.length === 0}
|
|
<p class="text-xs text-tertiary">
|
|
No other users in this workspace. Items will be left as-is.
|
|
</p>
|
|
{:else if cfg.reassign}
|
|
<OffboardWorkspaceSection
|
|
preview={wp.preview}
|
|
username={wp.username}
|
|
{deleteUser}
|
|
bind:targetKind={cfg.targetKind}
|
|
bind:selectedUser={cfg.selectedUser}
|
|
bind:selectedFolder={cfg.selectedFolder}
|
|
bind:selectedOperator={cfg.selectedOperator}
|
|
users={cfg.users}
|
|
folders={cfg.folders}
|
|
size="sm"
|
|
csvFilename="offboard-{email}-{wp.workspace_id}.csv"
|
|
instanceLevel
|
|
/>
|
|
{:else}
|
|
<p class="text-xs text-tertiary">Items will be left as-is.</p>
|
|
{/if}
|
|
{/if}
|
|
</div>
|
|
{/each}
|
|
{:else}
|
|
<Alert type="warning" title="Items will not be reassigned">
|
|
<p class="text-xs">
|
|
All items across {workspacesWithItems.length} workspace(s) will be left as-is.
|
|
Triggers and runnables may stop working if the user is removed.
|
|
</p>
|
|
</Alert>
|
|
{/if}
|
|
{/if}
|
|
|
|
{#if deleteUser}
|
|
<Alert type="warning" title="All tokens will be deleted">
|
|
<p class="text-xs">
|
|
All tokens for {email} will be permanently deleted across all workspaces, including
|
|
non-workspace-scoped tokens. This may break any API calls using these credentials.
|
|
</p>
|
|
</Alert>
|
|
{/if}
|
|
|
|
{#if workspacePreviews.length > workspacesWithItems.length}
|
|
<p class="text-xs text-tertiary">
|
|
{workspacePreviews.length - workspacesWithItems.length} workspace(s) with no items
|
|
to reassign
|
|
</p>
|
|
{/if}
|
|
|
|
{#if conflicts.length > 0}
|
|
<Alert type="error" title="Path conflicts detected">
|
|
<p class="text-xs mb-1"
|
|
>These items already exist at the target. Rename or delete them, or choose a
|
|
different user/folder.</p
|
|
>
|
|
<ul class="text-xs list-disc list-inside max-h-32 overflow-y-auto">
|
|
{#each conflicts as conflict, i (i)}
|
|
<li>{conflict}</li>
|
|
{/each}
|
|
</ul>
|
|
</Alert>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
|
|
<div class="flex items-center space-x-2 flex-row-reverse space-x-reverse mt-4">
|
|
{#if !deleteUser && workspacesWithItems.length > 0 && !anyReassignableWorkspace}
|
|
{#if !loading}
|
|
<Button onclick={onClose} variant="accent" size="sm">Close</Button>
|
|
{/if}
|
|
{:else if workspacesWithItems.length > 0 || deleteUser}
|
|
<Button
|
|
disabled={submitting || !canSubmit}
|
|
onclick={submit}
|
|
variant="accent"
|
|
size="sm"
|
|
destructive
|
|
shortCut={{ Icon: CornerDownLeft, hide: false, withoutModifier: true }}
|
|
>
|
|
{#if submitting}
|
|
<Loader2 class="animate-spin" />
|
|
{/if}
|
|
<span class="min-w-20">
|
|
{deleteUser ? 'Offboard' : 'Reassign'}
|
|
</span>
|
|
</Button>
|
|
{:else if !loading}
|
|
<Button onclick={onClose} variant="accent" size="sm">Close</Button>
|
|
{/if}
|
|
<Button
|
|
disabled={submitting}
|
|
onclick={onClose}
|
|
variant="default"
|
|
size="sm"
|
|
shortCut={{ key: 'Esc', hide: false, withoutModifier: true }}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|