From 9a18fde0bfe5f70872e3f32d28d8c7d87ed8d0f4 Mon Sep 17 00:00:00 2001 From: nhyiramante1 Date: Wed, 17 Jun 2026 15:55:40 -0400 Subject: [PATCH 1/4] feat(frontend): remove Auth0, make Better Auth the default (Phase 2) Make Better Auth the default auth on every real-auth surface and delete Auth0. Demo mode stays (Google Docs runs in demo). Token now persists across refreshes. - authTokenStore.ts (new): guarded localStorage token persistence. - useDeviceAuth.ts: persist/clear + hydrate-on-mount (validate stored token, restore session without a fresh device flow). - appAuthContext.tsx: drop the Auth0 adapter + opt-in gate; Better Auth is the default, Demo for demo mode. - types.d.ts + editor APIs: replace EditorAPI.doLogin/doLogout(auth0Client) with a surface-specific openExternal(url) (Word: openBrowserWindow guarded; standalone/GDocs: window.open). Delete dead Auth0 code. - app/index.tsx: remove Auth0Provider; approval link uses editorAPI.openExternal; drop Microsoft/Facebook provider icons (Google only). - Remove popup.tsx/popup.html + their webpack entries; remove AUTH0 DefinePlugin entries from both webpack configs; remove @auth0/auth0-react. - privacypolicy.html: minimal wording + TODO for team review. No OpenAI route protection; no Word/Google Docs behavior change beyond auth. Co-Authored-By: Claude Opus 4.8 --- frontend/package-lock.json | 51 +------ frontend/package.json | 1 - .../src/api/__tests__/authTokenStore.test.ts | 70 +++++++++ frontend/src/api/authTokenStore.ts | 39 +++++ frontend/src/api/googleDocsEditorAPI.ts | 83 +---------- frontend/src/api/wordEditorAPI.ts | 133 ++---------------- frontend/src/contexts/appAuthContext.tsx | 77 ++-------- frontend/src/contexts/editorContext.tsx | 5 +- frontend/src/editor/index.tsx | 22 +-- frontend/src/hooks/useDeviceAuth.ts | 47 +++++-- frontend/src/index-gdocs.tsx | 4 +- frontend/src/pages/app/index.tsx | 64 +++------ frontend/src/popup.html | 23 --- frontend/src/popup.tsx | 62 -------- frontend/src/static/privacypolicy.html | 5 +- frontend/src/types.d.ts | 8 +- frontend/webpack.config.js | 14 -- frontend/webpack.google-docs.config.js | 2 - 18 files changed, 217 insertions(+), 493 deletions(-) create mode 100644 frontend/src/api/__tests__/authTokenStore.test.ts create mode 100644 frontend/src/api/authTokenStore.ts delete mode 100644 frontend/src/popup.html delete mode 100644 frontend/src/popup.tsx diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f3c9c397..9ba5ded4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,7 +10,6 @@ "license": "MIT", "dependencies": { "@ai-sdk/openai": "^2.0.0", - "@auth0/auth0-react": "^2.2.4", "@lexical/react": "^0.16.1", "@posthog/react": "^1.9.0", "@react-hook/window-size": "^3.1.1", @@ -271,30 +270,6 @@ "license": "MIT", "peer": true }, - "node_modules/@auth0/auth0-react": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@auth0/auth0-react/-/auth0-react-2.8.0.tgz", - "integrity": "sha512-f3KOkq+TW7AC3T+ZAo9G0hNL339z15C9q00QDVrMGCzZAPyp8lvDHKcAs21d/u+GzhU5zmssvJTQggDR7JqxSA==", - "license": "MIT", - "dependencies": { - "@auth0/auth0-spa-js": "^2.7.0" - }, - "peerDependencies": { - "react": "^16.11.0 || ^17 || ^18 || ^19", - "react-dom": "^16.11.0 || ^17 || ^18 || ^19" - } - }, - "node_modules/@auth0/auth0-spa-js": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@auth0/auth0-spa-js/-/auth0-spa-js-2.8.0.tgz", - "integrity": "sha512-Lu3dBius0CMRHNAWtw/RyIZH0b5B4jV9ZlVjpp5s7A11AO/XyABkNl0VW7Cz5ZHpAkXEba1CMnkxDG1/9LNIqg==", - "license": "MIT", - "dependencies": { - "browser-tabs-lock": "^1.2.15", - "dpop": "^2.1.1", - "es-cookie": "~1.3.2" - } - }, "node_modules/@azure/abort-controller": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz", @@ -9478,16 +9453,6 @@ "node": ">=8" } }, - "node_modules/browser-tabs-lock": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/browser-tabs-lock/-/browser-tabs-lock-1.3.0.tgz", - "integrity": "sha512-g6nHaobTiT0eMZ7jh16YpD2kcjAp+PInbiVq3M1x6KKaEIVhT4v9oURNIpZLOZ3LQbQ3XYfNhMAb/9hzNLIWrw==", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "lodash": ">=4.17.21" - } - }, "node_modules/browserslist": { "version": "4.28.1", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", @@ -11231,15 +11196,6 @@ "node": ">=10" } }, - "node_modules/dpop": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/dpop/-/dpop-2.1.1.tgz", - "integrity": "sha512-J0Of2JTiM4h5si0tlbPQ/lkqfZ5wAEVkKYBhkwyyANnPJfWH4VsR5uIkZ+T+OSPIwDYUg1fbd5Mmodd25HjY1w==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -11475,12 +11431,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/es-cookie": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/es-cookie/-/es-cookie-1.3.2.tgz", - "integrity": "sha512-UTlYYhXGLOy05P/vKVT2Ui7WtC7NiRzGtJyAKKn32g5Gvcjn7KAClLPWlipCtxIus934dFg9o9jXiBL0nP+t9Q==", - "license": "MIT" - }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -15720,6 +15670,7 @@ "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, "license": "MIT" }, "node_modules/lodash.debounce": { diff --git a/frontend/package.json b/frontend/package.json index bb8102b5..b7f5f029 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -46,7 +46,6 @@ }, "dependencies": { "@ai-sdk/openai": "^2.0.0", - "@auth0/auth0-react": "^2.2.4", "@lexical/react": "^0.16.1", "@posthog/react": "^1.9.0", "@react-hook/window-size": "^3.1.1", diff --git a/frontend/src/api/__tests__/authTokenStore.test.ts b/frontend/src/api/__tests__/authTokenStore.test.ts new file mode 100644 index 00000000..d77da524 --- /dev/null +++ b/frontend/src/api/__tests__/authTokenStore.test.ts @@ -0,0 +1,70 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { clearToken, loadToken, persistToken } from '../authTokenStore'; + +// Minimal in-memory localStorage stub so the store can be tested in the node +// environment (no jsdom dependency). +function makeStorage() { + const map = new Map(); + return { + getItem: (k: string) => (map.has(k) ? map.get(k)! : null), + setItem: (k: string, v: string) => { + map.set(k, v); + }, + removeItem: (k: string) => { + map.delete(k); + }, + clear: () => map.clear(), + }; +} + +beforeEach(() => { + vi.stubGlobal('localStorage', makeStorage()); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('authTokenStore', () => { + it('persists and loads a token round-trip', () => { + expect(loadToken()).toBeNull(); + persistToken('tok-123'); + expect(loadToken()).toBe('tok-123'); + }); + + it('clears the token', () => { + persistToken('tok-123'); + clearToken(); + expect(loadToken()).toBeNull(); + }); + + it('degrades gracefully when setItem throws (no token persisted)', () => { + vi.stubGlobal('localStorage', { + ...makeStorage(), + setItem: () => { + throw new Error('storage disabled'); + }, + }); + expect(() => persistToken('tok-123')).not.toThrow(); + }); + + it('returns null when getItem throws', () => { + vi.stubGlobal('localStorage', { + ...makeStorage(), + getItem: () => { + throw new Error('storage disabled'); + }, + }); + expect(loadToken()).toBeNull(); + }); + + it('does not throw when removeItem throws', () => { + vi.stubGlobal('localStorage', { + ...makeStorage(), + removeItem: () => { + throw new Error('storage disabled'); + }, + }); + expect(() => clearToken()).not.toThrow(); + }); +}); diff --git a/frontend/src/api/authTokenStore.ts b/frontend/src/api/authTokenStore.ts new file mode 100644 index 00000000..4386f27e --- /dev/null +++ b/frontend/src/api/authTokenStore.ts @@ -0,0 +1,39 @@ +/** + * Persisted Better Auth token store. + * + * Keeps the device-flow access token across page refreshes so Better Auth can be the + * default auth without forcing a fresh interactive login on every reload. + * + * Every localStorage access is guarded: Office task panes and other embedded browsers + * can throw on storage access under some privacy/host settings. On failure we degrade to + * in-memory-only for the session (load returns null; persist/clear become no-ops) so the + * app keeps working. + * + * Security note: localStorage is XSS-exposed. This matches Auth0's prior + * `cacheLocation="localstorage"` posture, so it does not raise the existing risk level. + */ +const TOKEN_KEY = 'betterauth.token'; + +export function persistToken(token: string): void { + try { + localStorage.setItem(TOKEN_KEY, token); + } catch { + // Storage unavailable — fall back to in-memory for this session. + } +} + +export function loadToken(): string | null { + try { + return localStorage.getItem(TOKEN_KEY); + } catch { + return null; + } +} + +export function clearToken(): void { + try { + localStorage.removeItem(TOKEN_KEY); + } catch { + // No-op if storage is unavailable. + } +} diff --git a/frontend/src/api/googleDocsEditorAPI.ts b/frontend/src/api/googleDocsEditorAPI.ts index 88573f2a..14d60a53 100644 --- a/frontend/src/api/googleDocsEditorAPI.ts +++ b/frontend/src/api/googleDocsEditorAPI.ts @@ -8,8 +8,6 @@ * all document operations here go through Apps Script on Google's servers. */ -import type { Auth0ContextInterface } from '@auth0/auth0-react'; - // Declare the global GoogleAppsScript bridge (defined in sidebar.html) declare global { interface Window { @@ -111,82 +109,11 @@ function stopPolling() { */ export const googleDocsEditorAPI: EditorAPI = { /** - * Handles login for Google Docs. - * Since users are already authenticated with Google, we use their Google identity. - * For Auth0 integration, we could implement a popup flow similar to Word. + * Open a URL in a new browser tab. Google Docs runs in demo mode, so this is only + * used if the device-flow approval page is ever surfaced here. */ - async doLogin(auth0Client: Auth0ContextInterface): Promise { - // Option 1: Use Google identity directly (simpler) - // The user is already logged into Google, so we can use their email - // as the identifier and skip Auth0 entirely for Google Docs. - - // Option 2: Implement Auth0 popup flow (for consistency with Word) - // This would require opening a popup window and handling the OAuth flow. - - // For now, we'll use a simplified approach that works with Google identity - console.log( - 'Google Docs login: Using Google identity. Auth0 integration pending.', - ); - - // If Auth0 is required, we could implement a similar popup flow: - // 1. Open a popup to Auth0 login URL - // 2. Have the popup redirect back with tokens - // 3. Store tokens via Apps Script user properties - - // Placeholder: trigger Auth0 login if needed - try { - await auth0Client.loginWithRedirect({ - openUrl: (url: string) => { - // Open in a new window since we can't do redirects in an iframe - const popup = window.open( - url, - 'auth0-login', - 'width=500,height=600', - ); - - // Poll for completion (the popup should post a message when done) - const pollTimer = setInterval(() => { - if (popup?.closed) { - clearInterval(pollTimer); - // Check if we're now logged in - auth0Client.getAccessTokenSilently().catch(() => { - console.log('Auth0 login was cancelled or failed'); - }); - } - }, 500); - }, - }); - } catch (error) { - console.error('Auth0 login error:', error); - } - }, - - /** - * Handles logout for Google Docs. - */ - async doLogout(auth0Client: Auth0ContextInterface): Promise { - try { - await auth0Client.logout({ - openUrl: (url: string) => { - // Open logout URL in a popup - const popup = window.open( - url, - 'auth0-logout', - 'width=500,height=400', - ); - - // Close popup after a brief delay - setTimeout(() => { - popup?.close(); - }, 2000); - }, - logoutParams: { - returnTo: window.location.origin, - }, - }); - } catch (error) { - console.error('Auth0 logout error:', error); - } + openExternal(url: string): void { + window.open(url, '_blank', 'noopener'); }, /** @@ -246,7 +173,7 @@ export function isRunningInGoogleDocs(): boolean { /** * Gets the current user's email from Google. - * This can be used as a fallback identifier if Auth0 is not configured. + * Used as the identifier for the Google Docs surface (which runs in demo mode). */ export async function getGoogleUserEmail(): Promise { if (!isRunningInGoogleDocs()) { diff --git a/frontend/src/api/wordEditorAPI.ts b/frontend/src/api/wordEditorAPI.ts index 81202381..92833327 100644 --- a/frontend/src/api/wordEditorAPI.ts +++ b/frontend/src/api/wordEditorAPI.ts @@ -1,127 +1,14 @@ -import type { Auth0ContextInterface } from '@auth0/auth0-react'; - export const wordEditorAPI: EditorAPI = { - async doLogin(auth0Client: Auth0ContextInterface): Promise { - let dialog: Office.Dialog; - - // Strategy: the popup will pass its redirect-callback data here, so we can pass it on to handleRedirectCallback - const processMessage = ( - args: - | { message: string; origin: string | undefined } - | { error: number }, - ) => { - if ('error' in args) { - - console.error('Error:', args.error); - if (dialog) dialog.close(); - return; - } - const messageFromDialog = JSON.parse(args.message); - if (dialog) dialog.close(); - - if (messageFromDialog.status === 'success') { - // The dialog reported a successful login. - try { - auth0Client.handleRedirectCallback( - messageFromDialog.urlWithAuthInfo as string, - ); - } catch (error) { - - console.error( - 'auth0Client.handleRedirectCallback Error:', - error, - ); - } - } else { - - console.error('Login failed.', messageFromDialog); - } - }; - - await auth0Client.loginWithRedirect({ - openUrl: (url: string) => { - try { - const redirect = encodeURIComponent(url); - const bounceURL = - location.protocol + - '//' + - location.hostname + - (location.port ? ':' + location.port : '') + - '/popup.html?redirect=' + - redirect; - // height and width are percentages of the size of the screen. - // How MS use it: https://github.com/OfficeDev/Office-Add-in-samples/blob/main/Samples/auth/Office-Add-in-Microsoft-Graph-React/utilities/office-apis-helpers.ts#L38 - Office.context.ui.displayDialogAsync( - bounceURL, - { height: 45, width: 55 }, - (result) => { - dialog = result.value; - dialog.addEventHandler( - Office.EventType.DialogMessageReceived, - processMessage, - ); - }, - ); - } catch (error) { - - console.error('Error opening URL:', error); - } - }, - }); - }, - - async doLogout(auth0Client: Auth0ContextInterface): Promise { - let dialog: Office.Dialog; - - // Strategy: the popup will pass its redirect-callback data here, so we can pass it on to handleRedirectCallback - const processMessage = ( - args: - | { message: string; origin: string | undefined } - | { error: number }, - ) => { - dialog.close(); - if ('error' in args) { - - console.error('Error:', args.error); - return; - } - const messageFromDialog = JSON.parse(args.message); - - if (messageFromDialog.status === 'success') { - // The dialog reported a successful logout. - // It seems like we don't need to do anything here, since the auth0 client library has already cleared its cached credentials. - } else { - - console.error('Logout failed.', messageFromDialog); - } - }; - - await auth0Client.logout({ - openUrl: (url: string) => { - const redirect = encodeURIComponent(url); - const bounceURL = - location.protocol + - '//' + - location.hostname + - (location.port ? ':' + location.port : '') + - '/popup.html?redirect=' + - redirect; - Office.context.ui.displayDialogAsync( - bounceURL, - { height: 45, width: 55 }, - (result) => { - dialog = result.value; - dialog.addEventHandler( - Office.EventType.DialogMessageReceived, - processMessage, - ); - }, - ); - }, - logoutParams: { - returnTo: `${location.origin}/popup.html?logout=true`, - }, - }); + // Open the device-flow approval page in the system browser. Guarded so an Office + // host that doesn't expose openBrowserWindow fails explainably rather than silently. + openExternal(url: string): void { + if (Office?.context?.ui?.openBrowserWindow) { + Office.context.ui.openBrowserWindow(url); + } else { + throw new Error( + 'External browser login is not supported in this Office host.', + ); + } }, addSelectionChangeHandler: (handler: () => void) => { diff --git a/frontend/src/contexts/appAuthContext.tsx b/frontend/src/contexts/appAuthContext.tsx index 5380096d..e8ae870b 100644 --- a/frontend/src/contexts/appAuthContext.tsx +++ b/frontend/src/contexts/appAuthContext.tsx @@ -1,24 +1,17 @@ /** - * Provider-neutral auth session. + * App auth session. * - * `AppInner` consumes `useAppAuth()` instead of calling `useAuth0()` directly, so the - * visible login state (loading / authenticated / user / buttons) is decoupled from any - * one provider. Three adapters supply the same `AppAuthSession` shape: + * `AppInner` consumes `useAppAuth()` for all visible login state (loading / authenticated + * / user / buttons), decoupled from the auth implementation. Two providers supply the + * same `AppAuthSession` shape: * - * - Auth0AuthProvider (default everywhere; preserves current behavior) - * - BetterAuthProvider (opt-in: standalone editor + ?auth=betterauth) - * - DemoAuthProvider (OverallMode.demo) + * - BetterAuthProvider (default for every real-auth surface) + * - DemoAuthProvider (OverallMode.demo — Google Docs, no-backend dev) * * Hook-rule safety: we never call adapter hooks conditionally. `AppAuthProvider` chooses * WHICH provider component to render; each component unconditionally calls its own hooks * and is only mounted when selected. - * - * SCAFFOLDING: `Auth0AuthProvider`, `DemoAuthProvider`, and the `AppAuthProvider` - * selector are temporary compatibility shims that let Auth0 and Better Auth coexist - * during migration. Once Better Auth becomes the default, this collapses into a single - * Better Auth-only provider and the selector/adapters can be removed. */ -import { useAuth0 } from '@auth0/auth0-react'; import { useAtomValue } from 'jotai'; import { createContext, @@ -26,19 +19,17 @@ import { useMemo, type ReactNode, } from 'react'; -import { detectPlatform } from '@/api'; import { AccessTokenProvider } from '@/contexts/authTokenContext'; -import { EditorContext } from '@/contexts/editorContext'; import { OverallMode, overallModeAtom } from '@/contexts/pageContext'; import { useDeviceAuth } from '@/hooks/useDeviceAuth'; export interface AppAuthSession { - provider: 'auth0' | 'betterauth' | 'demo'; - isLoading: boolean; // initial session/provider loading only + provider: 'betterauth' | 'demo'; + isLoading: boolean; // initial session/provider loading (incl. token hydration) isAuthorizing: boolean; // device polling in progress (NOT isLoading) isAuthenticated: boolean; // token present AND user loaded user?: { name?: string; email?: string }; - error?: Error; // provider-level error (e.g. Auth0 error screen) + error?: Error; // provider-level error authorization?: { status: 'pending' | 'polling' | 'error'; userCode?: string; @@ -51,7 +42,7 @@ export interface AppAuthSession { } const DEFAULT_SESSION: AppAuthSession = { - provider: 'auth0', + provider: 'betterauth', isLoading: false, isAuthorizing: false, isAuthenticated: false, @@ -67,43 +58,6 @@ const AppAuthContext = createContext(DEFAULT_SESSION); export const useAppAuth = (): AppAuthSession => useContext(AppAuthContext); -/** True only on the standalone editor with the explicit opt-in query param. */ -function isBetterAuthOptIn(): boolean { - if (typeof window === 'undefined') return false; - if (detectPlatform() !== 'standalone') return false; - return new URLSearchParams(window.location.search).get('auth') === 'betterauth'; -} - -// --- Auth0 adapter ------------------------------------------------------------- - -function Auth0AuthProvider({ children }: { children: ReactNode }) { - const auth0 = useAuth0(); - const editorAPI = useContext(EditorContext); - - const session = useMemo( - () => ({ - provider: 'auth0', - isLoading: auth0.isLoading, - isAuthorizing: false, - isAuthenticated: auth0.isAuthenticated, - user: auth0.user - ? { name: auth0.user.name, email: auth0.user.email } - : undefined, - error: auth0.error, - getAccessToken: auth0.getAccessTokenSilently, - login: () => editorAPI.doLogin(auth0), - logout: () => editorAPI.doLogout(auth0), - }), - [auth0, editorAPI], - ); - - return ( - - {children} - - ); -} - // --- Better Auth adapter ------------------------------------------------------- function BetterAuthProvider({ children }: { children: ReactNode }) { @@ -130,7 +84,9 @@ function BetterAuthProvider({ children }: { children: ReactNode }) { return { provider: 'betterauth', - isLoading: false, + // Hydrating a persisted token on mount is initial session load, shown via the + // "Waiting" screen — NOT the device-code UI. + isLoading: device.status === 'hydrating', isAuthorizing, isAuthenticated: device.status === 'success' && !!device.token, user: device.user, @@ -182,17 +138,14 @@ function DemoAuthProvider({ children }: { children: ReactNode }) { // --- Selector + token bridge --------------------------------------------------- -/** Renders exactly one adapter provider by mode + opt-in. */ +/** Renders the demo provider in demo mode, Better Auth everywhere else. */ export function AppAuthProvider({ children }: { children: ReactNode }) { const mode = useAtomValue(overallModeAtom); if (mode === OverallMode.demo) { return {children}; } - if (isBetterAuthOptIn()) { - return {children}; - } - return {children}; + return {children}; } /** Feeds the selected session's getAccessToken into the existing token context. */ diff --git a/frontend/src/contexts/editorContext.tsx b/frontend/src/contexts/editorContext.tsx index 5fc98452..641f2880 100644 --- a/frontend/src/contexts/editorContext.tsx +++ b/frontend/src/contexts/editorContext.tsx @@ -2,8 +2,9 @@ import { createContext } from 'react'; // Provides editor API functionality through context export const EditorContext = createContext({ - doLogin: async () => {}, - doLogout: async () => {}, + openExternal: (url: string) => { + window.open(url, '_blank', 'noopener'); + }, getDocContext: () => new Promise((resolve) => resolve({ diff --git a/frontend/src/editor/index.tsx b/frontend/src/editor/index.tsx index 1ce35295..0f5f65bd 100644 --- a/frontend/src/editor/index.tsx +++ b/frontend/src/editor/index.tsx @@ -3,7 +3,6 @@ import { createRoot } from 'react-dom/client'; import { OverallMode, overallModeAtom } from '@/contexts/pageContext'; import * as SidebarInner from '@/pages/app'; -import type { Auth0ContextInterface } from '@auth0/auth0-react'; import { useAtomValue, useSetAtom } from 'jotai'; import LexicalEditor from './editor'; import './styles.css'; @@ -44,25 +43,8 @@ export function EditorScreen({ }; const editorAPI: EditorAPI = useMemo(() => ({ - doLogin: async (auth0Client: Auth0ContextInterface) => { - try { - await auth0Client.loginWithPopup(); - } catch (error) { - - console.error('auth0Client.loginWithPopup Error:', error); - } - }, - doLogout: async (auth0Client: Auth0ContextInterface) => { - try { - await auth0Client.logout({ - logoutParams: { - returnTo: `${location.origin}/editor.html`, - }, - }); - } catch (error) { - - console.error('auth0Client.logout Error:', error); - } + openExternal: (url: string) => { + window.open(url, '_blank', 'noopener'); }, getDocContext: async (): Promise => { return Promise.resolve(docContextRef.current); diff --git a/frontend/src/hooks/useDeviceAuth.ts b/frontend/src/hooks/useDeviceAuth.ts index d5e17f54..9adf3005 100644 --- a/frontend/src/hooks/useDeviceAuth.ts +++ b/frontend/src/hooks/useDeviceAuth.ts @@ -1,9 +1,11 @@ /** * React hook driving the interactive Better Auth device flow. * - * Owns the state machine and an in-memory access token (never persisted). Polling is - * cancellable: reset(), logout(), unmount, or a fresh start() abort the in-flight loop - * via an AbortController so a stale tick cannot deliver a token after the user leaves. + * Owns the state machine and the access token. The token is persisted (guarded + * localStorage) so a page refresh restores the session via hydrate-on-mount instead of + * forcing a fresh interactive login. Polling is cancellable: reset(), logout(), unmount, + * or a fresh start() abort the in-flight loop via an AbortController. The abort signal is + * the single source of truth for cancellation, so no mounted flag is needed. */ import { useCallback, useEffect, useRef, useState } from 'react'; import { @@ -14,9 +16,11 @@ import { requestDeviceCode, signOut as signOutRequest, } from '@/api/deviceAuth'; +import { clearToken, loadToken, persistToken } from '@/api/authTokenStore'; export type DeviceAuthStatus = | 'idle' + | 'hydrating' // validating a persisted token on mount | 'pending' // requesting the device code | 'polling' // waiting for the user to approve in the browser | 'success' @@ -65,6 +69,7 @@ export function useDeviceAuth(): UseDeviceAuth { const reset = useCallback(() => { abortInFlight(); tokenRef.current = undefined; + clearToken(); setState(INITIAL); }, [abortInFlight]); @@ -132,6 +137,7 @@ export function useDeviceAuth(): UseDeviceAuth { controller.signal, ); tokenRef.current = result.accessToken; + persistToken(result.accessToken); safeSet(controller, { status: 'success', token: result.accessToken, @@ -154,23 +160,48 @@ export function useDeviceAuth(): UseDeviceAuth { if (token) { await signOutRequest(token); } - } catch { + } catch { // Best-effort, ignore server sign-out failure. Clear local state regardless. } finally { - // Clear local state regardless of sign-out success, since the token is the only proof of auth. + // Clear local state regardless of sign-out success, since the token is the + // only proof of auth. tokenRef.current = undefined; + clearToken(); setState(INITIAL); } }, [abortInFlight]); useEffect(() => { - // Abort any in-flight device flow when the component unmounts. - // The signal is the source of truth so no mounted flag is needed. + const controller = new AbortController(); + abortRef.current = controller; + + // Hydrate: if a token was persisted, validate it instead of forcing a new login. + const stored = loadToken(); + if (stored) { + setState({ status: 'hydrating' }); + fetchUserInfo(stored, controller.signal) + .then((user) => { + tokenRef.current = stored; + safeSet(controller, { + status: 'success', + token: stored, + user, + }); + }) + .catch(() => { + if (controller.signal.aborted) return; + // Stale/invalid token — drop it and fall back to login. + clearToken(); + safeSet(controller, { status: 'idle' }); + }); + } + + // Abort any in-flight device flow / hydration when the component unmounts. return () => { abortRef.current?.abort(); abortRef.current = null; }; - }, []); + }, [safeSet]); return { ...state, start, reset, logout }; } diff --git a/frontend/src/index-gdocs.tsx b/frontend/src/index-gdocs.tsx index 7fc84bc2..2d8a34ec 100644 --- a/frontend/src/index-gdocs.tsx +++ b/frontend/src/index-gdocs.tsx @@ -4,7 +4,7 @@ * Unlike the Word add-in (index.tsx), this doesn't require Office.onReady. * It initializes immediately when loaded in the Google Docs sidebar. * - * Uses demo mode to bypass Auth0 since users are already authenticated with Google. + * Uses demo mode (no interactive sign-in) since users are already authenticated with Google. */ import { createRoot } from 'react-dom/client'; import { StrictMode } from 'react'; @@ -26,7 +26,7 @@ declare global { } } -// Create a Jotai store with demo mode pre-set to bypass Auth0 +// Create a Jotai store with demo mode pre-set (no interactive sign-in) const store = createStore(); store.set(overallModeAtom, OverallMode.demo); diff --git a/frontend/src/pages/app/index.tsx b/frontend/src/pages/app/index.tsx index 64f1ffe4..1dc13aa6 100644 --- a/frontend/src/pages/app/index.tsx +++ b/frontend/src/pages/app/index.tsx @@ -1,15 +1,15 @@ -import { Auth0Provider } from '@auth0/auth0-react'; import { PostHogProvider, PostHogErrorBoundary } from '@posthog/react'; import { useWindowSize } from '@react-hook/window-size/throttled'; import { useAtomValue } from 'jotai'; -import { useState } from 'react'; -import { CgFacebook, CgGoogle, CgMicrosoft } from 'react-icons/cg'; +import { useContext, useState } from 'react'; +import { CgGoogle } from 'react-icons/cg'; import { AppAuthProvider, AppAuthTokenBridge, useAppAuth, } from '@/contexts/appAuthContext'; import { useAccessToken } from '@/contexts/authTokenContext'; +import { EditorContext } from '@/contexts/editorContext'; import ChatContextWrapper from '@/contexts/chatContext'; import { OverallMode, @@ -32,7 +32,8 @@ const POSTHOG_HOST = 'https://e.thoughtful-ai.com/'; const POSTHOG_ENABLED = true; // Device-flow status surfaced during Better Auth sign-in. Shows the user code and a -// user-clicked link that opens the approval page in a new tab (no focus-stealing). +// button that opens the approval page in the system browser via the surface-specific +// EditorAPI.openExternal (Word: openBrowserWindow; standalone/GDocs: new tab). // Rendered inside the not-logged-in screen, NOT gated by the isLoading "Waiting" screen. function DeviceAuthStatus({ authorization, @@ -44,6 +45,8 @@ function DeviceAuthStatus({ error?: string; }; }) { + const editorAPI = useContext(EditorContext); + if (!authorization) return null; if (authorization.status === 'error') { @@ -87,22 +90,15 @@ function DeviceAuthStatus({

{authorization.verificationUri ? (

- { + editorAPI.openExternal(authorization.verificationUri!); }} > Open approval page → - +

) : null}

Open the approval page and enter the code above to continue.

@@ -232,11 +228,9 @@ function AppInner() {
-

Available Auth Providers

+

Sign in with Google

- -
- - - - - - - + + + + + diff --git a/frontend/src/popup.html b/frontend/src/popup.html deleted file mode 100644 index 4d59c452..00000000 --- a/frontend/src/popup.html +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - - - - - Thoughtful - - - - - - - -
- - - diff --git a/frontend/src/popup.tsx b/frontend/src/popup.tsx deleted file mode 100644 index f2c7ae1c..00000000 --- a/frontend/src/popup.tsx +++ /dev/null @@ -1,62 +0,0 @@ -Office.onReady(() => { - // From https://github.com/OfficeDev/Office-Add-in-Auth0/blob/master/Scripts/popup.js - // and https://github.com/OfficeDev/Office-Add-in-Auth0/blob/master/Scripts/popupRedirect.js - // (kca simplified this to just use a single page) - const DEBUG = false; - const searchParams = new URLSearchParams(window.location.search); - const auth0Domain = process.env.AUTH0_DOMAIN; - const redirect = searchParams.get('redirect'); - function constructValidatedURL(url: URL) { - if (url.hostname !== auth0Domain) { - return null; - } - const validatedURL = new URL(`https://${auth0Domain}`); - if (url.pathname === '/authorize' || url.pathname === '/v2/logout') { - validatedURL.pathname = url.pathname; - validatedURL.search = url.search; - } - return validatedURL; - } - if (redirect) { - const validatedURL = constructValidatedURL(new URL(redirect)); - if (!validatedURL) { - document.body.innerText = `Invalid redirect URL: ${redirect}`; - return; - } - if (DEBUG) { - document.body.innerText = `Redirecting to ${validatedURL.href}`; - } - setTimeout( - () => { - window.location.href = validatedURL.href; - }, - DEBUG ? 5000 : 0, - ); - } else { - // Note: this will also get called with `logout=true` in the logout flow, but - // the only thing we need to do here is message the parent to get the dialog to close, - // so it's fine to take the same action in both cases. - const message = { - status: 'success', - urlWithAuthInfo: window.location.href, - }; - if (DEBUG) { - document.body.innerText = `Messaging parent with ${JSON.stringify( - message, - )}`; - } - setTimeout( - () => { - if (Office.context && Office.context.ui) { - Office.context.ui.messageParent(JSON.stringify(message)); - } else { - - console.error( - 'Could not message parent: Office.context.ui is undefined', - ); - } - }, - DEBUG ? 5000 : 0, - ); - } -}); diff --git a/frontend/src/static/privacypolicy.html b/frontend/src/static/privacypolicy.html index eeff047f..ee4a0c46 100644 --- a/frontend/src/static/privacypolicy.html +++ b/frontend/src/static/privacypolicy.html @@ -200,7 +200,8 @@

Usage Data

Account Information

    -
  • Basic authentication information provided through Auth0
  • + +
  • Basic authentication information provided through our authentication provider
  • Email address (if you choose to provide it for support purposes)

How We Use Your Information

@@ -253,7 +254,7 @@

Service Providers

  • Cloud computing infrastructure
  • AI processing capabilities
  • -
  • Authentication (Auth0)
  • +
  • Authentication (Google sign-in)
  • Microsoft Office Add-in platform
  • All service providers are bound by strict confidentiality agreements
  • diff --git a/frontend/src/types.d.ts b/frontend/src/types.d.ts index d79a06ca..a8e600b2 100644 --- a/frontend/src/types.d.ts +++ b/frontend/src/types.d.ts @@ -23,8 +23,12 @@ interface SavedItem { } interface EditorAPI { - doLogin(auth0Client: Auth0ContextInterface): Promise; - doLogout(auth0Client: Auth0ContextInterface): Promise; + /** + * Open a URL in the user's external browser. Surface-specific: Word uses + * Office.context.ui.openBrowserWindow; standalone/Google Docs use window.open. + * Used to open the Better Auth device-flow approval page. + */ + openExternal(url: string): void; getDocContext(this: void): Promise; addSelectionChangeHandler: (handler: () => void) => void; removeSelectionChangeHandler: (handler: () => void) => void; diff --git a/frontend/webpack.config.js b/frontend/webpack.config.js index 2e1a9a62..fc2cb9ce 100644 --- a/frontend/webpack.config.js +++ b/frontend/webpack.config.js @@ -37,13 +37,6 @@ module.exports = async (env = {}, options = {}) => { import: ['./src/logs/index.tsx', './src/logs/logs.html'], dependOn: 'react' }, - popup: { - import: [ - './src/popup.tsx', - './src/popup.html' - ], - dependOn: 'react' - }, editor: { import: ['./src/editor/index.tsx', './src/editor/editor.html'], dependOn: 'react' @@ -144,11 +137,6 @@ module.exports = async (env = {}, options = {}) => { template: './src/logs/logs.html', chunks: ['logs', 'react'] }), - new HtmlWebpackPlugin({ - filename: 'popup.html', - template: './src/popup.html', - chunks: ['polyfill', 'popup', 'react'] - }), new HtmlWebpackPlugin({ filename: 'commands.html', template: './src/commands/commands.html', @@ -158,8 +146,6 @@ module.exports = async (env = {}, options = {}) => { Promise: ['es6-promise', 'Promise'] }), new webpack.DefinePlugin({ - 'process.env.AUTH0_DOMAIN': JSON.stringify('dev-rbroo1fvav24wamu.us.auth0.com'), - 'process.env.AUTH0_CLIENT_ID': JSON.stringify('YZhokQZRgE2YUqU5Is9LcaMiCzujoaVr'), // Device-flow client ID; must match a value in the backend's // BETTER_AUTH_DEVICE_CLIENT_IDS allowlist. Override via env for other envs. 'process.env.BETTER_AUTH_DEVICE_CLIENT_ID': JSON.stringify( diff --git a/frontend/webpack.google-docs.config.js b/frontend/webpack.google-docs.config.js index a33ab350..68c6ac08 100644 --- a/frontend/webpack.google-docs.config.js +++ b/frontend/webpack.google-docs.config.js @@ -53,8 +53,6 @@ module.exports = (_env = {}, options = {}) => { filename: '[name].css' }), new webpack.DefinePlugin({ - 'process.env.AUTH0_DOMAIN': JSON.stringify('dev-rbroo1fvav24wamu.us.auth0.com'), - 'process.env.AUTH0_CLIENT_ID': JSON.stringify('YZhokQZRgE2YUqU5Is9LcaMiCzujoaVr'), 'process.env.NODE_ENV': JSON.stringify(options.mode || 'development'), // Backend origin for the Google Docs sidebar. Empty in dev (the sidebar // reaches the backend through this dev server's /api proxy); in prod the From 2a605b57ac7143b99d67ffc9e3de334e6cef5dc4 Mon Sep 17 00:00:00 2001 From: "Kenneth C. Arnold" Date: Thu, 25 Jun 2026 17:46:06 -0400 Subject: [PATCH 2/4] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- frontend/src/static/privacypolicy.html | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend/src/static/privacypolicy.html b/frontend/src/static/privacypolicy.html index ee4a0c46..42e4bcd1 100644 --- a/frontend/src/static/privacypolicy.html +++ b/frontend/src/static/privacypolicy.html @@ -200,7 +200,6 @@

    Usage Data

    Account Information

      -
    • Basic authentication information provided through our authentication provider
    • Email address (if you choose to provide it for support purposes)
    From 9a5468274c9080e2f224e9ec66a2585ecee32095 Mon Sep 17 00:00:00 2001 From: "Kenneth C. Arnold" Date: Thu, 25 Jun 2026 17:53:48 -0400 Subject: [PATCH 3/4] Remove openExternal; an tag should work fine. --- frontend/src/api/googleDocsEditorAPI.ts | 8 -------- frontend/src/api/wordEditorAPI.ts | 11 ----------- frontend/src/contexts/editorContext.tsx | 3 --- frontend/src/editor/index.tsx | 3 --- frontend/src/pages/app/index.tsx | 13 ++----------- frontend/src/types.d.ts | 6 ------ 6 files changed, 2 insertions(+), 42 deletions(-) diff --git a/frontend/src/api/googleDocsEditorAPI.ts b/frontend/src/api/googleDocsEditorAPI.ts index 14d60a53..4e25ed2e 100644 --- a/frontend/src/api/googleDocsEditorAPI.ts +++ b/frontend/src/api/googleDocsEditorAPI.ts @@ -108,14 +108,6 @@ function stopPolling() { * Google Docs implementation of the EditorAPI interface. */ export const googleDocsEditorAPI: EditorAPI = { - /** - * Open a URL in a new browser tab. Google Docs runs in demo mode, so this is only - * used if the device-flow approval page is ever surfaced here. - */ - openExternal(url: string): void { - window.open(url, '_blank', 'noopener'); - }, - /** * Adds a handler for selection changes. * Uses polling since Google Docs doesn't provide native selection events. diff --git a/frontend/src/api/wordEditorAPI.ts b/frontend/src/api/wordEditorAPI.ts index 92833327..9e85d03b 100644 --- a/frontend/src/api/wordEditorAPI.ts +++ b/frontend/src/api/wordEditorAPI.ts @@ -1,15 +1,4 @@ export const wordEditorAPI: EditorAPI = { - // Open the device-flow approval page in the system browser. Guarded so an Office - // host that doesn't expose openBrowserWindow fails explainably rather than silently. - openExternal(url: string): void { - if (Office?.context?.ui?.openBrowserWindow) { - Office.context.ui.openBrowserWindow(url); - } else { - throw new Error( - 'External browser login is not supported in this Office host.', - ); - } - }, addSelectionChangeHandler: (handler: () => void) => { Office.context.document.addHandlerAsync( diff --git a/frontend/src/contexts/editorContext.tsx b/frontend/src/contexts/editorContext.tsx index 641f2880..4bf73f39 100644 --- a/frontend/src/contexts/editorContext.tsx +++ b/frontend/src/contexts/editorContext.tsx @@ -2,9 +2,6 @@ import { createContext } from 'react'; // Provides editor API functionality through context export const EditorContext = createContext({ - openExternal: (url: string) => { - window.open(url, '_blank', 'noopener'); - }, getDocContext: () => new Promise((resolve) => resolve({ diff --git a/frontend/src/editor/index.tsx b/frontend/src/editor/index.tsx index 0f5f65bd..1f5f53ba 100644 --- a/frontend/src/editor/index.tsx +++ b/frontend/src/editor/index.tsx @@ -43,9 +43,6 @@ export function EditorScreen({ }; const editorAPI: EditorAPI = useMemo(() => ({ - openExternal: (url: string) => { - window.open(url, '_blank', 'noopener'); - }, getDocContext: async (): Promise => { return Promise.resolve(docContextRef.current); }, diff --git a/frontend/src/pages/app/index.tsx b/frontend/src/pages/app/index.tsx index 1dc13aa6..879bd72b 100644 --- a/frontend/src/pages/app/index.tsx +++ b/frontend/src/pages/app/index.tsx @@ -32,8 +32,7 @@ const POSTHOG_HOST = 'https://e.thoughtful-ai.com/'; const POSTHOG_ENABLED = true; // Device-flow status surfaced during Better Auth sign-in. Shows the user code and a -// button that opens the approval page in the system browser via the surface-specific -// EditorAPI.openExternal (Word: openBrowserWindow; standalone/GDocs: new tab). +// button that opens the approval page in a new window/tab. // Rendered inside the not-logged-in screen, NOT gated by the isLoading "Waiting" screen. function DeviceAuthStatus({ authorization, @@ -90,15 +89,7 @@ function DeviceAuthStatus({

    {authorization.verificationUri ? (

    - + Open approval page

    ) : null}

    Open the approval page and enter the code above to continue.

    diff --git a/frontend/src/types.d.ts b/frontend/src/types.d.ts index a8e600b2..d93e9e0e 100644 --- a/frontend/src/types.d.ts +++ b/frontend/src/types.d.ts @@ -23,12 +23,6 @@ interface SavedItem { } interface EditorAPI { - /** - * Open a URL in the user's external browser. Surface-specific: Word uses - * Office.context.ui.openBrowserWindow; standalone/Google Docs use window.open. - * Used to open the Better Auth device-flow approval page. - */ - openExternal(url: string): void; getDocContext(this: void): Promise; addSelectionChangeHandler: (handler: () => void) => void; removeSelectionChangeHandler: (handler: () => void) => void; From 05aa4de0c670dfdfdbb66dbea469962dd583a2d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Jun 2026 22:03:48 +0000 Subject: [PATCH 4/4] Remove dead editorAPI/useContext after openExternal removal Drop the now-unused EditorContext lookup in DeviceAuthStatus and add rel="noopener" to the approval-page link. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CHabwqx37ssPEFQwbU7Hk7 --- frontend/src/pages/app/index.tsx | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/frontend/src/pages/app/index.tsx b/frontend/src/pages/app/index.tsx index 879bd72b..712047cf 100644 --- a/frontend/src/pages/app/index.tsx +++ b/frontend/src/pages/app/index.tsx @@ -1,7 +1,7 @@ import { PostHogProvider, PostHogErrorBoundary } from '@posthog/react'; import { useWindowSize } from '@react-hook/window-size/throttled'; import { useAtomValue } from 'jotai'; -import { useContext, useState } from 'react'; +import { useState } from 'react'; import { CgGoogle } from 'react-icons/cg'; import { AppAuthProvider, @@ -9,7 +9,6 @@ import { useAppAuth, } from '@/contexts/appAuthContext'; import { useAccessToken } from '@/contexts/authTokenContext'; -import { EditorContext } from '@/contexts/editorContext'; import ChatContextWrapper from '@/contexts/chatContext'; import { OverallMode, @@ -44,8 +43,6 @@ function DeviceAuthStatus({ error?: string; }; }) { - const editorAPI = useContext(EditorContext); - if (!authorization) return null; if (authorization.status === 'error') { @@ -89,7 +86,7 @@ function DeviceAuthStatus({

    {authorization.verificationUri ? (

    - Open approval page + Open approval page

    ) : null}

    Open the approval page and enter the code above to continue.