Files
AgentHub/client/src/components/settings/GitIdentitySettings.tsx
T
nntrivi2001 7e41c81d53 feat(git-identity): import an existing SSH key and sign commits with it
Settings → Git gains Commit signing: one picker signs every commit
(terminal, Changes panel, agents) with one of the account's keys —
gpg.format ssh, user.signingkey = the key's private file, commit.gpgsign
true in its own ~/.gitconfig; a signing key the user set up themselves is
replaced only after a confirm (EFOREIGNSIGN without `replace`). Import a key
you have takes a key file or paste (OpenSSH or PEM, ed25519 or RSA >= 2048)
into ~/.config/agenthub/ssh/ as a named key, written like a generated one
(member: write-as, 0600), and by default signs with it in the same step.
A passphrase key, a .pub, an ecdsa key, a mismatched paste and the same key
twice are refused; only the public half ever comes back. Deleting the
signing key stops signing so commits keep working.

The SSH tab is renamed "Git (GitHub, GitLab, Gitea, Backlog…)" with the
Gitea and Backlog steps, and the File Explorer's ESECRET toast now says
that top-level dot folders of a home are edited in a terminal, and where
the SSH keys live instead.
2026-11-02 16:12:57 +07:00

1160 lines
44 KiB
TypeScript

/**
* @file GitIdentitySettings.tsx
* @description Settings → Git → **Your git identity** (`/api/me/git`): the
* commit author (name + email in the account's global git config — prefilled
* from Settings → Account while unset, the email else `<username>@smartgift.vn`,
* since a member's commits fail without one; a new SSH key is labelled with
* that email), the SSH
* keys a clone (Add workspace → Clone) and the pushes from that clone use —
* an ED25519 and/or an RSA one (AWS CodeCommit takes only RSA) — make one, copy its public half to add to GitHub/GitLab, replace or delete
* it, and whether git typed in the account's own terminal uses it too (a
* switch over `core.sshCommand` in its `~/.gitconfig`, issue #82), the SSH
* login name per host (AWS CodeCommit's SSH key ID, a `url.<…>.insteadOf` in
* that same file, so a plain `ssh://git-codecommit…` URL works), and for
* SEVERAL accounts (two AWS accounts, a second GitHub user) named keys made
* beside the default ones plus a login per account NAME (`ssh://<name>/…`),
* each tied to the one key it uses (the SSH part is split into tabs: one per
* kind of host, then the other hosts; a named key is a card of its own under
* its type's default one, with the same steps, and "Add another key" makes
* the next one below) — and, for
* a user with their own Linux account (S3), their own HTTPS
* logins. The host account's HTTPS logins are the shared Git credentials
* table above it, so this lists them only for a member. **Commit signing**
* (under the author): one picker signs every commit with one of the keys
* (`PUT /api/me/git/signing`), and **Import a key you have** takes a private
* key file or paste (`POST /api/me/git/ssh-key/import`) and, ticked by
* default, signs with it in the same step. The private key and
* every token are write-only: the server never returns them. A host identity
* seen by a non-admin is read-only.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import {
Cloud,
Copy,
FileKey,
GitBranch,
KeyRound,
PenLine,
Plus,
RefreshCw,
Server,
Trash2,
Upload,
} from "lucide-react";
import { api, type GitIdentity } from "../../lib/api";
import { confirmDialog } from "../../lib/confirm";
import { copyText } from "../../lib/clipboard";
import { toast } from "../../lib/toast";
import { useAuth } from "../AuthGate";
import { PageTabs } from "../PageTabs";
import { Switch } from "../Switch";
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";
const BTN =
"inline-flex items-center gap-1.5 rounded border border-border-light px-2.5 py-1 text-xs text-fg-secondary hover:bg-surface-3 disabled:cursor-not-allowed disabled:opacity-40";
const CODECOMMIT_RE = /^git-codecommit\.([a-z0-9-]+)\.amazonaws\.com$/;
// The host a key ID is saved under when no URL names one: AWS takes the same
// key ID in every region, so the server writes the default login for all.
const AWS_DEFAULT_HOST = "git-codecommit.us-east-1.amazonaws.com";
function Step({ n, children }: { n: number; children: ReactNode }) {
return (
<div className="flex gap-2 text-[11px] text-fg-secondary">
<span className="flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-surface-3 text-[10px] text-fg-primary">
{n}
</span>
<div className="min-w-0 flex-1">{children}</div>
</div>
);
}
type Key = NonNullable<GitIdentity["sshKeys"]>[number];
type SshUser = NonNullable<GitIdentity["sshUsers"]>[number];
/**
* One SSH key and the steps of the host it is for: no key → a Generate
* button; a key → step 1 (copy its public half) followed by the host's own
* steps. The default key of a type and every named one share it.
*/
function KeyCard({
title,
sub,
label,
k,
writable,
busy,
onGenerate,
onRemove,
children,
}: {
title: string;
sub: string;
/** What names the public key field ("RSA", "ED25519" or the key's name). */
label: string;
k: Key | undefined;
writable: boolean;
busy: boolean;
onGenerate: () => Promise<void>;
onRemove: () => Promise<void>;
children: ReactNode;
}) {
const { t } = useTranslation("settings");
return (
<section aria-label={title} className="space-y-2 rounded border border-border p-2.5">
<div className="flex flex-wrap items-center gap-2">
<div className="min-w-0 flex-1">
<div className="text-xs font-medium text-fg-primary">{title}</div>
<div className="text-[10px] text-fg-muted">{sub}</div>
</div>
{writable && (
<button type="button" className={BTN} disabled={busy} onClick={() => void onGenerate()}>
{k ? <RefreshCw className="h-3.5 w-3.5" /> : <Plus className="h-3.5 w-3.5" />}
{k ? t("gitIdentity.replace") : t("gitIdentity.generate")}
</button>
)}
{k && writable && (
<button
type="button"
className={`${BTN} hover:text-status-danger`}
disabled={busy}
aria-label={k.name ? t("gitIdentity.removeKeyNamed", { name: k.name }) : undefined}
onClick={() => void onRemove()}
>
<Trash2 className="h-3.5 w-3.5" />
{t("gitIdentity.remove")}
</button>
)}
</div>
{k ? (
<>
<Step n={1}>
<div className="flex flex-wrap items-center gap-2">
<span className="flex-1">{t("gitIdentity.stepCopy")}</span>
<button type="button" className={BTN} onClick={() => void copyText(k.publicKey)}>
<Copy className="h-3.5 w-3.5" />
{t("gitIdentity.copy")}
</button>
</div>
<textarea
readOnly
aria-label={`${t("gitIdentity.publicKey")} ${label}`}
value={k.publicKey}
rows={k.type === "rsa" ? 3 : 2}
onFocus={(e) => e.currentTarget.select()}
className={`${INPUT} mt-1 resize-none break-all font-mono text-[10px]`}
/>
<div className="truncate font-mono text-[10px] text-fg-muted">{k.fingerprint}</div>
</Step>
{children}
</>
) : (
<p className="text-[11px] text-fg-muted">{t("gitIdentity.noKey")}</p>
)}
</section>
);
}
/**
* Step 3 of an AWS key: the SSH key ID AWS shows for it — nothing else. The
* default key's login is saved for every region; a named key's under its own
* name, tied to it. A clone of any CodeCommit URL tries each saved login.
*/
function AwsLoginForm({
busy,
onSave,
}: {
busy: boolean;
onSave: (v: { keyId: string }) => Promise<boolean>;
}) {
const { t } = useTranslation("settings");
const [v, setV] = useState({ keyId: "" });
return (
<form
className="mt-1 flex gap-2"
onSubmit={(e) => {
e.preventDefault();
void onSave(v).then((ok) => ok && setV({ keyId: "" }));
}}
>
<input
aria-label={t("gitIdentity.awsKeyId")}
placeholder="APKA…"
autoComplete="off"
value={v.keyId}
onChange={(e) => setV({ ...v, keyId: e.target.value })}
className={`${INPUT} font-mono`}
/>
<button type="submit" className={BTN} disabled={busy || !v.keyId.trim()}>
{t("gitIdentity.awsSave")}
</button>
</form>
);
}
/**
* Step 3 of a named GitHub/GitLab key: the host it is for. Saved as the login
* `ssh://<key name>/` → `git@<host>`, tied to this key, so a clone through it
* offers only this key — the default key would log in as the other account.
*/
function GitHostForm({
busy,
onSave,
}: {
busy: boolean;
onSave: (host: string) => Promise<boolean>;
}) {
const { t } = useTranslation("settings");
const [host, setHost] = useState("github.com");
return (
<form
className="mt-1 flex gap-2"
onSubmit={(e) => {
e.preventDefault();
void onSave(host.trim());
}}
>
<input
aria-label={t("gitIdentity.otherHost")}
placeholder="github.com"
autoComplete="off"
value={host}
onChange={(e) => setHost(e.target.value)}
className={`${INPUT} font-mono`}
/>
<button type="submit" className={BTN} disabled={busy || !host.trim()}>
{t("gitIdentity.awsSave")}
</button>
</form>
);
}
/**
* Commit signing: the key every commit is signed with (one picker — choosing
* a key turns signing on, "Don't sign" off) and Import a key the user already
* has, which signs with it in the same step unless unticked.
*/
function SigningCard({
id,
keys,
keyLabel,
busy,
run,
}: {
id: GitIdentity;
keys: Key[];
keyLabel: (keyId: string | null | undefined) => string;
busy: boolean;
run: (fn: () => Promise<GitIdentity>, done?: string) => Promise<boolean>;
}) {
const { t } = useTranslation("settings");
const signing = id.signing ?? { state: "off" as const, key: null, path: null };
const current = signing.state === "on" ? keys.find((k) => k.id === signing.key) : undefined;
const [form, setForm] = useState<{ text: string; name: string; sign: boolean } | null>(null);
/** Sign with `keyId` (null = stop), asking before taking over the user's own setup. */
const choose = async (keyId: string | null) => {
if (keyId && signing.state === "foreign") {
if (
!(await confirmDialog({
title: t("gitIdentity.signReplaceConfirm", {
path: signing.path,
key: keyLabel(keyId),
}),
confirmLabel: t("gitIdentity.signReplace"),
}))
)
return;
}
await run(
() => api.gitIdentity.setSigning(keyId, signing.state === "foreign"),
keyId ? t("gitIdentity.signSaved", { key: keyLabel(keyId) }) : t("gitIdentity.signStopped")
);
};
const doImport = async () => {
if (!form) return;
let imported: string | null = null;
let foreign = false;
const ok = await run(
async () => {
const r = await api.gitIdentity.importKey({
privateKey: form.text,
...(form.name.trim() ? { name: form.name.trim() } : {}),
sign: form.sign,
});
imported = r.imported?.id ?? null;
foreign = r.signError?.code === "EFOREIGNSIGN";
return r;
},
t("gitIdentity.imported", { name: form.name.trim() || "signing" })
);
if (!ok) return;
setForm(null);
// Saved, but the user's own signing setup was left alone: ask once.
if (imported && foreign) await choose(imported);
};
const readFile = async (file: File | undefined) => {
if (!file || !form) return;
// A key file is a few KB; anything far larger is not one.
if (file.size > 32 * 1024) return toast.error(t("gitIdentity.importPaste"));
const text = await file.text();
setForm({ ...form, text });
};
return (
<section
className="space-y-2 rounded-lg border border-border p-3"
aria-label={t("gitIdentity.signTitle")}
>
<div className="flex items-center gap-2">
<PenLine className="h-4 w-4 text-fg-muted" aria-hidden />
<h3 className="flex-1 text-xs font-semibold text-fg-primary">
{t("gitIdentity.signTitle")}
</h3>
{id.writable && !form && (
<button
type="button"
className={BTN}
disabled={busy}
onClick={() => setForm({ text: "", name: "", sign: true })}
>
<Upload className="h-3.5 w-3.5" />
{t("gitIdentity.importKey")}
</button>
)}
</div>
<p className="text-[11px] text-fg-muted">{t("gitIdentity.signIntro")}</p>
<div className="flex flex-wrap items-center gap-2">
<span
data-testid="git-signing-state"
className={`min-w-0 flex-1 text-[11px] ${
signing.state === "on" ? "text-status-success" : "text-fg-secondary"
}`}
>
{signing.state === "on"
? t("gitIdentity.signOn", { key: keyLabel(signing.key) })
: signing.state === "foreign"
? t("gitIdentity.signForeign", { path: signing.path })
: t("gitIdentity.signOff")}
</span>
{keys.length > 0 && (
<select
aria-label={t("gitIdentity.signKey")}
value={signing.state === "on" ? (signing.key ?? "") : ""}
disabled={busy || !id.writable}
onChange={(e) => void choose(e.target.value || null)}
className="select !py-1 !text-xs"
>
<option value="">
{signing.state === "foreign" ? signing.path : t("gitIdentity.signNone")}
</option>
{keys
.filter((k) => k.id)
.map((k) => (
<option key={k.id} value={k.id}>
{keyLabel(k.id)}
</option>
))}
</select>
)}
</div>
{current && (
<Step n={1}>
<div className="flex flex-wrap items-center gap-2">
<span className="flex-1">{t("gitIdentity.signHostStep")}</span>
<button type="button" className={BTN} onClick={() => void copyText(current.publicKey)}>
<Copy className="h-3.5 w-3.5" />
{t("gitIdentity.copy")}
</button>
</div>
<div className="truncate font-mono text-[10px] text-fg-muted">{current.fingerprint}</div>
</Step>
)}
{form && (
<form
className="space-y-2 rounded border border-border p-2.5"
aria-label={t("gitIdentity.importTitle")}
onSubmit={(e) => {
e.preventDefault();
void doImport();
}}
>
<div className="text-xs font-medium text-fg-primary">{t("gitIdentity.importTitle")}</div>
<label className={`${BTN} cursor-pointer`}>
<FileKey className="h-3.5 w-3.5" />
{t("gitIdentity.importFile")}
<input
type="file"
className="sr-only"
onChange={(e) => void readFile(e.target.files?.[0])}
/>
</label>
<textarea
aria-label={t("gitIdentity.importPaste")}
placeholder={t("gitIdentity.importPaste")}
value={form.text}
spellCheck={false}
autoComplete="off"
rows={4}
onChange={(e) => setForm({ ...form, text: e.target.value })}
className={`${INPUT} resize-y font-mono text-[10px]`}
/>
<div className="flex flex-wrap items-center gap-3">
<input
aria-label={t("gitIdentity.importName")}
placeholder={t("gitIdentity.importName")}
value={form.name}
autoComplete="off"
onChange={(e) => setForm({ ...form, name: e.target.value.toLowerCase() })}
className={`${INPUT} !w-40 font-mono`}
/>
<label className="flex items-center gap-1.5 text-[11px] text-fg-secondary">
<input
type="checkbox"
checked={form.sign}
onChange={(e) => setForm({ ...form, sign: e.target.checked })}
/>
{t("gitIdentity.importSign")}
</label>
</div>
<p className="text-[10px] text-fg-muted">{t("gitIdentity.importNote")}</p>
<div className="flex gap-2">
<button
type="submit"
disabled={busy || !form.text.trim()}
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("gitIdentity.importSubmit")}
</button>
<button type="button" onClick={() => setForm(null)} className={BTN}>
{t("gitCreds.cancel")}
</button>
</div>
</form>
)}
</section>
);
}
export function GitIdentitySettings() {
const { t } = useTranslation("settings");
const [id, setId] = useState<GitIdentity | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [form, setForm] = useState<{ host: string; username: string; token: string } | null>(null);
const auth = useAuth().state;
const me = auth.mode === "server" ? auth.user : null;
// null = not edited: the field shows the saved value, else the account's.
const [author, setAuthor] = useState<{ name: string; email: string } | null>(null);
// null = not picked: opens on the first kind of host that has a key.
const [sshTab, setSshTab] = useState<string | null>(null);
const load = useCallback(async () => {
try {
setId(await api.gitIdentity.get());
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
}
}, []);
useEffect(() => {
void load();
}, [load]);
const run = async (fn: () => Promise<GitIdentity>, done?: string) => {
setBusy(true);
setError(null);
try {
setId(await fn());
if (done) toast.success(done);
return true;
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
return false;
} finally {
setBusy(false);
}
};
// ed25519 suits GitHub/GitLab/Gitea; AWS IAM (CodeCommit) accepts only RSA.
// An account may hold one of each; an older server answers `ssh` only.
const keys: Key[] = id ? (id.sshKeys ?? (id.ssh ? [id.ssh] : [])) : [];
const terminal = id?.sshTerminal ?? id?.ssh?.terminal ?? null;
// The default key of a type (a named key sits beside it).
const keyOf = (type: "ed25519" | "rsa") =>
keys.find((k) => (k.type ?? "ed25519") === type && !k.name);
const named = keys.filter((k) => k.name);
const namedOf = (type: "ed25519" | "rsa") => named.filter((k) => (k.type ?? "ed25519") === type);
const keyLabel = (keyId: string | null | undefined) => {
const k = keyId ? keys.find((x) => x.id === keyId) : undefined;
if (!k) return t("gitIdentity.anyKey");
return k.name ?? (k.type === "rsa" ? t("gitIdentity.defaultRsa") : t("gitIdentity.defaultEd"));
};
// "Add another key" in a tab makes the next key of that tab's type at once,
// named `aws-2`, `aws-3`… / `git-2`… — the key is what step 1 needs, and an
// account name of the user's own goes in its step 3.
const makeNamed = (type: "ed25519" | "rsa") => {
const prefix = type === "rsa" ? "aws" : "git";
let n = 2;
while (keys.some((k) => k.name === `${prefix}-${n}`)) n++;
return run(
() => api.gitIdentity.generateKey(false, type, `${prefix}-${n}`),
t("gitIdentity.keyMade")
);
};
const addKey = (type: "ed25519" | "rsa") => (
<button type="button" className={BTN} disabled={busy} onClick={() => void makeNamed(type)}>
<Plus className="h-3.5 w-3.5" />
{t("gitIdentity.addKey")}
</button>
);
const replaceNamed = async (k: Key) => {
if (
!(await confirmDialog({
title: t("gitIdentity.replaceConfirm"),
confirmLabel: t("gitIdentity.replace"),
}))
)
return;
await run(
() => api.gitIdentity.generateKey(true, k.type ?? "ed25519", k.name ?? undefined),
t("gitIdentity.keyMade")
);
};
const removeNamed = async (keyId: string) => {
if (
!(await confirmDialog({
title: t("gitIdentity.removeKeyConfirm"),
confirmLabel: t("gitIdentity.remove"),
}))
)
return;
await run(() => api.gitIdentity.removeKeyById(keyId));
};
const generate = async (type: "ed25519" | "rsa") => {
const taken = !!keyOf(type);
if (
taken &&
!(await confirmDialog({
title: t("gitIdentity.replaceConfirm"),
confirmLabel: t("gitIdentity.replace"),
}))
)
return;
await run(() => api.gitIdentity.generateKey(taken, type), t("gitIdentity.keyMade"));
};
const removeKey = async (type: "ed25519" | "rsa") => {
if (
!(await confirmDialog({
title: t("gitIdentity.removeKeyConfirm"),
confirmLabel: t("gitIdentity.remove"),
}))
)
return;
await run(() => api.gitIdentity.removeKey(type));
};
const saveLogin = async () => {
if (!form) return;
// A bare host is the usual entry; a pasted URL is reduced to its host.
let host = form.host.trim();
try {
if (/^https?:\/\//i.test(host)) host = new URL(host).host;
} catch {
/* the server answers EBADHOST */
}
const ok = await run(
() =>
api.gitIdentity.saveHttps({ host, username: form.username.trim(), password: form.token }),
t("gitCreds.saved", { host })
);
if (ok) setForm(null);
};
// The SSH login name per host (`url.<…>.insteadOf`), so a plain
// `ssh://host/…` URL logs in. AWS CodeCommit's (user = the IAM SSH key ID,
// one host per region) gets its own form under the RSA key; anything else
// is the "other hosts" form.
const sshUsers = id?.sshUsers ?? [];
const awsRows = sshUsers.flatMap((r) => {
const m = CODECOMMIT_RE.exec(r.host);
return m ? [{ ...r, region: m[1] ?? "" }] : [];
});
const otherRows = sshUsers.filter((r) => !CODECOMMIT_RE.test(r.host));
// A login is shown under the key it names; one naming no key (or one gone)
// under the default key.
const namedIds = new Set(named.map((k) => k.id));
const rowsOf = <R extends { key?: string | null }>(rows: R[], k?: Key) =>
k ? rows.filter((r) => r.key === k.id) : rows.filter((r) => !r.key || !namedIds.has(r.key));
const saveAws = (v: { keyId: string }, k?: Key) => {
let user = v.keyId.trim();
let host = AWS_DEFAULT_HOST;
// A pasted `ssh://APKA…@git-codecommit.<region>…/…` URL: its key ID. The
// region does not matter — the key ID logs in to every one.
const m = /^ssh:\/\/([^@/]+)@([^/]+)/i.exec(user);
if (m?.[1] && m[2]) {
user = m[1];
if (CODECOMMIT_RE.test(m[2].toLowerCase())) host = m[2].toLowerCase();
}
return run(
() =>
k?.name
? // A named key (another AWS account): its login goes by the key's
// name; a clone of any CodeCommit URL tries it.
api.gitIdentity.setSshUser({ host, user, alias: k.name, key: k.id! })
: api.gitIdentity.setSshUser({ host, user, allRegions: true }),
k?.name ? t("gitIdentity.awsSavedNamed", { name: k.name }) : t("gitIdentity.awsSavedAll")
);
};
// The default key's logins, one row per key ID: "every region" once the
// server's whole list is covered, else the regions saved (an older save) —
// with a button that extends it to every region.
const regionCount = id?.codecommitRegions?.length ?? 0;
const awsDefaultGroups = (rows: typeof awsRows) => {
const out: { user: string; key: string | null; hosts: string[]; regions: string[] }[] = [];
for (const r of rows.filter((x) => !x.alias)) {
const g = out.find((o) => o.user === r.user && o.key === (r.key ?? null));
if (g) {
g.hosts.push(r.host);
g.regions.push(r.region);
} else out.push({ user: r.user, key: r.key ?? null, hosts: [r.host], regions: [r.region] });
}
return out;
};
const saveGitHost = (host: string, k: Key) => {
const h = /^(?:ssh:\/\/)?(?:[^@/]+@)?([^/:]+)/i.exec(host)?.[1]?.toLowerCase() ?? host;
return run(
() => api.gitIdentity.setSshUser({ host: h, user: "git", alias: k.name!, key: k.id! }),
t("gitIdentity.otherSaved", { host: k.name })
);
};
/** The logins of one key, each with its delete button. */
const loginRows = <R extends SshUser>(rows: R[], text: (r: R) => string) =>
rows.map((r) => (
<div key={r.alias ?? r.host} className="mt-1 flex items-center gap-2">
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-fg-secondary">
{text(r)}
</span>
{id?.writable && (
<button
type="button"
className={`${BTN} hover:text-status-danger`}
disabled={busy}
aria-label={`${t("gitIdentity.remove")} ${r.alias ?? r.host}`}
onClick={() => void run(() => api.gitIdentity.removeSshUser(r.alias ?? r.host))}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
));
const [other, setOther] = useState({ host: "", user: "", alias: "", key: "" });
const saveOther = async () => {
let host = other.host.trim();
const m = /^ssh:\/\/(?:[^@/]+@)?([^/]+)/i.exec(host);
if (m?.[1]) host = m[1];
const alias = other.alias.trim().toLowerCase();
const ok = await run(
() =>
api.gitIdentity.setSshUser({
host,
user: other.user.trim(),
...(alias ? { alias } : {}),
...(other.key ? { key: other.key } : {}),
}),
t("gitIdentity.otherSaved", { host: alias || host })
);
if (ok) setOther({ host: "", user: "", alias: "", key: "" });
};
/** The key picker of a login form: every key, or one. */
const keySelect = (value: string, onChange: (v: string) => void) => (
<select
aria-label={t("gitIdentity.loginKey")}
value={value}
onChange={(e) => onChange(e.target.value)}
className="select !py-1 !text-xs"
>
<option value="">{t("gitIdentity.anyKey")}</option>
{keys
.filter((k) => k.id)
.map((k) => (
<option key={k.id} value={k.id}>
{keyLabel(k.id)}
</option>
))}
</select>
);
const removeLogin = async (host: string) => {
if (
!(await confirmDialog({
title: t("gitIdentity.removeLoginConfirm", { host }),
confirmLabel: t("gitIdentity.remove"),
}))
)
return;
await run(() => api.gitIdentity.removeHttps(host));
};
if (!id) {
return error ? (
<p className="text-xs text-status-danger">{error}</p>
) : (
<p className="text-xs text-fg-muted">{t("gitCreds.loading")}</p>
);
}
const member = id.account === "member";
const authorShown = author ?? {
name: id.author.name ?? me?.displayName ?? me?.username ?? "",
email: id.author.email ?? me?.email ?? id.defaultEmail ?? "",
};
const authorUnset = !id.author.name || !id.author.email;
const authorDirty =
authorUnset ||
authorShown.name.trim() !== (id.author.name ?? "") ||
authorShown.email.trim() !== (id.author.email ?? "");
// The SSH keys, one tab per kind of host — every section at once was a wall.
const sshTabs = [
{ id: "git", label: t("gitIdentity.tabGit"), Icon: GitBranch },
{ id: "aws", label: t("gitIdentity.awsTitle"), Icon: Cloud },
...(id.sshUsers !== undefined && (otherRows.length > 0 || id.writable)
? [{ id: "other", label: t("gitIdentity.tabOther"), Icon: Server }]
: []),
];
const activeSshTab =
sshTab && sshTabs.some((x) => x.id === sshTab)
? sshTab
: !keyOf("ed25519") && keyOf("rsa")
? "aws"
: "git";
const saveAuthor = async () => {
const ok = await run(
() =>
api.gitIdentity.saveAuthor({
name: authorShown.name.trim(),
email: authorShown.email.trim(),
}),
t("gitIdentity.authorSaved")
);
if (ok) setAuthor(null);
};
return (
<div className="space-y-4">
<p className="text-xs text-fg-muted">
{member ? t("gitIdentity.descriptionMember") : t("gitIdentity.descriptionHost")}
</p>
{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
className="space-y-2 rounded-lg border border-border p-3"
onSubmit={(e) => {
e.preventDefault();
void saveAuthor();
}}
>
<h3 className="text-xs font-semibold text-fg-primary">{t("gitIdentity.authorTitle")}</h3>
{authorUnset && (
<p className="text-[11px] text-status-warning">{t("gitIdentity.authorUnset")}</p>
)}
<div className="grid gap-2 sm:grid-cols-2">
<input
aria-label={t("gitIdentity.authorName")}
placeholder={t("gitIdentity.authorName")}
value={authorShown.name}
disabled={!id.writable}
onChange={(e) => setAuthor({ ...authorShown, name: e.target.value })}
className={INPUT}
/>
<input
type="email"
aria-label={t("gitIdentity.authorEmail")}
placeholder="name@example.com"
value={authorShown.email}
disabled={!id.writable}
onChange={(e) => setAuthor({ ...authorShown, email: e.target.value })}
className={INPUT}
/>
</div>
{id.writable && (
<button
type="submit"
disabled={busy || !authorDirty || !authorShown.name.trim() || !authorShown.email.trim()}
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>
)}
</form>
{id.signing !== undefined && (
<SigningCard id={id} keys={keys} keyLabel={keyLabel} busy={busy} run={run} />
)}
<div className="space-y-3 rounded-lg border border-border p-3">
<div className="flex items-center gap-2">
<KeyRound className="h-4 w-4 text-fg-muted" aria-hidden />
<h3 className="flex-1 text-xs font-semibold text-fg-primary">
{t("gitIdentity.sshTitle")}
</h3>
</div>
<p className="text-[11px] text-fg-muted">{t("gitIdentity.sshIntro")}</p>
<PageTabs
ariaLabel={t("gitIdentity.sshTitle")}
tabs={sshTabs}
activeId={activeSshTab}
onSelect={setSshTab}
>
{activeSshTab === "git" && (
<div className="space-y-2">
<KeyCard
title={t("gitIdentity.gitHubTitle")}
sub={t("gitIdentity.gitHubSub")}
label="ED25519"
k={keyOf("ed25519")}
writable={id.writable}
busy={busy}
onGenerate={() => generate("ed25519")}
onRemove={() => removeKey("ed25519")}
>
<Step n={2}>{t("gitIdentity.gitHubStepAdd")}</Step>
</KeyCard>
{namedOf("ed25519").map((k) => {
const rows = rowsOf(otherRows, k);
return (
<KeyCard
key={k.id}
title={k.name!}
sub={t("gitIdentity.namedGitSub")}
label={k.name!}
k={k}
writable={id.writable}
busy={busy}
onGenerate={() => replaceNamed(k)}
onRemove={() => removeNamed(k.id!)}
>
<Step n={2}>{t("gitIdentity.gitHubStepAdd")}</Step>
{id.signing?.state === "on" && id.signing.key === k.id && (
<div className="text-[10px] text-status-success">
{t("gitIdentity.signingBadge")}
</div>
)}
<Step n={3}>
{t("gitIdentity.namedGitStepHost", { name: k.name })}
{loginRows(rows, (r) => `ssh://${r.alias}/ → ${r.user}@${r.host}`)}
{id.writable && rows.length === 0 && (
<GitHostForm busy={busy} onSave={(host) => saveGitHost(host, k)} />
)}
</Step>
</KeyCard>
);
})}
{id.writable &&
keys.some((k) => (k.type ?? "ed25519") === "ed25519") &&
addKey("ed25519")}
</div>
)}
{activeSshTab === "aws" && (
<div className="space-y-2">
{[keyOf("rsa"), ...namedOf("rsa")].map((k, i) => {
const isNamed = i > 0 && !!k;
const rows = rowsOf(awsRows, isNamed ? k : undefined);
return (
<KeyCard
key={k?.id ?? "id_rsa"}
title={isNamed ? k!.name! : t("gitIdentity.awsTitle")}
sub={isNamed ? t("gitIdentity.namedAwsSub") : t("gitIdentity.awsSub")}
label={isNamed ? k!.name! : "RSA"}
k={k}
writable={id.writable}
busy={busy}
onGenerate={() => (isNamed ? replaceNamed(k!) : generate("rsa"))}
onRemove={() => (isNamed ? removeNamed(k!.id!) : removeKey("rsa"))}
>
<Step n={2}>{t("gitIdentity.awsStepUpload")}</Step>
<Step n={3}>
{t("gitIdentity.awsStepKeyId")}
{id.sshUsers === undefined ? null : (
<>
{loginRows(
rows.filter((r) => !!r.alias),
(r) => `${r.alias} · ${r.user} · ${keyLabel(r.key)}`
)}
{awsDefaultGroups(rows).map((g) => {
const all = regionCount > 0 && g.regions.length >= regionCount;
return (
<div
key={`${g.user}\0${g.key ?? ""}`}
className="mt-1 flex items-center gap-2"
>
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-fg-secondary">
{all ? t("gitIdentity.awsAllRegions") : g.regions.join(", ")}
{` · ${g.user} · ${keyLabel(g.key)}`}
</span>
{id.writable && !all && regionCount > 0 && (
<button
type="button"
className={BTN}
disabled={busy}
onClick={() =>
void run(
() =>
api.gitIdentity.setSshUser({
host: g.hosts[0]!,
user: g.user,
allRegions: true,
}),
t("gitIdentity.awsSavedAll")
)
}
>
{t("gitIdentity.awsUseAll")}
</button>
)}
{id.writable && (
<button
type="button"
className={`${BTN} hover:text-status-danger`}
disabled={busy}
aria-label={`${t("gitIdentity.remove")} ${g.user}`}
onClick={() =>
void run(() =>
g.hosts.length > 1
? api.gitIdentity.removeSshUser(g.hosts[0]!, {
allRegions: true,
user: g.user,
})
: api.gitIdentity.removeSshUser(g.hosts[0]!)
)
}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
);
})}
{id.writable && (
<AwsLoginForm
busy={busy}
onSave={(v) => saveAws(v, isNamed ? k : undefined)}
/>
)}
</>
)}
</Step>
</KeyCard>
);
})}
{id.writable && keys.some((k) => k.type === "rsa") && (
<>
{addKey("rsa")}
<div className="text-[10px] text-fg-muted">{t("gitIdentity.awsMany")}</div>
</>
)}
</div>
)}
{activeSshTab === "other" && (
<section
aria-label={t("gitIdentity.otherTitle")}
className="space-y-1.5 rounded border border-border p-2.5"
>
<div className="text-xs font-medium text-fg-primary">
{t("gitIdentity.otherTitle")}
</div>
<div className="space-y-1.5">
<div className="text-[10px] text-fg-muted">{t("gitIdentity.otherHint")}</div>
{otherRows.map((r) => (
<div key={r.alias ?? r.host} className="flex items-center gap-2">
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-fg-secondary">
{r.alias ? `ssh://${r.alias}/ → ` : ""}
{r.user}@{r.host} · {keyLabel(r.key)}
</span>
{id.writable && (
<button
type="button"
className={`${BTN} hover:text-status-danger`}
disabled={busy}
aria-label={`${t("gitIdentity.remove")} ${r.alias ?? r.host}`}
onClick={() =>
void run(() => api.gitIdentity.removeSshUser(r.alias ?? r.host))
}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
))}
{id.writable && (
<form
className="grid gap-2 sm:grid-cols-[2fr_1fr_1fr_1fr_auto]"
onSubmit={(e) => {
e.preventDefault();
void saveOther();
}}
>
<input
aria-label={t("gitIdentity.otherHost")}
placeholder="git.example.com"
value={other.host}
onChange={(e) => setOther({ ...other, host: e.target.value })}
className={`${INPUT} font-mono`}
/>
<input
aria-label={t("gitIdentity.otherUser")}
placeholder={t("gitIdentity.otherUser")}
autoComplete="off"
value={other.user}
onChange={(e) => setOther({ ...other, user: e.target.value })}
className={`${INPUT} font-mono`}
/>
<input
aria-label={t("gitIdentity.alias")}
title={t("gitIdentity.aliasHint")}
placeholder={t("gitIdentity.aliasPlaceholder")}
autoComplete="off"
value={other.alias}
onChange={(e) => setOther({ ...other, alias: e.target.value })}
className={`${INPUT} font-mono`}
/>
{keySelect(other.key, (key) => setOther({ ...other, key }))}
<button
type="submit"
className={BTN}
disabled={busy || !other.host.trim() || !other.user.trim()}
>
{t("gitIdentity.awsSave")}
</button>
</form>
)}
</div>
</section>
)}
</PageTabs>
{terminal && (
<div className="flex items-start gap-2 rounded border border-border p-2">
<div className="min-w-0 flex-1">
<div className="text-[11px] text-fg-primary">{t("gitIdentity.terminal")}</div>
<div className="text-[10px] text-fg-muted">
{terminal === "foreign"
? t("gitIdentity.terminalForeign")
: t("gitIdentity.terminalHint")}
</div>
</div>
<Switch
label={t("gitIdentity.terminal")}
checked={terminal === "on"}
disabled={busy || !id.writable || terminal === "foreign"}
onChange={(on) => void run(() => api.gitIdentity.setTerminalSsh(on))}
/>
</div>
)}
<p className="text-[10px] text-fg-muted">{t("gitIdentity.privateNote")}</p>
</div>
{member && (
<div className="space-y-2 rounded-lg border border-border p-3">
<div className="flex items-center gap-2">
<h3 className="flex-1 text-xs font-semibold text-fg-primary">
{t("gitIdentity.httpsTitle")}
</h3>
<button
type="button"
className={BTN}
onClick={() => setForm({ host: "", username: "", token: "" })}
>
<Plus className="h-3.5 w-3.5" />
{t("gitCreds.add")}
</button>
</div>
{form && (
<form
className="grid gap-2 sm:grid-cols-3"
onSubmit={(e) => {
e.preventDefault();
void saveLogin();
}}
>
<input
aria-label={t("gitCreds.fieldHost")}
placeholder="github.com"
value={form.host}
onChange={(e) => setForm({ ...form, host: 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={busy || !form.host.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={BTN}>
{t("gitCreds.cancel")}
</button>
</div>
</form>
)}
{id.https.length === 0 ? (
<p className="text-[11px] text-fg-muted">{t("gitIdentity.noLogins")}</p>
) : (
<ul className="divide-y divide-border text-xs">
{id.https.map((c) => (
<li key={c.host} className="flex items-center gap-2 py-1.5">
<span className="font-mono text-[11px] text-fg-primary">{c.host}</span>
<span className="flex-1 text-fg-secondary">{c.username}</span>
<button
type="button"
aria-label={`${t("gitIdentity.remove")} ${c.host}`}
title={t("gitIdentity.remove")}
disabled={busy}
onClick={() => void removeLogin(c.host)}
className="rounded p-1 text-fg-muted hover:bg-surface-3 hover:text-status-danger"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</li>
))}
</ul>
)}
</div>
)}
</div>
);
}