Files
multica/packages/core/platform/auth-initializer.tsx
Bohan Jiang 205e8c1e9c feat(analytics): client_type super-property + Desktop $pageview (MUL-1253) (#1490)
* feat(analytics): client_type super-property + Desktop $pageview (MUL-1253)

Register a `client_type` super-property ("desktop" | "web") plus optional
`app_version` inside `initAnalytics`, so every PostHog event from the
renderer can be split by client without relying on `$lib` (both Electron
and Next.js report "web"). `appVersion` flows in from `ClientIdentity`
via `CoreProvider` → `AuthInitializer`.

Add a Desktop `PageviewTracker` mounted in `DesktopShell` that fires
`$pageview` whenever the active tab's path changes, mirroring the Web
tracker. Restores the `/ → signup → workspace_created` funnel for the
desktop client and enables web-vs-desktop breakdowns.

* fix(analytics): preserve super-props on reset + cover overlay/login pageviews

Two blockers from PR review:

1. `posthog.reset()` wipes persisted super-properties, so after logout or
   account switch the next session's events silently dropped `client_type`
   and `app_version` until a full reload. Cache the set at init time and
   re-register it inside `resetAnalytics()` so the breakdown survives the
   auth transition. Added unit tests to pin the invariant.

2. Desktop `PageviewTracker` only watched the active tab path, which
   missed pre-workspace overlays (`/onboarding`, `/workspaces/new`,
   `/invite/<id>`) — those aren't tab routes on desktop — and also missed
   the logged-out `/login` state. Move the tracker to the app root and
   derive the visible path from `(user, overlay, activeTabPath)` with
   overlay > tab precedence so the `$pageview` stream matches the
   surface the user actually sees.
2026-04-22 17:02:58 +08:00

129 lines
3.8 KiB
TypeScript

"use client";
import { useEffect, type ReactNode } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { getApi } from "../api";
import { useAuthStore } from "../auth";
import {
captureSignupSource,
identify as identifyAnalytics,
initAnalytics,
resetAnalytics,
} from "../analytics";
import { configStore } from "../config";
import { workspaceKeys } from "../workspace/queries";
import { createLogger } from "../logger";
import { defaultStorage } from "./storage";
import { setCurrentWorkspace } from "./workspace-storage";
import type { ClientIdentity } from "./types";
import type { StorageAdapter } from "../types/storage";
import type { User } from "../types";
const logger = createLogger("auth");
export function AuthInitializer({
children,
onLogin,
onLogout,
storage = defaultStorage,
cookieAuth,
identity,
}: {
children: ReactNode;
onLogin?: () => void;
onLogout?: () => void;
storage?: StorageAdapter;
cookieAuth?: boolean;
identity?: ClientIdentity;
}) {
const qc = useQueryClient();
useEffect(() => {
const api = getApi();
// Stamp attribution before anything else — the signup event (server-side)
// reads this cookie, so it has to be present before the user hits submit.
captureSignupSource();
// Fetch app config (CDN domain, PostHog key, …) in the background — non-blocking.
api
.getConfig()
.then((cfg) => {
if (cfg.cdn_domain) configStore.getState().setCdnDomain(cfg.cdn_domain);
configStore.getState().setAuthConfig({
allowSignup: cfg.allow_signup,
googleClientId: cfg.google_client_id,
});
if (cfg.posthog_key) {
initAnalytics({
key: cfg.posthog_key,
host: cfg.posthog_host || "",
appVersion: identity?.version,
});
}
})
.catch(() => {
/* config is optional — legacy file card matching degrades gracefully */
});
const onAuthSuccess = (user: User) => {
onLogin?.();
useAuthStore.setState({ user, isLoading: false });
identifyAnalytics(user.id, { email: user.email, name: user.name });
};
const onAuthFailure = () => {
onLogout?.();
resetAnalytics();
useAuthStore.setState({ user: null, isLoading: false });
};
if (cookieAuth) {
// Cookie mode: the HttpOnly cookie is sent automatically by the browser.
// Call the API to check if the session is still valid.
//
// Seed the workspace list into React Query so the URL-driven layout can
// resolve the slug without a second fetch. The active workspace itself
// is derived from the URL by [workspaceSlug]/layout.tsx — no imperative
// selection here.
Promise.all([api.getMe(), api.listWorkspaces()])
.then(([user, wsList]) => {
onAuthSuccess(user);
qc.setQueryData(workspaceKeys.list(), wsList);
})
.catch((err) => {
logger.error("cookie auth init failed", err);
onAuthFailure();
});
return;
}
// Token mode: read from localStorage (Electron / legacy).
const token = storage.getItem("multica_token");
if (!token) {
onLogout?.();
useAuthStore.setState({ isLoading: false });
return;
}
api.setToken(token);
Promise.all([api.getMe(), api.listWorkspaces()])
.then(([user, wsList]) => {
onAuthSuccess(user);
// Seed React Query cache so the URL-driven layout can resolve the
// slug without a second fetch.
qc.setQueryData(workspaceKeys.list(), wsList);
})
.catch((err) => {
logger.error("auth init failed", err);
api.setToken(null);
setCurrentWorkspace(null, null);
storage.removeItem("multica_token");
onAuthFailure();
});
}, []);
return <>{children}</>;
}