Rework login and navbar account display

This commit is contained in:
Zoe Roux 2023-12-01 18:38:12 +01:00
parent fbf43fb10f
commit 0ac388f3eb
4 changed files with 110 additions and 148 deletions

View File

@ -18,102 +18,74 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>. * along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { z } from "zod";
import { deleteSecureItem, getSecureItem, setSecureItem } from "./secure-store";
import { zdate } from "./utils";
import { queryFn } from "./query"; import { queryFn } from "./query";
import { KyooErrors } from "./kyoo-errors"; import { KyooErrors } from "./kyoo-errors";
import { Platform } from "react-native"; import { Account, Token, TokenP } from "./accounts";
import { UserP } from "./resources";
const TokenP = z.object({ import { addAccount, getCurrentAccount, removeAccounts, updateAccount } from "./account-internal";
token_type: z.literal("Bearer"),
access_token: z.string(),
refresh_token: z.string(),
expire_in: z.string(),
expire_at: zdate(),
});
type Token = z.infer<typeof TokenP>;
type Result<A, B> = type Result<A, B> =
| { ok: true; value: A; error?: undefined } | { ok: true; value: A; error?: undefined }
| { ok: false; value?: undefined; error: B }; | { ok: false; value?: undefined; error: B };
export type Account = Token & { apiUrl: string; username: string }; export const login = async (
action: "register" | "login",
const addAccount = (token: Token, apiUrl: string, username: string | null) => { { apiUrl, ...body }: { username: string; password: string; email?: string; apiUrl?: string },
const accounts: Account[] = JSON.parse(getSecureItem("accounts") ?? "[]");
if (accounts.find((x) => x.username === username && x.apiUrl === apiUrl)) return;
accounts.push({ ...token, username: username!, apiUrl });
setSecureItem("accounts", JSON.stringify(accounts));
setSecureItem("selected", (accounts.length - 1).toString());
};
const setCurrentAccountToken = (token: Token) => {
const accounts: Account[] = JSON.parse(getSecureItem("accounts") ?? "[]");
const selected = parseInt(getSecureItem("selected") ?? "0");
if (selected >= accounts.length) return;
accounts[selected] = { ...accounts[selected], ...token };
setSecureItem("accounts", JSON.stringify(accounts));
};
export const loginFunc = async (
action: "register" | "login" | "refresh",
body: { username: string; password: string; email?: string } | string,
apiUrl?: string,
timeout?: number, timeout?: number,
): Promise<Result<Token, string>> => { ): Promise<Result<Account, string>> => {
try { try {
const controller = timeout !== undefined ? new AbortController() : undefined;
if (controller) setTimeout(() => controller.abort(), timeout);
const token = await queryFn( const token = await queryFn(
{ {
path: ["auth", action, typeof body === "string" && `?token=${body}`], path: ["auth", action],
method: typeof body === "string" ? "GET" : "POST", method: "POST",
body: typeof body === "object" ? body : undefined, body,
authenticated: false, authenticated: false,
apiUrl, apiUrl,
abortSignal: controller?.signal, timeout,
}, },
TokenP, TokenP,
); );
const user = await queryFn(
if (typeof window !== "undefined") setSecureItem("auth", JSON.stringify(token)); { path: ["auth", "me"], method: "GET", apiUrl },
if (Platform.OS !== "web" && apiUrl && typeof body !== "string") UserP,
addAccount(token, apiUrl, body.username); token.access_token,
else if (Platform.OS !== "web" && action === "refresh") setCurrentAccountToken(token); );
return { ok: true, value: token }; const account: Account = { ...user, apiUrl: apiUrl ?? "/api", token, selected: true };
addAccount(account);
return { ok: true, value: account };
} catch (e) { } catch (e) {
console.error(action, e); console.error(action, e);
return { ok: false, error: (e as KyooErrors).errors[0] }; return { ok: false, error: (e as KyooErrors).errors[0] };
} }
}; };
export const getTokenWJ = async (cookies?: string): Promise<[string, Token] | [null, null]> => { export const getTokenWJ = async (account?: Account | null): Promise<[string, Token] | [null, null]> => {
const tokenStr = getSecureItem("auth", cookies); if (account === null)
if (!tokenStr) return [null, null]; account = getCurrentAccount();
let token = TokenP.parse(JSON.parse(tokenStr)); if (!account) return [null, null];
if (token.expire_at <= new Date(new Date().getTime() + 10 * 1000)) { if (account.token.expire_at <= new Date(new Date().getTime() + 10 * 1000)) {
const { ok, value: nToken, error } = await loginFunc("refresh", token.refresh_token); try {
if (!ok) console.error("Error refreshing token durring ssr:", error); const token = await queryFn(
else token = nToken; {
path: ["auth", "refresh", `?token=${account.token.refresh_token}`],
method: "GET",
},
TokenP,
);
updateAccount(account.id, { ...account, token });
} catch (e) {
console.error("Error refreshing token durring ssr:", e);
}
} }
return [`${token.token_type} ${token.access_token}`, token]; return [`${account.token.token_type} ${account.token.access_token}`, account.token];
}; };
export const getToken = async (cookies?: string): Promise<string | null> => export const getToken = async (): Promise<string | null> =>
(await getTokenWJ(cookies))[0]; (await getTokenWJ())[0];
export const logout = () => { export const logout = () => {
if (Platform.OS !== "web") { removeAccounts((x) => x.selected);
let accounts: Account[] = JSON.parse(getSecureItem("accounts") ?? "[]");
const selected = parseInt(getSecureItem("selected") ?? "0");
accounts.splice(selected, 1);
setSecureItem("accounts", JSON.stringify(accounts));
}
deleteSecureItem("auth");
}; };
export const deleteAccount = async () => { export const deleteAccount = async () => {

View File

@ -18,7 +18,7 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>. * along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { loginFunc, QueryPage } from "@kyoo/models"; import { login, QueryPage } from "@kyoo/models";
import { Button, P, Input, ts, H1, A } from "@kyoo/primitives"; import { Button, P, Input, ts, H1, A } from "@kyoo/primitives";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@ -74,10 +74,9 @@ export const LoginPage: QueryPage = () => {
<Button <Button
text={t("login.login")} text={t("login.login")}
onPress={async () => { onPress={async () => {
const { error } = await loginFunc("login", { username, password }, cleanApiUrl(apiUrl)); const { error } = await login("login", { username, password, apiUrl: cleanApiUrl(apiUrl) });
setError(error); setError(error);
if (error) return; if (error) return;
queryClient.invalidateQueries({ queryKey: ["auth", "me"] });
router.replace("/", undefined, { router.replace("/", undefined, {
experimental: { nativeBehavior: "stack-replace", isNestedNavigator: false }, experimental: { nativeBehavior: "stack-replace", isNestedNavigator: false },
}); });

View File

@ -18,7 +18,7 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>. * along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { loginFunc, QueryPage } from "@kyoo/models"; import { login, QueryPage } from "@kyoo/models";
import { Button, P, Input, ts, H1, A } from "@kyoo/primitives"; import { Button, P, Input, ts, H1, A } from "@kyoo/primitives";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@ -30,7 +30,6 @@ import { DefaultLayout } from "../layout";
import { FormPage } from "./form"; import { FormPage } from "./form";
import { PasswordInput } from "./password-input"; import { PasswordInput } from "./password-input";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { setSecureItem } from "@kyoo/models/src/secure-store";
import { cleanApiUrl } from "./login"; import { cleanApiUrl } from "./login";
export const RegisterPage: QueryPage = () => { export const RegisterPage: QueryPage = () => {
@ -84,15 +83,13 @@ export const RegisterPage: QueryPage = () => {
text={t("login.register")} text={t("login.register")}
disabled={password !== confirm} disabled={password !== confirm}
onPress={async () => { onPress={async () => {
const { error } = await loginFunc( const { error } = await login(
"register", "register",
{ email, username, password }, { email, username, password, apiUrl: cleanApiUrl(apiUrl) },
cleanApiUrl(apiUrl),
5_000, 5_000,
); );
setError(error); setError(error);
if (error) return; if (error) return;
queryClient.invalidateQueries({ queryKey: ["auth", "me"] });
router.replace("/", undefined, { router.replace("/", undefined, {
experimental: { nativeBehavior: "stack-replace", isNestedNavigator: false }, experimental: { nativeBehavior: "stack-replace", isNestedNavigator: false },
}); });

View File

@ -18,7 +18,15 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>. * along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { AccountContext, deleteAccount, logout, QueryIdentifier, User, UserP } from "@kyoo/models"; import {
deleteAccount,
logout,
QueryIdentifier,
useAccount,
useAccounts,
User,
UserP,
} from "@kyoo/models";
import { import {
Alert, Alert,
Input, Input,
@ -99,79 +107,65 @@ const getDisplayUrl = (url: string) => {
export const NavbarProfile = () => { export const NavbarProfile = () => {
const { css, theme } = useYoshiki(); const { css, theme } = useYoshiki();
const { t } = useTranslation(); const { t } = useTranslation();
const queryClient = useQueryClient(); const account = useAccount();
const { accounts, selected, setSelected } = useContext(AccountContext); const accounts = useAccounts();
return ( return (
<FetchNE query={MeQuery}> <Menu
{({ isError: isGuest, username }) => ( Trigger={Avatar}
<Menu as={PressableFeedback}
Trigger={Avatar} placeholder={account?.username}
as={PressableFeedback} alt={t("navbar.login")}
placeholder={username} size={24}
alt={t("navbar.login")} color={theme.colors.white}
size={24} {...css({ marginLeft: ts(1), justifyContent: "center" })}
color={theme.colors.white} {...tooltip(account?.username ?? t("navbar.login"))}
{...css({ marginLeft: ts(1), justifyContent: "center" })} >
{...tooltip(username ?? t("navbar.login"))} {accounts?.map((x, i) => (
> <Menu.Item
{accounts?.map((x, i) => ( key={x.id}
<Menu.Item label={`${x.username} - ${getDisplayUrl(x.apiUrl)}`}
key={x.refresh_token} left={<Avatar placeholder={x.username} />}
label={`${x.username} - ${getDisplayUrl(x.apiUrl)}`} selected={x.selected}
left={<Avatar placeholder={x.username} />} onSelect={() => x.select()}
selected={selected === i} />
onSelect={() => setSelected!(i)} ))}
/> {accounts.length > 0 && <HR />}
))} {!accounts ? (
{accounts && accounts.length > 0 && <HR />} <>
{isGuest ? ( <Menu.Item label={t("login.login")} icon={Login} href="/login" />
<> <Menu.Item label={t("login.register")} icon={Register} href="/register" />
<Menu.Item label={t("login.login")} icon={Login} href="/login" /> </>
<Menu.Item label={t("login.register")} icon={Register} href="/register" /> ) : (
</> <>
) : ( <Menu.Item label={t("login.add-account")} icon={Login} href="/login" />
<> <Menu.Item label={t("login.logout")} icon={Logout} onSelect={logout} />
<Menu.Item label={t("login.add-account")} icon={Login} href="/login" /> <Menu.Item
<Menu.Item label={t("login.delete")}
label={t("login.logout")} icon={Delete}
icon={Logout} onSelect={async () => {
onSelect={() => { Alert.alert(
logout(); t("login.delete"),
queryClient.invalidateQueries({ queryKey: ["auth", "me"] }); t("login.delete-confirmation"),
}} [
/> {
<Menu.Item text: t("misc.delete"),
label={t("login.delete")} onPress: deleteAccount,
icon={Delete} style: "destructive",
onSelect={async () => { },
Alert.alert( { text: t("misc.cancel"), style: "cancel" },
t("login.delete"), ],
t("login.delete-confirmation"), {
[ cancelable: true,
{ userInterfaceStyle: theme.mode === "auto" ? "light" : theme.mode,
text: t("misc.delete"), icon: "warning",
onPress: async () => { },
await deleteAccount(); );
queryClient.invalidateQueries({ queryKey: ["auth", "me"] }); }}
}, />
style: "destructive", </>
},
{ text: t("misc.cancel"), style: "cancel" },
],
{
cancelable: true,
userInterfaceStyle: theme.mode === "auto" ? "light" : theme.mode,
icon: "warning",
},
);
}}
/>
</>
)}
</Menu>
)} )}
</FetchNE> </Menu>
); );
}; };
export const NavbarRight = () => { export const NavbarRight = () => {