758609775f
- Files → Trash: ONE sidebar entry. An admin's opens on a folder per user's Trash (My Trash first, item counts from GET /api/homes/trash, an unreadable one showing its error); a folder opens that user's Trash (path bar Trash › user, Up back to the folders). An admin may restore/purge in another user's Trash (audited); purging asks first. The merged "All users" list is gone from the UI. - A home's Trash is its user's whole recycle bin (home store + host store, items tagged `store`). - `agenthub server setup` installs one Linux user per account on Linux by default (`--no-system` opts out); install-system messages drop `sudo`. - Settings → Git: the author email suggests <username>@<AGENTHUB_EMAIL_DOMAIN>. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
659 lines
24 KiB
TypeScript
659 lines
24 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
|
|
* 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 { Copy, KeyRound, Plus, RefreshCw, 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 { 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);
|
|
|
|
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 = id ? (id.sshKeys ?? (id.ssh ? [id.ssh] : [])) : [];
|
|
const terminal = id?.sshTerminal ?? id?.ssh?.terminal ?? null;
|
|
const keyOf = (type: "ed25519" | "rsa") => keys.find((k) => (k.type ?? "ed25519") === type);
|
|
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" });
|
|
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 ok = await run(
|
|
() => api.gitIdentity.setSshUser({ host, user }),
|
|
t("gitIdentity.awsSaved", { region: CODECOMMIT_RE.exec(host)?.[1] ?? host })
|
|
);
|
|
if (ok) setAws({ ...aws, keyId: "" });
|
|
};
|
|
const [other, setOther] = useState({ host: "", user: "" });
|
|
const saveOther = async () => {
|
|
let host = other.host.trim();
|
|
const m = /^ssh:\/\/(?:[^@/]+@)?([^/]+)/i.exec(host);
|
|
if (m?.[1]) host = m[1];
|
|
const ok = await run(
|
|
() => api.gitIdentity.setSshUser({ host, user: other.user.trim() }),
|
|
t("gitIdentity.otherSaved", { host })
|
|
);
|
|
if (ok) setOther({ host: "", user: "" });
|
|
};
|
|
|
|
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 ?? "");
|
|
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>
|
|
|
|
<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:<org>/<repo>.git</Code>
|
|
</Step>
|
|
</KeyCard>
|
|
|
|
<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.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.region} · {r.user}
|
|
</span>
|
|
{id.writable && (
|
|
<button
|
|
type="button"
|
|
className={`${BTN} hover:text-status-danger`}
|
|
disabled={busy}
|
|
aria-label={`${t("gitIdentity.remove")} ${r.host}`}
|
|
onClick={() => void run(() => api.gitIdentity.removeSshUser(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_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>
|
|
<button type="submit" className={BTN} disabled={busy || !aws.keyId.trim()}>
|
|
{t("gitIdentity.awsSave")}
|
|
</button>
|
|
</form>
|
|
)}
|
|
</>
|
|
)}
|
|
</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.host}>git clone ssh://{r.host}/v1/repos/<repo></Code>
|
|
))}
|
|
</div>
|
|
)}
|
|
</KeyCard>
|
|
|
|
{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>
|
|
)}
|
|
|
|
{id.sshUsers !== undefined && (otherRows.length > 0 || id.writable) && (
|
|
<details className="rounded border border-border p-2" open={otherRows.length > 0}>
|
|
<summary className="cursor-pointer text-[11px] text-fg-primary">
|
|
{t("gitIdentity.otherTitle")}
|
|
</summary>
|
|
<div className="mt-1.5 space-y-1.5">
|
|
<div className="text-[10px] text-fg-muted">{t("gitIdentity.otherHint")}</div>
|
|
{otherRows.map((r) => (
|
|
<div key={r.host} className="flex items-center gap-2">
|
|
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-fg-secondary">
|
|
{r.user}@{r.host}
|
|
</span>
|
|
{id.writable && (
|
|
<button
|
|
type="button"
|
|
className={`${BTN} hover:text-status-danger`}
|
|
disabled={busy}
|
|
aria-label={`${t("gitIdentity.remove")} ${r.host}`}
|
|
onClick={() => void run(() => api.gitIdentity.removeSshUser(r.host))}
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
))}
|
|
{id.writable && (
|
|
<form
|
|
className="grid gap-2 sm:grid-cols-[2fr_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`}
|
|
/>
|
|
<button
|
|
type="submit"
|
|
className={BTN}
|
|
disabled={busy || !other.host.trim() || !other.user.trim()}
|
|
>
|
|
{t("gitIdentity.awsSave")}
|
|
</button>
|
|
</form>
|
|
)}
|
|
</div>
|
|
</details>
|
|
)}
|
|
<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>
|
|
);
|
|
}
|