mirror of
https://github.com/zoriya/Kyoo.git
synced 2025-05-31 20:24:27 -04:00
Rework account system
This commit is contained in:
parent
0ac388f3eb
commit
14319a5c89
@ -21,7 +21,7 @@
|
|||||||
import { PortalProvider } from "@gorhom/portal";
|
import { PortalProvider } from "@gorhom/portal";
|
||||||
import { ThemeSelector } from "@kyoo/primitives";
|
import { ThemeSelector } from "@kyoo/primitives";
|
||||||
import { NavbarRight, NavbarTitle } from "@kyoo/ui";
|
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 { QueryClientProvider } from "@tanstack/react-query";
|
||||||
import i18next from "i18next";
|
import i18next from "i18next";
|
||||||
import { Stack } from "expo-router";
|
import { Stack } from "expo-router";
|
||||||
@ -121,8 +121,8 @@ export default function Root() {
|
|||||||
if (!isReady) return null;
|
if (!isReady) return null;
|
||||||
rendered = true;
|
rendered = true;
|
||||||
return (
|
return (
|
||||||
<AccountContext.Provider value={info}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<QueryClientProvider client={queryClient}>
|
<AccountProvider>
|
||||||
<ThemeSelector
|
<ThemeSelector
|
||||||
theme={theme ?? "light"}
|
theme={theme ?? "light"}
|
||||||
font={{
|
font={{
|
||||||
@ -138,7 +138,7 @@ export default function Root() {
|
|||||||
{info.type === "ok" && <AuthGuard selected={info.selected} />}
|
{info.type === "ok" && <AuthGuard selected={info.selected} />}
|
||||||
</PortalProvider>
|
</PortalProvider>
|
||||||
</ThemeSelector>
|
</ThemeSelector>
|
||||||
</QueryClientProvider>
|
</AccountProvider>
|
||||||
</AccountContext.Provider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -24,13 +24,13 @@ import { HydrationBoundary, QueryClientProvider } from "@tanstack/react-query";
|
|||||||
import { HiddenIfNoJs, SkeletonCss, ThemeSelector } from "@kyoo/primitives";
|
import { HiddenIfNoJs, SkeletonCss, ThemeSelector } from "@kyoo/primitives";
|
||||||
import { WebTooltip } from "@kyoo/primitives/src/tooltip.web";
|
import { WebTooltip } from "@kyoo/primitives/src/tooltip.web";
|
||||||
import {
|
import {
|
||||||
|
AccountProvider,
|
||||||
createQueryClient,
|
createQueryClient,
|
||||||
fetchQuery,
|
fetchQuery,
|
||||||
getTokenWJ,
|
getTokenWJ,
|
||||||
QueryIdentifier,
|
QueryIdentifier,
|
||||||
QueryPage,
|
QueryPage,
|
||||||
} from "@kyoo/models";
|
} from "@kyoo/models";
|
||||||
import { setSecureItem } from "@kyoo/models/src/secure-store.web";
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import NextApp, { AppContext, type AppProps } from "next/app";
|
import NextApp, { AppContext, type AppProps } from "next/app";
|
||||||
import { Poppins } from "next/font/google";
|
import { Poppins } from "next/font/google";
|
||||||
@ -40,6 +40,11 @@ import Head from "next/head";
|
|||||||
import { withTranslations } from "../i18n";
|
import { withTranslations } from "../i18n";
|
||||||
import arrayShuffle from "array-shuffle";
|
import arrayShuffle from "array-shuffle";
|
||||||
import { Tooltip } from "react-tooltip";
|
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" });
|
const font = Poppins({ weight: ["300", "400", "900"], subsets: ["latin"], display: "swap" });
|
||||||
|
|
||||||
@ -114,7 +119,10 @@ const App = ({ Component, pageProps }: AppProps) => {
|
|||||||
useMobileHover();
|
useMobileHover();
|
||||||
|
|
||||||
// Set the auth from the server (if the token was refreshed during SSR).
|
// 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 (
|
return (
|
||||||
<YoshikiDebug>
|
<YoshikiDebug>
|
||||||
@ -124,25 +132,27 @@ const App = ({ Component, pageProps }: AppProps) => {
|
|||||||
<meta name="description" content="A portable and vast media library solution." />
|
<meta name="description" content="A portable and vast media library solution." />
|
||||||
</Head>
|
</Head>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<HydrationBoundary state={queryState}>
|
<AccountProvider>
|
||||||
<ThemeSelector theme="auto" font={{ normal: "inherit" }}>
|
<HydrationBoundary state={queryState}>
|
||||||
<GlobalCssTheme />
|
<ThemeSelector theme="auto" font={{ normal: "inherit" }}>
|
||||||
<Layout
|
<GlobalCssTheme />
|
||||||
page={
|
<Layout
|
||||||
<Component
|
page={
|
||||||
randomItems={
|
<Component
|
||||||
randomItems[Component.displayName!] ??
|
randomItems={
|
||||||
arrayShuffle((Component as QueryPage).randomItems ?? [])
|
randomItems[Component.displayName!] ??
|
||||||
}
|
arrayShuffle((Component as QueryPage).randomItems ?? [])
|
||||||
{...props}
|
}
|
||||||
/>
|
{...props}
|
||||||
}
|
/>
|
||||||
randomItems={[]}
|
}
|
||||||
{...layoutProps}
|
randomItems={[]}
|
||||||
/>
|
{...layoutProps}
|
||||||
<Tooltip id="tooltip" positionStrategy={"fixed"} />
|
/>
|
||||||
</ThemeSelector>
|
<Tooltip id="tooltip" positionStrategy={"fixed"} />
|
||||||
</HydrationBoundary>
|
</ThemeSelector>
|
||||||
|
</HydrationBoundary>
|
||||||
|
</AccountProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</>
|
</>
|
||||||
</YoshikiDebug>
|
</YoshikiDebug>
|
||||||
@ -150,6 +160,7 @@ const App = ({ Component, pageProps }: AppProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
App.getInitialProps = async (ctx: AppContext) => {
|
App.getInitialProps = async (ctx: AppContext) => {
|
||||||
|
const account = readAccountCookie(ctx.ctx.req?.headers.cookie);
|
||||||
const appProps = await NextApp.getInitialProps(ctx);
|
const appProps = await NextApp.getInitialProps(ctx);
|
||||||
const Component = ctx.Component as QueryPage;
|
const Component = ctx.Component as QueryPage;
|
||||||
|
|
||||||
@ -167,7 +178,7 @@ App.getInitialProps = async (ctx: AppContext) => {
|
|||||||
...(getUrl ? getUrl(ctx.router.query as any, items) : []),
|
...(getUrl ? getUrl(ctx.router.query as any, items) : []),
|
||||||
...(getLayoutUrl ? getLayoutUrl(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.queryState = await fetchQuery(urls, authToken);
|
||||||
appProps.pageProps.token = token;
|
appProps.pageProps.token = token;
|
||||||
|
|
||||||
|
@ -18,74 +18,84 @@
|
|||||||
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
|
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { getSecureItem, setSecureItem, storage } from "./secure-store";
|
import { ReactNode, createContext, useContext, useEffect, useMemo, useRef } from "react";
|
||||||
import { setApiUrl } from "./query";
|
import { User, UserP } from "./resources";
|
||||||
import { createContext, useEffect, useState } from "react";
|
import { z } from "zod";
|
||||||
import { useMMKVListener } from "react-native-mmkv";
|
import { zdate } from "./utils";
|
||||||
import { Account, loginFunc } from "./login";
|
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<ReturnType<typeof useAccounts>>({ 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<typeof TokenP>;
|
||||||
|
|
||||||
|
export const AccountP = UserP.and(
|
||||||
|
z.object({
|
||||||
|
token: TokenP,
|
||||||
|
apiUrl: z.string(),
|
||||||
|
selected: z.boolean(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
export type Account = z.infer<typeof AccountP>;
|
||||||
|
|
||||||
|
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<string | undefined>(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 <AccountContext.Provider value={accounts}>{children}</AccountContext.Provider>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useAccount = () => {
|
||||||
|
const acc = useContext(AccountContext);
|
||||||
|
return acc.find((x) => x.selected);
|
||||||
|
};
|
||||||
|
|
||||||
export const useAccounts = () => {
|
export const useAccounts = () => {
|
||||||
const [accounts, setAccounts] = useState<Account[]>(
|
return useContext(AccountContext);
|
||||||
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<number | null>(
|
|
||||||
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;
|
|
||||||
};
|
};
|
||||||
|
@ -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 <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { createContext } from "react";
|
|
||||||
|
|
||||||
export const useAccounts = () => {};
|
|
||||||
|
|
||||||
export const AccountContext = createContext({});
|
|
Loading…
x
Reference in New Issue
Block a user