feat(i18n): persist Bokmal preference globally

This commit is contained in:
cesnimda
2026-08-28 12:44:02 +02:00
parent b6dcc7c760
commit 5805c1a621
21 changed files with 359 additions and 66 deletions
+30 -1
View File
@@ -96,6 +96,7 @@ public sealed class AuthController : ControllerBase
{
public string AppVersion { get; init; } = "unknown";
public string? AppCommitSha { get; init; }
public string UiLanguage { get; init; } = "en";
}
public sealed record PendingEmailChangeResult(string? PendingEmail, DateTimeOffset? RequestedAtUtc);
private const int MaxAvatarBytes = 1_000_000;
@@ -539,6 +540,21 @@ public sealed class AuthController : ControllerBase
return NoContent();
}
public sealed record UpdateLanguagePreferenceRequest(string? Language);
[HttpPut("preferences/language")]
[Authorize(AuthenticationSchemes = "local")]
public async Task<IActionResult> UpdateLanguagePreference([FromBody] UpdateLanguagePreferenceRequest request)
{
var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized();
var language = NormalizeUiLanguage(request?.Language);
if (language is null) return BadRequest("Supported languages are en and nb-NO.");
user.UiLanguage = language;
var result = await _users.UpdateAsync(user);
return result.Succeeded ? NoContent() : BadRequest(string.Join("; ", result.Errors.Select(error => error.Description)));
}
public sealed record RequestEmailChangeRequest(string Email, string CurrentPassword);
public sealed record ConfirmEmailChangeRequest(string UserId, string Email, string Token);
public sealed record CancelEmailChangeRequest(string CurrentPassword);
@@ -1200,7 +1216,20 @@ public sealed class AuthController : ControllerBase
MicrosoftLink: new MicrosoftLinkDto(
Linked: !string.IsNullOrWhiteSpace(user.MicrosoftTenantId) && !string.IsNullOrWhiteSpace(user.MicrosoftObjectId),
Email: user.MicrosoftEmail,
LinkedAt: user.MicrosoftLinkedAt));
LinkedAt: user.MicrosoftLinkedAt))
{
UiLanguage = NormalizeUiLanguage(user.UiLanguage) ?? "en",
};
}
private static string? NormalizeUiLanguage(string? language)
{
var value = language?.Trim();
if (string.Equals(value, "en", StringComparison.OrdinalIgnoreCase)) return "en";
if (string.Equals(value, "nb", StringComparison.OrdinalIgnoreCase)
|| string.Equals(value, "nb-NO", StringComparison.OrdinalIgnoreCase)
|| string.Equals(value, "no", StringComparison.OrdinalIgnoreCase)) return "nb-NO";
return null;
}
private MeResult WithBuildMetadata(MeResult result, bool include)
+4
View File
@@ -92,6 +92,10 @@ namespace JobTrackerApi.Data
.Property(x => x.EmailFollowUpRemindersEnabled)
.HasDefaultValue(true);
modelBuilder.Entity<ApplicationUser>()
.Property(x => x.UiLanguage)
.HasMaxLength(16);
modelBuilder.Entity<AccountDeletionRequest>().Property(x => x.OwnerUserId).HasMaxLength(255);
modelBuilder.Entity<AccountDeletionRequest>().Property(x => x.OwnerKey).HasMaxLength(64);
modelBuilder.Entity<AccountDeletionRequest>().Property(x => x.RequestedByUserId).HasMaxLength(255);
@@ -0,0 +1,27 @@
using JobTrackerApi.Data;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace JobTrackerApi.Migrations;
[DbContext(typeof(JobTrackerContext))]
[Migration("20260828090000_AddUiLanguagePreference")]
public partial class AddUiLanguagePreference : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "UiLanguage",
table: "AspNetUsers",
type: ActiveProvider.Contains("MySql", StringComparison.OrdinalIgnoreCase) ? "varchar(16)" : "TEXT",
maxLength: 16,
nullable: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(name: "UiLanguage", table: "AspNetUsers");
}
}
@@ -481,6 +481,10 @@ namespace JobTrackerApi.Migrations
b.Property<string>("StripeSubscriptionStatus")
.HasColumnType("TEXT");
b.Property<string>("UiLanguage")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("TotpEnabledAtUtc")
.HasColumnType("TEXT");
+2
View File
@@ -33,6 +33,8 @@ public sealed class ApplicationUser : IdentityUser
public bool AiEnabled { get; set; } = true;
public bool ExternalAiProcessingAllowed { get; set; }
public bool EmailFollowUpRemindersEnabled { get; set; } = true;
// BCP 47 application UI preference. Null/legacy values safely fall back to English.
public string? UiLanguage { get; set; }
public string DeletionStatus { get; set; } = AccountDeletionStatuses.Active;
public DateTimeOffset? DeletionRequestedAtUtc { get; set; }
}
@@ -533,6 +533,7 @@ public static class StartupInitializationExtensions
EnsureColumn(conn, "AspNetUsers", "StripeSubscriptionId", "ALTER TABLE AspNetUsers ADD COLUMN StripeSubscriptionId TEXT NULL;");
EnsureColumn(conn, "AspNetUsers", "StripeSubscriptionStatus", "ALTER TABLE AspNetUsers ADD COLUMN StripeSubscriptionStatus TEXT NULL;");
EnsureColumn(conn, "AspNetUsers", "StripeLastEventCreatedUtc", "ALTER TABLE AspNetUsers ADD COLUMN StripeLastEventCreatedUtc TEXT NULL;");
EnsureColumn(conn, "AspNetUsers", "UiLanguage", "ALTER TABLE AspNetUsers ADD COLUMN UiLanguage TEXT NULL;");
static void EnsureUserRuleSettingsTable(DbConnection c)
{
@@ -1435,6 +1436,7 @@ public static class StartupInitializationExtensions
EnsureMySqlColumn(conn, "AspNetUsers", "StripeSubscriptionId", "ALTER TABLE `AspNetUsers` ADD COLUMN `StripeSubscriptionId` varchar(255) NULL;");
EnsureMySqlColumn(conn, "AspNetUsers", "StripeSubscriptionStatus", "ALTER TABLE `AspNetUsers` ADD COLUMN `StripeSubscriptionStatus` varchar(64) NULL;");
EnsureMySqlColumn(conn, "AspNetUsers", "StripeLastEventCreatedUtc", "ALTER TABLE `AspNetUsers` ADD COLUMN `StripeLastEventCreatedUtc` datetime(6) NULL;");
EnsureMySqlColumn(conn, "AspNetUsers", "UiLanguage", "ALTER TABLE `AspNetUsers` ADD COLUMN `UiLanguage` varchar(16) NULL;");
// RuleSettings is MIGRATION-owned — the initial migration creates it. The reconciler
// used to create it too, which made a clean install fail with "Table 'RuleSettings'