Files
windmill/frontend/src/lib/components/GlobalUserOffboardingModal.svelte
T
hugocasaandClaude Opus 4.8 35861641f8 fix(offboarding): make global reassignment per-workspace and optional (#9863)
* 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>
2026-07-01 14:42:02 +00:00

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}