feat: move termix identity into a plugin (C6)

This commit is contained in:
LukeGus committed 2026-09-24 20:50:54 -05:00
1 parent 6e56c90aac
commit 4dbce08379
157 files changed
+20743 -5766

No files matched your search

@@ -0,0 +1,953 @@
import { useEffect, useState, useCallback } from "react";
import {
Check,
Copy,
Fingerprint,
KeyRound,
Loader2,
Plus,
RefreshCw,
ScrollText,
ShieldCheck,
Sparkles,
Trash2,
} from "lucide-react";
import { toast } from "sonner";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import {
Button,
FakeSwitch,
Input,
SectionCard,
Select2,
SettingRow,
Textarea,
} from "@termix/plugin-sdk/ui";
import type { TermixIdApi } from "./api";
import type { LinkedStore } from "./linked-store";
import {
errorMessage,
type CredentialOption,
type TermixIdCa,
type TermixIdentity,
type TermixIdentityKey,
} from "./types";
const DOCS_URL = "https://docs.termix.site/features/authentication/termix-id";
const accentBtn =
"border-accent-brand/40 text-accent-brand hover:bg-accent-brand/20 hover:text-accent-brand";
function CopyRow({ label, value }: { label: string; value: string }) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
async function copy() {
try {
await navigator.clipboard.writeText(value);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
toast.error(t("termixId.copyFailed"));
}
}
return (
<div className="flex flex-col gap-1.5 py-2.5 border-b border-border last:border-0">
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{label}
</span>
<div className="flex items-stretch gap-2">
<code className="flex-1 min-w-0 text-[11px] font-mono bg-muted/30 border border-border px-2 py-1.5 overflow-x-auto whitespace-nowrap text-muted-foreground">
{value}
</code>
<button
onClick={copy}
className="shrink-0 w-7 flex items-center justify-center border border-border bg-background hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
>
{copied ? (
<Check className="size-3.5 text-accent-brand" />
) : (
<Copy className="size-3.5" />
)}
</button>
</div>
</div>
);
}
export function TermixIdPanel({
api,
linked,
}: {
api: TermixIdApi;
linked: LinkedStore;
}) {
const { t } = useTranslation();
const [loading, setLoading] = useState(true);
const [identity, setIdentity] = useState<TermixIdentity | null>(null);
const [keys, setKeys] = useState<TermixIdentityKey[]>([]);
const [ca, setCa] = useState<TermixIdCa | null>(null);
const [linkedCredentialIds, setLinkedCredentialIds] = useState<Set<number>>(
new Set(),
);
const refresh = useCallback(async () => {
try {
const [data, caData, linkedData] = await Promise.all([
api.me(),
api.ca().catch(() => ({ ca: null })),
api.linkedCredentialIds().catch(() => ({ credentialIds: [] })),
]);
setIdentity(data.identity);
setKeys(data.keys);
setCa(data.identity ? caData.ca : null);
setLinkedCredentialIds(new Set(linkedData.credentialIds));
} catch {
toast.error(t("termixId.loadFailed"));
} finally {
setLoading(false);
}
}, [api, t]);
// Credential badges elsewhere follow what this panel changes.
const changed = useCallback(async () => {
await refresh();
void linked.refresh();
}, [refresh, linked]);
useEffect(() => {
refresh();
}, [refresh]);
if (loading) {
return (
<div className="flex items-center justify-center h-40 text-muted-foreground">
<Loader2 className="animate-spin size-4" />
</div>
);
}
return (
<div className="flex-1 min-h-0 overflow-y-auto scrollbar-none">
<div className="flex flex-col gap-3 p-3">
{!identity ? (
<ClaimHandle api={api} onCreated={changed} />
) : (
<>
<IdentityCard api={api} identity={identity} onChanged={changed} />
<AddKey
api={api}
handle={identity.handle}
linkedCredentialIds={linkedCredentialIds}
onAdded={changed}
/>
<KeyList
api={api}
keys={keys}
handle={identity.handle}
caEnabled={!!ca}
onChanged={changed}
/>
<CaCard
api={api}
handle={identity.handle}
ca={ca}
onChanged={changed}
/>
</>
)}
</div>
</div>
);
}
function ClaimHandle({
api,
onCreated,
}: {
api: TermixIdApi;
onCreated: () => void;
}) {
const { t } = useTranslation();
const [handle, setHandle] = useState("");
const [description, setDescription] = useState("");
const [checking, setChecking] = useState(false);
const [status, setStatus] = useState<
"idle" | "available" | "taken" | "invalid"
>("idle");
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
const h = handle.trim().toLowerCase();
if (!h) {
setStatus("idle");
return;
}
// Guard against an out-of-order response: if the input changed (effect
// cleanup ran) before this request resolved, ignore its result.
let cancelled = false;
setChecking(true);
const timer = setTimeout(async () => {
try {
const res = await api.checkHandle(h);
if (cancelled) return;
setStatus(
!res.valid ? "invalid" : res.available ? "available" : "taken",
);
} catch {
if (!cancelled) setStatus("idle");
} finally {
if (!cancelled) setChecking(false);
}
}, 350);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [api, handle]);
async function submit() {
setSubmitting(true);
try {
await api.create(
handle.trim().toLowerCase(),
description.trim() || undefined,
);
toast.success(t("termixId.created"));
onCreated();
} catch (e) {
toast.error(errorMessage(e, t("termixId.createFailed")));
} finally {
setSubmitting(false);
}
}
return (
<SectionCard
title={t("termixId.title")}
icon={<Fingerprint className="size-3.5" />}
action={
<a
href={DOCS_URL}
target="_blank"
rel="noreferrer"
className="text-[10px] text-accent-brand hover:underline"
>
{t("termixId.docsLink")}
</a>
}
>
<div className="flex flex-col gap-3 py-3">
<p className="text-xs text-muted-foreground leading-snug">
{t("termixId.claimIntro")}
</p>
<div className="flex flex-col gap-1.5">
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("termixId.handleLabel")}
</span>
<div className="relative flex items-center">
<span className="absolute left-2 text-muted-foreground text-xs pointer-events-none select-none">
@
</span>
<Input
value={handle}
placeholder={t("termixId.handlePlaceholder")}
autoCapitalize="none"
spellCheck={false}
className="h-8 text-xs pl-5"
onChange={(e) => setHandle(e.target.value)}
/>
</div>
<span className="text-[11px] h-4 leading-4">
{checking ? (
<span className="text-muted-foreground">
{t("termixId.checking")}
</span>
) : status === "available" ? (
<span className="text-accent-brand">
{t("termixId.available")}
</span>
) : status === "taken" ? (
<span className="text-destructive">{t("termixId.taken")}</span>
) : status === "invalid" ? (
<span className="text-destructive">
{t("termixId.invalidHandle")}
</span>
) : null}
</span>
</div>
<div className="flex flex-col gap-1.5">
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("termixId.descriptionLabel")}
</span>
<Input
value={description}
placeholder={t("termixId.descriptionPlaceholder")}
className="h-8 text-xs"
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<Button
variant="outline"
size="sm"
className={`self-start ${accentBtn}`}
disabled={status !== "available" || submitting}
onClick={submit}
>
{submitting ? (
<Loader2 className="animate-spin size-3.5" />
) : (
t("termixId.create")
)}
</Button>
</div>
</SectionCard>
);
}
function IdentityCard({
api,
identity,
onChanged,
}: {
api: TermixIdApi;
identity: TermixIdentity;
onChanged: () => void;
}) {
const { t } = useTranslation();
const [confirming, setConfirming] = useState(false);
const [busy, setBusy] = useState(false);
const url =
identity.resolverUrl ??
`${window.location.origin}${identity.resolverPath ?? ""}`;
const curl = `curl -fsSL ${url} >> ~/.ssh/authorized_keys`;
async function remove() {
setBusy(true);
try {
await api.remove();
toast.success(t("termixId.deleted"));
onChanged();
} catch (e) {
toast.error(errorMessage(e, t("termixId.deleteFailed")));
} finally {
setBusy(false);
setConfirming(false);
}
}
return (
<SectionCard
title={t("termixId.title")}
icon={<Fingerprint className="size-3.5" />}
action={
confirming ? (
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
className="border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={remove}
disabled={busy}
>
{busy ? (
<Loader2 className="animate-spin size-3.5" />
) : (
t("common.confirm")
)}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setConfirming(false)}
>
{t("common.cancel")}
</Button>
</div>
) : (
<div className="flex items-center gap-1">
<a
href={DOCS_URL}
target="_blank"
rel="noreferrer"
className="text-[10px] text-accent-brand hover:underline px-1"
>
{t("termixId.docsLink")}
</a>
<Button
variant="ghost"
size="icon-sm"
onClick={() => setConfirming(true)}
className="hover:text-destructive"
>
<Trash2 className="size-3.5" />
</Button>
</div>
)
}
>
{confirming && (
<div className="py-2 border-b border-border">
<p className="text-xs text-muted-foreground">
{t("termixId.deleteConfirm")}
</p>
</div>
)}
<div className="flex items-center gap-2 py-2.5 border-b border-border">
<span className="text-sm font-semibold text-accent-brand">
@{identity.handle}
</span>
</div>
<CopyRow label={t("termixId.resolverUrlLabel")} value={url} />
<CopyRow label={t("termixId.provisionLabel")} value={curl} />
</SectionCard>
);
}
function downloadText(filename: string, text: string) {
// octet-stream so the browser/OS keeps the given extension instead of
// appending .txt to a text/plain download.
const blob = new Blob([text], { type: "application/octet-stream" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
function CredentialImportRow({
credentials,
linkedCredentialIds,
submitting,
onImport,
}: {
credentials: CredentialOption[];
linkedCredentialIds: Set<number>;
submitting: boolean;
onImport: (id: number) => void;
}) {
const { t } = useTranslation();
const [selected, setSelected] = useState<string>("");
const selectedId = selected ? Number(selected) : null;
const alreadyLinked =
selectedId !== null && linkedCredentialIds.has(selectedId);
return (
<div className="flex items-center gap-2">
<Select2
value={selected}
onChange={(e) => setSelected(e.target.value)}
disabled={submitting}
className="flex-1 h-8 min-w-0 border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring disabled:opacity-50 disabled:cursor-not-allowed"
>
<option value="" disabled>
{t("termixId.selectCredential")}
</option>
{credentials.map((c) => (
<option key={c.id} value={String(c.id)}>
{linkedCredentialIds.has(c.id) ? `✓ ${c.name}` : c.name}
</option>
))}
</Select2>
<Button
variant="outline"
size="default"
className={`h-8 shrink-0 ${alreadyLinked ? "border-accent-brand/20 text-accent-brand/50" : accentBtn}`}
disabled={!selectedId || submitting || alreadyLinked}
onClick={() => selectedId !== null && onImport(selectedId)}
>
{alreadyLinked ? <Check className="size-3.5" /> : t("termixId.import")}
</Button>
</div>
);
}
function AddKey({
api,
handle,
linkedCredentialIds,
onAdded,
}: {
api: TermixIdApi;
handle: string;
linkedCredentialIds: Set<number>;
onAdded: () => void;
}) {
const { t } = useTranslation();
const [publicKey, setPublicKey] = useState("");
const [label, setLabel] = useState("");
const [submitting, setSubmitting] = useState(false);
const [generating, setGenerating] = useState(false);
const [saveToVault, setSaveToVault] = useState(true);
const [credentials, setCredentials] = useState<CredentialOption[]>([]);
const loadCredentials = useCallback(async () => {
try {
setCredentials((await api.credentials()).credentials);
} catch {
// Pasting a key still works without the list.
}
}, [api]);
useEffect(() => {
loadCredentials();
}, [loadCredentials]);
async function addManual() {
if (!publicKey.trim()) return;
setSubmitting(true);
try {
await api.addKey({
publicKey: publicKey.trim(),
label: label.trim() || undefined,
});
toast.success(t("termixId.keyPublished"));
setPublicKey("");
setLabel("");
onAdded();
} catch (e) {
toast.error(errorMessage(e, t("termixId.addKeyFailed")));
} finally {
setSubmitting(false);
}
}
async function generate() {
setGenerating(true);
try {
const result = await api.generateKey("ed25519", saveToVault);
downloadText(`termix-${handle}-ed25519.key`, result.privateKey);
toast.success(
saveToVault
? t("termixId.generatedSaved")
: t("termixId.generatedOnly"),
);
if (saveToVault) loadCredentials();
onAdded();
} catch (e) {
toast.error(errorMessage(e, t("termixId.generateFailed")));
} finally {
setGenerating(false);
}
}
async function importCredential(id: number) {
setSubmitting(true);
try {
await api.addKey({ credentialId: id });
toast.success(t("termixId.imported"));
onAdded();
} catch (e) {
toast.error(errorMessage(e, t("termixId.importFailed")));
} finally {
setSubmitting(false);
}
}
return (
<SectionCard
title={t("termixId.publishTitle")}
icon={<Plus className="size-3.5" />}
action={
<Button
variant="outline"
size="sm"
className={accentBtn}
onClick={generate}
disabled={generating}
title={t("termixId.generateTooltip")}
>
{generating ? (
<Loader2 className="animate-spin size-3.5" />
) : (
<>
<Sparkles className="size-3.5" />
{t("termixId.generate")}
</>
)}
</Button>
}
>
<div className="flex flex-col gap-3 py-3">
<SettingRow label={t("termixId.saveToVault")}>
<FakeSwitch checked={saveToVault} onChange={setSaveToVault} />
</SettingRow>
<Textarea
value={publicKey}
onChange={(e) => setPublicKey(e.target.value)}
placeholder={t("termixId.keyPlaceholder")}
rows={3}
className="rounded-none font-mono text-[11px]"
spellCheck={false}
/>
<div className="flex items-center gap-2">
<Input
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder={t("termixId.labelPlaceholder")}
className="h-8 text-xs flex-1"
/>
<Button
variant="outline"
size="default"
className={`h-8 ${accentBtn}`}
onClick={addManual}
disabled={!publicKey.trim() || submitting}
>
{submitting ? (
<Loader2 className="animate-spin size-3.5" />
) : (
t("termixId.add")
)}
</Button>
</div>
{credentials.length > 0 && (
<div className="flex flex-col gap-1.5 border-t border-border pt-3">
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("termixId.importFromCredential")}
</span>
<CredentialImportRow
credentials={credentials}
linkedCredentialIds={linkedCredentialIds}
submitting={submitting}
onImport={importCredential}
/>
</div>
)}
</div>
</SectionCard>
);
}
function KeyList({
api,
keys,
handle,
caEnabled,
onChanged,
}: {
api: TermixIdApi;
keys: TermixIdentityKey[];
handle: string;
caEnabled: boolean;
onChanged: () => void;
}) {
const { t } = useTranslation();
const [issuingId, setIssuingId] = useState<number | null>(null);
const [principalsByKey, setPrincipalsByKey] = useState<
Record<number, string>
>({});
async function toggle(k: TermixIdentityKey) {
try {
await api.setKeyEnabled(k.id, !k.enabled);
onChanged();
} catch (e) {
toast.error(errorMessage(e, t("termixId.updateKeyFailed")));
}
}
async function remove(k: TermixIdentityKey) {
try {
await api.removeKey(k.id);
toast.success(t("termixId.keyRemoved"));
onChanged();
} catch (e) {
toast.error(errorMessage(e, t("termixId.removeKeyFailed")));
}
}
async function issueCert(k: TermixIdentityKey) {
setIssuingId(k.id);
try {
const res = await api.issueCertificate(k.id, {
principals: (principalsByKey[k.id] ?? "")
.split(",")
.map((principal) => principal.trim())
.filter(Boolean),
});
downloadText(`termix-${handle}-${k.id}-cert.pub`, res.certificate + "\n");
toast.success(t("termixId.certIssued"));
} catch (e) {
toast.error(errorMessage(e, t("termixId.certIssueFailed")));
} finally {
setIssuingId(null);
}
}
return (
<div className="flex flex-col border border-border bg-card">
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-border shrink-0">
<span className="text-muted-foreground">
<KeyRound className="size-3.5" />
</span>
<span className="text-xs font-semibold uppercase tracking-widest text-muted-foreground flex-1">
{t("termixId.keysTitle")}
</span>
</div>
{keys.length === 0 ? (
<p className="text-xs text-muted-foreground px-4 py-3">
{t("termixId.noKeys")}
</p>
) : (
keys.map((k) => {
const canCert = caEnabled && k.algorithm.toUpperCase() === "ED25519";
return (
<div
key={k.id}
className="flex flex-col px-3 py-2 gap-1 border-b border-border last:border-0"
>
{/* row 1: label + badges */}
<div className="flex items-center gap-1.5 min-w-0">
<span className="text-[10px] font-bold uppercase tracking-wide text-accent-brand border border-accent-brand/40 px-1 py-px shrink-0 leading-none">
{k.algorithm}
</span>
{k.credentialId && (
<span className="text-[9px] font-semibold uppercase tracking-widest text-accent-brand/60 border border-accent-brand/20 px-1 py-px shrink-0 leading-none">
{t("termixId.fromVault")}
</span>
)}
<span className="text-xs font-medium truncate min-w-0">
{k.label || k.comment || k.keyType}
</span>
</div>
{/* row 2: public key */}
<code className="text-[10px] font-mono text-muted-foreground truncate">
{k.publicKey}
</code>
{/* row 3: actions */}
<div className="flex items-center gap-1 pt-0.5">
<FakeSwitch checked={k.enabled} onChange={() => toggle(k)} />
{canCert && (
<>
<Input
value={principalsByKey[k.id] ?? ""}
onChange={(e) =>
setPrincipalsByKey((current) => ({
...current,
[k.id]: e.target.value,
}))
}
placeholder={t("termixId.principalsPlaceholder")}
aria-label={t("termixId.principalsLabel")}
className="h-7 min-w-0 flex-1 text-xs"
spellCheck={false}
/>
<Button
variant="outline"
size="icon-sm"
onClick={() => issueCert(k)}
disabled={
issuingId === k.id ||
!(principalsByKey[k.id] ?? "").trim()
}
title={t("termixId.issueCertTooltip")}
>
{issuingId === k.id ? (
<Loader2 className="animate-spin size-3.5" />
) : (
<ScrollText className="size-3.5" />
)}
</Button>
</>
)}
<Button
variant="ghost"
size="icon-sm"
onClick={() => remove(k)}
className="hover:text-destructive"
>
<Trash2 className="size-3.5" />
</Button>
</div>
</div>
);
})
)}
</div>
);
}
function CaCard({
api,
handle,
ca,
onChanged,
}: {
api: TermixIdApi;
handle: string;
ca: TermixIdCa | null;
onChanged: () => void;
}) {
const { t } = useTranslation();
const [busy, setBusy] = useState(false);
const [confirmingRotate, setConfirmingRotate] = useState(false);
const [confirmingDelete, setConfirmingDelete] = useState(false);
const caUrl = ca?.resolverUrl ?? "";
const trustCmd = `curl -fsSL ${caUrl} | sudo tee /etc/ssh/${handle}-ca.pub && echo "TrustedUserCAKeys /etc/ssh/${handle}-ca.pub" | sudo tee -a /etc/ssh/sshd_config && sudo systemctl reload sshd`;
async function enable() {
setBusy(true);
try {
await api.createCa();
toast.success(t("termixId.caEnabled"));
onChanged();
} catch (e) {
toast.error(errorMessage(e, t("termixId.caCreateFailed")));
} finally {
setBusy(false);
}
}
async function rotate() {
setBusy(true);
try {
await api.rotateCa();
toast.success(t("termixId.caRotated"));
onChanged();
} catch (e) {
toast.error(errorMessage(e, t("termixId.caRotateFailed")));
} finally {
setBusy(false);
setConfirmingRotate(false);
}
}
async function remove() {
setBusy(true);
try {
await api.removeCa();
toast.success(t("termixId.caDeleted"));
onChanged();
} catch (e) {
toast.error(errorMessage(e, t("termixId.caDeleteFailed")));
} finally {
setBusy(false);
setConfirmingDelete(false);
}
}
return (
<SectionCard
title={t("termixId.caTitle")}
icon={<ShieldCheck className="size-3.5" />}
action={
!ca ? (
<Button
variant="outline"
size="sm"
className={accentBtn}
onClick={enable}
disabled={busy}
>
{busy ? (
<Loader2 className="animate-spin size-3.5" />
) : (
t("termixId.caEnable")
)}
</Button>
) : confirmingRotate ? (
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
className="border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={rotate}
disabled={busy}
>
{busy ? (
<Loader2 className="animate-spin size-3.5" />
) : (
t("termixId.caRotate")
)}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setConfirmingRotate(false)}
>
{t("common.cancel")}
</Button>
</div>
) : confirmingDelete ? (
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
className="border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={remove}
disabled={busy}
>
{busy ? (
<Loader2 className="animate-spin size-3.5" />
) : (
t("termixId.caDelete")
)}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setConfirmingDelete(false)}
>
{t("common.cancel")}
</Button>
</div>
) : (
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
onClick={() => setConfirmingRotate(true)}
disabled={busy}
>
<RefreshCw className="size-3.5" />
{t("termixId.caRotate")}
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={() => setConfirmingDelete(true)}
disabled={busy}
className="hover:text-destructive"
>
<Trash2 className="size-3.5" />
</Button>
</div>
)
}
>
<div className="py-2.5 border-b border-border">
<p className="text-xs text-muted-foreground leading-snug">
{t("termixId.caIntro")}
</p>
</div>
{confirmingRotate && (
<div className="py-2.5 border-b border-border">
<p className="text-xs text-destructive/80 leading-snug">
{t("termixId.caRotateConfirm")}
</p>
</div>
)}
{confirmingDelete && (
<div className="py-2.5 border-b border-border">
<p className="text-xs text-destructive/80 leading-snug">
{t("termixId.caDeleteConfirm")}
</p>
</div>
)}
{ca && (
<>
<CopyRow label={t("termixId.caTrustLabel")} value={trustCmd} />
<CopyRow
label={t("termixId.caPublicKeyLabel")}
value={ca.publicKey}
/>
</>
)}
</SectionCard>
);
}
@@ -0,0 +1,57 @@
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
import type {
CredentialOption,
GeneratedKey,
IssuedCertificate,
TermixIdCa,
TermixIdentity,
TermixIdentityKey,
TermixIdMe,
} from "./types";
/** The Termix ID routes, relative to /plugin-api/termix-identity/. */
export function createTermixIdApi(api: PluginApiClient) {
const data = async <T>(request: Promise<{ data: T }>) => (await request).data;
return {
me: () => data(api.get<TermixIdMe>("/me")),
checkHandle: (handle: string) =>
data(
api.get<{ available: boolean; valid: boolean }>(
`/check/${encodeURIComponent(handle)}`,
),
),
create: (handle: string, description?: string) =>
data(api.post<TermixIdentity>("/", { handle, description })),
update: (body: { handle?: string; description?: string }) =>
data(api.put<TermixIdentity>("/", body)),
remove: () => data(api.delete<{ success: boolean }>("/")),
addKey: (body: {
publicKey?: string;
credentialId?: number;
label?: string;
}) => data(api.post<TermixIdentityKey>("/keys", body)),
generateKey: (type: "ed25519" | "rsa", saveCredential: boolean) =>
data(api.post<GeneratedKey>("/keys/generate", { type, saveCredential })),
setKeyEnabled: (id: number, enabled: boolean) =>
data(api.patch<TermixIdentityKey>(`/keys/${id}`, { enabled })),
removeKey: (id: number) =>
data(api.delete<{ success: boolean }>(`/keys/${id}`)),
ca: () => data(api.get<{ ca: TermixIdCa | null }>("/ca")),
createCa: (validityDays?: number) =>
data(api.post<TermixIdCa>("/ca", { validityDays })),
rotateCa: (validityDays?: number) =>
data(api.post<TermixIdCa>("/ca/rotate", { validityDays })),
removeCa: () => data(api.delete<{ success: boolean }>("/ca")),
issueCertificate: (
keyId: number,
body: { principals?: string[]; validityDays?: number } = {},
) => data(api.post<IssuedCertificate>(`/keys/${keyId}/certificate`, body)),
linkedCredentialIds: () =>
data(api.get<{ credentialIds: number[] }>("/linked-credentials")),
credentials: () =>
data(api.get<{ credentials: CredentialOption[] }>("/credentials")),
};
}
export type TermixIdApi = ReturnType<typeof createTermixIdApi>;
@@ -0,0 +1,101 @@
import { useEffect, useReducer, type ComponentType } from "react";
import { Fingerprint } from "lucide-react";
import {
useTranslation,
type Disposer,
type TermixApp,
} from "@termix/plugin-sdk/frontend";
import { TermixIdPanel } from "./TermixIdPanel";
import { createTermixIdApi } from "./api";
import { createLinkedStore, type LinkedStore } from "./linked-store";
const VIEW = "termix-id";
function createCredentialBadge(linked: LinkedStore) {
/** The "ID" badge on a saved credential whose key is published. */
return function CredentialBadge({ credentialId }: { credentialId?: number }) {
const { t } = useTranslation();
const [, rerender] = useReducer((n: number) => n + 1, 0);
useEffect(() => {
linked.ensure();
return linked.subscribe(rerender);
}, []);
if (credentialId === undefined || !linked.has(Number(credentialId))) {
return null;
}
return (
<span className="text-[9px] px-1 py-px font-bold border leading-none shrink-0 border-accent-brand/30 text-accent-brand/70">
{t("credentials.idBadge")}
</span>
);
};
}
export function activate(app: TermixApp): void {
const api = createTermixIdApi(app.api);
const linked = createLinkedStore(api, () => app.hasPermission("use"));
const Panel = () => (
<div className="flex flex-col flex-1 min-h-0">
<TermixIdPanel api={api} linked={linked} />
</div>
);
// Publishing keys under a public handle means nothing to a standalone
// desktop install, so the rail item waits for a connected remote server.
let removeRailItem: Disposer | null = null;
let hidden: boolean | null = null;
const showRailItem = (hide: boolean) => {
if (hide === hidden) return;
hidden = hide;
removeRailItem?.();
removeRailItem = app.registerRailItem({
id: VIEW,
icon: Fingerprint,
titleKey: "nav.termixId",
after: "credentials",
promotable: true,
separatorAfter: true,
permission: "use",
hidden: hide,
});
};
if (app.desktop.available) {
let alive = true;
const check = () =>
void app.desktop.remoteServerUrl().then((url) => {
if (alive) showRailItem(!url);
});
showRailItem(true);
check();
app.desktop.onRemoteServerChange(check);
app.onDispose(() => {
alive = false;
});
} else {
showRailItem(false);
}
app.registerPanel(VIEW, Panel);
app.registerTab(VIEW, Panel, {
icon: Fingerprint,
titleKey: "nav.termixId",
hostless: true,
singleton: true,
panelFrame: true,
});
app.registerSlotContribution("credentials.badges", {
actionId: "termix-identity.credentialBadge",
titleKey: "credentials.idBadge",
kind: "component",
component: createCredentialBadge(linked) as unknown as ComponentType<
Record<string, unknown>
>,
});
}
export function deactivate(): void {
// Registrations through app are disposed by core.
}
@@ -0,0 +1,48 @@
import type { TermixIdApi } from "./api";
/**
* Which credentials have a published key, shared by every credential badge
* so the list fetches once instead of once per row. The panel calls
* refresh() after it publishes or removes a key.
*/
export function createLinkedStore(
api: TermixIdApi,
allowed: () => Promise<boolean>,
) {
let ids = new Set<number>();
let loading: Promise<void> | null = null;
let loaded = false;
const listeners = new Set<() => void>();
const refresh = (): Promise<void> => {
loading = (async () => {
try {
ids = (await allowed())
? new Set((await api.linkedCredentialIds()).credentialIds)
: new Set();
} catch {
// Badges are a hint; a failed lookup just shows none.
}
loaded = true;
listeners.forEach((listener) => listener());
})();
return loading;
};
return {
has: (id: number) => ids.has(id),
ids: () => ids,
ensure: () => {
if (!loaded && !loading) void refresh();
},
refresh,
subscribe: (listener: () => void) => {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
};
}
export type LinkedStore = ReturnType<typeof createLinkedStore>;
@@ -0,0 +1,64 @@
export interface TermixIdentity {
id: number;
userId: string;
handle: string;
description: string | null;
resolverPath?: string;
resolverUrl?: string;
createdAt: string;
updatedAt: string;
}
export interface TermixIdentityKey {
id: number;
identityId: number;
userId: string;
publicKey: string;
keyType: string;
algorithm: string;
label: string | null;
comment: string | null;
source: string;
credentialId: number | null;
enabled: boolean;
createdAt: string;
}
export interface TermixIdMe {
identity: TermixIdentity | null;
keys: TermixIdentityKey[];
}
export interface GeneratedKey {
key: TermixIdentityKey;
privateKey: string;
publicKey: string;
credentialId: number | null;
}
export interface TermixIdCa {
publicKey: string;
validityDays: number;
resolverPath: string;
resolverUrl: string;
}
export interface IssuedCertificate {
certificate: string;
keyId: string;
validBefore: number;
principals: string[];
validityDays: number;
}
export interface CredentialOption {
id: number;
name: string;
}
/** The server's error message when it sent one, else the fallback. */
export function errorMessage(error: unknown, fallback: string): string {
const data = (error as { response?: { data?: { error?: unknown } } })
?.response?.data;
return typeof data?.error === "string" ? data.error : fallback;
}