Files
GitLite/src/lib/components/CredentialDialog.svelte
T
Christoph 0dac3e9f09 feat(remote): support authenticated deletion of remote branches
Add support for authenticated deletion of remote branches and folders.
The backend adds a batch delete command that queries the remote,
performs an atomic push --delete when possible with a fallback, and
prunes stale tracking refs; it accepts optional credentials. The
frontend and JS API integrate credential handling and prompt users when
authentication is required.

- New batch deletion command with optional username/password for auth.
- Uses ls-remote to scope deletions, tries --atomic then falls back.
- Frontend updates: credential dialog/action, pendingRemoteDelete state.
2026-08-15 19:38:39 +02:00

220 lines
7.0 KiB
Svelte

<script lang="ts">
import { untrack } from "svelte";
import {
AlertCircle,
Download,
Eye,
EyeOff,
Key,
LoaderCircle,
Lock,
ShieldCheck,
Upload,
User,
X,
} from "@lucide/svelte";
interface Props {
action: "push" | "pull" | "fetch" | "clone" | "rename" | "delete";
error: string;
isBusy: boolean;
initialUsername?: string;
initialMode?: Mode;
onSubmit: (username: string, password: string, save: boolean, mode: Mode) => void;
onCancel: () => void;
}
let {
action,
error = "",
isBusy = false,
initialUsername = "",
initialMode = "credentials",
onSubmit,
onCancel,
}: Props = $props();
type Mode = "credentials" | "token";
let mode = $state<Mode>(untrack(() => initialMode));
let username = $state(untrack(() => initialUsername === "oauth2" ? "" : initialUsername));
let password = $state("");
let showPassword = $state(false);
let saveSession = $state(true);
let canSubmit = $derived(
!isBusy &&
password.trim().length > 0 &&
username.trim().length > 0,
);
let actionLabel = $derived(action === "push" ? "Push" : action === "fetch" ? "Fetch" : action === "clone" ? "Clone" : action === "rename" ? "Rename" : action === "delete" ? "Delete" : "Pull");
let actionTitle = $derived(
action === "push"
? "Authenticate push"
: action === "rename"
? "Authenticate remote rename"
: action === "delete"
? "Authenticate remote deletion"
: action === "fetch"
? "Authenticate fetch"
: action === "clone"
? "Authenticate clone"
: "Authenticate pull",
);
let actionHint = $derived(action === "push" || action === "rename" || action === "delete"
? "The remote needs write access. Use a password or a token with the appropriate repository permissions."
: action === "clone"
? "The repository needs access before it can be cloned. Use your Git credentials or a personal access token."
: "The remote needs access to the repository. Use your Git credentials or a personal access token.");
function handleSubmit(e: SubmitEvent) {
e.preventDefault();
if (!canSubmit) return;
onSubmit(username.trim(), password, saveSession, mode);
}
</script>
<div
class="dialog-backdrop"
role="presentation"
>
<div class="cred-card" role="dialog" aria-modal="true" aria-label="Git credentials" tabindex="-1">
<div class="cred-hero">
<div class="cred-hero-top">
<div class="cred-hero-icon">
{#if action === "push" || action === "rename" || action === "delete"}
<Upload size={27} aria-hidden="true" />
{:else}
<Download size={27} aria-hidden="true" />
{/if}
</div>
<div class="cred-hero-text">
<p class="cred-hero-label">{actionLabel} Remote</p>
<h2 class="cred-hero-title">{actionTitle}</h2>
</div>
<button class="cred-close" type="button" onclick={onCancel} title="Cancel" aria-label="Cancel">
<X size={16} aria-hidden="true" />
</button>
</div>
<p class="cred-hero-copy">{actionHint}</p>
<div class="cred-security-note">
<ShieldCheck size={14} aria-hidden="true" />
<span>When saved, the credentials are stored encrypted in the operating system's keychain — never in plain text.</span>
</div>
</div>
<form class="cred-body" onsubmit={handleSubmit}>
<div class="cred-segment" role="group" aria-label="Authentication method">
<button
type="button"
class="cred-seg-btn"
class:active={mode === "credentials"}
onclick={() => { mode = "credentials"; }}
aria-pressed={mode === "credentials"}
>
<User size={13} aria-hidden="true" />
Username + password
</button>
<button
type="button"
class="cred-seg-btn"
class:active={mode === "token"}
onclick={() => { mode = "token"; }}
aria-pressed={mode === "token"}
>
<Key size={13} aria-hidden="true" />
Access token
</button>
</div>
<div class="cred-fields">
<div class="cred-field">
<label class="cred-field-label" for="cred-username">Username</label>
<div class="cred-input">
<User size={15} class="cred-field-icon" aria-hidden="true" />
<input
id="cred-username"
type="text"
bind:value={username}
placeholder="Your account username"
autocomplete="username"
disabled={isBusy}
/>
</div>
</div>
<div class="cred-field">
<label class="cred-field-label" for="cred-password">
{mode === "token" ? "Token" : "Password"}
</label>
<div class="cred-input">
<Lock size={15} class="cred-field-icon" aria-hidden="true" />
<input
id="cred-password"
type={showPassword ? "text" : "password"}
bind:value={password}
placeholder={mode === "token"
? "ghp_... or another access token"
: "Password or personal access token"}
autocomplete="current-password"
disabled={isBusy}
/>
<button
type="button"
class="cred-reveal"
onclick={() => { showPassword = !showPassword; }}
tabindex="-1"
aria-label={showPassword ? "Hide" : "Show"}
>
{#if showPassword}
<EyeOff size={14} aria-hidden="true" />
{:else}
<Eye size={14} aria-hidden="true" />
{/if}
</button>
</div>
</div>
</div>
{#if mode === "token"}
<div class="cred-token-hint">
<Key size={13} aria-hidden="true" />
<span>Use your normal account username. The access token is sent as the password, as required by Gitea and most Git providers.</span>
</div>
{/if}
{#if error}
<div class="cred-error" role="alert">
<AlertCircle size={14} aria-hidden="true" />
<span>{error}</span>
</div>
{/if}
<div class="cred-footer">
<label class="cred-save">
<input type="checkbox" bind:checked={saveSession} disabled={isBusy} />
<span>Save in keychain</span>
</label>
<div class="cred-btns">
<button type="button" class="cred-cancel" onclick={onCancel} disabled={isBusy}>
Cancel
</button>
<button class="cred-submit" type="submit" disabled={!canSubmit}>
{#if isBusy}
<LoaderCircle class="spin" size={15} aria-hidden="true" />
{:else if action === "push"}
<Upload size={15} aria-hidden="true" />
{:else}
<Download size={15} aria-hidden="true" />
{/if}
{actionLabel}
</button>
</div>
</div>
</form>
</div>
</div>