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