You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@texera.apache.org by gi...@apache.org on 2026/08/10 21:49:37 UTC
(texera) 01/01: feat(frontend): consolidate login onto a dedicated /login page (#7339)
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 <53...@users.noreply.github.com>
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("alice@example.com");
- 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: "alice@example.com",
- 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: "alice@example.com",
- 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: "alice@example.com",
- 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: " alice@example.com ",
- registerPassword: "abcdef",
- registerConfirmationPassword: "abcdef",
- });
-
- component.register();
-
- expect(userServiceMock.register).toHaveBeenCalledWith("alice", "alice@example.com", "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: "alice@example.com",
- 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: "alice@example.com",
- 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: "alice@example.com",
+ 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: " alice@example.com ",
+ password: "secret1",
+ confirm: "secret1",
+ });
+ component.submit();
+ expect(userServiceMock.register).toHaveBeenCalledWith("alice", "alice@example.com", "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: "alice@example.com",
+ 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;
+ };
+}