mirror of
https://github.com/p-stream/p-stream.git
synced 2026-01-11 20:10:32 +00:00
meow
This commit is contained in:
parent
f09e71c16d
commit
b7e08b505f
6 changed files with 494 additions and 10 deletions
|
|
@ -152,3 +152,212 @@ export function decryptData(data: string, secret: Uint8Array) {
|
|||
|
||||
return decipher.output.toString();
|
||||
}
|
||||
|
||||
// Passkey/WebAuthn utilities
|
||||
|
||||
export function isPasskeySupported(): boolean {
|
||||
return (
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -50,6 +50,10 @@ export function RegisterPage() {
|
|||
|
||||
const [step, setStep] = useState(-1);
|
||||
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>(
|
||||
|
|
@ -113,6 +117,12 @@ export function RegisterPage() {
|
|||
<PassphraseGeneratePart
|
||||
onNext={(m) => {
|
||||
setMnemonic(m);
|
||||
setAuthMethod("mnemonic");
|
||||
setStep(2);
|
||||
}}
|
||||
onPasskeyNext={(credId) => {
|
||||
setCredentialId(credId);
|
||||
setAuthMethod("passkey");
|
||||
setStep(2);
|
||||
}}
|
||||
/>
|
||||
|
|
@ -129,7 +139,10 @@ export function RegisterPage() {
|
|||
<VerifyPassphrase
|
||||
hasCaptcha={!!siteKey}
|
||||
mnemonic={mnemonic}
|
||||
credentialId={credentialId}
|
||||
authMethod={authMethod}
|
||||
userData={account}
|
||||
backendUrl={selectedBackendUrl}
|
||||
onNext={() => {
|
||||
navigate("/");
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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,34 @@ export function LoginFormPart(props: LoginFormPartProps) {
|
|||
{t("auth.login.description")}
|
||||
</LargeCardText>
|
||||
<div className="space-y-4">
|
||||
{isPasskeySupported() && (
|
||||
<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") ?? "Use passkey"}
|
||||
</Button>
|
||||
<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") ?? "or"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<AuthInputBox
|
||||
label={t("auth.login.passphraseLabel") ?? undefined}
|
||||
value={mnemonic}
|
||||
|
|
@ -85,9 +160,11 @@ export function LoginFormPart(props: LoginFormPartProps) {
|
|||
onChange={setDevice}
|
||||
placeholder={t("auth.deviceNamePlaceholder") ?? undefined}
|
||||
/>
|
||||
{result.error && !result.loading ? (
|
||||
{(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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -41,6 +70,26 @@ export function PassphraseGeneratePart(props: PassphraseGeneratePartProps) {
|
|||
onCustomPassphrase={handleCustomPassphrase}
|
||||
/>
|
||||
|
||||
{isPasskeySupported() && (
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
theme="secondary"
|
||||
onClick={handlePasskeyClick}
|
||||
loading={passkeyResult.loading}
|
||||
disabled={passkeyResult.loading}
|
||||
className="w-full"
|
||||
>
|
||||
<Icon icon={Icons.LOCK} className="mr-2" />
|
||||
{t("auth.generate.usePasskeyInstead") ?? "Use passkey instead"}
|
||||
</Button>
|
||||
{passkeyResult.error && (
|
||||
<p className="mt-2 text-authentication-errorText text-sm text-center">
|
||||
{passkeyResult.error.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<LargeCardButtons>
|
||||
<Button theme="purple" onClick={() => props.onNext?.(mnemonic)}>
|
||||
{t("auth.generate.next")}
|
||||
|
|
|
|||
|
|
@ -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") ??
|
||||
"Please authenticate with your passkey to complete registration."}
|
||||
</LargeCardText>
|
||||
{passkeyResult.error ? (
|
||||
<p className="mt-3 text-authentication-errorText">
|
||||
{passkeyResult.error.message}
|
||||
</p>
|
||||
) : null}
|
||||
<LargeCardButtons>
|
||||
<Button
|
||||
theme="purple"
|
||||
loading={passkeyResult.loading}
|
||||
onClick={() => authenticatePasskeyFn()}
|
||||
>
|
||||
<Icon icon={Icons.LOCK} className="mr-2" />
|
||||
{t("auth.verify.authenticatePasskey") ??
|
||||
"Authenticate with Passkey"}
|
||||
</Button>
|
||||
</LargeCardButtons>
|
||||
</form>
|
||||
</LargeCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<LargeCard>
|
||||
<form>
|
||||
|
|
|
|||
Loading…
Reference in a new issue