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 357c731ee3f..6ee33a38553 100644 --- a/amber/src/main/scala/org/apache/texera/web/ServletAwareConfigurator.scala +++ b/amber/src/main/scala/org/apache/texera/web/ServletAwareConfigurator.scala @@ -78,6 +78,7 @@ class ServletAwareConfigurator extends ServerEndpointConfig.Configurator with La null, null, null, + null, null ) ) @@ -107,6 +108,7 @@ class ServletAwareConfigurator extends ServerEndpointConfig.Configurator with La null, null, null, + null, null ) ) 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 c2780add35d..4264a9ca180 100644 --- a/amber/src/main/scala/org/apache/texera/web/TexeraWebApplication.scala +++ b/amber/src/main/scala/org/apache/texera/web/TexeraWebApplication.scala @@ -38,6 +38,7 @@ import org.apache.texera.web.resource.dashboard.admin.execution.AdminExecutionRe import org.apache.texera.web.resource.dashboard.admin.settings.AdminSettingsResource import org.apache.texera.web.resource.dashboard.admin.user.AdminUserResource import org.apache.texera.web.resource.dashboard.hub.HubResource +import org.apache.texera.web.resource.dashboard.user.UserResource import org.apache.texera.web.resource.dashboard.user.project.{ ProjectAccessResource, ProjectResource, @@ -140,6 +141,7 @@ class TexeraWebApplication environment.jersey.register(classOf[WorkflowAccessResource]) environment.jersey.register(classOf[WorkflowResource]) environment.jersey.register(classOf[HubResource]) + environment.jersey.register(classOf[UserResource]) environment.jersey.register(classOf[WorkflowVersionResource]) environment.jersey.register(classOf[ProjectResource]) environment.jersey.register(classOf[ProjectAccessResource]) diff --git a/amber/src/main/scala/org/apache/texera/web/auth/GuestAuthFilter.scala b/amber/src/main/scala/org/apache/texera/web/auth/GuestAuthFilter.scala index 40f90ee8eaa..5946c40f11c 100644 --- a/amber/src/main/scala/org/apache/texera/web/auth/GuestAuthFilter.scala +++ b/amber/src/main/scala/org/apache/texera/web/auth/GuestAuthFilter.scala @@ -39,7 +39,7 @@ import javax.ws.rs.core.SecurityContext } val GUEST: User = - new User(null, "guest", null, null, null, null, UserRoleEnum.REGULAR, null, null) + new User(null, "guest", null, null, null, null, UserRoleEnum.REGULAR, null, null, null) } @PreMatching diff --git a/amber/src/main/scala/org/apache/texera/web/auth/UserAuthenticator.scala b/amber/src/main/scala/org/apache/texera/web/auth/UserAuthenticator.scala index 57109273e3d..2a6a2e47707 100644 --- a/amber/src/main/scala/org/apache/texera/web/auth/UserAuthenticator.scala +++ b/amber/src/main/scala/org/apache/texera/web/auth/UserAuthenticator.scala @@ -44,7 +44,18 @@ object UserAuthenticator extends Authenticator[JwtContext, SessionUser] with Laz val accountCreation = context.getJwtClaims.getClaimValue("accountCreation").asInstanceOf[OffsetDateTime] val user = - new User(userId, userName, email, null, googleId, null, role, comment, accountCreation) + new User( + userId, + userName, + email, + null, + googleId, + null, + role, + comment, + accountCreation, + null + ) Optional.of(new SessionUser(user)) } catch { case e: Exception => diff --git a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/admin/user/AdminUserResource.scala b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/admin/user/AdminUserResource.scala index 03ccd9296e3..7372ab6fc00 100644 --- a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/admin/user/AdminUserResource.scala +++ b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/admin/user/AdminUserResource.scala @@ -45,7 +45,8 @@ case class UserInfo( googleAvatar: String, comment: String, lastLogin: java.time.OffsetDateTime, // will be null if never logged in - accountCreation: java.time.OffsetDateTime + accountCreation: java.time.OffsetDateTime, + affiliation: String ) object AdminUserResource { @@ -78,7 +79,8 @@ class AdminUserResource { USER.GOOGLE_AVATAR, USER.COMMENT, USER_LAST_ACTIVE_TIME.LAST_ACTIVE_TIME, - USER.ACCOUNT_CREATION_TIME + USER.ACCOUNT_CREATION_TIME, + USER.AFFILIATION ) .from(USER) .leftJoin(USER_LAST_ACTIVE_TIME) diff --git a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/user/UserResource.scala b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/user/UserResource.scala new file mode 100644 index 00000000000..eaafe7f3230 --- /dev/null +++ b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/user/UserResource.scala @@ -0,0 +1,74 @@ +/* + * 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.dashboard.user + +import org.apache.texera.dao.SqlServer +import org.apache.texera.dao.jooq.generated.tables.daos.UserDao +import org.apache.texera.dao.jooq.generated.tables.User.USER +import javax.ws.rs._ +import javax.ws.rs.core.{MediaType, Response} + +case class AffiliationUpdateRequest(uid: Int, affiliation: String) + +object UserResource { + private lazy val context = SqlServer.getInstance().createDSLContext() + private lazy val userDao = new UserDao(context.configuration) +} + +@Path("/user") +class UserResource { + + /** + * Update the affiliation of a user. + * Used by a first-time user to set their own affiliation. + */ + @PUT + @Path("/affiliation") + @Consumes(Array(MediaType.APPLICATION_JSON)) + def updateAffiliation(request: AffiliationUpdateRequest): Unit = { + val rowsUpdated = UserResource.context + .update(USER) + .set(USER.AFFILIATION, request.affiliation) + .where(USER.UID.eq(request.uid)) + .execute() + + if (rowsUpdated == 0) { + throw new WebApplicationException("User not found", Response.Status.NOT_FOUND) + } + } + + /** + * Gets affiliation with uid. Returns "", null or affiliation. + * "": Prompted and no response + * null: never prompted + * @param uid + * @return + */ + @GET + @Path("/affiliation") + @Produces(Array(MediaType.APPLICATION_JSON)) + def needsAffiliation(@QueryParam("uid") uid: Int): java.lang.Boolean = { + val user = UserResource.userDao.fetchOneByUid(uid) + if (user == null) { + throw new WebApplicationException("User not found", Response.Status.NOT_FOUND) + } + java.lang.Boolean.valueOf(user.getAffiliation == null) + } +} diff --git a/common/auth/src/main/scala/org/apache/texera/auth/JwtParser.scala b/common/auth/src/main/scala/org/apache/texera/auth/JwtParser.scala index 1f7673c2752..48c6bacafc7 100644 --- a/common/auth/src/main/scala/org/apache/texera/auth/JwtParser.scala +++ b/common/auth/src/main/scala/org/apache/texera/auth/JwtParser.scala @@ -52,7 +52,7 @@ object JwtParser extends LazyLogging { val role = UserRoleEnum.valueOf(jwtClaims.getClaimValue("role").asInstanceOf[String]) val googleId = jwtClaims.getClaimValue("googleId", classOf[String]) - val user = new User(userId, userName, email, null, googleId, null, role, null, null) + val user = new User(userId, userName, email, null, googleId, null, role, null, null, null) Optional.of(new SessionUser(user)) } catch { case _: UnresolvableKeyException => diff --git a/frontend/src/app/common/service/user/user.service.ts b/frontend/src/app/common/service/user/user.service.ts index 88ab020c08b..689a95d0286 100644 --- a/frontend/src/app/common/service/user/user.service.ts +++ b/frontend/src/app/common/service/user/user.service.ts @@ -18,6 +18,8 @@ */ import { Injectable } from "@angular/core"; +import { HttpClient } from "@angular/common/http"; +import { AppSettings } from "../../app-setting"; import { Observable, of, ReplaySubject } from "rxjs"; import { Role, User } from "../../type/user"; import { AuthService } from "./auth.service"; @@ -39,7 +41,8 @@ export class UserService { constructor( private authService: AuthService, - private config: GuiConfigService + private config: GuiConfigService, + private http: HttpClient ) { const user = this.authService.loginWithExistingToken(); this.changeUser(user); @@ -82,6 +85,38 @@ export class UserService { .pipe(map(({ accessToken }) => this.handleAccessToken(accessToken))); } + /** + * Retrieves affiliation from backend and return if affiliation has been prompted + * true: already prompted + * false: never prompted + */ + public checkAffiliation(): Observable { + const user = this.currentUser; + if (!user) { + return of(false); + } + return this.http.get(`${AppSettings.getApiEndpoint()}/user/affiliation`, { + params: { uid: user.uid.toString() }, + }); + } + + /** + * updates a new registered user's affiliation + * @param affiliation + */ + public updateAffiliation(affiliation: string): Observable { + const user = this.currentUser; + + if (!user) { + return of(void 0); + } + + return this.http.put(`${AppSettings.getApiEndpoint()}/user/affiliation`, { + uid: user.uid, + affiliation: affiliation, + }); + } + /** * changes the current user and triggers currentUserSubject * @param user diff --git a/frontend/src/app/common/type/user.ts b/frontend/src/app/common/type/user.ts index 4d8b02cc393..2a191d52dc5 100644 --- a/frontend/src/app/common/type/user.ts +++ b/frontend/src/app/common/type/user.ts @@ -46,6 +46,7 @@ export interface User comment: string; lastLogin?: number; accountCreation?: Second; + affiliation?: string; }> {} export interface File diff --git a/frontend/src/app/dashboard/component/admin/user/admin-user.component.html b/frontend/src/app/dashboard/component/admin/user/admin-user.component.html index 446b35c9a29..e3f3d5a2ec7 100644 --- a/frontend/src/app/dashboard/component/admin/user/admin-user.component.html +++ b/frontend/src/app/dashboard/component/admin/user/admin-user.component.html @@ -69,6 +69,7 @@ nzType="search"> + Affiliation + {{ user.affiliation }}
diff --git a/frontend/src/app/dashboard/component/dashboard.component.html b/frontend/src/app/dashboard/component/dashboard.component.html index b238f56b938..d4d3d82d706 100644 --- a/frontend/src/app/dashboard/component/dashboard.component.html +++ b/frontend/src/app/dashboard/component/dashboard.component.html @@ -213,4 +213,36 @@
+ + +

+ To help us understand our users better, please tell us your affiliation (for example, your university, company, + or organization). +

+ +
+ + + + + +
diff --git a/frontend/src/app/dashboard/component/dashboard.component.ts b/frontend/src/app/dashboard/component/dashboard.component.ts index 076b9d28622..26448c88d6c 100644 --- a/frontend/src/app/dashboard/component/dashboard.component.ts +++ b/frontend/src/app/dashboard/component/dashboard.component.ts @@ -42,6 +42,7 @@ import { } from "../../app-routing.constant"; import { Version } from "../../../environments/version"; import { SidebarTabs } from "../../common/type/gui-config"; +import { User } from "../../common/type/user"; @Component({ selector: "texera-dashboard", @@ -74,6 +75,10 @@ export class DashboardComponent implements OnInit { forum_enabled: false, about_enabled: false, }; + // Variables related to updating user's affiliation + affiliationModalVisible = false; + affiliationInput: string = ""; + affiliationSaving = false; protected readonly DASHBOARD_USER_PROJECT = DASHBOARD_USER_PROJECT; protected readonly DASHBOARD_USER_WORKFLOW = DASHBOARD_USER_WORKFLOW; @@ -114,11 +119,12 @@ export class DashboardComponent implements OnInit { this.userService .userChanged() .pipe(untilDestroyed(this)) - .subscribe(() => { + .subscribe(user => { this.ngZone.run(() => { this.isLogin = this.userService.isLogin(); this.isAdmin = this.userService.isAdmin(); this.forumLogin(); + this.checkAffiliationPrompt(user); this.cdr.detectChanges(); }); }); @@ -194,6 +200,67 @@ export class DashboardComponent implements OnInit { } } + /** + * Prompts user to enter affiliation if they have not been prompted before + * @param user + */ + checkAffiliationPrompt(user: User | undefined): void { + // Null affiliation = never prompted before + if (!user || !this.config.env.googleLogin) { + return; + } + + this.userService + .checkAffiliation() + .pipe(untilDestroyed(this)) + .subscribe(response => { + if (response) { + this.affiliationInput = ""; + this.affiliationModalVisible = true; + } else { + this.affiliationModalVisible = false; + } + }); + } + + /** + * Saves the affiliation + */ + saveAffiliation(): void { + const value = this.affiliationInput?.trim() ?? ""; + this.affiliationSaving = true; + + this.userService + .updateAffiliation(value) + .pipe(untilDestroyed(this)) + .subscribe({ + next: () => { + this.affiliationSaving = false; + this.affiliationModalVisible = false; + }, + error: () => { + this.affiliationSaving = false; + this.affiliationModalVisible = false; + }, + }); + } + + /** + * Skips the affiliation input and update the database to store an empty string, which means the user has + * already been prompted. + */ + skipAffiliation(): void { + this.affiliationInput = ""; + this.saveAffiliation(); + } + + /** + * Skips the affiliation input when user closed the prompt window via outside click, ESC + */ + onAffiliationCancel(): void { + this.skipAffiliation(); + } + checkRoute() { const currentRoute = this.router.url; this.displayNavbar = this.isNavbarEnabled(currentRoute); diff --git a/sql/texera_ddl.sql b/sql/texera_ddl.sql index 7b0f9b9063d..48e51dca873 100644 --- a/sql/texera_ddl.sql +++ b/sql/texera_ddl.sql @@ -101,6 +101,7 @@ CREATE TABLE IF NOT EXISTS "user" role user_role_enum NOT NULL DEFAULT 'INACTIVE', comment TEXT, account_creation_time TIMESTAMPTZ NOT NULL DEFAULT now(), + affiliation VARCHAR(128), -- check that either password or google_id is not null CONSTRAINT ck_nulltest CHECK ((password IS NOT NULL) OR (google_id IS NOT NULL)) ); diff --git a/sql/updates/16.sql b/sql/updates/16.sql new file mode 100644 index 00000000000..8776415c466 --- /dev/null +++ b/sql/updates/16.sql @@ -0,0 +1,29 @@ +/* + * 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. + */ + +\c texera_db + +SET search_path TO texera_db; + +BEGIN; + +ALTER TABLE "user" + ADD COLUMN IF NOT EXISTS affiliation VARCHAR(128); + +COMMIT;