From f76b2732a181e2d5f9679c6cf6f219c95429a388 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Mon, 3 Feb 2025 00:55:26 +0100 Subject: [PATCH] wip: Account/login rework --- front/packages/models/src/accounts.tsx | 153 +---------------------- front/src/models/resources/account.ts | 23 ++++ front/src/models/resources/index.ts | 1 + front/src/providers/account-provider.tsx | 121 ++++++++++++++++++ front/src/query/query.tsx | 4 +- 5 files changed, 149 insertions(+), 153 deletions(-) create mode 100644 front/src/models/resources/account.ts create mode 100644 front/src/providers/account-provider.tsx diff --git a/front/packages/models/src/accounts.tsx b/front/packages/models/src/accounts.tsx index 68d02818..8f13ccb6 100644 --- a/front/packages/models/src/accounts.tsx +++ b/front/packages/models/src/accounts.tsx @@ -38,26 +38,6 @@ import { useFetch } from "./query"; import { ServerInfoP, type User, UserP } from "./resources"; import { zdate } from "./utils"; -export const TokenP = z.object({ - token_type: z.literal("Bearer"), - access_token: z.string(), - refresh_token: z.string(), - expire_in: z.string(), - expire_at: zdate(), -}); -export type Token = z.infer; - -export const AccountP = UserP.and( - z.object({ - // set it optional for accounts logged in before the kind was present - kind: z.literal("user").optional(), - token: TokenP, - apiUrl: z.string(), - selected: z.boolean(), - }), -); -export type Account = z.infer; - const defaultApiUrl = Platform.OS === "web" ? "/api" : null; const currentApiUrl = atom(defaultApiUrl); export const getCurrentApiUrl = () => { @@ -72,138 +52,6 @@ export const setSsrApiUrl = () => { store.set(currentApiUrl, process.env.KYOO_URL ?? "http://localhost:5000"); }; -const AccountContext = createContext<(Account & { select: () => void; remove: () => void })[]>([]); -export const ConnectionErrorContext = createContext<{ - error: KyooErrors | null; - loading: boolean; - retry?: () => void; - setError: (error: KyooErrors) => void; -}>({ error: null, loading: true, setError: () => {} }); - -export const AccountProvider = ({ - children, - ssrAccount, - ssrError, -}: { - children: ReactNode; - ssrAccount?: Account; - ssrError?: KyooErrors; -}) => { - const setApiUrl = useSetAtom(currentApiUrl); - if (Platform.OS === "web" && typeof window === "undefined") { - const accs = ssrAccount - ? [{ ...ssrAccount, selected: true, select: () => {}, remove: () => {} }] - : []; - - return ( - - { - queryClient.resetQueries({ queryKey: ["auth", "me"] }); - }, - setError: () => {}, - }} - > - {children} - - - ); - } - - const initialSsrError = useRef(ssrError); - - const [accStr] = useMMKVString("accounts"); - const acc = accStr ? z.array(AccountP).parse(JSON.parse(accStr)) : null; - const accounts = useMemo( - () => - acc?.map((account) => ({ - ...account, - select: () => updateAccount(account.id, { ...account, selected: true }), - remove: () => removeAccounts((x) => x.id === account.id), - })) ?? [], - [acc], - ); - - // update user's data from kyoo un startup, it could have changed. - const selected = useMemo(() => accounts.find((x) => x.selected), [accounts]); - useEffect(() => { - setApiUrl(selected?.apiUrl ?? defaultApiUrl); - }, [selected, setApiUrl]); - - const { - isSuccess: userIsSuccess, - isError: userIsError, - isLoading: userIsLoading, - isPlaceholderData: userIsPlaceholder, - data: user, - error: userError, - } = useFetch({ - path: ["auth", "me"], - parser: UserP, - placeholderData: selected as User, - enabled: !!selected, - }); - // Use a ref here because we don't want the effect to trigger when the selected - // value has changed, only when the fetch result changed - // If we trigger the effect when the selected value change, we enter an infinite render loop - const selectedRef = useRef(selected); - selectedRef.current = selected; - useEffect(() => { - if (!selectedRef.current || !userIsSuccess || userIsPlaceholder) return; - // The id is different when user is stale data, we need to wait for the use effect to invalidate the query. - if (user.id !== selectedRef.current.id) return; - const nUser = { ...selectedRef.current, ...user }; - updateAccount(nUser.id, nUser); - }, [user, userIsSuccess, userIsPlaceholder]); - - const queryClient = useQueryClient(); - const oldSelected = useRef<{ id: string; token: string } | null>( - selected ? { id: selected.id, token: selected.token.access_token } : null, - ); - - const [permissionError, setPermissionError] = useState(null); - - useEffect(() => { - // if the user change account (or connect/disconnect), reset query cache. - if ( - // biome-ignore lint/suspicious/noDoubleEquals: id can be an id, null or undefined - selected?.id != oldSelected.current?.id || - (userIsError && selected?.token.access_token !== oldSelected.current?.token) - ) { - initialSsrError.current = undefined; - setPermissionError(null); - queryClient.resetQueries(); - } - oldSelected.current = selected ? { id: selected.id, token: selected.token.access_token } : null; - - // update cookies for ssr (needs to contains token, theme, language...) - if (Platform.OS === "web") { - setCookie("account", selected); - // cookie used for images and videos since we can't add Authorization headers in img or video tags. - setCookie("X-Bearer", selected?.token.access_token); - } - }, [selected, queryClient, userIsError]); - - return ( - - { - queryClient.invalidateQueries({ queryKey: ["auth", "me"] }); - }, - setError: setPermissionError, - }} - > - {children} - - - ); -}; export const useAccount = () => { const acc = useContext(AccountContext); @@ -227,3 +75,4 @@ export const useHasPermission = (perms?: string[]) => { if (!available) return false; return perms.every((perm) => available.includes(perm)); }; + diff --git a/front/src/models/resources/account.ts b/front/src/models/resources/account.ts new file mode 100644 index 00000000..7a09ecee --- /dev/null +++ b/front/src/models/resources/account.ts @@ -0,0 +1,23 @@ +import { z } from "zod"; +import { zdate } from "../utils"; +import { UserP } from "./user"; + +export const TokenP = z.object({ + token_type: z.literal("Bearer"), + access_token: z.string(), + refresh_token: z.string(), + expire_in: z.string(), + expire_at: zdate(), +}); +export type Token = z.infer; + +export const AccountP = UserP.and( + z.object({ + // set it optional for accounts logged in before the kind was present + kind: z.literal("user").optional(), + token: TokenP, + apiUrl: z.string(), + selected: z.boolean(), + }), +); +export type Account = z.infer; diff --git a/front/src/models/resources/index.ts b/front/src/models/resources/index.ts index 3960c22c..9b813dba 100644 --- a/front/src/models/resources/index.ts +++ b/front/src/models/resources/index.ts @@ -1,3 +1,4 @@ +export * from "./account"; export * from "./library-item"; export * from "./news"; export * from "./show"; diff --git a/front/src/providers/account-provider.tsx b/front/src/providers/account-provider.tsx new file mode 100644 index 00000000..7cb51142 --- /dev/null +++ b/front/src/providers/account-provider.tsx @@ -0,0 +1,121 @@ +import { type ReactNode, createContext, useEffect, useMemo } from "react"; +import { Platform } from "react-native"; +import { type Account, type Token, type User, UserP } from "~/models"; +import { useFetch } from "~/query"; +import { useSetError } from "./error-provider"; + +const AccountContext = createContext<{ + apiUrl: string; + authToken: Token; + selectedAccount?: Account; + accounts: (Account & { select: () => void; remove: () => void })[]; +}>(undefined!); + +export const AccountProvider = ({ + children, + ssrAccount, +}: { + children: ReactNode; + ssrAccount?: Account; +}) => { + if (Platform.OS === "web" && typeof window === "undefined") { + const accounts = ssrAccount + ? [{ ...ssrAccount, selected: true, select: () => {}, remove: () => {} }] + : []; + + return ( + + {children} + + ); + } + + const setError = useSetError(); + + const [accStr] = useMMKVString("accounts"); + const accounts = accStr ? z.array(AccountP).parse(JSON.parse(accStr)) : null; + + const ret = useMemo(() => { + const acc = accounts.find((x) => x.selected); + return { + apiUrl: acc.apiUrl, + authToken: acc.token, + selectedAccount: acc, + accounts: + accounts?.map((account) => ({ + ...account, + select: () => updateAccount(account.id, { ...account, selected: true }), + remove: () => removeAccounts((x) => x.id === account.id), + })) ?? [], + }; + }, [accounts]); + + // update user's data from kyoo on startup, it could have changed. + const { + isSuccess: userIsSuccess, + isError: userIsError, + isLoading: userIsLoading, + isPlaceholderData: userIsPlaceholder, + data: user, + error: userError, + } = useFetch({ + path: ["auth", "me"], + parser: UserP, + placeholderData: ret.selectedAccount, + enabled: ret.selectedAccount, + options: { + apiUrl: ret.apiUrl, + authToken: ret.authToken, + }, + }); + // Use a ref here because we don't want the effect to trigger when the selected + // value has changed, only when the fetch result changed + // If we trigger the effect when the selected value change, we enter an infinite render loop + const selectedRef = useRef(selected); + selectedRef.current = selected; + useEffect(() => { + if (!selectedRef.current || !userIsSuccess || userIsPlaceholder) return; + // The id is different when user is stale data, we need to wait for the use effect to invalidate the query. + if (user.id !== selectedRef.current.id) return; + const nUser = { ...selectedRef.current, ...user }; + updateAccount(nUser.id, nUser); + }, [user, userIsSuccess, userIsPlaceholder]); + + const queryClient = useQueryClient(); + const oldSelected = useRef<{ id: string; token: string } | null>( + selected ? { id: selected.id, token: selected.token.access_token } : null, + ); + + const [permissionError, setPermissionError] = useState(null); + + useEffect(() => { + // if the user change account (or connect/disconnect), reset query cache. + if ( + // biome-ignore lint/suspicious/noDoubleEquals: id can be an id, null or undefined + selected?.id != oldSelected.current?.id || + (userIsError && selected?.token.access_token !== oldSelected.current?.token) + ) { + setPermissionError(null); + queryClient.resetQueries(); + } + oldSelected.current = selected ? { id: selected.id, token: selected.token.access_token } : null; + + // update cookies for ssr (needs to contains token, theme, language...) + if (Platform.OS === "web") { + setCookie("account", selected); + // cookie used for images and videos since we can't add Authorization headers in img or video tags. + setCookie("X-Bearer", selected?.token.access_token); + } + }, [selected, queryClient, userIsError]); + // { + // queryClient.invalidateQueries({ queryKey: ["auth", "me"] }); + // }, + // setError: setPermissionError, + // }} + + return {children}; +}; diff --git a/front/src/query/query.tsx b/front/src/query/query.tsx index 92305b29..d9a96bd0 100644 --- a/front/src/query/query.tsx +++ b/front/src/query/query.tsx @@ -122,7 +122,9 @@ export type QueryIdentifier = { placeholderData?: T | (() => T); enabled?: boolean; - options?: Partial[0]>; + options?: Partial[0]> & { + apiUrl?: string; + }; }; export type QueryPage = ComponentType<