wip: Account/login rework

This commit is contained in:
Zoe Roux 2025-02-03 00:55:26 +01:00
parent 7211699e87
commit f76b2732a1
No known key found for this signature in database
5 changed files with 149 additions and 153 deletions

View File

@ -38,26 +38,6 @@ import { useFetch } from "./query";
import { ServerInfoP, type User, UserP } from "./resources";
import { zdate } from "./utils";
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<typeof TokenP>;
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<typeof AccountP>;
const defaultApiUrl = Platform.OS === "web" ? "/api" : null;
const currentApiUrl = atom<string | null>(defaultApiUrl);
export const getCurrentApiUrl = () => {
@ -72,138 +52,6 @@ export const setSsrApiUrl = () => {
store.set(currentApiUrl, process.env.KYOO_URL ?? "http://localhost:5000");
};
const AccountContext = createContext<(Account & { select: () => void; remove: () => void })[]>([]);
export const ConnectionErrorContext = createContext<{
error: KyooErrors | null;
loading: boolean;
retry?: () => void;
setError: (error: KyooErrors) => void;
}>({ error: null, loading: true, setError: () => {} });
export const AccountProvider = ({
children,
ssrAccount,
ssrError,
}: {
children: ReactNode;
ssrAccount?: Account;
ssrError?: KyooErrors;
}) => {
const setApiUrl = useSetAtom(currentApiUrl);
if (Platform.OS === "web" && typeof window === "undefined") {
const accs = ssrAccount
? [{ ...ssrAccount, selected: true, select: () => {}, remove: () => {} }]
: [];
return (
<AccountContext.Provider value={accs}>
<ConnectionErrorContext.Provider
value={{
error: ssrError || null,
loading: false,
retry: () => {
queryClient.resetQueries({ queryKey: ["auth", "me"] });
},
setError: () => {},
}}
>
{children}
</ConnectionErrorContext.Provider>
</AccountContext.Provider>
);
}
const initialSsrError = useRef(ssrError);
const [accStr] = useMMKVString("accounts");
const acc = accStr ? z.array(AccountP).parse(JSON.parse(accStr)) : null;
const accounts = useMemo(
() =>
acc?.map((account) => ({
...account,
select: () => updateAccount(account.id, { ...account, selected: true }),
remove: () => removeAccounts((x) => x.id === account.id),
})) ?? [],
[acc],
);
// 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 {
isSuccess: userIsSuccess,
isError: userIsError,
isLoading: userIsLoading,
isPlaceholderData: userIsPlaceholder,
data: user,
error: userError,
} = useFetch({
path: ["auth", "me"],
parser: UserP,
placeholderData: selected as User,
enabled: !!selected,
});
// 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
const selectedRef = useRef(selected);
selectedRef.current = selected;
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;
const nUser = { ...selectedRef.current, ...user };
updateAccount(nUser.id, nUser);
}, [user, userIsSuccess, userIsPlaceholder]);
const queryClient = useQueryClient();
const oldSelected = useRef<{ id: string; token: string } | null>(
selected ? { id: selected.id, token: selected.token.access_token } : null,
);
const [permissionError, setPermissionError] = useState<KyooErrors | null>(null);
useEffect(() => {
// if the user change account (or connect/disconnect), reset query cache.
if (
// biome-ignore lint/suspicious/noDoubleEquals: id can be an id, null or undefined
selected?.id != oldSelected.current?.id ||
(userIsError && selected?.token.access_token !== oldSelected.current?.token)
) {
initialSsrError.current = undefined;
setPermissionError(null);
queryClient.resetQueries();
}
oldSelected.current = selected ? { id: selected.id, token: selected.token.access_token } : null;
// 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, userIsError]);
return (
<AccountContext.Provider value={accounts}>
<ConnectionErrorContext.Provider
value={{
error: (selected ? (initialSsrError.current ?? userError) : null) ?? permissionError,
loading: userIsLoading,
retry: () => {
queryClient.invalidateQueries({ queryKey: ["auth", "me"] });
},
setError: setPermissionError,
}}
>
{children}
</ConnectionErrorContext.Provider>
</AccountContext.Provider>
);
};
export const useAccount = () => {
const acc = useContext(AccountContext);
@ -227,3 +75,4 @@ export const useHasPermission = (perms?: string[]) => {
if (!available) return false;
return perms.every((perm) => available.includes(perm));
};

View File

@ -0,0 +1,23 @@
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<typeof TokenP>;
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<typeof AccountP>;

View File

@ -1,3 +1,4 @@
export * from "./account";
export * from "./library-item";
export * from "./news";
export * from "./show";

View File

@ -0,0 +1,121 @@
import { type ReactNode, createContext, useEffect, useMemo } from "react";
import { Platform } from "react-native";
import { type Account, type Token, type User, UserP } from "~/models";
import { useFetch } from "~/query";
import { useSetError } from "./error-provider";
const AccountContext = createContext<{
apiUrl: string;
authToken: Token;
selectedAccount?: Account;
accounts: (Account & { select: () => void; remove: () => void })[];
}>(undefined!);
export const AccountProvider = ({
children,
ssrAccount,
}: {
children: ReactNode;
ssrAccount?: Account;
}) => {
if (Platform.OS === "web" && typeof window === "undefined") {
const accounts = ssrAccount
? [{ ...ssrAccount, selected: true, select: () => {}, remove: () => {} }]
: [];
return (
<AccountContext.Provider value={{ ...ssrAccount, accounts }}>
{children}
</AccountContext.Provider>
);
}
const setError = useSetError();
const [accStr] = useMMKVString("accounts");
const accounts = accStr ? z.array(AccountP).parse(JSON.parse(accStr)) : null;
const ret = useMemo(() => {
const acc = accounts.find((x) => x.selected);
return {
apiUrl: acc.apiUrl,
authToken: acc.token,
selectedAccount: acc,
accounts:
accounts?.map((account) => ({
...account,
select: () => updateAccount(account.id, { ...account, selected: true }),
remove: () => removeAccounts((x) => x.id === account.id),
})) ?? [],
};
}, [accounts]);
// update user's data from kyoo on startup, it could have changed.
const {
isSuccess: userIsSuccess,
isError: userIsError,
isLoading: userIsLoading,
isPlaceholderData: userIsPlaceholder,
data: user,
error: userError,
} = useFetch({
path: ["auth", "me"],
parser: UserP,
placeholderData: ret.selectedAccount,
enabled: ret.selectedAccount,
options: {
apiUrl: ret.apiUrl,
authToken: ret.authToken,
},
});
// 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
const selectedRef = useRef(selected);
selectedRef.current = selected;
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;
const nUser = { ...selectedRef.current, ...user };
updateAccount(nUser.id, nUser);
}, [user, userIsSuccess, userIsPlaceholder]);
const queryClient = useQueryClient();
const oldSelected = useRef<{ id: string; token: string } | null>(
selected ? { id: selected.id, token: selected.token.access_token } : null,
);
const [permissionError, setPermissionError] = useState<KyooErrors | null>(null);
useEffect(() => {
// if the user change account (or connect/disconnect), reset query cache.
if (
// biome-ignore lint/suspicious/noDoubleEquals: id can be an id, null or undefined
selected?.id != oldSelected.current?.id ||
(userIsError && selected?.token.access_token !== oldSelected.current?.token)
) {
setPermissionError(null);
queryClient.resetQueries();
}
oldSelected.current = selected ? { id: selected.id, token: selected.token.access_token } : null;
// 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, userIsError]);
// <ConnectionErrorContext.Provider
// value={{
// error: (selected ? (userError) : null) ?? permissionError,
// loading: userIsLoading,
// retry: () => {
// queryClient.invalidateQueries({ queryKey: ["auth", "me"] });
// },
// setError: setPermissionError,
// }}
return <AccountContext.Provider value={ret}>{children}</AccountContext.Provider>;
};

View File

@ -122,7 +122,9 @@ export type QueryIdentifier<T = unknown, Ret = T> = {
placeholderData?: T | (() => T);
enabled?: boolean;
options?: Partial<Parameters<typeof queryFn>[0]>;
options?: Partial<Parameters<typeof queryFn>[0]> & {
apiUrl?: string;
};
};
export type QueryPage<Props = {}, Items = unknown> = ComponentType<