Rework apiurl handling to allow guests login on android

This commit is contained in:
Zoe Roux 2024-03-06 13:52:09 +01:00
parent 158058b720
commit d52cc045e0
8 changed files with 81 additions and 59 deletions

View File

@ -42,7 +42,7 @@ import {
UserP,
useUserTheme,
} from "@kyoo/models";
import { Component, ComponentType, useContext, useState } from "react";
import { ComponentType, useContext, useState } from "react";
import NextApp, { AppContext, type AppProps } from "next/app";
import { Poppins } from "next/font/google";
import { useTheme, useMobileHover, useStyleRegistry, StyleRegistryProvider } from "yoshiki/web";

View File

@ -19,14 +19,15 @@
*/
import { ReactNode, createContext, useContext, useEffect, useMemo, useRef } from "react";
import { User, UserP } from "./resources";
import { ServerInfoP, User, UserP } from "./resources";
import { z } from "zod";
import { zdate } from "./utils";
import { removeAccounts, setCookie, updateAccount } from "./account-internal";
import { useMMKVString } from "react-native-mmkv";
import { Platform } from "react-native";
import { useFetch } from "./query";
import { useQueryClient } from "@tanstack/react-query";
import { atom, getDefaultStore, useAtomValue, useSetAtom } from "jotai";
import { useFetch, } from "./query";
import { KyooErrors } from "./kyoo-errors";
export const TokenP = z.object({
@ -49,6 +50,16 @@ export const AccountP = UserP.and(
);
export type Account = z.infer<typeof AccountP>;
const defaultApiUrl = Platform.OS === "web" ? "/api" : null;
const currentApiUrl = atom<string | null>(defaultApiUrl);
export const getCurrentApiUrl = () => {
const store = getDefaultStore();
return store.get(currentApiUrl);
};
export const useCurrentApiUrl = () => {
return useAtomValue(currentApiUrl);
};
const AccountContext = createContext<(Account & { select: () => void; remove: () => void })[]>([]);
export const ConnectionErrorContext = createContext<{
error: KyooErrors | null;
@ -66,10 +77,14 @@ export const AccountProvider = ({
ssrAccount?: Account;
ssrError?: KyooErrors;
}) => {
const setApiUrl = useSetAtom(currentApiUrl);
if (Platform.OS === "web" && typeof window === "undefined") {
const accs = ssrAccount
? [{ ...ssrAccount, selected: true, select: () => {}, remove: () => {} }]
: [];
setApiUrl(process.env.KYOO_URL ?? "http://localhost:5000");
return (
<AccountContext.Provider value={accs}>
<ConnectionErrorContext.Provider
@ -103,6 +118,10 @@ export const AccountProvider = ({
// 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 user = useFetch({
path: ["auth", "me"],
parser: UserP,
@ -130,6 +149,7 @@ export const AccountProvider = ({
// 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]);
@ -162,10 +182,14 @@ export const useAccounts = () => {
export const useHasPermission = (perms?: string[]) => {
const account = useAccount();
const { data } = useFetch({
path: ["info"],
parser: ServerInfoP,
});
if (!perms || !perms[0]) return true;
// TODO: Read permission of guest account here.
if (!account) return false;
return perms.every((perm) => account.permissions.includes(perm));
const available = account?.permissions ?? data?.guestPermissions;
if (!available) return false;
return perms.every((perm) => available.includes(perm));
};

View File

@ -20,14 +20,9 @@
import { queryFn } from "./query";
import { KyooErrors } from "./kyoo-errors";
import { Account, TokenP } from "./accounts";
import { Account, TokenP, getCurrentApiUrl } from "./accounts";
import { UserP } from "./resources";
import {
addAccount,
getCurrentAccount,
removeAccounts,
updateAccount,
} from "./account-internal";
import { addAccount, getCurrentAccount, removeAccounts, updateAccount } from "./account-internal";
import { Platform } from "react-native";
type Result<A, B> =
@ -38,6 +33,7 @@ export const login = async (
action: "register" | "login",
{ apiUrl, ...body }: { username: string; password: string; email?: string; apiUrl?: string },
): Promise<Result<Account, string>> => {
apiUrl ??= getCurrentApiUrl()!;
try {
const controller = new AbortController();
setTimeout(() => controller.abort(), 5_000);
@ -57,7 +53,7 @@ export const login = async (
UserP,
`Bearer ${token.access_token}`,
);
const account: Account = { ...user, apiUrl: apiUrl ?? "/api", token, selected: true };
const account: Account = { ...user, apiUrl: apiUrl, token, selected: true };
addAccount(account);
return { ok: true, value: account };
} catch (e) {
@ -67,6 +63,7 @@ export const login = async (
};
export const oidcLogin = async (provider: string, code: string, apiUrl?: string) => {
apiUrl ??= getCurrentApiUrl()!;
try {
const token = await queryFn(
{
@ -82,7 +79,7 @@ export const oidcLogin = async (provider: string, code: string, apiUrl?: string)
UserP,
`Bearer ${token.access_token}`,
);
const account: Account = { ...user, apiUrl: apiUrl ?? "/api", token, selected: true };
const account: Account = { ...user, apiUrl: apiUrl, token, selected: true };
addAccount(account);
return { ok: true, value: account };
} catch (e) {

View File

@ -29,14 +29,8 @@ import {
import { z } from "zod";
import { KyooErrors } from "./kyoo-errors";
import { Page, Paged } from "./page";
import { Platform } from "react-native";
import { getToken } from "./login";
import { getCurrentAccount } from "./account-internal";
const kyooUrl =
typeof window === "undefined" ? process.env.KYOO_URL ?? "http://localhost:5000" : "/api";
// The url of kyoo, set after each query (used by the image parser).
export let kyooApiUrl = kyooUrl;
import { getCurrentApiUrl } from ".";
export const queryFn = async <Parser extends z.ZodTypeAny>(
context: {
@ -55,9 +49,7 @@ export const queryFn = async <Parser extends z.ZodTypeAny>(
type?: Parser,
token?: string | null,
): Promise<z.infer<Parser>> => {
const url = context.apiUrl ?? (Platform.OS === "web" ? kyooUrl : getCurrentAccount()?.apiUrl);
kyooApiUrl = url;
const url = context.apiUrl ?? getCurrentApiUrl();
if (token === undefined && context.authenticated !== false) token = await getToken();
const path = [url]
.concat(

View File

@ -20,11 +20,12 @@
import { Platform } from "react-native";
import { ZodObject, ZodRawShape, z } from "zod";
import { kyooApiUrl } from "..";
import { getCurrentApiUrl } from "..";
export const imageFn = (url: string) => (Platform.OS === "web" ? `/api${url}` : kyooApiUrl + url);
export const imageFn = (url: string) =>
Platform.OS === "web" ? `/api${url}` : `${getCurrentApiUrl()!}${url}`;
export const baseAppUrl = () => Platform.OS === "web" ? window.location.origin : "kyoo://";
export const baseAppUrl = () => (Platform.OS === "web" ? window.location.origin : "kyoo://");
export const Img = z.object({
source: z.string(),

View File

@ -20,9 +20,8 @@
import { login, QueryPage } from "@kyoo/models";
import { Button, P, Input, ts, H1, A } from "@kyoo/primitives";
import { useState } from "react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform } from "react-native";
import { Trans } from "react-i18next";
import { useRouter } from "solito/router";
import { percent, px, useYoshiki } from "yoshiki/native";
@ -30,8 +29,12 @@ import { DefaultLayout } from "../layout";
import { FormPage } from "./form";
import { PasswordInput } from "./password-input";
import { OidcLogin } from "./oidc";
import { Platform } from "react-native";
export const LoginPage: QueryPage<{ error?: string }> = ({ error: initialError }) => {
export const LoginPage: QueryPage<{ apiUrl?: string; error?: string }> = ({
apiUrl,
error: initialError,
}) => {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | undefined>(initialError);
@ -40,10 +43,17 @@ export const LoginPage: QueryPage<{ error?: string }> = ({ error: initialError }
const { t } = useTranslation();
const { css } = useYoshiki();
useEffect(() => {
if (!apiUrl && Platform.OS !== "web")
router.replace("/server-url", undefined, {
experimental: { nativeBehavior: "stack-replace", isNestedNavigator: false },
});
}, [apiUrl, router]);
return (
<FormPage>
<H1>{t("login.login")}</H1>
<OidcLogin />
<OidcLogin apiUrl={apiUrl} />
<P {...css({ paddingLeft: ts(1) })}>{t("login.username")}</P>
<Input autoComplete="username" variant="big" onChangeText={(value) => setUsername(value)} />
<P {...css({ paddingLeft: ts(1) })}>{t("login.password")}</P>
@ -59,7 +69,7 @@ export const LoginPage: QueryPage<{ error?: string }> = ({ error: initialError }
const { error } = await login("login", {
username,
password,
apiUrl: cleanApiUrl(apiUrl),
apiUrl,
});
setError(error);
if (error) return;

View File

@ -34,10 +34,10 @@ import { useEffect, useRef } from "react";
import { useRouter } from "solito/router";
import { ErrorView } from "../errors";
export const OidcLogin = () => {
export const OidcLogin = ({ apiUrl }: { apiUrl?: string }) => {
const { css } = useYoshiki();
const { t } = useTranslation();
const { data, error } = useFetch(OidcLogin.query());
const { data, error } = useFetch({ options: { apiUrl }, ...OidcLogin.query() });
const btn = css({ width: { xs: percent(100), sm: percent(75) }, marginY: ts(1) });
@ -48,7 +48,7 @@ export const OidcLogin = () => {
) : data ? (
Object.values(data.oidc).map((x) => (
<Button
href={x.link}
href={apiUrl ? `${x.link}&apiUrl=${apiUrl}` : x.link}
key={x.displayName}
licon={
x.logoUrl != null && (
@ -90,11 +90,12 @@ OidcLogin.query = (): QueryIdentifier<ServerInfo> => ({
parser: ServerInfoP,
});
export const OidcCallbackPage: QueryPage<{ provider: string; code: string; error?: string }> = ({
provider,
code,
error,
}) => {
export const OidcCallbackPage: QueryPage<{
apiUrl?: string;
provider: string;
code: string;
error?: string;
}> = ({ apiUrl, provider, code, error }) => {
const hasRun = useRef(false);
const router = useRouter();
@ -103,12 +104,12 @@ export const OidcCallbackPage: QueryPage<{ provider: string; code: string; error
hasRun.current = true;
function onError(error: string) {
router.replace(`/login?error=${error}`, undefined, {
router.replace(`/login?error=${error}${apiUrl ? `&apiUrl=${apiUrl}` : ""}`, undefined, {
experimental: { nativeBehavior: "stack-replace", isNestedNavigator: false },
});
}
async function run() {
const { error: loginError } = await oidcLogin(provider, code);
const { error: loginError } = await oidcLogin(provider, code, apiUrl);
if (loginError) onError(loginError);
else {
router.replace("/", undefined, {
@ -119,6 +120,6 @@ export const OidcCallbackPage: QueryPage<{ provider: string; code: string; error
if (error) onError(error);
else run();
}, [provider, code, router, error]);
}, [provider, code, apiUrl, router, error]);
return <P>{"Loading"}</P>;
};

View File

@ -20,9 +20,8 @@
import { login, QueryPage } from "@kyoo/models";
import { Button, P, Input, ts, H1, A } from "@kyoo/primitives";
import { useState } from "react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform } from "react-native";
import { Trans } from "react-i18next";
import { useRouter } from "solito/router";
import { percent, px, useYoshiki } from "yoshiki/native";
@ -30,8 +29,9 @@ import { DefaultLayout } from "../layout";
import { FormPage } from "./form";
import { PasswordInput } from "./password-input";
import { OidcLogin } from "./oidc";
import { Platform } from "react-native";
export const RegisterPage: QueryPage = () => {
export const RegisterPage: QueryPage<{ apiUrl?: string }> = ({ apiUrl }) => {
const [email, setEmail] = useState("");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
@ -42,17 +42,17 @@ export const RegisterPage: QueryPage = () => {
const { t } = useTranslation();
const { css } = useYoshiki();
useEffect(() => {
if (!apiUrl && Platform.OS !== "web")
router.replace("/server-url", undefined, {
experimental: { nativeBehavior: "stack-replace", isNestedNavigator: false },
});
}, [apiUrl, router]);
return (
<FormPage>
<H1>{t("login.register")}</H1>
<OidcLogin />
{Platform.OS !== "web" && (
<>
<P {...css({ paddingLeft: ts(1) })}>{t("login.server")}</P>
<Input variant="big" onChangeText={setApiUrl} />
</>
)}
<OidcLogin apiUrl={apiUrl} />
<P {...css({ paddingLeft: ts(1) })}>{t("login.username")}</P>
<Input autoComplete="username" variant="big" onChangeText={(value) => setUsername(value)} />
@ -81,10 +81,7 @@ export const RegisterPage: QueryPage = () => {
text={t("login.register")}
disabled={password !== confirm}
onPress={async () => {
const { error } = await login(
"register",
{ email, username, password, apiUrl: cleanApiUrl(apiUrl) },
);
const { error } = await login("register", { email, username, password, apiUrl });
setError(error);
if (error) return;
router.replace("/", undefined, {