You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@texera.apache.org by "Sentiaus (via GitHub)" <gi...@apache.org> on 2026/05/27 21:04:43 UTC

[PR] Gdrive/UI [texera]

Sentiaus opened a new pull request, #5252:
URL: https://github.com/apache/texera/pull/5252

   <!--
   Thanks for sending a pull request (PR)! Here are some tips for you:
     1. If this is your first time, please read our contributor guidelines: 
        [Contributing to Texera](https://github.com/apache/texera/blob/main/CONTRIBUTING.md)
     2. Ensure you have added or run the appropriate tests for your PR
     3. If the PR is work in progress, mark it a draft on GitHub.
     4. Please write your PR title to summarize what this PR proposes, we 
       are following Conventional Commits style for PR titles as well.
     5. Be sure to keep the PR description updated to reflect all changes.
   -->
   
   ### What changes were proposed in this PR?
   
   Wires the Drive export action into the three UI surfaces where download is currently available. The download button in each location has been converted to a dropdown with "Download" and "Export to Drive" (or "Connect to Drive" when not connected) options:
   
   - **Dashboard list items** (`list-item.component`) — for workflows and datasets
   - **Dataset detail page** (`dataset-detail.component`) — separate dropdowns for per-version ZIP download and per-file download
   - **Workflow editor menu** (`menu.component`) — for the workflow JSON export button
   
   All three surfaces:
   - Call `DriveService.getToken()` on init to set initial connection state
   - Subscribe to `DriveService.onConnected()` to update state live and show a "Google Drive connected" toast when the OAuth flow completes in the popup
   - Show a "Exported to Google Drive" success toast on successful export
   - Keep the dropdown button highlighted while the menu is open
   - Fall back to `connect()` (or `connect(true)` on `invalid_grant`) when the user clicks "Connect to Drive" while not connected
   
   <img width="400" height="179" alt="Screenshot 2026-05-21 at 4 12 15 PM" src="https://github.com/user-attachments/assets/a505e328-9648-49b8-9161-5f77cb003a54" />
   <img width="1496" height="763" alt="Screenshot 2026-05-21 at 4 12 44 PM" src="https://github.com/user-attachments/assets/c0f410cf-1503-4c0a-b202-64efd25058c2" />
   <img width="1268" height="538" alt="Screenshot 2026-05-21 at 4 10 32 PM" src="https://github.com/user-attachments/assets/830dcd2c-8205-4746-9dfc-52bc15f99f42" />
   <img width="1054" height="659" alt="Screenshot 2026-05-21 at 4 10 47 PM" src="https://github.com/user-attachments/assets/08950920-9988-42ce-9348-4f6266ad6413" />
   <img width="252" height="67" alt="Screenshot 2026-05-21 at 4 11 02 PM" src="https://github.com/user-attachments/assets/b7b43ed2-6663-4246-a84b-23f3ff75a1ef" />
   
   ### Any related issues, documentation, discussions?
   
   Closes #4240
   
   ### How was this PR tested?
   
   Unit tests added for all three components:
   
   - **`list-item.component.spec.ts`** — `Drive integration` describe block: token status sets `isDriveConnected`, `onConnected` emission updates state and shows toast, `connect()` called when not connected, workflow and dataset export paths call `exportToDrive` and show success toast
   - **`menu.component.spec.ts`** — `Drive integration` describe block: same connection state coverage, plus `onClickDriveExportWorkflow` serializes workflow content and shows success toast
   - **`dataset-detail.component.spec.ts`** — new spec file: token status, `onConnected` toast, `onClickDriveExportVersion` and `onClickDriveExportFile` across connected/disconnected/missing-selection/success paths
   
   ### Was this PR authored or co-authored using generative AI tooling?
   
   Generated-by: Claude Sonnet 4.6
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@texera.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] feat: gdrive export ui integration [texera]

Posted by "Copilot (via GitHub)" <gi...@apache.org>.
Copilot commented on code in PR #5252:
URL: https://github.com/apache/texera/pull/5252#discussion_r3448059837


##########
frontend/src/app/dashboard/service/user/google-drive/drive.service.ts:
##########
@@ -0,0 +1,202 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { Injectable, NgZone } from "@angular/core";
+import { HttpClient } from "@angular/common/http";
+import { Observable, Subject, from, firstValueFrom } from "rxjs";
+import { AppSettings } from "../../../../common/app-setting";
+
+export interface DriveTokenResponse {
+  status: string;
+  accessToken?: string;
+}
+
+export interface DriveFolder {
+  id: string;
+  name: string;
+}
+
+// gapi is loaded via the script tag in index.html
+declare var gapi: any;
+declare var google: any;
+
+@Injectable({
+  providedIn: "root",
+})
+export class DriveService {
+  private readonly BASE = `${AppSettings.getApiEndpoint()}/auth/google/drive`;
+  private readonly CONFIG_URL = `${AppSettings.getApiEndpoint()}/auth/google/config`;
+
+  private connected$ = new Subject<void>();
+  private pickerLoaded = false;
+
+  constructor(
+    private http: HttpClient,
+    private ngZone: NgZone
+  ) {}
+
+  connect(reauth = false): void {
+    this.http.get(`${this.BASE}/connect?reauth=${reauth}`, { responseType: "text" }).subscribe(url => {
+      const popup = window.open(url, "gdrive-connect", "width=500,height=600");
+
+      const onMessage = (event: MessageEvent) => {
+        if (event.data === "gdrive-connected") {
+          window.removeEventListener("message", onMessage);
+          popup?.close();
+          this.ngZone.run(() => this.connected$.next());
+        }
+      };
+
+      window.addEventListener("message", onMessage);
+    });
+  }

Review Comment:
   `DriveService.connect()` accepts any `postMessage` with data "gdrive-connected". Without validating `event.origin` (and ideally `event.source`), a malicious page can spoof the connection event and force UI state changes / close the popup. Also, if the popup is blocked or never posts back, the message listener is never removed.



##########
frontend/src/app/dashboard/service/user/google-drive/drive.service.ts:
##########
@@ -0,0 +1,202 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { Injectable, NgZone } from "@angular/core";
+import { HttpClient } from "@angular/common/http";
+import { Observable, Subject, from, firstValueFrom } from "rxjs";
+import { AppSettings } from "../../../../common/app-setting";
+
+export interface DriveTokenResponse {
+  status: string;
+  accessToken?: string;
+}
+
+export interface DriveFolder {
+  id: string;
+  name: string;
+}
+
+// gapi is loaded via the script tag in index.html
+declare var gapi: any;
+declare var google: any;
+
+@Injectable({
+  providedIn: "root",
+})
+export class DriveService {
+  private readonly BASE = `${AppSettings.getApiEndpoint()}/auth/google/drive`;
+  private readonly CONFIG_URL = `${AppSettings.getApiEndpoint()}/auth/google/config`;
+
+  private connected$ = new Subject<void>();
+  private pickerLoaded = false;
+
+  constructor(
+    private http: HttpClient,
+    private ngZone: NgZone
+  ) {}
+
+  connect(reauth = false): void {
+    this.http.get(`${this.BASE}/connect?reauth=${reauth}`, { responseType: "text" }).subscribe(url => {
+      const popup = window.open(url, "gdrive-connect", "width=500,height=600");
+
+      const onMessage = (event: MessageEvent) => {
+        if (event.data === "gdrive-connected") {
+          window.removeEventListener("message", onMessage);
+          popup?.close();
+          this.ngZone.run(() => this.connected$.next());
+        }
+      };
+
+      window.addEventListener("message", onMessage);
+    });
+  }
+
+  onConnected(): Observable<void> {
+    return this.connected$.asObservable();
+  }
+
+  getToken(): Observable<DriveTokenResponse> {
+    return this.http.get<DriveTokenResponse>(`${this.BASE}/token`);
+  }
+
+  exportToDrive(blob: Blob, fileName: string): Observable<void> {
+    const result$ = new Subject<void>();
+
+    Promise.all([this.loadPicker(), this.getAccessToken()]).then(([, accessToken]) => {
+      if (!accessToken) {
+        result$.error(new Error("Not connected to Google Drive"));
+        return;
+      }
+
+      this.http.get<{ clientId: string; apiKey: string }>(this.CONFIG_URL).subscribe(config => {
+        const folderView = new google.picker.DocsView(google.picker.ViewId.FOLDERS)
+          .setIncludeFolders(true)
+          .setSelectFolderEnabled(true)
+          .setMimeTypes("application/vnd.google-apps.folder");
+
+        const picker = new google.picker.PickerBuilder()
+          .addView(folderView)
+          .setOAuthToken(accessToken)
+          .setDeveloperKey(config.apiKey)
+          .setTitle("Choose a folder to export to")
+          .setCallback((data: any) => {
+            if (data.action === google.picker.Action.PICKED) {
+              const folderId = data.docs[0].id;
+              this.ngZone.run(() => {
+                this.uploadToDrive(blob, fileName, folderId, accessToken).subscribe({
+                  next: () => {
+                    result$.next();
+                    result$.complete();
+                  },
+                  error: (err: unknown) => result$.error(err),
+                });
+              });
+            } else if (data.action === google.picker.Action.CANCEL) {
+              this.ngZone.run(() => result$.complete());
+            }
+          })
+          .build();
+
+        picker.setVisible(true);
+      });
+    });
+
+    return result$.asObservable();
+  }
+
+  openFolderPicker(): Observable<DriveFolder> {
+    const result$ = new Subject<DriveFolder>();
+
+    Promise.all([this.loadPicker(), this.getAccessToken()]).then(([, accessToken]) => {
+      if (!accessToken) {
+        result$.error(new Error("Not connected to Google Drive"));
+        return;
+      }
+
+      this.http.get<{ clientId: string; apiKey: string }>(this.CONFIG_URL).subscribe(config => {
+        const folderView = new google.picker.DocsView(google.picker.ViewId.FOLDERS)
+          .setIncludeFolders(true)
+          .setSelectFolderEnabled(true)
+          .setMimeTypes("application/vnd.google-apps.folder");
+
+        const picker = new google.picker.PickerBuilder()
+          .addView(folderView)
+          .setOAuthToken(accessToken)
+          .setDeveloperKey(config.apiKey)
+          .setTitle("Choose a folder to export to")
+          .setCallback((data: any) => {
+            if (data.action === google.picker.Action.PICKED) {
+              const doc = data.docs[0];
+              this.ngZone.run(() => {
+                result$.next({ id: doc.id, name: doc.name });
+                result$.complete();
+              });
+            } else if (data.action === google.picker.Action.CANCEL) {
+              this.ngZone.run(() => result$.complete());
+            }
+          })
+          .build();
+
+        picker.setVisible(true);
+      });
+    });

Review Comment:
   Same as `exportToDrive()`: if the config request fails or the initial Promise rejects, `openFolderPicker()` never completes/errors its observable, leaving subscribers hanging.



##########
amber/src/main/scala/org/apache/texera/web/resource/auth/GoogleDriveAuthResource.scala:
##########
@@ -0,0 +1,209 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.texera.web.resource.auth
+
+import io.dropwizard.auth.Auth
+import com.fasterxml.jackson.databind.ObjectMapper
+import com.typesafe.scalalogging.LazyLogging
+import org.apache.texera.auth.{SessionUser, TokenEncryptionService}
+import org.apache.texera.web.model.http.response.DriveTokenIssueResponse
+import org.apache.texera.web.resource.auth.GoogleDriveAuthResource._
+import org.apache.texera.dao.jooq.generated.tables.daos.UserOauthTokenDao
+import org.apache.texera.dao.jooq.generated.tables.pojos.UserOauthToken
+import org.apache.texera.dao.SqlServer
+import org.apache.texera.config.UserSystemConfig
+import com.google.api.client.googleapis.auth.oauth2.{
+  GoogleAuthorizationCodeRequestUrl,
+  GoogleAuthorizationCodeTokenRequest,
+  GoogleRefreshTokenRequest,
+  GoogleTokenResponse
+}
+import com.google.api.client.auth.oauth2.TokenResponseException
+import com.google.api.client.http.javanet.NetHttpTransport
+import com.google.api.client.json.gson.GsonFactory
+
+import java.util.concurrent.ConcurrentHashMap
+import javax.annotation.security.RolesAllowed
+import javax.ws.rs._
+import javax.ws.rs.core.MediaType
+import javax.ws.rs.core.Response
+
+object GoogleDriveAuthResource {
+  private val STATUS_OK = "ok"
+  private val STATUS_NO_REFRESH_TOKEN = "no_refresh_token"
+  private val STATUS_INVALID_GRANT = "invalid_grant"
+  private val PROVIDER_GOOGLE_DRIVE = "google_drive"
+
+  private val STATE_TTL_MS = 10 * 60 * 1000L
+
+  private val mapper = new ObjectMapper()
+
+  // state token → (uid, expiresAtMs)
+  private val pendingStates = new ConcurrentHashMap[String, (Int, Long)]()
+
+  private def oauthTokenDao =
+    new UserOauthTokenDao(
+      SqlServer
+        .getInstance()
+        .createDSLContext()
+        .configuration
+    )
+}
+
+@Consumes(Array(MediaType.APPLICATION_JSON))
+@Produces(Array(MediaType.APPLICATION_JSON))
+class GoogleDriveAuthResource extends LazyLogging {
+  final private lazy val clientId = UserSystemConfig.googleClientId
+  final private lazy val clientSecret = UserSystemConfig.googleClientSecret
+  final private lazy val redirectUri = UserSystemConfig.appDomain
+    .map(domain => s"https://$domain/api/auth/google/drive/callback")
+    .getOrElse("http://localhost:4200/api/auth/google/drive/callback")
+
+  @GET
+  @Path("/token")
+  @RolesAllowed(Array("REGULAR", "ADMIN"))
+  def getDriveAccessToken(@Auth sessionUser: SessionUser): Response = {
+    val uid = sessionUser.getUid
+    val record = oauthTokenDao.fetchByUid(uid).stream()
+      .filter(r => r.getProvider == PROVIDER_GOOGLE_DRIVE)
+      .findFirst()
+      .orElse(null)
+
+    if (record == null) {
+      return Response.ok(DriveTokenIssueResponse(STATUS_NO_REFRESH_TOKEN, None)).build()
+    }
+
+    try {
+      val blob = mapper.readTree(TokenEncryptionService.decrypt(record.getAuthBlob))
+      val refreshToken = blob.get("refreshToken").asText()
+
+      val tokenResponse = new GoogleRefreshTokenRequest(
+        new NetHttpTransport(),
+        GsonFactory.getDefaultInstance,
+        refreshToken,
+        clientId,
+        clientSecret
+      ).execute()
+
+      Response.ok(DriveTokenIssueResponse(STATUS_OK, Some(tokenResponse.getAccessToken))).build()
+    } catch {
+      case e: TokenResponseException =>
+        if (e.getDetails != null && e.getDetails.getError == STATUS_INVALID_GRANT) {
+          Response.ok(DriveTokenIssueResponse(STATUS_INVALID_GRANT, None)).build()
+        } else {
+          logger.error("Failed to refresh access token", e)
+          Response.status(Response.Status.INTERNAL_SERVER_ERROR).build()
+        }
+      case e: Exception =>
+        logger.error("Unexpected error refreshing access token", e)
+        Response.status(Response.Status.INTERNAL_SERVER_ERROR).build()
+    }
+  }
+
+  @GET
+  @Path("/callback")
+  @Produces(Array(MediaType.TEXT_HTML, MediaType.APPLICATION_JSON))
+  def getCallback(
+      @QueryParam("code") @DefaultValue("") code: String,
+      @QueryParam("state") @DefaultValue("") state: String
+  ): Response = {
+    if (code.isEmpty || state.isEmpty) {
+      return Response.status(Response.Status.BAD_REQUEST).build()
+    }
+    try {
+      val entry = pendingStates.remove(state)
+      if (entry == null || System.currentTimeMillis() > entry._2) {
+        return Response
+          .status(Response.Status.UNAUTHORIZED)
+          .entity("OAuth state token is invalid or expired")
+          .build()
+      }
+
+      val uid = entry._1
+
+      val tokenResponse: GoogleTokenResponse = new GoogleAuthorizationCodeTokenRequest(
+        new NetHttpTransport(),
+        GsonFactory.getDefaultInstance,
+        clientId,
+        clientSecret,
+        code,
+        redirectUri
+      ).execute()
+
+      val blobMap = new java.util.HashMap[String, String]()
+      blobMap.put("refreshToken", tokenResponse.getRefreshToken)
+      blobMap.put("scopes", tokenResponse.getScope)
+      val blobJson = mapper.writeValueAsString(blobMap)
+      val encryptedBlob = TokenEncryptionService.encrypt(blobJson)

Review Comment:
   `tokenResponse.getRefreshToken` can be null (Google only returns a refresh token on first consent or when `prompt=consent`). Storing a null refresh token will overwrite any existing valid token and break future refreshes. Guard against a missing refresh token and return a clear error instructing the UI to retry with `reauth=true` (or preserve the existing token).



##########
frontend/src/app/dashboard/service/user/google-drive/drive.service.ts:
##########
@@ -0,0 +1,202 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { Injectable, NgZone } from "@angular/core";
+import { HttpClient } from "@angular/common/http";
+import { Observable, Subject, from, firstValueFrom } from "rxjs";
+import { AppSettings } from "../../../../common/app-setting";
+
+export interface DriveTokenResponse {
+  status: string;
+  accessToken?: string;
+}
+
+export interface DriveFolder {
+  id: string;
+  name: string;
+}
+
+// gapi is loaded via the script tag in index.html
+declare var gapi: any;
+declare var google: any;
+
+@Injectable({
+  providedIn: "root",
+})
+export class DriveService {
+  private readonly BASE = `${AppSettings.getApiEndpoint()}/auth/google/drive`;
+  private readonly CONFIG_URL = `${AppSettings.getApiEndpoint()}/auth/google/config`;
+
+  private connected$ = new Subject<void>();
+  private pickerLoaded = false;
+
+  constructor(
+    private http: HttpClient,
+    private ngZone: NgZone
+  ) {}
+
+  connect(reauth = false): void {
+    this.http.get(`${this.BASE}/connect?reauth=${reauth}`, { responseType: "text" }).subscribe(url => {
+      const popup = window.open(url, "gdrive-connect", "width=500,height=600");
+
+      const onMessage = (event: MessageEvent) => {
+        if (event.data === "gdrive-connected") {
+          window.removeEventListener("message", onMessage);
+          popup?.close();
+          this.ngZone.run(() => this.connected$.next());
+        }
+      };
+
+      window.addEventListener("message", onMessage);
+    });
+  }
+
+  onConnected(): Observable<void> {
+    return this.connected$.asObservable();
+  }
+
+  getToken(): Observable<DriveTokenResponse> {
+    return this.http.get<DriveTokenResponse>(`${this.BASE}/token`);
+  }
+
+  exportToDrive(blob: Blob, fileName: string): Observable<void> {
+    const result$ = new Subject<void>();
+
+    Promise.all([this.loadPicker(), this.getAccessToken()]).then(([, accessToken]) => {
+      if (!accessToken) {
+        result$.error(new Error("Not connected to Google Drive"));
+        return;
+      }
+
+      this.http.get<{ clientId: string; apiKey: string }>(this.CONFIG_URL).subscribe(config => {
+        const folderView = new google.picker.DocsView(google.picker.ViewId.FOLDERS)
+          .setIncludeFolders(true)
+          .setSelectFolderEnabled(true)
+          .setMimeTypes("application/vnd.google-apps.folder");
+
+        const picker = new google.picker.PickerBuilder()
+          .addView(folderView)
+          .setOAuthToken(accessToken)
+          .setDeveloperKey(config.apiKey)
+          .setTitle("Choose a folder to export to")
+          .setCallback((data: any) => {
+            if (data.action === google.picker.Action.PICKED) {
+              const folderId = data.docs[0].id;
+              this.ngZone.run(() => {
+                this.uploadToDrive(blob, fileName, folderId, accessToken).subscribe({
+                  next: () => {
+                    result$.next();
+                    result$.complete();
+                  },
+                  error: (err: unknown) => result$.error(err),
+                });
+              });
+            } else if (data.action === google.picker.Action.CANCEL) {
+              this.ngZone.run(() => result$.complete());
+            }
+          })
+          .build();
+
+        picker.setVisible(true);
+      });
+    });

Review Comment:
   If the config request fails or `loadPicker()` / `getAccessToken()` rejects, `exportToDrive()` leaves `result$` neither completed nor errored, which can hang callers waiting for completion. Add an HTTP error callback and a Promise `.catch(...)` to propagate errors into the observable.



##########
amber/src/main/scala/org/apache/texera/web/TexeraWebApplication.scala:
##########
@@ -160,6 +164,7 @@ class TexeraWebApplication
     environment.jersey.register(classOf[UserQuotaResource])
     environment.jersey.register(classOf[AdminSettingsResource])
     environment.jersey.register(classOf[AIAssistantResource])
+    environment.jersey.register(classOf[GoogleDriveAuthResource])

Review Comment:
   `GoogleDriveAuthResource` is defined as a sub-resource (it has no class-level `@Path`). Registering it as a root Jersey resource is at best a no-op and at worst can cause Jersey resource validation issues. Since it’s already exposed via `GoogleAuthResource` at `/auth/google/drive`, this extra registration should be removed.



##########
frontend/src/app/dashboard/service/user/google-drive/drive.service.spec.ts:
##########
@@ -0,0 +1,161 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { TestBed, fakeAsync, tick } from "@angular/core/testing";
+import { HttpClientTestingModule, HttpTestingController } from "@angular/common/http/testing";
+import { NgZone } from "@angular/core";
+import { DriveService } from "./drive.service";
+import { AppSettings } from "../../../../common/app-setting";
+import { firstValueFrom } from "rxjs";
+import { commonTestProviders } from "../../../../common/testing/test-utils";
+
+describe("DriveService", () => {
+  let service: DriveService;
+  let httpMock: HttpTestingController;
+  let ngZone: NgZone;
+
+  const BASE = `${AppSettings.getApiEndpoint()}/auth/google/drive`;
+
+  beforeEach(() => {
+    // Stub gapi so loadPicker() resolves without the real script
+    vi.stubGlobal("gapi", {
+      load: (_feature: string, cb: () => void) => cb(),
+    });
+
+    TestBed.configureTestingModule({
+      imports: [HttpClientTestingModule],
+      providers: [DriveService, ...commonTestProviders],
+    });
+
+    service = TestBed.inject(DriveService);
+    httpMock = TestBed.inject(HttpTestingController);
+    ngZone = TestBed.inject(NgZone);
+  });
+
+  afterEach(() => {
+    httpMock.verify();
+    vi.unstubAllGlobals();
+  });
+
+  describe("getToken", () => {
+    it("calls the token endpoint and returns the response", async () => {
+      const promise = firstValueFrom(service.getToken());
+
+      const req = httpMock.expectOne(`${BASE}/token`);
+      expect(req.request.method).toBe("GET");
+      req.flush({ status: "ok", accessToken: "abc123" });
+
+      const result = await promise;
+      expect(result.status).toBe("ok");
+      expect(result.accessToken).toBe("abc123");
+    });
+
+    it("returns no_refresh_token status when user has not connected Drive", async () => {
+      const promise = firstValueFrom(service.getToken());
+
+      httpMock.expectOne(`${BASE}/token`).flush({ status: "no_refresh_token" });
+
+      const result = await promise;
+      expect(result.status).toBe("no_refresh_token");
+      expect(result.accessToken).toBeUndefined();
+    });
+  });
+
+  describe("connect", () => {
+    it("fetches the connect URL and opens a popup", () => {
+      const openSpy = vi.spyOn(window, "open").mockReturnValue(null);
+
+      service.connect();
+
+      const req = httpMock.expectOne(`${BASE}/connect?reauth=false`);
+      expect(req.request.method).toBe("GET");
+      req.flush("https://accounts.google.com/o/oauth2/auth?...");
+
+      expect(openSpy).toHaveBeenCalledWith(
+        "https://accounts.google.com/o/oauth2/auth?...",
+        "gdrive-connect",
+        "width=500,height=600"
+      );
+    });
+
+    it("passes reauth=true when reauth flag is set", () => {
+      vi.spyOn(window, "open").mockReturnValue(null);
+
+      service.connect(true);
+
+      httpMock.expectOne(`${BASE}/connect?reauth=true`).flush("https://accounts.google.com/...");
+    });
+
+    it("emits on onConnected() when the popup posts gdrive-connected", fakeAsync(() => {
+      const mockPopup = { close: vi.fn() } as unknown as Window;
+      vi.spyOn(window, "open").mockReturnValue(mockPopup);
+
+      let connected = false;
+      service.onConnected().subscribe(() => {
+        connected = true;
+      });
+
+      service.connect();
+      httpMock.expectOne(`${BASE}/connect?reauth=false`).flush("https://accounts.google.com/...");
+
+      ngZone.run(() => {
+        window.dispatchEvent(new MessageEvent("message", { data: "gdrive-connected" }));
+      });
+      tick();
+
+      expect(connected).toBe(true);
+      expect(mockPopup.close).toHaveBeenCalled();
+    }));
+
+    it("does not emit on onConnected() for unrelated messages", fakeAsync(() => {
+      vi.spyOn(window, "open").mockReturnValue(null);
+
+      let connected = false;
+      service.onConnected().subscribe(() => {
+        connected = true;
+      });
+
+      service.connect();
+      httpMock.expectOne(`${BASE}/connect?reauth=false`).flush("https://accounts.google.com/...");
+
+      window.dispatchEvent(new MessageEvent("message", { data: "some-other-message" }));
+      tick();
+
+      expect(connected).toBe(false);
+    }));
+  });
+
+  describe("exportToDrive", () => {
+    it("errors the observable when no access token is available", fakeAsync(async () => {
+      // Token endpoint returns no_refresh_token so getAccessToken returns null
+      const result$ = service.exportToDrive(new Blob(["test"]), "test.json");
+
+      // getToken is called inside getAccessToken

Review Comment:
   This test subscribes to `result$` only after calling `tick()`. Because `exportToDrive()` uses a `Subject`, the error can be emitted before the subscription is attached (and then be lost), making the test flaky/incorrect. Subscribe before flushing microtasks/timers.



##########
frontend/src/app/app-routing.module.ts:
##########
@@ -143,6 +144,10 @@ routes.push({
           path: "discussion",
           component: FlarumComponent,
         },
+        {
+          path: "google-drive",
+          component: GoogleDriveConnectComponent,
+        },

Review Comment:
   This PR adds a new authenticated route `/dashboard/user/google-drive` that renders `GoogleDriveConnectComponent`, but the PR description focuses on integrating Drive export into existing download surfaces. If this page is only for development/testing, consider removing it (and its import) to avoid shipping an unlinked debug UI; if it’s intended as a user-facing settings page, it likely needs UX integration (navigation entry, styling, copy) and should be mentioned in the PR description.



##########
frontend/src/app/dashboard/service/user/google-drive/drive.service.spec.ts:
##########
@@ -0,0 +1,161 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { TestBed, fakeAsync, tick } from "@angular/core/testing";
+import { HttpClientTestingModule, HttpTestingController } from "@angular/common/http/testing";
+import { NgZone } from "@angular/core";
+import { DriveService } from "./drive.service";
+import { AppSettings } from "../../../../common/app-setting";
+import { firstValueFrom } from "rxjs";
+import { commonTestProviders } from "../../../../common/testing/test-utils";
+
+describe("DriveService", () => {
+  let service: DriveService;
+  let httpMock: HttpTestingController;
+  let ngZone: NgZone;
+
+  const BASE = `${AppSettings.getApiEndpoint()}/auth/google/drive`;
+
+  beforeEach(() => {
+    // Stub gapi so loadPicker() resolves without the real script
+    vi.stubGlobal("gapi", {
+      load: (_feature: string, cb: () => void) => cb(),
+    });
+
+    TestBed.configureTestingModule({
+      imports: [HttpClientTestingModule],
+      providers: [DriveService, ...commonTestProviders],
+    });
+
+    service = TestBed.inject(DriveService);
+    httpMock = TestBed.inject(HttpTestingController);
+    ngZone = TestBed.inject(NgZone);
+  });
+
+  afterEach(() => {
+    httpMock.verify();
+    vi.unstubAllGlobals();
+  });
+
+  describe("getToken", () => {
+    it("calls the token endpoint and returns the response", async () => {
+      const promise = firstValueFrom(service.getToken());
+
+      const req = httpMock.expectOne(`${BASE}/token`);
+      expect(req.request.method).toBe("GET");
+      req.flush({ status: "ok", accessToken: "abc123" });
+
+      const result = await promise;
+      expect(result.status).toBe("ok");
+      expect(result.accessToken).toBe("abc123");
+    });
+
+    it("returns no_refresh_token status when user has not connected Drive", async () => {
+      const promise = firstValueFrom(service.getToken());
+
+      httpMock.expectOne(`${BASE}/token`).flush({ status: "no_refresh_token" });
+
+      const result = await promise;
+      expect(result.status).toBe("no_refresh_token");
+      expect(result.accessToken).toBeUndefined();
+    });
+  });
+
+  describe("connect", () => {
+    it("fetches the connect URL and opens a popup", () => {
+      const openSpy = vi.spyOn(window, "open").mockReturnValue(null);
+
+      service.connect();
+
+      const req = httpMock.expectOne(`${BASE}/connect?reauth=false`);
+      expect(req.request.method).toBe("GET");
+      req.flush("https://accounts.google.com/o/oauth2/auth?...");
+
+      expect(openSpy).toHaveBeenCalledWith(
+        "https://accounts.google.com/o/oauth2/auth?...",
+        "gdrive-connect",
+        "width=500,height=600"
+      );
+    });
+
+    it("passes reauth=true when reauth flag is set", () => {
+      vi.spyOn(window, "open").mockReturnValue(null);
+
+      service.connect(true);
+
+      httpMock.expectOne(`${BASE}/connect?reauth=true`).flush("https://accounts.google.com/...");
+    });
+
+    it("emits on onConnected() when the popup posts gdrive-connected", fakeAsync(() => {
+      const mockPopup = { close: vi.fn() } as unknown as Window;
+      vi.spyOn(window, "open").mockReturnValue(mockPopup);
+
+      let connected = false;
+      service.onConnected().subscribe(() => {
+        connected = true;
+      });
+
+      service.connect();
+      httpMock.expectOne(`${BASE}/connect?reauth=false`).flush("https://accounts.google.com/...");
+
+      ngZone.run(() => {
+        window.dispatchEvent(new MessageEvent("message", { data: "gdrive-connected" }));
+      });

Review Comment:
   With the proposed `DriveService.connect()` origin/source validation, this test must include `origin: window.location.origin` (and the popup `source`) in the dispatched `MessageEvent`; otherwise the handler will ignore it and the test will fail.



##########
frontend/src/app/app-routing.constant.ts:
##########
@@ -46,3 +46,5 @@ export const DASHBOARD_ADMIN_EXECUTION = `${DASHBOARD_ADMIN}/execution`;
 export const DASHBOARD_ADMIN_SETTINGS = `${DASHBOARD_ADMIN}/settings`;
 
 export const DASHBOARD_SEARCH = `${DASHBOARD}/search`;
+
+export const DASHBOARD_USER_GOOGLE_DRIVE = `${DASHBOARD_USER}/google-drive`;

Review Comment:
   `DASHBOARD_USER_GOOGLE_DRIVE` is added but not referenced anywhere in the frontend (only its declaration exists). If this route constant isn't needed, removing it avoids dead exports and keeps the routing constants list tidy.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@texera.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] feat: gdrive export ui integration [texera]

Posted by "xuang7 (via GitHub)" <gi...@apache.org>.
xuang7 closed pull request #5252: feat: gdrive export ui integration
URL: https://github.com/apache/texera/pull/5252


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@texera.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] feat: gdrive export ui integration [texera]

Posted by "github-actions[bot] (via GitHub)" <gi...@apache.org>.
github-actions[bot] commented on PR #5252:
URL: https://github.com/apache/texera/pull/5252#issuecomment-5075942009

   This pull request has been automatically marked as **stale** because it has
   not had any activity for 30 days. It will be closed in **7 days** if no
   further activity occurs.
   
   If this PR is still relevant, please:
   - Push new commits or rebase onto the latest `main`, or
   - Comment with a status update, or
   - Address any open review feedback.
   
   To exempt this PR from auto-close, a maintainer can add the `pinned` label.
   Thank you for your contribution to Apache Texera (incubating)!


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@texera.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] feat: gdrive export ui integration [texera]

Posted by "codecov-commenter (via GitHub)" <gi...@apache.org>.
codecov-commenter commented on PR #5252:
URL: https://github.com/apache/texera/pull/5252#issuecomment-4558767023

   ### :warning: JUnit XML file not found
   The CLI was unable to find any JUnit XML files to upload.
   For more help, visit our [troubleshooting guide](https://docs.codecov.com/docs/test-analytics#troubleshooting).


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@texera.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] feat: gdrive export ui integration [texera]

Posted by "xuang7 (via GitHub)" <gi...@apache.org>.
xuang7 commented on PR #5252:
URL: https://github.com/apache/texera/pull/5252#issuecomment-5124231138

   Temporarily closed because the backend implementation is still in progress and this PR has many conflicts with the current main branch. Please feel free to reopen it once the backend is ready.
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@texera.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org