Refresh auth session with server-authoritative current user data (#59)

* Initial plan

* feat: refresh and reload current user profile

Agent-Logs-Url: https://github.com/RMCampos/tasknote/sessions/3f01367a-baee-4bbe-8f66-f04da58d485f

Co-authored-by: RMCampos <2219519+RMCampos@users.noreply.github.com>

* test: fix time zone test issue

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: RMCampos <2219519+RMCampos@users.noreply.github.com>
Co-authored-by: Ricardo Campos <ricardompcampos@gmail.com>
This commit is contained in:
Copilot
2026-05-18 22:26:31 -03:00
committed by GitHub
co-authored by copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> RMCampos rmcampos
parent 1bb59dcbc5
commit f34c359720
8 changed files with 129 additions and 17 deletions
@@ -7,6 +7,7 @@ import AuthProvider from '../../context/AuthProvider';
import AuthContext, { AuthContextData } from '../../context/AuthContext';
import api from '../../api-service/api';
import { API_TOKEN, USER_DATA } from '../../app-constants/app-constants';
import ApiConfig from '../../api-service/apiConfig';
// Mock the API service methods.
vi.mock('../../api-service/api');
@@ -119,17 +120,29 @@ describe('AuthProvider', () => {
});
it('should set loading to false and signed to true after successful initial auth check', async () => {
const fakeResponse = {
const fakeTokenResponse = {
token: 'refresh-token',
};
const fakeCurrentUser = {
userId: '789',
name: 'Refreshed User',
email: 'refreshed@example.com',
admin: false,
createdAt: new Date().toISOString(),
gravatarImageUrl: 'http://dummyimage.com'
gravatarImageUrl: 'http://dummyimage.com',
lang: 'en',
lastLogin: new Date().toISOString()
};
vi.spyOn(api, 'getJSON').mockResolvedValue(fakeResponse);
vi.spyOn(api, 'getJSON').mockImplementation(async(url: string) => {
if (url === ApiConfig.refreshTokenUrl) {
return fakeTokenResponse;
}
if (url === ApiConfig.currentUserUrl) {
return fakeCurrentUser;
}
return undefined;
});
localStorage.setItem(API_TOKEN, 'dummy');
const { getByTestId } = render(
@@ -142,6 +155,7 @@ describe('AuthProvider', () => {
expect(getByTestId('loading').textContent).toBe('false')
);
expect(getByTestId('signed').textContent).toBe('true');
expect(getByTestId('user').textContent).toBe('Refreshed User');
});
it('should sign in a user successfully', async () => {
@@ -251,17 +265,29 @@ describe('AuthProvider', () => {
});
it('should call fetchCurrentSession when checking current auth user', async () => {
const fakeResponse = {
const fakeTokenResponse = {
token: 'refresh-token',
};
const fakeCurrentUser = {
userId: '789',
name: 'Refreshed User',
email: 'refreshed@example.com',
admin: false,
createdAt: new Date().toISOString(),
gravatarImageUrl: 'http://dummyimage.com'
gravatarImageUrl: 'http://dummyimage.com',
lang: 'en',
lastLogin: new Date().toISOString()
};
vi.spyOn(api, 'getJSON').mockResolvedValue(fakeResponse);
vi.spyOn(api, 'getJSON').mockImplementation(async(url: string) => {
if (url === ApiConfig.refreshTokenUrl) {
return fakeTokenResponse;
}
if (url === ApiConfig.currentUserUrl) {
return fakeCurrentUser;
}
return undefined;
});
// Store API_TOKEN so that fetchCurrentSession runs the refresh logic.
localStorage.setItem(API_TOKEN, 'dummy');
@@ -283,8 +309,9 @@ describe('AuthProvider', () => {
await user.click(getByTestIdFunction('checkCurrentAuthUser'));
await waitFor(() =>
expect(localStorage.getItem(API_TOKEN)).toBe('refresh-token')
);
await waitFor(() => {
expect(localStorage.getItem(API_TOKEN)).toBe('refresh-token');
expect(localStorage.getItem(USER_DATA)).toContain('Refreshed User');
});
});
});
+3 -1
View File
@@ -28,7 +28,9 @@ const ApiConfig = {
publicNotesUrl: `${server}/public/notes`,
userUrl: `${server}/rest/users`
userUrl: `${server}/rest/users`,
currentUserUrl: `${server}/rest/users/me`
};
export default ApiConfig;
+3 -2
View File
@@ -24,7 +24,6 @@ const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }: Pro
}
try {
const bearerToken: SignInResponse = await api.getJSON(ApiConfig.refreshTokenUrl);
setSigned(true);
return bearerToken;
}
catch (e) {
@@ -66,8 +65,10 @@ const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }: Pro
const checkCurrentAuthUser = async (pathname: string): Promise<void> => {
const bearerToken: SignInResponse | undefined = await fetchCurrentSession(pathname);
if (bearerToken && bearerToken.token) {
const userLocal = updateUserSession(null, bearerToken.token);
const currentUser: UserResponse = await api.getJSON(ApiConfig.currentUserUrl);
const userLocal = updateUserSession(currentUser, bearerToken.token);
if (userLocal) {
setSigned(true);
setUser(userLocal);
}
}
@@ -33,6 +33,16 @@ public class UserController {
return authService.getAllUsers();
}
/**
* Get the current logged user.
*
* @return UserEntity with the current user information.
*/
@GetMapping("/me")
public UserResponse getCurrentUser() {
return authService.getCurrentUserResponse();
}
@PatchMapping
public ResponseEntity<UserResponse> patchUserInfo(
@RequestBody @Valid UserPatchRequest taskRequest) {
@@ -393,6 +393,17 @@ public class AuthService {
return findByEmail(email);
}
/**
* Get the current logged user as response object.
*
* @return An instance of {@link UserResponse} with the current user.
* @throws UserNotFoundException when the user was not found
*/
public UserResponse getCurrentUserResponse() {
UserEntity user = getCurrentUser().orElseThrow(UserNotFoundException::new);
return UserResponse.fromEntity(user, getGravatarImageUrl(user.getEmail()).orElse(null));
}
/**
* Confirm a user account.
*
@@ -18,10 +18,20 @@ public class TimeAgoUtil {
* @return String with formatted time.
*/
public static String format(LocalDateTime pastTime) {
return format(pastTime, LocalDateTime.now());
}
/**
* Format a time in the time ago format.
*
* @param pastTime The pastime to be formatted.
* @param now The current time.
* @return String with formatted time.
*/
public static String format(LocalDateTime pastTime, LocalDateTime now) {
if (Objects.isNull(pastTime)) {
return "Some time ago";
}
LocalDateTime now = LocalDateTime.now();
Period period = Period.between(pastTime.toLocalDate(), now.toLocalDate());
Duration duration = Duration.between(pastTime, now);
if (period.getYears() > 1) {
@@ -32,10 +42,12 @@ public class TimeAgoUtil {
return String.format("%d months ago", period.getMonths());
} else if (period.getMonths() > 0) {
return String.format("%d month ago", period.getMonths());
} else if (period.getDays() > 1) {
return String.format("%d days ago", period.getDays());
} else if (period.getDays() > 0) {
return String.format("%d day ago", period.getDays());
} else if (duration.toHours() >= 24) {
if (period.getDays() > 1) {
return String.format("%d days ago", period.getDays());
} else {
return String.format("%d day ago", period.getDays());
}
} else if (duration.toHours() > 1L) {
return String.format("%d hours ago", duration.toHours());
} else if (duration.toHours() > 0L) {
@@ -63,6 +63,40 @@ class UserControllerTest {
.andReturn();
}
@Test
@DisplayName("Get current user happy path should succeed")
@WithMockUser(username = "user@domain.com", password = "abcde123456A@")
void getCurrentUser_happyPath_shouldSucceed() throws Exception {
UserResponse userResponse =
new UserResponse(1L, "John", "email@test.com", false, null, null, null, null);
when(authService.getCurrentUserResponse()).thenReturn(userResponse);
mockMvc
.perform(
get("/rest/users/me")
.with(csrf().asHeader())
.header("Content-Type", MediaType.APPLICATION_JSON_VALUE)
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.userId").value(userResponse.userId()))
.andExpect(jsonPath("$.email").value(userResponse.email()))
.andExpect(jsonPath("$.admin").value(userResponse.admin()))
.andReturn();
}
@Test
@DisplayName("Get current user with 401 unauthorized request should fail")
void getCurrentUser_unauthorized_shouldFail() throws Exception {
mockMvc
.perform(
get("/rest/users/me")
.with(csrf().asHeader())
.header("Content-Type", MediaType.APPLICATION_JSON_VALUE)
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isUnauthorized())
.andReturn();
}
@Test
@DisplayName("Patch user info happy path should succeed")
@WithMockUser(username = "user@domain.com", password = "abcde123456A@")
@@ -21,6 +21,21 @@ class TimeAgoUtilTest {
"2 months ago", TimeAgoUtil.format(LocalDateTime.now().minusMonths(2L)));
}
@Test
void formatMidnightRolloverTest() {
LocalDateTime now = LocalDateTime.of(2026, 5, 19, 0, 30); // 12:30 AM next day
LocalDateTime pastTime = now.minusHours(1); // 11:30 PM previous day
Assertions.assertEquals("1 hour ago", TimeAgoUtil.format(pastTime, now));
// Test exactly 24 hours ago
LocalDateTime twentyFourHoursAgo = now.minusDays(1);
Assertions.assertEquals("1 day ago", TimeAgoUtil.format(twentyFourHoursAgo, now));
// Test 23 hours ago across midnight
LocalDateTime twentyThreeHoursAgo = now.minusHours(23); // 1:30 AM previous day
Assertions.assertEquals("23 hours ago", TimeAgoUtil.format(twentyThreeHoursAgo, now));
}
@Test
void formatDueDateTest() {
Assertions.assertNull(TimeAgoUtil.formatDueDate(null));