diff --git a/amber/src/main/scala/org/apache/texera/web/ServletAwareConfigurator.scala b/amber/src/main/scala/org/apache/texera/web/ServletAwareConfigurator.scala
index cb3628df5b3..dd5b8d89ef9 100644
--- a/amber/src/main/scala/org/apache/texera/web/ServletAwareConfigurator.scala
+++ b/amber/src/main/scala/org/apache/texera/web/ServletAwareConfigurator.scala
@@ -77,6 +77,7 @@ class ServletAwareConfigurator extends ServerEndpointConfig.Configurator with La
null,
null,
null,
+ null,
null
)
)
@@ -108,6 +109,7 @@ class ServletAwareConfigurator extends ServerEndpointConfig.Configurator with La
null,
null,
null,
+ null,
null
)
)
diff --git a/amber/src/main/scala/org/apache/texera/web/TexeraWebApplication.scala b/amber/src/main/scala/org/apache/texera/web/TexeraWebApplication.scala
index 98b7c68c974..09c3e91b270 100644
--- a/amber/src/main/scala/org/apache/texera/web/TexeraWebApplication.scala
+++ b/amber/src/main/scala/org/apache/texera/web/TexeraWebApplication.scala
@@ -33,7 +33,11 @@ import org.apache.texera.auth.SessionUser
import org.apache.texera.dao.SqlServer
import org.apache.texera.web.auth.JwtAuth.setupJwtAuth
import org.apache.texera.web.resource._
-import org.apache.texera.web.resource.auth.{AuthResource, GoogleAuthResource}
+import org.apache.texera.web.resource.auth.{
+ AuthResource,
+ GoogleAuthResource,
+ GoogleDriveAuthResource
+}
import org.apache.texera.web.resource.dashboard.DashboardResource
import org.apache.texera.web.resource.dashboard.admin.execution.AdminExecutionResource
import org.apache.texera.web.resource.dashboard.admin.settings.AdminSettingsResource
@@ -160,6 +164,7 @@ class TexeraWebApplication
environment.jersey.register(classOf[UserQuotaResource])
environment.jersey.register(classOf[AdminSettingsResource])
environment.jersey.register(classOf[AIAssistantResource])
+ environment.jersey.register(classOf[GoogleDriveAuthResource])
AuthResource.createAdminUser()
diff --git a/amber/src/main/scala/org/apache/texera/web/auth/GuestAuthFilter.scala b/amber/src/main/scala/org/apache/texera/web/auth/GuestAuthFilter.scala
index b7dda09489e..ced8824990f 100644
--- a/amber/src/main/scala/org/apache/texera/web/auth/GuestAuthFilter.scala
+++ b/amber/src/main/scala/org/apache/texera/web/auth/GuestAuthFilter.scala
@@ -39,7 +39,20 @@ import javax.ws.rs.core.SecurityContext
}
val GUEST: User =
- new User(null, "guest", null, null, null, null, UserRoleEnum.REGULAR, null, null, null, null)
+ new User(
+ null,
+ "guest",
+ null,
+ null,
+ null,
+ null,
+ UserRoleEnum.REGULAR,
+ null,
+ null,
+ null,
+ null,
+ null
+ )
}
@PreMatching
diff --git a/amber/src/main/scala/org/apache/texera/web/model/http/response/DriveTokenIssueResponse.scala b/amber/src/main/scala/org/apache/texera/web/model/http/response/DriveTokenIssueResponse.scala
new file mode 100644
index 00000000000..45d9498c2c8
--- /dev/null
+++ b/amber/src/main/scala/org/apache/texera/web/model/http/response/DriveTokenIssueResponse.scala
@@ -0,0 +1,25 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.texera.web.model.http.response
+
+case class DriveTokenIssueResponse(
+ status: String,
+ accessToken: Option[String]
+)
diff --git a/amber/src/main/scala/org/apache/texera/web/model/http/response/GoogleAuthConfigResponse.scala b/amber/src/main/scala/org/apache/texera/web/model/http/response/GoogleAuthConfigResponse.scala
new file mode 100644
index 00000000000..efc2584f374
--- /dev/null
+++ b/amber/src/main/scala/org/apache/texera/web/model/http/response/GoogleAuthConfigResponse.scala
@@ -0,0 +1,22 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.texera.web.model.http.response
+
+case class GoogleAuthConfigResponse(clientId: String, apiKey: String)
diff --git a/amber/src/main/scala/org/apache/texera/web/resource/auth/GoogleAuthResource.scala b/amber/src/main/scala/org/apache/texera/web/resource/auth/GoogleAuthResource.scala
index 2f99b9c1bd3..208a24dd1cc 100644
--- a/amber/src/main/scala/org/apache/texera/web/resource/auth/GoogleAuthResource.scala
+++ b/amber/src/main/scala/org/apache/texera/web/resource/auth/GoogleAuthResource.scala
@@ -29,6 +29,7 @@ import org.apache.texera.dao.jooq.generated.enums.UserRoleEnum
import org.apache.texera.dao.jooq.generated.tables.daos.UserDao
import org.apache.texera.dao.jooq.generated.tables.pojos.User
import org.apache.texera.web.model.http.response.TokenIssueResponse
+import org.apache.texera.web.model.http.response.GoogleAuthConfigResponse
import org.apache.texera.web.resource.auth.GoogleAuthResource.userDao
import java.util.Collections
@@ -48,11 +49,21 @@ object GoogleAuthResource {
@Path("/auth/google")
class GoogleAuthResource {
final private lazy val clientId = UserSystemConfig.googleClientId
+ final private lazy val apiKey = UserSystemConfig.googleApiKey
@GET
@Path("/clientid")
def getClientId: String = clientId
+ @GET
+ @Path("/config")
+ def getConfig: GoogleAuthConfigResponse = {
+ GoogleAuthConfigResponse(clientId, apiKey)
+ }
+
+ @Path("/drive")
+ def getDriveResource: GoogleDriveAuthResource = new GoogleDriveAuthResource()
+
@POST
@Consumes(Array(MediaType.TEXT_PLAIN))
@Produces(Array(MediaType.APPLICATION_JSON))
diff --git a/amber/src/main/scala/org/apache/texera/web/resource/auth/GoogleDriveAuthResource.scala b/amber/src/main/scala/org/apache/texera/web/resource/auth/GoogleDriveAuthResource.scala
new file mode 100644
index 00000000000..ee3e3f261a5
--- /dev/null
+++ b/amber/src/main/scala/org/apache/texera/web/resource/auth/GoogleDriveAuthResource.scala
@@ -0,0 +1,176 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.texera.web.resource.auth
+
+import io.dropwizard.auth.Auth
+import com.typesafe.scalalogging.LazyLogging
+import org.apache.texera.auth.{JwtParser, SessionUser}
+import org.apache.texera.web.model.http.response.DriveTokenIssueResponse
+import org.apache.texera.web.resource.auth.GoogleDriveAuthResource._
+import org.apache.texera.dao.jooq.generated.tables.daos.UserDao
+import org.apache.texera.dao.SqlServer
+import org.apache.texera.config.UserSystemConfig
+import org.apache.texera.auth.JwtAuth.{TOKEN_EXPIRE_TIME_IN_MINUTES, jwtClaims}
+import org.apache.texera.auth.JwtAuth
+import com.google.api.client.googleapis.auth.oauth2.{
+ GoogleAuthorizationCodeRequestUrl,
+ GoogleAuthorizationCodeTokenRequest,
+ GoogleRefreshTokenRequest,
+ GoogleTokenResponse
+}
+import com.google.api.client.auth.oauth2.TokenResponseException
+import com.google.api.client.http.javanet.NetHttpTransport
+import com.google.api.client.json.gson.GsonFactory
+
+import javax.annotation.security.RolesAllowed
+import javax.ws.rs._
+import javax.ws.rs.core.MediaType
+import javax.ws.rs.core.Response
+
+object GoogleDriveAuthResource {
+ // Status codes for token
+ private val STATUS_OK = "ok"
+ private val STATUS_NO_REFRESH_TOKEN = "no_refresh_token"
+ private val STATUS_INVALID_GRANT = "invalid_grant"
+
+ private def userDao =
+ new UserDao(
+ SqlServer
+ .getInstance()
+ .createDSLContext()
+ .configuration
+ )
+}
+
+@Consumes(Array(MediaType.APPLICATION_JSON))
+@Produces(Array(MediaType.APPLICATION_JSON))
+class GoogleDriveAuthResource extends LazyLogging {
+ final private lazy val clientId = UserSystemConfig.googleClientId
+ final private lazy val clientSecret = UserSystemConfig.googleClientSecret
+ final private lazy val redirectUri = UserSystemConfig.appDomain
+ .map(domain => s"https://$domain/api/auth/google/drive/callback")
+ .getOrElse("http://localhost:4200/api/auth/google/drive/callback")
+
+ @GET
+ @Path("/token")
+ @RolesAllowed(Array("REGULAR", "ADMIN"))
+ def getDriveAccessToken(@Auth sessionUser: SessionUser): Response = {
+ val user = userDao.fetchOneByUid(sessionUser.getUid)
+ val refreshToken = user.getGoogleDriveRefreshToken
+ if (refreshToken == null) {
+ return Response.ok(DriveTokenIssueResponse(STATUS_NO_REFRESH_TOKEN, None)).build()
+ }
+ try {
+ val tokenResponse = new GoogleRefreshTokenRequest(
+ new NetHttpTransport(),
+ GsonFactory.getDefaultInstance,
+ refreshToken,
+ clientId,
+ clientSecret
+ ).execute()
+ val accessToken = tokenResponse.getAccessToken
+ Response.ok(DriveTokenIssueResponse(STATUS_OK, Some(accessToken))).build()
+ } catch {
+ case e: TokenResponseException =>
+ if (e.getDetails != null && e.getDetails.getError == STATUS_INVALID_GRANT) {
+ Response.ok(DriveTokenIssueResponse(STATUS_INVALID_GRANT, None)).build()
+ } else {
+ logger.error("Failed to refresh access token", e)
+ Response.status(Response.Status.INTERNAL_SERVER_ERROR).build()
+ }
+ case e: Exception =>
+ logger.error("Unexpected error refreshing access token", e)
+ Response.status(Response.Status.INTERNAL_SERVER_ERROR).build()
+ }
+ }
+
+ @GET
+ @Path("/callback")
+ @Produces(Array(MediaType.TEXT_HTML, MediaType.APPLICATION_JSON))
+ def getCallback(
+ @QueryParam("code") @DefaultValue("") code: String,
+ @QueryParam("state") @DefaultValue("") state: String
+ ): Response = {
+ if (code.isEmpty || state.isEmpty) {
+ return Response.status(Response.Status.BAD_REQUEST).build()
+ }
+ try {
+ val sessionUserOpt = JwtParser.parseToken(state)
+ if (!sessionUserOpt.isPresent) {
+ return Response
+ .status(Response.Status.UNAUTHORIZED)
+ .entity("User is not authenticated")
+ .build()
+ }
+
+ val userId = sessionUserOpt.get().getUid
+ val user = userDao.fetchOneByUid(userId)
+
+ val response: GoogleTokenResponse = new GoogleAuthorizationCodeTokenRequest(
+ new NetHttpTransport(),
+ GsonFactory.getDefaultInstance,
+ clientId,
+ clientSecret,
+ code,
+ redirectUri
+ ).execute()
+
+ user.setGoogleDriveRefreshToken(response.getRefreshToken)
+ userDao.update(user)
+
+ val html =
+ """
""".stripMargin
+ Response.ok(html).build()
+ } catch {
+ case e: TokenResponseException =>
+ logger.error("Google token exchange failed in callback", e)
+ Response.status(Response.Status.BAD_GATEWAY).build()
+ case e: Exception =>
+ logger.error("Unexpected error in OAuth callback", e)
+ Response.status(Response.Status.INTERNAL_SERVER_ERROR).build()
+ }
+ }
+
+ @GET
+ @Path("/connect")
+ @RolesAllowed(Array("REGULAR", "ADMIN"))
+ def getOAuth(
+ @Auth sessionUser: SessionUser,
+ @QueryParam("reauth") @DefaultValue("false") reauth: Boolean
+ ): Response = {
+ val user = sessionUser.getUser
+ val state = JwtAuth.jwtToken(jwtClaims(user, TOKEN_EXPIRE_TIME_IN_MINUTES))
+
+ val url = new GoogleAuthorizationCodeRequestUrl(
+ clientId,
+ redirectUri,
+ java.util.Arrays.asList("https://www.googleapis.com/auth/drive")
+ )
+ .setState(state)
+ .setAccessType("offline")
+ .set("prompt", if (reauth) "consent" else null)
+ .set("include_granted_scopes", true)
+ .build()
+
+ Response.ok(url).build()
+ }
+}
diff --git a/common/auth/src/main/scala/org/apache/texera/auth/JwtParser.scala b/common/auth/src/main/scala/org/apache/texera/auth/JwtParser.scala
index bb139e7093a..607188d71ba 100644
--- a/common/auth/src/main/scala/org/apache/texera/auth/JwtParser.scala
+++ b/common/auth/src/main/scala/org/apache/texera/auth/JwtParser.scala
@@ -74,6 +74,7 @@ object JwtParser extends LazyLogging {
null,
null,
null,
+ null,
null
)
new SessionUser(user)
diff --git a/common/config/src/main/resources/user-system.conf b/common/config/src/main/resources/user-system.conf
index ffda7e2435a..41386e65ac3 100644
--- a/common/config/src/main/resources/user-system.conf
+++ b/common/config/src/main/resources/user-system.conf
@@ -26,6 +26,12 @@ user-sys {
google {
clientId = ""
clientId = ${?USER_SYS_GOOGLE_CLIENT_ID}
+
+ clientSecret = ""
+ clientSecret = ${?USER_SYS_GOOGLE_CLIENT_SECRET}
+
+ apiKey = ""
+ apiKey = ${?USER_SYS_GOOGLE_API_KEY}
smtp {
gmail = ""
diff --git a/common/config/src/main/scala/org/apache/texera/config/UserSystemConfig.scala b/common/config/src/main/scala/org/apache/texera/config/UserSystemConfig.scala
index b78eed02024..d263256a313 100644
--- a/common/config/src/main/scala/org/apache/texera/config/UserSystemConfig.scala
+++ b/common/config/src/main/scala/org/apache/texera/config/UserSystemConfig.scala
@@ -30,6 +30,8 @@ object UserSystemConfig {
val adminUsername: String = conf.getString("user-sys.admin-username")
val adminPassword: String = conf.getString("user-sys.admin-password")
val googleClientId: String = conf.getString("user-sys.google.clientId")
+ val googleClientSecret: String = conf.getString("user-sys.google.clientSecret")
+ val googleApiKey: String = conf.getString("user-sys.google.apiKey")
val gmail: String = conf.getString("user-sys.google.smtp.gmail")
val smtpPassword: String = conf.getString("user-sys.google.smtp.password")
val inviteOnly: Boolean = conf.getBoolean("user-sys.invite-only")
diff --git a/frontend/src/app/app-routing.constant.ts b/frontend/src/app/app-routing.constant.ts
index 4181df8a954..be22a595ebe 100644
--- a/frontend/src/app/app-routing.constant.ts
+++ b/frontend/src/app/app-routing.constant.ts
@@ -46,3 +46,5 @@ export const DASHBOARD_ADMIN_EXECUTION = `${DASHBOARD_ADMIN}/execution`;
export const DASHBOARD_ADMIN_SETTINGS = `${DASHBOARD_ADMIN}/settings`;
export const DASHBOARD_SEARCH = `${DASHBOARD}/search`;
+
+export const DASHBOARD_USER_GOOGLE_DRIVE = `${DASHBOARD_USER}/google-drive`;
diff --git a/frontend/src/app/app-routing.module.ts b/frontend/src/app/app-routing.module.ts
index 179caf5c088..959665a815b 100644
--- a/frontend/src/app/app-routing.module.ts
+++ b/frontend/src/app/app-routing.module.ts
@@ -39,6 +39,7 @@ import { UserDatasetComponent } from "./dashboard/component/user/user-dataset/us
import { HubWorkflowDetailComponent } from "./hub/component/workflow/detail/hub-workflow-detail.component";
import { LandingPageComponent } from "./hub/component/landing-page/landing-page.component";
import { DASHBOARD_ABOUT, DASHBOARD_USER_WORKFLOW } from "./app-routing.constant";
+import { GoogleDriveConnectComponent } from "./dashboard/component/user/google-drive-connect/google-drive-connect.component";
import { HubSearchResultComponent } from "./hub/component/hub-search-result/hub-search-result.component";
import { AdminSettingsComponent } from "./dashboard/component/admin/settings/admin-settings.component";
import { GuiConfigService } from "./common/service/gui-config.service";
@@ -143,6 +144,10 @@ routes.push({
path: "discussion",
component: FlarumComponent,
},
+ {
+ path: "google-drive",
+ component: GoogleDriveConnectComponent,
+ },
],
},
{
diff --git a/frontend/src/app/dashboard/component/user/google-drive-connect/google-drive-connect.component.spec.ts b/frontend/src/app/dashboard/component/user/google-drive-connect/google-drive-connect.component.spec.ts
new file mode 100644
index 00000000000..4071e7bfdaf
--- /dev/null
+++ b/frontend/src/app/dashboard/component/user/google-drive-connect/google-drive-connect.component.spec.ts
@@ -0,0 +1,115 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { ComponentFixture, TestBed } from "@angular/core/testing";
+import { GoogleDriveConnectComponent } from "./google-drive-connect.component";
+import { DriveService } from "../../../service/user/google-drive/drive.service";
+import { of, Subject, throwError } from "rxjs";
+import { NO_ERRORS_SCHEMA } from "@angular/core";
+import type { Mocked } from "vitest";
+
+describe("GoogleDriveConnectComponent", () => {
+ let component: GoogleDriveConnectComponent;
+ let fixture: ComponentFixture;
+ let driveServiceMock: Mocked;
+ let connected$: Subject;
+
+ beforeEach(async () => {
+ connected$ = new Subject();
+
+ driveServiceMock = {
+ getToken: vi.fn().mockReturnValue(of({ status: "ok", accessToken: "token123" })),
+ onConnected: vi.fn().mockReturnValue(connected$.asObservable()),
+ connect: vi.fn(),
+ exportToDrive: vi.fn(),
+ openFolderPicker: vi.fn(),
+ } as unknown as Mocked;
+
+ await TestBed.configureTestingModule({
+ imports: [GoogleDriveConnectComponent],
+ providers: [{ provide: DriveService, useValue: driveServiceMock }],
+ schemas: [NO_ERRORS_SCHEMA],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(GoogleDriveConnectComponent);
+ component = fixture.componentInstance;
+ });
+
+ describe("ngOnInit", () => {
+ it("sets status to connected when token is ok", () => {
+ driveServiceMock.getToken.mockReturnValue(of({ status: "ok", accessToken: "abc" }));
+ fixture.detectChanges();
+ expect(component.status).toBe("connected");
+ });
+
+ it("sets status from response when token is not ok", () => {
+ driveServiceMock.getToken.mockReturnValue(of({ status: "no_refresh_token" }));
+ fixture.detectChanges();
+ expect(component.status).toBe("no_refresh_token");
+ });
+
+ it("sets status to error when getToken fails", () => {
+ driveServiceMock.getToken.mockReturnValue(throwError(() => new Error("network error")));
+ fixture.detectChanges();
+ expect(component.status).toBe("error");
+ });
+
+ it("sets status to connected when onConnected emits", () => {
+ driveServiceMock.getToken.mockReturnValue(of({ status: "no_refresh_token" }));
+ fixture.detectChanges();
+ expect(component.status).toBe("no_refresh_token");
+
+ connected$.next();
+ expect(component.status).toBe("connected");
+ });
+ });
+
+ describe("connect", () => {
+ it("delegates to driveService.connect()", () => {
+ component.connect();
+ expect(driveServiceMock.connect).toHaveBeenCalledWith();
+ });
+ });
+
+ describe("reconnect", () => {
+ it("delegates to driveService.connect(true)", () => {
+ component.reconnect();
+ expect(driveServiceMock.connect).toHaveBeenCalledWith(true);
+ });
+ });
+
+ describe("openFolderPicker", () => {
+ it("sets selectedFolder when picker resolves", () => {
+ const folder = { id: "folder-1", name: "My Exports" };
+ driveServiceMock.openFolderPicker.mockReturnValue(of(folder));
+
+ component.openFolderPicker();
+
+ expect(component.selectedFolder).toEqual(folder);
+ });
+
+ it("does not set selectedFolder when picker is cancelled (completes without value)", () => {
+ driveServiceMock.openFolderPicker.mockReturnValue(of());
+
+ component.openFolderPicker();
+
+ expect(component.selectedFolder).toBeNull();
+ });
+ });
+});
diff --git a/frontend/src/app/dashboard/component/user/google-drive-connect/google-drive-connect.component.ts b/frontend/src/app/dashboard/component/user/google-drive-connect/google-drive-connect.component.ts
new file mode 100644
index 00000000000..244febf1c24
--- /dev/null
+++ b/frontend/src/app/dashboard/component/user/google-drive-connect/google-drive-connect.component.ts
@@ -0,0 +1,112 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { Component, OnInit } from "@angular/core";
+import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy";
+import { DriveService, DriveFolder } from "../../../service/user/google-drive/drive.service";
+import { NgIf } from "@angular/common";
+import { NzButtonComponent } from "ng-zorro-antd/button";
+import { NzIconDirective } from "ng-zorro-antd/icon";
+
+@UntilDestroy()
+@Component({
+ selector: "texera-google-drive-connect",
+ template: `
+