mirror of
https://github.com/zoriya/Kyoo.git
synced 2025-05-24 02:02:36 -04:00
Finish multi accounts
This commit is contained in:
parent
1237a9157c
commit
69760dd5f8
@ -21,7 +21,7 @@
|
||||
import { PortalProvider } from "@gorhom/portal";
|
||||
import { ThemeSelector } from "@kyoo/primitives";
|
||||
import { NavbarRight, NavbarTitle } from "@kyoo/ui";
|
||||
import { createQueryClient } from "@kyoo/models";
|
||||
import { AccountContext, createQueryClient, useAccounts } from "@kyoo/models";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import i18next from "i18next";
|
||||
import { Stack } from "expo-router";
|
||||
@ -37,8 +37,12 @@ import { useEffect, useState } from "react";
|
||||
import { useColorScheme } from "react-native";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
import { useTheme } from "yoshiki/native";
|
||||
import { Button, CircularProgress, H1, P, ts } from "@kyoo/primitives";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View } from "react-native";
|
||||
import { useYoshiki } from "yoshiki/native";
|
||||
import { useRouter } from "solito/router";
|
||||
import "intl-pluralrules";
|
||||
import { AccountContext, useAccounts } from "./index";
|
||||
|
||||
// TODO: use a backend to load jsons.
|
||||
import en from "../../../translations/en.json";
|
||||
@ -56,6 +60,26 @@ i18next.use(initReactI18next).init({
|
||||
},
|
||||
});
|
||||
|
||||
export const ConnectionError = ({ error, retry }: { error?: string; retry: () => void }) => {
|
||||
const { css } = useYoshiki();
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<View {...css({ padding: ts(2) })}>
|
||||
<H1 {...css({ textAlign: "center" })}>{t("errors.connection")}</H1>
|
||||
<P>{error ?? t("error.unknown")}</P>
|
||||
<P>{t("errors.connection-tips")}</P>
|
||||
<Button onPress={retry} text={t("errors.try-again")} {...css({ m: ts(1) })} />
|
||||
<Button
|
||||
onPress={() => router.push("/login")}
|
||||
text={t("errors.re-login")}
|
||||
{...css({ m: ts(1) })}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const ThemedStack = ({ onLayout }: { onLayout?: () => void }) => {
|
||||
const theme = useTheme();
|
||||
|
||||
@ -76,6 +100,18 @@ const ThemedStack = ({ onLayout }: { onLayout?: () => void }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const AuthGuard = ({ selected }: { selected: number | null }) => {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (selected === null)
|
||||
router.replace("/login", undefined, {
|
||||
experimental: { nativeBehavior: "stack-replace", isNestedNavigator: false },
|
||||
});
|
||||
}, [selected, router]);
|
||||
return null;
|
||||
};
|
||||
|
||||
let rendered: boolean = false;
|
||||
|
||||
export default function Root() {
|
||||
@ -99,7 +135,14 @@ export default function Root() {
|
||||
}}
|
||||
>
|
||||
<PortalProvider>
|
||||
<ThemedStack />
|
||||
{info.type === "loading" && <CircularProgress />}
|
||||
{info.type === "error" && <ConnectionError error={info.error} retry={info.retry} />}
|
||||
{info.type === "ok" && (
|
||||
<>
|
||||
<ThemedStack />
|
||||
<AuthGuard selected={info.selected} />
|
||||
</>
|
||||
)}
|
||||
</PortalProvider>
|
||||
</ThemeSelector>
|
||||
</QueryClientProvider>
|
||||
|
@ -18,86 +18,7 @@
|
||||
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { Account, loginFunc } from "@kyoo/models";
|
||||
import { getSecureItem, setSecureItem } from "@kyoo/models/src/secure-store";
|
||||
import { Button, CircularProgress, H1, P, ts } from "@kyoo/primitives";
|
||||
import { Redirect } from "expo-router";
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View } from "react-native";
|
||||
import { useYoshiki } from "yoshiki/native";
|
||||
import { useRouter } from "solito/router";
|
||||
import Browse from "./browse";
|
||||
|
||||
export const useAccounts = () => {
|
||||
const [accounts, setAccounts] = useState<Account[] | null>(null);
|
||||
const [verified, setVerified] = useState<{
|
||||
status: "ok" | "error" | "loading" | "unverified";
|
||||
error?: string;
|
||||
}>({ status: "loading" });
|
||||
const [retryCount, setRetryCount] = useState(0);
|
||||
// TODO: Remember the last selected account.
|
||||
const selected = accounts?.length ? 0 : null;
|
||||
|
||||
useEffect(() => {
|
||||
async function run() {
|
||||
const accounts = await getSecureItem("accounts");
|
||||
setAccounts(accounts ? JSON.parse(accounts) : []);
|
||||
}
|
||||
run();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
async function check() {
|
||||
const selAcc = accounts![selected!];
|
||||
await setSecureItem("apiUrl", selAcc.apiUrl);
|
||||
const verif = await loginFunc("refresh", selAcc.refresh_token);
|
||||
setVerified(verif.ok ? { status: "ok" } : { status: "error", error: verif.error });
|
||||
}
|
||||
|
||||
if (accounts && selected !== null) check();
|
||||
else setVerified({status: "unverified"});
|
||||
}, [accounts, selected, retryCount]);
|
||||
|
||||
if (accounts === null || verified.status === "loading") return { type: "loading" } as const;
|
||||
if (accounts !== null && verified.status === "unverified") return { type: "loading" } as const;
|
||||
if (verified.status === "error") {
|
||||
return {
|
||||
type: "error",
|
||||
error: verified.error,
|
||||
retry: () => {
|
||||
setVerified({ status: "loading" })
|
||||
setRetryCount((x) => x + 1);
|
||||
},
|
||||
} as const;
|
||||
}
|
||||
return { type: "ok", accounts, selected } as const;
|
||||
};
|
||||
|
||||
export const ConnectionError = ({ error, retry }: { error?: string; retry: () => void }) => {
|
||||
const { css } = useYoshiki();
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<View {...css({ padding: ts(2)})}>
|
||||
<H1 {...css({ textAlign: "center"})}>{t("errors.connection")}</H1>
|
||||
<P>{error ?? t("error.unknown")}</P>
|
||||
<P>{t("errors.connection-tips")}</P>
|
||||
<Button onPress={retry} text={t("errors.try-again")} {...css({ m: ts(1)})} />
|
||||
<Button onPress={() => router.push("/login")} text={t("errors.re-login")} {...css({ m: ts(1)})} />
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export const AccountContext = createContext<ReturnType<typeof useAccounts>>({ type: "loading" });
|
||||
|
||||
const App = () => {
|
||||
const info = useContext(AccountContext);
|
||||
if (info.type === "loading") return <CircularProgress />
|
||||
if (info.type === "error") return <ConnectionError error={info.error} retry={info.retry} />;
|
||||
if (info.selected === null) return <Redirect href="/login" />;
|
||||
// While there is no home page, show the browse page.
|
||||
return <Redirect href="/browse" />;
|
||||
};
|
||||
|
||||
export default App;
|
||||
// While there is no home page, show the browse page.
|
||||
export default Browse;
|
||||
|
@ -19,12 +19,13 @@
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
import { deleteSecureItem, getSecureItem, setSecureItem } from "./secure-store";
|
||||
import { deleteSecureItem, getSecureItem, setSecureItem, storage } from "./secure-store";
|
||||
import { zdate } from "./utils";
|
||||
import { queryFn } from "./query";
|
||||
import { queryFn, setApiUrl } from "./query";
|
||||
import { KyooErrors } from "./kyoo-errors";
|
||||
import { Platform } from "react-native";
|
||||
import { useEffect, useState } from "react";
|
||||
import { createContext, useEffect, useState } from "react";
|
||||
import { useMMKVListener } from "react-native-mmkv";
|
||||
|
||||
const TokenP = z.object({
|
||||
token_type: z.literal("Bearer"),
|
||||
@ -41,25 +42,78 @@ type Result<A, B> =
|
||||
|
||||
export type Account = Token & { apiUrl: string; username: string };
|
||||
|
||||
export const useAccounts = () => {
|
||||
const [accounts] = useState<Account[]>(JSON.parse(getSecureItem("accounts") ?? "[]"));
|
||||
const [selected, setSelected] = useState<number>(parseInt(getSecureItem("selected") ?? "0"));
|
||||
export const AccountContext = createContext<ReturnType<typeof useAccounts>>({ type: "loading" });
|
||||
|
||||
export const useAccounts = () => {
|
||||
const [accounts, setAccounts] = useState<Account[]>(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;
|
||||
|
||||
useEffect(() => {
|
||||
async function check() {
|
||||
setVerified({status: "loading"});
|
||||
const selAcc = accounts![selected!];
|
||||
setApiUrl(selAcc.apiUrl);
|
||||
const verif = await loginFunc("refresh", selAcc.refresh_token);
|
||||
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",
|
||||
error: verified.error,
|
||||
retry: () => {
|
||||
setVerified({ status: "loading" });
|
||||
setRetryCount((x) => x + 1);
|
||||
},
|
||||
} as const;
|
||||
}
|
||||
return {
|
||||
type: "ok",
|
||||
accounts,
|
||||
selected,
|
||||
setSelected: (selected: number) => {
|
||||
setSelected(selected);
|
||||
setSecureItem("selected", selected.toString());
|
||||
},
|
||||
};
|
||||
} as const;
|
||||
};
|
||||
|
||||
const addAccount = (token: Token, apiUrl: string, username: string | null) => {
|
||||
const addAccount = (token: Token, apiUrl: string, username: string | null) => {
|
||||
const accounts: Account[] = JSON.parse(getSecureItem("accounts") ?? "[]");
|
||||
const accIdx = accounts.findIndex((x) => x.refresh_token === token.refresh_token);
|
||||
if (accIdx === -1) accounts.push({ ...token, username: username!, apiUrl });
|
||||
else accounts[accIdx] = { ...accounts[accIdx], ...token };
|
||||
if (accounts.find((x) => x.username === username && x.apiUrl === apiUrl)) return;
|
||||
accounts.push({ ...token, username: username!, apiUrl });
|
||||
setSecureItem("accounts", JSON.stringify(accounts));
|
||||
};
|
||||
|
||||
const setCurrentAccountToken = (token: Token) => {
|
||||
const accounts: Account[] = JSON.parse(getSecureItem("accounts") ?? "[]");
|
||||
const selected = parseInt(getSecureItem("selected") ?? "0");
|
||||
if (selected >= accounts.length) return;
|
||||
|
||||
accounts[selected] = { ...accounts[selected], ...token };
|
||||
setSecureItem("accounts", JSON.stringify(accounts));
|
||||
};
|
||||
|
||||
@ -80,9 +134,10 @@ export const loginFunc = async (
|
||||
TokenP,
|
||||
);
|
||||
|
||||
if (typeof window !== "undefined") await setSecureItem("auth", JSON.stringify(token));
|
||||
if (Platform.OS !== "web" && apiUrl)
|
||||
await addAccount(token, apiUrl, typeof body !== "string" ? body.username : null);
|
||||
if (typeof window !== "undefined") setSecureItem("auth", JSON.stringify(token));
|
||||
if (Platform.OS !== "web" && apiUrl && typeof body !== "string")
|
||||
addAccount(token, apiUrl, body.username);
|
||||
else if (Platform.OS !== "web" && action === "refresh") setCurrentAccountToken(token);
|
||||
return { ok: true, value: token };
|
||||
} catch (e) {
|
||||
console.error(action, e);
|
||||
@ -91,8 +146,7 @@ export const loginFunc = async (
|
||||
};
|
||||
|
||||
export const getTokenWJ = async (cookies?: string): Promise<[string, Token] | [null, null]> => {
|
||||
// @ts-ignore Web only.
|
||||
const tokenStr = await getSecureItem("auth", cookies);
|
||||
const tokenStr = getSecureItem("auth", cookies);
|
||||
if (!tokenStr) return [null, null];
|
||||
let token = TokenP.parse(JSON.parse(tokenStr));
|
||||
|
||||
@ -107,21 +161,18 @@ export const getTokenWJ = async (cookies?: string): Promise<[string, Token] | [n
|
||||
export const getToken = async (cookies?: string): Promise<string | null> =>
|
||||
(await getTokenWJ(cookies))[0];
|
||||
|
||||
export const logout = async () => {
|
||||
export const logout = () => {
|
||||
if (Platform.OS !== "web") {
|
||||
const tokenStr = await getSecureItem("auth");
|
||||
if (!tokenStr) return;
|
||||
const token = TokenP.parse(JSON.parse(tokenStr));
|
||||
|
||||
let accounts: Account[] = JSON.parse((await getSecureItem("accounts")) ?? "[]");
|
||||
accounts = accounts.filter((x) => x.refresh_token !== token.refresh_token);
|
||||
await setSecureItem("accounts", JSON.stringify(accounts));
|
||||
let accounts: Account[] = JSON.parse(getSecureItem("accounts") ?? "[]");
|
||||
const selected = parseInt(getSecureItem("selected") ?? "0");
|
||||
accounts.splice(selected, 1);
|
||||
setSecureItem("accounts", JSON.stringify(accounts));
|
||||
}
|
||||
|
||||
await deleteSecureItem("auth");
|
||||
deleteSecureItem("auth");
|
||||
};
|
||||
|
||||
export const deleteAccount = async () => {
|
||||
await queryFn({ path: ["auth", "me"], method: "DELETE" });
|
||||
await logout();
|
||||
logout();
|
||||
};
|
||||
|
@ -43,6 +43,10 @@ const kyooUrl =
|
||||
|
||||
export let kyooApiUrl: string | null = kyooUrl || null;
|
||||
|
||||
export const setApiUrl = (apiUrl: string) => {
|
||||
kyooApiUrl = apiUrl;
|
||||
}
|
||||
|
||||
export const queryFn = async <Data,>(
|
||||
context:
|
||||
| QueryFunctionContext
|
||||
@ -57,7 +61,7 @@ export const queryFn = async <Data,>(
|
||||
token?: string | null,
|
||||
): Promise<Data> => {
|
||||
// @ts-ignore
|
||||
let url: string | null = context.apiUrl ?? (Platform.OS !== "web" ? await getSecureItem("apiUrl") : null) ?? kyooUrl;
|
||||
let url: string | null = context.apiUrl ?? kyooApiUrl;
|
||||
if (!url) console.error("Kyoo's url is not defined.");
|
||||
kyooApiUrl = url;
|
||||
|
||||
|
@ -20,7 +20,7 @@
|
||||
|
||||
import { MMKV } from 'react-native-mmkv'
|
||||
|
||||
const storage = new MMKV();
|
||||
export const storage = new MMKV();
|
||||
|
||||
export const setSecureItem = (key: string, value: string | null) => {
|
||||
if (value === null)
|
||||
|
@ -78,7 +78,9 @@ export const LoginPage: QueryPage = () => {
|
||||
setError(error);
|
||||
if (error) return;
|
||||
queryClient.invalidateQueries(["auth", "me"]);
|
||||
router.push("/");
|
||||
router.replace("/", undefined, {
|
||||
experimental: { nativeBehavior: "stack-replace", isNestedNavigator: false },
|
||||
});
|
||||
}}
|
||||
{...css({
|
||||
m: ts(1),
|
||||
|
@ -88,7 +88,9 @@ export const RegisterPage: QueryPage = () => {
|
||||
setError(error);
|
||||
if (error) return;
|
||||
queryClient.invalidateQueries(["auth", "me"]);
|
||||
router.push("/");
|
||||
router.replace("/", undefined, {
|
||||
experimental: { nativeBehavior: "stack-replace", isNestedNavigator: false },
|
||||
});
|
||||
}}
|
||||
{...css({
|
||||
m: ts(1),
|
||||
|
@ -19,6 +19,7 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
AccountContext,
|
||||
deleteAccount,
|
||||
Library,
|
||||
LibraryP,
|
||||
@ -26,7 +27,6 @@ import {
|
||||
Page,
|
||||
Paged,
|
||||
QueryIdentifier,
|
||||
useAccounts,
|
||||
User,
|
||||
UserP,
|
||||
} from "@kyoo/models";
|
||||
@ -48,7 +48,7 @@ import { Platform, TextInput, View, ViewProps } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createParam } from "solito";
|
||||
import { useRouter } from "solito/router";
|
||||
import { px, rem, Stylable, useYoshiki } from "yoshiki/native";
|
||||
import { rem, Stylable, useYoshiki } from "yoshiki/native";
|
||||
import MenuIcon from "@material-symbols/svg-400/rounded/menu-fill.svg";
|
||||
import Search from "@material-symbols/svg-400/rounded/search-fill.svg";
|
||||
import Login from "@material-symbols/svg-400/rounded/login.svg";
|
||||
@ -57,7 +57,7 @@ import Logout from "@material-symbols/svg-400/rounded/logout.svg";
|
||||
import Delete from "@material-symbols/svg-400/rounded/delete.svg";
|
||||
import { Fetch, FetchNE } from "../fetch";
|
||||
import { KyooLongLogo } from "./icon";
|
||||
import { forwardRef, useRef, useState } from "react";
|
||||
import { forwardRef, useContext, useRef, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
export const NavbarTitle = (props: Stylable & { onLayout?: ViewProps["onLayout"] }) => {
|
||||
@ -116,7 +116,7 @@ export const NavbarProfile = () => {
|
||||
const { css, theme } = useYoshiki();
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const { accounts, selected, setSelected } = useAccounts();
|
||||
const { type, accounts, selected, setSelected } = useContext(AccountContext);
|
||||
|
||||
return (
|
||||
<FetchNE query={MeQuery}>
|
||||
@ -131,7 +131,7 @@ export const NavbarProfile = () => {
|
||||
{...css({ marginLeft: ts(1), justifyContent: "center" })}
|
||||
{...tooltip(username ?? t("navbar.login"))}
|
||||
>
|
||||
{accounts.map((x, i) => (
|
||||
{accounts?.map((x, i) => (
|
||||
<Menu.Item
|
||||
key={x.refresh_token}
|
||||
label={`${x.username} - ${getDisplayUrl(x.apiUrl)}`}
|
||||
@ -140,7 +140,7 @@ export const NavbarProfile = () => {
|
||||
onSelect={() => setSelected(i)}
|
||||
/>
|
||||
))}
|
||||
{accounts.length > 0 && <HR />}
|
||||
{accounts && accounts.length > 0 && <HR />}
|
||||
{isGuest ? (
|
||||
<>
|
||||
<Menu.Item label={t("login.login")} icon={Login} href="/login" />
|
||||
@ -152,8 +152,8 @@ export const NavbarProfile = () => {
|
||||
<Menu.Item
|
||||
label={t("login.logout")}
|
||||
icon={Logout}
|
||||
onSelect={async () => {
|
||||
await logout();
|
||||
onSelect={() => {
|
||||
logout();
|
||||
queryClient.invalidateQueries(["auth", "me"]);
|
||||
}}
|
||||
/>
|
||||
|
Loading…
x
Reference in New Issue
Block a user