mirror of
https://github.com/p-stream/p-stream.git
synced 2026-03-11 17:55:33 +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();
|
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,
|
bytesToBase64,
|
||||||
bytesToBase64Url,
|
bytesToBase64Url,
|
||||||
encryptData,
|
encryptData,
|
||||||
|
getCredentialId,
|
||||||
|
keysFromCredentialId,
|
||||||
keysFromMnemonic,
|
keysFromMnemonic,
|
||||||
signChallenge,
|
signChallenge,
|
||||||
|
storeCredentialMapping,
|
||||||
} from "@/backend/accounts/crypto";
|
} from "@/backend/accounts/crypto";
|
||||||
import { getGroupOrder } from "@/backend/accounts/groupOrder";
|
import { getGroupOrder } from "@/backend/accounts/groupOrder";
|
||||||
import { importBookmarks, importProgress } from "@/backend/accounts/import";
|
import { importBookmarks, importProgress } from "@/backend/accounts/import";
|
||||||
|
|
@ -33,7 +36,8 @@ import { ProgressMediaItem } from "@/stores/progress";
|
||||||
|
|
||||||
export interface RegistrationData {
|
export interface RegistrationData {
|
||||||
recaptchaToken?: string;
|
recaptchaToken?: string;
|
||||||
mnemonic: string;
|
mnemonic?: string;
|
||||||
|
credentialId?: string;
|
||||||
userData: {
|
userData: {
|
||||||
device: string;
|
device: string;
|
||||||
profile: {
|
profile: {
|
||||||
|
|
@ -45,7 +49,8 @@ export interface RegistrationData {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LoginData {
|
export interface LoginData {
|
||||||
mnemonic: string;
|
mnemonic?: string;
|
||||||
|
credentialId?: string;
|
||||||
userData: {
|
userData: {
|
||||||
device: string;
|
device: string;
|
||||||
};
|
};
|
||||||
|
|
@ -65,8 +70,23 @@ export function useAuth() {
|
||||||
const login = useCallback(
|
const login = useCallback(
|
||||||
async (loginData: LoginData) => {
|
async (loginData: LoginData) => {
|
||||||
if (!backendUrl) return;
|
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);
|
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(
|
const { challenge } = await getLoginChallengeToken(
|
||||||
backendUrl,
|
backendUrl,
|
||||||
publicKeyBase64Url,
|
publicKeyBase64Url,
|
||||||
|
|
@ -83,6 +103,12 @@ export function useAuth() {
|
||||||
|
|
||||||
const user = await getUser(backendUrl, loginResult.token);
|
const user = await getUser(backendUrl, loginResult.token);
|
||||||
const seedBase64 = bytesToBase64(keys.seed);
|
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);
|
return userDataLogin(loginResult, user.user, user.session, seedBase64);
|
||||||
},
|
},
|
||||||
[userDataLogin, backendUrl],
|
[userDataLogin, backendUrl],
|
||||||
|
|
@ -120,22 +146,38 @@ export function useAuth() {
|
||||||
const register = useCallback(
|
const register = useCallback(
|
||||||
async (registerData: RegistrationData) => {
|
async (registerData: RegistrationData) => {
|
||||||
if (!backendUrl) return;
|
if (!backendUrl) return;
|
||||||
|
if (!registerData.mnemonic && !registerData.credentialId) {
|
||||||
|
throw new Error("Either mnemonic or credentialId must be provided");
|
||||||
|
}
|
||||||
|
|
||||||
const { challenge } = await getRegisterChallengeToken(
|
const { challenge } = await getRegisterChallengeToken(
|
||||||
backendUrl,
|
backendUrl,
|
||||||
registerData.recaptchaToken,
|
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 signature = await signChallenge(keys, challenge);
|
||||||
|
const publicKeyBase64Url = bytesToBase64Url(keys.publicKey);
|
||||||
const registerResult = await registerAccount(backendUrl, {
|
const registerResult = await registerAccount(backendUrl, {
|
||||||
challenge: {
|
challenge: {
|
||||||
code: challenge,
|
code: challenge,
|
||||||
signature,
|
signature,
|
||||||
},
|
},
|
||||||
publicKey: bytesToBase64Url(keys.publicKey),
|
publicKey: publicKeyBase64Url,
|
||||||
device: await encryptData(registerData.userData.device, keys.seed),
|
device: await encryptData(registerData.userData.device, keys.seed),
|
||||||
profile: registerData.userData.profile,
|
profile: registerData.userData.profile,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Store credential mapping if we have a credential ID
|
||||||
|
if (registerData.credentialId) {
|
||||||
|
storeCredentialMapping(
|
||||||
|
backendUrl,
|
||||||
|
publicKeyBase64Url,
|
||||||
|
registerData.credentialId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return userDataLogin(
|
return userDataLogin(
|
||||||
registerResult,
|
registerResult,
|
||||||
registerResult.user,
|
registerResult.user,
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,10 @@ export function RegisterPage() {
|
||||||
|
|
||||||
const [step, setStep] = useState(-1);
|
const [step, setStep] = useState(-1);
|
||||||
const [mnemonic, setMnemonic] = useState<null | string>(null);
|
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 [account, setAccount] = useState<null | AccountProfile>(null);
|
||||||
const [siteKey, setSiteKey] = useState<string | null>(null);
|
const [siteKey, setSiteKey] = useState<string | null>(null);
|
||||||
const [selectedBackendUrl, setSelectedBackendUrl] = useState<string | null>(
|
const [selectedBackendUrl, setSelectedBackendUrl] = useState<string | null>(
|
||||||
|
|
@ -113,6 +117,12 @@ export function RegisterPage() {
|
||||||
<PassphraseGeneratePart
|
<PassphraseGeneratePart
|
||||||
onNext={(m) => {
|
onNext={(m) => {
|
||||||
setMnemonic(m);
|
setMnemonic(m);
|
||||||
|
setAuthMethod("mnemonic");
|
||||||
|
setStep(2);
|
||||||
|
}}
|
||||||
|
onPasskeyNext={(credId) => {
|
||||||
|
setCredentialId(credId);
|
||||||
|
setAuthMethod("passkey");
|
||||||
setStep(2);
|
setStep(2);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
@ -129,7 +139,10 @@ export function RegisterPage() {
|
||||||
<VerifyPassphrase
|
<VerifyPassphrase
|
||||||
hasCaptcha={!!siteKey}
|
hasCaptcha={!!siteKey}
|
||||||
mnemonic={mnemonic}
|
mnemonic={mnemonic}
|
||||||
|
credentialId={credentialId}
|
||||||
|
authMethod={authMethod}
|
||||||
userData={account}
|
userData={account}
|
||||||
|
backendUrl={selectedBackendUrl}
|
||||||
onNext={() => {
|
onNext={() => {
|
||||||
navigate("/");
|
navigate("/");
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,13 @@ import { Trans, useTranslation } from "react-i18next";
|
||||||
import { useAsyncFn } from "react-use";
|
import { useAsyncFn } from "react-use";
|
||||||
import type { AsyncReturnType } from "type-fest";
|
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 { Button } from "@/components/buttons/Button";
|
||||||
|
import { Icon, Icons } from "@/components/Icon";
|
||||||
import { BrandPill } from "@/components/layout/BrandPill";
|
import { BrandPill } from "@/components/layout/BrandPill";
|
||||||
import {
|
import {
|
||||||
LargeCard,
|
LargeCard,
|
||||||
|
|
@ -14,6 +19,7 @@ import {
|
||||||
import { MwLink } from "@/components/text/Link";
|
import { MwLink } from "@/components/text/Link";
|
||||||
import { AuthInputBox } from "@/components/text-inputs/AuthInputBox";
|
import { AuthInputBox } from "@/components/text-inputs/AuthInputBox";
|
||||||
import { useAuth } from "@/hooks/auth/useAuth";
|
import { useAuth } from "@/hooks/auth/useAuth";
|
||||||
|
import { useBackendUrl } from "@/hooks/auth/useBackendUrl";
|
||||||
import { useBookmarkStore } from "@/stores/bookmarks";
|
import { useBookmarkStore } from "@/stores/bookmarks";
|
||||||
import { useProgressStore } from "@/stores/progress";
|
import { useProgressStore } from "@/stores/progress";
|
||||||
|
|
||||||
|
|
@ -25,10 +31,51 @@ export function LoginFormPart(props: LoginFormPartProps) {
|
||||||
const [mnemonic, setMnemonic] = useState("");
|
const [mnemonic, setMnemonic] = useState("");
|
||||||
const [device, setDevice] = useState("");
|
const [device, setDevice] = useState("");
|
||||||
const { login, restore, importData } = useAuth();
|
const { login, restore, importData } = useAuth();
|
||||||
|
const backendUrl = useBackendUrl();
|
||||||
const progressItems = useProgressStore((store) => store.items);
|
const progressItems = useProgressStore((store) => store.items);
|
||||||
const bookmarkItems = useBookmarkStore((store) => store.bookmarks);
|
const bookmarkItems = useBookmarkStore((store) => store.bookmarks);
|
||||||
const { t } = useTranslation();
|
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(
|
const [result, execute] = useAsyncFn(
|
||||||
async (inputMnemonic: string, inputdevice: string) => {
|
async (inputMnemonic: string, inputdevice: string) => {
|
||||||
if (!verifyValidMnemonic(inputMnemonic))
|
if (!verifyValidMnemonic(inputMnemonic))
|
||||||
|
|
@ -70,6 +117,34 @@ export function LoginFormPart(props: LoginFormPartProps) {
|
||||||
{t("auth.login.description")}
|
{t("auth.login.description")}
|
||||||
</LargeCardText>
|
</LargeCardText>
|
||||||
<div className="space-y-4">
|
<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
|
<AuthInputBox
|
||||||
label={t("auth.login.passphraseLabel") ?? undefined}
|
label={t("auth.login.passphraseLabel") ?? undefined}
|
||||||
value={mnemonic}
|
value={mnemonic}
|
||||||
|
|
@ -85,9 +160,11 @@ export function LoginFormPart(props: LoginFormPartProps) {
|
||||||
onChange={setDevice}
|
onChange={setDevice}
|
||||||
placeholder={t("auth.deviceNamePlaceholder") ?? undefined}
|
placeholder={t("auth.deviceNamePlaceholder") ?? undefined}
|
||||||
/>
|
/>
|
||||||
{result.error && !result.loading ? (
|
{(result.error || passkeyResult.error) &&
|
||||||
|
!result.loading &&
|
||||||
|
!passkeyResult.loading ? (
|
||||||
<p className="text-authentication-errorText">
|
<p className="text-authentication-errorText">
|
||||||
{result.error.message}
|
{result.error?.message || passkeyResult.error?.message}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,12 @@
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
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 { Button } from "@/components/buttons/Button";
|
||||||
import { PassphraseDisplay } from "@/components/form/PassphraseDisplay";
|
import { PassphraseDisplay } from "@/components/form/PassphraseDisplay";
|
||||||
import { Icon, Icons } from "@/components/Icon";
|
import { Icon, Icons } from "@/components/Icon";
|
||||||
|
|
@ -13,6 +18,7 @@ import {
|
||||||
|
|
||||||
interface PassphraseGeneratePartProps {
|
interface PassphraseGeneratePartProps {
|
||||||
onNext?: (mnemonic: string) => void;
|
onNext?: (mnemonic: string) => void;
|
||||||
|
onPasskeyNext?: (credentialId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PassphraseGeneratePart(props: PassphraseGeneratePartProps) {
|
export function PassphraseGeneratePart(props: PassphraseGeneratePartProps) {
|
||||||
|
|
@ -23,6 +29,29 @@ export function PassphraseGeneratePart(props: PassphraseGeneratePartProps) {
|
||||||
setMnemonic(customPassphrase);
|
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 (
|
return (
|
||||||
<LargeCard>
|
<LargeCard>
|
||||||
<LargeCardText
|
<LargeCardText
|
||||||
|
|
@ -41,6 +70,26 @@ export function PassphraseGeneratePart(props: PassphraseGeneratePartProps) {
|
||||||
onCustomPassphrase={handleCustomPassphrase}
|
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>
|
<LargeCardButtons>
|
||||||
<Button theme="purple" onClick={() => props.onNext?.(mnemonic)}>
|
<Button theme="purple" onClick={() => props.onNext?.(mnemonic)}>
|
||||||
{t("auth.generate.next")}
|
{t("auth.generate.next")}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { useGoogleReCaptcha } from "react-google-recaptcha-v3";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useAsyncFn } from "react-use";
|
import { useAsyncFn } from "react-use";
|
||||||
|
|
||||||
|
import { authenticatePasskey } from "@/backend/accounts/crypto";
|
||||||
import { updateSettings } from "@/backend/accounts/settings";
|
import { updateSettings } from "@/backend/accounts/settings";
|
||||||
import { Button } from "@/components/buttons/Button";
|
import { Button } from "@/components/buttons/Button";
|
||||||
import { Icon, Icons } from "@/components/Icon";
|
import { Icon, Icons } from "@/components/Icon";
|
||||||
|
|
@ -24,8 +25,11 @@ import { useThemeStore } from "@/stores/theme";
|
||||||
|
|
||||||
interface VerifyPassphraseProps {
|
interface VerifyPassphraseProps {
|
||||||
mnemonic: string | null;
|
mnemonic: string | null;
|
||||||
|
credentialId: string | null;
|
||||||
|
authMethod: "mnemonic" | "passkey";
|
||||||
hasCaptcha?: boolean;
|
hasCaptcha?: boolean;
|
||||||
userData: AccountProfile | null;
|
userData: AccountProfile | null;
|
||||||
|
backendUrl: string | null;
|
||||||
onNext?: () => void;
|
onNext?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -73,6 +77,64 @@ export function VerifyPassphrase(props: VerifyPassphraseProps) {
|
||||||
|
|
||||||
const { executeRecaptcha } = useGoogleReCaptcha();
|
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(
|
const [result, execute] = useAsyncFn(
|
||||||
async (inputMnemonic: string) => {
|
async (inputMnemonic: string) => {
|
||||||
if (!backendUrl)
|
if (!backendUrl)
|
||||||
|
|
@ -115,9 +177,41 @@ export function VerifyPassphrase(props: VerifyPassphraseProps) {
|
||||||
|
|
||||||
props.onNext?.();
|
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 (
|
return (
|
||||||
<LargeCard>
|
<LargeCard>
|
||||||
<form>
|
<form>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue