Logout button for new auth mode

Hey hey, logging out works! The server side of this was easy, but I made a few refactors to support it well on the client, like `useLoginActions` becoming just `useLogout` lol, and updating how the nav menu chooses between buttons vs menu because I wanted `<LogoutButton />` to contain some state.

We also did good Apollo cache stuff to update the page after you log in or out! I think some fields that don't derive from `User`, like `Item.currentUserOwnsThis`, are gonna fail to update until you reload the page but like that's fine idk :p

There's a known bug where logging out on the Your Outfits page turns into an infinite loop situation, because it's trying to do Auth0 stuff but the login keeps failing to have any effect because we're in db mode! I'll fix that next.
This commit is contained in:
Emi Matchu 2022-08-17 16:05:36 -07:00
parent 2dbfaf1557
commit db8fe9f3c2
5 changed files with 115 additions and 45 deletions

View file

@ -9,6 +9,7 @@ import {
MenuList, MenuList,
MenuItem, MenuItem,
useDisclosure, useDisclosure,
useToast,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { HamburgerIcon } from "@chakra-ui/icons"; import { HamburgerIcon } from "@chakra-ui/icons";
import { Link, useLocation } from "react-router-dom"; import { Link, useLocation } from "react-router-dom";
@ -17,9 +18,10 @@ import Image from "next/image";
import useCurrentUser, { import useCurrentUser, {
useAuthModeFeatureFlag, useAuthModeFeatureFlag,
useLoginActions, useLogout,
} from "./components/useCurrentUser"; } from "./components/useCurrentUser";
import HomeLinkIcon from "./images/home-link-icon.png"; import HomeLinkIcon from "./images/home-link-icon.png";
import { useAuth0 } from "@auth0/auth0-react";
function GlobalHeader() { function GlobalHeader() {
return ( return (
@ -113,7 +115,6 @@ function HomeLink(props) {
function UserNavBarSection() { function UserNavBarSection() {
const { isLoading, isLoggedIn, id, username } = useCurrentUser(); const { isLoading, isLoggedIn, id, username } = useCurrentUser();
const { logout } = useLoginActions();
if (isLoading) { if (isLoading) {
return null; return null;
@ -139,11 +140,7 @@ function UserNavBarSection() {
<NavLinkItem as={Link} to="/modeling"> <NavLinkItem as={Link} to="/modeling">
Modeling Modeling
</NavLinkItem> </NavLinkItem>
<NavLinkItem <LogoutButton />
onClick={() => logout({ returnTo: window.location.origin })}
>
Log out
</NavLinkItem>
</NavLinksList> </NavLinksList>
</HStack> </HStack>
); );
@ -161,12 +158,12 @@ function UserNavBarSection() {
function LoginButton() { function LoginButton() {
const authMode = useAuthModeFeatureFlag(); const authMode = useAuthModeFeatureFlag();
const { startLogin } = useLoginActions(); const { loginWithRedirect } = useAuth0();
const { isOpen, onOpen, onClose } = useDisclosure(); const { isOpen, onOpen, onClose } = useDisclosure();
const onClick = () => { const onClick = () => {
if (authMode === "auth0") { if (authMode === "auth0") {
startLogin(); loginWithRedirect();
} else if (authMode === "db") { } else if (authMode === "db") {
onOpen(); onOpen();
} else { } else {
@ -190,6 +187,39 @@ function LoginButton() {
// every single page. Split it out! // every single page. Split it out!
const LoginModal = React.lazy(() => import("./components/LoginModal")); const LoginModal = React.lazy(() => import("./components/LoginModal"));
function LogoutButton() {
const toast = useToast();
const [logout, { loading, error }] = useLogout();
React.useEffect(() => {
if (error != null) {
console.error(error);
toast({
title: "Oops, there was an error logging you out.",
description: "Reload the page and try again? Sorry about that!",
status: "warning",
duration: null,
isClosable: true,
});
}
}, [error, toast]);
return (
<NavLinkItem
onClick={() => logout({ returnTo: window.location.origin })}
// NOTE: The `isLoading` prop will only be relevant in the desktop case,
// where this renders as a NavButton. In the mobile case, the menu
// doesn't have a loading UI, and it closes when you click the
// button anyway. Not ideal, but fine for a simple quick action!
isLoading={loading}
>
Log out
</NavLinkItem>
);
}
const NavLinkTypeContext = React.createContext("button");
/** /**
* Renders the given <NavLinkItem /> children as a dropdown menu or as a list * Renders the given <NavLinkItem /> children as a dropdown menu or as a list
* of buttons, depending on the screen size. * of buttons, depending on the screen size.
@ -205,27 +235,30 @@ function NavLinksList({ children }) {
<NavButton icon={<HamburgerIcon />} /> <NavButton icon={<HamburgerIcon />} />
</MenuButton> </MenuButton>
<MenuList> <MenuList>
{React.Children.map(children, (c) => ( <NavLinkTypeContext.Provider value="menu">
<MenuItem {...c.props} /> {children}
))} </NavLinkTypeContext.Provider>
</MenuList> </MenuList>
</Menu> </Menu>
</Box> </Box>
<HStack spacing="2" display={{ base: "none", md: "flex" }}> <HStack spacing="2" display={{ base: "none", md: "flex" }}>
{React.Children.map(children, (c) => ( <NavLinkTypeContext.Provider value="button">
<NavButton {...c.props} /> {children}
))} </NavLinkTypeContext.Provider>
</HStack> </HStack>
</> </>
); );
} }
function NavLinkItem() { function NavLinkItem(props) {
throw new Error( const navLinkType = React.useContext(NavLinkTypeContext);
`NavLinkItem should only be rendered in a NavLinksList, which should ` + if (navLinkType === "button") {
`render it as both a MenuItem or NavButton element. That way, we can ` + return <NavButton {...props} />;
`show the best layout depending on a CSS media query!` } else if (navLinkType === "menu") {
); return <MenuItem {...props} />;
} else {
throw new Error(`unexpected navLinkType: ${JSON.stringify(navLinkType)}`);
}
} }
const NavButton = React.forwardRef(({ icon, ...props }, ref) => { const NavButton = React.forwardRef(({ icon, ...props }, ref) => {

View file

@ -67,15 +67,22 @@ function LoginForm({ onSuccess }) {
} }
`, `,
{ {
update: (cache) => { update: (cache, { data }) => {
// Evict the `currentUser` from the cache, which will force all queries // Evict the `currentUser` from the cache, which will force all queries
// on the page that depend on it to update. (This includes the // on the page that depend on it to update. (This includes the
// GlobalHeader that shows who you're logged in as!) // GlobalHeader that shows who you're logged in as!)
// //
// We also evict the user themself, to force-update things that we're
// allowed to see about this user (e.g. private lists).
//
// I don't do any optimistic UI here, because auth is complex enough // I don't do any optimistic UI here, because auth is complex enough
// that I'd rather only show login success after validating it through // that I'd rather only show login success after validating it through
// an actual server round-trip. // an actual server round-trip.
cache.evict({ id: "ROOT_QUERY", fieldName: "currentUser" }); cache.evict({ id: "ROOT_QUERY", fieldName: "currentUser" });
if (data.login?.id != null) {
cache.evict({ id: `User:${data.login.id}` });
}
cache.gc();
}, },
} }
); );

View file

@ -1,4 +1,4 @@
import { gql, useQuery } from "@apollo/client"; import { gql, useMutation, useQuery } from "@apollo/client";
import { useAuth0 } from "@auth0/auth0-react"; import { useAuth0 } from "@auth0/auth0-react";
import { useEffect } from "react"; import { useEffect } from "react";
import { useLocalStorage } from "../util"; import { useLocalStorage } from "../util";
@ -163,34 +163,50 @@ function getUserInfoFromAuth0Data(user) {
* Note that `startLogin` is only supported with the Auth0 auto mode. In db * Note that `startLogin` is only supported with the Auth0 auto mode. In db
* mode, you should open a `LoginModal` instead! * mode, you should open a `LoginModal` instead!
*/ */
export function useLoginActions() { export function useLogout() {
const { const { logout: logoutWithAuth0 } = useAuth0();
loginWithRedirect: auth0StartLogin,
logout: auth0Logout,
} = useAuth0();
const authMode = useAuthModeFeatureFlag(); const authMode = useAuthModeFeatureFlag();
const [sendLogoutMutation, { loading, error }] = useMutation(
gql`
mutation useLogout_Logout {
logout {
id
}
}
`,
{
update: (cache, { data }) => {
// Evict the `currentUser` from the cache, which will force all queries
// on the page that depend on it to update. (This includes the
// GlobalHeader that shows who you're logged in as!)
//
// We also evict the user themself, to force-update things that we're
// allowed to see about this user (e.g. private lists).
//
// I don't do any optimistic UI here, because auth is complex enough
// that I'd rather only show logout success after validating it through
// an actual server round-trip.
cache.evict({ id: "ROOT_QUERY", fieldName: "currentUser" });
if (data.logout?.id != null) {
cache.evict({ id: `User:${data.logout.id}` });
}
cache.gc();
},
}
);
const logoutWithDb = () => {
sendLogoutMutation().catch((e) => {}); // handled in error UI
};
if (authMode === "auth0") { if (authMode === "auth0") {
return { startLogin: auth0StartLogin, logout: auth0Logout }; return [logoutWithAuth0, { loading: false, error: null }];
} else if (authMode === "db") { } else if (authMode === "db") {
return { return [logoutWithDb, { loading, error }];
startLogin: () => {
console.error(
`Error: Cannot call startLogin in db login mode. Open a ` +
`<LoginModal /> instead.`
);
alert(
`Error: Cannot call startLogin in db login mode. Open a ` +
`<LoginModal /> instead.`
);
},
logout: () => {
alert(`TODO: logout`);
},
};
} else { } else {
console.error(`unexpected auth mode: ${JSON.stringify(authMode)}`); console.error(`unexpected auth mode: ${JSON.stringify(authMode)}`);
return { startLogin: () => {}, logout: () => {} }; return [() => {}, { loading: false, error: null }];
} }
} }

View file

@ -100,6 +100,12 @@ const config = {
); );
return authToken; return authToken;
}, },
logout: async () => {
// NOTE: This function isn't actually async in practice, but we mark it
// as such for consistency with `login`!
// Set a header to delete the cookie. (That is, empty and expired.)
res.setHeader("Set-Cookie", `DTIAuthToken=; Max-Age=-1`);
},
...buildLoaders(db), ...buildLoaders(db),
}; };
}, },

View file

@ -59,6 +59,7 @@ const typeDefs = gql`
extend type Mutation { extend type Mutation {
login(username: String!, password: String!): User login(username: String!, password: String!): User
logout: User
} }
`; `;
@ -371,6 +372,13 @@ const resolvers = {
} }
return { id: loginToken.userId }; return { id: loginToken.userId };
}, },
logout: async (_, __, { currentUserId, logout }) => {
await logout();
if (currentUserId == null) {
return null;
}
return { id: currentUserId };
},
}, },
}; };