mirror of
https://github.com/p-stream/p-stream.git
synced 2026-04-21 04:32:25 +00:00
Merge branch 'p-stream:production' into production
This commit is contained in:
commit
d7e5754384
12 changed files with 560 additions and 27 deletions
|
|
@ -8,7 +8,22 @@
|
||||||
<lastBuildDate>Mon, 29 Sep 2025 18:00:00 MST</lastBuildDate>
|
<lastBuildDate>Mon, 29 Sep 2025 18:00:00 MST</lastBuildDate>
|
||||||
<atom:link href="https://pstream.mov/notifications.xml" rel="self" type="application/rss+xml" />
|
<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>
|
<guid>notification-054</guid>
|
||||||
<title>P-Stream v5.3.3 released!</title>
|
<title>P-Stream v5.3.3 released!</title>
|
||||||
<description>Merry Christmas everyone! 🎄
|
<description>Merry Christmas everyone! 🎄
|
||||||
|
|
|
||||||
|
|
@ -158,7 +158,8 @@
|
||||||
"customPassphrasePlaceholder": "Enter your custom passphrase",
|
"customPassphrasePlaceholder": "Enter your custom passphrase",
|
||||||
"useCustomPassphrase": "Use Custom Passphrase",
|
"useCustomPassphrase": "Use Custom Passphrase",
|
||||||
"invalidPassphraseCharacters": "Invalid passphrase characters. Only English letters, numbers 1-10, and normal symbols are allowed.",
|
"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>",
|
"hasAccount": "Already have an account? <0>Login here.</0>",
|
||||||
"login": {
|
"login": {
|
||||||
|
|
@ -168,7 +169,10 @@
|
||||||
"passphrasePlaceholder": "Passphrase",
|
"passphrasePlaceholder": "Passphrase",
|
||||||
"submit": "Login",
|
"submit": "Login",
|
||||||
"title": "Login to your account",
|
"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": {
|
"register": {
|
||||||
"information": {
|
"information": {
|
||||||
|
|
@ -209,7 +213,10 @@
|
||||||
"passphraseLabel": "Your 12-word passphrase",
|
"passphraseLabel": "Your 12-word passphrase",
|
||||||
"recaptchaFailed": "ReCaptcha validation failed",
|
"recaptchaFailed": "ReCaptcha validation failed",
|
||||||
"register": "Create account",
|
"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": {
|
"errors": {
|
||||||
|
|
|
||||||
|
|
@ -152,3 +152,217 @@ export function decryptData(data: string, secret: Uint8Array) {
|
||||||
|
|
||||||
return decipher.output.toString();
|
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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,14 @@ export interface MetaResponse {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getBackendMeta(url: string): Promise<MetaResponse> {
|
export async function getBackendMeta(url: string): Promise<MetaResponse> {
|
||||||
return ofetch<MetaResponse>("/meta", {
|
const meta = await ofetch<MetaResponse>("/meta", {
|
||||||
baseURL: url,
|
baseURL: url,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Remove escaped backslashes before apostrophes (e.g., \' becomes ')
|
||||||
|
return {
|
||||||
|
...meta,
|
||||||
|
name: meta.name.replace(/\\'/g, "'"),
|
||||||
|
description: meta.description?.replace(/\\'/g, "'"),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,9 @@ function BackendOptionItem({
|
||||||
) : option.meta ? (
|
) : option.meta ? (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-white font-medium">{option.meta.name}</p>
|
<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>
|
<p className="text-type-secondary text-sm">{hostname}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -308,8 +308,15 @@ export function makeVideoElementDisplayInterface(): DisplayInterface {
|
||||||
});
|
});
|
||||||
hls.on(Hls.Events.LEVEL_SWITCHED, () => {
|
hls.on(Hls.Events.LEVEL_SWITCHED, () => {
|
||||||
if (!hls) return;
|
if (!hls) return;
|
||||||
const quality = hlsLevelToQuality(hls.levels[hls.currentLevel]);
|
if (automaticQuality) {
|
||||||
emit("changedquality", quality);
|
// 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, () => {
|
hls.on(Hls.Events.SUBTITLE_TRACK_LOADED, () => {
|
||||||
for (const [lang, resolve] of languagePromises) {
|
for (const [lang, resolve] of languagePromises) {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -161,7 +161,6 @@ function AuthWrapper() {
|
||||||
const backendUrl = conf().BACKEND_URL;
|
const backendUrl = conf().BACKEND_URL;
|
||||||
const userBackendUrl = useBackendUrl();
|
const userBackendUrl = useBackendUrl();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const isLoggedIn = !!useAuthStore((s) => s.account);
|
|
||||||
|
|
||||||
const isCustomUrl = backendUrl !== userBackendUrl;
|
const isCustomUrl = backendUrl !== userBackendUrl;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -48,12 +48,23 @@ export function RegisterPage() {
|
||||||
? [config.BACKEND_URL]
|
? [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 [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>(
|
||||||
currentBackendUrl ?? null,
|
currentBackendUrl ?? defaultBackend ?? null,
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleBackendSelect = (url: string | null) => {
|
const handleBackendSelect = (url: string | null) => {
|
||||||
|
|
@ -67,7 +78,7 @@ export function RegisterPage() {
|
||||||
<CaptchaProvider siteKey={siteKey}>
|
<CaptchaProvider siteKey={siteKey}>
|
||||||
<SubPageLayout>
|
<SubPageLayout>
|
||||||
<PageTitle subpage k="global.pages.register" />
|
<PageTitle subpage k="global.pages.register" />
|
||||||
{step === -1 ? (
|
{step === -1 && (availableBackends.length > 1 || !defaultBackend) ? (
|
||||||
<LargeCard>
|
<LargeCard>
|
||||||
<LargeCardText title={t("auth.backendSelection.title")}>
|
<LargeCardText title={t("auth.backendSelection.title")}>
|
||||||
{t("auth.backendSelection.description")}
|
{t("auth.backendSelection.description")}
|
||||||
|
|
@ -113,6 +124,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 +146,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,12 @@ 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">
|
||||||
|
<AuthInputBox
|
||||||
|
label={t("auth.deviceNameLabel") ?? undefined}
|
||||||
|
value={device}
|
||||||
|
onChange={setDevice}
|
||||||
|
placeholder={t("auth.deviceNamePlaceholder") ?? undefined}
|
||||||
|
/>
|
||||||
<AuthInputBox
|
<AuthInputBox
|
||||||
label={t("auth.login.passphraseLabel") ?? undefined}
|
label={t("auth.login.passphraseLabel") ?? undefined}
|
||||||
value={mnemonic}
|
value={mnemonic}
|
||||||
|
|
@ -79,15 +132,39 @@ export function LoginFormPart(props: LoginFormPartProps) {
|
||||||
placeholder={t("auth.login.passphrasePlaceholder") ?? undefined}
|
placeholder={t("auth.login.passphrasePlaceholder") ?? undefined}
|
||||||
passwordToggleable
|
passwordToggleable
|
||||||
/>
|
/>
|
||||||
<AuthInputBox
|
{isPasskeySupported() && (
|
||||||
label={t("auth.deviceNameLabel") ?? undefined}
|
<div className="relative mb-4">
|
||||||
value={device}
|
<div className="relative my-4">
|
||||||
onChange={setDevice}
|
<div className="absolute inset-0 flex items-center">
|
||||||
placeholder={t("auth.deviceNamePlaceholder") ?? undefined}
|
<div className="w-full border-t border-authentication-border/50" />
|
||||||
/>
|
</div>
|
||||||
{result.error && !result.loading ? (
|
<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">
|
<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
|
||||||
|
|
@ -42,6 +71,25 @@ export function PassphraseGeneratePart(props: PassphraseGeneratePartProps) {
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<LargeCardButtons>
|
<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)}>
|
<Button theme="purple" onClick={() => props.onNext?.(mnemonic)}>
|
||||||
{t("auth.generate.next")}
|
{t("auth.generate.next")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -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")}
|
||||||
|
</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 (
|
return (
|
||||||
<LargeCard>
|
<LargeCard>
|
||||||
<form>
|
<form>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue