Skip to content

Commit 344e6cc

Browse files
Merge pull request #228 from Open-STEM/env
movd to backend
2 parents 338c493 + ff72672 commit 344e6cc

6 files changed

Lines changed: 208 additions & 107 deletions

File tree

index.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
}
2020
}
2121
</script>
22+
<meta http-equiv="Cross-Origin-Opener-Policy" content="same-origin-allow-popups">
2223
</head>
2324

2425
<body class="debug-screens">

src/components/chat/ai-chat.tsx

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -31,27 +31,36 @@ export default function AIChat() {
3131

3232
// Generate a unique session ID for this chat session
3333
const newSessionId = uuidv4();
34-
console.log(`[AIChat] Generated new session ID: ${newSessionId}`);
3534
setSessionId(newSessionId);
3635

3736
// Ensure documentation is loaded via backend when chat opens
3837
const initDocs = async () => {
3938
if (!geminiClient.current || !newSessionId) return;
40-
setContextStatus('loading');
41-
const status = await geminiClient.current.getDocsStatus(newSessionId);
42-
if (!status.loaded) {
43-
const res = await geminiClient.current.loadDocs(newSessionId);
44-
setContextStatus(res.success ? 'loaded' : 'error');
45-
} else {
46-
setContextStatus('loaded');
39+
40+
try {
41+
setContextStatus('loading');
42+
43+
// STEP 1: Perform the handshake first!
44+
await geminiClient.current.performHandshake();
45+
46+
// STEP 2: Now that we have the cookie and token, check docs
47+
const status = await geminiClient.current.getDocsStatus(newSessionId);
48+
if (!status.loaded) {
49+
const res = await geminiClient.current.loadDocs(newSessionId);
50+
setContextStatus(res.success ? 'loaded' : 'error');
51+
} else {
52+
setContextStatus('loaded');
53+
}
54+
} catch (err) {
55+
console.error("Handshake/Init failed", err);
56+
setContextStatus('error');
4757
}
4858
};
4959
initDocs();
5060

5161
// Cleanup function when component unmounts (user closes chat or leaves IDE)
5262
return () => {
5363
if (geminiClient.current && newSessionId) {
54-
console.log(`[AIChat] Component unmounting, cleaning up session ${newSessionId.substring(0, 8)}...`);
5564
geminiClient.current.cleanupSession(newSessionId);
5665
}
5766
};
@@ -64,7 +73,6 @@ export default function AIChat() {
6473
// Use sendBeacon for more reliable cleanup on page unload (uses POST)
6574
const url = `/api/session/${sessionId}`;
6675
navigator.sendBeacon(url);
67-
console.log(`[AIChat] Page unloading, sent cleanup beacon for session ${sessionId.substring(0, 8)}...`);
6876
}
6977
};
7078

@@ -159,7 +167,6 @@ export default function AIChat() {
159167

160168
// Use the new simplified chat API - all teaching guidelines are now in backend
161169
const currentLanguage = i18n.language || 'en';
162-
console.log(`[AIChat] Sending message with session ${sessionId.substring(0, 8)}... (history: ${messages.length} messages, language: ${currentLanguage})`);
163170
await geminiClient.current.chatWithContext(
164171
sessionId,
165172
userMessage.content,
@@ -186,7 +193,7 @@ export default function AIChat() {
186193
} catch (error) {
187194
// Handle abort gracefully
188195
if (error instanceof Error && error.name === 'AbortError') {
189-
console.log('Generation was stopped by user');
196+
console.log('Generation was stopped by user'); // Keep this for user feedback on abort
190197
return;
191198
}
192199

@@ -344,4 +351,4 @@ export default function AIChat() {
344351
</div>
345352
</div>
346353
);
347-
}
354+
}

src/main.tsx

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,46 @@
1-
import { StrictMode } from 'react'
1+
import { StrictMode, useState, useEffect } from 'react'
22
import { createRoot } from 'react-dom/client'
33
import '@/index.css'
44
import '@/utils/i18n';
55
import '@/utils/blockly-global'; // Expose Blockly globally for external plugins
66
import App from '@/App.tsx'
77
import { GoogleOAuthProvider } from '@react-oauth/google';
88

9+
function Root() {
10+
const [googleClientId, setGoogleClientId] = useState<string | null>(null);
11+
const googleAuthBackendUrl = import.meta.env.VITE_GOOGLE_AUTH_URL;
12+
13+
useEffect(() => {
14+
const fetchClientId = async () => {
15+
try {
16+
const response = await fetch(`${googleAuthBackendUrl}/google-auth/client-id`);
17+
if (!response.ok) {
18+
throw new Error(`Failed to fetch client ID: ${response.statusText}`);
19+
}
20+
const data = await response.json();
21+
setGoogleClientId(data.client_id);
22+
} catch (error) {
23+
console.error("Error fetching Google Client ID:", error);
24+
// Handle error appropriately, e.g., show an error message to the user
25+
}
26+
};
27+
fetchClientId();
28+
}, []);
29+
30+
if (!googleClientId) {
31+
// Optionally render a loading spinner or message
32+
return <div>Loading Google authentication...</div>;
33+
}
34+
35+
return (
36+
<StrictMode>
37+
<GoogleOAuthProvider clientId={googleClientId}>
38+
<App />
39+
</GoogleOAuthProvider>
40+
</StrictMode>
41+
);
42+
}
43+
944
createRoot(document.getElementById('root')!).render(
10-
<StrictMode>
11-
<GoogleOAuthProvider clientId={import.meta.env.GOOGLE_CLIENT_ID}>
12-
<App />
13-
</GoogleOAuthProvider>
14-
</StrictMode>,
45+
<Root />
1546
)

src/services/google-auth.ts

Lines changed: 87 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -24,27 +24,23 @@ export type UserInfo = {
2424
* including obtaining access tokens and refreshing them.
2525
*/
2626
class GoogleAuthService {
27-
// Define the constructor with parameters if needed{
28-
private _tokenUrl: string = 'https://oauth2.googleapis.com/token';
29-
private _clientId: string;
27+
private _googleAuthBackendUrl: string;
28+
private _handshakeToken: string | null = null;
3029
private _isLogin: boolean = false;
3130
private _isAdmin: boolean = false;
32-
private _clientSecret: string;
33-
private _redirectUri: string;
3431
private _code: string | null = null;
3532
private _refreshToken: string | null = null;
3633
private _expiresIn: number | null = null;
3734
private _accessToken: string | null = null;
38-
private _timeoutId: NodeJS.Timeout | undefined
35+
private _timeoutId: NodeJS.Timeout | undefined;
3936
private _userProfile: UserProfile;
4037
private _modeLogger = logger.child({ module: 'googleapi' });
4138

4239

4340
constructor() {
44-
this._clientId = import.meta.env.GOOGLE_CLIENT_ID || '';
45-
this._clientSecret = import.meta.env.GOOGLE_CLIENT_SECRET || '';
46-
this._redirectUri = import.meta.env.GOOGLE_REDIRECT_URI || '';
41+
this._googleAuthBackendUrl = import.meta.env.VITE_GOOGLE_AUTH_URL;
4742
this._userProfile = { id: '', email: '', name: '', picture: '' };
43+
this.initHandshake(); // Initiate handshake on service creation
4844
}
4945

5046
dispose() {
@@ -111,21 +107,39 @@ class GoogleAuthService {
111107
this._isAdmin = isAdmin;
112108
}
113109

110+
private async initHandshake() {
111+
try {
112+
const response = await fetch(`${this._googleAuthBackendUrl}/google-auth/handshake`, {
113+
credentials: 'include', // Include cookies in cross-origin requests
114+
});
115+
if (!response.ok) {
116+
throw new Error(`Handshake failed: ${response.statusText}`);
117+
}
118+
const data = await response.json();
119+
this._handshakeToken = data.handshake_token;
120+
} catch (error) {
121+
if (error instanceof Error) {
122+
this._modeLogger.error(`Error during Google Auth handshake: ${error.stack ?? error.message}`);
123+
} else {
124+
this._modeLogger.error(`Error during Google Auth handshake: ${String(error)}`);
125+
}
126+
}
127+
}
128+
114129
/**
115130
* Refreshes the access token using the stored refresh token.
116131
* @returns A Promise that resolves to the new access token.
117132
*/
118133
private async refreshToken()
119134
{
120-
if (this._refreshToken) {
135+
if (this._refreshToken && this._handshakeToken) {
121136
this.getAccessToken().then((token) => {
122137
this._accessToken = token;
123-
this._modeLogger.debug(`Access Token refreshed: , ${this._accessToken}`);
124138
}).catch((error) => {
125139
this._modeLogger.error('Error refreshing access token:', error);
126140
});
127141
} else {
128-
this._modeLogger.warn('No refresh token available to refresh access token.');
142+
this._modeLogger.warn('No refresh token or handshake token available to refresh access token.');
129143
}
130144
// Set a timeout to refresh the token again after the current token expires
131145
this._timeoutId = setTimeout(this.refreshToken.bind(this), this._expiresIn ? this._expiresIn * 1000 * .95 : 3600000); // Default to 1 hour if expires_in is not set
@@ -146,38 +160,58 @@ class GoogleAuthService {
146160
// Logic to handle Google logout
147161
googleLogout();
148162
this._isLogin = false;
163+
// Also notify backend to clean up session
164+
if (this._handshakeToken) {
165+
try {
166+
await fetch(`${this._googleAuthBackendUrl}/google-auth/session/${this._handshakeToken}`, {
167+
method: 'DELETE',
168+
headers: {
169+
'X-Handshake-Token': this._handshakeToken,
170+
},
171+
credentials: 'include', // Include cookies in cross-origin requests
172+
});
173+
} catch (error) {
174+
if (error instanceof Error) {
175+
this._modeLogger.error(`Error cleaning up backend session: ${error.stack ?? error.message}`);
176+
} else {
177+
this._modeLogger.error(`Error cleaning up backend session: ${String(error)}`);
178+
}
179+
}
180+
}
149181
}
150182

151183
/**
152184
* Retrieves the access token using the stored refresh token.
153185
* @returns A Promise that resolves to the access token.
154186
*/
155187
async getAccessToken() {
156-
const payload = {
157-
grant_type: 'refresh_token',
158-
refresh_token: this._refreshToken ?? '',
159-
client_id: this._clientId,
160-
client_secret: this._clientSecret,
161-
};
188+
if (!this._handshakeToken || !this._refreshToken) {
189+
throw new Error('Handshake token or refresh token not available.');
190+
}
162191

163192
try {
164-
const response = fetch(this._tokenUrl, {
193+
const response = await fetch(`${this._googleAuthBackendUrl}/google-auth/refresh-token`, {
165194
method: 'POST',
166195
headers: {
167-
'Content-Type': 'application/x-www-form-urlencoded',
196+
'Content-Type': 'application/json',
197+
'X-Handshake-Token': this._handshakeToken,
168198
},
169-
body: new URLSearchParams(payload).toString(),
199+
body: JSON.stringify({ session_id: this._handshakeToken }), // Sending handshakeToken as session_id
200+
credentials: 'include', // Include cookies in cross-origin requests
170201
});
171202

172-
return (await response).json().then(data => {
173-
if (data.access_token) {
174-
this._accessToken = data.access_token;
175-
this._expiresIn = data.expires_in;
176-
return data.access_token;
177-
} else {
178-
throw new Error('Failed to get access token');
179-
}
180-
});
203+
if (!response.ok) {
204+
throw new Error(`Failed to refresh access token from backend: ${response.statusText}`);
205+
}
206+
const data = await response.json();
207+
208+
if (data.access_token) {
209+
this._accessToken = data.access_token;
210+
this._expiresIn = data.expires_in;
211+
return data.access_token;
212+
} else {
213+
throw new Error('Failed to get access token from backend response');
214+
}
181215
}
182216
catch (error) {
183217
if (error instanceof Error) {
@@ -192,34 +226,35 @@ class GoogleAuthService {
192226
* @returns A Promise that resolves to an object containing access_token, refresh_token, and expires_in.
193227
*/
194228
async getRefreshToken() {
195-
const payload = {
196-
grant_type: 'authorization_code',
197-
code: this._code ?? '',
198-
client_id: this._clientId,
199-
client_secret: this._clientSecret,
200-
redirect_uri: this._redirectUri,
201-
};
229+
if (!this._handshakeToken || !this._code) {
230+
throw new Error('Handshake token or authorization code not available.');
231+
}
202232

203233
try {
204-
const response = fetch(this._tokenUrl, {
234+
const response = await fetch(`${this._googleAuthBackendUrl}/google-auth/exchange-code`, {
205235
method: 'POST',
206236
headers: {
207-
'Content-Type': 'application/x-www-form-urlencoded',
237+
'Content-Type': 'application/json',
238+
'X-Handshake-Token': this._handshakeToken,
208239
},
209-
body: new URLSearchParams(payload).toString(),
240+
body: JSON.stringify({ code: this._code }),
241+
credentials: 'include', // Include cookies in cross-origin requests
210242
});
211243

212-
return (await response).json().then(data => {
213-
if (data.access_token) {
214-
this._timeoutId = setTimeout(() => this.refreshToken(), 1000);
215-
this._accessToken = data.access_token;
216-
this._refreshToken = data.refresh_token;
217-
this._expiresIn = data.expires_in;
218-
return { access_token: data.access_token, refresh_token: data.refresh_token, expires_in: data.expires_in };
219-
} else {
220-
throw new Error('Failed to refresh access token');
221-
}
222-
});
244+
if (!response.ok) {
245+
throw new Error(`Failed to exchange code for tokens from backend: ${response.statusText}`);
246+
}
247+
const data = await response.json();
248+
249+
if (data.access_token) {
250+
this._timeoutId = setTimeout(() => this.refreshToken(), 1000);
251+
this._accessToken = data.access_token;
252+
this._refreshToken = data.refresh_token; // Backend returns refresh token on first exchange
253+
this._expiresIn = data.expires_in;
254+
return { access_token: data.access_token, refresh_token: data.refresh_token, expires_in: data.expires_in };
255+
} else {
256+
throw new Error('Failed to get refresh token from backend response');
257+
}
223258
}
224259
catch (error) {
225260
if (error instanceof Error) {
@@ -230,4 +265,4 @@ class GoogleAuthService {
230265
}
231266
}
232267

233-
export default GoogleAuthService;
268+
export default GoogleAuthService;

0 commit comments

Comments
 (0)