pstream is dead; long live pstream
taciturnaxolotl.github.io/pstream-ng/
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}