From 6cfeba6c6216912efccf1adb17ab449a0f566659 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sat, 8 Feb 2025 00:05:29 +0100 Subject: [PATCH] Add error consumer for unauthorized --- front/app/(app)/_layout.tsx | 10 ++++ front/app/{ => (app)}/index.tsx | 0 front/routes.d.ts | 2 +- front/src/providers/account-provider.tsx | 16 ++++--- front/src/providers/error-provider.tsx | 56 ++++++++++++++-------- front/src/query/fetch.tsx | 15 +++--- front/src/query/query.tsx | 61 +++++++++++++----------- front/src/ui/errors/unauthorized.tsx | 1 + 8 files changed, 99 insertions(+), 62 deletions(-) create mode 100644 front/app/(app)/_layout.tsx rename front/app/{ => (app)}/index.tsx (100%) diff --git a/front/app/(app)/_layout.tsx b/front/app/(app)/_layout.tsx new file mode 100644 index 00000000..b43d4dec --- /dev/null +++ b/front/app/(app)/_layout.tsx @@ -0,0 +1,10 @@ +import { Slot } from "one"; +import { ErrorConsumer } from "~/providers/error-provider"; + +export default function Layout() { + return ( + + + + ); +} diff --git a/front/app/index.tsx b/front/app/(app)/index.tsx similarity index 100% rename from front/app/index.tsx rename to front/app/(app)/index.tsx diff --git a/front/routes.d.ts b/front/routes.d.ts index f2889912..103b2b71 100644 --- a/front/routes.d.ts +++ b/front/routes.d.ts @@ -6,7 +6,7 @@ import type { OneRouter } from 'one' declare module 'one' { export namespace OneRouter { export interface __routes extends Record { - StaticRoutes: `/` | `/_sitemap` + StaticRoutes: `/` | `/(app)` | `/_sitemap` DynamicRoutes: never DynamicRouteTemplate: never IsTyped: true diff --git a/front/src/providers/account-provider.tsx b/front/src/providers/account-provider.tsx index 58bc6aaa..94739b15 100644 --- a/front/src/providers/account-provider.tsx +++ b/front/src/providers/account-provider.tsx @@ -1,5 +1,5 @@ import { useQueryClient } from "@tanstack/react-query"; -import { type ReactNode, createContext, useEffect, useMemo, useRef } from "react"; +import { type ReactNode, createContext, useContext, useEffect, useMemo, useRef } from "react"; import { Platform } from "react-native"; import { z } from "zod"; import { type Account, AccountP, type Token, UserP } from "~/models"; @@ -8,14 +8,12 @@ import { removeAccounts, updateAccount } from "./account-store"; import { useSetError } from "./error-provider"; import { useStoreValue } from "./settings"; -export const ssrApiUrl = process.env.KYOO_URL ?? "http://back/api"; - export const AccountContext = createContext<{ apiUrl: string; authToken: Token | null; selectedAccount: Account | null; accounts: (Account & { select: () => void; remove: () => void })[]; -}>({ apiUrl: ssrApiUrl, authToken: null, selectedAccount: null, accounts: [] }); +}>({ apiUrl: "/api", authToken: null, selectedAccount: null, accounts: [] }); export const AccountProvider = ({ children }: { children: ReactNode }) => { const [setError, clearError] = useSetError("account"); @@ -26,7 +24,7 @@ export const AccountProvider = ({ children }: { children: ReactNode }) => { return { apiUrl: Platform.OS === "web" ? "/api" : acc?.apiUrl!, authToken: acc?.token ?? null, - selectedAccount: acc, + selectedAccount: acc ?? null, accounts: accounts.map((account) => ({ ...account, select: () => updateAccount(account.id, { ...account, selected: true }), @@ -51,6 +49,7 @@ export const AccountProvider = ({ children }: { children: ReactNode }) => { authToken: ret.authToken?.access_token, }, }); + console.log(user); // Use a ref here because we don't want the effect to trigger when the selected // value has changed, only when the fetch result changed // If we trigger the effect when the selected value change, we enter an infinite render loop @@ -59,7 +58,7 @@ export const AccountProvider = ({ children }: { children: ReactNode }) => { useEffect(() => { if (!selectedRef.current || !userIsSuccess || userIsPlaceholder) return; // The id is different when user is stale data, we need to wait for the use effect to invalidate the query. - if (user.id !== selectedRef.current.id) return; + if (user?.id !== selectedRef.current.id) return; const nUser = { ...selectedRef.current, ...user }; updateAccount(nUser.id, nUser); }, [user, userIsSuccess, userIsPlaceholder]); @@ -83,3 +82,8 @@ export const AccountProvider = ({ children }: { children: ReactNode }) => { return {children}; }; + +export const useAccount = () => { + const { selectedAccount } = useContext(AccountContext); + return selectedAccount; +}; diff --git a/front/src/providers/error-provider.tsx b/front/src/providers/error-provider.tsx index 901e9c02..cc0f73e1 100644 --- a/front/src/providers/error-provider.tsx +++ b/front/src/providers/error-provider.tsx @@ -1,4 +1,12 @@ -import { type ReactNode, createContext, useContext, useState } from "react"; +import { + type ReactNode, + createContext, + useCallback, + useContext, + useMemo, + useRef, + useState, +} from "react"; import type { KyooError } from "~/models"; import { ErrorView, errorHandlers } from "~/ui/errors"; @@ -8,41 +16,49 @@ type Error = { retry?: () => void; }; -const ErrorContext = createContext<{ - error: Error | null; +const ErrorContext = createContext(null); +const ErrorSetterContext = createContext<{ setError: (error: Error | null) => void; -}>({ error: null, setError: () => {} }); + clearError: (key: string) => void; +}>(null!); export const ErrorProvider = ({ children }: { children: ReactNode }) => { const [error, setError] = useState(null); + const currentKey = useRef(error?.key); + currentKey.current = error?.key; + const clearError = useCallback((key: string) => { + if (key === currentKey.current) setError(null); + }, []); + const provider = useMemo( + () => ({ + setError, + clearError, + }), + [clearError], + ); + return ( - - {children} - + + {children} + ); }; export const ErrorConsumer = ({ children, scope }: { children: ReactNode; scope: string }) => { - const { error } = useContext(ErrorContext); + const error = useContext(ErrorContext); if (!error) return children; const handler = errorHandlers[error.key] ?? { view: ErrorView }; if (handler.forbid && handler.forbid !== scope) return children; const Handler = handler.view; - return ; + const { key, ...val } = error; + return ; }; export const useSetError = (key: string) => { - const { error, setError } = useContext(ErrorContext); - const set = ({ key: nKey, ...obj }: Error & { key?: Error["key"] }) => + const { setError, clearError } = useContext(ErrorSetterContext); + const set = ({ key: nKey, ...obj }: Omit & { key?: Error["key"] }) => setError({ key: nKey ?? key, ...obj }); - const clearError = () => { - if (error?.key === key) setError(null); - }; - return [set, clearError] as const; + const clear = () => clearError(key); + return [set, clear] as const; }; diff --git a/front/src/query/fetch.tsx b/front/src/query/fetch.tsx index e41ff10a..f73c6330 100644 --- a/front/src/query/fetch.tsx +++ b/front/src/query/fetch.tsx @@ -1,4 +1,4 @@ -import type { ReactElement } from "react"; +import { useLayoutEffect, type ReactElement } from "react"; import { useSetError } from "~/providers/error-provider"; import { ErrorView } from "~/ui/errors"; import { type QueryIdentifier, useFetch } from "./query"; @@ -15,15 +15,18 @@ export const Fetch = ({ const { data, isPaused, error } = useFetch(query); const [setError] = useSetError("fetch"); - if (error) { - if (error.status === 401 || error.status === 403) { + useLayoutEffect(() => { + if (isPaused) { + setError({ key: "offline" }); + } + if (error && (error.status === 401 || error.status === 403)) { setError({ key: "unauthorized", error }); } + }, [error, isPaused]); + + if (error) { return ; } - if (isPaused) { - setError({ key: "offline" }); - } if (!data) return ; return ; }; diff --git a/front/src/query/query.tsx b/front/src/query/query.tsx index ace2d699..3cf6ae41 100644 --- a/front/src/query/query.tsx +++ b/front/src/query/query.tsx @@ -1,9 +1,12 @@ import { QueryClient, dehydrate, useInfiniteQuery, useQuery } from "@tanstack/react-query"; import { setServerData } from "one"; import { useContext } from "react"; +import { Platform } from "react-native"; import type { z } from "zod"; import { type KyooError, type Page, Paged } from "~/models"; -import { AccountContext, ssrApiUrl } from "~/providers/account-provider"; +import { AccountContext } from "~/providers/account-provider"; + +const ssrApiUrl = process.env.KYOO_URL ?? "http://back/api"; const cleanSlash = (str: string | null, keepFirst = false) => { if (!str) return null; @@ -21,6 +24,8 @@ export const queryFn = async (context: { parser?: Parser; signal: AbortSignal; }): Promise> => { + if (Platform.OS === "web" && typeof window === "undefined" && context.url.startsWith("/api")) + context.url = `${ssrApiUrl}/${context.url.substring(4)}`; let resp: Response; try { resp = await fetch(context.url, { @@ -55,11 +60,7 @@ export const queryFn = async (context: { data = { message: error } as KyooError; } data.status = resp.status; - console.trace( - `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; } @@ -199,39 +200,41 @@ export const prefetch = async (...queries: QueryIdentifier[]) => { const authToken = undefined; await Promise.all( - queries.map((query) => { - const key = toQueryKey({ - apiUrl: ssrApiUrl, - path: query.path, - params: query.params, - }); + queries + .filter((x) => x.enabled !== false) + .map((query) => { + const key = toQueryKey({ + apiUrl: ssrApiUrl, + path: query.path, + params: query.params, + }); - if (query.infinite) { - return client.prefetchInfiniteQuery({ + if (query.infinite) { + return client.prefetchInfiniteQuery({ + queryKey: key, + queryFn: (ctx) => + queryFn({ + url: key.join("/").replace("/?", "?"), + parser: Paged(query.parser), + signal: ctx.signal, + authToken: authToken?.access_token ?? null, + ...query.options, + }), + initialPageParam: undefined, + }); + } + return client.prefetchQuery({ queryKey: key, queryFn: (ctx) => queryFn({ url: key.join("/").replace("/?", "?"), - parser: Paged(query.parser), + parser: query.parser, signal: ctx.signal, authToken: authToken?.access_token ?? null, ...query.options, }), - initialPageParam: undefined, }); - } - return client.prefetchQuery({ - queryKey: key, - queryFn: (ctx) => - queryFn({ - url: key.join("/").replace("/?", "?"), - parser: query.parser, - signal: ctx.signal, - authToken: authToken?.access_token ?? null, - ...query.options, - }), - }); - }), + }), ); setServerData("queryState", dehydrate(client)); return client; diff --git a/front/src/ui/errors/unauthorized.tsx b/front/src/ui/errors/unauthorized.tsx index 57f9069c..a7d4737c 100644 --- a/front/src/ui/errors/unauthorized.tsx +++ b/front/src/ui/errors/unauthorized.tsx @@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next"; import { View } from "react-native"; import { useYoshiki } from "yoshiki/native"; import { Button, Icon, Link, P, ts } from "~/primitives"; +import { useAccount } from "~/providers/account-provider"; export const Unauthorized = ({ missing }: { missing: string[] }) => { const { t } = useTranslation();