refactor: show passkey generic error on all failures
This commit is contained in:
parent
250b1ca382
commit
fe6f0c49d7
5 changed files with 145 additions and 19 deletions
|
|
@ -4,14 +4,32 @@ import { Fingerprint } from "lucide-react";
|
||||||
import { Button } from "~/client/components/ui/button";
|
import { Button } from "~/client/components/ui/button";
|
||||||
import { authClient } from "~/client/lib/auth-client";
|
import { authClient } from "~/client/lib/auth-client";
|
||||||
import { logger } from "~/client/lib/logger";
|
import { logger } from "~/client/lib/logger";
|
||||||
|
import { LOGIN_ERROR_CODES, PASSKEY_LOGIN_FAILED_ERROR, type LoginErrorCode } from "~/lib/sso-errors";
|
||||||
|
|
||||||
type PasskeySignInButtonProps = {
|
type PasskeySignInButtonProps = {
|
||||||
onSignIn: () => Promise<void>;
|
onSignIn: () => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type PasskeySignInError = {
|
||||||
|
code?: string;
|
||||||
|
message?: string;
|
||||||
|
status?: number;
|
||||||
|
statusText?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const LOGIN_ERROR_CODE_SET = new Set<string>(LOGIN_ERROR_CODES);
|
||||||
|
|
||||||
|
function getPasskeyLoginErrorCode(code: string | undefined): LoginErrorCode {
|
||||||
|
if (code && LOGIN_ERROR_CODE_SET.has(code)) {
|
||||||
|
return code as LoginErrorCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
return PASSKEY_LOGIN_FAILED_ERROR;
|
||||||
|
}
|
||||||
|
|
||||||
export function PasskeySignInButton({ onSignIn }: PasskeySignInButtonProps) {
|
export function PasskeySignInButton({ onSignIn }: PasskeySignInButtonProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const passkeyLoginMutation = useMutation({
|
const passkeyLoginMutation = useMutation<void, PasskeySignInError>({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
const { error } = await authClient.signIn.passkey();
|
const { error } = await authClient.signIn.passkey();
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
|
@ -21,19 +39,10 @@ export function PasskeySignInButton({ onSignIn }: PasskeySignInButtonProps) {
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
logger.error(error);
|
logger.error(error);
|
||||||
|
|
||||||
let errorCode: string | undefined = undefined;
|
|
||||||
|
|
||||||
if ("code" in error && typeof error.code === "string") {
|
|
||||||
errorCode = error.code;
|
|
||||||
if (error.code === "AUTHENTICATION_FAILED") {
|
|
||||||
errorCode = "PASSKEY_LOGIN_FAILED";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void navigate({
|
void navigate({
|
||||||
to: "/login",
|
to: "/login",
|
||||||
search: {
|
search: {
|
||||||
error: errorCode,
|
error: getPasskeyLoginErrorCode(error.code),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||||
import { HttpResponse, http, server } from "~/test/msw/server";
|
import { HttpResponse, http, server } from "~/test/msw/server";
|
||||||
import { cleanup, render, screen, userEvent, waitFor } from "~/test/test-utils";
|
import { cleanup, render, screen, userEvent, waitFor } from "~/test/test-utils";
|
||||||
|
import { PASSKEY_LOGIN_FAILED_ERROR } from "~/lib/sso-errors";
|
||||||
|
|
||||||
const { mockGetLoginOptions, mockNavigate, mockPasskeySignIn } = vi.hoisted(() => ({
|
const { mockGetLoginOptions, mockNavigate, mockPasskeySignIn } = vi.hoisted(() => ({
|
||||||
mockGetLoginOptions: vi.fn(async () => ({ hasPasskeySignIn: false })),
|
mockGetLoginOptions: vi.fn(async () => ({ hasPasskeySignIn: false })),
|
||||||
|
|
@ -66,6 +67,7 @@ afterEach(() => {
|
||||||
mockNavigate.mockClear();
|
mockNavigate.mockClear();
|
||||||
mockPasskeySignIn.mockClear();
|
mockPasskeySignIn.mockClear();
|
||||||
mockPasskeySignIn.mockResolvedValue({ data: null, error: null });
|
mockPasskeySignIn.mockResolvedValue({ data: null, error: null });
|
||||||
|
vi.unstubAllGlobals();
|
||||||
cleanup();
|
cleanup();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -169,6 +171,75 @@ describe("LoginPage", () => {
|
||||||
await userEvent.click(await screen.findByRole("button", { name: "Sign in with passkey" }));
|
await userEvent.click(await screen.findByRole("button", { name: "Sign in with passkey" }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
|
expect(mockNavigate).toHaveBeenCalledWith({
|
||||||
|
to: "/login",
|
||||||
|
search: {
|
||||||
|
error: PASSKEY_LOGIN_FAILED_ERROR,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("redirects unauthorized passkey failures to the login error box", async () => {
|
||||||
|
mockSsoProvidersRequest();
|
||||||
|
mockGetLoginOptions.mockResolvedValue({ hasPasskeySignIn: true });
|
||||||
|
mockPasskeySignIn.mockResolvedValue({
|
||||||
|
data: null,
|
||||||
|
error: { code: "UNAUTHORIZED", message: "Unauthorized" },
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<LoginPage />, { withSuspense: true });
|
||||||
|
|
||||||
|
await userEvent.click(await screen.findByRole("button", { name: "Sign in with passkey" }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockNavigate).toHaveBeenCalledWith({
|
||||||
|
to: "/login",
|
||||||
|
search: {
|
||||||
|
error: PASSKEY_LOGIN_FAILED_ERROR,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("preserves specific passkey login error codes", async () => {
|
||||||
|
mockSsoProvidersRequest();
|
||||||
|
mockGetLoginOptions.mockResolvedValue({ hasPasskeySignIn: true });
|
||||||
|
mockPasskeySignIn.mockResolvedValue({
|
||||||
|
data: null,
|
||||||
|
error: { code: "ERROR_INVALID_RP_ID", message: "Auth cancelled" },
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<LoginPage />, { withSuspense: true });
|
||||||
|
|
||||||
|
await userEvent.click(await screen.findByRole("button", { name: "Sign in with passkey" }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockNavigate).toHaveBeenCalledWith({
|
||||||
|
to: "/login",
|
||||||
|
search: {
|
||||||
|
error: "ERROR_INVALID_RP_ID",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("redirects conditional passkey autofill failures to the login error box", async () => {
|
||||||
|
mockSsoProvidersRequest();
|
||||||
|
mockPasskeySignIn.mockResolvedValue({
|
||||||
|
data: null,
|
||||||
|
error: { code: "AUTHENTICATION_FAILED", message: "Authentication failed" },
|
||||||
|
});
|
||||||
|
vi.stubGlobal("PublicKeyCredential", {
|
||||||
|
isConditionalMediationAvailable: vi.fn(async () => true),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<LoginPage />, { withSuspense: true });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockPasskeySignIn).toHaveBeenCalledWith({
|
||||||
|
autoFill: true,
|
||||||
|
});
|
||||||
expect(mockNavigate).toHaveBeenCalledWith({
|
expect(mockNavigate).toHaveBeenCalledWith({
|
||||||
to: "/login",
|
to: "/login",
|
||||||
search: {
|
search: {
|
||||||
|
|
@ -178,6 +249,26 @@ describe("LoginPage", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("ignores conditional passkey autofill cancellation", async () => {
|
||||||
|
mockSsoProvidersRequest();
|
||||||
|
mockPasskeySignIn.mockResolvedValue({
|
||||||
|
data: null,
|
||||||
|
error: { code: "AUTH_CANCELLED", message: "Authentication cancelled" },
|
||||||
|
});
|
||||||
|
vi.stubGlobal("PublicKeyCredential", {
|
||||||
|
isConditionalMediationAvailable: vi.fn(async () => true),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<LoginPage />, { withSuspense: true });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockPasskeySignIn).toHaveBeenCalledWith({
|
||||||
|
autoFill: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
expect(mockNavigate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
test("hides alternative sign-in when no SSO providers or passkeys are available", async () => {
|
test("hides alternative sign-in when no SSO providers or passkeys are available", async () => {
|
||||||
mockSsoProvidersRequest();
|
mockSsoProvidersRequest();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import { authClient } from "~/client/lib/auth-client";
|
||||||
import { logger } from "~/client/lib/logger";
|
import { logger } from "~/client/lib/logger";
|
||||||
import { RECOVERY_KEY_DOWNLOAD_SKIPPED_COOKIE_NAME } from "~/lib/recovery-key-skip";
|
import { RECOVERY_KEY_DOWNLOAD_SKIPPED_COOKIE_NAME } from "~/lib/recovery-key-skip";
|
||||||
import { decodeLoginError, getLoginErrorDescription } from "~/client/lib/sso-errors";
|
import { decodeLoginError, getLoginErrorDescription } from "~/client/lib/sso-errors";
|
||||||
|
import { PASSKEY_LOGIN_FAILED_ERROR } from "~/lib/sso-errors";
|
||||||
import { ResetPasswordDialog } from "../components/reset-password-dialog";
|
import { ResetPasswordDialog } from "../components/reset-password-dialog";
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
import { normalizeUsername } from "~/lib/username";
|
import { normalizeUsername } from "~/lib/username";
|
||||||
|
|
@ -33,6 +34,17 @@ type LoginPageProps = {
|
||||||
error?: string;
|
error?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type PasskeySignInError = {
|
||||||
|
code?: string;
|
||||||
|
message?: string;
|
||||||
|
status?: number;
|
||||||
|
statusText?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function isPasskeyVerificationFailure(error: PasskeySignInError | null) {
|
||||||
|
return error?.code === "AUTHENTICATION_FAILED" || error?.code === "UNAUTHORIZED";
|
||||||
|
}
|
||||||
|
|
||||||
function hasSkippedRecoveryKeyDownload(userId: string) {
|
function hasSkippedRecoveryKeyDownload(userId: string) {
|
||||||
return document.cookie
|
return document.cookie
|
||||||
.split(";")
|
.split(";")
|
||||||
|
|
@ -74,16 +86,27 @@ export function LoginPage({ error }: LoginPageProps = {}) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await authClient.signIn.passkey({
|
const { data, error } = await authClient.signIn.passkey({
|
||||||
autoFill: true,
|
autoFill: true,
|
||||||
fetchOptions: {
|
|
||||||
onResponse: navigateAfterLogin,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (isPasskeyVerificationFailure(error)) {
|
||||||
|
void navigate({
|
||||||
|
to: "/login",
|
||||||
|
search: {
|
||||||
|
error: PASSKEY_LOGIN_FAILED_ERROR,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
await navigateAfterLogin();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
void autoSignIn();
|
void autoSignIn();
|
||||||
}, [navigateAfterLogin]);
|
}, [navigate, navigateAfterLogin]);
|
||||||
|
|
||||||
const form = useForm<LoginFormValues>({
|
const form = useForm<LoginFormValues>({
|
||||||
resolver: zodResolver(loginSchema),
|
resolver: zodResolver(loginSchema),
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
|
export const PASSKEY_LOGIN_FAILED_ERROR = "PASSKEY_LOGIN_FAILED";
|
||||||
|
|
||||||
export const LOGIN_ERROR_CODES = [
|
export const LOGIN_ERROR_CODES = [
|
||||||
"ACCOUNT_LINK_REQUIRED",
|
"ACCOUNT_LINK_REQUIRED",
|
||||||
"EMAIL_NOT_VERIFIED",
|
"EMAIL_NOT_VERIFIED",
|
||||||
"INVITE_REQUIRED",
|
"INVITE_REQUIRED",
|
||||||
"BANNED_USER",
|
"BANNED_USER",
|
||||||
"SSO_LOGIN_FAILED",
|
"SSO_LOGIN_FAILED",
|
||||||
"PASSKEY_LOGIN_FAILED",
|
PASSKEY_LOGIN_FAILED_ERROR,
|
||||||
"ERROR_INVALID_RP_ID",
|
"ERROR_INVALID_RP_ID",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|
@ -22,7 +24,7 @@ export function getLoginErrorDescription(errorCode: LoginErrorCode): string {
|
||||||
return "You have been banned from this application. Please contact support if you believe this is an error.";
|
return "You have been banned from this application. Please contact support if you believe this is an error.";
|
||||||
case "SSO_LOGIN_FAILED":
|
case "SSO_LOGIN_FAILED":
|
||||||
return "SSO authentication failed. Please try again.";
|
return "SSO authentication failed. Please try again.";
|
||||||
case "PASSKEY_LOGIN_FAILED":
|
case PASSKEY_LOGIN_FAILED_ERROR:
|
||||||
return "Passkey sign-in failed. If 2FA is enabled, use a passkey protected by a PIN, biometrics, or screen lock, or sign in with your password and authenticator code.";
|
return "Passkey sign-in failed. If 2FA is enabled, use a passkey protected by a PIN, biometrics, or screen lock, or sign in with your password and authenticator code.";
|
||||||
case "ERROR_INVALID_RP_ID":
|
case "ERROR_INVALID_RP_ID":
|
||||||
return "You can only sign in with a passkey on the domain set by the BASE_URL environment variable";
|
return "You can only sign in with a passkey on the domain set by the BASE_URL environment variable";
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import viteReact, { reactCompilerPreset } from "@vitejs/plugin-react";
|
||||||
import babel from "@rolldown/plugin-babel";
|
import babel from "@rolldown/plugin-babel";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
clearScreen: false,
|
||||||
plugins: [
|
plugins: [
|
||||||
tanstackStart({
|
tanstackStart({
|
||||||
srcDirectory: "app",
|
srcDirectory: "app",
|
||||||
|
|
@ -30,7 +31,7 @@ export default defineConfig({
|
||||||
server: {
|
server: {
|
||||||
host: "0.0.0.0",
|
host: "0.0.0.0",
|
||||||
port: 3000,
|
port: 3000,
|
||||||
allowedHosts: [".ts.net"]
|
allowedHosts: [".ts.net"],
|
||||||
},
|
},
|
||||||
fmt: {
|
fmt: {
|
||||||
printWidth: 120,
|
printWidth: 120,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue