9dd97a369c
- Plugin update reports its running step (plugin-update-progress.json → plugin_update_progress); a hidden update or clone leaves a progress chip. - Clone here can run in the background. - Git identity: extra SSH keys are added inline per host (no More keys tab). - Users: spend today (viewer's local day, ?tz_offset=). - Prettier on the files touched since v2.8.20.
932 lines
35 KiB
TypeScript
932 lines
35 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. 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, 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];
|
|
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, in one region. The
|
|
* default key's login is the region's plain host; a named key's is saved
|
|
* under an account name (its own name unless another is typed), tied to it.
|
|
*/
|
|
function AwsLoginForm({
|
|
busy,
|
|
named,
|
|
onSave,
|
|
}: {
|
|
busy: boolean;
|
|
/** The named key's name; undefined for the default key. */
|
|
named?: string;
|
|
onSave: (v: { keyId: string; region: string; alias: string }) => Promise<boolean>;
|
|
}) {
|
|
const { t } = useTranslation("settings");
|
|
const [v, setV] = useState({ keyId: "", region: "ap-southeast-1", alias: "" });
|
|
return (
|
|
<form
|
|
className="mt-1 grid gap-2 sm:grid-cols-[2fr_1fr_1fr_auto]"
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
void onSave(v).then((ok) => ok && setV({ ...v, keyId: "", alias: "" }));
|
|
}}
|
|
>
|
|
<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`}
|
|
/>
|
|
<select
|
|
aria-label={t("gitIdentity.awsRegion")}
|
|
value={v.region}
|
|
onChange={(e) => setV({ ...v, 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={named ?? t("gitIdentity.aliasPlaceholder")}
|
|
autoComplete="off"
|
|
value={v.alias}
|
|
onChange={(e) => setV({ ...v, alias: 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>
|
|
);
|
|
}
|
|
|
|
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; region: string; alias: string }, k?: Key) => {
|
|
let user = v.keyId.trim();
|
|
let host = `git-codecommit.${v.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();
|
|
}
|
|
// A named key's account goes by the key's name unless another is typed.
|
|
const alias = v.alias.trim().toLowerCase() || (k?.name ?? "");
|
|
return run(
|
|
() =>
|
|
api.gitIdentity.setSshUser({
|
|
host,
|
|
user,
|
|
...(alias ? { alias } : {}),
|
|
...(k?.id ? { key: k.id } : {}),
|
|
}),
|
|
t("gitIdentity.awsSaved", { region: alias || (CODECOMMIT_RE.exec(host)?.[1] ?? host) })
|
|
);
|
|
};
|
|
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>
|
|
|
|
<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>
|
|
<Step n={3}>
|
|
{t("gitIdentity.gitHubStepClone")}
|
|
<Code>git clone git@github.com:<org>/<repo>.git</Code>
|
|
</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>
|
|
<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)} />
|
|
)}
|
|
{rows.map((r) => (
|
|
<Code key={r.alias ?? r.host}>
|
|
git clone ssh://{r.alias ?? r.host}/<org>/<repo>.git
|
|
</Code>
|
|
))}
|
|
</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,
|
|
(r) =>
|
|
`${r.alias ? `${r.alias} · ` : ""}${r.region} · ${r.user} · ${keyLabel(r.key)}`
|
|
)}
|
|
{id.writable && (
|
|
<AwsLoginForm
|
|
busy={busy}
|
|
named={isNamed ? k!.name! : undefined}
|
|
onSave={(v) => saveAws(v, isNamed ? k : undefined)}
|
|
/>
|
|
)}
|
|
</>
|
|
)}
|
|
</Step>
|
|
{rows.length > 0 && (
|
|
<div className="rounded border border-status-success/40 bg-status-success/10 p-2 text-[11px] text-fg-primary">
|
|
{t("gitIdentity.awsReady")}
|
|
{rows.map((r) => (
|
|
<Code key={r.alias ?? r.host}>
|
|
git clone ssh://{r.alias ?? r.host}/v1/repos/<repo>
|
|
</Code>
|
|
))}
|
|
</div>
|
|
)}
|
|
</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>
|
|
);
|
|
}
|