Sent native users to server url screen

This commit is contained in:
Zoe Roux 2026-02-10 17:42:56 +01:00
parent 92cdae82d7
commit 1e17f75aaf
No known key found for this signature in database
10 changed files with 98 additions and 156 deletions

View File

@ -246,6 +246,7 @@
"try-again": "Try again",
"re-login": "Re login",
"offline": "You are not connected to internet. Try again later.",
"invalid-server": "Could not reach kyoo's server.",
"unauthorized": "You are missing the permissions {{permission}} to access this page.",
"needVerification": "Your account needs to be verified by your server administrator before you can use it.",
"needAccount": "This page can't be accessed in guest mode. You need to create an account or login.",

View File

@ -8,10 +8,8 @@ import {
Text,
type TextProps,
} from "react-native";
import { useTheme } from "yoshiki/native";
import { cn } from "~/utils";
import { alpha } from "./theme";
import { useResolveClassNames } from "uniwind";
import { cn } from "~/utils";
export function useLinkTo({
href,

View File

@ -1,5 +1,7 @@
import { useQueryClient } from "@tanstack/react-query";
import { useRouter } from "expo-router";
import { type ReactNode, useEffect, useMemo, useRef } from "react";
import { Platform } from "react-native";
import { z } from "zod/v4";
import { Account, User } from "~/models";
import { RetryableError } from "~/models/retryable-error";
@ -11,10 +13,11 @@ import { useStoreValue } from "./settings";
export const defaultApiUrl = "";
export const AccountProvider = ({ children }: { children: ReactNode }) => {
const queryClient = useQueryClient();
const accounts = useStoreValue("accounts", z.array(Account)) ?? [];
const ret = useMemo(() => {
const acc = accounts.find((x) => x.selected);
const acc = accounts.find((x) => x.selected) ?? accounts[0];
return {
apiUrl: acc?.apiUrl ?? defaultApiUrl,
authToken: acc?.token ?? null,
@ -27,6 +30,20 @@ export const AccountProvider = ({ children }: { children: ReactNode }) => {
};
}, [accounts]);
if (Platform.OS !== "web") {
// biome-ignore lint/correctness/useHookAtTopLevel: static
const router = useRouter();
// biome-ignore lint/correctness/useHookAtTopLevel: static
useEffect(() => {
if (!ret.apiUrl) {
setTimeout(() => {
console.log("go to login");
router.replace("/login");
}, 0);
}
}, [ret.apiUrl, router]);
}
// update user's data from kyoo on startup, it could have changed.
const {
isSuccess: userIsSuccess,
@ -62,7 +79,6 @@ export const AccountProvider = ({ children }: { children: ReactNode }) => {
updateAccount(nUser.id, nUser);
}, [user, userIsSuccess, userIsPlaceholder]);
const queryClient = useQueryClient();
const selectedId = ret.selectedAccount?.id;
useEffect(() => {
selectedId;

View File

@ -83,11 +83,11 @@ export const Providers = ({ children }: { children: ReactNode }) => {
<QueryProvider>
<ThemeProvider>
<RnTheme>
<AccountProvider>
<TranslationsProvider>
<TranslationsProvider>
<AccountProvider>
<PortalProvider>{children}</PortalProvider>
</TranslationsProvider>
</AccountProvider>
</AccountProvider>
</TranslationsProvider>
</RnTheme>
</ThemeProvider>
</QueryProvider>

View File

@ -59,10 +59,9 @@ export const queryFn = async <Parser extends z.ZodTypeAny>(context: {
if (typeof e === "object" && e && "name" in e && e.name === "AbortError")
throw { message: "Aborted", status: "aborted" } as KyooError;
console.log("Fetch error", e, context.url);
throw {
message: "Could not reach Kyoo's server.",
status: "aborted",
} as KyooError;
throw new RetryableError({
key: "offline",
});
}
if (resp.status === 404) {
throw { message: "Resource not found.", status: 404 } as KyooError;
@ -144,6 +143,7 @@ export type QueryIdentifier<T = unknown> = {
enabled?: boolean;
options?: Partial<Parameters<typeof queryFn>[0]> & {
apiUrl?: string;
returnError?: boolean;
};
};
@ -189,14 +189,16 @@ export const useFetch = <Data,>(query: QueryIdentifier<Data>) => {
enabled: query.enabled,
});
if (ret.isPaused) throw new RetryableError({ key: "offline" });
if (ret.error && (ret.error.status === 401 || ret.error.status === 403)) {
throw new RetryableError({
key: !selectedAccount ? "needAccount" : "unauthorized",
inner: ret.error,
});
if (query.options?.returnError !== true) {
if (ret.isPaused) throw new RetryableError({ key: "offline" });
if (ret.error && (ret.error.status === 401 || ret.error.status === 403)) {
throw new RetryableError({
key: !selectedAccount ? "needAccount" : "unauthorized",
inner: ret.error,
});
}
if (ret.error) throw ret.error;
}
if (ret.error) throw ret.error;
return ret;
};

View File

@ -1,20 +1,25 @@
import type { ReactNode } from "react";
import { ImageBackground, ScrollView, View } from "react-native";
import Svg, { Path, type SvgProps } from "react-native-svg";
import { min, px, type Stylable, useYoshiki, vh } from "yoshiki/native";
import { ts } from "~/primitives";
import {
ImageBackground,
ScrollView,
View,
type ViewProps,
} from "react-native";
import { Path } from "react-native-svg";
import { Svg } from "~/primitives";
import { defaultApiUrl } from "~/providers/account-provider";
import { cn } from "~/utils";
const SvgBlob = (props: SvgProps) => {
const { css, theme } = useYoshiki();
const SvgBlob = ({ className, ...props }: ViewProps) => {
return (
<View {...css({ width: min(vh(90), px(1200)), aspectRatio: 5 / 6 }, props)}>
<Svg width="100%" height="100%" viewBox="0 0 500 600">
<Path
d="M459.7 0c-20.2 43.3-40.3 86.6-51.7 132.6-11.3 45.9-13.9 94.6-36.1 137.6-22.2 43-64.1 80.3-111.5 88.2s-100.2-13.7-144.5-1.8C71.6 368.6 35.8 414.2 0 459.7V0h459.7z"
fill={theme.background}
/>
<View className={cn("aspect-5/6 w-[90vh] max-w-5xl", className)} {...props}>
<Svg
width="100%"
height="100%"
viewBox="0 0 500 600"
className="fill-background"
>
<Path d="M459.7 0c-20.2 43.3-40.3 86.6-51.7 132.6-11.3 45.9-13.9 94.6-36.1 137.6-22.2 43-64.1 80.3-111.5 88.2s-100.2-13.7-144.5-1.8C71.6 368.6 35.8 414.2 0 459.7V0h459.7z" />
</Svg>
</View>
);
@ -23,37 +28,26 @@ const SvgBlob = (props: SvgProps) => {
export const FormPage = ({
children,
apiUrl,
className,
...props
}: { children: ReactNode; apiUrl?: string } & Stylable) => {
const { css } = useYoshiki();
}: {
children: ReactNode;
apiUrl?: string;
className?: string;
}) => {
return (
<ImageBackground
source={{ uri: `${apiUrl ?? defaultApiUrl}/api/shows/random/thumbnail` }}
{...css({
flexDirection: "row",
flexGrow: 1,
flexShrink: 1,
backgroundColor: (theme) => theme.dark.background,
})}
className="flex-1 flex-row bg-dark"
>
<SvgBlob {...css({ position: "absolute", top: 0, left: 0 })} />
<ScrollView
{...css({
paddingRight: ts(3),
})}
>
<SvgBlob className="absolute top-0 left-0" />
<ScrollView className="pr-6">
<View
{...css(
{
maxWidth: px(600),
backgroundColor: (theme) => theme.background,
borderBottomRightRadius: ts(25),
paddingBottom: ts(5),
paddingLeft: ts(3),
},
props,
className={cn(
"max-w-xl rounded-[25rem] bg-background py-10 pl-6",
className,
)}
{...props}
>
{children}
</View>

View File

@ -2,8 +2,7 @@ import { useRouter } from "expo-router";
import { useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import { Platform } from "react-native";
import { percent, px, useYoshiki } from "yoshiki/native";
import { A, Button, H1, Input, P, ts } from "~/primitives";
import { A, Button, H1, Input, P } from "~/primitives";
import { defaultApiUrl } from "~/providers/account-provider";
import { useQueryState } from "~/utils";
import { FormPage } from "./form";
@ -18,31 +17,25 @@ export const LoginPage = () => {
const [error, setError] = useState<string | undefined>();
const { t } = useTranslation();
const { css } = useYoshiki();
const router = useRouter();
if (Platform.OS !== "web" && !apiUrl) return <ServerUrlPage />;
return (
<FormPage apiUrl={apiUrl!}>
<H1>{t("login.login")}</H1>
{/* <OidcLogin apiUrl={apiUrl} hideOr={!data?.passwordLoginEnabled} /> */}
{/* {data?.passwordLoginEnabled && ( */}
{/* <> */}
<P {...(css({ paddingLeft: ts(1) }) as any)}>{t("login.username")}</P>
<H1 className="pb-4">{t("login.login")}</H1>
<P className="pl-2">{t("login.username")}</P>
<Input
autoComplete="username"
variant="big"
onChangeText={(value) => setUsername(value)}
autoCapitalize="none"
/>
<P {...(css({ paddingLeft: ts(1) }) as any)}>{t("login.password")}</P>
<P className="pt-2 pl-2">{t("login.password")}</P>
<PasswordInput
autoComplete="password"
variant="big"
onChangeText={(value) => setPassword(value)}
/>
{error && <P {...css({ color: (theme) => theme.colors.red })}>{error}</P>}
{error && <P className="text-red-500 dark:text-red-500">{error}</P>}
<Button
text={t("login.login")}
onPress={async () => {
@ -55,16 +48,8 @@ export const LoginPage = () => {
if (error) return;
router.replace("/");
}}
{...css({
m: ts(1),
width: px(250),
maxWidth: percent(100),
alignSelf: "center",
mY: ts(3),
})}
className="m-2 my-6 w-60 self-center"
/>
{/* </> */}
{/* )} */}
<P>
<Trans i18nKey="login.or-register">
Dont have an account?

View File

@ -1,11 +1,9 @@
import VisibilityOff from "@material-symbols/svg-400/rounded/visibility_off-fill.svg";
import Visibility from "@material-symbols/svg-400/rounded/visibility-fill.svg";
import { type ComponentProps, useState } from "react";
import { px, useYoshiki } from "yoshiki/native";
import { IconButton, Input } from "~/primitives";
export const PasswordInput = (props: ComponentProps<typeof Input>) => {
const { css } = useYoshiki();
const [show, setVisibility] = useState(false);
return (
@ -14,9 +12,7 @@ export const PasswordInput = (props: ComponentProps<typeof Input>) => {
right={
<IconButton
icon={show ? VisibilityOff : Visibility}
size={19}
onPress={() => setVisibility(!show)}
{...css({ width: px(19), height: px(19), m: 0, p: 0 })}
/>
}
{...props}

View File

@ -2,8 +2,7 @@ import { useRouter } from "expo-router";
import { useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import { Platform } from "react-native";
import { percent, px, useYoshiki } from "yoshiki/native";
import { A, Button, H1, Input, P, ts } from "~/primitives";
import { A, Button, H1, Input, P } from "~/primitives";
import { defaultApiUrl } from "~/providers/account-provider";
import { useQueryState } from "~/utils";
import { FormPage } from "./form";
@ -21,50 +20,39 @@ export const RegisterPage = () => {
const router = useRouter();
const { t } = useTranslation();
const { css } = useYoshiki();
if (Platform.OS !== "web" && !apiUrl) return <ServerUrlPage />;
return (
<FormPage apiUrl={apiUrl!}>
<H1>{t("login.register")}</H1>
{/* <OidcLogin apiUrl={apiUrl} hideOr={!data?.passwordLoginEnabled} /> */}
{/* {data?.registrationEnabled && ( */}
{/* <> */}
<P {...(css({ paddingLeft: ts(1) }) as any)}>{t("login.username")}</P>
<H1 className="pb-4">{t("login.register")}</H1>
<P className="pl-2">{t("login.username")}</P>
<Input
autoComplete="username"
variant="big"
onChangeText={(value) => setUsername(value)}
/>
<P {...(css({ paddingLeft: ts(1) }) as any)}>{t("login.email")}</P>
<Input
autoComplete="email"
variant="big"
onChangeText={(value) => setEmail(value)}
/>
<P className="pt-2 pl-2">{t("login.email")}</P>
<Input autoComplete="email" onChangeText={(value) => setEmail(value)} />
<P {...(css({ paddingLeft: ts(1) }) as any)}>{t("login.password")}</P>
<P className="pt-2 pl-2">{t("login.password")}</P>
<PasswordInput
autoComplete="new-password"
variant="big"
onChangeText={(value) => setPassword(value)}
/>
<P {...(css({ paddingLeft: ts(1) }) as any)}>{t("login.confirm")}</P>
<P className="pt-2 pl-2">{t("login.confirm")}</P>
<PasswordInput
autoComplete="new-password"
variant="big"
onChangeText={(value) => setConfirm(value)}
/>
{password !== confirm && (
<P {...css({ color: (theme) => theme.colors.red })}>
<P className="text-red-500 dark:text-red-500">
{t("login.password-no-match")}
</P>
)}
{error && <P {...css({ color: (theme) => theme.colors.red })}>{error}</P>}
{error && <P className="text-red-500 dark:text-red-500">{error}</P>}
<Button
text={t("login.register")}
disabled={password !== confirm}
@ -79,16 +67,8 @@ export const RegisterPage = () => {
if (error) return;
router.replace("/");
}}
{...css({
m: ts(1),
width: px(250),
maxWidth: percent(100),
alignSelf: "center",
mY: ts(3),
})}
className="m-2 my-6 w-60 self-center"
/>
{/* </> */}
{/* )} */}
<P>
<Trans i18nKey="login.or-login">
Have an account already?

View File

@ -1,8 +1,7 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform, View } from "react-native";
import { type Theme, useYoshiki } from "yoshiki/native";
import { Button, H1, Input, Link, P, ts } from "~/primitives";
import { Button, H1, Input, Link, P } from "~/primitives";
import { type QueryIdentifier, useFetch } from "~/query";
export const cleanApiUrl = (apiUrl: string) => {
@ -16,73 +15,44 @@ export const ServerUrlPage = () => {
const apiUrl = cleanApiUrl(_apiUrl);
const { data, error } = useFetch({
...ServerUrlPage.query,
options: { apiUrl, authToken: null },
options: { apiUrl, authToken: null, returnError: true },
});
const { t } = useTranslation();
const { css } = useYoshiki();
return (
<View
{...css({
marginX: ts(3),
justifyContent: "space-between",
flexGrow: 1,
})}
>
<View className="m-6 flex-1 justify-between">
<H1>{t("login.server")}</H1>
<View {...css({ justifyContent: "center" })}>
<View className="justify-center">
<Input
variant="big"
onChangeText={setApiUrl}
autoCorrect={false}
autoCapitalize="none"
/>
{!data && (
<P
{...css({
color: (theme: Theme) => theme.colors.red,
alignSelf: "center",
})}
>
{error?.message ?? t("misc.loading")}
<P className="self-center text-red-500 dark:text-red-500">
{error
? error.message === "offline"
? t("errors.invalid-server")
: error.message
: t("misc.loading")}
</P>
)}
</View>
<View {...css({ marginTop: ts(5) })}>
{/* <View {...css({ flexDirection: "row", width: percent(100), alignItems: "center" })}> */}
{/* {data && */}
{/* Object.values(data.oidc).map((x) => ( */}
{/* <Link */}
{/* key={x.displayName} */}
{/* href={{ pathname: x.link, query: { apiUrl } }} */}
{/* {...css({ justifyContent: "center" })} */}
{/* > */}
{/* {x.logoUrl != null ? ( */}
{/* <ImageBackground */}
{/* source={{ uri: x.logoUrl }} */}
{/* {...css({ width: ts(3), height: ts(3), margin: ts(1) })} */}
{/* /> */}
{/* ) : ( */}
{/* t("login.via", { provider: x.displayName }) */}
{/* )} */}
{/* </Link> */}
{/* ))} */}
{/* </View> */}
{/* <HR /> */}
<View {...css({ flexDirection: "row", gap: ts(2) })}>
<View className="mb-2">
<View className="flex-row gap-4">
<Button
text={t("login.login")}
as={Link}
href={`/login?apiUrl=${apiUrl}`}
disabled={data == null}
{...css({ flexGrow: 1, flexShrink: 1 })}
className="flex-1"
/>
<Button
text={t("login.register")}
as={Link}
href={`/register?apiUrl=${apiUrl}`}
disabled={data == null}
{...css({ flexGrow: 1, flexShrink: 1 })}
className="flex-1"
/>
</View>
</View>