📦 v5.0.18 - https://docs.rxresu.me/changelog (Passkeys Support)

This commit is contained in:
Amruth Pillai
2026-04-14 10:51:04 +02:00
parent 4ed6177aee
commit c19b9746c8
70 changed files with 4758 additions and 622 deletions
+2
View File
@@ -2,6 +2,7 @@ import { apiKeyClient } from "@better-auth/api-key/client";
import { dashClient } from "@better-auth/infra/client";
import { oauthProviderClient } from "@better-auth/oauth-provider/client";
import { oauthProviderResourceClient } from "@better-auth/oauth-provider/resource-client";
import { passkeyClient } from "@better-auth/passkey/client";
import {
adminClient,
genericOAuthClient,
@@ -19,6 +20,7 @@ const getAuthClient = () => {
dashClient(),
adminClient(),
apiKeyClient(),
passkeyClient(),
usernameClient(),
twoFactorClient({
onTwoFactorRedirect() {
+2
View File
@@ -4,6 +4,7 @@ import { apiKey } from "@better-auth/api-key";
import { drizzleAdapter } from "@better-auth/drizzle-adapter";
import { dash } from "@better-auth/infra";
import { oauthProvider } from "@better-auth/oauth-provider";
import { passkey } from "@better-auth/passkey";
import { BetterAuthError, betterAuth } from "better-auth";
import { verifyAccessToken } from "better-auth/oauth2";
import { admin, jwt, openAPI, type GenericOAuthConfig } from "better-auth/plugins";
@@ -341,6 +342,7 @@ const getAuthConfig = () => {
jwt(),
admin(),
openAPI(),
passkey(),
genericOAuth({ config: authConfigs }),
twoFactor({ issuer: "Reactive Resume" }),
apiKey({ enableSessionForAPIKeys: true, rateLimit: { enabled: false } }),
+1 -1
View File
@@ -7,6 +7,6 @@ export type AuthSession = {
user: typeof auth.$Infer.Session.user;
};
const authProviderSchema = z.enum(["credential", "google", "github", "linkedin", "custom"]);
const authProviderSchema = z.enum(["credential", "passkey", "google", "github", "linkedin", "custom"]);
export type AuthProvider = z.infer<typeof authProviderSchema>;
+1 -1
View File
@@ -13,7 +13,7 @@ export type ProviderList = Partial<Record<AuthProvider, string>>;
const providers = {
list: (): ProviderList => {
const providers: ProviderList = { credential: "Password" };
const providers: ProviderList = { credential: "Password", passkey: "Passkey" };
if (env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) providers.google = "Google";
if (env.GITHUB_CLIENT_ID && env.GITHUB_CLIENT_SECRET) providers.github = "GitHub";
+36 -1
View File
@@ -1,8 +1,9 @@
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { GithubLogoIcon, GoogleLogoIcon, LinkedinLogoIcon, VaultIcon } from "@phosphor-icons/react";
import { FingerprintIcon, GithubLogoIcon, GoogleLogoIcon, LinkedinLogoIcon, VaultIcon } from "@phosphor-icons/react";
import { useQuery } from "@tanstack/react-query";
import { useRouter } from "@tanstack/react-router";
import { useEffect } from "react";
import { toast } from "sonner";
import type { RouterOutput } from "@/integrations/orpc/client";
@@ -85,6 +86,31 @@ function SocialAuthButtons({ providers }: SocialAuthButtonsProps) {
await router.invalidate();
};
const handlePasskeyLogin = async () => {
const toastId = toast.loading(t`Signing in...`);
const { error } = await authClient.signIn.passkey({ autoFill: false });
if (error) {
toast.error(error.message, { id: toastId });
return;
}
toast.dismiss(toastId);
await router.invalidate();
};
useEffect(() => {
if (typeof window === "undefined") return;
if (!("PublicKeyCredential" in window)) return;
if (!PublicKeyCredential.isConditionalMediationAvailable) return;
void PublicKeyCredential.isConditionalMediationAvailable().then((isAvailable) => {
if (!isAvailable) return;
void authClient.signIn.passkey({ autoFill: true });
});
}, []);
return (
<div className="grid grid-cols-2 gap-4">
<Button
@@ -96,6 +122,15 @@ function SocialAuthButtons({ providers }: SocialAuthButtonsProps) {
{providers.custom}
</Button>
<Button
variant="secondary"
onClick={handlePasskeyLogin}
className={cn("hidden", "passkey" in providers && "inline-flex")}
>
<FingerprintIcon />
Passkey
</Button>
<Button
onClick={() => handleSocialLogin("google")}
className={cn(
@@ -1,7 +1,14 @@
import type { ReactNode } from "react";
import { t } from "@lingui/core/macro";
import { GithubLogoIcon, GoogleLogoIcon, LinkedinLogoIcon, PasswordIcon, VaultIcon } from "@phosphor-icons/react";
import {
FingerprintIcon,
GithubLogoIcon,
GoogleLogoIcon,
LinkedinLogoIcon,
PasswordIcon,
VaultIcon,
} from "@phosphor-icons/react";
import { useQuery } from "@tanstack/react-query";
import { useCallback } from "react";
import { toast } from "sonner";
@@ -18,6 +25,7 @@ import { orpc } from "@/integrations/orpc/client";
export function getProviderName(providerId: AuthProvider): string {
return match(providerId)
.with("credential", () => "Password")
.with("passkey", () => "Passkey")
.with("google", () => "Google")
.with("github", () => "GitHub")
.with("linkedin", () => "LinkedIn")
@@ -31,6 +39,7 @@ export function getProviderName(providerId: AuthProvider): string {
export function getProviderIcon(providerId: AuthProvider): ReactNode {
return match(providerId)
.with("credential", () => <PasswordIcon />)
.with("passkey", () => <FingerprintIcon />)
.with("google", () => <GoogleLogoIcon />)
.with("github", () => <GithubLogoIcon />)
.with("linkedin", () => <LinkedinLogoIcon />)
@@ -0,0 +1,146 @@
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { KeyIcon, PlusIcon, TrashIcon } from "@phosphor-icons/react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { motion } from "motion/react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { usePrompt } from "@/hooks/use-prompt";
import { authClient } from "@/integrations/auth/client";
export function PasskeysSection() {
const queryClient = useQueryClient();
const prompt = usePrompt();
const { data: passkeys = [] } = useQuery({
queryKey: ["auth", "passkeys"],
queryFn: () => authClient.passkey.listUserPasskeys(),
select: ({ data }) => data ?? [],
});
const registerPasskeyMutation = useMutation({
mutationFn: async () => {
return await authClient.passkey.addPasskey();
},
onSuccess: async ({ data, error }) => {
if (error) {
toast.error(error.message);
return;
}
toast.success(t`Passkey registered successfully.`);
await queryClient.invalidateQueries({ queryKey: ["auth", "passkeys"] });
const name = await prompt(t`Name your passkey`, {
description: t`You can skip this and keep the default name.`,
defaultValue: "",
confirmText: t`Save`,
inputProps: { placeholder: t`MacBook Touch ID` },
});
if (name === null) return;
const passkeyId = typeof data?.id === "string" ? data.id : null;
const passkeyName = name.trim();
if (!passkeyId || passkeyName.length === 0) return;
const { error: renameError } = await authClient.passkey.updatePasskey({ id: passkeyId, name: passkeyName });
if (renameError) {
toast.error(renameError.message);
return;
}
await queryClient.invalidateQueries({ queryKey: ["auth", "passkeys"] });
},
onError: () => {
toast.error(t`Failed to register passkey. Please try again.`);
},
});
const deletePasskeyMutation = useMutation({
mutationFn: async (id: string) => {
return await authClient.passkey.deletePasskey({ id });
},
onSuccess: async ({ error }) => {
if (error) {
toast.error(error.message);
return;
}
toast.success(t`Passkey deleted successfully.`);
await queryClient.invalidateQueries({ queryKey: ["auth", "passkeys"] });
},
onError: () => {
toast.error(t`Failed to delete passkey. Please try again.`);
},
});
const handleRegisterPasskey = () => {
if (registerPasskeyMutation.isPending) return;
registerPasskeyMutation.mutate();
};
const handleDeletePasskey = (id: string) => {
if (deletePasskeyMutation.isPending) return;
deletePasskeyMutation.mutate(id);
};
return (
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2, delay: 0.3, ease: "easeOut" }}
className="will-change-[transform,opacity]"
>
<Separator />
<div className="mt-4 grid gap-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<h2 className="flex items-center gap-x-3 text-base font-medium">
<KeyIcon />
<Trans>Passkeys</Trans>
</h2>
<Button variant="outline" onClick={handleRegisterPasskey} disabled={registerPasskeyMutation.isPending}>
<PlusIcon />
<Trans>Register New Device</Trans>
</Button>
</div>
{passkeys.length === 0 && (
<p className="text-sm text-muted-foreground">
<Trans>No passkeys registered yet.</Trans>
</p>
)}
{passkeys.length > 0 && (
<div className="grid gap-2">
{passkeys.map((passkey) => {
return (
<div
key={passkey.id}
className="flex flex-wrap items-center justify-between gap-2 rounded-md border bg-muted/40 px-3 py-2"
>
<p className="truncate text-sm font-medium">{passkey.name ?? t`Unnamed passkey`}</p>
<div className="flex items-center gap-2">
<Button
variant="destructive"
size="sm"
onClick={() => handleDeletePasskey(passkey.id)}
disabled={deletePasskeyMutation.isPending}
>
<TrashIcon />
<Trans>Delete</Trans>
</Button>
</div>
</div>
);
})}
</div>
)}
</div>
</motion.div>
);
}
@@ -7,6 +7,7 @@ import { Separator } from "@/components/ui/separator";
import { DashboardHeader } from "../../-components/header";
import { useEnabledProviders } from "./-components/hooks";
import { PasskeysSection } from "./-components/passkeys";
import { PasswordSection } from "./-components/password";
import { SocialProviderSection } from "./-components/social-provider";
import { TwoFactorSection } from "./-components/two-factor";
@@ -34,12 +35,7 @@ function RouteComponent() {
<TwoFactorSection />
{/*
Passkeys are temporarily disabled due to an upstream issue with the authentication provider.
See https://github.com/better-auth/better-auth/issues/7463 for more details.
<PasskeysSection />
*/}
<PasskeysSection />
{"google" in enabledProviders && <SocialProviderSection provider="google" animationDelay={0.4} />}