From 6c32f89feedf52670ff468670715bf64c65aea19 Mon Sep 17 00:00:00 2001 From: Victor Fawole Date: Tue, 26 May 2026 23:59:23 -0700 Subject: [PATCH 1/7] feat: add Google Drive OAuth backend endpoints, token encryption, and DB schema - Add user_oauth_token table to store encrypted OAuth refresh tokens per provider - Add TokenEncryptionService using jose4j AES-256-GCM for encrypting auth blobs - Add AuthConfig.encryptionSecretKey reading from auth.encryption.256-bit-secret - Add GoogleDriveAuthResource with /connect, /callback, and /token endpoints - Add GoogleAuthResource config endpoint exposing client ID and redirect URI - Add DriveTokenIssueResponse and GoogleAuthConfigResponse HTTP models - Wire GoogleDriveAuthResource into TexeraWebApplication and GuestAuthFilter - Add google.client-id, client-secret, and app-domain to UserSystemConfig - Update k8s values with new config keys Co-Authored-By: Claude Sonnet 4.6 --- .../texera/web/TexeraWebApplication.scala | 7 +- .../texera/web/auth/GuestAuthFilter.scala | 14 +- .../response/DriveTokenIssueResponse.scala | 25 +++ .../response/GoogleAuthConfigResponse.scala | 22 ++ .../resource/auth/GoogleAuthResource.scala | 11 + .../auth/GoogleDriveAuthResource.scala | 205 ++++++++++++++++++ bin/k8s/values-development.yaml | 4 + bin/k8s/values.yaml | 4 + .../texera/auth/TokenEncryptionService.scala | 46 ++++ common/config/src/main/resources/auth.conf | 4 + .../src/main/resources/user-system.conf | 6 + .../org/apache/texera/config/AuthConfig.scala | 13 ++ .../texera/config/UserSystemConfig.scala | 2 + sql/updates/23.sql | 36 +++ 14 files changed, 397 insertions(+), 2 deletions(-) create mode 100644 amber/src/main/scala/org/apache/texera/web/model/http/response/DriveTokenIssueResponse.scala create mode 100644 amber/src/main/scala/org/apache/texera/web/model/http/response/GoogleAuthConfigResponse.scala create mode 100644 amber/src/main/scala/org/apache/texera/web/resource/auth/GoogleDriveAuthResource.scala create mode 100644 common/auth/src/main/scala/org/apache/texera/auth/TokenEncryptionService.scala create mode 100644 sql/updates/23.sql 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..47a4a074a94 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,19 @@ 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 + ) } @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..ae460bd52a1 --- /dev/null +++ b/amber/src/main/scala/org/apache/texera/web/resource/auth/GoogleDriveAuthResource.scala @@ -0,0 +1,205 @@ +/* + * 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.{JwtParser, 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 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 { + 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 mapper = new ObjectMapper() + + 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 sessionUserOpt = JwtParser.parseToken(state) + if (!sessionUserOpt.isPresent) { + return Response + .status(Response.Status.UNAUTHORIZED) + .entity("User is not authenticated") + .build() + } + + val uid = sessionUserOpt.get().getUid + + 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) + + val existing = oauthTokenDao.fetchByUid(uid).stream() + .filter(r => r.getProvider == PROVIDER_GOOGLE_DRIVE) + .findFirst() + + if (existing.isPresent) { + existing.get().setAuthBlob(encryptedBlob) + oauthTokenDao.update(existing.get()) + } else { + val record = new UserOauthToken() + record.setUid(uid) + record.setProvider(PROVIDER_GOOGLE_DRIVE) + record.setAuthBlob(encryptedBlob) + oauthTokenDao.insert(record) + } + + 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/bin/k8s/values-development.yaml b/bin/k8s/values-development.yaml index cbf0183f004..c3659fbd814 100644 --- a/bin/k8s/values-development.yaml +++ b/bin/k8s/values-development.yaml @@ -291,6 +291,10 @@ texeraEnvVars: value: "true" - name: USER_SYS_GOOGLE_CLIENT_ID value: "" + - name: USER_SYS_GOOGLE_CLIENT_SECRET + value: "" + - name: USER_SYS_GOOGLE_API_KEY + value: "" - name: USER_SYS_GOOGLE_SMTP_GMAIL value: "" - name: USER_SYS_GOOGLE_SMTP_PASSWORD diff --git a/bin/k8s/values.yaml b/bin/k8s/values.yaml index 2d7c520ff7a..992ede384a2 100644 --- a/bin/k8s/values.yaml +++ b/bin/k8s/values.yaml @@ -273,6 +273,10 @@ texeraEnvVars: value: "true" - name: USER_SYS_GOOGLE_CLIENT_ID value: "" + - name: USER_SYS_GOOGLE_CLIENT_SECRET + value: "" + - name: USER_SYS_GOOGLE_API_KEY + value: "" - name: USER_SYS_GOOGLE_SMTP_GMAIL value: "" - name: USER_SYS_GOOGLE_SMTP_PASSWORD diff --git a/common/auth/src/main/scala/org/apache/texera/auth/TokenEncryptionService.scala b/common/auth/src/main/scala/org/apache/texera/auth/TokenEncryptionService.scala new file mode 100644 index 00000000000..2b8f784e484 --- /dev/null +++ b/common/auth/src/main/scala/org/apache/texera/auth/TokenEncryptionService.scala @@ -0,0 +1,46 @@ +/* + * 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.auth + +import org.apache.texera.config.AuthConfig +import org.jose4j.jwe.{ContentEncryptionAlgorithmIdentifiers, JsonWebEncryption, KeyManagementAlgorithmIdentifiers} +import org.jose4j.keys.AesKey + +import java.nio.charset.StandardCharsets + +object TokenEncryptionService { + private val key = new AesKey(AuthConfig.encryptionSecretKey.getBytes(StandardCharsets.UTF_8)) + + def encrypt(plaintext: String): String = { + val jwe = new JsonWebEncryption() + jwe.setAlgorithmHeaderValue(KeyManagementAlgorithmIdentifiers.DIRECT) + jwe.setEncryptionMethodHeaderParameter(ContentEncryptionAlgorithmIdentifiers.AES_256_GCM) + jwe.setKey(key) + jwe.setPayload(plaintext) + jwe.getCompactSerialization + } + + def decrypt(ciphertext: String): String = { + val jwe = new JsonWebEncryption() + jwe.setKey(key) + jwe.setCompactSerialization(ciphertext) + jwe.getPayload + } +} diff --git a/common/config/src/main/resources/auth.conf b/common/config/src/main/resources/auth.conf index c99db10c85e..eee12b7d697 100644 --- a/common/config/src/main/resources/auth.conf +++ b/common/config/src/main/resources/auth.conf @@ -25,4 +25,8 @@ auth { 256-bit-secret = "8a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d" 256-bit-secret = ${?AUTH_JWT_SECRET} } + encryption { + 256-bit-secret = "8a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d" + 256-bit-secret = ${?AUTH_ENCRYPTION_SECRET} + } } \ No newline at end of file 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/AuthConfig.scala b/common/config/src/main/scala/org/apache/texera/config/AuthConfig.scala index 748db036c98..72a9549e68a 100644 --- a/common/config/src/main/scala/org/apache/texera/config/AuthConfig.scala +++ b/common/config/src/main/scala/org/apache/texera/config/AuthConfig.scala @@ -30,6 +30,7 @@ object AuthConfig { // For storing the generated/configured secret @volatile private var secretKey: String = _ + @volatile private var eSecretKey: String = _ // Read JWT secret key with support for random generation def jwtSecretKey: String = { @@ -44,6 +45,18 @@ object AuthConfig { secretKey } + def encryptionSecretKey: String = { + synchronized { + if (eSecretKey == null) { + eSecretKey = conf.getString("auth.encryption.256-bit-secret").toLowerCase() match { + case "random" => getRandomHexString + case key => key + } + } + } + eSecretKey + } + private def getRandomHexString: String = { val bytes = 32 val r = new SecureRandom() 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/sql/updates/23.sql b/sql/updates/23.sql new file mode 100644 index 00000000000..71ce114fce9 --- /dev/null +++ b/sql/updates/23.sql @@ -0,0 +1,36 @@ +/* + * 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. + */ + +\c texera_db + +SET search_path TO texera_db; + +BEGIN; + +CREATE TABLE IF NOT EXISTS user_oauth_token ( + otid SERIAL PRIMARY KEY, + uid INT NOT NULL, + provider VARCHAR(64) NOT NULL, + auth_blob TEXT NOT NULL, + + UNIQUE (uid, provider), + FOREIGN KEY (uid) REFERENCES "user"(uid) ON DELETE CASCADE +); + +COMMIT; From de4290fbac87b63d9b8e454da9611618abd67247 Mon Sep 17 00:00:00 2001 From: Victor Fawole Date: Wed, 27 May 2026 01:55:11 -0700 Subject: [PATCH 2/7] test: add TokenEncryptionServiceSpec for encrypt/decrypt round-trip and error case Co-Authored-By: Claude Sonnet 4.6 --- .../auth/TokenEncryptionServiceSpec.scala | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 common/auth/src/test/scala/org/apache/texera/auth/TokenEncryptionServiceSpec.scala diff --git a/common/auth/src/test/scala/org/apache/texera/auth/TokenEncryptionServiceSpec.scala b/common/auth/src/test/scala/org/apache/texera/auth/TokenEncryptionServiceSpec.scala new file mode 100644 index 00000000000..1ce7b0526d5 --- /dev/null +++ b/common/auth/src/test/scala/org/apache/texera/auth/TokenEncryptionServiceSpec.scala @@ -0,0 +1,35 @@ +/* + * 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.auth + +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +class TokenEncryptionServiceSpec extends AnyFlatSpec with Matchers { + + "TokenEncryptionService" should "round-trip a plaintext string" in { + val plaintext = """{"refreshToken":"1//0gtoken","scopes":"https://www.googleapis.com/auth/drive"}""" + TokenEncryptionService.decrypt(TokenEncryptionService.encrypt(plaintext)) shouldBe plaintext + } + + it should "throw when decrypting a non-JWE string" in { + an[Exception] should be thrownBy TokenEncryptionService.decrypt("not-a-jwe-token") + } +} From c56362955c202130062ee3d5f0a60287a4cbb195 Mon Sep 17 00:00:00 2001 From: Victor Fawole Date: Wed, 27 May 2026 13:39:34 -0700 Subject: [PATCH 3/7] style: apply scalafmt to TokenEncryptionService and AuthConfig Co-Authored-By: Claude Sonnet 4.6 --- .../org/apache/texera/auth/TokenEncryptionService.scala | 6 +++++- .../main/scala/org/apache/texera/config/AuthConfig.scala | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/common/auth/src/main/scala/org/apache/texera/auth/TokenEncryptionService.scala b/common/auth/src/main/scala/org/apache/texera/auth/TokenEncryptionService.scala index 2b8f784e484..3d0803e037b 100644 --- a/common/auth/src/main/scala/org/apache/texera/auth/TokenEncryptionService.scala +++ b/common/auth/src/main/scala/org/apache/texera/auth/TokenEncryptionService.scala @@ -20,7 +20,11 @@ package org.apache.texera.auth import org.apache.texera.config.AuthConfig -import org.jose4j.jwe.{ContentEncryptionAlgorithmIdentifiers, JsonWebEncryption, KeyManagementAlgorithmIdentifiers} +import org.jose4j.jwe.{ + ContentEncryptionAlgorithmIdentifiers, + JsonWebEncryption, + KeyManagementAlgorithmIdentifiers +} import org.jose4j.keys.AesKey import java.nio.charset.StandardCharsets diff --git a/common/config/src/main/scala/org/apache/texera/config/AuthConfig.scala b/common/config/src/main/scala/org/apache/texera/config/AuthConfig.scala index 72a9549e68a..1aae76cf855 100644 --- a/common/config/src/main/scala/org/apache/texera/config/AuthConfig.scala +++ b/common/config/src/main/scala/org/apache/texera/config/AuthConfig.scala @@ -50,7 +50,7 @@ object AuthConfig { if (eSecretKey == null) { eSecretKey = conf.getString("auth.encryption.256-bit-secret").toLowerCase() match { case "random" => getRandomHexString - case key => key + case key => key } } } From 6e3c7c8918b3e808bb30686a96449138cef4d657 Mon Sep 17 00:00:00 2001 From: Victor Fawole Date: Wed, 27 May 2026 23:57:29 -0700 Subject: [PATCH 4/7] refactor: removed unneccesary changes --- .../apache/texera/web/auth/GuestAuthFilter.scala | 14 +------------- bin/k8s/values-development.yaml | 4 ---- bin/k8s/values.yaml | 4 ---- 3 files changed, 1 insertion(+), 21 deletions(-) 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 47a4a074a94..b7dda09489e 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,19 +39,7 @@ 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) } @PreMatching diff --git a/bin/k8s/values-development.yaml b/bin/k8s/values-development.yaml index c3659fbd814..cbf0183f004 100644 --- a/bin/k8s/values-development.yaml +++ b/bin/k8s/values-development.yaml @@ -291,10 +291,6 @@ texeraEnvVars: value: "true" - name: USER_SYS_GOOGLE_CLIENT_ID value: "" - - name: USER_SYS_GOOGLE_CLIENT_SECRET - value: "" - - name: USER_SYS_GOOGLE_API_KEY - value: "" - name: USER_SYS_GOOGLE_SMTP_GMAIL value: "" - name: USER_SYS_GOOGLE_SMTP_PASSWORD diff --git a/bin/k8s/values.yaml b/bin/k8s/values.yaml index 992ede384a2..2d7c520ff7a 100644 --- a/bin/k8s/values.yaml +++ b/bin/k8s/values.yaml @@ -273,10 +273,6 @@ texeraEnvVars: value: "true" - name: USER_SYS_GOOGLE_CLIENT_ID value: "" - - name: USER_SYS_GOOGLE_CLIENT_SECRET - value: "" - - name: USER_SYS_GOOGLE_API_KEY - value: "" - name: USER_SYS_GOOGLE_SMTP_GMAIL value: "" - name: USER_SYS_GOOGLE_SMTP_PASSWORD From a0b97fa3afd552e7d8c35bcf144f853202344c6d Mon Sep 17 00:00:00 2001 From: Victor Fawole Date: Thu, 28 May 2026 00:31:28 -0700 Subject: [PATCH 5/7] refactor: replace JWT state param with short-lived opaque token in GoogleDriveAuthResource OAuth state is now a UUID stored in a ConcurrentHashMap with a 10-minute TTL, consumed exactly once on callback. Removes JwtParser/JwtAuth dependency from the Drive resource and avoids encoding user info in the callback URL. Co-Authored-By: Claude Sonnet 4.6 --- .../auth/GoogleDriveAuthResource.scala | 24 +++++++++++-------- common/config/src/main/resources/auth.conf | 2 +- 2 files changed, 15 insertions(+), 11 deletions(-) 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 index ae460bd52a1..4bbec8c5743 100644 --- 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 @@ -21,15 +21,13 @@ 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.{JwtParser, SessionUser, TokenEncryptionService} +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 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, @@ -40,6 +38,7 @@ 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 @@ -51,8 +50,13 @@ object GoogleDriveAuthResource { 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 @@ -123,15 +127,15 @@ class GoogleDriveAuthResource extends LazyLogging { return Response.status(Response.Status.BAD_REQUEST).build() } try { - val sessionUserOpt = JwtParser.parseToken(state) - if (!sessionUserOpt.isPresent) { + val entry = pendingStates.remove(state) + if (entry == null || System.currentTimeMillis() > entry._2) { return Response .status(Response.Status.UNAUTHORIZED) - .entity("User is not authenticated") + .entity("OAuth state token is invalid or expired") .build() } - val uid = sessionUserOpt.get().getUid + val uid = entry._1 val tokenResponse: GoogleTokenResponse = new GoogleAuthorizationCodeTokenRequest( new NetHttpTransport(), @@ -186,15 +190,15 @@ class GoogleDriveAuthResource extends LazyLogging { @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 stateToken = java.util.UUID.randomUUID().toString + pendingStates.put(stateToken, (sessionUser.getUid, System.currentTimeMillis() + STATE_TTL_MS)) val url = new GoogleAuthorizationCodeRequestUrl( clientId, redirectUri, java.util.Arrays.asList("https://www.googleapis.com/auth/drive") ) - .setState(state) + .setState(stateToken) .setAccessType("offline") .set("prompt", if (reauth) "consent" else null) .set("include_granted_scopes", true) diff --git a/common/config/src/main/resources/auth.conf b/common/config/src/main/resources/auth.conf index eee12b7d697..d8978949ecb 100644 --- a/common/config/src/main/resources/auth.conf +++ b/common/config/src/main/resources/auth.conf @@ -26,7 +26,7 @@ auth { 256-bit-secret = ${?AUTH_JWT_SECRET} } encryption { - 256-bit-secret = "8a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d" + 256-bit-secret = "9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e" 256-bit-secret = ${?AUTH_ENCRYPTION_SECRET} } } \ No newline at end of file From 9ebbe10e7bdece40a58054b5b956b3d175e51bbc Mon Sep 17 00:00:00 2001 From: Victor Fawole Date: Tue, 26 May 2026 23:59:37 -0700 Subject: [PATCH 6/7] feat: add DriveService, OAuth callback page, and routing for Google Drive - Add DriveService to manage Drive token retrieval, OAuth connection, and file export - Add GoogleDriveConnectComponent as the OAuth callback landing page - Add /gdrive-connect route to the app router - Inject gapi and Google Identity Services scripts into index.html Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/app/app-routing.constant.ts | 2 + frontend/src/app/app-routing.module.ts | 5 + .../google-drive-connect.component.spec.ts | 115 ++++++++++ .../google-drive-connect.component.ts | 112 ++++++++++ .../user/google-drive/drive.service.spec.ts | 161 ++++++++++++++ .../user/google-drive/drive.service.ts | 202 ++++++++++++++++++ frontend/src/index.html | 3 + 7 files changed, 600 insertions(+) create mode 100644 frontend/src/app/dashboard/component/user/google-drive-connect/google-drive-connect.component.spec.ts create mode 100644 frontend/src/app/dashboard/component/user/google-drive-connect/google-drive-connect.component.ts create mode 100644 frontend/src/app/dashboard/service/user/google-drive/drive.service.spec.ts create mode 100644 frontend/src/app/dashboard/service/user/google-drive/drive.service.ts 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/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/index.html b/frontend/src/index.html index 833fb4c423b..7a0714a2693 100644 --- a/frontend/src/index.html +++ b/frontend/src/index.html @@ -58,6 +58,9 @@ href="https://fonts.googleapis.com/icon?family=Material+Icons&display=block" rel="stylesheet" /> + + + Date: Tue, 26 May 2026 23:59:54 -0700 Subject: [PATCH 7/7] feat: add Drive export button to list-item, dataset-detail, and workspace menu - Add Drive export action to list-item component (workflows and datasets) - Add Drive export buttons to dataset-detail download section - Add Drive export to workspace menu download button - Subscribe to DriveService.onConnected to update connection state and show toast - Add unit tests for all three UI surfaces Co-Authored-By: Claude Sonnet 4.6 --- .../user/list-item/list-item.component.html | 23 +- .../user/list-item/list-item.component.scss | 15 +- .../list-item/list-item.component.spec.ts | 86 ++++++- .../user/list-item/list-item.component.ts | 62 ++++- .../dataset-detail.component.html | 44 +++- .../dataset-detail.component.scss | 6 + .../dataset-detail.component.spec.ts | 211 ++++++++++++++++++ .../dataset-detail.component.ts | 63 +++++- .../component/menu/menu.component.html | 21 +- .../component/menu/menu.component.scss | 6 + .../component/menu/menu.component.spec.ts | 74 +++++- .../component/menu/menu.component.ts | 35 ++- 12 files changed, 625 insertions(+), 21 deletions(-) create mode 100644 frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.spec.ts 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/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' }} +
  • +
+