diff --git a/docs/web/architecture.md b/docs/web/architecture.md index c7e5291c..5d78666f 100644 --- a/docs/web/architecture.md +++ b/docs/web/architecture.md @@ -264,7 +264,8 @@ everything needed to understand or change one capability lives in one project - Controllers generally bind directly to the Command/Query as the request body; there is no separate request DTO + mapping step for writes - The exception to this is for authenticated routes, when the current user id - needs to be extracted from the authentication token to be used in a command. + needs to be extracted from the authentication token to be used in a command + or query. - A single shared `ValidationBehavior` (`Shared.Application/Behaviors/`) runs FluentValidation validators in the MediatR pipeline before any handler executes @@ -379,6 +380,7 @@ web/frontend/ │ │ - catalog beers, beer styles │ │ - home landing page │ │ - theme theme switcher/guide +│ │ - users public user profile page (/users/:id) │ ├── hooks/ Shared hooks not tied to one feature, for example useMediaQuery │ ├── routes.ts Route table (React Router 8 config-based routing) │ ├── root.tsx App shell and global providers @@ -395,6 +397,8 @@ web/frontend/ `/breweries/:id`) with Leaflet-powered maps and geolocation-based "nearby" search - Render the auth demo, account dashboard, and theme guide routes +- Render the public user profile route (`/users/:id`), including a user's + following list and (via brewery comments) links back to commenter profiles - Manage cookie-backed website session state - Call the .NET API for login, registration, token refresh, confirmation, and brewery/location data diff --git a/docs/web/testing.md b/docs/web/testing.md index 9f50e28d..97a525d9 100644 --- a/docs/web/testing.md +++ b/docs/web/testing.md @@ -249,9 +249,11 @@ API.Specs/ │ ├── ResendConfirmation.feature # Resend-confirmation scenarios │ ├── TokenRefresh.feature # Refresh token scenarios │ ├── AccessTokenValidation.feature # Protected endpoint access scenarios -│ └── NotFound.feature # 404 handling +│ ├── NotFound.feature # 404 handling +│ └── PublicUserProfile.feature # Public profile & locked-down account scenarios ├── Steps/ │ ├── AuthSteps.cs # Step definitions for the Auth features +│ ├── UserSteps.cs # Step definitions for the public profile & user-account features │ └── ApiGeneralSteps.cs # Shared/general step definitions ├── Mocks/ │ ├── MockEmailDispatcher.cs # Substitutes Features.Emails' IEmailDispatcher diff --git a/web/backend/API/API.Specs/Features/PublicUserProfile.feature b/web/backend/API/API.Specs/Features/PublicUserProfile.feature new file mode 100644 index 00000000..160871d8 --- /dev/null +++ b/web/backend/API/API.Specs/Features/PublicUserProfile.feature @@ -0,0 +1,66 @@ +Feature: Public User Profile +As a client of the API +I want to read a user's public-facing profile +So that a public profile page can render without exposing private account fields + +Background: + Given the API is running + +Scenario: Public profile exposes only allowlisted fields + Given I have registered a new account + And I have a valid access token for my account + When I retrieve the public profile by ID + Then the response has HTTP status 200 + And the public profile response should match the registered account + And the response JSON should not contain "email" + And the response JSON should not contain "dateOfBirth" + +Scenario: Public profile for a non-existent account returns not found + Given I have registered a new account + And I have a valid access token for my account + When I retrieve the public profile by a non-existent ID + Then the response has HTTP status 404 + +Scenario: Fetching the public profile without authentication succeeds + Given I have registered a new account + When I retrieve the public profile by ID without authentication + Then the response has HTTP status 200 + And the response JSON should not contain "email" + And the response JSON should not contain "dateOfBirth" + +Scenario: A different user can read someone else's public profile + Given I have registered a new account + And I am logged in as a different user + When I retrieve the public profile by ID + Then the response has HTTP status 200 + And the response JSON should not contain "email" + And the response JSON should not contain "dateOfBirth" + +Scenario: Fetching a user account by ID without authentication is rejected + When I retrieve the user account by a non-existent ID without authentication + Then the response has HTTP status 401 + +Scenario: Listing user accounts without authentication is rejected + When I list user accounts without authentication + Then the response has HTTP status 401 + +Scenario: Listing user accounts as an authenticated user excludes private fields + Given I have registered a new account + And I have a valid access token for my account + When I list user accounts + Then the response has HTTP status 200 + And the response JSON should not contain "email" + And the response JSON should not contain "dateOfBirth" + +Scenario: The owner can fetch their own account by ID + Given I have registered a new account + And I have a valid access token for my account + When I retrieve the registered account by ID + Then the response has HTTP status 200 + And the public profile response should match the registered account + +Scenario: A different user cannot fetch someone else's account by ID + Given I have registered a new account + And I am logged in as a different user + When I retrieve the registered account by ID + Then the response has HTTP status 403 diff --git a/web/backend/API/API.Specs/Steps/ApiGeneralSteps.cs b/web/backend/API/API.Specs/Steps/ApiGeneralSteps.cs index c0159449..211f9355 100644 --- a/web/backend/API/API.Specs/Steps/ApiGeneralSteps.cs +++ b/web/backend/API/API.Specs/Steps/ApiGeneralSteps.cs @@ -167,4 +167,40 @@ public class ApiGeneralSteps(ScenarioContext scenario) actualValue ); } + + [Then("the response JSON should not contain {string}")] + public void ThenTheResponseJsonShouldNotContainString(string field) + { + scenario.TryGetValue(ResponseBodyKey, out string? responseBody).Should().BeTrue(); + + using JsonDocument doc = JsonDocument.Parse(responseBody!); + JsonElement root = doc.RootElement; + + AssertFieldAbsent(root, field); + + if (root.ValueKind == JsonValueKind.Object && + root.TryGetProperty("payload", out JsonElement payloadElem)) + AssertFieldAbsent(payloadElem, field); + } + + /// + /// Recurses into arrays so a listing endpoint's response can be asserted the same way as a + /// single-resource response. + /// + private static void AssertFieldAbsent(JsonElement element, string field) + { + switch (element.ValueKind) + { + case JsonValueKind.Object: + element + .TryGetProperty(field, out _) + .Should() + .BeFalse("Expected field '{0}' to be absent from the response", field); + break; + case JsonValueKind.Array: + foreach (JsonElement item in element.EnumerateArray()) + AssertFieldAbsent(item, field); + break; + } + } } diff --git a/web/backend/API/API.Specs/Steps/AuthSteps.cs b/web/backend/API/API.Specs/Steps/AuthSteps.cs index 45624eca..a05e10ef 100644 --- a/web/backend/API/API.Specs/Steps/AuthSteps.cs +++ b/web/backend/API/API.Specs/Steps/AuthSteps.cs @@ -347,6 +347,46 @@ public class AuthSteps(ScenarioContext scenario) : ApiStepsBase(scenario) Scenario[RegisteredUsernameKey] = ParseRegisteredUsername(root); } + [Given("I am logged in as a different user")] + public async Task GivenIAmLoggedInAsADifferentUser() + { + HttpClient client = GetClient(); + string suffix = Guid.NewGuid().ToString("N")[..8]; + var registrationData = new + { + username = $"otheruser-{suffix}", + firstName = "Other", + lastName = "User", + email = $"otheruser-{suffix}@example.com", + dateOfBirth = "1990-01-01", + password = "Password1!", + }; + + HttpRequestMessage requestMessage = new(HttpMethod.Post, "/api/auth/register") + { + Content = new StringContent( + JsonSerializer.Serialize(registrationData), + Encoding.UTF8, + "application/json" + ), + }; + + HttpResponseMessage response = await client.SendAsync(requestMessage); + response.EnsureSuccessStatusCode(); + string responseBody = await response.Content.ReadAsStringAsync(); + + using JsonDocument doc = JsonDocument.Parse(responseBody); + JsonElement payload = doc.RootElement.GetProperty("payload"); + string accessToken = + ( + payload.TryGetProperty("accessToken", out JsonElement tokenElem) + ? tokenElem.GetString() + : null + ) ?? throw new InvalidOperationException("accessToken missing from registration payload"); + + Scenario["accessToken"] = accessToken; + } + [Given("I am logged in")] public async Task GivenIAmLoggedIn() { diff --git a/web/backend/API/API.Specs/Steps/BrewerySteps.cs b/web/backend/API/API.Specs/Steps/BrewerySteps.cs index 4f25f19e..5e0527a5 100644 --- a/web/backend/API/API.Specs/Steps/BrewerySteps.cs +++ b/web/backend/API/API.Specs/Steps/BrewerySteps.cs @@ -262,46 +262,6 @@ public class BrewerySteps(ScenarioContext scenario) : ApiStepsBase(scenario) await SendAsync(requestMessage); } - [Given("I am logged in as a different user")] - public async Task GivenIAmLoggedInAsADifferentUser() - { - HttpClient client = GetClient(); - string suffix = Guid.NewGuid().ToString("N")[..8]; - var registrationData = new - { - username = $"otheruser-{suffix}", - firstName = "Other", - lastName = "User", - email = $"otheruser-{suffix}@example.com", - dateOfBirth = "1990-01-01", - password = "Password1!", - }; - - HttpRequestMessage requestMessage = new(HttpMethod.Post, "/api/auth/register") - { - Content = new StringContent( - JsonSerializer.Serialize(registrationData), - Encoding.UTF8, - "application/json" - ), - }; - - HttpResponseMessage response = await client.SendAsync(requestMessage); - response.EnsureSuccessStatusCode(); - string responseBody = await response.Content.ReadAsStringAsync(); - - using JsonDocument doc = JsonDocument.Parse(responseBody); - JsonElement payload = doc.RootElement.GetProperty("payload"); - string accessToken = - ( - payload.TryGetProperty("accessToken", out JsonElement tokenElem) - ? tokenElem.GetString() - : null - ) ?? throw new InvalidOperationException("accessToken missing from registration payload"); - - Scenario[AccessTokenKey] = accessToken; - } - [Then("retrieving the brewery by ID should now return HTTP status {int}")] public async Task ThenRetrievingTheBreweryByIdShouldNowReturnHttpStatus(int expectedCode) { diff --git a/web/backend/API/API.Specs/Steps/UserSteps.cs b/web/backend/API/API.Specs/Steps/UserSteps.cs new file mode 100644 index 00000000..8bb0e588 --- /dev/null +++ b/web/backend/API/API.Specs/Steps/UserSteps.cs @@ -0,0 +1,95 @@ +using System.Text.Json; +using FluentAssertions; +using Reqnroll; + +namespace API.Specs.Steps; + +[Binding] +public class UserSteps(ScenarioContext scenario) : ApiStepsBase(scenario) +{ + private const string AccessTokenKey = "accessToken"; + private const string RegisteredUserIdKey = "registeredUserId"; + private const string RegisteredUsernameKey = "registeredUsername"; + + private async Task SendAsync(HttpRequestMessage requestMessage) + { + HttpClient client = GetClient(); + HttpResponseMessage response = await client.SendAsync(requestMessage); + string responseBody = await response.Content.ReadAsStringAsync(); + + Scenario[ResponseKey] = response; + Scenario[ResponseBodyKey] = responseBody; + } + + private HttpRequestMessage NewAuthenticatedRequest(HttpMethod method, string url) + { + HttpRequestMessage requestMessage = new(method, url); + if (Scenario.TryGetValue(AccessTokenKey, out string? accessToken)) + requestMessage.Headers.Add("Authorization", $"Bearer {accessToken}"); + return requestMessage; + } + + private Guid GetRegisteredUserId() + { + return Scenario.TryGetValue(RegisteredUserIdKey, out Guid id) + ? id + : throw new InvalidOperationException("registered user ID not found in scenario"); + } + + [When("I retrieve the public profile by ID")] + public async Task WhenIRetrieveThePublicProfileById() + { + await SendAsync(NewAuthenticatedRequest(HttpMethod.Get, $"/api/user/{GetRegisteredUserId()}/profile")); + } + + [When("I retrieve the public profile by ID without authentication")] + public async Task WhenIRetrieveThePublicProfileByIdWithoutAuthentication() + { + await SendAsync(new HttpRequestMessage(HttpMethod.Get, $"/api/user/{GetRegisteredUserId()}/profile")); + } + + [When("I retrieve the public profile by a non-existent ID")] + public async Task WhenIRetrieveThePublicProfileByANonExistentId() + { + await SendAsync(NewAuthenticatedRequest(HttpMethod.Get, $"/api/user/{Guid.NewGuid()}/profile")); + } + + [When("I retrieve the user account by a non-existent ID without authentication")] + public async Task WhenIRetrieveTheUserAccountByANonExistentIdWithoutAuthentication() + { + await SendAsync(new HttpRequestMessage(HttpMethod.Get, $"/api/user/{Guid.NewGuid()}")); + } + + [When("I list user accounts without authentication")] + public async Task WhenIListUserAccountsWithoutAuthentication() + { + await SendAsync(new HttpRequestMessage(HttpMethod.Get, "/api/user")); + } + + [When("I list user accounts")] + public async Task WhenIListUserAccounts() + { + await SendAsync(NewAuthenticatedRequest(HttpMethod.Get, "/api/user")); + } + + [When("I retrieve the registered account by ID")] + public async Task WhenIRetrieveTheRegisteredAccountById() + { + await SendAsync(NewAuthenticatedRequest(HttpMethod.Get, $"/api/user/{GetRegisteredUserId()}")); + } + + [Then("the public profile response should match the registered account")] + public void ThenThePublicProfileResponseShouldMatchTheRegisteredAccount() + { + Scenario.TryGetValue(ResponseBodyKey, out string? responseBody).Should().BeTrue(); + string? username = Scenario.TryGetValue(RegisteredUsernameKey, out string? u) + ? u + : throw new InvalidOperationException("registered username not found in scenario"); + + using JsonDocument doc = JsonDocument.Parse(responseBody!); + JsonElement root = doc.RootElement; + + root.GetProperty("userAccountId").GetGuid().Should().Be(GetRegisteredUserId()); + root.GetProperty("username").GetString().Should().Be(username); + } +} diff --git a/web/backend/Features/Features.Users.Tests/Queries/GetAllUsersHandlerTests.cs b/web/backend/Features/Features.Users.Tests/Queries/GetAllUsersHandlerTests.cs index 6628589f..c7f8bd44 100644 --- a/web/backend/Features/Features.Users.Tests/Queries/GetAllUsersHandlerTests.cs +++ b/web/backend/Features/Features.Users.Tests/Queries/GetAllUsersHandlerTests.cs @@ -1,4 +1,5 @@ using Domain.Entities; +using Features.Users.Dtos; using Features.Users.Queries.GetAllUsers; using Features.Users.Repository; using FluentAssertions; @@ -15,7 +16,7 @@ public class GetAllUsersHandlerTests GetAllUsersHandler handler = new(repoMock.Object); repoMock.Setup(r => r.GetAllAsync(10, 5)).ReturnsAsync(Array.Empty()); - IEnumerable result = await handler.Handle( + IEnumerable result = await handler.Handle( new GetAllUsersQuery(10, 5), CancellationToken.None ); @@ -23,4 +24,27 @@ public class GetAllUsersHandlerTests result.Should().BeEmpty(); repoMock.Verify(r => r.GetAllAsync(10, 5), Times.Once); } + + [Fact] + public async Task Handle_MapsUsersToPublicProfileDtos_AndExcludesPrivateFields() + { + Mock repoMock = new(); + GetAllUsersHandler handler = new(repoMock.Object); + UserAccount user = new() + { + UserAccountId = Guid.NewGuid(), + Username = "someone", + FirstName = "Some", + LastName = "One", + CreatedAt = DateTime.UtcNow, + }; + repoMock.Setup(r => r.GetAllAsync(null, null)).ReturnsAsync([user]); + + IEnumerable result = await handler.Handle( + new GetAllUsersQuery(null, null), + CancellationToken.None + ); + + result.Should().ContainSingle().Which.Should().BeEquivalentTo(user.ToPublicProfileDto()); + } } diff --git a/web/backend/Features/Features.Users.Tests/Queries/GetPublicUserProfileByIdHandlerTests.cs b/web/backend/Features/Features.Users.Tests/Queries/GetPublicUserProfileByIdHandlerTests.cs new file mode 100644 index 00000000..fc732a7c --- /dev/null +++ b/web/backend/Features/Features.Users.Tests/Queries/GetPublicUserProfileByIdHandlerTests.cs @@ -0,0 +1,65 @@ +using Domain.Entities; +using Domain.Exceptions; +using Features.Users.Dtos; +using Features.Users.Queries.GetPublicUserProfileById; +using Features.Users.Repository; +using FluentAssertions; +using Moq; + +namespace Features.Users.Tests.Queries; + +public class GetPublicUserProfileByIdHandlerTests +{ + private readonly GetPublicUserProfileByIdHandler _handler; + private readonly Mock _repoMock = new(); + + public GetPublicUserProfileByIdHandlerTests() + { + _handler = new GetPublicUserProfileByIdHandler(_repoMock.Object); + } + + [Fact] + public async Task Handle_ReturnsPublicProfile_WhenFound() + { + UserAccount user = new() + { + UserAccountId = Guid.NewGuid(), + Username = "test", + FirstName = "Jane", + LastName = "Doe", + Email = "jane@example.com", + DateOfBirth = new DateTime(1990, 1, 1), + CreatedAt = new DateTime(2024, 1, 1), + }; + _repoMock.Setup(r => r.GetByIdAsync(user.UserAccountId)).ReturnsAsync(user); + + PublicUserProfileDto result = await _handler.Handle( + new GetPublicUserProfileByIdQuery(user.UserAccountId), + CancellationToken.None + ); + + result + .Should() + .Be( + new PublicUserProfileDto( + user.UserAccountId, + user.Username, + user.FirstName, + user.LastName, + user.CreatedAt + ) + ); + } + + [Fact] + public async Task Handle_Throws_WhenNotFound() + { + Guid id = Guid.NewGuid(); + _repoMock.Setup(r => r.GetByIdAsync(id)).ReturnsAsync((UserAccount?)null); + + Func> act = async () => + await _handler.Handle(new GetPublicUserProfileByIdQuery(id), CancellationToken.None); + + await act.Should().ThrowAsync(); + } +} diff --git a/web/backend/Features/Features.Users.Tests/Queries/GetUserByIdHandlerTests.cs b/web/backend/Features/Features.Users.Tests/Queries/GetUserByIdHandlerTests.cs index 86f00db5..af2ee7e0 100644 --- a/web/backend/Features/Features.Users.Tests/Queries/GetUserByIdHandlerTests.cs +++ b/web/backend/Features/Features.Users.Tests/Queries/GetUserByIdHandlerTests.cs @@ -24,7 +24,7 @@ public class GetUserByIdHandlerTests _repoMock.Setup(r => r.GetByIdAsync(user.UserAccountId)).ReturnsAsync(user); UserAccount result = await _handler.Handle( - new GetUserByIdQuery(user.UserAccountId), + new GetUserByIdQuery(user.UserAccountId, user.UserAccountId), CancellationToken.None ); @@ -38,8 +38,21 @@ public class GetUserByIdHandlerTests _repoMock.Setup(r => r.GetByIdAsync(id)).ReturnsAsync((UserAccount?)null); Func> act = async () => - await _handler.Handle(new GetUserByIdQuery(id), CancellationToken.None); + await _handler.Handle(new GetUserByIdQuery(id, id), CancellationToken.None); await act.Should().ThrowAsync(); } + + [Fact] + public async Task Handle_Throws_WhenRequestingSomeoneElsesAccount() + { + Guid id = Guid.NewGuid(); + Guid requestingUserId = Guid.NewGuid(); + + Func> act = async () => + await _handler.Handle(new GetUserByIdQuery(id, requestingUserId), CancellationToken.None); + + await act.Should().ThrowAsync(); + _repoMock.Verify(r => r.GetByIdAsync(It.IsAny()), Times.Never); + } } diff --git a/web/backend/Features/Features.Users/Controllers/UserController.cs b/web/backend/Features/Features.Users/Controllers/UserController.cs index beb75d8c..778453bc 100644 --- a/web/backend/Features/Features.Users/Controllers/UserController.cs +++ b/web/backend/Features/Features.Users/Controllers/UserController.cs @@ -1,40 +1,76 @@ using Domain.Entities; using Domain.Exceptions; +using Features.Users.Dtos; using Features.Users.Queries.GetAllUsers; +using Features.Users.Queries.GetPublicUserProfileById; using Features.Users.Queries.GetUserById; using MediatR; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Shared.Contracts; namespace Features.Users.Controllers; /// /// Exposes read endpoints for user accounts. /// +/// +/// Every endpoint requires [Authorize(AuthenticationSchemes = "JWT")] except +/// , which is since it +/// only returns an allowlisted DTO. also restricts access to the +/// caller's own account. Neither nor may expose +/// another user's Email or DateOfBirth. +/// [ApiController] [Route("api/[controller]")] +[Authorize(AuthenticationSchemes = "JWT")] public class UserController(IMediator mediator) : ControllerBase { - /// Gets a page of user accounts, ordered by creation date descending. + /// Gets a page of user accounts' public profiles, ordered by creation date descending. /// null for no limit. /// null for no offset. [HttpGet] - public async Task>> GetAll( + public async Task>> GetAll( [FromQuery] int? limit, [FromQuery] int? offset ) { - IEnumerable users = await mediator.Send(new GetAllUsersQuery(limit, offset)); + IEnumerable users = await mediator.Send( + new GetAllUsersQuery(limit, offset) + ); return Ok(users); } - /// Gets the user account with the given ID. + /// Gets the caller's own user account by ID. + /// + /// Thrown when is not the caller's own account. The API returns HTTP status 403. + /// /// /// Thrown when no user account exists with the given ID, resulting in a 404 response. /// [HttpGet("{id:guid}")] public async Task> GetById(Guid id) { - UserAccount user = await mediator.Send(new GetUserByIdQuery(id)); + UserAccount user = await mediator.Send( + new GetUserByIdQuery(id, User.GetAuthenticatedUserId()) + ); return Ok(user); } + + /// Gets the public profile for the user account with the given ID. + /// + /// This endpoint is anonymous-accessible and requires no ownership. Anyone can view any + /// account's public profile. The response includes only fields that are safe to show publicly. + /// Unlike , it never includes Email or DateOfBirth. + /// + /// + /// Thrown when no user account exists with the given ID. The API returns HTTP status 404. + /// + [HttpGet("{id:guid}/profile")] + [AllowAnonymous] + public async Task> GetPublicProfile(Guid id) + { + PublicUserProfileDto profile = await mediator.Send(new GetPublicUserProfileByIdQuery(id)); + return Ok(profile); + } } diff --git a/web/backend/Features/Features.Users/Dtos/UserDtoMapper.cs b/web/backend/Features/Features.Users/Dtos/UserDtoMapper.cs new file mode 100644 index 00000000..a38b96bd --- /dev/null +++ b/web/backend/Features/Features.Users/Dtos/UserDtoMapper.cs @@ -0,0 +1,11 @@ +using Domain.Entities; + +namespace Features.Users.Dtos; + +/// Converts user domain entities into API response models. +public static class UserDtoMapper +{ + /// Converts a user account into its public-profile representation. + public static PublicUserProfileDto ToPublicProfileDto(this UserAccount user) => + new(user.UserAccountId, user.Username, user.FirstName, user.LastName, user.CreatedAt); +} diff --git a/web/backend/Features/Features.Users/Dtos/UserDtos.cs b/web/backend/Features/Features.Users/Dtos/UserDtos.cs new file mode 100644 index 00000000..db1188a0 --- /dev/null +++ b/web/backend/Features/Features.Users/Dtos/UserDtos.cs @@ -0,0 +1,10 @@ +namespace Features.Users.Dtos; + +/// Represents the subset of a user account that is safe to expose on a public profile. +public record PublicUserProfileDto( + Guid UserAccountId, + string Username, + string FirstName, + string LastName, + DateTime CreatedAt +); diff --git a/web/backend/Features/Features.Users/Queries/GetAllUsers/GetAllUsersHandler.cs b/web/backend/Features/Features.Users/Queries/GetAllUsers/GetAllUsersHandler.cs index a376c092..9ccabf84 100644 --- a/web/backend/Features/Features.Users/Queries/GetAllUsers/GetAllUsersHandler.cs +++ b/web/backend/Features/Features.Users/Queries/GetAllUsers/GetAllUsersHandler.cs @@ -1,17 +1,19 @@ using Domain.Entities; +using Features.Users.Dtos; using Features.Users.Repository; using MediatR; namespace Features.Users.Queries.GetAllUsers; public class GetAllUsersHandler(IUserListRepository repository) - : IRequestHandler> + : IRequestHandler> { - public Task> Handle( + public async Task> Handle( GetAllUsersQuery request, CancellationToken cancellationToken ) { - return repository.GetAllAsync(request.Limit, request.Offset); + IEnumerable users = await repository.GetAllAsync(request.Limit, request.Offset); + return users.Select(user => user.ToPublicProfileDto()); } } diff --git a/web/backend/Features/Features.Users/Queries/GetAllUsers/GetAllUsersQuery.cs b/web/backend/Features/Features.Users/Queries/GetAllUsers/GetAllUsersQuery.cs index 708f62d8..161f440b 100644 --- a/web/backend/Features/Features.Users/Queries/GetAllUsers/GetAllUsersQuery.cs +++ b/web/backend/Features/Features.Users/Queries/GetAllUsers/GetAllUsersQuery.cs @@ -1,11 +1,11 @@ -using Domain.Entities; +using Features.Users.Dtos; using MediatR; namespace Features.Users.Queries.GetAllUsers; /// -/// Requests a page of user accounts, ordered by creation date descending. +/// Requests a page of user accounts' public profiles, ordered by creation date descending. /// /// null for no limit. /// null for no offset. -public record GetAllUsersQuery(int? Limit, int? Offset) : IRequest>; +public record GetAllUsersQuery(int? Limit, int? Offset) : IRequest>; diff --git a/web/backend/Features/Features.Users/Queries/GetPublicUserProfileById/GetPublicUserProfileByIdHandler.cs b/web/backend/Features/Features.Users/Queries/GetPublicUserProfileById/GetPublicUserProfileByIdHandler.cs new file mode 100644 index 00000000..47965586 --- /dev/null +++ b/web/backend/Features/Features.Users/Queries/GetPublicUserProfileById/GetPublicUserProfileByIdHandler.cs @@ -0,0 +1,23 @@ +using Domain.Entities; +using Domain.Exceptions; +using Features.Users.Dtos; +using Features.Users.Repository; +using MediatR; + +namespace Features.Users.Queries.GetPublicUserProfileById; + +public class GetPublicUserProfileByIdHandler(IUserListRepository repository) + : IRequestHandler +{ + /// Thrown when no user account exists with the given ID. + public async Task Handle( + GetPublicUserProfileByIdQuery request, + CancellationToken cancellationToken + ) + { + UserAccount? user = await repository.GetByIdAsync(request.UserAccountId); + if (user is null) + throw new NotFoundException($"User with ID {request.UserAccountId} not found"); + return user.ToPublicProfileDto(); + } +} diff --git a/web/backend/Features/Features.Users/Queries/GetPublicUserProfileById/GetPublicUserProfileByIdQuery.cs b/web/backend/Features/Features.Users/Queries/GetPublicUserProfileById/GetPublicUserProfileByIdQuery.cs new file mode 100644 index 00000000..b4dfa53f --- /dev/null +++ b/web/backend/Features/Features.Users/Queries/GetPublicUserProfileById/GetPublicUserProfileByIdQuery.cs @@ -0,0 +1,6 @@ +using Features.Users.Dtos; +using MediatR; + +namespace Features.Users.Queries.GetPublicUserProfileById; + +public record GetPublicUserProfileByIdQuery(Guid UserAccountId) : IRequest; diff --git a/web/backend/Features/Features.Users/Queries/GetUserById/GetUserByIdHandler.cs b/web/backend/Features/Features.Users/Queries/GetUserById/GetUserByIdHandler.cs index 76e770d7..0193a76f 100644 --- a/web/backend/Features/Features.Users/Queries/GetUserById/GetUserByIdHandler.cs +++ b/web/backend/Features/Features.Users/Queries/GetUserById/GetUserByIdHandler.cs @@ -8,12 +8,18 @@ namespace Features.Users.Queries.GetUserById; public class GetUserByIdHandler(IUserListRepository repository) : IRequestHandler { + /// + /// Thrown when the caller requests an account other than their own. + /// /// Thrown when no user account exists with the given ID. public async Task Handle( GetUserByIdQuery request, CancellationToken cancellationToken ) { + if (request.UserAccountId != request.RequestingUserId) + throw new ForbiddenException("You are not authorized to view this account."); + UserAccount? user = await repository.GetByIdAsync(request.UserAccountId); if (user is null) throw new NotFoundException($"User with ID {request.UserAccountId} not found"); diff --git a/web/backend/Features/Features.Users/Queries/GetUserById/GetUserByIdQuery.cs b/web/backend/Features/Features.Users/Queries/GetUserById/GetUserByIdQuery.cs index 94953271..9dbc90bd 100644 --- a/web/backend/Features/Features.Users/Queries/GetUserById/GetUserByIdQuery.cs +++ b/web/backend/Features/Features.Users/Queries/GetUserById/GetUserByIdQuery.cs @@ -3,4 +3,9 @@ using MediatR; namespace Features.Users.Queries.GetUserById; -public record GetUserByIdQuery(Guid UserAccountId) : IRequest; +/// The account to fetch. +/// +/// The authenticated caller's ID, from the access token. It must equal +/// . This query returns private fields, including Email. +/// +public record GetUserByIdQuery(Guid UserAccountId, Guid RequestingUserId) : IRequest; diff --git a/web/backend/Features/Features.Users/Repository/UserListRepository.cs b/web/backend/Features/Features.Users/Repository/UserListRepository.cs index deb04bd8..ce4940a2 100644 --- a/web/backend/Features/Features.Users/Repository/UserListRepository.cs +++ b/web/backend/Features/Features.Users/Repository/UserListRepository.cs @@ -33,13 +33,15 @@ public class UserListRepository(ISqlConnectionFactory connectionFactory) /// /// Always applies pagination via OFFSET/FETCH, ordered by creation date descending. /// A null / is treated as "no limit"/"no offset". + /// Does not select Email or DateOfBirth: this listing is consumed only as public + /// profiles, so those fields are left at their default rather than read from the database. /// public async Task> GetAllAsync(int? limit, int? offset) { await using DbConnection connection = await CreateConnection(); return await connection.QueryAsync( """ - SELECT UserAccountID, Username, FirstName, LastName, Email, CreatedAt, UpdatedAt, DateOfBirth, RowVersion + SELECT UserAccountID, Username, FirstName, LastName, CreatedAt, UpdatedAt, RowVersion FROM Auth.UserAccount ORDER BY CreatedAt DESC OFFSET @Offset ROWS FETCH NEXT @Limit ROWS ONLY diff --git a/web/frontend/app/components/ui/navbar/Navbar.stories.tsx b/web/frontend/app/components/ui/navbar/Navbar.stories.tsx index a5744a24..6ff5345e 100644 --- a/web/frontend/app/components/ui/navbar/Navbar.stories.tsx +++ b/web/frontend/app/components/ui/navbar/Navbar.stories.tsx @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; -import { expect, userEvent, within } from 'storybook/test'; +import { expect, screen, userEvent, within } from 'storybook/test'; import Navbar from './Navbar'; const navbarDescription = `Top-level navigation for the Biergarten website. These stories cover guest, authenticated, and mobile states so you can review branding, route visibility, account menu behavior, and responsive collapse without leaving Storybook.`; @@ -47,8 +47,10 @@ export const Authenticated: Story = { const userButton = canvas.getByRole('button', { name: /hans/i }); await expect(userButton).toBeInTheDocument(); await userEvent.click(userButton); - await expect(canvas.getByRole('menuitem', { name: /dashboard/i })).toBeInTheDocument(); - await expect(canvas.getByRole('menuitem', { name: /logout/i })).toBeInTheDocument(); + // MenuItems is anchor-positioned, which portals it outside canvasElement. + await expect(screen.getByRole('menuitem', { name: /my profile/i })).toBeInTheDocument(); + await expect(screen.getByRole('menuitem', { name: /dashboard/i })).toBeInTheDocument(); + await expect(screen.getByRole('menuitem', { name: /logout/i })).toBeInTheDocument(); }, }; diff --git a/web/frontend/app/components/ui/navbar/Navbar.tsx b/web/frontend/app/components/ui/navbar/Navbar.tsx index 1583b794..b781b464 100644 --- a/web/frontend/app/components/ui/navbar/Navbar.tsx +++ b/web/frontend/app/components/ui/navbar/Navbar.tsx @@ -72,7 +72,10 @@ const Navbar = ({ auth }: NavbarProps) => ( Dashboard - + ) : ( diff --git a/web/frontend/app/components/ui/navbar/UserMenu.tsx b/web/frontend/app/components/ui/navbar/UserMenu.tsx index 7f9f8809..1ed73e0e 100644 --- a/web/frontend/app/components/ui/navbar/UserMenu.tsx +++ b/web/frontend/app/components/ui/navbar/UserMenu.tsx @@ -3,25 +3,40 @@ import { Link } from 'react-router'; interface UserMenuProps { username: string; + userAccountId: string; } -const UserMenu = ({ username }: UserMenuProps) => ( - +const UserMenu = ({ username, userAccountId }: UserMenuProps) => ( + {username} - + - {({ focus }) => ( - - Dashboard - - )} + + My profile + - {({ focus }) => ( - - Logout - - )} + + Dashboard + + + + + Logout + diff --git a/web/frontend/app/features/account/routes/account.tsx b/web/frontend/app/features/account/routes/account.tsx index 1c685fa4..a4a3e6cf 100644 --- a/web/frontend/app/features/account/routes/account.tsx +++ b/web/frontend/app/features/account/routes/account.tsx @@ -37,7 +37,7 @@ export const loader = async ({ request }: Route.LoaderArgs) => { let details = { firstName: '', lastName: '', email: '', dateOfBirth: '' }; try { - const account = await getUserAccount(auth.userAccountId); + const account = await getUserAccount(auth.accessToken, auth.userAccountId); details = { firstName: account.firstName, lastName: account.lastName, diff --git a/web/frontend/app/features/auth/auth.server.ts b/web/frontend/app/features/auth/auth.server.ts index 29abc33f..c738e1fc 100644 --- a/web/frontend/app/features/auth/auth.server.ts +++ b/web/frontend/app/features/auth/auth.server.ts @@ -162,8 +162,13 @@ export const refreshTokens = async (refreshToken: string) => { return data.payload; }; -export const getUserAccount = async (userAccountId: string): Promise => { - const res = await fetch(`${API_BASE_URL}/api/user/${userAccountId}`); +export const getUserAccount = async ( + accessToken: string, + userAccountId: string, +): Promise => { + const res = await fetch(`${API_BASE_URL}/api/user/${userAccountId}`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); if (!res.ok) { const text = await res.text(); diff --git a/web/frontend/app/features/breweries/components/detail/comments/CommentItem.stories.tsx b/web/frontend/app/features/breweries/components/detail/comments/CommentItem.stories.tsx index 539c4354..18b45014 100644 --- a/web/frontend/app/features/breweries/components/detail/comments/CommentItem.stories.tsx +++ b/web/frontend/app/features/breweries/components/detail/comments/CommentItem.stories.tsx @@ -54,3 +54,14 @@ export const Liked: Story = { await expect(canvas.getByRole('button', { name: /13 likes/i })).toBeInTheDocument(); }, }; + +export const WithLinkedAccount: Story = { + args: { comment: { ...comment, userAccountId: '11111111-1111-1111-1111-111111111111' } }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByRole('link', { name: 'malt_kettle' })).toHaveAttribute( + 'href', + '/users/11111111-1111-1111-1111-111111111111', + ); + }, +}; diff --git a/web/frontend/app/features/breweries/components/detail/comments/CommentItem.tsx b/web/frontend/app/features/breweries/components/detail/comments/CommentItem.tsx index 370b502d..c412eef7 100644 --- a/web/frontend/app/features/breweries/components/detail/comments/CommentItem.tsx +++ b/web/frontend/app/features/breweries/components/detail/comments/CommentItem.tsx @@ -1,4 +1,5 @@ import { Heart, HeartSolid } from 'iconoir-react'; +import { Link } from 'react-router'; import StarRating from '../../shared/StarRating'; import type { FillerComment } from '../../../utils/filler-brewery-detail'; @@ -19,7 +20,16 @@ const CommentItem = ({ comment, onToggleLike }: CommentItemProps) => {
- {comment.user} + {comment.userAccountId ? ( + + {comment.user} + + ) : ( + {comment.user} + )} {comment.time}
diff --git a/web/frontend/app/features/breweries/routes/brewery-detail.tsx b/web/frontend/app/features/breweries/routes/brewery-detail.tsx index e715c2f2..7c1a494b 100644 --- a/web/frontend/app/features/breweries/routes/brewery-detail.tsx +++ b/web/frontend/app/features/breweries/routes/brewery-detail.tsx @@ -28,13 +28,14 @@ export const loader = async ({ request, params }: Route.LoaderArgs) => { brewery, loggedIn: auth !== null, username: auth?.username ?? null, + userAccountId: auth?.userAccountId ?? null, }; }; const initials = (username: string) => username.slice(0, 2).toUpperCase(); const BreweryDetail = ({ loaderData }: Route.ComponentProps) => { - const { brewery, loggedIn, username } = loaderData; + const { brewery, loggedIn, username, userAccountId } = loaderData; const [liked, setLiked] = useState(false); const [likeCount, setLikeCount] = useState(FILLER_BREWERY_META.likeCount); @@ -57,6 +58,7 @@ const BreweryDetail = ({ loaderData }: Route.ComponentProps) => { { id: crypto.randomUUID(), user: username ?? 'you', + userAccountId: userAccountId ?? undefined, initials: username ? initials(username) : 'YO', rating: yourRating, time: 'just now', diff --git a/web/frontend/app/features/breweries/utils/filler-brewery-detail.ts b/web/frontend/app/features/breweries/utils/filler-brewery-detail.ts index 554900a3..d9298434 100644 --- a/web/frontend/app/features/breweries/utils/filler-brewery-detail.ts +++ b/web/frontend/app/features/breweries/utils/filler-brewery-detail.ts @@ -88,6 +88,7 @@ export const FILLER_BEERS: FillerBeer[] = [ export interface FillerComment { id: string; user: string; + userAccountId?: string; initials: string; rating: number; time: string; @@ -96,6 +97,8 @@ export interface FillerComment { liked: boolean; } +// `userAccountId` is left unset below since these commenters aren't backed by real accounts +// yet; CommentItem only links a commenter's name once a real account GUID is available. export const FILLER_COMMENTS: FillerComment[] = [ { id: '1', diff --git a/web/frontend/app/features/users/USER_PROFILE_HANDOFF.md b/web/frontend/app/features/users/USER_PROFILE_HANDOFF.md new file mode 100644 index 00000000..407ace9f --- /dev/null +++ b/web/frontend/app/features/users/USER_PROFILE_HANDOFF.md @@ -0,0 +1,57 @@ +# Public user profile: data requirements + +Covers `/users/:id` (`routes/user-profile.tsx`). Mirrors the format of +`../breweries/BREWERY_HANDOFF.md`. + +## Status legend + +- ✅ **Available**: real data, wired up. +- ⚠️ **Partial**: schema/repository exists but no public read endpoint. +- ❌ **Missing**: no backend support; page shows filler or local-only state. + +## Header & sidebar + +| Field | Status | Source | Gap | +| ---------------------------------------- | ------ | --------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Username, name, joined date | ✅ | `GET /api/user/{id}/profile` (`PublicUserProfileDto`) | | +| `isOwnProfile` | ✅ | Compares the session's `userAccountId` to the route param | | +| Bio | ⚠️ | `FILLER_PROFILE_META.bio` | `Social.UserProfile.Biography` exists (`IUserProfileRepository`, `UpdateBiographyHandler`) but there's no query/controller route to read another user's profile — only a write path for the authenticated user's own. | +| Location | ❌ | `FILLER_PROFILE_META.location` | No location field on `UserAccount` or `UserProfile`. | +| Avatar photo | ⚠️ | Initials placeholder (`avatar-placeholder`) | `Social.UserAvatar` + `UploadAvatarHandler` exist but there's no GET endpoint to read a user's stored avatar back. | +| Cover photo | ❌ | Flat `bg-base-300` block | No cover-photo concept anywhere in the schema. | +| Following / Followers counts | ❌ | `FILLER_PROFILE_META.following` / `.followers` | `Social.UserFollow` table exists but is unused by any query/controller — needs count queries (`WHERE UserAccountID = ?` / `WHERE FollowingID = ?`). | +| Breweries visited / Ratings given counts | ❌ | `FILLER_PROFILE_META.breweriesVisited` / `.ratingsGiven` | Blocked on the same like/rating gaps called out in `BREWERY_HANDOFF.md`. | + +## Follow action + +| Field | Status | Source | Gap | +| ----------------------------- | ------ | ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------- | +| Follow / unfollow the profile | ❌ | Local component state | `Social.UserFollow` has no command/query/controller (create, delete, "am I following them" check) — mirrors the brewery-like gap. | +| Edit profile (own profile) | ✅ | Links to `/account`, the existing profile-edit surface | | + +## Profile links from other pages + +| Field | Status | Source | Gap | +| ----------------------------------------------- | ------ | --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Commenter name links (brewery detail comments) | ⚠️ | `CommentItem` links only when `comment.userAccountId` is set | `FILLER_COMMENTS` has no backing accounts, so those rows render as plain text; a comment added during the session by a signed-in user does carry a real GUID and links correctly. | +| Following-list person links (profile page) | ⚠️ | `FollowingTab` links only when `f.userAccountId` is set | Same gap as above — `FILLER_FOLLOWING`'s two person rows have no backing accounts, so they render as plain text until real follow data exists. | + +Both components already support linking (see `CommentItem.stories.tsx`'s `WithLinkedAccount` story); what's missing is real account IDs on the filler rows, which can't be faked without recreating the 404 this was fixed for. This resolves itself once (4) below closes and comments/follows are loader-backed. + +## Activity / Following / Liked tabs + +| Field | Status | Source | Gap | +| -------------------------------------- | ------ | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Activity feed (ratings/likes/comments) | ❌ | `FILLER_ACTIVITY` | No unified activity/timeline concept. Needs a query that unions ratings, likes, and comments across brewery/beer posts for a given user, ordered by time. | +| Following list (people + breweries) | ❌ | `FILLER_FOLLOWING` | Needs a query over `Social.UserFollow` joined to `Auth.UserAccount`, plus (once the like/rating gaps close) a way to follow breweries specifically. | +| Liked list | ❌ | `FILLER_LIKED` | Blocked on the `BreweryPostLike`/`BeerPostLike` gap in `BREWERY_HANDOFF.md`. | +| Unfollow from the Following tab | ❌ | Local component state (optimistic toggle) | Same `Social.UserFollow` gap as the header Follow action. | + +## Summary of backend work + +1. A public query + controller route to read a user's `Social.UserProfile.Biography` by `UserAccountId` (the write path already exists; only the public read is missing). +2. A public query + controller route to read a user's stored `Social.UserAvatar`. +3. A location field on `UserAccount` or `UserProfile`, and a cover-photo storage concept (mirrors the avatar gap). +4. `Social.UserFollow` command/query/controller: follow, unfollow, "is following" check, and follower/following counts. +5. An activity/timeline query unioning ratings, likes, and comments for a user, ordered by time — depends on the like/rating/comment tables called out in `BREWERY_HANDOFF.md` existing first. +6. Once (4) and the like/rating gaps close, swap `FILLER_PROFILE_META` / `FILLER_ACTIVITY` / `FILLER_FOLLOWING` / `FILLER_LIKED` (`utils/filler-user-profile.ts`) and the local `useState` in `routes/user-profile.tsx` for loader data and `authorizedRequest`-based mutations (see `auth.server.ts`). Swapping in real (possibly empty) arrays is also what makes the Activity/Following `EmptyState`s reachable from the actual route — today they're only exercised by the `Empty` Storybook stories, since the filler arrays are never empty. diff --git a/web/frontend/app/features/users/components/profile/EmptyState.tsx b/web/frontend/app/features/users/components/profile/EmptyState.tsx new file mode 100644 index 00000000..24232baa --- /dev/null +++ b/web/frontend/app/features/users/components/profile/EmptyState.tsx @@ -0,0 +1,9 @@ +const EmptyState = ({ message }: { message: string }) => ( +
+
+

{message}

+
+
+); + +export default EmptyState; diff --git a/web/frontend/app/features/users/components/profile/ProfileSidebarCard.stories.tsx b/web/frontend/app/features/users/components/profile/ProfileSidebarCard.stories.tsx new file mode 100644 index 00000000..258af837 --- /dev/null +++ b/web/frontend/app/features/users/components/profile/ProfileSidebarCard.stories.tsx @@ -0,0 +1,62 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { expect, fn, userEvent, within } from 'storybook/test'; +import ProfileSidebarCard from './ProfileSidebarCard'; + +const profileSidebarCardDescription = `Identity card in the public profile page's sidebar: name/handle, the Follow/Edit profile action, bio, location and join date, and the follower/following stat list.`; + +const meta = { + title: 'Users/ProfileSidebarCard', + component: ProfileSidebarCard, + tags: ['autodocs'], + args: { + displayName: 'Maren Iverson', + username: 'mareniverson', + bio: 'Homebrewer turned hop enthusiast. Chasing the best stout in the Midwest, one taproom at a time. 🍺', + location: 'Portland, OR', + joinedLabel: 'Joined March 2023', + stats: [ + { label: 'Following', value: 68 }, + { label: 'Followers', value: 214 }, + { label: 'Breweries visited', value: 37 }, + { label: 'Ratings given', value: 112 }, + ], + onToggleFollow: fn(), + }, + parameters: { + layout: 'padded', + docs: { + description: { + component: profileSidebarCardDescription, + }, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Visitor: Story = { + args: { isOwnProfile: false, isFollowing: false }, + play: async ({ args, canvasElement }) => { + const canvas = within(canvasElement); + const followButton = canvas.getByRole('button', { name: /^follow$/i }); + await userEvent.click(followButton); + await expect(args.onToggleFollow).toHaveBeenCalledTimes(1); + }, +}; + +export const FollowingVisitor: Story = { + args: { isOwnProfile: false, isFollowing: true }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByRole('button', { name: /^following$/i })).toBeInTheDocument(); + }, +}; + +export const OwnProfile: Story = { + args: { isOwnProfile: true, isFollowing: false }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByRole('link', { name: /edit profile/i })).toBeInTheDocument(); + }, +}; diff --git a/web/frontend/app/features/users/components/profile/ProfileSidebarCard.tsx b/web/frontend/app/features/users/components/profile/ProfileSidebarCard.tsx new file mode 100644 index 00000000..c99a9a89 --- /dev/null +++ b/web/frontend/app/features/users/components/profile/ProfileSidebarCard.tsx @@ -0,0 +1,82 @@ +import { Calendar, MapPin } from 'iconoir-react'; +import { Link } from 'react-router'; + +interface ProfileStat { + label: string; + value: number; +} + +interface ProfileSidebarCardProps { + displayName: string; + username: string; + bio: string; + location: string; + joinedLabel: string; + stats: ProfileStat[]; + isOwnProfile: boolean; + isFollowing: boolean; + onToggleFollow: () => void; +} + +const ProfileSidebarCard = ({ + displayName, + username, + bio, + location, + joinedLabel, + stats, + isOwnProfile, + isFollowing, + onToggleFollow, +}: ProfileSidebarCardProps) => ( +
+
+
+

{displayName}

+

@{username}

+
+ + {isOwnProfile ? ( + + Edit profile + + ) : ( + + )} + +

{bio}

+ +
+
+
+
+
+
+ +
+ +
+ {stats.map((stat) => ( +
+ {stat.label} + + {stat.value} + +
+ ))} +
+
+
+); + +export default ProfileSidebarCard; diff --git a/web/frontend/app/features/users/components/profile/ProfileTabs.stories.tsx b/web/frontend/app/features/users/components/profile/ProfileTabs.stories.tsx new file mode 100644 index 00000000..f276c119 --- /dev/null +++ b/web/frontend/app/features/users/components/profile/ProfileTabs.stories.tsx @@ -0,0 +1,47 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { useState } from 'react'; +import { expect, fn, userEvent, within } from 'storybook/test'; +import ProfileTabs, { type ProfileTab } from './ProfileTabs'; + +const profileTabsDescription = `Activity / Following / Liked tab bar for the public profile page. Switching tabs is an instant swap with no transition, consistent with the design system's stance on tab bars.`; + +const meta = { + title: 'Users/ProfileTabs', + component: ProfileTabs, + tags: ['autodocs'], + parameters: { + layout: 'padded', + docs: { + description: { + component: profileTabsDescription, + }, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const panels = { + activity:

Activity panel

, + following:

Following panel

, + liked:

Liked panel

, +}; + +const ControlledTabs = ({ initialTab }: { initialTab: ProfileTab }) => { + const [activeTab, setActiveTab] = useState(initialTab); + return ; +}; + +export const Default: Story = { + args: { activeTab: 'activity', onChange: fn(), panels }, + render: (args) => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const followingTab = canvas.getByRole('tab', { name: /following/i }); + await expect(followingTab).toHaveAttribute('aria-selected', 'false'); + await userEvent.click(followingTab); + await expect(followingTab).toHaveAttribute('aria-selected', 'true'); + await expect(canvas.getByText('Following panel')).toBeInTheDocument(); + }, +}; diff --git a/web/frontend/app/features/users/components/profile/ProfileTabs.tsx b/web/frontend/app/features/users/components/profile/ProfileTabs.tsx new file mode 100644 index 00000000..1713f021 --- /dev/null +++ b/web/frontend/app/features/users/components/profile/ProfileTabs.tsx @@ -0,0 +1,42 @@ +import { Tab, TabGroup, TabList, TabPanel, TabPanels } from '@headlessui/react'; +import type { ReactNode } from 'react'; + +export type ProfileTab = 'activity' | 'following' | 'liked'; + +const TABS: { id: ProfileTab; label: string }[] = [ + { id: 'activity', label: 'Activity' }, + { id: 'following', label: 'Following' }, + { id: 'liked', label: 'Liked' }, +]; + +interface ProfileTabsProps { + activeTab: ProfileTab; + onChange: (tab: ProfileTab) => void; + panels: Record; +} + +const ProfileTabs = ({ activeTab, onChange, panels }: ProfileTabsProps) => { + const selectedIndex = TABS.findIndex((tab) => tab.id === activeTab); + + return ( + onChange(TABS[index].id)}> + + {TABS.map((tab) => ( + + {tab.label} + + ))} + + + {TABS.map((tab) => ( + {panels[tab.id]} + ))} + + + ); +}; + +export default ProfileTabs; diff --git a/web/frontend/app/features/users/components/profile/tabs/ActivityTab.stories.tsx b/web/frontend/app/features/users/components/profile/tabs/ActivityTab.stories.tsx new file mode 100644 index 00000000..ad789c40 --- /dev/null +++ b/web/frontend/app/features/users/components/profile/tabs/ActivityTab.stories.tsx @@ -0,0 +1,42 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { expect, within } from 'storybook/test'; +import ActivityTab from './ActivityTab'; +import { FILLER_ACTIVITY } from '../../../utils/filler-user-profile'; + +const activityTabDescription = `A profile's Activity tab: ratings, likes, and comments as a vertical feed, newest first. Renders the empty-state card when the collection is empty.`; + +const meta = { + title: 'Users/ActivityTab', + component: ActivityTab, + tags: ['autodocs'], + parameters: { + layout: 'padded', + docs: { + description: { + component: activityTabDescription, + }, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Populated: Story = { + args: { activity: FILLER_ACTIVITY }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText(/interurban ipa/i)).toBeInTheDocument(); + await expect(canvas.getByText(/best fruited stout/i)).toBeInTheDocument(); + }, +}; + +export const Empty: Story = { + args: { activity: [] }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + canvas.getByText('No activity yet. Ratings, likes, and comments will show up here.'), + ).toBeInTheDocument(); + }, +}; diff --git a/web/frontend/app/features/users/components/profile/tabs/ActivityTab.tsx b/web/frontend/app/features/users/components/profile/tabs/ActivityTab.tsx new file mode 100644 index 00000000..e377faa5 --- /dev/null +++ b/web/frontend/app/features/users/components/profile/tabs/ActivityTab.tsx @@ -0,0 +1,55 @@ +import { ChatBubble, Heart, Star } from 'iconoir-react'; +import EmptyState from '../EmptyState'; +import StarRating from '../../../../breweries/components/shared/StarRating'; +import type { FillerActivity } from '../../../utils/filler-user-profile'; + +const ICONS = { star: Star, heart: Heart, comment: ChatBubble } as const; + +interface ActivityTabProps { + activity: FillerActivity[]; +} + +const ActivityTab = ({ activity }: ActivityTabProps) => { + if (activity.length === 0) { + return ( + + ); + } + + return ( +
+ {activity.map((item) => { + const Icon = ICONS[item.icon]; + return ( +
+
+
+
+
+

+ {item.verb} {item.target} +

+ {item.rating !== undefined && ( +
+ +
+ )} + {item.comment && ( +

+ “{item.comment}” +

+ )} +

+ {item.time} +

+
+
+
+ ); + })} +
+ ); +}; + +export default ActivityTab; diff --git a/web/frontend/app/features/users/components/profile/tabs/FollowingTab.stories.tsx b/web/frontend/app/features/users/components/profile/tabs/FollowingTab.stories.tsx new file mode 100644 index 00000000..01f8b429 --- /dev/null +++ b/web/frontend/app/features/users/components/profile/tabs/FollowingTab.stories.tsx @@ -0,0 +1,46 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { expect, fn, userEvent, within } from 'storybook/test'; +import FollowingTab from './FollowingTab'; +import { FILLER_FOLLOWING } from '../../../utils/filler-user-profile'; + +const followingTabDescription = `A profile's Following tab: the people and breweries this user follows, each with an unfollow toggle.`; + +const meta = { + title: 'Users/FollowingTab', + component: FollowingTab, + tags: ['autodocs'], + args: { + onToggleFollow: fn(), + }, + parameters: { + layout: 'padded', + docs: { + description: { + component: followingTabDescription, + }, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Populated: Story = { + args: { + following: FILLER_FOLLOWING.map((f) => ({ ...f, isFollowing: true })), + }, + play: async ({ args, canvasElement }) => { + const canvas = within(canvasElement); + const button = canvas.getAllByRole('button', { name: /^following$/i })[0]; + await userEvent.click(button); + await expect(args.onToggleFollow).toHaveBeenCalledWith(FILLER_FOLLOWING[0].id); + }, +}; + +export const Empty: Story = { + args: { following: [] }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText('Not following anyone yet.')).toBeInTheDocument(); + }, +}; diff --git a/web/frontend/app/features/users/components/profile/tabs/FollowingTab.tsx b/web/frontend/app/features/users/components/profile/tabs/FollowingTab.tsx new file mode 100644 index 00000000..6d60e4ae --- /dev/null +++ b/web/frontend/app/features/users/components/profile/tabs/FollowingTab.tsx @@ -0,0 +1,61 @@ +import { Link } from 'react-router'; +import EmptyState from '../EmptyState'; +import type { FillerFollowing } from '../../../utils/filler-user-profile'; + +const initials = (name: string) => + name + .split(' ') + .map((part) => part[0]) + .filter(Boolean) + .slice(0, 2) + .join('') + .toUpperCase(); + +interface FollowingTabProps { + following: (FillerFollowing & { isFollowing: boolean })[]; + onToggleFollow: (id: string) => void; +} + +const FollowingTab = ({ following, onToggleFollow }: FollowingTabProps) => { + if (following.length === 0) { + return ; + } + + return ( +
+ {following.map((f) => ( +
+
+
+
+ {initials(f.name)} +
+
+
+ {f.userAccountId ? ( + + {f.name} + + ) : ( +

{f.name}

+ )} +

{f.meta}

+
+ +
+
+ ))} +
+ ); +}; + +export default FollowingTab; diff --git a/web/frontend/app/features/users/components/profile/tabs/LikedTab.stories.tsx b/web/frontend/app/features/users/components/profile/tabs/LikedTab.stories.tsx new file mode 100644 index 00000000..23257429 --- /dev/null +++ b/web/frontend/app/features/users/components/profile/tabs/LikedTab.stories.tsx @@ -0,0 +1,40 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { expect, within } from 'storybook/test'; +import LikedTab from './LikedTab'; +import { FILLER_LIKED } from '../../../utils/filler-user-profile'; + +const likedTabDescription = `A profile's Liked tab: a 2-column grid of beers this user has liked, each with a rating.`; + +const meta = { + title: 'Users/LikedTab', + component: LikedTab, + tags: ['autodocs'], + parameters: { + layout: 'padded', + docs: { + description: { + component: likedTabDescription, + }, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Populated: Story = { + args: { liked: FILLER_LIKED }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText('Interurban IPA')).toBeInTheDocument(); + await expect(canvas.getByText('Fremont Brewing')).toBeInTheDocument(); + }, +}; + +export const Empty: Story = { + args: { liked: [] }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText('No likes yet.')).toBeInTheDocument(); + }, +}; diff --git a/web/frontend/app/features/users/components/profile/tabs/LikedTab.tsx b/web/frontend/app/features/users/components/profile/tabs/LikedTab.tsx new file mode 100644 index 00000000..85ccad22 --- /dev/null +++ b/web/frontend/app/features/users/components/profile/tabs/LikedTab.tsx @@ -0,0 +1,32 @@ +import EmptyState from '../EmptyState'; +import StarRating from '../../../../breweries/components/shared/StarRating'; +import type { FillerLiked } from '../../../utils/filler-user-profile'; + +interface LikedTabProps { + liked: FillerLiked[]; +} + +const LikedTab = ({ liked }: LikedTabProps) => { + if (liked.length === 0) { + return ; + } + + return ( +
+ {liked.map((item) => ( +
+
+
+

{item.name}

+

{item.meta}

+
+ +
+
+
+ ))} +
+ ); +}; + +export default LikedTab; diff --git a/web/frontend/app/features/users/routes/user-profile.tsx b/web/frontend/app/features/users/routes/user-profile.tsx new file mode 100644 index 00000000..a81daf8f --- /dev/null +++ b/web/frontend/app/features/users/routes/user-profile.tsx @@ -0,0 +1,144 @@ +import { useState } from 'react'; +import { data } from 'react-router'; +import RouteErrorState from '../../../components/ui/error/RouteErrorState'; +import { getOptionalAuth } from '../../auth/auth.server'; +import { getPublicUserProfile, type PublicUserProfile } from '../users.server'; +import ProfileSidebarCard from '../components/profile/ProfileSidebarCard'; +import ProfileTabs, { type ProfileTab } from '../components/profile/ProfileTabs'; +import ActivityTab from '../components/profile/tabs/ActivityTab'; +import FollowingTab from '../components/profile/tabs/FollowingTab'; +import LikedTab from '../components/profile/tabs/LikedTab'; +import { + FILLER_ACTIVITY, + FILLER_FOLLOWING, + FILLER_LIKED, + FILLER_PROFILE_META, +} from '../utils/filler-user-profile'; +import type { Route } from './+types/user-profile'; + +export const meta = ({ loaderData }: Route.MetaArgs) => [ + { + title: loaderData + ? `${loaderData.profile.username} | The Biergarten App` + : 'Profile | The Biergarten App', + }, +]; + +export const loader = async ({ request, params }: Route.LoaderArgs) => { + const auth = await getOptionalAuth(request); + + const profile = await getPublicUserProfile(params.id, auth?.accessToken); + if (!profile) { + throw data('User not found.', { status: 404, statusText: 'Not Found' }); + } + + return { + profile, + isOwnProfile: auth?.userAccountId === profile.userAccountId, + }; +}; + +const joinedLabel = (isoDate: string) => + `Joined ${new Intl.DateTimeFormat('en-US', { month: 'long', year: 'numeric' }).format(new Date(isoDate))}`; + +interface ProfileContentProps { + profile: PublicUserProfile; + isOwnProfile: boolean; +} + +/** + * Owns the profile-scoped interactive state. Keyed by `profile.userAccountId` in the + * parent so React remounts it (resetting follow/tab state) when the route's `:id` changes, + * since React Router reuses the route component itself across param-only navigations. + */ +const ProfileContent = ({ profile, isOwnProfile }: ProfileContentProps) => { + const [activeTab, setActiveTab] = useState('activity'); + const [isFollowingProfile, setIsFollowingProfile] = useState(false); + const [following, setFollowing] = useState(() => + FILLER_FOLLOWING.map((f) => ({ ...f, isFollowing: true })), + ); + + const handleToggleFollowRow = (id: string) => { + setFollowing((prev) => + prev.map((f) => (f.id === id ? { ...f, isFollowing: !f.isFollowing } : f)), + ); + }; + + const displayName = `${profile.firstName} ${profile.lastName}`.trim() || profile.username; + + const stats = [ + { label: 'Following', value: FILLER_PROFILE_META.following }, + { label: 'Followers', value: FILLER_PROFILE_META.followers }, + { label: 'Breweries visited', value: FILLER_PROFILE_META.breweriesVisited }, + { label: 'Ratings given', value: FILLER_PROFILE_META.ratingsGiven }, + ]; + + return ( +
+
+
+ +
+
+
+
+
+ + {displayName.slice(0, 2).toUpperCase()} + +
+
+
+ + setIsFollowingProfile((prev) => !prev)} + /> +
+ +
+ , + following: ( + + ), + liked: , + }} + /> +
+
+
+
+ ); +}; + +const UserProfile = ({ loaderData }: Route.ComponentProps) => { + const { profile, isOwnProfile } = loaderData; + + return ( + + ); +}; + +export default UserProfile; + +export const ErrorBoundary = ({ error }: Route.ErrorBoundaryProps) => ( + +); diff --git a/web/frontend/app/features/users/users.server.ts b/web/frontend/app/features/users/users.server.ts new file mode 100644 index 00000000..35205ea9 --- /dev/null +++ b/web/frontend/app/features/users/users.server.ts @@ -0,0 +1,60 @@ +import { data } from 'react-router'; + +const API_BASE_URL = process.env.API_BASE_URL || 'http://localhost:8080'; + +export interface PublicUserProfile { + userAccountId: string; + username: string; + firstName: string; + lastName: string; + createdAt: string; +} + +/** + * Fetches the public-facing slice of a user account from the dedicated public-profile + * endpoint, which never includes `email` or `dateOfBirth`. The endpoint is anonymous-accessible, + * so `accessToken` is optional and only sent when the caller is signed in. + */ +export const getPublicUserProfile = async ( + userAccountId: string, + accessToken?: string, +): Promise => { + let res: Response; + try { + res = await fetch(`${API_BASE_URL}/api/user/${encodeURIComponent(userAccountId)}/profile`, { + headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined, + }); + } catch { + throw data('The user service is unreachable right now. Please try again in a moment.', { + status: 503, + statusText: 'Service Unavailable', + }); + } + + if (res.status === 404) { + return null; + } + + if (!res.ok) { + throw data(`Failed to load user profile (${res.status}).`, { + status: res.status, + statusText: res.statusText, + }); + } + + const account: { + userAccountId: string; + username: string; + firstName: string; + lastName: string; + createdAt: string; + } = await res.json(); + + return { + userAccountId: account.userAccountId, + username: account.username, + firstName: account.firstName, + lastName: account.lastName, + createdAt: account.createdAt, + }; +}; diff --git a/web/frontend/app/features/users/utils/filler-user-profile.ts b/web/frontend/app/features/users/utils/filler-user-profile.ts new file mode 100644 index 00000000..28831dc7 --- /dev/null +++ b/web/frontend/app/features/users/utils/filler-user-profile.ts @@ -0,0 +1,101 @@ +// see USER_PROFILE_HANDOFF.md — filler data pending backend support for +// bio, location, avatar/cover photos, follower/following stats, and activity feeds. + +export interface FillerProfileMeta { + bio: string; + location: string; + following: number; + followers: number; + breweriesVisited: number; + ratingsGiven: number; +} + +export const FILLER_PROFILE_META: FillerProfileMeta = { + bio: 'Homebrewer turned hop enthusiast. Chasing the best stout in the Midwest, one taproom at a time. 🍺', + location: 'Portland, OR', + following: 68, + followers: 214, + breweriesVisited: 37, + ratingsGiven: 112, +}; + +export interface FillerActivity { + id: string; + icon: 'star' | 'heart' | 'comment'; + verb: string; + target: string; + rating?: number; + comment?: string; + time: string; +} + +export const FILLER_ACTIVITY: FillerActivity[] = [ + { + id: '1', + icon: 'star', + verb: 'Rated', + target: 'Fremont Brewing — Interurban IPA', + rating: 5, + time: '2 hours ago', + }, + { + id: '2', + icon: 'heart', + verb: 'Liked', + target: 'Stormbreaker Brewing', + time: '1 day ago', + }, + { + id: '3', + icon: 'comment', + verb: 'Commented on', + target: 'Great Notion — Blueberry Muffin', + comment: "Best fruited stout I've had this year, worth the line.", + time: '3 days ago', + }, + { + id: '4', + icon: 'star', + verb: 'Rated', + target: 'Baerlic Brewing Co.', + rating: 4, + time: '5 days ago', + }, + { + id: '5', + icon: 'heart', + verb: 'Liked', + target: 'Ecliptic Brewing — Phase Shift IPA', + time: '1 week ago', + }, +]; + +export interface FillerFollowing { + id: string; + name: string; + meta: string; + userAccountId?: string; +} + +// `userAccountId` is left unset for rows below since no backend account backs them yet; +// FollowingTab only links a row once a real account GUID is available (see USER_PROFILE_HANDOFF.md). +export const FILLER_FOLLOWING: FillerFollowing[] = [ + { id: '1', name: 'Breakside Brewery', meta: 'Portland, OR · Brewery' }, + { id: '2', name: 'Jonah Weckstein', meta: '@jweck · 142 ratings' }, + { id: '3', name: 'Wayfinder Beer', meta: 'Portland, OR · Brewery' }, + { id: '4', name: 'Casey Blume', meta: '@caseyb · 88 ratings' }, +]; + +export interface FillerLiked { + id: string; + name: string; + meta: string; + rating: number; +} + +export const FILLER_LIKED: FillerLiked[] = [ + { id: '1', name: 'Interurban IPA', meta: 'Fremont Brewing', rating: 5 }, + { id: '2', name: 'Blueberry Muffin', meta: 'Great Notion Brewing', rating: 5 }, + { id: '3', name: 'Phase Shift IPA', meta: 'Ecliptic Brewing', rating: 4 }, + { id: '4', name: 'Snapshot Wheat', meta: 'Deschutes Brewery', rating: 4 }, +]; diff --git a/web/frontend/app/routes.ts b/web/frontend/app/routes.ts index fbf0fb0d..2d338492 100644 --- a/web/frontend/app/routes.ts +++ b/web/frontend/app/routes.ts @@ -15,4 +15,5 @@ export default [ route('breweries/nearby', 'features/breweries/routes/resources/breweries-nearby.ts'), route('breweries/:id', 'features/breweries/routes/brewery-detail.tsx'), route('beer-styles', 'features/catalog/routes/beer-styles.tsx'), + route('users/:id', 'features/users/routes/user-profile.tsx'), ] satisfies RouteConfig;