Skip to content
This repository was archived by the owner on Jun 15, 2026. It is now read-only.

Commit f34c359

Browse files
CopilotRMCampos
andauthored
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>
1 parent 1bb59dc commit f34c359

8 files changed

Lines changed: 129 additions & 17 deletions

File tree

client/src/__test__/context/AuthProvider.test.tsx

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import AuthProvider from '../../context/AuthProvider';
77
import AuthContext, { AuthContextData } from '../../context/AuthContext';
88
import api from '../../api-service/api';
99
import { API_TOKEN, USER_DATA } from '../../app-constants/app-constants';
10+
import ApiConfig from '../../api-service/apiConfig';
1011

1112
// Mock the API service methods.
1213
vi.mock('../../api-service/api');
@@ -119,17 +120,29 @@ describe('AuthProvider', () => {
119120
});
120121

121122
it('should set loading to false and signed to true after successful initial auth check', async () => {
122-
const fakeResponse = {
123+
const fakeTokenResponse = {
123124
token: 'refresh-token',
125+
};
126+
const fakeCurrentUser = {
124127
userId: '789',
125128
name: 'Refreshed User',
126129
email: 'refreshed@example.com',
127130
admin: false,
128131
createdAt: new Date().toISOString(),
129-
gravatarImageUrl: 'http://dummyimage.com'
132+
gravatarImageUrl: 'http://dummyimage.com',
133+
lang: 'en',
134+
lastLogin: new Date().toISOString()
130135
};
131136

132-
vi.spyOn(api, 'getJSON').mockResolvedValue(fakeResponse);
137+
vi.spyOn(api, 'getJSON').mockImplementation(async(url: string) => {
138+
if (url === ApiConfig.refreshTokenUrl) {
139+
return fakeTokenResponse;
140+
}
141+
if (url === ApiConfig.currentUserUrl) {
142+
return fakeCurrentUser;
143+
}
144+
return undefined;
145+
});
133146
localStorage.setItem(API_TOKEN, 'dummy');
134147

135148
const { getByTestId } = render(
@@ -142,6 +155,7 @@ describe('AuthProvider', () => {
142155
expect(getByTestId('loading').textContent).toBe('false')
143156
);
144157
expect(getByTestId('signed').textContent).toBe('true');
158+
expect(getByTestId('user').textContent).toBe('Refreshed User');
145159
});
146160

147161
it('should sign in a user successfully', async () => {
@@ -251,17 +265,29 @@ describe('AuthProvider', () => {
251265
});
252266

253267
it('should call fetchCurrentSession when checking current auth user', async () => {
254-
const fakeResponse = {
268+
const fakeTokenResponse = {
255269
token: 'refresh-token',
270+
};
271+
const fakeCurrentUser = {
256272
userId: '789',
257273
name: 'Refreshed User',
258274
email: 'refreshed@example.com',
259275
admin: false,
260276
createdAt: new Date().toISOString(),
261-
gravatarImageUrl: 'http://dummyimage.com'
277+
gravatarImageUrl: 'http://dummyimage.com',
278+
lang: 'en',
279+
lastLogin: new Date().toISOString()
262280
};
263281

264-
vi.spyOn(api, 'getJSON').mockResolvedValue(fakeResponse);
282+
vi.spyOn(api, 'getJSON').mockImplementation(async(url: string) => {
283+
if (url === ApiConfig.refreshTokenUrl) {
284+
return fakeTokenResponse;
285+
}
286+
if (url === ApiConfig.currentUserUrl) {
287+
return fakeCurrentUser;
288+
}
289+
return undefined;
290+
});
265291

266292
// Store API_TOKEN so that fetchCurrentSession runs the refresh logic.
267293
localStorage.setItem(API_TOKEN, 'dummy');
@@ -283,8 +309,9 @@ describe('AuthProvider', () => {
283309

284310
await user.click(getByTestIdFunction('checkCurrentAuthUser'));
285311

286-
await waitFor(() =>
287-
expect(localStorage.getItem(API_TOKEN)).toBe('refresh-token')
288-
);
312+
await waitFor(() => {
313+
expect(localStorage.getItem(API_TOKEN)).toBe('refresh-token');
314+
expect(localStorage.getItem(USER_DATA)).toContain('Refreshed User');
315+
});
289316
});
290317
});

client/src/api-service/apiConfig.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,9 @@ const ApiConfig = {
2828

2929
publicNotesUrl: `${server}/public/notes`,
3030

31-
userUrl: `${server}/rest/users`
31+
userUrl: `${server}/rest/users`,
32+
33+
currentUserUrl: `${server}/rest/users/me`
3234
};
3335

3436
export default ApiConfig;

client/src/context/AuthProvider.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }: Pro
2424
}
2525
try {
2626
const bearerToken: SignInResponse = await api.getJSON(ApiConfig.refreshTokenUrl);
27-
setSigned(true);
2827
return bearerToken;
2928
}
3029
catch (e) {
@@ -66,8 +65,10 @@ const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }: Pro
6665
const checkCurrentAuthUser = async (pathname: string): Promise<void> => {
6766
const bearerToken: SignInResponse | undefined = await fetchCurrentSession(pathname);
6867
if (bearerToken && bearerToken.token) {
69-
const userLocal = updateUserSession(null, bearerToken.token);
68+
const currentUser: UserResponse = await api.getJSON(ApiConfig.currentUserUrl);
69+
const userLocal = updateUserSession(currentUser, bearerToken.token);
7070
if (userLocal) {
71+
setSigned(true);
7172
setUser(userLocal);
7273
}
7374
}

server/src/main/java/br/com/tasknoteapp/server/controller/UserController.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,16 @@ public List<UserResponse> getAllUsers() {
3333
return authService.getAllUsers();
3434
}
3535

36+
/**
37+
* Get the current logged user.
38+
*
39+
* @return UserEntity with the current user information.
40+
*/
41+
@GetMapping("/me")
42+
public UserResponse getCurrentUser() {
43+
return authService.getCurrentUserResponse();
44+
}
45+
3646
@PatchMapping
3747
public ResponseEntity<UserResponse> patchUserInfo(
3848
@RequestBody @Valid UserPatchRequest taskRequest) {

server/src/main/java/br/com/tasknoteapp/server/service/AuthService.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -393,6 +393,17 @@ public Optional<UserEntity> getCurrentUser() {
393393
return findByEmail(email);
394394
}
395395

396+
/**
397+
* Get the current logged user as response object.
398+
*
399+
* @return An instance of {@link UserResponse} with the current user.
400+
* @throws UserNotFoundException when the user was not found
401+
*/
402+
public UserResponse getCurrentUserResponse() {
403+
UserEntity user = getCurrentUser().orElseThrow(UserNotFoundException::new);
404+
return UserResponse.fromEntity(user, getGravatarImageUrl(user.getEmail()).orElse(null));
405+
}
406+
396407
/**
397408
* Confirm a user account.
398409
*

server/src/main/java/br/com/tasknoteapp/server/util/TimeAgoUtil.java

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,20 @@ public class TimeAgoUtil {
1818
* @return String with formatted time.
1919
*/
2020
public static String format(LocalDateTime pastTime) {
21+
return format(pastTime, LocalDateTime.now());
22+
}
23+
24+
/**
25+
* Format a time in the time ago format.
26+
*
27+
* @param pastTime The pastime to be formatted.
28+
* @param now The current time.
29+
* @return String with formatted time.
30+
*/
31+
public static String format(LocalDateTime pastTime, LocalDateTime now) {
2132
if (Objects.isNull(pastTime)) {
2233
return "Some time ago";
2334
}
24-
LocalDateTime now = LocalDateTime.now();
2535
Period period = Period.between(pastTime.toLocalDate(), now.toLocalDate());
2636
Duration duration = Duration.between(pastTime, now);
2737
if (period.getYears() > 1) {
@@ -32,10 +42,12 @@ public static String format(LocalDateTime pastTime) {
3242
return String.format("%d months ago", period.getMonths());
3343
} else if (period.getMonths() > 0) {
3444
return String.format("%d month ago", period.getMonths());
35-
} else if (period.getDays() > 1) {
36-
return String.format("%d days ago", period.getDays());
37-
} else if (period.getDays() > 0) {
38-
return String.format("%d day ago", period.getDays());
45+
} else if (duration.toHours() >= 24) {
46+
if (period.getDays() > 1) {
47+
return String.format("%d days ago", period.getDays());
48+
} else {
49+
return String.format("%d day ago", period.getDays());
50+
}
3951
} else if (duration.toHours() > 1L) {
4052
return String.format("%d hours ago", duration.toHours());
4153
} else if (duration.toHours() > 0L) {

server/src/test/java/br/com/tasknoteapp/server/controller/UserControllerTest.java

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,40 @@ void getAllUsers_unauthorized_shouldFail() throws Exception {
6363
.andReturn();
6464
}
6565

66+
@Test
67+
@DisplayName("Get current user happy path should succeed")
68+
@WithMockUser(username = "user@domain.com", password = "abcde123456A@")
69+
void getCurrentUser_happyPath_shouldSucceed() throws Exception {
70+
UserResponse userResponse =
71+
new UserResponse(1L, "John", "email@test.com", false, null, null, null, null);
72+
when(authService.getCurrentUserResponse()).thenReturn(userResponse);
73+
74+
mockMvc
75+
.perform(
76+
get("/rest/users/me")
77+
.with(csrf().asHeader())
78+
.header("Content-Type", MediaType.APPLICATION_JSON_VALUE)
79+
.accept(MediaType.APPLICATION_JSON))
80+
.andExpect(status().isOk())
81+
.andExpect(jsonPath("$.userId").value(userResponse.userId()))
82+
.andExpect(jsonPath("$.email").value(userResponse.email()))
83+
.andExpect(jsonPath("$.admin").value(userResponse.admin()))
84+
.andReturn();
85+
}
86+
87+
@Test
88+
@DisplayName("Get current user with 401 unauthorized request should fail")
89+
void getCurrentUser_unauthorized_shouldFail() throws Exception {
90+
mockMvc
91+
.perform(
92+
get("/rest/users/me")
93+
.with(csrf().asHeader())
94+
.header("Content-Type", MediaType.APPLICATION_JSON_VALUE)
95+
.accept(MediaType.APPLICATION_JSON))
96+
.andExpect(status().isUnauthorized())
97+
.andReturn();
98+
}
99+
66100
@Test
67101
@DisplayName("Patch user info happy path should succeed")
68102
@WithMockUser(username = "user@domain.com", password = "abcde123456A@")

server/src/test/java/br/com/tasknoteapp/server/util/TimeAgoUtilTest.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,21 @@ void formatTest() {
2121
"2 months ago", TimeAgoUtil.format(LocalDateTime.now().minusMonths(2L)));
2222
}
2323

24+
@Test
25+
void formatMidnightRolloverTest() {
26+
LocalDateTime now = LocalDateTime.of(2026, 5, 19, 0, 30); // 12:30 AM next day
27+
LocalDateTime pastTime = now.minusHours(1); // 11:30 PM previous day
28+
Assertions.assertEquals("1 hour ago", TimeAgoUtil.format(pastTime, now));
29+
30+
// Test exactly 24 hours ago
31+
LocalDateTime twentyFourHoursAgo = now.minusDays(1);
32+
Assertions.assertEquals("1 day ago", TimeAgoUtil.format(twentyFourHoursAgo, now));
33+
34+
// Test 23 hours ago across midnight
35+
LocalDateTime twentyThreeHoursAgo = now.minusHours(23); // 1:30 AM previous day
36+
Assertions.assertEquals("23 hours ago", TimeAgoUtil.format(twentyThreeHoursAgo, now));
37+
}
38+
2439
@Test
2540
void formatDueDateTest() {
2641
Assertions.assertNull(TimeAgoUtil.formatDueDate(null));

0 commit comments

Comments
 (0)