Add logout button for logged in users

This commit is contained in:
Zoe Roux 2023-03-11 11:53:16 +09:00
parent 98a0466761
commit ea53eb9b24
12 changed files with 119 additions and 61 deletions

View File

@ -19,7 +19,7 @@
*/
import { z } from "zod";
import { getSecureItem, setSecureItem } from "./secure-store";
import { deleteSecureItem, getSecureItem, setSecureItem } from "./secure-store";
import { zdate } from "./utils";
import { queryFn } from "./query";
import { KyooErrors } from "./kyoo-errors";
@ -79,3 +79,7 @@ export const getTokenWJ = async (cookies?: string): Promise<[string, Token] | [n
export const getToken = async (cookies?: string): Promise<string | null> =>
(await getTokenWJ(cookies))[0]
export const logout = async () =>{
deleteSecureItem("auth")
}

View File

@ -18,4 +18,8 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
export { setItemAsync as setSecureItem, getItemAsync as getSecureItem } from "expo-secure-store";
export {
setItemAsync as setSecureItem,
deleteItemAsync as deleteSecureItem,
getItemAsync as getSecureItem,
} from "expo-secure-store";

View File

@ -18,7 +18,7 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
export const setSecureItemSync = (key: string, value?: string) => {
export const setSecureItemSync = (key: string, value: string | null) => {
const d = new Date();
// A year
d.setTime(d.getTime() + 365 * 24 * 60 * 60 * 1000);
@ -27,9 +27,11 @@ export const setSecureItemSync = (key: string, value?: string) => {
return null;
};
export const setSecureItem = async (key: string, value: string): Promise<null> =>
export const setSecureItem = async (key: string, value: string | null): Promise<null> =>
setSecureItemSync(key, value);
export const deleteSecureItem = async (key: string) => setSecureItem(key, null);
export const getSecureItem = async (key: string, cookies?: string): Promise<string | null> => {
// Don't try to use document's cookies on SSR.
if (!cookies && typeof window === "undefined") return null;

View File

@ -25,31 +25,30 @@ import { Icon } from "./icons";
import AccountCircle from "@material-symbols/svg-400/rounded/account_circle-fill.svg";
import { YoshikiStyle } from "yoshiki/dist/type";
import { P } from "@expo/html-elements";
import { forwardRef } from "react";
export const Avatar = ({
src,
alt,
size = px(24),
color,
placeholder,
isLoading = false,
fill = false,
...props
}: {
src?: string | null;
alt?: string;
size?: YoshikiStyle<number | string>;
placeholder?: string;
color?: string;
isLoading?: boolean;
fill?: boolean;
} & Stylable) => {
export const Avatar = forwardRef<
View,
{
src?: string | null;
alt?: string;
size?: YoshikiStyle<number | string>;
placeholder?: string;
color?: string;
isLoading?: boolean;
fill?: boolean;
} & Stylable
>(function _Avatar(
{ src, alt, size = px(24), color, placeholder, isLoading = false, fill = false, ...props },
ref,
) {
const { css, theme } = useYoshiki();
const col = color ?? theme.overlay0;
// TODO: Support dark themes when fill === true
return (
<View
ref={ref}
{...css(
[
{ borderRadius: 999999, width: size, height: size, overflow: "hidden" },
@ -76,4 +75,4 @@ export const Avatar = ({
)}
</View>
);
};
});

View File

@ -30,20 +30,21 @@ import { P } from "./text";
import { ContrastArea } from "./themes";
import { ts } from "./utils";
import Check from "@material-symbols/svg-400/rounded/check-fill.svg";
import { useRouter } from "solito/router";
const MenuContext = createContext<((open: boolean) => void) | undefined>(undefined);
const Menu = <AsProps,>({
Triger,
Trigger,
onMenuOpen,
onMenuClose,
children,
...props
}: {
Triger: ComponentType<AsProps>;
Trigger: ComponentType<AsProps>;
children: ReactNode | ReactNode[] | null;
onMenuOpen: () => void;
onMenuClose: () => void;
onMenuOpen?: () => void;
onMenuClose?: () => void;
} & Omit<AsProps, "onPress">) => {
const [isOpen, setOpen] = useState(false);
@ -55,7 +56,7 @@ const Menu = <AsProps,>({
return (
<>
{/* @ts-ignore */}
<Triger onPress={() => setOpen(true)} {...props} />
<Trigger onPress={() => setOpen(true)} {...props} />
{isOpen && (
<Portal>
<ContrastArea mode="user">
@ -112,20 +113,22 @@ const MenuItem = ({
label,
selected,
onSelect,
href,
...props
}: {
label: string;
selected?: boolean;
onSelect: () => void;
}) => {
} & ({ onSelect: () => void; href?: undefined } | { href: string; onSelect?: undefined })) => {
const { css, theme } = useYoshiki();
const setOpen = useContext(MenuContext);
const router = useRouter();
return (
<PressableFeedback
onPress={() => {
setOpen?.call(null, false);
onSelect?.call(null);
if (href) router.push(href);
}}
{...css(
{

View File

@ -19,7 +19,8 @@
*/
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import { ComponentType, forwardRef, ReactNode } from "react";
import { ComponentProps, ComponentType, forwardRef, ReactNode } from "react";
import Link from "next/link";
import { PressableProps } from "react-native";
import { useYoshiki } from "yoshiki/web";
import { px, useYoshiki as useNativeYoshiki } from "yoshiki/native";
@ -43,16 +44,16 @@ const InternalTriger = forwardRef<unknown, any>(function _Triger(
});
const Menu = <AsProps extends { onPress: PressableProps["onPress"] }>({
Triger,
Trigger,
onMenuOpen,
onMenuClose,
children,
...props
}: {
Triger: ComponentType<AsProps>;
Trigger: ComponentType<AsProps>;
children: ReactNode | ReactNode[] | null;
onMenuOpen: () => void;
onMenuClose: () => void;
onMenuOpen?: () => void;
onMenuClose?: () => void;
} & Omit<AsProps, "onPress">) => {
return (
<DropdownMenu.Root
@ -63,7 +64,7 @@ const Menu = <AsProps extends { onPress: PressableProps["onPress"] }>({
}}
>
<DropdownMenu.Trigger asChild>
<InternalTriger Component={Triger} ComponentProps={props} />
<InternalTriger Component={Trigger} ComponentProps={props} />
</DropdownMenu.Trigger>
<ContrastArea mode="user">
<YoshikiProvider>
@ -90,18 +91,38 @@ const Menu = <AsProps extends { onPress: PressableProps["onPress"] }>({
);
};
const Item = forwardRef<
HTMLDivElement,
ComponentProps<typeof DropdownMenu.Item> & { href?: string }
>(function _Item({ children, href, ...props }, ref) {
if (href) {
return (
<DropdownMenu.Item ref={ref} {...props} asChild>
<Link href={href} style={{ textDecoration: "none" }}>
{children}
</Link>
</DropdownMenu.Item>
);
}
return (
<DropdownMenu.Item ref={ref} {...props}>
{children}
</DropdownMenu.Item>
);
});
const MenuItem = ({
label,
icon,
selected,
onSelect,
href,
...props
}: {
label: string;
icon?: JSX.Element;
selected?: boolean;
onSelect: () => void;
}) => {
} & ({ onSelect: () => void; href?: undefined } | { href: string; onSelect?: undefined })) => {
const { css: nCss } = useNativeYoshiki();
const { css, theme } = useYoshiki();
@ -112,8 +133,9 @@ const MenuItem = ({
background: ${theme.alternate.accent};
}
`}</style>
<DropdownMenu.Item
<Item
onSelect={onSelect}
href={href}
{...css(
[
{
@ -121,7 +143,6 @@ const MenuItem = ({
alignItems: "center",
padding: "8px",
height: "32px",
color: (theme) => theme.paragraph,
focus: {
boxShadow: "none",
},
@ -138,8 +159,8 @@ const MenuItem = ({
{...nCss({ paddingRight: px(8) })}
/>
)}
{<P {...nCss([{ color: "inherit" }, !selected && { paddingLeft: px(8 * 2) }])}>{label}</P>}
</DropdownMenu.Item>
{<P {...nCss(!selected && { paddingLeft: px(8 * 2) })}>{label}</P>}
</Item>
</>
);
};

View File

@ -29,6 +29,7 @@ import { percent, px, useYoshiki } from "yoshiki/native";
import { DefaultLayout } from "../layout";
import { FormPage } from "./form";
import { PasswordInput } from "./password-input";
import { useQueryClient } from "@tanstack/react-query";
export const LoginPage: QueryPage = () => {
const [username, setUsername] = useState("");
@ -36,6 +37,7 @@ export const LoginPage: QueryPage = () => {
const [error, setError] = useState<string | undefined>(undefined);
const router = useRouter();
const queryClient = useQueryClient();
const { t } = useTranslation();
const { css } = useYoshiki();
@ -66,7 +68,9 @@ export const LoginPage: QueryPage = () => {
onPress={async () => {
const { error } = await loginFunc("login", { username, password });
setError(error);
if (!error) router.push("/");
if (error) return;
queryClient.invalidateQueries(["auth", "me"]);
router.push("/");
}}
{...css({
m: ts(1),

View File

@ -29,6 +29,7 @@ import { percent, px, useYoshiki } from "yoshiki/native";
import { DefaultLayout } from "../layout";
import { FormPage } from "./form";
import { PasswordInput } from "./password-input";
import { useQueryClient } from "@tanstack/react-query";
export const RegisterPage: QueryPage = () => {
const [email, setEmail] = useState("");
@ -38,6 +39,7 @@ export const RegisterPage: QueryPage = () => {
const [error, setError] = useState<string | undefined>(undefined);
const router = useRouter();
const queryClient = useQueryClient();
const { t } = useTranslation();
const { css } = useYoshiki();
@ -81,7 +83,9 @@ export const RegisterPage: QueryPage = () => {
onPress={async () => {
const { error } = await loginFunc("register", { email, username, password });
setError(error);
if (!error) router.push("/");
if (error) return;
queryClient.invalidateQueries(["auth", "me"]);
router.push("/");
}}
{...css({
m: ts(1),

View File

@ -18,7 +18,7 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
import { Library, LibraryP, Page, Paged, QueryIdentifier, User, UserP } from "@kyoo/models";
import { Library, LibraryP, logout, Page, Paged, QueryIdentifier, User, UserP } from "@kyoo/models";
import {
Input,
IconButton,
@ -29,17 +29,19 @@ import {
tooltip,
ts,
Link,
Menu,
} from "@kyoo/primitives";
import { Platform, TextInput, View, ViewProps } from "react-native";
import { useTranslation } from "react-i18next";
import { createParam } from "solito";
import { useRouter } from "solito/router";
import { rem, Stylable, useYoshiki } from "yoshiki/native";
import Menu from "@material-symbols/svg-400/rounded/menu-fill.svg";
import MenuIcon from "@material-symbols/svg-400/rounded/menu-fill.svg";
import Search from "@material-symbols/svg-400/rounded/search-fill.svg";
import { Fetch, FetchNE } from "../fetch";
import { KyooLongLogo } from "./icon";
import { forwardRef, useRef, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
export const NavbarTitle = (props: Stylable & { onLayout?: ViewProps["onLayout"] }) => {
const { t } = useTranslation();
@ -90,23 +92,37 @@ export const MeQuery: QueryIdentifier<User> = {
export const NavbarProfile = () => {
const { css, theme } = useYoshiki();
const { t } = useTranslation();
const queryClient = useQueryClient();
// TODO: show logged in user.
return (
<FetchNE query={MeQuery}>
{({ username }) => (
<Link
href="/login"
{({ isError: isGuest, username }) => (
<Menu
Trigger={Avatar}
placeholder={username}
alt={t("navbar.login")}
size={30}
color={theme.colors.white}
{...css({ marginLeft: ts(1), marginVertical: "auto", justifyContent: "center" })}
{...tooltip(username ?? t("navbar.login"))}
{...css({ marginLeft: ts(1), justifyContent: "center" })}
>
<Avatar
placeholder={username}
alt={t("navbar.login")}
size={30}
color={theme.colors.white}
/>
</Link>
{isGuest ? (
<>
<Menu.Item label={t("login.login")} href="/login" />
<Menu.Item label={t("login.register")} href="/register" />
</>
) : (
<>
<Menu.Item
label={t("login.logout")}
onSelect={() => {
logout();
queryClient.invalidateQueries(["auth", "me"]);
}}
/>
</>
)}
</Menu>
)}
</FetchNE>
);
@ -175,13 +191,12 @@ export const Navbar = (props: Stylable) => {
shadowOpacity: 0.3,
shadowRadius: 4.65,
elevation: 8,
zIndex: 1,
},
props,
)}
>
<IconButton
icon={Menu}
icon={MenuIcon}
aria-label="more"
aria-controls="menu-appbar"
aria-haspopup="true"

View File

@ -65,7 +65,7 @@ export const RightButtons = ({
<View {...css({ flexDirection: "row" }, props)}>
{subtitles && (
<Menu
Triger={IconButton}
Trigger={IconButton}
icon={ClosedCaption}
onMenuOpen={onMenuOpen}
onMenuClose={onMenuClose}

View File

@ -53,6 +53,7 @@
"login": {
"login": "Login",
"register": "Register",
"logout": "Logout",
"server": "Server Address",
"email": "Email",
"username": "Username",

View File

@ -53,6 +53,7 @@
"login": {
"login": "Connexion",
"register": "Créer un compte",
"logout": "Déconnexion",
"server": "Addresse du serveur",
"email": "Email",
"username": "Username",