This is an automated email from the ASF dual-hosted git repository. github-merge-queue[bot] pushed a commit to branch gh-readonly-queue/main/pr-7339-a7f4386ba7ccb58155e44f79c8e10a25e60d2208 in repository https://gitbox.apache.org/repos/asf/texera.git
commit 18cdd72c3eb155ec6ffd12938272f40ae737019b Author: Neil Ketteringham <[email protected]> AuthorDate: Mon Aug 10 14:49:14 2026 -0700 feat(frontend): consolidate login onto a dedicated /login page (#7339) ### What changes were proposed in this PR? This PR replaces the two ad-hoc login surfaces with one dedicated login page. <img width="725" height="658" alt="image" src="https://github.com/user-attachments/assets/689a778a-e874-4b8b-a977-9d19863a8a4c" /> Before, a visitor could sign in from either the local form embedded as a column in the **About** page, or the Google button sitting in the **dashboard navbar** — the latter wired directly into `DashboardComponent`, which subscribed to `SocialAuthService.authState`, exchanged the id token, and navigated as a side concern of rendering the app chrome. `AuthGuardService` and the 401 interceptor both had to redirect to `/about` because there was nowhere better to send anyone. <img width="1225" height="722" alt="image" src="https://github.com/user-attachments/assets/16d877e2-f8e8-4e50-8312-7c9f30afeaa2" /> After, there is a single `/login` page and everything points at it. **New** - `TexeraLoginComponent` (`frontend/src/app/hub/component/login/`) — a centred full-page card with tabbed local **Sign In** / **Sign Up** plus a `social-buttons` block for the Google button. The tabs render only when `localLogin` is enabled and the Google button only when `googleLogin` is enabled, so a deployment with one provider disabled gets a coherent page rather than a dead one. Adding another provider means one more button in that block, not another login surface. The component owns the Google `authState` subscription, the id-token exchange, and post-login navigation; it filters `null` out of `authState` because that subject is a `ReplaySubject` and logout pushes a stale `null` that would otherwise replay into a fresh subscription. It also keeps the previous form's behaviour of prefilling `defaultLocalUser` credentials in local dev. - `GuestGuardService` — the mirror of `AuthGuardService`. It keeps an already-signed-in user off `/login`, sending them to their `returnUrl` when one survived the round trip and to their workflows otherwise. - The `login` route is registered at the top level of `app-routing.module.ts`, a sibling of the `DashboardComponent` shell, so it renders in the root outlet without the navbar and sidebar. **Changed** - `AuthGuardService` and `UnauthorizedHttpInterceptor` now navigate to `LOGIN` instead of `ABOUT`; the existing `returnUrl` handling is unchanged. - `DashboardComponent` drops `SocialAuthService`, `GoogleSigninButtonModule`, and the `authState` login flow. Logged-out visitors get a **Sign in** link to `/login` in the navbar slot the user icon occupies once signed in, styled to read as an ng-zorro primary button. - `AboutComponent` is now static marketing copy — with the login form gone it has no auth state left to track, so `OnInit`, `UserService`, and the `isLogin$` subject were removed. **Removed** - `hub/component/about/local-login/` (component, template, styles, spec) and its `app.module.ts` declaration. ### Any related issues, documentation, discussions? Closes #7340 Discussion #6717 The page layout and styling were designed in Figma first and transcribed into the implementation here. ### How was this PR tested? New and updated Angular unit specs, run with the frontend unit suite: - `texera-login.component.spec.ts` (new, 19 cases) — `defaultLocalUser` prefill and the empty-config case; mode switching clearing the error message; the confirm-password validator firing only in sign-up mode; sign-in validation short-circuits for a blank username and a short password; `UserService.login` called with a trimmed username; navigation to `USER_WORKFLOW` and to `returnUrl`; login failure surfacing a message without navigating, including the fallback when the error carries none; registration rejecting a malformed email and mismatched passwords, calling `UserService.register`, and notifying on success; the Google `authState` path handing the id token to `googleLogin` and navigating, ignoring a `null` state, and notifying without navigating when the exchange fails. - `guest-guard.service.spec.ts` (new, 3 cases) — a logged-out visitor is allowed onto `/login`; a signed-in user is redirected to their workflows; a `returnUrl` is honoured when present. - `auth-guard.service.spec.ts` and `unauthorized-http-interceptor.service.spec.ts` — updated to assert the redirect target is `LOGIN`. - `dashboard.component.spec.ts` — asserts the navbar renders a sign-in link rather than a provider button when logged out, and neither when logged in. - `about.component.spec.ts` — trimmed to the static component, with a case asserting it no longer embeds a login form. Every spec covering the files this PR touches passes. Not covered by unit tests, and worth exercising by hand on review: the real Google sign-in round trip against a configured client id, and the page's appearance with `localLogin` / `googleLogin` toggled independently. ### Was this PR authored or co-authored using generative AI tooling? Co-authored. The design was produced in Figma by a human author and transcribed into this implementation with assistance from Claude Opus 4.8. Generated-by: Claude Opus 4.8 --- frontend/src/app/app-routing.constant.ts | 1 + frontend/src/app/app-routing.module.ts | 9 + frontend/src/app/app.module.ts | 4 +- .../unauthorized-http-interceptor.service.spec.ts | 8 +- .../unauthorized-http-interceptor.service.ts | 4 +- .../common/service/user/auth-guard.service.spec.ts | 8 +- .../app/common/service/user/auth-guard.service.ts | 4 +- .../dashboard/component/dashboard.component.html | 14 +- .../dashboard/component/dashboard.component.scss | 6 + .../component/dashboard.component.spec.ts | 17 +- .../app/dashboard/component/dashboard.component.ts | 18 +- .../app/hub/component/about/about.component.html | 8 - .../hub/component/about/about.component.spec.ts | 56 +-- .../src/app/hub/component/about/about.component.ts | 34 +- .../about/local-login/local-login.component.html | 151 ------- .../about/local-login/local-login.component.scss | 33 -- .../local-login/local-login.component.spec.ts | 440 --------------------- .../about/local-login/local-login.component.ts | 186 --------- .../component/login/texera-login.component.html | 156 ++++++++ .../component/login/texera-login.component.scss | 151 +++++++ .../component/login/texera-login.component.spec.ts | 309 +++++++++++++++ .../hub/component/login/texera-login.component.ts | 238 +++++++++++ 22 files changed, 924 insertions(+), 931 deletions(-) diff --git a/frontend/src/app/app-routing.constant.ts b/frontend/src/app/app-routing.constant.ts index f5a0130039..53cc71d79c 100644 --- a/frontend/src/app/app-routing.constant.ts +++ b/frontend/src/app/app-routing.constant.ts @@ -19,6 +19,7 @@ export const HOME = "/home"; export const ABOUT = "/about"; +export const LOGIN = "/login"; export const HUB = "/hub"; export const HUB_WORKFLOW = `${HUB}/workflow`; diff --git a/frontend/src/app/app-routing.module.ts b/frontend/src/app/app-routing.module.ts index 58f9014300..e255fe1671 100644 --- a/frontend/src/app/app-routing.module.ts +++ b/frontend/src/app/app-routing.module.ts @@ -28,6 +28,7 @@ import { UserComputingUnitComponent } from "./dashboard/component/user/user-comp import { UserVenvComponent } from "./dashboard/component/user/user-venv/user-venv.component"; import { WorkspaceComponent } from "./workspace/component/workspace.component"; import { AboutComponent } from "./hub/component/about/about.component"; +import { TexeraLoginComponent } from "./hub/component/login/texera-login.component"; import { AuthGuardService } from "./common/service/user/auth-guard.service"; import { AdminUserComponent } from "./dashboard/component/admin/user/admin-user.component"; import { AdminExecutionComponent } from "./dashboard/component/admin/execution/admin-execution.component"; @@ -46,6 +47,14 @@ import { AdminSettingsComponent } from "./dashboard/component/admin/settings/adm const routes: Routes = []; +// Full-page login: a top-level route (sibling of the DashboardComponent shell) so it renders +// in the root outlet without the navbar/sidebar chrome. The component itself redirects an +// already-signed-in visitor away in ngOnInit. +routes.push({ + path: "login", + component: TexeraLoginComponent, +}); + routes.push({ path: "", component: DashboardComponent, diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index fef2fd5aa9..19c94f3deb 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -117,9 +117,9 @@ import { CollabWrapperComponent } from "./common/formly/collab-wrapper/collab-wr import { NzSwitchModule } from "ng-zorro-antd/switch"; import { NzRadioModule } from "ng-zorro-antd/radio"; import { AboutComponent } from "./hub/component/about/about.component"; +import { TexeraLoginComponent } from "./hub/component/login/texera-login.component"; import { NzLayoutModule } from "ng-zorro-antd/layout"; import { AuthGuardService } from "./common/service/user/auth-guard.service"; -import { LocalLoginComponent } from "./hub/component/about/local-login/local-login.component"; import { MarkdownModule } from "ngx-markdown"; import { FileSaverService } from "./dashboard/service/user/file/file-saver.service"; import { DragDropModule } from "@angular/cdk/drag-drop"; @@ -291,7 +291,6 @@ registerLocaleData(en); AdminExecutionComponent, UserIconComponent, UserAvatarComponent, - LocalLoginComponent, UserWorkflowComponent, UserQuotaComponent, RowModalComponent, @@ -343,6 +342,7 @@ registerLocaleData(en); ReActStepDetailModalComponent, CollabWrapperComponent, AboutComponent, + TexeraLoginComponent, UserWorkflowListItemComponent, UserProjectListItemComponent, SortButtonComponent, diff --git a/frontend/src/app/common/service/unauthorized-http-interceptor.service.spec.ts b/frontend/src/app/common/service/unauthorized-http-interceptor.service.spec.ts index 1b11d9128b..82e28194d7 100644 --- a/frontend/src/app/common/service/unauthorized-http-interceptor.service.spec.ts +++ b/frontend/src/app/common/service/unauthorized-http-interceptor.service.spec.ts @@ -21,7 +21,7 @@ import { HTTP_INTERCEPTORS, HttpClient } from "@angular/common/http"; import { HttpClientTestingModule, HttpTestingController } from "@angular/common/http/testing"; import { TestBed } from "@angular/core/testing"; import { Router } from "@angular/router"; -import { ABOUT } from "../../app-routing.constant"; +import { LOGIN } from "../../app-routing.constant"; import { NotificationService } from "./notification/notification.service"; import { UserService } from "./user/user.service"; import { UnauthorizedHttpInterceptor } from "./unauthorized-http-interceptor.service"; @@ -60,7 +60,7 @@ describe("UnauthorizedHttpInterceptor", () => { return http.get(url, { headers: { Authorization: "Bearer stale-token" } }); } - it("logs out, notifies, and redirects to ABOUT on 401 for an authenticated request", () => { + it("logs out, notifies, and redirects to LOGIN on 401 for an authenticated request", () => { // The decision to log out hinges on whether *this* request was authenticated. // A 401 from an anonymous request is the server saying "you need to log in", // not "your session is invalid" — clearing the session there would wipe a @@ -71,7 +71,7 @@ describe("UnauthorizedHttpInterceptor", () => { expect(userServiceSpy.logout).toHaveBeenCalledTimes(1); expect(notificationSpy.error).toHaveBeenCalledTimes(1); expect(notificationSpy.error.mock.calls[0][0]).toMatch(/session.*expired|log in/i); - expect(routerSpy.navigate).toHaveBeenCalledWith([ABOUT], { + expect(routerSpy.navigate).toHaveBeenCalledWith([LOGIN], { queryParams: { returnUrl: "/user/workflow/42" }, }); }); @@ -102,7 +102,7 @@ describe("UnauthorizedHttpInterceptor", () => { authedGet("/api/secret").subscribe({ error: () => {} }); httpMock.expectOne("/api/secret").flush(null, { status: 401, statusText: "Unauthorized" }); - expect(routerSpy.navigate).toHaveBeenCalledWith([ABOUT], { queryParams: { returnUrl: null } }); + expect(routerSpy.navigate).toHaveBeenCalledWith([LOGIN], { queryParams: { returnUrl: null } }); }); // Adversarial-review fix #1: a stale token gets auto-attached to /auth/login diff --git a/frontend/src/app/common/service/unauthorized-http-interceptor.service.ts b/frontend/src/app/common/service/unauthorized-http-interceptor.service.ts index 2ff0cbc5db..1ce76a0fcc 100644 --- a/frontend/src/app/common/service/unauthorized-http-interceptor.service.ts +++ b/frontend/src/app/common/service/unauthorized-http-interceptor.service.ts @@ -22,7 +22,7 @@ import { Injectable, Injector } from "@angular/core"; import { Router } from "@angular/router"; import { Observable, throwError } from "rxjs"; import { catchError } from "rxjs/operators"; -import { ABOUT } from "../../app-routing.constant"; +import { LOGIN } from "../../app-routing.constant"; import { NotificationService } from "./notification/notification.service"; import { UserService } from "./user/user.service"; @@ -66,7 +66,7 @@ export class UnauthorizedHttpInterceptor implements HttpInterceptor { userService.logout(); this.notificationService.error("Your session has expired. Please log in again."); const currentUrl = this.router.url; - this.router.navigate([ABOUT], { + this.router.navigate([LOGIN], { queryParams: { returnUrl: currentUrl === "/" ? null : currentUrl }, }); } diff --git a/frontend/src/app/common/service/user/auth-guard.service.spec.ts b/frontend/src/app/common/service/user/auth-guard.service.spec.ts index 482d8db4ac..d85c30c437 100644 --- a/frontend/src/app/common/service/user/auth-guard.service.spec.ts +++ b/frontend/src/app/common/service/user/auth-guard.service.spec.ts @@ -23,7 +23,7 @@ import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from "@angular/ro import { AuthGuardService } from "./auth-guard.service"; import { UserService } from "./user.service"; import { MOCK_USER, StubUserService } from "./stub-user.service"; -import { ABOUT } from "../../../app-routing.constant"; +import { LOGIN } from "../../../app-routing.constant"; import { commonTestProviders } from "../../testing/test-utils"; describe("AuthGuardService", () => { @@ -54,16 +54,16 @@ describe("AuthGuardService", () => { expect(routerSpy.navigate).not.toHaveBeenCalled(); }); - it("blocks navigation and redirects to ABOUT with a null returnUrl from the root url", () => { + it("blocks navigation and redirects to LOGIN with a null returnUrl from the root url", () => { userService.user = undefined; expect(guard.canActivate(route, stateAt("/"))).toBe(false); - expect(routerSpy.navigate).toHaveBeenCalledWith([ABOUT], { queryParams: { returnUrl: null } }); + expect(routerSpy.navigate).toHaveBeenCalledWith([LOGIN], { queryParams: { returnUrl: null } }); }); it("blocks navigation and preserves the return url for a deep link", () => { userService.user = undefined; expect(guard.canActivate(route, stateAt("/dashboard/user/workflow/42"))).toBe(false); - expect(routerSpy.navigate).toHaveBeenCalledWith([ABOUT], { + expect(routerSpy.navigate).toHaveBeenCalledWith([LOGIN], { queryParams: { returnUrl: "/dashboard/user/workflow/42" }, }); }); diff --git a/frontend/src/app/common/service/user/auth-guard.service.ts b/frontend/src/app/common/service/user/auth-guard.service.ts index 1a69ad0289..b2ac79e6dc 100644 --- a/frontend/src/app/common/service/user/auth-guard.service.ts +++ b/frontend/src/app/common/service/user/auth-guard.service.ts @@ -21,7 +21,7 @@ import { Injectable } from "@angular/core"; import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot } from "@angular/router"; import { GuiConfigService } from "../gui-config.service"; import { UserService } from "./user.service"; -import { ABOUT } from "../../../app-routing.constant"; +import { LOGIN } from "../../../app-routing.constant"; /** * AuthGuardService is a service can tell the router whether @@ -38,7 +38,7 @@ export class AuthGuardService implements CanActivate { if (this.userService.isLogin()) { return true; } else { - this.router.navigate([ABOUT], { queryParams: { returnUrl: state.url === "/" ? null : state.url } }); + this.router.navigate([LOGIN], { queryParams: { returnUrl: state.url === "/" ? null : state.url } }); return false; } } diff --git a/frontend/src/app/dashboard/component/dashboard.component.html b/frontend/src/app/dashboard/component/dashboard.component.html index 6f074d692e..ca30e6df04 100644 --- a/frontend/src/app/dashboard/component/dashboard.component.html +++ b/frontend/src/app/dashboard/component/dashboard.component.html @@ -249,11 +249,15 @@ <ng-container *ngIf="isLogin"> <texera-user-icon></texera-user-icon> </ng-container> - <asl-google-signin-button - *ngIf="!isLogin && this.config.env.googleLogin" - type="standard" - size="large" - [width]="200"></asl-google-signin-button> + <a + *ngIf="!isLogin" + nz-button + nzType="primary" + nzSize="large" + class="nav-login-link" + [routerLink]="LOGIN"> + Sign in + </a> </div> <nz-content> diff --git a/frontend/src/app/dashboard/component/dashboard.component.scss b/frontend/src/app/dashboard/component/dashboard.component.scss index 50bee303af..c6f3c78f0a 100644 --- a/frontend/src/app/dashboard/component/dashboard.component.scss +++ b/frontend/src/app/dashboard/component/dashboard.component.scss @@ -40,6 +40,12 @@ texera-user-icon { padding: 0 24px; } +.nav-login-link { + margin: 0 8px; + padding: 0 36px; + border-radius: 4px; +} + button { border: none; } diff --git a/frontend/src/app/dashboard/component/dashboard.component.spec.ts b/frontend/src/app/dashboard/component/dashboard.component.spec.ts index 19b6a298ad..c6190c7caf 100644 --- a/frontend/src/app/dashboard/component/dashboard.component.spec.ts +++ b/frontend/src/app/dashboard/component/dashboard.component.spec.ts @@ -163,12 +163,23 @@ describe("DashboardComponent", () => { expect(component).toBeTruthy(); }); - it("should render Google sign-in button when user is NOT logged in", () => { + // Sign-in moved to the dedicated /login page, so the shell offers a link to it rather than + // rendering a provider button of its own. + it("should render a sign-in link, not a provider button, when the user is NOT logged in", () => { (userServiceMock.isLogin as Mock).mockReturnValue(false); + component.isLogin = false; fixture.detectChanges(); - const googleSignInBtn = fixture.debugElement.query(By.css("asl-google-signin-button")); - expect(googleSignInBtn).toBeTruthy(); + expect(fixture.debugElement.query(By.css("asl-google-signin-button"))).toBeNull(); + expect(fixture.debugElement.query(By.css(".nav-login-link"))).toBeTruthy(); + }); + + it("should not render the sign-in link when the user IS logged in", () => { + (userServiceMock.isLogin as Mock).mockReturnValue(true); + component.isLogin = true; + fixture.detectChanges(); + + expect(fixture.debugElement.query(By.css(".nav-login-link"))).toBeNull(); }); it("should render the powered-by attribution when attributionEnabled is true", () => { diff --git a/frontend/src/app/dashboard/component/dashboard.component.ts b/frontend/src/app/dashboard/component/dashboard.component.ts index 598959edd8..7da993dace 100644 --- a/frontend/src/app/dashboard/component/dashboard.component.ts +++ b/frontend/src/app/dashboard/component/dashboard.component.ts @@ -24,7 +24,6 @@ import { FlarumService } from "../service/user/flarum/flarum.service"; import { HttpErrorResponse } from "@angular/common/http"; import { ActivatedRoute, NavigationEnd, Router, RouterLink, RouterOutlet } from "@angular/router"; import { HubComponent } from "../../hub/component/hub.component"; -import { SocialAuthService, GoogleSigninButtonModule } from "@abacritt/angularx-social-login"; import { AdminSettingsService } from "../service/admin/settings/admin-settings.service"; import { GuiConfigService } from "../../common/service/gui-config.service"; @@ -42,6 +41,7 @@ import { USER_QUOTA, USER_WORKFLOW, USER_FEEDBACK, + LOGIN, } from "../../app-routing.constant"; import { Version } from "../../../environments/version"; import { SidebarTabs } from "../../common/type/gui-config"; @@ -53,6 +53,7 @@ import { NgIf } from "@angular/common"; import { ɵNzTransitionPatchDirective } from "ng-zorro-antd/core/transition-patch"; import { NzTooltipDirective } from "ng-zorro-antd/tooltip"; import { NzIconDirective } from "ng-zorro-antd/icon"; +import { NzButtonComponent } from "ng-zorro-antd/button"; import { SearchBarComponent } from "./user/search-bar/search-bar.component"; import { UserIconComponent } from "./user/user-icon/user-icon.component"; @@ -72,9 +73,9 @@ import { UserIconComponent } from "./user/user-icon/user-icon.component"; NzTooltipDirective, RouterLink, NzIconDirective, + NzButtonComponent, SearchBarComponent, UserIconComponent, - GoogleSigninButtonModule, NzContentComponent, RouterOutlet, ], @@ -111,6 +112,7 @@ export class DashboardComponent implements OnInit { about_enabled: false, }; + protected readonly LOGIN = LOGIN; protected readonly USER_PROJECT = USER_PROJECT; protected readonly USER_WORKFLOW = USER_WORKFLOW; protected readonly USER_DATASET = USER_DATASET; @@ -131,7 +133,6 @@ export class DashboardComponent implements OnInit { private router: Router, private flarumService: FlarumService, private ngZone: NgZone, - private socialAuthService: SocialAuthService, private route: ActivatedRoute, private adminSettingsService: AdminSettingsService, protected config: GuiConfigService @@ -162,17 +163,6 @@ export class DashboardComponent implements OnInit { }); }); - this.socialAuthService.authState.pipe(untilDestroyed(this)).subscribe(user => { - this.userService - .googleLogin(user.idToken) - .pipe(untilDestroyed(this)) - .subscribe(() => { - this.ngZone.run(() => { - this.router.navigateByUrl(this.route.snapshot.queryParams["returnUrl"] || USER_WORKFLOW); - }); - }); - }); - this.loadLogos(); this.loadTabs(); diff --git a/frontend/src/app/hub/component/about/about.component.html b/frontend/src/app/hub/component/about/about.component.html index 44e26111bc..4a0d974896 100644 --- a/frontend/src/app/hub/component/about/about.component.html +++ b/frontend/src/app/hub/component/about/about.component.html @@ -49,14 +49,6 @@ <li>Runtime debugging and interactive workflow execution</li> </ul> </div> - <ng-container *ngIf="(isLogin$ | async) === false"> - <div - *ngIf="this.config.env.localLogin" - nz-col - nzFlex="350px"> - <texera-local-login class="login-container"></texera-local-login> - </div> - </ng-container> </div> <img diff --git a/frontend/src/app/hub/component/about/about.component.spec.ts b/frontend/src/app/hub/component/about/about.component.spec.ts index 15acfb5da1..0babee25f8 100644 --- a/frontend/src/app/hub/component/about/about.component.spec.ts +++ b/frontend/src/app/hub/component/about/about.component.spec.ts @@ -19,72 +19,30 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; import { RouterTestingModule } from "@angular/router/testing"; -import { NzIconModule } from "ng-zorro-antd/icon"; -import { UserOutline, LockOutline } from "@ant-design/icons-angular/icons"; -import { vi } from "vitest"; import { AboutComponent } from "./about.component"; -import { UserService } from "../../../common/service/user/user.service"; -import { StubUserService } from "../../../common/service/user/stub-user.service"; -import { GuiConfigService } from "../../../common/service/gui-config.service"; -import { MockGuiConfigService } from "../../../common/service/gui-config.service.mock"; -import { NotificationService } from "../../../common/service/notification/notification.service"; import { commonTestProviders } from "../../../common/testing/test-utils"; describe("AboutComponent", () => { let component: AboutComponent; let fixture: ComponentFixture<AboutComponent>; - let userService: StubUserService; - let configService: MockGuiConfigService; - - function build() { - fixture = TestBed.createComponent(AboutComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - } beforeEach(() => { - const notificationSpy = { info: vi.fn(), success: vi.fn(), error: vi.fn() }; TestBed.configureTestingModule({ - imports: [ - AboutComponent, - RouterTestingModule.withRoutes([]), - // Register the icons used by <texera-local-login>'s nzPrefixIcon - // bindings. jsdom can't fetch icon SVGs over HTTP, so without this - // the icon registry emits unhandled errors that fail the run in CI. - NzIconModule.forChild([UserOutline, LockOutline]), - ], - providers: [ - { provide: UserService, useClass: StubUserService }, - { provide: NotificationService, useValue: notificationSpy }, - ...commonTestProviders, - ], + imports: [AboutComponent, RouterTestingModule.withRoutes([])], + providers: [...commonTestProviders], }); - userService = TestBed.inject(UserService) as unknown as StubUserService; - configService = TestBed.inject(GuiConfigService) as unknown as MockGuiConfigService; + fixture = TestBed.createComponent(AboutComponent); + component = fixture.componentInstance; + fixture.detectChanges(); }); it("should create", () => { - build(); expect(component).toBeTruthy(); }); - it("hides the local login form when the user is already logged in", () => { - // StubUserService starts with MOCK_USER, so isLogin() === true. - build(); - expect(fixture.nativeElement.querySelector("texera-local-login")).toBeNull(); - }); - - it("shows the local login form when logged out and localLogin is enabled", () => { - userService.user = undefined; - build(); - expect(fixture.nativeElement.querySelector("texera-local-login")).not.toBeNull(); - }); - - it("hides the local login form when localLogin is disabled in config", () => { - userService.user = undefined; - configService.setConfig({ localLogin: false }); - build(); + // The login form moved to the dedicated /login page; this page must not render one. + it("no longer embeds a login form", () => { expect(fixture.nativeElement.querySelector("texera-local-login")).toBeNull(); }); }); diff --git a/frontend/src/app/hub/component/about/about.component.ts b/frontend/src/app/hub/component/about/about.component.ts index 7995403540..4efa1d72d5 100644 --- a/frontend/src/app/hub/component/about/about.component.ts +++ b/frontend/src/app/hub/component/about/about.component.ts @@ -17,38 +17,16 @@ * under the License. */ -import { Component, OnInit } from "@angular/core"; -import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy"; -import { UserService } from "src/app/common/service/user/user.service"; -import { BehaviorSubject } from "rxjs"; -import { GuiConfigService } from "../../../common/service/gui-config.service"; +import { Component } from "@angular/core"; import { NzRowDirective, NzColDirective } from "ng-zorro-antd/grid"; -import { NgIf, AsyncPipe } from "@angular/common"; -import { LocalLoginComponent } from "./local-login/local-login.component"; -@UntilDestroy() +/** + * Static marketing copy for the platform. + */ @Component({ selector: "texera-about", templateUrl: "./about.component.html", styleUrls: ["./about.component.scss"], - imports: [NzRowDirective, NzColDirective, NgIf, LocalLoginComponent, AsyncPipe], + imports: [NzRowDirective, NzColDirective], }) -export class AboutComponent implements OnInit { - isLogin$ = new BehaviorSubject<boolean>(false); // control the visibility of the local login component - - constructor( - private userService: UserService, - protected config: GuiConfigService - ) {} - - ngOnInit() { - this.isLogin$.next(this.userService.isLogin()); - // Subscribe to user changes - this.userService - .userChanged() - .pipe(untilDestroyed(this)) - .subscribe(user => { - this.isLogin$.next(user !== undefined); - }); - } -} +export class AboutComponent {} diff --git a/frontend/src/app/hub/component/about/local-login/local-login.component.html b/frontend/src/app/hub/component/about/local-login/local-login.component.html deleted file mode 100644 index 4234ab8520..0000000000 --- a/frontend/src/app/hub/component/about/local-login/local-login.component.html +++ /dev/null @@ -1,151 +0,0 @@ -<!-- - 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. ---> - -<div class="form"> - <nz-tabs - nzCentered - nzSize="large"> - <!-- template for validation error messages (shared by sign in and sign out validation) --> - <ng-template - #passwordErrorTip - let-control> - <ng-container *ngIf="control.hasError('required')">Please input your password. </ng-container> - <ng-container *ngIf="control.hasError('minlength')">Minimal password length is 6. </ng-container> - </ng-template> - <ng-template - #confirmErrorTip - let-control> - <ng-container *ngIf="control.hasError('required')">Please confirm your password. </ng-container> - <ng-container *ngIf="control.hasError('confirm')">Two passwords are inconsistent. </ng-container> - </ng-template> - <ng-template - #emailErrorTip - let-control> - <ng-container *ngIf="control.hasError('required')">Please input your email. </ng-container> - <ng-container *ngIf="control.hasError('email')">Please input a valid email. </ng-container> - </ng-template> - - <nz-tab nzTitle="Sign In"> - <form - nz-form - [formGroup]="allForms" - class="login-form" - (ngSubmit)="login()"> - <nz-form-item> - <nz-form-control nzErrorTip="Please input your username"> - <nz-input-group nzPrefixIcon="user"> - <input - type="text" - nz-input - formControlName="loginUsername" - placeholder="Username" /> - </nz-input-group> - </nz-form-control> - </nz-form-item> - <nz-form-item> - <nz-form-control [nzErrorTip]="passwordErrorTip"> - <nz-input-group nzPrefixIcon="lock"> - <input - type="password" - nz-input - formControlName="loginPassword" - placeholder="Password" /> - </nz-input-group> - </nz-form-control> - </nz-form-item> - <button - nz-button - class="login-form-button login-form-margin" - [nzType]="'primary'"> - Sign in - </button> - <p - *ngIf="loginErrorMessage" - style="color: red"> - {{ loginErrorMessage }} - </p> - </form> - </nz-tab> - - <nz-tab nzTitle="Sign Up"> - <form - nz-form - [formGroup]="allForms" - class="login-form" - (ngSubmit)="register()"> - <nz-form-item> - <nz-form-control nzErrorTip="Please input your username"> - <nz-input-group nzPrefixIcon="user"> - <input - type="text" - nz-input - formControlName="registerUsername" - placeholder="Username" /> - </nz-input-group> - </nz-form-control> - </nz-form-item> - <nz-form-item> - <nz-form-control [nzErrorTip]="emailErrorTip"> - <nz-input-group nzPrefixIcon="mail"> - <input - type="email" - nz-input - formControlName="registerEmail" - placeholder="Email" /> - </nz-input-group> - </nz-form-control> - </nz-form-item> - <nz-form-item> - <nz-form-control [nzErrorTip]="passwordErrorTip"> - <nz-input-group nzPrefixIcon="lock"> - <input - type="password" - nz-input - formControlName="registerPassword" - placeholder="Password" - (ngModelChange)="updateConfirmValidator()" /> - </nz-input-group> - </nz-form-control> - </nz-form-item> - <nz-form-item> - <nz-form-control [nzErrorTip]="confirmErrorTip"> - <nz-input-group nzPrefixIcon="lock"> - <input - type="password" - nz-input - formControlName="registerConfirmationPassword" - placeholder="Confirm password" /> - </nz-input-group> - </nz-form-control> - </nz-form-item> - <button - nz-button - class="login-form-button login-form-margin" - [nzType]="'primary'"> - Sign up - </button> - <p - *ngIf="registerErrorMessage" - style="color: red"> - {{ registerErrorMessage }} - </p> - </form> - </nz-tab> - </nz-tabs> -</div> diff --git a/frontend/src/app/hub/component/about/local-login/local-login.component.scss b/frontend/src/app/hub/component/about/local-login/local-login.component.scss deleted file mode 100644 index 74aacfba18..0000000000 --- a/frontend/src/app/hub/component/about/local-login/local-login.component.scss +++ /dev/null @@ -1,33 +0,0 @@ -/** - * 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. - */ - -.login-form-button { - width: 100%; -} - -.form { - border: 2px solid black; - border-radius: 5px; - margin: 16px; - padding: 20px; - - ::ng-deep nz-form-item { - margin-bottom: 12px; - } -} diff --git a/frontend/src/app/hub/component/about/local-login/local-login.component.spec.ts b/frontend/src/app/hub/component/about/local-login/local-login.component.spec.ts deleted file mode 100644 index bc39ac7fd1..0000000000 --- a/frontend/src/app/hub/component/about/local-login/local-login.component.spec.ts +++ /dev/null @@ -1,440 +0,0 @@ -/** - * 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 { FormControl } from "@angular/forms"; -import { ActivatedRoute, ActivatedRouteSnapshot, Router } from "@angular/router"; -import { HttpClientTestingModule } from "@angular/common/http/testing"; -import { of, throwError } from "rxjs"; - -import { LocalLoginComponent } from "./local-login.component"; -import { UserService } from "../../../../common/service/user/user.service"; -import { NotificationService } from "../../../../common/service/notification/notification.service"; -import { GuiConfigService } from "../../../../common/service/gui-config.service"; -import { MockGuiConfigService } from "../../../../common/service/gui-config.service.mock"; -import { commonTestProviders } from "../../../../common/testing/test-utils"; -import { USER_WORKFLOW } from "../../../../app-routing.constant"; - -describe("LocalLoginComponent", () => { - let component: LocalLoginComponent; - let fixture: ComponentFixture<LocalLoginComponent>; - - let userServiceMock: Partial<UserService>; - let notificationServiceMock: Partial<NotificationService>; - let routerMock: Partial<Router>; - let activatedRouteMock: { snapshot: Partial<ActivatedRouteSnapshot> }; - - const createComponent = async (queryParams: Record<string, any> = {}) => { - TestBed.resetTestingModule(); - userServiceMock = { - login: vi.fn().mockReturnValue(of(undefined)), - register: vi.fn().mockReturnValue(of(undefined)), - }; - notificationServiceMock = { - error: vi.fn(), - success: vi.fn(), - }; - routerMock = { - navigateByUrl: vi.fn(), - }; - activatedRouteMock = { - snapshot: { queryParams } as Partial<ActivatedRouteSnapshot>, - }; - - await TestBed.configureTestingModule({ - imports: [LocalLoginComponent, HttpClientTestingModule], - providers: [ - { provide: UserService, useValue: userServiceMock }, - { provide: NotificationService, useValue: notificationServiceMock }, - { provide: Router, useValue: routerMock }, - { provide: ActivatedRoute, useValue: activatedRouteMock }, - ...commonTestProviders, - ], - }).compileComponents(); - - fixture = TestBed.createComponent(LocalLoginComponent); - component = fixture.componentInstance; - }; - - beforeEach(async () => { - await createComponent(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it("should create the component", () => { - fixture.detectChanges(); - expect(component).toBeTruthy(); - }); - - describe("form construction", () => { - it("builds allForms with the expected controls", () => { - const controls = component.allForms.controls; - expect(Object.keys(controls).sort()).toEqual( - [ - "loginPassword", - "loginUsername", - "registerConfirmationPassword", - "registerEmail", - "registerPassword", - "registerUsername", - ].sort() - ); - }); - - it("requires loginUsername and registerUsername", () => { - const loginUsername = component.allForms.get("loginUsername")!; - const registerUsername = component.allForms.get("registerUsername")!; - loginUsername.setValue(""); - registerUsername.setValue(""); - expect(loginUsername.hasError("required")).toBe(true); - expect(registerUsername.hasError("required")).toBe(true); - }); - - it("requires registerEmail and enforces email format", () => { - const registerEmail = component.allForms.get("registerEmail")!; - registerEmail.setValue(""); - expect(registerEmail.hasError("required")).toBe(true); - - registerEmail.setValue("not-an-email"); - expect(registerEmail.hasError("email")).toBe(true); - - registerEmail.setValue("[email protected]"); - expect(registerEmail.valid).toBe(true); - expect(registerEmail.hasError("email")).toBe(false); - expect(registerEmail.hasError("required")).toBe(false); - }); - - it("requires passwords and enforces minLength(6)", () => { - const loginPassword = component.allForms.get("loginPassword")!; - const registerPassword = component.allForms.get("registerPassword")!; - loginPassword.setValue(""); - registerPassword.setValue(""); - expect(loginPassword.hasError("required")).toBe(true); - expect(registerPassword.hasError("required")).toBe(true); - - loginPassword.setValue("12345"); - registerPassword.setValue("12345"); - expect(loginPassword.hasError("minlength")).toBe(true); - expect(registerPassword.hasError("minlength")).toBe(true); - - loginPassword.setValue("123456"); - registerPassword.setValue("123456"); - expect(loginPassword.valid).toBe(true); - expect(registerPassword.valid).toBe(true); - }); - - it("wires the confirmationValidator on registerConfirmationPassword", () => { - const registerPassword = component.allForms.get("registerPassword")!; - const registerConfirmationPassword = component.allForms.get("registerConfirmationPassword")!; - registerPassword.setValue("abcdef"); - registerConfirmationPassword.setValue("zzzzzz"); - registerConfirmationPassword.updateValueAndValidity(); - expect(registerConfirmationPassword.hasError("confirm")).toBe(true); - - registerConfirmationPassword.setValue("abcdef"); - registerConfirmationPassword.updateValueAndValidity(); - expect(registerConfirmationPassword.hasError("confirm")).toBe(false); - }); - - it("requires registerConfirmationPassword to be non-empty", () => { - const registerConfirmationPassword = component.allForms.get("registerConfirmationPassword")!; - registerConfirmationPassword.setValue(""); - expect(registerConfirmationPassword.hasError("required")).toBe(true); - }); - }); - - describe("confirmationValidator", () => { - it("returns { confirm: true } when the value does not match registerPassword", () => { - component.allForms.get("registerPassword")!.setValue("password1"); - const control = new FormControl("password2"); - expect(component.confirmationValidator(control as FormControl)).toEqual({ confirm: true }); - }); - - it("returns {} when the value matches registerPassword", () => { - component.allForms.get("registerPassword")!.setValue("password1"); - const control = new FormControl("password1"); - expect(component.confirmationValidator(control as FormControl)).toEqual({}); - }); - }); - - describe("updateConfirmValidator", () => { - it("schedules updateValueAndValidity on registerConfirmationPassword via setTimeout", () => { - vi.useFakeTimers(); - try { - const control = component.allForms.controls.registerConfirmationPassword; - const updateSpy = vi.spyOn(control, "updateValueAndValidity"); - component.updateConfirmValidator(); - expect(updateSpy).not.toHaveBeenCalled(); - vi.runAllTimers(); - expect(updateSpy).toHaveBeenCalledTimes(1); - } finally { - vi.useRealTimers(); - } - }); - }); - - describe("ngOnInit", () => { - it("patches loginUsername and loginPassword from defaultLocalUser when populated", () => { - const config = TestBed.inject(GuiConfigService) as unknown as MockGuiConfigService; - config.setConfig({ defaultLocalUser: { username: "preset-user", password: "preset-pass" } }); - - component.ngOnInit(); - - expect(component.allForms.get("loginUsername")!.value).toBe("preset-user"); - expect(component.allForms.get("loginPassword")!.value).toBe("preset-pass"); - }); - - it("does not patch login fields when defaultLocalUser is empty", () => { - const config = TestBed.inject(GuiConfigService) as unknown as MockGuiConfigService; - config.setConfig({ defaultLocalUser: {} }); - - component.ngOnInit(); - - expect(component.allForms.get("loginUsername")!.value).toBe(""); - expect(component.allForms.get("loginPassword")!.value).toBe(""); - }); - }); - - describe("login", () => { - it("sets loginErrorMessage and short-circuits when validateUsername fails", () => { - const validateSpy = vi.spyOn(UserService, "validateUsername").mockReturnValue({ - result: false, - message: "Username should not be empty.", - }); - component.allForms.patchValue({ loginUsername: "", loginPassword: "123456" }); - - component.login(); - - expect(validateSpy).toHaveBeenCalledWith(""); - expect(component.loginErrorMessage).toBe("Username should not be empty."); - expect(userServiceMock.login).not.toHaveBeenCalled(); - expect(routerMock.navigateByUrl).not.toHaveBeenCalled(); - validateSpy.mockRestore(); - }); - - it("calls UserService.login with trimmed username and navigates to USER_WORKFLOW on success", () => { - vi.spyOn(UserService, "validateUsername").mockReturnValue({ result: true, message: "ok" }); - component.allForms.patchValue({ loginUsername: " alice ", loginPassword: "secret" }); - - component.login(); - - expect(userServiceMock.login).toHaveBeenCalledWith("alice", "secret"); - expect(routerMock.navigateByUrl).toHaveBeenCalledWith(USER_WORKFLOW); - expect(component.loginErrorMessage).toBeUndefined(); - }); - - it("navigates to queryParams.returnUrl when present", async () => { - await createComponent({ returnUrl: "/custom/return" }); - vi.spyOn(UserService, "validateUsername").mockReturnValue({ result: true, message: "ok" }); - component.allForms.patchValue({ loginUsername: "alice", loginPassword: "secret" }); - - component.login(); - - expect(routerMock.navigateByUrl).toHaveBeenCalledWith("/custom/return"); - }); - - it("surfaces the error's message via NotificationService.error on failure", () => { - vi.spyOn(UserService, "validateUsername").mockReturnValue({ result: true, message: "ok" }); - vi.mocked(userServiceMock.login!).mockReturnValueOnce(throwError(() => new Error("boom"))); - component.allForms.patchValue({ loginUsername: "alice", loginPassword: "secret" }); - - component.login(); - - expect(notificationServiceMock.error).toHaveBeenCalledWith("boom"); - expect(routerMock.navigateByUrl).not.toHaveBeenCalled(); - }); - - it("falls back to 'Incorrect username or password' when the error has no message", () => { - vi.spyOn(UserService, "validateUsername").mockReturnValue({ result: true, message: "ok" }); - vi.mocked(userServiceMock.login!).mockReturnValueOnce(throwError(() => ({}))); - component.allForms.patchValue({ loginUsername: "alice", loginPassword: "secret" }); - - component.login(); - - expect(notificationServiceMock.error).toHaveBeenCalledWith("Incorrect username or password"); - }); - }); - - describe("register", () => { - it("sets registerErrorMessage when the password is shorter than 6 characters", () => { - const validateSpy = vi.spyOn(UserService, "validateUsername").mockReturnValue({ result: true, message: "ok" }); - component.allForms.patchValue({ - registerUsername: "alice", - registerEmail: "[email protected]", - registerPassword: "abc", - registerConfirmationPassword: "abc", - }); - - component.register(); - - expect(component.registerErrorMessage).toBe("Password length should be greater than 5"); - expect(userServiceMock.register).not.toHaveBeenCalled(); - validateSpy.mockRestore(); - }); - - it("sets registerErrorMessage when passwords do not match", () => { - vi.spyOn(UserService, "validateUsername").mockReturnValue({ result: true, message: "ok" }); - component.allForms.patchValue({ - registerUsername: "alice", - registerEmail: "[email protected]", - registerPassword: "abcdef", - registerConfirmationPassword: "ghijkl", - }); - - component.register(); - - expect(component.registerErrorMessage).toBe("Passwords do not match"); - expect(userServiceMock.register).not.toHaveBeenCalled(); - }); - - it("sets registerErrorMessage when validateUsername fails", () => { - vi.spyOn(UserService, "validateUsername").mockReturnValue({ - result: false, - message: "Username should not be empty.", - }); - vi.spyOn(UserService, "validateEmail").mockReturnValue({ result: true, message: "ok" }); - component.allForms.patchValue({ - registerUsername: "", - registerEmail: "[email protected]", - registerPassword: "abcdef", - registerConfirmationPassword: "abcdef", - }); - - component.register(); - - expect(component.registerErrorMessage).toBe("Username should not be empty."); - expect(userServiceMock.register).not.toHaveBeenCalled(); - }); - - it("sets registerErrorMessage when the email is empty", () => { - vi.spyOn(UserService, "validateEmail").mockReturnValue({ - result: false, - message: "Email should not be empty.", - }); - component.allForms.patchValue({ - registerUsername: "alice", - registerEmail: "", - registerPassword: "abcdef", - registerConfirmationPassword: "abcdef", - }); - - component.register(); - - expect(component.registerErrorMessage).toBe("Email should not be empty."); - expect(userServiceMock.register).not.toHaveBeenCalled(); - }); - - it("sets registerErrorMessage when the email is malformed", () => { - vi.spyOn(UserService, "validateEmail").mockReturnValue({ - result: false, - message: "Email format is invalid.", - }); - component.allForms.patchValue({ - registerUsername: "alice", - registerEmail: "not-an-email", - registerPassword: "abcdef", - registerConfirmationPassword: "abcdef", - }); - - component.register(); - - expect(component.registerErrorMessage).toBe("Email format is invalid."); - expect(userServiceMock.register).not.toHaveBeenCalled(); - }); - - it("checks email validity before username validity", () => { - // Email validation runs before username validation in register(), so a - // bad email must short-circuit the flow even if username is also bad. - const validateUsernameSpy = vi - .spyOn(UserService, "validateUsername") - .mockReturnValue({ result: false, message: "Username should not be empty." }); - const validateEmailSpy = vi - .spyOn(UserService, "validateEmail") - .mockReturnValue({ result: false, message: "Email format is invalid." }); - component.allForms.patchValue({ - registerUsername: "", - registerEmail: "not-an-email", - registerPassword: "abcdef", - registerConfirmationPassword: "abcdef", - }); - - component.register(); - - expect(component.registerErrorMessage).toBe("Email format is invalid."); - expect(validateUsernameSpy).not.toHaveBeenCalled(); - expect(validateEmailSpy).toHaveBeenCalledWith("not-an-email"); - expect(userServiceMock.register).not.toHaveBeenCalled(); - }); - - it("calls UserService.register with the trimmed username,email and surfaces a success notification", () => { - vi.spyOn(UserService, "validateUsername").mockReturnValue({ result: true, message: "ok" }); - vi.spyOn(UserService, "validateEmail").mockReturnValue({ result: true, message: "ok" }); - component.allForms.patchValue({ - registerUsername: " alice ", - registerEmail: " [email protected] ", - registerPassword: "abcdef", - registerConfirmationPassword: "abcdef", - }); - - component.register(); - - expect(userServiceMock.register).toHaveBeenCalledWith("alice", "[email protected]", "abcdef"); - expect(notificationServiceMock.success).toHaveBeenCalledWith( - "Your account has been created. Please contact the Texera administrator to activate your account." - ); - expect(component.registerErrorMessage).toBeUndefined(); - }); - - it("surfaces the error's message via NotificationService.error on failure", () => { - vi.spyOn(UserService, "validateUsername").mockReturnValue({ result: true, message: "ok" }); - vi.spyOn(UserService, "validateEmail").mockReturnValue({ result: true, message: "ok" }); - vi.mocked(userServiceMock.register!).mockReturnValueOnce(throwError(() => new Error("nope"))); - component.allForms.patchValue({ - registerUsername: "alice", - registerEmail: "[email protected]", - registerPassword: "abcdef", - registerConfirmationPassword: "abcdef", - }); - - component.register(); - - expect(notificationServiceMock.error).toHaveBeenCalledWith("nope"); - expect(notificationServiceMock.success).not.toHaveBeenCalled(); - }); - - it("falls back to 'Registration failed' when the error has no message", () => { - vi.spyOn(UserService, "validateUsername").mockReturnValue({ result: true, message: "ok" }); - vi.spyOn(UserService, "validateEmail").mockReturnValue({ result: true, message: "ok" }); - vi.mocked(userServiceMock.register!).mockReturnValueOnce(throwError(() => ({}))); - component.allForms.patchValue({ - registerUsername: "alice", - registerEmail: "[email protected]", - registerPassword: "abcdef", - registerConfirmationPassword: "abcdef", - }); - - component.register(); - - expect(notificationServiceMock.error).toHaveBeenCalledWith("Registration failed"); - }); - }); -}); diff --git a/frontend/src/app/hub/component/about/local-login/local-login.component.ts b/frontend/src/app/hub/component/about/local-login/local-login.component.ts deleted file mode 100644 index bc5bee933e..0000000000 --- a/frontend/src/app/hub/component/about/local-login/local-login.component.ts +++ /dev/null @@ -1,186 +0,0 @@ -/** - * 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 { FormBuilder, FormControl, FormGroup, Validators, FormsModule, ReactiveFormsModule } from "@angular/forms"; -import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy"; -import { ActivatedRoute, Router } from "@angular/router"; -import { UserService } from "../../../../common/service/user/user.service"; -import { NotificationService } from "../../../../common/service/notification/notification.service"; -import { catchError } from "rxjs/operators"; -import { throwError } from "rxjs"; -import { USER_WORKFLOW } from "../../../../app-routing.constant"; -import { GuiConfigService } from "../../../../common/service/gui-config.service"; -import { NzTabsComponent, NzTabComponent } from "ng-zorro-antd/tabs"; -import { NgIf } from "@angular/common"; -import { NzFormDirective, NzFormItemComponent, NzFormControlComponent } from "ng-zorro-antd/form"; -import { NzRowDirective, NzColDirective } from "ng-zorro-antd/grid"; -import { ɵNzTransitionPatchDirective } from "ng-zorro-antd/core/transition-patch"; -import { NzSpaceCompactItemDirective } from "ng-zorro-antd/space"; -import { NzInputGroupComponent, NzInputDirective } from "ng-zorro-antd/input"; -import { NzButtonComponent } from "ng-zorro-antd/button"; -import { NzWaveDirective } from "ng-zorro-antd/core/wave"; - -@UntilDestroy() -@Component({ - selector: "texera-local-login", - templateUrl: "./local-login.component.html", - styleUrls: ["./local-login.component.scss"], - imports: [ - NzTabsComponent, - NgIf, - NzTabComponent, - FormsModule, - NzFormDirective, - ReactiveFormsModule, - NzRowDirective, - NzFormItemComponent, - NzColDirective, - NzFormControlComponent, - ɵNzTransitionPatchDirective, - NzSpaceCompactItemDirective, - NzInputGroupComponent, - NzInputDirective, - NzButtonComponent, - NzWaveDirective, - ], -}) -export class LocalLoginComponent implements OnInit { - public loginErrorMessage: string | undefined; - public registerErrorMessage: string | undefined; - public allForms: FormGroup; - - constructor( - private formBuilder: FormBuilder, - private userService: UserService, - private route: ActivatedRoute, - private notificationService: NotificationService, - private router: Router, - private config: GuiConfigService - ) { - this.allForms = this.formBuilder.group({ - loginUsername: new FormControl("", [Validators.required]), - registerUsername: new FormControl("", [Validators.required]), - registerEmail: new FormControl("", [Validators.required, Validators.email]), - loginPassword: new FormControl("", [Validators.required, Validators.minLength(6)]), - registerPassword: new FormControl("", [Validators.required, Validators.minLength(6)]), - registerConfirmationPassword: new FormControl("", [Validators.required, this.confirmationValidator]), - }); - } - - ngOnInit() { - if (this.config.env.defaultLocalUser && Object.keys(this.config.env.defaultLocalUser).length > 0) { - this.allForms.patchValue({ - loginUsername: this.config.env.defaultLocalUser.username, - loginPassword: this.config.env.defaultLocalUser.password, - }); - } - } - - public updateConfirmValidator(): void { - // immediately update validator (asynchronously to wait for value to refresh) - setTimeout(() => this.allForms.controls.registerConfirmationPassword.updateValueAndValidity(), 0); - } - - // validator for confirm password in sign up page - public confirmationValidator = (control: FormControl): { [s: string]: boolean } => { - if (this.allForms && control.value !== this.allForms.controls.registerPassword.value) { - return { confirm: true }; - } - return {}; - }; - - /** - * This method responds to the sign-in button - * It will send data inside the text entry to the user service to login - */ - public login(): void { - // validate the credentials format - this.loginErrorMessage = undefined; - const validation = UserService.validateUsername(this.allForms.get("loginUsername")?.value); - if (!validation.result) { - this.loginErrorMessage = validation.message; - return; - } - - const username = this.allForms.get("loginUsername")?.value.trim(); - const password = this.allForms.get("loginPassword")?.value; - - this.userService - .login(username, password) - .pipe( - catchError((e: unknown) => { - const errorMessage = (e as Error)?.message || "Incorrect username or password"; - this.notificationService.error(errorMessage); - return throwError(() => e); - }), - untilDestroyed(this) - ) - .subscribe(() => this.router.navigateByUrl(this.route.snapshot.queryParams["returnUrl"] || USER_WORKFLOW)); - } - - /** - * This method responds to the sign-up button - * It will send data inside the text entry to the user service to register - */ - public register(): void { - // validate the credentials format - this.registerErrorMessage = undefined; - const registerPassword = this.allForms.get("registerPassword")?.value; - const registerConfirmationPassword = this.allForms.get("registerConfirmationPassword")?.value; - const registerEmail = (this.allForms.get("registerEmail")?.value ?? "").trim(); - const registerUsername = (this.allForms.get("registerUsername")?.value ?? "").trim(); - - const validateEmail = UserService.validateEmail(registerEmail); - if (!validateEmail.result) { - this.registerErrorMessage = validateEmail.message; - return; - } - if (registerPassword.length < 6) { - this.registerErrorMessage = "Password length should be greater than 5"; - return; - } - if (registerPassword !== registerConfirmationPassword) { - this.registerErrorMessage = "Passwords do not match"; - return; - } - - const validateUsername = UserService.validateUsername(registerUsername); - if (!validateUsername.result) { - this.registerErrorMessage = validateUsername.message; - return; - } - // register the credentials with backend - this.userService - .register(registerUsername, registerEmail, registerPassword) - .pipe( - catchError((e: unknown) => { - const errorMessage = (e as Error)?.message || "Registration failed"; - this.notificationService.error(errorMessage); - return throwError(() => e); - }), - untilDestroyed(this) - ) - .subscribe(() => - this.notificationService.success( - "Your account has been created. Please contact the Texera administrator to activate your account." - ) - ); - } -} diff --git a/frontend/src/app/hub/component/login/texera-login.component.html b/frontend/src/app/hub/component/login/texera-login.component.html new file mode 100644 index 0000000000..7a67597315 --- /dev/null +++ b/frontend/src/app/hub/component/login/texera-login.component.html @@ -0,0 +1,156 @@ +<!-- + 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. +--> + +<main class="card"> + <div class="brand"> + <img + src="assets/logos/logo.png" + alt="Texera" /> + <div + nz-typography + nzType="secondary" + class="sub"> + Data science, together + </div> + </div> + + @if (config.env.localLogin) { + <nz-tabs + [nzSelectedIndex]="mode === 'signin' ? 0 : 1" + (nzSelectedIndexChange)="setMode($event === 0 ? 'signin' : 'signup')"> + <nz-tab nzTitle="Sign In"></nz-tab> + <nz-tab nzTitle="Sign Up"></nz-tab> + </nz-tabs> + } + + <div class="social-buttons"> + @if (config.env.googleLogin) { + <div class="google-wrapper"> + <asl-google-signin-button + type="standard" + size="large" + [width]="328"></asl-google-signin-button> + </div> + } + </div> + + @if (config.env.localLogin && config.env.googleLogin) { + <nz-divider + nzPlain + nzText="or continue with"></nz-divider> + } @if (config.env.localLogin) { + <form + class="form" + [formGroup]="form" + (ngSubmit)="submit()"> + <nz-input-group + nzSize="large" + nzPrefixIcon="user"> + <input + type="text" + nz-input + formControlName="username" + placeholder="Username" + autocomplete="username" /> + </nz-input-group> + + @if (mode === "signup") { + <nz-input-group + nzSize="large" + nzPrefixIcon="mail"> + <input + type="email" + nz-input + formControlName="email" + placeholder="Email" + autocomplete="email" /> + </nz-input-group> + } + + <nz-input-group + nzSize="large" + nzPrefixIcon="lock" + [nzSuffix]="passwordToggle"> + <input + [type]="passwordVisible ? 'text' : 'password'" + nz-input + formControlName="password" + placeholder="Password" + [attr.autocomplete]="mode === 'signup' ? 'new-password' : 'current-password'" /> + </nz-input-group> + + @if (mode === "signup") { + <nz-input-group + nzSize="large" + nzPrefixIcon="lock" + [nzSuffix]="passwordToggle"> + <input + [type]="passwordVisible ? 'text' : 'password'" + nz-input + formControlName="confirm" + placeholder="Confirm password" + autocomplete="new-password" /> + </nz-input-group> + <p + nz-typography + nzType="secondary" + class="hint"> + Password must be at least 6 characters. After registering, contact the Texera administrator to activate your + account. + </p> + } + + <p class="error">{{ errorMessage }}</p> + + <button + nz-button + nzType="primary" + nzSize="large" + nzBlock + type="submit"> + {{ mode === "signup" ? "Sign up" : "Sign in" }} + </button> + </form> + } + + <ng-template #passwordToggle> + <nz-icon + class="ant-input-password-icon" + role="button" + tabindex="0" + [attr.aria-label]="passwordVisible ? 'Hide password' : 'Show password'" + [nzType]="passwordVisible ? 'eye' : 'eye-invisible'" + nzTheme="outline" + (click)="togglePasswordVisibility()" + (keydown.enter)="togglePasswordVisibility()" /> + </ng-template> + + <p + nz-typography + nzType="secondary" + class="foot"> + Apache Texera (Incubating) · + <a + href="https://texera.apache.org/" + target="_blank" + rel="noopener" + >texera.apache.org</a + > + </p> +</main> diff --git a/frontend/src/app/hub/component/login/texera-login.component.scss b/frontend/src/app/hub/component/login/texera-login.component.scss new file mode 100644 index 0000000000..8315a72ca4 --- /dev/null +++ b/frontend/src/app/hub/component/login/texera-login.component.scss @@ -0,0 +1,151 @@ +/** + * 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. + */ + +$text-secondary: rgba(0, 0, 0, 0.45); +:host { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + background: #f0f2f5; + padding: 32px 16px; +} + +.card { + max-width: 400px; + background: #fff; + border-radius: 8px; + box-sizing: border-box; + box-shadow: + 0 6px 16px -8px rgba(0, 0, 0, 0.08), + 0 9px 28px 0 rgba(0, 0, 0, 0.05), + 0 12px 48px 16px rgba(0, 0, 0, 0.03); + padding: 40px 36px 32px; +} + +.brand { + text-align: center; + + margin-top: 6px; + margin-bottom: 12px; + + img { + height: 46px; + width: auto; + } + + .sub { + padding-top: 14px; + font-size: 13px; + letter-spacing: 0.02em; + } +} + +.social-buttons { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; +} + +.google-wrapper { + display: flex; + justify-content: center; +} + +.form { + display: flex; + flex-direction: column; + gap: 16px; +} + +.hint { + font-size: 13px; + margin: 2px 2px -4px; +} + +.error { + color: #ff4d4f; + font-size: 13px; + margin: -6px 2px 0; +} + +.foot { + text-align: center; + font-size: 13px; + margin-top: 26px; +} + +:host ::ng-deep { + .ant-tabs-content-holder { + display: none; + } + + .ant-tabs-top > .ant-tabs-nav { + margin-bottom: 22px; + } + + .ant-tabs-nav-list { + width: 100%; + } + + .ant-tabs-tab { + flex: 1; + justify-content: center; + padding: 10px 0 12px; + font-size: 15px; + } + + .ant-tabs-top > .ant-tabs-nav .ant-tabs-tab + .ant-tabs-tab { + margin-left: 0; + } + + .ant-input-affix-wrapper { + height: 44px; + border-radius: 6px; + font-size: 15px; + + > .ant-input { + font-size: 15px; + } + } + + .ant-input-prefix { + margin-right: 11px; + color: $text-secondary; + font-size: 15px; + } + + .ant-input-password-icon { + font-size: 16px; + } + + .ant-btn-lg { + height: 44px; + border-radius: 6px; + font-size: 15px; + font-weight: 500; + } + + .ant-divider-horizontal.ant-divider-with-text { + margin: 24px 0; + color: $text-secondary; + font-size: 13px; + } +} diff --git a/frontend/src/app/hub/component/login/texera-login.component.spec.ts b/frontend/src/app/hub/component/login/texera-login.component.spec.ts new file mode 100644 index 0000000000..c17b595c70 --- /dev/null +++ b/frontend/src/app/hub/component/login/texera-login.component.spec.ts @@ -0,0 +1,309 @@ +/** + * 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 { ActivatedRoute, ActivatedRouteSnapshot, Router } from "@angular/router"; +import { HttpClientTestingModule } from "@angular/common/http/testing"; +import { EMPTY, Subject, of, throwError } from "rxjs"; +import { SocialAuthService, SocialUser } from "@abacritt/angularx-social-login"; +import { vi } from "vitest"; + +import { TexeraLoginComponent } from "./texera-login.component"; +import { UserService } from "../../../common/service/user/user.service"; +import { NotificationService } from "../../../common/service/notification/notification.service"; +import { GuiConfigService } from "../../../common/service/gui-config.service"; +import { MockGuiConfigService } from "../../../common/service/gui-config.service.mock"; +import { commonTestProviders } from "../../../common/testing/test-utils"; +import { USER_WORKFLOW } from "../../../app-routing.constant"; + +describe("TexeraLoginComponent", () => { + let component: TexeraLoginComponent; + let fixture: ComponentFixture<TexeraLoginComponent>; + + let userServiceMock: Partial<UserService>; + let notificationServiceMock: Partial<NotificationService>; + let routerMock: Partial<Router>; + let socialAuthServiceMock: Partial<SocialAuthService>; + // Typed to allow null so the replayed-logout case can be exercised. + let authState$: Subject<SocialUser | null>; + + const googleUser = (idToken: string): SocialUser => ({ provider: "GOOGLE", idToken }) as unknown as SocialUser; + + const createComponent = async (queryParams: Record<string, any> = {}) => { + TestBed.resetTestingModule(); + authState$ = new Subject<SocialUser | null>(); + userServiceMock = { + isLogin: vi.fn().mockReturnValue(false), + login: vi.fn().mockReturnValue(of(undefined)), + register: vi.fn().mockReturnValue(of(undefined)), + googleLogin: vi.fn().mockReturnValue(of(undefined)), + }; + notificationServiceMock = { error: vi.fn(), success: vi.fn() }; + routerMock = { navigateByUrl: vi.fn() }; + socialAuthServiceMock = { + authState: authState$.asObservable() as SocialAuthService["authState"], + // GoogleSigninButtonDirective subscribes to initState in its constructor; + // EMPTY keeps the subscription open without triggering google.accounts.id.renderButton. + initState: EMPTY, + }; + + await TestBed.configureTestingModule({ + imports: [TexeraLoginComponent, HttpClientTestingModule], + providers: [ + { provide: UserService, useValue: userServiceMock }, + { provide: NotificationService, useValue: notificationServiceMock }, + { provide: Router, useValue: routerMock }, + { provide: ActivatedRoute, useValue: { snapshot: { queryParams } as Partial<ActivatedRouteSnapshot> } }, + { provide: SocialAuthService, useValue: socialAuthServiceMock }, + ...commonTestProviders, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(TexeraLoginComponent); + component = fixture.componentInstance; + }; + + beforeEach(async () => { + await createComponent(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("should create the component", () => { + fixture.detectChanges(); + expect(component).toBeTruthy(); + }); + + describe("ngOnInit", () => { + it("prefills username/password from defaultLocalUser when populated", () => { + const config = TestBed.inject(GuiConfigService) as unknown as MockGuiConfigService; + config.setConfig({ defaultLocalUser: { username: "preset-user", password: "preset-pass" } }); + + component.ngOnInit(); + + expect(component.form.get("username")!.value).toBe("preset-user"); + expect(component.form.get("password")!.value).toBe("preset-pass"); + }); + + it("does not prefill when defaultLocalUser is empty", () => { + const config = TestBed.inject(GuiConfigService) as unknown as MockGuiConfigService; + config.setConfig({ defaultLocalUser: {} }); + + component.ngOnInit(); + + expect(component.form.get("username")!.value).toBe(""); + expect(component.form.get("password")!.value).toBe(""); + }); + + // Nothing on this page is useful to someone already signed in. This replaces the + // route guard that used to bounce them before the component was ever constructed. + it("sends an already-signed-in visitor to their workflows", () => { + (userServiceMock.isLogin as any).mockReturnValue(true); + + component.ngOnInit(); + + expect(routerMock.navigateByUrl).toHaveBeenCalledWith(USER_WORKFLOW); + }); + + // The auth guard and the 401 interceptor both attach a returnUrl; if the visitor turns + // out to still be signed in, honour it rather than dumping them on the default page. + it("honours a returnUrl when redirecting an already-signed-in visitor", async () => { + await createComponent({ returnUrl: "/dashboard/user/dataset" }); + (userServiceMock.isLogin as any).mockReturnValue(true); + + component.ngOnInit(); + + expect(routerMock.navigateByUrl).toHaveBeenCalledWith("/dashboard/user/dataset"); + }); + + it("skips the prefill and the Google subscription when already signed in", () => { + const config = TestBed.inject(GuiConfigService) as unknown as MockGuiConfigService; + config.setConfig({ defaultLocalUser: { username: "preset-user", password: "preset-pass" } }); + (userServiceMock.isLogin as any).mockReturnValue(true); + + component.ngOnInit(); + authState$.next(googleUser("tok")); + + expect(component.form.get("username")!.value).toBe(""); + expect(userServiceMock.googleLogin).not.toHaveBeenCalled(); + }); + }); + + describe("setMode", () => { + it("switches mode and clears the error message", () => { + component.errorMessage = "stale"; + component.setMode("signup"); + expect(component.mode).toBe("signup"); + expect(component.errorMessage).toBeUndefined(); + }); + }); + + describe("togglePasswordVisibility", () => { + it("flips the passwordVisible flag", () => { + expect(component.passwordVisible).toBe(false); + component.togglePasswordVisibility(); + expect(component.passwordVisible).toBe(true); + }); + }); + + describe("confirmationValidator (via the confirm control)", () => { + it("flags a mismatch only in sign-up mode", () => { + component.setMode("signup"); + component.form.patchValue({ password: "secret1", confirm: "secret2" }); + component.form.controls.confirm.updateValueAndValidity(); + expect(component.form.controls.confirm.errors).toEqual({ confirm: true }); + }); + + it("does not flag a mismatch in sign-in mode", () => { + component.setMode("signin"); + component.form.patchValue({ password: "secret1", confirm: "secret2" }); + component.form.controls.confirm.updateValueAndValidity(); + expect(component.form.controls.confirm.errors).toBeNull(); + }); + }); + + describe("submit -> login (sign-in mode)", () => { + beforeEach(() => component.setMode("signin")); + + it("short-circuits and sets errorMessage when the username is blank", () => { + component.form.patchValue({ username: " ", password: "secret1" }); + component.submit(); + expect(userServiceMock.login).not.toHaveBeenCalled(); + expect(component.errorMessage).toBeTruthy(); + }); + + it("sets errorMessage when the password is shorter than 6 characters", () => { + component.form.patchValue({ username: "alice", password: "abc" }); + component.submit(); + expect(userServiceMock.login).not.toHaveBeenCalled(); + expect(component.errorMessage).toBe("Password length should be greater than 5."); + }); + + it("calls UserService.login with a trimmed username and navigates to USER_WORKFLOW", () => { + component.form.patchValue({ username: " alice ", password: "secret1" }); + component.submit(); + expect(userServiceMock.login).toHaveBeenCalledWith("alice", "secret1"); + expect(routerMock.navigateByUrl).toHaveBeenCalledWith(USER_WORKFLOW); + }); + + it("navigates to queryParams.returnUrl when present", async () => { + await createComponent({ returnUrl: "/user/dataset" }); + component.setMode("signin"); + component.form.patchValue({ username: "alice", password: "secret1" }); + component.submit(); + expect(routerMock.navigateByUrl).toHaveBeenCalledWith("/user/dataset"); + }); + + it("surfaces the error message on login failure and does not navigate", () => { + (userServiceMock.login as any).mockReturnValue(throwError(() => new Error("bad credentials"))); + component.form.patchValue({ username: "alice", password: "secret1" }); + component.submit(); + expect(component.errorMessage).toBe("bad credentials"); + expect(routerMock.navigateByUrl).not.toHaveBeenCalled(); + }); + + it("falls back to a default message when the login error has no message", () => { + (userServiceMock.login as any).mockReturnValue(throwError(() => ({}))); + component.form.patchValue({ username: "alice", password: "secret1" }); + component.submit(); + expect(component.errorMessage).toBe("Incorrect username or password"); + }); + }); + + describe("submit -> register (sign-up mode)", () => { + beforeEach(() => component.setMode("signup")); + + // Registration requires an email; the backend's register endpoint takes one. + it("sets errorMessage when the email is missing or malformed", () => { + component.form.patchValue({ + username: "alice", + email: "not-an-email", + password: "secret1", + confirm: "secret1", + }); + component.submit(); + expect(userServiceMock.register).not.toHaveBeenCalled(); + expect(component.errorMessage).toBeTruthy(); + }); + + it("sets errorMessage when passwords are inconsistent", () => { + component.form.patchValue({ + username: "alice", + email: "[email protected]", + password: "secret1", + confirm: "secret2", + }); + component.submit(); + expect(userServiceMock.register).not.toHaveBeenCalled(); + expect(component.errorMessage).toBe("Two passwords are inconsistent."); + }); + + it("calls UserService.register with username, email and password, then notifies success", () => { + component.form.patchValue({ + username: " alice ", + email: " [email protected] ", + password: "secret1", + confirm: "secret1", + }); + component.submit(); + expect(userServiceMock.register).toHaveBeenCalledWith("alice", "[email protected]", "secret1"); + expect(notificationServiceMock.success).toHaveBeenCalled(); + }); + + it("surfaces the error message on registration failure", () => { + (userServiceMock.register as any).mockReturnValue(throwError(() => new Error("username taken"))); + component.form.patchValue({ + username: "alice", + email: "[email protected]", + password: "secret1", + confirm: "secret1", + }); + component.submit(); + expect(component.errorMessage).toBe("username taken"); + }); + }); + + describe("google sign-in (authState)", () => { + beforeEach(() => fixture.detectChanges()); + + it("hands the id token to UserService.googleLogin and navigates", () => { + authState$.next(googleUser("google-id-token")); + expect(userServiceMock.googleLogin).toHaveBeenCalledWith("google-id-token"); + expect(routerMock.navigateByUrl).toHaveBeenCalledWith(USER_WORKFLOW); + }); + + // authState is a ReplaySubject that emits null on logout, so a stale null is replayed + // into this subscription on arrival. Without the filter that would call googleLogin + // with the id token of `undefined`. + it("ignores a null auth state", () => { + authState$.next(null); + expect(userServiceMock.googleLogin).not.toHaveBeenCalled(); + expect(routerMock.navigateByUrl).not.toHaveBeenCalled(); + }); + + it("notifies and does not navigate when the google exchange fails", () => { + (userServiceMock.googleLogin as any).mockReturnValue(throwError(() => new Error("google boom"))); + authState$.next(googleUser("google-id-token")); + expect(notificationServiceMock.error).toHaveBeenCalledWith("google boom"); + expect(routerMock.navigateByUrl).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/frontend/src/app/hub/component/login/texera-login.component.ts b/frontend/src/app/hub/component/login/texera-login.component.ts new file mode 100644 index 0000000000..578f7ddda2 --- /dev/null +++ b/frontend/src/app/hub/component/login/texera-login.component.ts @@ -0,0 +1,238 @@ +/** + * 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, NgZone, OnInit } from "@angular/core"; +import { + AbstractControl, + FormBuilder, + FormControl, + FormGroup, + ReactiveFormsModule, + ValidationErrors, + Validators, +} from "@angular/forms"; +import { ActivatedRoute, Router } from "@angular/router"; +import { catchError, filter } from "rxjs/operators"; +import { throwError } from "rxjs"; +import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy"; +import { SocialAuthService, GoogleSigninButtonModule, SocialUser } from "@abacritt/angularx-social-login"; +import { UserService } from "../../../common/service/user/user.service"; +import { NotificationService } from "../../../common/service/notification/notification.service"; +import { GuiConfigService } from "../../../common/service/gui-config.service"; +import { USER_WORKFLOW } from "../../../app-routing.constant"; +import { NzIconDirective } from "ng-zorro-antd/icon"; +import { NzTabComponent, NzTabsComponent } from "ng-zorro-antd/tabs"; +import { NzInputDirective, NzInputGroupComponent, NzInputGroupWhitSuffixOrPrefixDirective } from "ng-zorro-antd/input"; +import { NzButtonComponent } from "ng-zorro-antd/button"; +import { NzDividerComponent } from "ng-zorro-antd/divider"; +import { NzTypographyComponent } from "ng-zorro-antd/typography"; + +type LoginMode = "signin" | "signup"; + +/** + * Full-page login card: tabbed local sign-in / sign-up plus Google sign-in. + * + * This is the single login surface. It replaces the `texera-local-login` form that used to be + * embedded in the About page and the standalone Google button that sat on the dashboard shell, + * so `auth-guard` and the 401 interceptor now both redirect here. Adding another identity + * provider means one more button in the `social-buttons` block, not another login surface. + */ +@UntilDestroy() +@Component({ + selector: "texera-login", + templateUrl: "./texera-login.component.html", + styleUrls: ["./texera-login.component.scss"], + imports: [ + ReactiveFormsModule, + GoogleSigninButtonModule, + NzIconDirective, + NzTabsComponent, + NzTabComponent, + NzInputGroupComponent, + NzInputGroupWhitSuffixOrPrefixDirective, + NzInputDirective, + NzButtonComponent, + NzDividerComponent, + NzTypographyComponent, + ], +}) +export class TexeraLoginComponent implements OnInit { + public mode: LoginMode = "signin"; + public passwordVisible = false; + public errorMessage: string | undefined; + + public form: FormGroup; + + constructor( + private formBuilder: FormBuilder, + private userService: UserService, + private notificationService: NotificationService, + private route: ActivatedRoute, + private router: Router, + private ngZone: NgZone, + private socialAuthService: SocialAuthService, + protected config: GuiConfigService + ) { + this.form = this.formBuilder.group({ + username: new FormControl("", [Validators.required]), + // Registration requires an email; sign-in does not, so the control is only + // validated and submitted in sign-up mode. + email: new FormControl("", [Validators.email]), + password: new FormControl("", [Validators.required, Validators.minLength(6)]), + confirm: new FormControl("", [this.confirmationValidator]), + }); + } + + ngOnInit(): void { + // Nothing on this page is useful to someone already signed in, so send them straight on. + if (this.userService.isLogin()) { + this.navigateAfterLogin(); + return; + } + + // Prefill the configured local dev credentials, as the previous login form did. + if (this.config.env.defaultLocalUser && Object.keys(this.config.env.defaultLocalUser).length > 0) { + this.form.patchValue({ + username: this.config.env.defaultLocalUser.username, + password: this.config.env.defaultLocalUser.password, + }); + } + + // Google emits the signed-in user here after its own button completes the flow. + // The null filter matters: logging out pushes null through this subject, and it is a + // ReplaySubject, so that stale null is replayed into this subscription the moment it starts. + this.socialAuthService.authState + .pipe( + filter((user): user is SocialUser => user != null), + untilDestroyed(this) + ) + .subscribe(user => { + this.userService + .googleLogin(user.idToken) + .pipe( + catchError((e: unknown) => { + this.notificationService.error((e as Error)?.message || "Google sign-in failed"); + return throwError(() => e); + }), + untilDestroyed(this) + ) + .subscribe(() => this.ngZone.run(() => this.navigateAfterLogin())); + }); + } + + public setMode(mode: LoginMode): void { + this.mode = mode; + this.errorMessage = undefined; + // The confirm-password rule only applies in sign-up mode, so re-evaluate it on every switch. + this.form.controls.confirm.updateValueAndValidity(); + } + + public togglePasswordVisibility(): void { + this.passwordVisible = !this.passwordVisible; + } + + public submit(): void { + if (this.mode === "signin") { + this.login(); + } else { + this.register(); + } + } + + private login(): void { + this.errorMessage = undefined; + const username = this.form.get("username")?.value?.trim(); + const password = this.form.get("password")?.value; + + const validation = UserService.validateUsername(username); + if (!validation.result) { + this.errorMessage = validation.message; + return; + } + if (!password || password.length < 6) { + this.errorMessage = "Password length should be greater than 5."; + return; + } + + this.userService + .login(username, password) + .pipe( + catchError((e: unknown) => { + this.errorMessage = (e as Error)?.message || "Incorrect username or password"; + return throwError(() => e); + }), + untilDestroyed(this) + ) + .subscribe(() => this.navigateAfterLogin()); + } + + private register(): void { + this.errorMessage = undefined; + const username = this.form.get("username")?.value?.trim(); + const email = (this.form.get("email")?.value ?? "").trim(); + const password = this.form.get("password")?.value; + const confirm = this.form.get("confirm")?.value; + + const usernameValidation = UserService.validateUsername(username); + if (!usernameValidation.result) { + this.errorMessage = usernameValidation.message; + return; + } + const emailValidation = UserService.validateEmail(email); + if (!emailValidation.result) { + this.errorMessage = emailValidation.message; + return; + } + if (!password || password.length < 6) { + this.errorMessage = "Password length should be greater than 5."; + return; + } + if (password !== confirm) { + this.errorMessage = "Two passwords are inconsistent."; + return; + } + + this.userService + .register(username, email, password) + .pipe( + catchError((e: unknown) => { + this.errorMessage = (e as Error)?.message || "Registration failed"; + return throwError(() => e); + }), + untilDestroyed(this) + ) + .subscribe(() => + this.notificationService.success( + "Your account has been created. Please contact the Texera administrator to activate your account." + ) + ); + } + + private navigateAfterLogin(): void { + this.router.navigateByUrl(this.route.snapshot.queryParams["returnUrl"] || USER_WORKFLOW); + } + + // Confirm-password matches password; only enforced in sign-up mode. + private confirmationValidator = (control: AbstractControl): ValidationErrors | null => { + if (this.mode === "signup" && this.form && control.value !== this.form.controls.password.value) { + return { confirm: true }; + } + return null; + }; +}
