From d97bb420ef6df021ccd5aa58bd3529dea40d8ed7 Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Wed, 1 Jul 2026 17:06:42 +0200 Subject: [PATCH 1/5] Add GET /users/me endpoint for authenticated user lookup Returns the calling user's PublicUser (id + name), resolved from whichever credential the proxy injected as ?userId. Works with both session cookies and API keys since the proxy handles auth before forwarding. Co-Authored-By: Claude Sonnet 4.6 --- .../controller/UsersController.kt | 8 ++++++++ .../controller/UsersClient.kt | 6 ++++++ .../controller/UsersControllerTest.kt | 19 +++++++++++++++++++ website/src/backendApi/backendService.ts | 4 ++++ 4 files changed, 37 insertions(+) diff --git a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/controller/UsersController.kt b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/controller/UsersController.kt index 4232f6638..5b9554b4f 100644 --- a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/controller/UsersController.kt +++ b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/controller/UsersController.kt @@ -10,6 +10,7 @@ import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController @RestController @@ -21,6 +22,13 @@ class UsersController(private val userModel: UserModel) { ) fun syncUser(@RequestBody request: UserSyncRequest): User = userModel.syncUser(request) + @GetMapping("/users/me", produces = [MediaType.APPLICATION_JSON_VALUE]) + @Operation( + summary = "Get the authenticated user", + description = "Returns public info for the currently authenticated user, resolved from the session or API key.", + ) + fun getMe(@RequestParam userId: Long): PublicUser = userModel.getUser(userId) + @GetMapping("/users/{id}", produces = [MediaType.APPLICATION_JSON_VALUE]) @Operation( summary = "Get user by internal ID", diff --git a/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/UsersClient.kt b/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/UsersClient.kt index 7c728b10e..7413b65ee 100644 --- a/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/UsersClient.kt +++ b/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/UsersClient.kt @@ -31,6 +31,12 @@ class UsersClient(private val mockMvc: MockMvc, private val objectMapper: Object getUserRaw(id).andExpect(status().isOk), ) + fun getMeRaw(userId: Long): ResultActions = mockMvc.perform(get("/users/me").param("userId", userId.toString())) + + fun getMe(userId: Long): PublicUser = deserializeJsonResponse( + getMeRaw(userId).andExpect(status().isOk), + ) + /** Creates a user with a random GitHub ID and returns the internal Long user ID. */ fun createUser(): Long = syncUser( UserSyncRequest( diff --git a/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/UsersControllerTest.kt b/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/UsersControllerTest.kt index 939287468..fb35796e3 100644 --- a/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/UsersControllerTest.kt +++ b/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/UsersControllerTest.kt @@ -80,6 +80,25 @@ class UsersControllerTest(@param:Autowired private val usersClient: UsersClient) .andExpect(jsonPath("$.detail").value("User 999999999 not found")) } + @Test + fun `WHEN getting me THEN returns own public user info`() { + val created = usersClient.syncUser( + UserSyncRequest(githubId = UUID.randomUUID().toString(), name = "Eve", email = null), + ) + + val me = usersClient.getMe(created.id) + + assertThat(me.id, equalTo(created.id)) + assertThat(me.name, equalTo("Eve")) + } + + @Test + fun `WHEN getting me with nonexistent user ID THEN returns 404`() { + usersClient.getMeRaw(999999999L) + .andExpect(status().isNotFound) + .andExpect(jsonPath("$.detail").value("User 999999999 not found")) + } + @Test fun `WHEN syncing two different github IDs THEN they get different internal IDs`() { val first = usersClient.syncUser( diff --git a/website/src/backendApi/backendService.ts b/website/src/backendApi/backendService.ts index dbe332519..e4aa0772a 100644 --- a/website/src/backendApi/backendService.ts +++ b/website/src/backendApi/backendService.ts @@ -173,6 +173,10 @@ export class BackendService extends ApiService { }); } + public async getMe() { + return this.get({ url: '/users/me', schema: publicUserSchema }); + } + public async getUser({ id }: { id: number }) { return this.get({ url: `/users/${id}`, schema: publicUserSchema }); } From 92d6ee6ccd6a661865def539f23942042047a47f Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Wed, 1 Jul 2026 17:15:03 +0200 Subject: [PATCH 2/5] Add /api/users/me frontend route with auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without this, /api/users/me fell through to the dynamic [id].ts route which uses proxyToBackendNoAuth — no userId would be injected and the backend would return 400 (missing required param). Co-Authored-By: Claude Sonnet 4.6 --- website/src/pages/api/users/me.ts | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 website/src/pages/api/users/me.ts diff --git a/website/src/pages/api/users/me.ts b/website/src/pages/api/users/me.ts new file mode 100644 index 000000000..b1a3e59e6 --- /dev/null +++ b/website/src/pages/api/users/me.ts @@ -0,0 +1,3 @@ +import { proxyToBackend } from '../../../backendApi/backendProxy.ts'; + +export const GET = proxyToBackend; From 1b067d454505b33c97438f1bb12e85a6945f6047 Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Thu, 2 Jul 2026 13:19:47 +0200 Subject: [PATCH 3/5] Move GET /users/me entirely into the frontend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The backend endpoint was redundant — /users/{id} already returns the same PublicUser shape. The frontend me.ts route now resolves the caller from context.locals.gsUserId (set by authMiddleware for both session cookies and API keys) and fetches /users/{id} on the backend directly. No backend changes required. Co-Authored-By: Claude Sonnet 4.6 --- .../controller/UsersController.kt | 8 ---- .../controller/UsersClient.kt | 6 --- .../controller/UsersControllerTest.kt | 19 --------- website/src/pages/api/users/me.ts | 40 ++++++++++++++++++- 4 files changed, 38 insertions(+), 35 deletions(-) diff --git a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/controller/UsersController.kt b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/controller/UsersController.kt index 5b9554b4f..4232f6638 100644 --- a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/controller/UsersController.kt +++ b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/controller/UsersController.kt @@ -10,7 +10,6 @@ import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody -import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController @RestController @@ -22,13 +21,6 @@ class UsersController(private val userModel: UserModel) { ) fun syncUser(@RequestBody request: UserSyncRequest): User = userModel.syncUser(request) - @GetMapping("/users/me", produces = [MediaType.APPLICATION_JSON_VALUE]) - @Operation( - summary = "Get the authenticated user", - description = "Returns public info for the currently authenticated user, resolved from the session or API key.", - ) - fun getMe(@RequestParam userId: Long): PublicUser = userModel.getUser(userId) - @GetMapping("/users/{id}", produces = [MediaType.APPLICATION_JSON_VALUE]) @Operation( summary = "Get user by internal ID", diff --git a/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/UsersClient.kt b/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/UsersClient.kt index 7413b65ee..7c728b10e 100644 --- a/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/UsersClient.kt +++ b/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/UsersClient.kt @@ -31,12 +31,6 @@ class UsersClient(private val mockMvc: MockMvc, private val objectMapper: Object getUserRaw(id).andExpect(status().isOk), ) - fun getMeRaw(userId: Long): ResultActions = mockMvc.perform(get("/users/me").param("userId", userId.toString())) - - fun getMe(userId: Long): PublicUser = deserializeJsonResponse( - getMeRaw(userId).andExpect(status().isOk), - ) - /** Creates a user with a random GitHub ID and returns the internal Long user ID. */ fun createUser(): Long = syncUser( UserSyncRequest( diff --git a/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/UsersControllerTest.kt b/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/UsersControllerTest.kt index fb35796e3..939287468 100644 --- a/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/UsersControllerTest.kt +++ b/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/UsersControllerTest.kt @@ -80,25 +80,6 @@ class UsersControllerTest(@param:Autowired private val usersClient: UsersClient) .andExpect(jsonPath("$.detail").value("User 999999999 not found")) } - @Test - fun `WHEN getting me THEN returns own public user info`() { - val created = usersClient.syncUser( - UserSyncRequest(githubId = UUID.randomUUID().toString(), name = "Eve", email = null), - ) - - val me = usersClient.getMe(created.id) - - assertThat(me.id, equalTo(created.id)) - assertThat(me.name, equalTo("Eve")) - } - - @Test - fun `WHEN getting me with nonexistent user ID THEN returns 404`() { - usersClient.getMeRaw(999999999L) - .andExpect(status().isNotFound) - .andExpect(jsonPath("$.detail").value("User 999999999 not found")) - } - @Test fun `WHEN syncing two different github IDs THEN they get different internal IDs`() { val first = usersClient.syncUser( diff --git a/website/src/pages/api/users/me.ts b/website/src/pages/api/users/me.ts index b1a3e59e6..56bbe21de 100644 --- a/website/src/pages/api/users/me.ts +++ b/website/src/pages/api/users/me.ts @@ -1,3 +1,39 @@ -import { proxyToBackend } from '../../../backendApi/backendProxy.ts'; +import type { APIContext } from 'astro'; -export const GET = proxyToBackend; +import { getBackendHost } from '../../../config.ts'; +import { getInstanceLogger } from '../../../logger.ts'; +import type { ProblemDetail } from '../../../types/ProblemDetail.ts'; +import { getErrorLogMessage } from '../../../util/getErrorLogMessage.ts'; + +const logger = getInstanceLogger('UsersMe'); + +export async function GET(context: APIContext): Promise { + const userId = context.locals.gsUserId; + + if (userId === undefined) { + const body: ProblemDetail = { + title: 'Unauthorized', + detail: "You're not authorized to access this resource", + status: 401, + instance: context.request.url, + }; + return Response.json(body, { status: 401 }); + } + + try { + const response = await fetch(new URL(`/users/${userId}`, getBackendHost())); + return new Response(response.body, { + status: response.status, + headers: response.headers, + }); + } catch (error) { + logger.error(getErrorLogMessage(error)); + const body: ProblemDetail = { + title: 'Internal Server Error', + detail: 'Failed to connect the backend service', + status: 500, + instance: context.request.url, + }; + return Response.json(body, { status: 500 }); + } +} From 6c44c47dbd88ee757361e1383e9f2cbb1115cad6 Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Mon, 6 Jul 2026 09:40:11 +0200 Subject: [PATCH 4/5] =?UTF-8?q?Remove=20unused=20BackendService.getMe()=20?= =?UTF-8?q?=E2=80=94=20route=20is=20called=20directly?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- website/src/backendApi/backendService.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/website/src/backendApi/backendService.ts b/website/src/backendApi/backendService.ts index e4aa0772a..dbe332519 100644 --- a/website/src/backendApi/backendService.ts +++ b/website/src/backendApi/backendService.ts @@ -173,10 +173,6 @@ export class BackendService extends ApiService { }); } - public async getMe() { - return this.get({ url: '/users/me', schema: publicUserSchema }); - } - public async getUser({ id }: { id: number }) { return this.get({ url: `/users/${id}`, schema: publicUserSchema }); } From b31a999516d7332ec3a1f9ee0e7d33b3c4958f68 Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Tue, 7 Jul 2026 09:47:52 +0200 Subject: [PATCH 5/5] Add E2E tests for GET /api/users/me Co-Authored-By: Claude Sonnet 4.6 --- website/tests/auth/usersMe.spec.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 website/tests/auth/usersMe.spec.ts diff --git a/website/tests/auth/usersMe.spec.ts b/website/tests/auth/usersMe.spec.ts new file mode 100644 index 000000000..5a47dbf62 --- /dev/null +++ b/website/tests/auth/usersMe.spec.ts @@ -0,0 +1,18 @@ +import { expect } from '@playwright/test'; + +import { test } from '../e2e.fixture.ts'; + +test('GET /api/users/me returns 401 when not authenticated', async ({ request }) => { + const response = await request.get('/api/users/me'); + + expect(response.status()).toBe(401); +}); + +test('GET /api/users/me returns user data when authenticated', async ({ authenticatedPage }) => { + const response = await authenticatedPage.request.get('/api/users/me'); + + expect(response.status()).toBe(200); + const body = (await response.json()) as { id: number; name: string }; + expect(typeof body.id).toBe('number'); + expect(body.name).toBe('e2e-test'); +});