mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 07:05:24 +00:00
feat: move totp and passkeys into plugins (C1)
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
@@ -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)}`);
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user