From 863e1d31e3beb39dde13f0719e5f2405f04b621b Mon Sep 17 00:00:00 2001 From: mantrakp04 Date: Fri, 20 Mar 2026 13:04:42 -0700 Subject: [PATCH 01/20] Update pnpm-lock.yaml and enhance dashboard dev tools - Updated package versions for '@supabase/*' libraries to 2.99.2 and '@supabase/ssr' to 0.9.0. - Added new devDependencies for 'rimraf' and 'framer-motion' in the pnpm-lock file. - Modified Next.js configuration to conditionally omit 'X-Frame-Options' in development mode for better integration with Stack Auth dev tools. - Refactored component exports in the template package to include tracking for dev tools. - Introduced new dev tool components and context for improved logging and state management. - Added styles for the dev tool indicator and panel, ensuring a consistent dark theme. - Implemented fetch interception to log API calls and user authentication events in the dev tool. --- apps/dashboard/next.config.mjs | 7 +- claude/CLAUDE-KNOWLEDGE.md | 15 + .../src/app/team/[teamId]/page.tsx | 7 +- packages/react/package.json | 5 + packages/template/package-template.json | 18 +- .../template/src/components-page/sign-in.tsx | 2 + .../src/dev-tool/component-catalog.tsx | 141 ++ .../src/dev-tool/dev-tool-context.tsx | 289 ++++ .../src/dev-tool/dev-tool-indicator.tsx | 243 ++++ .../template/src/dev-tool/dev-tool-panel.tsx | 197 +++ .../template/src/dev-tool/dev-tool-styles.ts | 1269 +++++++++++++++++ .../src/dev-tool/dev-tool-tab-bar.tsx | 99 ++ .../src/dev-tool/dev-tool-trigger.tsx | 21 + .../dev-tool/hooks/use-component-registry.tsx | 130 ++ .../src/dev-tool/hooks/use-dev-tool-state.tsx | 8 + packages/template/src/dev-tool/iframe-tab.tsx | 85 ++ packages/template/src/dev-tool/index.tsx | 29 + .../src/dev-tool/tabs/components-tab.tsx | 584 ++++++++ .../src/dev-tool/tabs/console-tab.tsx | 193 +++ .../src/dev-tool/tabs/dashboard-tab.tsx | 25 + .../template/src/dev-tool/tabs/docs-tab.tsx | 19 + .../src/dev-tool/tabs/overview-tab.tsx | 150 ++ .../src/dev-tool/tabs/support-tab.tsx | 223 +++ packages/template/src/index.ts | 51 +- .../src/providers/stack-provider-client.tsx | 10 + packages/template/src/react-dom.d.ts | 9 + pnpm-lock.yaml | 147 +- 27 files changed, 3888 insertions(+), 88 deletions(-) create mode 100644 packages/template/src/dev-tool/component-catalog.tsx create mode 100644 packages/template/src/dev-tool/dev-tool-context.tsx create mode 100644 packages/template/src/dev-tool/dev-tool-indicator.tsx create mode 100644 packages/template/src/dev-tool/dev-tool-panel.tsx create mode 100644 packages/template/src/dev-tool/dev-tool-styles.ts create mode 100644 packages/template/src/dev-tool/dev-tool-tab-bar.tsx create mode 100644 packages/template/src/dev-tool/dev-tool-trigger.tsx create mode 100644 packages/template/src/dev-tool/hooks/use-component-registry.tsx create mode 100644 packages/template/src/dev-tool/hooks/use-dev-tool-state.tsx create mode 100644 packages/template/src/dev-tool/iframe-tab.tsx create mode 100644 packages/template/src/dev-tool/index.tsx create mode 100644 packages/template/src/dev-tool/tabs/components-tab.tsx create mode 100644 packages/template/src/dev-tool/tabs/console-tab.tsx create mode 100644 packages/template/src/dev-tool/tabs/dashboard-tab.tsx create mode 100644 packages/template/src/dev-tool/tabs/docs-tab.tsx create mode 100644 packages/template/src/dev-tool/tabs/overview-tab.tsx create mode 100644 packages/template/src/dev-tool/tabs/support-tab.tsx create mode 100644 packages/template/src/react-dom.d.ts diff --git a/apps/dashboard/next.config.mjs b/apps/dashboard/next.config.mjs index 8aef410da0..7fb518d6b3 100644 --- a/apps/dashboard/next.config.mjs +++ b/apps/dashboard/next.config.mjs @@ -91,6 +91,7 @@ const nextConfig = { }, async headers() { + const isDev = process.env.NODE_ENV === "development"; return [ { source: "/(.*)", @@ -112,10 +113,12 @@ const nextConfig = { key: "X-Content-Type-Options", value: "nosniff", }, - { + // In development, omit X-Frame-Options so the Stack Auth dev tool + // indicator can embed the dashboard in an iframe. + ...(!isDev ? [{ key: "X-Frame-Options", value: "SAMEORIGIN", - }, + }] : []), { key: "Content-Security-Policy", value: "", diff --git a/claude/CLAUDE-KNOWLEDGE.md b/claude/CLAUDE-KNOWLEDGE.md index 4d5b54ba9e..30ff366ccb 100644 --- a/claude/CLAUDE-KNOWLEDGE.md +++ b/claude/CLAUDE-KNOWLEDGE.md @@ -99,3 +99,18 @@ A: Update affected inline snapshots in `apps/e2e/tests/backend/endpoints/api/v1/ Q: How should `createOrUpdateProjectWithLegacyConfig` handle `onboardingStatus` for forward-compat checks? A: Only write `onboardingStatus` when the `Project.onboardingStatus` column exists (for example by checking `information_schema.columns` in-transaction) so current code can still run against older schemas where that column is absent. + +Q: Why can a client bundle fail with `the chunking context does not support external modules (request: node:module)` after using `require("react-dom")` in `packages/template`? +A: The ESM build rewrites `require(...)` to `createRequire(import.meta.url)` from `node:module`, which Turbopack rejects in client chunks. In client code like `packages/template/src/dev-tool/dev-tool-indicator.tsx`, import `createPortal` directly from `react-dom` instead of using runtime `require`. + +Q: Where does the Stack dev tool Config tab read the API URL and publishable key from? +A: Use `app[stackAppInternalsSymbol].getConstructorOptions()` for `baseUrl` and `publishableClientKey`, resolve the API URL with `getBaseUrl` from `packages/template/src/lib/stack-app/apps/implementations/common.ts`, and show keys redacted (never the full secret server key in the browser). + +Q: Why does the Stack dev tool Components tab preview throw "Translation context not found"? +A: `DevToolEntry` was rendered as a sibling of app `children` inside `StackProviderClient`, while `TranslationProvider` in `StackProvider` only wrapped `children`. Wrap `DevToolEntry` in `TranslationProvider` in `stack-provider-client.tsx` so previews and the panel use the same translation context as Stack UI components. + +Q: How does the Stack dev tool Components tab list every SDK component plus custom UI and show what is mounted? +A: Built-in names come from `BUILTIN_STACK_DEV_TOOL_COMPONENT_NAMES` in `packages/template/src/dev-tool/builtin-component-names.ts` (always listed). Apps call `registerDevToolComponentCatalog([{ id, displayName? }])` once (e.g. in a root client provider) to list custom components. Each component that should report instances calls `useDevToolRegister('SameIdAsCatalog', props)` with a **stable** `props` reference where possible. The list shows green = at least one mounted instance on the current route, gray = not rendered; multiple instances expand into sub-rows. + +Q: Why did Next.js report `Can't resolve '../utils.js'` from `packages/stack/dist/esm/dev-tool/tabs/console-tab.js`? +A: `tsdown` emits each source file as its own chunk and marks relative imports as external, so `console-tab.js` expects a sibling `utils.js`. If `utils.ts` was added but `packages/stack` was not fully rebuilt (or `dist` was partially updated), that file can be missing. Dev-tool URL/key helpers live in `dev-tool-context.tsx` so tabs import them from the same module as the context (no separate `dev-tool/utils` chunk). After template changes, run `pnpm run generate-sdks` and rebuild `@stackframe/stack` so `dist` stays consistent. diff --git a/examples/docs-examples/src/app/team/[teamId]/page.tsx b/examples/docs-examples/src/app/team/[teamId]/page.tsx index 40cbb1df5a..cbf91bfaf5 100644 --- a/examples/docs-examples/src/app/team/[teamId]/page.tsx +++ b/examples/docs-examples/src/app/team/[teamId]/page.tsx @@ -13,7 +13,12 @@ export default function TeamPage({ params }: { params: { teamId: string } }) { return (
`/team/${team.id}`} + urlMap={(t) => { + if (t == null) { + throw new Error("SelectedTeamSwitcher urlMap expected a non-null team"); + } + return `/team/${t.id}`; + }} selectedTeam={team} /> diff --git a/packages/react/package.json b/packages/react/package.json index 3d9d16aaa4..ec6f1e0f06 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -83,9 +83,14 @@ }, "peerDependencies": { "@types/react": ">=18.3.0", + "@types/react-dom": ">=18.3.0", + "react-dom": ">=18.3.0", "react": ">=18.3.0" }, "peerDependenciesMeta": { + "@types/react-dom": { + "optional": true + }, "@types/react": { "optional": true } diff --git a/packages/template/package-template.json b/packages/template/package-template.json index 938a8ab11f..9224acb961 100644 --- a/packages/template/package-template.json +++ b/packages/template/package-template.json @@ -121,18 +121,20 @@ }, "//": "IF_PLATFORM react-like", "peerDependencies": { - "@types/react": ">=18.3.0", - "//": "IF_PLATFORM next", - "@types/react-dom": ">=18.3.0", - "react-dom": ">=18.3.0", - "next": ">=14.1 || >=15.0.0-canary.0 || >=15.0.0-rc.0", - "//": "END_PLATFORM", - "react": ">=18.3.0" + "@types/react": ">=18.3.0" + ,"//": "IF_PLATFORM react-like" + ,"@types/react-dom": ">=18.3.0", + "react-dom": ">=18.3.0" + ,"//": "END_PLATFORM", + ,"//": "IF_PLATFORM next", + ,"next": ">=14.1 || >=15.0.0-canary.0 || >=15.0.0-rc.0" + ,"//": "END_PLATFORM", + ,"react": ">=18.3.0" }, "//": "END_PLATFORM", "//": "IF_PLATFORM react-like", "peerDependenciesMeta": { - "//": "IF_PLATFORM next", + "//": "IF_PLATFORM react-like", "@types/react-dom": { "optional": true }, diff --git a/packages/template/src/components-page/sign-in.tsx b/packages/template/src/components-page/sign-in.tsx index 8a934f5f1b..4d5bf43b35 100644 --- a/packages/template/src/components-page/sign-in.tsx +++ b/packages/template/src/components-page/sign-in.tsx @@ -1,3 +1,5 @@ +'use client'; + import { AuthPage } from "./auth-page"; export function SignIn(props: { diff --git a/packages/template/src/dev-tool/component-catalog.tsx b/packages/template/src/dev-tool/component-catalog.tsx new file mode 100644 index 0000000000..f1a6f0b479 --- /dev/null +++ b/packages/template/src/dev-tool/component-catalog.tsx @@ -0,0 +1,141 @@ +"use client"; + +import React from "react"; +import { stringCompare } from "@stackframe/stack-shared/dist/utils/strings"; +import { AccountSettings } from "../components-page/account-settings"; +import { AuthPage } from "../components-page/auth-page"; +import { EmailVerification } from "../components-page/email-verification"; +import { ForgotPassword } from "../components-page/forgot-password"; +import { PasswordReset } from "../components-page/password-reset"; +import { SignIn } from "../components-page/sign-in"; +import { SignUp } from "../components-page/sign-up"; +import { CredentialSignIn } from "../components/credential-sign-in"; +import { CredentialSignUp } from "../components/credential-sign-up"; +import { MagicLinkSignIn } from "../components/magic-link-sign-in"; +import { OAuthButton } from "../components/oauth-button"; +import { OAuthButtonGroup } from "../components/oauth-button-group"; +import { SelectedTeamSwitcher } from "../components/selected-team-switcher"; +import { TeamSwitcher } from "../components/team-switcher"; +import { UserButton } from "../components/user-button"; + +// IF_PLATFORM react-like + +/** + * Catalog entry for a Stack SDK component. The dev tool uses this to: + * - list every known component (with in-use status) + * - render live previews + * - show prop tables + * + * Adding a component here is all that's needed — no hooks inside the component. + */ +export type CatalogEntry = { + /** The actual component function/class */ + component: React.ComponentType; + /** + * Optional preview renderer. Defaults to rendering `component` with the + * detected props. Override when a component needs special handling + * (e.g. PasswordReset depends on async token verification). + */ + preview?: 'none' | ((props: Record) => React.ReactNode); + /** + * Extra instructions for generating implementation prompts from the dev tool. + */ + promptNotes?: readonly string[]; +}; + +/** + * The single source of truth for every Stack SDK component the dev tool knows + * about. Keys are display names; values carry the component reference. + * + * To register a new component, just add it here. + */ +export const COMPONENT_CATALOG: Record = { + AccountSettings: { + component: AccountSettings, + promptNotes: [ + "Use this inside an app that is already wrapped in Stack Auth's provider.", + "Prefer the built-in Account Settings experience instead of rebuilding profile, sessions, and auth settings manually.", + ], + }, + AuthPage: { + component: AuthPage, + promptNotes: [ + "Set the `type` prop explicitly to either `sign-in` or `sign-up`.", + "Keep auth flows delegated to Stack Auth instead of custom form wiring where possible.", + ], + }, + CredentialSignIn: { component: CredentialSignIn }, + CredentialSignUp: { component: CredentialSignUp }, + EmailVerification: { + component: EmailVerification, + promptNotes: [ + "Use this on a route that can pass the email verification code from URL search params.", + "Keep the verify/cancel flows handled by Stack Auth.", + ], + }, + ForgotPassword: { + component: ForgotPassword, + promptNotes: [ + "Use this on a client page and rely on Stack Auth to send the reset email.", + ], + }, + MagicLinkSignIn: { component: MagicLinkSignIn }, + OAuthButton: { + component: OAuthButton, + promptNotes: [ + "Pass a concrete provider id like `google`, `github`, or another configured OAuth provider.", + "Use the existing Stack Auth app configuration instead of hardcoding OAuth URLs.", + ], + }, + OAuthButtonGroup: { + component: OAuthButtonGroup, + promptNotes: [ + "Render this when you want the configured OAuth providers for the current project automatically.", + ], + }, + PasswordReset: { + component: PasswordReset, + preview: 'none', + promptNotes: [ + "Use this on a route that can pass the password reset code from URL search params.", + "Do not reimplement password reset verification manually; let Stack Auth handle it.", + ], + }, + SelectedTeamSwitcher: { + component: SelectedTeamSwitcher, + promptNotes: [ + "Use this when your app already has a selected team context and you want to switch or clear it.", + ], + }, + SignIn: { + component: SignIn, + promptNotes: [ + "Use the built-in sign-in page rather than rebuilding the flow by hand.", + ], + }, + SignUp: { + component: SignUp, + promptNotes: [ + "Use the built-in sign-up page rather than rebuilding the flow by hand.", + ], + }, + TeamSwitcher: { + component: TeamSwitcher, + promptNotes: [ + "Use this where a signed-in user can switch teams or open team settings.", + ], + }, + UserButton: { + component: UserButton, + promptNotes: [ + "Use this inside authenticated app chrome, typically in a header or account menu area.", + ], + }, +}; + +/** Sorted list of all catalog component names */ +export const CATALOG_NAMES: readonly string[] = Object.keys(COMPONENT_CATALOG).sort( + stringCompare +); + +// END_PLATFORM diff --git a/packages/template/src/dev-tool/dev-tool-context.tsx b/packages/template/src/dev-tool/dev-tool-context.tsx new file mode 100644 index 0000000000..5ef52470f0 --- /dev/null +++ b/packages/template/src/dev-tool/dev-tool-context.tsx @@ -0,0 +1,289 @@ +"use client"; + +import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; +import { getBaseUrl } from "../lib/stack-app/apps/implementations/common"; +import { stackAppInternalsSymbol, type StackClientApp } from "../lib/stack-app"; + +// IF_PLATFORM react-like + +export type TabId = 'overview' | 'components' | 'docs' | 'dashboard' | 'console' | 'support'; + +export type RegisteredComponent = { + name: string; + instanceId: string; + props: Record; + mountedAt: number; +}; + +export type ApiLogEntry = { + id: string; + timestamp: number; + method: string; + url: string; + status?: number; + duration?: number; + error?: string; +}; + +export type EventLogEntry = { + id: string; + timestamp: number; + type: 'sign-in' | 'sign-out' | 'sign-up' | 'token-refresh' | 'error' | 'info'; + message: string; +}; + +export type ConsoleSubTab = 'console' | 'events' | 'info'; + +export type DevToolState = { + isOpen: boolean; + activeTab: TabId; + consoleSubTab: ConsoleSubTab; + panelWidth: number; + panelHeight: number; +}; + +const STORAGE_KEY = '__stack-dev-tool-state'; +const MAX_LOG_ENTRIES = 500; + +const DEFAULT_STATE: DevToolState = { + isOpen: false, + activeTab: 'overview', + consoleSubTab: 'console', + panelWidth: 800, + panelHeight: 520, +}; + +function loadState(): DevToolState { + try { + if (typeof window !== 'undefined') { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) { + const parsed = JSON.parse(stored); + return { ...DEFAULT_STATE, ...parsed }; + } + } + } catch { + // ignore + } + return DEFAULT_STATE; +} + +function saveState(state: DevToolState) { + try { + if (typeof window !== 'undefined') { + localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); + } + } catch { + // ignore + } +} + +// --------------------------------------------------------------------------- +// Global log store — survives React remounts / navigations (SPA) +// --------------------------------------------------------------------------- +type LogListener = () => void; + +const globalLogStore = { + apiLogs: [] as ApiLogEntry[], + eventLogs: [] as EventLogEntry[], + listeners: new Set(), + + addApiLog(entry: ApiLogEntry) { + this.apiLogs = [entry, ...this.apiLogs].slice(0, MAX_LOG_ENTRIES); + this.notify(); + }, + + addEventLog(entry: EventLogEntry) { + this.eventLogs = [entry, ...this.eventLogs].slice(0, MAX_LOG_ENTRIES); + this.notify(); + }, + + clear() { + this.apiLogs = []; + this.eventLogs = []; + this.notify(); + }, + + subscribe(listener: LogListener) { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + }, + + notify() { + for (const listener of this.listeners) { + listener(); + } + }, +}; + +// Expose globally so the fetch interceptor (which may be installed once) can +// always reach the latest store even after HMR / remounts. +if (typeof globalThis !== 'undefined') { + (globalThis as any).__STACK_DEV_TOOL_LOG_STORE__ = globalLogStore; +} + +/** + * React hook that subscribes to the global log store and returns the current + * snapshot plus mutation helpers. The snapshot reference only changes when the + * store is actually mutated, so downstream memoisation works correctly. + */ +function useGlobalLogStore() { + const [, forceRender] = useState(0); + + useEffect(() => { + return globalLogStore.subscribe(() => forceRender((n) => n + 1)); + }, []); + + const addApiLog = useCallback((entry: ApiLogEntry) => globalLogStore.addApiLog(entry), []); + const addEventLog = useCallback((entry: EventLogEntry) => globalLogStore.addEventLog(entry), []); + const clearLogs = useCallback(() => globalLogStore.clear(), []); + + return { + apiLogs: globalLogStore.apiLogs, + eventLogs: globalLogStore.eventLogs, + addApiLog, + addEventLog, + clearLogs, + }; +} + +// --------------------------------------------------------------------------- + +type DevToolContextValue = { + state: DevToolState; + setState: React.Dispatch>; + components: Map; + registerComponent: (name: string, instanceId: string, props: Record) => void; + unregisterComponent: (instanceId: string) => void; + apiLogs: ApiLogEntry[]; + addApiLog: (entry: ApiLogEntry) => void; + eventLogs: EventLogEntry[]; + addEventLog: (entry: EventLogEntry) => void; + clearLogs: () => void; +}; + +const DevToolContext = createContext(null); + +export function DevToolProvider({ children }: { children: React.ReactNode }) { + const [state, setStateRaw] = useState(loadState); + const [components, setComponents] = useState>(new Map()); + const { apiLogs, addApiLog, eventLogs, addEventLog, clearLogs } = useGlobalLogStore(); + + const setState: React.Dispatch> = useCallback((action) => { + setStateRaw((prev) => { + const next = typeof action === 'function' ? action(prev) : action; + saveState(next); + return next; + }); + }, []); + + const registerComponent = useCallback((name: string, instanceId: string, props: Record) => { + setComponents((prev) => { + const next = new Map(prev); + next.set(instanceId, { name, instanceId, props, mountedAt: Date.now() }); + return next; + }); + }, []); + + const unregisterComponent = useCallback((instanceId: string) => { + setComponents((prev) => { + const next = new Map(prev); + next.delete(instanceId); + return next; + }); + }, []); + + const value = useMemo(() => ({ + state, + setState, + components, + registerComponent, + unregisterComponent, + apiLogs, + addApiLog, + eventLogs, + addEventLog, + clearLogs, + }), [state, setState, components, registerComponent, unregisterComponent, apiLogs, addApiLog, eventLogs, addEventLog, clearLogs]); + + return ( + + {children} + + ); +} + +export function useDevToolContext() { + const context = useContext(DevToolContext); + if (!context) { + throw new Error('useDevToolContext must be used within a DevToolProvider'); + } + return context; +} + +/** + * Derives the dashboard base URL from the resolved Stack Auth API base URL. + * + * Mapping: + * - Production API `https://api.stack-auth.com` → `https://app.stack-auth.com` + * - Local dev API `http://localhost:8102` → `http://localhost:8101` (port XX02 → XX01) + * - Self-hosted `https://api.myapp.com` → `https://app.myapp.com` + */ +export function deriveDashboardBaseUrl(apiBaseUrl: string): string { + try { + const url = new URL(apiBaseUrl); + + // localhost / 127.0.0.1: shift port from XX02 → XX01 + if (url.hostname === 'localhost' || url.hostname === '127.0.0.1') { + const port = url.port; + if (port && port.endsWith('02')) { + url.port = port.slice(0, -2) + '01'; + } + return url.origin; + } + + // Hosted: api.example.com → app.example.com + if (url.hostname.startsWith('api.')) { + url.hostname = 'app.' + url.hostname.slice(4); + return url.origin; + } + + return url.origin; + } catch { + return 'https://app.stack-auth.com'; + } +} + +/** + * Resolves the API base URL from a StackClientApp instance. + */ +export function resolveApiBaseUrl(app: StackClientApp): string { + const opts = app[stackAppInternalsSymbol].getConstructorOptions(); + return getBaseUrl(opts.baseUrl); +} + +/** + * Returns the full project-specific dashboard URL for the given app. + */ +export function resolveDashboardUrl(app: StackClientApp): string { + const apiUrl = resolveApiBaseUrl(app); + const base = deriveDashboardBaseUrl(apiUrl); + return `${base}/projects/${encodeURIComponent(app.projectId)}`; +} + +/** + * Redacts the middle of API keys for safe display in the dev tool. + */ +export function maskProjectKey(value: string | undefined): string { + if (value == null || value === '') { + return 'Not set'; + } + if (value.length <= 8) { + return '\u2022'.repeat(value.length); + } + return `${value.slice(0, 8)}\u2026${value.slice(-4)}`; +} + +// END_PLATFORM diff --git a/packages/template/src/dev-tool/dev-tool-indicator.tsx b/packages/template/src/dev-tool/dev-tool-indicator.tsx new file mode 100644 index 0000000000..8a30a4d916 --- /dev/null +++ b/packages/template/src/dev-tool/dev-tool-indicator.tsx @@ -0,0 +1,243 @@ +"use client"; + +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { DevToolProvider, useDevToolContext, resolveApiBaseUrl, type ApiLogEntry, type EventLogEntry } from "./dev-tool-context"; +import { DevToolTrigger } from "./dev-tool-trigger"; +import { DevToolPanel } from "./dev-tool-panel"; +import { devToolCSS } from "./dev-tool-styles"; +import { useStackApp, useUser } from "../lib/hooks"; + +// IF_PLATFORM react-like + +let idCounter = 0; +function nextId() { + return `sdt-${++idCounter}-${Date.now()}`; +} + +/** + * Intercepts window.fetch to capture Stack Auth API calls. + * Only intercepts requests that include the `X-Stack-Project-Id` header. + */ +function useFetchInterceptor(addApiLog: (entry: ApiLogEntry) => void, addEventLog: (entry: EventLogEntry) => void) { + const addApiLogRef = useRef(addApiLog); + addApiLogRef.current = addApiLog; + const addEventLogRef = useRef(addEventLog); + addEventLogRef.current = addEventLog; + + useEffect(() => { + if (typeof window === 'undefined') return; + + const originalFetch = window.fetch; + + window.fetch = async function patchedFetch(input: RequestInfo | URL, init?: RequestInit) { + // Determine if this is a Stack Auth API call by checking for the header + const headers = init?.headers; + let isStackCall = false; + let method = init?.method || 'GET'; + + if (headers) { + if (headers instanceof Headers) { + isStackCall = headers.has('X-Stack-Project-Id'); + } else if (Array.isArray(headers)) { + isStackCall = headers.some(([key]) => key === 'X-Stack-Project-Id'); + } else { + isStackCall = 'X-Stack-Project-Id' in headers; + } + } + + if (!isStackCall) { + return await originalFetch.call(window, input, init); + } + + const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; + // Strip query params with nonces for cleaner display + let displayUrl = url; + try { + const u = new URL(url); + u.searchParams.delete('X-Stack-Random-Nonce'); + // Show path only for cleaner logs + displayUrl = u.pathname + (u.search || ''); + } catch { + // keep full url + } + + const startTime = Date.now(); + + try { + const response = await originalFetch.call(window, input, init); + + const duration = Date.now() - startTime; + addApiLogRef.current({ + id: nextId(), + timestamp: startTime, + method: method.toUpperCase(), + url: displayUrl, + status: response.status, + duration, + }); + + // Detect auth-related events from the response path + if (displayUrl.includes('/auth/')) { + if (displayUrl.includes('/auth/oauth/token') && response.ok) { + addEventLogRef.current({ + id: nextId(), + timestamp: Date.now(), + type: 'token-refresh', + message: 'Token refreshed', + }); + } + if (displayUrl.includes('/auth/sessions') && init?.method === 'DELETE' && response.ok) { + addEventLogRef.current({ + id: nextId(), + timestamp: Date.now(), + type: 'sign-out', + message: 'User signed out (session deleted)', + }); + } + } + + if (!response.ok && response.status >= 400) { + addEventLogRef.current({ + id: nextId(), + timestamp: Date.now(), + type: 'error', + message: `API error ${response.status} on ${method.toUpperCase()} ${displayUrl}`, + }); + } + + return response; + } catch (err) { + const duration = Date.now() - startTime; + addApiLogRef.current({ + id: nextId(), + timestamp: startTime, + method: method.toUpperCase(), + url: displayUrl, + duration, + error: err instanceof Error ? err.message : 'Network error', + }); + + addEventLogRef.current({ + id: nextId(), + timestamp: Date.now(), + type: 'error', + message: `Network error on ${method.toUpperCase()} ${displayUrl}: ${err instanceof Error ? err.message : 'Unknown'}`, + }); + + throw err; + } + }; + + return () => { + window.fetch = originalFetch; + }; + }, []); +} + +/** + * Watches user state changes to log auth events. + */ +function useAuthEventTracker(addEventLog: (entry: EventLogEntry) => void) { + const user = useUser(); + const prevUserRef = useRef(undefined); + const addEventLogRef = useRef(addEventLog); + addEventLogRef.current = addEventLog; + + useEffect(() => { + const prevUser = prevUserRef.current; + // Skip initial mount (prevUser is undefined) + if (prevUser === undefined) { + prevUserRef.current = user; + if (user) { + addEventLogRef.current({ + id: nextId(), + timestamp: Date.now(), + type: 'info', + message: `Session started: ${user.displayName || user.primaryEmail || user.id}`, + }); + } + return; + } + + if (!prevUser && user) { + addEventLogRef.current({ + id: nextId(), + timestamp: Date.now(), + type: 'sign-in', + message: `User signed in: ${user.displayName || user.primaryEmail || user.id}`, + }); + } else if (prevUser && !user) { + addEventLogRef.current({ + id: nextId(), + timestamp: Date.now(), + type: 'sign-out', + message: 'User signed out', + }); + } + + prevUserRef.current = user; + }, [user]); +} + +function DevToolIndicatorInner() { + const { state, setState, addApiLog, addEventLog } = useDevToolContext(); + const [portalContainer, setPortalContainer] = useState(null); + + // Wire up fetch interceptor and auth event tracking + useFetchInterceptor(addApiLog, addEventLog); + useAuthEventTracker(addEventLog); + + useEffect(() => { + // Create a portal container attached to document.body + const container = document.createElement('div'); + container.id = '__stack-dev-tool-root'; + document.body.appendChild(container); + setPortalContainer(container); + + return () => { + document.body.removeChild(container); + }; + }, []); + + // Keyboard shortcut: Ctrl+Shift+S / Cmd+Shift+S + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'S') { + e.preventDefault(); + setState((prev) => ({ ...prev, isOpen: !prev.isOpen })); + } + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [setState]); + + const togglePanel = useCallback(() => { + setState((prev) => ({ ...prev, isOpen: !prev.isOpen })); + }, [setState]); + + const closePanel = useCallback(() => { + setState((prev) => ({ ...prev, isOpen: false })); + }, [setState]); + + if (portalContainer == null) return null; + + return createPortal( +
+