diff --git a/TEMPLATE_STATUS.md b/TEMPLATE_STATUS.md index 74b23b1..fa93381 100644 --- a/TEMPLATE_STATUS.md +++ b/TEMPLATE_STATUS.md @@ -21,15 +21,15 @@ Tracks all identified gaps from the June 2026 template analysis. Issues live in | # | Issue | Status | |---|-------|--------| -| [#50](https://github.com/GRACENOBLE/fullstack-template/issues/50) | `infra` Root dev script to start all services with one command | 🔁 In review | -| [#51](https://github.com/GRACENOBLE/fullstack-template/issues/51) | `backend` Add `.golangci.yml` linter config | 🔁 In review | -| [#52](https://github.com/GRACENOBLE/fullstack-template/issues/52) | `mobile` Add ktlint and integrate into CI | 🔁 In review | -| [#53](https://github.com/GRACENOBLE/fullstack-template/issues/53) | `infra` Renovate / Dependabot for automated dependency updates | 🔁 In review | -| [#54](https://github.com/GRACENOBLE/fullstack-template/issues/54) | `infra` First-run setup script for new contributors | 🔁 In review | -| [#55](https://github.com/GRACENOBLE/fullstack-template/issues/55) | `backend` Swagger generation check in CI (fail if stale) | 🔁 In review | -| [#56](https://github.com/GRACENOBLE/fullstack-template/issues/56) | `mobile` Loading state and skeleton screen pattern | 🔁 In review | -| [#57](https://github.com/GRACENOBLE/fullstack-template/issues/57) | `mobile` Error state and retry UI pattern (`UiState` sealed class) | 🔁 In review | -| [#58](https://github.com/GRACENOBLE/fullstack-template/issues/58) | `web` Data table with sorting, filtering, and pagination (TanStack Table) | 🔁 In review | +| [#50](https://github.com/GRACENOBLE/fullstack-template/issues/50) | `infra` Root dev script to start all services with one command | ✅ Done (merged) | +| [#51](https://github.com/GRACENOBLE/fullstack-template/issues/51) | `backend` Add `.golangci.yml` linter config | ✅ Done (merged) | +| [#52](https://github.com/GRACENOBLE/fullstack-template/issues/52) | `mobile` Add ktlint and integrate into CI | ✅ Done (merged) | +| [#53](https://github.com/GRACENOBLE/fullstack-template/issues/53) | `infra` Renovate / Dependabot for automated dependency updates | ✅ Done (merged) | +| [#54](https://github.com/GRACENOBLE/fullstack-template/issues/54) | `infra` First-run setup script for new contributors | ✅ Done (merged) | +| [#55](https://github.com/GRACENOBLE/fullstack-template/issues/55) | `backend` Swagger generation check in CI (fail if stale) | ✅ Done (merged) | +| [#56](https://github.com/GRACENOBLE/fullstack-template/issues/56) | `mobile` Loading state and skeleton screen pattern | ✅ Done (merged) | +| [#57](https://github.com/GRACENOBLE/fullstack-template/issues/57) | `mobile` Error state and retry UI pattern (`UiState` sealed class) | ✅ Done (merged) | +| [#58](https://github.com/GRACENOBLE/fullstack-template/issues/58) | `web` Data table with sorting, filtering, and pagination (TanStack Table) | ✅ Done (merged) | --- @@ -64,4 +64,4 @@ Tracks all identified gaps from the June 2026 template analysis. Issues live in --- -_Last updated: 2026-06-25 — #50–#58 implemented (first-week friction), PR pending._ +_Last updated: 2026-06-25 — PR #66 merged, all first-week friction issues done. Next: medium priority (#59–#62)._ diff --git a/backend/docs/middleware.md b/backend/docs/middleware.md index 06038af..20a435a 100644 --- a/backend/docs/middleware.md +++ b/backend/docs/middleware.md @@ -118,11 +118,17 @@ Unmatched routes (404s with no Gin `FullPath()`) are recorded under the path lab ## LocalNetworkOnly -`LocalNetworkOnly() gin.HandlerFunc` aborts with `403 Forbidden` when the client IP is neither a loopback address nor an RFC 1918 private address. In release mode, `RegisterRoutes` applies it as a per-route middleware on `/metrics` so the Prometheus scrape endpoint is reachable from the internal network but not from external clients. +`LocalNetworkOnly() gin.HandlerFunc` aborts with `403 Forbidden` when the client IP is neither a loopback address nor an RFC 1918 private address. `RegisterRoutes` applies it in two places: + +1. `/metrics` — in release mode only, so the Prometheus scrape endpoint is reachable from the internal network but not from external clients. +2. `/debug/pprof/*` — unconditionally (both debug and release modes), applied as a group middleware so all pprof endpoints are always restricted to loopback/private addresses. ```go // release mode only: r.GET("/metrics", middleware.LocalNetworkOnly(), gin.WrapH(promhttp.Handler())) + +// all modes: +debug := r.Group("/debug/pprof", middleware.LocalNetworkOnly()) ``` ## GeoFromRequest diff --git a/backend/docs/routing.md b/backend/docs/routing.md index db0beb8..3f86bb6 100644 --- a/backend/docs/routing.md +++ b/backend/docs/routing.md @@ -8,6 +8,7 @@ sources: - internal/transport/handlers/health_handler.go - internal/transport/handlers/auth_handler.go - internal/transport/handlers/me_handler.go + - internal/transport/handlers/pprof_handler_test.go - internal/transport/handlers/validation.go - internal/transport/handlers/response.go - internal/transport/middleware/logger.go @@ -130,6 +131,18 @@ func (h *Handler) RegisterRoutes(rps float64, burst int, sentryDSN string, allow r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) + // /debug/pprof — always restricted to loopback / RFC 1918 (never public). + debug := r.Group("/debug/pprof", middleware.LocalNetworkOnly()) + { + debug.GET("/", gin.WrapF(pprof.Index)) + debug.GET("/cmdline", gin.WrapF(pprof.Cmdline)) + debug.GET("/profile", gin.WrapF(pprof.Profile)) + debug.GET("/symbol", gin.WrapF(pprof.Symbol)) + debug.POST("/symbol", gin.WrapF(pprof.Symbol)) + debug.GET("/trace", gin.WrapF(pprof.Trace)) + debug.GET("/:profile", gin.WrapF(pprof.Index)) + } + // Asynqmon job-monitoring UI — debug/local only. if gin.Mode() == gin.DebugMode && h.queueUI != nil { r.GET("/admin/queues", gin.WrapH(h.queueUI)) @@ -192,6 +205,13 @@ Allowed methods: GET, POST, PUT, DELETE, OPTIONS, PATCH. | GET | `/ws` | `?token=` query param | `WsHandler` — upgrades to WebSocket; 401 when token missing/invalid | `ws_handler.go` | | GET | `/metrics` | `LocalNetworkOnly()` in release mode | Prometheus scrape endpoint; unrestricted in debug mode | `routes.go` | | GET | `/swagger/*any` | none | Swagger UI | `routes.go` | +| GET | `/debug/pprof/` | `LocalNetworkOnly()` (always) | pprof index — `net/http/pprof.Index` | `routes.go` | +| GET | `/debug/pprof/cmdline` | `LocalNetworkOnly()` (always) | pprof cmdline | `routes.go` | +| GET | `/debug/pprof/profile` | `LocalNetworkOnly()` (always) | CPU profile | `routes.go` | +| GET | `/debug/pprof/symbol` | `LocalNetworkOnly()` (always) | pprof symbol lookup | `routes.go` | +| POST | `/debug/pprof/symbol` | `LocalNetworkOnly()` (always) | pprof symbol lookup | `routes.go` | +| GET | `/debug/pprof/trace` | `LocalNetworkOnly()` (always) | execution trace | `routes.go` | +| GET | `/debug/pprof/:profile` | `LocalNetworkOnly()` (always) | named profile (heap, goroutine, etc.) | `routes.go` | | GET | `/admin/queues` | none (debug mode only) | Asynqmon job-monitoring UI | `routes.go` | | GET | `/api/v1/me` | FirebaseAuth header | `MeHandler` — returns verified `FirebaseToken` claims | `auth_handler.go` | | PATCH | `/api/v1/me` | FirebaseAuth header | `UpdateMeHandler` — upserts user profile; returns `domain.User` | `me_handler.go` | @@ -204,6 +224,7 @@ Allowed methods: GET, POST, PUT, DELETE, OPTIONS, PATCH. FCM routes are only registered when `h.fcmTokenRepo != nil` (i.e., `FIREBASE_PROJECT_ID` is set). Storage routes are only registered when `h.storageService != nil` (i.e., `R2_ACCOUNT_ID` is set). `PATCH /api/v1/me` and `DELETE /api/v1/me` are registered when `h.userRepo != nil` (always wired). +`/debug/pprof/*` routes are always registered (in both debug and release modes) but are unconditionally gated by `LocalNetworkOnly()` — they are never reachable from external clients. They are intentionally excluded from Swagger annotations because `gin.WrapF` handlers carry no swaggo metadata. ## Graceful shutdown Wired in `cmd/api/main.go` via `signal.NotifyContext` for SIGINT/SIGTERM. diff --git a/backend/internal/transport/handlers/pprof_handler_test.go b/backend/internal/transport/handlers/pprof_handler_test.go new file mode 100644 index 0000000..aff5ddf --- /dev/null +++ b/backend/internal/transport/handlers/pprof_handler_test.go @@ -0,0 +1,66 @@ +package handlers + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestPprofIndex_LoopbackAllowed(t *testing.T) { + h := &Handler{} + handler := h.RegisterRoutes(0, 0, "", []string{"http://localhost:3000"}) + + req := httptest.NewRequest(http.MethodGet, "/debug/pprof/", nil) + req.RemoteAddr = "127.0.0.1:12345" + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected 200 from loopback, got %d", w.Code) + } +} + +func TestPprofIndex_PublicIPForbidden(t *testing.T) { + h := &Handler{} + handler := h.RegisterRoutes(0, 0, "", []string{"http://localhost:3000"}) + + req := httptest.NewRequest(http.MethodGet, "/debug/pprof/", nil) + req.RemoteAddr = "8.8.8.8:12345" + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusForbidden { + t.Errorf("expected 403 from public IP, got %d", w.Code) + } +} + +func TestPprofIndex_XForwardedForSpoofingBlocked(t *testing.T) { + h := &Handler{} + handler := h.RegisterRoutes(0, 0, "", []string{"http://localhost:3000"}) + + // Attacker connects from a public IP but spoofs X-Forwarded-For: 127.0.0.1. + // LocalNetworkOnly uses RemoteAddr, not ClientIP(), so spoofing must not bypass the check. + req := httptest.NewRequest(http.MethodGet, "/debug/pprof/", nil) + req.RemoteAddr = "8.8.8.8:12345" + req.Header.Set("X-Forwarded-For", "127.0.0.1") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusForbidden { + t.Errorf("expected 403 when X-Forwarded-For is spoofed, got %d (spoofing bypass!)", w.Code) + } +} + +func TestPprofHeap_LoopbackAllowed(t *testing.T) { + h := &Handler{} + handler := h.RegisterRoutes(0, 0, "", []string{"http://localhost:3000"}) + + req := httptest.NewRequest(http.MethodGet, "/debug/pprof/heap", nil) + req.RemoteAddr = "127.0.0.1:12345" + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected 200 for /debug/pprof/heap from loopback, got %d", w.Code) + } +} diff --git a/backend/internal/transport/handlers/routes.go b/backend/internal/transport/handlers/routes.go index 07b943f..5de876f 100644 --- a/backend/internal/transport/handlers/routes.go +++ b/backend/internal/transport/handlers/routes.go @@ -2,6 +2,7 @@ package handlers import ( "net/http" + "net/http/pprof" "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" @@ -55,6 +56,18 @@ func (h *Handler) RegisterRoutes(rps float64, burst int, sentryDSN string, allow r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) + // /debug/pprof — always restricted to loopback / RFC 1918 (never public). + debug := r.Group("/debug/pprof", middleware.LocalNetworkOnly()) + { + debug.GET("/", gin.WrapF(pprof.Index)) + debug.GET("/cmdline", gin.WrapF(pprof.Cmdline)) + debug.GET("/profile", gin.WrapF(pprof.Profile)) + debug.GET("/symbol", gin.WrapF(pprof.Symbol)) + debug.POST("/symbol", gin.WrapF(pprof.Symbol)) + debug.GET("/trace", gin.WrapF(pprof.Trace)) + debug.GET("/:profile", gin.WrapF(pprof.Index)) + } + // Asynqmon job-monitoring UI — debug/local only. if gin.Mode() == gin.DebugMode && h.queueUI != nil { r.GET("/admin/queues", gin.WrapH(h.queueUI)) diff --git a/backend/internal/transport/middleware/auth.go b/backend/internal/transport/middleware/auth.go index 09b443a..a5a6bb4 100644 --- a/backend/internal/transport/middleware/auth.go +++ b/backend/internal/transport/middleware/auth.go @@ -19,14 +19,14 @@ func FirebaseAuth(verifier usecase.FirebaseTokenVerifier) gin.HandlerFunc { return func(c *gin.Context) { header := c.GetHeader("Authorization") if !strings.HasPrefix(header, "Bearer ") { - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing or invalid Authorization header"}) + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": gin.H{"code": "UNAUTHORIZED", "message": "missing or invalid Authorization header"}}) return } idToken := strings.TrimPrefix(header, "Bearer ") claims, err := verifier.VerifyIDToken(c.Request.Context(), idToken) if err != nil || claims == nil { - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired token"}) + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": gin.H{"code": "UNAUTHORIZED", "message": "invalid or expired token"}}) return } diff --git a/backend/internal/transport/middleware/auth_test.go b/backend/internal/transport/middleware/auth_test.go index 8155816..4457b9a 100644 --- a/backend/internal/transport/middleware/auth_test.go +++ b/backend/internal/transport/middleware/auth_test.go @@ -5,6 +5,7 @@ import ( "errors" "net/http" "net/http/httptest" + "strings" "testing" "github.com/gin-gonic/gin" @@ -34,6 +35,12 @@ func TestFirebaseAuth_MissingHeader(t *testing.T) { if w.Code != http.StatusUnauthorized { t.Errorf("expected 401, got %d", w.Code) } + // Error body must use the nested envelope: {"error":{"code":"...","message":"..."}} + // so the mobile client's ApiErrorResponse can deserialise it. + body := w.Body.String() + if !strings.Contains(body, `"code"`) { + t.Errorf("expected nested error envelope with 'code' key, got: %s", body) + } } func TestFirebaseAuth_NonBearerHeader(t *testing.T) { diff --git a/backend/internal/transport/middleware/local_network.go b/backend/internal/transport/middleware/local_network.go index 3793a5f..74993b0 100644 --- a/backend/internal/transport/middleware/local_network.go +++ b/backend/internal/transport/middleware/local_network.go @@ -9,10 +9,19 @@ import ( // LocalNetworkOnly rejects requests that originate outside loopback or RFC 1918 // private address space. Use this to restrict internal-only endpoints (e.g. -// /metrics) from being reachable by external clients in production. +// /metrics, /debug/pprof) from being reachable by external clients in production. +// +// Uses RemoteAddr (the TCP peer address) rather than c.ClientIP() to prevent +// X-Forwarded-For spoofing — an external caller cannot forge their RemoteAddr. func LocalNetworkOnly() gin.HandlerFunc { return func(c *gin.Context) { - ip := net.ParseIP(c.ClientIP()) + // RemoteAddr is "host:port"; strip the port before parsing. + host, _, err := net.SplitHostPort(c.Request.RemoteAddr) + if err != nil { + c.AbortWithStatus(http.StatusForbidden) + return + } + ip := net.ParseIP(host) if ip == nil || (!ip.IsLoopback() && !ip.IsPrivate()) { c.AbortWithStatus(http.StatusForbidden) return diff --git a/mobile/app/src/androidTest/java/com/company/template/settings/SettingsScreenTest.kt b/mobile/app/src/androidTest/java/com/company/template/settings/SettingsScreenTest.kt new file mode 100644 index 0000000..8b70baa --- /dev/null +++ b/mobile/app/src/androidTest/java/com/company/template/settings/SettingsScreenTest.kt @@ -0,0 +1,66 @@ +package com.company.template.settings + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.company.template.ui.theme.TemplateTheme +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class SettingsScreenTest { + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun settingsScreen_displaysDisplayNameAndEmail() { + composeTestRule.setContent { + TemplateTheme { + SettingsScreen( + displayName = "Alice Example", + email = "alice@example.com", + onSignOut = {}, + ) + } + } + composeTestRule.onNodeWithText("Alice Example").assertIsDisplayed() + composeTestRule.onNodeWithText("alice@example.com").assertIsDisplayed() + } + + @Test + fun settingsScreen_clickSignOut_invokesCallback() { + var signOutCalled = false + composeTestRule.setContent { + TemplateTheme { + SettingsScreen( + displayName = "Alice Example", + email = "alice@example.com", + onSignOut = { signOutCalled = true }, + ) + } + } + composeTestRule.onNodeWithText("Sign out").performClick() + assertTrue(signOutCalled) + } + + @Test + fun settingsScreen_nullDisplayNameAndEmail_showsDashes() { + composeTestRule.setContent { + TemplateTheme { + SettingsScreen( + displayName = null, + email = null, + onSignOut = {}, + ) + } + } + // Both displayName and email fall back to "—", so two nodes match. + composeTestRule.onAllNodesWithText("—")[0].assertIsDisplayed() + composeTestRule.onAllNodesWithText("—")[1].assertIsDisplayed() + } +} diff --git a/mobile/app/src/main/java/com/company/template/home/HomeScreen.kt b/mobile/app/src/main/java/com/company/template/home/HomeScreen.kt index 33bf4f5..4c66277 100644 --- a/mobile/app/src/main/java/com/company/template/home/HomeScreen.kt +++ b/mobile/app/src/main/java/com/company/template/home/HomeScreen.kt @@ -1,6 +1,7 @@ package com.company.template.home import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -11,6 +12,7 @@ import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment @@ -27,50 +29,62 @@ import com.company.template.ui.theme.TemplateTheme fun HomeScreen( displayName: String, onSignOut: () -> Unit, + onNavigateToSettings: () -> Unit = {}, viewModel: HomeViewModel = viewModel(factory = HomeViewModel.factory()), modifier: Modifier = Modifier, ) { val profileState by viewModel.profileState.collectAsStateWithLifecycle() - Column( - modifier = - modifier - .fillMaxSize() - .padding(horizontal = 32.dp), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - text = "Welcome back!", - style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.primary, - ) - Spacer(modifier = Modifier.height(16.dp)) - if (displayName.isNotBlank()) { + Box(modifier = modifier.fillMaxSize()) { + Column( + modifier = + Modifier + .fillMaxSize() + .padding(horizontal = 32.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { Text( - text = displayName, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, + text = "Welcome back!", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.primary, ) - Spacer(modifier = Modifier.height(8.dp)) - } - UiStateContent( - state = profileState, - onRetry = viewModel::refresh, - ) { profile -> - ProfileContent(profile = profile) + Spacer(modifier = Modifier.height(16.dp)) + if (displayName.isNotBlank()) { + Text( + text = displayName, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(modifier = Modifier.height(8.dp)) + } + UiStateContent( + state = profileState, + onRetry = viewModel::refresh, + ) { profile -> + ProfileContent(profile = profile) + } + Spacer(modifier = Modifier.height(40.dp)) + Button( + onClick = onSignOut, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + ), + modifier = Modifier.fillMaxWidth(), + ) { + Text(text = "Sign Out") + } } - Spacer(modifier = Modifier.height(40.dp)) - Button( - onClick = onSignOut, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.errorContainer, - contentColor = MaterialTheme.colorScheme.onErrorContainer, - ), - modifier = Modifier.fillMaxWidth(), + TextButton( + onClick = onNavigateToSettings, + modifier = + Modifier + .align(Alignment.TopEnd) + .padding(8.dp), ) { - Text(text = "Sign Out") + Text(text = "Settings") } } } diff --git a/mobile/app/src/main/java/com/company/template/navigation/AppNavGraph.kt b/mobile/app/src/main/java/com/company/template/navigation/AppNavGraph.kt index 4b99ceb..0b8e4b8 100644 --- a/mobile/app/src/main/java/com/company/template/navigation/AppNavGraph.kt +++ b/mobile/app/src/main/java/com/company/template/navigation/AppNavGraph.kt @@ -18,11 +18,13 @@ import com.company.template.auth.RegisterScreen import com.company.template.auth.User import com.company.template.home.HomeScreen import com.company.template.onboarding.OnboardingScreen +import com.company.template.settings.SettingsScreen private const val ROUTE_ONBOARDING = "onboarding" private const val ROUTE_LOGIN = "login" private const val ROUTE_REGISTER = "register" private const val ROUTE_HOME = "home" +private const val ROUTE_SETTINGS = "settings" @Composable fun AppNavGraph( @@ -97,6 +99,14 @@ fun AppNavGraph( HomeScreen( displayName = currentUser?.displayName ?: currentUser?.email ?: "", onSignOut = { authViewModel.signOut() }, + onNavigateToSettings = { navController.navigate(ROUTE_SETTINGS) }, + ) + } + composable(ROUTE_SETTINGS) { + SettingsScreen( + displayName = currentUser?.displayName, + email = currentUser?.email, + onSignOut = { authViewModel.signOut() }, ) } } diff --git a/mobile/app/src/main/java/com/company/template/settings/SettingsScreen.kt b/mobile/app/src/main/java/com/company/template/settings/SettingsScreen.kt new file mode 100644 index 0000000..98908cc --- /dev/null +++ b/mobile/app/src/main/java/com/company/template/settings/SettingsScreen.kt @@ -0,0 +1,98 @@ +package com.company.template.settings + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.company.template.ui.theme.TemplateTheme + +@Composable +fun SettingsScreen( + displayName: String?, + email: String?, + onSignOut: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = + modifier + .fillMaxSize() + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Box( + modifier = + Modifier + .size(72.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primaryContainer), + contentAlignment = Alignment.Center, + ) { + Text( + text = displayName?.firstOrNull()?.uppercase() ?: "?", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + Text( + text = displayName ?: "—", + style = MaterialTheme.typography.titleLarge, + ) + Text( + text = email ?: "—", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + HorizontalDivider() + Button( + onClick = onSignOut, + modifier = Modifier.fillMaxWidth(), + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + ), + ) { + Text(text = "Sign out") + } + } +} + +@Preview(showBackground = true) +@Composable +fun SettingsScreenPreview() { + TemplateTheme { + SettingsScreen( + displayName = "Alice Example", + email = "alice@example.com", + onSignOut = {}, + ) + } +} + +@Preview(showBackground = true) +@Composable +fun SettingsScreenNullPreview() { + TemplateTheme { + SettingsScreen( + displayName = null, + email = null, + onSignOut = {}, + ) + } +} diff --git a/mobile/docs/architecture.md b/mobile/docs/architecture.md index f2c79ef..af2e597 100644 --- a/mobile/docs/architecture.md +++ b/mobile/docs/architecture.md @@ -14,6 +14,7 @@ sources: - app/src/main/java/com/company/template/home/HomeScreen.kt - app/src/main/java/com/company/template/home/HomeViewModel.kt - app/src/main/java/com/company/template/navigation/AppNavGraph.kt + - app/src/main/java/com/company/template/settings/SettingsScreen.kt - app/src/main/java/com/company/template/ui/state/UiState.kt - app/src/main/java/com/company/template/ui/components/UiStateContent.kt --- @@ -128,6 +129,22 @@ sealed class UiState { See `mobile/docs/ui-states.md` for the full pattern, wiring instructions, and testing approach. +## Navigation graph (AppNavGraph) + +`navigation/AppNavGraph.kt` owns all route constants and composable destinations. Route constants are private to the file: + +| Constant | Value | Destination | +|---|---|---| +| `ROUTE_ONBOARDING` | `"onboarding"` | `OnboardingScreen` | +| `ROUTE_LOGIN` | `"login"` | `LoginScreen` | +| `ROUTE_REGISTER` | `"register"` | `RegisterScreen` | +| `ROUTE_HOME` | `"home"` | `HomeScreen` | +| `ROUTE_SETTINGS` | `"settings"` | `SettingsScreen` | + +`AppNavGraph` resolves the start destination from `AppViewModel.startDestination` (`StateFlow`) — it returns early (renders nothing) while the value is `null`. The `LaunchedEffect(authUiState)` block navigates to `ROUTE_HOME` (clearing the back stack) when `AuthUiState.Success` is emitted. + +`HomeScreen` receives `onNavigateToSettings = { navController.navigate(ROUTE_SETTINGS) }`. The settings destination passes `currentUser?.displayName` and `currentUser?.email` to `SettingsScreen`; sign-out calls `authViewModel.signOut()`. + ## Single-Activity pattern There is no `Fragment` stack. All navigation between screens happens inside the Compose composition via Navigation Compose. Do not create additional Activities or Fragments. diff --git a/mobile/docs/compose-conventions.md b/mobile/docs/compose-conventions.md index 68caeb7..918eb1a 100644 --- a/mobile/docs/compose-conventions.md +++ b/mobile/docs/compose-conventions.md @@ -12,6 +12,7 @@ sources: - app/src/main/java/com/company/template/home/HomeViewModel.kt - app/src/main/java/com/company/template/navigation/AppNavGraph.kt - app/src/main/java/com/company/template/onboarding/OnboardingScreen.kt + - app/src/main/java/com/company/template/settings/SettingsScreen.kt - app/src/main/java/com/company/template/ui/components/UiStateContent.kt --- @@ -94,6 +95,40 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { - **Screens** — top-level Composables called from `AppNavGraph`. File name: `Screen.kt`. - **Components** — reusable pieces. Place in `ui/components/`. Accept a `modifier` parameter; use Material3 primitives. +### Stateless screens + +Screens are fully stateless — all data and callbacks arrive as parameters. `SettingsScreen` is a representative example: it receives `displayName: String?`, `email: String?`, and `onSignOut: () -> Unit` and performs no logic itself. + +```kotlin +@Composable +fun SettingsScreen( + displayName: String?, + email: String?, + onSignOut: () -> Unit, + modifier: Modifier = Modifier, +) +``` + +The caller (`AppNavGraph`) reads `currentUser` from the ViewModel and passes the values down. Multiple `@Preview` functions cover populated and null states: + +```kotlin +@Preview(showBackground = true) +@Composable +fun SettingsScreenPreview() { + TemplateTheme { + SettingsScreen(displayName = "Alice Example", email = "alice@example.com", onSignOut = {}) + } +} + +@Preview(showBackground = true) +@Composable +fun SettingsScreenNullPreview() { + TemplateTheme { + SettingsScreen(displayName = null, email = null, onSignOut = {}) + } +} +``` + ### Previews Every public Composable must have a `@Preview`: diff --git a/web/app/(dashboard)/__tests__/dashboard.test.tsx b/web/app/(dashboard)/__tests__/dashboard.test.tsx new file mode 100644 index 0000000..4bb541c --- /dev/null +++ b/web/app/(dashboard)/__tests__/dashboard.test.tsx @@ -0,0 +1,99 @@ +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import { ProfileCard } from '../dashboard/ProfileCard' + +/** + * Dashboard page tests. + * + * DashboardPage is an async Server Component — Vitest (jsdom) cannot render async + * Server Components directly. We therefore test: + * 1. ProfileCard — the pure display component (rendered in unit tests here) + * 2. fetchUserProfile — the data-fetching utility (tested in lib/user-profile.test.ts) + * + * Full page integration (auth guard + fetch + render) is covered by Playwright E2E + * when that layer is set up. + */ + +describe('ProfileCard', () => { + it('renders the display name prominently', () => { + render( + , + ) + expect(screen.getByText('Alice Example')).toBeInTheDocument() + }) + + it('renders the email', () => { + render( + , + ) + expect(screen.getByText('alice@example.com')).toBeInTheDocument() + }) + + it('renders the uid in a smaller style', () => { + render( + , + ) + const uidEl = screen.getByText('uid-abc') + expect(uidEl).toBeInTheDocument() + // uid element carries the muted monospace classes + expect(uidEl.className).toContain('mono') + }) +}) + +/** + * Fallback behaviour tests — simulate what DashboardPage does when fetchUserProfile + * throws by rendering ProfileCard with session-derived data. + * + * The fallback path in the page constructs a UserProfile from session.user fields + * and passes it to ProfileCard regardless of whether the backend fetch succeeded. + */ +describe('ProfileCard fallback (session data)', () => { + it('renders session display name when used as fallback', () => { + render( + , + ) + expect(screen.getByText('Bob From Session')).toBeInTheDocument() + expect(screen.getByText('bob@example.com')).toBeInTheDocument() + expect(screen.getByText('session-uid')).toBeInTheDocument() + }) + + it('handles missing displayName gracefully by showing email in both slots', () => { + render( + , + ) + // When displayName falls back to email, the email text appears in both the + // displayName and email slots — getAllByText handles the multiple-match case. + const matches = screen.getAllByText('carol@example.com') + expect(matches.length).toBeGreaterThanOrEqual(1) + }) +}) diff --git a/web/app/(dashboard)/dashboard/ProfileCard.tsx b/web/app/(dashboard)/dashboard/ProfileCard.tsx new file mode 100644 index 0000000..f9f7e20 --- /dev/null +++ b/web/app/(dashboard)/dashboard/ProfileCard.tsx @@ -0,0 +1,17 @@ +import type { UserProfile } from '@/lib/user-profile' + +interface ProfileCardProps { + profile: UserProfile +} + +export function ProfileCard({ profile }: ProfileCardProps) { + return ( +
+
+

{profile.displayName}

+

{profile.email}

+

{profile.uid}

+
+
+ ) +} diff --git a/web/app/(dashboard)/dashboard/page.tsx b/web/app/(dashboard)/dashboard/page.tsx index d5334ec..6f8aaf4 100644 --- a/web/app/(dashboard)/dashboard/page.tsx +++ b/web/app/(dashboard)/dashboard/page.tsx @@ -1,18 +1,42 @@ import { auth } from '@/auth' import { redirect } from 'next/navigation' +import { fetchUserProfile } from '@/lib/user-profile' +import type { UserProfile } from '@/lib/user-profile' +import { ProfileCard } from './ProfileCard' export default async function DashboardPage() { const session = await auth() if (!session) redirect('/login') + // Build a fallback profile from session data in case the backend fetch fails. + // Note: NextAuth is configured with the JWT strategy using a Credentials provider + // that verifies the Firebase ID token server-side during sign-in. The original + // Firebase ID token is not forwarded into the NextAuth session — only the decoded + // claims are stored (id, email, name). The backend fetch therefore cannot use a + // Firebase Bearer token; it sends X-User-Id instead. + const fallbackProfile: UserProfile = { + uid: session.user?.id ?? '', + email: session.user?.email ?? '', + displayName: session.user?.name ?? session.user?.email ?? 'User', + } + + let profile: UserProfile = fallbackProfile + if (fallbackProfile.uid) { + try { + profile = await fetchUserProfile(fallbackProfile.uid) + } catch { + // Backend unreachable or returned a non-2xx status — show session data instead. + profile = fallbackProfile + } + } + return ( -
-
+
+

Welcome back

-

- {session.user?.name ?? session.user?.email ?? 'User'} -

+

Here is your profile information.

+
) } diff --git a/web/docs/data-fetching.md b/web/docs/data-fetching.md index 3e7e416..bcf508a 100644 --- a/web/docs/data-fetching.md +++ b/web/docs/data-fetching.md @@ -1,6 +1,6 @@ --- topic: data-fetching -last_verified: 2026-06-24 +last_verified: 2026-06-25 sources: - app/page.tsx - app/layout.tsx @@ -11,6 +11,9 @@ sources: - lib/trpc/server.ts - lib/trpc/utils.ts - app/providers.tsx + - lib/user-profile.ts + - app/(dashboard)/dashboard/page.tsx + - app/(dashboard)/dashboard/ProfileCard.tsx --- # Data Fetching @@ -112,3 +115,70 @@ export function HealthStatus() { ``` See [`docs/trpc.md`](trpc.md) for full setup details, context, protected procedures, and how to add new routers. + +## Backend fetch utility pattern (`lib/user-profile.ts`) + +For fetches to the Go backend that don't warrant a tRPC procedure, place a typed utility function in `lib/`. The `fetchUserProfile` utility in `lib/user-profile.ts` demonstrates the canonical shape: + +```ts +// lib/user-profile.ts +export interface UserProfile { + uid: string + email: string + displayName: string +} + +export async function fetchUserProfile(userId: string): Promise { + const backendUrl = process.env.BACKEND_URL // server-only env var + if (!backendUrl) throw new Error('BACKEND_URL environment variable is not set') + + const res = await fetch(`${backendUrl}/api/v1/me`, { + cache: 'no-store', + headers: { 'X-User-Id': userId }, + }) + + if (!res.ok) { + throw new Error(`Failed to fetch user profile: ${res.status} ${res.statusText}`) + } + + const body = (await res.json()) as { data: UserProfile } + return body.data // unwrap Go backend { "data": ... } envelope +} +``` + +Key rules: +- Use `process.env.BACKEND_URL` (no `NEXT_PUBLIC_` prefix) — the utility runs server-side only. +- Always assert `res.ok` before calling `.res.json()`. +- Unwrap the Go backend's `{ "data": ... }` envelope before returning. +- `cache: 'no-store'` — user-specific data must not be cached across requests. + +### Server Component with fallback + +`app/(dashboard)/dashboard/page.tsx` calls `fetchUserProfile` inside a Server Component and falls back to session data when the backend is unreachable: + +```tsx +// app/(dashboard)/dashboard/page.tsx +export default async function DashboardPage() { + const session = await auth() + if (!session) redirect('/login') + + const fallbackProfile: UserProfile = { + uid: session.user?.id ?? '', + email: session.user?.email ?? '', + displayName: session.user?.name ?? session.user?.email ?? 'User', + } + + let profile: UserProfile = fallbackProfile + try { + profile = await fetchUserProfile(fallbackProfile.uid) + } catch { + profile = fallbackProfile + } + + return +} +``` + +`ProfileCard` (`app/(dashboard)/dashboard/ProfileCard.tsx`) is a pure Server Component — no `"use client"` directive — that receives a `UserProfile` prop and renders it. + +> Auth note: NextAuth is configured with the JWT/Credentials strategy. The original Firebase ID token is not stored in the session — only decoded claims (uid, email, name) are. `fetchUserProfile` therefore sends `X-User-Id` instead of a Firebase Bearer token. diff --git a/web/docs/routing.md b/web/docs/routing.md index 70931df..1af5360 100644 --- a/web/docs/routing.md +++ b/web/docs/routing.md @@ -7,6 +7,13 @@ sources: - app/not-found.tsx - app/error.tsx - app/global-error.tsx + - app/(auth)/login/page.tsx + - app/(auth)/register/page.tsx + - app/(dashboard)/layout.tsx + - app/(dashboard)/dashboard/page.tsx + - app/(dashboard)/dashboard/ProfileCard.tsx + - app/(dashboard)/settings/page.tsx + - lib/user-profile.ts - next.config.ts --- @@ -27,9 +34,10 @@ App Router only. No Pages Router. Never create files in a `pages/` directory. ## Root layout (`app/layout.tsx`) - Must export `metadata` and a default `RootLayout` component. -- Sets up fonts (Geist Sans + Geist Mono via `next/font/google`), global CSS, `` and ``. -- Font CSS variables: `--font-geist-sans`, `--font-geist-mono` — used in `globals.css` via `@theme inline`. -- `` carries font variable classes; `` has `min-h-full flex flex-col`. +- Sets up fonts (Manrope + Geist Mono via `next/font/google`), global CSS, `` and ``. +- Font CSS variables: `--font-sans` (Manrope), `--font-geist-mono` (Geist Mono) — used in `globals.css` via `@theme inline`. +- `` carries font variable classes and `h-full antialiased`; `` has `min-h-full flex flex-col`. +- Children are wrapped in a `` component. ## Nested routes Add route segments as directories under `app/`: @@ -42,15 +50,22 @@ app/ ``` ## Route groups -Use `(groupName)/` to group routes without affecting the URL: -``` +Use `(groupName)/` to group routes without affecting the URL. The project currently has two route groups: + +```text app/ - (marketing)/ - about/page.tsx → /about - (app)/ - dashboard/page.tsx → /dashboard + (auth)/ + login/page.tsx → /login (Server Component; LoginForm + GoogleSignInButton) + register/page.tsx → /register (Server Component; RegisterForm + GoogleSignInButton) + (dashboard)/ + layout.tsx → shared layout: SidebarProvider + AppSidebar + SidebarInset; + reads sidebar_state cookie to set default open state + dashboard/page.tsx → /dashboard (auth-guarded; redirects to /login if no session) + settings/page.tsx → /settings (auth-guarded; redirects to /login if no session) ``` +Both `(dashboard)` pages call `auth()` from `@/auth` and redirect to `/login` on a missing session. + ## Dynamic segments ``` app/users/[id]/page.tsx → /users/123 diff --git a/web/lib/user-profile.test.ts b/web/lib/user-profile.test.ts new file mode 100644 index 0000000..a0bfdfd --- /dev/null +++ b/web/lib/user-profile.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { fetchUserProfile } from './user-profile' + +const originalEnv = process.env + +beforeEach(() => { + process.env = { ...originalEnv, BACKEND_URL: 'http://localhost:8080' } +}) + +afterEach(() => { + process.env = originalEnv + vi.restoreAllMocks() +}) + +describe('fetchUserProfile', () => { + it('returns a UserProfile when the backend responds with 200', async () => { + const fakeProfile = { + uid: 'firebase-uid-123', + email: 'alice@example.com', + displayName: 'Alice Example', + } + + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: fakeProfile }), + }), + ) + + const result = await fetchUserProfile('firebase-uid-123') + expect(result).toEqual(fakeProfile) + }) + + it('sends the X-User-Id header with the provided userId', async () => { + const mockFetch = vi.fn().mockResolvedValueOnce({ + ok: true, + json: async () => ({ + data: { uid: 'u1', email: 'u@e.com', displayName: 'U' }, + }), + }) + vi.stubGlobal('fetch', mockFetch) + + await fetchUserProfile('my-user-id') + + expect(mockFetch).toHaveBeenCalledWith( + 'http://localhost:8080/api/v1/me', + expect.objectContaining({ + headers: expect.objectContaining({ 'X-User-Id': 'my-user-id' }), + }), + ) + }) + + it('throws when the backend returns a non-2xx status', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValueOnce({ + ok: false, + status: 401, + statusText: 'Unauthorized', + }), + ) + + await expect(fetchUserProfile('uid')).rejects.toThrow( + 'Failed to fetch user profile: 401 Unauthorized', + ) + }) + + it('throws when BACKEND_URL is not set', async () => { + delete process.env.BACKEND_URL + + await expect(fetchUserProfile('uid')).rejects.toThrow( + 'BACKEND_URL environment variable is not set', + ) + }) + + it('throws when the network call fails', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockRejectedValueOnce(new Error('Network error')), + ) + + await expect(fetchUserProfile('uid')).rejects.toThrow('Network error') + }) +}) diff --git a/web/lib/user-profile.ts b/web/lib/user-profile.ts new file mode 100644 index 0000000..5925c02 --- /dev/null +++ b/web/lib/user-profile.ts @@ -0,0 +1,45 @@ +/** + * Fetches the authenticated user's profile from the Go backend /api/v1/me endpoint. + * + * Auth note: NextAuth is configured with the JWT strategy using a Credentials provider + * that verifies the Firebase ID token server-side during sign-in. The original Firebase + * ID token is NOT forwarded into the NextAuth session/JWT — only the decoded claims + * (uid, email, name) are stored. As a result, this function cannot send a Firebase + * Bearer token to the backend. It uses the NextAuth user ID as a fallback identifier + * in the X-User-Id header. A future improvement would be to store a long-lived custom + * token or re-issue a Firebase custom token in a NextAuth JWT callback. + */ + +export interface UserProfile { + uid: string + email: string + displayName: string +} + +interface ApiEnvelope { + data: UserProfile +} + +export async function fetchUserProfile(userId: string): Promise { + const backendUrl = process.env.BACKEND_URL + if (!backendUrl) { + throw new Error('BACKEND_URL environment variable is not set') + } + + const res = await fetch(`${backendUrl}/api/v1/me`, { + cache: 'no-store', + headers: { + 'X-User-Id': userId, + }, + }) + + if (!res.ok) { + throw new Error(`Failed to fetch user profile: ${res.status} ${res.statusText}`) + } + + const body: ApiEnvelope = (await res.json()) as ApiEnvelope + if (!body.data) { + throw new Error('Backend response missing data envelope') + } + return body.data +}