pstream is dead; long live pstream taciturnaxolotl.github.io/pstream-ng/

Merge branch 'p-stream:production' into production

authored by

vlOd and committed by
GitHub
d7e57543 f8b9f663

+560 -27
+16 -1
public/notifications.xml
··· 8 8 <lastBuildDate>Mon, 29 Sep 2025 18:00:00 MST</lastBuildDate> 9 9 <atom:link href="https://pstream.mov/notifications.xml" rel="self" type="application/rss+xml" /> 10 10 11 - <item> 11 + <item> 12 + <guid>notification-055</guid> 13 + <title>Backend issues have been fixed!</title> 14 + <description>You are now able to log into your account again! Hopefully less downtime going forward! 15 + 16 + We decided to move the backend (previously server.fifthwit.net) to a new server at backend.pstream.mov. 17 + 18 + All of your account data has been migrated to the new server, so you can log in with existing passphrase. 19 + 20 + We've also introduced some new backend servers, so you can now choose which one you want to use. Sorry for all the downtime! 21 + </description> 22 + <pubDate>Mon, 29 Dec 2025 13:30:00 MST</pubDate> 23 + <category>announcement</category> 24 + </item> 25 + 26 + <item> 12 27 <guid>notification-054</guid> 13 28 <title>P-Stream v5.3.3 released!</title> 14 29 <description>Merry Christmas everyone! 🎄
+10 -3
src/assets/locales/en.json
··· 158 158 "customPassphrasePlaceholder": "Enter your custom passphrase", 159 159 "useCustomPassphrase": "Use Custom Passphrase", 160 160 "invalidPassphraseCharacters": "Invalid passphrase characters. Only English letters, numbers 1-10, and normal symbols are allowed.", 161 - "passphraseTooShort": "Passphrase must be at least 8 characters long." 161 + "passphraseTooShort": "Passphrase must be at least 8 characters long.", 162 + "usePasskeyInstead": "Use passkey instead" 162 163 }, 163 164 "hasAccount": "Already have an account? <0>Login here.</0>", 164 165 "login": { ··· 168 169 "passphrasePlaceholder": "Passphrase", 169 170 "submit": "Login", 170 171 "title": "Login to your account", 171 - "validationError": "Incorrect or incomplete passphrase /ᐠ. .ᐟ\\" 172 + "validationError": "Incorrect or incomplete passphrase /ᐠ. .ᐟ\\", 173 + "usePasskey": "Use passkey", 174 + "or": "or", 175 + "noBackendUrl": "No backend URL" 172 176 }, 173 177 "register": { 174 178 "information": { ··· 209 213 "passphraseLabel": "Your 12-word passphrase", 210 214 "recaptchaFailed": "ReCaptcha validation failed", 211 215 "register": "Create account", 212 - "title": "Confirm your passphrase" 216 + "title": "Confirm your account", 217 + "passkeyDescription": "Please authenticate with your passkey to complete registration.", 218 + "authenticatePasskey": "Authenticate with Passkey", 219 + "passkeyError": "Passkey verification failed" 213 220 } 214 221 }, 215 222 "errors": {
+214
src/backend/accounts/crypto.ts
··· 152 152 153 153 return decipher.output.toString(); 154 154 } 155 + 156 + // Passkey/WebAuthn utilities 157 + 158 + export function isPasskeySupported(): boolean { 159 + // Passkeys require HTTPS 160 + const isSecureContext = 161 + typeof window !== "undefined" && window.location.protocol === "https:"; 162 + 163 + return ( 164 + isSecureContext && 165 + typeof navigator !== "undefined" && 166 + "credentials" in navigator && 167 + "create" in navigator.credentials && 168 + "get" in navigator.credentials && 169 + typeof PublicKeyCredential !== "undefined" 170 + ); 171 + } 172 + 173 + function base64UrlToArrayBuffer(base64Url: string): ArrayBuffer { 174 + if (typeof base64Url !== "string") { 175 + throw new Error( 176 + `Invalid credential ID: expected string, got ${typeof base64Url}`, 177 + ); 178 + } 179 + // Convert base64url to base64 180 + let base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/"); 181 + // Add padding if needed 182 + while (base64.length % 4) { 183 + base64 += "="; 184 + } 185 + const binary = atob(base64); 186 + const bytes = new Uint8Array(binary.length); 187 + for (let i = 0; i < binary.length; i += 1) { 188 + bytes[i] = binary.charCodeAt(i); 189 + } 190 + return bytes.buffer; 191 + } 192 + 193 + export interface PasskeyCredential { 194 + id: string; 195 + rawId: ArrayBuffer; 196 + response: AuthenticatorAttestationResponse; 197 + } 198 + 199 + export interface PasskeyAssertion { 200 + id: string; 201 + rawId: ArrayBuffer; 202 + response: AuthenticatorAssertionResponse; 203 + } 204 + 205 + export async function createPasskey( 206 + userId: string, 207 + userName: string, 208 + ): Promise<PasskeyCredential> { 209 + if (!isPasskeySupported()) { 210 + throw new Error("Passkeys are not supported in this browser"); 211 + } 212 + 213 + // Generate a random user ID (8 bytes) 214 + const userIdBuffer = new Uint8Array(8); 215 + crypto.getRandomValues(userIdBuffer); 216 + 217 + const challenge = new Uint8Array(32); 218 + crypto.getRandomValues(challenge); 219 + 220 + const publicKeyCredentialCreationOptions: PublicKeyCredentialCreationOptions = 221 + { 222 + challenge, 223 + rp: { 224 + name: "P-Stream", 225 + id: window.location.hostname, 226 + }, 227 + user: { 228 + id: userIdBuffer, 229 + name: userName, 230 + displayName: userName, 231 + }, 232 + pubKeyCredParams: [ 233 + { alg: -7, type: "public-key" }, // ES256 234 + { alg: -257, type: "public-key" }, // RS256 235 + ], 236 + authenticatorSelection: { 237 + authenticatorAttachment: "platform", 238 + userVerification: "preferred", 239 + }, 240 + timeout: 60000, 241 + attestation: "none", 242 + }; 243 + 244 + try { 245 + const credential = (await navigator.credentials.create({ 246 + publicKey: publicKeyCredentialCreationOptions, 247 + })) as PublicKeyCredential | null; 248 + 249 + if (!credential) { 250 + throw new Error("Failed to create passkey"); 251 + } 252 + 253 + return { 254 + id: credential.id, 255 + rawId: credential.rawId, 256 + response: credential.response as AuthenticatorAttestationResponse, 257 + }; 258 + } catch (error) { 259 + throw new Error( 260 + `Failed to create passkey: ${error instanceof Error ? error.message : String(error)}`, 261 + ); 262 + } 263 + } 264 + 265 + export async function authenticatePasskey( 266 + credentialId?: string, 267 + ): Promise<PasskeyAssertion> { 268 + if (!isPasskeySupported()) { 269 + throw new Error("Passkeys are not supported in this browser"); 270 + } 271 + 272 + const challenge = new Uint8Array(32); 273 + crypto.getRandomValues(challenge); 274 + 275 + const allowCredentials: PublicKeyCredentialDescriptor[] | undefined = 276 + credentialId && typeof credentialId === "string" && credentialId.length > 0 277 + ? [ 278 + { 279 + id: base64UrlToArrayBuffer(credentialId), 280 + type: "public-key", 281 + }, 282 + ] 283 + : undefined; 284 + 285 + const publicKeyCredentialRequestOptions: PublicKeyCredentialRequestOptions = { 286 + challenge, 287 + timeout: 60000, 288 + userVerification: "preferred", 289 + allowCredentials, 290 + rpId: window.location.hostname, 291 + }; 292 + 293 + try { 294 + const assertion = (await navigator.credentials.get({ 295 + publicKey: publicKeyCredentialRequestOptions, 296 + })) as PublicKeyCredential | null; 297 + 298 + if (!assertion) { 299 + throw new Error("Failed to authenticate with passkey"); 300 + } 301 + 302 + return { 303 + id: assertion.id, 304 + rawId: assertion.rawId, 305 + response: assertion.response as AuthenticatorAssertionResponse, 306 + }; 307 + } catch (error) { 308 + throw new Error( 309 + `Failed to authenticate with passkey: ${error instanceof Error ? error.message : String(error)}`, 310 + ); 311 + } 312 + } 313 + 314 + async function seedFromCredentialId(credentialId: string): Promise<Uint8Array> { 315 + // Hash credential ID the same way we hash mnemonics 316 + return pbkdf2Async(sha256, credentialId, "mnemonic", { 317 + c: 2048, 318 + dkLen: 32, 319 + }); 320 + } 321 + 322 + export async function keysFromCredentialId( 323 + credentialId: string, 324 + ): Promise<Keys> { 325 + const seed = await seedFromCredentialId(credentialId); 326 + return keysFromSeed(seed); 327 + } 328 + 329 + // Storage helpers for credential mappings 330 + const STORAGE_PREFIX = "__MW::passkey::"; 331 + 332 + function getStorageKey(backendUrl: string, publicKey: string): string { 333 + return `${STORAGE_PREFIX}${backendUrl}::${publicKey}`; 334 + } 335 + 336 + export function storeCredentialMapping( 337 + backendUrl: string, 338 + publicKey: string, 339 + credentialId: string, 340 + ): void { 341 + if (typeof window === "undefined" || !window.localStorage) { 342 + throw new Error("localStorage is not available"); 343 + } 344 + const key = getStorageKey(backendUrl, publicKey); 345 + localStorage.setItem(key, credentialId); 346 + } 347 + 348 + export function getCredentialId( 349 + backendUrl: string, 350 + publicKey: string, 351 + ): string | null { 352 + if (typeof window === "undefined" || !window.localStorage) { 353 + return null; 354 + } 355 + const key = getStorageKey(backendUrl, publicKey); 356 + return localStorage.getItem(key); 357 + } 358 + 359 + export function removeCredentialMapping( 360 + backendUrl: string, 361 + publicKey: string, 362 + ): void { 363 + if (typeof window === "undefined" || !window.localStorage) { 364 + return; 365 + } 366 + const key = getStorageKey(backendUrl, publicKey); 367 + localStorage.removeItem(key); 368 + }
+8 -1
src/backend/accounts/meta.ts
··· 9 9 } 10 10 11 11 export async function getBackendMeta(url: string): Promise<MetaResponse> { 12 - return ofetch<MetaResponse>("/meta", { 12 + const meta = await ofetch<MetaResponse>("/meta", { 13 13 baseURL: url, 14 14 }); 15 + 16 + // Remove escaped backslashes before apostrophes (e.g., \' becomes ') 17 + return { 18 + ...meta, 19 + name: meta.name.replace(/\\'/g, "'"), 20 + description: meta.description?.replace(/\\'/g, "'"), 21 + }; 15 22 }
+3
src/components/form/BackendSelector.tsx
··· 80 80 ) : option.meta ? ( 81 81 <div> 82 82 <p className="text-white font-medium">{option.meta.name}</p> 83 + <p className="text-type-secondary text-sm"> 84 + {option.meta.description} 85 + </p> 83 86 <p className="text-type-secondary text-sm">{hostname}</p> 84 87 </div> 85 88 ) : (
+9 -2
src/components/player/display/base.ts
··· 308 308 }); 309 309 hls.on(Hls.Events.LEVEL_SWITCHED, () => { 310 310 if (!hls) return; 311 - const quality = hlsLevelToQuality(hls.levels[hls.currentLevel]); 312 - emit("changedquality", quality); 311 + if (automaticQuality) { 312 + // Only emit quality changes when automatic quality is enabled 313 + const quality = hlsLevelToQuality(hls.levels[hls.currentLevel]); 314 + emit("changedquality", quality); 315 + } else { 316 + // When automatic quality is disabled, re-lock to preferred quality 317 + // This prevents HLS.js from switching levels unexpectedly 318 + setupQualityForHls(); 319 + } 313 320 }); 314 321 hls.on(Hls.Events.SUBTITLE_TRACK_LOADED, () => { 315 322 for (const [lang, resolve] of languagePromises) {
+47 -5
src/hooks/auth/useAuth.ts
··· 6 6 bytesToBase64, 7 7 bytesToBase64Url, 8 8 encryptData, 9 + getCredentialId, 10 + keysFromCredentialId, 9 11 keysFromMnemonic, 10 12 signChallenge, 13 + storeCredentialMapping, 11 14 } from "@/backend/accounts/crypto"; 12 15 import { getGroupOrder } from "@/backend/accounts/groupOrder"; 13 16 import { importBookmarks, importProgress } from "@/backend/accounts/import"; ··· 33 36 34 37 export interface RegistrationData { 35 38 recaptchaToken?: string; 36 - mnemonic: string; 39 + mnemonic?: string; 40 + credentialId?: string; 37 41 userData: { 38 42 device: string; 39 43 profile: { ··· 45 49 } 46 50 47 51 export interface LoginData { 48 - mnemonic: string; 52 + mnemonic?: string; 53 + credentialId?: string; 49 54 userData: { 50 55 device: string; 51 56 }; ··· 65 70 const login = useCallback( 66 71 async (loginData: LoginData) => { 67 72 if (!backendUrl) return; 68 - const keys = await keysFromMnemonic(loginData.mnemonic); 73 + if (!loginData.mnemonic && !loginData.credentialId) { 74 + throw new Error("Either mnemonic or credentialId must be provided"); 75 + } 76 + 77 + const keys = loginData.credentialId 78 + ? await keysFromCredentialId(loginData.credentialId) 79 + : await keysFromMnemonic(loginData.mnemonic!); 69 80 const publicKeyBase64Url = bytesToBase64Url(keys.publicKey); 81 + 82 + // Try to get credential ID from storage if using mnemonic 83 + let credentialId: string | null = null; 84 + if (loginData.mnemonic) { 85 + credentialId = getCredentialId(backendUrl, publicKeyBase64Url); 86 + } else { 87 + credentialId = loginData.credentialId || null; 88 + } 89 + 70 90 const { challenge } = await getLoginChallengeToken( 71 91 backendUrl, 72 92 publicKeyBase64Url, ··· 83 103 84 104 const user = await getUser(backendUrl, loginResult.token); 85 105 const seedBase64 = bytesToBase64(keys.seed); 106 + 107 + // Store credential mapping if we have a credential ID 108 + if (credentialId) { 109 + storeCredentialMapping(backendUrl, publicKeyBase64Url, credentialId); 110 + } 111 + 86 112 return userDataLogin(loginResult, user.user, user.session, seedBase64); 87 113 }, 88 114 [userDataLogin, backendUrl], ··· 120 146 const register = useCallback( 121 147 async (registerData: RegistrationData) => { 122 148 if (!backendUrl) return; 149 + if (!registerData.mnemonic && !registerData.credentialId) { 150 + throw new Error("Either mnemonic or credentialId must be provided"); 151 + } 152 + 123 153 const { challenge } = await getRegisterChallengeToken( 124 154 backendUrl, 125 155 registerData.recaptchaToken, 126 156 ); 127 - const keys = await keysFromMnemonic(registerData.mnemonic); 157 + const keys = registerData.credentialId 158 + ? await keysFromCredentialId(registerData.credentialId) 159 + : await keysFromMnemonic(registerData.mnemonic!); 128 160 const signature = await signChallenge(keys, challenge); 161 + const publicKeyBase64Url = bytesToBase64Url(keys.publicKey); 129 162 const registerResult = await registerAccount(backendUrl, { 130 163 challenge: { 131 164 code: challenge, 132 165 signature, 133 166 }, 134 - publicKey: bytesToBase64Url(keys.publicKey), 167 + publicKey: publicKeyBase64Url, 135 168 device: await encryptData(registerData.userData.device, keys.seed), 136 169 profile: registerData.userData.profile, 137 170 }); 171 + 172 + // Store credential mapping if we have a credential ID 173 + if (registerData.credentialId) { 174 + storeCredentialMapping( 175 + backendUrl, 176 + publicKeyBase64Url, 177 + registerData.credentialId, 178 + ); 179 + } 138 180 139 181 return userDataLogin( 140 182 registerResult,
-1
src/index.tsx
··· 161 161 const backendUrl = conf().BACKEND_URL; 162 162 const userBackendUrl = useBackendUrl(); 163 163 const { t } = useTranslation(); 164 - const isLoggedIn = !!useAuthStore((s) => s.account); 165 164 166 165 const isCustomUrl = backendUrl !== userBackendUrl; 167 166
+23 -3
src/pages/Register.tsx
··· 48 48 ? [config.BACKEND_URL] 49 49 : []; 50 50 51 - const [step, setStep] = useState(-1); 51 + // If there's only one backend and user hasn't selected a custom one, auto-select it 52 + const defaultBackend = 53 + currentBackendUrl ?? 54 + (availableBackends.length === 1 ? availableBackends[0] : null); 55 + 56 + const [step, setStep] = useState( 57 + availableBackends.length > 1 || !defaultBackend ? -1 : 0, 58 + ); 52 59 const [mnemonic, setMnemonic] = useState<null | string>(null); 60 + const [credentialId, setCredentialId] = useState<null | string>(null); 61 + const [authMethod, setAuthMethod] = useState<"mnemonic" | "passkey">( 62 + "mnemonic", 63 + ); 53 64 const [account, setAccount] = useState<null | AccountProfile>(null); 54 65 const [siteKey, setSiteKey] = useState<string | null>(null); 55 66 const [selectedBackendUrl, setSelectedBackendUrl] = useState<string | null>( 56 - currentBackendUrl ?? null, 67 + currentBackendUrl ?? defaultBackend ?? null, 57 68 ); 58 69 59 70 const handleBackendSelect = (url: string | null) => { ··· 67 78 <CaptchaProvider siteKey={siteKey}> 68 79 <SubPageLayout> 69 80 <PageTitle subpage k="global.pages.register" /> 70 - {step === -1 ? ( 81 + {step === -1 && (availableBackends.length > 1 || !defaultBackend) ? ( 71 82 <LargeCard> 72 83 <LargeCardText title={t("auth.backendSelection.title")}> 73 84 {t("auth.backendSelection.description")} ··· 113 124 <PassphraseGeneratePart 114 125 onNext={(m) => { 115 126 setMnemonic(m); 127 + setAuthMethod("mnemonic"); 128 + setStep(2); 129 + }} 130 + onPasskeyNext={(credId) => { 131 + setCredentialId(credId); 132 + setAuthMethod("passkey"); 116 133 setStep(2); 117 134 }} 118 135 /> ··· 129 146 <VerifyPassphrase 130 147 hasCaptcha={!!siteKey} 131 148 mnemonic={mnemonic} 149 + credentialId={credentialId} 150 + authMethod={authMethod} 132 151 userData={account} 152 + backendUrl={selectedBackendUrl} 133 153 onNext={() => { 134 154 navigate("/"); 135 155 }}
+86 -9
src/pages/parts/auth/LoginFormPart.tsx
··· 3 3 import { useAsyncFn } from "react-use"; 4 4 import type { AsyncReturnType } from "type-fest"; 5 5 6 - import { verifyValidMnemonic } from "@/backend/accounts/crypto"; 6 + import { 7 + authenticatePasskey, 8 + isPasskeySupported, 9 + verifyValidMnemonic, 10 + } from "@/backend/accounts/crypto"; 7 11 import { Button } from "@/components/buttons/Button"; 12 + import { Icon, Icons } from "@/components/Icon"; 8 13 import { BrandPill } from "@/components/layout/BrandPill"; 9 14 import { 10 15 LargeCard, ··· 14 19 import { MwLink } from "@/components/text/Link"; 15 20 import { AuthInputBox } from "@/components/text-inputs/AuthInputBox"; 16 21 import { useAuth } from "@/hooks/auth/useAuth"; 22 + import { useBackendUrl } from "@/hooks/auth/useBackendUrl"; 17 23 import { useBookmarkStore } from "@/stores/bookmarks"; 18 24 import { useProgressStore } from "@/stores/progress"; 19 25 ··· 25 31 const [mnemonic, setMnemonic] = useState(""); 26 32 const [device, setDevice] = useState(""); 27 33 const { login, restore, importData } = useAuth(); 34 + const backendUrl = useBackendUrl(); 28 35 const progressItems = useProgressStore((store) => store.items); 29 36 const bookmarkItems = useBookmarkStore((store) => store.bookmarks); 30 37 const { t } = useTranslation(); 31 38 39 + const [passkeyResult, executePasskey] = useAsyncFn( 40 + async (inputDevice: string) => { 41 + if (!backendUrl) { 42 + throw new Error(t("auth.login.noBackendUrl") ?? "No backend URL"); 43 + } 44 + 45 + const validatedDevice = inputDevice.trim(); 46 + if (validatedDevice.length === 0) 47 + throw new Error(t("auth.login.deviceLengthError") ?? undefined); 48 + 49 + // Authenticate with passkey (no credential ID specified, browser will show all available) 50 + const assertion = await authenticatePasskey(); 51 + const credentialId = assertion.id; 52 + 53 + let account: AsyncReturnType<typeof login>; 54 + try { 55 + account = await login({ 56 + credentialId, 57 + userData: { 58 + device: validatedDevice, 59 + }, 60 + }); 61 + } catch (err) { 62 + if ((err as any).status === 401) 63 + throw new Error(t("auth.login.validationError") ?? undefined); 64 + throw err; 65 + } 66 + 67 + if (!account) 68 + throw new Error(t("auth.login.validationError") ?? undefined); 69 + 70 + await importData(account, progressItems, bookmarkItems); 71 + 72 + await restore(account); 73 + 74 + props.onLogin?.(); 75 + }, 76 + [props, login, restore, backendUrl, t], 77 + ); 78 + 32 79 const [result, execute] = useAsyncFn( 33 80 async (inputMnemonic: string, inputdevice: string) => { 34 81 if (!verifyValidMnemonic(inputMnemonic)) ··· 71 118 </LargeCardText> 72 119 <div className="space-y-4"> 73 120 <AuthInputBox 121 + label={t("auth.deviceNameLabel") ?? undefined} 122 + value={device} 123 + onChange={setDevice} 124 + placeholder={t("auth.deviceNamePlaceholder") ?? undefined} 125 + /> 126 + <AuthInputBox 74 127 label={t("auth.login.passphraseLabel") ?? undefined} 75 128 value={mnemonic} 76 129 autoComplete="username" ··· 79 132 placeholder={t("auth.login.passphrasePlaceholder") ?? undefined} 80 133 passwordToggleable 81 134 /> 82 - <AuthInputBox 83 - label={t("auth.deviceNameLabel") ?? undefined} 84 - value={device} 85 - onChange={setDevice} 86 - placeholder={t("auth.deviceNamePlaceholder") ?? undefined} 87 - /> 88 - {result.error && !result.loading ? ( 135 + {isPasskeySupported() && ( 136 + <div className="relative mb-4"> 137 + <div className="relative my-4"> 138 + <div className="absolute inset-0 flex items-center"> 139 + <div className="w-full border-t border-authentication-border/50" /> 140 + </div> 141 + <div className="relative flex justify-center text-sm"> 142 + <span className="px-2 bg-authentication-bg text-authentication-text"> 143 + {t("auth.login.or")} 144 + </span> 145 + </div> 146 + </div> 147 + <Button 148 + theme="secondary" 149 + onClick={() => executePasskey(device)} 150 + loading={passkeyResult.loading} 151 + disabled={ 152 + passkeyResult.loading || 153 + result.loading || 154 + device.trim().length === 0 155 + } 156 + className="w-full" 157 + > 158 + <Icon icon={Icons.LOCK} className="mr-2" /> 159 + {t("auth.login.usePasskey")} 160 + </Button> 161 + </div> 162 + )} 163 + {(result.error || passkeyResult.error) && 164 + !result.loading && 165 + !passkeyResult.loading ? ( 89 166 <p className="text-authentication-errorText"> 90 - {result.error.message} 167 + {result.error?.message || passkeyResult.error?.message} 91 168 </p> 92 169 ) : null} 93 170 </div>
+49 -1
src/pages/parts/auth/PassphraseGeneratePart.tsx
··· 1 1 import { useCallback, useState } from "react"; 2 2 import { Trans, useTranslation } from "react-i18next"; 3 + import { useAsyncFn } from "react-use"; 3 4 4 - import { genMnemonic } from "@/backend/accounts/crypto"; 5 + import { 6 + createPasskey, 7 + genMnemonic, 8 + isPasskeySupported, 9 + } from "@/backend/accounts/crypto"; 5 10 import { Button } from "@/components/buttons/Button"; 6 11 import { PassphraseDisplay } from "@/components/form/PassphraseDisplay"; 7 12 import { Icon, Icons } from "@/components/Icon"; ··· 13 18 14 19 interface PassphraseGeneratePartProps { 15 20 onNext?: (mnemonic: string) => void; 21 + onPasskeyNext?: (credentialId: string) => void; 16 22 } 17 23 18 24 export function PassphraseGeneratePart(props: PassphraseGeneratePartProps) { ··· 23 29 setMnemonic(customPassphrase); 24 30 }, []); 25 31 32 + const [passkeyResult, createPasskeyFn] = useAsyncFn(async () => { 33 + if (!isPasskeySupported()) { 34 + throw new Error("Passkeys are not supported in this browser"); 35 + } 36 + 37 + const credential = await createPasskey( 38 + `user-${Date.now()}`, 39 + "P-Stream User", 40 + ); 41 + return credential.id; 42 + }, []); 43 + 44 + const handlePasskeyClick = useCallback(async () => { 45 + try { 46 + const credentialId = await createPasskeyFn(); 47 + if (credentialId) { 48 + props.onPasskeyNext?.(credentialId); 49 + } 50 + } catch (error) { 51 + // Error is handled by passkeyResult.error 52 + } 53 + }, [createPasskeyFn, props]); 54 + 26 55 return ( 27 56 <LargeCard> 28 57 <LargeCardText ··· 42 71 /> 43 72 44 73 <LargeCardButtons> 74 + {isPasskeySupported() && ( 75 + <div className="mt-4"> 76 + <Button 77 + theme="purple" 78 + onClick={handlePasskeyClick} 79 + loading={passkeyResult.loading} 80 + disabled={passkeyResult.loading} 81 + className="w-full" 82 + > 83 + <Icon icon={Icons.LOCK} className="mr-2" /> 84 + {t("auth.generate.usePasskeyInstead")} 85 + </Button> 86 + {passkeyResult.error && ( 87 + <p className="mt-2 text-authentication-errorText text-sm text-center"> 88 + {passkeyResult.error.message} 89 + </p> 90 + )} 91 + </div> 92 + )} 45 93 <Button theme="purple" onClick={() => props.onNext?.(mnemonic)}> 46 94 {t("auth.generate.next")} 47 95 </Button>
+95 -1
src/pages/parts/auth/VerifyPassphrasePart.tsx
··· 3 3 import { useTranslation } from "react-i18next"; 4 4 import { useAsyncFn } from "react-use"; 5 5 6 + import { authenticatePasskey } from "@/backend/accounts/crypto"; 6 7 import { updateSettings } from "@/backend/accounts/settings"; 7 8 import { Button } from "@/components/buttons/Button"; 8 9 import { Icon, Icons } from "@/components/Icon"; ··· 24 25 25 26 interface VerifyPassphraseProps { 26 27 mnemonic: string | null; 28 + credentialId: string | null; 29 + authMethod: "mnemonic" | "passkey"; 27 30 hasCaptcha?: boolean; 28 31 userData: AccountProfile | null; 32 + backendUrl: string | null; 29 33 onNext?: () => void; 30 34 } 31 35 ··· 73 77 74 78 const { executeRecaptcha } = useGoogleReCaptcha(); 75 79 80 + const [passkeyResult, authenticatePasskeyFn] = useAsyncFn(async () => { 81 + if (!props.backendUrl) 82 + throw new Error(t("auth.verify.noBackendUrl") ?? undefined); 83 + if (!props.userData) 84 + throw new Error(t("auth.verify.invalidData") ?? undefined); 85 + 86 + // Validate credential ID is a non-empty string 87 + if ( 88 + !props.credentialId || 89 + typeof props.credentialId !== "string" || 90 + props.credentialId.length === 0 91 + ) { 92 + throw new Error( 93 + t("auth.verify.invalidData") ?? "Invalid passkey credential", 94 + ); 95 + } 96 + 97 + let recaptchaToken: string | undefined; 98 + if (props.hasCaptcha) { 99 + recaptchaToken = executeRecaptcha ? await executeRecaptcha() : undefined; 100 + if (!recaptchaToken) 101 + throw new Error(t("auth.verify.recaptchaFailed") ?? undefined); 102 + } 103 + 104 + // Authenticate with passkey using the credential ID from registration 105 + const assertion = await authenticatePasskey(props.credentialId); 106 + 107 + // Verify the credential ID matches 108 + if (assertion.id !== props.credentialId) { 109 + throw new Error( 110 + t("auth.verify.noMatch") ?? "Passkey verification failed", 111 + ); 112 + } 113 + 114 + const account = await register({ 115 + credentialId: props.credentialId, 116 + userData: props.userData, 117 + recaptchaToken, 118 + }); 119 + 120 + if (!account) 121 + throw new Error(t("auth.verify.registrationFailed") ?? undefined); 122 + 123 + await importData(account, progressItems, bookmarkItems); 124 + 125 + await updateSettings(props.backendUrl, account, { 126 + applicationLanguage, 127 + defaultSubtitleLanguage: defaultSubtitleLanguage ?? undefined, 128 + applicationTheme: applicationTheme ?? undefined, 129 + proxyUrls: undefined, 130 + ...preferences, 131 + }); 132 + 133 + await restore(account); 134 + 135 + props.onNext?.(); 136 + }, [props, register, restore, executeRecaptcha]); 137 + 76 138 const [result, execute] = useAsyncFn( 77 139 async (inputMnemonic: string) => { 78 140 if (!backendUrl) ··· 115 177 116 178 props.onNext?.(); 117 179 }, 118 - [props, register, restore], 180 + [props, register, restore, executeRecaptcha], 119 181 ); 182 + 183 + if (props.authMethod === "passkey") { 184 + return ( 185 + <LargeCard> 186 + <form> 187 + <LargeCardText 188 + icon={<Icon icon={Icons.CIRCLE_CHECK} />} 189 + title={t("auth.verify.title")} 190 + > 191 + {t("auth.verify.passkeyDescription")} 192 + </LargeCardText> 193 + {passkeyResult.error ? ( 194 + <p className="mt-3 text-authentication-errorText"> 195 + {t("auth.verify.passkeyError")} 196 + </p> 197 + ) : null} 198 + <LargeCardButtons> 199 + <Button 200 + theme="purple" 201 + loading={passkeyResult.loading} 202 + onClick={() => authenticatePasskeyFn()} 203 + > 204 + {!passkeyResult.loading && ( 205 + <Icon icon={Icons.LOCK} className="mr-2" /> 206 + )} 207 + {t("auth.verify.authenticatePasskey")} 208 + </Button> 209 + </LargeCardButtons> 210 + </form> 211 + </LargeCard> 212 + ); 213 + } 120 214 121 215 return ( 122 216 <LargeCard>