Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/acceptance/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,7 @@ Logout should end the server session, but the local rollout gate should still re
### Steps

1. Log in with email/password or Google.
2. Use the app logout path.
2. Use the `Z` shortcut or the command-palette logout action.
3. Confirm you are redirected back into the app.
4. Reload `/day`.
5. Open the command palette.
Expand Down
95 changes: 95 additions & 0 deletions packages/web/src/auth/compass/hooks/useLogout.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { act, renderHook } from "@testing-library/react";
import {
afterAll,
beforeEach,
describe,
expect,
it,
mock,
spyOn,
} from "bun:test";

const clearAuthenticationState = mock();
const setAuthenticated = mock();
const signOut = mock();
const mockUseSession = mock();

mock.module("@web/auth/compass/session/useSession", () => ({
useSession: mockUseSession,
}));

mock.module("@web/auth/compass/state/auth.state.util", () => ({
clearAuthenticationState,
}));

mock.module("@web/common/classes/Session", () => ({
session: {
signOut,
},
}));

const { useLogout } = await import("./useLogout");

describe("useLogout", () => {
beforeEach(() => {
clearAuthenticationState.mockClear();
setAuthenticated.mockClear();
signOut.mockReset();
mockUseSession.mockReset();
mockUseSession.mockReturnValue({
authenticated: true,
setAuthenticated,
});
signOut.mockResolvedValue(undefined);
});

it("signs out and clears authenticated state immediately", () => {
const { result } = renderHook(() => useLogout());

act(() => {
result.current();
});

expect(signOut).toHaveBeenCalledTimes(1);
expect(clearAuthenticationState).toHaveBeenCalledTimes(1);
expect(setAuthenticated).toHaveBeenCalledWith(false);
});

it("does not wait for backend sign-out before clearing local state", () => {
signOut.mockReturnValue(new Promise(() => undefined));
const { result } = renderHook(() => useLogout());

act(() => {
result.current();
});

expect(clearAuthenticationState).toHaveBeenCalledTimes(1);
expect(setAuthenticated).toHaveBeenCalledWith(false);
});

it("logs backend sign-out failures after local logout completes", async () => {
const consoleWarn = spyOn(console, "warn").mockImplementation(() => {});
const error = new Error("network");
signOut.mockRejectedValue(error);
const { result } = renderHook(() => useLogout());

act(() => {
result.current();
});

await Promise.resolve();

expect(clearAuthenticationState).toHaveBeenCalledTimes(1);
expect(setAuthenticated).toHaveBeenCalledWith(false);
expect(consoleWarn).toHaveBeenCalledWith(
"Failed to complete backend sign-out:",
error,
);

consoleWarn.mockRestore();
});
});

afterAll(() => {
mock.restore();
});
17 changes: 17 additions & 0 deletions packages/web/src/auth/compass/hooks/useLogout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { useCallback } from "react";
import { useSession } from "@web/auth/compass/session/useSession";
import { clearAuthenticationState } from "@web/auth/compass/state/auth.state.util";
import { session } from "@web/common/classes/Session";

export function useLogout() {
const { setAuthenticated } = useSession();

return useCallback(() => {
void session.signOut().catch((error) => {
console.warn("Failed to complete backend sign-out:", error);
});

clearAuthenticationState();
setAuthenticated(false);
}, [setAuthenticated]);
}
1 change: 0 additions & 1 deletion packages/web/src/common/constants/routes.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
export const ROOT_ROUTES = {
API: "/api",
LOGOUT: "/logout",
CLEANUP: "/cleanup",
GOOGLE_AUTH_CALLBACK: "/auth/google/callback",
ROOT: "/",
Expand Down
63 changes: 63 additions & 0 deletions packages/web/src/common/hooks/useLogoutCmdItems.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { renderHook } from "@testing-library/react";
import { act, type MouseEvent } from "react";
import { afterAll, beforeEach, describe, expect, it, mock } from "bun:test";

const clearAuthenticationState = mock();
const signOut = mock();
const mockUseSession = mock();

mock.module("@web/auth/compass/session/useSession", () => ({
useSession: mockUseSession,
}));

mock.module("@web/auth/compass/state/auth.state.util", () => ({
clearAuthenticationState,
}));

mock.module("@web/common/classes/Session", () => ({
session: {
signOut,
},
}));

const { useLogoutCmdItems } = await import("./useLogoutCmdItems");

describe("useLogoutCmdItems", () => {
beforeEach(() => {
clearAuthenticationState.mockClear();
signOut.mockReset();
mockUseSession.mockReset();
mockUseSession.mockReturnValue({
authenticated: true,
setAuthenticated: mock(),
});
signOut.mockResolvedValue(undefined);
});

it("returns no items when logged out", () => {
mockUseSession.mockReturnValue({
authenticated: false,
setAuthenticated: mock(),
});

const { result } = renderHook(() => useLogoutCmdItems());

expect(result.current).toEqual([]);
});

it("logs out from the command palette item", () => {
const { result } = renderHook(() => useLogoutCmdItems());
const logoutItem = result.current[0];

act(() => {
logoutItem.onClick?.({} as MouseEvent<HTMLButtonElement>);
});

expect(signOut).toHaveBeenCalledTimes(1);
expect(clearAuthenticationState).toHaveBeenCalledTimes(1);
});
});

afterAll(() => {
mock.restore();
});
21 changes: 21 additions & 0 deletions packages/web/src/common/hooks/useLogoutCmdItems.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { type JsonStructureItem } from "react-cmdk";
import { useLogout } from "@web/auth/compass/hooks/useLogout";
import { useSession } from "@web/auth/compass/session/useSession";

export const useLogoutCmdItems = (): JsonStructureItem[] => {
const { authenticated } = useSession();
const logout = useLogout();

if (!authenticated) {
return [];
}

return [
{
id: "log-out",
children: "Log Out [z]",
icon: "ArrowRightOnRectangleIcon",
onClick: logout,
},
];
};
9 changes: 0 additions & 9 deletions packages/web/src/routers/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,15 +70,6 @@ export const router = createBrowserRouter(
},
],
},
{
path: ROOT_ROUTES.LOGOUT,
lazy: async () =>
import(/* webpackChunkName: "logout" */ "@web/views/Logout").then(
(module) => ({
Component: module.LogoutView,
}),
),
},
{
path: ROOT_ROUTES.WEEK,
lazy: async () =>
Expand Down
6 changes: 0 additions & 6 deletions packages/web/src/routers/loaders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,6 @@ export async function loadAuthenticated() {
return { authenticated };
}

export async function loadLogoutData() {
const { authenticated } = await loadAuthenticated();

return { authenticated };
}

export function loadTodayData(): DayLoaderData {
const dateInView = dayjs();
const dateFormat = dayjs.DateFormat.YEAR_MONTH_DAY_FORMAT;
Expand Down
14 changes: 3 additions & 11 deletions packages/web/src/views/CmdPalette/CmdPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { Categories_Event } from "@core/types/event.types";
import { moreCommandPaletteItems } from "@web/common/constants/more.cmd.constants";
import { useAuthCmdItems } from "@web/common/hooks/useAuthCmdItems";
import { useGoogleCmdItems } from "@web/common/hooks/useGoogleCmdItems";
import { pressKey } from "@web/common/utils/dom/event-emitter.util";
import { useLogoutCmdItems } from "@web/common/hooks/useLogoutCmdItems";
import { onEventTargetVisibility } from "@web/common/utils/dom/event-target-visibility.util";
import {
createAlldayDraft,
Expand Down Expand Up @@ -46,6 +46,7 @@ const CmdPalette = ({
const [search, setSearch] = useState("");
const authCmdItems = useAuthCmdItems();
const googleCmdItems = useGoogleCmdItems();
const logoutCmdItems = useLogoutCmdItems();

const handleCreateSomedayDraft = async (
category: Categories_Event.SOMEDAY_WEEK | Categories_Event.SOMEDAY_MONTH,
Expand Down Expand Up @@ -137,16 +138,7 @@ const CmdPalette = ({
{
heading: "Settings",
id: "settings",
items: [
...googleCmdItems,
...authCmdItems,
{
id: "log-out",
children: "Log Out [z]",
icon: "ArrowRightOnRectangleIcon",
onClick: () => pressKey("z"),
},
],
items: [...googleCmdItems, ...authCmdItems, ...logoutCmdItems],
},
...moreCommandPaletteItems,
],
Expand Down
13 changes: 3 additions & 10 deletions packages/web/src/views/Day/components/DayCmdPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { moreCommandPaletteItems } from "@web/common/constants/more.cmd.constant
import { VIEW_SHORTCUTS } from "@web/common/constants/shortcuts.constants";
import { useAuthCmdItems } from "@web/common/hooks/useAuthCmdItems";
import { useGoogleCmdItems } from "@web/common/hooks/useGoogleCmdItems";
import { useLogoutCmdItems } from "@web/common/hooks/useLogoutCmdItems";
import { pressKey } from "@web/common/utils/dom/event-emitter.util";
import {
openEventFormCreateEvent,
Expand All @@ -30,6 +31,7 @@ export const DayCmdPalette = ({ onGoToToday }: DayCmdPaletteProps) => {
const today = dayjs();
const authCmdItems = useAuthCmdItems();
const googleCmdItems = useGoogleCmdItems();
const logoutCmdItems = useLogoutCmdItems();

const filteredItems = filterItems(
[
Expand Down Expand Up @@ -74,16 +76,7 @@ export const DayCmdPalette = ({ onGoToToday }: DayCmdPaletteProps) => {
{
heading: "Settings",
id: "settings",
items: [
...googleCmdItems,
...authCmdItems,
{
id: "log-out",
children: "Log Out [z]",
icon: "ArrowRightOnRectangleIcon",
onClick: () => pressKey("z"),
},
],
items: [...googleCmdItems, ...authCmdItems, ...logoutCmdItems],
},
...moreCommandPaletteItems,
],
Expand Down
67 changes: 0 additions & 67 deletions packages/web/src/views/Logout/Logout.test.tsx

This file was deleted.

Loading