mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-15 01:54:43 +00:00
Add authenticated user profile page with locked-down account endpoint (#315)
This commit is contained in:
@@ -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;
|
- Controllers generally bind directly to the Command/Query as the request body;
|
||||||
there is no separate request DTO + mapping step for writes
|
there is no separate request DTO + mapping step for writes
|
||||||
- The exception to this is for authenticated routes, when the current user id
|
- 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<TRequest,TResponse>`
|
- A single shared `ValidationBehavior<TRequest,TResponse>`
|
||||||
(`Shared.Application/Behaviors/`) runs FluentValidation validators in the
|
(`Shared.Application/Behaviors/`) runs FluentValidation validators in the
|
||||||
MediatR pipeline before any handler executes
|
MediatR pipeline before any handler executes
|
||||||
@@ -379,6 +380,7 @@ web/frontend/
|
|||||||
│ │ - catalog beers, beer styles
|
│ │ - catalog beers, beer styles
|
||||||
│ │ - home landing page
|
│ │ - home landing page
|
||||||
│ │ - theme theme switcher/guide
|
│ │ - theme theme switcher/guide
|
||||||
|
│ │ - users public user profile page (/users/:id)
|
||||||
│ ├── hooks/ Shared hooks not tied to one feature, for example useMediaQuery
|
│ ├── hooks/ Shared hooks not tied to one feature, for example useMediaQuery
|
||||||
│ ├── routes.ts Route table (React Router 8 config-based routing)
|
│ ├── routes.ts Route table (React Router 8 config-based routing)
|
||||||
│ ├── root.tsx App shell and global providers
|
│ ├── root.tsx App shell and global providers
|
||||||
@@ -395,6 +397,8 @@ web/frontend/
|
|||||||
`/breweries/:id`) with Leaflet-powered maps and geolocation-based "nearby"
|
`/breweries/:id`) with Leaflet-powered maps and geolocation-based "nearby"
|
||||||
search
|
search
|
||||||
- Render the auth demo, account dashboard, and theme guide routes
|
- 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
|
- Manage cookie-backed website session state
|
||||||
- Call the .NET API for login, registration, token refresh, confirmation, and
|
- Call the .NET API for login, registration, token refresh, confirmation, and
|
||||||
brewery/location data
|
brewery/location data
|
||||||
|
|||||||
@@ -249,9 +249,11 @@ API.Specs/
|
|||||||
│ ├── ResendConfirmation.feature # Resend-confirmation scenarios
|
│ ├── ResendConfirmation.feature # Resend-confirmation scenarios
|
||||||
│ ├── TokenRefresh.feature # Refresh token scenarios
|
│ ├── TokenRefresh.feature # Refresh token scenarios
|
||||||
│ ├── AccessTokenValidation.feature # Protected endpoint access scenarios
|
│ ├── AccessTokenValidation.feature # Protected endpoint access scenarios
|
||||||
│ └── NotFound.feature # 404 handling
|
│ ├── NotFound.feature # 404 handling
|
||||||
|
│ └── PublicUserProfile.feature # Public profile & locked-down account scenarios
|
||||||
├── Steps/
|
├── Steps/
|
||||||
│ ├── AuthSteps.cs # Step definitions for the Auth features
|
│ ├── 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
|
│ └── ApiGeneralSteps.cs # Shared/general step definitions
|
||||||
├── Mocks/
|
├── Mocks/
|
||||||
│ ├── MockEmailDispatcher.cs # Substitutes Features.Emails' IEmailDispatcher
|
│ ├── MockEmailDispatcher.cs # Substitutes Features.Emails' IEmailDispatcher
|
||||||
|
|||||||
66
web/backend/API/API.Specs/Features/PublicUserProfile.feature
Normal file
66
web/backend/API/API.Specs/Features/PublicUserProfile.feature
Normal 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
|
||||||
@@ -167,4 +167,40 @@ public class ApiGeneralSteps(ScenarioContext scenario)
|
|||||||
actualValue
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -347,6 +347,46 @@ public class AuthSteps(ScenarioContext scenario) : ApiStepsBase(scenario)
|
|||||||
Scenario[RegisteredUsernameKey] = ParseRegisteredUsername(root);
|
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")]
|
[Given("I am logged in")]
|
||||||
public async Task GivenIAmLoggedIn()
|
public async Task GivenIAmLoggedIn()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -262,46 +262,6 @@ public class BrewerySteps(ScenarioContext scenario) : ApiStepsBase(scenario)
|
|||||||
await SendAsync(requestMessage);
|
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}")]
|
[Then("retrieving the brewery by ID should now return HTTP status {int}")]
|
||||||
public async Task ThenRetrievingTheBreweryByIdShouldNowReturnHttpStatus(int expectedCode)
|
public async Task ThenRetrievingTheBreweryByIdShouldNowReturnHttpStatus(int expectedCode)
|
||||||
{
|
{
|
||||||
|
|||||||
95
web/backend/API/API.Specs/Steps/UserSteps.cs
Normal file
95
web/backend/API/API.Specs/Steps/UserSteps.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using Domain.Entities;
|
using Domain.Entities;
|
||||||
|
using Features.Users.Dtos;
|
||||||
using Features.Users.Queries.GetAllUsers;
|
using Features.Users.Queries.GetAllUsers;
|
||||||
using Features.Users.Repository;
|
using Features.Users.Repository;
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
@@ -15,7 +16,7 @@ public class GetAllUsersHandlerTests
|
|||||||
GetAllUsersHandler handler = new(repoMock.Object);
|
GetAllUsersHandler handler = new(repoMock.Object);
|
||||||
repoMock.Setup(r => r.GetAllAsync(10, 5)).ReturnsAsync(Array.Empty<UserAccount>());
|
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),
|
new GetAllUsersQuery(10, 5),
|
||||||
CancellationToken.None
|
CancellationToken.None
|
||||||
);
|
);
|
||||||
@@ -23,4 +24,27 @@ public class GetAllUsersHandlerTests
|
|||||||
result.Should().BeEmpty();
|
result.Should().BeEmpty();
|
||||||
repoMock.Verify(r => r.GetAllAsync(10, 5), Times.Once);
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,7 +24,7 @@ public class GetUserByIdHandlerTests
|
|||||||
_repoMock.Setup(r => r.GetByIdAsync(user.UserAccountId)).ReturnsAsync(user);
|
_repoMock.Setup(r => r.GetByIdAsync(user.UserAccountId)).ReturnsAsync(user);
|
||||||
|
|
||||||
UserAccount result = await _handler.Handle(
|
UserAccount result = await _handler.Handle(
|
||||||
new GetUserByIdQuery(user.UserAccountId),
|
new GetUserByIdQuery(user.UserAccountId, user.UserAccountId),
|
||||||
CancellationToken.None
|
CancellationToken.None
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -38,8 +38,21 @@ public class GetUserByIdHandlerTests
|
|||||||
_repoMock.Setup(r => r.GetByIdAsync(id)).ReturnsAsync((UserAccount?)null);
|
_repoMock.Setup(r => r.GetByIdAsync(id)).ReturnsAsync((UserAccount?)null);
|
||||||
|
|
||||||
Func<Task<UserAccount>> act = async () =>
|
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>();
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,40 +1,76 @@
|
|||||||
using Domain.Entities;
|
using Domain.Entities;
|
||||||
using Domain.Exceptions;
|
using Domain.Exceptions;
|
||||||
|
using Features.Users.Dtos;
|
||||||
using Features.Users.Queries.GetAllUsers;
|
using Features.Users.Queries.GetAllUsers;
|
||||||
|
using Features.Users.Queries.GetPublicUserProfileById;
|
||||||
using Features.Users.Queries.GetUserById;
|
using Features.Users.Queries.GetUserById;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Shared.Contracts;
|
||||||
|
|
||||||
namespace Features.Users.Controllers;
|
namespace Features.Users.Controllers;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Exposes read endpoints for user accounts.
|
/// Exposes read endpoints for user accounts.
|
||||||
/// </summary>
|
/// </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]
|
[ApiController]
|
||||||
[Route("api/[controller]")]
|
[Route("api/[controller]")]
|
||||||
|
[Authorize(AuthenticationSchemes = "JWT")]
|
||||||
public class UserController(IMediator mediator) : ControllerBase
|
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="limit"><c>null</c> for no limit.</param>
|
||||||
/// <param name="offset"><c>null</c> for no offset.</param>
|
/// <param name="offset"><c>null</c> for no offset.</param>
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<ActionResult<IEnumerable<UserAccount>>> GetAll(
|
public async Task<ActionResult<IEnumerable<PublicUserProfileDto>>> GetAll(
|
||||||
[FromQuery] int? limit,
|
[FromQuery] int? limit,
|
||||||
[FromQuery] int? offset
|
[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);
|
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">
|
/// <exception cref="NotFoundException">
|
||||||
/// Thrown when no user account exists with the given ID, resulting in a 404 response.
|
/// Thrown when no user account exists with the given ID, resulting in a 404 response.
|
||||||
/// </exception>
|
/// </exception>
|
||||||
[HttpGet("{id:guid}")]
|
[HttpGet("{id:guid}")]
|
||||||
public async Task<ActionResult<UserAccount>> GetById(Guid id)
|
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);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
11
web/backend/Features/Features.Users/Dtos/UserDtoMapper.cs
Normal file
11
web/backend/Features/Features.Users/Dtos/UserDtoMapper.cs
Normal 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);
|
||||||
|
}
|
||||||
10
web/backend/Features/Features.Users/Dtos/UserDtos.cs
Normal file
10
web/backend/Features/Features.Users/Dtos/UserDtos.cs
Normal 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
|
||||||
|
);
|
||||||
@@ -1,17 +1,19 @@
|
|||||||
using Domain.Entities;
|
using Domain.Entities;
|
||||||
|
using Features.Users.Dtos;
|
||||||
using Features.Users.Repository;
|
using Features.Users.Repository;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
|
|
||||||
namespace Features.Users.Queries.GetAllUsers;
|
namespace Features.Users.Queries.GetAllUsers;
|
||||||
|
|
||||||
public class GetAllUsersHandler(IUserListRepository repository)
|
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,
|
GetAllUsersQuery request,
|
||||||
CancellationToken cancellationToken
|
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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
using Domain.Entities;
|
using Features.Users.Dtos;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
|
|
||||||
namespace Features.Users.Queries.GetAllUsers;
|
namespace Features.Users.Queries.GetAllUsers;
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
/// <param name="Limit"><c>null</c> for no limit.</param>
|
/// <param name="Limit"><c>null</c> for no limit.</param>
|
||||||
/// <param name="Offset"><c>null</c> for no offset.</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>>;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
using Features.Users.Dtos;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Features.Users.Queries.GetPublicUserProfileById;
|
||||||
|
|
||||||
|
public record GetPublicUserProfileByIdQuery(Guid UserAccountId) : IRequest<PublicUserProfileDto>;
|
||||||
@@ -8,12 +8,18 @@ namespace Features.Users.Queries.GetUserById;
|
|||||||
public class GetUserByIdHandler(IUserListRepository repository)
|
public class GetUserByIdHandler(IUserListRepository repository)
|
||||||
: IRequestHandler<GetUserByIdQuery, UserAccount>
|
: 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>
|
/// <exception cref="NotFoundException">Thrown when no user account exists with the given ID.</exception>
|
||||||
public async Task<UserAccount> Handle(
|
public async Task<UserAccount> Handle(
|
||||||
GetUserByIdQuery request,
|
GetUserByIdQuery request,
|
||||||
CancellationToken cancellationToken
|
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);
|
UserAccount? user = await repository.GetByIdAsync(request.UserAccountId);
|
||||||
if (user is null)
|
if (user is null)
|
||||||
throw new NotFoundException($"User with ID {request.UserAccountId} not found");
|
throw new NotFoundException($"User with ID {request.UserAccountId} not found");
|
||||||
|
|||||||
@@ -3,4 +3,9 @@ using MediatR;
|
|||||||
|
|
||||||
namespace Features.Users.Queries.GetUserById;
|
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>;
|
||||||
|
|||||||
@@ -33,13 +33,15 @@ public class UserListRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Always applies pagination via <c>OFFSET</c>/<c>FETCH</c>, ordered by creation date descending.
|
/// 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".
|
/// 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>
|
/// </remarks>
|
||||||
public async Task<IEnumerable<UserAccount>> GetAllAsync(int? limit, int? offset)
|
public async Task<IEnumerable<UserAccount>> GetAllAsync(int? limit, int? offset)
|
||||||
{
|
{
|
||||||
await using DbConnection connection = await CreateConnection();
|
await using DbConnection connection = await CreateConnection();
|
||||||
return await connection.QueryAsync<UserAccount>(
|
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
|
FROM Auth.UserAccount
|
||||||
ORDER BY CreatedAt DESC
|
ORDER BY CreatedAt DESC
|
||||||
OFFSET @Offset ROWS FETCH NEXT @Limit ROWS ONLY
|
OFFSET @Offset ROWS FETCH NEXT @Limit ROWS ONLY
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
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';
|
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.`;
|
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 });
|
const userButton = canvas.getByRole('button', { name: /hans/i });
|
||||||
await expect(userButton).toBeInTheDocument();
|
await expect(userButton).toBeInTheDocument();
|
||||||
await userEvent.click(userButton);
|
await userEvent.click(userButton);
|
||||||
await expect(canvas.getByRole('menuitem', { name: /dashboard/i })).toBeInTheDocument();
|
// MenuItems is anchor-positioned, which portals it outside canvasElement.
|
||||||
await expect(canvas.getByRole('menuitem', { name: /logout/i })).toBeInTheDocument();
|
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();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -72,7 +72,10 @@ const Navbar = ({ auth }: NavbarProps) => (
|
|||||||
Dashboard
|
Dashboard
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<UserMenu username={auth.username} />
|
<UserMenu
|
||||||
|
username={auth.username}
|
||||||
|
userAccountId={auth.userAccountId}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Link to="/login" className="btn btn-primary btn-sm">
|
<Link to="/login" className="btn btn-primary btn-sm">
|
||||||
|
|||||||
@@ -3,25 +3,40 @@ import { Link } from 'react-router';
|
|||||||
|
|
||||||
interface UserMenuProps {
|
interface UserMenuProps {
|
||||||
username: string;
|
username: string;
|
||||||
|
userAccountId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const UserMenu = ({ username }: UserMenuProps) => (
|
const UserMenu = ({ username, userAccountId }: UserMenuProps) => (
|
||||||
<Menu as="div" className="relative">
|
<Menu>
|
||||||
<MenuButton className="btn btn-ghost btn-sm">{username}</MenuButton>
|
<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>
|
<MenuItem>
|
||||||
{({ focus }) => (
|
<Link
|
||||||
<Link to="/dashboard" className={focus ? 'active' : ''}>
|
to={`/users/${userAccountId}`}
|
||||||
Dashboard
|
className="rounded-field block px-3 py-1.5 data-[focus]:bg-base-200"
|
||||||
|
>
|
||||||
|
My profile
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem>
|
<MenuItem>
|
||||||
{({ focus }) => (
|
<Link
|
||||||
<Link to="/logout" className={focus ? 'active' : ''}>
|
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
|
Logout
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
</MenuItems>
|
</MenuItems>
|
||||||
</Menu>
|
</Menu>
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export const loader = async ({ request }: Route.LoaderArgs) => {
|
|||||||
|
|
||||||
let details = { firstName: '', lastName: '', email: '', dateOfBirth: '' };
|
let details = { firstName: '', lastName: '', email: '', dateOfBirth: '' };
|
||||||
try {
|
try {
|
||||||
const account = await getUserAccount(auth.userAccountId);
|
const account = await getUserAccount(auth.accessToken, auth.userAccountId);
|
||||||
details = {
|
details = {
|
||||||
firstName: account.firstName,
|
firstName: account.firstName,
|
||||||
lastName: account.lastName,
|
lastName: account.lastName,
|
||||||
|
|||||||
@@ -162,8 +162,13 @@ export const refreshTokens = async (refreshToken: string) => {
|
|||||||
return data.payload;
|
return data.payload;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getUserAccount = async (userAccountId: string): Promise<UserAccountDetails> => {
|
export const getUserAccount = async (
|
||||||
const res = await fetch(`${API_BASE_URL}/api/user/${userAccountId}`);
|
accessToken: string,
|
||||||
|
userAccountId: string,
|
||||||
|
): Promise<UserAccountDetails> => {
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/user/${userAccountId}`, {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
|
|||||||
@@ -54,3 +54,14 @@ export const Liked: Story = {
|
|||||||
await expect(canvas.getByRole('button', { name: /13 likes/i })).toBeInTheDocument();
|
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',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Heart, HeartSolid } from 'iconoir-react';
|
import { Heart, HeartSolid } from 'iconoir-react';
|
||||||
|
import { Link } from 'react-router';
|
||||||
import StarRating from '../../shared/StarRating';
|
import StarRating from '../../shared/StarRating';
|
||||||
import type { FillerComment } from '../../../utils/filler-brewery-detail';
|
import type { FillerComment } from '../../../utils/filler-brewery-detail';
|
||||||
|
|
||||||
@@ -19,7 +20,16 @@ const CommentItem = ({ comment, onToggleLike }: CommentItemProps) => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex-1 flex flex-col gap-1 min-w-0">
|
<div className="flex-1 flex flex-col gap-1 min-w-0">
|
||||||
<div className="flex items-baseline gap-2 flex-wrap">
|
<div className="flex items-baseline gap-2 flex-wrap">
|
||||||
|
{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>
|
<span className="font-bold text-sm">{comment.user}</span>
|
||||||
|
)}
|
||||||
<StarRating value={comment.rating} size="xs" />
|
<StarRating value={comment.rating} size="xs" />
|
||||||
<span className="text-xs text-base-content/60">{comment.time}</span>
|
<span className="text-xs text-base-content/60">{comment.time}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -28,13 +28,14 @@ export const loader = async ({ request, params }: Route.LoaderArgs) => {
|
|||||||
brewery,
|
brewery,
|
||||||
loggedIn: auth !== null,
|
loggedIn: auth !== null,
|
||||||
username: auth?.username ?? null,
|
username: auth?.username ?? null,
|
||||||
|
userAccountId: auth?.userAccountId ?? null,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const initials = (username: string) => username.slice(0, 2).toUpperCase();
|
const initials = (username: string) => username.slice(0, 2).toUpperCase();
|
||||||
|
|
||||||
const BreweryDetail = ({ loaderData }: Route.ComponentProps) => {
|
const BreweryDetail = ({ loaderData }: Route.ComponentProps) => {
|
||||||
const { brewery, loggedIn, username } = loaderData;
|
const { brewery, loggedIn, username, userAccountId } = loaderData;
|
||||||
|
|
||||||
const [liked, setLiked] = useState(false);
|
const [liked, setLiked] = useState(false);
|
||||||
const [likeCount, setLikeCount] = useState(FILLER_BREWERY_META.likeCount);
|
const [likeCount, setLikeCount] = useState(FILLER_BREWERY_META.likeCount);
|
||||||
@@ -57,6 +58,7 @@ const BreweryDetail = ({ loaderData }: Route.ComponentProps) => {
|
|||||||
{
|
{
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
user: username ?? 'you',
|
user: username ?? 'you',
|
||||||
|
userAccountId: userAccountId ?? undefined,
|
||||||
initials: username ? initials(username) : 'YO',
|
initials: username ? initials(username) : 'YO',
|
||||||
rating: yourRating,
|
rating: yourRating,
|
||||||
time: 'just now',
|
time: 'just now',
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ export const FILLER_BEERS: FillerBeer[] = [
|
|||||||
export interface FillerComment {
|
export interface FillerComment {
|
||||||
id: string;
|
id: string;
|
||||||
user: string;
|
user: string;
|
||||||
|
userAccountId?: string;
|
||||||
initials: string;
|
initials: string;
|
||||||
rating: number;
|
rating: number;
|
||||||
time: string;
|
time: string;
|
||||||
@@ -96,6 +97,8 @@ export interface FillerComment {
|
|||||||
liked: boolean;
|
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[] = [
|
export const FILLER_COMMENTS: FillerComment[] = [
|
||||||
{
|
{
|
||||||
id: '1',
|
id: '1',
|
||||||
|
|||||||
57
web/frontend/app/features/users/USER_PROFILE_HANDOFF.md
Normal file
57
web/frontend/app/features/users/USER_PROFILE_HANDOFF.md
Normal 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.
|
||||||
@@ -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;
|
||||||
@@ -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();
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
@@ -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();
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
@@ -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();
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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">
|
||||||
|
“{item.comment}”
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-base-content/60 mt-1.5 mb-0">
|
||||||
|
{item.time}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ActivityTab;
|
||||||
@@ -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();
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
@@ -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();
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
144
web/frontend/app/features/users/routes/user-profile.tsx
Normal file
144
web/frontend/app/features/users/routes/user-profile.tsx
Normal 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} />
|
||||||
|
);
|
||||||
60
web/frontend/app/features/users/users.server.ts
Normal file
60
web/frontend/app/features/users/users.server.ts
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
101
web/frontend/app/features/users/utils/filler-user-profile.ts
Normal file
101
web/frontend/app/features/users/utils/filler-user-profile.ts
Normal 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 },
|
||||||
|
];
|
||||||
@@ -15,4 +15,5 @@ export default [
|
|||||||
route('breweries/nearby', 'features/breweries/routes/resources/breweries-nearby.ts'),
|
route('breweries/nearby', 'features/breweries/routes/resources/breweries-nearby.ts'),
|
||||||
route('breweries/:id', 'features/breweries/routes/brewery-detail.tsx'),
|
route('breweries/:id', 'features/breweries/routes/brewery-detail.tsx'),
|
||||||
route('beer-styles', 'features/catalog/routes/beer-styles.tsx'),
|
route('beer-styles', 'features/catalog/routes/beer-styles.tsx'),
|
||||||
|
route('users/:id', 'features/users/routes/user-profile.tsx'),
|
||||||
] satisfies RouteConfig;
|
] satisfies RouteConfig;
|
||||||
|
|||||||
Reference in New Issue
Block a user