mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-06 00:02:13 +00:00
feat: replace a repository's stored token from its resource
This commit is contained in:
@@ -14,6 +14,7 @@
|
||||
import { clearJsonSchemaResourceCache } from './schema/jsonSchemaResource.svelte'
|
||||
import ResourceForm from './ResourceForm.svelte'
|
||||
import { managedCredentialHost } from './git_sync/managedCredential'
|
||||
import ReplaceGitCredential from './git_sync/ReplaceGitCredential.svelte'
|
||||
import { invalidateWorkspacePaths } from './PathNameAutocomplete.svelte'
|
||||
import Alert from './common/alert/Alert.svelte'
|
||||
import { resource } from 'runed'
|
||||
@@ -151,6 +152,16 @@
|
||||
|
||||
let current = $derived(selected ? states[selected]?.draft : undefined)
|
||||
let managedHost = $derived(managedCredentialHost(current?.args))
|
||||
// The saved URL, not the draft's: a credential is bound to the repository it
|
||||
// is issued for, so binding one to an edit that has not landed yet would tie
|
||||
// it to something the resource does not point at.
|
||||
let deployedUrl = $derived(
|
||||
selected ? ((fetchedResources[selected]?.value as any)?.url as string | undefined) : undefined
|
||||
)
|
||||
// Only an unsaved *URL* blocks replacing the token, not any unsaved change:
|
||||
// opening the drawer materialises schema defaults (`folder: ""`), so a whole-
|
||||
// resource dirty check would disable it the moment the drawer opens.
|
||||
let urlDirty = $derived(!!deployedUrl && current?.args?.url !== deployedUrl)
|
||||
let resourceToEdit: Resource | undefined = $derived(
|
||||
selected ? fetchedResources[selected] : undefined
|
||||
)
|
||||
@@ -412,12 +423,24 @@
|
||||
</Alert>
|
||||
{/if}
|
||||
|
||||
{#if managedHost}
|
||||
{#if managedHost && selected}
|
||||
<Alert type="info" title="Windmill holds this repository's access token">
|
||||
The URL below carries no credential. Windmill renews the token before it expires and hands
|
||||
it to this workspace's sync jobs, and forks of this workspace use it without storing their
|
||||
own copy. To replace it, pick the project again with the
|
||||
{managedHost === 'gitlab' ? 'GitLab' : 'git'} button below.
|
||||
<div class="flex flex-col items-start gap-2">
|
||||
<div>
|
||||
The URL below carries no credential. Windmill renews the token before it expires and
|
||||
hands it to this workspace's sync jobs, and forks of this workspace use it without
|
||||
storing their own copy.
|
||||
{#if urlDirty}
|
||||
Save the URL change to replace the token.
|
||||
{/if}
|
||||
</div>
|
||||
<ReplaceGitCredential
|
||||
workspace={selected}
|
||||
resourcePath={current?.path ?? initialPath ?? ''}
|
||||
repoUrl={deployedUrl ?? ''}
|
||||
disabled={urlDirty || !deployedUrl}
|
||||
/>
|
||||
</div>
|
||||
</Alert>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -226,9 +226,15 @@
|
||||
return {
|
||||
type: days <= 7 ? ('error' as const) : days <= 14 ? ('warning' as const) : ('info' as const),
|
||||
title: `Repository token ${when}`,
|
||||
body: canSelfRotate
|
||||
? 'Windmill cannot renew it because it cannot write the new token back to where this URL is stored. Move the URL into a Windmill variable, or replace the token before it expires.'
|
||||
: 'Give the token the api or self_rotate scope and Windmill will renew it on its own. Otherwise, replace it before it expires to keep sync running.'
|
||||
body:
|
||||
(canSelfRotate
|
||||
? 'Windmill cannot renew it because it cannot write the new token back to where this URL is stored. Move the URL into a Windmill variable, or replace the token before it expires.'
|
||||
: 'Give the token the api or self_rotate scope and Windmill will renew it on its own. Otherwise, replace it before it expires to keep sync running.') +
|
||||
// The remedy lives with the credential, which the resource owns; saying
|
||||
// where stops the warning being a dead end.
|
||||
(managedCredential
|
||||
? ` Replace it on the ${repo?.git_repo_resource_path?.replace(/^\$res:/, '') ?? 'repository'} resource.`
|
||||
: '')
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
<script lang="ts">
|
||||
import { GitSyncService } from '$lib/gen'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import Popover from '../meltComponents/Popover.svelte'
|
||||
import Button from '../common/button/Button.svelte'
|
||||
import { Alert } from '../common'
|
||||
import TextInput from '../text_input/TextInput.svelte'
|
||||
import { KeyRound, Loader2 } from 'lucide-svelte'
|
||||
|
||||
interface Props {
|
||||
workspace: string
|
||||
/** Resource the credential is filed under. */
|
||||
resourcePath: string
|
||||
/** The repository as currently saved. The new token is bound to it, so a
|
||||
* URL edited but not yet saved would bind the credential to something the
|
||||
* resource does not point at; the caller disables this until it is saved. */
|
||||
repoUrl: string
|
||||
disabled?: boolean
|
||||
onReplaced?: () => void
|
||||
}
|
||||
|
||||
let { workspace, resourcePath, repoUrl, disabled = false, onReplaced }: Props = $props()
|
||||
|
||||
let token = $state('')
|
||||
let saving = $state(false)
|
||||
let error: string | undefined = $state(undefined)
|
||||
|
||||
/** The instance and project a repository URL names, for checking the pasted
|
||||
* token against the repository it is meant for. */
|
||||
function repoParts(url: string): { base: string; project: string } | undefined {
|
||||
try {
|
||||
const u = new URL(url)
|
||||
const project = u.pathname.replace(/^\/+/, '').replace(/\.git$/, '')
|
||||
return project ? { base: `${u.protocol}//${u.host}`, project } : undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
async function replace(close: (_: any) => void) {
|
||||
if (!token || saving) return
|
||||
saving = true
|
||||
error = undefined
|
||||
try {
|
||||
// Check the token before storing it. The server binds a credential to its
|
||||
// repository but only refuses it when something tries to use it, so a
|
||||
// wrong token would otherwise be accepted here and surface as a failed
|
||||
// sync later.
|
||||
const parts = repoParts(repoUrl)
|
||||
if (parts) {
|
||||
const projects = await GitSyncService.listGitlabProjects({
|
||||
workspace,
|
||||
requestBody: { base_url: parts.base, token }
|
||||
})
|
||||
if (!projects.some((p) => p.path_with_namespace === parts.project)) {
|
||||
error = `That token cannot push to ${parts.project}. Check its role and that it belongs to this project.`
|
||||
return
|
||||
}
|
||||
}
|
||||
await GitSyncService.setGitCredential({
|
||||
workspace,
|
||||
requestBody: { repo_path: resourcePath, repo_url: repoUrl, token }
|
||||
})
|
||||
token = ''
|
||||
sendUserToast('Token replaced')
|
||||
onReplaced?.()
|
||||
close(null)
|
||||
} catch (err) {
|
||||
error = err?.body ?? err?.message ?? String(err)
|
||||
} finally {
|
||||
saving = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Popover contentClasses="overflow-auto" {disabled}>
|
||||
{#snippet trigger()}
|
||||
<Button
|
||||
variant="default"
|
||||
unifiedSize="xs"
|
||||
{disabled}
|
||||
startIcon={{ icon: KeyRound }}
|
||||
nonCaptureEvent
|
||||
>
|
||||
Replace token
|
||||
</Button>
|
||||
{/snippet}
|
||||
{#snippet content({ close })}
|
||||
<div class="block text-primary p-4">
|
||||
<div class="flex flex-col gap-3 w-[420px]">
|
||||
<div class="flex flex-col gap-y-1">
|
||||
<div class="text-xs font-semibold text-emphasis">New access token</div>
|
||||
<div class="text-xs font-normal text-secondary">
|
||||
For the same repository. Windmill stores it in place of the current one and renews it
|
||||
from then on.
|
||||
</div>
|
||||
<TextInput bind:value={token} size="sm" inputProps={{ type: 'password' }} />
|
||||
</div>
|
||||
{#if error}
|
||||
<Alert type="error" title="Could not replace the token" size="xs">{error}</Alert>
|
||||
{/if}
|
||||
<div class="flex justify-end">
|
||||
<Button
|
||||
variant="accent"
|
||||
unifiedSize="sm"
|
||||
disabled={!token || saving}
|
||||
startIcon={{ icon: saving ? Loader2 : KeyRound, classes: saving ? 'animate-spin' : '' }}
|
||||
onclick={() => replace(close)}
|
||||
>
|
||||
Replace
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
Reference in New Issue
Block a user