Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/app/components/account-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
51 changes: 24 additions & 27 deletions src/app/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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) {
Expand All @@ -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`);
Expand All @@ -230,16 +209,33 @@ 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
// still empty when this mount effect runs and ?plan / ?redirect would be lost.
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);
Expand Down Expand Up @@ -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;
}
Expand Down
34 changes: 4 additions & 30 deletions src/app/oauth-callback/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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('/');
Expand Down Expand Up @@ -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
Expand All @@ -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);
Expand Down
1 change: 1 addition & 0 deletions src/app/services/user.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const getToken = () => {

const logout = () => {
localStorage.removeItem("token");
localStorage.removeItem("refresh_token");
localStorage.removeItem("u");
}

Expand Down
74 changes: 74 additions & 0 deletions src/app/shared/desktop.ts
Original file line number Diff line number Diff line change
@@ -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;
}
};
Loading