From db8fe9f3c2943117673a3b619d5bc867312dd30b Mon Sep 17 00:00:00 2001 From: Matchu Date: Wed, 17 Aug 2022 16:05:36 -0700 Subject: [PATCH] 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 `` 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. --- src/app/GlobalHeader.js | 75 ++++++++++++++++++++-------- src/app/components/LoginModal.js | 9 +++- src/app/components/useCurrentUser.js | 62 ++++++++++++++--------- src/server/index.js | 6 +++ src/server/types/User.js | 8 +++ 5 files changed, 115 insertions(+), 45 deletions(-) diff --git a/src/app/GlobalHeader.js b/src/app/GlobalHeader.js index a67a81c..69b5b05 100644 --- a/src/app/GlobalHeader.js +++ b/src/app/GlobalHeader.js @@ -9,6 +9,7 @@ import { MenuList, MenuItem, useDisclosure, + useToast, } from "@chakra-ui/react"; import { HamburgerIcon } from "@chakra-ui/icons"; import { Link, useLocation } from "react-router-dom"; @@ -17,9 +18,10 @@ import Image from "next/image"; import useCurrentUser, { useAuthModeFeatureFlag, - useLoginActions, + useLogout, } from "./components/useCurrentUser"; import HomeLinkIcon from "./images/home-link-icon.png"; +import { useAuth0 } from "@auth0/auth0-react"; function GlobalHeader() { return ( @@ -113,7 +115,6 @@ function HomeLink(props) { function UserNavBarSection() { const { isLoading, isLoggedIn, id, username } = useCurrentUser(); - const { logout } = useLoginActions(); if (isLoading) { return null; @@ -139,11 +140,7 @@ function UserNavBarSection() { Modeling - logout({ returnTo: window.location.origin })} - > - Log out - + ); @@ -161,12 +158,12 @@ function UserNavBarSection() { function LoginButton() { const authMode = useAuthModeFeatureFlag(); - const { startLogin } = useLoginActions(); + const { loginWithRedirect } = useAuth0(); const { isOpen, onOpen, onClose } = useDisclosure(); const onClick = () => { if (authMode === "auth0") { - startLogin(); + loginWithRedirect(); } else if (authMode === "db") { onOpen(); } else { @@ -190,6 +187,39 @@ function LoginButton() { // every single page. Split it out! 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 ( + 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 + + ); +} + +const NavLinkTypeContext = React.createContext("button"); + /** * Renders the given children as a dropdown menu or as a list * of buttons, depending on the screen size. @@ -205,27 +235,30 @@ function NavLinksList({ children }) { } /> - {React.Children.map(children, (c) => ( - - ))} + + {children} + - {React.Children.map(children, (c) => ( - - ))} + + {children} + ); } -function NavLinkItem() { - throw new Error( - `NavLinkItem should only be rendered in a NavLinksList, which should ` + - `render it as both a MenuItem or NavButton element. That way, we can ` + - `show the best layout depending on a CSS media query!` - ); +function NavLinkItem(props) { + const navLinkType = React.useContext(NavLinkTypeContext); + if (navLinkType === "button") { + return ; + } else if (navLinkType === "menu") { + return ; + } else { + throw new Error(`unexpected navLinkType: ${JSON.stringify(navLinkType)}`); + } } const NavButton = React.forwardRef(({ icon, ...props }, ref) => { diff --git a/src/app/components/LoginModal.js b/src/app/components/LoginModal.js index 354dabc..200e24e 100644 --- a/src/app/components/LoginModal.js +++ b/src/app/components/LoginModal.js @@ -67,15 +67,22 @@ function LoginForm({ onSuccess }) { } `, { - update: (cache) => { + 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 login success after validating it through // an actual server round-trip. cache.evict({ id: "ROOT_QUERY", fieldName: "currentUser" }); + if (data.login?.id != null) { + cache.evict({ id: `User:${data.login.id}` }); + } + cache.gc(); }, } ); diff --git a/src/app/components/useCurrentUser.js b/src/app/components/useCurrentUser.js index 794236f..1a29e24 100644 --- a/src/app/components/useCurrentUser.js +++ b/src/app/components/useCurrentUser.js @@ -1,4 +1,4 @@ -import { gql, useQuery } from "@apollo/client"; +import { gql, useMutation, useQuery } from "@apollo/client"; import { useAuth0 } from "@auth0/auth0-react"; import { useEffect } from "react"; import { useLocalStorage } from "../util"; @@ -163,34 +163,50 @@ function getUserInfoFromAuth0Data(user) { * Note that `startLogin` is only supported with the Auth0 auto mode. In db * mode, you should open a `LoginModal` instead! */ -export function useLoginActions() { - const { - loginWithRedirect: auth0StartLogin, - logout: auth0Logout, - } = useAuth0(); +export function useLogout() { + const { logout: logoutWithAuth0 } = useAuth0(); 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") { - return { startLogin: auth0StartLogin, logout: auth0Logout }; + return [logoutWithAuth0, { loading: false, error: null }]; } else if (authMode === "db") { - return { - startLogin: () => { - console.error( - `Error: Cannot call startLogin in db login mode. Open a ` + - ` instead.` - ); - alert( - `Error: Cannot call startLogin in db login mode. Open a ` + - ` instead.` - ); - }, - logout: () => { - alert(`TODO: logout`); - }, - }; + return [logoutWithDb, { loading, error }]; } else { console.error(`unexpected auth mode: ${JSON.stringify(authMode)}`); - return { startLogin: () => {}, logout: () => {} }; + return [() => {}, { loading: false, error: null }]; } } diff --git a/src/server/index.js b/src/server/index.js index 8247080..5b98037 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -100,6 +100,12 @@ const config = { ); 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), }; }, diff --git a/src/server/types/User.js b/src/server/types/User.js index 5b06c9d..47fc8cf 100644 --- a/src/server/types/User.js +++ b/src/server/types/User.js @@ -59,6 +59,7 @@ const typeDefs = gql` extend type Mutation { login(username: String!, password: String!): User + logout: User } `; @@ -371,6 +372,13 @@ const resolvers = { } return { id: loginToken.userId }; }, + logout: async (_, __, { currentUserId, logout }) => { + await logout(); + if (currentUserId == null) { + return null; + } + return { id: currentUserId }; + }, }, };