This is an automated email from the ASF dual-hosted git repository.

aicam pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/texera.git


The following commit(s) were added to refs/heads/main by this push:
     new 179f177d72 feat: add user registration form (#4185)
179f177d72 is described below

commit 179f177d7299a823b4f75e1221ee7a10eb26a230
Author: Jaeyun Kim <[email protected]>
AuthorDate: Mon Feb 16 10:13:37 2026 -0800

    feat: add user registration form (#4185)
    
    <!--
    Thanks for sending a pull request (PR)! Here are some tips for you:
    1. If this is your first time, please read our contributor guidelines:
    [Contributing to
    Texera](https://github.com/apache/texera/blob/main/CONTRIBUTING.md)
      2. Ensure you have added or run the appropriate tests for your PR
      3. If the PR is work in progress, mark it a draft on GitHub.
      4. Please write your PR title to summarize what this PR proposes, we
        are following Conventional Commits style for PR titles as well.
      5. Be sure to keep the PR description updated to reflect all changes.
    -->
    
    ## What changes were proposed in this PR?
    ### Summary
    To gather more user information for a better overview of users, this PR
    introduces a new column 'joining_reason' to the user table. Now when a
    user logins to the Texera for the first time, they will be prompted to
    enter their affiliation(optional) and reason of joining Texera. The
    answer will be recorded to the database and retrieved when admins enter
    the admin dashboard. The prior affiliation modal implemented in #4113
    has been removed. In accordance to the change, new APIs and files have
    been introduced to the system.
    
    ### For Developers
    Please do the following steps to incorporate with new changes:
    - Apply sql/updates/20.sql to your local postgres instance
    - Run
    common/dao/src/main/scala/org/apache/texera/dao/JooqCodeGenerator.scala
    to generate jooq tables
    
    ### Screenshots of the modal
    <img width="300" height="300" alt="image"
    
src="https://github.com/user-attachments/assets/9edace89-2a6b-4f32-8521-714f0cf28258";
    />
    <img width="400" height="200" alt="image"
    
src="https://github.com/user-attachments/assets/b1e19a07-2e57-4023-bb07-9ac6df50342c";
    />
    
    ### Sample Video
    
    
    
https://github.com/user-attachments/assets/6d4af0f8-be99-4c74-91f7-b59b800c77d9
    
    
    
    
    ### Design of the Feature
    When a user logins to Texera for the first time, they will be prompted
    to enter their affiliation (optional) and reason of joining Texera
    (required) to send request to admin users. Once they finish filling out
    the form, until they get approved (role gets changed from INACTIVE to
    REGULAR), they will keep seeing a message informing them to wait until
    an admin user grants them access.
    
    The new `joining_reason` column in `user` table will be used to
    determine if the system needs to prompt the user or not.
    1. If the `joining_reason` is null, then the modal will prompt user to
    enter affiliation and joining reason (first time logging in).
    2. If `joining_reason` is not null, then the user has answered the form
    before. The system will not prompt, but will inform user to wait until
    an admin user grants access to the user.
    
    Current version of Texera has two forms related to user registration:
    1. Request Access modal that only prompts user to send request or not,
    3. Affiliation modal that prompts user to enter their affiliation after
    an admin user grants access to them.
    As we see redundance on these modals, we decided to combine these modals
    into one modal, therefore we are removing the two modals mentioned
    above.
    
    ### Current `user` schema
    <img width="200" height="400" alt="image"
    
src="https://github.com/user-attachments/assets/b801924d-daab-4c2a-8528-6d377af55033";
    />
    
    ### Proposed `user` schema
    <img width="200" height="400" alt="image"
    
src="https://github.com/user-attachments/assets/f015dbd2-d682-4adc-82a7-3309e7e4e752";
    />
    
    ### Backend Changes
    Added two new API calls:
    - GET /user/joining-reason/required -- Returns if the registration form
    is needed or not
    - PUT /user/joining-reason -- Updates the user's joining reason and
    affiliation
    * As the jwtToken carries joiningReason, some codes related to jwtToken
    has been configured accordingly.
    
    ### Frontend Changes
    - Added new `registration-request-modal` component for the registration
    form.
    - Added new column to admin dashboard
    - Changed User interface, leading to multiple file changes
    
    ### Any related issues, documentation, discussions?
    Closes #4188.
    
    ### How was this PR tested?
    Manually tested.
    
    
    ### Was this PR authored or co-authored using generative AI tooling?
    Generated-by: ChatGPT 5.2
    
    ---------
    
    Co-authored-by: ali risheh <[email protected]>
---
 .../texera/web/ServletAwareConfigurator.scala      |   2 +
 .../apache/texera/web/auth/GuestAuthFilter.scala   |   2 +-
 .../apache/texera/web/auth/UserAuthenticator.scala |   1 +
 .../dashboard/admin/user/AdminUserResource.scala   |   6 +-
 .../web/resource/dashboard/user/UserResource.scala |  61 ++++++------
 .../scala/org/apache/texera/auth/JwtParser.scala   |   3 +-
 frontend/src/app/app.module.ts                     |   2 +
 .../src/app/common/service/gmail/gmail.service.ts  |   2 +-
 .../src/app/common/service/user/auth.service.ts    | 104 +++++++++++++++++++--
 .../registration-request-modal.component.html      |  54 +++++++++++
 .../registration-request-modal.component.scss      |  30 ++++++
 .../registration-request-modal.component.ts        |  51 ++++++++++
 .../app/common/service/user/stub-user.service.ts   |   2 +
 .../src/app/common/service/user/user.service.ts    |  32 -------
 frontend/src/app/common/type/user.ts               |   1 +
 .../component/admin/user/admin-user.component.html |   6 ++
 .../component/admin/user/admin-user.component.ts   |   5 +
 .../dashboard/component/dashboard.component.html   |  32 -------
 .../app/dashboard/component/dashboard.component.ts |  67 +------------
 .../coeditor-user-icon.component.ts                |  10 +-
 sql/texera_ddl.sql                                 |   1 +
 sql/updates/21.sql                                 |  29 ++++++
 22 files changed, 330 insertions(+), 173 deletions(-)

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 6ee33a3855..66a4521064 100644
--- a/amber/src/main/scala/org/apache/texera/web/ServletAwareConfigurator.scala
+++ b/amber/src/main/scala/org/apache/texera/web/ServletAwareConfigurator.scala
@@ -79,6 +79,7 @@ class ServletAwareConfigurator extends 
ServerEndpointConfig.Configurator with La
             null,
             null,
             null,
+            null,
             null
           )
         )
@@ -109,6 +110,7 @@ class ServletAwareConfigurator extends 
ServerEndpointConfig.Configurator with La
                 null,
                 null,
                 null,
+                null,
                 null
               )
             )
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 5946c40f11..b7dda09489 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, null)
+    new User(null, "guest", null, null, null, null, UserRoleEnum.REGULAR, 
null, 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 2a6a2e4770..e7fe67ca10 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
@@ -54,6 +54,7 @@ object UserAuthenticator extends Authenticator[JwtContext, 
SessionUser] with Laz
           role,
           comment,
           accountCreation,
+          null,
           null
         )
       Optional.of(new SessionUser(user))
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 a6778f27e9..cd5ead915d 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
@@ -46,7 +46,8 @@ case class UserInfo(
     comment: String,
     lastLogin: java.time.OffsetDateTime, // will be null if never logged in
     accountCreation: java.time.OffsetDateTime,
-    affiliation: String
+    affiliation: String,
+    joiningReason: String
 )
 
 object AdminUserResource {
@@ -81,7 +82,8 @@ class AdminUserResource {
         USER.COMMENT,
         USER_LAST_ACTIVE_TIME.LAST_ACTIVE_TIME,
         USER.ACCOUNT_CREATION_TIME,
-        USER.AFFILIATION
+        USER.AFFILIATION,
+        USER.JOINING_REASON
       )
       .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
index 6282bf24ea..fd73f0ff4e 100644
--- 
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
@@ -21,11 +21,10 @@ 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)
+case class RegistrationUpdateRequest(uid: Int, affiliation: String, 
joiningReason: String)
 
 object UserResource {
   private def context = SqlServer.getInstance().createDSLContext()
@@ -36,39 +35,45 @@ object UserResource {
 class UserResource {
 
   /**
-    * Update the affiliation of a user.
-    * Used by a first-time user to set their own affiliation.
+    * Checks whether the user needs to submit joining reason.
+    * null: never prompted, need to prompt -> return true
+    * not null: already prompted, no need to prompt -> return false
+    * @param uid: user id
+    * @return boolean value to whether prompt user to enter joining reason or 
not
     */
-  @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) {
+  @GET
+  @Path("/joining-reason/required")
+  @Produces(Array(MediaType.APPLICATION_JSON))
+  def isJoiningReasonRequired(@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.getJoiningReason == null)
   }
 
   /**
-    * Gets affiliation with uid. Returns "", null or affiliation.
-    * "": Prompted and no response
-    * null: never prompted
-    * @param uid
-    * @return
+    * Updates the user's affiliation and joining reason.
+    * This is required and cannot be blank.
+    * @param request: provides uid, affiliation and joining reason
     */
-  @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)
+  @PUT
+  @Path("/joining-reason")
+  @Consumes(Array(MediaType.APPLICATION_JSON))
+  def updateJoiningReason(request: RegistrationUpdateRequest): Unit = {
+    val affiliation = Option(request.affiliation).getOrElse("").trim
+    val reason = Option(request.joiningReason).getOrElse("").trim
+
+    if (reason.isEmpty) {
+      throw new WebApplicationException(
+        "Field 'Reason of joining Texera' cannot be empty",
+        Response.Status.BAD_REQUEST
+      )
     }
-    java.lang.Boolean.valueOf(user.getAffiliation == null)
+
+    val user = UserResource.userDao.fetchOneByUid(request.uid)
+    user.setAffiliation(affiliation)
+    user.setJoiningReason(reason)
+    UserResource.userDao.update(user)
   }
 }
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 48c6bacafc..79e331cc64 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,8 @@ 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, null)
+      val user =
+        new User(userId, userName, email, null, googleId, null, role, null, 
null, null, null)
       Optional.of(new SessionUser(user))
     } catch {
       case _: UnresolvableKeyException =>
diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts
index a591207f60..32cfd2261d 100644
--- a/frontend/src/app/app.module.ts
+++ b/frontend/src/app/app.module.ts
@@ -183,6 +183,7 @@ import { FormlyRepeatDndComponent } from 
"./common/formly/repeat-dnd/repeat-dnd.
 import { NzInputNumberModule } from "ng-zorro-antd/input-number";
 import { NzCheckboxModule } from "ng-zorro-antd/checkbox";
 import { NzRadioModule } from "ng-zorro-antd/radio";
+import { RegistrationRequestModalComponent } from 
"./common/service/user/registration-request-modal/registration-request-modal.component";
 
 registerLocaleData(en);
 
@@ -279,6 +280,7 @@ registerLocaleData(en);
     HubSearchResultComponent,
     ComputingUnitSelectionComponent,
     AdminSettingsComponent,
+    RegistrationRequestModalComponent,
   ],
   imports: [
     BrowserModule,
diff --git a/frontend/src/app/common/service/gmail/gmail.service.ts 
b/frontend/src/app/common/service/gmail/gmail.service.ts
index c4794e7471..4645a694e7 100644
--- a/frontend/src/app/common/service/gmail/gmail.service.ts
+++ b/frontend/src/app/common/service/gmail/gmail.service.ts
@@ -50,7 +50,7 @@ export class GmailService {
 
   public notifyUnauthorizedLogin(userEmail: string): void {
     
this.http.post(`${AppSettings.getApiEndpoint()}/gmail/notify-unauthorized`, { 
receiver: userEmail }).subscribe({
-      next: () => this.notificationService.success("Admin has been notified 
about your account request."),
+      next: () => this.notificationService.success("An admin has been notified 
about your account request."),
       error: (error: unknown) => {
         if (error instanceof HttpErrorResponse) {
           this.notificationService.error("Failed to notify admin about your 
account request.");
diff --git a/frontend/src/app/common/service/user/auth.service.ts 
b/frontend/src/app/common/service/user/auth.service.ts
index d97c8afc25..72f443cb27 100644
--- a/frontend/src/app/common/service/user/auth.service.ts
+++ b/frontend/src/app/common/service/user/auth.service.ts
@@ -19,7 +19,7 @@
 
 import { HttpClient } from "@angular/common/http";
 import { Injectable } from "@angular/core";
-import { Observable, Subscription, timer } from "rxjs";
+import { firstValueFrom, Observable, Subscription, timer } from "rxjs";
 import { AppSettings } from "../../app-setting";
 import { Role, User } from "../../type/user";
 import { ignoreElements } from "rxjs/operators";
@@ -28,6 +28,7 @@ import { NotificationService } from 
"../notification/notification.service";
 import { GmailService } from "../gmail/gmail.service";
 import { GuiConfigService } from "../gui-config.service";
 import { NzModalService } from "ng-zorro-antd/modal";
+import { RegistrationRequestModalComponent } from 
"./registration-request-modal/registration-request-modal.component";
 
 export const TOKEN_KEY = "access_token";
 
@@ -127,15 +128,30 @@ export class AuthService {
     }
 
     const role = this.jwtHelperService.decodeToken(token).role;
+    const uid = this.jwtHelperService.decodeToken(token).userId;
     const email = this.jwtHelperService.decodeToken(token).email;
-    if (this.config.env.inviteOnly && role == Role.INACTIVE) {
-      this.modal.confirm({
-        nzTitle: "You Need Access",
-        nzContent:
-          "Currently the platform is invitation-only. Please request access 
from the platform admin or switch to an account that already has access.",
-        nzOkText: "Send request to Admin",
-        nzCancelText: "Cancel",
-        nzOnOk: () => this.gmailService.notifyUnauthorizedLogin(email),
+    const name = this.jwtHelperService.decodeToken(token).sub;
+
+    if (this.config.env.inviteOnly && role === Role.INACTIVE) {
+      this.checkRegistrationRequired(uid).subscribe(required => {
+        if (required) {
+          this.openRegistrationModal(uid, email, name);
+        } else {
+          this.modal.info({
+            nzTitle: "Access Pending",
+            nzContent: `
+            Your account is still inactive, and we already received your 
request.
+            Please wait for an admin to approve your access.
+          `,
+            nzOkText: "OK",
+            nzMaskClosable: false,
+            nzClosable: false,
+            nzOnOk: () => {
+              this.logout();
+              return true;
+            },
+          });
+        }
       });
 
       return this.logout();
@@ -150,6 +166,7 @@ export class AuthService {
       googleAvatar: this.jwtHelperService.decodeToken(token).googleAvatar,
       role: role,
       comment: this.jwtHelperService.decodeToken(token).comment,
+      joiningReason: this.jwtHelperService.decodeToken(token).joiningReason,
     };
   }
 
@@ -177,4 +194,73 @@ export class AuthService {
   static removeAccessToken(): void {
     localStorage.removeItem(TOKEN_KEY);
   }
+
+  /**
+   * Returns true if the system needs to prompt the user with the registration 
form
+   * @param uid
+   * @private
+   */
+  private checkRegistrationRequired(uid: number): Observable<boolean> {
+    return 
this.http.get<boolean>(`${AppSettings.getApiEndpoint()}/user/joining-reason/required`,
 {
+      params: { uid: uid.toString() },
+    });
+  }
+
+  /**
+   * Submits changes to the backend with affiliation and joining reason
+   * @param uid
+   * @param affiliation
+   * @param reason
+   * @private
+   */
+  private submitRegistration(uid: number, affiliation: string, reason: 
string): Observable<void> {
+    return 
this.http.put<void>(`${AppSettings.getApiEndpoint()}/user/joining-reason`, {
+      uid,
+      affiliation,
+      joiningReason: reason,
+    });
+  }
+
+  /**
+   * Opens the registration modal (registration request modal)
+   * @param uid
+   * @param email
+   * @param defaultName
+   * @private
+   */
+  private openRegistrationModal(uid: number, email: string, defaultName: 
string): void {
+    const modalRef = this.modal.create<RegistrationRequestModalComponent>({
+      nzContent: RegistrationRequestModalComponent,
+      nzData: { uid, email, name: defaultName },
+      nzOkText: "Send request to Admin",
+      nzCancelText: "Cancel",
+      nzMaskClosable: false,
+      nzClosable: false,
+
+      nzOnOk: async () => {
+        const comp = modalRef.getContentComponent();
+        const { affiliation, reason } = comp.getValues();
+
+        if (!reason) {
+          this.notificationService.error("Reason is required");
+          return false;
+        }
+
+        try {
+          await firstValueFrom(this.submitRegistration(uid, affiliation, 
reason));
+          this.gmailService.notifyUnauthorizedLogin(email);
+        } finally {
+          this.logout();
+        }
+        return true;
+      },
+
+      nzOnCancel: () => this.logout(),
+    });
+
+    const comp = modalRef.getContentComponent();
+    modalRef.updateConfig({
+      nzTitle: comp.modalTitle,
+    });
+  }
 }
diff --git 
a/frontend/src/app/common/service/user/registration-request-modal/registration-request-modal.component.html
 
b/frontend/src/app/common/service/user/registration-request-modal/registration-request-modal.component.html
new file mode 100644
index 0000000000..8a46600122
--- /dev/null
+++ 
b/frontend/src/app/common/service/user/registration-request-modal/registration-request-modal.component.html
@@ -0,0 +1,54 @@
+<!--
+ 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.
+-->
+<ng-template #modalTitle>
+  <div class="registration-modal-title">
+    <span>Request access</span>
+    <img
+      src="assets/logos/full_logo_small.png"
+      alt="Texera logo"
+      class="registration-modal-logo" />
+  </div>
+</ng-template>
+
+<p>Please provide the information below so an administrator can review your 
request.</p>
+
+<label style="display: block">Name</label>
+<input
+  nz-input
+  [ngModel]="name"
+  disabled />
+
+<label style="display: block; margin-top: 12px">Email</label>
+<input
+  nz-input
+  [ngModel]="email"
+  disabled />
+
+<label style="display: block; margin-top: 12px">Affiliation (optional)</label>
+<input
+  nz-input
+  [(ngModel)]="affiliation"
+  placeholder="e.g. UC Irvine" />
+
+<label style="display: block; margin-top: 12px">Reason (required)</label>
+<textarea
+  nz-input
+  [(ngModel)]="reason"
+  rows="4"
+  placeholder="Briefly explain why you want access"></textarea>
diff --git 
a/frontend/src/app/common/service/user/registration-request-modal/registration-request-modal.component.scss
 
b/frontend/src/app/common/service/user/registration-request-modal/registration-request-modal.component.scss
new file mode 100644
index 0000000000..888a81d5dd
--- /dev/null
+++ 
b/frontend/src/app/common/service/user/registration-request-modal/registration-request-modal.component.scss
@@ -0,0 +1,30 @@
+/**
+ * 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.
+ */
+.registration-modal-title {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding-right: 12px;
+}
+
+.registration-modal-logo {
+  height: 28px;
+  margin-left: 12px;
+  opacity: 0.9;
+}
diff --git 
a/frontend/src/app/common/service/user/registration-request-modal/registration-request-modal.component.ts
 
b/frontend/src/app/common/service/user/registration-request-modal/registration-request-modal.component.ts
new file mode 100644
index 0000000000..195c4b5621
--- /dev/null
+++ 
b/frontend/src/app/common/service/user/registration-request-modal/registration-request-modal.component.ts
@@ -0,0 +1,51 @@
+/**
+ * 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, Inject, Input, TemplateRef, ViewChild } from 
"@angular/core";
+import { NZ_MODAL_DATA } from "ng-zorro-antd/modal";
+
+@Component({
+  selector: "texera-registration-request-modal",
+  templateUrl: "./registration-request-modal.component.html",
+  styleUrls: ["./registration-request-modal.component.scss"],
+})
+
+// Component for registration form modal
+export class RegistrationRequestModalComponent {
+  name = "";
+  email = "";
+
+  affiliation = "";
+  reason = "";
+
+  @ViewChild("modalTitle", { static: true })
+  modalTitle!: TemplateRef<any>;
+
+  constructor(@Inject(NZ_MODAL_DATA) public data: { uid: number; email: 
string; name: string }) {
+    this.name = data?.name ?? "";
+    this.email = data?.email ?? "";
+  }
+
+  getValues() {
+    return {
+      affiliation: (this.affiliation ?? "").trim(),
+      reason: (this.reason ?? "").trim(),
+    };
+  }
+}
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 b703d40331..fba46bd007 100644
--- a/frontend/src/app/common/service/user/stub-user.service.ts
+++ b/frontend/src/app/common/service/user/stub-user.service.ts
@@ -28,6 +28,7 @@ export const MOCK_USER_ID = 1;
 export const MOCK_USER_NAME = "testUser";
 export const MOCK_USER_EMAIL = "[email protected]";
 export const MOCK_USER_COMMENT = "testComent";
+export const MOCK_USER_JOININGREASON = "testJoiningReason";
 export const MOCK_USER = {
   uid: MOCK_USER_ID,
   name: MOCK_USER_NAME,
@@ -35,6 +36,7 @@ export const MOCK_USER = {
   googleId: undefined,
   role: Role.REGULAR,
   comment: MOCK_USER_COMMENT,
+  joiningReason: MOCK_USER_JOININGREASON,
 };
 
 /**
diff --git a/frontend/src/app/common/service/user/user.service.ts 
b/frontend/src/app/common/service/user/user.service.ts
index 689a95d028..c5f2d43635 100644
--- a/frontend/src/app/common/service/user/user.service.ts
+++ b/frontend/src/app/common/service/user/user.service.ts
@@ -85,38 +85,6 @@ 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<Boolean> {
-    const user = this.currentUser;
-    if (!user) {
-      return of(false);
-    }
-    return 
this.http.get<Boolean>(`${AppSettings.getApiEndpoint()}/user/affiliation`, {
-      params: { uid: user.uid.toString() },
-    });
-  }
-
-  /**
-   * updates a new registered user's affiliation
-   * @param affiliation
-   */
-  public updateAffiliation(affiliation: string): Observable<void> {
-    const user = this.currentUser;
-
-    if (!user) {
-      return of(void 0);
-    }
-
-    return 
this.http.put<void>(`${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 2a191d52dc..58e34b6800 100644
--- a/frontend/src/app/common/type/user.ts
+++ b/frontend/src/app/common/type/user.ts
@@ -47,6 +47,7 @@ export interface User
     lastLogin?: number;
     accountCreation?: Second;
     affiliation?: string;
+    joiningReason: 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 77d12b8445..a5ec56fb00 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
@@ -74,6 +74,11 @@
         [nzSortDirections]="['ascend', 'descend']">
         Affiliation
       </th>
+      <th
+        [nzSortFn]="sortByJoiningReason"
+        [nzSortDirections]="['ascend', 'descend']">
+        Joining Reason
+      </th>
       <th
         [nzSortFn]="sortByComment"
         [nzSortDirections]="['ascend', 'descend']"
@@ -236,6 +241,7 @@
         </div>
       </td>
       <td>{{ user.affiliation }}</td>
+      <td>{{ user.joiningReason }}</td>
       <td>
         <div (focusout)="saveEdit()">
           <ng-container *ngIf="editUid !== user.uid || editAttribute !== 
'comment'; else editCommentTemplate">
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 345737df37..5b3b8ea373 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
@@ -192,6 +192,11 @@ export class AdminUserComponent implements OnInit {
     return compare === 0 ? a.uid - b.uid : compare;
   };
 
+  public sortByJoiningReason: NzTableSortFn<User> = (a: User, b: User) => {
+    const compare = (b.joiningReason || "").localeCompare(a.joiningReason || 
"");
+    return compare === 0 ? a.uid - b.uid : compare;
+  };
+
   reset(): void {
     this.nameSearchValue = "";
     this.emailSearchValue = "";
diff --git a/frontend/src/app/dashboard/component/dashboard.component.html 
b/frontend/src/app/dashboard/component/dashboard.component.html
index d4d3d82d70..b238f56b93 100644
--- a/frontend/src/app/dashboard/component/dashboard.component.html
+++ b/frontend/src/app/dashboard/component/dashboard.component.html
@@ -213,36 +213,4 @@
       </nz-content>
     </nz-layout>
   </div>
-  <nz-modal
-    [(nzVisible)]="affiliationModalVisible"
-    [nzMaskClosable]="true"
-    [nzClosable]="true"
-    (nzOnCancel)="onAffiliationCancel()"
-    nzTitle="Tell us your affiliation">
-    <ng-container *nzModalContent>
-      <p>
-        To help us understand our users better, please tell us your 
affiliation (for example, your university, company,
-        or organization).
-      </p>
-      <input
-        nz-input
-        [(ngModel)]="affiliationInput"
-        placeholder="e.g. UC Irvine" />
-    </ng-container>
-
-    <ng-container *nzModalFooter>
-      <button
-        nz-button
-        (click)="skipAffiliation()">
-        Skip
-      </button>
-      <button
-        nz-button
-        nzType="primary"
-        [nzLoading]="affiliationSaving"
-        (click)="saveAffiliation()">
-        Save
-      </button>
-    </ng-container>
-  </nz-modal>
 </nz-layout>
diff --git a/frontend/src/app/dashboard/component/dashboard.component.ts 
b/frontend/src/app/dashboard/component/dashboard.component.ts
index 26448c88d6..4f642e01d1 100644
--- a/frontend/src/app/dashboard/component/dashboard.component.ts
+++ b/frontend/src/app/dashboard/component/dashboard.component.ts
@@ -43,6 +43,7 @@ import {
 import { Version } from "../../../environments/version";
 import { SidebarTabs } from "../../common/type/gui-config";
 import { User } from "../../common/type/user";
+import { Role } from "../../common/type/user";
 
 @Component({
   selector: "texera-dashboard",
@@ -75,10 +76,6 @@ 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;
@@ -124,7 +121,6 @@ export class DashboardComponent implements OnInit {
           this.isLogin = this.userService.isLogin();
           this.isAdmin = this.userService.isAdmin();
           this.forumLogin();
-          this.checkAffiliationPrompt(user);
           this.cdr.detectChanges();
         });
       });
@@ -200,67 +196,6 @@ 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/frontend/src/app/workspace/component/menu/coeditor-user-icon/coeditor-user-icon.component.ts
 
b/frontend/src/app/workspace/component/menu/coeditor-user-icon/coeditor-user-icon.component.ts
index 8e836a4b83..057e8ed824 100644
--- 
a/frontend/src/app/workspace/component/menu/coeditor-user-icon/coeditor-user-icon.component.ts
+++ 
b/frontend/src/app/workspace/component/menu/coeditor-user-icon/coeditor-user-icon.component.ts
@@ -33,7 +33,15 @@ import { CoeditorPresenceService } from 
"../../../service/workflow-graph/model/c
   styleUrls: ["coeditor-user-icon.component.css"],
 })
 export class CoeditorUserIconComponent {
-  @Input() coeditor: Coeditor = { name: "", email: "", uid: -1, role: 
Role.REGULAR, comment: "", clientId: "0" };
+  @Input() coeditor: Coeditor = {
+    name: "",
+    email: "",
+    uid: -1,
+    role: Role.REGULAR,
+    comment: "",
+    clientId: "0",
+    joiningReason: "",
+  };
 
   constructor(public coeditorPresenceService: CoeditorPresenceService) {}
 
diff --git a/sql/texera_ddl.sql b/sql/texera_ddl.sql
index a2de095688..179822b0da 100644
--- a/sql/texera_ddl.sql
+++ b/sql/texera_ddl.sql
@@ -105,6 +105,7 @@ CREATE TABLE IF NOT EXISTS "user"
     comment                 TEXT,
     account_creation_time   TIMESTAMPTZ NOT NULL DEFAULT now(),
     affiliation             VARCHAR(128),
+    joining_reason          VARCHAR(500),
     -- 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/21.sql b/sql/updates/21.sql
new file mode 100644
index 0000000000..4a140d9a12
--- /dev/null
+++ b/sql/updates/21.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 joining_reason VARCHAR(500);
+
+COMMIT;


Reply via email to