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 }; + }, }, };