Skip to content

Commit

Permalink
Enable seamless updates to user data and locale without full page rel…
Browse files Browse the repository at this point in the history
…oads (#674)

### Summary & Motivation

Introduce a more elegant solution for updating user data and UI language
without requiring a full page reload. Previously, when a user's locale
or profile data was updated via `/users/change-locale` or
`/api/account-management/users`, the SPA triggered
`/api/account-management/authentication/refresh-authentication-tokens`
followed by a `window.location.reload()` to reflect changes in the UI.
This approach was slow and cumbersome.

With this update:
- A new `AddRefreshAuthenticationTokens` extension sets an
`x-refresh-authentication-tokens-required` HTTP header, instructing the
AppGateway to refresh authentication tokens before serving the response.
This ensures the updated JWT is included in the API response.
- The `/refresh-authentication-tokens` endpoint has been moved to the
`internal-api` namespace for enhanced security and proper scoping.
- The browser now updates the UI language and user profile information
dynamically, eliminating the need for a page reload.

### Changes
- Added a new `ApiResult` extension to set an HTTP header for token
refresh.
- Updated the `/users/change-locale` and `/admin/user` endpoints to
enforce authentication token updates, removing the need for SPA reloads.
- Modified `/admin/user` to use the logged-in user's data instead of an
`{id}` parameter, ensuring proper behavior for updating user
information.
- Updated the AvatarButton logic to prevent the `UserProfileModal` from
opening twice.
- Simplified `useActionState` usage in `InviteUserModal`.
- Updated project guidelines with instructions to run builds and tests
after making changes to the backend and frontend.

### Checklist

- [x] I have added tests, or done manual regression tests
- [x] I have updated the documentation, if necessary
  • Loading branch information
tjementum authored Jan 13, 2025
2 parents 280f17d + 7b38657 commit 432c231
Show file tree
Hide file tree
Showing 14 changed files with 194 additions and 186 deletions.
32 changes: 20 additions & 12 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
When replying, list the sections from this rules file that guided your response. For example:
When replying, list *ALL* the sections from this guide that could be relevant to guide your response, and ensure to follow the guidance.For example:

Sections used:
- General Practices
- Backend Guidelines > Over all
- Backend Guidelines > API

# General Practices (always include this section)
- AVOID unrelated code changes (e.g., don’t remove comments or alter types).
- Consistency is extremely important. Before implementing new code, always look for similar code in the existing code base, and do you utmost to follow conventions for naming, structure, patterns, formatting, styling, etc.
- SCS means self-contained system.
- Use long descriptive variable names (e.g commitMessage not commit).
- Never use acronyms (e.g. SharedAccessSignatureLink not SasLink).
- Use one-line imperative, sentence-case commit messages with no trailing dot. Don't prefix with "feat," "fix," etc.
- VERY IMPORTANT:
- Avoid making changes to code that is not relevant (e.g., don’t remove comments or alter types).
- Consistency is extremely important. Always look for similar code in the existing code base before adding new code, and do you utmost to follow conventions for naming, structure, patterns, formatting, styling, etc.
- Ask questions if guidance is unclear instead of making assumptions.
- General info:
- SCS means self-contained system.
- Use long descriptive variable names (e.g commitMessage not commit).
- Never use acronyms (e.g. SharedAccessSignatureLink not SasLink).

# Project Overview
PlatformPlatform is a multi-tenant SaaS foundation monorepo with:
Expand All @@ -22,15 +24,17 @@ PlatformPlatform is a multi-tenant SaaS foundation monorepo with:
# Backend Guidelines

## Over all
+ Always use new C# 8/9 language features like top-level namespaces, primary constructors and array initializers.
+ Always use new C# 8/9 language features like top-level namespaces, primary constructors, array initializers, and `is null`/`is not null` over `== null`/`!= null`.
- Only throw exceptions for exceptional cases.
- Prefer long lines, and break at 120-140 characters.
- Use TimeProvider.System.GetUtcNow() to get current time.
- All IDs on domain entities, commands, queries, API endpoints, etc. are strongly typed IDs. E.g. `TenantId Id` instead of `long Id`.
- IMPORTANT: After making backend changes, run `dotnet build` from `/application/` and `dotnet test --no-restore --no-build` to validate changes.

## API
- Always return Response DTOs
- Always use strongly TypedIDs in contract
- Implement in Endpoint namespace in the API project for the SCS.
- Always return Response DTOs.
- Always use strongly TypedIDs in contract.
- Implement Minimal API endpoints in a single line, calling `mediator.Send()` and convert any path parameters using the ` with { Id = id }` like shown here:

```csharp
Expand Down Expand Up @@ -115,16 +119,17 @@ PlatformPlatform is a multi-tenant SaaS foundation monorepo with:
- Emphasize accessibility and type safety.
- Leverage Tailwind variants for styling.
- Global, reusable components live in Shared-Web. Change here only if it’s universally needed.
- IMPORTANT: After making frontend changes, run `npm run build` from `/application`, and if successful run `npm run format` and `npm run check`.

## React Aria Components
- Build UI using components from `@application/shared-webapp/ui/components`.
- Use `onPress` instead of `onClick`.

## API integration
- A strongly typed API Contract is generated by the .NET API in each SCS (look for @/WebApp/shared/lib/api/api.generated.d.ts)
- A strongly typed API Contract is generated by the .NET API in each SCS (look for @/WebApp/shared/lib/api/api.generated.d.ts).
- When making API calls, don't use standard fetch, but instead use `@application/shared-webapp/infrastructure/api/PlatformApiClient.ts`, that contains methods for get, post, put, delete, options, head, patch, trace.

Here is an example of how to use the API client for a GET request
Here is an example of how to use the API client for a GET request:
```typescript
await api.get("/api/account-management/users/{id}", {
params: { path: { id: userId } }
Expand All @@ -145,3 +150,6 @@ Here is an example of how to use the API client for a GET request
- Avoid adding new dependencies to the root package.json.
- If needed always Pin versions (no ^ or ~).
- Use React Aria Components before adding anything new.
# Git
- Use one-line imperative, sentence-case commit messages with no trailing dot; don't prefix with "feat," "fix," etc.
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ ILogger<AuthenticationCookieMiddleware> logger
)
: IMiddleware
{
private const string? RefreshAuthenticationTokensEndpoint = "/api/account-management/authentication/refresh-authentication-tokens";
private const string? RefreshAuthenticationTokensEndpoint = "/internal-api/account-management/authentication/refresh-authentication-tokens";

public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
Expand All @@ -26,8 +26,15 @@ public async Task InvokeAsync(HttpContext context, RequestDelegate next)

await next(context);

if (context.Response.Headers.TryGetValue(AuthenticationTokenHttpKeys.RefreshTokenHttpHeaderKey, out var refreshToken) &&
context.Response.Headers.TryGetValue(AuthenticationTokenHttpKeys.AccessTokenHttpHeaderKey, out var accessToken))
if (context.Response.Headers.TryGetValue(AuthenticationTokenHttpKeys.RefreshAuthenticationTokensHeaderKey, out _))
{
logger.LogDebug("Refreshing authentication tokens as requested by endpoint.");
var (refreshToken, accessToken) = await RefreshAuthenticationTokensAsync(refreshTokenCookieValue!);
ReplaceAuthenticationHeaderWithCookie(context, refreshToken, accessToken);
context.Response.Headers.Remove(AuthenticationTokenHttpKeys.RefreshAuthenticationTokensHeaderKey);
}
else if (context.Response.Headers.TryGetValue(AuthenticationTokenHttpKeys.RefreshTokenHttpHeaderKey, out var refreshToken) &&
context.Response.Headers.TryGetValue(AuthenticationTokenHttpKeys.AccessTokenHttpHeaderKey, out var accessToken))
{
ReplaceAuthenticationHeaderWithCookie(context, refreshToken.Single()!, accessToken.Single()!);
}
Expand All @@ -54,6 +61,8 @@ private async Task ValidateAuthenticationCookieAndConvertToHttpBearerHeader(Http
return;
}

logger.LogDebug("The access-token has expired, attempting to refresh.");

(refreshToken, accessToken) = await RefreshAuthenticationTokensAsync(refreshToken);

// Update the authentication token cookies with the new tokens
Expand All @@ -74,8 +83,6 @@ private async Task ValidateAuthenticationCookieAndConvertToHttpBearerHeader(Http

private async Task<(string newRefreshToken, string newAccessToken)> RefreshAuthenticationTokensAsync(string refreshToken)
{
logger.LogDebug("The access-token has expired, attempting to refresh...");

var request = new HttpRequestMessage(HttpMethod.Post, RefreshAuthenticationTokensEndpoint);

// Use refresh Token as Bearer when refreshing Access Token
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ public void MapEndpoints(IEndpointRouteBuilder routes)
);

// Note: This endpoint must be called with the refresh token as Bearer token in the Authorization header
group.MapPost("refresh-authentication-tokens", async Task<ApiResult> (IMediator mediator)
routes.MapPost("/internal-api/account-management/authentication/refresh-authentication-tokens", async Task<ApiResult> (IMediator mediator)
=> await mediator.Send(new RefreshAuthenticationTokensCommand())
);
}
Expand Down
19 changes: 10 additions & 9 deletions application/account-management/Api/Endpoints/UserEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ public void MapEndpoints(IEndpointRouteBuilder routes)
=> await mediator.Send(query)
).Produces<GetUsersResponse>();

group.MapGet("/summary", async Task<ApiResult<GetUserSummaryResponse>> (IMediator mediator)
=> await mediator.Send(new GetUserSummaryQuery())
).Produces<GetUserSummaryResponse>();

group.MapGet("/{id}", async Task<ApiResult<UserResponse>> ([AsParameters] GetUserQuery query, IMediator mediator)
=> await mediator.Send(query)
).Produces<UserResponse>();
Expand All @@ -26,10 +30,6 @@ public void MapEndpoints(IEndpointRouteBuilder routes)
=> (await mediator.Send(command)).AddResourceUri(RoutesPrefix)
);

group.MapPut("/{id}", async Task<ApiResult> (UserId id, UpdateUserCommand command, IMediator mediator)
=> await mediator.Send(command with { Id = id })
);

group.MapDelete("/{id}", async Task<ApiResult> (UserId id, IMediator mediator)
=> await mediator.Send(new DeleteUserCommand(id))
);
Expand All @@ -42,6 +42,11 @@ public void MapEndpoints(IEndpointRouteBuilder routes)
=> await mediator.Send(command)
);

// The following endpoints are for the current user only
group.MapPut("/", async Task<ApiResult> (UpdateUserCommand command, IMediator mediator)
=> (await mediator.Send(command)).AddRefreshAuthenticationTokens()
);

group.MapPost("/update-avatar", async Task<ApiResult> (IFormFile file, IMediator mediator)
=> await mediator.Send(new UpdateAvatarCommand(file.OpenReadStream(), file.ContentType))
).DisableAntiforgery(); // Disable anti-forgery until we implement it
Expand All @@ -51,11 +56,7 @@ public void MapEndpoints(IEndpointRouteBuilder routes)
);

group.MapPut("/change-locale", async Task<ApiResult> (ChangeLocaleCommand command, IMediator mediator)
=> await mediator.Send(command)
=> (await mediator.Send(command)).AddRefreshAuthenticationTokens()
);

group.MapGet("/summary", async Task<ApiResult<GetUserSummaryResponse>> (IMediator mediator)
=> await mediator.Send(new GetUserSummaryQuery())
).Produces<GetUserSummaryResponse>();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
using JetBrains.Annotations;
using PlatformPlatform.AccountManagement.Features.Users.Domain;
using PlatformPlatform.SharedKernel.Cqrs;
using PlatformPlatform.SharedKernel.Domain;
using PlatformPlatform.SharedKernel.Telemetry;
using PlatformPlatform.SharedKernel.Validation;

Expand All @@ -11,9 +10,6 @@ namespace PlatformPlatform.AccountManagement.Features.Users.Commands;
[PublicAPI]
public sealed record UpdateUserCommand : ICommand, IRequest<Result>
{
[JsonIgnore] // Removes this property from the API contract
public UserId Id { get; init; } = null!;

public required string Email { get; init; }

public required string FirstName { get; init; }
Expand All @@ -39,8 +35,8 @@ public sealed class UpdateUserHandler(IUserRepository userRepository, ITelemetry
{
public async Task<Result> Handle(UpdateUserCommand command, CancellationToken cancellationToken)
{
var user = await userRepository.GetByIdAsync(command.Id, cancellationToken);
if (user is null) return Result.NotFound($"User with id '{command.Id}' not found.");
var user = await userRepository.GetLoggedInUserAsync(cancellationToken);
if (user is null) return Result.BadRequest("User not found.");

user.UpdateEmail(command.Email);
user.Update(command.FirstName, command.LastName, command.Title);
Expand Down
27 changes: 2 additions & 25 deletions application/account-management/Tests/Users/UpdateUserTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
using System.Net.Http.Json;
using PlatformPlatform.AccountManagement.Database;
using PlatformPlatform.AccountManagement.Features.Users.Commands;
using PlatformPlatform.SharedKernel.Domain;
using PlatformPlatform.SharedKernel.Tests;
using PlatformPlatform.SharedKernel.Validation;
using Xunit;
Expand All @@ -15,7 +14,6 @@ public sealed class UpdateUserTests : EndpointBaseTest<AccountManagementDbContex
public async Task UpdateUser_WhenValid_ShouldUpdateUser()
{
// Arrange
var existingUserId = DatabaseSeeder.User1.Id;
var command = new UpdateUserCommand
{
Email = Faker.Internet.Email(),
Expand All @@ -25,7 +23,7 @@ public async Task UpdateUser_WhenValid_ShouldUpdateUser()
};

// Act
var response = await AuthenticatedHttpClient.PutAsJsonAsync($"/api/account-management/users/{existingUserId}", command);
var response = await AuthenticatedHttpClient.PutAsJsonAsync("/api/account-management/users", command);

// Assert
response.ShouldHaveEmptyHeaderAndLocationOnSuccess();
Expand All @@ -35,7 +33,6 @@ public async Task UpdateUser_WhenValid_ShouldUpdateUser()
public async Task UpdateUser_WhenInvalid_ShouldReturnBadRequest()
{
// Arrange
var existingUserId = DatabaseSeeder.User1.Id;
var command = new UpdateUserCommand
{
Email = Faker.InvalidEmail(),
Expand All @@ -45,7 +42,7 @@ public async Task UpdateUser_WhenInvalid_ShouldReturnBadRequest()
};

// Act
var response = await AuthenticatedHttpClient.PutAsJsonAsync($"/api/account-management/users/{existingUserId}", command);
var response = await AuthenticatedHttpClient.PutAsJsonAsync("/api/account-management/users", command);

// Assert
var expectedErrors = new[]
Expand All @@ -57,24 +54,4 @@ public async Task UpdateUser_WhenInvalid_ShouldReturnBadRequest()
};
await response.ShouldHaveErrorStatusCode(HttpStatusCode.BadRequest, expectedErrors);
}

[Fact]
public async Task UpdateUser_WhenUserDoesNotExists_ShouldReturnNotFound()
{
// Arrange
var unknownUserId = UserId.NewId();
var command = new UpdateUserCommand
{
Email = Faker.Internet.Email(),
FirstName = Faker.Name.FirstName(),
LastName = Faker.Name.LastName(),
Title = Faker.Name.JobTitle()
};

// Act
var response = await AuthenticatedHttpClient.PutAsJsonAsync($"/api/account-management/users/{unknownUserId}", command);

//Assert
await response.ShouldHaveErrorStatusCode(HttpStatusCode.NotFound, $"User with id '{unknownUserId}' not found.");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,21 +21,17 @@ export default function InviteUserModal({ isOpen, onOpenChange }: Readonly<Invit
onOpenChange(false);
}, [onOpenChange]);

let [{ success, errors, title, message }, action, isPending] = useActionState(
const [{ success, errors, title, message }, action, isPending] = useActionState(
api.actionPost("/api/account-management/users/invite"),
{ success: null }
);

useEffect(() => {
if (isPending) {
success = undefined;
}

if (success) {
closeDialog();
window.location.reload();
}
}, [success, isPending, closeDialog]);
}, [success, closeDialog]);

return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange} isDismissable={true}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,10 +72,6 @@ export default function AvatarButton() {
/>
<UserProfileModal isOpen={isProfileModalOpen} onOpenChange={setIsProfileModalOpen} userId={userInfo.id ?? ""} />
<DeleteAccountModal isOpen={isDeleteAccountModalOpen} onOpenChange={setIsDeleteAccountModalOpen} />

{userInfo?.isAuthenticated && (
<UserProfileModal isOpen={isProfileModalOpen} onOpenChange={setIsProfileModalOpen} userId={userInfo.id ?? ""} />
)}
</>
);
}
Loading

0 comments on commit 432c231

Please sign in to comment.