Skip to content
Open
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
3 changes: 1 addition & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,7 @@ packages/**/yarn.lock
# DIRS #
########
# root
.claude/settings.local.json
.claude/worktrees/
.claude/
.idea/
.mcp.json
.vscode/
Expand Down
4 changes: 2 additions & 2 deletions docs/env-and-dev-modes.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ Important variables:
- `SUPERTOKENS_KEY`
- `TOKEN_GCAL_NOTIFICATION`
- `TOKEN_COMPASS_SYNC`
- `LOCAL_WEB_URL`
- `FRONTEND_URL`

Optional but behavior-changing:

Expand All @@ -86,7 +86,7 @@ Primary files:
Variables used by CLI/build flows:

- `BASEURL` (required for local CLI operations; returned as-is for local API base URL)
- `LOCAL_WEB_URL` (required; used by backend auth email flows)
- `FRONTEND_URL` (required; used by backend auth email flows — set to the public-facing frontend URL for this deployment)
- `STAGING_WEB_URL` (optional; used to derive `https://<host>/api` for staging CLI runs)
- `PROD_WEB_URL` (optional; used to derive `https://<host>/api` for production CLI runs)

Expand Down
4 changes: 2 additions & 2 deletions docs/password-auth-flow.md
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,7 @@ Current behavior in `supertokens.middleware.ts`:

The rewritten link shape comes from `buildResetPasswordLink()` and looks like:

The host/origin portion is taken from backend env (`LOCAL_WEB_URL`), and the route is always `/day` with `auth=reset` plus the token.
The host/origin portion is taken from backend env (`FRONTEND_URL`), and the route is always `/day` with `auth=reset` plus the token.

- `http://localhost:9080/day?auth=reset&token=...`

Expand All @@ -260,4 +260,4 @@ That lets password-auth users use Compass without blocking on Google connectivit
## Known Caveats

- The rollout gate is not limited to `lastKnownEmail`; any `?auth=` URL currently enables the auth UI.
- Reset password links always target the `/day` route and require a valid `LOCAL_WEB_URL` in backend env.
- Reset password links always target the `/day` route and require a valid `FRONTEND_URL` in backend env.
2 changes: 1 addition & 1 deletion packages/backend/.env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ SUPERTOKENS_KEY=UNIQUE_KEY_FROM_YOUR_SUPERTOKENS_ACCOUNT
####################################################
# 5. Web #
####################################################
LOCAL_WEB_URL=http://localhost:9080
FRONTEND_URL=http://localhost:9080
# STAGING_WEB_URL=https://staging.yourdomain.com
# PROD_WEB_URL=https://app.yourdomain.com

Expand Down
2 changes: 1 addition & 1 deletion packages/backend/src/__tests__/backend.test.init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,4 @@ process.env["EMAILER_API_SECRET"] = "emailerApiSecret";
process.env["EMAILER_USER_TAG_ID"] = "910111213";
process.env["TOKEN_GCAL_NOTIFICATION"] = "secretToken1";
process.env["TOKEN_COMPASS_SYNC"] = "secretToken2";
process.env["LOCAL_WEB_URL"] = "http://localhost:9080";
process.env["FRONTEND_URL"] = "http://localhost:9080";
4 changes: 2 additions & 2 deletions packages/backend/src/common/constants/env.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ const EnvSchema = z
DB: z.string().nonempty(),
EMAILER_SECRET: z.string().nonempty().optional(),
EMAILER_USER_TAG_ID: z.string().nonempty().optional(),
LOCAL_WEB_URL: z.string().url(),
FRONTEND_URL: z.string().url(),
MONGO_URI: z.string().nonempty(),
NODE_ENV: z.nativeEnum(NodeEnv),
TZ: z.enum(["Etc/UTC", "UTC"]),
Expand Down Expand Up @@ -57,7 +57,7 @@ const processEnv = {
DB: IS_DEV ? "dev_calendar" : "prod_calendar",
EMAILER_SECRET: process.env["EMAILER_API_SECRET"],
EMAILER_USER_TAG_ID: process.env["EMAILER_USER_TAG_ID"],
LOCAL_WEB_URL: process.env["LOCAL_WEB_URL"],
FRONTEND_URL: process.env["FRONTEND_URL"],
MONGO_URI: process.env["MONGO_URI"],
NODE_ENV: _nodeEnv,
TZ: process.env["TZ"],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,7 @@ describe("supertokens.middleware", () => {

expect(buildResetPasswordLink).toHaveBeenCalledWith(
"http://localhost:1234/auth/reset-password?token=abc",
ENV.LOCAL_WEB_URL,
ENV.FRONTEND_URL,
);
// In test env, sending is suppressed — originalSendEmail must not be called
expect(originalSendEmail).not.toHaveBeenCalled();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ export const initSupertokens = () => {
sendEmail: async (input) => {
const resetLink = buildResetPasswordLink(
input.passwordResetLink,
ENV.LOCAL_WEB_URL,
ENV.FRONTEND_URL,
);

if (ENV.NODE_ENV === "test") {
Expand Down
8 changes: 6 additions & 2 deletions packages/scripts/src/commands/delete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ const getCleanupUrl = (): string => {
return `${CLI_ENV.STAGING_WEB_URL}/cleanup`;
}

return `${CLI_ENV.LOCAL_WEB_URL}/cleanup`;
return `${CLI_ENV.FRONTEND_URL}/cleanup`;
};

/**
Expand Down Expand Up @@ -141,7 +141,11 @@ export const deleteCompassDataForMatchingUsers = async (user: string) => {
const totalSummary: Summary_Delete[] = [];
for (const user of users) {
const userId = user?._id.toString();
const summary = await userService.deleteCompassDataForUser(userId);
const gcalAccess = !!user.google?.gRefreshToken;
const summary = await userService.deleteCompassDataForUser(
userId,
gcalAccess,
);
totalSummary.push(summary);
}

Expand Down
4 changes: 2 additions & 2 deletions packages/scripts/src/common/cli.constants.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
type CliEnv = {
LOCAL_WEB_URL: string;
FRONTEND_URL: string;
STAGING_WEB_URL: string | undefined;
PROD_WEB_URL: string | undefined;
DEV_BROWSER: string | undefined;
Expand All @@ -23,7 +23,7 @@ export const ENVIRONMENT = {
};

export const CLI_ENV: CliEnv = {
LOCAL_WEB_URL: process.env["LOCAL_WEB_URL"] || `http://localhost:9080`,
FRONTEND_URL: process.env["FRONTEND_URL"] || `http://localhost:9080`,
STAGING_WEB_URL: process.env["STAGING_WEB_URL"],
PROD_WEB_URL: process.env["PROD_WEB_URL"],
DEV_BROWSER: process.env["DEV_BROWSER"],
Expand Down
127 changes: 125 additions & 2 deletions packages/web/src/components/AuthModal/AuthModal.test.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
import { type ReactElement, type ReactNode } from "react";
import { MemoryRouter } from "react-router-dom";
import { type ReactElement, type ReactNode, useLayoutEffect } from "react";
import {
MemoryRouter,
Outlet,
RouterProvider,
createMemoryRouter,
useLocation,
} from "react-router-dom";
import EmailPassword from "supertokens-web-js/recipe/emailpassword";
import "@testing-library/jest-dom";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { loadDayData, loadTodayData } from "@web/routers/loaders";
import { AccountIcon } from "./AccountIcon";
import { AuthModal } from "./AuthModal";
import { AuthModalProvider } from "./AuthModalProvider";
Expand Down Expand Up @@ -126,6 +133,63 @@ async function flushEffects() {
await Promise.resolve();
}

const RouteLocationMirror = ({ children }: { children: ReactNode }) => {
const location = useLocation();

useLayoutEffect(() => {
mockWindowLocation(
`${location.pathname}${location.search}${location.hash}`,
);
}, [location]);

return <>{children}</>;
};

const DayRedirectShell = () => (
<RouteLocationMirror>
<AuthModalProvider>
<AuthModal />
<Outlet />
</AuthModalProvider>
</RouteLocationMirror>
);

const renderWithDayRedirectRoute = (initialRoute: string) => {
mockWindowLocation(initialRoute);

const router = createMemoryRouter(
[
{
path: "/day",
Component: DayRedirectShell,
children: [
{
index: true,
loader: loadDayData,
},
{
path: ":dateString",
element: <div>Day route loaded</div>,
},
],
},
],
{
initialEntries: [initialRoute],
future: {
v7_relativeSplatPath: true,
},
},
);

return {
router,
...render(
<RouterProvider router={router} future={{ v7_startTransition: true }} />,
),
};
};

describe("AuthModal", () => {
beforeEach(() => {
jest.clearAllMocks();
Expand Down Expand Up @@ -860,6 +924,25 @@ describe("URL Parameter Support", () => {
});
});

it("opens reset password after the /day redirect preserves auth params", async () => {
const { dateString } = loadTodayData();

renderWithDayRedirectRoute("/day?auth=reset&token=reset-token");

await waitFor(() => {
expect(screen.getByText("Day route loaded")).toBeInTheDocument();
expect(
screen.getByRole("heading", { name: /set new password/i }),
).toBeInTheDocument();
});

expect(mockReplaceState).toHaveBeenCalledWith(
null,
"",
`/day/${dateString}?token=reset-token`,
);
});

it("submits reset password with the initial token after the URL changes", async () => {
const user = userEvent.setup();
mockWindowLocation("/day?auth=reset&token=reset-token");
Expand All @@ -882,10 +965,50 @@ describe("URL Parameter Support", () => {
});
});

expect(mockReplaceState).toHaveBeenLastCalledWith(undefined, "", "/day");
expect(screen.getByRole("status")).toHaveTextContent(
"Password reset successful. Log in with your new password.",
);
expect(
screen.getByRole("heading", { name: /hey, welcome back/i }),
).toBeInTheDocument();
expect(mockCompleteAuthentication).not.toHaveBeenCalled();
expect(
mockEmailPassword.getResetPasswordTokenFromURL,
).not.toHaveBeenCalled();
});

it("switches to signUp (not back to loginAfterReset) when Sign up is clicked after reset", async () => {
const user = userEvent.setup();
mockWindowLocation("/day?auth=reset&token=reset-token");
mockEmailPassword.submitNewPassword.mockResolvedValue({
status: "OK",
} as never);
renderWithProviders(<div />, "/day?auth=reset&token=reset-token");

await waitFor(() => {
expect(
screen.getByRole("heading", { name: /set new password/i }),
).toBeInTheDocument();
});

await user.type(screen.getByLabelText(/new password/i), "newpassword123");
await user.click(screen.getByRole("button", { name: /set new password/i }));

await waitFor(() => {
expect(screen.getByRole("status")).toHaveTextContent(
"Password reset successful. Log in with your new password.",
);
});

await user.click(screen.getByRole("button", { name: /^sign up$/i }));

await waitFor(() => {
expect(
screen.getByRole("heading", { name: /nice to meet you/i }),
).toBeInTheDocument();
});
});
});

describe("AccountIcon", () => {
Expand Down
20 changes: 13 additions & 7 deletions packages/web/src/components/AuthModal/AuthModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ export const AuthModal: FC = () => {
const { isOpen, currentView, openModal, closeModal, setView } =
useAuthModal();
const googleAuth = useGoogleAuth();
const isLoginView =
currentView === "login" || currentView === "loginAfterReset";
const resetPasswordToken = useRef(getInitialResetPasswordToken()).current;
const {
isSubmitting,
Expand Down Expand Up @@ -68,12 +70,12 @@ export const AuthModal: FC = () => {
}, [currentView]);

const handleSwitchAuth = useCallback(
() => setView(currentView === "login" ? "signUp" : "login"),
() => setView(currentView === "signUp" ? "login" : "signUp"),
[currentView, setView],
);

const handleGoogleSignIn = useCallback(() => {
googleAuth.login();
void googleAuth.login();
closeModal();
}, [googleAuth, closeModal]);

Expand All @@ -93,11 +95,10 @@ export const AuthModal: FC = () => {
return null;
}

const showAuthSwitch = currentView === "login" || currentView === "signUp";
const showAuthSwitch = isLoginView || currentView === "signUp";
const showGoogleAuth = currentView !== "resetPassword";
const showSubmitError =
submitError !== null &&
(currentView === "login" || currentView === "signUp");
submitError !== null && (isLoginView || currentView === "signUp");
const trimmedName = signUpName.trim();
const title =
currentView === "forgotPassword"
Expand All @@ -121,11 +122,16 @@ export const AuthModal: FC = () => {
isSubmitting={isSubmitting}
/>
)}
{currentView === "login" && (
{isLoginView && (
<LogInForm
onSubmit={handleLogin}
onForgotPassword={navigateToForgotPassword}
isSubmitting={isSubmitting}
statusMessage={
currentView === "loginAfterReset"
? "Password reset successful. Log in with your new password."
: null
}
/>
)}
{currentView === "forgotPassword" && (
Expand Down Expand Up @@ -161,7 +167,7 @@ export const AuthModal: FC = () => {
variant="outline"
onClick={handleSwitchAuth}
>
{currentView === "login" ? "Sign up" : "Log in"}
{isLoginView ? "Sign up" : "Log in"}
</AuthButton>
</>
)}
Expand Down
9 changes: 9 additions & 0 deletions packages/web/src/components/AuthModal/forms/LogInForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ interface SignInFormProps {
onForgotPassword: () => void;
/** Whether form submission is in progress */
isSubmitting?: boolean;
/** Optional status message shown above the form */
statusMessage?: string | null;
}

/**
Expand All @@ -25,6 +27,7 @@ export const LogInForm: FC<SignInFormProps> = ({
onSubmit,
onForgotPassword,
isSubmitting,
statusMessage,
}) => {
const form = useZodForm({
schema: LogInSchema,
Expand All @@ -34,6 +37,12 @@ export const LogInForm: FC<SignInFormProps> = ({

return (
<form onSubmit={form.handleSubmit} className="flex w-full flex-col gap-4">
{statusMessage ? (
<p className="text-status-success text-center text-sm" role="status">
{statusMessage}
</p>
) : null}

<AuthInput
type="email"
placeholder="Email"
Expand Down
Loading
Loading