Files
AgentHub/client/src/components/settings/GitIdentitySettings.tsx
T
nntrivi2001 a58e95a957 chore: v2.8.7 — Git SSH keys in tabs, safer Artifacts preview and search
- Settings → Git → Your git identity: SSH keys are one tab per host kind
  (GitHub · GitLab, AWS CodeCommit, More keys, Other hosts); opens on the
  AWS tab when only an RSA key exists.
- Artifacts: a file type the sandboxed preview cannot show (Office, zip,
  PDF) offers Download only, never a blank Preview.
- Artifacts search: `%`/`_`/`\` in a query match literally (LIKE ... ESCAPE).
- Skill frontmatter rewrite replaces a key with its continuation lines and
  quotes values YAML could misread.
- Plugin marketplace.json is read with a 1 MB streaming cap.
- Artifact lists look each owner up once.
2026-10-15 17:17:47 +07:00

912 lines
34 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, the extra keys, the other hosts) — 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. 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,
GitBranch,
KeyRound,
KeySquare,
Plus,
RefreshCw,
Server,
Trash2,
} 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 regions AWS CodeCommit runs in.
const AWS_REGIONS = [
"ap-southeast-1",
"ap-southeast-2",
"ap-southeast-3",
"ap-northeast-1",
"ap-northeast-2",
"ap-northeast-3",
"ap-south-1",
"ap-east-1",
"us-east-1",
"us-east-2",
"us-west-1",
"us-west-2",
"ca-central-1",
"eu-west-1",
"eu-west-2",
"eu-west-3",
"eu-central-1",
"eu-north-1",
"eu-south-1",
"sa-east-1",
"me-south-1",
"af-south-1",
"il-central-1",
];
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>
);
}
function Code({ children }: { children: ReactNode }) {
return (
<code className="mt-1 block break-all rounded bg-surface-2 px-1.5 py-1 font-mono text-[10px] text-fg-primary">
{children}
</code>
);
}
type Key = NonNullable<GitIdentity["sshKeys"]>[number];
/**
* One kind of git host and the key it takes: no key → a Generate button;
* a key → step 1 (copy its public half) followed by the host's own steps.
*/
function KeyCard({
title,
sub,
type,
k,
id,
busy,
onGenerate,
onRemove,
children,
}: {
title: string;
sub: string;
type: "ed25519" | "rsa";
k: Key | undefined;
id: GitIdentity;
busy: boolean;
onGenerate: (type: "ed25519" | "rsa") => Promise<void>;
onRemove: (type: "ed25519" | "rsa") => 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>
{id.writable && (
<button
type="button"
className={BTN}
disabled={busy}
onClick={() => void onGenerate(type)}
>
{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 && id.writable && (
<button
type="button"
className={`${BTN} hover:text-status-danger`}
disabled={busy}
onClick={() => void onRemove(type)}
>
<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")} ${type === "rsa" ? "RSA" : "ED25519"}`}
value={k.publicKey}
rows={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>
);
}
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 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"));
};
const [newKey, setNewKey] = useState<{ name: string; type: "ed25519" | "rsa" } | null>(null);
const makeNamed = async () => {
if (!newKey) return;
const ok = await run(
() => api.gitIdentity.generateKey(false, newKey.type, newKey.name.trim().toLowerCase()),
t("gitIdentity.keyMade")
);
if (ok) setNewKey(null);
};
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));
const [aws, setAws] = useState({ keyId: "", region: "ap-southeast-1", alias: "", key: "" });
const saveAws = async () => {
let user = aws.keyId.trim();
let host = `git-codecommit.${aws.region}.amazonaws.com`;
// A pasted `ssh://APKA…@git-codecommit.<region>…/…` URL fills both.
const m = /^ssh:\/\/([^@/]+)@([^/]+)/i.exec(user);
if (m?.[1] && m[2]) {
user = m[1];
host = m[2].toLowerCase();
}
const alias = aws.alias.trim().toLowerCase();
const ok = await run(
() =>
api.gitIdentity.setSshUser({
host,
user,
...(alias ? { alias } : {}),
...(aws.key ? { key: aws.key } : {}),
}),
t("gitIdentity.awsSaved", { region: alias || (CODECOMMIT_RE.exec(host)?.[1] ?? host) })
);
if (ok) setAws({ ...aws, keyId: "", alias: "" });
};
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, only?: "rsa") => (
<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 && (!only || k.type === only))
.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 },
...(named.length > 0 || id.writable
? [
{
id: "more",
label: named.length
? `${t("gitIdentity.tabMore")} (${named.length})`
: t("gitIdentity.tabMore"),
Icon: KeySquare,
},
]
: []),
...(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>
<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" && (
<KeyCard
title={t("gitIdentity.gitHubTitle")}
sub={t("gitIdentity.gitHubSub")}
type="ed25519"
k={keyOf("ed25519")}
id={id}
busy={busy}
onGenerate={generate}
onRemove={removeKey}
>
<Step n={2}>{t("gitIdentity.gitHubStepAdd")}</Step>
<Step n={3}>
{t("gitIdentity.gitHubStepClone")}
<Code>git clone git@github.com:&lt;org&gt;/&lt;repo&gt;.git</Code>
</Step>
</KeyCard>
)}
{activeSshTab === "aws" && (
<KeyCard
title={t("gitIdentity.awsTitle")}
sub={t("gitIdentity.awsSub")}
type="rsa"
k={keyOf("rsa")}
id={id}
busy={busy}
onGenerate={generate}
onRemove={removeKey}
>
<Step n={2}>{t("gitIdentity.awsStepUpload")}</Step>
<Step n={3}>
{t("gitIdentity.awsStepKeyId")}
{id.sshUsers === undefined ? null : (
<>
{awsRows.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">
{r.alias ? `${r.alias} · ` : ""}
{r.region} · {r.user} · {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="mt-1 grid gap-2 sm:grid-cols-[2fr_1fr_1fr_1fr_auto]"
onSubmit={(e) => {
e.preventDefault();
void saveAws();
}}
>
<input
aria-label={t("gitIdentity.awsKeyId")}
placeholder="APKA…"
autoComplete="off"
value={aws.keyId}
onChange={(e) => setAws({ ...aws, keyId: e.target.value })}
className={`${INPUT} font-mono`}
/>
<select
aria-label={t("gitIdentity.awsRegion")}
value={aws.region}
onChange={(e) => setAws({ ...aws, region: e.target.value })}
className="select !py-1 !text-xs"
>
{AWS_REGIONS.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
<input
aria-label={t("gitIdentity.alias")}
title={t("gitIdentity.aliasHint")}
placeholder={t("gitIdentity.aliasPlaceholder")}
autoComplete="off"
value={aws.alias}
onChange={(e) => setAws({ ...aws, alias: e.target.value })}
className={`${INPUT} font-mono`}
/>
{keySelect(aws.key, (key) => setAws({ ...aws, key }), "rsa")}
<button type="submit" className={BTN} disabled={busy || !aws.keyId.trim()}>
{t("gitIdentity.awsSave")}
</button>
</form>
)}
<div className="mt-1 text-[10px] text-fg-muted">{t("gitIdentity.awsMany")}</div>
</>
)}
</Step>
{awsRows.length > 0 && (
<div className="rounded border border-status-success/40 bg-status-success/10 p-2 text-[11px] text-fg-primary">
{t("gitIdentity.awsReady")}
{awsRows.map((r) => (
<Code key={r.alias ?? r.host}>
git clone ssh://{r.alias ?? r.host}/v1/repos/&lt;repo&gt;
</Code>
))}
</div>
)}
</KeyCard>
)}
{activeSshTab === "more" && (
<section
aria-label={t("gitIdentity.moreKeysTitle")}
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">
{t("gitIdentity.moreKeysTitle")}
</div>
<div className="text-[10px] text-fg-muted">{t("gitIdentity.moreKeysSub")}</div>
</div>
{id.writable && !newKey && (
<button
type="button"
className={BTN}
disabled={busy}
onClick={() => setNewKey({ name: "", type: "rsa" })}
>
<Plus className="h-3.5 w-3.5" />
{t("gitIdentity.newKey")}
</button>
)}
</div>
{newKey && (
<form
className="grid gap-2 sm:grid-cols-[2fr_1fr_auto_auto]"
onSubmit={(e) => {
e.preventDefault();
void makeNamed();
}}
>
<input
aria-label={t("gitIdentity.keyName")}
placeholder={t("gitIdentity.keyNamePlaceholder")}
autoComplete="off"
autoFocus
value={newKey.name}
onChange={(e) => setNewKey({ ...newKey, name: e.target.value })}
className={`${INPUT} font-mono`}
/>
<select
aria-label={t("gitIdentity.keyType")}
value={newKey.type}
onChange={(e) =>
setNewKey({ ...newKey, type: e.target.value === "rsa" ? "rsa" : "ed25519" })
}
className="select !py-1 !text-xs"
>
<option value="rsa">RSA 4096 (AWS)</option>
<option value="ed25519">ED25519</option>
</select>
<button type="submit" className={BTN} disabled={busy || !newKey.name.trim()}>
{t("gitIdentity.generate")}
</button>
<button type="button" className={BTN} onClick={() => setNewKey(null)}>
{t("gitCreds.cancel")}
</button>
</form>
)}
{named.map((k) => (
<div
key={k.id}
className="space-y-1 rounded border border-border-light p-2"
data-testid="git-named-key"
>
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-[11px] font-medium text-fg-primary">
{k.name}
</span>
<span className="rounded bg-surface-3 px-1.5 py-0.5 text-[10px] text-fg-secondary">
{k.type === "rsa" ? "RSA" : "ED25519"}
</span>
<span className="min-w-0 flex-1 truncate font-mono text-[10px] text-fg-muted">
{k.fingerprint}
</span>
<button
type="button"
className={BTN}
onClick={() => void copyText(k.publicKey)}
>
<Copy className="h-3.5 w-3.5" />
{t("gitIdentity.copy")}
</button>
{id.writable && (
<button
type="button"
className={`${BTN} hover:text-status-danger`}
disabled={busy}
aria-label={`${t("gitIdentity.remove")} ${k.name}`}
onClick={() => void removeNamed(k.id!)}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
<textarea
readOnly
aria-label={`${t("gitIdentity.publicKey")} ${k.name}`}
value={k.publicKey}
rows={2}
onFocus={(e) => e.currentTarget.select()}
className={`${INPUT} resize-none break-all font-mono text-[10px]`}
/>
</div>
))}
</section>
)}
{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>
);
}