diff --git a/amber/src/main/scala/org/apache/texera/web/ServletAwareConfigurator.scala b/amber/src/main/scala/org/apache/texera/web/ServletAwareConfigurator.scala index cb3628df5b3..dd5b8d89ef9 100644 --- a/amber/src/main/scala/org/apache/texera/web/ServletAwareConfigurator.scala +++ b/amber/src/main/scala/org/apache/texera/web/ServletAwareConfigurator.scala @@ -77,6 +77,7 @@ class ServletAwareConfigurator extends ServerEndpointConfig.Configurator with La null, null, null, + null, null ) ) @@ -108,6 +109,7 @@ class ServletAwareConfigurator extends ServerEndpointConfig.Configurator with La null, null, null, + null, null ) ) diff --git a/amber/src/main/scala/org/apache/texera/web/TexeraWebApplication.scala b/amber/src/main/scala/org/apache/texera/web/TexeraWebApplication.scala index 98b7c68c974..09c3e91b270 100644 --- a/amber/src/main/scala/org/apache/texera/web/TexeraWebApplication.scala +++ b/amber/src/main/scala/org/apache/texera/web/TexeraWebApplication.scala @@ -33,7 +33,11 @@ import org.apache.texera.auth.SessionUser import org.apache.texera.dao.SqlServer import org.apache.texera.web.auth.JwtAuth.setupJwtAuth import org.apache.texera.web.resource._ -import org.apache.texera.web.resource.auth.{AuthResource, GoogleAuthResource} +import org.apache.texera.web.resource.auth.{ + AuthResource, + GoogleAuthResource, + GoogleDriveAuthResource +} import org.apache.texera.web.resource.dashboard.DashboardResource import org.apache.texera.web.resource.dashboard.admin.execution.AdminExecutionResource import org.apache.texera.web.resource.dashboard.admin.settings.AdminSettingsResource @@ -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]) AuthResource.createAdminUser() diff --git a/amber/src/main/scala/org/apache/texera/web/auth/GuestAuthFilter.scala b/amber/src/main/scala/org/apache/texera/web/auth/GuestAuthFilter.scala index b7dda09489e..ced8824990f 100644 --- a/amber/src/main/scala/org/apache/texera/web/auth/GuestAuthFilter.scala +++ b/amber/src/main/scala/org/apache/texera/web/auth/GuestAuthFilter.scala @@ -39,7 +39,20 @@ import javax.ws.rs.core.SecurityContext } val GUEST: User = - new User(null, "guest", null, null, null, null, UserRoleEnum.REGULAR, null, null, null, null) + new User( + null, + "guest", + null, + null, + null, + null, + UserRoleEnum.REGULAR, + null, + null, + null, + null, + null + ) } @PreMatching diff --git a/amber/src/main/scala/org/apache/texera/web/model/http/response/DriveTokenIssueResponse.scala b/amber/src/main/scala/org/apache/texera/web/model/http/response/DriveTokenIssueResponse.scala new file mode 100644 index 00000000000..45d9498c2c8 --- /dev/null +++ b/amber/src/main/scala/org/apache/texera/web/model/http/response/DriveTokenIssueResponse.scala @@ -0,0 +1,25 @@ +/* + * 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.model.http.response + +case class DriveTokenIssueResponse( + status: String, + accessToken: Option[String] +) diff --git a/amber/src/main/scala/org/apache/texera/web/model/http/response/GoogleAuthConfigResponse.scala b/amber/src/main/scala/org/apache/texera/web/model/http/response/GoogleAuthConfigResponse.scala new file mode 100644 index 00000000000..efc2584f374 --- /dev/null +++ b/amber/src/main/scala/org/apache/texera/web/model/http/response/GoogleAuthConfigResponse.scala @@ -0,0 +1,22 @@ +/* + * 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.model.http.response + +case class GoogleAuthConfigResponse(clientId: String, apiKey: String) diff --git a/amber/src/main/scala/org/apache/texera/web/resource/auth/GoogleAuthResource.scala b/amber/src/main/scala/org/apache/texera/web/resource/auth/GoogleAuthResource.scala index 2f99b9c1bd3..208a24dd1cc 100644 --- a/amber/src/main/scala/org/apache/texera/web/resource/auth/GoogleAuthResource.scala +++ b/amber/src/main/scala/org/apache/texera/web/resource/auth/GoogleAuthResource.scala @@ -29,6 +29,7 @@ import org.apache.texera.dao.jooq.generated.enums.UserRoleEnum import org.apache.texera.dao.jooq.generated.tables.daos.UserDao import org.apache.texera.dao.jooq.generated.tables.pojos.User import org.apache.texera.web.model.http.response.TokenIssueResponse +import org.apache.texera.web.model.http.response.GoogleAuthConfigResponse import org.apache.texera.web.resource.auth.GoogleAuthResource.userDao import java.util.Collections @@ -48,11 +49,21 @@ object GoogleAuthResource { @Path("/auth/google") class GoogleAuthResource { final private lazy val clientId = UserSystemConfig.googleClientId + final private lazy val apiKey = UserSystemConfig.googleApiKey @GET @Path("/clientid") def getClientId: String = clientId + @GET + @Path("/config") + def getConfig: GoogleAuthConfigResponse = { + GoogleAuthConfigResponse(clientId, apiKey) + } + + @Path("/drive") + def getDriveResource: GoogleDriveAuthResource = new GoogleDriveAuthResource() + @POST @Consumes(Array(MediaType.TEXT_PLAIN)) @Produces(Array(MediaType.APPLICATION_JSON)) diff --git a/amber/src/main/scala/org/apache/texera/web/resource/auth/GoogleDriveAuthResource.scala b/amber/src/main/scala/org/apache/texera/web/resource/auth/GoogleDriveAuthResource.scala new file mode 100644 index 00000000000..ee3e3f261a5 --- /dev/null +++ b/amber/src/main/scala/org/apache/texera/web/resource/auth/GoogleDriveAuthResource.scala @@ -0,0 +1,176 @@ +/* + * 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.typesafe.scalalogging.LazyLogging +import org.apache.texera.auth.{JwtParser, SessionUser} +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.UserDao +import org.apache.texera.dao.SqlServer +import org.apache.texera.config.UserSystemConfig +import org.apache.texera.auth.JwtAuth.{TOKEN_EXPIRE_TIME_IN_MINUTES, jwtClaims} +import org.apache.texera.auth.JwtAuth +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 javax.annotation.security.RolesAllowed +import javax.ws.rs._ +import javax.ws.rs.core.MediaType +import javax.ws.rs.core.Response + +object GoogleDriveAuthResource { + // Status codes for token + private val STATUS_OK = "ok" + private val STATUS_NO_REFRESH_TOKEN = "no_refresh_token" + private val STATUS_INVALID_GRANT = "invalid_grant" + + private def userDao = + new UserDao( + 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 user = userDao.fetchOneByUid(sessionUser.getUid) + val refreshToken = user.getGoogleDriveRefreshToken + if (refreshToken == null) { + return Response.ok(DriveTokenIssueResponse(STATUS_NO_REFRESH_TOKEN, None)).build() + } + try { + val tokenResponse = new GoogleRefreshTokenRequest( + new NetHttpTransport(), + GsonFactory.getDefaultInstance, + refreshToken, + clientId, + clientSecret + ).execute() + val accessToken = tokenResponse.getAccessToken + Response.ok(DriveTokenIssueResponse(STATUS_OK, Some(accessToken))).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 sessionUserOpt = JwtParser.parseToken(state) + if (!sessionUserOpt.isPresent) { + return Response + .status(Response.Status.UNAUTHORIZED) + .entity("User is not authenticated") + .build() + } + + val userId = sessionUserOpt.get().getUid + val user = userDao.fetchOneByUid(userId) + + val response: GoogleTokenResponse = new GoogleAuthorizationCodeTokenRequest( + new NetHttpTransport(), + GsonFactory.getDefaultInstance, + clientId, + clientSecret, + code, + redirectUri + ).execute() + + user.setGoogleDriveRefreshToken(response.getRefreshToken) + userDao.update(user) + + val html = + """""".stripMargin + Response.ok(html).build() + } catch { + case e: TokenResponseException => + logger.error("Google token exchange failed in callback", e) + Response.status(Response.Status.BAD_GATEWAY).build() + case e: Exception => + logger.error("Unexpected error in OAuth callback", e) + Response.status(Response.Status.INTERNAL_SERVER_ERROR).build() + } + } + + @GET + @Path("/connect") + @RolesAllowed(Array("REGULAR", "ADMIN")) + def getOAuth( + @Auth sessionUser: SessionUser, + @QueryParam("reauth") @DefaultValue("false") reauth: Boolean + ): Response = { + val user = sessionUser.getUser + val state = JwtAuth.jwtToken(jwtClaims(user, TOKEN_EXPIRE_TIME_IN_MINUTES)) + + val url = new GoogleAuthorizationCodeRequestUrl( + clientId, + redirectUri, + java.util.Arrays.asList("https://www.googleapis.com/auth/drive") + ) + .setState(state) + .setAccessType("offline") + .set("prompt", if (reauth) "consent" else null) + .set("include_granted_scopes", true) + .build() + + Response.ok(url).build() + } +} diff --git a/common/auth/src/main/scala/org/apache/texera/auth/JwtParser.scala b/common/auth/src/main/scala/org/apache/texera/auth/JwtParser.scala index bb139e7093a..607188d71ba 100644 --- a/common/auth/src/main/scala/org/apache/texera/auth/JwtParser.scala +++ b/common/auth/src/main/scala/org/apache/texera/auth/JwtParser.scala @@ -74,6 +74,7 @@ object JwtParser extends LazyLogging { null, null, null, + null, null ) new SessionUser(user) diff --git a/common/config/src/main/resources/user-system.conf b/common/config/src/main/resources/user-system.conf index ffda7e2435a..41386e65ac3 100644 --- a/common/config/src/main/resources/user-system.conf +++ b/common/config/src/main/resources/user-system.conf @@ -26,6 +26,12 @@ user-sys { google { clientId = "" clientId = ${?USER_SYS_GOOGLE_CLIENT_ID} + + clientSecret = "" + clientSecret = ${?USER_SYS_GOOGLE_CLIENT_SECRET} + + apiKey = "" + apiKey = ${?USER_SYS_GOOGLE_API_KEY} smtp { gmail = "" diff --git a/common/config/src/main/scala/org/apache/texera/config/UserSystemConfig.scala b/common/config/src/main/scala/org/apache/texera/config/UserSystemConfig.scala index b78eed02024..d263256a313 100644 --- a/common/config/src/main/scala/org/apache/texera/config/UserSystemConfig.scala +++ b/common/config/src/main/scala/org/apache/texera/config/UserSystemConfig.scala @@ -30,6 +30,8 @@ object UserSystemConfig { val adminUsername: String = conf.getString("user-sys.admin-username") val adminPassword: String = conf.getString("user-sys.admin-password") val googleClientId: String = conf.getString("user-sys.google.clientId") + val googleClientSecret: String = conf.getString("user-sys.google.clientSecret") + val googleApiKey: String = conf.getString("user-sys.google.apiKey") val gmail: String = conf.getString("user-sys.google.smtp.gmail") val smtpPassword: String = conf.getString("user-sys.google.smtp.password") val inviteOnly: Boolean = conf.getBoolean("user-sys.invite-only") diff --git a/frontend/src/app/app-routing.constant.ts b/frontend/src/app/app-routing.constant.ts index 4181df8a954..be22a595ebe 100644 --- a/frontend/src/app/app-routing.constant.ts +++ b/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`; diff --git a/frontend/src/app/app-routing.module.ts b/frontend/src/app/app-routing.module.ts index 179caf5c088..959665a815b 100644 --- a/frontend/src/app/app-routing.module.ts +++ b/frontend/src/app/app-routing.module.ts @@ -39,6 +39,7 @@ import { UserDatasetComponent } from "./dashboard/component/user/user-dataset/us import { HubWorkflowDetailComponent } from "./hub/component/workflow/detail/hub-workflow-detail.component"; import { LandingPageComponent } from "./hub/component/landing-page/landing-page.component"; import { DASHBOARD_ABOUT, DASHBOARD_USER_WORKFLOW } from "./app-routing.constant"; +import { GoogleDriveConnectComponent } from "./dashboard/component/user/google-drive-connect/google-drive-connect.component"; import { HubSearchResultComponent } from "./hub/component/hub-search-result/hub-search-result.component"; import { AdminSettingsComponent } from "./dashboard/component/admin/settings/admin-settings.component"; import { GuiConfigService } from "./common/service/gui-config.service"; @@ -143,6 +144,10 @@ routes.push({ path: "discussion", component: FlarumComponent, }, + { + path: "google-drive", + component: GoogleDriveConnectComponent, + }, ], }, { diff --git a/frontend/src/app/dashboard/component/user/google-drive-connect/google-drive-connect.component.spec.ts b/frontend/src/app/dashboard/component/user/google-drive-connect/google-drive-connect.component.spec.ts new file mode 100644 index 00000000000..4071e7bfdaf --- /dev/null +++ b/frontend/src/app/dashboard/component/user/google-drive-connect/google-drive-connect.component.spec.ts @@ -0,0 +1,115 @@ +/** + * 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 { ComponentFixture, TestBed } from "@angular/core/testing"; +import { GoogleDriveConnectComponent } from "./google-drive-connect.component"; +import { DriveService } from "../../../service/user/google-drive/drive.service"; +import { of, Subject, throwError } from "rxjs"; +import { NO_ERRORS_SCHEMA } from "@angular/core"; +import type { Mocked } from "vitest"; + +describe("GoogleDriveConnectComponent", () => { + let component: GoogleDriveConnectComponent; + let fixture: ComponentFixture; + let driveServiceMock: Mocked; + let connected$: Subject; + + beforeEach(async () => { + connected$ = new Subject(); + + driveServiceMock = { + getToken: vi.fn().mockReturnValue(of({ status: "ok", accessToken: "token123" })), + onConnected: vi.fn().mockReturnValue(connected$.asObservable()), + connect: vi.fn(), + exportToDrive: vi.fn(), + openFolderPicker: vi.fn(), + } as unknown as Mocked; + + await TestBed.configureTestingModule({ + imports: [GoogleDriveConnectComponent], + providers: [{ provide: DriveService, useValue: driveServiceMock }], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(GoogleDriveConnectComponent); + component = fixture.componentInstance; + }); + + describe("ngOnInit", () => { + it("sets status to connected when token is ok", () => { + driveServiceMock.getToken.mockReturnValue(of({ status: "ok", accessToken: "abc" })); + fixture.detectChanges(); + expect(component.status).toBe("connected"); + }); + + it("sets status from response when token is not ok", () => { + driveServiceMock.getToken.mockReturnValue(of({ status: "no_refresh_token" })); + fixture.detectChanges(); + expect(component.status).toBe("no_refresh_token"); + }); + + it("sets status to error when getToken fails", () => { + driveServiceMock.getToken.mockReturnValue(throwError(() => new Error("network error"))); + fixture.detectChanges(); + expect(component.status).toBe("error"); + }); + + it("sets status to connected when onConnected emits", () => { + driveServiceMock.getToken.mockReturnValue(of({ status: "no_refresh_token" })); + fixture.detectChanges(); + expect(component.status).toBe("no_refresh_token"); + + connected$.next(); + expect(component.status).toBe("connected"); + }); + }); + + describe("connect", () => { + it("delegates to driveService.connect()", () => { + component.connect(); + expect(driveServiceMock.connect).toHaveBeenCalledWith(); + }); + }); + + describe("reconnect", () => { + it("delegates to driveService.connect(true)", () => { + component.reconnect(); + expect(driveServiceMock.connect).toHaveBeenCalledWith(true); + }); + }); + + describe("openFolderPicker", () => { + it("sets selectedFolder when picker resolves", () => { + const folder = { id: "folder-1", name: "My Exports" }; + driveServiceMock.openFolderPicker.mockReturnValue(of(folder)); + + component.openFolderPicker(); + + expect(component.selectedFolder).toEqual(folder); + }); + + it("does not set selectedFolder when picker is cancelled (completes without value)", () => { + driveServiceMock.openFolderPicker.mockReturnValue(of()); + + component.openFolderPicker(); + + expect(component.selectedFolder).toBeNull(); + }); + }); +}); diff --git a/frontend/src/app/dashboard/component/user/google-drive-connect/google-drive-connect.component.ts b/frontend/src/app/dashboard/component/user/google-drive-connect/google-drive-connect.component.ts new file mode 100644 index 00000000000..244febf1c24 --- /dev/null +++ b/frontend/src/app/dashboard/component/user/google-drive-connect/google-drive-connect.component.ts @@ -0,0 +1,112 @@ +/** + * 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 { Component, OnInit } from "@angular/core"; +import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy"; +import { DriveService, DriveFolder } from "../../../service/user/google-drive/drive.service"; +import { NgIf } from "@angular/common"; +import { NzButtonComponent } from "ng-zorro-antd/button"; +import { NzIconDirective } from "ng-zorro-antd/icon"; + +@UntilDestroy() +@Component({ + selector: "texera-google-drive-connect", + template: ` +
+

Google Drive Status: {{ status }}

+ + + +

Export to: {{ selectedFolder.name }}

+
+ `, + imports: [NgIf, NzButtonComponent, NzIconDirective], +}) +export class GoogleDriveConnectComponent implements OnInit { + status = "checking..."; + selectedFolder: DriveFolder | null = null; + + constructor(private driveService: DriveService) {} + + ngOnInit(): void { + this.driveService + .getToken() + .pipe(untilDestroyed(this)) + .subscribe({ + next: res => { + this.status = res.status === "ok" ? "connected" : res.status; + }, + error: () => { + this.status = "error"; + }, + }); + + this.driveService + .onConnected() + .pipe(untilDestroyed(this)) + .subscribe(() => { + this.status = "connected"; + }); + } + + connect(): void { + this.driveService.connect(); + } + + reconnect(): void { + this.driveService.connect(true); + } + + openFolderPicker(): void { + this.driveService + .openFolderPicker() + .pipe(untilDestroyed(this)) + .subscribe(folder => { + this.selectedFolder = folder; + }); + } +} diff --git a/frontend/src/app/dashboard/component/user/list-item/list-item.component.html b/frontend/src/app/dashboard/component/user/list-item/list-item.component.html index 16e190b41f9..913ef7fd5b5 100644 --- a/frontend/src/app/dashboard/component/user/list-item/list-item.component.html +++ b/frontend/src/app/dashboard/component/user/list-item/list-item.component.html @@ -22,7 +22,8 @@ class="list-item-card" [class.selected]="entry.checked" [class.has-button-group]="isPrivateSearch" - [class.editing-description]="editingDescription"> + [class.editing-description]="editingDescription" + [class.export-menu-open]="exportMenuVisible">
+ nz-dropdown + [(nzVisible)]="exportMenuVisible" + nzOverlayClassName="export-dropdown-menu" + [nzDropdownMenu]="exportMenu"> + +
    +
  • + Download +
  • +
  • + {{ isDriveConnected ? 'Export to Drive' : 'Connect to Drive' }} +
  • +
+
+ +
    +
  • + Download +
  • +
  • + {{ isDriveConnected ? 'Export to Drive' : 'Connect to Drive' }} +
  • +
+
+ +
    +
  • + Download +
  • +
  • + {{ isDriveConnected ? 'Export to Drive' : 'Connect to Drive' }} +
  • +
+
diff --git a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.scss b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.scss index ef68650a39a..11c9575ed99 100644 --- a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.scss +++ b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.scss @@ -333,3 +333,9 @@ nz-select { border-radius: 8px; margin-right: 80px; } + +button.export-menu-open { + color: #1890ff; + border-color: #1890ff; + background-color: #e6f7ff; +} diff --git a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.spec.ts b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.spec.ts new file mode 100644 index 00000000000..0de9f0eab2d --- /dev/null +++ b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.spec.ts @@ -0,0 +1,211 @@ +/** + * 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 { ComponentFixture, TestBed } from "@angular/core/testing"; +import { NO_ERRORS_SCHEMA } from "@angular/core"; +import { HttpClientTestingModule } from "@angular/common/http/testing"; +import { RouterTestingModule } from "@angular/router/testing"; +import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; +import { of, Subject } from "rxjs"; + +import { DatasetDetailComponent } from "./dataset-detail.component"; +import { DatasetService } from "../../../../service/user/dataset/dataset.service"; +import { NotificationService } from "../../../../../common/service/notification/notification.service"; +import { DriveService } from "../../../../service/user/google-drive/drive.service"; +import { UserService } from "../../../../../common/service/user/user.service"; +import { StubUserService } from "../../../../../common/service/user/stub-user.service"; +import { commonTestProviders } from "../../../../../common/testing/test-utils"; +import { ActivatedRoute } from "@angular/router"; +import { HubService } from "../../../../../hub/service/hub.service"; +import { AdminSettingsService } from "../../../../service/admin/settings/admin-settings.service"; +import { DownloadService } from "../../../../service/user/download/download.service"; +import { NzModalService } from "ng-zorro-antd/modal"; + +describe("DatasetDetailComponent - Drive integration", () => { + let component: DatasetDetailComponent; + let fixture: ComponentFixture; + let driveServiceMock: { + getToken: ReturnType; + onConnected: ReturnType; + connect: ReturnType; + exportToDrive: ReturnType; + }; + let datasetServiceMock: Record>; + let notificationServiceMock: { success: ReturnType; error: ReturnType }; + let connected$: Subject; + + beforeEach(async () => { + connected$ = new Subject(); + + driveServiceMock = { + getToken: vi.fn().mockReturnValue(of({ status: "ok", accessToken: "token" })), + onConnected: vi.fn().mockReturnValue(connected$.asObservable()), + connect: vi.fn(), + exportToDrive: vi.fn().mockReturnValue(of(undefined)), + }; + + datasetServiceMock = { + retrieveDatasetVersionZip: vi.fn().mockReturnValue(of(new Blob(["zip"], { type: "application/zip" }))), + retrieveDatasetVersionSingleFile: vi.fn().mockReturnValue(of(new Blob(["file"]))), + getDataset: vi.fn().mockReturnValue( + of({ + dataset: { name: "test", description: "", isPublic: false, isDownloadable: true, creationTime: undefined }, + accessPrivilege: "WRITE", + ownerEmail: "test@test.com", + isOwner: true, + }) + ), + retrieveDatasetVersionList: vi.fn().mockReturnValue(of([])), + }; + + notificationServiceMock = { success: vi.fn(), error: vi.fn() }; + + TestBed.overrideComponent(DatasetDetailComponent, { set: { template: "" } }); + + await TestBed.configureTestingModule({ + imports: [DatasetDetailComponent, HttpClientTestingModule, BrowserAnimationsModule, RouterTestingModule], + providers: [ + { provide: UserService, useClass: StubUserService }, + { provide: DriveService, useValue: driveServiceMock }, + { provide: DatasetService, useValue: datasetServiceMock }, + { provide: NotificationService, useValue: notificationServiceMock }, + { + provide: ActivatedRoute, + useValue: { params: of({ did: 1 }), data: of({}) }, + }, + { + provide: HubService, + useValue: { + getCounts: vi.fn().mockReturnValue(of([])), + postView: vi.fn().mockReturnValue(of(0)), + isLiked: vi.fn().mockReturnValue(of([])), + }, + }, + { + provide: AdminSettingsService, + useValue: { getSetting: vi.fn().mockReturnValue(of("10")) }, + }, + { + provide: DownloadService, + useValue: { + downloadDatasetVersion: vi.fn().mockReturnValue(of(undefined)), + downloadDatasetFile: vi.fn().mockReturnValue(of(undefined)), + }, + }, + NzModalService, + ...commonTestProviders, + ], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(DatasetDetailComponent); + component = fixture.componentInstance; + component.currentUid = 1; + fixture.detectChanges(); + }); + + it("should set isDriveConnected = true when token status is ok", () => { + expect(component.isDriveConnected).toBe(true); + }); + + it("should set isDriveConnected = false when getToken returns error status", () => { + driveServiceMock.getToken.mockReturnValue(of({ status: "error", accessToken: "" })); + const newFixture = TestBed.createComponent(DatasetDetailComponent); + const newComponent = newFixture.componentInstance; + newComponent.currentUid = 1; + newComponent.ngOnInit(); + expect(newComponent.isDriveConnected).toBe(false); + }); + + it("should set isDriveConnected = true and show toast when onConnected fires", () => { + // Drive subscription is already active from beforeEach fixture.detectChanges() + component.isDriveConnected = false; + + connected$.next(); + + expect(component.isDriveConnected).toBe(true); + expect(notificationServiceMock.success).toHaveBeenCalledWith("Google Drive connected"); + }); + + describe("onClickDriveExportVersion", () => { + it("should call connect() when not connected", () => { + component.isDriveConnected = false; + + component.onClickDriveExportVersion(); + + expect(driveServiceMock.connect).toHaveBeenCalled(); + expect(driveServiceMock.exportToDrive).not.toHaveBeenCalled(); + }); + + it("should do nothing when did or selectedVersion is missing", () => { + component.isDriveConnected = true; + component.did = undefined; + + component.onClickDriveExportVersion(); + + expect(driveServiceMock.exportToDrive).not.toHaveBeenCalled(); + }); + + it("should export version zip to Drive and show success toast", () => { + component.isDriveConnected = true; + component.did = 42; + component.selectedVersion = { dvid: 7, name: "v1" } as any; + component.datasetName = "my-dataset"; + + component.onClickDriveExportVersion(); + + expect(datasetServiceMock.retrieveDatasetVersionZip).toHaveBeenCalledWith(42, 7); + expect(driveServiceMock.exportToDrive).toHaveBeenCalledWith(expect.any(Blob), "my-dataset-v1.zip"); + expect(notificationServiceMock.success).toHaveBeenCalledWith("Exported to Google Drive"); + }); + }); + + describe("onClickDriveExportFile", () => { + it("should call connect() when not connected", () => { + component.isDriveConnected = false; + + component.onClickDriveExportFile(); + + expect(driveServiceMock.connect).toHaveBeenCalled(); + expect(driveServiceMock.exportToDrive).not.toHaveBeenCalled(); + }); + + it("should do nothing when no file is selected", () => { + component.isDriveConnected = true; + component.currentDisplayedFileName = ""; + + component.onClickDriveExportFile(); + + expect(driveServiceMock.exportToDrive).not.toHaveBeenCalled(); + }); + + it("should export the selected file to Drive and show success toast", () => { + component.isDriveConnected = true; + component.currentDisplayedFileName = "path/to/report.csv"; + component.datasetIsPublic = false; + component.isOwner = true; + + component.onClickDriveExportFile(); + + expect(datasetServiceMock.retrieveDatasetVersionSingleFile).toHaveBeenCalledWith("path/to/report.csv", true); + expect(driveServiceMock.exportToDrive).toHaveBeenCalledWith(expect.any(Blob), "report.csv"); + expect(notificationServiceMock.success).toHaveBeenCalledWith("Exported to Google Drive"); + }); + }); +}); diff --git a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.ts b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.ts index 85ca8d27cc0..12917fdb47b 100644 --- a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.ts +++ b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.ts @@ -68,6 +68,9 @@ import { NzProgressComponent } from "ng-zorro-antd/progress"; import { UserDatasetStagedObjectsListComponent } from "./user-dataset-staged-objects-list/user-dataset-staged-objects-list.component"; import { NzInputDirective } from "ng-zorro-antd/input"; import { AppSettings } from "../../../../../common/app-setting"; +import { NzDropdownDirective, NzDropdownMenuComponent } from "ng-zorro-antd/dropdown"; +import { NzMenuDirective, NzMenuItemComponent } from "ng-zorro-antd/menu"; +import { DriveService } from "../../../../service/user/google-drive/drive.service"; export const THROTTLE_TIME_MS = 1000; export const ABORT_RETRY_MAX_ATTEMPTS = 10; @@ -112,6 +115,10 @@ const DEFAULT_COVER_IMAGE = "assets/card_background.jpg"; NzProgressComponent, UserDatasetStagedObjectsListComponent, NzInputDirective, + NzDropdownDirective, + NzDropdownMenuComponent, + NzMenuDirective, + NzMenuItemComponent, ], }) export class DatasetDetailComponent implements OnInit { @@ -147,6 +154,10 @@ export class DatasetDetailComponent implements OnInit { public currentUid: number | undefined; public viewCount: number = 0; public displayPreciseViewCount = false; + public isDriveConnected = false; + public driveNeedsReauth = false; + public fileExportMenuVisible = false; + public versionExportMenuVisible = false; userHasPendingChanges: boolean = false; pendingChangesCount: number = 0; @@ -184,7 +195,8 @@ export class DatasetDetailComponent implements OnInit { private downloadService: DownloadService, private userService: UserService, private hubService: HubService, - private adminSettingsService: AdminSettingsService + private adminSettingsService: AdminSettingsService, + private driveService: DriveService ) { this.userService .userChanged() @@ -251,6 +263,21 @@ export class DatasetDetailComponent implements OnInit { }); this.loadUploadSettings(); + + this.driveService + .getToken() + .pipe(untilDestroyed(this)) + .subscribe(res => { + this.isDriveConnected = res.status === "ok"; + this.driveNeedsReauth = res.status === "invalid_grant"; + }); + this.driveService + .onConnected() + .pipe(untilDestroyed(this)) + .subscribe(() => { + this.isDriveConnected = true; + this.notificationService.success("Google Drive connected"); + }); } public onClickOpenVersionCreator() { @@ -277,6 +304,40 @@ export class DatasetDetailComponent implements OnInit { } } + public onClickDriveExportVersion(): void { + if (!this.isDriveConnected) { + this.driveService.connect(this.driveNeedsReauth); + return; + } + if (!this.did || !this.selectedVersion?.dvid) return; + this.datasetService + .retrieveDatasetVersionZip(this.did, this.selectedVersion.dvid) + .pipe( + switchMap(blob => + this.driveService.exportToDrive(blob, `${this.datasetName}-${this.selectedVersion!.name}.zip`) + ), + untilDestroyed(this) + ) + .subscribe({ next: () => this.notificationService.success("Exported to Google Drive") }); + } + + public onClickDriveExportFile(): void { + if (!this.isDriveConnected) { + this.driveService.connect(this.driveNeedsReauth); + return; + } + if (!this.currentDisplayedFileName) return; + const shouldUsePublicEndpoint = this.datasetIsPublic && !this.isOwner; + const fileName = this.currentDisplayedFileName.split("/").pop() || "download"; + this.datasetService + .retrieveDatasetVersionSingleFile(this.currentDisplayedFileName, !shouldUsePublicEndpoint) + .pipe( + switchMap(blob => this.driveService.exportToDrive(blob, fileName)), + untilDestroyed(this) + ) + .subscribe({ next: () => this.notificationService.success("Exported to Google Drive") }); + } + public onClickDownloadVersionAsZip() { if (this.did && this.selectedVersion && this.selectedVersion.dvid) { this.downloadService diff --git a/frontend/src/app/dashboard/service/user/google-drive/drive.service.spec.ts b/frontend/src/app/dashboard/service/user/google-drive/drive.service.spec.ts new file mode 100644 index 00000000000..8a59084762d --- /dev/null +++ b/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 + httpMock.expectOne(`${BASE}/token`).flush({ status: "no_refresh_token" }); + + tick(); + + let errorMessage = ""; + result$.subscribe({ error: (e: unknown) => (errorMessage = (e as Error).message) }); + tick(); + + expect(errorMessage).toBe("Not connected to Google Drive"); + })); + }); +}); diff --git a/frontend/src/app/dashboard/service/user/google-drive/drive.service.ts b/frontend/src/app/dashboard/service/user/google-drive/drive.service.ts new file mode 100644 index 00000000000..fe8b3ca9cc8 --- /dev/null +++ b/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(); + 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 { + return this.connected$.asObservable(); + } + + getToken(): Observable { + return this.http.get(`${this.BASE}/token`); + } + + exportToDrive(blob: Blob, fileName: string): Observable { + const result$ = new Subject(); + + 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 { + const result$ = new Subject(); + + 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); + }); + }); + + return result$.asObservable(); + } + + private uploadToDrive(blob: Blob, fileName: string, folderId: string, accessToken: string): Observable { + const boundary = "texera_gdrive_boundary"; + const metadata = JSON.stringify({ name: fileName, parents: [folderId] }); + const contentType = blob.type || "application/octet-stream"; + + const body = new Blob([ + `--${boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n${metadata}\r\n--${boundary}\r\nContent-Type: ${contentType}\r\n\r\n`, + blob, + `\r\n--${boundary}--`, + ]); + + return from( + fetch("https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart", { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": `multipart/related; boundary="${boundary}"`, + }, + body, + }).then(res => { + if (!res.ok) throw new Error(`Drive upload failed: ${res.status}`); + }) + ); + } + + private loadPicker(): Promise { + if (this.pickerLoaded) return Promise.resolve(); + return new Promise(resolve => { + gapi.load("picker", () => { + this.pickerLoaded = true; + resolve(); + }); + }); + } + + private async getAccessToken(): Promise { + const res = await firstValueFrom(this.getToken()); + return res.status === "ok" ? res.accessToken ?? null : null; + } +} diff --git a/frontend/src/app/workspace/component/menu/menu.component.html b/frontend/src/app/workspace/component/menu/menu.component.html index a21e4d56429..7bc5ee55f74 100644 --- a/frontend/src/app/workspace/component/menu/menu.component.html +++ b/frontend/src/app/workspace/component/menu/menu.component.html @@ -134,13 +134,30 @@ + +
    +
  • + Download +
  • +
  • + {{ isDriveConnected ? 'Export to Drive' : 'Connect to Drive' }} +
  • +
+