Add error consumer for unauthorized

This commit is contained in:
Zoe Roux 2025-02-08 00:05:29 +01:00
parent b4144e61d3
commit 6cfeba6c62
No known key found for this signature in database
8 changed files with 99 additions and 62 deletions

View File

@ -0,0 +1,10 @@
import { Slot } from "one";
import { ErrorConsumer } from "~/providers/error-provider";
export default function Layout() {
return (
<ErrorConsumer scope="app">
<Slot />
</ErrorConsumer>
);
}

2
front/routes.d.ts vendored
View File

@ -6,7 +6,7 @@ import type { OneRouter } from 'one'
declare module 'one' {
export namespace OneRouter {
export interface __routes<T extends string = string> extends Record<string, unknown> {
StaticRoutes: `/` | `/_sitemap`
StaticRoutes: `/` | `/(app)` | `/_sitemap`
DynamicRoutes: never
DynamicRouteTemplate: never
IsTyped: true

View File

@ -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 <AccountContext.Provider value={ret}>{children}</AccountContext.Provider>;
};
export const useAccount = () => {
const { selectedAccount } = useContext(AccountContext);
return selectedAccount;
};

View File

@ -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<Error | null>(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<Error | null>(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 (
<ErrorContext.Provider
value={{
error,
setError,
}}
>
{children}
</ErrorContext.Provider>
<ErrorSetterContext.Provider value={provider}>
<ErrorContext.Provider value={error}>{children}</ErrorContext.Provider>
</ErrorSetterContext.Provider>
);
};
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 <Handler {...(error as any)} />;
const { key, ...val } = error;
return <Handler {...(val as any)} />;
};
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<Error, "key"> & { 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;
};

View File

@ -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 = <Data,>({
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 <ErrorView error={error} />;
}
if (isPaused) {
setError({ key: "offline" });
}
if (!data) return <Loader />;
return <Render {...data} />;
};

View File

@ -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 <Parser extends z.ZodTypeAny>(context: {
parser?: Parser;
signal: AbortSignal;
}): Promise<z.infer<Parser>> => {
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 <Parser extends z.ZodTypeAny>(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;

View File

@ -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();