diff --git a/src/app/components/account-menu.tsx b/src/app/components/account-menu.tsx index c348986..7add096 100644 --- a/src/app/components/account-menu.tsx +++ b/src/app/components/account-menu.tsx @@ -50,6 +50,7 @@ export default function AccountMenu({ user }: { user?: any }) { const handleLogout = () => { // Only clear auth tokens, keep chat history localStorage.removeItem('token'); + localStorage.removeItem('refresh_token'); localStorage.removeItem('u'); sessionStorage.clear(); router.push('/login'); diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 6feaea9..b04b3ca 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -8,6 +8,7 @@ import { useRouter, useSearchParams } from 'next/navigation' import { API_ENDPOINT } from '@/app/constants'; import { getPostAuthPath, isValidEmail, OAuthUtils } from '@/app/shared/utils'; import { userService } from '@/app/services/user.service'; +import { refreshStoredSession, sendTokensToDesktop, storeRefreshToken, REFRESH_TOKEN_KEY } from '@/app/shared/desktop'; import { Box, CircularProgress } from '@mui/material'; import ArrowBackIosNewIcon from '@mui/icons-material/ArrowBackIosNew'; @@ -169,6 +170,7 @@ function LoginContent() { */ const completeSignIn = async (result: { access_token: string, refresh_token?: string }) => { localStorage.setItem("token", result.access_token); + storeRefreshToken(result.refresh_token); // Handle OAuth flow if present if (isOAuthFlow) { @@ -184,30 +186,7 @@ function LoginContent() { // Handle desktop mode - send tokens to localhost if (mode === 'desktop' && desktopPort) { - localStorage.removeItem('desktop_port'); - const callbackUrl = `http://127.0.0.1:${desktopPort}/v1/auth/callback`; - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 3000); - - try { - await fetch(callbackUrl, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - access_token: result.access_token, - refresh_token: result.refresh_token, - }), - signal: controller.signal, - }); - clearTimeout(timeout); - window.location.href = '/redirect/?mode=desktop'; - } catch (err) { - clearTimeout(timeout); - // Fallback to GET navigation (Safari blocks mixed content fetch) - window.location.href = callbackUrl - + `?access_token=${encodeURIComponent(result.access_token)}` - + `&refresh_token=${encodeURIComponent(result.refresh_token || '')}`; - } + await sendTokensToDesktop(result.access_token, result.refresh_token, desktopPort); } else if (mode === 'native') { // Legacy native mode - redirect via custom URL scheme console.log(`Navigating to redirect page for native app`); @@ -230,8 +209,9 @@ function LoginContent() { // If the user already has a valid session, skip the login form and send them // straight to their destination (e.g. "Get Started" -> /login?plan=pro lands // on /upgrade, which goes straight to checkout — instead of the dashboard). - // Only for the standard web flow: OAuth / desktop / native still re-auth so - // their tokens reach the right place. + // Desktop mode does the same when a refresh token is stored: it is exchanged + // for a fresh pair and handed to the local agent, as a sign-in would. OAuth / + // native still re-auth so their tokens reach the right place. // // The destination is re-read from window.location here rather than taken from // `postLoginPath`: this page is statically exported, so `useSearchParams()` is @@ -239,7 +219,23 @@ function LoginContent() { useEffect(() => { const params = new URLSearchParams(window.location.search); const urlMode = params.get('mode'); - const isStandardFlow = !OAuthUtils.isOAuthFlow(OAuthUtils.getOAuthParams(params)) + const urlPort = params.get('port'); + const isOAuth = OAuthUtils.isOAuthFlow(OAuthUtils.getOAuthParams(params)); + + if (!isOAuth && urlMode === 'desktop' && urlPort) { + let active = true; + refreshStoredSession().then((pair) => { + if (!active) return; + if (pair) { + sendTokensToDesktop(pair.access_token, pair.refresh_token, urlPort); + } else { + setCheckingSession(false); + } + }); + return () => { active = false; }; + } + + const isStandardFlow = !isOAuth && urlMode !== 'desktop' && urlMode !== 'native'; if (!isStandardFlow) { setCheckingSession(false); @@ -349,6 +345,7 @@ function LoginContent() { setDigits(Array(CODE_LENGTH).fill('')); codeInputs.current[0]?.focus(); localStorage.removeItem("token"); + localStorage.removeItem(REFRESH_TOKEN_KEY); localStorage.removeItem("u"); return; } diff --git a/src/app/oauth-callback/page.tsx b/src/app/oauth-callback/page.tsx index 0318b13..7027b67 100644 --- a/src/app/oauth-callback/page.tsx +++ b/src/app/oauth-callback/page.tsx @@ -8,6 +8,7 @@ import { useRouter, useSearchParams } from 'next/navigation'; import { API_ENDPOINT } from '@/app/constants'; import { userService } from '@/app/services/user.service'; +import { sendTokensToDesktop, storeRefreshToken } from '@/app/shared/desktop'; import LoadingSpinner from '../components/loading-spinner'; // Content component that uses searchParams @@ -18,41 +19,12 @@ function OAuthCallbackContent() { const [isLoading, setIsLoading] = useState(true); const hasCalledApi = useRef(false); - // Helper function to send tokens to the desktop app's local server - // Tries POST first (works in Firefox/Chrome), falls back to GET navigation (Safari) - const sendToDesktopApp = async (accessToken: string, refreshToken: string | null, port: string) => { - localStorage.removeItem('desktop_port'); - const callbackUrl = `http://127.0.0.1:${port}/v1/auth/callback`; - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 3000); - - try { - await fetch(callbackUrl, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - access_token: accessToken, - refresh_token: refreshToken, - }), - signal: controller.signal, - }); - clearTimeout(timeout); - window.location.href = '/redirect/?mode=desktop'; - } catch (err) { - clearTimeout(timeout); - // Fallback to GET navigation (Safari blocks mixed content fetch) - window.location.href = callbackUrl - + `?access_token=${encodeURIComponent(accessToken)}` - + `&refresh_token=${encodeURIComponent(refreshToken || '')}`; - } - }; - // Helper function to handle app redirect (native, desktop, or regular) const handleAppRedirect = async (token: string, refreshToken: string | null, cliState: string | null, mode: string | null) => { if (mode === 'desktop') { const port = localStorage.getItem('desktop_port'); if (port) { - await sendToDesktopApp(token, refreshToken, port); + await sendTokensToDesktop(token, refreshToken, port); } else { console.error('Desktop mode but no port found'); router.push('/'); @@ -144,6 +116,7 @@ function OAuthCallbackContent() { // Store the token localStorage.setItem("token", token); + storeRefreshToken(null); // Handle redirect for both native and regular modes // Apple OAuth doesn't provide a refresh_token here @@ -169,6 +142,7 @@ function OAuthCallbackContent() { const data = await response.json(); localStorage.setItem("token", data.access_token); + storeRefreshToken(data.refresh_token); // Handle redirect for both native and regular modes await handleAppRedirect(data.access_token, data.refresh_token || null, cliState, mode); diff --git a/src/app/services/user.service.js b/src/app/services/user.service.js index 93bc55c..a77a489 100644 --- a/src/app/services/user.service.js +++ b/src/app/services/user.service.js @@ -13,6 +13,7 @@ const getToken = () => { const logout = () => { localStorage.removeItem("token"); + localStorage.removeItem("refresh_token"); localStorage.removeItem("u"); } diff --git a/src/app/shared/desktop.ts b/src/app/shared/desktop.ts new file mode 100644 index 0000000..9f22080 --- /dev/null +++ b/src/app/shared/desktop.ts @@ -0,0 +1,74 @@ +import { API_ENDPOINT } from '@/app/constants'; + +/** + * Where the browser keeps the refresh token beside `token`, so a session that + * already exists can be handed to the desktop app or `docent` without signing + * in again: they need the refresh token too, or their sign-in dies with the + * access token. + */ +export const REFRESH_TOKEN_KEY = 'refresh_token'; + +/** Keep (or, when the sign-in route gave none, forget) the refresh token. */ +export const storeRefreshToken = (refreshToken?: string | null) => { + if (refreshToken) { + localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken); + } else { + localStorage.removeItem(REFRESH_TOKEN_KEY); + } +}; + +/** + * Hand a token pair to the local agent's `/v1/auth/callback` on `port`. + * POST first (Firefox/Chrome); Safari blocks that mixed-content fetch, so fall + * back to navigating to the callback with the tokens in the query. + */ +export const sendTokensToDesktop = async (accessToken: string, refreshToken: string | null | undefined, port: string) => { + localStorage.removeItem('desktop_port'); + const callbackUrl = `http://127.0.0.1:${port}/v1/auth/callback`; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 3000); + + try { + await fetch(callbackUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ access_token: accessToken, refresh_token: refreshToken }), + signal: controller.signal, + }); + clearTimeout(timeout); + window.location.href = '/redirect/?mode=desktop'; + } catch { + clearTimeout(timeout); + window.location.href = callbackUrl + + `?access_token=${encodeURIComponent(accessToken)}` + + `&refresh_token=${encodeURIComponent(refreshToken || '')}`; + } +}; + +/** + * Turn the stored refresh token into a fresh pair, storing it. `null` when + * there is none or the platform no longer honours it (expired, signed out + * elsewhere): the caller then asks for a sign-in as usual. + */ +export const refreshStoredSession = async (): Promise<{ access_token: string, refresh_token: string } | null> => { + const refreshToken = localStorage.getItem(REFRESH_TOKEN_KEY); + if (!refreshToken) return null; + try { + const res = await fetch(`${API_ENDPOINT}/auth/jwt/refresh`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refresh_token: refreshToken }), + }); + if (!res.ok) { + localStorage.removeItem(REFRESH_TOKEN_KEY); + return null; + } + const pair = await res.json(); + if (!pair?.access_token || !pair?.refresh_token) return null; + localStorage.setItem('token', pair.access_token); + storeRefreshToken(pair.refresh_token); + return pair; + } catch { + return null; + } +};