f514602697
Part A — a fetch/pull/push against a remote that needs a login no longer dies on "terminal prompts disabled": the server answers 409 EGITAUTH with the host, the git bar asks for a username + password/access token and resends the same action. AgentHub keeps its OWN credential store (<CLAUDE_HOME>/agenthub-git-credentials/, 0700/0600), one login per host, shared by every user of the install, managed from Settings -> Git credentials. A login is only sent to one of the repo's own remote hosts, saved only after git accepted it, never returned by any route, and reaches git through an exclusive credential helper (never argv). Part B — merge a branch (or the diverged upstream) from the git bar; a merge that stops on conflicts lists them in the Changes panel with Keep mine / Take theirs / Mark resolved, then Complete merge or Abort merge (confirm gate). Clean tree, repo-root lane and an existing ref only; pull/switch/hunk actions are blocked mid-merge; staging a conflicted file with markers left is refused. Rebase/reset/stash stay out.
207 lines
7.8 KiB
TypeScript
207 lines
7.8 KiB
TypeScript
/**
|
|
* @file GitCredentialsConfig.tsx
|
|
* @description The **Git credentials** tab of Settings (issue #45) — the
|
|
* logins AgentHub itself gives git for a dashboard fetch/pull/push, one per
|
|
* host, shared by everyone using this install. Lists them, replaces a token,
|
|
* adds one, deletes one. A token is write-only here: the server never returns
|
|
* one, so the form starts blank and the list shows only that one is saved.
|
|
* The git bar's login form saves into the same store.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
import { useCallback, useEffect, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { KeyRound, Pencil, Plus, Trash2 } from "lucide-react";
|
|
import { api, type GitCredential } from "../lib/api";
|
|
|
|
const INPUT =
|
|
"w-full rounded border border-border-light bg-surface-0 px-2 py-1 text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none";
|
|
|
|
export function GitCredentialsConfig() {
|
|
const { t } = useTranslation("settings");
|
|
const [items, setItems] = useState<GitCredential[]>([]);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [form, setForm] = useState<{ url: string; username: string; token: string } | null>(null);
|
|
|
|
const load = useCallback(async () => {
|
|
try {
|
|
setItems((await api.gitCredentials.list()).items);
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : String(e));
|
|
}
|
|
}, []);
|
|
useEffect(() => {
|
|
void load();
|
|
}, [load]);
|
|
|
|
const save = async () => {
|
|
if (!form) return;
|
|
setError(null);
|
|
let url: URL;
|
|
try {
|
|
url = new URL(form.url.trim());
|
|
} catch {
|
|
setError(t("gitCreds.badUrl"));
|
|
return;
|
|
}
|
|
try {
|
|
const r = await api.gitCredentials.save({
|
|
protocol: url.protocol.replace(/:$/, ""),
|
|
host: url.host,
|
|
username: form.username.trim(),
|
|
password: form.token,
|
|
});
|
|
setItems(r.items);
|
|
setForm(null);
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : String(e));
|
|
}
|
|
};
|
|
|
|
const remove = async (c: GitCredential) => {
|
|
if (!window.confirm(t("gitCreds.removeConfirm"))) return;
|
|
try {
|
|
setItems((await api.gitCredentials.remove(c.protocol, c.host)).items);
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : String(e));
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div>
|
|
<h2 className="flex items-center gap-2 text-sm font-semibold text-fg-primary">
|
|
<KeyRound className="h-4 w-4 text-fg-muted" />
|
|
{t("gitCreds.title")}
|
|
</h2>
|
|
<p className="mt-1 max-w-2xl text-xs text-fg-muted">{t("gitCreds.description")}</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => setForm({ url: "", username: "", token: "" })}
|
|
className="inline-flex shrink-0 items-center gap-1.5 rounded-lg border border-accent/40 bg-accent/15 px-3 py-1.5 text-xs font-medium text-accent transition-colors hover:bg-accent/25"
|
|
>
|
|
<Plus className="h-3.5 w-3.5" />
|
|
{t("gitCreds.add")}
|
|
</button>
|
|
</div>
|
|
|
|
{error && (
|
|
<p className="rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-xs text-status-danger">
|
|
{error}
|
|
</p>
|
|
)}
|
|
|
|
{form && (
|
|
<form
|
|
className="grid max-w-2xl gap-2 rounded-lg border border-border p-3 sm:grid-cols-3"
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
void save();
|
|
}}
|
|
>
|
|
<input
|
|
aria-label={t("gitCreds.fieldHost")}
|
|
placeholder="https://git.example.com"
|
|
value={form.url}
|
|
onChange={(e) => setForm({ ...form, url: e.target.value })}
|
|
className={`${INPUT} font-mono`}
|
|
/>
|
|
<input
|
|
aria-label={t("gitCreds.fieldUser")}
|
|
placeholder={t("gitCreds.fieldUser")}
|
|
autoComplete="off"
|
|
value={form.username}
|
|
onChange={(e) => setForm({ ...form, username: e.target.value })}
|
|
className={INPUT}
|
|
/>
|
|
<input
|
|
type="password"
|
|
aria-label={t("gitCreds.fieldToken")}
|
|
placeholder={t("gitCreds.fieldToken")}
|
|
autoComplete="new-password"
|
|
value={form.token}
|
|
onChange={(e) => setForm({ ...form, token: e.target.value })}
|
|
className={INPUT}
|
|
/>
|
|
<div className="flex gap-2 sm:col-span-3">
|
|
<button
|
|
type="submit"
|
|
disabled={!form.url.trim() || !form.username.trim() || !form.token}
|
|
className="rounded border border-accent/40 bg-accent/15 px-3 py-1 text-xs text-accent hover:bg-accent/25 disabled:cursor-not-allowed disabled:opacity-40"
|
|
>
|
|
{t("gitCreds.save")}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setForm(null)}
|
|
className="rounded border border-border-light px-3 py-1 text-xs text-fg-secondary hover:bg-surface-3"
|
|
>
|
|
{t("gitCreds.cancel")}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
)}
|
|
|
|
<div className="overflow-hidden rounded-lg border border-border">
|
|
<table className="w-full text-left text-xs">
|
|
<thead className="bg-surface-2 text-[10px] uppercase tracking-wider text-fg-muted">
|
|
<tr>
|
|
<th className="px-3 py-2 font-medium">{t("gitCreds.colHost")}</th>
|
|
<th className="px-3 py-2 font-medium">{t("gitCreds.colUser")}</th>
|
|
<th className="px-3 py-2 font-medium">{t("gitCreds.colSaved")}</th>
|
|
<th className="px-3 py-2" />
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{items.map((c) => (
|
|
<tr key={`${c.protocol}://${c.host}`} className="border-t border-border">
|
|
<td className="px-3 py-2 font-mono text-[11px] text-fg-primary">{c.host}</td>
|
|
<td className="px-3 py-2 text-fg-secondary">{c.username}</td>
|
|
<td className="px-3 py-2 text-fg-secondary">
|
|
{new Date(c.savedAt).toLocaleString()}
|
|
</td>
|
|
<td className="px-3 py-2 text-right">
|
|
<span className="inline-flex gap-1">
|
|
<button
|
|
type="button"
|
|
aria-label={`${t("gitCreds.replace")} ${c.host}`}
|
|
title={t("gitCreds.replace")}
|
|
onClick={() =>
|
|
setForm({
|
|
url: `${c.protocol}://${c.host}`,
|
|
username: c.username,
|
|
token: "",
|
|
})
|
|
}
|
|
className="rounded p-1 text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary"
|
|
>
|
|
<Pencil className="h-3.5 w-3.5" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
aria-label={`${t("gitCreds.remove")} ${c.host}`}
|
|
title={t("gitCreds.remove")}
|
|
onClick={() => void remove(c)}
|
|
className="rounded p-1 text-fg-muted transition-colors hover:bg-surface-3 hover:text-status-danger"
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
</button>
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
{items.length === 0 && (
|
|
<tr className="border-t border-border">
|
|
<td colSpan={4} className="px-3 py-4 text-center text-fg-muted">
|
|
{t("gitCreds.empty")}
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|