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;
+  };
+}

Reply via email to