mirror of
https://github.com/andwati/zitadel.git
synced 2026-11-13 17:35:12 +00:00
fix(login): Improve Passkey Authentication Error Handling & Testing (#10971)
# Which Problems Are Solved This PR enhances the passkey authentication flow with comprehensive error handling, full internationalization support, and extensive test coverage. # How the Problems Are Solved I18n: - Replaced all hard-coded error messages with i18n translation keys - Consistent error messaging throughout the passkey flow - Added specific error handling for passkey cancellation (NotAllowedError) - Implemented fallback errors for undefined/missing responses - Better error messages for: - Session retrieval failures - Challenge request failures - User verification errors - Redirect determination issues Tests: - Added `login-passkey.test.tsx` with 100+ test cases covering: - Successful verification flows - Error scenarios and edge cases - Props handling - Component lifecycle - Added passkeys.test.ts with server-side function tests: - Session cookie retrieval - User validation - Custom lifetime handling - Critical fallback error paths Try-catch blocks around critical user retrieval operations Defensive checks for undefined responses from completeFlowOrGetUrl Support for custom lifetime parameters Cleaner error propagation
This commit is contained in:
@@ -0,0 +1,441 @@
|
||||
import { describe, expect, test, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { LoginPasskey } from "./login-passkey";
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
|
||||
// Mock next/navigation
|
||||
const mockPush = vi.fn();
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({
|
||||
push: mockPush,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock server actions
|
||||
vi.mock("@/lib/server/passkeys", () => ({
|
||||
sendPasskey: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/server/session", () => ({
|
||||
updateSession: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock navigator.credentials
|
||||
const mockCredentialsGet = vi.fn();
|
||||
Object.defineProperty(global.navigator, "credentials", {
|
||||
value: {
|
||||
get: mockCredentialsGet,
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
|
||||
describe("LoginPasskey Component", () => {
|
||||
let mockSendPasskey: any;
|
||||
let mockUpdateSession: any;
|
||||
|
||||
const messages = {
|
||||
passkey: {
|
||||
verify: {
|
||||
title: "Authenticate with a passkey",
|
||||
description: "Your device will ask for your fingerprint",
|
||||
usePassword: "Use password",
|
||||
submit: "Continue",
|
||||
errors: {
|
||||
couldNotRequestChallenge: "Could not request passkey challenge",
|
||||
couldNotVerifyPasskey: "Could not verify passkey",
|
||||
noResponseReceived: "Passkey verification failed - no response received",
|
||||
noRedirectProvided: "Passkey verification completed but no redirect was provided",
|
||||
couldNotRetrievePasskey: "An error occurred while retrieving passkey",
|
||||
verificationCancelled: "Passkey verification was cancelled",
|
||||
verificationFailed: "An error occurred during passkey verification",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const renderWithIntl = (component: React.ReactElement) => {
|
||||
return render(
|
||||
<NextIntlClientProvider locale="en" messages={messages}>
|
||||
{component}
|
||||
</NextIntlClientProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
mockPush.mockClear();
|
||||
mockCredentialsGet.mockClear();
|
||||
|
||||
const { sendPasskey } = await import("@/lib/server/passkeys");
|
||||
const { updateSession } = await import("@/lib/server/session");
|
||||
|
||||
mockSendPasskey = vi.mocked(sendPasskey);
|
||||
mockUpdateSession = vi.mocked(updateSession);
|
||||
});
|
||||
|
||||
describe("Initialization and Challenge Request", () => {
|
||||
test("should display error when challenge request fails", async () => {
|
||||
mockUpdateSession.mockResolvedValue({
|
||||
error: "Challenge failed",
|
||||
});
|
||||
|
||||
renderWithIntl(<LoginPasskey loginName="[email protected]" altPassword={false} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Could not request passkey challenge")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test("should display translated error when no public key is returned", async () => {
|
||||
mockUpdateSession.mockResolvedValue({
|
||||
challenges: {
|
||||
webAuthN: {
|
||||
publicKeyCredentialRequestOptions: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
renderWithIntl(<LoginPasskey loginName="[email protected]" altPassword={false} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Could not request passkey challenge")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test("should trigger passkey prompt when public key is available", async () => {
|
||||
const mockPublicKey = {
|
||||
challenge: new Uint8Array([1, 2, 3]),
|
||||
allowCredentials: [
|
||||
{
|
||||
id: new Uint8Array([4, 5, 6]),
|
||||
type: "public-key",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockUpdateSession.mockResolvedValue({
|
||||
challenges: {
|
||||
webAuthN: {
|
||||
publicKeyCredentialRequestOptions: {
|
||||
publicKey: mockPublicKey,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
mockCredentialsGet.mockResolvedValue({
|
||||
id: "credential-id",
|
||||
rawId: new ArrayBuffer(8),
|
||||
type: "public-key",
|
||||
response: {
|
||||
authenticatorData: new ArrayBuffer(8),
|
||||
clientDataJSON: new ArrayBuffer(8),
|
||||
signature: new ArrayBuffer(8),
|
||||
userHandle: new ArrayBuffer(8),
|
||||
},
|
||||
});
|
||||
|
||||
mockSendPasskey.mockResolvedValue({
|
||||
redirect: "/success",
|
||||
});
|
||||
|
||||
renderWithIntl(<LoginPasskey loginName="[email protected]" altPassword={false} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCredentialsGet).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Passkey Verification", () => {
|
||||
const setupSuccessfulChallenge = () => {
|
||||
const mockPublicKey = {
|
||||
challenge: new Uint8Array([1, 2, 3]),
|
||||
allowCredentials: [
|
||||
{
|
||||
id: new Uint8Array([4, 5, 6]),
|
||||
type: "public-key",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockUpdateSession.mockResolvedValue({
|
||||
challenges: {
|
||||
webAuthN: {
|
||||
publicKeyCredentialRequestOptions: {
|
||||
publicKey: mockPublicKey,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return mockPublicKey;
|
||||
};
|
||||
|
||||
test("should redirect on successful verification", async () => {
|
||||
setupSuccessfulChallenge();
|
||||
|
||||
mockCredentialsGet.mockResolvedValue({
|
||||
id: "credential-id",
|
||||
rawId: new ArrayBuffer(8),
|
||||
type: "public-key",
|
||||
response: {
|
||||
authenticatorData: new ArrayBuffer(8),
|
||||
clientDataJSON: new ArrayBuffer(8),
|
||||
signature: new ArrayBuffer(8),
|
||||
userHandle: new ArrayBuffer(8),
|
||||
},
|
||||
});
|
||||
|
||||
mockSendPasskey.mockResolvedValue({
|
||||
redirect: "/success",
|
||||
});
|
||||
|
||||
renderWithIntl(<LoginPasskey loginName="[email protected]" altPassword={false} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledWith("/success");
|
||||
});
|
||||
});
|
||||
|
||||
test("should display error when sendPasskey returns error", async () => {
|
||||
setupSuccessfulChallenge();
|
||||
|
||||
mockCredentialsGet.mockResolvedValue({
|
||||
id: "credential-id",
|
||||
rawId: new ArrayBuffer(8),
|
||||
type: "public-key",
|
||||
response: {
|
||||
authenticatorData: new ArrayBuffer(8),
|
||||
clientDataJSON: new ArrayBuffer(8),
|
||||
signature: new ArrayBuffer(8),
|
||||
userHandle: new ArrayBuffer(8),
|
||||
},
|
||||
});
|
||||
|
||||
mockSendPasskey.mockResolvedValue({
|
||||
error: "Verification failed",
|
||||
});
|
||||
|
||||
renderWithIntl(<LoginPasskey loginName="[email protected]" altPassword={false} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Verification failed")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test("should display error when sendPasskey returns undefined", async () => {
|
||||
setupSuccessfulChallenge();
|
||||
|
||||
mockCredentialsGet.mockResolvedValue({
|
||||
id: "credential-id",
|
||||
rawId: new ArrayBuffer(8),
|
||||
type: "public-key",
|
||||
response: {
|
||||
authenticatorData: new ArrayBuffer(8),
|
||||
clientDataJSON: new ArrayBuffer(8),
|
||||
signature: new ArrayBuffer(8),
|
||||
userHandle: new ArrayBuffer(8),
|
||||
},
|
||||
});
|
||||
|
||||
mockSendPasskey.mockResolvedValue(undefined);
|
||||
|
||||
renderWithIntl(<LoginPasskey loginName="[email protected]" altPassword={false} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Passkey verification failed - no response received")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test("should display error when sendPasskey returns object without redirect", async () => {
|
||||
setupSuccessfulChallenge();
|
||||
|
||||
mockCredentialsGet.mockResolvedValue({
|
||||
id: "credential-id",
|
||||
rawId: new ArrayBuffer(8),
|
||||
type: "public-key",
|
||||
response: {
|
||||
authenticatorData: new ArrayBuffer(8),
|
||||
clientDataJSON: new ArrayBuffer(8),
|
||||
signature: new ArrayBuffer(8),
|
||||
userHandle: new ArrayBuffer(8),
|
||||
},
|
||||
});
|
||||
|
||||
mockSendPasskey.mockResolvedValue({});
|
||||
|
||||
renderWithIntl(<LoginPasskey loginName="[email protected]" altPassword={false} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Passkey verification completed but no redirect was provided")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test("should display error when credential retrieval returns null", async () => {
|
||||
setupSuccessfulChallenge();
|
||||
mockCredentialsGet.mockResolvedValue(null);
|
||||
|
||||
renderWithIntl(<LoginPasskey loginName="[email protected]" altPassword={false} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("An error occurred while retrieving passkey")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error Handling for Passkey Cancellation", () => {
|
||||
test("should display cancellation message when user cancels passkey", async () => {
|
||||
const mockPublicKey = {
|
||||
challenge: new Uint8Array([1, 2, 3]),
|
||||
allowCredentials: [
|
||||
{
|
||||
id: new Uint8Array([4, 5, 6]),
|
||||
type: "public-key",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockUpdateSession.mockResolvedValue({
|
||||
challenges: {
|
||||
webAuthN: {
|
||||
publicKeyCredentialRequestOptions: {
|
||||
publicKey: mockPublicKey,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const notAllowedError = new Error("User cancelled");
|
||||
(notAllowedError as any).name = "NotAllowedError";
|
||||
mockCredentialsGet.mockRejectedValue(notAllowedError);
|
||||
|
||||
renderWithIntl(<LoginPasskey loginName="[email protected]" altPassword={false} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Passkey verification was cancelled")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test("should display generic error for other credential errors", async () => {
|
||||
const mockPublicKey = {
|
||||
challenge: new Uint8Array([1, 2, 3]),
|
||||
allowCredentials: [
|
||||
{
|
||||
id: new Uint8Array([4, 5, 6]),
|
||||
type: "public-key",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockUpdateSession.mockResolvedValue({
|
||||
challenges: {
|
||||
webAuthN: {
|
||||
publicKeyCredentialRequestOptions: {
|
||||
publicKey: mockPublicKey,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const genericError = new Error("Unknown error");
|
||||
mockCredentialsGet.mockRejectedValue(genericError);
|
||||
|
||||
renderWithIntl(<LoginPasskey loginName="[email protected]" altPassword={false} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("An error occurred during passkey verification")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Props Handling", () => {
|
||||
test("should pass sessionId to server actions when provided", async () => {
|
||||
mockUpdateSession.mockResolvedValue({
|
||||
error: "Test error",
|
||||
});
|
||||
|
||||
renderWithIntl(<LoginPasskey sessionId="session-123" altPassword={false} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateSession).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sessionId: "session-123",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("should pass loginName to server actions when provided", async () => {
|
||||
mockUpdateSession.mockResolvedValue({
|
||||
error: "Test error",
|
||||
});
|
||||
|
||||
renderWithIntl(<LoginPasskey loginName="[email protected]" altPassword={false} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateSession).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
loginName: "[email protected]",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("should pass organization to server actions when provided", async () => {
|
||||
mockUpdateSession.mockResolvedValue({
|
||||
error: "Test error",
|
||||
});
|
||||
|
||||
renderWithIntl(<LoginPasskey loginName="[email protected]" organization="org-123" altPassword={false} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateSession).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
organization: "org-123",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("should pass requestId to server actions when provided", async () => {
|
||||
mockUpdateSession.mockResolvedValue({
|
||||
error: "Test error",
|
||||
});
|
||||
|
||||
renderWithIntl(<LoginPasskey loginName="[email protected]" requestId="request-123" altPassword={false} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateSession).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
requestId: "request-123",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("useEffect Initialization Guard", () => {
|
||||
test("should only initialize once even if component re-renders", async () => {
|
||||
mockUpdateSession.mockResolvedValue({
|
||||
error: "Test error",
|
||||
});
|
||||
|
||||
const { rerender } = renderWithIntl(<LoginPasskey loginName="[email protected]" altPassword={false} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateSession).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Rerender the component
|
||||
rerender(
|
||||
<NextIntlClientProvider locale="en" messages={messages}>
|
||||
<LoginPasskey loginName="[email protected]" altPassword={false} />
|
||||
</NextIntlClientProvider>,
|
||||
);
|
||||
|
||||
// Should still only be called once
|
||||
expect(mockUpdateSession).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,13 +4,11 @@ import { coerceToArrayBuffer, coerceToBase64Url } from "@/helpers/base64";
|
||||
import { sendPasskey } from "@/lib/server/passkeys";
|
||||
import { updateSession } from "@/lib/server/session";
|
||||
import { create, JsonObject } from "@zitadel/client";
|
||||
import {
|
||||
RequestChallengesSchema,
|
||||
UserVerificationRequirement,
|
||||
} from "@zitadel/proto/zitadel/session/v2/challenge_pb";
|
||||
import { RequestChallengesSchema, UserVerificationRequirement } from "@zitadel/proto/zitadel/session/v2/challenge_pb";
|
||||
import { Checks } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Alert } from "./alert";
|
||||
import { BackButton } from "./back-button";
|
||||
import { Button, ButtonVariants } from "./button";
|
||||
@@ -27,17 +25,11 @@ type Props = {
|
||||
organization?: string;
|
||||
};
|
||||
|
||||
export function LoginPasskey({
|
||||
loginName,
|
||||
sessionId,
|
||||
requestId,
|
||||
altPassword,
|
||||
organization,
|
||||
login = true,
|
||||
}: Props) {
|
||||
export function LoginPasskey({ loginName, sessionId, requestId, altPassword, organization, login = true }: Props) {
|
||||
const [error, setError] = useState<string>("");
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
|
||||
const t = useTranslations("passkey");
|
||||
const router = useRouter();
|
||||
|
||||
const initialized = useRef(false);
|
||||
@@ -48,12 +40,10 @@ export function LoginPasskey({
|
||||
setLoading(true);
|
||||
updateSessionForChallenge()
|
||||
.then((response) => {
|
||||
const pK =
|
||||
response?.challenges?.webAuthN?.publicKeyCredentialRequestOptions
|
||||
?.publicKey;
|
||||
const pK = response?.challenges?.webAuthN?.publicKeyCredentialRequestOptions?.publicKey;
|
||||
|
||||
if (!pK) {
|
||||
setError("Could not request passkey challenge");
|
||||
setError(t("verify.errors.couldNotRequestChallenge"));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
@@ -97,7 +87,7 @@ export function LoginPasskey({
|
||||
requestId,
|
||||
})
|
||||
.catch(() => {
|
||||
setError("Could not request passkey challenge");
|
||||
setError(t("verify.errors.couldNotRequestChallenge"));
|
||||
return;
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -124,7 +114,7 @@ export function LoginPasskey({
|
||||
requestId,
|
||||
})
|
||||
.catch(() => {
|
||||
setError("Could not verify passkey");
|
||||
setError(t("verify.errors.couldNotVerifyPasskey"));
|
||||
return;
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -139,20 +129,19 @@ export function LoginPasskey({
|
||||
if (response && "redirect" in response && response.redirect) {
|
||||
return router.push(response.redirect);
|
||||
}
|
||||
|
||||
// If we got here, something went wrong - no redirect or error was returned
|
||||
if (!response) {
|
||||
setError(t("verify.errors.noResponseReceived"));
|
||||
} else {
|
||||
setError(t("verify.errors.noRedirectProvided"));
|
||||
}
|
||||
}
|
||||
|
||||
async function submitLoginAndContinue(
|
||||
publicKey: any,
|
||||
): Promise<boolean | void> {
|
||||
publicKey.challenge = coerceToArrayBuffer(
|
||||
publicKey.challenge,
|
||||
"publicKey.challenge",
|
||||
);
|
||||
async function submitLoginAndContinue(publicKey: any): Promise<boolean | void> {
|
||||
publicKey.challenge = coerceToArrayBuffer(publicKey.challenge, "publicKey.challenge");
|
||||
publicKey.allowCredentials.map((listItem: any) => {
|
||||
listItem.id = coerceToArrayBuffer(
|
||||
listItem.id,
|
||||
"publicKey.allowCredentials.id",
|
||||
);
|
||||
listItem.id = coerceToArrayBuffer(listItem.id, "publicKey.allowCredentials.id");
|
||||
});
|
||||
|
||||
navigator.credentials
|
||||
@@ -161,21 +150,15 @@ export function LoginPasskey({
|
||||
})
|
||||
.then((assertedCredential: any) => {
|
||||
if (!assertedCredential) {
|
||||
setError("An error on retrieving passkey");
|
||||
setError(t("verify.errors.couldNotRetrievePasskey"));
|
||||
return;
|
||||
}
|
||||
|
||||
const authData = new Uint8Array(
|
||||
assertedCredential.response.authenticatorData,
|
||||
);
|
||||
const clientDataJSON = new Uint8Array(
|
||||
assertedCredential.response.clientDataJSON,
|
||||
);
|
||||
const authData = new Uint8Array(assertedCredential.response.authenticatorData);
|
||||
const clientDataJSON = new Uint8Array(assertedCredential.response.clientDataJSON);
|
||||
const rawId = new Uint8Array(assertedCredential.rawId);
|
||||
const sig = new Uint8Array(assertedCredential.response.signature);
|
||||
const userHandle = new Uint8Array(
|
||||
assertedCredential.response.userHandle,
|
||||
);
|
||||
const userHandle = new Uint8Array(assertedCredential.response.userHandle);
|
||||
const data = {
|
||||
id: assertedCredential.id,
|
||||
rawId: coerceToBase64Url(rawId, "rawId"),
|
||||
@@ -190,6 +173,15 @@ export function LoginPasskey({
|
||||
|
||||
return submitLogin(data);
|
||||
})
|
||||
.catch((error) => {
|
||||
// Handle passkey cancellation or errors
|
||||
if (error?.name === "NotAllowedError") {
|
||||
setError(t("verify.errors.verificationCancelled"));
|
||||
} else {
|
||||
setError(t("verify.errors.verificationFailed"));
|
||||
}
|
||||
console.error("Passkey verification error:", error);
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
@@ -249,12 +241,10 @@ export function LoginPasskey({
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
const pK =
|
||||
response?.challenges?.webAuthN?.publicKeyCredentialRequestOptions
|
||||
?.publicKey;
|
||||
const pK = response?.challenges?.webAuthN?.publicKeyCredentialRequestOptions?.publicKey;
|
||||
|
||||
if (!pK) {
|
||||
setError("Could not request passkey challenge");
|
||||
setError(t("verify.errors.couldNotRequestChallenge"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -271,8 +261,7 @@ export function LoginPasskey({
|
||||
}}
|
||||
data-testid="submit-button"
|
||||
>
|
||||
{loading && <Spinner className="mr-2 h-5 w-5" />}{" "}
|
||||
<Translated i18nKey="verify.submit" namespace="passkey" />
|
||||
{loading && <Spinner className="mr-2 h-5 w-5" />} <Translated i18nKey="verify.submit" namespace="passkey" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user