feat: add account creation time (#3736)

## Context
In effort to improve admin dashboard and allow admins to view account
creation time, a new column `account_creation_time` is introduced to the
`user` table. This attribute is meant to store the timestamp of a user's
account creation time and is a required column of the table. Existing
user rows have been given a set time for the column.

Closes Issue #3673.

## For Developers 
Please do the following steps to incorporate with new changes:
- Apply core/scripts/sql/updates/14.sql to your local postgres instance

## Summary of Feature
When a new user is added to the system (created by admin or google
login), the user's information will be logged into the database. The
`NOT NULL ON DEFAULT SET now()` statement would make sure that every
user's account creation time is logged. And admins will be able to view
every user's account creation time in the admin dashboard.

## Database Change
A new column `account_creation_time` has been added to the `user` table
and is set to `NOT NULL`. On default when a new user is inserted into
the table, the creation time for the user will be the current timestamp.
Attached below are the current & proposed schema of the `user` table.

## Codebase Change
To cooperate with the change, we added a new attribute `accountCreation`
to `user.ts` to ensure the frontend receives the account creation time
from the backend.

Multiple files have been changed as adding the not null attribute to
`user.ts` required changes to those files.

## Current Schema
<img width="276" height="277" alt="image"
src="https://github.com/user-attachments/assets/efffa9a0-ea4a-4ef1-8a28-435f59b83d6d"
/>

## Proposed Schema
<img width="270" height="303" alt="image"
src="https://github.com/user-attachments/assets/3271d5f0-b2da-4b6e-9756-15e9289d6a86"
/>

## Screenshot of Implementation
<img width="1311" height="774" alt="image"
src="https://github.com/user-attachments/assets/e20507b7-121e-40c4-bce1-b4cb0d6ac60b"
/>

---------

Co-authored-by: ali risheh <ali.risheh876@gmail.com>
diff --git a/core/amber/src/main/scala/edu/uci/ics/texera/web/ServletAwareConfigurator.scala b/core/amber/src/main/scala/edu/uci/ics/texera/web/ServletAwareConfigurator.scala
index 75accc1..0df8481 100644
--- a/core/amber/src/main/scala/edu/uci/ics/texera/web/ServletAwareConfigurator.scala
+++ b/core/amber/src/main/scala/edu/uci/ics/texera/web/ServletAwareConfigurator.scala
@@ -64,6 +64,7 @@
               null,
               null,
               null,
+              null,
               null
             )
           )
diff --git a/core/amber/src/main/scala/edu/uci/ics/texera/web/auth/GuestAuthFilter.scala b/core/amber/src/main/scala/edu/uci/ics/texera/web/auth/GuestAuthFilter.scala
index 8ca7438..fb5ecc6 100644
--- a/core/amber/src/main/scala/edu/uci/ics/texera/web/auth/GuestAuthFilter.scala
+++ b/core/amber/src/main/scala/edu/uci/ics/texera/web/auth/GuestAuthFilter.scala
@@ -38,7 +38,8 @@
     override protected def newInstance = new GuestAuthFilter
   }
 
-  val GUEST: User = new User(null, "guest", null, null, null, null, UserRoleEnum.REGULAR, null)
+  val GUEST: User =
+    new User(null, "guest", null, null, null, null, UserRoleEnum.REGULAR, null, null)
 }
 
 @PreMatching
diff --git a/core/amber/src/main/scala/edu/uci/ics/texera/web/auth/UserAuthenticator.scala b/core/amber/src/main/scala/edu/uci/ics/texera/web/auth/UserAuthenticator.scala
index 3571940..b820a87 100644
--- a/core/amber/src/main/scala/edu/uci/ics/texera/web/auth/UserAuthenticator.scala
+++ b/core/amber/src/main/scala/edu/uci/ics/texera/web/auth/UserAuthenticator.scala
@@ -26,6 +26,7 @@
 import io.dropwizard.auth.Authenticator
 import org.jose4j.jwt.consumer.JwtContext
 
+import java.time.OffsetDateTime
 import java.util.Optional
 
 object UserAuthenticator extends Authenticator[JwtContext, SessionUser] with LazyLogging {
@@ -40,7 +41,10 @@
         UserRoleEnum.valueOf(context.getJwtClaims.getClaimValue("role").asInstanceOf[String])
       val googleId = context.getJwtClaims.getClaimValue("googleId").asInstanceOf[String]
       val comment = context.getJwtClaims.getClaimValue("comment").asInstanceOf[String]
-      val user = new User(userId, userName, email, null, googleId, null, role, comment)
+      val accountCreation =
+        context.getJwtClaims.getClaimValue("accountCreation").asInstanceOf[OffsetDateTime]
+      val user =
+        new User(userId, userName, email, null, googleId, null, role, comment, accountCreation)
       Optional.of(new SessionUser(user))
     } catch {
       case e: Exception =>
diff --git a/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/dashboard/admin/user/AdminUserResource.scala b/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/dashboard/admin/user/AdminUserResource.scala
index 844bb26..4d8cde9 100644
--- a/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/dashboard/admin/user/AdminUserResource.scala
+++ b/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/dashboard/admin/user/AdminUserResource.scala
@@ -44,7 +44,8 @@
     role: UserRoleEnum,
     googleAvatar: String,
     comment: String,
-    lastLogin: java.time.OffsetDateTime // will be null if never logged in
+    lastLogin: java.time.OffsetDateTime, // will be null if never logged in
+    accountCreation: java.time.OffsetDateTime
 )
 
 object AdminUserResource {
@@ -76,7 +77,8 @@
         USER.ROLE,
         USER.GOOGLE_AVATAR,
         USER.COMMENT,
-        USER_LAST_ACTIVE_TIME.LAST_ACTIVE_TIME
+        USER_LAST_ACTIVE_TIME.LAST_ACTIVE_TIME,
+        USER.ACCOUNT_CREATION_TIME
       )
       .from(USER)
       .leftJoin(USER_LAST_ACTIVE_TIME)
diff --git a/core/amber/src/test/scala/edu/uci/ics/texera/web/resource/dashboard/file/WorkflowResourceSpec.scala b/core/amber/src/test/scala/edu/uci/ics/texera/web/resource/dashboard/file/WorkflowResourceSpec.scala
index 132d04a..ae965d3 100644
--- a/core/amber/src/test/scala/edu/uci/ics/texera/web/resource/dashboard/file/WorkflowResourceSpec.scala
+++ b/core/amber/src/test/scala/edu/uci/ics/texera/web/resource/dashboard/file/WorkflowResourceSpec.scala
@@ -35,12 +35,12 @@
 import edu.uci.ics.texera.web.resource.dashboard.{DashboardResource, FulltextSearchQueryUtils}
 import org.jooq.Condition
 import org.jooq.impl.DSL.noCondition
-
 import org.scalatest.flatspec.AnyFlatSpec
 import org.scalatest.{BeforeAndAfterAll, BeforeAndAfterEach}
 
 import java.sql.Timestamp
 import java.text.{ParseException, SimpleDateFormat}
+import java.time.{OffsetDateTime, ZoneOffset, Duration}
 import java.util
 import java.util.Collections
 import java.util.concurrent.TimeUnit
@@ -51,6 +51,10 @@
     with BeforeAndAfterEach
     with MockTexeraDB {
 
+  // An example creation time to test Account Creation Time attribute
+  private val exampleCreationTime: OffsetDateTime =
+    OffsetDateTime.parse("2025-01-01T00:00:00Z")
+
   private val testUser: User = {
     val user = new User
     user.setUid(Integer.valueOf(1))
@@ -58,6 +62,7 @@
     user.setRole(UserRoleEnum.ADMIN)
     user.setPassword("123")
     user.setComment("test_comment")
+    user.setAccountCreationTime(exampleCreationTime)
     user
   }
 
@@ -68,6 +73,7 @@
     user.setRole(UserRoleEnum.ADMIN)
     user.setPassword("123")
     user.setComment("test_comment2")
+    user.setAccountCreationTime(exampleCreationTime)
     user
   }
 
@@ -197,10 +203,103 @@
     keywordsList
   }
 
+  private def insertAndAssertAccountCreation(uid: Int, ts: OffsetDateTime): Unit = {
+    val userDao = new UserDao(getDSLContext.configuration())
+    val u = new User
+    u.setUid(Integer.valueOf(uid))
+    u.setName(s"tmp_user_$uid")
+    u.setRole(UserRoleEnum.REGULAR)
+    u.setPassword("pw")
+    u.setComment("tmp")
+    u.setAccountCreationTime(ts)
+    userDao.insert(u)
+
+    try {
+      val fetched = userDao.fetchOneByUid(Integer.valueOf(uid))
+      assert(fetched.getAccountCreationTime != null)
+      assert(fetched.getAccountCreationTime.isEqual(ts))
+    } finally {
+      userDao.deleteById(Integer.valueOf(uid))
+    }
+  }
+
   private def assertSameWorkflow(a: Workflow, b: DashboardWorkflow): Unit = {
     assert(a.getName == b.workflow.getName)
   }
 
+  "User.accountCreationTime" should "be persisted and retrievable via UserDao" in {
+    val userDao = new UserDao(getDSLContext.configuration())
+    val u1 = userDao.fetchOneByUid(Integer.valueOf(1))
+    val u2 = userDao.fetchOneByUid(Integer.valueOf(2))
+
+    assert(u1.getAccountCreationTime != null)
+    assert(u2.getAccountCreationTime != null)
+
+    assert(u1.getAccountCreationTime.isEqual(exampleCreationTime))
+    assert(u2.getAccountCreationTime.isEqual(exampleCreationTime))
+  }
+
+  it should "remain unchanged when updating unrelated fields" in {
+    val userDao = new UserDao(getDSLContext.configuration())
+    val u1 = userDao.fetchOneByUid(Integer.valueOf(1))
+    val originalTime = u1.getAccountCreationTime
+
+    u1.setComment("updated_comment")
+    userDao.update(u1)
+
+    val test_u1 = userDao.fetchOneByUid(Integer.valueOf(1))
+    assert(test_u1.getAccountCreationTime.isEqual(originalTime))
+  }
+
+  it should "fallback to DB default when not explicitly set on insert" in {
+    // account_creation_time TIMESTAMPTZ NOT NULL DEFAULT now()
+    val userDao = new UserDao(getDSLContext.configuration())
+    // Test user 3 on top of test user 1 and 2
+    val userId = 3
+    val tmp = new User
+    tmp.setUid(Integer.valueOf(userId))
+    tmp.setName("tmp_user")
+    tmp.setRole(UserRoleEnum.REGULAR)
+    tmp.setPassword("pw")
+    tmp.setComment("tmp")
+    // Account creation time not set
+    userDao.insert(tmp)
+
+    val fetched = userDao.fetchOneByUid(Integer.valueOf(3))
+    assert(fetched.getAccountCreationTime != null)
+
+    val now = OffsetDateTime.now(ZoneOffset.UTC)
+    val diff = Duration.between(fetched.getAccountCreationTime, now).abs()
+    assert(diff.toMinutes <= 2)
+  }
+
+  // Testing with user id 4
+  it should "persist and retrieve a non-UTC offset time (ex: +09:00 JST)" in {
+    val userId = 4
+    insertAndAssertAccountCreation(
+      uid = userId,
+      ts = OffsetDateTime.parse("2020-06-15T12:34:56+09:00")
+    )
+  }
+
+  // Testing with user id 5
+  it should "persist and retrieve a leap day timestamp" in {
+    val userId = 5
+    insertAndAssertAccountCreation(
+      uid = userId,
+      ts = OffsetDateTime.parse("2024-02-29T23:59:59Z")
+    )
+  }
+
+  // Testing with user id 6
+  it should "persist and retrieve a future timestamp" in {
+    val userId = 6
+    insertAndAssertAccountCreation(
+      uid = userId,
+      ts = OffsetDateTime.parse("2100-12-31T23:59:59Z")
+    )
+  }
+
   "/search API " should "be able to search for workflows in different columns in Workflow table" in {
     // testWorkflow1: {name: test_name, descrption: test_description, content: test_content}
     // search "test_name" or "test_description" or "test_content" should return testWorkflow1
diff --git a/core/auth/src/main/scala/edu/uci/ics/texera/auth/JwtParser.scala b/core/auth/src/main/scala/edu/uci/ics/texera/auth/JwtParser.scala
index 998deec..0981947 100644
--- a/core/auth/src/main/scala/edu/uci/ics/texera/auth/JwtParser.scala
+++ b/core/auth/src/main/scala/edu/uci/ics/texera/auth/JwtParser.scala
@@ -52,7 +52,7 @@
       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)
+      val user = new User(userId, userName, email, null, googleId, null, role, null, null)
       Optional.of(new SessionUser(user))
     } catch {
       case _: UnresolvableKeyException =>
diff --git a/core/gui/src/app/common/type/user.ts b/core/gui/src/app/common/type/user.ts
index b1157ba..01c7b8c 100644
--- a/core/gui/src/app/common/type/user.ts
+++ b/core/gui/src/app/common/type/user.ts
@@ -33,6 +33,9 @@
   ADMIN = "ADMIN",
 }
 
+export type Second = number;
+export type MilliSecond = number;
+
 export interface User
   extends Readonly<{
     uid: number;
@@ -44,6 +47,7 @@
     googleAvatar?: string;
     comment: string;
     lastLogin?: number;
+    accountCreation?: Second;
   }> {}
 
 export interface File
diff --git a/core/gui/src/app/dashboard/component/admin/user/admin-user.component.html b/core/gui/src/app/dashboard/component/admin/user/admin-user.component.html
index 3e856ff..8e59e0d 100644
--- a/core/gui/src/app/dashboard/component/admin/user/admin-user.component.html
+++ b/core/gui/src/app/dashboard/component/admin/user/admin-user.component.html
@@ -92,6 +92,7 @@
         User Role
       </th>
       <th>Quota</th>
+      <th>Account Creation Time</th>
     </tr>
   </thead>
   <nz-dropdown-menu #nameMenu="nzDropdownMenu">
@@ -293,6 +294,12 @@
             nzType="dashboard"></i>
         </button>
       </td>
+      <td>
+        <ng-container *ngIf="getAccountCreation(user) as ac; else noAC">
+          {{ ac | date:'MM/dd/y, h:mm a' }}
+        </ng-container>
+        <ng-template #noAC>—</ng-template>
+      </td>
     </tr>
   </tbody>
   <button
diff --git a/core/gui/src/app/dashboard/component/admin/user/admin-user.component.ts b/core/gui/src/app/dashboard/component/admin/user/admin-user.component.ts
index 7662e84..1546488 100644
--- a/core/gui/src/app/dashboard/component/admin/user/admin-user.component.ts
+++ b/core/gui/src/app/dashboard/component/admin/user/admin-user.component.ts
@@ -23,7 +23,7 @@
 import { NzModalService } from "ng-zorro-antd/modal";
 import { NzMessageService } from "ng-zorro-antd/message";
 import { AdminUserService } from "../../../service/admin/user/admin-user.service";
-import { Role, User } from "../../../../common/type/user";
+import { Role, User, MilliSecond } from "../../../../common/type/user";
 import { UserService } from "../../../../common/service/user/user.service";
 import { UserQuotaComponent } from "../../user/user-quota/user-quota.component";
 import { GuiConfigService } from "../../../../common/service/gui-config.service";
@@ -196,6 +196,13 @@
     return Date.now() - lastMs < active_window;
   }
 
+  getAccountCreation(user: User): MilliSecond {
+    if (!user.accountCreation) {
+      return 0;
+    }
+    return user.accountCreation * 1000;
+  }
+
   public filterByRole: NzTableFilterFn<User> = (list: string[], user: User) =>
     list.some(role => user.role.indexOf(role) !== -1);
 }
diff --git a/core/scripts/sql/texera_ddl.sql b/core/scripts/sql/texera_ddl.sql
index 648f96c..e98f8e2 100644
--- a/core/scripts/sql/texera_ddl.sql
+++ b/core/scripts/sql/texera_ddl.sql
@@ -81,14 +81,15 @@
 -- "user" table
 CREATE TABLE IF NOT EXISTS "user"
 (
-    uid           SERIAL PRIMARY KEY,
-    name          VARCHAR(256) NOT NULL,
-    email         VARCHAR(256) UNIQUE,
-    password      VARCHAR(256),
-    google_id     VARCHAR(256) UNIQUE,
-    google_avatar VARCHAR(100),
-    role          user_role_enum NOT NULL DEFAULT 'INACTIVE',
-    comment TEXT,
+    uid                     SERIAL PRIMARY KEY,
+    name                    VARCHAR(256) NOT NULL,
+    email                   VARCHAR(256) UNIQUE,
+    password                VARCHAR(256),
+    google_id               VARCHAR(256) UNIQUE,
+    google_avatar           VARCHAR(100),
+    role                    user_role_enum NOT NULL DEFAULT 'INACTIVE',
+    comment                 TEXT,
+    account_creation_time   TIMESTAMPTZ NOT NULL DEFAULT now(),
     -- 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/core/scripts/sql/updates/14.sql b/core/scripts/sql/updates/14.sql
new file mode 100644
index 0000000..25092ef
--- /dev/null
+++ b/core/scripts/sql/updates/14.sql
@@ -0,0 +1,40 @@
+/*
+ * 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 account_creation_time TIMESTAMPTZ;
+
+WITH ts AS (
+    SELECT '2025-01-01 00:00:00-00'::timestamptz AS t
+)
+
+UPDATE "user" u
+SET account_creation_time = ts.t
+FROM ts
+WHERE u.account_creation_time IS NULL;
+
+ALTER TABLE "user"
+    ALTER COLUMN account_creation_time SET NOT NULL,
+    ALTER COLUMN account_creation_time SET DEFAULT now();
+
+COMMIT;
\ No newline at end of file
diff --git a/deployment/k8s/texera-helmchart/files/texera_ddl.sql b/deployment/k8s/texera-helmchart/files/texera_ddl.sql
index 2593368..bece11a 100644
--- a/deployment/k8s/texera-helmchart/files/texera_ddl.sql
+++ b/deployment/k8s/texera-helmchart/files/texera_ddl.sql
@@ -116,14 +116,15 @@
 -- "user" table
 CREATE TABLE IF NOT EXISTS "user"
 (
-    uid           SERIAL PRIMARY KEY,
-    name          VARCHAR(256) NOT NULL,
-    email         VARCHAR(256) UNIQUE,
-    password      VARCHAR(256),
-    google_id     VARCHAR(256) UNIQUE,
-    google_avatar VARCHAR(100),
-    role          user_role_enum NOT NULL DEFAULT 'INACTIVE',
-    comment TEXT,
+    uid                     SERIAL PRIMARY KEY,
+    name                    VARCHAR(256) NOT NULL,
+    email                   VARCHAR(256) UNIQUE,
+    password                VARCHAR(256),
+    google_id               VARCHAR(256) UNIQUE,
+    google_avatar           VARCHAR(100),
+    role                    user_role_enum NOT NULL DEFAULT 'INACTIVE',
+    comment                 TEXT,
+    account_creation_time   TIMESTAMPTZ NOT NULL DEFAULT now(),
     -- check that either password or google_id is not null
     CONSTRAINT ck_nulltest CHECK ((password IS NOT NULL) OR (google_id IS NOT NULL))
     );