pstream is dead; long live pstream taciturnaxolotl.github.io/pstream-ng/
at main 284 lines 8.3 kB view raw
1import { useCallback } from "react"; 2 3import { SessionResponse } from "@/backend/accounts/auth"; 4import { bookmarkMediaToInput } from "@/backend/accounts/bookmarks"; 5import { 6 bytesToBase64, 7 bytesToBase64Url, 8 encryptData, 9 getCredentialId, 10 keysFromCredentialId, 11 keysFromMnemonic, 12 signChallenge, 13 storeCredentialMapping, 14} from "@/backend/accounts/crypto"; 15import { getGroupOrder } from "@/backend/accounts/groupOrder"; 16import { importBookmarks, importProgress } from "@/backend/accounts/import"; 17import { getLoginChallengeToken, loginAccount } from "@/backend/accounts/login"; 18import { progressMediaItemToInputs } from "@/backend/accounts/progress"; 19import { 20 getRegisterChallengeToken, 21 registerAccount, 22} from "@/backend/accounts/register"; 23import { removeSession } from "@/backend/accounts/sessions"; 24import { getSettings } from "@/backend/accounts/settings"; 25import { 26 UserResponse, 27 getBookmarks, 28 getProgress, 29 getUser, 30 getWatchHistory, 31} from "@/backend/accounts/user"; 32import { useAuthData } from "@/hooks/auth/useAuthData"; 33import { useBackendUrl } from "@/hooks/auth/useBackendUrl"; 34import { AccountWithToken, useAuthStore } from "@/stores/auth"; 35import { BookmarkMediaItem } from "@/stores/bookmarks"; 36import { ProgressMediaItem } from "@/stores/progress"; 37 38export interface RegistrationData { 39 recaptchaToken?: string; 40 mnemonic?: string; 41 credentialId?: string; 42 userData: { 43 device: string; 44 profile: { 45 colorA: string; 46 colorB: string; 47 icon: string; 48 }; 49 }; 50} 51 52export interface LoginData { 53 mnemonic?: string; 54 credentialId?: string; 55 userData: { 56 device: string; 57 }; 58} 59 60export function useAuth() { 61 const currentAccount = useAuthStore((s) => s.account); 62 const profile = useAuthStore((s) => s.account?.profile); 63 const loggedIn = !!useAuthStore((s) => s.account); 64 const backendUrl = useBackendUrl(); 65 const { 66 logout: userDataLogout, 67 login: userDataLogin, 68 syncData, 69 } = useAuthData(); 70 71 const login = useCallback( 72 async (loginData: LoginData) => { 73 if (!backendUrl) return; 74 if (!loginData.mnemonic && !loginData.credentialId) { 75 throw new Error("Either mnemonic or credentialId must be provided"); 76 } 77 78 const keys = loginData.credentialId 79 ? await keysFromCredentialId(loginData.credentialId) 80 : await keysFromMnemonic(loginData.mnemonic!); 81 const publicKeyBase64Url = bytesToBase64Url(keys.publicKey); 82 83 // Try to get credential ID from storage if using mnemonic 84 let credentialId: string | null = null; 85 if (loginData.mnemonic) { 86 credentialId = getCredentialId(backendUrl, publicKeyBase64Url); 87 } else { 88 credentialId = loginData.credentialId || null; 89 } 90 91 const { challenge } = await getLoginChallengeToken( 92 backendUrl, 93 publicKeyBase64Url, 94 ); 95 const signature = await signChallenge(keys, challenge); 96 const loginResult = await loginAccount(backendUrl, { 97 challenge: { 98 code: challenge, 99 signature, 100 }, 101 publicKey: publicKeyBase64Url, 102 device: await encryptData(loginData.userData.device, keys.seed), 103 }); 104 105 const user = await getUser(backendUrl, loginResult.token); 106 const seedBase64 = bytesToBase64(keys.seed); 107 108 // Store credential mapping if we have a credential ID 109 if (credentialId) { 110 storeCredentialMapping(backendUrl, publicKeyBase64Url, credentialId); 111 } 112 113 return userDataLogin(loginResult, user.user, user.session, seedBase64); 114 }, 115 [userDataLogin, backendUrl], 116 ); 117 118 const logout = useCallback(async () => { 119 if (!currentAccount || !backendUrl) return; 120 try { 121 await removeSession( 122 backendUrl, 123 currentAccount.token, 124 currentAccount.sessionId, 125 ); 126 } catch { 127 // we dont care about failing to delete session 128 } 129 await userDataLogout(); 130 }, [userDataLogout, backendUrl, currentAccount]); 131 132 const disconnectFromBackend = useCallback(async () => { 133 if (!currentAccount || !backendUrl) return; 134 try { 135 await removeSession( 136 backendUrl, 137 currentAccount.token, 138 currentAccount.sessionId, 139 ); 140 } catch { 141 // we dont care about failing to delete session 142 } 143 // Only remove the account, keep all local data 144 useAuthStore.getState().removeAccount(); 145 }, [backendUrl, currentAccount]); 146 147 const register = useCallback( 148 async (registerData: RegistrationData) => { 149 if (!backendUrl) return; 150 if (!registerData.mnemonic && !registerData.credentialId) { 151 throw new Error("Either mnemonic or credentialId must be provided"); 152 } 153 154 const { challenge } = await getRegisterChallengeToken( 155 backendUrl, 156 registerData.recaptchaToken, 157 ); 158 const keys = registerData.credentialId 159 ? await keysFromCredentialId(registerData.credentialId) 160 : await keysFromMnemonic(registerData.mnemonic!); 161 const signature = await signChallenge(keys, challenge); 162 const publicKeyBase64Url = bytesToBase64Url(keys.publicKey); 163 const registerResult = await registerAccount(backendUrl, { 164 challenge: { 165 code: challenge, 166 signature, 167 }, 168 publicKey: publicKeyBase64Url, 169 device: await encryptData(registerData.userData.device, keys.seed), 170 profile: registerData.userData.profile, 171 }); 172 173 // Store credential mapping if we have a credential ID 174 if (registerData.credentialId) { 175 storeCredentialMapping( 176 backendUrl, 177 publicKeyBase64Url, 178 registerData.credentialId, 179 ); 180 } 181 182 return userDataLogin( 183 registerResult, 184 registerResult.user, 185 registerResult.session, 186 bytesToBase64(keys.seed), 187 ); 188 }, 189 [backendUrl, userDataLogin], 190 ); 191 192 const importData = useCallback( 193 async ( 194 account: AccountWithToken, 195 progressItems: Record<string, ProgressMediaItem>, 196 bookmarks: Record<string, BookmarkMediaItem>, 197 ) => { 198 if (!backendUrl) return; 199 if ( 200 Object.keys(progressItems).length === 0 && 201 Object.keys(bookmarks).length === 0 202 ) { 203 return; 204 } 205 206 const progressInputs = Object.entries(progressItems).flatMap( 207 ([tmdbId, item]) => progressMediaItemToInputs(tmdbId, item), 208 ); 209 210 const bookmarkInputs = Object.entries(bookmarks).map(([tmdbId, item]) => 211 bookmarkMediaToInput(tmdbId, item), 212 ); 213 214 await Promise.all([ 215 importProgress(backendUrl, account, progressInputs), 216 importBookmarks(backendUrl, account, bookmarkInputs), 217 ]); 218 }, 219 [backendUrl], 220 ); 221 222 const restore = useCallback( 223 async (account: AccountWithToken) => { 224 if (!backendUrl) return; 225 let user: { user: UserResponse; session: SessionResponse }; 226 try { 227 user = await getUser(backendUrl, account.token); 228 } catch (err) { 229 const anyError: any = err; 230 if ( 231 anyError?.response?.status === 401 || 232 anyError?.response?.status === 403 || 233 anyError?.response?.status === 400 234 ) { 235 await logout(); 236 return; 237 } 238 console.error(err); 239 throw err; 240 } 241 242 const [bookmarks, progress, watchHistory, settings, groupOrder] = 243 await Promise.all([ 244 getBookmarks(backendUrl, account), 245 getProgress(backendUrl, account), 246 getWatchHistory(backendUrl, account), 247 getSettings(backendUrl, account), 248 getGroupOrder(backendUrl, account), 249 ]); 250 251 // Update account store with fresh user data (including nickname) 252 const { setAccount } = useAuthStore.getState(); 253 if (account) { 254 setAccount({ 255 ...account, 256 nickname: user.user.nickname, 257 profile: user.user.profile, 258 }); 259 } 260 261 syncData( 262 user.user, 263 user.session, 264 progress, 265 bookmarks, 266 watchHistory, 267 settings, 268 groupOrder, 269 ); 270 }, 271 [backendUrl, syncData, logout], 272 ); 273 274 return { 275 loggedIn, 276 profile, 277 login, 278 logout, 279 disconnectFromBackend, 280 register, 281 restore, 282 importData, 283 }; 284}