From dfe0d52b1eb061f2ffba3f1559411052625bc3a6 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Mon, 23 Jun 2025 00:34:43 +0200 Subject: [PATCH] Add back login/register (& server url picker on mobile) --- front/app/(app)/(tabs)/_layout.tsx | 12 +- front/app/(app)/(tabs)/index.tsx | 14 +- front/app/(app)/_layout.tsx | 6 - front/app/(public)/_layout.tsx | 27 +++ front/app/(public)/login.tsx | 3 + front/app/(public)/register.tsx | 3 + front/app/_middleware.ts | 22 --- front/packages/models/src/accounts.tsx | 1 - front/src/models/account.ts | 11 ++ front/src/models/index.ts | 2 + front/src/models/resources/account.ts | 23 --- front/src/models/resources/user.ts | 67 -------- front/src/models/user.ts | 58 +++++++ front/src/models/utils/images.ts | 6 +- front/src/primitives/menu.web.tsx | 87 ++++++---- front/src/providers/account-context.tsx | 21 +-- front/src/providers/account-provider.tsx | 16 +- front/src/providers/account-store.ts | 10 +- front/src/providers/index.tsx | 62 +++++-- front/src/providers/permissions.tsx | 17 ++ front/src/providers/settings.ts | 6 +- front/src/query/fetch-infinite.tsx | 4 +- front/src/query/query.tsx | 60 +++++-- front/src/ui/browse/index.tsx | 2 +- front/src/ui/errors/index.tsx | 4 +- front/src/ui/login/form.tsx | 20 +-- front/src/ui/login/index.ts | 1 - front/src/ui/login/logic.tsx | 106 +++++++----- front/src/ui/login/login.tsx | 118 ++++++------- front/src/ui/login/password-input.tsx | 4 +- front/src/ui/login/register.tsx | 139 ++++++++------- front/src/ui/login/server-url.tsx | 96 ++++++----- front/src/ui/navbar/index.tsx | 204 +++++++++++++---------- 33 files changed, 671 insertions(+), 561 deletions(-) create mode 100644 front/app/(public)/_layout.tsx create mode 100644 front/app/(public)/register.tsx delete mode 100644 front/app/_middleware.ts create mode 100644 front/src/models/account.ts delete mode 100644 front/src/models/resources/account.ts delete mode 100644 front/src/models/resources/user.ts create mode 100644 front/src/models/user.ts create mode 100644 front/src/providers/permissions.tsx diff --git a/front/app/(app)/(tabs)/_layout.tsx b/front/app/(app)/(tabs)/_layout.tsx index 3bf22117..de6d56ed 100644 --- a/front/app/(app)/(tabs)/_layout.tsx +++ b/front/app/(app)/(tabs)/_layout.tsx @@ -18,21 +18,27 @@ export default function TabsLayout() { name="index" options={{ tabBarLabel: t("navbar.home"), - tabBarIcon: ({ color, size }) => , + tabBarIcon: ({ color, size }) => ( + + ), }} /> , + tabBarIcon: ({ color, size }) => ( + + ), }} /> , + tabBarIcon: ({ color, size }) => ( + + ), }} /> diff --git a/front/app/(app)/(tabs)/index.tsx b/front/app/(app)/(tabs)/index.tsx index 7fea36e4..59856281 100644 --- a/front/app/(app)/(tabs)/index.tsx +++ b/front/app/(app)/(tabs)/index.tsx @@ -1,9 +1,9 @@ import { useTranslation } from "react-i18next"; import { View } from "react-native"; import { useYoshiki } from "yoshiki/native"; -import { type LibraryItem, LibraryItemP } from "~/models"; +import { Show } from "~/models"; import { P } from "~/primitives"; -import { Fetch, type QueryIdentifier, prefetch } from "~/query"; +import { Fetch, prefetch, type QueryIdentifier } from "~/query"; export async function loader() { await prefetch(Header.query()); @@ -18,17 +18,17 @@ export default function Header() {

{t("home.recommended")}

{name}

} + Render={({ name }) =>

{name}

} Loader={() =>

Loading

} /> ); } -Header.query = (): QueryIdentifier => ({ - parser: LibraryItemP, - path: ["items", "random"], +Header.query = (): QueryIdentifier => ({ + parser: Show, + path: ["shows", "random"], params: { - fields: ["firstEpisode"], + fields: ["firstEntry"], }, }); diff --git a/front/app/(app)/_layout.tsx b/front/app/(app)/_layout.tsx index 7765c532..6ff038ba 100644 --- a/front/app/(app)/_layout.tsx +++ b/front/app/(app)/_layout.tsx @@ -12,21 +12,15 @@ export default function Layout() { , headerRight: () => , contentStyle: { - backgroundColor: theme.background, paddingLeft: insets.left, paddingRight: insets.right, }, headerStyle: { backgroundColor: theme.accent, }, - headerTintColor: theme.colors.white, }} /> diff --git a/front/app/(public)/_layout.tsx b/front/app/(public)/_layout.tsx new file mode 100644 index 00000000..9ce92e8e --- /dev/null +++ b/front/app/(public)/_layout.tsx @@ -0,0 +1,27 @@ +import { Stack } from "expo-router"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { useTheme } from "yoshiki/native"; +import { ErrorConsumer } from "~/providers/error-consumer"; +import { NavbarTitle } from "~/ui/navbar"; + +export default function Layout() { + const insets = useSafeAreaInsets(); + const theme = useTheme(); + + return ( + + , + contentStyle: { + paddingLeft: insets.left, + paddingRight: insets.right, + }, + headerStyle: { + backgroundColor: theme.accent, + }, + }} + /> + + ); +} diff --git a/front/app/(public)/login.tsx b/front/app/(public)/login.tsx index e69de29b..1fe8a75a 100644 --- a/front/app/(public)/login.tsx +++ b/front/app/(public)/login.tsx @@ -0,0 +1,3 @@ +import { LoginPage } from "~/ui/login"; + +export default LoginPage; diff --git a/front/app/(public)/register.tsx b/front/app/(public)/register.tsx new file mode 100644 index 00000000..484aa6f1 --- /dev/null +++ b/front/app/(public)/register.tsx @@ -0,0 +1,3 @@ +import { RegisterPage } from "~/ui/login"; + +export default RegisterPage; diff --git a/front/app/_middleware.ts b/front/app/_middleware.ts deleted file mode 100644 index 5959256a..00000000 --- a/front/app/_middleware.ts +++ /dev/null @@ -1,22 +0,0 @@ -// import { createMiddleware, setServerData } from "one"; -// import { supportedLanguages } from "~/providers/translations.compile"; -// -// export default createMiddleware(({ request, next }) => { -// const systemLanguage = request.headers -// .get("accept-languages") -// ?.split(",") -// .map((x) => { -// const [lang, q] = x.trim().split(";q="); -// return [lang, q ? Number.parseFloat(q) : 1] as const; -// }) -// .sort(([_, q1], [__, q2]) => q1 - q2) -// .flatMap(([lang]) => { -// const [base, spec] = lang.split("-"); -// if (spec) return [lang, base]; -// return [lang]; -// }) -// .find((x) => supportedLanguages.includes(x)); -// setServerData("systemLanguage", systemLanguage); -// setServerData("cookies", request.headers.get("Cookies") ?? ""); -// return next(); -// }); diff --git a/front/packages/models/src/accounts.tsx b/front/packages/models/src/accounts.tsx index 8f13ccb6..550d4c77 100644 --- a/front/packages/models/src/accounts.tsx +++ b/front/packages/models/src/accounts.tsx @@ -38,7 +38,6 @@ import { useFetch } from "./query"; import { ServerInfoP, type User, UserP } from "./resources"; import { zdate } from "./utils"; -const defaultApiUrl = Platform.OS === "web" ? "/api" : null; const currentApiUrl = atom(defaultApiUrl); export const getCurrentApiUrl = () => { const store = getDefaultStore(); diff --git a/front/src/models/account.ts b/front/src/models/account.ts new file mode 100644 index 00000000..734d5956 --- /dev/null +++ b/front/src/models/account.ts @@ -0,0 +1,11 @@ +import { z } from "zod"; +import { User } from "./user"; + +export const AccountP = User.and( + z.object({ + token: TokenP, + apiUrl: z.string(), + selected: z.boolean(), + }), +); +export type Account = z.infer; diff --git a/front/src/models/index.ts b/front/src/models/index.ts index e0a6a322..cbeff92b 100644 --- a/front/src/models/index.ts +++ b/front/src/models/index.ts @@ -9,4 +9,6 @@ export * from "./entry"; export * from "./studio"; export * from "./video"; +export * from "./user"; + export * from "./utils/images"; diff --git a/front/src/models/resources/account.ts b/front/src/models/resources/account.ts deleted file mode 100644 index 7a09ecee..00000000 --- a/front/src/models/resources/account.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { z } from "zod"; -import { zdate } from "../utils"; -import { UserP } from "./user"; - -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; - -export const AccountP = UserP.and( - z.object({ - // set it optional for accounts logged in before the kind was present - kind: z.literal("user").optional(), - token: TokenP, - apiUrl: z.string(), - selected: z.boolean(), - }), -); -export type Account = z.infer; diff --git a/front/src/models/resources/user.ts b/front/src/models/resources/user.ts deleted file mode 100644 index 50d96564..00000000 --- a/front/src/models/resources/user.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { z } from "zod"; -import { ResourceP } from "../traits"; - -export const UserP = ResourceP("user") - .extend({ - /** - * The name of this user. - */ - username: z.string(), - /** - * The user email address. - */ - email: z.string(), - /** - * The list of permissions of the user. The format of this is implementation dependent. - */ - permissions: z.array(z.string()), - /** - * Does the user can sign-in with a password or only via oidc? - */ - hasPassword: z.boolean().default(true), - /** - * User settings - */ - settings: z - .object({ - downloadQuality: z - .union([ - z.literal("original"), - z.literal("8k"), - z.literal("4k"), - z.literal("1440p"), - z.literal("1080p"), - z.literal("720p"), - z.literal("480p"), - z.literal("360p"), - z.literal("240p"), - ]) - .default("original") - .catch("original"), - audioLanguage: z.string().default("default").catch("default"), - subtitleLanguage: z.string().nullable().default(null).catch(null), - }) - // keep a default for older versions of the api - .default({}), - /** - * User accounts on other services. - */ - externalId: z - .record( - z.string(), - z.object({ - id: z.string(), - username: z.string().nullable().default(""), - profileUrl: z.string().nullable(), - }), - ) - .default({}), - }) - .transform((x) => ({ - ...x, - logo: `/user/${x.slug}/logo`, - isVerified: x.permissions.length > 0, - isAdmin: x.permissions?.includes("admin.write"), - })); - -export type User = z.infer; diff --git a/front/src/models/user.ts b/front/src/models/user.ts new file mode 100644 index 00000000..c3e0f225 --- /dev/null +++ b/front/src/models/user.ts @@ -0,0 +1,58 @@ +import { z } from "zod/v4"; + +export const User = z + .object({ + id: z.string(), + username: z.string(), + email: z.string(), + // permissions: z.array(z.string()), + // hasPassword: z.boolean().default(true), + // settings: z + // .object({ + // downloadQuality: z + // .union([ + // z.literal("original"), + // z.literal("8k"), + // z.literal("4k"), + // z.literal("1440p"), + // z.literal("1080p"), + // z.literal("720p"), + // z.literal("480p"), + // z.literal("360p"), + // z.literal("240p"), + // ]) + // .default("original") + // .catch("original"), + // audioLanguage: z.string().default("default").catch("default"), + // subtitleLanguage: z.string().nullable().default(null).catch(null), + // }) + // // keep a default for older versions of the api + // .default({}), + // externalId: z + // .record( + // z.string(), + // z.object({ + // id: z.string(), + // username: z.string().nullable().default(""), + // profileUrl: z.string().nullable(), + // }), + // ) + // .default({}), + }) + .transform((x) => ({ + ...x, + logo: `auth/users/${x.id}/logo`, + // isVerified: x.permissions.length > 0, + // isAdmin: x.permissions?.includes("admin.write"), + })); +export type User = z.infer; + +// not an api stuff, used internally +export const Account = User.and( + z.object({ + apiUrl: z.string(), + token: z.string(), + selected: z.boolean(), + }), +); +export type Account = z.infer; diff --git a/front/src/models/utils/images.ts b/front/src/models/utils/images.ts index 8d7fc915..10177f46 100644 --- a/front/src/models/utils/images.ts +++ b/front/src/models/utils/images.ts @@ -8,9 +8,9 @@ export const KImage = z }) .transform((x) => ({ ...x, - low: `/images/${x.id}?quality=low`, - medium: `/images/${x.id}?quality=medium`, - high: `/images/${x.id}?quality=high`, + low: `/api/images/${x.id}?quality=low`, + medium: `/api/images/${x.id}?quality=medium`, + high: `/api/images/${x.id}?quality=high`, })); export type KImage = z.infer; diff --git a/front/src/primitives/menu.web.tsx b/front/src/primitives/menu.web.tsx index 45111ea3..c0921f13 100644 --- a/front/src/primitives/menu.web.tsx +++ b/front/src/primitives/menu.web.tsx @@ -3,21 +3,26 @@ import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; import { type ComponentProps, type ComponentType, + forwardRef, type ReactElement, type ReactNode, - forwardRef, } from "react"; import type { PressableProps } from "react-native"; import type { SvgProps } from "react-native-svg"; import { useYoshiki as useNativeYoshiki } from "yoshiki/native"; import { useYoshiki } from "yoshiki/web"; -import { ContrastArea, SwitchVariant } from "~/primitives"; import { Icon } from "./icons"; +import { Link } from "./links"; import { P } from "./text"; +import { ContrastArea, SwitchVariant } from "./theme"; import { focusReset, ts } from "./utils"; type YoshikiFunc = (props: ReturnType) => T; -export const YoshikiProvider = ({ children }: { children: YoshikiFunc }) => { +export const YoshikiProvider = ({ + children, +}: { + children: YoshikiFunc; +}) => { const yoshiki = useYoshiki(); return <>{children(yoshiki)}; }; @@ -26,7 +31,12 @@ export const InternalTriger = forwardRef(function _Triger( ref, ) { return ( - + ); }); @@ -74,7 +84,8 @@ const Menu = ({ boxShadow: "0px 10px 38px -10px rgba(22, 23, 24, 0.35), 0px 10px 20px -15px rgba(22, 23, 24, 0.2)", zIndex: 2, - maxHeight: "calc(var(--radix-dropdown-menu-content-available-height) * 0.8)", + maxHeight: + "calc(var(--radix-dropdown-menu-content-available-height) * 0.8)", })} > {children} @@ -89,25 +100,25 @@ const Menu = ({ ); }; -const Item = forwardRef< - HTMLDivElement, - ComponentProps & { href?: string } ->(function _Item({ children, href, onSelect, ...props }, ref) { +const Item = ({ + children, + href, + onSelect, + ...props +}: ComponentProps & { href?: string }) => { if (href) { return ( - - - {children} - + + {children} ); } return ( - + {children} ); -}); +}; const MenuItem = forwardRef< HTMLDivElement, @@ -117,8 +128,14 @@ const MenuItem = forwardRef< left?: ReactElement; disabled?: boolean; selected?: boolean; - } & ({ onSelect: () => void; href?: undefined } | { href: string; onSelect?: undefined }) ->(function MenuItem({ label, icon, left, selected, onSelect, href, disabled, ...props }, ref) { + } & ( + | { onSelect: () => void; href?: undefined } + | { href: string; onSelect?: undefined } + ) +>(function MenuItem( + { label, icon, left, selected, onSelect, href, disabled, ...props }, + ref, +) { const { css: nCss } = useNativeYoshiki(); const { css, theme } = useYoshiki(); @@ -133,17 +150,17 @@ const MenuItem = forwardRef< return ( <> - + {/* */} ({ return ( - e.preventDefault()} /> + e.preventDefault()} + /> ({ boxShadow: "0px 10px 38px -10px rgba(22, 23, 24, 0.35), 0px 10px 20px -15px rgba(22, 23, 24, 0.2)", zIndex: 2, - maxHeight: "calc(var(--radix-dropdown-menu-content-available-height) * 0.8)", + maxHeight: + "calc(var(--radix-dropdown-menu-content-available-height) * 0.8)", })} > {children} diff --git a/front/src/providers/account-context.tsx b/front/src/providers/account-context.tsx index c3871bec..b17cf594 100644 --- a/front/src/providers/account-context.tsx +++ b/front/src/providers/account-context.tsx @@ -1,13 +1,12 @@ import { createContext, useContext } from "react"; -import { type Account, ServerInfoP, type Token } from "~/models"; -import { useFetch } from "~/query"; +import type { Account } from "~/models"; export const AccountContext = createContext<{ apiUrl: string; - authToken: string | null; //Token | null; + authToken: string | null; selectedAccount: Account | null; accounts: (Account & { select: () => void; remove: () => void })[]; -}>({ apiUrl: "api", authToken: null, selectedAccount: null, accounts: [] }); +}>({ apiUrl: "", authToken: null, selectedAccount: null, accounts: [] }); export const useToken = () => { const { apiUrl, authToken } = useContext(AccountContext); @@ -23,17 +22,3 @@ export const useAccounts = () => { const { accounts } = useContext(AccountContext); return accounts; }; - -export const useHasPermission = (perms?: string[]) => { - const account = useAccount(); - const { data } = useFetch({ - path: ["info"], - parser: ServerInfoP, - }); - - if (!perms || !perms[0]) return true; - - const available = account?.permissions ?? data?.guestPermissions; - if (!available) return false; - return perms.every((perm) => available.includes(perm)); -}; diff --git a/front/src/providers/account-provider.tsx b/front/src/providers/account-provider.tsx index 5e72cec3..aa3a785a 100644 --- a/front/src/providers/account-provider.tsx +++ b/front/src/providers/account-provider.tsx @@ -2,7 +2,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { type ReactNode, useEffect, useMemo, useRef } from "react"; import { Platform } from "react-native"; import { z } from "zod/v4"; -import { AccountP, UserP } from "~/models"; +import { Account, User } from "~/models"; import { useFetch } from "~/query"; import { AccountContext } from "./account-context"; import { removeAccounts, updateAccount } from "./account-store"; @@ -11,12 +11,12 @@ import { useStoreValue } from "./settings"; export const AccountProvider = ({ children }: { children: ReactNode }) => { const [setError, clearError] = useSetError("account"); - const accounts = useStoreValue("accounts", z.array(AccountP)) ?? []; + const accounts = useStoreValue("accounts", z.array(Account)) ?? []; const ret = useMemo(() => { const acc = accounts.find((x) => x.selected); return { - apiUrl: Platform.OS === "web" ? "/api" : acc?.apiUrl!, + apiUrl: acc?.apiUrl ?? "", authToken: acc?.token ?? null, selectedAccount: acc ?? null, accounts: accounts.map((account) => ({ @@ -34,13 +34,13 @@ export const AccountProvider = ({ children }: { children: ReactNode }) => { data: user, error: userError, } = useFetch({ - path: ["auth", "me"], - parser: UserP, + path: ["auth", "users", "me"], + parser: User, placeholderData: ret.selectedAccount, enabled: !!ret.selectedAccount, options: { apiUrl: ret.apiUrl, - authToken: ret.authToken?.access_token, + authToken: ret.authToken, }, }); // Use a ref here because we don't want the effect to trigger when the selected @@ -74,5 +74,7 @@ export const AccountProvider = ({ children }: { children: ReactNode }) => { queryClient.resetQueries(); }, [selectedId, queryClient]); - return {children}; + return ( + {children} + ); }; diff --git a/front/src/providers/account-store.ts b/front/src/providers/account-store.ts index 44dfc7b8..343bcafe 100644 --- a/front/src/providers/account-store.ts +++ b/front/src/providers/account-store.ts @@ -1,6 +1,6 @@ import { Platform } from "react-native"; import { z } from "zod/v4"; -import { type Account, AccountP } from "~/models"; +import { Account } from "~/models"; import { readValue, setCookie, storeValue } from "./settings"; const writeAccounts = (accounts: Account[]) => { @@ -10,12 +10,12 @@ const writeAccounts = (accounts: Account[]) => { if (!selected) return; 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); + setCookie("X-Bearer", selected?.token); } }; export const addAccount = (account: Account) => { - const accounts = readValue("accounts", z.array(AccountP)) ?? []; + const accounts = readValue("accounts", z.array(Account)) ?? []; // Prevent the user from adding the same account twice. if (accounts.find((x) => x.id === account.id)) { @@ -30,7 +30,7 @@ export const addAccount = (account: Account) => { }; export const removeAccounts = (filter: (acc: Account) => boolean) => { - let accounts = readValue("accounts", z.array(AccountP)) ?? []; + let accounts = readValue("accounts", z.array(Account)) ?? []; accounts = accounts.filter((x) => !filter(x)); if (!accounts.find((x) => x.selected) && accounts.length > 0) { accounts[0].selected = true; @@ -39,7 +39,7 @@ export const removeAccounts = (filter: (acc: Account) => boolean) => { }; export const updateAccount = (id: string, account: Account) => { - const accounts = readValue("accounts", z.array(AccountP)) ?? []; + const accounts = readValue("accounts", z.array(Account)) ?? []; const idx = accounts.findIndex((x) => x.id === id); if (idx === -1) return; diff --git a/front/src/providers/index.tsx b/front/src/providers/index.tsx index 117fb521..582c3d1c 100644 --- a/front/src/providers/index.tsx +++ b/front/src/providers/index.tsx @@ -1,6 +1,11 @@ +import { + DefaultTheme, + ThemeProvider as RNThemeProvider, +} from "@react-navigation/native"; import { HydrationBoundary, QueryClientProvider } from "@tanstack/react-query"; import { type ReactNode, useState } from "react"; -// import { useUserTheme } from "@kyoo/models"; +import { useColorScheme } from "react-native"; +import { useTheme } from "yoshiki/native"; import { ThemeSelector } from "~/primitives/theme"; import { createQueryClient } from "~/query"; import { AccountProvider } from "./account-provider"; @@ -9,41 +14,68 @@ import { ErrorProvider } from "./error-provider"; import { NativeProviders } from "./native-providers"; import { TranslationsProvider } from "./translations.native"; -function getServerData(key: string) {} +function getServerData(_key: string) {} const QueryProvider = ({ children }: { children: ReactNode }) => { const [queryClient] = useState(() => createQueryClient()); return ( - {children} + + {children} + ); }; const ThemeProvider = ({ children }: { children: ReactNode }) => { - // TODO: change "auto" and use the user's theme cookie - const userTheme = "auto"; //useUserTheme("auto"); + // we can't use "auto" here because it breaks the `RnThemeProvider` + const userTheme = useColorScheme(); return ( - + {children} ); }; +const RnTheme = ({ children }: { children: ReactNode }) => { + const theme = useTheme(); + + return ( + + {children} + + ); +}; + export const Providers = ({ children }: { children: ReactNode }) => { return ( - - - - - {children} - - - - + + + + + + {children} + + + + + ); diff --git a/front/src/providers/permissions.tsx b/front/src/providers/permissions.tsx new file mode 100644 index 00000000..b382ffe7 --- /dev/null +++ b/front/src/providers/permissions.tsx @@ -0,0 +1,17 @@ +import { useFetch } from "~/query"; +import { useAccount } from "./account-context"; +// import { ServerInfoP } from "~/models/resources/server-info"; + +// export const useHasPermission = (perms?: string[]) => { +// const account = useAccount(); +// const { data } = useFetch({ +// path: ["info"], +// parser: ServerInfoP, +// }); +// +// if (!perms || !perms[0]) return true; +// +// const available = account?.permissions ?? data?.guestPermissions; +// if (!available) return false; +// return perms.every((perm) => available.includes(perm)); +// }; diff --git a/front/src/providers/settings.ts b/front/src/providers/settings.ts index f8f7ca2c..a751f642 100644 --- a/front/src/providers/settings.ts +++ b/front/src/providers/settings.ts @@ -1,6 +1,6 @@ import { Platform } from "react-native"; import { MMKV, useMMKVString } from "react-native-mmkv"; -import type { z, ZodType } from "zod/v4"; +import type { ZodType, z } from "zod/v4"; import { getServerData } from "~/utils"; export const storage = new MMKV(); @@ -20,7 +20,9 @@ export const setCookie = (key: string, val?: unknown) => { const d = new Date(); // A year d.setTime(d.getTime() + 365 * 24 * 60 * 60 * 1000); - const expires = value ? `expires=${d.toUTCString()}` : "expires=Thu, 01 Jan 1970 00:00:01 GMT"; + const expires = value + ? `expires=${d.toUTCString()}` + : "expires=Thu, 01 Jan 1970 00:00:01 GMT"; document.cookie = `${key}=${value};${expires};path=/;samesite=strict`; }; diff --git a/front/src/query/fetch-infinite.tsx b/front/src/query/fetch-infinite.tsx index a58b551e..3ebca5a7 100644 --- a/front/src/query/fetch-infinite.tsx +++ b/front/src/query/fetch-infinite.tsx @@ -2,8 +2,8 @@ import { LegendList } from "@legendapp/list"; import { type ComponentType, type ReactElement, useRef } from "react"; import { type Breakpoint, HR, useBreakpointMap } from "~/primitives"; import { useSetError } from "~/providers/error-provider"; -import { type QueryIdentifier, useInfiniteFetch } from "~/query"; -import { ErrorView } from "../ui/errors"; +import { ErrorView } from "~/ui/errors"; +import { type QueryIdentifier, useInfiniteFetch } from "./query"; export type Layout = { numColumns: Breakpoint; diff --git a/front/src/query/query.tsx b/front/src/query/query.tsx index c731bd52..5f47016a 100644 --- a/front/src/query/query.tsx +++ b/front/src/query/query.tsx @@ -21,18 +21,22 @@ const cleanSlash = (str: string | null, keepFirst = false) => { return str.replace(/^\/|\/$/g, ""); }; -const queryFn = async (context: { +export const queryFn = async (context: { method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; url: string; body?: object; formData?: FormData; plainText?: boolean; authToken: string | null; - parser?: Parser; + parser: Parser | null; signal?: AbortSignal; }): Promise> => { - if (Platform.OS === "web" && typeof window === "undefined" && context.url.startsWith("/api")) - context.url = `${ssrApiUrl}/${context.url.substring(4)}`; + if ( + Platform.OS === "web" && + typeof window === "undefined" && + context.url.startsWith("/") + ) + context.url = `${ssrApiUrl}${context.url}`; let resp: Response; try { resp = await fetch(context.url, { @@ -44,7 +48,9 @@ const queryFn = async (context: { ? context.formData : undefined, headers: { - ...(context.authToken ? { Authorization: `Bearer ${context.authToken}` } : {}), + ...(context.authToken + ? { Authorization: `Bearer ${context.authToken}` } + : {}), ...("body" in context ? { "Content-Type": "application/json" } : {}), }, signal: context.signal, @@ -53,7 +59,10 @@ const queryFn = async (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 { + message: "Could not reach Kyoo's server.", + status: "aborted", + } as KyooError; } if (resp.status === 404) { throw { message: "Resource not found.", status: 404 } as KyooError; @@ -67,7 +76,11 @@ const queryFn = async (context: { data = { message: error } as KyooError; } data.status = resp.status; - console.log(`Invalid response (${context.method ?? "GET"} ${context.url}):`, data, resp.status); + console.log( + `Invalid response (${context.method ?? "GET"} ${context.url}):`, + data, + resp.status, + ); throw data as KyooError; } @@ -80,12 +93,22 @@ const queryFn = async (context: { data = await resp.json(); } catch (e) { console.error("Invalid json from kyoo", e); - throw { message: "Invalid response from kyoo", status: "json" } as KyooError; + throw { + message: "Invalid response from kyoo", + status: "json", + } as KyooError; } if (!context.parser) return data as any; const parsed = await context.parser.safeParseAsync(data); if (!parsed.success) { - console.log("Url: ", context.url, " Response: ", resp.status, " Parse error: ", parsed.error); + console.log( + "Url: ", + context.url, + " Response: ", + resp.status, + " Parse error: ", + parsed.error, + ); throw { status: "parse", message: @@ -109,9 +132,11 @@ export const createQueryClient = () => }); export type QueryIdentifier = { - parser: z.ZodType; + parser: z.ZodType | null; path: (string | undefined)[]; - params?: { [query: string]: boolean | number | string | string[] | undefined }; + params?: { + [query: string]: boolean | number | string | string[] | undefined; + }; infinite?: boolean; placeholderData?: T | (() => T); @@ -124,7 +149,9 @@ export type QueryIdentifier = { const toQueryKey = (query: { apiUrl: string; path: (string | undefined)[]; - params?: { [query: string]: boolean | number | string | string[] | undefined }; + params?: { + [query: string]: boolean | number | string | string[] | undefined; + }; }) => { return [ cleanSlash(query.apiUrl, true), @@ -172,7 +199,7 @@ export const useInfiniteFetch = (query: QueryIdentifier) => { queryFn: (ctx) => queryFn({ url: (ctx.pageParam as string) ?? keyToUrl(key), - parser: Paged(query.parser), + parser: query.parser ? Paged(query.parser) : null, signal: ctx.signal, authToken: authToken ?? null, ...query.options, @@ -207,7 +234,7 @@ export const prefetch = async (...queries: QueryIdentifier[]) => { queryFn: (ctx) => queryFn({ url: keyToUrl(key), - parser: Paged(query.parser), + parser: query.parser ? Paged(query.parser) : null, signal: ctx.signal, authToken: authToken ?? null, ...query.options, @@ -235,7 +262,9 @@ export const prefetch = async (...queries: QueryIdentifier[]) => { type MutationParams = { method?: "POST" | "PUT" | "DELETE"; path?: string[]; - params?: { [query: string]: boolean | number | string | string[] | undefined }; + params?: { + [query: string]: boolean | number | string | string[] | undefined; + }; body?: object; }; @@ -261,6 +290,7 @@ export const useMutation = ({ url: keyToUrl(toQueryKey({ apiUrl, path, params })), body, authToken, + parser: null, }); }, onSuccess: invalidate diff --git a/front/src/ui/browse/index.tsx b/front/src/ui/browse/index.tsx index 4cbfab1f..d438754c 100644 --- a/front/src/ui/browse/index.tsx +++ b/front/src/ui/browse/index.tsx @@ -45,7 +45,7 @@ BrowsePage.query = ( ): QueryIdentifier => { return { parser: Show, - path: ["shows"], + path: ["api", "shows"], infinite: true, params: { sort: sortBy ? `${sortOrd === "desc" ? "-" : ""}${sortBy}` : "name", diff --git a/front/src/ui/errors/index.tsx b/front/src/ui/errors/index.tsx index a8839645..dfff3cf7 100644 --- a/front/src/ui/errors/index.tsx +++ b/front/src/ui/errors/index.tsx @@ -5,8 +5,8 @@ import { OfflineView } from "./offline"; // import { SetupPage } from "./setup"; import { Unauthorized } from "./unauthorized"; -export * from "./error"; export * from "./empty"; +export * from "./error"; export type ErrorHandler = { view: FC<{ error: KyooError; retry: () => void }>; @@ -16,6 +16,6 @@ export type ErrorHandler = { export const errorHandlers: Record = { unauthorized: { view: Unauthorized, forbid: "app" }, // setup: { view: SetupPage, forbid: "setup" }, - connection: { view: ConnectionError }, + connection: { view: ConnectionError, forbid: "login" }, offline: { view: OfflineView }, }; diff --git a/front/src/ui/login/form.tsx b/front/src/ui/login/form.tsx index 727b1774..2e0d9322 100644 --- a/front/src/ui/login/form.tsx +++ b/front/src/ui/login/form.tsx @@ -1,9 +1,8 @@ -import { imageFn } from "@kyoo/models"; -import { ts } from "@kyoo/primitives"; import type { ReactNode } from "react"; -import { ImageBackground, type ImageProps, Platform, ScrollView, View } from "react-native"; -import Svg, { type SvgProps, Path } from "react-native-svg"; -import { type Stylable, min, px, useYoshiki, vh } from "yoshiki/native"; +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"; const SvgBlob = (props: SvgProps) => { const { css, theme } = useYoshiki(); @@ -27,18 +26,9 @@ export const FormPage = ({ }: { children: ReactNode; apiUrl?: string } & Stylable) => { const { css } = useYoshiki(); - const src = apiUrl ? `${apiUrl}/items/random/thumbnail` : imageFn("/items/random/thumbnail"); - const nativeProps = Platform.select>({ - web: { - defaultSource: { uri: src }, - }, - default: {}, - }); - return ( = export const login = async ( action: "register" | "login", - { apiUrl, ...body }: { username: string; password: string; email?: string; apiUrl?: string }, + { + apiUrl, + ...body + }: { + login?: string; + username?: string; + password: string; + email?: string; + apiUrl: string | null; + }, ): Promise> => { - if (!apiUrl || apiUrl.length === 0) apiUrl = getCurrentApiUrl()!; + apiUrl ??= ""; try { const controller = new AbortController(); setTimeout(() => controller.abort(), 5_000); - const token = await queryFn( - { - path: ["auth", action], - method: "POST", - body, - authenticated: false, - apiUrl, - signal: controller.signal, - }, - TokenP, - ); + const { token } = await queryFn({ + method: "POST", + url: `${apiUrl}/auth/${action === "login" ? "sessions" : "users"}`, + body, + authToken: null, + signal: controller.signal, + parser: z.object({ token: z.string() }), + }); const user = await queryFn({ method: "GET", - path: ["auth", "me"], - apiUrl, - authToken: token.access_token, - parser: UserP, + url: `${apiUrl}/auth/users/me`, + authToken: token, + parser: User, }); - const account: Account = { ...user, apiUrl: apiUrl, token, selected: true }; + const account: Account = { ...user, apiUrl, token, selected: true }; addAccount(account); return { ok: true, value: account }; } catch (e) { console.error(action, e); - return { ok: false, error: (e as KyooErrors).errors[0] }; + return { ok: false, error: (e as KyooError).message }; } }; -export const oidcLogin = async (provider: string, code: string, apiUrl?: string) => { - if (!apiUrl || apiUrl.length === 0) apiUrl = getCurrentApiUrl()!; - try { - const token = await queryFn( - { - path: ["auth", "callback", provider, `?code=${code}`], - method: "POST", - authenticated: false, - apiUrl, - }, - TokenP, - ); - const user = await queryFn( - { path: ["auth", "me"], method: "GET", apiUrl }, - UserP, - `Bearer ${token.access_token}`, - ); - const account: Account = { ...user, apiUrl: apiUrl, token, selected: true }; - addAccount(account); - return { ok: true, value: account }; - } catch (e) { - console.error("oidcLogin", e); - return { ok: false, error: (e as KyooErrors).errors[0] }; - } -}; +// export const oidcLogin = async ( +// provider: string, +// code: string, +// apiUrl?: string, +// ) => { +// if (!apiUrl || apiUrl.length === 0) apiUrl = getCurrentApiUrl()!; +// try { +// const token = await queryFn( +// { +// path: ["auth", "callback", provider, `?code=${code}`], +// method: "POST", +// authenticated: false, +// apiUrl, +// }, +// TokenP, +// ); +// const user = await queryFn( +// { path: ["auth", "me"], method: "GET", apiUrl }, +// UserP, +// `Bearer ${token.access_token}`, +// ); +// const account: Account = { ...user, apiUrl: apiUrl, token, selected: true }; +// addAccount(account); +// return { ok: true, value: account }; +// } catch (e) { +// console.error("oidcLogin", e); +// return { ok: false, error: (e as KyooErrors).errors[0] }; +// } +// }; export const logout = () => { removeAccounts((x) => x.selected); }; export const deleteAccount = async () => { - await queryFn({ path: ["auth", "me"], method: "DELETE" }); + // await queryFn({ + // method: "DELETE", + // url: "auth/users/me", + // parser: null, + // }); logout(); }; diff --git a/front/src/ui/login/login.tsx b/front/src/ui/login/login.tsx index d95ebd9b..94c6a00d 100644 --- a/front/src/ui/login/login.tsx +++ b/front/src/ui/login/login.tsx @@ -1,89 +1,75 @@ -import { useEffect, useState } from "react"; +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 { type QueryPage, login } from "~/models"; import { A, Button, H1, Input, P, ts } from "~/primitives"; +import { useQueryState } from "~/utils"; import { FormPage } from "./form"; -import { OidcLogin } from "./oidc"; +import { login } from "./logic"; import { PasswordInput } from "./password-input"; +import { ServerUrlPage } from "./server-url"; -export const LoginPage: QueryPage<{ apiUrl?: string; error?: string }> = ({ - apiUrl, - error: initialError, -}) => { - const { data } = useFetch({ - path: ["info"], - parser: ServerInfoP, - }); - +export const LoginPage = () => { + const [apiUrl] = useQueryState("apiUrl", null); const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); - const [error, setError] = useState(initialError); + const [error, setError] = useState(); - const router = useRouter(); const { t } = useTranslation(); const { css } = useYoshiki(); + const router = useRouter(); - useEffect(() => { - if (!apiUrl && Platform.OS !== "web") - router.replace("/server-url", undefined, { - experimental: { nativeBehavior: "stack-replace", isNestedNavigator: false }, - }); - }, [apiUrl, router]); + if (Platform.OS !== "web" && !apiUrl) return ; return ( - +

{t("login.login")}

- - {data?.passwordLoginEnabled && ( - <> -

{t("login.username")}

- setUsername(value)} - autoCapitalize="none" - /> -

{t("login.password")}

- setPassword(value)} - /> - {error &&

theme.colors.red })}>{error}

} -