mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-11 05:24:59 +10:00
📦 v5.0.18 - https://docs.rxresu.me/changelog (Passkeys Support)
This commit is contained in:
@@ -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() {
|
||||
|
||||
@@ -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 } }),
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user