Merge branch 'p-stream:production' into production

This commit is contained in:
vlOd 2025-12-30 19:02:16 +02:00 committed by GitHub
commit d7e5754384
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 560 additions and 27 deletions

View file

@ -8,7 +8,22 @@
<lastBuildDate>Mon, 29 Sep 2025 18:00:00 MST</lastBuildDate>
<atom:link href="https://pstream.mov/notifications.xml" rel="self" type="application/rss+xml" />
<item>
<item>
<guid>notification-055</guid>
<title>Backend issues have been fixed!</title>
<description>You are now able to log into your account again! Hopefully less downtime going forward!
We decided to move the backend (previously server.fifthwit.net) to a new server at backend.pstream.mov.
All of your account data has been migrated to the new server, so you can log in with existing passphrase.
We've also introduced some new backend servers, so you can now choose which one you want to use. Sorry for all the downtime!
</description>
<pubDate>Mon, 29 Dec 2025 13:30:00 MST</pubDate>
<category>announcement</category>
</item>
<item>
<guid>notification-054</guid>
<title>P-Stream v5.3.3 released!</title>
<description>Merry Christmas everyone! 🎄

View file

@ -158,7 +158,8 @@
"customPassphrasePlaceholder": "Enter your custom passphrase",
"useCustomPassphrase": "Use Custom Passphrase",
"invalidPassphraseCharacters": "Invalid passphrase characters. Only English letters, numbers 1-10, and normal symbols are allowed.",
"passphraseTooShort": "Passphrase must be at least 8 characters long."
"passphraseTooShort": "Passphrase must be at least 8 characters long.",
"usePasskeyInstead": "Use passkey instead"
},
"hasAccount": "Already have an account? <0>Login here.</0>",
"login": {
@ -168,7 +169,10 @@
"passphrasePlaceholder": "Passphrase",
"submit": "Login",
"title": "Login to your account",
"validationError": "Incorrect or incomplete passphrase /ᐠ. .ᐟ\\"
"validationError": "Incorrect or incomplete passphrase /ᐠ. .ᐟ\\",
"usePasskey": "Use passkey",
"or": "or",
"noBackendUrl": "No backend URL"
},
"register": {
"information": {
@ -209,7 +213,10 @@
"passphraseLabel": "Your 12-word passphrase",
"recaptchaFailed": "ReCaptcha validation failed",
"register": "Create account",
"title": "Confirm your passphrase"
"title": "Confirm your account",
"passkeyDescription": "Please authenticate with your passkey to complete registration.",
"authenticatePasskey": "Authenticate with Passkey",
"passkeyError": "Passkey verification failed"
}
},
"errors": {

View file

@ -152,3 +152,217 @@ export function decryptData(data: string, secret: Uint8Array) {
return decipher.output.toString();
}
// Passkey/WebAuthn utilities
export function isPasskeySupported(): boolean {
// Passkeys require HTTPS
const isSecureContext =
typeof window !== "undefined" && window.location.protocol === "https:";
return (
isSecureContext &&
typeof navigator !== "undefined" &&
"credentials" in navigator &&
"create" in navigator.credentials &&
"get" in navigator.credentials &&
typeof PublicKeyCredential !== "undefined"
);
}
function base64UrlToArrayBuffer(base64Url: string): ArrayBuffer {
if (typeof base64Url !== "string") {
throw new Error(
`Invalid credential ID: expected string, got ${typeof base64Url}`,
);
}
// Convert base64url to base64
let base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");
// Add padding if needed
while (base64.length % 4) {
base64 += "=";
}
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i);
}
return bytes.buffer;
}
export interface PasskeyCredential {
id: string;
rawId: ArrayBuffer;
response: AuthenticatorAttestationResponse;
}
export interface PasskeyAssertion {
id: string;
rawId: ArrayBuffer;
response: AuthenticatorAssertionResponse;
}
export async function createPasskey(
userId: string,
userName: string,
): Promise<PasskeyCredential> {
if (!isPasskeySupported()) {
throw new Error("Passkeys are not supported in this browser");
}
// Generate a random user ID (8 bytes)
const userIdBuffer = new Uint8Array(8);
crypto.getRandomValues(userIdBuffer);
const challenge = new Uint8Array(32);
crypto.getRandomValues(challenge);
const publicKeyCredentialCreationOptions: PublicKeyCredentialCreationOptions =
{
challenge,
rp: {
name: "P-Stream",
id: window.location.hostname,
},
user: {
id: userIdBuffer,
name: userName,
displayName: userName,
},
pubKeyCredParams: [
{ alg: -7, type: "public-key" }, // ES256
{ alg: -257, type: "public-key" }, // RS256
],
authenticatorSelection: {
authenticatorAttachment: "platform",
userVerification: "preferred",
},
timeout: 60000,
attestation: "none",
};
try {
const credential = (await navigator.credentials.create({
publicKey: publicKeyCredentialCreationOptions,
})) as PublicKeyCredential | null;
if (!credential) {
throw new Error("Failed to create passkey");
}
return {
id: credential.id,
rawId: credential.rawId,
response: credential.response as AuthenticatorAttestationResponse,
};
} catch (error) {
throw new Error(
`Failed to create passkey: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
export async function authenticatePasskey(
credentialId?: string,
): Promise<PasskeyAssertion> {
if (!isPasskeySupported()) {
throw new Error("Passkeys are not supported in this browser");
}
const challenge = new Uint8Array(32);
crypto.getRandomValues(challenge);
const allowCredentials: PublicKeyCredentialDescriptor[] | undefined =
credentialId && typeof credentialId === "string" && credentialId.length > 0
? [
{
id: base64UrlToArrayBuffer(credentialId),
type: "public-key",
},
]
: undefined;
const publicKeyCredentialRequestOptions: PublicKeyCredentialRequestOptions = {
challenge,
timeout: 60000,
userVerification: "preferred",
allowCredentials,
rpId: window.location.hostname,
};
try {
const assertion = (await navigator.credentials.get({
publicKey: publicKeyCredentialRequestOptions,
})) as PublicKeyCredential | null;
if (!assertion) {
throw new Error("Failed to authenticate with passkey");
}
return {
id: assertion.id,
rawId: assertion.rawId,
response: assertion.response as AuthenticatorAssertionResponse,
};
} catch (error) {
throw new Error(
`Failed to authenticate with passkey: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
async function seedFromCredentialId(credentialId: string): Promise<Uint8Array> {
// Hash credential ID the same way we hash mnemonics
return pbkdf2Async(sha256, credentialId, "mnemonic", {
c: 2048,
dkLen: 32,
});
}
export async function keysFromCredentialId(
credentialId: string,
): Promise<Keys> {
const seed = await seedFromCredentialId(credentialId);
return keysFromSeed(seed);
}
// Storage helpers for credential mappings
const STORAGE_PREFIX = "__MW::passkey::";
function getStorageKey(backendUrl: string, publicKey: string): string {
return `${STORAGE_PREFIX}${backendUrl}::${publicKey}`;
}
export function storeCredentialMapping(
backendUrl: string,
publicKey: string,
credentialId: string,
): void {
if (typeof window === "undefined" || !window.localStorage) {
throw new Error("localStorage is not available");
}
const key = getStorageKey(backendUrl, publicKey);
localStorage.setItem(key, credentialId);
}
export function getCredentialId(
backendUrl: string,
publicKey: string,
): string | null {
if (typeof window === "undefined" || !window.localStorage) {
return null;
}
const key = getStorageKey(backendUrl, publicKey);
return localStorage.getItem(key);
}
export function removeCredentialMapping(
backendUrl: string,
publicKey: string,
): void {
if (typeof window === "undefined" || !window.localStorage) {
return;
}
const key = getStorageKey(backendUrl, publicKey);
localStorage.removeItem(key);
}

View file

@ -9,7 +9,14 @@ export interface MetaResponse {
}
export async function getBackendMeta(url: string): Promise<MetaResponse> {
return ofetch<MetaResponse>("/meta", {
const meta = await ofetch<MetaResponse>("/meta", {
baseURL: url,
});
// Remove escaped backslashes before apostrophes (e.g., \' becomes ')
return {
...meta,
name: meta.name.replace(/\\'/g, "'"),
description: meta.description?.replace(/\\'/g, "'"),
};
}

View file

@ -80,6 +80,9 @@ function BackendOptionItem({
) : option.meta ? (
<div>
<p className="text-white font-medium">{option.meta.name}</p>
<p className="text-type-secondary text-sm">
{option.meta.description}
</p>
<p className="text-type-secondary text-sm">{hostname}</p>
</div>
) : (

View file

@ -308,8 +308,15 @@ export function makeVideoElementDisplayInterface(): DisplayInterface {
});
hls.on(Hls.Events.LEVEL_SWITCHED, () => {
if (!hls) return;
const quality = hlsLevelToQuality(hls.levels[hls.currentLevel]);
emit("changedquality", quality);
if (automaticQuality) {
// Only emit quality changes when automatic quality is enabled
const quality = hlsLevelToQuality(hls.levels[hls.currentLevel]);
emit("changedquality", quality);
} else {
// When automatic quality is disabled, re-lock to preferred quality
// This prevents HLS.js from switching levels unexpectedly
setupQualityForHls();
}
});
hls.on(Hls.Events.SUBTITLE_TRACK_LOADED, () => {
for (const [lang, resolve] of languagePromises) {

View file

@ -6,8 +6,11 @@ import {
bytesToBase64,
bytesToBase64Url,
encryptData,
getCredentialId,
keysFromCredentialId,
keysFromMnemonic,
signChallenge,
storeCredentialMapping,
} from "@/backend/accounts/crypto";
import { getGroupOrder } from "@/backend/accounts/groupOrder";
import { importBookmarks, importProgress } from "@/backend/accounts/import";
@ -33,7 +36,8 @@ import { ProgressMediaItem } from "@/stores/progress";
export interface RegistrationData {
recaptchaToken?: string;
mnemonic: string;
mnemonic?: string;
credentialId?: string;
userData: {
device: string;
profile: {
@ -45,7 +49,8 @@ export interface RegistrationData {
}
export interface LoginData {
mnemonic: string;
mnemonic?: string;
credentialId?: string;
userData: {
device: string;
};
@ -65,8 +70,23 @@ export function useAuth() {
const login = useCallback(
async (loginData: LoginData) => {
if (!backendUrl) return;
const keys = await keysFromMnemonic(loginData.mnemonic);
if (!loginData.mnemonic && !loginData.credentialId) {
throw new Error("Either mnemonic or credentialId must be provided");
}
const keys = loginData.credentialId
? await keysFromCredentialId(loginData.credentialId)
: await keysFromMnemonic(loginData.mnemonic!);
const publicKeyBase64Url = bytesToBase64Url(keys.publicKey);
// Try to get credential ID from storage if using mnemonic
let credentialId: string | null = null;
if (loginData.mnemonic) {
credentialId = getCredentialId(backendUrl, publicKeyBase64Url);
} else {
credentialId = loginData.credentialId || null;
}
const { challenge } = await getLoginChallengeToken(
backendUrl,
publicKeyBase64Url,
@ -83,6 +103,12 @@ export function useAuth() {
const user = await getUser(backendUrl, loginResult.token);
const seedBase64 = bytesToBase64(keys.seed);
// Store credential mapping if we have a credential ID
if (credentialId) {
storeCredentialMapping(backendUrl, publicKeyBase64Url, credentialId);
}
return userDataLogin(loginResult, user.user, user.session, seedBase64);
},
[userDataLogin, backendUrl],
@ -120,22 +146,38 @@ export function useAuth() {
const register = useCallback(
async (registerData: RegistrationData) => {
if (!backendUrl) return;
if (!registerData.mnemonic && !registerData.credentialId) {
throw new Error("Either mnemonic or credentialId must be provided");
}
const { challenge } = await getRegisterChallengeToken(
backendUrl,
registerData.recaptchaToken,
);
const keys = await keysFromMnemonic(registerData.mnemonic);
const keys = registerData.credentialId
? await keysFromCredentialId(registerData.credentialId)
: await keysFromMnemonic(registerData.mnemonic!);
const signature = await signChallenge(keys, challenge);
const publicKeyBase64Url = bytesToBase64Url(keys.publicKey);
const registerResult = await registerAccount(backendUrl, {
challenge: {
code: challenge,
signature,
},
publicKey: bytesToBase64Url(keys.publicKey),
publicKey: publicKeyBase64Url,
device: await encryptData(registerData.userData.device, keys.seed),
profile: registerData.userData.profile,
});
// Store credential mapping if we have a credential ID
if (registerData.credentialId) {
storeCredentialMapping(
backendUrl,
publicKeyBase64Url,
registerData.credentialId,
);
}
return userDataLogin(
registerResult,
registerResult.user,

View file

@ -161,7 +161,6 @@ function AuthWrapper() {
const backendUrl = conf().BACKEND_URL;
const userBackendUrl = useBackendUrl();
const { t } = useTranslation();
const isLoggedIn = !!useAuthStore((s) => s.account);
const isCustomUrl = backendUrl !== userBackendUrl;

View file

@ -48,12 +48,23 @@ export function RegisterPage() {
? [config.BACKEND_URL]
: [];
const [step, setStep] = useState(-1);
// If there's only one backend and user hasn't selected a custom one, auto-select it
const defaultBackend =
currentBackendUrl ??
(availableBackends.length === 1 ? availableBackends[0] : null);
const [step, setStep] = useState(
availableBackends.length > 1 || !defaultBackend ? -1 : 0,
);
const [mnemonic, setMnemonic] = useState<null | string>(null);
const [credentialId, setCredentialId] = useState<null | string>(null);
const [authMethod, setAuthMethod] = useState<"mnemonic" | "passkey">(
"mnemonic",
);
const [account, setAccount] = useState<null | AccountProfile>(null);
const [siteKey, setSiteKey] = useState<string | null>(null);
const [selectedBackendUrl, setSelectedBackendUrl] = useState<string | null>(
currentBackendUrl ?? null,
currentBackendUrl ?? defaultBackend ?? null,
);
const handleBackendSelect = (url: string | null) => {
@ -67,7 +78,7 @@ export function RegisterPage() {
<CaptchaProvider siteKey={siteKey}>
<SubPageLayout>
<PageTitle subpage k="global.pages.register" />
{step === -1 ? (
{step === -1 && (availableBackends.length > 1 || !defaultBackend) ? (
<LargeCard>
<LargeCardText title={t("auth.backendSelection.title")}>
{t("auth.backendSelection.description")}
@ -113,6 +124,12 @@ export function RegisterPage() {
<PassphraseGeneratePart
onNext={(m) => {
setMnemonic(m);
setAuthMethod("mnemonic");
setStep(2);
}}
onPasskeyNext={(credId) => {
setCredentialId(credId);
setAuthMethod("passkey");
setStep(2);
}}
/>
@ -129,7 +146,10 @@ export function RegisterPage() {
<VerifyPassphrase
hasCaptcha={!!siteKey}
mnemonic={mnemonic}
credentialId={credentialId}
authMethod={authMethod}
userData={account}
backendUrl={selectedBackendUrl}
onNext={() => {
navigate("/");
}}

View file

@ -3,8 +3,13 @@ import { Trans, useTranslation } from "react-i18next";
import { useAsyncFn } from "react-use";
import type { AsyncReturnType } from "type-fest";
import { verifyValidMnemonic } from "@/backend/accounts/crypto";
import {
authenticatePasskey,
isPasskeySupported,
verifyValidMnemonic,
} from "@/backend/accounts/crypto";
import { Button } from "@/components/buttons/Button";
import { Icon, Icons } from "@/components/Icon";
import { BrandPill } from "@/components/layout/BrandPill";
import {
LargeCard,
@ -14,6 +19,7 @@ import {
import { MwLink } from "@/components/text/Link";
import { AuthInputBox } from "@/components/text-inputs/AuthInputBox";
import { useAuth } from "@/hooks/auth/useAuth";
import { useBackendUrl } from "@/hooks/auth/useBackendUrl";
import { useBookmarkStore } from "@/stores/bookmarks";
import { useProgressStore } from "@/stores/progress";
@ -25,10 +31,51 @@ export function LoginFormPart(props: LoginFormPartProps) {
const [mnemonic, setMnemonic] = useState("");
const [device, setDevice] = useState("");
const { login, restore, importData } = useAuth();
const backendUrl = useBackendUrl();
const progressItems = useProgressStore((store) => store.items);
const bookmarkItems = useBookmarkStore((store) => store.bookmarks);
const { t } = useTranslation();
const [passkeyResult, executePasskey] = useAsyncFn(
async (inputDevice: string) => {
if (!backendUrl) {
throw new Error(t("auth.login.noBackendUrl") ?? "No backend URL");
}
const validatedDevice = inputDevice.trim();
if (validatedDevice.length === 0)
throw new Error(t("auth.login.deviceLengthError") ?? undefined);
// Authenticate with passkey (no credential ID specified, browser will show all available)
const assertion = await authenticatePasskey();
const credentialId = assertion.id;
let account: AsyncReturnType<typeof login>;
try {
account = await login({
credentialId,
userData: {
device: validatedDevice,
},
});
} catch (err) {
if ((err as any).status === 401)
throw new Error(t("auth.login.validationError") ?? undefined);
throw err;
}
if (!account)
throw new Error(t("auth.login.validationError") ?? undefined);
await importData(account, progressItems, bookmarkItems);
await restore(account);
props.onLogin?.();
},
[props, login, restore, backendUrl, t],
);
const [result, execute] = useAsyncFn(
async (inputMnemonic: string, inputdevice: string) => {
if (!verifyValidMnemonic(inputMnemonic))
@ -70,6 +117,12 @@ export function LoginFormPart(props: LoginFormPartProps) {
{t("auth.login.description")}
</LargeCardText>
<div className="space-y-4">
<AuthInputBox
label={t("auth.deviceNameLabel") ?? undefined}
value={device}
onChange={setDevice}
placeholder={t("auth.deviceNamePlaceholder") ?? undefined}
/>
<AuthInputBox
label={t("auth.login.passphraseLabel") ?? undefined}
value={mnemonic}
@ -79,15 +132,39 @@ export function LoginFormPart(props: LoginFormPartProps) {
placeholder={t("auth.login.passphrasePlaceholder") ?? undefined}
passwordToggleable
/>
<AuthInputBox
label={t("auth.deviceNameLabel") ?? undefined}
value={device}
onChange={setDevice}
placeholder={t("auth.deviceNamePlaceholder") ?? undefined}
/>
{result.error && !result.loading ? (
{isPasskeySupported() && (
<div className="relative mb-4">
<div className="relative my-4">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-authentication-border/50" />
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-authentication-bg text-authentication-text">
{t("auth.login.or")}
</span>
</div>
</div>
<Button
theme="secondary"
onClick={() => executePasskey(device)}
loading={passkeyResult.loading}
disabled={
passkeyResult.loading ||
result.loading ||
device.trim().length === 0
}
className="w-full"
>
<Icon icon={Icons.LOCK} className="mr-2" />
{t("auth.login.usePasskey")}
</Button>
</div>
)}
{(result.error || passkeyResult.error) &&
!result.loading &&
!passkeyResult.loading ? (
<p className="text-authentication-errorText">
{result.error.message}
{result.error?.message || passkeyResult.error?.message}
</p>
) : null}
</div>

View file

@ -1,7 +1,12 @@
import { useCallback, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import { useAsyncFn } from "react-use";
import { genMnemonic } from "@/backend/accounts/crypto";
import {
createPasskey,
genMnemonic,
isPasskeySupported,
} from "@/backend/accounts/crypto";
import { Button } from "@/components/buttons/Button";
import { PassphraseDisplay } from "@/components/form/PassphraseDisplay";
import { Icon, Icons } from "@/components/Icon";
@ -13,6 +18,7 @@ import {
interface PassphraseGeneratePartProps {
onNext?: (mnemonic: string) => void;
onPasskeyNext?: (credentialId: string) => void;
}
export function PassphraseGeneratePart(props: PassphraseGeneratePartProps) {
@ -23,6 +29,29 @@ export function PassphraseGeneratePart(props: PassphraseGeneratePartProps) {
setMnemonic(customPassphrase);
}, []);
const [passkeyResult, createPasskeyFn] = useAsyncFn(async () => {
if (!isPasskeySupported()) {
throw new Error("Passkeys are not supported in this browser");
}
const credential = await createPasskey(
`user-${Date.now()}`,
"P-Stream User",
);
return credential.id;
}, []);
const handlePasskeyClick = useCallback(async () => {
try {
const credentialId = await createPasskeyFn();
if (credentialId) {
props.onPasskeyNext?.(credentialId);
}
} catch (error) {
// Error is handled by passkeyResult.error
}
}, [createPasskeyFn, props]);
return (
<LargeCard>
<LargeCardText
@ -42,6 +71,25 @@ export function PassphraseGeneratePart(props: PassphraseGeneratePartProps) {
/>
<LargeCardButtons>
{isPasskeySupported() && (
<div className="mt-4">
<Button
theme="purple"
onClick={handlePasskeyClick}
loading={passkeyResult.loading}
disabled={passkeyResult.loading}
className="w-full"
>
<Icon icon={Icons.LOCK} className="mr-2" />
{t("auth.generate.usePasskeyInstead")}
</Button>
{passkeyResult.error && (
<p className="mt-2 text-authentication-errorText text-sm text-center">
{passkeyResult.error.message}
</p>
)}
</div>
)}
<Button theme="purple" onClick={() => props.onNext?.(mnemonic)}>
{t("auth.generate.next")}
</Button>

View file

@ -3,6 +3,7 @@ import { useGoogleReCaptcha } from "react-google-recaptcha-v3";
import { useTranslation } from "react-i18next";
import { useAsyncFn } from "react-use";
import { authenticatePasskey } from "@/backend/accounts/crypto";
import { updateSettings } from "@/backend/accounts/settings";
import { Button } from "@/components/buttons/Button";
import { Icon, Icons } from "@/components/Icon";
@ -24,8 +25,11 @@ import { useThemeStore } from "@/stores/theme";
interface VerifyPassphraseProps {
mnemonic: string | null;
credentialId: string | null;
authMethod: "mnemonic" | "passkey";
hasCaptcha?: boolean;
userData: AccountProfile | null;
backendUrl: string | null;
onNext?: () => void;
}
@ -73,6 +77,64 @@ export function VerifyPassphrase(props: VerifyPassphraseProps) {
const { executeRecaptcha } = useGoogleReCaptcha();
const [passkeyResult, authenticatePasskeyFn] = useAsyncFn(async () => {
if (!props.backendUrl)
throw new Error(t("auth.verify.noBackendUrl") ?? undefined);
if (!props.userData)
throw new Error(t("auth.verify.invalidData") ?? undefined);
// Validate credential ID is a non-empty string
if (
!props.credentialId ||
typeof props.credentialId !== "string" ||
props.credentialId.length === 0
) {
throw new Error(
t("auth.verify.invalidData") ?? "Invalid passkey credential",
);
}
let recaptchaToken: string | undefined;
if (props.hasCaptcha) {
recaptchaToken = executeRecaptcha ? await executeRecaptcha() : undefined;
if (!recaptchaToken)
throw new Error(t("auth.verify.recaptchaFailed") ?? undefined);
}
// Authenticate with passkey using the credential ID from registration
const assertion = await authenticatePasskey(props.credentialId);
// Verify the credential ID matches
if (assertion.id !== props.credentialId) {
throw new Error(
t("auth.verify.noMatch") ?? "Passkey verification failed",
);
}
const account = await register({
credentialId: props.credentialId,
userData: props.userData,
recaptchaToken,
});
if (!account)
throw new Error(t("auth.verify.registrationFailed") ?? undefined);
await importData(account, progressItems, bookmarkItems);
await updateSettings(props.backendUrl, account, {
applicationLanguage,
defaultSubtitleLanguage: defaultSubtitleLanguage ?? undefined,
applicationTheme: applicationTheme ?? undefined,
proxyUrls: undefined,
...preferences,
});
await restore(account);
props.onNext?.();
}, [props, register, restore, executeRecaptcha]);
const [result, execute] = useAsyncFn(
async (inputMnemonic: string) => {
if (!backendUrl)
@ -115,9 +177,41 @@ export function VerifyPassphrase(props: VerifyPassphraseProps) {
props.onNext?.();
},
[props, register, restore],
[props, register, restore, executeRecaptcha],
);
if (props.authMethod === "passkey") {
return (
<LargeCard>
<form>
<LargeCardText
icon={<Icon icon={Icons.CIRCLE_CHECK} />}
title={t("auth.verify.title")}
>
{t("auth.verify.passkeyDescription")}
</LargeCardText>
{passkeyResult.error ? (
<p className="mt-3 text-authentication-errorText">
{t("auth.verify.passkeyError")}
</p>
) : null}
<LargeCardButtons>
<Button
theme="purple"
loading={passkeyResult.loading}
onClick={() => authenticatePasskeyFn()}
>
{!passkeyResult.loading && (
<Icon icon={Icons.LOCK} className="mr-2" />
)}
{t("auth.verify.authenticatePasskey")}
</Button>
</LargeCardButtons>
</form>
</LargeCard>
);
}
return (
<LargeCard>
<form>