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,
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() {
<NavLinkItem as={Link} to="/modeling">
Modeling
</NavLinkItem>
<NavLinkItem
onClick={() => logout({ returnTo: window.location.origin })}
>
Log out
</NavLinkItem>
<LogoutButton />
</NavLinksList>
</HStack>
);
@ -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 (
<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
* of buttons, depending on the screen size.
@ -205,27 +235,30 @@ function NavLinksList({ children }) {
<NavButton icon={<HamburgerIcon />} />
</MenuButton>
<MenuList>
{React.Children.map(children, (c) => (
<MenuItem {...c.props} />
))}
<NavLinkTypeContext.Provider value="menu">
{children}
</NavLinkTypeContext.Provider>
</MenuList>
</Menu>
</Box>
<HStack spacing="2" display={{ base: "none", md: "flex" }}>
{React.Children.map(children, (c) => (
<NavButton {...c.props} />
))}
<NavLinkTypeContext.Provider value="button">
{children}
</NavLinkTypeContext.Provider>
</HStack>
</>
);
}
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 <NavButton {...props} />;
} else if (navLinkType === "menu") {
return <MenuItem {...props} />;
} else {
throw new Error(`unexpected navLinkType: ${JSON.stringify(navLinkType)}`);
}
}
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
// 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();
},
}
);

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 { 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 ` +
`<LoginModal /> instead.`
);
alert(
`Error: Cannot call startLogin in db login mode. Open a ` +
`<LoginModal /> 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 }];
}
}

View file

@ -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),
};
},

View file

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