mirror of
https://github.com/zoriya/Kyoo.git
synced 2025-07-08 18:54:22 -04:00
wip: Account/login rework
This commit is contained in:
parent
7211699e87
commit
f76b2732a1
@ -38,26 +38,6 @@ import { useFetch } from "./query";
|
|||||||
import { ServerInfoP, type User, UserP } from "./resources";
|
import { ServerInfoP, type User, UserP } from "./resources";
|
||||||
import { zdate } from "./utils";
|
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 defaultApiUrl = Platform.OS === "web" ? "/api" : null;
|
||||||
const currentApiUrl = atom<string | null>(defaultApiUrl);
|
const currentApiUrl = atom<string | null>(defaultApiUrl);
|
||||||
export const getCurrentApiUrl = () => {
|
export const getCurrentApiUrl = () => {
|
||||||
@ -72,138 +52,6 @@ export const setSsrApiUrl = () => {
|
|||||||
store.set(currentApiUrl, process.env.KYOO_URL ?? "http://localhost:5000");
|
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 = () => {
|
export const useAccount = () => {
|
||||||
const acc = useContext(AccountContext);
|
const acc = useContext(AccountContext);
|
||||||
@ -227,3 +75,4 @@ export const useHasPermission = (perms?: string[]) => {
|
|||||||
if (!available) return false;
|
if (!available) return false;
|
||||||
return perms.every((perm) => available.includes(perm));
|
return perms.every((perm) => available.includes(perm));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
23
front/src/models/resources/account.ts
Normal file
23
front/src/models/resources/account.ts
Normal 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>;
|
@ -1,3 +1,4 @@
|
|||||||
|
export * from "./account";
|
||||||
export * from "./library-item";
|
export * from "./library-item";
|
||||||
export * from "./news";
|
export * from "./news";
|
||||||
export * from "./show";
|
export * from "./show";
|
||||||
|
121
front/src/providers/account-provider.tsx
Normal file
121
front/src/providers/account-provider.tsx
Normal 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>;
|
||||||
|
};
|
@ -122,7 +122,9 @@ export type QueryIdentifier<T = unknown, Ret = T> = {
|
|||||||
|
|
||||||
placeholderData?: T | (() => T);
|
placeholderData?: T | (() => T);
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
options?: Partial<Parameters<typeof queryFn>[0]>;
|
options?: Partial<Parameters<typeof queryFn>[0]> & {
|
||||||
|
apiUrl?: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type QueryPage<Props = {}, Items = unknown> = ComponentType<
|
export type QueryPage<Props = {}, Items = unknown> = ComponentType<
|
||||||
|
Loading…
x
Reference in New Issue
Block a user