diff --git a/front/apps/mobile/app/_layout.tsx b/front/apps/mobile/app/_layout.tsx index b4f27db8..19ada4e9 100644 --- a/front/apps/mobile/app/_layout.tsx +++ b/front/apps/mobile/app/_layout.tsx @@ -21,7 +21,7 @@ import { PortalProvider } from "@gorhom/portal"; import { ThemeSelector } from "@kyoo/primitives"; import { NavbarRight, NavbarTitle } from "@kyoo/ui"; -import { AccountContext, createQueryClient, useAccounts } from "@kyoo/models"; +import { AccountProvider, createQueryClient, useAccounts } from "@kyoo/models"; import { QueryClientProvider } from "@tanstack/react-query"; import i18next from "i18next"; import { Stack } from "expo-router"; @@ -121,8 +121,8 @@ export default function Root() { if (!isReady) return null; rendered = true; return ( - - + + } - - + + ); } diff --git a/front/apps/web/src/pages/_app.tsx b/front/apps/web/src/pages/_app.tsx index 774d75a9..f41c10dc 100755 --- a/front/apps/web/src/pages/_app.tsx +++ b/front/apps/web/src/pages/_app.tsx @@ -24,13 +24,13 @@ import { HydrationBoundary, QueryClientProvider } from "@tanstack/react-query"; import { HiddenIfNoJs, SkeletonCss, ThemeSelector } from "@kyoo/primitives"; import { WebTooltip } from "@kyoo/primitives/src/tooltip.web"; import { + AccountProvider, createQueryClient, fetchQuery, getTokenWJ, QueryIdentifier, QueryPage, } from "@kyoo/models"; -import { setSecureItem } from "@kyoo/models/src/secure-store.web"; import { useState } from "react"; import NextApp, { AppContext, type AppProps } from "next/app"; import { Poppins } from "next/font/google"; @@ -40,6 +40,11 @@ import Head from "next/head"; import { withTranslations } from "../i18n"; import arrayShuffle from "array-shuffle"; import { Tooltip } from "react-tooltip"; +import { + getCurrentAccount, + readAccountCookie, + updateAccount, +} from "@kyoo/models/src/account-internal"; const font = Poppins({ weight: ["300", "400", "900"], subsets: ["latin"], display: "swap" }); @@ -114,7 +119,10 @@ const App = ({ Component, pageProps }: AppProps) => { useMobileHover(); // Set the auth from the server (if the token was refreshed during SSR). - if (typeof window !== "undefined" && token) setSecureItem("auth", JSON.stringify(token)); + if (typeof window !== "undefined" && token) { + const account = getCurrentAccount(); + if (account) updateAccount(account.id, { ...account, token }); + } return ( @@ -124,25 +132,27 @@ const App = ({ Component, pageProps }: AppProps) => { - - - - - } - randomItems={[]} - {...layoutProps} - /> - - - + + + + + + } + randomItems={[]} + {...layoutProps} + /> + + + + @@ -150,6 +160,7 @@ const App = ({ Component, pageProps }: AppProps) => { }; App.getInitialProps = async (ctx: AppContext) => { + const account = readAccountCookie(ctx.ctx.req?.headers.cookie); const appProps = await NextApp.getInitialProps(ctx); const Component = ctx.Component as QueryPage; @@ -167,7 +178,7 @@ App.getInitialProps = async (ctx: AppContext) => { ...(getUrl ? getUrl(ctx.router.query as any, items) : []), ...(getLayoutUrl ? getLayoutUrl(ctx.router.query as any, items) : []), ]; - const [authToken, token] = await getTokenWJ(ctx.ctx.req?.headers.cookie); + const [authToken, token] = await getTokenWJ(account ?? null); appProps.pageProps.queryState = await fetchQuery(urls, authToken); appProps.pageProps.token = token; diff --git a/front/packages/models/src/accounts.tsx b/front/packages/models/src/accounts.tsx index 072a8d64..430bd4a3 100644 --- a/front/packages/models/src/accounts.tsx +++ b/front/packages/models/src/accounts.tsx @@ -18,74 +18,84 @@ * along with Kyoo. If not, see . */ -import { getSecureItem, setSecureItem, storage } from "./secure-store"; -import { setApiUrl } from "./query"; -import { createContext, useEffect, useState } from "react"; -import { useMMKVListener } from "react-native-mmkv"; -import { Account, loginFunc } from "./login"; +import { ReactNode, createContext, useContext, useEffect, useMemo, useRef } from "react"; +import { User, UserP } from "./resources"; +import { z } from "zod"; +import { zdate } from "./utils"; +import { removeAccounts, setAccountCookie, updateAccount } from "./account-internal"; +import { useMMKVString } from "react-native-mmkv"; +import { Platform } from "react-native"; +import { queryFn, useFetch } from "./query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; -export const AccountContext = createContext>({ type: "loading" }); +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({ + token: TokenP, + apiUrl: z.string(), + selected: z.boolean(), + }), +); +export type Account = z.infer; + +const AccountContext = createContext<(Account & { select: () => void; remove: () => void })[]>([]); + +export const AccountProvider = ({ children }: { children: ReactNode }) => { + const [accStr] = useMMKVString("accounts"); + const acc = z.array(AccountP).parse(accStr); + const accounts = useMemo( + () => + acc.map((account) => ({ + ...account, + select: () => updateAccount(account.id, { ...account, selected: true }), + remove: () => removeAccounts((x) => x.id == x.id), + })), + [acc], + ); + + // update user's data from kyoo un startup, it could have changed. + const selected = useMemo(() => accounts.find((x) => x.selected), [accounts]); + const user = useFetch({ + path: ["auth", "me"], + parser: UserP, + placeholderData: selected, + enabled: !!selected, + timeout: 5_000, + }); + useEffect(() => { + if (!selected || !user.isSuccess || user.isPlaceholderData) return; + const nUser = { ...selected, ...user.data }; + if (!Object.is(selected, nUser)) updateAccount(nUser.id, nUser); + }, [selected, user]); + + const queryClient = useQueryClient(); + const oldSelectedId = useRef(selected?.id); + useEffect(() => { + // if the user change account (or connect/disconnect), reset query cache. + if (selected?.id !== oldSelectedId.current) + queryClient.invalidateQueries(); + oldSelectedId.current = selected?.id; + + // update cookies for ssr (needs to contains token, theme, language...) + if (Platform.OS === "web") setAccountCookie(selected); + }, [selected, queryClient]); + + return {children}; +}; + +export const useAccount = () => { + const acc = useContext(AccountContext); + return acc.find((x) => x.selected); +}; export const useAccounts = () => { - const [accounts, setAccounts] = useState( - JSON.parse(getSecureItem("accounts") ?? "[]"), - ); - const [verified, setVerified] = useState<{ - status: "ok" | "error" | "loading" | "unverified"; - error?: string; - }>({ status: "loading" }); - const [retryCount, setRetryCount] = useState(0); - - const sel = getSecureItem("selected"); - let [selected, _setSelected] = useState( - sel ? parseInt(sel) : accounts.length > 0 ? 0 : null, - ); - if (selected === null && accounts.length > 0) selected = 0; - if (accounts.length === 0) selected = null; - - const setSelected = (selected: number) => { - _setSelected(selected); - setSecureItem("selected", selected.toString()); - }; - - useEffect(() => { - async function check() { - setVerified({ status: "loading" }); - const selAcc = accounts![selected!]; - setApiUrl(selAcc.apiUrl); - const verif = await loginFunc("refresh", selAcc.refresh_token, undefined, 5_000); - setVerified(verif.ok ? { status: "ok" } : { status: "error", error: verif.error }); - } - - if (accounts.length && selected !== null) check(); - else setVerified({ status: "unverified" }); - // Use the length of the array and not the array directly because we don't care if the refresh token changes. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [accounts.length, selected, retryCount]); - - useMMKVListener((key) => { - if (key === "accounts") setAccounts(JSON.parse(getSecureItem("accounts") ?? "[]")); - }, storage); - - if (verified.status === "loading") return { type: "loading" } as const; - if (accounts.length && verified.status === "unverified") return { type: "loading" } as const; - if (verified.status === "error") { - return { - type: "error", - accounts, - selected, - error: verified.error, - setSelected, - retry: () => { - setVerified({ status: "loading" }); - setRetryCount((x) => x + 1); - }, - } as const; - } - return { - type: "ok", - accounts, - selected, - setSelected, - } as const; + return useContext(AccountContext); }; diff --git a/front/packages/models/src/accounts.web.ts b/front/packages/models/src/accounts.web.ts deleted file mode 100644 index d40e258e..00000000 --- a/front/packages/models/src/accounts.web.ts +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Kyoo - A portable and vast media library solution. - * Copyright (c) Kyoo. - * - * See AUTHORS.md and LICENSE file in the project root for full license information. - * - * Kyoo is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * any later version. - * - * Kyoo is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Kyoo. If not, see . - */ - -import { createContext } from "react"; - -export const useAccounts = () => {}; - -export const AccountContext = createContext({});