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

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


The following commit(s) were added to refs/heads/master by this push:
     new a67da0799f feat: add user's activeness to admin dashboard (#3625)
a67da0799f is described below

commit a67da0799f763f3bfe1e4f6ec09e98c0603c597e
Author: Jae Yun Kim <[email protected]>
AuthorDate: Mon Aug 11 15:15:34 2025 -0700

    feat: add user's activeness to admin dashboard (#3625)
    
    This pr adds a new feature where the admin can keep track of the
    active/inactive users on the Admin Dashboard. The feature is added in
    order to give admin users a better use experience and straightforward
    overview of users' activeness. A ring is added around the avatars and it
    will turn green whenever a user is active and gray if a user is
    inactive.
    
    Closes #3624.
    
    ##  For Developers
    Please do the following steps to incorporate with new changes:
    - Apply core/scripts/sql/updates/10.sql to your local postgres instance
    
    ---
    
    Since every http request triggers the jwtFilter, my design is to upsert
    the user's last login time record whenever the request triggers the
    jwtFilter. The upsert function will add a new record if the user never
    logged in before, and will update the last_login attribute. When the
    admin visits the admin dashboard, the call to the getUserList will now
    contain a list of users with a new attribute named lastActive, which is
    a Unix epoch timestamp in seconds. If a user's lastActive attribute is
    null or the difference between the current timestamp and its lastActive
    timestamp is larger than the set threshold (a.k.a active window), the
    user will be inactive, otherwise active. Here we define the active
    window as 15 minutes. With this threshold, admin users will be able to
    know which users were active within the last 15 minutes.
    
    A new table named time_log is introduced to the db and it has a primary
    key uid that references the uid from user table. When a user makes a
    http request, the attribute will be updated in the db. Future changes
    might be made to this table since we are planning to introduce more
    attributes like account creation time.
    
    The token's expiration time is set to 15 minutes, and refresh time is
    set to 16 minutes in order to make sure a user is "logged out" when they
    don't make anymore http requests. As soon as the system detects that the
    last active time has a 15 minutes difference with the current time, it
    will mark the user as inactive.
    
    ### Screenshot of Feature
    <img width="792" height="748" alt="image"
    
src="https://github.com/user-attachments/assets/2251d9e9-f77c-484d-89a5-0d5a42d8f85a";
    />
    
    ---------
    
    Co-authored-by: Xinyuan Lin <[email protected]>
    Co-authored-by: ali risheh <[email protected]>
---
 .../texera/web/resource/auth/AuthResource.scala    |   9 +-
 .../web/resource/auth/GoogleAuthResource.scala     |   4 +-
 .../dashboard/admin/user/AdminUserResource.scala   |  39 ++++
 .../texera/web/service/ResultExportService.scala   |   4 +-
 .../scala/edu/uci/ics/texera/auth/JwtAuth.scala    |   8 +-
 .../edu/uci/ics/texera/auth/JwtAuthFilter.scala    |  15 +-
 .../resource/ComputingUnitManagingResource.scala   |   4 +-
 core/config/src/main/resources/auth.conf           |   3 -
 .../edu/uci/ics/texera/config/AuthConfig.scala     |   3 -
 .../uci/ics/texera/dao/jooq/generated/Keys.java    |   4 +
 .../uci/ics/texera/dao/jooq/generated/Tables.java  |   6 +
 .../ics/texera/dao/jooq/generated/TexeraDb.java    |   7 +
 .../texera/dao/jooq/generated/tables/TimeLog.java  | 172 ++++++++++++++++++
 .../dao/jooq/generated/tables/daos/TimeLogDao.java | 103 +++++++++++
 .../jooq/generated/tables/interfaces/ITimeLog.java |  69 ++++++++
 .../dao/jooq/generated/tables/pojos/TimeLog.java   | 113 ++++++++++++
 .../generated/tables/records/TimeLogRecord.java    | 196 +++++++++++++++++++++
 .../src/app/common/service/user/auth.service.ts    |   2 +-
 core/gui/src/app/common/type/user.ts               |   1 +
 .../component/admin/user/admin-user.component.html |   4 +-
 .../component/admin/user/admin-user.component.scss |  12 ++
 .../component/admin/user/admin-user.component.ts   |  11 ++
 .../service/admin/user/admin-user.service.ts       |   2 +-
 core/scripts/sql/texera_ddl.sql                    |   9 +
 core/scripts/sql/updates/10.sql                    |  30 ++++
 25 files changed, 803 insertions(+), 27 deletions(-)

diff --git 
a/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/auth/AuthResource.scala
 
b/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/auth/AuthResource.scala
index 668cb31966..272886e654 100644
--- 
a/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/auth/AuthResource.scala
+++ 
b/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/auth/AuthResource.scala
@@ -20,8 +20,7 @@
 package edu.uci.ics.texera.web.resource.auth
 
 import edu.uci.ics.texera.auth.JwtAuth.{
-  TOKEN_EXPIRE_TIME_IN_DAYS,
-  dayToMin,
+  TOKEN_EXPIRE_TIME_IN_MINUTES,
   jwtClaims,
   jwtConsumer,
   jwtToken
@@ -103,7 +102,7 @@ class AuthResource {
       throw new NotAcceptableException("User System is disabled on the 
backend!")
     retrieveUserByUsernameAndPassword(request.username, request.password) 
match {
       case Some(user) =>
-        TokenIssueResponse(jwtToken(jwtClaims(user, 
dayToMin(TOKEN_EXPIRE_TIME_IN_DAYS))))
+        TokenIssueResponse(jwtToken(jwtClaims(user, 
TOKEN_EXPIRE_TIME_IN_MINUTES)))
       case None => throw new NotAuthorizedException("Login credentials are 
incorrect.")
     }
   }
@@ -112,7 +111,7 @@ class AuthResource {
   @Path("/refresh")
   def refresh(request: RefreshTokenRequest): TokenIssueResponse = {
     val claims = jwtConsumer.process(request.accessToken).getJwtClaims
-    
claims.setExpirationTimeMinutesInTheFuture(dayToMin(TOKEN_EXPIRE_TIME_IN_DAYS).toFloat)
+    
claims.setExpirationTimeMinutesInTheFuture(TOKEN_EXPIRE_TIME_IN_MINUTES.toFloat)
     TokenIssueResponse(jwtToken(claims))
   }
 
@@ -133,7 +132,7 @@ class AuthResource {
         // hash the plain text password
         user.setPassword(new 
StrongPasswordEncryptor().encryptPassword(request.password))
         userDao.insert(user)
-        TokenIssueResponse(jwtToken(jwtClaims(user, 
dayToMin(TOKEN_EXPIRE_TIME_IN_DAYS))))
+        TokenIssueResponse(jwtToken(jwtClaims(user, 
TOKEN_EXPIRE_TIME_IN_MINUTES)))
       case _ =>
         // the username exists already
         throw new NotAcceptableException("Username exists already.")
diff --git 
a/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/auth/GoogleAuthResource.scala
 
b/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/auth/GoogleAuthResource.scala
index 7f7f7aa326..d49d4d41a5 100644
--- 
a/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/auth/GoogleAuthResource.scala
+++ 
b/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/auth/GoogleAuthResource.scala
@@ -24,7 +24,7 @@ import com.google.api.client.http.javanet.NetHttpTransport
 import com.google.api.client.json.gson.GsonFactory
 import edu.uci.ics.texera.config.UserSystemConfig
 import edu.uci.ics.texera.dao.SqlServer
-import edu.uci.ics.texera.auth.JwtAuth.{TOKEN_EXPIRE_TIME_IN_DAYS, dayToMin, 
jwtClaims, jwtToken}
+import edu.uci.ics.texera.auth.JwtAuth.{TOKEN_EXPIRE_TIME_IN_MINUTES, 
jwtClaims, jwtToken}
 import edu.uci.ics.texera.web.model.http.response.TokenIssueResponse
 import edu.uci.ics.texera.dao.jooq.generated.enums.UserRoleEnum
 import edu.uci.ics.texera.dao.jooq.generated.tables.daos.UserDao
@@ -111,7 +111,7 @@ class GoogleAuthResource {
               user
           }
       }
-      TokenIssueResponse(jwtToken(jwtClaims(user, 
dayToMin(TOKEN_EXPIRE_TIME_IN_DAYS))))
+      TokenIssueResponse(jwtToken(jwtClaims(user, 
TOKEN_EXPIRE_TIME_IN_MINUTES)))
     } else throw new NotAuthorizedException("Login credentials are incorrect.")
   }
 }
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 87124dc4e6..9a5486430a 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
@@ -28,12 +28,25 @@ import 
edu.uci.ics.texera.web.resource.GmailResource.sendEmail
 import 
edu.uci.ics.texera.web.resource.dashboard.admin.user.AdminUserResource.userDao
 import edu.uci.ics.texera.web.resource.dashboard.user.quota.UserQuotaResource._
 import org.jasypt.util.password.StrongPasswordEncryptor
+import edu.uci.ics.texera.dao.jooq.generated.tables.User.USER
+import edu.uci.ics.texera.dao.jooq.generated.tables.TimeLog.TIME_LOG
 
 import java.util
 import javax.annotation.security.RolesAllowed
 import javax.ws.rs._
 import javax.ws.rs.core.{MediaType, Response}
 
+case class UserWithLastLogin(
+    uid: Int,
+    name: String,
+    email: String,
+    googleId: String,
+    role: UserRoleEnum,
+    googleAvatar: String,
+    comment: String,
+    lastLogin: java.time.OffsetDateTime // will be null if never logged in
+)
+
 object AdminUserResource {
   final private lazy val context = SqlServer
     .getInstance()
@@ -57,6 +70,32 @@ class AdminUserResource {
     userDao.fetchRangeOfUid(Integer.MIN_VALUE, Integer.MAX_VALUE)
   }
 
+  /**
+    * This method returns the list of users with lastLogin time
+    *
+    * @return a list of UserWithLastLogin
+    */
+  @GET
+  @Path("/listWithActivity")
+  @Produces(Array(MediaType.APPLICATION_JSON))
+  def listUserWithActivity(): util.List[UserWithLastLogin] = {
+    AdminUserResource.context
+      .select(
+        USER.UID,
+        USER.NAME,
+        USER.EMAIL,
+        USER.GOOGLE_ID,
+        USER.ROLE,
+        USER.GOOGLE_AVATAR,
+        USER.COMMENT,
+        TIME_LOG.LAST_LOGIN
+      )
+      .from(USER)
+      .leftJoin(TIME_LOG)
+      .on(USER.UID.eq(TIME_LOG.UID))
+      .fetchInto(classOf[UserWithLastLogin])
+  }
+
   @PUT
   @Path("/update")
   def updateUser(user: User): Unit = {
diff --git 
a/core/amber/src/main/scala/edu/uci/ics/texera/web/service/ResultExportService.scala
 
b/core/amber/src/main/scala/edu/uci/ics/texera/web/service/ResultExportService.scala
index f66271301a..d4125883a9 100644
--- 
a/core/amber/src/main/scala/edu/uci/ics/texera/web/service/ResultExportService.scala
+++ 
b/core/amber/src/main/scala/edu/uci/ics/texera/web/service/ResultExportService.scala
@@ -28,7 +28,7 @@ import 
edu.uci.ics.amber.core.virtualidentity.{OperatorIdentity, WorkflowIdentit
 import edu.uci.ics.amber.core.workflow.PortIdentity
 import edu.uci.ics.amber.util.ArrowUtils
 import edu.uci.ics.texera.auth.JwtAuth
-import edu.uci.ics.texera.auth.JwtAuth.{TOKEN_EXPIRE_TIME_IN_DAYS, dayToMin, 
jwtClaims}
+import edu.uci.ics.texera.auth.JwtAuth.{TOKEN_EXPIRE_TIME_IN_MINUTES, 
jwtClaims}
 import edu.uci.ics.texera.dao.jooq.generated.tables.pojos.User
 import edu.uci.ics.texera.web.model.http.request.result.{OperatorExportInfo, 
ResultExportRequest}
 import edu.uci.ics.texera.web.model.http.response.result.ResultExportResponse
@@ -516,7 +516,7 @@ class ResultExportService(workflowIdentity: 
WorkflowIdentity, computingUnitId: I
         connection.setRequestProperty("Content-Type", 
"application/octet-stream")
         connection.setRequestProperty(
           "Authorization",
-          s"Bearer ${JwtAuth.jwtToken(jwtClaims(user, 
dayToMin(TOKEN_EXPIRE_TIME_IN_DAYS)))}"
+          s"Bearer ${JwtAuth.jwtToken(jwtClaims(user, 
TOKEN_EXPIRE_TIME_IN_MINUTES))}"
         )
         connection.setChunkedStreamingMode(0)
 
diff --git a/core/auth/src/main/scala/edu/uci/ics/texera/auth/JwtAuth.scala 
b/core/auth/src/main/scala/edu/uci/ics/texera/auth/JwtAuth.scala
index 5e8d3ea047..7ede2e981a 100644
--- a/core/auth/src/main/scala/edu/uci/ics/texera/auth/JwtAuth.scala
+++ b/core/auth/src/main/scala/edu/uci/ics/texera/auth/JwtAuth.scala
@@ -32,8 +32,8 @@ import java.nio.charset.StandardCharsets
 // TODO: move this logic to Auth
 object JwtAuth {
 
-  final val TOKEN_EXPIRE_TIME_IN_DAYS = AuthConfig.jwtExpirationDays
   final val TOKEN_SECRET: String = AuthConfig.jwtSecretKey
+  final val TOKEN_EXPIRE_TIME_IN_MINUTES: Int = 15
 
   val jwtConsumer: JwtConsumer = new JwtConsumerBuilder()
     .setAllowedClockSkewInSeconds(30)
@@ -59,11 +59,7 @@ object JwtAuth {
     claims.setClaim("email", user.getEmail)
     claims.setClaim("role", user.getRole)
     claims.setClaim("googleAvatar", user.getGoogleAvatar)
-    claims.setExpirationTimeMinutesInTheFuture(dayToMin(expireInDays).toFloat)
+    
claims.setExpirationTimeMinutesInTheFuture(TOKEN_EXPIRE_TIME_IN_MINUTES.toFloat)
     claims
   }
-
-  def dayToMin(days: Int): Int = {
-    days * 24 * 60
-  }
 }
diff --git 
a/core/auth/src/main/scala/edu/uci/ics/texera/auth/JwtAuthFilter.scala 
b/core/auth/src/main/scala/edu/uci/ics/texera/auth/JwtAuthFilter.scala
index 34c5ec6c78..d8aec6fc25 100644
--- a/core/auth/src/main/scala/edu/uci/ics/texera/auth/JwtAuthFilter.scala
+++ b/core/auth/src/main/scala/edu/uci/ics/texera/auth/JwtAuthFilter.scala
@@ -20,11 +20,13 @@
 package edu.uci.ics.texera.auth
 
 import com.typesafe.scalalogging.LazyLogging
+import edu.uci.ics.texera.dao.SqlServer
 import edu.uci.ics.texera.dao.jooq.generated.enums.UserRoleEnum
 import jakarta.ws.rs.container.{ContainerRequestContext, 
ContainerRequestFilter, ResourceInfo}
 import jakarta.ws.rs.core.{Context, HttpHeaders, SecurityContext}
 import jakarta.ws.rs.ext.Provider
-
+import edu.uci.ics.texera.dao.jooq.generated.Tables.TIME_LOG
+import java.time.OffsetDateTime
 import java.security.Principal
 
 @Provider
@@ -32,6 +34,7 @@ class JwtAuthFilter extends ContainerRequestFilter with 
LazyLogging {
 
   @Context
   private var resourceInfo: ResourceInfo = _
+  private val ctx = SqlServer.getInstance().createDSLContext()
 
   override def filter(requestContext: ContainerRequestContext): Unit = {
     val authHeader = requestContext.getHeaderString(HttpHeaders.AUTHORIZATION)
@@ -42,6 +45,16 @@ class JwtAuthFilter extends ContainerRequestFilter with 
LazyLogging {
 
       if (userOpt.isPresent) {
         val user = userOpt.get()
+
+        ctx
+          .insertInto(TIME_LOG)
+          .set(TIME_LOG.UID, user.getUid)
+          .set(TIME_LOG.LAST_LOGIN, OffsetDateTime.now())
+          .onConflict(TIME_LOG.UID) // conflict on primary key uid
+          .doUpdate()
+          .set(TIME_LOG.LAST_LOGIN, OffsetDateTime.now())
+          .execute()
+
         requestContext.setSecurityContext(new SecurityContext {
           override def getUserPrincipal: Principal = user
           override def isUserInRole(role: String): Boolean =
diff --git 
a/core/computing-unit-managing-service/src/main/scala/edu/uci/ics/texera/service/resource/ComputingUnitManagingResource.scala
 
b/core/computing-unit-managing-service/src/main/scala/edu/uci/ics/texera/service/resource/ComputingUnitManagingResource.scala
index f2e300652f..25e1d7524c 100644
--- 
a/core/computing-unit-managing-service/src/main/scala/edu/uci/ics/texera/service/resource/ComputingUnitManagingResource.scala
+++ 
b/core/computing-unit-managing-service/src/main/scala/edu/uci/ics/texera/service/resource/ComputingUnitManagingResource.scala
@@ -20,7 +20,7 @@
 package edu.uci.ics.texera.service.resource
 
 import edu.uci.ics.amber.config.{EnvironmentalVariable, StorageConfig}
-import edu.uci.ics.texera.auth.JwtAuth.{TOKEN_EXPIRE_TIME_IN_DAYS, dayToMin, 
jwtClaims}
+import edu.uci.ics.texera.auth.JwtAuth.{TOKEN_EXPIRE_TIME_IN_MINUTES, 
jwtClaims}
 import edu.uci.ics.texera.auth.{JwtAuth, SessionUser}
 import edu.uci.ics.texera.config.{ComputingUnitConfig, KubernetesConfig}
 import edu.uci.ics.texera.dao.SqlServer
@@ -381,7 +381,7 @@ class ComputingUnitManagingResource {
       }
 
       val computingUnit = new WorkflowComputingUnit()
-      val userToken = JwtAuth.jwtToken(jwtClaims(user.user, 
dayToMin(TOKEN_EXPIRE_TIME_IN_DAYS)))
+      val userToken = JwtAuth.jwtToken(jwtClaims(user.user, 
TOKEN_EXPIRE_TIME_IN_MINUTES))
       computingUnit.setUid(user.getUid)
       computingUnit.setName(param.name)
       computingUnit.setCreationTime(new Timestamp(System.currentTimeMillis()))
diff --git a/core/config/src/main/resources/auth.conf 
b/core/config/src/main/resources/auth.conf
index 8c849288b4..ecc6ba71cc 100644
--- a/core/config/src/main/resources/auth.conf
+++ b/core/config/src/main/resources/auth.conf
@@ -19,9 +19,6 @@
 # Configuration for JWT Authentication. Currently it is used by the 
FileService to parse the given JWT Token
 auth {
     jwt {
-        exp-in-days = 30
-        exp-in-days = ${?AUTH_JWT_EXP_IN_DAYS}
-
         256-bit-secret = "8a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d"
         256-bit-secret = ${?AUTH_JWT_SECRET}
     }
diff --git 
a/core/config/src/main/scala/edu/uci/ics/texera/config/AuthConfig.scala 
b/core/config/src/main/scala/edu/uci/ics/texera/config/AuthConfig.scala
index 97d2b457d8..53bbe4becd 100644
--- a/core/config/src/main/scala/edu/uci/ics/texera/config/AuthConfig.scala
+++ b/core/config/src/main/scala/edu/uci/ics/texera/config/AuthConfig.scala
@@ -26,9 +26,6 @@ object AuthConfig {
   // Load configuration
   private val conf: Config = 
ConfigFactory.parseResources("auth.conf").resolve()
 
-  // Read JWT expiration time
-  val jwtExpirationDays: Int = conf.getInt("auth.jwt.exp-in-days")
-
   // For storing the generated/configured secret
   @volatile private var secretKey: String = _
 
diff --git 
a/core/dao/src/main/scala/edu/uci/ics/texera/dao/jooq/generated/Keys.java 
b/core/dao/src/main/scala/edu/uci/ics/texera/dao/jooq/generated/Keys.java
index 429f448fc3..0fbee5a7e5 100644
--- a/core/dao/src/main/scala/edu/uci/ics/texera/dao/jooq/generated/Keys.java
+++ b/core/dao/src/main/scala/edu/uci/ics/texera/dao/jooq/generated/Keys.java
@@ -32,6 +32,7 @@ import 
edu.uci.ics.texera.dao.jooq.generated.tables.OperatorPortExecutions;
 import edu.uci.ics.texera.dao.jooq.generated.tables.Project;
 import edu.uci.ics.texera.dao.jooq.generated.tables.ProjectUserAccess;
 import edu.uci.ics.texera.dao.jooq.generated.tables.PublicProject;
+import edu.uci.ics.texera.dao.jooq.generated.tables.TimeLog;
 import edu.uci.ics.texera.dao.jooq.generated.tables.User;
 import edu.uci.ics.texera.dao.jooq.generated.tables.UserConfig;
 import edu.uci.ics.texera.dao.jooq.generated.tables.Workflow;
@@ -55,6 +56,7 @@ import 
edu.uci.ics.texera.dao.jooq.generated.tables.records.OperatorPortExecutio
 import edu.uci.ics.texera.dao.jooq.generated.tables.records.ProjectRecord;
 import 
edu.uci.ics.texera.dao.jooq.generated.tables.records.ProjectUserAccessRecord;
 import 
edu.uci.ics.texera.dao.jooq.generated.tables.records.PublicProjectRecord;
+import edu.uci.ics.texera.dao.jooq.generated.tables.records.TimeLogRecord;
 import edu.uci.ics.texera.dao.jooq.generated.tables.records.UserConfigRecord;
 import edu.uci.ics.texera.dao.jooq.generated.tables.records.UserRecord;
 import 
edu.uci.ics.texera.dao.jooq.generated.tables.records.WorkflowComputingUnitRecord;
@@ -98,6 +100,7 @@ public class Keys {
     public static final UniqueKey<ProjectRecord> PROJECT_PKEY = 
Internal.createUniqueKey(Project.PROJECT, DSL.name("project_pkey"), new 
TableField[] { Project.PROJECT.PID }, true);
     public static final UniqueKey<ProjectUserAccessRecord> 
PROJECT_USER_ACCESS_PKEY = 
Internal.createUniqueKey(ProjectUserAccess.PROJECT_USER_ACCESS, 
DSL.name("project_user_access_pkey"), new TableField[] { 
ProjectUserAccess.PROJECT_USER_ACCESS.UID, 
ProjectUserAccess.PROJECT_USER_ACCESS.PID }, true);
     public static final UniqueKey<PublicProjectRecord> PUBLIC_PROJECT_PKEY = 
Internal.createUniqueKey(PublicProject.PUBLIC_PROJECT, 
DSL.name("public_project_pkey"), new TableField[] { 
PublicProject.PUBLIC_PROJECT.PID }, true);
+    public static final UniqueKey<TimeLogRecord> TIME_LOG_PKEY = 
Internal.createUniqueKey(TimeLog.TIME_LOG, DSL.name("time_log_pkey"), new 
TableField[] { TimeLog.TIME_LOG.UID }, true);
     public static final UniqueKey<UserRecord> USER_EMAIL_KEY = 
Internal.createUniqueKey(User.USER, DSL.name("user_email_key"), new 
TableField[] { User.USER.EMAIL }, true);
     public static final UniqueKey<UserRecord> USER_GOOGLE_ID_KEY = 
Internal.createUniqueKey(User.USER, DSL.name("user_google_id_key"), new 
TableField[] { User.USER.GOOGLE_ID }, true);
     public static final UniqueKey<UserRecord> USER_PKEY = 
Internal.createUniqueKey(User.USER, DSL.name("user_pkey"), new TableField[] { 
User.USER.UID }, true);
@@ -132,6 +135,7 @@ public class Keys {
     public static final ForeignKey<ProjectUserAccessRecord, ProjectRecord> 
PROJECT_USER_ACCESS__PROJECT_USER_ACCESS_PID_FKEY = 
Internal.createForeignKey(ProjectUserAccess.PROJECT_USER_ACCESS, 
DSL.name("project_user_access_pid_fkey"), new TableField[] { 
ProjectUserAccess.PROJECT_USER_ACCESS.PID }, Keys.PROJECT_PKEY, new 
TableField[] { Project.PROJECT.PID }, true);
     public static final ForeignKey<ProjectUserAccessRecord, UserRecord> 
PROJECT_USER_ACCESS__PROJECT_USER_ACCESS_UID_FKEY = 
Internal.createForeignKey(ProjectUserAccess.PROJECT_USER_ACCESS, 
DSL.name("project_user_access_uid_fkey"), new TableField[] { 
ProjectUserAccess.PROJECT_USER_ACCESS.UID }, Keys.USER_PKEY, new TableField[] { 
User.USER.UID }, true);
     public static final ForeignKey<PublicProjectRecord, ProjectRecord> 
PUBLIC_PROJECT__PUBLIC_PROJECT_PID_FKEY = 
Internal.createForeignKey(PublicProject.PUBLIC_PROJECT, 
DSL.name("public_project_pid_fkey"), new TableField[] { 
PublicProject.PUBLIC_PROJECT.PID }, Keys.PROJECT_PKEY, new TableField[] { 
Project.PROJECT.PID }, true);
+    public static final ForeignKey<TimeLogRecord, UserRecord> 
TIME_LOG__TIME_LOG_UID_FKEY = Internal.createForeignKey(TimeLog.TIME_LOG, 
DSL.name("time_log_uid_fkey"), new TableField[] { TimeLog.TIME_LOG.UID }, 
Keys.USER_PKEY, new TableField[] { User.USER.UID }, true);
     public static final ForeignKey<UserConfigRecord, UserRecord> 
USER_CONFIG__USER_CONFIG_UID_FKEY = 
Internal.createForeignKey(UserConfig.USER_CONFIG, 
DSL.name("user_config_uid_fkey"), new TableField[] { UserConfig.USER_CONFIG.UID 
}, Keys.USER_PKEY, new TableField[] { User.USER.UID }, true);
     public static final ForeignKey<WorkflowComputingUnitRecord, UserRecord> 
WORKFLOW_COMPUTING_UNIT__WORKFLOW_COMPUTING_UNIT_UID_FKEY = 
Internal.createForeignKey(WorkflowComputingUnit.WORKFLOW_COMPUTING_UNIT, 
DSL.name("workflow_computing_unit_uid_fkey"), new TableField[] { 
WorkflowComputingUnit.WORKFLOW_COMPUTING_UNIT.UID }, Keys.USER_PKEY, new 
TableField[] { User.USER.UID }, true);
     public static final ForeignKey<WorkflowExecutionsRecord, 
WorkflowComputingUnitRecord> WORKFLOW_EXECUTIONS__WORKFLOW_EXECUTIONS_CUID_FKEY 
= Internal.createForeignKey(WorkflowExecutions.WORKFLOW_EXECUTIONS, 
DSL.name("workflow_executions_cuid_fkey"), new TableField[] { 
WorkflowExecutions.WORKFLOW_EXECUTIONS.CUID }, 
Keys.WORKFLOW_COMPUTING_UNIT_PKEY, new TableField[] { 
WorkflowComputingUnit.WORKFLOW_COMPUTING_UNIT.CUID }, true);
diff --git 
a/core/dao/src/main/scala/edu/uci/ics/texera/dao/jooq/generated/Tables.java 
b/core/dao/src/main/scala/edu/uci/ics/texera/dao/jooq/generated/Tables.java
index 1d56bd95dc..fe590e3f0c 100644
--- a/core/dao/src/main/scala/edu/uci/ics/texera/dao/jooq/generated/Tables.java
+++ b/core/dao/src/main/scala/edu/uci/ics/texera/dao/jooq/generated/Tables.java
@@ -32,6 +32,7 @@ import 
edu.uci.ics.texera.dao.jooq.generated.tables.OperatorPortExecutions;
 import edu.uci.ics.texera.dao.jooq.generated.tables.Project;
 import edu.uci.ics.texera.dao.jooq.generated.tables.ProjectUserAccess;
 import edu.uci.ics.texera.dao.jooq.generated.tables.PublicProject;
+import edu.uci.ics.texera.dao.jooq.generated.tables.TimeLog;
 import edu.uci.ics.texera.dao.jooq.generated.tables.User;
 import edu.uci.ics.texera.dao.jooq.generated.tables.UserActivity;
 import edu.uci.ics.texera.dao.jooq.generated.tables.UserConfig;
@@ -108,6 +109,11 @@ public class Tables {
      */
     public static final PublicProject PUBLIC_PROJECT = 
PublicProject.PUBLIC_PROJECT;
 
+    /**
+     * The table <code>texera_db.time_log</code>.
+     */
+    public static final TimeLog TIME_LOG = TimeLog.TIME_LOG;
+
     /**
      * The table <code>texera_db.user</code>.
      */
diff --git 
a/core/dao/src/main/scala/edu/uci/ics/texera/dao/jooq/generated/TexeraDb.java 
b/core/dao/src/main/scala/edu/uci/ics/texera/dao/jooq/generated/TexeraDb.java
index a021868a5c..800becf396 100644
--- 
a/core/dao/src/main/scala/edu/uci/ics/texera/dao/jooq/generated/TexeraDb.java
+++ 
b/core/dao/src/main/scala/edu/uci/ics/texera/dao/jooq/generated/TexeraDb.java
@@ -32,6 +32,7 @@ import 
edu.uci.ics.texera.dao.jooq.generated.tables.OperatorPortExecutions;
 import edu.uci.ics.texera.dao.jooq.generated.tables.Project;
 import edu.uci.ics.texera.dao.jooq.generated.tables.ProjectUserAccess;
 import edu.uci.ics.texera.dao.jooq.generated.tables.PublicProject;
+import edu.uci.ics.texera.dao.jooq.generated.tables.TimeLog;
 import edu.uci.ics.texera.dao.jooq.generated.tables.User;
 import edu.uci.ics.texera.dao.jooq.generated.tables.UserActivity;
 import edu.uci.ics.texera.dao.jooq.generated.tables.UserConfig;
@@ -122,6 +123,11 @@ public class TexeraDb extends SchemaImpl {
      */
     public final PublicProject PUBLIC_PROJECT = PublicProject.PUBLIC_PROJECT;
 
+    /**
+     * The table <code>texera_db.time_log</code>.
+     */
+    public final TimeLog TIME_LOG = TimeLog.TIME_LOG;
+
     /**
      * The table <code>texera_db.user</code>.
      */
@@ -214,6 +220,7 @@ public class TexeraDb extends SchemaImpl {
             Project.PROJECT,
             ProjectUserAccess.PROJECT_USER_ACCESS,
             PublicProject.PUBLIC_PROJECT,
+            TimeLog.TIME_LOG,
             User.USER,
             UserActivity.USER_ACTIVITY,
             UserConfig.USER_CONFIG,
diff --git 
a/core/dao/src/main/scala/edu/uci/ics/texera/dao/jooq/generated/tables/TimeLog.java
 
b/core/dao/src/main/scala/edu/uci/ics/texera/dao/jooq/generated/tables/TimeLog.java
new file mode 100644
index 0000000000..bc6d48e7dd
--- /dev/null
+++ 
b/core/dao/src/main/scala/edu/uci/ics/texera/dao/jooq/generated/tables/TimeLog.java
@@ -0,0 +1,172 @@
+/*
+ * 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.
+ * 
+ * This file is generated by jOOQ.
+ */
+package edu.uci.ics.texera.dao.jooq.generated.tables;
+
+
+import edu.uci.ics.texera.dao.jooq.generated.Keys;
+import edu.uci.ics.texera.dao.jooq.generated.TexeraDb;
+import edu.uci.ics.texera.dao.jooq.generated.tables.records.TimeLogRecord;
+
+import java.time.OffsetDateTime;
+import java.util.Arrays;
+import java.util.List;
+
+import org.jooq.Field;
+import org.jooq.ForeignKey;
+import org.jooq.Name;
+import org.jooq.Record;
+import org.jooq.Row2;
+import org.jooq.Schema;
+import org.jooq.Table;
+import org.jooq.TableField;
+import org.jooq.TableOptions;
+import org.jooq.UniqueKey;
+import org.jooq.impl.DSL;
+import org.jooq.impl.SQLDataType;
+import org.jooq.impl.TableImpl;
+
+
+/**
+ * This class is generated by jOOQ.
+ */
+@SuppressWarnings({ "all", "unchecked", "rawtypes" })
+public class TimeLog extends TableImpl<TimeLogRecord> {
+
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * The reference instance of <code>texera_db.time_log</code>
+     */
+    public static final TimeLog TIME_LOG = new TimeLog();
+
+    /**
+     * The class holding records for this type
+     */
+    @Override
+    public Class<TimeLogRecord> getRecordType() {
+        return TimeLogRecord.class;
+    }
+
+    /**
+     * The column <code>texera_db.time_log.uid</code>.
+     */
+    public final TableField<TimeLogRecord, Integer> UID = 
createField(DSL.name("uid"), SQLDataType.INTEGER.nullable(false), this, "");
+
+    /**
+     * The column <code>texera_db.time_log.last_login</code>.
+     */
+    public final TableField<TimeLogRecord, OffsetDateTime> LAST_LOGIN = 
createField(DSL.name("last_login"), SQLDataType.TIMESTAMPWITHTIMEZONE(6), this, 
"");
+
+    private TimeLog(Name alias, Table<TimeLogRecord> aliased) {
+        this(alias, aliased, null);
+    }
+
+    private TimeLog(Name alias, Table<TimeLogRecord> aliased, Field<?>[] 
parameters) {
+        super(alias, null, aliased, parameters, DSL.comment(""), 
TableOptions.table());
+    }
+
+    /**
+     * Create an aliased <code>texera_db.time_log</code> table reference
+     */
+    public TimeLog(String alias) {
+        this(DSL.name(alias), TIME_LOG);
+    }
+
+    /**
+     * Create an aliased <code>texera_db.time_log</code> table reference
+     */
+    public TimeLog(Name alias) {
+        this(alias, TIME_LOG);
+    }
+
+    /**
+     * Create a <code>texera_db.time_log</code> table reference
+     */
+    public TimeLog() {
+        this(DSL.name("time_log"), null);
+    }
+
+    public <O extends Record> TimeLog(Table<O> child, ForeignKey<O, 
TimeLogRecord> key) {
+        super(child, key, TIME_LOG);
+    }
+
+    @Override
+    public Schema getSchema() {
+        return aliased() ? null : TexeraDb.TEXERA_DB;
+    }
+
+    @Override
+    public UniqueKey<TimeLogRecord> getPrimaryKey() {
+        return Keys.TIME_LOG_PKEY;
+    }
+
+    @Override
+    public List<ForeignKey<TimeLogRecord, ?>> getReferences() {
+        return Arrays.asList(Keys.TIME_LOG__TIME_LOG_UID_FKEY);
+    }
+
+    private transient User _user;
+
+    /**
+     * Get the implicit join path to the <code>texera_db.user</code> table.
+     */
+    public User user() {
+        if (_user == null)
+            _user = new User(this, Keys.TIME_LOG__TIME_LOG_UID_FKEY);
+
+        return _user;
+    }
+
+    @Override
+    public TimeLog as(String alias) {
+        return new TimeLog(DSL.name(alias), this);
+    }
+
+    @Override
+    public TimeLog as(Name alias) {
+        return new TimeLog(alias, this);
+    }
+
+    /**
+     * Rename this table
+     */
+    @Override
+    public TimeLog rename(String name) {
+        return new TimeLog(DSL.name(name), null);
+    }
+
+    /**
+     * Rename this table
+     */
+    @Override
+    public TimeLog rename(Name name) {
+        return new TimeLog(name, null);
+    }
+
+    // 
-------------------------------------------------------------------------
+    // Row2 type methods
+    // 
-------------------------------------------------------------------------
+
+    @Override
+    public Row2<Integer, OffsetDateTime> fieldsRow() {
+        return (Row2) super.fieldsRow();
+    }
+}
diff --git 
a/core/dao/src/main/scala/edu/uci/ics/texera/dao/jooq/generated/tables/daos/TimeLogDao.java
 
b/core/dao/src/main/scala/edu/uci/ics/texera/dao/jooq/generated/tables/daos/TimeLogDao.java
new file mode 100644
index 0000000000..48e90b8a35
--- /dev/null
+++ 
b/core/dao/src/main/scala/edu/uci/ics/texera/dao/jooq/generated/tables/daos/TimeLogDao.java
@@ -0,0 +1,103 @@
+/*
+ * 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.
+ * 
+ * This file is generated by jOOQ.
+ */
+package edu.uci.ics.texera.dao.jooq.generated.tables.daos;
+
+
+import edu.uci.ics.texera.dao.jooq.generated.tables.TimeLog;
+import edu.uci.ics.texera.dao.jooq.generated.tables.records.TimeLogRecord;
+
+import java.time.OffsetDateTime;
+import java.util.List;
+import java.util.Optional;
+
+import org.jooq.Configuration;
+import org.jooq.impl.DAOImpl;
+
+
+/**
+ * This class is generated by jOOQ.
+ */
+@SuppressWarnings({ "all", "unchecked", "rawtypes" })
+public class TimeLogDao extends DAOImpl<TimeLogRecord, 
edu.uci.ics.texera.dao.jooq.generated.tables.pojos.TimeLog, Integer> {
+
+    /**
+     * Create a new TimeLogDao without any configuration
+     */
+    public TimeLogDao() {
+        super(TimeLog.TIME_LOG, 
edu.uci.ics.texera.dao.jooq.generated.tables.pojos.TimeLog.class);
+    }
+
+    /**
+     * Create a new TimeLogDao with an attached configuration
+     */
+    public TimeLogDao(Configuration configuration) {
+        super(TimeLog.TIME_LOG, 
edu.uci.ics.texera.dao.jooq.generated.tables.pojos.TimeLog.class, 
configuration);
+    }
+
+    @Override
+    public Integer 
getId(edu.uci.ics.texera.dao.jooq.generated.tables.pojos.TimeLog object) {
+        return object.getUid();
+    }
+
+    /**
+     * Fetch records that have <code>uid BETWEEN lowerInclusive AND
+     * upperInclusive</code>
+     */
+    public List<edu.uci.ics.texera.dao.jooq.generated.tables.pojos.TimeLog> 
fetchRangeOfUid(Integer lowerInclusive, Integer upperInclusive) {
+        return fetchRange(TimeLog.TIME_LOG.UID, lowerInclusive, 
upperInclusive);
+    }
+
+    /**
+     * Fetch records that have <code>uid IN (values)</code>
+     */
+    public List<edu.uci.ics.texera.dao.jooq.generated.tables.pojos.TimeLog> 
fetchByUid(Integer... values) {
+        return fetch(TimeLog.TIME_LOG.UID, values);
+    }
+
+    /**
+     * Fetch a unique record that has <code>uid = value</code>
+     */
+    public edu.uci.ics.texera.dao.jooq.generated.tables.pojos.TimeLog 
fetchOneByUid(Integer value) {
+        return fetchOne(TimeLog.TIME_LOG.UID, value);
+    }
+
+    /**
+     * Fetch a unique record that has <code>uid = value</code>
+     */
+    public 
Optional<edu.uci.ics.texera.dao.jooq.generated.tables.pojos.TimeLog> 
fetchOptionalByUid(Integer value) {
+        return fetchOptional(TimeLog.TIME_LOG.UID, value);
+    }
+
+    /**
+     * Fetch records that have <code>last_login BETWEEN lowerInclusive AND
+     * upperInclusive</code>
+     */
+    public List<edu.uci.ics.texera.dao.jooq.generated.tables.pojos.TimeLog> 
fetchRangeOfLastLogin(OffsetDateTime lowerInclusive, OffsetDateTime 
upperInclusive) {
+        return fetchRange(TimeLog.TIME_LOG.LAST_LOGIN, lowerInclusive, 
upperInclusive);
+    }
+
+    /**
+     * Fetch records that have <code>last_login IN (values)</code>
+     */
+    public List<edu.uci.ics.texera.dao.jooq.generated.tables.pojos.TimeLog> 
fetchByLastLogin(OffsetDateTime... values) {
+        return fetch(TimeLog.TIME_LOG.LAST_LOGIN, values);
+    }
+}
diff --git 
a/core/dao/src/main/scala/edu/uci/ics/texera/dao/jooq/generated/tables/interfaces/ITimeLog.java
 
b/core/dao/src/main/scala/edu/uci/ics/texera/dao/jooq/generated/tables/interfaces/ITimeLog.java
new file mode 100644
index 0000000000..1fef635ac8
--- /dev/null
+++ 
b/core/dao/src/main/scala/edu/uci/ics/texera/dao/jooq/generated/tables/interfaces/ITimeLog.java
@@ -0,0 +1,69 @@
+/*
+ * 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.
+ * 
+ * This file is generated by jOOQ.
+ */
+package edu.uci.ics.texera.dao.jooq.generated.tables.interfaces;
+
+
+import java.io.Serializable;
+import java.time.OffsetDateTime;
+
+
+/**
+ * This class is generated by jOOQ.
+ */
+@SuppressWarnings({ "all", "unchecked", "rawtypes" })
+public interface ITimeLog extends Serializable {
+
+    /**
+     * Setter for <code>texera_db.time_log.uid</code>.
+     */
+    public void setUid(Integer value);
+
+    /**
+     * Getter for <code>texera_db.time_log.uid</code>.
+     */
+    public Integer getUid();
+
+    /**
+     * Setter for <code>texera_db.time_log.last_login</code>.
+     */
+    public void setLastLogin(OffsetDateTime value);
+
+    /**
+     * Getter for <code>texera_db.time_log.last_login</code>.
+     */
+    public OffsetDateTime getLastLogin();
+
+    // 
-------------------------------------------------------------------------
+    // FROM and INTO
+    // 
-------------------------------------------------------------------------
+
+    /**
+     * Load data from another generated Record/POJO implementing the common
+     * interface ITimeLog
+     */
+    public void from(ITimeLog from);
+
+    /**
+     * Copy data into another generated Record/POJO implementing the common
+     * interface ITimeLog
+     */
+    public <E extends ITimeLog> E into(E into);
+}
diff --git 
a/core/dao/src/main/scala/edu/uci/ics/texera/dao/jooq/generated/tables/pojos/TimeLog.java
 
b/core/dao/src/main/scala/edu/uci/ics/texera/dao/jooq/generated/tables/pojos/TimeLog.java
new file mode 100644
index 0000000000..85c57c243e
--- /dev/null
+++ 
b/core/dao/src/main/scala/edu/uci/ics/texera/dao/jooq/generated/tables/pojos/TimeLog.java
@@ -0,0 +1,113 @@
+/*
+ * 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.
+ * 
+ * This file is generated by jOOQ.
+ */
+package edu.uci.ics.texera.dao.jooq.generated.tables.pojos;
+
+
+import edu.uci.ics.texera.dao.jooq.generated.tables.interfaces.ITimeLog;
+
+import java.time.OffsetDateTime;
+
+
+/**
+ * This class is generated by jOOQ.
+ */
+@SuppressWarnings({ "all", "unchecked", "rawtypes" })
+public class TimeLog implements ITimeLog {
+
+    private static final long serialVersionUID = 1L;
+
+    private Integer        uid;
+    private OffsetDateTime lastLogin;
+
+    public TimeLog() {}
+
+    public TimeLog(ITimeLog value) {
+        this.uid = value.getUid();
+        this.lastLogin = value.getLastLogin();
+    }
+
+    public TimeLog(
+        Integer        uid,
+        OffsetDateTime lastLogin
+    ) {
+        this.uid = uid;
+        this.lastLogin = lastLogin;
+    }
+
+    /**
+     * Getter for <code>texera_db.time_log.uid</code>.
+     */
+    @Override
+    public Integer getUid() {
+        return this.uid;
+    }
+
+    /**
+     * Setter for <code>texera_db.time_log.uid</code>.
+     */
+    @Override
+    public void setUid(Integer uid) {
+        this.uid = uid;
+    }
+
+    /**
+     * Getter for <code>texera_db.time_log.last_login</code>.
+     */
+    @Override
+    public OffsetDateTime getLastLogin() {
+        return this.lastLogin;
+    }
+
+    /**
+     * Setter for <code>texera_db.time_log.last_login</code>.
+     */
+    @Override
+    public void setLastLogin(OffsetDateTime lastLogin) {
+        this.lastLogin = lastLogin;
+    }
+
+    @Override
+    public String toString() {
+        StringBuilder sb = new StringBuilder("TimeLog (");
+
+        sb.append(uid);
+        sb.append(", ").append(lastLogin);
+
+        sb.append(")");
+        return sb.toString();
+    }
+
+    // 
-------------------------------------------------------------------------
+    // FROM and INTO
+    // 
-------------------------------------------------------------------------
+
+    @Override
+    public void from(ITimeLog from) {
+        setUid(from.getUid());
+        setLastLogin(from.getLastLogin());
+    }
+
+    @Override
+    public <E extends ITimeLog> E into(E into) {
+        into.from(this);
+        return into;
+    }
+}
diff --git 
a/core/dao/src/main/scala/edu/uci/ics/texera/dao/jooq/generated/tables/records/TimeLogRecord.java
 
b/core/dao/src/main/scala/edu/uci/ics/texera/dao/jooq/generated/tables/records/TimeLogRecord.java
new file mode 100644
index 0000000000..f1a4585bc1
--- /dev/null
+++ 
b/core/dao/src/main/scala/edu/uci/ics/texera/dao/jooq/generated/tables/records/TimeLogRecord.java
@@ -0,0 +1,196 @@
+/*
+ * 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.
+ * 
+ * This file is generated by jOOQ.
+ */
+package edu.uci.ics.texera.dao.jooq.generated.tables.records;
+
+
+import edu.uci.ics.texera.dao.jooq.generated.tables.TimeLog;
+import edu.uci.ics.texera.dao.jooq.generated.tables.interfaces.ITimeLog;
+
+import java.time.OffsetDateTime;
+
+import org.jooq.Field;
+import org.jooq.Record1;
+import org.jooq.Record2;
+import org.jooq.Row2;
+import org.jooq.impl.UpdatableRecordImpl;
+
+
+/**
+ * This class is generated by jOOQ.
+ */
+@SuppressWarnings({ "all", "unchecked", "rawtypes" })
+public class TimeLogRecord extends UpdatableRecordImpl<TimeLogRecord> 
implements Record2<Integer, OffsetDateTime>, ITimeLog {
+
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * Setter for <code>texera_db.time_log.uid</code>.
+     */
+    @Override
+    public void setUid(Integer value) {
+        set(0, value);
+    }
+
+    /**
+     * Getter for <code>texera_db.time_log.uid</code>.
+     */
+    @Override
+    public Integer getUid() {
+        return (Integer) get(0);
+    }
+
+    /**
+     * Setter for <code>texera_db.time_log.last_login</code>.
+     */
+    @Override
+    public void setLastLogin(OffsetDateTime value) {
+        set(1, value);
+    }
+
+    /**
+     * Getter for <code>texera_db.time_log.last_login</code>.
+     */
+    @Override
+    public OffsetDateTime getLastLogin() {
+        return (OffsetDateTime) get(1);
+    }
+
+    // 
-------------------------------------------------------------------------
+    // Primary key information
+    // 
-------------------------------------------------------------------------
+
+    @Override
+    public Record1<Integer> key() {
+        return (Record1) super.key();
+    }
+
+    // 
-------------------------------------------------------------------------
+    // Record2 type implementation
+    // 
-------------------------------------------------------------------------
+
+    @Override
+    public Row2<Integer, OffsetDateTime> fieldsRow() {
+        return (Row2) super.fieldsRow();
+    }
+
+    @Override
+    public Row2<Integer, OffsetDateTime> valuesRow() {
+        return (Row2) super.valuesRow();
+    }
+
+    @Override
+    public Field<Integer> field1() {
+        return TimeLog.TIME_LOG.UID;
+    }
+
+    @Override
+    public Field<OffsetDateTime> field2() {
+        return TimeLog.TIME_LOG.LAST_LOGIN;
+    }
+
+    @Override
+    public Integer component1() {
+        return getUid();
+    }
+
+    @Override
+    public OffsetDateTime component2() {
+        return getLastLogin();
+    }
+
+    @Override
+    public Integer value1() {
+        return getUid();
+    }
+
+    @Override
+    public OffsetDateTime value2() {
+        return getLastLogin();
+    }
+
+    @Override
+    public TimeLogRecord value1(Integer value) {
+        setUid(value);
+        return this;
+    }
+
+    @Override
+    public TimeLogRecord value2(OffsetDateTime value) {
+        setLastLogin(value);
+        return this;
+    }
+
+    @Override
+    public TimeLogRecord values(Integer value1, OffsetDateTime value2) {
+        value1(value1);
+        value2(value2);
+        return this;
+    }
+
+    // 
-------------------------------------------------------------------------
+    // FROM and INTO
+    // 
-------------------------------------------------------------------------
+
+    @Override
+    public void from(ITimeLog from) {
+        setUid(from.getUid());
+        setLastLogin(from.getLastLogin());
+    }
+
+    @Override
+    public <E extends ITimeLog> E into(E into) {
+        into.from(this);
+        return into;
+    }
+
+    // 
-------------------------------------------------------------------------
+    // Constructors
+    // 
-------------------------------------------------------------------------
+
+    /**
+     * Create a detached TimeLogRecord
+     */
+    public TimeLogRecord() {
+        super(TimeLog.TIME_LOG);
+    }
+
+    /**
+     * Create a detached, initialised TimeLogRecord
+     */
+    public TimeLogRecord(Integer uid, OffsetDateTime lastLogin) {
+        super(TimeLog.TIME_LOG);
+
+        setUid(uid);
+        setLastLogin(lastLogin);
+    }
+
+    /**
+     * Create a detached, initialised TimeLogRecord
+     */
+    public 
TimeLogRecord(edu.uci.ics.texera.dao.jooq.generated.tables.pojos.TimeLog value) 
{
+        super(TimeLog.TIME_LOG);
+
+        if (value != null) {
+            setUid(value.getUid());
+            setLastLogin(value.getLastLogin());
+        }
+    }
+}
diff --git a/core/gui/src/app/common/service/user/auth.service.ts 
b/core/gui/src/app/common/service/user/auth.service.ts
index 197cb41721..b58d2bde27 100644
--- a/core/gui/src/app/common/service/user/auth.service.ts
+++ b/core/gui/src/app/common/service/user/auth.service.ts
@@ -31,7 +31,7 @@ import { GuiConfigService } from "../gui-config.service";
 import { NzModalService } from "ng-zorro-antd/modal";
 
 export const TOKEN_KEY = "access_token";
-export const TOKEN_REFRESH_INTERVAL_IN_MIN = 15;
+export const TOKEN_REFRESH_INTERVAL_IN_MIN = 16;
 
 /**
  * User Service contains the function of registering and logging the user.
diff --git a/core/gui/src/app/common/type/user.ts 
b/core/gui/src/app/common/type/user.ts
index 91691d7b3a..b1157ba1c3 100644
--- a/core/gui/src/app/common/type/user.ts
+++ b/core/gui/src/app/common/type/user.ts
@@ -43,6 +43,7 @@ export interface User
     color?: string;
     googleAvatar?: string;
     comment: string;
+    lastLogin?: number;
   }> {}
 
 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 c40b61e1f9..3e856ff08f 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
@@ -175,7 +175,9 @@
         <texera-user-avatar
           [googleAvatar]="user.googleAvatar"
           [userName]="user.name"
-          class="user-avatar"></texera-user-avatar>
+          class="user-avatar"
+          [ngClass]="{ active: isUserActive(user) }">
+        </texera-user-avatar>
       </td>
       <td>{{user.uid}}</td>
       <td>
diff --git 
a/core/gui/src/app/dashboard/component/admin/user/admin-user.component.scss 
b/core/gui/src/app/dashboard/component/admin/user/admin-user.component.scss
index 0854e5b651..15b8939139 100644
--- a/core/gui/src/app/dashboard/component/admin/user/admin-user.component.scss
+++ b/core/gui/src/app/dashboard/component/admin/user/admin-user.component.scss
@@ -78,3 +78,15 @@
   word-break: break-word;
   white-space: normal;
 }
+
+:host ::ng-deep .user-avatar {
+  display: inline-block;
+  border: 2.5px solid #ccc;
+  border-radius: 50%;
+  padding: 2px;
+  transition: border-color 0.2s ease;
+}
+
+:host ::ng-deep .user-avatar.active {
+  border-color: #4caf50;
+}
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 d899e5e707..7aa1b93c5b 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
@@ -184,6 +184,17 @@ export class AdminUserComponent implements OnInit {
     });
   }
 
+  private static readonly ACTIVE_WINDOW = 15 * 60 * 1000;
+
+  isUserActive(user: User): boolean {
+    if (!user.lastLogin) {
+      return false;
+    }
+
+    const lastMs = user.lastLogin * 1000;
+    return Date.now() - lastMs < AdminUserComponent.ACTIVE_WINDOW;
+  }
+
   public filterByRole: NzTableFilterFn<User> = (list: string[], user: User) =>
     list.some(role => user.role.indexOf(role) !== -1);
 }
diff --git 
a/core/gui/src/app/dashboard/service/admin/user/admin-user.service.ts 
b/core/gui/src/app/dashboard/service/admin/user/admin-user.service.ts
index 203cc0bbed..c43202072f 100644
--- a/core/gui/src/app/dashboard/service/admin/user/admin-user.service.ts
+++ b/core/gui/src/app/dashboard/service/admin/user/admin-user.service.ts
@@ -25,7 +25,7 @@ import { Role, User, File, Workflow, ExecutionQuota } from 
"../../../../common/t
 import { DatasetQuota } from 
"src/app/dashboard/type/quota-statistic.interface";
 
 export const USER_BASE_URL = `${AppSettings.getApiEndpoint()}/admin/user`;
-export const USER_LIST_URL = `${USER_BASE_URL}/list`;
+export const USER_LIST_URL = `${USER_BASE_URL}/listWithActivity`;
 export const USER_UPDATE_URL = `${USER_BASE_URL}/update`;
 export const USER_ADD_URL = `${USER_BASE_URL}/add`;
 export const USER_CREATED_FILES = `${USER_BASE_URL}/uploaded_files`;
diff --git a/core/scripts/sql/texera_ddl.sql b/core/scripts/sql/texera_ddl.sql
index f9d73cc1b2..a19004712c 100644
--- a/core/scripts/sql/texera_ddl.sql
+++ b/core/scripts/sql/texera_ddl.sql
@@ -41,6 +41,7 @@ DROP TABLE IF EXISTS workflow_user_access CASCADE;
 DROP TABLE IF EXISTS workflow_of_user CASCADE;
 DROP TABLE IF EXISTS user_config CASCADE;
 DROP TABLE IF EXISTS "user" CASCADE;
+DROP TABLE IF EXISTS time_log CASCADE;
 DROP TABLE IF EXISTS workflow CASCADE;
 DROP TABLE IF EXISTS workflow_version CASCADE;
 DROP TABLE IF EXISTS project CASCADE;
@@ -348,6 +349,14 @@ CREATE TABLE IF NOT EXISTS site_settings
     updated_at  TIMESTAMP DEFAULT CURRENT_TIMESTAMP
     );
 
+CREATE TABLE IF NOT EXISTS time_log
+(
+    uid            INT          NOT NULL
+        PRIMARY KEY
+        REFERENCES "user"(uid),
+    last_login     TIMESTAMPTZ
+);
+
 -- computing_unit_user_access table
 CREATE TABLE IF NOT EXISTS computing_unit_user_access
 (
diff --git a/core/scripts/sql/updates/10.sql b/core/scripts/sql/updates/10.sql
new file mode 100644
index 0000000000..c449cb3cd2
--- /dev/null
+++ b/core/scripts/sql/updates/10.sql
@@ -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.
+
+\c texera_db
+
+SET search_path TO texera_db;
+
+BEGIN;
+CREATE TABLE time_log
+(
+    uid            INT          NOT NULL
+        PRIMARY KEY
+        REFERENCES "user"(uid),
+    last_login     TIMESTAMPTZ
+);
+COMMIT;
\ No newline at end of file

Reply via email to