From 722923732ea3fc0d3d853204a6a69f059400b3f6 Mon Sep 17 00:00:00 2001 From: Victor Fawole Date: Fri, 8 May 2026 00:52:56 -0700 Subject: [PATCH 01/20] feat: Added gdrive refresh token to user table --- sql/updates/23.sql | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 sql/updates/23.sql diff --git a/sql/updates/23.sql b/sql/updates/23.sql new file mode 100644 index 00000000000..333966de9d2 --- /dev/null +++ b/sql/updates/23.sql @@ -0,0 +1,28 @@ +/* + * 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; + +ALTER TABLE "user" + ADD COLUMN IF NOT EXISTS google_drive_refresh_token VARCHAR(512) NULL; +COMMIT; From fe5d866d7c0ff751c2dcc86a11905c299586e170 Mon Sep 17 00:00:00 2001 From: Victor Fawole Date: Fri, 8 May 2026 00:54:20 -0700 Subject: [PATCH 02/20] feat: Added google clientSecret and apiKey The clientSecret is required for auth code exchange on backend for the access and refresh tokens. The apiKey is required for the Google Drive Picker component in the frontend. --- common/config/src/main/resources/user-system.conf | 6 ++++++ 1 file changed, 6 insertions(+) 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 = "" From 2fea1c617bb34254a36fb585ee455f4f3d4ff4c1 Mon Sep 17 00:00:00 2001 From: Victor Fawole Date: Fri, 8 May 2026 00:58:20 -0700 Subject: [PATCH 03/20] feat: Added clientSecret and apiKey to conf --- .../main/scala/org/apache/texera/config/UserSystemConfig.scala | 2 ++ 1 file changed, 2 insertions(+) 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") From 54975b8c9a6d7825d110e7317701a23643dd1dbd Mon Sep 17 00:00:00 2001 From: Victor Fawole Date: Tue, 12 May 2026 00:11:54 -0700 Subject: [PATCH 04/20] feat: Add refresh_token field to user code and token endpoint --- .../texera/web/ServletAwareConfigurator.scala | 2 + .../texera/web/TexeraWebApplication.scala | 3 +- .../texera/web/auth/GuestAuthFilter.scala | 2 +- .../response/DriveTokenIssueResponse.scala | 6 ++ .../response/GoogleAuthConfigResponse.scala | 3 + .../resource/auth/GoogleAuthResource.scala | 11 +++ .../auth/GoogleDriveAuthResource.scala | 99 +++++++++++++++++++ .../src/main/resources/user-system.conf | 6 ++ 8 files changed, 130 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 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..260b015a3e6 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,7 @@ 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 +160,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..2529cb6b1fd 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,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, 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..baf595d45b1 --- /dev/null +++ b/amber/src/main/scala/org/apache/texera/web/model/http/response/DriveTokenIssueResponse.scala @@ -0,0 +1,6 @@ +package org.apache.texera.web.model.http.response + +case class DriveTokenIssueResponse( + status: String, + accessToken: Option[String] +) \ No newline at end of file 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..0068010074f --- /dev/null +++ b/amber/src/main/scala/org/apache/texera/web/model/http/response/GoogleAuthConfigResponse.scala @@ -0,0 +1,3 @@ +package org.apache.texera.web.model.http.response + +case class GoogleAuthConfigResponse(clientId: String, apiKey: String) \ No newline at end of file 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..7344d63ec67 --- /dev/null +++ b/amber/src/main/scala/org/apache/texera/web/resource/auth/GoogleDriveAuthResource.scala @@ -0,0 +1,99 @@ +/* + * 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 org.apache.texera.dao.jooq.generated.tables.pojos.User +import org.apache.texera.auth.SessionUser +import org.apache.texera.web.model.http.response.DriveTokenIssueResponse +import org.apache.texera.dao.jooq.generated.tables.daos.UserDao +import org.apache.texera.dao.SqlServer +import org.apache.texera.config.UserSystemConfig +import com.google.api.client.googleapis.auth.oauth2.GoogleRefreshTokenRequest +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.ws.rs._ +import javax.ws.rs.core.MediaType +import javax.ws.rs.core.Response + +object GoogleDriveAuthResource { + // Status codes for token + + + // private def userDao = + // new UserDao( + // SqlServer + // .getInstance() + // .createDSLContext() + // .configuration + // ) + + // def getRefreshToken(user: User): Option[String] = { + // if (user == null) return None + // val token = user.getGoogleDriveRefreshToken + // return token + // } +} + +@Consumes(Array(MediaType.APPLICATION_JSON)) +@Produces(Array(MediaType.APPLICATION_JSON)) +class GoogleDriveAuthResource { + final private lazy val clientId = UserSystemConfig.googleClientId + final private lazy val clientSecret = UserSystemConfig.googleClientSecret + final val STATUS_OK = "ok" + final val STATUS_NO_REFRESH_TOKEN = "no_refresh_token" + final val STATUS_INVALID_GRANT = "invalid_grant" + + @GET + @Path("/token") + def getDriveAccessToken(@Auth sessionUser: SessionUser): Response = { + val user = sessionUser.getUser + 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() + return Response.ok(DriveTokenIssueResponse(STATUS_OK, Some(accessToken))).build() + } catch { + case e: TokenResponseException => + if (e.getDetails != null && e.getDetails.getError == STATUS_INVALID_GRANT) { + return Response.ok(DriveTokenIssueResponse(STATUS_INVALID_GRANT, None)).build() + } else { + return Response.status(Response.Status.INTERNAL_SERVER_ERROR).build() + } + case _: Exception => + return Response.status(Response.Status.INTERNAL_SERVER_ERROR).build() + } + } + + // def startOAuthFlow(@Auth sessionUser: SessionUser): Response = { + + // } +} + diff --git a/common/config/src/main/resources/user-system.conf b/common/config/src/main/resources/user-system.conf index 41386e65ac3..33cff94a69d 100644 --- a/common/config/src/main/resources/user-system.conf +++ b/common/config/src/main/resources/user-system.conf @@ -33,6 +33,12 @@ user-sys { apiKey = "" apiKey = ${?USER_SYS_GOOGLE_API_KEY} + clientSecret = "" + clientSecret = ${?USER_SYS_GOOGLE_CLIENT_SECRET} + + apiKey = "" + apiKey = ${?USER_SYS_GOOGLE_API_KEY} + smtp { gmail = "" gmail = ${?USER_SYS_GOOGLE_SMTP_GMAIL} From 24039d62c507380d2080429deb3ce7a2447af028 Mon Sep 17 00:00:00 2001 From: Victor Fawole Date: Mon, 18 May 2026 17:25:53 -0700 Subject: [PATCH 05/20] feat: implemented connect and callback methods for getting refresh and access tokens --- .../auth/GoogleDriveAuthResource.scala | 163 ++++++++++++------ 1 file changed, 111 insertions(+), 52 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 7344d63ec67..c64bf093315 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 @@ -19,13 +19,18 @@ package org.apache.texera.web.resource.auth import io.dropwizard.auth.Auth -import org.apache.texera.dao.jooq.generated.tables.pojos.User -import org.apache.texera.auth.SessionUser +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 com.google.api.client.googleapis.auth.oauth2.GoogleRefreshTokenRequest +import org.apache.texera.auth.JwtAuth.{jwtClaims, TOKEN_EXPIRE_TIME_IN_MINUTES} +import org.apache.texera.auth.JwtAuth +import com.google.api.client.googleapis.auth.oauth2.{GoogleRefreshTokenRequest, + GoogleAuthorizationCodeTokenRequest, + GoogleTokenResponse, + GoogleAuthorizationCodeRequestUrl} import com.google.api.client.auth.oauth2.TokenResponseException import com.google.api.client.http.javanet.NetHttpTransport import com.google.api.client.json.gson.GsonFactory @@ -35,65 +40,119 @@ import javax.ws.rs.core.MediaType import javax.ws.rs.core.Response object GoogleDriveAuthResource { - // Status codes for token - + // 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 - // ) - - // def getRefreshToken(user: User): Option[String] = { - // if (user == null) return None - // val token = user.getGoogleDriveRefreshToken - // return token - // } + private def userDao = + new UserDao( + SqlServer + .getInstance() + .createDSLContext() + .configuration + ) } @Consumes(Array(MediaType.APPLICATION_JSON)) @Produces(Array(MediaType.APPLICATION_JSON)) class GoogleDriveAuthResource { - final private lazy val clientId = UserSystemConfig.googleClientId - final private lazy val clientSecret = UserSystemConfig.googleClientSecret - final val STATUS_OK = "ok" - final val STATUS_NO_REFRESH_TOKEN = "no_refresh_token" - final val STATUS_INVALID_GRANT = "invalid_grant" + 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:8080/api/auth/google/drive/callback") - @GET - @Path("/token") - def getDriveAccessToken(@Auth sessionUser: SessionUser): Response = { - val user = sessionUser.getUser - 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() - return Response.ok(DriveTokenIssueResponse(STATUS_OK, Some(accessToken))).build() - } catch { - case e: TokenResponseException => - if (e.getDetails != null && e.getDetails.getError == STATUS_INVALID_GRANT) { - return Response.ok(DriveTokenIssueResponse(STATUS_INVALID_GRANT, None)).build() - } else { - return Response.status(Response.Status.INTERNAL_SERVER_ERROR).build() - } - case _: Exception => - return Response.status(Response.Status.INTERNAL_SERVER_ERROR).build() + @GET + @Path("/token") + def getDriveAccessToken(@Auth sessionUser: SessionUser): Response = { + val user = sessionUser.getUser + 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 { + Response.status(Response.Status.INTERNAL_SERVER_ERROR).build() } + case _: Exception => + Response.status(Response.Status.INTERNAL_SERVER_ERROR).build() + } + } + + @GET + @Path("/callback") + 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).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) + + Response.ok(response.getAccessToken).build() + } catch { + case _: TokenResponseException => + Response.status(Response.Status.BAD_GATEWAY).build() + case _: Exception => + Response.status(Response.Status.INTERNAL_SERVER_ERROR).build() } + } + + @GET + @Path("/connect") + 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)) - // def startOAuthFlow(@Auth sessionUser: SessionUser): Response = { + 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() + } } From 02af7807463f3c390bf6da689486ff26ccf82d73 Mon Sep 17 00:00:00 2001 From: Victor Fawole Date: Mon, 18 May 2026 19:44:58 -0700 Subject: [PATCH 06/20] feat: add google access and refresh tokens --- .../resource/auth/GoogleDriveAuthResource.scala | 14 ++++++++++---- .../scala/org/apache/texera/auth/JwtParser.scala | 1 + common/config/src/main/resources/user-system.conf | 6 ------ 3 files changed, 11 insertions(+), 10 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 c64bf093315..ef1fad6731f 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 @@ -61,7 +61,7 @@ class GoogleDriveAuthResource { 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:8080/api/auth/google/drive/callback") + .getOrElse("http://localhost:4200/api/auth/google/drive/callback") @GET @Path("/token") @@ -95,6 +95,7 @@ class GoogleDriveAuthResource { @GET @Path("/callback") + @Produces(Array(MediaType.TEXT_HTML,MediaType.APPLICATION_JSON)) def getCallback( @QueryParam("code") @DefaultValue("") code: String, @QueryParam("state") @DefaultValue("") state: String @@ -123,10 +124,15 @@ class GoogleDriveAuthResource { user.setGoogleDriveRefreshToken(response.getRefreshToken) userDao.update(user) - Response.ok(response.getAccessToken).build() + val html = + """""".stripMargin + Response.ok(html).build() } catch { - case _: TokenResponseException => - Response.status(Response.Status.BAD_GATEWAY).build() + case e: TokenResponseException => + Response.status(Response.Status.BAD_GATEWAY).entity(e.getDetails).build() case _: Exception => Response.status(Response.Status.INTERNAL_SERVER_ERROR).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 33cff94a69d..41386e65ac3 100644 --- a/common/config/src/main/resources/user-system.conf +++ b/common/config/src/main/resources/user-system.conf @@ -33,12 +33,6 @@ user-sys { apiKey = "" apiKey = ${?USER_SYS_GOOGLE_API_KEY} - clientSecret = "" - clientSecret = ${?USER_SYS_GOOGLE_CLIENT_SECRET} - - apiKey = "" - apiKey = ${?USER_SYS_GOOGLE_API_KEY} - smtp { gmail = "" gmail = ${?USER_SYS_GOOGLE_SMTP_GMAIL} From 52e3080c442923364ab5c4df5b9919a20d375eba Mon Sep 17 00:00:00 2001 From: Victor Fawole Date: Wed, 20 May 2026 13:51:52 -0700 Subject: [PATCH 07/20] fix(amber): improve logging in GoogleDriveAuthResource Pass exception as second arg to logger.error so stack traces are captured. Also fetch user from DB in /token instead of JWT, and add LazyLogging trait. --- .../resource/auth/GoogleDriveAuthResource.scala | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 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 ef1fad6731f..de38d343e56 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 @@ -19,6 +19,7 @@ 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._ @@ -56,7 +57,7 @@ object GoogleDriveAuthResource { @Consumes(Array(MediaType.APPLICATION_JSON)) @Produces(Array(MediaType.APPLICATION_JSON)) -class GoogleDriveAuthResource { +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 @@ -66,7 +67,7 @@ class GoogleDriveAuthResource { @GET @Path("/token") def getDriveAccessToken(@Auth sessionUser: SessionUser): Response = { - val user = sessionUser.getUser + val user = userDao.fetchOneByUid(sessionUser.getUid) val refreshToken = user.getGoogleDriveRefreshToken if (refreshToken == null) { return Response.ok(DriveTokenIssueResponse(STATUS_NO_REFRESH_TOKEN, None)).build() @@ -86,9 +87,11 @@ class GoogleDriveAuthResource { 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 _: Exception => + case e: Exception => + logger.error("Unexpected error refreshing access token", e) Response.status(Response.Status.INTERNAL_SERVER_ERROR).build() } } @@ -106,7 +109,7 @@ class GoogleDriveAuthResource { try { val sessionUserOpt = JwtParser.parseToken(state) if (!sessionUserOpt.isPresent) { - return Response.status(Response.Status.UNAUTHORIZED).build() + return Response.status(Response.Status.UNAUTHORIZED).entity("User is not authenticated").build() } val userId = sessionUserOpt.get().getUid @@ -132,8 +135,10 @@ class GoogleDriveAuthResource { Response.ok(html).build() } catch { case e: TokenResponseException => - Response.status(Response.Status.BAD_GATEWAY).entity(e.getDetails).build() - case _: Exception => + 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() } } From 08e5c1b541cc5d3f205c0bc59215bc79bfa92858 Mon Sep 17 00:00:00 2001 From: Victor Fawole Date: Wed, 20 May 2026 15:48:54 -0700 Subject: [PATCH 08/20] feat: add Google Drive export to list-item component - Add DriveService with OAuth connect flow, Google Picker integration, and multipart upload to Drive - Add GoogleDriveConnectComponent for the OAuth callback page - Add export dropdown to list-item (Download / Export to Drive) for workflows and datasets - Register /dashboard/user/google-drive route for the callback - Load Google API script in 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.ts | 92 ++++++++ .../user/list-item/list-item.component.html | 16 +- .../user/list-item/list-item.component.ts | 58 ++++- .../user/google-drive/drive.service.ts | 202 ++++++++++++++++++ frontend/src/index.html | 3 + 7 files changed, 371 insertions(+), 7 deletions(-) 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.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.ts b/frontend/src/app/dashboard/component/user/google-drive-connect/google-drive-connect.component.ts new file mode 100644 index 00000000000..11ddbc63821 --- /dev/null +++ b/frontend/src/app/dashboard/component/user/google-drive-connect/google-drive-connect.component.ts @@ -0,0 +1,92 @@ +/** + * 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..3d5475f2818 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 @@ -217,12 +217,18 @@ nz-button nzType="text" *ngIf="entry.type === 'workflow' || entry.type === 'dataset'" - title="Download" - (click)="onClickDownload()"> - + nz-dropdown + [nzDropdownMenu]="exportMenu"> + + +
    +
  • Download
  • +
  • + {{ isDriveConnected ? 'Export to Drive' : 'Connect to Drive' }} +
  • +
+
diff --git a/frontend/src/app/dashboard/component/user/list-item/list-item.component.scss b/frontend/src/app/dashboard/component/user/list-item/list-item.component.scss index 97cec2e60c3..3b73fb02faf 100644 --- a/frontend/src/app/dashboard/component/user/list-item/list-item.component.scss +++ b/frontend/src/app/dashboard/component/user/list-item/list-item.component.scss @@ -29,14 +29,15 @@ z-index: 10; } - &:hover { + &:hover, + &.export-menu-open { background-color: #f0f0f0; .edit-button { display: flex; } - &.has-button-group:hover .resource-info { + &.has-button-group .resource-info { opacity: 0.3; } } @@ -120,7 +121,8 @@ } } -.list-item-card:hover .button-group { +.list-item-card:hover .button-group, +.list-item-card.export-menu-open .button-group { display: flex; background-color: transparent; } @@ -142,7 +144,8 @@ transition: all 0.2s ease; } -.list-item-card:hover .resource-description { +.list-item-card:hover .resource-description, +.list-item-card.export-menu-open .resource-description { font-size: 15px; font-weight: 500; color: #1e90ff; @@ -198,3 +201,7 @@ :host ::ng-deep nz-card.list-item-card.ant-card:hover .ant-card-body { cursor: pointer; } + +::ng-deep .export-dropdown-menu.ant-dropdown { + animation-duration: 0.075s !important; +} diff --git a/frontend/src/app/dashboard/component/user/list-item/list-item.component.ts b/frontend/src/app/dashboard/component/user/list-item/list-item.component.ts index d7a8f052f6d..bb3359c2057 100644 --- a/frontend/src/app/dashboard/component/user/list-item/list-item.component.ts +++ b/frontend/src/app/dashboard/component/user/list-item/list-item.component.ts @@ -119,6 +119,7 @@ export class ListItemComponent implements OnChanges, OnInit { private _entry?: DashboardEntry; hovering: boolean = false; isDriveConnected = false; + exportMenuVisible = false; @Input() get entry(): DashboardEntry { From 07a6bad06a4ae3245730f8d2dbfe21c4a6613a48 Mon Sep 17 00:00:00 2001 From: Victor Fawole Date: Wed, 20 May 2026 17:54:48 -0700 Subject: [PATCH 11/20] feat: show toast on Google Drive connection and successful export Co-Authored-By: Claude Sonnet 4.6 --- .../component/user/list-item/list-item.component.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/dashboard/component/user/list-item/list-item.component.ts b/frontend/src/app/dashboard/component/user/list-item/list-item.component.ts index bb3359c2057..b344a13cbf4 100644 --- a/frontend/src/app/dashboard/component/user/list-item/list-item.component.ts +++ b/frontend/src/app/dashboard/component/user/list-item/list-item.component.ts @@ -162,6 +162,7 @@ export class ListItemComponent implements OnChanges, OnInit { .pipe(untilDestroyed(this)) .subscribe(() => { this.isDriveConnected = true; + this.notificationService.success("Google Drive connected"); }); } @@ -299,7 +300,7 @@ export class ListItemComponent implements OnChanges, OnInit { }), untilDestroyed(this) ) - .subscribe(); + .subscribe({ next: () => this.notificationService.success("Exported to Google Drive") }); } else if (this.entry.type === "dataset") { this.datasetService .retrieveDatasetVersionZip(this.entry.id) @@ -307,7 +308,7 @@ export class ListItemComponent implements OnChanges, OnInit { switchMap(blob => this.driveService.exportToDrive(blob, `${this.entry.name}.zip`)), untilDestroyed(this) ) - .subscribe(); + .subscribe({ next: () => this.notificationService.success("Exported to Google Drive") }); } } From 6ebab5c31f3be2d7427d31aeecf48d7dabd6c925 Mon Sep 17 00:00:00 2001 From: Victor Fawole Date: Wed, 20 May 2026 18:23:19 -0700 Subject: [PATCH 12/20] feat: add Drive export to dataset detail page download buttons Both the per-file and per-version download buttons show a dropdown with Download and Export to Drive options. Button stays highlighted while the menu is open. Toasts on successful connection and export. Co-Authored-By: Claude Sonnet 4.6 --- .../dataset-detail.component.html | 32 ++++++++-- .../dataset-detail.component.scss | 6 ++ .../dataset-detail.component.ts | 61 ++++++++++++++++++- 3 files changed, 93 insertions(+), 6 deletions(-) diff --git a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.html b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.html index 4fc5b9510e1..7df18c207a7 100644 --- a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.html +++ b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.html @@ -181,15 +181,26 @@

+ +
    +
  • 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.ts b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.ts index 85ca8d27cc0..95a3e04fb26 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,9 @@ export class DatasetDetailComponent implements OnInit { public currentUid: number | undefined; public viewCount: number = 0; public displayPreciseViewCount = false; + public isDriveConnected = false; + public fileExportMenuVisible = false; + public versionExportMenuVisible = false; userHasPendingChanges: boolean = false; pendingChangesCount: number = 0; @@ -184,7 +194,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 +262,20 @@ export class DatasetDetailComponent implements OnInit { }); this.loadUploadSettings(); + + this.driveService + .getToken() + .pipe(untilDestroyed(this)) + .subscribe(res => { + this.isDriveConnected = res.status === "ok"; + }); + this.driveService + .onConnected() + .pipe(untilDestroyed(this)) + .subscribe(() => { + this.isDriveConnected = true; + this.notificationService.success("Google Drive connected"); + }); } public onClickOpenVersionCreator() { @@ -277,6 +302,40 @@ export class DatasetDetailComponent implements OnInit { } } + public onClickDriveExportVersion(): void { + if (!this.isDriveConnected) { + this.driveService.connect(); + 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(); + 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 From 3c3fd56cac4f21ffc05773a121a48581a55f10f4 Mon Sep 17 00:00:00 2001 From: Victor Fawole Date: Thu, 21 May 2026 10:54:20 -0700 Subject: [PATCH 13/20] feat: add Drive export to workspace menu download button Co-Authored-By: Claude Sonnet 4.6 --- .../component/menu/menu.component.html | 15 +++++++-- .../component/menu/menu.component.scss | 6 ++++ .../component/menu/menu.component.ts | 33 ++++++++++++++++++- 3 files changed, 51 insertions(+), 3 deletions(-) diff --git a/frontend/src/app/workspace/component/menu/menu.component.html b/frontend/src/app/workspace/component/menu/menu.component.html index a21e4d56429..f570c5a377c 100644 --- a/frontend/src/app/workspace/component/menu/menu.component.html +++ b/frontend/src/app/workspace/component/menu/menu.component.html @@ -134,13 +134,24 @@ + +
    +
  • Download
  • +
  • + {{ isDriveConnected ? 'Export to Drive' : 'Connect to Drive' }} +
  • +
+
- -

Export to: {{ selectedFolder.name }}

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 ab7b01518ac..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 @@ -222,12 +222,20 @@ [(nzVisible)]="exportMenuVisible" nzOverlayClassName="export-dropdown-menu" [nzDropdownMenu]="exportMenu"> - +
    -
  • Download
  • -
  • +
  • + 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.html b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.html index 7df18c207a7..9291fe802eb 100644 --- a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.html +++ b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.html @@ -195,8 +195,14 @@

    -
  • Download
  • -
  • +
  • + Download +
  • +
  • {{ isDriveConnected ? 'Export to Drive' : 'Connect to Drive' }}
@@ -318,8 +324,14 @@
Choose a Version:
    -
  • Download
  • -
  • +
  • + Download +
  • +
  • {{ isDriveConnected ? 'Export to Drive' : 'Connect to 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 index f97e37f2e6c..fe8b3ca9cc8 100644 --- a/frontend/src/app/dashboard/service/user/google-drive/drive.service.ts +++ b/frontend/src/app/dashboard/service/user/google-drive/drive.service.ts @@ -104,7 +104,7 @@ export class DriveService { result$.next(); result$.complete(); }, - error: err => result$.error(err), + error: (err: unknown) => result$.error(err), }); }); } else if (data.action === google.picker.Action.CANCEL) { @@ -197,6 +197,6 @@ export class DriveService { private async getAccessToken(): Promise { const res = await firstValueFrom(this.getToken()); - return res.status === "ok" ? (res.accessToken ?? null) : null; + 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 f570c5a377c..7bc5ee55f74 100644 --- a/frontend/src/app/workspace/component/menu/menu.component.html +++ b/frontend/src/app/workspace/component/menu/menu.component.html @@ -146,8 +146,14 @@
    -
  • Download
  • -
  • +
  • + Download +
  • +
  • {{ isDriveConnected ? 'Export to Drive' : 'Connect to Drive' }}
diff --git a/frontend/src/app/workspace/component/menu/menu.component.spec.ts b/frontend/src/app/workspace/component/menu/menu.component.spec.ts index e230c3db666..96c0e257592 100644 --- a/frontend/src/app/workspace/component/menu/menu.component.spec.ts +++ b/frontend/src/app/workspace/component/menu/menu.component.spec.ts @@ -64,7 +64,12 @@ describe("MenuComponent", () => { let notificationService: NotificationService; let location: Location; let validationStream$: BehaviorSubject; - let driveServiceMock: { getToken: ReturnType; onConnected: ReturnType; connect: ReturnType; exportToDrive: ReturnType }; + let driveServiceMock: { + getToken: ReturnType; + onConnected: ReturnType; + connect: ReturnType; + exportToDrive: ReturnType; + }; let driveConnected$: Subject; beforeEach(async () => { From eec2822bc84cfaa0f4e9061f91f95268500e7b6a Mon Sep 17 00:00:00 2001 From: Victor Fawole Date: Thu, 21 May 2026 16:05:46 -0700 Subject: [PATCH 20/20] fix: annotate error callback as unknown in drive.service.spec.ts --- .../dashboard/service/user/google-drive/drive.service.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 2cec161feda..8a59084762d 100644 --- 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 @@ -152,7 +152,7 @@ describe("DriveService", () => { tick(); let errorMessage = ""; - result$.subscribe({ error: (e: Error) => (errorMessage = e.message) }); + result$.subscribe({ error: (e: unknown) => (errorMessage = (e as Error).message) }); tick(); expect(errorMessage).toBe("Not connected to Google Drive");