diff --git a/application/account-management/Core/Features/Signups/Commands/CompleteSignup.cs b/application/account-management/Core/Features/Signups/Commands/CompleteSignup.cs index 80368f600..df73538bb 100644 --- a/application/account-management/Core/Features/Signups/Commands/CompleteSignup.cs +++ b/application/account-management/Core/Features/Signups/Commands/CompleteSignup.cs @@ -15,6 +15,8 @@ public sealed record CompleteSignupCommand(string OneTimePassword) : ICommand, I { [JsonIgnore] // Removes this property from the API contract public SignupId Id { get; init; } = null!; + + public string? PreferredLocale { get; init; } } public sealed class CompleteSignupHandler( @@ -65,7 +67,10 @@ public async Task<Result> Handle(CompleteSignupCommand command, CancellationToke return Result.BadRequest("The code is no longer valid, please request a new code.", true); } - var result = await mediator.Send(new CreateTenantCommand(signup.TenantId, signup.Email, true), cancellationToken); + var result = await mediator.Send( + new CreateTenantCommand(signup.TenantId, signup.Email, true, command.PreferredLocale), + cancellationToken + ); var user = await userRepository.GetByIdAsync(result.Value!, cancellationToken); authenticationTokenService.CreateAndSetAuthenticationTokens(user!.Adapt<UserInfo>()); diff --git a/application/account-management/Core/Features/Tenants/Commands/CreateTenant.cs b/application/account-management/Core/Features/Tenants/Commands/CreateTenant.cs index 5cd8e8986..a55dec937 100644 --- a/application/account-management/Core/Features/Tenants/Commands/CreateTenant.cs +++ b/application/account-management/Core/Features/Tenants/Commands/CreateTenant.cs @@ -9,7 +9,7 @@ namespace PlatformPlatform.AccountManagement.Features.Tenants.Commands; [PublicAPI] -public sealed record CreateTenantCommand(TenantId Id, string OwnerEmail, bool EmailConfirmed) +public sealed record CreateTenantCommand(TenantId Id, string OwnerEmail, bool EmailConfirmed, string? Locale) : ICommand, IRequest<Result<UserId>>; public sealed class CreateTenantHandler(ITenantRepository tenantRepository, IMediator mediator, ITelemetryEventsCollector events) @@ -23,8 +23,8 @@ public async Task<Result<UserId>> Handle(CreateTenantCommand command, Cancellati events.CollectEvent(new TenantCreated(tenant.Id, tenant.State)); var result = await mediator.Send( - new CreateUserCommand(tenant.Id, command.OwnerEmail, UserRole.Owner, command.EmailConfirmed) - , cancellationToken + new CreateUserCommand(tenant.Id, command.OwnerEmail, UserRole.Owner, command.EmailConfirmed, command.Locale), + cancellationToken ); return result.Value!; diff --git a/application/account-management/Core/Features/Users/Commands/CreateUser.cs b/application/account-management/Core/Features/Users/Commands/CreateUser.cs index 4e71354ee..4069adbdd 100644 --- a/application/account-management/Core/Features/Users/Commands/CreateUser.cs +++ b/application/account-management/Core/Features/Users/Commands/CreateUser.cs @@ -7,13 +7,20 @@ using PlatformPlatform.SharedKernel.Cqrs; using PlatformPlatform.SharedKernel.Domain; using PlatformPlatform.SharedKernel.ExecutionContext; +using PlatformPlatform.SharedKernel.SinglePageApp; using PlatformPlatform.SharedKernel.Telemetry; using PlatformPlatform.SharedKernel.Validation; namespace PlatformPlatform.AccountManagement.Features.Users.Commands; [PublicAPI] -public sealed record CreateUserCommand(TenantId TenantId, string Email, UserRole UserRole, bool EmailConfirmed) +public sealed record CreateUserCommand( + TenantId TenantId, + string Email, + UserRole UserRole, + bool EmailConfirmed, + string? PreferredLocale +) : ICommand, IRequest<Result<UserId>>; public sealed class CreateUserValidator : AbstractValidator<CreateUserCommand> @@ -50,7 +57,10 @@ public async Task<Result<UserId>> Handle(CreateUserCommand command, Cancellation throw new UnreachableException("Only when signing up a new tenant, is the TenantID allowed to different than the current tenant."); } - var user = User.Create(command.TenantId, command.Email, command.UserRole, command.EmailConfirmed); + var locale = SinglePageAppConfiguration.SupportedLocalizations.Contains(command.PreferredLocale) + ? command.PreferredLocale + : string.Empty; + var user = User.Create(command.TenantId, command.Email, command.UserRole, command.EmailConfirmed, locale); await userRepository.AddAsync(user, cancellationToken); var gravatar = await gravatarClient.GetGravatar(user.Id, user.Email, cancellationToken); diff --git a/application/account-management/Core/Features/Users/Commands/InviteUser.cs b/application/account-management/Core/Features/Users/Commands/InviteUser.cs index ad86ec396..002c19889 100644 --- a/application/account-management/Core/Features/Users/Commands/InviteUser.cs +++ b/application/account-management/Core/Features/Users/Commands/InviteUser.cs @@ -41,7 +41,10 @@ public async Task<Result> Handle(InviteUserCommand command, CancellationToken ca return Result.Forbidden("Only owners are allowed to invite other users."); } - var result = await mediator.Send(new CreateUserCommand(executionContext.TenantId!, command.Email, UserRole.Member, false), cancellationToken); + var result = await mediator.Send( + new CreateUserCommand(executionContext.TenantId!, command.Email, UserRole.Member, false, null), + cancellationToken + ); events.CollectEvent(new UserInvited(result.Value!)); diff --git a/application/account-management/Core/Features/Users/Domain/User.cs b/application/account-management/Core/Features/Users/Domain/User.cs index 50609fbc1..7f3db3035 100644 --- a/application/account-management/Core/Features/Users/Domain/User.cs +++ b/application/account-management/Core/Features/Users/Domain/User.cs @@ -6,13 +6,14 @@ public sealed class User : AggregateRoot<UserId>, ITenantScopedEntity { private string _email = string.Empty; - private User(TenantId tenantId, string email, UserRole role, bool emailConfirmed) + private User(TenantId tenantId, string email, UserRole role, bool emailConfirmed, string? locale) : base(UserId.NewId()) { Email = email; TenantId = tenantId; Role = role; EmailConfirmed = emailConfirmed; + Locale = locale ?? string.Empty; Avatar = new Avatar(); } @@ -34,13 +35,13 @@ public string Email public Avatar Avatar { get; private set; } - public string Locale { get; private set; } = string.Empty; + public string Locale { get; private set; } public TenantId TenantId { get; } - public static User Create(TenantId tenantId, string email, UserRole role, bool emailConfirmed) + public static User Create(TenantId tenantId, string email, UserRole role, bool emailConfirmed, string? locale) { - return new User(tenantId, email, role, emailConfirmed); + return new User(tenantId, email, role, emailConfirmed, locale); } public void Update(string firstName, string lastName, string title) diff --git a/application/account-management/Tests/DatabaseSeeder.cs b/application/account-management/Tests/DatabaseSeeder.cs index 86f688c7c..372e65aed 100644 --- a/application/account-management/Tests/DatabaseSeeder.cs +++ b/application/account-management/Tests/DatabaseSeeder.cs @@ -14,7 +14,7 @@ public DatabaseSeeder(AccountManagementDbContext accountManagementDbContext) { Tenant1 = Tenant.Create(new TenantId("tenant-1"), "owner@tenant-1.com"); accountManagementDbContext.Tenants.AddRange(Tenant1); - User1 = User.Create(Tenant1.Id, "owner@tenant-1.com", UserRole.Owner, true); + User1 = User.Create(Tenant1.Id, "owner@tenant-1.com", UserRole.Owner, true, null); accountManagementDbContext.Users.AddRange(User1); accountManagementDbContext.SaveChanges(); diff --git a/application/account-management/Tests/Users/CreateUserTests.cs b/application/account-management/Tests/Users/CreateUserTests.cs index 7bab1e600..a4c9d242f 100644 --- a/application/account-management/Tests/Users/CreateUserTests.cs +++ b/application/account-management/Tests/Users/CreateUserTests.cs @@ -18,7 +18,7 @@ public async Task CreateUser_WhenValid_ShouldCreateUser() { // Arrange var existingTenantId = DatabaseSeeder.Tenant1.Id; - var command = new CreateUserCommand(existingTenantId, Faker.Internet.Email(), UserRole.Member, false); + var command = new CreateUserCommand(existingTenantId, Faker.Internet.Email(), UserRole.Member, false, null); // Act var response = await AuthenticatedHttpClient.PostAsJsonAsync("/api/account-management/users", command); @@ -34,7 +34,7 @@ public async Task CreateUser_WhenInvalidEmail_ShouldReturnBadRequest() // Arrange var existingTenantId = DatabaseSeeder.Tenant1.Id; var invalidEmail = Faker.InvalidEmail(); - var command = new CreateUserCommand(existingTenantId, invalidEmail, UserRole.Member, false); + var command = new CreateUserCommand(existingTenantId, invalidEmail, UserRole.Member, false, null); // Act var response = await AuthenticatedHttpClient.PostAsJsonAsync("/api/account-management/users", command); @@ -53,7 +53,7 @@ public async Task CreateUser_WhenUserExists_ShouldReturnBadRequest() // Arrange var existingTenantId = DatabaseSeeder.Tenant1.Id; var existingUserEmail = DatabaseSeeder.User1.Email; - var command = new CreateUserCommand(existingTenantId, existingUserEmail, UserRole.Member, false); + var command = new CreateUserCommand(existingTenantId, existingUserEmail, UserRole.Member, false, null); // Act var response = await AuthenticatedHttpClient.PostAsJsonAsync("/api/account-management/users", command); @@ -71,7 +71,7 @@ public async Task CreateUser_WhenTenantDoesNotExists_ShouldReturnBadRequest() { // Arrange var unknownTenantId = new TenantId(Faker.Subdomain()); - var command = new CreateUserCommand(unknownTenantId, Faker.Internet.Email(), UserRole.Member, false); + var command = new CreateUserCommand(unknownTenantId, Faker.Internet.Email(), UserRole.Member, false, null); // Act var response = await AuthenticatedHttpClient.PostAsJsonAsync("/api/account-management/users", command); diff --git a/application/account-management/WebApp/routes/__root.tsx b/application/account-management/WebApp/routes/__root.tsx index cc9766bb8..1300159e2 100644 --- a/application/account-management/WebApp/routes/__root.tsx +++ b/application/account-management/WebApp/routes/__root.tsx @@ -4,6 +4,7 @@ import { NotFound } from "@repo/infrastructure/errorComponents/NotFoundPage"; import { AuthenticationProvider } from "@repo/infrastructure/auth/AuthenticationProvider"; import { ReactAriaRouterProvider } from "@repo/infrastructure/router/ReactAriaRouterProvider"; import { ThemeModeProvider } from "@repo/ui/theme/mode/ThemeMode"; +import { useInitializeLocale } from "@repo/infrastructure/translations/useInitializeLocale"; export const Route = createRootRoute({ component: Root, @@ -13,6 +14,7 @@ export const Route = createRootRoute({ function Root() { const navigate = useNavigate(); + useInitializeLocale(); return ( <ThemeModeProvider> diff --git a/application/account-management/WebApp/routes/signup/verify.tsx b/application/account-management/WebApp/routes/signup/verify.tsx index 27683433c..d045b595b 100644 --- a/application/account-management/WebApp/routes/signup/verify.tsx +++ b/application/account-management/WebApp/routes/signup/verify.tsx @@ -15,6 +15,7 @@ import { getSignupState, setSignupState } from "./-shared/signupState"; import { api } from "@/shared/lib/api/client"; import { FormErrorMessage } from "@repo/ui/components/FormErrorMessage"; import { loggedInPath, signedUpPath } from "@repo/infrastructure/auth/constants"; +import { preferredLocaleKey } from "@repo/infrastructure/translations/constants"; import { useActionState, useEffect } from "react"; import { useIsAuthenticated } from "@repo/infrastructure/auth/hooks"; @@ -80,6 +81,7 @@ export function CompleteSignupForm() { <div className="w-full max-w-sm space-y-3"> <Form action={action} validationErrors={errors} validationBehavior="aria"> <input type="hidden" name="id" value={signupId} /> + <input type="hidden" name="preferredLocale" value={localStorage.getItem(preferredLocaleKey) ?? ""} /> <div className="flex w-full flex-col gap-4 rounded-lg px-6 pt-8 pb-4"> <div className="flex justify-center"> <Link href="/"> diff --git a/application/account-management/WebApp/shared/lib/api/AccountManagement.Api.json b/application/account-management/WebApp/shared/lib/api/AccountManagement.Api.json index 7b195c4cb..6d9b488db 100644 --- a/application/account-management/WebApp/shared/lib/api/AccountManagement.Api.json +++ b/application/account-management/WebApp/shared/lib/api/AccountManagement.Api.json @@ -834,6 +834,10 @@ "properties": { "oneTimePassword": { "type": "string" + }, + "preferredLocale": { + "type": "string", + "nullable": true } } }, @@ -1129,6 +1133,10 @@ }, "emailConfirmed": { "type": "boolean" + }, + "preferredLocale": { + "type": "string", + "nullable": true } } }, diff --git a/application/back-office/WebApp/routes/__root.tsx b/application/back-office/WebApp/routes/__root.tsx index a457e1031..1300159e2 100644 --- a/application/back-office/WebApp/routes/__root.tsx +++ b/application/back-office/WebApp/routes/__root.tsx @@ -4,6 +4,7 @@ import { NotFound } from "@repo/infrastructure/errorComponents/NotFoundPage"; import { AuthenticationProvider } from "@repo/infrastructure/auth/AuthenticationProvider"; import { ReactAriaRouterProvider } from "@repo/infrastructure/router/ReactAriaRouterProvider"; import { ThemeModeProvider } from "@repo/ui/theme/mode/ThemeMode"; +import { useInitializeLocale } from "@repo/infrastructure/translations/useInitializeLocale"; export const Route = createRootRoute({ component: Root, @@ -13,6 +14,8 @@ export const Route = createRootRoute({ function Root() { const navigate = useNavigate(); + useInitializeLocale(); + return ( <ThemeModeProvider> <ReactAriaRouterProvider> diff --git a/application/shared-webapp/infrastructure/translations/LocaleSwitcher.tsx b/application/shared-webapp/infrastructure/translations/LocaleSwitcher.tsx index a66ac327a..f9c010893 100644 --- a/application/shared-webapp/infrastructure/translations/LocaleSwitcher.tsx +++ b/application/shared-webapp/infrastructure/translations/LocaleSwitcher.tsx @@ -8,6 +8,7 @@ import { Popover } from "@repo/ui/components/Popover"; import { DialogTrigger } from "@repo/ui/components/Dialog"; import type { Selection } from "react-aria-components"; import { AuthenticationContext } from "@repo/infrastructure/auth/AuthenticationProvider"; +import { preferredLocaleKey } from "./constants"; export function LocaleSwitcher() { const [isOpen, setIsOpen] = useState(false); @@ -35,10 +36,12 @@ export function LocaleSwitcher() { }) .then(async (_) => { await setLocale(newLocale); + localStorage.setItem(preferredLocaleKey, newLocale); }) .catch((error) => console.error("Failed to update locale:", error)); } else { await setLocale(newLocale); + localStorage.setItem(preferredLocaleKey, newLocale); } setIsOpen(false); diff --git a/application/shared-webapp/infrastructure/translations/constants.ts b/application/shared-webapp/infrastructure/translations/constants.ts new file mode 100644 index 000000000..eefee14bb --- /dev/null +++ b/application/shared-webapp/infrastructure/translations/constants.ts @@ -0,0 +1 @@ +export const preferredLocaleKey = "preferred-locale"; diff --git a/application/shared-webapp/infrastructure/translations/useInitializeLocale.ts b/application/shared-webapp/infrastructure/translations/useInitializeLocale.ts new file mode 100644 index 000000000..4239576a6 --- /dev/null +++ b/application/shared-webapp/infrastructure/translations/useInitializeLocale.ts @@ -0,0 +1,22 @@ +import { useEffect, useContext } from "react"; +import { preferredLocaleKey } from "./constants"; +import { AuthenticationContext } from "../auth/AuthenticationProvider"; +import { translationContext } from "./TranslationContext"; +import type { Locale } from "./Translation"; + +export function useInitializeLocale() { + const { userInfo } = useContext(AuthenticationContext); + const { setLocale } = useContext(translationContext); + + useEffect(() => { + if (userInfo?.isAuthenticated) { + localStorage.setItem(preferredLocaleKey, document.documentElement.lang); + } else { + const storedLocale = localStorage.getItem(preferredLocaleKey) as Locale; + if (storedLocale) { + document.documentElement.lang = storedLocale; + setLocale(storedLocale); + } + } + }, [userInfo?.isAuthenticated, setLocale]); +}