feat: move totp and passkeys into plugins (C1)

This commit is contained in:
LukeGus committed 2026-09-24 16:57:24 -05:00
1 parent f4f26a9b8b
commit 5c18435105
235 files changed
+22545 -5610

No files matched your search

@@ -0,0 +1,137 @@
import { useEffect, useMemo, useState } from "react";
import { KeyRound, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Button, Input, Select2 } from "@termix/plugin-sdk/ui";
import { usePluginApi, useTranslation } from "@termix/plugin-sdk/frontend";
import {
createWebAuthnApi,
type WebAuthnCredentialSummary,
type WebAuthnUserVerification,
} from "./webauthn-api";
function apiErrorMessage(error: unknown, fallback: string): string {
const data = (error as { response?: { data?: { error?: unknown } } })
?.response?.data;
return typeof data?.error === "string" && data.error ? data.error : fallback;
}
/** Settings > Security: register and remove passkeys. */
export function PasskeyEnrollment() {
const { t } = useTranslation();
const pluginApi = usePluginApi();
const api = useMemo(() => createWebAuthnApi(pluginApi), [pluginApi]);
const [passkeys, setPasskeys] = useState<WebAuthnCredentialSummary[]>([]);
const [loading, setLoading] = useState(false);
const [name, setName] = useState("");
const [userVerification, setUserVerification] =
useState<WebAuthnUserVerification>("preferred");
useEffect(() => {
api
.list()
.then(setPasskeys)
.catch(() => {});
}, [api]);
async function add() {
setLoading(true);
try {
await api.register(name || t("defaultName"), userVerification);
setPasskeys(await api.list());
setName("");
toast.success(t("added"));
} catch (e) {
toast.error(apiErrorMessage(e, t("addFailed")));
} finally {
setLoading(false);
}
}
async function remove(id: string) {
setLoading(true);
try {
await api.remove(id);
setPasskeys((prev) => prev.filter((item) => item.id !== id));
toast.success(t("deleted"));
} catch (e) {
toast.error(apiErrorMessage(e, t("deleteFailed")));
} finally {
setLoading(false);
}
}
return (
<div className="flex flex-col gap-3">
<span className="text-[10px] text-muted-foreground">
{t("description")}
</span>
<div className="grid grid-cols-[1fr_auto] gap-2">
<Input
placeholder={t("namePlaceholder")}
value={name}
onChange={(e) => setName(e.target.value)}
className="h-8 text-xs"
disabled={loading}
/>
<Select2
value={userVerification}
onChange={(e) =>
setUserVerification(e.target.value as WebAuthnUserVerification)
}
className="h-8 w-28 text-xs border border-border bg-background px-2 outline-none focus:ring-1 focus:ring-ring"
disabled={loading}
>
<option value="preferred">{t("uv.preferred")}</option>
<option value="required">{t("uv.required")}</option>
<option value="discouraged">{t("uv.discouraged")}</option>
</Select2>
</div>
<Button
variant="outline"
size="sm"
className="h-7 text-[10px] border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={add}
disabled={loading}
>
<KeyRound className="size-3" />
{t("add")}
</Button>
<div className="flex flex-col divide-y divide-border border border-border">
{passkeys.length === 0 ? (
<div className="py-4 text-center text-[10px] text-muted-foreground">
{t("none")}
</div>
) : (
passkeys.map((passkey) => (
<div
key={passkey.id}
className="flex items-center justify-between gap-2 px-2 py-2"
>
<div className="min-w-0 flex flex-col">
<span className="text-xs font-medium truncate">
{passkey.name}
</span>
<span className="text-[10px] text-muted-foreground truncate">
{passkey.deviceType || t("unknownDevice")}
{passkey.backedUp ? ` / ${t("synced")}` : ""}
</span>
</div>
<Button
variant="ghost"
size="icon"
className="size-6 text-muted-foreground hover:text-destructive"
onClick={() => remove(passkey.id)}
disabled={loading}
aria-label={t("delete")}
>
<Trash2 className="size-3" />
</Button>
</div>
))
)}
</div>
</div>
);
}
@@ -0,0 +1,59 @@
import { useMemo, useState } from "react";
import { Fingerprint } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@termix/plugin-sdk/ui";
import {
usePluginApi,
useTranslation,
type LoginMethodUIProps,
} from "@termix/plugin-sdk/frontend";
import { createWebAuthnApi, isPasskeySupported } from "./webauthn-api";
export function PasskeyLoginButton({
disabled,
submit,
username,
}: LoginMethodUIProps) {
const { t } = useTranslation();
const pluginApi = usePluginApi();
const api = useMemo(() => createWebAuthnApi(pluginApi), [pluginApi]);
const [loading, setLoading] = useState(false);
const [supported] = useState(isPasskeySupported);
if (!supported) return null;
return (
<Button
type="button"
variant="outline"
onClick={async () => {
setLoading(true);
try {
await submit(
await api.passkeyAssertion(username?.trim() || undefined),
);
} catch (err: unknown) {
const error = err as {
name?: string;
message?: string;
response?: { data?: { error?: string } };
};
// Closing the browser prompt is not a failure worth a toast.
if (error?.name === "NotAllowedError" || error?.name === "AbortError")
return;
toast.error(
error?.response?.data?.error || error?.message || t("loginFailed"),
);
} finally {
setLoading(false);
}
}}
disabled={disabled || loading}
className="w-full h-10 font-bold"
>
<span className="flex items-center gap-2">
<Fingerprint className="size-4" />
{t("signIn")}
</span>
</Button>
);
}
+13
View File
@@ -0,0 +1,13 @@
import type { TermixApp } from "@termix/plugin-sdk/frontend";
import { PasskeyLoginButton } from "./PasskeyLoginButton";
import { PasskeyEnrollment } from "./PasskeyEnrollment";
export function activate(app: TermixApp): void {
app.registerLoginMethod({
id: "passkey",
titleKey: "title",
placement: "inline",
component: PasskeyLoginButton,
enrollment: PasskeyEnrollment,
});
}
@@ -0,0 +1,82 @@
import type {
AuthenticationResponseJSON,
PublicKeyCredentialCreationOptionsJSON,
PublicKeyCredentialRequestOptionsJSON,
RegistrationResponseJSON,
} from "@simplewebauthn/browser";
import {
browserSupportsWebAuthn,
startAuthentication,
startRegistration,
} from "@simplewebauthn/browser";
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
export type WebAuthnUserVerification = "discouraged" | "preferred" | "required";
export type WebAuthnCredentialSummary = {
id: string;
name: string;
deviceType?: string | null;
backedUp: boolean;
transports: string[];
userVerification: WebAuthnUserVerification;
createdAt: string;
lastUsedAt?: string | null;
};
type OptionsResponse<T> = { options: T; challengeId: string };
export function isPasskeySupported(): boolean {
try {
return browserSupportsWebAuthn();
} catch {
return false;
}
}
export function createWebAuthnApi(api: PluginApiClient) {
return {
/**
* Runs the browser ceremony and returns the body for the login method's
* verify step. Without a username, discoverable passkeys still work.
*/
async passkeyAssertion(username?: string) {
const { data } = await api.post<
OptionsResponse<PublicKeyCredentialRequestOptionsJSON>
>("authenticate/options", username ? { username } : {});
const response = await startAuthentication({
optionsJSON: data.options,
});
return {
challengeId: data.challengeId,
response: response as AuthenticationResponseJSON,
};
},
async list(): Promise<WebAuthnCredentialSummary[]> {
const { data } = await api.get<{
credentials?: WebAuthnCredentialSummary[];
}>("credentials");
return data.credentials ?? [];
},
async register(
name: string,
userVerification: WebAuthnUserVerification,
): Promise<void> {
const { data } = await api.post<
OptionsResponse<PublicKeyCredentialCreationOptionsJSON>
>("register/options", { userVerification });
const response = await startRegistration({ optionsJSON: data.options });
await api.post("register/verify", {
challengeId: data.challengeId,
name,
response: response as RegistrationResponseJSON,
});
},
async remove(id: string): Promise<void> {
await api.delete(`credentials/${encodeURIComponent(id)}`);
},
};
}