diff --git a/client/src/components/Sidebar/index.tsx b/client/src/components/Sidebar/index.tsx index c485edad..cdd57275 100644 --- a/client/src/components/Sidebar/index.tsx +++ b/client/src/components/Sidebar/index.tsx @@ -1,4 +1,4 @@ -import React, { useContext, useEffect } from 'react'; +import React, { useContext, useEffect, useState } from 'react'; import { Nav } from 'react-bootstrap'; import { NavLink } from 'react-router'; import { useTranslation } from 'react-i18next'; @@ -22,6 +22,7 @@ interface Props { function Sidebar(props: React.PropsWithChildren): React.ReactNode { const { signOut, user } = useContext(AuthContext); const { currentPage, setNewPage } = useContext(SidebarContext); + const [lastSeen, setLastSeen] = useState(''); const { t } = useTranslation(); const build = `Build: ${env.VITE_BUILD}`; @@ -46,7 +47,25 @@ function Sidebar(props: React.PropsWithChildren): React.ReactNode { return ''; }; - useEffect(() => {}, [user, currentPage]); + useEffect(() => { + if (user && user.lastLogin) { + const utcString = user.lastLogin.endsWith('Z') ? user.lastLogin : `${user.lastLogin}Z`; + const date = new Date(utcString); + if (Number.isNaN(date.getTime())) { + setLastSeen(''); + return; + } + const fmtted = date.toLocaleString(navigator.language, { + timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + setLastSeen(fmtted); + } + }, [user]); return ( <> @@ -95,9 +114,12 @@ function Sidebar(props: React.PropsWithChildren): React.ReactNode { {/* Footer at the bottom */}
- - {build} - + {lastSeen && ( +
+ {t('sidebar_last_seen', { time: lastSeen })} +
+ )} + {build}
diff --git a/client/src/constants/english.ts b/client/src/constants/english.ts index 3877f28a..5f13a62a 100644 --- a/client/src/constants/english.ts +++ b/client/src/constants/english.ts @@ -187,6 +187,7 @@ const enTranslations = { account_delete_btn: 'Yes, delete everything', footer_my_account: 'My Account ', + sidebar_last_seen: 'Last seen {{time}}', logout: 'Logout' }; diff --git a/client/src/constants/portuguese.ts b/client/src/constants/portuguese.ts index 66e93421..675e7dfb 100644 --- a/client/src/constants/portuguese.ts +++ b/client/src/constants/portuguese.ts @@ -188,6 +188,7 @@ const ptBrTranslations = { account_delete_btn: 'Sim, deletar tudo', footer_my_account: 'Minha Conta ', + sidebar_last_seen: 'Último acesso {{time}}', logout: 'Sair' }; diff --git a/client/src/constants/russian.ts b/client/src/constants/russian.ts index bb813aeb..adba7d5f 100644 --- a/client/src/constants/russian.ts +++ b/client/src/constants/russian.ts @@ -187,6 +187,7 @@ const ruTranslations = { account_delete_btn: 'Да, удалить все', footer_my_account: 'Мой аккаунт ', + sidebar_last_seen: 'Последний вход {{time}}', logout: 'Выйти' }; diff --git a/client/src/constants/spanish.ts b/client/src/constants/spanish.ts index 78d40511..f6827ca4 100644 --- a/client/src/constants/spanish.ts +++ b/client/src/constants/spanish.ts @@ -187,6 +187,7 @@ const esTranslations = { account_delete_btn: 'Sí, borra todo', footer_my_account: 'Mi Cuenta ', + sidebar_last_seen: 'Último acceso {{time}}', logout: 'Cerrar sesión' }; diff --git a/client/src/context/AuthProvider.tsx b/client/src/context/AuthProvider.tsx index afb36248..34391a93 100644 --- a/client/src/context/AuthProvider.tsx +++ b/client/src/context/AuthProvider.tsx @@ -97,7 +97,8 @@ const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }: Pro admin: registerResponse.admin, createdAt: new Date(registerResponse.createdAt), gravatarImageUrl: registerResponse.gravatarImageUrl, - lang: registerResponse.lang + lang: registerResponse.lang, + lastLogin: registerResponse.lastLogin }; setSigned(true); diff --git a/client/src/types/SigninResponse.ts b/client/src/types/SigninResponse.ts index 2a36bda9..09396ad5 100644 --- a/client/src/types/SigninResponse.ts +++ b/client/src/types/SigninResponse.ts @@ -7,4 +7,5 @@ export type SignInResponse = { token: string; gravatarImageUrl: string; lang: string; + lastLogin: string; }; diff --git a/client/src/types/UserResponse.ts b/client/src/types/UserResponse.ts index cbf5ae51..a9890531 100644 --- a/client/src/types/UserResponse.ts +++ b/client/src/types/UserResponse.ts @@ -6,4 +6,5 @@ export type UserResponse = { createdAt: Date; gravatarImageUrl: string; lang: string; + lastLogin: string; }; diff --git a/server/src/main/java/br/com/tasknoteapp/server/entity/UserEntity.java b/server/src/main/java/br/com/tasknoteapp/server/entity/UserEntity.java index b942d7e5..8e6a014f 100644 --- a/server/src/main/java/br/com/tasknoteapp/server/entity/UserEntity.java +++ b/server/src/main/java/br/com/tasknoteapp/server/entity/UserEntity.java @@ -58,6 +58,9 @@ public class UserEntity implements UserDetails { @Column(name = "last_password_change", nullable = false) private LocalDateTime lastPasswordChange; + @Column(name = "last_login") + private LocalDateTime lastLogin; + @Override public Collection getAuthorities() { return List.of(); @@ -173,4 +176,12 @@ public LocalDateTime getLastPasswordChange() { public void setLastPasswordChange(LocalDateTime lastPasswordChange) { this.lastPasswordChange = lastPasswordChange; } + + public LocalDateTime getLastLogin() { + return lastLogin; + } + + public void setLastLogin(LocalDateTime lastLogin) { + this.lastLogin = lastLogin; + } } diff --git a/server/src/main/java/br/com/tasknoteapp/server/response/UserResponse.java b/server/src/main/java/br/com/tasknoteapp/server/response/UserResponse.java index 9f1d0711..f158ef4c 100644 --- a/server/src/main/java/br/com/tasknoteapp/server/response/UserResponse.java +++ b/server/src/main/java/br/com/tasknoteapp/server/response/UserResponse.java @@ -11,6 +11,7 @@ public record UserResponse( Boolean admin, LocalDateTime createdAt, LocalDateTime inactivatedAt, + LocalDateTime lastLogin, String gravatarImageUrl) { /** @@ -27,6 +28,7 @@ public static UserResponse fromEntity(UserEntity user, String gravatarUrl) { user.getAdmin(), user.getCreatedAt(), user.getInactivatedAt(), + user.getLastLogin(), gravatarUrl); } } diff --git a/server/src/main/java/br/com/tasknoteapp/server/response/UserResponseWithToken.java b/server/src/main/java/br/com/tasknoteapp/server/response/UserResponseWithToken.java index 5e28156a..f335d266 100644 --- a/server/src/main/java/br/com/tasknoteapp/server/response/UserResponseWithToken.java +++ b/server/src/main/java/br/com/tasknoteapp/server/response/UserResponseWithToken.java @@ -11,6 +11,7 @@ public record UserResponseWithToken( Boolean admin, LocalDateTime createdAt, LocalDateTime inactivatedAt, + LocalDateTime lastLogin, String gravatarImageUrl, String token, String lang) { @@ -31,6 +32,7 @@ public static UserResponseWithToken fromEntity( user.getAdmin(), user.getCreatedAt(), user.getInactivatedAt(), + user.getLastLogin(), gravatarUrl, token, user.getLang()); diff --git a/server/src/main/java/br/com/tasknoteapp/server/service/AuthService.java b/server/src/main/java/br/com/tasknoteapp/server/service/AuthService.java index 94bfc945..0ffa5c0d 100644 --- a/server/src/main/java/br/com/tasknoteapp/server/service/AuthService.java +++ b/server/src/main/java/br/com/tasknoteapp/server/service/AuthService.java @@ -29,6 +29,7 @@ import java.security.NoSuchAlgorithmException; import java.time.Duration; import java.time.LocalDateTime; +import java.time.ZoneOffset; import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.Arrays; @@ -209,10 +210,23 @@ public UserResponseWithToken signInUser(LoginRequest login) { logger.info("User authenticated! Token {}", token.substring(0, 6) + "..."); + final LocalDateTime previousLastLogin = user.getLastLogin(); + + user.setLastLogin(LocalDateTime.now(ZoneOffset.UTC).truncatedTo(ChronoUnit.SECONDS)); userPwdLimitRepository.deleteAllForUser(user.getId()); userRepository.save(user); - return UserResponseWithToken.fromEntity( - user, token, getGravatarImageUrl(login.email()).orElse(null)); + + return new UserResponseWithToken( + user.getId(), + user.getName(), + user.getEmail(), + user.getAdmin(), + user.getCreatedAt(), + user.getInactivatedAt(), + previousLastLogin, + getGravatarImageUrl(login.email()).orElse(null), + token, + user.getLang()); } catch (BadCredentialsException e) { logger.error( "BadCredentialsException when logging in user {}: {}", user.getId(), e.getMessage()); @@ -553,4 +567,5 @@ private boolean hasValidMailgunApiKey() { return Optional.ofNullable(apiKey).isPresent() && !"invalid-api-key-only-placeholder".equals(apiKey); } + } diff --git a/server/src/main/resources/db/migration/V202605171000__add_user_last_login.sql b/server/src/main/resources/db/migration/V202605171000__add_user_last_login.sql new file mode 100644 index 00000000..db0fb2b7 --- /dev/null +++ b/server/src/main/resources/db/migration/V202605171000__add_user_last_login.sql @@ -0,0 +1 @@ +ALTER TABLE tasknote.users ADD COLUMN last_login TIMESTAMP; diff --git a/server/src/test/java/br/com/tasknoteapp/server/controller/AuthenticationControllerTest.java b/server/src/test/java/br/com/tasknoteapp/server/controller/AuthenticationControllerTest.java index 37eae3d3..42ab95bc 100644 --- a/server/src/test/java/br/com/tasknoteapp/server/controller/AuthenticationControllerTest.java +++ b/server/src/test/java/br/com/tasknoteapp/server/controller/AuthenticationControllerTest.java @@ -32,12 +32,22 @@ class AuthenticationControllerTest { @Test @DisplayName("Sign up happy path should succeed") void signup_happyPath_shouldSucceed() throws Exception { - LoginRequest request = new LoginRequest("user@domain.com", "abcde123456", "abcde123456", "en"); + LoginRequest request = + new LoginRequest("user@domain.com", "abcde123456", "abcde123456", "en"); final String token = "xaxbxcxdx1x2x3A@"; UserResponseWithToken response = new UserResponseWithToken( - 123L, null, request.email(), false, LocalDateTime.now(), null, null, token, "en"); + 123L, + null, + request.email(), + false, + LocalDateTime.now(), + null, + null, + null, + token, + "en"); when(authService.signUpNewUser(request)).thenReturn(response); String jsonString = @@ -45,7 +55,8 @@ void signup_happyPath_shouldSucceed() throws Exception { { "email": "user@domain.com", "password": "abcde123456", - "passwordAgain": "abcde123456" + "passwordAgain": "abcde123456", + "timezone": "UTC" } """; @@ -63,12 +74,22 @@ void signup_happyPath_shouldSucceed() throws Exception { @Test @DisplayName("Sign up bad email request should fail") void signup_badEmailRequest_shouldFail() throws Exception { - LoginRequest request = new LoginRequest("user@domain..com", "abcde123456", "abcde123456", "en"); + LoginRequest request = + new LoginRequest("user@domain..com", "abcde123456", "abcde123456", "en"); final String token = "xaxbxcxdx1x2x3@A"; UserResponseWithToken response = new UserResponseWithToken( - 123L, null, request.email(), false, LocalDateTime.now(), null, null, token, "en"); + 123L, + null, + request.email(), + false, + LocalDateTime.now(), + null, + null, + null, + token, + "en"); when(authService.signUpNewUser(request)).thenReturn(response); String jsonString = @@ -76,7 +97,8 @@ void signup_badEmailRequest_shouldFail() throws Exception { { "email": "user@domain..com", "password": "abcde123456", - "passwordAgain": "abcde123456" + "passwordAgain": "abcde123456", + "timezone": "UTC" } """; @@ -94,7 +116,8 @@ void signup_badEmailRequest_shouldFail() throws Exception { @Test @DisplayName("Sign up email already exists should fail") void signup_userAlreadyExists_shouldFail() throws Exception { - LoginRequest request = new LoginRequest("user@domain.com", "abcde123456", "abcde123456", "en"); + LoginRequest request = + new LoginRequest("user@domain.com", "abcde123456", "abcde123456", "en"); when(authService.signUpNewUser(request)).thenThrow(new EmailAlreadyExistsException()); @@ -104,7 +127,8 @@ void signup_userAlreadyExists_shouldFail() throws Exception { "email": "user@domain.com", "password": "abcde123456", "passwordAgain": "abcde123456", - "lang": "en" + "lang": "en", + "timezone": "UTC" } """; @@ -122,12 +146,22 @@ void signup_userAlreadyExists_shouldFail() throws Exception { @Test @DisplayName("Sign in happy path should succeed") void signin_happyPath_shouldSucceed() throws Exception { - LoginRequest request = new LoginRequest("user@domain.com", "abcde123456", "abcde123456", "en"); + LoginRequest request = + new LoginRequest("user@domain.com", "abcde123456", "abcde123456", "en"); final String token = "xaxbxcxdx1x2x3A@"; UserResponseWithToken response = new UserResponseWithToken( - 123L, null, request.email(), false, LocalDateTime.now(), null, null, token, "en"); + 123L, + null, + request.email(), + false, + LocalDateTime.now(), + null, + null, + null, + token, + "en"); when(authService.signInUser(request)).thenReturn(response); String jsonString = @@ -136,7 +170,8 @@ void signin_happyPath_shouldSucceed() throws Exception { "email": "user@domain.com", "password": "abcde123456", "passwordAgain": "abcde123456", - "lang": "en" + "lang": "en", + "timezone": "UTC" } """; @@ -158,7 +193,8 @@ void signin_happyPath_shouldSucceed() throws Exception { @Test @DisplayName("Sign in invalid credentials should fail") void signIn_invalidCredentials_shouldFail() throws Exception { - LoginRequest request = new LoginRequest("user@domain.com", "abcde123456", "abcde123456", "en"); + LoginRequest request = + new LoginRequest("user@domain.com", "abcde123456", "abcde123456", "en"); when(authService.signInUser(request)).thenReturn(null); @@ -166,7 +202,8 @@ void signIn_invalidCredentials_shouldFail() throws Exception { """ { "email": "user@domain.com", - "password": "abcde123456" + "password": "abcde123456", + "timezone": "UTC" } """; diff --git a/server/src/test/java/br/com/tasknoteapp/server/controller/UserControllerTest.java b/server/src/test/java/br/com/tasknoteapp/server/controller/UserControllerTest.java index a4703f4e..31c43a32 100644 --- a/server/src/test/java/br/com/tasknoteapp/server/controller/UserControllerTest.java +++ b/server/src/test/java/br/com/tasknoteapp/server/controller/UserControllerTest.java @@ -34,7 +34,7 @@ class UserControllerTest { @WithMockUser(username = "user@domain.com", password = "abcde123456A@") void getAllUsers_happyPath_shouldSucceed() throws Exception { UserResponse userResponse = - new UserResponse(1L, "John", "email@test.com", false, null, null, null); + new UserResponse(1L, "John", "email@test.com", false, null, null, null, null); when(authService.getAllUsers()).thenReturn(List.of(userResponse)); mockMvc @@ -68,7 +68,7 @@ void getAllUsers_unauthorized_shouldFail() throws Exception { @WithMockUser(username = "user@domain.com", password = "abcde123456A@") void patchUserInfo_happyPath_shouldSucceed() throws Exception { UserResponse response = - new UserResponse(1L, "John", "email@example.com", false, null, null, null); + new UserResponse(1L, "John", "email@example.com", false, null, null, null, null); UserPatchRequest request = new UserPatchRequest("John Doe", response.email(), null, null, null); when(authService.patchUserInfo(request)).thenReturn(response); diff --git a/server/src/test/java/br/com/tasknoteapp/server/controller/UserSessionControllerTest.java b/server/src/test/java/br/com/tasknoteapp/server/controller/UserSessionControllerTest.java index d1f75097..6b99909c 100644 --- a/server/src/test/java/br/com/tasknoteapp/server/controller/UserSessionControllerTest.java +++ b/server/src/test/java/br/com/tasknoteapp/server/controller/UserSessionControllerTest.java @@ -63,7 +63,8 @@ void refresh_unauthorized_shouldFail() throws Exception { @DisplayName("Delete account happy path should succeed") @WithMockUser(username = "user@domain.com", password = "abcde123456A@") void deleteAccount_happyPath_shouldSucceed() throws Exception { - UserResponse response = new UserResponse(1L, "John", "email@test.com", false, null, null, null); + UserResponse response = + new UserResponse(1L, "John", "email@test.com", false, null, null, null, null); when(userSessionService.deleteCurrentUserAccount()).thenReturn(response); mockMvc diff --git a/server/src/test/java/br/com/tasknoteapp/server/service/AuthServiceTest.java b/server/src/test/java/br/com/tasknoteapp/server/service/AuthServiceTest.java index bba793f2..bf346dba 100644 --- a/server/src/test/java/br/com/tasknoteapp/server/service/AuthServiceTest.java +++ b/server/src/test/java/br/com/tasknoteapp/server/service/AuthServiceTest.java @@ -100,6 +100,7 @@ void signUpNewUser_happyPath_shouldSucceed() { Assertions.assertNull(token.token()); Assertions.assertEquals(entity.getEmail(), token.email()); Assertions.assertNotNull(entity.getEmailUuid()); + verify(userRepository).save(any(UserEntity.class)); } @Test @@ -194,11 +195,13 @@ void loadUserByUsername_notFound_shouldFail() { @DisplayName("SignIn user happy path should succeed") void signInUser_happyPath_shouldSucceed() { LoginRequest request = new LoginRequest("email@domain.com", "123456", "123456", "en"); + LocalDateTime oldLastLogin = LocalDateTime.of(2026, 5, 1, 10, 20, 30); UserEntity existing = new UserEntity(); existing.setId(919L); existing.setEmail(request.email()); existing.setEmailConfirmedAt(LocalDateTime.now()); + existing.setLastLogin(oldLastLogin); when(userRepository.findByEmail(request.email())).thenReturn(Optional.of(existing)); when(userPwdLimitRepository.findTop3ByUser_idOrderByWhenHappenedDesc(existing.getId())) @@ -212,6 +215,7 @@ void signInUser_happyPath_shouldSucceed() { Assertions.assertNotNull(token); Assertions.assertEquals("a1b2c3", token.token()); + Assertions.assertEquals(oldLastLogin, token.lastLogin()); } @Test diff --git a/server/src/test/java/br/com/tasknoteapp/server/service/UserSessionServiceTest.java b/server/src/test/java/br/com/tasknoteapp/server/service/UserSessionServiceTest.java index 81036432..76ab17ca 100644 --- a/server/src/test/java/br/com/tasknoteapp/server/service/UserSessionServiceTest.java +++ b/server/src/test/java/br/com/tasknoteapp/server/service/UserSessionServiceTest.java @@ -51,7 +51,8 @@ void deleteCurrentUserAccount_happyPath_shouldSucceed() { when(noteService.getAllNotes()).thenReturn(List.of(note)); when(authService.deleteUserAccount()) .thenReturn( - new UserResponse(user.getId(), "user", user.getEmail(), false, null, null, null)); + new UserResponse( + user.getId(), "user", user.getEmail(), false, null, null, null, null)); // Act UserResponse response = userSessionService.deleteCurrentUserAccount();