webapp: route new Google SSO users to the welcome page (once)

After Google sign-in, brand-new accounts now land on /welcome instead of
the set-password page; returning users go to their normal destination.

"New user" is detected via the SSO response's username_not_yet_customized
(forced to false by the backend for returning Google logins, so repeat
logins never re-trigger it). A per-user-token localStorage marker guards
against the rare email→Google-link edge case so welcome shows at most
once per account.

- session.ts: add consumeNewUserWelcome(isNewUser) helper
- login / signup-form / signup-cta-modal: route via the helper
This commit is contained in:
bflatastic
2026-05-26 23:01:10 +07:00
parent ee4a7f7e2f
commit dd68defdc2
4 changed files with 48 additions and 20 deletions
@@ -15,7 +15,7 @@ import {
getReferralUsername,
getReferrer,
} from "@storyteller/common";
import { refreshSession, useSessionStore } from "../../lib/session";
import { consumeNewUserWelcome, refreshSession } from "../../lib/session";
import { GoogleLoginButton } from "./GoogleLoginButton";
interface SignupFormProps {
@@ -85,13 +85,13 @@ export const SignupForm = ({
}
};
const handleGoogleSuccess = async () => {
// Refresh so the session reflects the new cookie (and whether the account
// still needs a password), then route accordingly. New SSO users skip
// setting a password and go straight to pricing; everyone else goes home.
const handleGoogleSuccess = async (isNewUser: boolean) => {
// Refresh so the session reflects the new cookie, then route accordingly.
// Brand-new accounts see the welcome page once (mirrors the signup flow);
// returning users go home.
await refreshSession(true);
if (useSessionStore.getState().passwordNotSet) {
navigate("/pricing");
if (consumeNewUserWelcome(isNewUser)) {
navigate("/welcome");
} else {
navigate("/");
}
@@ -6,7 +6,7 @@ import { faCheck } from "@fortawesome/pro-solid-svg-icons";
import { Modal } from "@storyteller/ui-modal";
import { Button } from "@storyteller/ui-button";
import { GoogleLoginButton } from "./auth";
import { refreshSession, useSession, useSessionStore } from "../lib/session";
import { consumeNewUserWelcome, refreshSession, useSession } from "../lib/session";
interface SignupCtaState {
isOpen: boolean;
@@ -51,13 +51,13 @@ export function SignupCtaModal() {
const [error, setError] = useState<string | null>(null);
// Mirror the login/signup pages: refresh the session so it reflects the new
// cookie, then send brand-new SSO users straight to pricing. Everyone else
// just closes the modal and stays on the page they were on.
const handleGoogleSuccess = async () => {
// cookie, then send brand-new accounts to the welcome page (once). Everyone
// else just closes the modal and stays on the page they were on.
const handleGoogleSuccess = async (isNewUser: boolean) => {
await refreshSession(true);
close();
if (useSessionStore.getState().passwordNotSet) {
navigate("/pricing");
if (consumeNewUserWelcome(isNewUser)) {
navigate("/welcome");
}
};
@@ -81,6 +81,34 @@ export function updateSessionUser(partial: Partial<UserInfo>): void {
invalidateSession();
}
// Decide whether a just-authenticated user should see the one-time post-signup
// welcome page, and record that they have. Call once after `refreshSession`.
//
// `isNewUser` comes from the SSO response (`username_not_yet_customized`), which
// the backend forces to false for returning Google logins — so it never re-fires
// for normal repeat logins regardless of whether the user customized their
// username. As a belt-and-suspenders guard (e.g. the email→Google-link case where
// the flag can read true on an existing account), we also persist a per-user-token
// marker so the welcome page shows at most once per account on this device.
const WELCOME_SHOWN_KEY_PREFIX = "artcraft.welcomeShown.";
export function consumeNewUserWelcome(isNewUser: boolean): boolean {
if (!isNewUser) return false;
if (typeof window === "undefined") return true;
const userToken = useSessionStore.getState().user?.user_token;
if (!userToken) return true;
const key = `${WELCOME_SHOWN_KEY_PREFIX}${userToken}`;
try {
if (window.localStorage.getItem(key)) return false;
window.localStorage.setItem(key, "1");
} catch {
// localStorage blocked (e.g. privacy mode) — fall through and just show it.
}
return true;
}
// Attach the auth-change listener exactly once per page load. Login/logout/
// password-reset flows dispatch this event; every consumer shares the resulting
// store update instead of each re-running its own effect.
@@ -11,7 +11,7 @@ import { Link, useNavigate, useSearchParams } from "react-router-dom";
import { UsersApi } from "@storyteller/api";
import { AuthHeader, AuthFooter, GoogleLoginButton } from "../../components/auth";
import Seo from "../../components/seo";
import { refreshSession, useSessionStore } from "../../lib/session";
import { consumeNewUserWelcome, refreshSession } from "../../lib/session";
const Login = () => {
const navigate = useNavigate();
@@ -48,13 +48,13 @@ const Login = () => {
}
};
const handleGoogleSuccess = async () => {
// Refresh so the session store reflects the new cookie (and whether the
// account still needs a password) before deciding where to send them.
const handleGoogleSuccess = async (isNewUser: boolean) => {
// Refresh so the session store reflects the new cookie before navigating.
await refreshSession(true);
if (useSessionStore.getState().passwordNotSet) {
// New SSO users skip setting a password and go straight to pricing.
navigate("/pricing");
if (consumeNewUserWelcome(isNewUser)) {
// Brand-new accounts see the welcome page once (mirrors the signup flow);
// returning users go wherever they were headed.
navigate("/welcome");
} else {
navigate(redirectTo);
}