From 7eca10c9c923bdef07f866f5df39b058d8400d14 Mon Sep 17 00:00:00 2001 From: Neilk1021 Date: Mon, 13 Jul 2026 13:28:26 -0700 Subject: [PATCH 01/26] refactor(sql): migrate authentication to auth_provider table. --- sql/changelog.xml | 4 ++++ sql/texera_ddl.sql | 7 +------ sql/updates/28.sql | 48 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 6 deletions(-) create mode 100644 sql/updates/28.sql diff --git a/sql/changelog.xml b/sql/changelog.xml index c9f4b0d1919..b02d608b45a 100644 --- a/sql/changelog.xml +++ b/sql/changelog.xml @@ -48,6 +48,10 @@ + + + + Date: Thu, 16 Jul 2026 12:10:22 -0700 Subject: [PATCH 12/26] style(auth): ran scalafmt --- .../UserActivityEventListenerSpec.scala | 2 +- .../texera/web/ServletAwareConfigurator.scala | 33 ++++++++----------- .../resource/auth/GoogleAuthResource.scala | 21 +++++++----- .../resource/DatasetResourceSpec.scala | 20 ++++++++--- 4 files changed, 41 insertions(+), 35 deletions(-) diff --git a/access-control-service/src/test/scala/org/apache/texera/service/activity/UserActivityEventListenerSpec.scala b/access-control-service/src/test/scala/org/apache/texera/service/activity/UserActivityEventListenerSpec.scala index f895bd04433..5a8ee02d4c9 100644 --- a/access-control-service/src/test/scala/org/apache/texera/service/activity/UserActivityEventListenerSpec.scala +++ b/access-control-service/src/test/scala/org/apache/texera/service/activity/UserActivityEventListenerSpec.scala @@ -37,7 +37,7 @@ class UserActivityEventListenerSpec extends AnyFlatSpec with Matchers { private def sessionUser(uid: Integer): SessionUser = { new SessionUser({ - new User().tap{ u => + new User().tap { u => u.setUid(uid) u.setName("u") u.setRole(UserRoleEnum.REGULAR) diff --git a/amber/src/main/scala/org/apache/texera/web/ServletAwareConfigurator.scala b/amber/src/main/scala/org/apache/texera/web/ServletAwareConfigurator.scala index 04596c0e1e4..e35611b7063 100644 --- a/amber/src/main/scala/org/apache/texera/web/ServletAwareConfigurator.scala +++ b/amber/src/main/scala/org/apache/texera/web/ServletAwareConfigurator.scala @@ -31,6 +31,7 @@ import java.nio.charset.Charset import javax.websocket.HandshakeResponse import javax.websocket.server.{HandshakeRequest, ServerEndpointConfig} import scala.jdk.CollectionConverters.{ListHasAsScala, _} +import scala.util.chaining.scalaUtilChainingOps /** * This configurator extracts user identity from the HTTP handshake request @@ -64,16 +65,12 @@ class ServletAwareConfigurator extends ServerEndpointConfig.Configurator with La s"User ID: $userId, User Name: $userName, User Email: $userEmail with CU Access: $cuAccess" ) - config.getUserProperties.put( - classOf[User].getName, - { - val user = new User() - user.setUid(userId) - user.setName(userName) - user.setEmail(userEmail) - user - } - ) + val user = new User().tap { u => + u.setUid(userId) + u.setName(userName) + u.setEmail(userEmail) + } + config.getUserProperties.put(classOf[User].getName, user) logger.debug(s"User created from headers: ID=$userId, Name=$userName") } else { // SINGLE NODE MODE: Construct the User object from JWT in query parameters. @@ -89,16 +86,12 @@ class ServletAwareConfigurator extends ServerEndpointConfig.Configurator with La .get("access-token") .map(token => { val claims = jwtConsumer.process(token).getJwtClaims - config.getUserProperties.put( - classOf[User].getName, - { - val user = new User() - user.setUid(claims.getClaimValue("userId").asInstanceOf[Long].toInt) - user.setName(claims.getSubject) - user.setEmail(String.valueOf(claims.getClaimValue("email").asInstanceOf[String])) - user - } - ) + val user = new User().tap { u => + u.setUid(claims.getClaimValue("userId").asInstanceOf[Long].toInt) + u.setName(claims.getSubject) + u.setEmail(String.valueOf(claims.getClaimValue("email").asInstanceOf[String])) + } + config.getUserProperties.put(classOf[User].getName, user) }) } } catch { diff --git a/amber/src/main/scala/org/apache/texera/web/resource/auth/GoogleAuthResource.scala b/amber/src/main/scala/org/apache/texera/web/resource/auth/GoogleAuthResource.scala index 6117540a9be..2d88d8a9010 100644 --- a/amber/src/main/scala/org/apache/texera/web/resource/auth/GoogleAuthResource.scala +++ b/amber/src/main/scala/org/apache/texera/web/resource/auth/GoogleAuthResource.scala @@ -87,8 +87,9 @@ class GoogleAuthResource { val uid = record.get(USER.UID) txUserDao.fetchOneByUid(uid).tap { user => // name/email/avatar are profile fields on "user"; refresh from Google if changed - if (user.getName != googleName || user.getEmail != googleEmail || user.getAvatar != googleAvatar) - { + if ( + user.getName != googleName || user.getEmail != googleEmail || user.getAvatar != googleAvatar + ) { user.setName(googleName) user.setEmail(googleEmail) user.setAvatar(googleAvatar) @@ -98,7 +99,7 @@ class GoogleAuthResource { case None => val user = Option(txUserDao.fetchOneByEmail(googleEmail)) match { case Some(user) => - user.tap{ user => + user.tap { user => if (user.getName != googleName || user.getAvatar != googleAvatar) { user.setName(googleName) user.setAvatar(googleAvatar) @@ -131,12 +132,14 @@ class GoogleAuthResource { .and(AUTH_PROVIDER.PROVIDER_TYPE.eq(ProviderTypeEnum.GOOGLE)) .execute() } else { - val auth = new AuthProvider - auth.setUid(user.getUid) - auth.setProviderType(ProviderTypeEnum.GOOGLE) - auth.setProviderId(googleId) - auth.setCreatedAt(OffsetDateTime.now()) - txAuthDao.insert(auth) + txAuthDao.insert( + (new AuthProvider).tap { auth => + auth.setUid(user.getUid) + auth.setProviderType(ProviderTypeEnum.GOOGLE) + auth.setProviderId(googleId) + auth.setCreatedAt(OffsetDateTime.now()) + } + ) } user } diff --git a/file-service/src/test/scala/org/apache/texera/service/resource/DatasetResourceSpec.scala b/file-service/src/test/scala/org/apache/texera/service/resource/DatasetResourceSpec.scala index 6babc051309..ec23f91487e 100644 --- a/file-service/src/test/scala/org/apache/texera/service/resource/DatasetResourceSpec.scala +++ b/file-service/src/test/scala/org/apache/texera/service/resource/DatasetResourceSpec.scala @@ -29,8 +29,18 @@ import org.apache.texera.dao.MockTexeraDB import org.apache.texera.dao.jooq.generated.enums.{PrivilegeEnum, UserRoleEnum} import org.apache.texera.dao.jooq.generated.tables.DatasetUploadSession.DATASET_UPLOAD_SESSION import org.apache.texera.dao.jooq.generated.tables.DatasetUploadSessionPart.DATASET_UPLOAD_SESSION_PART -import org.apache.texera.dao.jooq.generated.tables.daos.{DatasetDao, DatasetUserAccessDao, DatasetVersionDao, UserDao} -import org.apache.texera.dao.jooq.generated.tables.pojos.{Dataset, DatasetUserAccess, DatasetVersion, User} +import org.apache.texera.dao.jooq.generated.tables.daos.{ + DatasetDao, + DatasetUserAccessDao, + DatasetVersionDao, + UserDao +} +import org.apache.texera.dao.jooq.generated.tables.pojos.{ + Dataset, + DatasetUserAccess, + DatasetVersion, + User +} import org.apache.texera.service.MockLakeFS import org.apache.texera.service.util.S3StorageClient import org.jooq.SQLDialect @@ -126,7 +136,7 @@ class DatasetResourceSpec // Shared fixtures (DatasetResource basic tests) // --------------------------------------------------------------------------- private val ownerUser: User = { - new User().tap{ user => + new User().tap { user => user.setName("test_user") user.setEmail("test_user@test.com") user.setRole(UserRoleEnum.ADMIN) @@ -134,7 +144,7 @@ class DatasetResourceSpec } private val otherAdminUser: User = { - new User().tap{ user => + new User().tap { user => user.setName("test_user2") user.setEmail("test_user2@test.com") user.setRole(UserRoleEnum.ADMIN) @@ -143,7 +153,7 @@ class DatasetResourceSpec // REGULAR user used specifically for multipart "no WRITE access" tests. private val multipartNoWriteUser: User = { - new User().tap{user => + new User().tap { user => user.setName("multipart_user2") user.setEmail("multipart_user2@test.com") user.setRole(UserRoleEnum.REGULAR) From 605f92ffe86439f7183b847c28b03c13ccff8ea0 Mon Sep 17 00:00:00 2001 From: Neilk1021 Date: Thu, 16 Jul 2026 12:49:08 -0700 Subject: [PATCH 13/26] merge(auth): pulled main into this branch --- .../apache/texera/web/resource/auth/GoogleAuthResource.scala | 4 ++-- .../texera/service/resource/DatasetAccessResourceSpec.scala | 4 ---- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/amber/src/main/scala/org/apache/texera/web/resource/auth/GoogleAuthResource.scala b/amber/src/main/scala/org/apache/texera/web/resource/auth/GoogleAuthResource.scala index 2d88d8a9010..70c8a4d4047 100644 --- a/amber/src/main/scala/org/apache/texera/web/resource/auth/GoogleAuthResource.scala +++ b/amber/src/main/scala/org/apache/texera/web/resource/auth/GoogleAuthResource.scala @@ -107,7 +107,7 @@ class GoogleAuthResource { } } case None => - (new User).tap { user => + new User().tap { user => user.setName(googleName) user.setEmail(googleEmail) user.setAvatar(googleAvatar) @@ -133,7 +133,7 @@ class GoogleAuthResource { .execute() } else { txAuthDao.insert( - (new AuthProvider).tap { auth => + new AuthProvider().tap { auth => auth.setUid(user.getUid) auth.setProviderType(ProviderTypeEnum.GOOGLE) auth.setProviderId(googleId) diff --git a/file-service/src/test/scala/org/apache/texera/service/resource/DatasetAccessResourceSpec.scala b/file-service/src/test/scala/org/apache/texera/service/resource/DatasetAccessResourceSpec.scala index 78fc6f09c55..7587f06d6b7 100644 --- a/file-service/src/test/scala/org/apache/texera/service/resource/DatasetAccessResourceSpec.scala +++ b/file-service/src/test/scala/org/apache/texera/service/resource/DatasetAccessResourceSpec.scala @@ -50,7 +50,6 @@ class DatasetAccessResourceSpec private val ownerUser: User = { val user = new User user.setName("dataset_owner") - user.setPassword("123") user.setEmail("dataset_owner@test.com") user.setRole(UserRoleEnum.REGULAR) user @@ -59,7 +58,6 @@ class DatasetAccessResourceSpec private val readGranteeUser: User = { val user = new User user.setName("read_grantee") - user.setPassword("123") user.setEmail("read_grantee@test.com") user.setRole(UserRoleEnum.REGULAR) user @@ -68,7 +66,6 @@ class DatasetAccessResourceSpec private val writeGranteeUser: User = { val user = new User user.setName("write_grantee") - user.setPassword("123") user.setEmail("write_grantee@test.com") user.setRole(UserRoleEnum.REGULAR) user @@ -77,7 +74,6 @@ class DatasetAccessResourceSpec private val strangerUser: User = { val user = new User user.setName("stranger") - user.setPassword("123") user.setEmail("stranger@test.com") user.setRole(UserRoleEnum.REGULAR) user From fe5b8f7dec1c00797b3b33f1496e392c1f70a6d4 Mon Sep 17 00:00:00 2001 From: Neilk1021 Date: Fri, 17 Jul 2026 13:19:33 -0700 Subject: [PATCH 14/26] temp(auth): login page and incomplete facebook backend --- .../web/resource/FacebookAuthResource.scala | 68 +++++++++++++++++++ .../common/config/UserSystemConfig.scala | 2 + frontend/src/app/app-routing.constant.ts | 1 + frontend/src/app/app-routing.module.ts | 8 +++ .../unauthorized-http-interceptor.service.ts | 4 +- .../common/service/user/auth-guard.service.ts | 4 +- .../app/common/service/user/auth.service.ts | 21 ++++-- .../common/service/user/stub-auth.service.ts | 4 ++ .../common/service/user/stub-user.service.ts | 4 ++ .../app/common/service/user/user.service.ts | 6 ++ .../user/user-icon/user-icon.component.ts | 4 +- .../service/user/flarum/flarum.service.ts | 12 +++- .../hub/component/about/about.component.html | 8 --- .../component/about/about.component.spec.ts | 55 ++------------- .../hub/component/about/about.component.ts | 35 ++-------- 15 files changed, 136 insertions(+), 100 deletions(-) create mode 100644 amber/src/main/scala/org/apache/texera/web/resource/FacebookAuthResource.scala diff --git a/amber/src/main/scala/org/apache/texera/web/resource/FacebookAuthResource.scala b/amber/src/main/scala/org/apache/texera/web/resource/FacebookAuthResource.scala new file mode 100644 index 00000000000..f34cc2852e5 --- /dev/null +++ b/amber/src/main/scala/org/apache/texera/web/resource/FacebookAuthResource.scala @@ -0,0 +1,68 @@ +package org.apache.texera.web.resource + +import com.fasterxml.jackson.databind.ObjectMapper +import org.apache.texera.common.config.UserSystemConfig +import org.apache.texera.web.model.http.response.TokenIssueResponse + +import java.net.{URI, URLEncoder} +import java.net.http.{HttpClient, HttpRequest, HttpResponse} +import java.nio.charset.StandardCharsets + +import javax.ws.rs.core.MediaType +import javax.ws.rs._ + +@Path("/auth/facebook") +class FacebookAuthResource { + final private lazy val clientId = UserSystemConfig.facebookClientId + + @GET + @Path("/clientid") + def getClientId: String = clientId + + private val http = HttpClient.newHttpClient() + private val mapper = new ObjectMapper() + + private def getJson(url: String) = { + val resp = http.send( + HttpRequest.newBuilder(URI.create(url)).GET().build(), + HttpResponse.BodyHandlers.ofString() + ) + if (resp.statusCode() != 200) + throw new NotAuthorizedException(s"Facebook API error: ${resp.statusCode()}") + mapper.readTree(resp.body()) + } + private def enc(s: String) = URLEncoder.encode(s, StandardCharsets.UTF_8) + + private def verifyFacebookToken(accessToken: String): (String, String, String) = { + val appId = UserSystemConfig.facebookClientId + val appSecret = UserSystemConfig.facebookAppSecret + val appToken = s"$appId|$appSecret" + + // 1. validate the token belongs to *this* app and is live + val debug = getJson( + s"https://graph.facebook.com/debug_token?input_token=${enc(accessToken)}&access_token=${enc(appToken)}" + ).path("data") + if (!debug.path("is_valid").asBoolean(false) || debug.path("app_id").asText() != appId) + throw new NotAuthorizedException("Invalid Facebook token") + + // 2. fetch the profile + val me = getJson( + s"https://graph.facebook.com/me?fields=id,name,email&access_token=${enc(accessToken)}" + ) + val id = me.path("id").asText() + val name = Option(me.path("name").asText(null)).filter(_.nonEmpty).getOrElse(id) + val email = me.path("email").asText(null) // may be null — decide policy + (id, name, email) + } + + @POST + @Consumes(Array(MediaType.TEXT_PLAIN)) + @Produces(Array(MediaType.APPLICATION_JSON)) + @Path("/login") + def login(credential: String): TokenIssueResponse = { + throw new NotImplementedError("Facebook Oauth isn't implemented yet!") + + + } + +} diff --git a/common/config/src/main/scala/org/apache/texera/common/config/UserSystemConfig.scala b/common/config/src/main/scala/org/apache/texera/common/config/UserSystemConfig.scala index ae41a75c2d2..04335aa01ac 100644 --- a/common/config/src/main/scala/org/apache/texera/common/config/UserSystemConfig.scala +++ b/common/config/src/main/scala/org/apache/texera/common/config/UserSystemConfig.scala @@ -30,6 +30,8 @@ object UserSystemConfig { val adminUsername: String = conf.getString("user-sys.admin-username") val adminPassword: String = conf.getString("user-sys.admin-password") val googleClientId: String = conf.getString("user-sys.google.clientId") + val facebookClientId: String = conf.getString("user-sys.facebook.clientId") + val facebookAppSecret: String = conf.getString("user-sys.facebook.appSecret") val gmail: String = conf.getString("user-sys.google.smtp.gmail") val smtpPassword: String = conf.getString("user-sys.google.smtp.password") val inviteOnly: Boolean = conf.getBoolean("user-sys.invite-only") diff --git a/frontend/src/app/app-routing.constant.ts b/frontend/src/app/app-routing.constant.ts index f5a01300394..53cc71d79cc 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 58f90143006..12d55261de5 100644 --- a/frontend/src/app/app-routing.module.ts +++ b/frontend/src/app/app-routing.module.ts @@ -43,6 +43,7 @@ import { LandingPageComponent } from "./hub/component/landing-page/landing-page. import { USER_WORKFLOW } from "./app-routing.constant"; import { HubSearchResultComponent } from "./hub/component/hub-search-result/hub-search-result.component"; import { AdminSettingsComponent } from "./dashboard/component/admin/settings/admin-settings.component"; +import { TexeraLoginComponent } from "./hub/component/login/texera-login.component"; const routes: Routes = []; @@ -177,6 +178,13 @@ routes.push({ ], }); +// Full-page login: a top-level route (sibling of the DashboardComponent shell) so it +// renders in the root outlet without the navbar/sidebar chrome. +routes.push({ + path: "login", + component: TexeraLoginComponent, +}); + // redirect all other paths to index. routes.push({ path: "**", 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 2ff0cbc5dbb..1ce76a0fccf 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.ts b/frontend/src/app/common/service/user/auth-guard.service.ts index 1a69ad0289f..b2ac79e6dc0 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/common/service/user/auth.service.ts b/frontend/src/app/common/service/user/auth.service.ts index 74a49040008..3571dd6dfa4 100644 --- a/frontend/src/app/common/service/user/auth.service.ts +++ b/frontend/src/app/common/service/user/auth.service.ts @@ -46,6 +46,7 @@ export class AuthService { public static readonly REFRESH_TOKEN = "auth/refresh"; public static readonly REGISTER_ENDPOINT = "auth/register"; public static readonly GOOGLE_LOGIN_ENDPOINT = "auth/google/login"; + public static readonly FACEBOOK_LOGIN_ENDPOINT = "auth/facebook/login"; private tokenExpirationSubscription?: Subscription; @@ -74,14 +75,9 @@ export class AuthService { ); } - /** - * This method will handle the request for Google login. - * It will automatically login, save the user account inside and trigger userChangeEvent when success - - */ - public googleAuth(credential: string): Observable> { + private authRequest(authUrl: string, credential: string): Observable> { return this.http.post>( - `${AppSettings.getApiEndpoint()}/${AuthService.GOOGLE_LOGIN_ENDPOINT}`, + authUrl, credential, { headers: { @@ -91,6 +87,17 @@ export class AuthService { } ); } + /** + * This method will handle the request for Google login. + * It will automatically login, save the user account inside and trigger userChangeEvent when success + */ + public googleAuth(credential: string): Observable> { + return this.authRequest(`${AppSettings.getApiEndpoint()}/${AuthService.GOOGLE_LOGIN_ENDPOINT}`, credential) + } + + public facebookAuth(credential: string): Observable> { + return this.authRequest(`${AppSettings.getApiEndpoint()}/${AuthService.FACEBOOK_LOGIN_ENDPOINT}`, credential) + } /** * This method will handle the request for user login. diff --git a/frontend/src/app/common/service/user/stub-auth.service.ts b/frontend/src/app/common/service/user/stub-auth.service.ts index 2bb5f915349..9d68be26739 100644 --- a/frontend/src/app/common/service/user/stub-auth.service.ts +++ b/frontend/src/app/common/service/user/stub-auth.service.ts @@ -53,6 +53,10 @@ export class StubAuthService implements PublicInterfaceOf { return of(MOCK_TOKEN); } + facebookAuth(): Observable> { + return of(MOCK_TOKEN); + } + loginWithExistingToken(): User | undefined { if (AuthService.getAccessToken() === MOCK_TOKEN.accessToken) { return MOCK_USER; diff --git a/frontend/src/app/common/service/user/stub-user.service.ts b/frontend/src/app/common/service/user/stub-user.service.ts index 4303ea5c16a..6f3dd64603f 100644 --- a/frontend/src/app/common/service/user/stub-user.service.ts +++ b/frontend/src/app/common/service/user/stub-user.service.ts @@ -56,6 +56,10 @@ export class StubUserService implements PublicInterfaceOf { throw new Error("Method not implemented."); } + facebookLogin(): Observable { + throw new Error("Method not implemented."); + } + isLogin(): boolean { return this.user !== undefined; } diff --git a/frontend/src/app/common/service/user/user.service.ts b/frontend/src/app/common/service/user/user.service.ts index 9a55df64871..8584ab8888b 100644 --- a/frontend/src/app/common/service/user/user.service.ts +++ b/frontend/src/app/common/service/user/user.service.ts @@ -58,6 +58,12 @@ export class UserService { .pipe(switchMap(({ accessToken }) => this.handleAccessToken(accessToken))); } + public facebookLogin(credential: string): Observable { + return this.authService + .facebookAuth(credential) + .pipe(switchMap(({accessToken}) => this.handleAccessToken(accessToken))) + } + public googleLogin(credential: string): Observable { return this.authService .googleAuth(credential) diff --git a/frontend/src/app/dashboard/component/user/user-icon/user-icon.component.ts b/frontend/src/app/dashboard/component/user/user-icon/user-icon.component.ts index 94bb7d9d4e1..9bbac4394eb 100644 --- a/frontend/src/app/dashboard/component/user/user-icon/user-icon.component.ts +++ b/frontend/src/app/dashboard/component/user/user-icon/user-icon.component.ts @@ -22,7 +22,7 @@ import { UserService } from "../../../../common/service/user/user.service"; import { User } from "../../../../common/type/user"; import { UntilDestroy } from "@ngneat/until-destroy"; import { Router } from "@angular/router"; -import { ABOUT } from "../../../../app-routing.constant"; +import { LOGIN } from "../../../../app-routing.constant"; import { UserAvatarComponent } from "../user-avatar/user-avatar.component"; import { ɵNzTransitionPatchDirective } from "ng-zorro-antd/core/transition-patch"; import { NzDropdownDirective, NzDropdownMenuComponent } from "ng-zorro-antd/dropdown"; @@ -63,6 +63,6 @@ export class UserIconComponent { public onClickLogout(): void { this.userService.logout(); document.cookie = "flarum_remember=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;"; - this.router.navigate([ABOUT]); + this.router.navigate([LOGIN]); } } diff --git a/frontend/src/app/dashboard/service/user/flarum/flarum.service.ts b/frontend/src/app/dashboard/service/user/flarum/flarum.service.ts index b5549f40158..0a7dcc9706e 100644 --- a/frontend/src/app/dashboard/service/user/flarum/flarum.service.ts +++ b/frontend/src/app/dashboard/service/user/flarum/flarum.service.ts @@ -39,7 +39,11 @@ export class FlarumService { "forum/api/users", { data: { - attributes: { username: user!.email.split("@")[0] + user!.uid, email: user!.email, password: String(user!.uid) }, + attributes: { + username: user!.email.split("@")[0] + user!.uid, + email: user!.email, + password: String(user!.uid), + }, }, }, { headers: { Authorization: "Token hdebsyxiigyklxgsqivyswwiisohzlnezzzzzzzz;userId=1" } } @@ -48,6 +52,10 @@ export class FlarumService { auth() { const user = this.userService.getCurrentUser(); - return this.http.post("forum/api/token", { identification: user!.email, password: String(user!.uid), remember: "1" }); + return this.http.post("forum/api/token", { + identification: user!.email, + password: String(user!.uid), + remember: "1", + }); } } diff --git a/frontend/src/app/hub/component/about/about.component.html b/frontend/src/app/hub/component/about/about.component.html index 44e26111bc3..4a0d9748963 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 @@

The platform has the following key features:

  • Runtime debugging and interactive workflow execution
  • - -
    - -
    -
    { let component: AboutComponent; let fixture: ComponentFixture; - 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 '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(); + it("no longer embeds a login form (sign-in moved to the standalone /login page)", () => { 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 79954035404..3f8beacb3a0 100644 --- a/frontend/src/app/hub/component/about/about.component.ts +++ b/frontend/src/app/hub/component/about/about.component.ts @@ -17,38 +17,17 @@ * 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() +/** + * Informational "About Texera" page. Sign-in was moved out to the standalone, + * full-page TexeraLoginComponent (`/login`); this page no longer embeds a login form. + */ @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(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 {} From 8b8d5b58fa798ae26d39202cb0bc672ea1f4ef5f Mon Sep 17 00:00:00 2001 From: Neilk1021 Date: Mon, 20 Jul 2026 14:21:48 -0700 Subject: [PATCH 15/26] feat(auth): facebook integration finished --- .../texera/web/TexeraWebApplication.scala | 1 + .../web/resource/FacebookAuthResource.scala | 125 +++++++++++++++--- .../src/main/resources/user-system.conf | 8 ++ frontend/src/app/app.module.ts | 21 ++- sql/texera_ddl.sql | 8 +- sql/updates/28.sql | 6 +- 6 files changed, 141 insertions(+), 28 deletions(-) diff --git a/amber/src/main/scala/org/apache/texera/web/TexeraWebApplication.scala b/amber/src/main/scala/org/apache/texera/web/TexeraWebApplication.scala index 20ce01008b5..47ad47993d1 100644 --- a/amber/src/main/scala/org/apache/texera/web/TexeraWebApplication.scala +++ b/amber/src/main/scala/org/apache/texera/web/TexeraWebApplication.scala @@ -143,6 +143,7 @@ class TexeraWebApplication environment.jersey.register(classOf[AuthResource]) environment.jersey.register(classOf[GoogleAuthResource]) + environment.jersey.register(classOf[FacebookAuthResource]) environment.jersey.register(classOf[UserConfigResource]) environment.jersey.register(classOf[FeedbackResource]) environment.jersey.register(classOf[AdminUserResource]) diff --git a/amber/src/main/scala/org/apache/texera/web/resource/FacebookAuthResource.scala b/amber/src/main/scala/org/apache/texera/web/resource/FacebookAuthResource.scala index f34cc2852e5..67daf6b3282 100644 --- a/amber/src/main/scala/org/apache/texera/web/resource/FacebookAuthResource.scala +++ b/amber/src/main/scala/org/apache/texera/web/resource/FacebookAuthResource.scala @@ -1,18 +1,25 @@ package org.apache.texera.web.resource import com.fasterxml.jackson.databind.ObjectMapper +import org.apache.texera.auth.JwtAuth.{TOKEN_EXPIRE_TIME_IN_MINUTES, jwtClaims, jwtToken} import org.apache.texera.common.config.UserSystemConfig +import org.apache.texera.dao.SqlServer +import org.apache.texera.dao.jooq.generated.Tables.{AUTH_PROVIDER, USER} +import org.apache.texera.dao.jooq.generated.enums.{ProviderTypeEnum, UserRoleEnum} +import org.apache.texera.dao.jooq.generated.tables.daos.{AuthProviderDao, UserDao} +import org.apache.texera.dao.jooq.generated.tables.pojos.{AuthProvider, User} import org.apache.texera.web.model.http.response.TokenIssueResponse -import java.net.{URI, URLEncoder} +import java.net.URI import java.net.http.{HttpClient, HttpRequest, HttpResponse} -import java.nio.charset.StandardCharsets - -import javax.ws.rs.core.MediaType +import java.time.OffsetDateTime +import javax.ws.rs.core.{MediaType, UriBuilder} import javax.ws.rs._ +import scala.util.chaining.scalaUtilChainingOps @Path("/auth/facebook") class FacebookAuthResource { + final private lazy val clientId = UserSystemConfig.facebookClientId @GET @@ -22,36 +29,44 @@ class FacebookAuthResource { private val http = HttpClient.newHttpClient() private val mapper = new ObjectMapper() - private def getJson(url: String) = { + private def getJson(uri: URI) = { val resp = http.send( - HttpRequest.newBuilder(URI.create(url)).GET().build(), + HttpRequest.newBuilder(uri).GET().build(), HttpResponse.BodyHandlers.ofString() ) if (resp.statusCode() != 200) throw new NotAuthorizedException(s"Facebook API error: ${resp.statusCode()}") mapper.readTree(resp.body()) } - private def enc(s: String) = URLEncoder.encode(s, StandardCharsets.UTF_8) - private def verifyFacebookToken(accessToken: String): (String, String, String) = { + private def verifyFacebookToken(accessToken: String): (String, Option[String], Option[String]) = { + val facebookUrl = "https://graph.facebook.com" val appId = UserSystemConfig.facebookClientId val appSecret = UserSystemConfig.facebookAppSecret val appToken = s"$appId|$appSecret" - // 1. validate the token belongs to *this* app and is live - val debug = getJson( - s"https://graph.facebook.com/debug_token?input_token=${enc(accessToken)}&access_token=${enc(appToken)}" + val verifyRequest = getJson( + UriBuilder + .fromUri(s"$facebookUrl/debug_token") + .queryParam("input_token", accessToken) + .queryParam("access_token", appToken) + .build() ).path("data") - if (!debug.path("is_valid").asBoolean(false) || debug.path("app_id").asText() != appId) + + if (!verifyRequest.path("is_valid").asBoolean(false) || verifyRequest.path("app_id").asText() != appId) throw new NotAuthorizedException("Invalid Facebook token") - // 2. fetch the profile val me = getJson( - s"https://graph.facebook.com/me?fields=id,name,email&access_token=${enc(accessToken)}" + UriBuilder + .fromUri(s"$facebookUrl/me") + .queryParam("fields", "id,name,email") + .queryParam("access_token", accessToken) + .build() ) + val id = me.path("id").asText() - val name = Option(me.path("name").asText(null)).filter(_.nonEmpty).getOrElse(id) - val email = me.path("email").asText(null) // may be null — decide policy + val name = Option(me.path("name").asText(null)) + val email = Option(me.path("email").asText(null)) (id, name, email) } @@ -60,9 +75,85 @@ class FacebookAuthResource { @Produces(Array(MediaType.APPLICATION_JSON)) @Path("/login") def login(credential: String): TokenIssueResponse = { - throw new NotImplementedError("Facebook Oauth isn't implemented yet!") + val (facebookId, nameOpt, emailOpt) = verifyFacebookToken(credential) + + if (facebookId.isEmpty) throw new NotAuthorizedException("Login credentials are incorrect.") + + val facebookEmail = emailOpt.filter(_.nonEmpty).getOrElse(s"$facebookId@facebook.local") + val facebookName = nameOpt.filter(_.nonEmpty).getOrElse(facebookEmail) + + val user = SqlServer.withTransaction(SqlServer.getInstance().createDSLContext()) { ctx => + val txUserDao = new UserDao(ctx.configuration()) + val txAuthDao = new AuthProviderDao(ctx.configuration()) + + Option( + ctx + .select() + .from(USER) + .join(AUTH_PROVIDER) + .on(USER.UID.eq(AUTH_PROVIDER.UID)) + .where(AUTH_PROVIDER.PROVIDER_TYPE.eq(ProviderTypeEnum.FACEBOOK)) + .and(AUTH_PROVIDER.PROVIDER_ID.eq(facebookId)) + .fetchOne() + ) match { + case Some(record) => + val uid = record.get(USER.UID) + txUserDao.fetchOneByUid(uid).tap { user => + // name/email are profile fields on "user"; refresh from Facebook if changed + if (user.getName != facebookName || user.getEmail != facebookEmail) { + user.setName(facebookName) + user.setEmail(facebookEmail) + txUserDao.update(user) + } + } + case None => + val user = Option(txUserDao.fetchOneByEmail(facebookEmail)) match { + case Some(user) => + user.tap { user => + if (user.getName != facebookName) { + user.setName(facebookName) + txUserDao.update(user) + } + } + case None => + new User().tap { user => + user.setName(facebookName) + user.setEmail(facebookEmail) + user.setRole(UserRoleEnum.INACTIVE) + txUserDao.insert(user) + } + } + // an email-matched user may already have a FACEBOOK provider row, so + // upsert rather than blindly insert (avoids a (uid, provider_type) PK collision) + val hasFacebookProvider = ctx.fetchExists( + ctx + .selectFrom(AUTH_PROVIDER) + .where(AUTH_PROVIDER.UID.eq(user.getUid)) + .and(AUTH_PROVIDER.PROVIDER_TYPE.eq(ProviderTypeEnum.FACEBOOK)) + ) + if (hasFacebookProvider) { + ctx + .update(AUTH_PROVIDER) + .set(AUTH_PROVIDER.PROVIDER_ID, facebookId) + .where(AUTH_PROVIDER.UID.eq(user.getUid)) + .and(AUTH_PROVIDER.PROVIDER_TYPE.eq(ProviderTypeEnum.FACEBOOK)) + .execute() + } else { + txAuthDao.insert( + new AuthProvider().tap { auth => + auth.setUid(user.getUid) + auth.setProviderType(ProviderTypeEnum.FACEBOOK) + auth.setProviderId(facebookId) + auth.setCreatedAt(OffsetDateTime.now()) + } + ) + } + user + } + } + TokenIssueResponse(jwtToken(jwtClaims(user, TOKEN_EXPIRE_TIME_IN_MINUTES))) } } diff --git a/common/config/src/main/resources/user-system.conf b/common/config/src/main/resources/user-system.conf index ffda7e2435a..9c7f5430e26 100644 --- a/common/config/src/main/resources/user-system.conf +++ b/common/config/src/main/resources/user-system.conf @@ -36,6 +36,14 @@ user-sys { } } + facebook { + clientId = "" + clientId = ${?USER_SYS_FACEBOOK_CLIENT_ID} + + appSecret = "" + appSecret = ${?USER_SYS_FACEBOOK_APP_SECRET} + } + domain = "" domain = ${?USER_SYS_DOMAIN} diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index 8f5023b50ac..b8a5b657a74 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -175,7 +175,9 @@ import { BreakpointConditionInputComponent } from "./workspace/component/code-ed import { CodeDebuggerComponent } from "./workspace/component/code-editor-dialog/code-debugger.component"; import { AgentInteractionComponent } from "./workspace/component/agent/agent-interaction/agent-interaction.component"; import { GoogleAuthService } from "./common/service/user/google-auth.service"; +import { FacebookAuthService } from "./common/service/user/facebook-auth.service"; import { + FacebookLoginProvider, GoogleLoginProvider, GoogleSigninButtonModule, SocialAuthServiceConfig, @@ -393,17 +395,28 @@ registerLocaleData(en); }, { provide: "SocialAuthServiceConfig", - useFactory: (googleAuthService: GoogleAuthService, userService: UserService) => { - return lastValueFrom(googleAuthService.getClientId()).then(clientId => ({ + useFactory: ( + googleAuthService: GoogleAuthService, + facebookAuthService: FacebookAuthService, + userService: UserService + ) => { + return Promise.all([ + lastValueFrom(googleAuthService.getClientId()), + lastValueFrom(facebookAuthService.getClientId()), + ]).then(([googleClientId, facebookClientId]) => ({ providers: [ { id: GoogleLoginProvider.PROVIDER_ID, - provider: new GoogleLoginProvider(clientId, { oneTapEnabled: !userService.isLogin() }), + provider: new GoogleLoginProvider(googleClientId, { oneTapEnabled: !userService.isLogin() }), + }, + { + id: FacebookLoginProvider.PROVIDER_ID, + provider: new FacebookLoginProvider(facebookClientId), }, ], })) as Promise; }, - deps: [GoogleAuthService, UserService], + deps: [GoogleAuthService, FacebookAuthService, UserService], }, { provide: APP_INITIALIZER, diff --git a/sql/texera_ddl.sql b/sql/texera_ddl.sql index 9c0a8f96337..0f79cc2ffda 100644 --- a/sql/texera_ddl.sql +++ b/sql/texera_ddl.sql @@ -92,7 +92,7 @@ CREATE TYPE user_role_enum AS ENUM ('INACTIVE', 'RESTRICTED', 'REGULAR', 'ADMIN' CREATE TYPE action_enum AS ENUM ('like', 'unlike', 'view', 'clone'); CREATE TYPE privilege_enum AS ENUM ('NONE', 'READ', 'WRITE'); CREATE TYPE workflow_computing_unit_type_enum AS ENUM ('local', 'kubernetes'); -CREATE TYPE provider_type_enum AS ENUM ('LOCAL', 'GOOGLE'); +CREATE TYPE provider_type_enum AS ENUM ('LOCAL', 'GOOGLE', 'FACEBOOK'); -- ============================================ -- 5. Create tables @@ -117,7 +117,7 @@ CREATE TABLE IF NOT EXISTS auth_provider ( uid INT NOT NULL, provider_type provider_type_enum NOT NULL, - provider_id VARCHAR(256), -- external subject id (Google sub); NULL for LOCAL + provider_id VARCHAR(256), -- external subject id (e.g. Google sub, Facebook id); NULL for LOCAL password VARCHAR(256), -- hashed credential; only for LOCAL created_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (uid, provider_type), @@ -125,9 +125,9 @@ CREATE TABLE IF NOT EXISTS auth_provider CONSTRAINT uq_provider_identity UNIQUE (provider_type, provider_id), CONSTRAINT ck_provider_credential CHECK ( (provider_type = 'LOCAL' AND password IS NOT NULL AND provider_id IS NULL) OR - (provider_type = 'GOOGLE' AND provider_id IS NOT NULL AND password IS NULL) + (provider_type != 'LOCAL' AND provider_id IS NOT NULL AND password IS NULL) ) - ); +); -- user_config CREATE TABLE IF NOT EXISTS user_config diff --git a/sql/updates/28.sql b/sql/updates/28.sql index d249836c59b..56ecb7da598 100644 --- a/sql/updates/28.sql +++ b/sql/updates/28.sql @@ -26,7 +26,7 @@ BEGIN; DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'provider_type_enum') THEN - CREATE TYPE provider_type_enum AS ENUM ('LOCAL', 'GOOGLE'); + CREATE TYPE provider_type_enum AS ENUM ('LOCAL', 'GOOGLE', 'FACEBOOK'); END IF; END $$; @@ -35,7 +35,7 @@ $$; CREATE TABLE IF NOT EXISTS auth_provider ( uid INT NOT NULL, provider_type provider_type_enum NOT NULL, - provider_id VARCHAR(256), -- external subject id (Google sub); NULL for LOCAL + provider_id VARCHAR(256), -- external subject id (e.g. Google sub, Facebook id); NULL for LOCAL password VARCHAR(256), -- hashed credential; only for LOCAL created_at TIMESTAMPTZ NOT NULL DEFAULT now(), @@ -48,7 +48,7 @@ CREATE TABLE IF NOT EXISTS auth_provider ( -- credential shape must match the provider (replaces the old ck_nulltest) CONSTRAINT ck_provider_credential CHECK ( (provider_type = 'LOCAL' AND password IS NOT NULL AND provider_id IS NULL) OR - (provider_type = 'GOOGLE' AND provider_id IS NOT NULL AND password IS NULL) + (provider_type != 'LOCAL' AND provider_id IS NOT NULL AND password IS NULL) ) ); From e7fcddb7ab6434347203ad76bf21300dec0a4e3f Mon Sep 17 00:00:00 2001 From: Neilk1021 Date: Tue, 21 Jul 2026 12:12:43 -0700 Subject: [PATCH 16/26] feat(auth): Abstraction of login to `ExternalAuthProvisioner` and inclusion of front end components I forgot to add to the VCS. --- .../web/resource/FacebookAuthResource.scala | 82 +---- .../auth/ExternalAuthProvisioner.scala | 151 ++++++++ .../resource/auth/GoogleAuthResource.scala | 92 +---- .../service/user/facebook-auth.service.ts | 34 ++ .../login/texera-login.component.html | 184 ++++++++++ .../login/texera-login.component.scss | 327 ++++++++++++++++++ .../component/login/texera-login.component.ts | 215 ++++++++++++ 7 files changed, 926 insertions(+), 159 deletions(-) create mode 100644 amber/src/main/scala/org/apache/texera/web/resource/auth/ExternalAuthProvisioner.scala create mode 100644 frontend/src/app/common/service/user/facebook-auth.service.ts create mode 100644 frontend/src/app/hub/component/login/texera-login.component.html create mode 100644 frontend/src/app/hub/component/login/texera-login.component.scss create mode 100644 frontend/src/app/hub/component/login/texera-login.component.ts diff --git a/amber/src/main/scala/org/apache/texera/web/resource/FacebookAuthResource.scala b/amber/src/main/scala/org/apache/texera/web/resource/FacebookAuthResource.scala index 67daf6b3282..896d1e12c48 100644 --- a/amber/src/main/scala/org/apache/texera/web/resource/FacebookAuthResource.scala +++ b/amber/src/main/scala/org/apache/texera/web/resource/FacebookAuthResource.scala @@ -3,19 +3,14 @@ package org.apache.texera.web.resource import com.fasterxml.jackson.databind.ObjectMapper import org.apache.texera.auth.JwtAuth.{TOKEN_EXPIRE_TIME_IN_MINUTES, jwtClaims, jwtToken} import org.apache.texera.common.config.UserSystemConfig -import org.apache.texera.dao.SqlServer -import org.apache.texera.dao.jooq.generated.Tables.{AUTH_PROVIDER, USER} -import org.apache.texera.dao.jooq.generated.enums.{ProviderTypeEnum, UserRoleEnum} -import org.apache.texera.dao.jooq.generated.tables.daos.{AuthProviderDao, UserDao} -import org.apache.texera.dao.jooq.generated.tables.pojos.{AuthProvider, User} +import org.apache.texera.dao.jooq.generated.enums.ProviderTypeEnum import org.apache.texera.web.model.http.response.TokenIssueResponse +import org.apache.texera.web.resource.auth.{ExternalAuthProvisioner, ExternalProfile} import java.net.URI import java.net.http.{HttpClient, HttpRequest, HttpResponse} -import java.time.OffsetDateTime import javax.ws.rs.core.{MediaType, UriBuilder} import javax.ws.rs._ -import scala.util.chaining.scalaUtilChainingOps @Path("/auth/facebook") class FacebookAuthResource { @@ -82,76 +77,9 @@ class FacebookAuthResource { val facebookEmail = emailOpt.filter(_.nonEmpty).getOrElse(s"$facebookId@facebook.local") val facebookName = nameOpt.filter(_.nonEmpty).getOrElse(facebookEmail) - val user = SqlServer.withTransaction(SqlServer.getInstance().createDSLContext()) { ctx => - val txUserDao = new UserDao(ctx.configuration()) - val txAuthDao = new AuthProviderDao(ctx.configuration()) - - Option( - ctx - .select() - .from(USER) - .join(AUTH_PROVIDER) - .on(USER.UID.eq(AUTH_PROVIDER.UID)) - .where(AUTH_PROVIDER.PROVIDER_TYPE.eq(ProviderTypeEnum.FACEBOOK)) - .and(AUTH_PROVIDER.PROVIDER_ID.eq(facebookId)) - .fetchOne() - ) match { - case Some(record) => - val uid = record.get(USER.UID) - txUserDao.fetchOneByUid(uid).tap { user => - // name/email are profile fields on "user"; refresh from Facebook if changed - if (user.getName != facebookName || user.getEmail != facebookEmail) { - user.setName(facebookName) - user.setEmail(facebookEmail) - txUserDao.update(user) - } - } - case None => - val user = Option(txUserDao.fetchOneByEmail(facebookEmail)) match { - case Some(user) => - user.tap { user => - if (user.getName != facebookName) { - user.setName(facebookName) - txUserDao.update(user) - } - } - case None => - new User().tap { user => - user.setName(facebookName) - user.setEmail(facebookEmail) - user.setRole(UserRoleEnum.INACTIVE) - txUserDao.insert(user) - } - } - - // an email-matched user may already have a FACEBOOK provider row, so - // upsert rather than blindly insert (avoids a (uid, provider_type) PK collision) - val hasFacebookProvider = ctx.fetchExists( - ctx - .selectFrom(AUTH_PROVIDER) - .where(AUTH_PROVIDER.UID.eq(user.getUid)) - .and(AUTH_PROVIDER.PROVIDER_TYPE.eq(ProviderTypeEnum.FACEBOOK)) - ) - if (hasFacebookProvider) { - ctx - .update(AUTH_PROVIDER) - .set(AUTH_PROVIDER.PROVIDER_ID, facebookId) - .where(AUTH_PROVIDER.UID.eq(user.getUid)) - .and(AUTH_PROVIDER.PROVIDER_TYPE.eq(ProviderTypeEnum.FACEBOOK)) - .execute() - } else { - txAuthDao.insert( - new AuthProvider().tap { auth => - auth.setUid(user.getUid) - auth.setProviderType(ProviderTypeEnum.FACEBOOK) - auth.setProviderId(facebookId) - auth.setCreatedAt(OffsetDateTime.now()) - } - ) - } - user - } - } + val user = ExternalAuthProvisioner.loginOrProvision( + ExternalProfile(ProviderTypeEnum.FACEBOOK, facebookId, facebookName, facebookEmail) + ) TokenIssueResponse(jwtToken(jwtClaims(user, TOKEN_EXPIRE_TIME_IN_MINUTES))) } diff --git a/amber/src/main/scala/org/apache/texera/web/resource/auth/ExternalAuthProvisioner.scala b/amber/src/main/scala/org/apache/texera/web/resource/auth/ExternalAuthProvisioner.scala new file mode 100644 index 00000000000..d3869a12051 --- /dev/null +++ b/amber/src/main/scala/org/apache/texera/web/resource/auth/ExternalAuthProvisioner.scala @@ -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. + */ + +package org.apache.texera.web.resource.auth + +import org.apache.texera.dao.SqlServer +import org.apache.texera.dao.jooq.generated.Tables.{AUTH_PROVIDER, USER} +import org.apache.texera.dao.jooq.generated.enums.{ProviderTypeEnum, UserRoleEnum} +import org.apache.texera.dao.jooq.generated.tables.daos.{AuthProviderDao, UserDao} +import org.apache.texera.dao.jooq.generated.tables.pojos.{AuthProvider, User} +import org.jooq.DSLContext + +import java.time.OffsetDateTime +import scala.util.chaining.scalaUtilChainingOps + +/** + * A verified external identity (Google, Facebook, ...) reduced to the fields we + * persist. `avatar` is optional: `None` means the provider supplies no avatar, so + * the user's existing avatar column is left untouched rather than overwritten. + */ +final case class ExternalProfile( + providerType: ProviderTypeEnum, + providerId: String, + name: String, + email: String, + avatar: Option[String] = None +) + +object ExternalAuthProvisioner { + + /** + * Resolve the user behind an external identity, creating one if necessary, and + * ensure its auth-provider row is present and up to date. Runs in a single + * transaction and returns the (possibly newly created) user. + */ + def loginOrProvision(profile: ExternalProfile): User = + SqlServer.withTransaction(SqlServer.getInstance().createDSLContext()) { ctx => + val txUserDao = new UserDao(ctx.configuration()) + val txAuthDao = new AuthProviderDao(ctx.configuration()) + + Option( + ctx + .select() + .from(USER) + .join(AUTH_PROVIDER) + .on(USER.UID.eq(AUTH_PROVIDER.UID)) + .where(AUTH_PROVIDER.PROVIDER_TYPE.eq(profile.providerType)) + .and(AUTH_PROVIDER.PROVIDER_ID.eq(profile.providerId)) + .fetchOne() + ) match { + case Some(record) => + // known identity: refresh the profile fields if they drifted + txUserDao.fetchOneByUid(record.get(USER.UID)).tap { user => + if (refresh(user, profile)) txUserDao.update(user) + } + + case None => + val user = Option(txUserDao.fetchOneByEmail(profile.email)) match { + case Some(existing) => + existing.tap { user => + if (refresh(user, profile)) txUserDao.update(user) + } + case None => + new User().tap { user => + user.setName(profile.name) + user.setEmail(profile.email) + profile.avatar.foreach(user.setAvatar) + user.setRole(UserRoleEnum.INACTIVE) + txUserDao.insert(user) + } + } + + upsertProvider(ctx, txAuthDao, user, profile) + user + } + } + + /** + * Mutate `user` in place to match `profile`, returning true iff anything changed + * (so the caller only issues an UPDATE when needed). + */ + private def refresh(user: User, profile: ExternalProfile): Boolean = { + var changed = false + if (user.getName != profile.name) { + user.setName(profile.name) + changed = true + } + if (user.getEmail != profile.email) { + user.setEmail(profile.email) + changed = true + } + profile.avatar.foreach { avatar => + if (user.getAvatar != avatar) { + user.setAvatar(avatar) + changed = true + } + } + changed + } + + /** + * An email-matched user may already have a provider row, so upsert rather than + * blindly insert (avoids a (uid, provider_type) PK collision). + */ + private def upsertProvider( + ctx: DSLContext, + authDao: AuthProviderDao, + user: User, + profile: ExternalProfile + ): Unit = { + val hasProvider = ctx.fetchExists( + ctx + .selectFrom(AUTH_PROVIDER) + .where(AUTH_PROVIDER.UID.eq(user.getUid)) + .and(AUTH_PROVIDER.PROVIDER_TYPE.eq(profile.providerType)) + ) + if (hasProvider) { + ctx + .update(AUTH_PROVIDER) + .set(AUTH_PROVIDER.PROVIDER_ID, profile.providerId) + .where(AUTH_PROVIDER.UID.eq(user.getUid)) + .and(AUTH_PROVIDER.PROVIDER_TYPE.eq(profile.providerType)) + .execute() + } else { + authDao.insert( + new AuthProvider().tap { auth => + auth.setUid(user.getUid) + auth.setProviderType(profile.providerType) + auth.setProviderId(profile.providerId) + auth.setCreatedAt(OffsetDateTime.now()) + } + ) + } + } +} diff --git a/amber/src/main/scala/org/apache/texera/web/resource/auth/GoogleAuthResource.scala b/amber/src/main/scala/org/apache/texera/web/resource/auth/GoogleAuthResource.scala index 70c8a4d4047..b6ffb0d83ba 100644 --- a/amber/src/main/scala/org/apache/texera/web/resource/auth/GoogleAuthResource.scala +++ b/amber/src/main/scala/org/apache/texera/web/resource/auth/GoogleAuthResource.scala @@ -24,18 +24,12 @@ import com.google.api.client.http.javanet.NetHttpTransport import com.google.api.client.json.gson.GsonFactory import org.apache.texera.auth.JwtAuth.{TOKEN_EXPIRE_TIME_IN_MINUTES, jwtClaims, jwtToken} import org.apache.texera.common.config.UserSystemConfig -import org.apache.texera.dao.SqlServer -import org.apache.texera.dao.jooq.generated.Tables.{AUTH_PROVIDER, USER} -import org.apache.texera.dao.jooq.generated.enums.{ProviderTypeEnum, UserRoleEnum} -import org.apache.texera.dao.jooq.generated.tables.daos.{AuthProviderDao, UserDao} -import org.apache.texera.dao.jooq.generated.tables.pojos.{AuthProvider, User} +import org.apache.texera.dao.jooq.generated.enums.ProviderTypeEnum import org.apache.texera.web.model.http.response.TokenIssueResponse -import java.time.OffsetDateTime import java.util.Collections import javax.ws.rs._ import javax.ws.rs.core.MediaType -import scala.util.chaining.scalaUtilChainingOps @Path("/auth/google") class GoogleAuthResource { @@ -69,81 +63,15 @@ class GoogleAuthResource { .flatMap(_.split("/").lastOption) .getOrElse("") - val user = SqlServer.withTransaction(SqlServer.getInstance().createDSLContext()) { ctx => - val txUserDao = new UserDao(ctx.configuration()) - val txAuthDao = new AuthProviderDao(ctx.configuration()) - - Option( - ctx - .select() - .from(USER) - .join(AUTH_PROVIDER) - .on(USER.UID.eq(AUTH_PROVIDER.UID)) - .where(AUTH_PROVIDER.PROVIDER_TYPE.eq(ProviderTypeEnum.GOOGLE)) - .and(AUTH_PROVIDER.PROVIDER_ID.eq(googleId)) - .fetchOne() - ) match { - case Some(record) => - val uid = record.get(USER.UID) - txUserDao.fetchOneByUid(uid).tap { user => - // name/email/avatar are profile fields on "user"; refresh from Google if changed - if ( - user.getName != googleName || user.getEmail != googleEmail || user.getAvatar != googleAvatar - ) { - user.setName(googleName) - user.setEmail(googleEmail) - user.setAvatar(googleAvatar) - txUserDao.update(user) - } - } - case None => - val user = Option(txUserDao.fetchOneByEmail(googleEmail)) match { - case Some(user) => - user.tap { user => - if (user.getName != googleName || user.getAvatar != googleAvatar) { - user.setName(googleName) - user.setAvatar(googleAvatar) - txUserDao.update(user) - } - } - case None => - new User().tap { user => - user.setName(googleName) - user.setEmail(googleEmail) - user.setAvatar(googleAvatar) - user.setRole(UserRoleEnum.INACTIVE) - txUserDao.insert(user) - } - } - - // an email-matched user may already have a GOOGLE provider row, so - // upsert rather than blindly insert (avoids a (uid, provider_type) PK collision) - val hasGoogleProvider = ctx.fetchExists( - ctx - .selectFrom(AUTH_PROVIDER) - .where(AUTH_PROVIDER.UID.eq(user.getUid)) - .and(AUTH_PROVIDER.PROVIDER_TYPE.eq(ProviderTypeEnum.GOOGLE)) - ) - if (hasGoogleProvider) { - ctx - .update(AUTH_PROVIDER) - .set(AUTH_PROVIDER.PROVIDER_ID, googleId) - .where(AUTH_PROVIDER.UID.eq(user.getUid)) - .and(AUTH_PROVIDER.PROVIDER_TYPE.eq(ProviderTypeEnum.GOOGLE)) - .execute() - } else { - txAuthDao.insert( - new AuthProvider().tap { auth => - auth.setUid(user.getUid) - auth.setProviderType(ProviderTypeEnum.GOOGLE) - auth.setProviderId(googleId) - auth.setCreatedAt(OffsetDateTime.now()) - } - ) - } - user - } - } + val user = ExternalAuthProvisioner.loginOrProvision( + ExternalProfile( + ProviderTypeEnum.GOOGLE, + googleId, + googleName, + googleEmail, + Some(googleAvatar) + ) + ) TokenIssueResponse(jwtToken(jwtClaims(user, TOKEN_EXPIRE_TIME_IN_MINUTES))) } else throw new NotAuthorizedException("Login credentials are incorrect.") } diff --git a/frontend/src/app/common/service/user/facebook-auth.service.ts b/frontend/src/app/common/service/user/facebook-auth.service.ts new file mode 100644 index 00000000000..40c2d56c7ce --- /dev/null +++ b/frontend/src/app/common/service/user/facebook-auth.service.ts @@ -0,0 +1,34 @@ +/** + * 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 { Injectable } from "@angular/core"; +import { Observable } from "rxjs"; +import { HttpClient } from "@angular/common/http"; +import { AppSettings } from "../../app-setting"; + +@Injectable({ + providedIn: "root", +}) +export class FacebookAuthService { + constructor(private http: HttpClient) {} + + getClientId(): Observable { + return this.http.get(`${AppSettings.getApiEndpoint()}/auth/facebook/clientid`, { responseType: "text" }); + } +} \ No newline at end of file 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 00000000000..8d9ff4d65ce --- /dev/null +++ b/frontend/src/app/hub/component/login/texera-login.component.html @@ -0,0 +1,184 @@ + + +
    +
    + Texera + Data science, together +
    + +
    +
    + Sign In +
    +
    + Sign Up +
    +
    + + + +
    + or continue with +
    + +
    +
    + + + + + + +
    + +
    + + + + + + + +
    + +
    + + + + + + +
    + +
    + +
    + +

    + Password must be at least 6 characters. After registering, contact the Texera administrator to activate your + account. +

    + +

    + {{ errorMessage }} +

    + + +
    + +

    + Apache Texera (Incubating) · + texera.apache.org +

    +
    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 00000000000..154d682a023 --- /dev/null +++ b/frontend/src/app/hub/component/login/texera-login.component.scss @@ -0,0 +1,327 @@ +/** + * 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. + */ + +:host { + --primary: #1890ff; + --primary-hover: #40a9ff; + --primary-active: #096dd9; + --text: rgba(0, 0, 0, 0.85); + --text-secondary: rgba(0, 0, 0, 0.45); + --border: #d9d9d9; + --border-hover: #40a9ff; + --bg: #f0f2f5; + + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + padding: 32px 16px; + background: var(--bg); + color: var(--text); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + -webkit-font-smoothing: antialiased; +} + +* { + box-sizing: border-box; +} + +a { + color: var(--primary); + text-decoration: none; + + &:hover { + color: var(--primary-hover); + } +} + +.card { + width: 100%; + max-width: 400px; + background: #fff; + border-radius: 8px; + 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 { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + margin-bottom: 28px; + + img { + height: 46px; + width: auto; + } + + .sub { + font-size: 13px; + color: var(--text-secondary); + letter-spacing: 0.02em; + } +} + +/* tabs */ +.tabs { + display: flex; + margin-bottom: 22px; + border-bottom: 1px solid #f0f0f0; +} + +.tab { + flex: 1; + text-align: center; + padding: 10px 0 12px; + font-size: 15px; + cursor: pointer; + color: var(--text-secondary); + border-bottom: 2px solid transparent; + margin-bottom: -1px; + transition: + color 0.2s, + border-color 0.2s; + user-select: none; + + &:hover { + color: var(--primary); + } + + &.active { + color: var(--primary); + font-weight: 500; + border-bottom-color: var(--primary); + } +} + +/* social sign-in buttons */ +.social-buttons { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; +} + +/* google button (rendered by the social-login library) */ +.google-wrapper { + display: flex; + justify-content: center; +} + +/* facebook: custom button (the library ships no Facebook button component) */ +.btn-facebook { + position: relative; + width: 100%; + max-width: 328px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + background: #1877f2; + border: none; + border-radius: 6px; + color: #fff; + font-size: 15px; + font-weight: 500; + font-family: inherit; + cursor: pointer; + transition: background 0.2s; + + &:hover { + background: #166fe5; + } + + &:active { + background: #1464cf; + } + + /* Pin the logo to the left (matching Google's standard button) while the label stays centered. */ + svg { + position: absolute; + left: 13px; + width: 18px; + height: 18px; + } +} + +/* divider */ +.divider { + display: flex; + align-items: center; + gap: 16px; + margin: 24px 0; + color: var(--text-secondary); + font-size: 13px; + + &::before, + &::after { + content: ""; + flex: 1; + height: 1px; + background: #f0f0f0; + } +} + +/* fields */ +.form { + display: flex; + flex-direction: column; + gap: 16px; +} + +.field { + position: relative; + display: flex; + align-items: center; + + .icon { + position: absolute; + left: 12px; + display: flex; + color: var(--text-secondary); + pointer-events: none; + + svg { + width: 15px; + height: 15px; + } + } + + input { + width: 100%; + height: 44px; + padding: 0 12px 0 38px; + font-size: 15px; + color: var(--text); + border: 1px solid var(--border); + border-radius: 6px; + background: #fff; + outline: none; + transition: + border-color 0.2s, + box-shadow 0.2s; + font-family: inherit; + + &::placeholder { + color: #bfbfbf; + } + + &:hover { + border-color: var(--border-hover); + } + + &:focus { + border-color: var(--primary); + box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2); + } + } + + .toggle { + position: absolute; + right: 10px; + background: none; + border: none; + cursor: pointer; + color: var(--text-secondary); + display: flex; + padding: 4px; + + &:hover { + color: var(--text); + } + + svg { + width: 16px; + height: 16px; + } + } +} + +.row-extra { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 13px; + margin-top: -4px; +} + +.remember { + display: flex; + align-items: center; + gap: 7px; + color: var(--text); + cursor: pointer; + user-select: none; + + input { + width: 15px; + height: 15px; + accent-color: var(--primary); + cursor: pointer; + margin: 0; + } +} + +.btn-primary { + width: 100%; + height: 44px; + margin-top: 4px; + border: none; + border-radius: 6px; + cursor: pointer; + background: var(--primary); + color: #fff; + font-size: 15px; + font-weight: 500; + font-family: inherit; + transition: + background 0.2s, + box-shadow 0.2s; + + &:hover { + background: var(--primary-hover); + } + + &:active { + background: var(--primary-active); + } +} + +.hint { + font-size: 13px; + color: var(--text-secondary); + margin: 2px 2px -4px; +} + +.error { + color: #ff4d4f; + font-size: 13px; + margin: -6px 2px 0; +} + +.foot { + text-align: center; + font-size: 13px; + color: var(--text-secondary); + margin-top: 26px; +} 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 00000000000..de2e8550ec5 --- /dev/null +++ b/frontend/src/app/hub/component/login/texera-login.component.ts @@ -0,0 +1,215 @@ +/** + * 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 { NgIf } from "@angular/common"; +import { ActivatedRoute, Router } from "@angular/router"; +import { catchError } from "rxjs/operators"; +import { throwError } from "rxjs"; +import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy"; +import { SocialAuthService, GoogleSigninButtonModule, FacebookLoginProvider } 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"; + +type LoginMode = "signin" | "signup"; + +/** + * Full-page Texera login card: local sign-in / sign-up (tabbed) plus Google sign-in. + * Splits the single-file `Texera Login.html` prototype into the standard 3-way Angular + * component (ts / html / scss), mirroring the AboutComponent layout and reusing the same + * auth wiring as LocalLoginComponent + DashboardComponent (UserService + SocialAuthService). + */ +@UntilDestroy() +@Component({ + selector: "texera-login", + templateUrl: "./texera-login.component.html", + styleUrls: ["./texera-login.component.scss"], + imports: [NgIf, ReactiveFormsModule, GoogleSigninButtonModule], +}) +export class TexeraLoginComponent implements OnInit { + public mode: LoginMode = "signin"; + public passwordVisible = false; + public rememberMe = false; // UI-only: the backend session lifetime is driven by the JWT expiry + 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]), + password: new FormControl("", [Validators.required, Validators.minLength(6)]), + confirm: new FormControl("", [this.confirmationValidator]), + }); + } + + ngOnInit(): void { + // Prefill the local dev credentials when configured, matching LocalLoginComponent. + 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, + }); + } + + // Social sign-in: both Google and Facebook emit here after their sign-in flow. + // Branch on the provider — Google yields an idToken, Facebook an authToken. + this.socialAuthService.authState.pipe(untilDestroyed(this)).subscribe(user => { + const isFacebook = user.provider === FacebookLoginProvider.PROVIDER_ID; + const login$ = isFacebook + ? this.userService.facebookLogin(user.authToken) + : this.userService.googleLogin(user.idToken); + login$ + .pipe( + catchError((e: unknown) => { + this.notificationService.error( + (e as Error)?.message || `${isFacebook ? "Facebook" : "Google"} sign-in failed` + ); + return throwError(() => e); + }), + untilDestroyed(this) + ) + .subscribe(() => this.ngZone.run(() => this.navigateAfterLogin())); + }); + } + + /** + * Facebook has no rendered-button component in the social-login library, so this is wired + * to a custom button and triggers the flow programmatically. The resulting credential + * arrives via socialAuthService.authState (handled in ngOnInit). + */ + public facebookLogin(): void { + this.socialAuthService.signIn(FacebookLoginProvider.PROVIDER_ID).catch((e: unknown) => { + this.notificationService.error((e as Error)?.message || "Facebook sign-in failed"); + }); + } + + public setMode(mode: LoginMode): void { + this.mode = mode; + this.errorMessage = undefined; + // Re-evaluate the confirm-password validator, which only applies in sign-up mode. + 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 password = this.form.get("password")?.value; + const confirm = this.form.get("confirm")?.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; + } + if (password !== confirm) { + this.errorMessage = "Two passwords are inconsistent."; + return; + } + + this.userService + .register(username, 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; + }; +} From a6f28340720fb572a3653c8868300760194ee29d Mon Sep 17 00:00:00 2001 From: Neilk1021 Date: Tue, 21 Jul 2026 12:49:49 -0700 Subject: [PATCH 17/26] test(Auth): Added tests to cover front and backend modifications --- .../auth/ExternalAuthProvisionerSpec.scala | 197 +++++++++++ .../org/apache/texera/auth/JwtAuthSpec.scala | 8 + .../user/facebook-auth.service.spec.ts | 55 +++ .../login/texera-login.component.spec.ts | 326 ++++++++++++++++++ 4 files changed, 586 insertions(+) create mode 100644 amber/src/test/scala/org/apache/texera/web/resource/auth/ExternalAuthProvisionerSpec.scala create mode 100644 frontend/src/app/common/service/user/facebook-auth.service.spec.ts create mode 100644 frontend/src/app/hub/component/login/texera-login.component.spec.ts diff --git a/amber/src/test/scala/org/apache/texera/web/resource/auth/ExternalAuthProvisionerSpec.scala b/amber/src/test/scala/org/apache/texera/web/resource/auth/ExternalAuthProvisionerSpec.scala new file mode 100644 index 00000000000..17ce38a95f8 --- /dev/null +++ b/amber/src/test/scala/org/apache/texera/web/resource/auth/ExternalAuthProvisionerSpec.scala @@ -0,0 +1,197 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.texera.web.resource.auth + +import org.apache.texera.dao.MockTexeraDB +import org.apache.texera.dao.jooq.generated.Tables.{AUTH_PROVIDER, USER} +import org.apache.texera.dao.jooq.generated.enums.{ProviderTypeEnum, UserRoleEnum} +import org.apache.texera.dao.jooq.generated.tables.daos.{AuthProviderDao, UserDao} +import org.apache.texera.dao.jooq.generated.tables.pojos.{AuthProvider, User} +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers +import org.scalatest.{BeforeAndAfterAll, BeforeAndAfterEach} + +/** + * Integration spec for [[ExternalAuthProvisioner]] against embedded Postgres + * ([[MockTexeraDB]] loads the real `texera_ddl.sql`, so the `auth_provider` table and + * its `ck_provider_credential` / `uq_provider_identity` constraints are exercised). + * `loginOrProvision` runs the same transaction the Google/Facebook resources call. + */ +class ExternalAuthProvisionerSpec + extends AnyFlatSpec + with Matchers + with BeforeAndAfterAll + with BeforeAndAfterEach + with MockTexeraDB { + + // All test users share this email suffix so cleanup can target them precisely; + // the auth_provider FK is ON DELETE CASCADE, so deleting the user clears its rows. + private val emailDomain = "@provisioner-test.com" + + private var userDao: UserDao = _ + private var authDao: AuthProviderDao = _ + + override protected def beforeAll(): Unit = { + initializeDBAndReplaceDSLContext() + userDao = new UserDao(getDSLContext.configuration()) + authDao = new AuthProviderDao(getDSLContext.configuration()) + } + + override protected def afterAll(): Unit = shutdownDB() + + override protected def beforeEach(): Unit = cleanup() + override protected def afterEach(): Unit = cleanup() + + private def cleanup(): Unit = + getDSLContext.deleteFrom(USER).where(USER.EMAIL.like("%" + emailDomain)).execute() + + // ---- helpers ------------------------------------------------------------- + + /** Seed a user row directly; uid is DB-assigned and read back into the pojo. */ + private def seedUser(name: String, localPart: String, avatar: String = null): User = { + val user = new User + user.setName(name) + user.setEmail(localPart + emailDomain) + user.setRole(UserRoleEnum.REGULAR) + if (avatar != null) user.setAvatar(avatar) + userDao.insert(user) + user + } + + /** Seed an external (non-LOCAL) provider row for an existing user. */ + private def seedExternalProvider(uid: Integer, pt: ProviderTypeEnum, providerId: String): Unit = { + val auth = new AuthProvider + auth.setUid(uid) + auth.setProviderType(pt) + auth.setProviderId(providerId) + authDao.insert(auth) + } + + private def providerRowCount(uid: Integer): Int = + getDSLContext.fetchCount(AUTH_PROVIDER, AUTH_PROVIDER.UID.eq(uid)) + + private def providerIdOf(uid: Integer, pt: ProviderTypeEnum): String = + getDSLContext + .select(AUTH_PROVIDER.PROVIDER_ID) + .from(AUTH_PROVIDER) + .where(AUTH_PROVIDER.UID.eq(uid)) + .and(AUTH_PROVIDER.PROVIDER_TYPE.eq(pt)) + .fetchOne(AUTH_PROVIDER.PROVIDER_ID) + + private def userCountByEmail(localPart: String): Int = + getDSLContext.fetchCount(USER, USER.EMAIL.eq(localPart + emailDomain)) + + // ---- new-identity provisioning ------------------------------------------- + + "ExternalAuthProvisioner.loginOrProvision" should "create an INACTIVE user and provider row for a brand-new Google identity" in { + val user = ExternalAuthProvisioner.loginOrProvision( + ExternalProfile(ProviderTypeEnum.GOOGLE, "google-sub-1", "New User", "new" + emailDomain, Some("avatar1")) + ) + + user.getUid should not be null + user.getName shouldBe "New User" + user.getEmail shouldBe "new" + emailDomain + user.getAvatar shouldBe "avatar1" + user.getRole shouldBe UserRoleEnum.INACTIVE + + providerRowCount(user.getUid) shouldBe 1 + providerIdOf(user.getUid, ProviderTypeEnum.GOOGLE) shouldBe "google-sub-1" + } + + it should "leave avatar null for a new Facebook identity (avatar = None)" in { + val user = ExternalAuthProvisioner.loginOrProvision( + ExternalProfile(ProviderTypeEnum.FACEBOOK, "fb-1", "FB User", "fb" + emailDomain) + ) + + user.getAvatar shouldBe null + providerIdOf(user.getUid, ProviderTypeEnum.FACEBOOK) shouldBe "fb-1" + } + + // ---- returning known identity -------------------------------------------- + + it should "be idempotent for a returning identity (same uid, no duplicate provider row or user)" in { + val profile = + ExternalProfile(ProviderTypeEnum.GOOGLE, "google-sub-return", "Ret", "ret" + emailDomain, Some("a")) + + val first = ExternalAuthProvisioner.loginOrProvision(profile) + val second = ExternalAuthProvisioner.loginOrProvision(profile) + + second.getUid shouldBe first.getUid + providerRowCount(first.getUid) shouldBe 1 + userCountByEmail("ret") shouldBe 1 + } + + it should "refresh drifted profile fields for a known identity" in { + ExternalAuthProvisioner.loginOrProvision( + ExternalProfile(ProviderTypeEnum.GOOGLE, "sub-drift", "Old Name", "drift" + emailDomain, Some("oldpic")) + ) + val updated = ExternalAuthProvisioner.loginOrProvision( + ExternalProfile(ProviderTypeEnum.GOOGLE, "sub-drift", "New Name", "drift" + emailDomain, Some("newpic")) + ) + + updated.getName shouldBe "New Name" + updated.getAvatar shouldBe "newpic" + // confirm it persisted, not just mutated in memory + userDao.fetchOneByUid(updated.getUid).getName shouldBe "New Name" + userDao.fetchOneByUid(updated.getUid).getAvatar shouldBe "newpic" + } + + it should "not clobber an existing avatar when the provider supplies none (Facebook, avatar = None)" in { + val existing = seedUser("Has Avatar", "avatarkeep", avatar = "keep-me") + seedExternalProvider(existing.getUid, ProviderTypeEnum.FACEBOOK, "fb-keep") + + val result = ExternalAuthProvisioner.loginOrProvision( + ExternalProfile(ProviderTypeEnum.FACEBOOK, "fb-keep", "Renamed", "avatarkeep" + emailDomain) + ) + + result.getName shouldBe "Renamed" // name still refreshed + result.getAvatar shouldBe "keep-me" // avatar untouched + userDao.fetchOneByUid(existing.getUid).getAvatar shouldBe "keep-me" + } + + // ---- email match, no provider yet ---------------------------------------- + + it should "link a new provider to an existing email-matched user instead of creating a duplicate" in { + val existing = seedUser("Local User", "linkme") + + val result = ExternalAuthProvisioner.loginOrProvision( + ExternalProfile(ProviderTypeEnum.GOOGLE, "sub-link", "Local User", "linkme" + emailDomain, Some("pic")) + ) + + result.getUid shouldBe existing.getUid + userCountByEmail("linkme") shouldBe 1 + providerIdOf(existing.getUid, ProviderTypeEnum.GOOGLE) shouldBe "sub-link" + } + + // ---- email match, provider row exists with a different id (upsert) -------- + + it should "update the existing provider id in place rather than inserting a colliding row" in { + val existing = seedUser("Rotating", "rotate") + seedExternalProvider(existing.getUid, ProviderTypeEnum.GOOGLE, "old-sub") + + val result = ExternalAuthProvisioner.loginOrProvision( + ExternalProfile(ProviderTypeEnum.GOOGLE, "new-sub", "Rotating", "rotate" + emailDomain, Some("p")) + ) + + result.getUid shouldBe existing.getUid + providerRowCount(existing.getUid) shouldBe 1 // upserted, not a second row + providerIdOf(existing.getUid, ProviderTypeEnum.GOOGLE) shouldBe "new-sub" + } +} diff --git a/common/auth/src/test/scala/org/apache/texera/auth/JwtAuthSpec.scala b/common/auth/src/test/scala/org/apache/texera/auth/JwtAuthSpec.scala index 57eccdaa15b..b795d998f03 100644 --- a/common/auth/src/test/scala/org/apache/texera/auth/JwtAuthSpec.scala +++ b/common/auth/src/test/scala/org/apache/texera/auth/JwtAuthSpec.scala @@ -67,6 +67,13 @@ class JwtAuthSpec extends AnyFlatSpec with Matchers { user.getRole shouldBe UserRoleEnum.ADMIN } + it should "carry the user's avatar onto the avatar claim" in { + val user = buildUser() + user.setAvatar("avatar-123") + val claims = JwtAuth.jwtClaims(user, 1) + claims.getClaimValueAsString("avatar") shouldBe "avatar-123" + } + it should "carry through null optional fields without error" in { val user = new User() user.setUid(7) @@ -75,5 +82,6 @@ class JwtAuthSpec extends AnyFlatSpec with Matchers { val claims = JwtAuth.jwtClaims(user, 1) claims.getSubject shouldBe "bob" claims.getClaimValueAsString("email") shouldBe null + claims.getClaimValueAsString("avatar") shouldBe null } } diff --git a/frontend/src/app/common/service/user/facebook-auth.service.spec.ts b/frontend/src/app/common/service/user/facebook-auth.service.spec.ts new file mode 100644 index 00000000000..abe1cfee4ce --- /dev/null +++ b/frontend/src/app/common/service/user/facebook-auth.service.spec.ts @@ -0,0 +1,55 @@ +/** + * 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 { TestBed } from "@angular/core/testing"; +import { HttpClientTestingModule, HttpTestingController } from "@angular/common/http/testing"; +import { firstValueFrom } from "rxjs"; + +import { FacebookAuthService } from "./facebook-auth.service"; +import { AppSettings } from "../../app-setting"; + +describe("FacebookAuthService", () => { + let service: FacebookAuthService; + let httpTestingController: HttpTestingController; + const expectedUrl = `${AppSettings.getApiEndpoint()}/auth/facebook/clientid`; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [FacebookAuthService], + }); + service = TestBed.inject(FacebookAuthService); + httpTestingController = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpTestingController.verify(); + }); + + it("issues a GET to the client-id endpoint and emits the returned id", async () => { + const clientId$ = firstValueFrom(service.getClientId()); + + const req = httpTestingController.expectOne( + r => r.method === "GET" && r.url === expectedUrl && r.responseType === "text" + ); + req.flush("facebook-client-id-abc"); + + expect(await clientId$).toBe("facebook-client-id-abc"); + }); +}); 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 00000000000..e2736da8d21 --- /dev/null +++ b/frontend/src/app/hub/component/login/texera-login.component.spec.ts @@ -0,0 +1,326 @@ +/** + * 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, FacebookLoginProvider, SocialUser } from "@abacritt/angularx-social-login"; + +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; + + let userServiceMock: Partial; + let notificationServiceMock: Partial; + let routerMock: Partial; + let activatedRouteMock: { snapshot: Partial }; + let socialAuthServiceMock: Partial; + let authState$: Subject; + + const facebookUser = (authToken: string): SocialUser => + ({ provider: FacebookLoginProvider.PROVIDER_ID, authToken } as unknown as SocialUser); + const googleUser = (idToken: string): SocialUser => + ({ provider: "GOOGLE", idToken } as unknown as SocialUser); + + const createComponent = async (queryParams: Record = {}) => { + TestBed.resetTestingModule(); + authState$ = new Subject(); + userServiceMock = { + login: vi.fn().mockReturnValue(of(undefined)), + register: vi.fn().mockReturnValue(of(undefined)), + googleLogin: vi.fn().mockReturnValue(of(undefined)), + facebookLogin: vi.fn().mockReturnValue(of(undefined)), + }; + notificationServiceMock = { + error: vi.fn(), + success: vi.fn(), + }; + routerMock = { + navigateByUrl: vi.fn(), + }; + activatedRouteMock = { + snapshot: { queryParams } as Partial, + }; + socialAuthServiceMock = { + authState: authState$.asObservable(), + // GoogleSigninButtonDirective subscribes to initState in its constructor; + // EMPTY keeps the subscription open without triggering google.accounts.id.renderButton. + initState: EMPTY, + signIn: vi.fn().mockResolvedValue(facebookUser("fb-token")), + }; + + await TestBed.configureTestingModule({ + imports: [TexeraLoginComponent, HttpClientTestingModule], + providers: [ + { provide: UserService, useValue: userServiceMock }, + { provide: NotificationService, useValue: notificationServiceMock }, + { provide: Router, useValue: routerMock }, + { provide: ActivatedRoute, useValue: activatedRouteMock }, + { 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(""); + }); + }); + + 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.get("password")!.setValue("abcdef"); + const confirm = component.form.get("confirm")!; + confirm.setValue("zzzzzz"); + confirm.updateValueAndValidity(); + expect(confirm.hasError("confirm")).toBe(true); + + confirm.setValue("abcdef"); + confirm.updateValueAndValidity(); + expect(confirm.hasError("confirm")).toBe(false); + }); + + it("does not flag a mismatch in sign-in mode", () => { + component.setMode("signin"); + component.form.get("password")!.setValue("abcdef"); + const confirm = component.form.get("confirm")!; + confirm.setValue("different"); + confirm.updateValueAndValidity(); + expect(confirm.hasError("confirm")).toBe(false); + }); + }); + + describe("submit -> login (sign-in mode)", () => { + beforeEach(() => component.setMode("signin")); + + it("short-circuits and sets errorMessage when validateUsername fails", () => { + const validateSpy = vi + .spyOn(UserService, "validateUsername") + .mockReturnValue({ result: false, message: "Username should not be empty." }); + component.form.patchValue({ username: "", password: "123456" }); + + component.submit(); + + expect(validateSpy).toHaveBeenCalledWith(""); + expect(component.errorMessage).toBe("Username should not be empty."); + expect(userServiceMock.login).not.toHaveBeenCalled(); + expect(routerMock.navigateByUrl).not.toHaveBeenCalled(); + }); + + it("sets errorMessage when the password is shorter than 6 characters", () => { + vi.spyOn(UserService, "validateUsername").mockReturnValue({ result: true, message: "ok" }); + component.form.patchValue({ username: "alice", password: "12345" }); + + component.submit(); + + expect(component.errorMessage).toBe("Password length should be greater than 5."); + expect(userServiceMock.login).not.toHaveBeenCalled(); + }); + + it("calls UserService.login with a trimmed username and navigates to USER_WORKFLOW on success", () => { + vi.spyOn(UserService, "validateUsername").mockReturnValue({ result: true, message: "ok" }); + component.form.patchValue({ username: " alice ", password: "secret" }); + + component.submit(); + + expect(userServiceMock.login).toHaveBeenCalledWith("alice", "secret"); + expect(routerMock.navigateByUrl).toHaveBeenCalledWith(USER_WORKFLOW); + expect(component.errorMessage).toBeUndefined(); + }); + + it("navigates to queryParams.returnUrl when present", async () => { + await createComponent({ returnUrl: "/custom/return" }); + component.setMode("signin"); + vi.spyOn(UserService, "validateUsername").mockReturnValue({ result: true, message: "ok" }); + component.form.patchValue({ username: "alice", password: "secret" }); + + component.submit(); + + expect(routerMock.navigateByUrl).toHaveBeenCalledWith("/custom/return"); + }); + + it("surfaces the error message on login failure and does not navigate", () => { + vi.spyOn(UserService, "validateUsername").mockReturnValue({ result: true, message: "ok" }); + vi.mocked(userServiceMock.login!).mockReturnValueOnce(throwError(() => new Error("boom"))); + component.form.patchValue({ username: "alice", password: "secret" }); + + component.submit(); + + expect(component.errorMessage).toBe("boom"); + expect(routerMock.navigateByUrl).not.toHaveBeenCalled(); + }); + + it("falls back to a default message when the login error has no message", () => { + vi.spyOn(UserService, "validateUsername").mockReturnValue({ result: true, message: "ok" }); + vi.mocked(userServiceMock.login!).mockReturnValueOnce(throwError(() => ({}))); + component.form.patchValue({ username: "alice", password: "secret" }); + + component.submit(); + + expect(component.errorMessage).toBe("Incorrect username or password"); + }); + }); + + describe("submit -> register (sign-up mode)", () => { + beforeEach(() => component.setMode("signup")); + + it("sets errorMessage when passwords are inconsistent", () => { + vi.spyOn(UserService, "validateUsername").mockReturnValue({ result: true, message: "ok" }); + component.form.patchValue({ username: "alice", password: "abcdef", confirm: "ghijkl" }); + + component.submit(); + + expect(component.errorMessage).toBe("Two passwords are inconsistent."); + expect(userServiceMock.register).not.toHaveBeenCalled(); + }); + + it("calls UserService.register with a trimmed username and shows a success notification", () => { + vi.spyOn(UserService, "validateUsername").mockReturnValue({ result: true, message: "ok" }); + component.form.patchValue({ username: " alice ", password: "abcdef", confirm: "abcdef" }); + + component.submit(); + + expect(userServiceMock.register).toHaveBeenCalledWith("alice", "abcdef"); + expect(notificationServiceMock.success).toHaveBeenCalledWith( + "Your account has been created. Please contact the Texera administrator to activate your account." + ); + expect(component.errorMessage).toBeUndefined(); + }); + + it("surfaces the error message on registration failure", () => { + vi.spyOn(UserService, "validateUsername").mockReturnValue({ result: true, message: "ok" }); + vi.mocked(userServiceMock.register!).mockReturnValueOnce(throwError(() => new Error("nope"))); + component.form.patchValue({ username: "alice", password: "abcdef", confirm: "abcdef" }); + + component.submit(); + + expect(component.errorMessage).toBe("nope"); + expect(notificationServiceMock.success).not.toHaveBeenCalled(); + }); + }); + + describe("social sign-in (authState)", () => { + it("routes a Facebook auth event to UserService.facebookLogin and navigates", () => { + component.ngOnInit(); + + authState$.next(facebookUser("fb-token")); + + expect(userServiceMock.facebookLogin).toHaveBeenCalledWith("fb-token"); + expect(userServiceMock.googleLogin).not.toHaveBeenCalled(); + expect(routerMock.navigateByUrl).toHaveBeenCalledWith(USER_WORKFLOW); + }); + + it("routes a Google auth event to UserService.googleLogin and navigates", () => { + component.ngOnInit(); + + authState$.next(googleUser("g-token")); + + expect(userServiceMock.googleLogin).toHaveBeenCalledWith("g-token"); + expect(userServiceMock.facebookLogin).not.toHaveBeenCalled(); + expect(routerMock.navigateByUrl).toHaveBeenCalledWith(USER_WORKFLOW); + }); + + it("notifies and does not navigate when the social login call fails", () => { + vi.mocked(userServiceMock.facebookLogin!).mockReturnValueOnce(throwError(() => new Error("fb boom"))); + component.ngOnInit(); + + authState$.next(facebookUser("fb-token")); + + expect(notificationServiceMock.error).toHaveBeenCalledWith("fb boom"); + expect(routerMock.navigateByUrl).not.toHaveBeenCalled(); + }); + }); + + describe("facebookLogin()", () => { + it("triggers the Facebook sign-in flow via SocialAuthService", () => { + component.facebookLogin(); + expect(socialAuthServiceMock.signIn).toHaveBeenCalledWith(FacebookLoginProvider.PROVIDER_ID); + }); + + it("notifies when SocialAuthService.signIn rejects", async () => { + vi.mocked(socialAuthServiceMock.signIn!).mockRejectedValueOnce(new Error("sign-in refused")); + + component.facebookLogin(); + await Promise.resolve(); + await Promise.resolve(); + + expect(notificationServiceMock.error).toHaveBeenCalledWith("sign-in refused"); + }); + }); +}); From c3e64a7e0f9e13084cfa95b90ce6d879ae29e555 Mon Sep 17 00:00:00 2001 From: Neilk1021 Date: Tue, 21 Jul 2026 13:05:17 -0700 Subject: [PATCH 18/26] fix(auth): move 28.sql to 29.sql --- sql/changelog.xml | 4 ++-- sql/updates/{28.sql => 29.sql} | 0 2 files changed, 2 insertions(+), 2 deletions(-) rename sql/updates/{28.sql => 29.sql} (100%) diff --git a/sql/changelog.xml b/sql/changelog.xml index b02d608b45a..fe387ea5929 100644 --- a/sql/changelog.xml +++ b/sql/changelog.xml @@ -48,8 +48,8 @@ - - + + + {{ user.localHandle }} {{ user.affiliation }} {{ user.joiningReason }} diff --git a/frontend/src/app/dashboard/component/admin/user/admin-user.component.ts b/frontend/src/app/dashboard/component/admin/user/admin-user.component.ts index c6758f88df8..7acc1d826bb 100644 --- a/frontend/src/app/dashboard/component/admin/user/admin-user.component.ts +++ b/frontend/src/app/dashboard/component/admin/user/admin-user.component.ts @@ -253,6 +253,11 @@ export class AdminUserComponent implements OnInit { return compare === 0 ? a.uid - b.uid : compare; }; + public sortByLocalHandle: NzTableSortFn = (a: User, b: User) => { + const compare = (b.localHandle || "").localeCompare(a.localHandle || ""); + return compare === 0 ? a.uid - b.uid : compare; + }; + public sortByComment: NzTableSortFn = (a: User, b: User) => { const compare = (b.comment || "").localeCompare(a.comment || ""); return compare === 0 ? a.uid - b.uid : compare; diff --git a/sql/texera_ddl.sql b/sql/texera_ddl.sql index c16bde92ba0..8f17a8a2a97 100644 --- a/sql/texera_ddl.sql +++ b/sql/texera_ddl.sql @@ -117,16 +117,15 @@ CREATE TABLE IF NOT EXISTS auth_provider ( uid INT NOT NULL, provider_type provider_type_enum NOT NULL, - provider_id VARCHAR(256), -- external subject id (e.g. Google sub, Facebook id); NULL for LOCAL - password VARCHAR(256), -- hashed credential; only for LOCAL + provider_id VARCHAR(256) NOT NULL, -- subject id at the provider: the login username for LOCAL, the external id (e.g. Google sub, Facebook id) otherwise + password VARCHAR(256), -- hashed credential; only for LOCAL created_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (uid, provider_type), FOREIGN KEY (uid) REFERENCES "user"(uid) ON DELETE CASCADE, + -- one identity per provider; for LOCAL this is what makes the login username unique CONSTRAINT uq_provider_identity UNIQUE (provider_type, provider_id), - CONSTRAINT ck_provider_credential CHECK ( - (provider_type = 'LOCAL' AND password IS NOT NULL AND provider_id IS NULL) OR - (provider_type != 'LOCAL' AND provider_id IS NOT NULL AND password IS NULL) - ) + -- a password exists for LOCAL and only for LOCAL (replaces the old ck_nulltest on "user") + CONSTRAINT ck_provider_credential CHECK ((provider_type = 'LOCAL') = (password IS NOT NULL)) ); -- user_config diff --git a/sql/updates/29.sql b/sql/updates/29.sql index 56ecb7da598..8a796898b67 100644 --- a/sql/updates/29.sql +++ b/sql/updates/29.sql @@ -32,34 +32,92 @@ END $$; -- 2. The auth_provider table. +-- provider_id is the subject id at the provider: the login username for LOCAL, the +-- external id (Google sub, Facebook id) otherwise. It is created nullable here and +-- made NOT NULL at step 6, once existing rows have been backfilled. CREATE TABLE IF NOT EXISTS auth_provider ( uid INT NOT NULL, provider_type provider_type_enum NOT NULL, - provider_id VARCHAR(256), -- external subject id (e.g. Google sub, Facebook id); NULL for LOCAL + provider_id VARCHAR(256), password VARCHAR(256), -- hashed credential; only for LOCAL created_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (uid, provider_type), FOREIGN KEY (uid) REFERENCES "user"(uid) ON DELETE CASCADE, - -- one external identity maps to exactly one Texera user - CONSTRAINT uq_provider_identity UNIQUE (provider_type, provider_id), - - -- credential shape must match the provider (replaces the old ck_nulltest) - CONSTRAINT ck_provider_credential CHECK ( - (provider_type = 'LOCAL' AND password IS NOT NULL AND provider_id IS NULL) OR - (provider_type != 'LOCAL' AND provider_id IS NOT NULL AND password IS NULL) - ) + -- one identity per provider; for LOCAL this is what makes the login username unique + CONSTRAINT uq_provider_identity UNIQUE (provider_type, provider_id) ); +-- 3. Drop the credential check before touching provider_id. On a database that already +-- ran an earlier version of this migration the old check asserts provider_id IS NULL +-- for LOCAL, which would reject the backfill below. Re-added in its new shape at step 6. +ALTER TABLE auth_provider DROP CONSTRAINT IF EXISTS ck_provider_credential; + +-- 4. Pre-flight. "user".name has never been unique, but it is about to become a unique +-- authentication key, so abort naming the offenders rather than let the backfill die +-- with a bare unique_violation. Handles that are blank or whitespace-padded are +-- rejected too: they are reachable today (register stores the name un-trimmed) and +-- make for handles nobody can type. DO $$ +DECLARE + offenders TEXT; + orphans TEXT; BEGIN IF EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_schema = 'texera_db' AND table_name = 'user' AND column_name = 'password' ) THEN - INSERT INTO auth_provider (uid, provider_type, password) - SELECT uid, 'LOCAL'::provider_type_enum, password + -- upgrading from the pre-auth_provider schema: handles come straight from "user" + SELECT string_agg(DISTINCT quote_literal(name), ', ') + INTO offenders + FROM "user" + WHERE password IS NOT NULL + AND (btrim(name) = '' OR name <> btrim(name) OR name IN ( + SELECT name FROM "user" WHERE password IS NOT NULL + GROUP BY name HAVING count(*) > 1)); + + SELECT string_agg(uid::TEXT, ', ') + INTO orphans + FROM "user" + WHERE password IS NULL AND google_id IS NULL; + ELSE + -- re-running: an earlier version of this migration created LOCAL rows with no handle + SELECT string_agg(DISTINCT quote_literal(u.name), ', ') + INTO offenders + FROM "user" u + JOIN auth_provider a ON a.uid = u.uid AND a.provider_type = 'LOCAL' + WHERE a.provider_id IS NULL + AND (btrim(u.name) = '' OR u.name <> btrim(u.name) OR u.name IN ( + SELECT u2.name + FROM "user" u2 + JOIN auth_provider a2 ON a2.uid = u2.uid AND a2.provider_type = 'LOCAL' + WHERE a2.provider_id IS NULL + GROUP BY u2.name HAVING count(*) > 1)); + END IF; + + IF offenders IS NOT NULL THEN + RAISE EXCEPTION 'migration 29: cannot promote "user".name to a login handle - ' + 'the following names are duplicated, blank, or whitespace-padded: %. ' + 'Resolve them and re-run.', offenders; + END IF; + + IF orphans IS NOT NULL THEN + RAISE NOTICE 'migration 29: uid(s) % have neither a password nor a google_id, so they ' + 'get no auth_provider row and cannot log in.', orphans; + END IF; +END +$$; + +-- 5. Backfill. +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'texera_db' AND table_name = 'user' AND column_name = 'password' + ) THEN + INSERT INTO auth_provider (uid, provider_type, provider_id, password) + SELECT uid, 'LOCAL'::provider_type_enum, name, password FROM "user" WHERE password IS NOT NULL ON CONFLICT (uid, provider_type) DO NOTHING; @@ -73,6 +131,20 @@ BEGIN END $$; +-- Fill handles left NULL by an earlier version of this migration; a no-op otherwise. +UPDATE auth_provider a + SET provider_id = u.name + FROM "user" u + WHERE u.uid = a.uid + AND a.provider_type = 'LOCAL' + AND a.provider_id IS NULL; + +-- 6. Every row now has a handle, so make it mandatory and restore the credential check +-- in its new shape: a password exists for LOCAL and only for LOCAL. +ALTER TABLE auth_provider ALTER COLUMN provider_id SET NOT NULL; +ALTER TABLE auth_provider + ADD CONSTRAINT ck_provider_credential CHECK ((provider_type = 'LOCAL') = (password IS NOT NULL)); + -- Keep the avatar as a provider-neutral profile column on "user" (rename in place). -- Guarded so it is a no-op on a fresh DB where "user" already has "avatar". DO $$ From ef0bca6f5d2a98a67f4f453ba21b539aedd7ad71 Mon Sep 17 00:00:00 2001 From: Neilk1021 Date: Mon, 27 Jul 2026 14:44:30 -0700 Subject: [PATCH 25/26] refactor(auth): fix migrations --- .../texera/web/resource/auth/ExternalAuthProvisioner.scala | 4 ++-- .../web/resource/dashboard/file/DatasetResourceSpec.scala | 2 -- .../resource/ComputingUnitAccessSharingDisabledSpec.scala | 1 - 3 files changed, 2 insertions(+), 5 deletions(-) diff --git a/amber/src/main/scala/org/apache/texera/web/resource/auth/ExternalAuthProvisioner.scala b/amber/src/main/scala/org/apache/texera/web/resource/auth/ExternalAuthProvisioner.scala index 2e821ede55d..d3869a12051 100644 --- a/amber/src/main/scala/org/apache/texera/web/resource/auth/ExternalAuthProvisioner.scala +++ b/amber/src/main/scala/org/apache/texera/web/resource/auth/ExternalAuthProvisioner.scala @@ -133,7 +133,7 @@ object ExternalAuthProvisioner { if (hasProvider) { ctx .update(AUTH_PROVIDER) - .set(AUTH_PROVIDER.SECRET, profile.providerId) + .set(AUTH_PROVIDER.PROVIDER_ID, profile.providerId) .where(AUTH_PROVIDER.UID.eq(user.getUid)) .and(AUTH_PROVIDER.PROVIDER_TYPE.eq(profile.providerType)) .execute() @@ -142,7 +142,7 @@ object ExternalAuthProvisioner { new AuthProvider().tap { auth => auth.setUid(user.getUid) auth.setProviderType(profile.providerType) - auth.setSecret(profile.providerId) + auth.setProviderId(profile.providerId) auth.setCreatedAt(OffsetDateTime.now()) } ) diff --git a/amber/src/test/scala/org/apache/texera/web/resource/dashboard/file/DatasetResourceSpec.scala b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/file/DatasetResourceSpec.scala index 1d4b5635e04..70b8252c096 100644 --- a/amber/src/test/scala/org/apache/texera/web/resource/dashboard/file/DatasetResourceSpec.scala +++ b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/file/DatasetResourceSpec.scala @@ -52,7 +52,6 @@ class DatasetResourceSpec user.setName("owner_user") user.setRole(UserRoleEnum.ADMIN) user.setEmail("owner_user@mail.com") - user.setPassword("123") user.setComment("test_comment") user.setAccountCreationTime(exampleCreationTime) user @@ -64,7 +63,6 @@ class DatasetResourceSpec user.setName("test_user") user.setEmail("test_user@mail.com") user.setRole(UserRoleEnum.REGULAR) - user.setPassword("123") user.setComment("test_comment2") user.setAccountCreationTime(exampleCreationTime) user diff --git a/computing-unit-managing-service/src/test/scala/org/apache/texera/service/resource/ComputingUnitAccessSharingDisabledSpec.scala b/computing-unit-managing-service/src/test/scala/org/apache/texera/service/resource/ComputingUnitAccessSharingDisabledSpec.scala index 675ad5ccddd..ffd92658fa8 100644 --- a/computing-unit-managing-service/src/test/scala/org/apache/texera/service/resource/ComputingUnitAccessSharingDisabledSpec.scala +++ b/computing-unit-managing-service/src/test/scala/org/apache/texera/service/resource/ComputingUnitAccessSharingDisabledSpec.scala @@ -48,7 +48,6 @@ class ComputingUnitAccessSharingDisabledSpec private val user: User = { val u = new User u.setName("cu_user") - u.setPassword("123") u.setEmail("cu_user@test.com") u.setRole(UserRoleEnum.REGULAR) u From 2329c507482f9f3c2c2f6e8d50e7b1f6db0bb6a1 Mon Sep 17 00:00:00 2001 From: Neilk1021 Date: Tue, 28 Jul 2026 13:08:23 -0700 Subject: [PATCH 26/26] fix(auth): migrate google functionality / fix logout on external auth providers --- .../resource/auth/GoogleAuthResource.scala | 2 - .../app/common/service/user/auth.service.ts | 19 ++++++- .../common/service/user/stub-user.service.ts | 4 ++ .../app/common/service/user/user.service.ts | 17 +++++++ .../component/dashboard.component.spec.ts | 51 +++++++++++++++++-- .../component/dashboard.component.ts | 35 +++++++++---- .../login/texera-login.component.spec.ts | 36 +++++++++---- .../component/login/texera-login.component.ts | 51 +++++++++++-------- 8 files changed, 166 insertions(+), 49 deletions(-) diff --git a/amber/src/main/scala/org/apache/texera/web/resource/auth/GoogleAuthResource.scala b/amber/src/main/scala/org/apache/texera/web/resource/auth/GoogleAuthResource.scala index b6ffb0d83ba..550386db9f2 100644 --- a/amber/src/main/scala/org/apache/texera/web/resource/auth/GoogleAuthResource.scala +++ b/amber/src/main/scala/org/apache/texera/web/resource/auth/GoogleAuthResource.scala @@ -55,8 +55,6 @@ class GoogleAuthResource { val payload = idToken.getPayload val googleId = payload.getSubject val googleEmail = payload.getEmail - // "name" is not guaranteed on the payload; fall back to the email so we - // never write null into the NOT NULL user.name column val googleName = Option(payload.get("name").asInstanceOf[String]).filter(_.nonEmpty).getOrElse(googleEmail) val googleAvatar = Option(payload.get("picture").asInstanceOf[String]) diff --git a/frontend/src/app/common/service/user/auth.service.ts b/frontend/src/app/common/service/user/auth.service.ts index 37c734bd542..592712dc933 100644 --- a/frontend/src/app/common/service/user/auth.service.ts +++ b/frontend/src/app/common/service/user/auth.service.ts @@ -18,7 +18,8 @@ */ import { HttpClient } from "@angular/common/http"; -import { Injectable } from "@angular/core"; +import { Injectable, Injector } from "@angular/core"; +import { SocialAuthService } from "@abacritt/angularx-social-login"; import { firstValueFrom, Observable, Subscription, timer } from "rxjs"; import { AppSettings } from "../../app-setting"; import { Role, User } from "../../type/user"; @@ -56,7 +57,8 @@ export class AuthService { private notificationService: NotificationService, private gmailService: GmailService, private config: GuiConfigService, - private modal: NzModalService + private modal: NzModalService, + private injector: Injector ) {} /** @@ -114,9 +116,22 @@ export class AuthService { public logout(): undefined { AuthService.removeAccessToken(); this.tokenExpirationSubscription?.unsubscribe(); + this.signOutOfSocialProviders(); return undefined; } + private signOutOfSocialProviders(): void { + let socialAuthService: SocialAuthService; + try { + socialAuthService = this.injector.get(SocialAuthService); + } catch { + // Social login is not configured in this context; there is no session to clear. + return; + } + + socialAuthService.signOut().catch(() => {}); + } + public loginWithExistingToken(): User | undefined { this.tokenExpirationSubscription?.unsubscribe(); const token = AuthService.getAccessToken(); diff --git a/frontend/src/app/common/service/user/stub-user.service.ts b/frontend/src/app/common/service/user/stub-user.service.ts index 6f3dd64603f..3864d1ef496 100644 --- a/frontend/src/app/common/service/user/stub-user.service.ts +++ b/frontend/src/app/common/service/user/stub-user.service.ts @@ -52,6 +52,10 @@ export class StubUserService implements PublicInterfaceOf { this.userChangeSubject.next(this.user); } + externalLogin(): Observable { + throw new Error("Method not implemented."); + } + googleLogin(): Observable { throw new Error("Method not implemented."); } diff --git a/frontend/src/app/common/service/user/user.service.ts b/frontend/src/app/common/service/user/user.service.ts index 810dd19cf42..b6d78c4c173 100644 --- a/frontend/src/app/common/service/user/user.service.ts +++ b/frontend/src/app/common/service/user/user.service.ts @@ -25,6 +25,7 @@ import { Role, User } from "../../type/user"; import { AuthService } from "./auth.service"; import { GuiConfigService } from "../gui-config.service"; import { catchError, map, shareReplay, switchMap } from "rxjs/operators"; +import { FacebookLoginProvider, SocialUser } from "@abacritt/angularx-social-login"; /** * User Service manages User information. It relies on different @@ -58,6 +59,22 @@ export class UserService { .pipe(switchMap(({ accessToken }) => this.handleAccessToken(accessToken))); } + /** + * Exchange a verified external identity for a Texera session. "External" matches the backend + * counterpart (`ExternalAuthProvisioner` / `ExternalProfile`); `Social*` names below belong to + * the angularx-social-login library, not to this codebase's vocabulary. + * + * Both providers emit on the same `SocialAuthService.authState`, so every subscriber has to + * dispatch on `user.provider`: Google supplies an `idToken`, Facebook an `authToken`, and the + * two backend endpoints verify different things. Keeping that dispatch here means the login + * page and the dashboard cannot drift apart on it. + */ + public externalLogin(user: SocialUser): Observable { + return user.provider === FacebookLoginProvider.PROVIDER_ID + ? this.facebookLogin(user.authToken) + : this.googleLogin(user.idToken); + } + public facebookLogin(credential: string): Observable { return this.authService .facebookAuth(credential) diff --git a/frontend/src/app/dashboard/component/dashboard.component.spec.ts b/frontend/src/app/dashboard/component/dashboard.component.spec.ts index 19b6a298ad8..86640d9a7c4 100644 --- a/frontend/src/app/dashboard/component/dashboard.component.spec.ts +++ b/frontend/src/app/dashboard/component/dashboard.component.spec.ts @@ -21,11 +21,11 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; import { DashboardComponent } from "./dashboard.component"; import { ChangeDetectorRef, EventEmitter, NgZone } from "@angular/core"; import { By } from "@angular/platform-browser"; -import { EMPTY, of, throwError } from "rxjs"; +import { EMPTY, of, Subject, throwError } from "rxjs"; import { UserService } from "../../common/service/user/user.service"; import { FlarumService } from "../service/user/flarum/flarum.service"; -import { SocialAuthService } from "@abacritt/angularx-social-login"; +import { FacebookLoginProvider, SocialAuthService, SocialUser } from "@abacritt/angularx-social-login"; import { AdminSettingsService } from "../service/admin/settings/admin-settings.service"; import { ActivatedRoute, @@ -66,6 +66,7 @@ describe("DashboardComponent", () => { let cdrMock: Partial; let ngZoneMock: Partial; let socialAuthServiceMock: Partial; + let authState$: Subject; let adminSettingsServiceMock: Partial; let activatedRouteMock: Partial; @@ -92,6 +93,11 @@ describe("DashboardComponent", () => { isLogin: vi.fn().mockReturnValue(false), userChanged: vi.fn().mockReturnValue(of(null)), getCurrentUser: vi.fn().mockReturnValue(undefined), + externalLogin: vi.fn().mockReturnValue(of(undefined)), + // Present so the assertions below can prove this component never reaches past + // externalLogin to a provider-specific call, which is what the old code did. + googleLogin: vi.fn().mockReturnValue(of(undefined)), + facebookLogin: vi.fn().mockReturnValue(of(undefined)), }; routerMock = { @@ -122,8 +128,9 @@ describe("DashboardComponent", () => { runTask: (fn: () => any) => fn(), }; + authState$ = new Subject(); socialAuthServiceMock = { - authState: EMPTY, + authState: authState$.asObservable(), // GoogleSigninButtonDirective subscribes to initState in its constructor; // EMPTY keeps the subscription open without triggering google.accounts.id.renderButton. initState: EMPTY, @@ -399,4 +406,42 @@ describe("DashboardComponent", () => { expect(Object.values(component.sidebarTabs).every(enabled => enabled === false)).toBe(true); }); }); + + // This component renders its own , so it subscribes to authState + // itself. It used to call googleLogin(user.idToken) unconditionally, which sent a Facebook + // credential (idToken undefined) to the Google endpoint; the exchange now goes through + // UserService.externalLogin, which dispatches on the provider. + describe("external sign-in (authState)", () => { + const facebookUser = (authToken: string): SocialUser => + ({ provider: FacebookLoginProvider.PROVIDER_ID, authToken }) as unknown as SocialUser; + const googleUser = (idToken: string): SocialUser => ({ provider: "GOOGLE", idToken }) as unknown as SocialUser; + + it("hands a Google auth event to UserService.externalLogin and navigates", () => { + const user = googleUser("g-token"); + + authState$.next(user); + + expect(userServiceMock.externalLogin).toHaveBeenCalledWith(user); + expect(routerMock.navigateByUrl).toHaveBeenCalledWith(USER_WORKFLOW); + }); + + it("does not force a Facebook auth event down the Google path", () => { + const user = facebookUser("fb-token"); + + authState$.next(user); + + // The whole SocialUser is forwarded, so the provider is still known downstream — + // as opposed to picking off user.idToken here, which is undefined for Facebook. + expect(userServiceMock.externalLogin).toHaveBeenCalledWith(user); + expect(userServiceMock.googleLogin).not.toHaveBeenCalled(); + }); + + it("ignores a null auth state instead of attempting a login", () => { + // Logout emits null through this subject; reacting to it would sign the user back in. + authState$.next(null as unknown as SocialUser); + + expect(userServiceMock.externalLogin).not.toHaveBeenCalled(); + expect(routerMock.navigateByUrl).not.toHaveBeenCalled(); + }); + }); }); diff --git a/frontend/src/app/dashboard/component/dashboard.component.ts b/frontend/src/app/dashboard/component/dashboard.component.ts index 598959edd80..787f8e48301 100644 --- a/frontend/src/app/dashboard/component/dashboard.component.ts +++ b/frontend/src/app/dashboard/component/dashboard.component.ts @@ -24,7 +24,10 @@ 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 { SocialAuthService, GoogleSigninButtonModule, SocialUser } from "@abacritt/angularx-social-login"; +import { throwError } from "rxjs"; +import { catchError, filter } from "rxjs/operators"; +import { NotificationService } from "../../common/service/notification/notification.service"; import { AdminSettingsService } from "../service/admin/settings/admin-settings.service"; import { GuiConfigService } from "../../common/service/gui-config.service"; @@ -134,6 +137,7 @@ export class DashboardComponent implements OnInit { private socialAuthService: SocialAuthService, private route: ActivatedRoute, private adminSettingsService: AdminSettingsService, + private notificationService: NotificationService, protected config: GuiConfigService ) {} @@ -162,16 +166,27 @@ 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.socialAuthService.authState + .pipe( + filter((user): user is SocialUser => user != null), + untilDestroyed(this) + ) + .subscribe(user => { + this.userService + .externalLogin(user) + .pipe( + catchError((e: unknown) => { + this.notificationService.error((e as Error)?.message || "Sign-in failed"); + return throwError(() => e); + }), + untilDestroyed(this) + ) + .subscribe(() => { + this.ngZone.run(() => { + this.router.navigateByUrl(this.route.snapshot.queryParams["returnUrl"] || USER_WORKFLOW); + }); }); - }); - }); + }); this.loadLogos(); 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 index 1d5c50f5bce..c1fd59d900d 100644 --- a/frontend/src/app/hub/component/login/texera-login.component.spec.ts +++ b/frontend/src/app/hub/component/login/texera-login.component.spec.ts @@ -52,6 +52,7 @@ describe("TexeraLoginComponent", () => { userServiceMock = { login: vi.fn().mockReturnValue(of(undefined)), register: vi.fn().mockReturnValue(of(undefined)), + externalLogin: vi.fn().mockReturnValue(of(undefined)), googleLogin: vi.fn().mockReturnValue(of(undefined)), facebookLogin: vi.fn().mockReturnValue(of(undefined)), }; @@ -274,29 +275,31 @@ describe("TexeraLoginComponent", () => { }); }); - describe("social sign-in (authState)", () => { - it("routes a Facebook auth event to UserService.facebookLogin and navigates", () => { + describe("external sign-in (authState)", () => { + // The provider -> endpoint dispatch itself lives in UserService.externalLogin; here we only + // pin down that the credential is handed over intact and that nav/error handling works. + it("hands a Facebook auth event to UserService.externalLogin and navigates", () => { component.ngOnInit(); + const user = facebookUser("fb-token"); - authState$.next(facebookUser("fb-token")); + authState$.next(user); - expect(userServiceMock.facebookLogin).toHaveBeenCalledWith("fb-token"); - expect(userServiceMock.googleLogin).not.toHaveBeenCalled(); + expect(userServiceMock.externalLogin).toHaveBeenCalledWith(user); expect(routerMock.navigateByUrl).toHaveBeenCalledWith(USER_WORKFLOW); }); - it("routes a Google auth event to UserService.googleLogin and navigates", () => { + it("hands a Google auth event to UserService.externalLogin and navigates", () => { component.ngOnInit(); + const user = googleUser("g-token"); - authState$.next(googleUser("g-token")); + authState$.next(user); - expect(userServiceMock.googleLogin).toHaveBeenCalledWith("g-token"); - expect(userServiceMock.facebookLogin).not.toHaveBeenCalled(); + expect(userServiceMock.externalLogin).toHaveBeenCalledWith(user); expect(routerMock.navigateByUrl).toHaveBeenCalledWith(USER_WORKFLOW); }); - it("notifies and does not navigate when the social login call fails", () => { - vi.mocked(userServiceMock.facebookLogin!).mockReturnValueOnce(throwError(() => new Error("fb boom"))); + it("notifies and does not navigate when the external login call fails", () => { + vi.mocked(userServiceMock.externalLogin!).mockReturnValueOnce(throwError(() => new Error("fb boom"))); component.ngOnInit(); authState$.next(facebookUser("fb-token")); @@ -304,6 +307,17 @@ describe("TexeraLoginComponent", () => { expect(notificationServiceMock.error).toHaveBeenCalledWith("fb boom"); expect(routerMock.navigateByUrl).not.toHaveBeenCalled(); }); + + // Logging out pushes null through authState (and it is a ReplaySubject, so a stale value + // reaches any fresh subscription). Reacting to it would sign the user straight back in. + it("ignores a null auth state instead of attempting a login", () => { + component.ngOnInit(); + + authState$.next(null as unknown as SocialUser); + + expect(userServiceMock.externalLogin).not.toHaveBeenCalled(); + expect(routerMock.navigateByUrl).not.toHaveBeenCalled(); + }); }); describe("facebookLogin()", () => { diff --git a/frontend/src/app/hub/component/login/texera-login.component.ts b/frontend/src/app/hub/component/login/texera-login.component.ts index de2e8550ec5..c0d9c2dbfa9 100644 --- a/frontend/src/app/hub/component/login/texera-login.component.ts +++ b/frontend/src/app/hub/component/login/texera-login.component.ts @@ -29,10 +29,15 @@ import { } from "@angular/forms"; import { NgIf } from "@angular/common"; import { ActivatedRoute, Router } from "@angular/router"; -import { catchError } from "rxjs/operators"; +import { catchError, filter } from "rxjs/operators"; import { throwError } from "rxjs"; import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy"; -import { SocialAuthService, GoogleSigninButtonModule, FacebookLoginProvider } from "@abacritt/angularx-social-login"; +import { + SocialAuthService, + GoogleSigninButtonModule, + FacebookLoginProvider, + 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"; @@ -87,25 +92,29 @@ export class TexeraLoginComponent implements OnInit { }); } - // Social sign-in: both Google and Facebook emit here after their sign-in flow. - // Branch on the provider — Google yields an idToken, Facebook an authToken. - this.socialAuthService.authState.pipe(untilDestroyed(this)).subscribe(user => { - const isFacebook = user.provider === FacebookLoginProvider.PROVIDER_ID; - const login$ = isFacebook - ? this.userService.facebookLogin(user.authToken) - : this.userService.googleLogin(user.idToken); - login$ - .pipe( - catchError((e: unknown) => { - this.notificationService.error( - (e as Error)?.message || `${isFacebook ? "Facebook" : "Google"} sign-in failed` - ); - return throwError(() => e); - }), - untilDestroyed(this) - ) - .subscribe(() => this.ngZone.run(() => this.navigateAfterLogin())); - }); + // External sign-in: both Google and Facebook emit here after their sign-in flow. + // The null filter matters — logging out emits null through this subject, and it is a + // ReplaySubject, so a stale value is replayed to this subscription the moment it starts. + this.socialAuthService.authState + .pipe( + filter((user): user is SocialUser => user != null), + untilDestroyed(this) + ) + .subscribe(user => { + const isFacebook = user.provider === FacebookLoginProvider.PROVIDER_ID; + this.userService + .externalLogin(user) + .pipe( + catchError((e: unknown) => { + this.notificationService.error( + (e as Error)?.message || `${isFacebook ? "Facebook" : "Google"} sign-in failed` + ); + return throwError(() => e); + }), + untilDestroyed(this) + ) + .subscribe(() => this.ngZone.run(() => this.navigateAfterLogin())); + }); } /**