feat: replace a repository's stored token from its resource

This commit is contained in:
hugocasa
2026-09-04 19:16:23 +02:00
parent 57a6017054
commit 0d8c58ff1b
3 changed files with 153 additions and 8 deletions
@@ -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>