mirror of
https://github.com/zoriya/Kyoo.git
synced 2025-06-01 20:54:13 -04:00
Rework login guard on android
This commit is contained in:
parent
86533153bf
commit
0969d68adc
@ -22,7 +22,6 @@ 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 { createQueryClient } from "@kyoo/models";
|
import { createQueryClient } from "@kyoo/models";
|
||||||
import { getSecureItem } from "@kyoo/models/src/secure-store";
|
|
||||||
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";
|
||||||
@ -39,7 +38,7 @@ import { useColorScheme } from "react-native";
|
|||||||
import { initReactI18next } from "react-i18next";
|
import { initReactI18next } from "react-i18next";
|
||||||
import { useTheme } from "yoshiki/native";
|
import { useTheme } from "yoshiki/native";
|
||||||
import "intl-pluralrules";
|
import "intl-pluralrules";
|
||||||
import { ApiUrlContext } from "./index";
|
import { AccountContext, useAccounts } from "./index";
|
||||||
|
|
||||||
// TODO: use a backend to load jsons.
|
// TODO: use a backend to load jsons.
|
||||||
import en from "../../../translations/en.json";
|
import en from "../../../translations/en.json";
|
||||||
@ -77,29 +76,16 @@ const ThemedStack = ({ onLayout }: { onLayout?: () => void }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const useApiUrl = (): string | null | undefined => {
|
|
||||||
const [apiUrl, setApiUrl] = useState<string | null | undefined>(undefined);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
async function run() {
|
|
||||||
const apiUrl = await getSecureItem("apiUrl");
|
|
||||||
setApiUrl(apiUrl);
|
|
||||||
}
|
|
||||||
run();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return apiUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Root() {
|
export default function Root() {
|
||||||
const [queryClient] = useState(() => createQueryClient());
|
const [queryClient] = useState(() => createQueryClient());
|
||||||
const theme = useColorScheme();
|
const theme = useColorScheme();
|
||||||
const [fontsLoaded] = useFonts({ Poppins_300Light, Poppins_400Regular, Poppins_900Black });
|
const [fontsLoaded] = useFonts({ Poppins_300Light, Poppins_400Regular, Poppins_900Black });
|
||||||
const apiUrl = useApiUrl();
|
const info = useAccounts();
|
||||||
|
|
||||||
if (!fontsLoaded || apiUrl === undefined) return <SplashScreen />;
|
if (!fontsLoaded || info.type === "loading") return <SplashScreen />;
|
||||||
return (
|
return (
|
||||||
<ApiUrlContext.Provider value={apiUrl}>
|
<AccountContext.Provider value={info}>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<ThemeSelector
|
<ThemeSelector
|
||||||
theme={theme ?? "light"}
|
theme={theme ?? "light"}
|
||||||
@ -115,6 +101,6 @@ export default function Root() {
|
|||||||
</PortalProvider>
|
</PortalProvider>
|
||||||
</ThemeSelector>
|
</ThemeSelector>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</ApiUrlContext.Provider>
|
</AccountContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -18,15 +18,85 @@
|
|||||||
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
|
* 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 } from "@kyoo/primitives";
|
||||||
import { Redirect } from "expo-router";
|
import { Redirect } from "expo-router";
|
||||||
import { createContext, useContext } from "react";
|
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";
|
||||||
|
|
||||||
export const ApiUrlContext = createContext<string | null>(null);
|
export const useAccounts = () => {
|
||||||
|
const [accounts, setAccounts] = useState<Account[] | null>(null);
|
||||||
|
const [verified, setVerified] = useState<{
|
||||||
|
status: "ok" | "error" | "loading" | "unverified";
|
||||||
|
error?: string;
|
||||||
|
}>({ status: "loading" });
|
||||||
|
// 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, verified.status]);
|
||||||
|
|
||||||
|
if (accounts === null || verified.status === "loading") return { type: "loading" } as const;
|
||||||
|
if (verified.status === "error") {
|
||||||
|
return {
|
||||||
|
type: "error",
|
||||||
|
error: verified.error,
|
||||||
|
retry: () => setVerified({ status: "loading" }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
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({ bg: (theme) => theme.colors.red })}>
|
||||||
|
<H1>{t("error.connection")}</H1>
|
||||||
|
<P>{error ?? t("error.unknown")}</P>
|
||||||
|
<P>{t("error.connection-tips")}</P>
|
||||||
|
<Button onPress={retry} text={t("error.try-again")} />
|
||||||
|
<Button onPress={() => router.push("/login")} text={t("error.re-login")} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AccountContext = createContext<ReturnType<typeof useAccounts>>({ type: "loading" });
|
||||||
|
|
||||||
|
let initialRender = true;
|
||||||
|
|
||||||
const App = () => {
|
const App = () => {
|
||||||
const apiUrl = useContext(ApiUrlContext);
|
// Using context on the initial one to keep the splashscreen and not show a spinner.
|
||||||
if (!apiUrl)
|
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||||
return <Redirect href="/login" />;
|
const info = initialRender ? useContext(AccountContext) : useAccounts();
|
||||||
|
initialRender = false;
|
||||||
|
console.log(info);
|
||||||
|
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.
|
// While there is no home page, show the browse page.
|
||||||
return <Redirect href="/browse" />;
|
return <Redirect href="/browse" />;
|
||||||
};
|
};
|
||||||
|
@ -23,8 +23,7 @@ import { deleteSecureItem, getSecureItem, setSecureItem } from "./secure-store";
|
|||||||
import { zdate } from "./utils";
|
import { zdate } from "./utils";
|
||||||
import { queryFn } from "./query";
|
import { queryFn } from "./query";
|
||||||
import { KyooErrors } from "./kyoo-errors";
|
import { KyooErrors } from "./kyoo-errors";
|
||||||
import { createContext, useContext } from "react";
|
import { Platform } from "react-native";
|
||||||
import { User } from "./resources/user";
|
|
||||||
|
|
||||||
const TokenP = z.object({
|
const TokenP = z.object({
|
||||||
token_type: z.literal("Bearer"),
|
token_type: z.literal("Bearer"),
|
||||||
@ -39,9 +38,23 @@ type Result<A, B> =
|
|||||||
| { ok: true; value: A; error?: undefined }
|
| { ok: true; value: A; error?: undefined }
|
||||||
| { ok: false; value?: undefined; error: B };
|
| { ok: false; value?: undefined; error: B };
|
||||||
|
|
||||||
|
export type Account = Token & { apiUrl: string; username: string | null };
|
||||||
|
|
||||||
|
|
||||||
|
const addAccount = async (token: Token, apiUrl: string, username: string | null): Promise<void> => {
|
||||||
|
const accounts: Account[] = JSON.parse(await getSecureItem("accounts") ?? "[]");
|
||||||
|
const accIdx = accounts.findIndex(x => x.refresh_token === token.refresh_token);
|
||||||
|
if (accIdx === -1)
|
||||||
|
accounts.push({...token, username, apiUrl});
|
||||||
|
else
|
||||||
|
accounts[accIdx] = {...accounts[accIdx], ...token};
|
||||||
|
await setSecureItem("accounts", JSON.stringify(accounts));
|
||||||
|
}
|
||||||
|
|
||||||
export const loginFunc = async (
|
export const loginFunc = async (
|
||||||
action: "register" | "login" | "refresh",
|
action: "register" | "login" | "refresh",
|
||||||
body: object | string,
|
body: { username: string, password: string, email?: string } | string,
|
||||||
|
apiUrl?: string
|
||||||
): Promise<Result<Token, string>> => {
|
): Promise<Result<Token, string>> => {
|
||||||
try {
|
try {
|
||||||
const token = await queryFn(
|
const token = await queryFn(
|
||||||
@ -50,11 +63,14 @@ export const loginFunc = async (
|
|||||||
method: typeof body === "string" ? "GET" : "POST",
|
method: typeof body === "string" ? "GET" : "POST",
|
||||||
body: typeof body === "object" ? body : undefined,
|
body: typeof body === "object" ? body : undefined,
|
||||||
authenticated: false,
|
authenticated: false,
|
||||||
|
apiUrl,
|
||||||
},
|
},
|
||||||
TokenP,
|
TokenP,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (typeof window !== "undefined") await setSecureItem("auth", JSON.stringify(token));
|
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);
|
||||||
return { ok: true, value: token };
|
return { ok: true, value: token };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(action, e);
|
console.error(action, e);
|
||||||
|
@ -32,8 +32,9 @@ import { KyooErrors } from "./kyoo-errors";
|
|||||||
import { Page, Paged } from "./page";
|
import { Page, Paged } from "./page";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import { getToken } from "./login";
|
import { getToken } from "./login";
|
||||||
|
import { getSecureItem } from "./secure-store";
|
||||||
|
|
||||||
export const kyooUrl =
|
const kyooUrl =
|
||||||
Platform.OS !== "web"
|
Platform.OS !== "web"
|
||||||
? process.env.PUBLIC_BACK_URL
|
? process.env.PUBLIC_BACK_URL
|
||||||
: typeof window === "undefined"
|
: typeof window === "undefined"
|
||||||
@ -48,15 +49,18 @@ export const queryFn = async <Data,>(
|
|||||||
body?: object;
|
body?: object;
|
||||||
method: "GET" | "POST";
|
method: "GET" | "POST";
|
||||||
authenticated?: boolean;
|
authenticated?: boolean;
|
||||||
|
apiUrl?: string
|
||||||
},
|
},
|
||||||
type?: z.ZodType<Data>,
|
type?: z.ZodType<Data>,
|
||||||
token?: string | null,
|
token?: string | null,
|
||||||
): Promise<Data> => {
|
): Promise<Data> => {
|
||||||
if (!kyooUrl) console.error("Kyoo's url is not defined.");
|
// @ts-ignore
|
||||||
|
let url: string | null = context.apiUrl ?? (Platform.OS !== "web" ? await getSecureItem("apiUrl") : null) ?? kyooUrl;
|
||||||
|
if (!url) console.error("Kyoo's url is not defined.");
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
if (!token && context.authenticated !== false) token = await getToken();
|
if (!token && context.authenticated !== false) token = await getToken();
|
||||||
const path = [kyooUrl]
|
const path = [url]
|
||||||
.concat(
|
.concat(
|
||||||
"path" in context
|
"path" in context
|
||||||
? context.path.filter((x) => x)
|
? context.path.filter((x) => x)
|
||||||
@ -93,7 +97,7 @@ export const queryFn = async <Data,>(
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
data = { errors: [error] } as KyooErrors;
|
data = { errors: [error] } as KyooErrors;
|
||||||
}
|
}
|
||||||
console.log(`Invalid response (${path}):`, data);
|
console.log(`Invalid response (${path}):`, data, resp.status);
|
||||||
throw data as KyooErrors;
|
throw data as KyooErrors;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -22,7 +22,7 @@ import { ts } from "@kyoo/primitives";
|
|||||||
import { ReactNode } from "react";
|
import { ReactNode } from "react";
|
||||||
import { ScrollView, ImageBackground, ImageProps, Platform, View } from "react-native";
|
import { ScrollView, ImageBackground, ImageProps, Platform, View } from "react-native";
|
||||||
import Svg, { SvgProps, Path } from "react-native-svg";
|
import Svg, { SvgProps, Path } from "react-native-svg";
|
||||||
import { min, percent, px, Stylable, useYoshiki, vh, vw } from "yoshiki/native";
|
import { min, px, Stylable, useYoshiki, vh } from "yoshiki/native";
|
||||||
|
|
||||||
const SvgBlob = (props: SvgProps) => {
|
const SvgBlob = (props: SvgProps) => {
|
||||||
const { css, theme } = useYoshiki();
|
const { css, theme } = useYoshiki();
|
||||||
|
@ -31,7 +31,14 @@ import { FormPage } from "./form";
|
|||||||
import { PasswordInput } from "./password-input";
|
import { PasswordInput } from "./password-input";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
export const cleanApiUrl = (apiUrl: string) => {
|
||||||
|
if (!/https?:\/\//.test(apiUrl)) apiUrl = "http://" + apiUrl;
|
||||||
|
apiUrl = apiUrl.replace(/\/$/, "");
|
||||||
|
return apiUrl + "/api";
|
||||||
|
};
|
||||||
|
|
||||||
export const LoginPage: QueryPage = () => {
|
export const LoginPage: QueryPage = () => {
|
||||||
|
const [apiUrl, setApiUrl] = useState("");
|
||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [error, setError] = useState<string | undefined>(undefined);
|
const [error, setError] = useState<string | undefined>(undefined);
|
||||||
@ -51,7 +58,7 @@ export const LoginPage: QueryPage = () => {
|
|||||||
{Platform.OS !== "web" && (
|
{Platform.OS !== "web" && (
|
||||||
<>
|
<>
|
||||||
<P {...css({ paddingLeft: ts(1) })}>{t("login.server")}</P>
|
<P {...css({ paddingLeft: ts(1) })}>{t("login.server")}</P>
|
||||||
<Input variant="big" />
|
<Input variant="big" onChangeText={setApiUrl} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<P {...css({ paddingLeft: ts(1) })}>{t("login.username")}</P>
|
<P {...css({ paddingLeft: ts(1) })}>{t("login.username")}</P>
|
||||||
@ -66,7 +73,7 @@ export const LoginPage: QueryPage = () => {
|
|||||||
<Button
|
<Button
|
||||||
text={t("login.login")}
|
text={t("login.login")}
|
||||||
onPress={async () => {
|
onPress={async () => {
|
||||||
const { error } = await loginFunc("login", { username, password });
|
const { error } = await loginFunc("login", { username, password }, cleanApiUrl(apiUrl));
|
||||||
setError(error);
|
setError(error);
|
||||||
if (error) return;
|
if (error) return;
|
||||||
queryClient.invalidateQueries(["auth", "me"]);
|
queryClient.invalidateQueries(["auth", "me"]);
|
||||||
|
@ -30,8 +30,11 @@ import { DefaultLayout } from "../layout";
|
|||||||
import { FormPage } from "./form";
|
import { FormPage } from "./form";
|
||||||
import { PasswordInput } from "./password-input";
|
import { PasswordInput } from "./password-input";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { setSecureItem } from "@kyoo/models/src/secure-store";
|
||||||
|
import { cleanApiUrl } from "./login";
|
||||||
|
|
||||||
export const RegisterPage: QueryPage = () => {
|
export const RegisterPage: QueryPage = () => {
|
||||||
|
const [apiUrl, setApiUrl] = useState("");
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
@ -49,7 +52,7 @@ export const RegisterPage: QueryPage = () => {
|
|||||||
{Platform.OS !== "web" && (
|
{Platform.OS !== "web" && (
|
||||||
<>
|
<>
|
||||||
<P {...css({ paddingLeft: ts(1) })}>{t("login.server")}</P>
|
<P {...css({ paddingLeft: ts(1) })}>{t("login.server")}</P>
|
||||||
<Input variant="big" />
|
<Input variant="big" onChangeText={setApiUrl} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -81,7 +84,7 @@ export const RegisterPage: QueryPage = () => {
|
|||||||
text={t("login.register")}
|
text={t("login.register")}
|
||||||
disabled={password !== confirm}
|
disabled={password !== confirm}
|
||||||
onPress={async () => {
|
onPress={async () => {
|
||||||
const { error } = await loginFunc("register", { email, username, password });
|
const { error } = await loginFunc("register", { email, username, password }, cleanApiUrl(apiUrl));
|
||||||
setError(error);
|
setError(error);
|
||||||
if (error) return;
|
if (error) return;
|
||||||
queryClient.invalidateQueries(["auth", "me"]);
|
queryClient.invalidateQueries(["auth", "me"]);
|
||||||
|
@ -137,8 +137,8 @@ const Video = forwardRef<{ seek: (value: number) => void }, VideoProps>(function
|
|||||||
} catch { }
|
} catch { }
|
||||||
});
|
});
|
||||||
hls.on(Hls.Events.ERROR, (_, d) => {
|
hls.on(Hls.Events.ERROR, (_, d) => {
|
||||||
console.log("Hls error", d);
|
|
||||||
if (!d.fatal || !hls?.media) return;
|
if (!d.fatal || !hls?.media) return;
|
||||||
|
console.warn("Hls error", d);
|
||||||
onError?.call(null, {
|
onError?.call(null, {
|
||||||
error: { "": "", errorString: d.reason ?? d.err?.message ?? "Unknown hls error" },
|
error: { "": "", errorString: d.reason ?? d.err?.message ?? "Unknown hls error" },
|
||||||
});
|
});
|
||||||
|
@ -67,5 +67,12 @@
|
|||||||
"or-register": "Don’t have an account? <1>Register</1>.",
|
"or-register": "Don’t have an account? <1>Register</1>.",
|
||||||
"or-login": "Have an account already? <1>Log in</1>.",
|
"or-login": "Have an account already? <1>Log in</1>.",
|
||||||
"password-no-match": "Passwords do not match."
|
"password-no-match": "Passwords do not match."
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"connection": "Could not connect to the kyoo's server",
|
||||||
|
"connection-tips": "Troublshotting tips:\n - Are you connected to internet?\n - Is your kyoo's server online?\n - Have your account been banned?",
|
||||||
|
"unknown": "Unknown error",
|
||||||
|
"try-again": "Try again",
|
||||||
|
"re-login": "Re login"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -67,5 +67,12 @@
|
|||||||
"or-register": "Vous n'avez pas de compte ? <1>Inscrivez-vous</1>.",
|
"or-register": "Vous n'avez pas de compte ? <1>Inscrivez-vous</1>.",
|
||||||
"or-login": "Vous avez déjà un compte ? <1>Connectez-vous.<1/>",
|
"or-login": "Vous avez déjà un compte ? <1>Connectez-vous.<1/>",
|
||||||
"password-no-match": "Mots de passe differents"
|
"password-no-match": "Mots de passe differents"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"connection": "Impossible de se connecter au serveur de kyoo.",
|
||||||
|
"connection-tips": "Possible causes:\n - Etes-vous connecté a internet ?\n - Votre serveur kyoo est-il allumé ?\n - Votre compte est-il bannis ?",
|
||||||
|
"unknown": "Erreur inconnue",
|
||||||
|
"try-again": "Réessayer",
|
||||||
|
"re-login": "Se reconnecter"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user