diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index c5f49d75..0a69ce4b 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",
@@ -245,30 +244,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",
@@ -8104,16 +8079,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",
@@ -9485,15 +9450,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",
@@ -9673,12 +9629,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",
@@ -13306,6 +13256,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.includes": {
diff --git a/frontend/package.json b/frontend/package.json
index ab69c382..1ceb5877 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -47,7 +47,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/popup.html b/frontend/popup.html
deleted file mode 100644
index a05d05a7..00000000
--- a/frontend/popup.html
+++ /dev/null
@@ -1,24 +0,0 @@
-
-
-
-
-
-
-
-
-
-
- Thoughtful
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/frontend/public/privacypolicy.html b/frontend/public/privacypolicy.html
index eeff047f..42e4bcd1 100644
--- a/frontend/public/privacypolicy.html
+++ b/frontend/public/privacypolicy.html
@@ -200,7 +200,7 @@ Usage Data
- - 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)
@@ -253,7 +253,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/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..4e25ed2e 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 {
@@ -110,85 +108,6 @@ function stopPolling() {
* Google Docs implementation of the EditorAPI interface.
*/
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.
- */
- 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);
- }
- },
-
/**
* Adds a handler for selection changes.
* Uses polling since Google Docs doesn't provide native selection events.
@@ -246,7 +165,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..9e85d03b 100644
--- a/frontend/src/api/wordEditorAPI.ts
+++ b/frontend/src/api/wordEditorAPI.ts
@@ -1,128 +1,4 @@
-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`,
- },
- });
- },
addSelectionChangeHandler: (handler: () => void) => {
Office.context.document.addHandlerAsync(
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..4bf73f39 100644
--- a/frontend/src/contexts/editorContext.tsx
+++ b/frontend/src/contexts/editorContext.tsx
@@ -2,8 +2,6 @@ import { createContext } from 'react';
// Provides editor API functionality through context
export const EditorContext = createContext({
- doLogin: async () => {},
- doLogout: async () => {},
getDocContext: () =>
new Promise((resolve) =>
resolve({
diff --git a/frontend/src/editor/index.tsx b/frontend/src/editor/index.tsx
index 1ce35295..1f5f53ba 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,26 +43,6 @@ 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);
- }
- },
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..712047cf 100644
--- a/frontend/src/pages/app/index.tsx
+++ b/frontend/src/pages/app/index.tsx
@@ -1,9 +1,8 @@
-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 { CgGoogle } from 'react-icons/cg';
import {
AppAuthProvider,
AppAuthTokenBridge,
@@ -32,7 +31,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
-// user-clicked link that opens the approval page in a new tab (no focus-stealing).
+// 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,
@@ -87,22 +86,7 @@ function DeviceAuthStatus({
{authorization.verificationUri ? (
-
- Open approval page →
-
+ Open approval page
) : null}
Open the approval page and enter the code above to continue.
@@ -232,11 +216,9 @@ function AppInner() {
- Available Auth Providers
+ Sign in with Google
-
-
-
-
-
-
-
-
-
+
+
+
+
+
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/types.d.ts b/frontend/src/types.d.ts
index d79a06ca..d93e9e0e 100644
--- a/frontend/src/types.d.ts
+++ b/frontend/src/types.d.ts
@@ -23,8 +23,6 @@ interface SavedItem {
}
interface EditorAPI {
- doLogin(auth0Client: Auth0ContextInterface): Promise
;
- doLogout(auth0Client: Auth0ContextInterface): Promise;
getDocContext(this: void): Promise;
addSelectionChangeHandler: (handler: () => void) => void;
removeSelectionChangeHandler: (handler: () => void) => void;
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index b57b6f2a..4b208c22 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -62,8 +62,6 @@ export default defineConfig(async ({ mode }) => {
}
},
define: {
- '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(
@@ -80,7 +78,6 @@ export default defineConfig(async ({ mode }) => {
taskpane: path.resolve(__dirname, 'taskpane.html'),
editor: path.resolve(__dirname, 'editor.html'),
logs: path.resolve(__dirname, 'logs.html'),
- popup: path.resolve(__dirname, 'popup.html'),
commands: path.resolve(__dirname, 'commands.html')
},
output: {
diff --git a/frontend/vite.google-docs.config.ts b/frontend/vite.google-docs.config.ts
index a8d910c3..460d8186 100644
--- a/frontend/vite.google-docs.config.ts
+++ b/frontend/vite.google-docs.config.ts
@@ -22,8 +22,6 @@ export default defineConfig(({ mode }) => {
}
},
define: {
- '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(isDev ? 'development' : 'production'),
// Backend origin for the sidebar: empty in dev (reaches the backend via
// the dev server's /api proxy); the deployed origin in prod.