Add authenticated user profile page with locked-down account endpoint (#315)

This commit is contained in:
Aaron Po
2026-09-23 00:58:54 -04:00
committed by GitHub
parent a6a9046457
commit 0ff42c3c70
45 changed files with 1418 additions and 80 deletions

View File

@@ -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

View File

@@ -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<string>(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);
}
/// <summary>
/// Recurses into arrays so a listing endpoint's response can be asserted the same way as a
/// single-resource response.
/// </summary>
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;
}
}
}

View File

@@ -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()
{

View File

@@ -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)
{

View File

@@ -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<string>(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<string>(ResponseBodyKey, out string? responseBody).Should().BeTrue();
string? username = Scenario.TryGetValue<string>(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);
}
}

View File

@@ -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<UserAccount>());
IEnumerable<UserAccount> result = await handler.Handle(
IEnumerable<PublicUserProfileDto> 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<IUserListRepository> 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<PublicUserProfileDto> result = await handler.Handle(
new GetAllUsersQuery(null, null),
CancellationToken.None
);
result.Should().ContainSingle().Which.Should().BeEquivalentTo(user.ToPublicProfileDto());
}
}

View File

@@ -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<IUserListRepository> _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<Task<PublicUserProfileDto>> act = async () =>
await _handler.Handle(new GetPublicUserProfileByIdQuery(id), CancellationToken.None);
await act.Should().ThrowAsync<NotFoundException>();
}
}

View File

@@ -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<Task<UserAccount>> act = async () =>
await _handler.Handle(new GetUserByIdQuery(id), CancellationToken.None);
await _handler.Handle(new GetUserByIdQuery(id, id), CancellationToken.None);
await act.Should().ThrowAsync<NotFoundException>();
}
[Fact]
public async Task Handle_Throws_WhenRequestingSomeoneElsesAccount()
{
Guid id = Guid.NewGuid();
Guid requestingUserId = Guid.NewGuid();
Func<Task<UserAccount>> act = async () =>
await _handler.Handle(new GetUserByIdQuery(id, requestingUserId), CancellationToken.None);
await act.Should().ThrowAsync<ForbiddenException>();
_repoMock.Verify(r => r.GetByIdAsync(It.IsAny<Guid>()), Times.Never);
}
}

View File

@@ -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;
/// <summary>
/// Exposes read endpoints for user accounts.
/// </summary>
/// <remarks>
/// Every endpoint requires <c>[Authorize(AuthenticationSchemes = "JWT")]</c> except
/// <see cref="GetPublicProfile" />, which is <see cref="AllowAnonymousAttribute" /> since it
/// only returns an allowlisted DTO. <see cref="GetById" /> also restricts access to the
/// caller's own account. Neither <see cref="GetAll" /> nor <see cref="GetById" /> may expose
/// another user's <c>Email</c> or <c>DateOfBirth</c>.
/// </remarks>
[ApiController]
[Route("api/[controller]")]
[Authorize(AuthenticationSchemes = "JWT")]
public class UserController(IMediator mediator) : ControllerBase
{
/// <summary>Gets a page of user accounts, ordered by creation date descending.</summary>
/// <summary>Gets a page of user accounts' public profiles, ordered by creation date descending.</summary>
/// <param name="limit"><c>null</c> for no limit.</param>
/// <param name="offset"><c>null</c> for no offset.</param>
[HttpGet]
public async Task<ActionResult<IEnumerable<UserAccount>>> GetAll(
public async Task<ActionResult<IEnumerable<PublicUserProfileDto>>> GetAll(
[FromQuery] int? limit,
[FromQuery] int? offset
)
{
IEnumerable<UserAccount> users = await mediator.Send(new GetAllUsersQuery(limit, offset));
IEnumerable<PublicUserProfileDto> users = await mediator.Send(
new GetAllUsersQuery(limit, offset)
);
return Ok(users);
}
/// <summary>Gets the user account with the given ID.</summary>
/// <summary>Gets the caller's own user account by ID.</summary>
/// <exception cref="ForbiddenException">
/// Thrown when <paramref name="id"/> is not the caller's own account. The API returns HTTP status 403.
/// </exception>
/// <exception cref="NotFoundException">
/// Thrown when no user account exists with the given ID, resulting in a 404 response.
/// </exception>
[HttpGet("{id:guid}")]
public async Task<ActionResult<UserAccount>> GetById(Guid id)
{
UserAccount user = await mediator.Send(new GetUserByIdQuery(id));
UserAccount user = await mediator.Send(
new GetUserByIdQuery(id, User.GetAuthenticatedUserId())
);
return Ok(user);
}
/// <summary>Gets the public profile for the user account with the given ID.</summary>
/// <remarks>
/// 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 <see cref="GetById"/>, it never includes <c>Email</c> or <c>DateOfBirth</c>.
/// </remarks>
/// <exception cref="NotFoundException">
/// Thrown when no user account exists with the given ID. The API returns HTTP status 404.
/// </exception>
[HttpGet("{id:guid}/profile")]
[AllowAnonymous]
public async Task<ActionResult<PublicUserProfileDto>> GetPublicProfile(Guid id)
{
PublicUserProfileDto profile = await mediator.Send(new GetPublicUserProfileByIdQuery(id));
return Ok(profile);
}
}

View File

@@ -0,0 +1,11 @@
using Domain.Entities;
namespace Features.Users.Dtos;
/// <summary>Converts user domain entities into API response models.</summary>
public static class UserDtoMapper
{
/// <summary>Converts a user account into its public-profile representation.</summary>
public static PublicUserProfileDto ToPublicProfileDto(this UserAccount user) =>
new(user.UserAccountId, user.Username, user.FirstName, user.LastName, user.CreatedAt);
}

View File

@@ -0,0 +1,10 @@
namespace Features.Users.Dtos;
/// <summary>Represents the subset of a user account that is safe to expose on a public profile.</summary>
public record PublicUserProfileDto(
Guid UserAccountId,
string Username,
string FirstName,
string LastName,
DateTime CreatedAt
);

View File

@@ -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<GetAllUsersQuery, IEnumerable<UserAccount>>
: IRequestHandler<GetAllUsersQuery, IEnumerable<PublicUserProfileDto>>
{
public Task<IEnumerable<UserAccount>> Handle(
public async Task<IEnumerable<PublicUserProfileDto>> Handle(
GetAllUsersQuery request,
CancellationToken cancellationToken
)
{
return repository.GetAllAsync(request.Limit, request.Offset);
IEnumerable<UserAccount> users = await repository.GetAllAsync(request.Limit, request.Offset);
return users.Select(user => user.ToPublicProfileDto());
}
}

View File

@@ -1,11 +1,11 @@
using Domain.Entities;
using Features.Users.Dtos;
using MediatR;
namespace Features.Users.Queries.GetAllUsers;
/// <summary>
/// Requests a page of user accounts, ordered by creation date descending.
/// Requests a page of user accounts' public profiles, ordered by creation date descending.
/// </summary>
/// <param name="Limit"><c>null</c> for no limit.</param>
/// <param name="Offset"><c>null</c> for no offset.</param>
public record GetAllUsersQuery(int? Limit, int? Offset) : IRequest<IEnumerable<UserAccount>>;
public record GetAllUsersQuery(int? Limit, int? Offset) : IRequest<IEnumerable<PublicUserProfileDto>>;

View File

@@ -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<GetPublicUserProfileByIdQuery, PublicUserProfileDto>
{
/// <exception cref="NotFoundException">Thrown when no user account exists with the given ID.</exception>
public async Task<PublicUserProfileDto> 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();
}
}

View File

@@ -0,0 +1,6 @@
using Features.Users.Dtos;
using MediatR;
namespace Features.Users.Queries.GetPublicUserProfileById;
public record GetPublicUserProfileByIdQuery(Guid UserAccountId) : IRequest<PublicUserProfileDto>;

View File

@@ -8,12 +8,18 @@ namespace Features.Users.Queries.GetUserById;
public class GetUserByIdHandler(IUserListRepository repository)
: IRequestHandler<GetUserByIdQuery, UserAccount>
{
/// <exception cref="ForbiddenException">
/// Thrown when the caller requests an account other than their own.
/// </exception>
/// <exception cref="NotFoundException">Thrown when no user account exists with the given ID.</exception>
public async Task<UserAccount> 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");

View File

@@ -3,4 +3,9 @@ using MediatR;
namespace Features.Users.Queries.GetUserById;
public record GetUserByIdQuery(Guid UserAccountId) : IRequest<UserAccount>;
/// <param name="UserAccountId">The account to fetch.</param>
/// <param name="RequestingUserId">
/// The authenticated caller's ID, from the access token. It must equal
/// <paramref name="UserAccountId" />. This query returns private fields, including <c>Email</c>.
/// </param>
public record GetUserByIdQuery(Guid UserAccountId, Guid RequestingUserId) : IRequest<UserAccount>;

View File

@@ -33,13 +33,15 @@ public class UserListRepository(ISqlConnectionFactory connectionFactory)
/// <remarks>
/// Always applies pagination via <c>OFFSET</c>/<c>FETCH</c>, ordered by creation date descending.
/// A <c>null</c> <paramref name="limit" />/<paramref name="offset" /> is treated as "no limit"/"no offset".
/// Does not select <c>Email</c> or <c>DateOfBirth</c>: this listing is consumed only as public
/// profiles, so those fields are left at their default rather than read from the database.
/// </remarks>
public async Task<IEnumerable<UserAccount>> GetAllAsync(int? limit, int? offset)
{
await using DbConnection connection = await CreateConnection();
return await connection.QueryAsync<UserAccount>(
"""
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

View File

@@ -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();
},
};

View File

@@ -72,7 +72,10 @@ const Navbar = ({ auth }: NavbarProps) => (
Dashboard
</Link>
<UserMenu username={auth.username} />
<UserMenu
username={auth.username}
userAccountId={auth.userAccountId}
/>
</>
) : (
<Link to="/login" className="btn btn-primary btn-sm">

View File

@@ -3,25 +3,40 @@ import { Link } from 'react-router';
interface UserMenuProps {
username: string;
userAccountId: string;
}
const UserMenu = ({ username }: UserMenuProps) => (
<Menu as="div" className="relative">
const UserMenu = ({ username, userAccountId }: UserMenuProps) => (
<Menu>
<MenuButton className="btn btn-ghost btn-sm">{username}</MenuButton>
<MenuItems className="menu absolute right-0 z-60 mt-2 w-52 rounded-box border border-base-300 bg-base-100 p-2 shadow-xl focus:outline-none">
<MenuItems
anchor="bottom end"
transition
className="menu z-60 w-52 rounded-box border border-base-300 bg-base-100 p-2 shadow-xl [--anchor-gap:8px] focus:outline-none transition duration-100 ease-out data-[closed]:scale-95 data-[closed]:opacity-0"
>
<MenuItem>
{({ focus }) => (
<Link to="/dashboard" className={focus ? 'active' : ''}>
Dashboard
</Link>
)}
<Link
to={`/users/${userAccountId}`}
className="rounded-field block px-3 py-1.5 data-[focus]:bg-base-200"
>
My profile
</Link>
</MenuItem>
<MenuItem>
{({ focus }) => (
<Link to="/logout" className={focus ? 'active' : ''}>
Logout
</Link>
)}
<Link
to="/dashboard"
className="rounded-field block px-3 py-1.5 data-[focus]:bg-base-200"
>
Dashboard
</Link>
</MenuItem>
<MenuItem>
<Link
to="/logout"
className="rounded-field block px-3 py-1.5 data-[focus]:bg-base-200"
>
Logout
</Link>
</MenuItem>
</MenuItems>
</Menu>

View File

@@ -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,

View File

@@ -162,8 +162,13 @@ export const refreshTokens = async (refreshToken: string) => {
return data.payload;
};
export const getUserAccount = async (userAccountId: string): Promise<UserAccountDetails> => {
const res = await fetch(`${API_BASE_URL}/api/user/${userAccountId}`);
export const getUserAccount = async (
accessToken: string,
userAccountId: string,
): Promise<UserAccountDetails> => {
const res = await fetch(`${API_BASE_URL}/api/user/${userAccountId}`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!res.ok) {
const text = await res.text();

View File

@@ -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',
);
},
};

View File

@@ -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) => {
</div>
<div className="flex-1 flex flex-col gap-1 min-w-0">
<div className="flex items-baseline gap-2 flex-wrap">
<span className="font-bold text-sm">{comment.user}</span>
{comment.userAccountId ? (
<Link
to={`/users/${comment.userAccountId}`}
className="font-bold text-sm link link-hover"
>
{comment.user}
</Link>
) : (
<span className="font-bold text-sm">{comment.user}</span>
)}
<StarRating value={comment.rating} size="xs" />
<span className="text-xs text-base-content/60">{comment.time}</span>
</div>

View File

@@ -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',

View File

@@ -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',

View File

@@ -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.

View File

@@ -0,0 +1,9 @@
const EmptyState = ({ message }: { message: string }) => (
<div className="card bg-base-100 shadow">
<div className="card-body items-center py-12">
<p className="text-sm text-base-content/60 text-center m-0">{message}</p>
</div>
</div>
);
export default EmptyState;

View File

@@ -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<typeof ProfileSidebarCard>;
export default meta;
type Story = StoryObj<typeof meta>;
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();
},
};

View File

@@ -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) => (
<div className="card bg-base-100 shadow">
<div className="card-body gap-4 p-6">
<div>
<h1 className="text-2xl font-bold m-0 leading-tight">{displayName}</h1>
<p className="text-sm text-base-content/60 m-0 mt-0.5">@{username}</p>
</div>
{isOwnProfile ? (
<Link to="/account" className="btn btn-outline btn-block">
Edit profile
</Link>
) : (
<button
type="button"
onClick={onToggleFollow}
className={`btn btn-block ${isFollowing ? 'btn-outline' : 'btn-primary'}`}
>
{isFollowing ? 'Following' : 'Follow'}
</button>
)}
<p className="text-sm leading-normal m-0">{bio}</p>
<div className="flex flex-col gap-1.5 text-sm text-base-content/60">
<div className="flex items-center gap-1.5">
<MapPin className="size-4 shrink-0" aria-hidden="true" />
{location}
</div>
<div className="flex items-center gap-1.5">
<Calendar className="size-4 shrink-0" aria-hidden="true" />
{joinedLabel}
</div>
</div>
<div className="divider m-0" />
<div className="flex flex-col gap-2">
{stats.map((stat) => (
<div key={stat.label} className="flex items-center justify-between gap-2">
<span className="text-sm text-base-content/60">{stat.label}</span>
<span className="font-serif text-lg font-bold tabular-nums">
{stat.value}
</span>
</div>
))}
</div>
</div>
</div>
);
export default ProfileSidebarCard;

View File

@@ -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<typeof ProfileTabs>;
export default meta;
type Story = StoryObj<typeof meta>;
const panels = {
activity: <p>Activity panel</p>,
following: <p>Following panel</p>,
liked: <p>Liked panel</p>,
};
const ControlledTabs = ({ initialTab }: { initialTab: ProfileTab }) => {
const [activeTab, setActiveTab] = useState<ProfileTab>(initialTab);
return <ProfileTabs activeTab={activeTab} onChange={setActiveTab} panels={panels} />;
};
export const Default: Story = {
args: { activeTab: 'activity', onChange: fn(), panels },
render: (args) => <ControlledTabs initialTab={args.activeTab} />,
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();
},
};

View File

@@ -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<ProfileTab, ReactNode>;
}
const ProfileTabs = ({ activeTab, onChange, panels }: ProfileTabsProps) => {
const selectedIndex = TABS.findIndex((tab) => tab.id === activeTab);
return (
<TabGroup selectedIndex={selectedIndex} onChange={(index) => onChange(TABS[index].id)}>
<TabList className="flex gap-6 border-b border-base-300">
{TABS.map((tab) => (
<Tab
key={tab.id}
className="-mb-px border-b-2 border-transparent py-3 text-sm font-semibold text-base-content/60 transition-colors hover:text-base-content focus:outline-none data-[focus]:outline-2 data-[focus]:outline-offset-2 data-[focus]:outline-primary data-[selected]:border-primary data-[selected]:text-primary"
>
{tab.label}
</Tab>
))}
</TabList>
<TabPanels className="mt-5">
{TABS.map((tab) => (
<TabPanel key={tab.id}>{panels[tab.id]}</TabPanel>
))}
</TabPanels>
</TabGroup>
);
};
export default ProfileTabs;

View File

@@ -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<typeof ActivityTab>;
export default meta;
type Story = StoryObj<typeof meta>;
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();
},
};

View File

@@ -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 (
<EmptyState message="No activity yet. Ratings, likes, and comments will show up here." />
);
}
return (
<div className="flex flex-col gap-3">
{activity.map((item) => {
const Icon = ICONS[item.icon];
return (
<div key={item.id} className="card bg-base-100 shadow">
<div className="card-body flex-row gap-4 items-start p-4">
<div className="w-[34px] h-[34px] rounded-full bg-accent text-accent-content flex items-center justify-center shrink-0">
<Icon className="size-4" aria-hidden="true" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm leading-normal m-0">
{item.verb} <span className="font-semibold">{item.target}</span>
</p>
{item.rating !== undefined && (
<div className="mt-1.5">
<StarRating value={item.rating} size="xs" />
</div>
)}
{item.comment && (
<p className="text-sm text-base-content/60 italic mt-1.5 mb-0">
&ldquo;{item.comment}&rdquo;
</p>
)}
<p className="text-xs text-base-content/60 mt-1.5 mb-0">
{item.time}
</p>
</div>
</div>
</div>
);
})}
</div>
);
};
export default ActivityTab;

View File

@@ -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<typeof FollowingTab>;
export default meta;
type Story = StoryObj<typeof meta>;
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();
},
};

View File

@@ -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 <EmptyState message="Not following anyone yet." />;
}
return (
<div className="flex flex-col gap-3">
{following.map((f) => (
<div key={f.id} className="card bg-base-100 shadow">
<div className="card-body flex-row items-center gap-4 p-4">
<div className="avatar avatar-placeholder shrink-0">
<div className="bg-secondary text-secondary-content w-11 rounded-full">
<span className="text-sm font-bold">{initials(f.name)}</span>
</div>
</div>
<div className="flex-1 min-w-0">
{f.userAccountId ? (
<Link
to={`/users/${f.userAccountId}`}
className="font-semibold text-sm truncate link link-hover block"
>
{f.name}
</Link>
) : (
<p className="font-semibold text-sm m-0 truncate">{f.name}</p>
)}
<p className="text-xs text-base-content/60 m-0 truncate">{f.meta}</p>
</div>
<button
type="button"
onClick={() => onToggleFollow(f.id)}
className={`btn btn-sm shrink-0 ${f.isFollowing ? 'btn-outline' : 'btn-primary'}`}
>
{f.isFollowing ? 'Following' : 'Follow'}
</button>
</div>
</div>
))}
</div>
);
};
export default FollowingTab;

View File

@@ -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<typeof LikedTab>;
export default meta;
type Story = StoryObj<typeof meta>;
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();
},
};

View File

@@ -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 <EmptyState message="No likes yet." />;
}
return (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{liked.map((item) => (
<div key={item.id} className="card bg-base-100 shadow overflow-hidden">
<div className="h-[120px] bg-base-300" />
<div className="card-body p-4 gap-1">
<p className="font-serif font-bold m-0">{item.name}</p>
<p className="text-sm text-base-content/60 m-0">{item.meta}</p>
<div className="mt-1">
<StarRating value={item.rating} size="xs" />
</div>
</div>
</div>
))}
</div>
);
};
export default LikedTab;

View File

@@ -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<ProfileTab>('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 (
<div className="min-h-screen bg-base-200 pb-16">
<div className="max-w-[1080px] mx-auto px-6 pt-8">
<div className="rounded-box h-[190px] bg-base-300" />
<div className="grid grid-cols-1 lg:grid-cols-[300px_1fr] gap-8 items-start -mt-12">
<div className="lg:sticky lg:top-8 flex flex-col gap-5 px-2">
<div className="w-[120px] h-[120px] rounded-full border-4 border-base-200 shadow-md overflow-hidden">
<div className="avatar avatar-placeholder w-full h-full">
<div className="bg-secondary text-secondary-content w-full h-full flex items-center justify-center">
<span className="text-2xl font-bold">
{displayName.slice(0, 2).toUpperCase()}
</span>
</div>
</div>
</div>
<ProfileSidebarCard
displayName={displayName}
username={profile.username}
bio={FILLER_PROFILE_META.bio}
location={FILLER_PROFILE_META.location}
joinedLabel={joinedLabel(profile.createdAt)}
stats={stats}
isOwnProfile={isOwnProfile}
isFollowing={isFollowingProfile}
onToggleFollow={() => setIsFollowingProfile((prev) => !prev)}
/>
</div>
<div className="mt-12">
<ProfileTabs
activeTab={activeTab}
onChange={setActiveTab}
panels={{
activity: <ActivityTab activity={FILLER_ACTIVITY} />,
following: (
<FollowingTab
following={following}
onToggleFollow={handleToggleFollowRow}
/>
),
liked: <LikedTab liked={FILLER_LIKED} />,
}}
/>
</div>
</div>
</div>
</div>
);
};
const UserProfile = ({ loaderData }: Route.ComponentProps) => {
const { profile, isOwnProfile } = loaderData;
return (
<ProfileContent
key={profile.userAccountId}
profile={profile}
isOwnProfile={isOwnProfile}
/>
);
};
export default UserProfile;
export const ErrorBoundary = ({ error }: Route.ErrorBoundaryProps) => (
<RouteErrorState error={error} />
);

View File

@@ -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<PublicUserProfile | null> => {
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,
};
};

View File

@@ -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 },
];

View File

@@ -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;