/** * @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. A right-click on a row * opens the same Replace / Remove (plus Copy host) as a menu. A bare host * (`git.example.com`) means https; Replace keeps the host fixed (it replaces * that host's login, never moves it), and a remove asks through `confirmDialog`. * @author Nguyễn Ngọc Trí Vĩ */ import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { Pencil, Plus, Trash2 } from "lucide-react"; import { api, type GitCredential } from "../lib/api"; import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../components/ContextMenu"; import { confirmDialog } from "../lib/confirm"; import { copyText } from "../lib/clipboard"; import { toast } from "../lib/toast"; 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([]); const [error, setError] = useState(null); // `replacing`: the host is the saved one's and stays fixed. const [form, setForm] = useState<{ url: string; username: string; token: string; replacing?: boolean; } | null>(null); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const ctx = useContextMenu(); // A menu item: close the menu, then act on the row it was opened on. const pick = (fn: (c: GitCredential) => unknown) => () => { const c = ctx.menu?.item; ctx.close(); if (c) fn(c); }; const load = useCallback(async () => { try { setItems((await api.gitCredentials.list()).items); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } finally { setLoading(false); } }, []); useEffect(() => { void load(); }, [load]); const save = async () => { if (!form) return; setError(null); let url: URL; const typed = form.url.trim(); try { // A bare host (no scheme) is the common case: take it as https. url = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(typed) ? typed : `https://${typed}`); } catch { setError(t("gitCreds.badUrl")); return; } setSaving(true); 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); toast.success(t("gitCreds.saved", { host: url.host })); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } finally { setSaving(false); } }; const replace = (c: GitCredential) => setForm({ url: `${c.protocol}://${c.host}`, username: c.username, token: "", replacing: true }); const remove = async (c: GitCredential) => { if ( !(await confirmDialog({ title: t("gitCreds.removeConfirm"), confirmLabel: t("gitCreds.remove"), })) ) return; try { setItems((await api.gitCredentials.remove(c.protocol, c.host)).items); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } }; return (
{/* The page header above already names the section. */}

{t("gitCreds.description")}

{error && (

{error}

)} {form && (
{ e.preventDefault(); void save(); }} > setForm({ ...form, url: e.target.value })} className={`${INPUT} font-mono read-only:opacity-70`} /> setForm({ ...form, username: e.target.value })} className={INPUT} /> setForm({ ...form, token: e.target.value })} className={INPUT} />
)}
{items.map((c) => ( ctx.open(e, c)} className="border-t border-border" > ))} {items.length === 0 && ( )}
{t("gitCreds.colHost")} {t("gitCreds.colUser")} {t("gitCreds.colSaved")}
{c.host} {c.username} {new Date(c.savedAt).toLocaleString()}
{loading ? t("gitCreds.loading") : t("gitCreds.empty")}
{ctx.menu && ( {t("gitCreds.replace")} void copyText(c.host))}>{t("rowMenu.copyHost")} void remove(c))}> {t("gitCreds.remove")} )}
); }