diff --git a/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/auth/GoogleAuthResource.scala b/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/auth/GoogleAuthResource.scala index 96d4a4e61da..f93a516ea09 100644 --- a/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/auth/GoogleAuthResource.scala +++ b/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/auth/GoogleAuthResource.scala @@ -37,8 +37,9 @@ class GoogleAuthResource { @GET @Path("/clientid") - def getClientId: String = { - clientId + @Produces(Array(MediaType.APPLICATION_JSON)) + def getClientId: Map[String, String] = { + Map("clientId" -> clientId) } @POST diff --git a/core/gui/package.json b/core/gui/package.json index 29a4bb0588e..6208258a3dd 100644 --- a/core/gui/package.json +++ b/core/gui/package.json @@ -21,6 +21,7 @@ }, "private": true, "dependencies": { + "@abacritt/angularx-social-login": "2.1.0", "@ali-hm/angular-tree-component": "12.0.5", "@angular/animations": "16.2.12", "@angular/cdk": "16.2.12", diff --git a/core/gui/src/app/app.module.ts b/core/gui/src/app/app.module.ts index c477a8af9c0..7a00a308a50 100644 --- a/core/gui/src/app/app.module.ts +++ b/core/gui/src/app/app.module.ts @@ -133,16 +133,35 @@ import { SearchBarComponent } from "./dashboard/component/user/search-bar/search import { ListItemComponent } from "./dashboard/component/user/list-item/list-item.component"; import { HubComponent } from "./hub/component/hub.component"; import { HubWorkflowSearchComponent } from "./hub/component/workflow/search/hub-workflow-search.component"; -import { GoogleLoginComponent } from "./dashboard/component/user/google-login/google-login.component"; import { HubWorkflowComponent } from "./hub/component/workflow/hub-workflow.component"; import { HubWorkflowDetailComponent } from "./hub/component/workflow/detail/hub-workflow-detail.component"; import { LandingPageComponent } from "./hub/component/landing-page/landing-page.component"; import { BrowseSectionComponent } from "./hub/component/browse-section/browse-section.component"; import { BreakpointConditionInputComponent } from "./workspace/component/code-editor-dialog/breakpoint-condition-input/breakpoint-condition-input.component"; import { CodeDebuggerComponent } from "./workspace/component/code-editor-dialog/code-debugger.component"; +import { GoogleAuthService } from "./common/service/user/google-auth.service"; +import { SocialLoginModule, SocialAuthServiceConfig, GoogleSigninButtonModule } from "@abacritt/angularx-social-login"; +import { GoogleLoginProvider } from "@abacritt/angularx-social-login"; +import { lastValueFrom } from "rxjs"; registerLocaleData(en); +const socialConfigFactory = (googleAuthService: GoogleAuthService) => { + return lastValueFrom(googleAuthService.getClientId()).then(response => ({ + autoLogin: false, + lang: "en", + providers: [ + { + id: GoogleLoginProvider.PROVIDER_ID, + provider: new GoogleLoginProvider(response?.clientId || ""), + }, + ], + onError: err => { + console.error(err); + }, + })) as Promise; +}; + @NgModule({ declarations: [ AdminGmailComponent, @@ -225,7 +244,6 @@ registerLocaleData(en); HubWorkflowComponent, HubWorkflowSearchComponent, HubWorkflowDetailComponent, - GoogleLoginComponent, LandingPageComponent, BrowseSectionComponent, BreakpointConditionInputComponent, @@ -289,6 +307,8 @@ registerLocaleData(en); NzTreeViewModule, NzNoAnimationModule, TreeModule, + SocialLoginModule, + GoogleSigninButtonModule, ], providers: [ provideNzI18n(en_US), @@ -303,6 +323,11 @@ registerLocaleData(en); useClass: BlobErrorHttpInterceptor, multi: true, }, + { + provide: "SocialAuthServiceConfig", + useFactory: socialConfigFactory, + deps: [GoogleAuthService], + }, ], bootstrap: [AppComponent], }) diff --git a/core/gui/src/app/common/service/user/auth.service.ts b/core/gui/src/app/common/service/user/auth.service.ts index 71699e3eadd..dc6430dd109 100644 --- a/core/gui/src/app/common/service/user/auth.service.ts +++ b/core/gui/src/app/common/service/user/auth.service.ts @@ -61,7 +61,13 @@ export class AuthService { public googleAuth(credential: string): Observable> { return this.http.post>( `${AppSettings.getApiEndpoint()}/${AuthService.GOOGLE_LOGIN_ENDPOINT}`, - `${credential}` + credential, + { + headers: { + "Content-Type": "text/plain", + Accept: "application/json", + }, + } ); } diff --git a/core/gui/src/app/common/service/user/google-auth.service.ts b/core/gui/src/app/common/service/user/google-auth.service.ts index 001d533b61c..f68a782915d 100644 --- a/core/gui/src/app/common/service/user/google-auth.service.ts +++ b/core/gui/src/app/common/service/user/google-auth.service.ts @@ -1,40 +1,15 @@ import { Injectable } from "@angular/core"; -import { Subject } from "rxjs"; +import { Observable } from "rxjs"; import { HttpClient } from "@angular/common/http"; import { AppSettings } from "../../app-setting"; -declare var window: any; -export interface CredentialResponse { - client_id: string; - credential: string; - select_by: string; -} + @Injectable({ providedIn: "root", }) export class GoogleAuthService { - private _googleCredentialResponse = new Subject(); constructor(private http: HttpClient) {} - public googleAuthInit(parent: HTMLElement | null) { - this.http.get(`${AppSettings.getApiEndpoint()}/auth/google/clientid`, { responseType: "text" }).subscribe({ - next: response => { - window.onGoogleLibraryLoad = () => { - window.google.accounts.id.initialize({ - client_id: response, - callback: (auth: CredentialResponse) => { - this._googleCredentialResponse.next(auth); - }, - }); - window.google.accounts.id.renderButton(parent, { width: 200 }); - window.google.accounts.id.prompt(); - }; - }, - error: (err: unknown) => { - console.error(err); - }, - }); - } - get googleCredentialResponse() { - return this._googleCredentialResponse.asObservable(); + getClientId(): Observable<{ clientId: string }> { + return this.http.get<{ clientId: string }>(`${AppSettings.getApiEndpoint()}/auth/google/clientid`); } } diff --git a/core/gui/src/app/dashboard/component/dashboard.component.html b/core/gui/src/app/dashboard/component/dashboard.component.html index 059146949be..10830089c5e 100644 --- a/core/gui/src/app/dashboard/component/dashboard.component.html +++ b/core/gui/src/app/dashboard/component/dashboard.component.html @@ -141,13 +141,17 @@
diff --git a/core/gui/src/app/dashboard/component/dashboard.component.scss b/core/gui/src/app/dashboard/component/dashboard.component.scss index c51701e4c64..22491879e99 100644 --- a/core/gui/src/app/dashboard/component/dashboard.component.scss +++ b/core/gui/src/app/dashboard/component/dashboard.component.scss @@ -70,3 +70,9 @@ nz-content { .hidden { display: none; } + +#nav { + max-width: 100%; + max-height: 100%; + overflow: hidden; +} diff --git a/core/gui/src/app/dashboard/component/dashboard.component.spec.ts b/core/gui/src/app/dashboard/component/dashboard.component.spec.ts new file mode 100644 index 00000000000..7318d330f2c --- /dev/null +++ b/core/gui/src/app/dashboard/component/dashboard.component.spec.ts @@ -0,0 +1,93 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { DashboardComponent } from "./dashboard.component"; +import { UserService } from "../../common/service/user/user.service"; +import { SocialAuthService } from "@abacritt/angularx-social-login"; +import { FlarumService } from "../service/user/flarum/flarum.service"; +import { Router, ActivatedRoute } from "@angular/router"; +import { RouterTestingModule } from "@angular/router/testing"; +import { HttpClientTestingModule } from "@angular/common/http/testing"; +import { of, BehaviorSubject } from "rxjs"; +import { CUSTOM_ELEMENTS_SCHEMA } from "@angular/core"; +import { DASHBOARD_USER_WORKFLOW } from "../../app-routing.constant"; + +describe("DashboardComponent", () => { + let component: DashboardComponent; + let fixture: ComponentFixture; + let userServiceMock: jasmine.SpyObj; + let socialAuthServiceMock: jasmine.SpyObj; + let flarumServiceMock: jasmine.SpyObj; + let routerMock: jasmine.SpyObj; + let activatedRouteMock: any; + let authStateMock: BehaviorSubject; + + beforeEach(async () => { + userServiceMock = jasmine.createSpyObj("UserService", ["isLogin", "isAdmin", "userChanged", "googleLogin"]); + userServiceMock.isLogin.and.returnValue(false); + userServiceMock.isAdmin.and.returnValue(false); + userServiceMock.userChanged.and.returnValue(of(undefined)); + userServiceMock.googleLogin.and.returnValue(of(undefined)); + + authStateMock = new BehaviorSubject(null); + socialAuthServiceMock = jasmine.createSpyObj("SocialAuthService", [], { + authState: authStateMock.asObservable(), + }); + + flarumServiceMock = jasmine.createSpyObj("FlarumService", ["auth", "register"]); + flarumServiceMock.auth.and.returnValue(of({ token: "fake_token" })); + flarumServiceMock.register.and.returnValue(of()); + + routerMock = jasmine.createSpyObj("Router", ["navigateByUrl"]); + + activatedRouteMock = { + snapshot: { + queryParams: {}, + }, + }; + + await TestBed.configureTestingModule({ + imports: [ + RouterTestingModule.withRoutes([]), // 确保 Router 正常工作 + HttpClientTestingModule, + ], + declarations: [DashboardComponent], + providers: [ + { provide: UserService, useValue: userServiceMock }, + { provide: SocialAuthService, useValue: socialAuthServiceMock }, + { provide: FlarumService, useValue: flarumServiceMock }, + { provide: Router, useValue: routerMock }, + { provide: ActivatedRoute, useValue: activatedRouteMock }, + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(DashboardComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should render Google login button when user is NOT logged in", () => { + const compiled = fixture.nativeElement; + const googleSignInButton = compiled.querySelector("asl-google-signin-button"); + + expect(googleSignInButton).toBeTruthy(); + }); + + it("should NOT render Google login button when user IS logged in", () => { + userServiceMock.isLogin.and.returnValue(true); + fixture.detectChanges(); + + const compiled = fixture.nativeElement; + const googleSignInButton = compiled.querySelector("asl-google-signin-button"); + + expect(googleSignInButton).toBeFalsy(); + }); + + it("should redirect to DASHBOARD_USER_WORKFLOW after Google login", () => { + authStateMock.next({ idToken: "test_token" } as any); + fixture.detectChanges(); + + expect(routerMock.navigateByUrl).toHaveBeenCalledWith(DASHBOARD_USER_WORKFLOW); + }); +}); diff --git a/core/gui/src/app/dashboard/component/dashboard.component.ts b/core/gui/src/app/dashboard/component/dashboard.component.ts index f825801c71e..4f52ce5a81d 100644 --- a/core/gui/src/app/dashboard/component/dashboard.component.ts +++ b/core/gui/src/app/dashboard/component/dashboard.component.ts @@ -3,8 +3,10 @@ import { UserService } from "../../common/service/user/user.service"; import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy"; import { FlarumService } from "../service/user/flarum/flarum.service"; import { HttpErrorResponse } from "@angular/common/http"; -import { NavigationEnd, Router } from "@angular/router"; +import { ActivatedRoute, NavigationEnd, Router } from "@angular/router"; import { HubComponent } from "../../hub/component/hub.component"; +import { SocialAuthService } from "@abacritt/angularx-social-login"; +import { filter, switchMap } from "rxjs/operators"; import { DASHBOARD_ADMIN_EXECUTION, @@ -47,13 +49,12 @@ export class DashboardComponent implements OnInit { private router: Router, private flarumService: FlarumService, private cdr: ChangeDetectorRef, - private ngZone: NgZone + private ngZone: NgZone, + private socialAuthService: SocialAuthService, + private route: ActivatedRoute ) {} ngOnInit(): void { - this.isLogin = this.userService.isLogin(); - this.isAdmin = this.userService.isAdmin(); - this.isCollpased = false; this.router.events.pipe(untilDestroyed(this)).subscribe(() => { @@ -78,6 +79,20 @@ export class DashboardComponent implements OnInit { this.cdr.detectChanges(); }); }); + + if (!this.isLogin) { + this.socialAuthService.authState + .pipe( + filter(res => !!res), + switchMap(res => this.userService.googleLogin(res.idToken)), + untilDestroyed(this) + ) + .subscribe(() => { + this.ngZone.run(() => { + this.router.navigateByUrl(this.route.snapshot.queryParams["returnUrl"] || DASHBOARD_USER_WORKFLOW); + }); + }); + } } forumLogin() { diff --git a/core/gui/src/app/dashboard/component/user/google-login/google-login.component.ts b/core/gui/src/app/dashboard/component/user/google-login/google-login.component.ts deleted file mode 100644 index f446a337d1a..00000000000 --- a/core/gui/src/app/dashboard/component/user/google-login/google-login.component.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { AfterViewInit, Component, ElementRef, ViewChild } from "@angular/core"; -import { UserService } from "../../../../common/service/user/user.service"; -import { mergeMap } from "rxjs/operators"; -import { GoogleAuthService } from "../../../../common/service/user/google-auth.service"; -import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy"; -import { DASHBOARD_USER_WORKFLOW } from "../../../../app-routing.constant"; -import { ActivatedRoute, Router } from "@angular/router"; - -@UntilDestroy() -@Component({ - selector: "texera-google-login", - template: "", -}) -export class GoogleLoginComponent implements AfterViewInit { - @ViewChild("googleButton") googleButton!: ElementRef; - constructor( - private userService: UserService, - private route: ActivatedRoute, - private googleAuthService: GoogleAuthService, - private router: Router, - private elementRef: ElementRef - ) {} - - ngAfterViewInit(): void { - this.googleAuthService.googleAuthInit(this.elementRef.nativeElement); - this.googleAuthService.googleCredentialResponse - .pipe( - mergeMap(res => this.userService.googleLogin(res.credential)), - untilDestroyed(this) - ) - .subscribe(() => { - this.router.navigateByUrl(this.route.snapshot.queryParams["returnUrl"] || DASHBOARD_USER_WORKFLOW); - }); - } -} diff --git a/core/gui/yarn.lock b/core/gui/yarn.lock index 5926766289c..8bbe7249723 100644 --- a/core/gui/yarn.lock +++ b/core/gui/yarn.lock @@ -5,6 +5,18 @@ __metadata: version: 8 cacheKey: 10c0 +"@abacritt/angularx-social-login@npm:2.1.0": + version: 2.1.0 + resolution: "@abacritt/angularx-social-login@npm:2.1.0" + dependencies: + tslib: "npm:>=2.5.0" + peerDependencies: + "@angular/common": ">=16.0.0" + "@angular/core": ">=16.0.0" + checksum: 10c0/b1759db0d79fdbc902180aa1974c5aa8db5372a7c4750127bbf5b1d94aadeb1f7bad27a0ac488e98cbe53a600a438a3900b4974009e4a648df5edf583cc5eb0d + languageName: node + linkType: hard + "@adobe/css-tools@npm:^4.0.1": version: 4.4.0 resolution: "@adobe/css-tools@npm:4.4.0" @@ -11066,6 +11078,7 @@ __metadata: version: 0.0.0-use.local resolution: "gui@workspace:." dependencies: + "@abacritt/angularx-social-login": "npm:2.1.0" "@ali-hm/angular-tree-component": "npm:12.0.5" "@angular-builders/custom-webpack": "npm:16.0.1" "@angular-devkit/build-angular": "npm:16.2.12" @@ -17971,6 +17984,13 @@ __metadata: languageName: node linkType: hard +"tslib@npm:>=2.5.0": + version: 2.8.1 + resolution: "tslib@npm:2.8.1" + checksum: 10c0/9c4759110a19c53f992d9aae23aac5ced636e99887b51b9e61def52611732872ff7668757d4e4c61f19691e36f4da981cd9485e869b4a7408d689f6bf1f14e62 + languageName: node + linkType: hard + "tslib@npm:^1.6.0, tslib@npm:^1.8.1": version: 1.14.1 resolution: "tslib@npm:1.14.1"