From 52df47ceda3e22ebc9a0631dc303d2dfb219f426 Mon Sep 17 00:00:00 2001 From: sumomomomomo <102288745+sumomomomomo@users.noreply.github.com> Date: Fri, 26 Jan 2024 23:08:27 +0800 Subject: [PATCH 01/16] Replace gapi.auth2 --- src/commons/sagas/PersistenceSaga.tsx | 146 +++++++++++++++--- .../sagas/__tests__/PersistenceSaga.ts | 4 +- 2 files changed, 127 insertions(+), 23 deletions(-) diff --git a/src/commons/sagas/PersistenceSaga.tsx b/src/commons/sagas/PersistenceSaga.tsx index e87e2f923d..8d76d332c9 100644 --- a/src/commons/sagas/PersistenceSaga.tsx +++ b/src/commons/sagas/PersistenceSaga.tsx @@ -27,7 +27,7 @@ import { AsyncReturnType } from '../utils/TypeHelper'; import { safeTakeEvery as takeEvery, safeTakeLatest as takeLatest } from './SafeEffects'; const DISCOVERY_DOCS = ['https://www.googleapis.com/discovery/v1/apis/drive/v3/rest']; -const SCOPES = 'profile https://www.googleapis.com/auth/drive.file'; +const SCOPES = 'profile https://www.googleapis.com/auth/drive.file https://www.googleapis.com/auth/userinfo.email'; const UPLOAD_PATH = 'https://www.googleapis.com/upload/drive/v3/files'; // Special ID value for the Google Drive API. @@ -36,11 +36,15 @@ const ROOT_ID = 'root'; const MIME_SOURCE = 'text/plain'; // const MIME_FOLDER = 'application/vnd.google-apps.folder'; +// TODO: fix all calls to (window.google as any).accounts export function* persistenceSaga(): SagaIterator { + // Starts the function* () for every dispatched LOGOUT_GOOGLE action + // Same for all takeLatest() calls below, with respective types from PersistenceTypes yield takeLatest(LOGOUT_GOOGLE, function* () { yield put(actions.playgroundUpdatePersistenceFile(undefined)); yield call(ensureInitialised); - yield call([gapi.auth2.getAuthInstance(), 'signOut']); + yield gapi.client.setToken(null); + yield handleUserChanged(null); }); yield takeLatest(PERSISTENCE_OPEN_PICKER, function* (): any { @@ -307,24 +311,120 @@ const initialisationPromise: Promise = new Promise(res => { startInitialisation = res; }).then(initialise); -function handleUserChanged(user: gapi.auth2.GoogleUser) { - store.dispatch( - actions.setGoogleUser(user.isSignedIn() ? user.getBasicProfile().getEmail() : undefined) - ); +const getUserProfileData = async (accessToken: string) => { + const headers = new Headers() + headers.append('Authorization', `Bearer ${accessToken}`) + const response = await fetch('https://www.googleapis.com/oauth2/v3/userinfo', { + headers + }) + const data = await response.json(); + return data; } +async function handleUserChanged(accessToken: string | null) { + if (accessToken === null) { // TODO: check if access token is invalid instead of null + store.dispatch(actions.setGoogleUser(undefined)); + } + else { + const userProfileData = await getUserProfileData(accessToken) + const email = userProfileData.email; + console.log("handleUserChanged", email); + store.dispatch(actions.setGoogleUser(email)); + } +} + +let tokenClient: any; + async function initialise() { - await new Promise((resolve, reject) => - gapi.load('client:auth2', { callback: resolve, onerror: reject }) + // load GIS script + await new Promise((resolve, reject) => { + const scriptTag = document.createElement('script'); + scriptTag.src = 'https://accounts.google.com/gsi/client'; + scriptTag.async = true; + scriptTag.defer = true; + //scriptTag.nonce = nonce; + scriptTag.onload = () => { + console.log("success"); + resolve(); + //setScriptLoadedSuccessfully(true); + //onScriptLoadSuccessRef.current?.(); + }; + scriptTag.onerror = (ev) => { + console.log("failure"); + reject(ev); + //setScriptLoadedSuccessfully(false); + //onScriptLoadErrorRef.current?.(); + }; + + document.body.appendChild(scriptTag); + }); + + // load and initialize gapi.client + await new Promise((resolve, reject) => + gapi.load('client', { callback: () => {console.log("gapi.client loaded");resolve();}, onerror: reject }) ); await gapi.client.init({ - apiKey: Constants.googleApiKey, - clientId: Constants.googleClientId, - discoveryDocs: DISCOVERY_DOCS, - scope: SCOPES + discoveryDocs: DISCOVERY_DOCS }); - gapi.auth2.getAuthInstance().currentUser.listen(handleUserChanged); - handleUserChanged(gapi.auth2.getAuthInstance().currentUser.get()); + + // juju + // TODO: properly fix types here + await new Promise((resolve, reject) => { + //console.log("At least ur here"); + resolve((window.google as any).accounts.oauth2.initTokenClient({ + client_id: Constants.googleClientId, + scope: SCOPES, + callback: '' + })); + }).then((c) => { + //console.log(c); + tokenClient = c; + //console.log(tokenClient.requestAccessToken); + }); + + //await console.log("tokenClient", tokenClient); + + + //await gapi.client.init({ + //apiKey: Constants.googleApiKey, + //clientId: Constants.googleClientId, + //discoveryDocs: DISCOVERY_DOCS, + //scope: SCOPES + //}); + //gapi.auth2.getAuthInstance().currentUser.listen(handleUserChanged); + //handleUserChanged(gapi.auth2.getAuthInstance().currentUser.get()); +} + +// TODO: fix types +// adapted from https://developers.google.com/identity/oauth2/web/guides/migration-to-gis +async function getToken(err: any) { + + //if (err.result.error.code == 401 || (err.result.error.code == 403) && + // (err.result.error.status == "PERMISSION_DENIED")) { + if (err) { //TODO: fix after debugging + + // The access token is missing, invalid, or expired, prompt for user consent to obtain one. + await new Promise((resolve, reject) => { + try { + // Settle this promise in the response callback for requestAccessToken() + tokenClient.callback = (resp: any) => { + if (resp.error !== undefined) { + reject(resp); + } + // GIS has automatically updated gapi.client with the newly issued access token. + console.log('gapi.client access token: ' + JSON.stringify(gapi.client.getToken())); + resolve(resp); + }; + console.log(tokenClient.requestAccessToken); + tokenClient.requestAccessToken(); + } catch (err) { + console.log(err) + } + }); + } else { + // Errors unrelated to authorization: server errors, exceeding quota, bad requests, and so on. + throw new Error(err); + } } function* ensureInitialised() { @@ -334,9 +434,15 @@ function* ensureInitialised() { function* ensureInitialisedAndAuthorised() { yield call(ensureInitialised); - if (!gapi.auth2.getAuthInstance().isSignedIn.get()) { - yield gapi.auth2.getAuthInstance().signIn(); - } + // only call getToken if there is no token in gapi + console.log(gapi.client.getToken()); + if (gapi.client.getToken() === null) { + yield getToken(true); + yield handleUserChanged(gapi.client.getToken().access_token); + } //TODO: fix after debugging + //if (!gapi.auth2.getAuthInstance().isSignedIn.get()) { + // yield gapi.auth2.getAuthInstance().signIn(); + //} } type PickFileResult = @@ -372,9 +478,7 @@ function pickFile( .setTitle(title) .enableFeature(google.picker.Feature.NAV_HIDDEN) .addView(view) - .setOAuthToken( - gapi.auth2.getAuthInstance().currentUser.get().getAuthResponse().access_token - ) + .setOAuthToken(gapi.client.getToken().access_token) .setAppId(Constants.googleAppId!) .setDeveloperKey(Constants.googleApiKey!) .setCallback((data: any) => { @@ -519,4 +623,4 @@ function generateBoundary(): string { // End adapted part -export default persistenceSaga; +export default persistenceSaga; \ No newline at end of file diff --git a/src/commons/sagas/__tests__/PersistenceSaga.ts b/src/commons/sagas/__tests__/PersistenceSaga.ts index 4c9e9bb299..8916040ad2 100644 --- a/src/commons/sagas/__tests__/PersistenceSaga.ts +++ b/src/commons/sagas/__tests__/PersistenceSaga.ts @@ -28,6 +28,7 @@ const SOURCE_VARIANT = Variant.LAZY; const SOURCE_LIBRARY = ExternalLibraryName.SOUNDS; beforeAll(() => { + // TODO: rewrite const authInstance: gapi.auth2.GoogleAuth = { signOut: () => {}, isSignedIn: { @@ -62,10 +63,9 @@ beforeAll(() => { } } as any; }); - +// TODO: rewrite test test('LOGOUT_GOOGLE causes logout', async () => { const signOut = jest.spyOn(window.gapi.auth2.getAuthInstance(), 'signOut'); - await expectSaga(PersistenceSaga).dispatch(actions.logoutGoogle()).silentRun(); expect(signOut).toBeCalled(); }); From a3f5468a9a3beeba2d98911089a07cd3ab9be59c Mon Sep 17 00:00:00 2001 From: sumomomomomo <102288745+sumomomomomo@users.noreply.github.com> Date: Wed, 31 Jan 2024 14:21:38 +0800 Subject: [PATCH 02/16] Clean up PersistenceSaga + add persistence for GIS login --- package.json | 1 + src/commons/sagas/PersistenceSaga.tsx | 161 ++++++++++++-------------- 2 files changed, 75 insertions(+), 87 deletions(-) diff --git a/package.json b/package.json index 4fd6b01542..789327b29d 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "@szhsin/react-menu": "^4.0.0", "@tanstack/react-table": "^8.9.3", "@tremor/react": "^1.8.2", + "@types/google.accounts": "^0.0.14", "ace-builds": "^1.4.14", "acorn": "^8.9.0", "ag-grid-community": "^31.0.0", diff --git a/src/commons/sagas/PersistenceSaga.tsx b/src/commons/sagas/PersistenceSaga.tsx index 8d76d332c9..53446d6b86 100644 --- a/src/commons/sagas/PersistenceSaga.tsx +++ b/src/commons/sagas/PersistenceSaga.tsx @@ -36,22 +36,22 @@ const ROOT_ID = 'root'; const MIME_SOURCE = 'text/plain'; // const MIME_FOLDER = 'application/vnd.google-apps.folder'; -// TODO: fix all calls to (window.google as any).accounts +// GIS Token Client +let tokenClient: google.accounts.oauth2.TokenClient; + export function* persistenceSaga(): SagaIterator { - // Starts the function* () for every dispatched LOGOUT_GOOGLE action - // Same for all takeLatest() calls below, with respective types from PersistenceTypes yield takeLatest(LOGOUT_GOOGLE, function* () { yield put(actions.playgroundUpdatePersistenceFile(undefined)); yield call(ensureInitialised); yield gapi.client.setToken(null); yield handleUserChanged(null); + yield localStorage.removeItem("gsi-access-token"); }); yield takeLatest(PERSISTENCE_OPEN_PICKER, function* (): any { let toastKey: string | undefined; try { yield call(ensureInitialisedAndAuthorised); - const { id, name, picked } = yield call(pickFile, 'Pick a file to open'); if (!picked) { return; @@ -311,120 +311,105 @@ const initialisationPromise: Promise = new Promise(res => { startInitialisation = res; }).then(initialise); -const getUserProfileData = async (accessToken: string) => { - const headers = new Headers() - headers.append('Authorization', `Bearer ${accessToken}`) +async function getUserProfileData(accessToken: string) { + const headers = new Headers(); + headers.append('Authorization', `Bearer ${accessToken}`); const response = await fetch('https://www.googleapis.com/oauth2/v3/userinfo', { headers - }) + }); const data = await response.json(); return data; } +async function isOAuthTokenValid(accessToken: string) { + const userProfileData = await getUserProfileData(accessToken); + return userProfileData.error ? false : true; +} + +// updates store and localStorage async function handleUserChanged(accessToken: string | null) { - if (accessToken === null) { // TODO: check if access token is invalid instead of null + // logs out if null + if (accessToken === null) { store.dispatch(actions.setGoogleUser(undefined)); - } - else { + } else { const userProfileData = await getUserProfileData(accessToken) const email = userProfileData.email; - console.log("handleUserChanged", email); store.dispatch(actions.setGoogleUser(email)); + localStorage.setItem("gsi-access-token", accessToken); } } -let tokenClient: any; - -async function initialise() { +async function initialise() { // only called once // load GIS script - await new Promise((resolve, reject) => { + // adapted from https://github.com/MomenSherif/react-oauth + await new Promise ((resolve, reject) => { const scriptTag = document.createElement('script'); scriptTag.src = 'https://accounts.google.com/gsi/client'; scriptTag.async = true; scriptTag.defer = true; - //scriptTag.nonce = nonce; - scriptTag.onload = () => { - console.log("success"); - resolve(); - //setScriptLoadedSuccessfully(true); - //onScriptLoadSuccessRef.current?.(); - }; + scriptTag.onload = () => resolve(); scriptTag.onerror = (ev) => { - console.log("failure"); reject(ev); - //setScriptLoadedSuccessfully(false); - //onScriptLoadErrorRef.current?.(); }; - document.body.appendChild(scriptTag); }); // load and initialize gapi.client - await new Promise((resolve, reject) => - gapi.load('client', { callback: () => {console.log("gapi.client loaded");resolve();}, onerror: reject }) + await new Promise ((resolve, reject) => + gapi.load('client', { + callback: resolve, + onerror: reject + }) ); await gapi.client.init({ discoveryDocs: DISCOVERY_DOCS }); - // juju - // TODO: properly fix types here - await new Promise((resolve, reject) => { - //console.log("At least ur here"); - resolve((window.google as any).accounts.oauth2.initTokenClient({ - client_id: Constants.googleClientId, + // initialize GIS client + await new Promise ((resolve, reject) => { + resolve(window.google.accounts.oauth2.initTokenClient({ + client_id: Constants.googleClientId!, scope: SCOPES, - callback: '' + callback: () => void 0 // will be updated in getToken() })); }).then((c) => { - //console.log(c); tokenClient = c; - //console.log(tokenClient.requestAccessToken); }); - //await console.log("tokenClient", tokenClient); - - - //await gapi.client.init({ - //apiKey: Constants.googleApiKey, - //clientId: Constants.googleClientId, - //discoveryDocs: DISCOVERY_DOCS, - //scope: SCOPES - //}); - //gapi.auth2.getAuthInstance().currentUser.listen(handleUserChanged); - //handleUserChanged(gapi.auth2.getAuthInstance().currentUser.get()); + // check for stored token + // if it exists and is valid, load manually + // leave checking whether it is valid or not to ensureInitialisedAndAuthorised + if (localStorage.getItem("gsi-access-token")) { + await loadToken(localStorage.getItem("gsi-access-token")!); + } } -// TODO: fix types // adapted from https://developers.google.com/identity/oauth2/web/guides/migration-to-gis -async function getToken(err: any) { - - //if (err.result.error.code == 401 || (err.result.error.code == 403) && - // (err.result.error.status == "PERMISSION_DENIED")) { - if (err) { //TODO: fix after debugging +async function getToken() { + await new Promise((resolve, reject) => { + try { + // Settle this promise in the response callback for requestAccessToken() + // as any used here cos of limitations of the type declaration library + (tokenClient as any).callback = (resp: google.accounts.oauth2.TokenResponse) => { + if (resp.error !== undefined) { + reject(resp); + } + // GIS has automatically updated gapi.client with the newly issued access token. + handleUserChanged(gapi.client.getToken().access_token); + resolve(resp); + }; + tokenClient.requestAccessToken(); + } catch (err) { + reject(err); + } + }); +} - // The access token is missing, invalid, or expired, prompt for user consent to obtain one. - await new Promise((resolve, reject) => { - try { - // Settle this promise in the response callback for requestAccessToken() - tokenClient.callback = (resp: any) => { - if (resp.error !== undefined) { - reject(resp); - } - // GIS has automatically updated gapi.client with the newly issued access token. - console.log('gapi.client access token: ' + JSON.stringify(gapi.client.getToken())); - resolve(resp); - }; - console.log(tokenClient.requestAccessToken); - tokenClient.requestAccessToken(); - } catch (err) { - console.log(err) - } - }); - } else { - // Errors unrelated to authorization: server errors, exceeding quota, bad requests, and so on. - throw new Error(err); - } +// manually load token, when token is not gotten from getToken() +// but instead from localStorage +async function loadToken(accessToken: string) { + gapi.client.setToken({access_token: accessToken}); + return handleUserChanged(accessToken); } function* ensureInitialised() { @@ -432,17 +417,19 @@ function* ensureInitialised() { yield initialisationPromise; } -function* ensureInitialisedAndAuthorised() { +function* ensureInitialisedAndAuthorised() { // called multiple times yield call(ensureInitialised); - // only call getToken if there is no token in gapi - console.log(gapi.client.getToken()); - if (gapi.client.getToken() === null) { - yield getToken(true); - yield handleUserChanged(gapi.client.getToken().access_token); - } //TODO: fix after debugging - //if (!gapi.auth2.getAuthInstance().isSignedIn.get()) { - // yield gapi.auth2.getAuthInstance().signIn(); - //} + const currToken = gapi.client.getToken(); + + if (currToken === null) { + yield call(getToken); + } else { + // check if loaded token is still valid + const isValid: boolean = yield call(isOAuthTokenValid, currToken.access_token); + if (!isValid) { + yield call(getToken); + } + } } type PickFileResult = From 70846e439b225be8a34901907ef0e133d983eadd Mon Sep 17 00:00:00 2001 From: sumomomomomo <102288745+sumomomomomo@users.noreply.github.com> Date: Wed, 31 Jan 2024 23:25:24 +0800 Subject: [PATCH 03/16] Update Google Drive Login Buttons UI + Use session for login persistence --- src/commons/application/ApplicationTypes.ts | 1 + .../application/actions/SessionActions.ts | 6 ++ .../application/reducers/SessionsReducer.ts | 6 ++ src/commons/application/types/SessionTypes.ts | 3 + .../ControlBarGoogleDriveButtons.tsx | 25 +++++-- src/commons/sagas/PersistenceSaga.tsx | 68 ++++++++++--------- src/pages/createStore.ts | 5 +- src/pages/localStorage.ts | 3 +- src/pages/playground/Playground.tsx | 4 +- 9 files changed, 80 insertions(+), 41 deletions(-) diff --git a/src/commons/application/ApplicationTypes.ts b/src/commons/application/ApplicationTypes.ts index ad09522290..529cab2142 100644 --- a/src/commons/application/ApplicationTypes.ts +++ b/src/commons/application/ApplicationTypes.ts @@ -513,6 +513,7 @@ export const defaultSession: SessionState = { assessmentOverviews: undefined, agreedToResearch: undefined, sessionId: Date.now(), + googleAccessToken: undefined, githubOctokitObject: { octokit: undefined }, gradingOverviews: undefined, students: undefined, diff --git a/src/commons/application/actions/SessionActions.ts b/src/commons/application/actions/SessionActions.ts index c8cbc7d758..5d56e47a5a 100644 --- a/src/commons/application/actions/SessionActions.ts +++ b/src/commons/application/actions/SessionActions.ts @@ -51,6 +51,7 @@ import { LOGIN, LOGIN_GITHUB, LOGOUT_GITHUB, + LOGIN_GOOGLE, LOGOUT_GOOGLE, NotificationConfiguration, NotificationPreference, @@ -64,6 +65,7 @@ import { SET_COURSE_REGISTRATION, SET_GITHUB_ACCESS_TOKEN, SET_GITHUB_OCTOKIT_OBJECT, + SET_GOOGLE_ACCESS_TOKEN, SET_GOOGLE_USER, SET_NOTIFICATION_CONFIGS, SET_TOKENS, @@ -159,6 +161,8 @@ export const fetchStudents = createAction(FETCH_STUDENTS, () => ({ payload: {} } export const login = createAction(LOGIN, (providerId: string) => ({ payload: providerId })); +export const loginGoogle = createAction(LOGIN_GOOGLE, () => ({ payload: {} })); + export const logoutGoogle = createAction(LOGOUT_GOOGLE, () => ({ payload: {} })); export const loginGitHub = createAction(LOGIN_GITHUB, () => ({ payload: {} })); @@ -203,6 +207,8 @@ export const setAdminPanelCourseRegistrations = createAction( export const setGoogleUser = createAction(SET_GOOGLE_USER, (user?: string) => ({ payload: user })); +export const setGoogleAccessToken = createAction(SET_GOOGLE_ACCESS_TOKEN, (accessToken?: string) => ({ payload: accessToken })); + export const setGitHubOctokitObject = createAction( SET_GITHUB_OCTOKIT_OBJECT, (authToken?: string) => ({ payload: generateOctokitInstance(authToken || '') }) diff --git a/src/commons/application/reducers/SessionsReducer.ts b/src/commons/application/reducers/SessionsReducer.ts index 9f98f583e1..e988c3fbc9 100644 --- a/src/commons/application/reducers/SessionsReducer.ts +++ b/src/commons/application/reducers/SessionsReducer.ts @@ -18,6 +18,7 @@ import { SET_GITHUB_ACCESS_TOKEN, SET_GITHUB_OCTOKIT_OBJECT, SET_GOOGLE_USER, + SET_GOOGLE_ACCESS_TOKEN, SET_NOTIFICATION_CONFIGS, SET_TOKENS, SET_USER, @@ -54,6 +55,11 @@ export const SessionsReducer: Reducer = ( ...state, googleUser: action.payload }; + case SET_GOOGLE_ACCESS_TOKEN: + return { + ...state, + googleAccessToken: action.payload + } case SET_TOKENS: return { ...state, diff --git a/src/commons/application/types/SessionTypes.ts b/src/commons/application/types/SessionTypes.ts index 39eaeae823..1e1b966757 100644 --- a/src/commons/application/types/SessionTypes.ts +++ b/src/commons/application/types/SessionTypes.ts @@ -31,6 +31,7 @@ export const FETCH_STUDENTS = 'FETCH_STUDENTS'; export const FETCH_TEAM_FORMATION_OVERVIEW = 'FETCH_TEAM_FORMATION_OVERVIEW'; export const FETCH_TEAM_FORMATION_OVERVIEWS = 'FETCH_TEAM_FORMATION_OVERVIEWS'; export const LOGIN = 'LOGIN'; +export const LOGIN_GOOGLE = 'LOGIN_GOOGLE'; export const LOGOUT_GOOGLE = 'LOGOUT_GOOGLE'; export const LOGIN_GITHUB = 'LOGIN_GITHUB'; export const LOGOUT_GITHUB = 'LOGOUT_GITHUB'; @@ -41,6 +42,7 @@ export const SET_COURSE_REGISTRATION = 'SET_COURSE_REGISTRATION'; export const SET_ASSESSMENT_CONFIGURATIONS = 'SET_ASSESSMENT_CONFIGURATIONS'; export const SET_ADMIN_PANEL_COURSE_REGISTRATIONS = 'SET_ADMIN_PANEL_COURSE_REGISTRATIONS'; export const SET_GOOGLE_USER = 'SET_GOOGLE_USER'; +export const SET_GOOGLE_ACCESS_TOKEN = 'SET_GOOGLE_ACCESS_TOKEN'; export const SET_GITHUB_OCTOKIT_OBJECT = 'SET_GITHUB_OCTOKIT_OBJECT'; export const SET_GITHUB_ACCESS_TOKEN = 'SET_GITHUB_ACCESS_TOKEN'; export const SUBMIT_ANSWER = 'SUBMIT_ANSWER'; @@ -132,6 +134,7 @@ export type SessionState = { readonly gradings: Map; readonly notifications: Notification[]; readonly googleUser?: string; + readonly googleAccessToken?: string; readonly githubOctokitObject: { octokit: Octokit | undefined }; readonly githubAccessToken?: string; readonly remoteExecutionDevices?: Device[]; diff --git a/src/commons/controlBar/ControlBarGoogleDriveButtons.tsx b/src/commons/controlBar/ControlBarGoogleDriveButtons.tsx index ce70690d60..bab6af6df2 100644 --- a/src/commons/controlBar/ControlBarGoogleDriveButtons.tsx +++ b/src/commons/controlBar/ControlBarGoogleDriveButtons.tsx @@ -22,6 +22,7 @@ type Props = { onClickSave?: () => any; onClickSaveAs?: () => any; onClickLogOut?: () => any; + onClickLogIn?: () => any; onPopoverOpening?: () => any; }; @@ -41,7 +42,12 @@ export const ControlBarGoogleDriveButtons: React.FC = props => { /> ); const openButton = ( - + ); const saveButton = ( = props => { /> ); const saveAsButton = ( - + ); - const logoutButton = props.loggedInAs && ( + + const loginButton = props.loggedInAs ? ( - + + ) : ( + ); + const tooltipContent = props.isFolderModeEnabled ? 'Currently unsupported in Folder mode' : undefined; @@ -74,7 +89,7 @@ export const ControlBarGoogleDriveButtons: React.FC = props => { {openButton} {saveButton} {saveAsButton} - {logoutButton} + {loginButton} } diff --git a/src/commons/sagas/PersistenceSaga.tsx b/src/commons/sagas/PersistenceSaga.tsx index 53446d6b86..5550510323 100644 --- a/src/commons/sagas/PersistenceSaga.tsx +++ b/src/commons/sagas/PersistenceSaga.tsx @@ -13,7 +13,7 @@ import { import { store } from '../../pages/createStore'; import { OverallState } from '../application/ApplicationTypes'; import { ExternalLibraryName } from '../application/types/ExternalTypes'; -import { LOGOUT_GOOGLE } from '../application/types/SessionTypes'; +import { LOGIN_GOOGLE, LOGOUT_GOOGLE } from '../application/types/SessionTypes'; import { actions } from '../utils/ActionsHelper'; import Constants from '../utils/Constants'; import { showSimpleConfirmDialog, showSimplePromptDialog } from '../utils/DialogHelper'; @@ -43,9 +43,13 @@ export function* persistenceSaga(): SagaIterator { yield takeLatest(LOGOUT_GOOGLE, function* () { yield put(actions.playgroundUpdatePersistenceFile(undefined)); yield call(ensureInitialised); - yield gapi.client.setToken(null); - yield handleUserChanged(null); - yield localStorage.removeItem("gsi-access-token"); + yield call([gapi.client, "setToken"], null); + yield call(handleUserChanged, null); + }); + + yield takeLatest(LOGIN_GOOGLE, function* () { + yield call(ensureInitialised); + yield call(getToken); }); yield takeLatest(PERSISTENCE_OPEN_PICKER, function* (): any { @@ -311,31 +315,35 @@ const initialisationPromise: Promise = new Promise(res => { startInitialisation = res; }).then(initialise); -async function getUserProfileData(accessToken: string) { +/** + * Calls Google useinfo API to get user's profile data, specifically email, using accessToken. + * If email field does not exist in the JSON response (invalid access token), will return undefined. + * Used with handleUserChanged to handle login/logout. + * @param accessToken GIS access token + * @returns string if email field exists in JSON response, undefined if not + */ +async function getUserProfileDataEmail(accessToken: string): Promise { const headers = new Headers(); headers.append('Authorization', `Bearer ${accessToken}`); const response = await fetch('https://www.googleapis.com/oauth2/v3/userinfo', { headers }); const data = await response.json(); - return data; + return data.email; } -async function isOAuthTokenValid(accessToken: string) { - const userProfileData = await getUserProfileData(accessToken); - return userProfileData.error ? false : true; -} - -// updates store and localStorage +// only function that updates store async function handleUserChanged(accessToken: string | null) { // logs out if null - if (accessToken === null) { + if (accessToken === null) { // clear store store.dispatch(actions.setGoogleUser(undefined)); + store.dispatch(actions.setGoogleAccessToken(undefined)); } else { - const userProfileData = await getUserProfileData(accessToken) - const email = userProfileData.email; + const email = await getUserProfileDataEmail(accessToken); + // if access token is invalid, const email will be undefined + // so stores will also be cleared here if it is invalid store.dispatch(actions.setGoogleUser(email)); - localStorage.setItem("gsi-access-token", accessToken); + store.dispatch(actions.setGoogleAccessToken(accessToken)); } } @@ -377,24 +385,24 @@ async function initialise() { // only called once }); // check for stored token - // if it exists and is valid, load manually - // leave checking whether it is valid or not to ensureInitialisedAndAuthorised - if (localStorage.getItem("gsi-access-token")) { - await loadToken(localStorage.getItem("gsi-access-token")!); + const accessToken = store.getState().session.googleAccessToken; + if (accessToken) { + gapi.client.setToken({access_token: accessToken}); + handleUserChanged(accessToken); // this also logs out user if stored token is invalid } } // adapted from https://developers.google.com/identity/oauth2/web/guides/migration-to-gis -async function getToken() { - await new Promise((resolve, reject) => { +function* getToken() { + yield new Promise((resolve, reject) => { try { // Settle this promise in the response callback for requestAccessToken() - // as any used here cos of limitations of the type declaration library (tokenClient as any).callback = (resp: google.accounts.oauth2.TokenResponse) => { if (resp.error !== undefined) { reject(resp); } - // GIS has automatically updated gapi.client with the newly issued access token. + // GIS has already automatically updated gapi.client + // with the newly issued access token by this point handleUserChanged(gapi.client.getToken().access_token); resolve(resp); }; @@ -405,13 +413,6 @@ async function getToken() { }); } -// manually load token, when token is not gotten from getToken() -// but instead from localStorage -async function loadToken(accessToken: string) { - gapi.client.setToken({access_token: accessToken}); - return handleUserChanged(accessToken); -} - function* ensureInitialised() { startInitialisation(); yield initialisationPromise; @@ -419,13 +420,14 @@ function* ensureInitialised() { function* ensureInitialisedAndAuthorised() { // called multiple times yield call(ensureInitialised); - const currToken = gapi.client.getToken(); + const currToken: GoogleApiOAuth2TokenObject = yield call(gapi.client.getToken); if (currToken === null) { yield call(getToken); } else { // check if loaded token is still valid - const isValid: boolean = yield call(isOAuthTokenValid, currToken.access_token); + const email: string | undefined = yield call(getUserProfileDataEmail, currToken.access_token); + const isValid = email ? true : false; if (!isValid) { yield call(getToken); } diff --git a/src/pages/createStore.ts b/src/pages/createStore.ts index 48962a4a3e..9360d1c842 100644 --- a/src/pages/createStore.ts +++ b/src/pages/createStore.ts @@ -53,7 +53,10 @@ function loadStore(loadedStore: SavedState | undefined) { octokit: loadedStore.session.githubAccessToken ? generateOctokitInstance(loadedStore.session.githubAccessToken) : undefined - } + }, + googleUser: loadedStore.session.googleAccessToken + ? 'placeholder' + : undefined }, workspaces: { ...defaultState.workspaces, diff --git a/src/pages/localStorage.ts b/src/pages/localStorage.ts index 261f8b8c1d..3979eec3c4 100644 --- a/src/pages/localStorage.ts +++ b/src/pages/localStorage.ts @@ -74,7 +74,8 @@ export const saveState = (state: OverallState) => { assessmentConfigurations: state.session.assessmentConfigurations, notificationConfigs: state.session.notificationConfigs, configurableNotificationConfigs: state.session.configurableNotificationConfigs, - githubAccessToken: state.session.githubAccessToken + githubAccessToken: state.session.githubAccessToken, + googleAccessToken: state.session.googleAccessToken }, achievements: state.achievement.achievements, playgroundIsFolderModeEnabled: state.workspaces.playground.isFolderModeEnabled, diff --git a/src/pages/playground/Playground.tsx b/src/pages/playground/Playground.tsx index 4ae2ae9741..14a396a236 100644 --- a/src/pages/playground/Playground.tsx +++ b/src/pages/playground/Playground.tsx @@ -20,6 +20,7 @@ import { import { loginGitHub, logoutGitHub, + loginGoogle, logoutGoogle } from 'src/commons/application/actions/SessionActions'; import { @@ -271,7 +272,7 @@ const Playground: React.FC = props => { googleUser: persistenceUser, githubOctokitObject } = useTypedSelector(state => state.session); - + const dispatch = useDispatch(); const { handleChangeExecTime, @@ -596,6 +597,7 @@ const Playground: React.FC = props => { onClickSave={ persistenceFile ? () => dispatch(persistenceSaveFile(persistenceFile)) : undefined } + onClickLogIn={() => dispatch(loginGoogle())} onClickLogOut={() => dispatch(logoutGoogle())} onPopoverOpening={() => dispatch(persistenceInitialise())} /> From 58ba4c7f87c23e82801701788ed5e6a9959cad6f Mon Sep 17 00:00:00 2001 From: sumomomomomo <102288745+sumomomomomo@users.noreply.github.com> Date: Mon, 5 Feb 2024 16:12:13 +0800 Subject: [PATCH 04/16] Cleanup + Fix PersistenceSaga test --- .../application/actions/SessionActions.ts | 3 + .../application/reducers/SessionsReducer.ts | 7 ++ src/commons/application/types/SessionTypes.ts | 2 + src/commons/sagas/PersistenceSaga.tsx | 73 ++++++++----------- .../sagas/__tests__/PersistenceSaga.ts | 54 +++++++------- src/features/persistence/PersistenceUtils.tsx | 16 ++++ src/pages/__tests__/createStore.test.ts | 10 ++- src/pages/createStore.ts | 2 +- 8 files changed, 92 insertions(+), 75 deletions(-) create mode 100644 src/features/persistence/PersistenceUtils.tsx diff --git a/src/commons/application/actions/SessionActions.ts b/src/commons/application/actions/SessionActions.ts index 5d56e47a5a..94a83197e3 100644 --- a/src/commons/application/actions/SessionActions.ts +++ b/src/commons/application/actions/SessionActions.ts @@ -58,6 +58,7 @@ import { REAUTOGRADE_ANSWER, REAUTOGRADE_SUBMISSION, REMOVE_GITHUB_OCTOKIT_OBJECT_AND_ACCESS_TOKEN, + REMOVE_GOOGLE_USER_AND_ACCESS_TOKEN, SET_ADMIN_PANEL_COURSE_REGISTRATIONS, SET_ASSESSMENT_CONFIGURATIONS, SET_CONFIGURABLE_NOTIFICATION_CONFIGS, @@ -223,6 +224,8 @@ export const removeGitHubOctokitObjectAndAccessToken = createAction( () => ({ payload: {} }) ); +export const removeGoogleUserAndAccessToken = createAction(REMOVE_GOOGLE_USER_AND_ACCESS_TOKEN, () => ({ payload: {} })); + export const submitAnswer = createAction( SUBMIT_ANSWER, (id: number, answer: string | number | ContestEntry[]) => ({ payload: { id, answer } }) diff --git a/src/commons/application/reducers/SessionsReducer.ts b/src/commons/application/reducers/SessionsReducer.ts index e988c3fbc9..dbf95440e4 100644 --- a/src/commons/application/reducers/SessionsReducer.ts +++ b/src/commons/application/reducers/SessionsReducer.ts @@ -9,6 +9,7 @@ import { defaultSession } from '../ApplicationTypes'; import { LOG_OUT } from '../types/CommonsTypes'; import { REMOVE_GITHUB_OCTOKIT_OBJECT_AND_ACCESS_TOKEN, + REMOVE_GOOGLE_USER_AND_ACCESS_TOKEN, SessionState, SET_ADMIN_PANEL_COURSE_REGISTRATIONS, SET_ASSESSMENT_CONFIGURATIONS, @@ -162,6 +163,12 @@ export const SessionsReducer: Reducer = ( githubOctokitObject: { octokit: undefined }, githubAccessToken: undefined }; + case REMOVE_GOOGLE_USER_AND_ACCESS_TOKEN: + return { + ...state, + googleUser: undefined, + googleAccessToken: undefined + }; default: return state; } diff --git a/src/commons/application/types/SessionTypes.ts b/src/commons/application/types/SessionTypes.ts index 1e1b966757..1fe30a96f1 100644 --- a/src/commons/application/types/SessionTypes.ts +++ b/src/commons/application/types/SessionTypes.ts @@ -53,6 +53,8 @@ export const REAUTOGRADE_SUBMISSION = 'REAUTOGRADE_SUBMISSION'; export const REAUTOGRADE_ANSWER = 'REAUTOGRADE_ANSWER'; export const REMOVE_GITHUB_OCTOKIT_OBJECT_AND_ACCESS_TOKEN = 'REMOVE_GITHUB_OCTOKIT_OBJECT_AND_ACCESS_TOKEN'; +export const REMOVE_GOOGLE_USER_AND_ACCESS_TOKEN = + 'REMOVE_GOOGLE_USER_AND_ACCESS_TOKEN'; export const UNSUBMIT_SUBMISSION = 'UNSUBMIT_SUBMISSION'; export const UPDATE_ASSESSMENT_OVERVIEWS = 'UPDATE_ASSESSMENT_OVERVIEWS'; export const UPDATE_TOTAL_XP = 'UPDATE_TOTAL_XP'; diff --git a/src/commons/sagas/PersistenceSaga.tsx b/src/commons/sagas/PersistenceSaga.tsx index 5550510323..3bcdc23dab 100644 --- a/src/commons/sagas/PersistenceSaga.tsx +++ b/src/commons/sagas/PersistenceSaga.tsx @@ -10,6 +10,7 @@ import { PERSISTENCE_SAVE_FILE_AS, PersistenceFile } from '../../features/persistence/PersistenceTypes'; +import { getUserProfileDataEmail } from '../../features/persistence/PersistenceUtils'; import { store } from '../../pages/createStore'; import { OverallState } from '../application/ApplicationTypes'; import { ExternalLibraryName } from '../application/types/ExternalTypes'; @@ -40,18 +41,28 @@ const MIME_SOURCE = 'text/plain'; let tokenClient: google.accounts.oauth2.TokenClient; export function* persistenceSaga(): SagaIterator { - yield takeLatest(LOGOUT_GOOGLE, function* () { + yield takeLatest(LOGOUT_GOOGLE, function* (): any { yield put(actions.playgroundUpdatePersistenceFile(undefined)); yield call(ensureInitialised); - yield call([gapi.client, "setToken"], null); - yield call(handleUserChanged, null); + yield call(gapi.client.setToken, null); + yield put(actions.removeGoogleUserAndAccessToken()); }); - yield takeLatest(LOGIN_GOOGLE, function* () { + yield takeLatest(LOGIN_GOOGLE, function* (): any { yield call(ensureInitialised); yield call(getToken); }); + yield takeEvery(PERSISTENCE_INITIALISE, function* (): any { + yield call(ensureInitialised); + // check for stored token + const accessToken = yield select((state: OverallState) => state.session.googleAccessToken); + if (accessToken) { + yield call(gapi.client.setToken, {access_token: accessToken}); + yield call(handleUserChanged, accessToken); + } + }); + yield takeLatest(PERSISTENCE_OPEN_PICKER, function* (): any { let toastKey: string | undefined; try { @@ -290,8 +301,6 @@ export function* persistenceSaga(): SagaIterator { } } ); - - yield takeEvery(PERSISTENCE_INITIALISE, ensureInitialised); } interface IPlaygroundConfig { @@ -315,38 +324,6 @@ const initialisationPromise: Promise = new Promise(res => { startInitialisation = res; }).then(initialise); -/** - * Calls Google useinfo API to get user's profile data, specifically email, using accessToken. - * If email field does not exist in the JSON response (invalid access token), will return undefined. - * Used with handleUserChanged to handle login/logout. - * @param accessToken GIS access token - * @returns string if email field exists in JSON response, undefined if not - */ -async function getUserProfileDataEmail(accessToken: string): Promise { - const headers = new Headers(); - headers.append('Authorization', `Bearer ${accessToken}`); - const response = await fetch('https://www.googleapis.com/oauth2/v3/userinfo', { - headers - }); - const data = await response.json(); - return data.email; -} - -// only function that updates store -async function handleUserChanged(accessToken: string | null) { - // logs out if null - if (accessToken === null) { // clear store - store.dispatch(actions.setGoogleUser(undefined)); - store.dispatch(actions.setGoogleAccessToken(undefined)); - } else { - const email = await getUserProfileDataEmail(accessToken); - // if access token is invalid, const email will be undefined - // so stores will also be cleared here if it is invalid - store.dispatch(actions.setGoogleUser(email)); - store.dispatch(actions.setGoogleAccessToken(accessToken)); - } -} - async function initialise() { // only called once // load GIS script // adapted from https://github.com/MomenSherif/react-oauth @@ -384,11 +361,19 @@ async function initialise() { // only called once tokenClient = c; }); - // check for stored token - const accessToken = store.getState().session.googleAccessToken; - if (accessToken) { - gapi.client.setToken({access_token: accessToken}); - handleUserChanged(accessToken); // this also logs out user if stored token is invalid +} + +function* handleUserChanged(accessToken: string | null) { + if (accessToken === null) { + yield put(actions.removeGoogleUserAndAccessToken()); + } else { + const email: string | undefined = yield call(getUserProfileDataEmail, accessToken); + if (!email) { + yield put(actions.removeGoogleUserAndAccessToken()); + } else { + yield put(store.dispatch(actions.setGoogleUser(email))); + yield put(store.dispatch(actions.setGoogleAccessToken(accessToken))); + } } } @@ -403,7 +388,6 @@ function* getToken() { } // GIS has already automatically updated gapi.client // with the newly issued access token by this point - handleUserChanged(gapi.client.getToken().access_token); resolve(resp); }; tokenClient.requestAccessToken(); @@ -411,6 +395,7 @@ function* getToken() { reject(err); } }); + yield call(handleUserChanged, gapi.client.getToken().access_token); } function* ensureInitialised() { diff --git a/src/commons/sagas/__tests__/PersistenceSaga.ts b/src/commons/sagas/__tests__/PersistenceSaga.ts index 8916040ad2..d7b3f97bcc 100644 --- a/src/commons/sagas/__tests__/PersistenceSaga.ts +++ b/src/commons/sagas/__tests__/PersistenceSaga.ts @@ -2,6 +2,7 @@ import { Chapter, Variant } from 'js-slang/dist/types'; import { expectSaga } from 'redux-saga-test-plan'; import { PLAYGROUND_UPDATE_PERSISTENCE_FILE } from '../../../features/playground/PlaygroundTypes'; +import { REMOVE_GOOGLE_USER_AND_ACCESS_TOKEN } from '../../application/types/SessionTypes'; import { ExternalLibraryName } from '../../application/types/ExternalTypes'; import { actions } from '../../utils/ActionsHelper'; import { @@ -19,7 +20,6 @@ jest.mock('../../../pages/createStore'); // eslint-disable-next-line @typescript-eslint/no-var-requires const PersistenceSaga = require('../PersistenceSaga').default; -const USER_EMAIL = 'test@email.com'; const FILE_ID = '123'; const FILE_NAME = 'file'; const FILE_DATA = '// Hello world'; @@ -28,28 +28,12 @@ const SOURCE_VARIANT = Variant.LAZY; const SOURCE_LIBRARY = ExternalLibraryName.SOUNDS; beforeAll(() => { - // TODO: rewrite - const authInstance: gapi.auth2.GoogleAuth = { - signOut: () => {}, - isSignedIn: { - get: () => true, - listen: () => {} - }, - currentUser: { - listen: () => {}, - get: () => ({ - isSignedIn: () => true, - getBasicProfile: () => ({ - getEmail: () => USER_EMAIL - }) - }) - } - } as any; - window.gapi = { client: { request: () => {}, init: () => Promise.resolve(), + getToken: () => {}, + setToken: () => {}, drive: { files: { get: () => {} @@ -57,17 +41,31 @@ beforeAll(() => { } }, load: (apiName: string, callbackOrConfig: gapi.CallbackOrConfig) => - typeof callbackOrConfig === 'function' ? callbackOrConfig() : callbackOrConfig.callback(), - auth2: { - getAuthInstance: () => authInstance - } + typeof callbackOrConfig === 'function' ? callbackOrConfig() : callbackOrConfig.callback() } as any; }); -// TODO: rewrite test -test('LOGOUT_GOOGLE causes logout', async () => { - const signOut = jest.spyOn(window.gapi.auth2.getAuthInstance(), 'signOut'); - await expectSaga(PersistenceSaga).dispatch(actions.logoutGoogle()).silentRun(); - expect(signOut).toBeCalled(); + +test('LOGOUT_GOOGLE results in REMOVE_GOOGLE_USER_AND_ACCESS_TOKEN being dispatched', async () => { + await expectSaga(PersistenceSaga) + .put({ + type: PLAYGROUND_UPDATE_PERSISTENCE_FILE, + payload: undefined, + meta: undefined, + error: undefined + }) + .put({ + type: REMOVE_GOOGLE_USER_AND_ACCESS_TOKEN, + payload: undefined, + meta: undefined, + error: undefined + }) + .provide({ + call(effect, next) { + return; + } + }) + .dispatch(actions.logoutGoogle()) + .silentRun(); }); describe('PERSISTENCE_OPEN_PICKER', () => { diff --git a/src/features/persistence/PersistenceUtils.tsx b/src/features/persistence/PersistenceUtils.tsx new file mode 100644 index 0000000000..b1491ee25d --- /dev/null +++ b/src/features/persistence/PersistenceUtils.tsx @@ -0,0 +1,16 @@ +/** + * Calls Google useinfo API to get user's profile data, specifically email, using accessToken. + * If email field does not exist in the JSON response (invalid access token), will return undefined. + * Used with handleUserChanged to handle login. + * @param accessToken GIS access token + * @returns string if email field exists in JSON response, undefined if not + */ +export async function getUserProfileDataEmail(accessToken: string): Promise { + const headers = new Headers(); + headers.append('Authorization', `Bearer ${accessToken}`); + const response = await fetch('https://www.googleapis.com/oauth2/v3/userinfo', { + headers + }); + const data = await response.json(); + return data.email; + } \ No newline at end of file diff --git a/src/pages/__tests__/createStore.test.ts b/src/pages/__tests__/createStore.test.ts index 99407108cb..2e9233dd1c 100644 --- a/src/pages/__tests__/createStore.test.ts +++ b/src/pages/__tests__/createStore.test.ts @@ -20,7 +20,8 @@ const mockChangedStoredState: SavedState = { role: undefined, name: 'Jeff', userId: 1, - githubAccessToken: 'githubAccessToken' + githubAccessToken: 'githubAccessToken', + googleAccessToken: 'googleAccessToken' }, playgroundIsFolderModeEnabled: true, playgroundActiveEditorTabIndex: { @@ -57,7 +58,8 @@ const mockChangedState: OverallState = { role: undefined, name: 'Jeff', userId: 1, - githubAccessToken: 'githubAccessToken' + githubAccessToken: 'githubAccessToken', + googleAccessToken: 'googleAccessToken' }, workspaces: { ...defaultState.workspaces, @@ -102,8 +104,12 @@ describe('createStore() function', () => { const octokit = received.session.githubOctokitObject.octokit; delete received.session.githubOctokitObject.octokit; + const googleUser = received.session.googleUser; + delete received.session.googleUser; + expect(received).toEqual(mockChangedState); expect(octokit).toBeDefined(); + expect(googleUser).toEqual("placeholder"); localStorage.removeItem('storedState'); }); }); diff --git a/src/pages/createStore.ts b/src/pages/createStore.ts index 9360d1c842..6e0019691e 100644 --- a/src/pages/createStore.ts +++ b/src/pages/createStore.ts @@ -55,7 +55,7 @@ function loadStore(loadedStore: SavedState | undefined) { : undefined }, googleUser: loadedStore.session.googleAccessToken - ? 'placeholder' + ? 'placeholder' // updates in PersistenceSaga : undefined }, workspaces: { From 7a0811644b7cd7694988b252b3036bcf2120de09 Mon Sep 17 00:00:00 2001 From: sayomaki Date: Mon, 5 Feb 2024 16:51:30 +0800 Subject: [PATCH 05/16] Add updated yarn lockfile --- yarn.lock | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/yarn.lock b/yarn.lock index 4274b2d4ab..b19a7c7332 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2910,6 +2910,11 @@ resolved "https://registry.yarnpkg.com/@types/gapi/-/gapi-0.0.44.tgz#f097f7a0f59d63a59098a08a62a560ca168426fb" integrity sha512-hsgJMfZ/pMwI15UlAYHMNwj8DRoigo1odhbPwEXdp19ZQwQAXbcRrpzaDsfc+9XM6RtGpvl4Ja7uW8A+KPCa7w== +"@types/google.accounts@^0.0.14": + version "0.0.14" + resolved "https://registry.yarnpkg.com/@types/google.accounts/-/google.accounts-0.0.14.tgz#ffc36c30c5107b9bdab115830c85f7e377bc0dea" + integrity sha512-HqIVkVzpiLWhlajhQQd4rIV7czanFvXblJI2J1fSrL+VKQuQwwZ63m35D/mI0flsqKE6p/hNrAG0Yn4FD6JvNA== + "@types/google.picker@^0.0.39": version "0.0.39" resolved "https://registry.yarnpkg.com/@types/google.picker/-/google.picker-0.0.39.tgz#bb205ffb9736e8ec4a1af7cc87811d0fc5dc30fa" From 688a8a99a56751b996edc617df8428581b9fad7f Mon Sep 17 00:00:00 2001 From: sayomaki Date: Mon, 5 Feb 2024 16:59:08 +0800 Subject: [PATCH 06/16] Fix formatting through `yarn format` --- .../application/actions/SessionActions.ts | 2 +- .../application/reducers/SessionsReducer.ts | 4 +- src/commons/application/types/SessionTypes.ts | 3 +- .../ControlBarGoogleDriveButtons.tsx | 12 ++--- src/commons/sagas/PersistenceSaga.tsx | 48 ++++++++++--------- .../sagas/__tests__/PersistenceSaga.ts | 40 ++++++++-------- src/features/persistence/PersistenceUtils.tsx | 16 +++---- src/pages/__tests__/createStore.test.ts | 2 +- src/pages/createStore.ts | 2 +- src/pages/playground/Playground.tsx | 4 +- 10 files changed, 68 insertions(+), 65 deletions(-) diff --git a/src/commons/application/actions/SessionActions.ts b/src/commons/application/actions/SessionActions.ts index 94a83197e3..f12f42607e 100644 --- a/src/commons/application/actions/SessionActions.ts +++ b/src/commons/application/actions/SessionActions.ts @@ -50,8 +50,8 @@ import { FETCH_USER_AND_COURSE, LOGIN, LOGIN_GITHUB, - LOGOUT_GITHUB, LOGIN_GOOGLE, + LOGOUT_GITHUB, LOGOUT_GOOGLE, NotificationConfiguration, NotificationPreference, diff --git a/src/commons/application/reducers/SessionsReducer.ts b/src/commons/application/reducers/SessionsReducer.ts index dbf95440e4..b35dc5bd69 100644 --- a/src/commons/application/reducers/SessionsReducer.ts +++ b/src/commons/application/reducers/SessionsReducer.ts @@ -18,8 +18,8 @@ import { SET_COURSE_REGISTRATION, SET_GITHUB_ACCESS_TOKEN, SET_GITHUB_OCTOKIT_OBJECT, - SET_GOOGLE_USER, SET_GOOGLE_ACCESS_TOKEN, + SET_GOOGLE_USER, SET_NOTIFICATION_CONFIGS, SET_TOKENS, SET_USER, @@ -60,7 +60,7 @@ export const SessionsReducer: Reducer = ( return { ...state, googleAccessToken: action.payload - } + }; case SET_TOKENS: return { ...state, diff --git a/src/commons/application/types/SessionTypes.ts b/src/commons/application/types/SessionTypes.ts index 1fe30a96f1..abd96eb80f 100644 --- a/src/commons/application/types/SessionTypes.ts +++ b/src/commons/application/types/SessionTypes.ts @@ -53,8 +53,7 @@ export const REAUTOGRADE_SUBMISSION = 'REAUTOGRADE_SUBMISSION'; export const REAUTOGRADE_ANSWER = 'REAUTOGRADE_ANSWER'; export const REMOVE_GITHUB_OCTOKIT_OBJECT_AND_ACCESS_TOKEN = 'REMOVE_GITHUB_OCTOKIT_OBJECT_AND_ACCESS_TOKEN'; -export const REMOVE_GOOGLE_USER_AND_ACCESS_TOKEN = - 'REMOVE_GOOGLE_USER_AND_ACCESS_TOKEN'; +export const REMOVE_GOOGLE_USER_AND_ACCESS_TOKEN = 'REMOVE_GOOGLE_USER_AND_ACCESS_TOKEN'; export const UNSUBMIT_SUBMISSION = 'UNSUBMIT_SUBMISSION'; export const UPDATE_ASSESSMENT_OVERVIEWS = 'UPDATE_ASSESSMENT_OVERVIEWS'; export const UPDATE_TOTAL_XP = 'UPDATE_TOTAL_XP'; diff --git a/src/commons/controlBar/ControlBarGoogleDriveButtons.tsx b/src/commons/controlBar/ControlBarGoogleDriveButtons.tsx index bab6af6df2..e35bc4b22e 100644 --- a/src/commons/controlBar/ControlBarGoogleDriveButtons.tsx +++ b/src/commons/controlBar/ControlBarGoogleDriveButtons.tsx @@ -42,10 +42,10 @@ export const ControlBarGoogleDriveButtons: React.FC = props => { /> ); const openButton = ( - ); @@ -59,8 +59,8 @@ export const ControlBarGoogleDriveButtons: React.FC = props => { /> ); const saveAsButton = ( - state.session.googleAccessToken); if (accessToken) { - yield call(gapi.client.setToken, {access_token: accessToken}); + yield call(gapi.client.setToken, { access_token: accessToken }); yield call(handleUserChanged, accessToken); } }); @@ -324,26 +325,27 @@ const initialisationPromise: Promise = new Promise(res => { startInitialisation = res; }).then(initialise); -async function initialise() { // only called once +// only called once +async function initialise() { // load GIS script // adapted from https://github.com/MomenSherif/react-oauth - await new Promise ((resolve, reject) => { + await new Promise((resolve, reject) => { const scriptTag = document.createElement('script'); scriptTag.src = 'https://accounts.google.com/gsi/client'; scriptTag.async = true; scriptTag.defer = true; scriptTag.onload = () => resolve(); - scriptTag.onerror = (ev) => { + scriptTag.onerror = ev => { reject(ev); }; document.body.appendChild(scriptTag); }); // load and initialize gapi.client - await new Promise ((resolve, reject) => - gapi.load('client', { - callback: resolve, - onerror: reject + await new Promise((resolve, reject) => + gapi.load('client', { + callback: resolve, + onerror: reject }) ); await gapi.client.init({ @@ -351,16 +353,17 @@ async function initialise() { // only called once }); // initialize GIS client - await new Promise ((resolve, reject) => { - resolve(window.google.accounts.oauth2.initTokenClient({ - client_id: Constants.googleClientId!, - scope: SCOPES, - callback: () => void 0 // will be updated in getToken() - })); - }).then((c) => { - tokenClient = c; - }); - + await new Promise((resolve, reject) => { + resolve( + window.google.accounts.oauth2.initTokenClient({ + client_id: Constants.googleClientId!, + scope: SCOPES, + callback: () => void 0 // will be updated in getToken() + }) + ); + }).then(c => { + tokenClient = c; + }); } function* handleUserChanged(accessToken: string | null) { @@ -386,7 +389,7 @@ function* getToken() { if (resp.error !== undefined) { reject(resp); } - // GIS has already automatically updated gapi.client + // GIS has already automatically updated gapi.client // with the newly issued access token by this point resolve(resp); }; @@ -403,7 +406,8 @@ function* ensureInitialised() { yield initialisationPromise; } -function* ensureInitialisedAndAuthorised() { // called multiple times +// called multiple times +function* ensureInitialisedAndAuthorised() { yield call(ensureInitialised); const currToken: GoogleApiOAuth2TokenObject = yield call(gapi.client.getToken); @@ -597,4 +601,4 @@ function generateBoundary(): string { // End adapted part -export default persistenceSaga; \ No newline at end of file +export default persistenceSaga; diff --git a/src/commons/sagas/__tests__/PersistenceSaga.ts b/src/commons/sagas/__tests__/PersistenceSaga.ts index d7b3f97bcc..6b980b1fb6 100644 --- a/src/commons/sagas/__tests__/PersistenceSaga.ts +++ b/src/commons/sagas/__tests__/PersistenceSaga.ts @@ -2,8 +2,8 @@ import { Chapter, Variant } from 'js-slang/dist/types'; import { expectSaga } from 'redux-saga-test-plan'; import { PLAYGROUND_UPDATE_PERSISTENCE_FILE } from '../../../features/playground/PlaygroundTypes'; -import { REMOVE_GOOGLE_USER_AND_ACCESS_TOKEN } from '../../application/types/SessionTypes'; import { ExternalLibraryName } from '../../application/types/ExternalTypes'; +import { REMOVE_GOOGLE_USER_AND_ACCESS_TOKEN } from '../../application/types/SessionTypes'; import { actions } from '../../utils/ActionsHelper'; import { CHANGE_EXTERNAL_LIBRARY, @@ -47,25 +47,25 @@ beforeAll(() => { test('LOGOUT_GOOGLE results in REMOVE_GOOGLE_USER_AND_ACCESS_TOKEN being dispatched', async () => { await expectSaga(PersistenceSaga) - .put({ - type: PLAYGROUND_UPDATE_PERSISTENCE_FILE, - payload: undefined, - meta: undefined, - error: undefined - }) - .put({ - type: REMOVE_GOOGLE_USER_AND_ACCESS_TOKEN, - payload: undefined, - meta: undefined, - error: undefined - }) - .provide({ - call(effect, next) { - return; - } - }) - .dispatch(actions.logoutGoogle()) - .silentRun(); + .put({ + type: PLAYGROUND_UPDATE_PERSISTENCE_FILE, + payload: undefined, + meta: undefined, + error: undefined + }) + .put({ + type: REMOVE_GOOGLE_USER_AND_ACCESS_TOKEN, + payload: undefined, + meta: undefined, + error: undefined + }) + .provide({ + call(effect, next) { + return; + } + }) + .dispatch(actions.logoutGoogle()) + .silentRun(); }); describe('PERSISTENCE_OPEN_PICKER', () => { diff --git a/src/features/persistence/PersistenceUtils.tsx b/src/features/persistence/PersistenceUtils.tsx index b1491ee25d..03c30dcaf4 100644 --- a/src/features/persistence/PersistenceUtils.tsx +++ b/src/features/persistence/PersistenceUtils.tsx @@ -6,11 +6,11 @@ * @returns string if email field exists in JSON response, undefined if not */ export async function getUserProfileDataEmail(accessToken: string): Promise { - const headers = new Headers(); - headers.append('Authorization', `Bearer ${accessToken}`); - const response = await fetch('https://www.googleapis.com/oauth2/v3/userinfo', { - headers - }); - const data = await response.json(); - return data.email; - } \ No newline at end of file + const headers = new Headers(); + headers.append('Authorization', `Bearer ${accessToken}`); + const response = await fetch('https://www.googleapis.com/oauth2/v3/userinfo', { + headers + }); + const data = await response.json(); + return data.email; +} diff --git a/src/pages/__tests__/createStore.test.ts b/src/pages/__tests__/createStore.test.ts index 2e9233dd1c..b377d14ea8 100644 --- a/src/pages/__tests__/createStore.test.ts +++ b/src/pages/__tests__/createStore.test.ts @@ -109,7 +109,7 @@ describe('createStore() function', () => { expect(received).toEqual(mockChangedState); expect(octokit).toBeDefined(); - expect(googleUser).toEqual("placeholder"); + expect(googleUser).toEqual('placeholder'); localStorage.removeItem('storedState'); }); }); diff --git a/src/pages/createStore.ts b/src/pages/createStore.ts index 6e0019691e..815d7937bf 100644 --- a/src/pages/createStore.ts +++ b/src/pages/createStore.ts @@ -54,7 +54,7 @@ function loadStore(loadedStore: SavedState | undefined) { ? generateOctokitInstance(loadedStore.session.githubAccessToken) : undefined }, - googleUser: loadedStore.session.googleAccessToken + googleUser: loadedStore.session.googleAccessToken ? 'placeholder' // updates in PersistenceSaga : undefined }, diff --git a/src/pages/playground/Playground.tsx b/src/pages/playground/Playground.tsx index 14a396a236..81fc856eb1 100644 --- a/src/pages/playground/Playground.tsx +++ b/src/pages/playground/Playground.tsx @@ -19,8 +19,8 @@ import { } from 'src/commons/application/actions/InterpreterActions'; import { loginGitHub, - logoutGitHub, loginGoogle, + logoutGitHub, logoutGoogle } from 'src/commons/application/actions/SessionActions'; import { @@ -272,7 +272,7 @@ const Playground: React.FC = props => { googleUser: persistenceUser, githubOctokitObject } = useTypedSelector(state => state.session); - + const dispatch = useDispatch(); const { handleChangeExecTime, From ecdf4b41e9899995645b2170ad83eecf85489104 Mon Sep 17 00:00:00 2001 From: sumomomomomo <102288745+sumomomomomo@users.noreply.github.com> Date: Wed, 14 Feb 2024 20:35:06 +0800 Subject: [PATCH 07/16] Fix packages + use gapi.client to fetch email --- package.json | 3 +-- src/commons/sagas/PersistenceSaga.tsx | 13 ++++++++++--- src/features/persistence/PersistenceUtils.tsx | 16 ---------------- yarn.lock | 9 +-------- 4 files changed, 12 insertions(+), 29 deletions(-) delete mode 100644 src/features/persistence/PersistenceUtils.tsx diff --git a/package.json b/package.json index 789327b29d..534952fae4 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,6 @@ "@szhsin/react-menu": "^4.0.0", "@tanstack/react-table": "^8.9.3", "@tremor/react": "^1.8.2", - "@types/google.accounts": "^0.0.14", "ace-builds": "^1.4.14", "acorn": "^8.9.0", "ag-grid-community": "^31.0.0", @@ -107,9 +106,9 @@ "@testing-library/user-event": "^14.4.3", "@types/acorn": "^6.0.0", "@types/gapi": "^0.0.44", - "@types/gapi.auth2": "^0.0.57", "@types/gapi.client": "^1.0.5", "@types/gapi.client.drive": "^3.0.14", + "@types/google.accounts": "^0.0.14", "@types/google.picker": "^0.0.39", "@types/jest": "^29.0.0", "@types/js-yaml": "^4.0.5", diff --git a/src/commons/sagas/PersistenceSaga.tsx b/src/commons/sagas/PersistenceSaga.tsx index be6023a139..aba90f9538 100644 --- a/src/commons/sagas/PersistenceSaga.tsx +++ b/src/commons/sagas/PersistenceSaga.tsx @@ -10,7 +10,6 @@ import { PERSISTENCE_SAVE_FILE_AS, PersistenceFile } from '../../features/persistence/PersistenceTypes'; -import { getUserProfileDataEmail } from '../../features/persistence/PersistenceUtils'; import { store } from '../../pages/createStore'; import { OverallState } from '../application/ApplicationTypes'; import { ExternalLibraryName } from '../application/types/ExternalTypes'; @@ -31,6 +30,7 @@ const DISCOVERY_DOCS = ['https://www.googleapis.com/discovery/v1/apis/drive/v3/r const SCOPES = 'profile https://www.googleapis.com/auth/drive.file https://www.googleapis.com/auth/userinfo.email'; const UPLOAD_PATH = 'https://www.googleapis.com/upload/drive/v3/files'; +const USER_INFO_PATH = 'https://www.googleapis.com/oauth2/v3/userinfo'; // Special ID value for the Google Drive API. const ROOT_ID = 'root'; @@ -370,7 +370,7 @@ function* handleUserChanged(accessToken: string | null) { if (accessToken === null) { yield put(actions.removeGoogleUserAndAccessToken()); } else { - const email: string | undefined = yield call(getUserProfileDataEmail, accessToken); + const email: string | undefined = yield call(getUserProfileDataEmail); if (!email) { yield put(actions.removeGoogleUserAndAccessToken()); } else { @@ -415,7 +415,7 @@ function* ensureInitialisedAndAuthorised() { yield call(getToken); } else { // check if loaded token is still valid - const email: string | undefined = yield call(getUserProfileDataEmail, currToken.access_token); + const email: string | undefined = yield call(getUserProfileDataEmail); const isValid = email ? true : false; if (!isValid) { yield call(getToken); @@ -423,6 +423,13 @@ function* ensureInitialisedAndAuthorised() { } } +function getUserProfileDataEmail(): Promise { + return gapi.client.request({ + path: USER_INFO_PATH + }).then(r => r.result.email) + .catch(() => undefined); +} + type PickFileResult = | { id: string; name: string; mimeType: string; parentId: string; picked: true } | { picked: false }; diff --git a/src/features/persistence/PersistenceUtils.tsx b/src/features/persistence/PersistenceUtils.tsx deleted file mode 100644 index 03c30dcaf4..0000000000 --- a/src/features/persistence/PersistenceUtils.tsx +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Calls Google useinfo API to get user's profile data, specifically email, using accessToken. - * If email field does not exist in the JSON response (invalid access token), will return undefined. - * Used with handleUserChanged to handle login. - * @param accessToken GIS access token - * @returns string if email field exists in JSON response, undefined if not - */ -export async function getUserProfileDataEmail(accessToken: string): Promise { - const headers = new Headers(); - headers.append('Authorization', `Bearer ${accessToken}`); - const response = await fetch('https://www.googleapis.com/oauth2/v3/userinfo', { - headers - }); - const data = await response.json(); - return data.email; -} diff --git a/yarn.lock b/yarn.lock index b19a7c7332..7b17d6f3da 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2879,13 +2879,6 @@ "@types/qs" "*" "@types/serve-static" "*" -"@types/gapi.auth2@^0.0.57": - version "0.0.57" - resolved "https://registry.yarnpkg.com/@types/gapi.auth2/-/gapi.auth2-0.0.57.tgz#5544a696d97fc979044d48a8bf7de5e2b35899b5" - integrity sha512-2nYF2OZlEqWvF5rLk0nrQlAkk51Abe9zLHvA85NZqpIauYT/TluDNsPWkncI969BR/Ts5mdKrXgiWE/f4N6mMQ== - dependencies: - "@types/gapi" "*" - "@types/gapi.client.discovery@*": version "1.0.9" resolved "https://registry.yarnpkg.com/@types/gapi.client.discovery/-/gapi.client.discovery-1.0.9.tgz#e2472989baa01f2e32a2d5a80981da8513f875ae" @@ -2905,7 +2898,7 @@ resolved "https://registry.yarnpkg.com/@types/gapi.client/-/gapi.client-1.0.8.tgz#8e02c57493b014521f2fa3359166c01dc2861cd7" integrity sha512-qJQUmmumbYym3Amax0S8CVzuSngcXsC1fJdwRS2zeW5lM63zXkw4wJFP+bG0jzgi0R6EsJKoHnGNVTDbOyG1ng== -"@types/gapi@*", "@types/gapi@^0.0.44": +"@types/gapi@^0.0.44": version "0.0.44" resolved "https://registry.yarnpkg.com/@types/gapi/-/gapi-0.0.44.tgz#f097f7a0f59d63a59098a08a62a560ca168426fb" integrity sha512-hsgJMfZ/pMwI15UlAYHMNwj8DRoigo1odhbPwEXdp19ZQwQAXbcRrpzaDsfc+9XM6RtGpvl4Ja7uW8A+KPCa7w== From 0fb1028bbd3a216de33e006e660e921137ead47b Mon Sep 17 00:00:00 2001 From: sumomomomomo <102288745+sumomomomomo@users.noreply.github.com> Date: Wed, 14 Feb 2024 21:24:57 +0800 Subject: [PATCH 08/16] Revert googleUser placeholder --- .../controlBar/ControlBarGoogleDriveButtons.tsx | 9 +++++---- src/commons/sagas/PersistenceSaga.tsx | 10 ++++++---- src/pages/__tests__/createStore.test.ts | 4 ---- src/pages/createStore.ts | 5 +---- src/pages/playground/Playground.tsx | 11 ++++++++++- 5 files changed, 22 insertions(+), 17 deletions(-) diff --git a/src/commons/controlBar/ControlBarGoogleDriveButtons.tsx b/src/commons/controlBar/ControlBarGoogleDriveButtons.tsx index e35bc4b22e..986c769c6a 100644 --- a/src/commons/controlBar/ControlBarGoogleDriveButtons.tsx +++ b/src/commons/controlBar/ControlBarGoogleDriveButtons.tsx @@ -16,6 +16,7 @@ const stateToIntent: { [state in PersistenceState]: Intent } = { type Props = { isFolderModeEnabled: boolean; loggedInAs?: string; + accessToken?: string; currentFile?: PersistenceFile; isDirty?: boolean; onClickOpen?: () => any; @@ -46,7 +47,7 @@ export const ControlBarGoogleDriveButtons: React.FC = props => { label="Open" icon={IconNames.DOCUMENT_OPEN} onClick={props.onClickOpen} - isDisabled={props.loggedInAs ? false : true} + isDisabled={props.accessToken ? false : true} /> ); const saveButton = ( @@ -63,12 +64,12 @@ export const ControlBarGoogleDriveButtons: React.FC = props => { label="Save as" icon={IconNames.SEND_TO} onClick={props.onClickSaveAs} - isDisabled={props.loggedInAs ? false : true} + isDisabled={props.accessToken ? false : true} /> ); - const loginButton = props.loggedInAs ? ( - + const loginButton = props.accessToken ? ( + ) : ( diff --git a/src/commons/sagas/PersistenceSaga.tsx b/src/commons/sagas/PersistenceSaga.tsx index aba90f9538..7498fbc3cc 100644 --- a/src/commons/sagas/PersistenceSaga.tsx +++ b/src/commons/sagas/PersistenceSaga.tsx @@ -424,10 +424,12 @@ function* ensureInitialisedAndAuthorised() { } function getUserProfileDataEmail(): Promise { - return gapi.client.request({ - path: USER_INFO_PATH - }).then(r => r.result.email) - .catch(() => undefined); + return gapi.client + .request({ + path: USER_INFO_PATH + }) + .then(r => r.result.email) + .catch(() => undefined); } type PickFileResult = diff --git a/src/pages/__tests__/createStore.test.ts b/src/pages/__tests__/createStore.test.ts index b377d14ea8..bc31df2e8e 100644 --- a/src/pages/__tests__/createStore.test.ts +++ b/src/pages/__tests__/createStore.test.ts @@ -104,12 +104,8 @@ describe('createStore() function', () => { const octokit = received.session.githubOctokitObject.octokit; delete received.session.githubOctokitObject.octokit; - const googleUser = received.session.googleUser; - delete received.session.googleUser; - expect(received).toEqual(mockChangedState); expect(octokit).toBeDefined(); - expect(googleUser).toEqual('placeholder'); localStorage.removeItem('storedState'); }); }); diff --git a/src/pages/createStore.ts b/src/pages/createStore.ts index 815d7937bf..48962a4a3e 100644 --- a/src/pages/createStore.ts +++ b/src/pages/createStore.ts @@ -53,10 +53,7 @@ function loadStore(loadedStore: SavedState | undefined) { octokit: loadedStore.session.githubAccessToken ? generateOctokitInstance(loadedStore.session.githubAccessToken) : undefined - }, - googleUser: loadedStore.session.googleAccessToken - ? 'placeholder' // updates in PersistenceSaga - : undefined + } }, workspaces: { ...defaultState.workspaces, diff --git a/src/pages/playground/Playground.tsx b/src/pages/playground/Playground.tsx index 81fc856eb1..4de305dce5 100644 --- a/src/pages/playground/Playground.tsx +++ b/src/pages/playground/Playground.tsx @@ -270,6 +270,7 @@ const Playground: React.FC = props => { sourceChapter: courseSourceChapter, sourceVariant: courseSourceVariant, googleUser: persistenceUser, + googleAccessToken, githubOctokitObject } = useTypedSelector(state => state.session); @@ -591,6 +592,7 @@ const Playground: React.FC = props => { currentFile={persistenceFile} loggedInAs={persistenceUser} isDirty={persistenceIsDirty} + accessToken={googleAccessToken} key="googledrive" onClickSaveAs={() => dispatch(persistenceSaveFileAs())} onClickOpen={() => dispatch(persistenceOpenPicker())} @@ -602,7 +604,14 @@ const Playground: React.FC = props => { onPopoverOpening={() => dispatch(persistenceInitialise())} /> ); - }, [isFolderModeEnabled, persistenceFile, persistenceUser, persistenceIsDirty, dispatch]); + }, [ + isFolderModeEnabled, + persistenceFile, + persistenceUser, + persistenceIsDirty, + dispatch, + googleAccessToken + ]); const githubPersistenceIsDirty = githubSaveInfo && (!githubSaveInfo.lastSaved || githubSaveInfo.lastSaved < lastEdit); From 47b89a6fed3230148c455a9c1914d49060ff6d8f Mon Sep 17 00:00:00 2001 From: sumomomomomo <102288745+sumomomomomo@users.noreply.github.com> Date: Wed, 14 Feb 2024 23:59:13 +0800 Subject: [PATCH 09/16] Migrate to google-oauth-gsi --- package.json | 1 + src/commons/sagas/PersistenceSaga.tsx | 73 +++++++++------------------ yarn.lock | 5 ++ 3 files changed, 30 insertions(+), 49 deletions(-) diff --git a/package.json b/package.json index 534952fae4..448b923afe 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "classnames": "^2.3.2", "flexboxgrid": "^6.3.1", "flexboxgrid-helpers": "^1.1.3", + "google-oauth-gsi": "^4.0.0", "hastscript": "^9.0.0", "js-slang": "^1.0.52", "js-yaml": "^4.1.0", diff --git a/src/commons/sagas/PersistenceSaga.tsx b/src/commons/sagas/PersistenceSaga.tsx index 7498fbc3cc..f7a3352624 100644 --- a/src/commons/sagas/PersistenceSaga.tsx +++ b/src/commons/sagas/PersistenceSaga.tsx @@ -1,4 +1,5 @@ import { Intent } from '@blueprintjs/core'; +import { GoogleOAuthProvider, SuccessTokenResponse } from 'google-oauth-gsi'; import { Chapter, Variant } from 'js-slang/dist/types'; import { SagaIterator } from 'redux-saga'; import { call, put, select } from 'redux-saga/effects'; @@ -39,7 +40,15 @@ const MIME_SOURCE = 'text/plain'; // const MIME_FOLDER = 'application/vnd.google-apps.folder'; // GIS Token Client -let tokenClient: google.accounts.oauth2.TokenClient; +let googleProvider: GoogleOAuthProvider; +// Login function +const googleLogin = () => new Promise ((resolve, reject) => { + googleProvider.useGoogleLogin({ + flow: 'implicit', + onSuccess: resolve, + scope: SCOPES, + })() +}); export function* persistenceSaga(): SagaIterator { yield takeLatest(LOGOUT_GOOGLE, function* (): any { @@ -51,7 +60,8 @@ export function* persistenceSaga(): SagaIterator { yield takeLatest(LOGIN_GOOGLE, function* (): any { yield call(ensureInitialised); - yield call(getToken); + yield call(googleLogin); + yield call(handleUserChanged, gapi.client.getToken().access_token); }); yield takeEvery(PERSISTENCE_INITIALISE, function* (): any { @@ -327,18 +337,13 @@ const initialisationPromise: Promise = new Promise(res => { // only called once async function initialise() { - // load GIS script - // adapted from https://github.com/MomenSherif/react-oauth - await new Promise((resolve, reject) => { - const scriptTag = document.createElement('script'); - scriptTag.src = 'https://accounts.google.com/gsi/client'; - scriptTag.async = true; - scriptTag.defer = true; - scriptTag.onload = () => resolve(); - scriptTag.onerror = ev => { - reject(ev); - }; - document.body.appendChild(scriptTag); + // initialize GIS client + googleProvider = new GoogleOAuthProvider({ + clientId: Constants.googleClientId!, + onScriptLoadError: () => console.log('onScriptLoadError'), + onScriptLoadSuccess: () => { + console.log('onScriptLoadSuccess'); + }, }); // load and initialize gapi.client @@ -352,18 +357,7 @@ async function initialise() { discoveryDocs: DISCOVERY_DOCS }); - // initialize GIS client - await new Promise((resolve, reject) => { - resolve( - window.google.accounts.oauth2.initTokenClient({ - client_id: Constants.googleClientId!, - scope: SCOPES, - callback: () => void 0 // will be updated in getToken() - }) - ); - }).then(c => { - tokenClient = c; - }); + } function* handleUserChanged(accessToken: string | null) { @@ -380,27 +374,6 @@ function* handleUserChanged(accessToken: string | null) { } } -// adapted from https://developers.google.com/identity/oauth2/web/guides/migration-to-gis -function* getToken() { - yield new Promise((resolve, reject) => { - try { - // Settle this promise in the response callback for requestAccessToken() - (tokenClient as any).callback = (resp: google.accounts.oauth2.TokenResponse) => { - if (resp.error !== undefined) { - reject(resp); - } - // GIS has already automatically updated gapi.client - // with the newly issued access token by this point - resolve(resp); - }; - tokenClient.requestAccessToken(); - } catch (err) { - reject(err); - } - }); - yield call(handleUserChanged, gapi.client.getToken().access_token); -} - function* ensureInitialised() { startInitialisation(); yield initialisationPromise; @@ -412,13 +385,15 @@ function* ensureInitialisedAndAuthorised() { const currToken: GoogleApiOAuth2TokenObject = yield call(gapi.client.getToken); if (currToken === null) { - yield call(getToken); + yield call(googleLogin); + yield call(handleUserChanged, gapi.client.getToken().access_token); } else { // check if loaded token is still valid const email: string | undefined = yield call(getUserProfileDataEmail); const isValid = email ? true : false; if (!isValid) { - yield call(getToken); + yield call(googleLogin); + yield call(handleUserChanged, gapi.client.getToken().access_token); } } } diff --git a/yarn.lock b/yarn.lock index 7b17d6f3da..b3fee937ed 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7084,6 +7084,11 @@ glsl-tokenizer@^2.1.5: dependencies: through2 "^0.6.3" +google-oauth-gsi@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/google-oauth-gsi/-/google-oauth-gsi-4.0.0.tgz#5564e97df5535af8c150909e0df9adcb32af2758" + integrity sha512-6A2QTSB4iPPfqd7spIOnHLhP4Iu8WeZ7REq+zM47nzIC805FwgOTFj5UsatKpMoNDsmb2xXG2GpKKVNBxbE9Pw== + gopd@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.0.1.tgz#29ff76de69dac7489b7c0918a5788e56477c332c" From ff559bc731b9d66305a7e5b8bdbf221badbd2160 Mon Sep 17 00:00:00 2001 From: sumomomomomo <102288745+sumomomomomo@users.noreply.github.com> Date: Thu, 15 Feb 2024 00:10:10 +0800 Subject: [PATCH 10/16] Remove console.log from PersistenceSaga.tsx --- src/commons/sagas/PersistenceSaga.tsx | 32 +++++++++++++-------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/src/commons/sagas/PersistenceSaga.tsx b/src/commons/sagas/PersistenceSaga.tsx index f7a3352624..d70457ef15 100644 --- a/src/commons/sagas/PersistenceSaga.tsx +++ b/src/commons/sagas/PersistenceSaga.tsx @@ -42,13 +42,14 @@ const MIME_SOURCE = 'text/plain'; // GIS Token Client let googleProvider: GoogleOAuthProvider; // Login function -const googleLogin = () => new Promise ((resolve, reject) => { - googleProvider.useGoogleLogin({ - flow: 'implicit', - onSuccess: resolve, - scope: SCOPES, - })() -}); +const googleLogin = () => + new Promise((resolve, reject) => { + googleProvider.useGoogleLogin({ + flow: 'implicit', + onSuccess: resolve, + scope: SCOPES + })(); + }); export function* persistenceSaga(): SagaIterator { yield takeLatest(LOGOUT_GOOGLE, function* (): any { @@ -338,13 +339,14 @@ const initialisationPromise: Promise = new Promise(res => { // only called once async function initialise() { // initialize GIS client - googleProvider = new GoogleOAuthProvider({ - clientId: Constants.googleClientId!, - onScriptLoadError: () => console.log('onScriptLoadError'), - onScriptLoadSuccess: () => { - console.log('onScriptLoadSuccess'); - }, - }); + await new Promise( + (resolve, reject) => + (googleProvider = new GoogleOAuthProvider({ + clientId: Constants.googleClientId!, + onScriptLoadSuccess: resolve, + onScriptLoadError: reject + })) + ); // load and initialize gapi.client await new Promise((resolve, reject) => @@ -356,8 +358,6 @@ async function initialise() { await gapi.client.init({ discoveryDocs: DISCOVERY_DOCS }); - - } function* handleUserChanged(accessToken: string | null) { From beee70a73ab698f4032f71fc1830cad43c144107 Mon Sep 17 00:00:00 2001 From: sumomomomomo <102288745+sumomomomomo@users.noreply.github.com> Date: Thu, 15 Feb 2024 00:23:02 +0800 Subject: [PATCH 11/16] Remove unused deps --- package.json | 1 - yarn.lock | 5 ----- 2 files changed, 6 deletions(-) diff --git a/package.json b/package.json index 448b923afe..f0013bcd8b 100644 --- a/package.json +++ b/package.json @@ -109,7 +109,6 @@ "@types/gapi": "^0.0.44", "@types/gapi.client": "^1.0.5", "@types/gapi.client.drive": "^3.0.14", - "@types/google.accounts": "^0.0.14", "@types/google.picker": "^0.0.39", "@types/jest": "^29.0.0", "@types/js-yaml": "^4.0.5", diff --git a/yarn.lock b/yarn.lock index b3fee937ed..a90ea8133d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2903,11 +2903,6 @@ resolved "https://registry.yarnpkg.com/@types/gapi/-/gapi-0.0.44.tgz#f097f7a0f59d63a59098a08a62a560ca168426fb" integrity sha512-hsgJMfZ/pMwI15UlAYHMNwj8DRoigo1odhbPwEXdp19ZQwQAXbcRrpzaDsfc+9XM6RtGpvl4Ja7uW8A+KPCa7w== -"@types/google.accounts@^0.0.14": - version "0.0.14" - resolved "https://registry.yarnpkg.com/@types/google.accounts/-/google.accounts-0.0.14.tgz#ffc36c30c5107b9bdab115830c85f7e377bc0dea" - integrity sha512-HqIVkVzpiLWhlajhQQd4rIV7czanFvXblJI2J1fSrL+VKQuQwwZ63m35D/mI0flsqKE6p/hNrAG0Yn4FD6JvNA== - "@types/google.picker@^0.0.39": version "0.0.39" resolved "https://registry.yarnpkg.com/@types/google.picker/-/google.picker-0.0.39.tgz#bb205ffb9736e8ec4a1af7cc87811d0fc5dc30fa" From 58b7981085a94db1c1cc1673915e5b67ca70e0c2 Mon Sep 17 00:00:00 2001 From: sumomomomomo <102288745+sumomomomomo@users.noreply.github.com> Date: Thu, 15 Feb 2024 02:11:00 +0800 Subject: [PATCH 12/16] Modify googleLogin --- src/commons/sagas/PersistenceSaga.tsx | 28 ++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/src/commons/sagas/PersistenceSaga.tsx b/src/commons/sagas/PersistenceSaga.tsx index d70457ef15..24ff7bb99e 100644 --- a/src/commons/sagas/PersistenceSaga.tsx +++ b/src/commons/sagas/PersistenceSaga.tsx @@ -42,14 +42,23 @@ const MIME_SOURCE = 'text/plain'; // GIS Token Client let googleProvider: GoogleOAuthProvider; // Login function -const googleLogin = () => - new Promise((resolve, reject) => { - googleProvider.useGoogleLogin({ - flow: 'implicit', - onSuccess: resolve, - scope: SCOPES - })(); - }); +function* googleLogin() { + try { + const tokenResp: SuccessTokenResponse = yield new Promise( + (resolve, reject) => { + googleProvider.useGoogleLogin({ + flow: 'implicit', + onSuccess: resolve, + onError: reject, + scope: SCOPES + })(); + } + ); + yield call(handleUserChanged, tokenResp.access_token); + } catch (ex) { + console.error(ex); + } +} export function* persistenceSaga(): SagaIterator { yield takeLatest(LOGOUT_GOOGLE, function* (): any { @@ -62,7 +71,6 @@ export function* persistenceSaga(): SagaIterator { yield takeLatest(LOGIN_GOOGLE, function* (): any { yield call(ensureInitialised); yield call(googleLogin); - yield call(handleUserChanged, gapi.client.getToken().access_token); }); yield takeEvery(PERSISTENCE_INITIALISE, function* (): any { @@ -386,14 +394,12 @@ function* ensureInitialisedAndAuthorised() { if (currToken === null) { yield call(googleLogin); - yield call(handleUserChanged, gapi.client.getToken().access_token); } else { // check if loaded token is still valid const email: string | undefined = yield call(getUserProfileDataEmail); const isValid = email ? true : false; if (!isValid) { yield call(googleLogin); - yield call(handleUserChanged, gapi.client.getToken().access_token); } } } From 892b4a2c88faace904d0ff05946027c656592d8f Mon Sep 17 00:00:00 2001 From: Richard Dominick <34370238+RichDom2185@users.noreply.github.com> Date: Sat, 24 Feb 2024 03:58:44 +0800 Subject: [PATCH 13/16] Fix failing tests --- src/commons/sagas/__tests__/PersistenceSaga.ts | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/src/commons/sagas/__tests__/PersistenceSaga.ts b/src/commons/sagas/__tests__/PersistenceSaga.ts index 6b980b1fb6..25a8ac078f 100644 --- a/src/commons/sagas/__tests__/PersistenceSaga.ts +++ b/src/commons/sagas/__tests__/PersistenceSaga.ts @@ -1,9 +1,10 @@ import { Chapter, Variant } from 'js-slang/dist/types'; import { expectSaga } from 'redux-saga-test-plan'; +import { removeGoogleUserAndAccessToken } from 'src/commons/application/actions/SessionActions'; +import { playgroundUpdatePersistenceFile } from 'src/features/playground/PlaygroundActions'; import { PLAYGROUND_UPDATE_PERSISTENCE_FILE } from '../../../features/playground/PlaygroundTypes'; import { ExternalLibraryName } from '../../application/types/ExternalTypes'; -import { REMOVE_GOOGLE_USER_AND_ACCESS_TOKEN } from '../../application/types/SessionTypes'; import { actions } from '../../utils/ActionsHelper'; import { CHANGE_EXTERNAL_LIBRARY, @@ -47,18 +48,8 @@ beforeAll(() => { test('LOGOUT_GOOGLE results in REMOVE_GOOGLE_USER_AND_ACCESS_TOKEN being dispatched', async () => { await expectSaga(PersistenceSaga) - .put({ - type: PLAYGROUND_UPDATE_PERSISTENCE_FILE, - payload: undefined, - meta: undefined, - error: undefined - }) - .put({ - type: REMOVE_GOOGLE_USER_AND_ACCESS_TOKEN, - payload: undefined, - meta: undefined, - error: undefined - }) + .put(playgroundUpdatePersistenceFile(undefined)) + .put(removeGoogleUserAndAccessToken()) .provide({ call(effect, next) { return; From 1ae632439f40dff7b29fb299f6eb48fb3a0c9bb0 Mon Sep 17 00:00:00 2001 From: Matthew Ho Date: Thu, 28 Mar 2024 23:45:51 +0800 Subject: [PATCH 14/16] Format SessionActions.ts --- src/commons/application/actions/SessionActions.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/commons/application/actions/SessionActions.ts b/src/commons/application/actions/SessionActions.ts index f12f42607e..4972cc715f 100644 --- a/src/commons/application/actions/SessionActions.ts +++ b/src/commons/application/actions/SessionActions.ts @@ -208,7 +208,10 @@ export const setAdminPanelCourseRegistrations = createAction( export const setGoogleUser = createAction(SET_GOOGLE_USER, (user?: string) => ({ payload: user })); -export const setGoogleAccessToken = createAction(SET_GOOGLE_ACCESS_TOKEN, (accessToken?: string) => ({ payload: accessToken })); +export const setGoogleAccessToken = createAction( + SET_GOOGLE_ACCESS_TOKEN, + (accessToken?: string) => ({ payload: accessToken }) +); export const setGitHubOctokitObject = createAction( SET_GITHUB_OCTOKIT_OBJECT, @@ -224,7 +227,10 @@ export const removeGitHubOctokitObjectAndAccessToken = createAction( () => ({ payload: {} }) ); -export const removeGoogleUserAndAccessToken = createAction(REMOVE_GOOGLE_USER_AND_ACCESS_TOKEN, () => ({ payload: {} })); +export const removeGoogleUserAndAccessToken = createAction( + REMOVE_GOOGLE_USER_AND_ACCESS_TOKEN, + () => ({ payload: {} }) +); export const submitAnswer = createAction( SUBMIT_ANSWER, From 936d586dc900174a90be1627a8d3b2c6b22895a2 Mon Sep 17 00:00:00 2001 From: Richard Dominick <34370238+RichDom2185@users.noreply.github.com> Date: Fri, 3 May 2024 16:44:59 +0800 Subject: [PATCH 15/16] Migrate new reducers to RTK --- .../application/reducers/SessionsReducer.ts | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/src/commons/application/reducers/SessionsReducer.ts b/src/commons/application/reducers/SessionsReducer.ts index ad20a3e823..ebfb085569 100644 --- a/src/commons/application/reducers/SessionsReducer.ts +++ b/src/commons/application/reducers/SessionsReducer.ts @@ -9,6 +9,7 @@ import { SourceActionType } from '../../utils/ActionsHelper'; import { logOut } from '../actions/CommonsActions'; import { removeGitHubOctokitObjectAndAccessToken, + removeGoogleUserAndAccessToken, setAdminPanelCourseRegistrations, setAssessmentConfigurations, setConfigurableNotificationConfigs, @@ -16,6 +17,7 @@ import { setCourseRegistration, setGitHubAccessToken, setGitHubOctokitObject, + setGoogleAccessToken, setGoogleUser, setNotificationConfigs, setTokens, @@ -40,17 +42,7 @@ export const SessionsReducer: Reducer = ( state = newSessionsReducer(state, action); return state; }; -// case SET_GOOGLE_ACCESS_TOKEN: -// return { -// ...state, -// googleAccessToken: action.payload -// }; -// case REMOVE_GOOGLE_USER_AND_ACCESS_TOKEN: -// return { -// ...state, -// googleUser: undefined, -// googleAccessToken: undefined -// }; + const newSessionsReducer = createReducer(defaultSession, builder => { builder .addCase(logOut, () => { @@ -122,6 +114,13 @@ const newSessionsReducer = createReducer(defaultSession, builder => { .addCase(remoteExecUpdateSession, (state, action) => { state.remoteExecutionSession = action.payload; }) + .addCase(setGoogleAccessToken, (state, action) => { + state.googleAccessToken = action.payload; + }) + .addCase(removeGoogleUserAndAccessToken, (state, action) => { + state.googleUser = undefined; + state.googleAccessToken = undefined; + }) .addCase(removeGitHubOctokitObjectAndAccessToken, (state, action) => { state.githubOctokitObject = { octokit: undefined }; state.githubAccessToken = undefined; From 504531df9696d0081b35a4ec778425d29fa3ecc9 Mon Sep 17 00:00:00 2001 From: Matthew Ho Date: Thu, 9 May 2024 15:42:58 +0800 Subject: [PATCH 16/16] Remove google-oauth-gsi, put script in index.html instead --- package.json | 2 +- public/index.html | 1 + src/commons/sagas/PersistenceSaga.tsx | 70 ++++++++++++++------------- yarn.lock | 10 ++-- 4 files changed, 44 insertions(+), 39 deletions(-) diff --git a/package.json b/package.json index a803de5615..9fa7d9ede8 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,6 @@ "classnames": "^2.3.2", "flexboxgrid": "^6.3.1", "flexboxgrid-helpers": "^1.1.3", - "google-oauth-gsi": "^4.0.0", "hastscript": "^9.0.0", "i18next": "^23.11.2", "i18next-browser-languagedetector": "^7.2.1", @@ -114,6 +113,7 @@ "@types/gapi": "^0.0.44", "@types/gapi.client": "^1.0.5", "@types/gapi.client.drive": "^3.0.14", + "@types/google.accounts": "^0.0.14", "@types/google.picker": "^0.0.39", "@types/jest": "^29.0.0", "@types/js-yaml": "^4.0.5", diff --git a/public/index.html b/public/index.html index bf37250c90..7d5db11c1c 100644 --- a/public/index.html +++ b/public/index.html @@ -9,6 +9,7 @@ +