From c6cf659ea4625133aedd1a2594edaa16106a370f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 20:53:46 +0000 Subject: [PATCH 1/9] Fix wrong current user in FW Lite comments for cross-server projects Opening a project from the home page could show the wrong current user in the comments UI: Edit buttons on other people's comments, hidden on your own, and new comments authored as the wrong user. Root cause: ProjectData.LastUserId was written by two flows with different scoping. The home page enumerates every logged-in server and, matching local projects by GUID alone, stamped each server's user onto them - so a project downloaded from one server got clobbered with another server's user when both list the same GUID (shared history). That also authored CRDT commits as the wrong user until the next sync. - Scope the write to the project's origin server in CombinedProjectsService. - Resolve the origin user from the auth cache at project open, before the UI reads identity, so it is right from the first render (falls back to the persisted value when offline/signed out). - Rename LastUser* -> OriginUser* to reflect the single origin-user meaning and single write path; DB columns keep their names, so no migration. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015ZEJ549cwaz1uHfq7EKyPu --- .../Projects/CombinedProjectsServiceTests.cs | 27 ++++++++++++++++ .../Projects/CombinedProjectsService.cs | 11 +++++-- .../Services/ProjectServicesProvider.cs | 16 ++++++++++ .../FwLite/FwLiteShared/Sync/SyncService.cs | 4 +-- .../CurrentProjectServiceTests.cs | 31 +++++++++++++++++++ ...elSnapshotTests.VerifyDbModel.verified.txt | 8 +++-- .../DateTimeOffsetOrmParityTests.cs | 2 +- .../MiniLcmTests/CommentTests.cs | 16 +++++----- .../MiniLcmTests/CustomViewTests.cs | 2 +- backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs | 13 ++++---- backend/FwLite/LcmCrdt/CrdtProject.cs | 4 ++- backend/FwLite/LcmCrdt/CrdtProjectsService.cs | 4 +-- .../FwLite/LcmCrdt/CurrentProjectService.cs | 8 ++--- backend/FwLite/LcmCrdt/LcmCrdtDbContext.cs | 3 ++ .../generated-types/LcmCrdt/IProjectData.ts | 4 +-- .../src/lib/entry-editor/CommentDialog.svelte | 2 +- 16 files changed, 121 insertions(+), 34 deletions(-) create mode 100644 backend/FwLite/FwLiteShared.Tests/Projects/CombinedProjectsServiceTests.cs create mode 100644 backend/FwLite/LcmCrdt.Tests/CurrentProjectServiceTests.cs diff --git a/backend/FwLite/FwLiteShared.Tests/Projects/CombinedProjectsServiceTests.cs b/backend/FwLite/FwLiteShared.Tests/Projects/CombinedProjectsServiceTests.cs new file mode 100644 index 0000000000..54312f17d9 --- /dev/null +++ b/backend/FwLite/FwLiteShared.Tests/Projects/CombinedProjectsServiceTests.cs @@ -0,0 +1,27 @@ +using FwLiteShared.Auth; +using FwLiteShared.Projects; +using LcmCrdt; + +namespace FwLiteShared.Tests.Projects; + +public class CombinedProjectsServiceTests +{ + private static readonly LexboxServer Staging = new(new Uri("https://staging.languagedepot.org"), "Staging"); + private static readonly LexboxServer Dev = new(new Uri("https://lexbox.dev.languagetechnology.org"), "Dev"); + + private static ProjectData ProjectFrom(LexboxServer origin) => + new("Sena 3", "sena-3", Guid.NewGuid(), ProjectData.GetOriginDomain(origin.Authority), Guid.NewGuid()); + + [Fact] + public void ServerOwnsProject_TrueForOriginServer() + { + CombinedProjectsService.ServerOwnsProject(ProjectFrom(Staging), Staging).Should().BeTrue(); + } + + [Fact] + public void ServerOwnsProject_FalseForOtherServerSharingTheGuid() + { + // A project downloaded from staging must not be claimed by dev when both list the same GUID. + CombinedProjectsService.ServerOwnsProject(ProjectFrom(Staging), Dev).Should().BeFalse(); + } +} diff --git a/backend/FwLite/FwLiteShared/Projects/CombinedProjectsService.cs b/backend/FwLite/FwLiteShared/Projects/CombinedProjectsService.cs index 1815db8b8e..0ba4514f08 100644 --- a/backend/FwLite/FwLiteShared/Projects/CombinedProjectsService.cs +++ b/backend/FwLite/FwLiteShared/Projects/CombinedProjectsService.cs @@ -67,7 +67,7 @@ private async Task ServerProjects(LexboxServer server, bool forc lexboxProjectService.InvalidateProjectsCache(server); var lexboxProjects = await lexboxProjectService.GetLexboxProjects(server); var user = await lexboxProjectService.GetLexboxUser(server); - await UpdateProjectServerInfo(lexboxProjects.Projects, user); + await UpdateProjectServerInfo(server, lexboxProjects.Projects, user); var projectModels = lexboxProjects.Projects.Select(p => new ProjectModel( p.Name, p.Code, @@ -81,16 +81,21 @@ private async Task ServerProjects(LexboxServer server, bool forc return new(server, projectModels, lexboxProjects.CanDownloadByCode); } - private async Task UpdateProjectServerInfo(FieldWorksLiteProject[] lexboxProjects, LexboxUser? lexboxUser) + private async Task UpdateProjectServerInfo(LexboxServer server, FieldWorksLiteProject[] lexboxProjects, LexboxUser? lexboxUser) { foreach (var serverProject in lexboxProjects) { var localProject = crdtProjectsService.GetProject(serverProject.Id); - if (localProject?.Data is null) continue; + if (localProject?.Data is null || !ServerOwnsProject(localProject.Data, server)) continue; await crdtProjectsService.UpdateProjectServerInfo(localProject, lexboxUser?.Name, lexboxUser?.Id, ToRole(serverProject.Role)); } } + // A project's user/role belong to its origin server. The same GUID can live on more than one server + // (shared history) and GetProject(Guid) matches on GUID alone, so a non-origin server must not stamp its + // user here — that mislabels the current user and, until the next sync, the CRDT commit author. + internal static bool ServerOwnsProject(ProjectData localProjectData, LexboxServer server) => + localProjectData.ServerId == server.Id; [JSInvokable] public async Task ServerProjects(string serverId, bool forceRefresh) diff --git a/backend/FwLite/FwLiteShared/Services/ProjectServicesProvider.cs b/backend/FwLite/FwLiteShared/Services/ProjectServicesProvider.cs index ef8b52806f..7929c5572a 100644 --- a/backend/FwLite/FwLiteShared/Services/ProjectServicesProvider.cs +++ b/backend/FwLite/FwLiteShared/Services/ProjectServicesProvider.cs @@ -17,6 +17,7 @@ public class ProjectServicesProvider( CrdtProjectsService crdtProjectsService, IServiceProvider serviceProvider, LexboxProjectService lexboxProjectService, + OAuthClientFactory oAuthClientFactory, IEnumerable projectProviders ): IAsyncDisposable { @@ -60,6 +61,7 @@ public Task OpenCrdtProject(string code) var server = lexboxProjectService.GetServer(project.Data); var currentProjectService = scopedServices.GetRequiredService(); var projectData = await currentProjectService.SetupProjectContext(project); + projectData = await ResolveOriginUser(server, currentProjectService, projectData); scopedServices.GetRequiredService().TriggerSync(project); var miniLcm = ActivatorUtilities.CreateInstance(scopedServices, project); scope = ProjectScope.Create(serviceScope, this, projectData.Name, miniLcm); @@ -82,6 +84,20 @@ public Task OpenCrdtProject(string code) }); } + // Persist the origin server's current user before the UI reads identity off the project context, so it's + // right from the first render instead of racing the background sync. GetCurrentUser only reads the local + // MSAL cache (no network). Null means offline/signed-out: keep the persisted last-known-good value. + private async Task ResolveOriginUser(LexboxServer? server, + CurrentProjectService currentProjectService, + ProjectData projectData) + { + if (server is null) return projectData; + var currentUser = await oAuthClientFactory.GetClient(server).GetCurrentUser(); + if (currentUser is null) return projectData; + await currentProjectService.UpdateOriginUser(currentUser.Name, currentUser.Id); + return await currentProjectService.GetProjectData(); + } + [JSInvokable] public Task OpenFwDataProject(string projectName) { diff --git a/backend/FwLite/FwLiteShared/Sync/SyncService.cs b/backend/FwLite/FwLiteShared/Sync/SyncService.cs index e02da191a7..e79ec5bd62 100644 --- a/backend/FwLite/FwLiteShared/Sync/SyncService.cs +++ b/backend/FwLite/FwLiteShared/Sync/SyncService.cs @@ -86,7 +86,7 @@ public async Task ExecuteSync(bool skipNotifications = false) try { var currentUser = await oAuthClient.GetCurrentUser(); - await currentProjectService.UpdateLastUser(currentUser?.Name, currentUser?.Id); + await currentProjectService.UpdateOriginUser(currentUser?.Name, currentUser?.Id); var remoteModel = await remoteSyncServiceServer.CreateProjectSyncable(project, httpClient); if (!await remoteModel.ShouldSync()) @@ -107,7 +107,7 @@ public async Task ExecuteSync(bool skipNotifications = false) } logger.LogInformation("Synced project {ProjectName} with server", project.Name); UpdateSyncStatus(SyncStatus.Success); - await ApplySyncedCommentReadStatus(syncResults, project.LastUserId); + await ApplySyncedCommentReadStatus(syncResults, project.OriginUserId); await syncRepository.UpdateSyncDate(syncDate); // Best-effort: if the push listener failed to start at project-open (e.g. user was offline), this // restarts it now that we know auth + network are healthy. If it's already running, the cache diff --git a/backend/FwLite/LcmCrdt.Tests/CurrentProjectServiceTests.cs b/backend/FwLite/LcmCrdt.Tests/CurrentProjectServiceTests.cs new file mode 100644 index 0000000000..9e4f76914b --- /dev/null +++ b/backend/FwLite/LcmCrdt.Tests/CurrentProjectServiceTests.cs @@ -0,0 +1,31 @@ +namespace LcmCrdt.Tests; + +public class CurrentProjectServiceTests(MiniLcmApiFixture fixture) : IClassFixture +{ + private CurrentProjectService ProjectService => fixture.GetService(); + + [Fact] + public async Task UpdateOriginUser_WithNullUser_KeepsPersistedValue() + { + // Open-time resolution falls back to this persisted value when auth is null, so a null update mustn't wipe it. + await ProjectService.UpdateOriginUser("Tim Haasdyk", "tim-id"); + + await ProjectService.UpdateOriginUser(null, null); + + var projectData = await ProjectService.GetProjectData(); + projectData.OriginUserName.Should().Be("Tim Haasdyk"); + projectData.OriginUserId.Should().Be("tim-id"); + } + + [Fact] + public async Task UpdateOriginUser_ReplacesPreviousUser() + { + await ProjectService.UpdateOriginUser("first", "first-id"); + + await ProjectService.UpdateOriginUser("second", "second-id"); + + var projectData = await ProjectService.GetProjectData(); + projectData.OriginUserName.Should().Be("second"); + projectData.OriginUserId.Should().Be("second-id"); + } +} diff --git a/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyDbModel.verified.txt b/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyDbModel.verified.txt index 20fd028c36..17a23e768a 100644 --- a/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyDbModel.verified.txt +++ b/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyDbModel.verified.txt @@ -38,10 +38,14 @@ ClientId (Guid) Required Code (string) Required FwProjectId (Guid?) - LastUserId (string) - LastUserName (string) Name (string) Required OriginDomain (string) + OriginUserId (string) + Annotations: + Relational:ColumnName: LastUserId + OriginUserName (string) + Annotations: + Relational:ColumnName: LastUserName Role (UserProjectRole) Required ValueGenerated.OnAdd Annotations: Relational:DefaultValue: Editor diff --git a/backend/FwLite/LcmCrdt.Tests/DateTimeOffsetOrmParityTests.cs b/backend/FwLite/LcmCrdt.Tests/DateTimeOffsetOrmParityTests.cs index c03f0bb46e..1c0aba545d 100644 --- a/backend/FwLite/LcmCrdt.Tests/DateTimeOffsetOrmParityTests.cs +++ b/backend/FwLite/LcmCrdt.Tests/DateTimeOffsetOrmParityTests.cs @@ -66,7 +66,7 @@ public async Task CommentTimestamp_SameUtcInstant_ThroughEfCoreAndLinq2db() // One plain column is representative: every DateTimeOffset column shares the same global converter. // Set an explicit non-UTC-offset instant so we also verify the write records the value (the API // return is already round-tripped through the DB) and that it's normalized to UTC. - await _fixture.GetService().UpdateLastUser("tester", Guid.NewGuid().ToString()); + await _fixture.GetService().UpdateOriginUser("tester", Guid.NewGuid().ToString()); var created = new DateTimeOffset(2024, 3, 15, 10, 30, 0, TimeSpan.FromHours(5)); var thread = await _fixture.Api.CreateCommentThread( diff --git a/backend/FwLite/LcmCrdt.Tests/MiniLcmTests/CommentTests.cs b/backend/FwLite/LcmCrdt.Tests/MiniLcmTests/CommentTests.cs index 963dc74176..06c05b8ddb 100644 --- a/backend/FwLite/LcmCrdt.Tests/MiniLcmTests/CommentTests.cs +++ b/backend/FwLite/LcmCrdt.Tests/MiniLcmTests/CommentTests.cs @@ -9,7 +9,7 @@ public class CommentTests(MiniLcmApiFixture fixture) : IClassFixture(); - await projectService.UpdateLastUser(name ?? userId, userId); + await projectService.UpdateOriginUser(name ?? userId, userId); await projectService.UpdateUserRole(UserProjectRole.Editor); } @@ -24,11 +24,11 @@ private Task ClearUnreadComments() return fixture.Api.MarkAllCommentsRead(); } - private async Task ClearLastUserId() + private async Task ClearOriginUser() { await fixture.DbContext.ProjectData.ExecuteUpdateAsync(calls => calls - .SetProperty(p => p.LastUserId, (string?)null) - .SetProperty(p => p.LastUserName, (string?)null)); + .SetProperty(p => p.OriginUserId, (string?)null) + .SetProperty(p => p.OriginUserName, (string?)null)); await fixture.GetService().RefreshProjectData(); } @@ -348,9 +348,9 @@ await Task.WhenAll( } [Fact] - public async Task CreateCommentThread_WithoutLastUserId_ThrowsValidationException() + public async Task CreateCommentThread_WithoutOriginUserId_ThrowsValidationException() { - await ClearLastUserId(); + await ClearOriginUser(); var act = () => fixture.Api.CreateCommentThread(NewThread(), NewComment("hello")); @@ -359,11 +359,11 @@ await act.Should().ThrowAsync() } [Fact] - public async Task AddUserComment_WithoutLastUserId_ThrowsValidationException() + public async Task AddUserComment_WithoutOriginUserId_ThrowsValidationException() { var authorId = $"author-{Guid.NewGuid()}"; var (thread, _) = await CreateThreadWithComment(authorId); - await ClearLastUserId(); + await ClearOriginUser(); var act = () => fixture.Api.AddUserComment(thread.Id, NewComment("reply")); diff --git a/backend/FwLite/LcmCrdt.Tests/MiniLcmTests/CustomViewTests.cs b/backend/FwLite/LcmCrdt.Tests/MiniLcmTests/CustomViewTests.cs index 9e42291f7c..2ca5ea7432 100644 --- a/backend/FwLite/LcmCrdt.Tests/MiniLcmTests/CustomViewTests.cs +++ b/backend/FwLite/LcmCrdt.Tests/MiniLcmTests/CustomViewTests.cs @@ -8,7 +8,7 @@ public class CustomViewTests(MiniLcmApiFixture fixture) : IClassFixture(); - await projectService.UpdateLastUser("test-user", userId); + await projectService.UpdateOriginUser("test-user", userId); await projectService.UpdateUserRole(role); } diff --git a/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs b/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs index 69f66956ea..663a993db4 100644 --- a/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs +++ b/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs @@ -45,9 +45,8 @@ private CommitMetadata NewMetadata() var metadata = new CommitMetadata { ClientVersion = AppVersion.Version, - //todo, if a user logs out and in with another account, this will be out of date until the next sync - AuthorName = ProjectData.LastUserName ?? config.Value.DefaultAuthorForCommits, - AuthorId = ProjectData.LastUserId + AuthorName = ProjectData.OriginUserName ?? config.Value.DefaultAuthorForCommits, + AuthorId = ProjectData.OriginUserId }; commitMetadataInterceptor.Apply(metadata); return metadata; @@ -1209,7 +1208,7 @@ private void StampCommentThreadAuthor(CommentThread thread, DateTimeOffset now) { if (thread.Id == Guid.Empty) thread.Id = Guid.NewGuid(); thread.AuthorId = RequireCommentUserId(); - thread.AuthorName = ProjectData.LastUserName; + thread.AuthorName = ProjectData.OriginUserName; thread.CreatedAt = thread.CreatedAt == default ? now : thread.CreatedAt; thread.UpdatedAt = thread.UpdatedAt == default ? thread.CreatedAt : thread.UpdatedAt; } @@ -1218,16 +1217,16 @@ private void StampCommentAuthor(UserComment comment, DateTimeOffset now) { if (comment.Id == Guid.Empty) comment.Id = Guid.NewGuid(); comment.AuthorId = RequireCommentUserId(); - comment.AuthorName = ProjectData.LastUserName; + comment.AuthorName = ProjectData.OriginUserName; comment.CreatedAt = comment.CreatedAt == default ? now : comment.CreatedAt; comment.UpdatedAt = comment.UpdatedAt == default ? comment.CreatedAt : comment.UpdatedAt; } private string RequireCommentUserId() { - if (string.IsNullOrEmpty(ProjectData.LastUserId)) + if (string.IsNullOrEmpty(ProjectData.OriginUserId)) throw new ValidationException("Cannot create or modify comments without a known user identity."); - return ProjectData.LastUserId; + return ProjectData.OriginUserId; } private void AssertCurrentUserCanChangeComment(UserComment comment) diff --git a/backend/FwLite/LcmCrdt/CrdtProject.cs b/backend/FwLite/LcmCrdt/CrdtProject.cs index 48c59e5953..91ffcea056 100644 --- a/backend/FwLite/LcmCrdt/CrdtProject.cs +++ b/backend/FwLite/LcmCrdt/CrdtProject.cs @@ -29,7 +29,9 @@ public CrdtProject(string code, string dbPath, ProjectDataCache projectDataCache /// Server to sync with, null if not synced /// Unique id for this client machine /// FieldWorks project id, aka LangProjectId -public record ProjectData(string Name, string Code, Guid Id, string? OriginDomain, Guid ClientId, Guid? FwProjectId = null, string? LastUserName = null, string? LastUserId = null, +/// Display name of the signed-in user on the origin server; identifies the current user in the UI and authors CRDT commits. Set at project open and on sync. +/// Id of the signed-in user on the origin server. See . +public record ProjectData(string Name, string Code, Guid Id, string? OriginDomain, Guid ClientId, Guid? FwProjectId = null, string? OriginUserName = null, string? OriginUserId = null, UserProjectRole Role = UserProjectRole.Unknown) { public static string? GetOriginDomain(Uri? uri) diff --git a/backend/FwLite/LcmCrdt/CrdtProjectsService.cs b/backend/FwLite/LcmCrdt/CrdtProjectsService.cs index a7aa058dc7..7425e78f66 100644 --- a/backend/FwLite/LcmCrdt/CrdtProjectsService.cs +++ b/backend/FwLite/LcmCrdt/CrdtProjectsService.cs @@ -89,10 +89,10 @@ public async ValueTask UpdateProjectServerInfo(CrdtProject project, string? userId, UserProjectRole role) { - if (project.Data?.LastUserName == userName && project.Data?.LastUserId == userId && project.Data?.Role == role) return; + if (project.Data?.OriginUserName == userName && project.Data?.OriginUserId == userId && project.Data?.Role == role) return; await ExecInProject(project, async (scopedServices, currentProjectService) => { - await currentProjectService.UpdateLastUser(userName, userId); + await currentProjectService.UpdateOriginUser(userName, userId); await currentProjectService.UpdateUserRole(role); }); } diff --git a/backend/FwLite/LcmCrdt/CurrentProjectService.cs b/backend/FwLite/LcmCrdt/CurrentProjectService.cs index 830cd213dc..dfe1ecc0a4 100644 --- a/backend/FwLite/LcmCrdt/CurrentProjectService.cs +++ b/backend/FwLite/LcmCrdt/CurrentProjectService.cs @@ -152,15 +152,15 @@ await dbContext.Set() await RefreshProjectData(); } - public async Task UpdateLastUser(string? userName, string? userId) + public async Task UpdateOriginUser(string? userName, string? userId) { if (userName is null && userId is null) return; - if (userName != ProjectData.LastUserName || userId != ProjectData.LastUserId) + if (userName != ProjectData.OriginUserName || userId != ProjectData.OriginUserId) { await using var dbContext = await DbContextFactory.CreateDbContextAsync(); await dbContext.ProjectData.ExecuteUpdateAsync(calls => calls - .SetProperty(p => p.LastUserName, userName) - .SetProperty(p => p.LastUserId, userId)); + .SetProperty(p => p.OriginUserName, userName) + .SetProperty(p => p.OriginUserId, userId)); await RefreshProjectData(); } } diff --git a/backend/FwLite/LcmCrdt/LcmCrdtDbContext.cs b/backend/FwLite/LcmCrdt/LcmCrdtDbContext.cs index e1fcf6eac3..0ad711055c 100644 --- a/backend/FwLite/LcmCrdt/LcmCrdtDbContext.cs +++ b/backend/FwLite/LcmCrdt/LcmCrdtDbContext.cs @@ -41,6 +41,9 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) var projectDataModel = modelBuilder.Entity(); projectDataModel.HasKey(p => p.Id); projectDataModel.Ignore(p => p.ServerId); + // OriginUser* is a property-only rename; the columns keep their original names to avoid a migration. + projectDataModel.Property(p => p.OriginUserName).HasColumnName("LastUserName"); + projectDataModel.Property(p => p.OriginUserId).HasColumnName("LastUserId"); //setting default value to handle migration projectDataModel.Property(p => p.Role).HasConversion>().HasDefaultValue(UserProjectRole.Editor); diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/IProjectData.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/IProjectData.ts index f3870d73a2..9d6b68b9fc 100644 --- a/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/IProjectData.ts +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/IProjectData.ts @@ -13,8 +13,8 @@ export interface IProjectData originDomain?: string; clientId: string; fwProjectId?: string; - lastUserName?: string; - lastUserId?: string; + originUserName?: string; + originUserId?: string; role: UserProjectRole; serverId?: string; isReadonly: boolean; diff --git a/frontend/viewer/src/lib/entry-editor/CommentDialog.svelte b/frontend/viewer/src/lib/entry-editor/CommentDialog.svelte index fa187a4c8b..ffef631ec5 100644 --- a/frontend/viewer/src/lib/entry-editor/CommentDialog.svelte +++ b/frontend/viewer/src/lib/entry-editor/CommentDialog.svelte @@ -42,7 +42,7 @@ const api = useMiniLcmApi(); const projectContext = useProjectContext(); - const currentUserId = $derived(projectContext.projectData?.lastUserId); + const currentUserId = $derived(projectContext.projectData?.originUserId); const canComment = $derived(Boolean(currentUserId)); const isExtraLarge = new MediaQuery('min-width: 1280px'); From 9bd3c46ffecf76fef05483ccc408d89116ae4d8f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 21:02:19 +0000 Subject: [PATCH 2/9] Attribute synced comment read status to the freshly resolved user ExecuteSync refreshes the origin user (UpdateOriginUser) after capturing the ProjectData record, so passing the captured record's user id to ApplySyncedCommentReadStatus used a stale/missing id. Use the just-fetched currentUser id instead, so read status is attributed to the right user right after coming back online or switching accounts. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015ZEJ549cwaz1uHfq7EKyPu --- backend/FwLite/FwLiteShared/Sync/SyncService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/FwLite/FwLiteShared/Sync/SyncService.cs b/backend/FwLite/FwLiteShared/Sync/SyncService.cs index e79ec5bd62..77f0eb9fc0 100644 --- a/backend/FwLite/FwLiteShared/Sync/SyncService.cs +++ b/backend/FwLite/FwLiteShared/Sync/SyncService.cs @@ -107,7 +107,7 @@ public async Task ExecuteSync(bool skipNotifications = false) } logger.LogInformation("Synced project {ProjectName} with server", project.Name); UpdateSyncStatus(SyncStatus.Success); - await ApplySyncedCommentReadStatus(syncResults, project.OriginUserId); + await ApplySyncedCommentReadStatus(syncResults, currentUser?.Id); await syncRepository.UpdateSyncDate(syncDate); // Best-effort: if the push listener failed to start at project-open (e.g. user was offline), this // restarts it now that we know auth + network are healthy. If it's already running, the cache From 256c3081dbac324a4ca9d6566293d59cdf5770a4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 21:26:30 +0000 Subject: [PATCH 3/9] Fall back to the persisted origin user for read-status when auth is null If GetCurrentUser returns null on a transient token refresh (the user is still signed in, so UpdateOriginUser keeps the persisted identity), passing that null straight to ApplySyncedCommentReadStatus marks every synced comment unread. Fall back to the persisted OriginUserId so read status stays attributed to the last-known user. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015ZEJ549cwaz1uHfq7EKyPu --- backend/FwLite/FwLiteShared/Sync/SyncService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/FwLite/FwLiteShared/Sync/SyncService.cs b/backend/FwLite/FwLiteShared/Sync/SyncService.cs index 77f0eb9fc0..6c0eedbb55 100644 --- a/backend/FwLite/FwLiteShared/Sync/SyncService.cs +++ b/backend/FwLite/FwLiteShared/Sync/SyncService.cs @@ -107,7 +107,7 @@ public async Task ExecuteSync(bool skipNotifications = false) } logger.LogInformation("Synced project {ProjectName} with server", project.Name); UpdateSyncStatus(SyncStatus.Success); - await ApplySyncedCommentReadStatus(syncResults, currentUser?.Id); + await ApplySyncedCommentReadStatus(syncResults, currentUser?.Id ?? project.OriginUserId); await syncRepository.UpdateSyncDate(syncDate); // Best-effort: if the push listener failed to start at project-open (e.g. user was offline), this // restarts it now that we know auth + network are healthy. If it's already running, the cache From f16f85cb9e0c822a05f3bb452b023a4b4e23ae2e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 14:33:35 +0000 Subject: [PATCH 4/9] Revert the LastUser rename; keep origin-scoping in the gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review: LastUserId/LastUserName are the honest names — the value is the last signed-in user we saw and is overwritten on the next open/sync (e.g. an account switch), so "OriginUser" over-claimed a canonical per-project user. The origin scoping that fixes the bug lives in the CombinedProjectsService gate, not the field name, so the rename bought nothing and cost clarity. Reverts the property/method rename, the HasColumnName mapping, the generated TS, and the verified snapshot back to develop, and restores the staleness todo in NewMetadata (reworded: now stale only until the next open or sync, since identity is resolved at open). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015ZEJ549cwaz1uHfq7EKyPu --- .../Services/ProjectServicesProvider.cs | 2 +- .../FwLite/FwLiteShared/Sync/SyncService.cs | 4 ++-- .../CurrentProjectServiceTests.cs | 20 +++++++++---------- ...elSnapshotTests.VerifyDbModel.verified.txt | 8 ++------ .../DateTimeOffsetOrmParityTests.cs | 2 +- .../MiniLcmTests/CommentTests.cs | 16 +++++++-------- .../MiniLcmTests/CustomViewTests.cs | 2 +- backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs | 13 ++++++------ backend/FwLite/LcmCrdt/CrdtProject.cs | 4 +--- backend/FwLite/LcmCrdt/CrdtProjectsService.cs | 4 ++-- .../FwLite/LcmCrdt/CurrentProjectService.cs | 8 ++++---- backend/FwLite/LcmCrdt/LcmCrdtDbContext.cs | 3 --- .../generated-types/LcmCrdt/IProjectData.ts | 4 ++-- .../src/lib/entry-editor/CommentDialog.svelte | 2 +- 14 files changed, 42 insertions(+), 50 deletions(-) diff --git a/backend/FwLite/FwLiteShared/Services/ProjectServicesProvider.cs b/backend/FwLite/FwLiteShared/Services/ProjectServicesProvider.cs index 7929c5572a..b285fcd1e6 100644 --- a/backend/FwLite/FwLiteShared/Services/ProjectServicesProvider.cs +++ b/backend/FwLite/FwLiteShared/Services/ProjectServicesProvider.cs @@ -94,7 +94,7 @@ private async Task ResolveOriginUser(LexboxServer? server, if (server is null) return projectData; var currentUser = await oAuthClientFactory.GetClient(server).GetCurrentUser(); if (currentUser is null) return projectData; - await currentProjectService.UpdateOriginUser(currentUser.Name, currentUser.Id); + await currentProjectService.UpdateLastUser(currentUser.Name, currentUser.Id); return await currentProjectService.GetProjectData(); } diff --git a/backend/FwLite/FwLiteShared/Sync/SyncService.cs b/backend/FwLite/FwLiteShared/Sync/SyncService.cs index 6c0eedbb55..d326496ab3 100644 --- a/backend/FwLite/FwLiteShared/Sync/SyncService.cs +++ b/backend/FwLite/FwLiteShared/Sync/SyncService.cs @@ -86,7 +86,7 @@ public async Task ExecuteSync(bool skipNotifications = false) try { var currentUser = await oAuthClient.GetCurrentUser(); - await currentProjectService.UpdateOriginUser(currentUser?.Name, currentUser?.Id); + await currentProjectService.UpdateLastUser(currentUser?.Name, currentUser?.Id); var remoteModel = await remoteSyncServiceServer.CreateProjectSyncable(project, httpClient); if (!await remoteModel.ShouldSync()) @@ -107,7 +107,7 @@ public async Task ExecuteSync(bool skipNotifications = false) } logger.LogInformation("Synced project {ProjectName} with server", project.Name); UpdateSyncStatus(SyncStatus.Success); - await ApplySyncedCommentReadStatus(syncResults, currentUser?.Id ?? project.OriginUserId); + await ApplySyncedCommentReadStatus(syncResults, currentUser?.Id ?? project.LastUserId); await syncRepository.UpdateSyncDate(syncDate); // Best-effort: if the push listener failed to start at project-open (e.g. user was offline), this // restarts it now that we know auth + network are healthy. If it's already running, the cache diff --git a/backend/FwLite/LcmCrdt.Tests/CurrentProjectServiceTests.cs b/backend/FwLite/LcmCrdt.Tests/CurrentProjectServiceTests.cs index 9e4f76914b..7d947e0af0 100644 --- a/backend/FwLite/LcmCrdt.Tests/CurrentProjectServiceTests.cs +++ b/backend/FwLite/LcmCrdt.Tests/CurrentProjectServiceTests.cs @@ -5,27 +5,27 @@ public class CurrentProjectServiceTests(MiniLcmApiFixture fixture) : IClassFixtu private CurrentProjectService ProjectService => fixture.GetService(); [Fact] - public async Task UpdateOriginUser_WithNullUser_KeepsPersistedValue() + public async Task UpdateLastUser_WithNullUser_KeepsPersistedValue() { // Open-time resolution falls back to this persisted value when auth is null, so a null update mustn't wipe it. - await ProjectService.UpdateOriginUser("Tim Haasdyk", "tim-id"); + await ProjectService.UpdateLastUser("Tim Haasdyk", "tim-id"); - await ProjectService.UpdateOriginUser(null, null); + await ProjectService.UpdateLastUser(null, null); var projectData = await ProjectService.GetProjectData(); - projectData.OriginUserName.Should().Be("Tim Haasdyk"); - projectData.OriginUserId.Should().Be("tim-id"); + projectData.LastUserName.Should().Be("Tim Haasdyk"); + projectData.LastUserId.Should().Be("tim-id"); } [Fact] - public async Task UpdateOriginUser_ReplacesPreviousUser() + public async Task UpdateLastUser_ReplacesPreviousUser() { - await ProjectService.UpdateOriginUser("first", "first-id"); + await ProjectService.UpdateLastUser("first", "first-id"); - await ProjectService.UpdateOriginUser("second", "second-id"); + await ProjectService.UpdateLastUser("second", "second-id"); var projectData = await ProjectService.GetProjectData(); - projectData.OriginUserName.Should().Be("second"); - projectData.OriginUserId.Should().Be("second-id"); + projectData.LastUserName.Should().Be("second"); + projectData.LastUserId.Should().Be("second-id"); } } diff --git a/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyDbModel.verified.txt b/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyDbModel.verified.txt index 17a23e768a..20fd028c36 100644 --- a/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyDbModel.verified.txt +++ b/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyDbModel.verified.txt @@ -38,14 +38,10 @@ ClientId (Guid) Required Code (string) Required FwProjectId (Guid?) + LastUserId (string) + LastUserName (string) Name (string) Required OriginDomain (string) - OriginUserId (string) - Annotations: - Relational:ColumnName: LastUserId - OriginUserName (string) - Annotations: - Relational:ColumnName: LastUserName Role (UserProjectRole) Required ValueGenerated.OnAdd Annotations: Relational:DefaultValue: Editor diff --git a/backend/FwLite/LcmCrdt.Tests/DateTimeOffsetOrmParityTests.cs b/backend/FwLite/LcmCrdt.Tests/DateTimeOffsetOrmParityTests.cs index 1c0aba545d..c03f0bb46e 100644 --- a/backend/FwLite/LcmCrdt.Tests/DateTimeOffsetOrmParityTests.cs +++ b/backend/FwLite/LcmCrdt.Tests/DateTimeOffsetOrmParityTests.cs @@ -66,7 +66,7 @@ public async Task CommentTimestamp_SameUtcInstant_ThroughEfCoreAndLinq2db() // One plain column is representative: every DateTimeOffset column shares the same global converter. // Set an explicit non-UTC-offset instant so we also verify the write records the value (the API // return is already round-tripped through the DB) and that it's normalized to UTC. - await _fixture.GetService().UpdateOriginUser("tester", Guid.NewGuid().ToString()); + await _fixture.GetService().UpdateLastUser("tester", Guid.NewGuid().ToString()); var created = new DateTimeOffset(2024, 3, 15, 10, 30, 0, TimeSpan.FromHours(5)); var thread = await _fixture.Api.CreateCommentThread( diff --git a/backend/FwLite/LcmCrdt.Tests/MiniLcmTests/CommentTests.cs b/backend/FwLite/LcmCrdt.Tests/MiniLcmTests/CommentTests.cs index 06c05b8ddb..963dc74176 100644 --- a/backend/FwLite/LcmCrdt.Tests/MiniLcmTests/CommentTests.cs +++ b/backend/FwLite/LcmCrdt.Tests/MiniLcmTests/CommentTests.cs @@ -9,7 +9,7 @@ public class CommentTests(MiniLcmApiFixture fixture) : IClassFixture(); - await projectService.UpdateOriginUser(name ?? userId, userId); + await projectService.UpdateLastUser(name ?? userId, userId); await projectService.UpdateUserRole(UserProjectRole.Editor); } @@ -24,11 +24,11 @@ private Task ClearUnreadComments() return fixture.Api.MarkAllCommentsRead(); } - private async Task ClearOriginUser() + private async Task ClearLastUserId() { await fixture.DbContext.ProjectData.ExecuteUpdateAsync(calls => calls - .SetProperty(p => p.OriginUserId, (string?)null) - .SetProperty(p => p.OriginUserName, (string?)null)); + .SetProperty(p => p.LastUserId, (string?)null) + .SetProperty(p => p.LastUserName, (string?)null)); await fixture.GetService().RefreshProjectData(); } @@ -348,9 +348,9 @@ await Task.WhenAll( } [Fact] - public async Task CreateCommentThread_WithoutOriginUserId_ThrowsValidationException() + public async Task CreateCommentThread_WithoutLastUserId_ThrowsValidationException() { - await ClearOriginUser(); + await ClearLastUserId(); var act = () => fixture.Api.CreateCommentThread(NewThread(), NewComment("hello")); @@ -359,11 +359,11 @@ await act.Should().ThrowAsync() } [Fact] - public async Task AddUserComment_WithoutOriginUserId_ThrowsValidationException() + public async Task AddUserComment_WithoutLastUserId_ThrowsValidationException() { var authorId = $"author-{Guid.NewGuid()}"; var (thread, _) = await CreateThreadWithComment(authorId); - await ClearOriginUser(); + await ClearLastUserId(); var act = () => fixture.Api.AddUserComment(thread.Id, NewComment("reply")); diff --git a/backend/FwLite/LcmCrdt.Tests/MiniLcmTests/CustomViewTests.cs b/backend/FwLite/LcmCrdt.Tests/MiniLcmTests/CustomViewTests.cs index 2ca5ea7432..9e42291f7c 100644 --- a/backend/FwLite/LcmCrdt.Tests/MiniLcmTests/CustomViewTests.cs +++ b/backend/FwLite/LcmCrdt.Tests/MiniLcmTests/CustomViewTests.cs @@ -8,7 +8,7 @@ public class CustomViewTests(MiniLcmApiFixture fixture) : IClassFixture(); - await projectService.UpdateOriginUser("test-user", userId); + await projectService.UpdateLastUser("test-user", userId); await projectService.UpdateUserRole(role); } diff --git a/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs b/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs index 663a993db4..e863d19a88 100644 --- a/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs +++ b/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs @@ -45,8 +45,9 @@ private CommitMetadata NewMetadata() var metadata = new CommitMetadata { ClientVersion = AppVersion.Version, - AuthorName = ProjectData.OriginUserName ?? config.Value.DefaultAuthorForCommits, - AuthorId = ProjectData.OriginUserId + //todo if a user logs out and in with another account this is out of date until the next open or sync + AuthorName = ProjectData.LastUserName ?? config.Value.DefaultAuthorForCommits, + AuthorId = ProjectData.LastUserId }; commitMetadataInterceptor.Apply(metadata); return metadata; @@ -1208,7 +1209,7 @@ private void StampCommentThreadAuthor(CommentThread thread, DateTimeOffset now) { if (thread.Id == Guid.Empty) thread.Id = Guid.NewGuid(); thread.AuthorId = RequireCommentUserId(); - thread.AuthorName = ProjectData.OriginUserName; + thread.AuthorName = ProjectData.LastUserName; thread.CreatedAt = thread.CreatedAt == default ? now : thread.CreatedAt; thread.UpdatedAt = thread.UpdatedAt == default ? thread.CreatedAt : thread.UpdatedAt; } @@ -1217,16 +1218,16 @@ private void StampCommentAuthor(UserComment comment, DateTimeOffset now) { if (comment.Id == Guid.Empty) comment.Id = Guid.NewGuid(); comment.AuthorId = RequireCommentUserId(); - comment.AuthorName = ProjectData.OriginUserName; + comment.AuthorName = ProjectData.LastUserName; comment.CreatedAt = comment.CreatedAt == default ? now : comment.CreatedAt; comment.UpdatedAt = comment.UpdatedAt == default ? comment.CreatedAt : comment.UpdatedAt; } private string RequireCommentUserId() { - if (string.IsNullOrEmpty(ProjectData.OriginUserId)) + if (string.IsNullOrEmpty(ProjectData.LastUserId)) throw new ValidationException("Cannot create or modify comments without a known user identity."); - return ProjectData.OriginUserId; + return ProjectData.LastUserId; } private void AssertCurrentUserCanChangeComment(UserComment comment) diff --git a/backend/FwLite/LcmCrdt/CrdtProject.cs b/backend/FwLite/LcmCrdt/CrdtProject.cs index 91ffcea056..48c59e5953 100644 --- a/backend/FwLite/LcmCrdt/CrdtProject.cs +++ b/backend/FwLite/LcmCrdt/CrdtProject.cs @@ -29,9 +29,7 @@ public CrdtProject(string code, string dbPath, ProjectDataCache projectDataCache /// Server to sync with, null if not synced /// Unique id for this client machine /// FieldWorks project id, aka LangProjectId -/// Display name of the signed-in user on the origin server; identifies the current user in the UI and authors CRDT commits. Set at project open and on sync. -/// Id of the signed-in user on the origin server. See . -public record ProjectData(string Name, string Code, Guid Id, string? OriginDomain, Guid ClientId, Guid? FwProjectId = null, string? OriginUserName = null, string? OriginUserId = null, +public record ProjectData(string Name, string Code, Guid Id, string? OriginDomain, Guid ClientId, Guid? FwProjectId = null, string? LastUserName = null, string? LastUserId = null, UserProjectRole Role = UserProjectRole.Unknown) { public static string? GetOriginDomain(Uri? uri) diff --git a/backend/FwLite/LcmCrdt/CrdtProjectsService.cs b/backend/FwLite/LcmCrdt/CrdtProjectsService.cs index 7425e78f66..a7aa058dc7 100644 --- a/backend/FwLite/LcmCrdt/CrdtProjectsService.cs +++ b/backend/FwLite/LcmCrdt/CrdtProjectsService.cs @@ -89,10 +89,10 @@ public async ValueTask UpdateProjectServerInfo(CrdtProject project, string? userId, UserProjectRole role) { - if (project.Data?.OriginUserName == userName && project.Data?.OriginUserId == userId && project.Data?.Role == role) return; + if (project.Data?.LastUserName == userName && project.Data?.LastUserId == userId && project.Data?.Role == role) return; await ExecInProject(project, async (scopedServices, currentProjectService) => { - await currentProjectService.UpdateOriginUser(userName, userId); + await currentProjectService.UpdateLastUser(userName, userId); await currentProjectService.UpdateUserRole(role); }); } diff --git a/backend/FwLite/LcmCrdt/CurrentProjectService.cs b/backend/FwLite/LcmCrdt/CurrentProjectService.cs index dfe1ecc0a4..830cd213dc 100644 --- a/backend/FwLite/LcmCrdt/CurrentProjectService.cs +++ b/backend/FwLite/LcmCrdt/CurrentProjectService.cs @@ -152,15 +152,15 @@ await dbContext.Set() await RefreshProjectData(); } - public async Task UpdateOriginUser(string? userName, string? userId) + public async Task UpdateLastUser(string? userName, string? userId) { if (userName is null && userId is null) return; - if (userName != ProjectData.OriginUserName || userId != ProjectData.OriginUserId) + if (userName != ProjectData.LastUserName || userId != ProjectData.LastUserId) { await using var dbContext = await DbContextFactory.CreateDbContextAsync(); await dbContext.ProjectData.ExecuteUpdateAsync(calls => calls - .SetProperty(p => p.OriginUserName, userName) - .SetProperty(p => p.OriginUserId, userId)); + .SetProperty(p => p.LastUserName, userName) + .SetProperty(p => p.LastUserId, userId)); await RefreshProjectData(); } } diff --git a/backend/FwLite/LcmCrdt/LcmCrdtDbContext.cs b/backend/FwLite/LcmCrdt/LcmCrdtDbContext.cs index 0ad711055c..e1fcf6eac3 100644 --- a/backend/FwLite/LcmCrdt/LcmCrdtDbContext.cs +++ b/backend/FwLite/LcmCrdt/LcmCrdtDbContext.cs @@ -41,9 +41,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) var projectDataModel = modelBuilder.Entity(); projectDataModel.HasKey(p => p.Id); projectDataModel.Ignore(p => p.ServerId); - // OriginUser* is a property-only rename; the columns keep their original names to avoid a migration. - projectDataModel.Property(p => p.OriginUserName).HasColumnName("LastUserName"); - projectDataModel.Property(p => p.OriginUserId).HasColumnName("LastUserId"); //setting default value to handle migration projectDataModel.Property(p => p.Role).HasConversion>().HasDefaultValue(UserProjectRole.Editor); diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/IProjectData.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/IProjectData.ts index 9d6b68b9fc..f3870d73a2 100644 --- a/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/IProjectData.ts +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/IProjectData.ts @@ -13,8 +13,8 @@ export interface IProjectData originDomain?: string; clientId: string; fwProjectId?: string; - originUserName?: string; - originUserId?: string; + lastUserName?: string; + lastUserId?: string; role: UserProjectRole; serverId?: string; isReadonly: boolean; diff --git a/frontend/viewer/src/lib/entry-editor/CommentDialog.svelte b/frontend/viewer/src/lib/entry-editor/CommentDialog.svelte index ffef631ec5..fa187a4c8b 100644 --- a/frontend/viewer/src/lib/entry-editor/CommentDialog.svelte +++ b/frontend/viewer/src/lib/entry-editor/CommentDialog.svelte @@ -42,7 +42,7 @@ const api = useMiniLcmApi(); const projectContext = useProjectContext(); - const currentUserId = $derived(projectContext.projectData?.originUserId); + const currentUserId = $derived(projectContext.projectData?.lastUserId); const canComment = $derived(Boolean(currentUserId)); const isExtraLarge = new MediaQuery('min-width: 1280px'); From 61e767b8fc5a3a181a939f1f257358aaa8a6db00 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 14:58:21 +0000 Subject: [PATCH 5/9] Resolve open-time identity from the MSAL cache without a token refresh Opening a project resolved the origin user via GetCurrentUser, which goes through GetAuth and can trigger a silent token refresh (a network round-trip) when the cached token is near/after expiry. We only need who the user is here, not a usable token, so read the account straight from the local MSAL cache instead. Adds OAuthClient.GetCachedUser (a sibling of IsSignedIn: cache-only, no network) and uses it at project open, keeping OpenCrdtProject off the network. The token refresh still happens in the background sync, where latency doesn't matter. Also corrects the now-accurate "no network" comment. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015ZEJ549cwaz1uHfq7EKyPu --- .../Auth/OAuthClientIsSignedInTests.cs | 20 +++++++++++++++++++ .../FwLite/FwLiteShared/Auth/OAuthClient.cs | 14 +++++++++++++ .../Services/ProjectServicesProvider.cs | 9 +++++---- 3 files changed, 39 insertions(+), 4 deletions(-) diff --git a/backend/FwLite/FwLiteShared.Tests/Auth/OAuthClientIsSignedInTests.cs b/backend/FwLite/FwLiteShared.Tests/Auth/OAuthClientIsSignedInTests.cs index a7b6c2d941..e4a7da33b8 100644 --- a/backend/FwLite/FwLiteShared.Tests/Auth/OAuthClientIsSignedInTests.cs +++ b/backend/FwLite/FwLiteShared.Tests/Auth/OAuthClientIsSignedInTests.cs @@ -38,4 +38,24 @@ public async Task ReturnsFalse_WhenNoAccounts() (await client.IsSignedIn()).Should().BeFalse(); } + + // GetCachedUser is the same purely-local read: the strict mock only stubs GetAccountsAsync, so these also + // prove it never reaches for a token (which would keep project-open off the network). + [Fact] + public async Task GetCachedUser_ReturnsAccountIdentity() + { + var account = Mock.Of(a => a.Username == "tester@example.test" + && a.HomeAccountId == new AccountId("uid.tid", "uid", "tid")); + var (client, _) = BuildClient([account]); + + (await client.GetCachedUser()).Should().Be(new LexboxUser("tester@example.test", "uid")); + } + + [Fact] + public async Task GetCachedUser_ReturnsNull_WhenNoAccounts() + { + var (client, _) = BuildClient([]); + + (await client.GetCachedUser()).Should().BeNull(); + } } diff --git a/backend/FwLite/FwLiteShared/Auth/OAuthClient.cs b/backend/FwLite/FwLiteShared/Auth/OAuthClient.cs index 22172e4126..31dc41fa72 100644 --- a/backend/FwLite/FwLiteShared/Auth/OAuthClient.cs +++ b/backend/FwLite/FwLiteShared/Auth/OAuthClient.cs @@ -191,6 +191,20 @@ public async ValueTask IsSignedIn() return accounts.Any(); } + /// + /// The signed-in user's identity read straight from the local MSAL account cache — no token acquisition, + /// so no network. Use when you only need who the user is (not a usable token), e.g. to keep a hot path fast. + /// Like this is optimistic: an account can stay cached after its refresh token has + /// expired, so a non-null result means "was signed in", not "can get a token right now". Null when no + /// account is cached. To get who the user is *and* a usable token, use . + /// + public async ValueTask GetCachedUser() + { + await ConfigureCache(); + var account = (await _application.GetAccountsAsync()).FirstOrDefault(); + return account?.Username is null ? null : new LexboxUser(account.Username, account.HomeAccountId.ObjectId); + } + /// /// Gets a usable authentication result, silently refreshing when the cached token is missing or near /// expiry. Returns null when none can be produced — in one of two cases the caller may want to tell apart: diff --git a/backend/FwLite/FwLiteShared/Services/ProjectServicesProvider.cs b/backend/FwLite/FwLiteShared/Services/ProjectServicesProvider.cs index b285fcd1e6..c90c72fba4 100644 --- a/backend/FwLite/FwLiteShared/Services/ProjectServicesProvider.cs +++ b/backend/FwLite/FwLiteShared/Services/ProjectServicesProvider.cs @@ -84,15 +84,16 @@ public Task OpenCrdtProject(string code) }); } - // Persist the origin server's current user before the UI reads identity off the project context, so it's - // right from the first render instead of racing the background sync. GetCurrentUser only reads the local - // MSAL cache (no network). Null means offline/signed-out: keep the persisted last-known-good value. + // Persist the origin server's signed-in user before the UI reads identity off the project context, so it's + // right from the first render instead of racing the background sync. GetCachedUser reads the local MSAL + // account cache only — no token acquisition, no network — to keep project open fast; the token refresh + // happens later in the background sync. Null means signed-out: keep the persisted last-known-good value. private async Task ResolveOriginUser(LexboxServer? server, CurrentProjectService currentProjectService, ProjectData projectData) { if (server is null) return projectData; - var currentUser = await oAuthClientFactory.GetClient(server).GetCurrentUser(); + var currentUser = await oAuthClientFactory.GetClient(server).GetCachedUser(); if (currentUser is null) return projectData; await currentProjectService.UpdateLastUser(currentUser.Name, currentUser.Id); return await currentProjectService.GetProjectData(); From e3122ab1f537d7e5860e9c6605e320ddf75e2b54 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 15:07:27 +0000 Subject: [PATCH 6/9] Keep the NewMetadata staleness todo verbatim from develop Drops the reword so the comment is unchanged from develop (no diff there). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015ZEJ549cwaz1uHfq7EKyPu --- backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs b/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs index e863d19a88..69f66956ea 100644 --- a/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs +++ b/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs @@ -45,7 +45,7 @@ private CommitMetadata NewMetadata() var metadata = new CommitMetadata { ClientVersion = AppVersion.Version, - //todo if a user logs out and in with another account this is out of date until the next open or sync + //todo, if a user logs out and in with another account, this will be out of date until the next sync AuthorName = ProjectData.LastUserName ?? config.Value.DefaultAuthorForCommits, AuthorId = ProjectData.LastUserId }; From 05fe9643300ee2615066e45f81ae84cfc685a462 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Mon, 27 Jul 2026 17:24:48 +0200 Subject: [PATCH 7/9] Tidy review-pass comments Drop em-dashes and de-duplicate the cached-read rationale that now lives on GetCachedUser's docstring. No behavior change. Co-Authored-By: Claude Opus 4.8 --- backend/FwLite/FwLiteShared/Auth/OAuthClient.cs | 4 ++-- .../FwLite/FwLiteShared/Projects/CombinedProjectsService.cs | 2 +- .../FwLite/FwLiteShared/Services/ProjectServicesProvider.cs | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/backend/FwLite/FwLiteShared/Auth/OAuthClient.cs b/backend/FwLite/FwLiteShared/Auth/OAuthClient.cs index 31dc41fa72..f8518b92f4 100644 --- a/backend/FwLite/FwLiteShared/Auth/OAuthClient.cs +++ b/backend/FwLite/FwLiteShared/Auth/OAuthClient.cs @@ -192,8 +192,8 @@ public async ValueTask IsSignedIn() } /// - /// The signed-in user's identity read straight from the local MSAL account cache — no token acquisition, - /// so no network. Use when you only need who the user is (not a usable token), e.g. to keep a hot path fast. + /// The signed-in user's identity read straight from the local MSAL account cache (no token acquisition, + /// so no network). Use when you only need who the user is (not a usable token), e.g. to keep a hot path fast. /// Like this is optimistic: an account can stay cached after its refresh token has /// expired, so a non-null result means "was signed in", not "can get a token right now". Null when no /// account is cached. To get who the user is *and* a usable token, use . diff --git a/backend/FwLite/FwLiteShared/Projects/CombinedProjectsService.cs b/backend/FwLite/FwLiteShared/Projects/CombinedProjectsService.cs index 0ba4514f08..bc0b9de1fb 100644 --- a/backend/FwLite/FwLiteShared/Projects/CombinedProjectsService.cs +++ b/backend/FwLite/FwLiteShared/Projects/CombinedProjectsService.cs @@ -93,7 +93,7 @@ private async Task UpdateProjectServerInfo(LexboxServer server, FieldWorksLitePr // A project's user/role belong to its origin server. The same GUID can live on more than one server // (shared history) and GetProject(Guid) matches on GUID alone, so a non-origin server must not stamp its - // user here — that mislabels the current user and, until the next sync, the CRDT commit author. + // user here: that mislabels the current user and, until the next sync, the CRDT commit author. internal static bool ServerOwnsProject(ProjectData localProjectData, LexboxServer server) => localProjectData.ServerId == server.Id; diff --git a/backend/FwLite/FwLiteShared/Services/ProjectServicesProvider.cs b/backend/FwLite/FwLiteShared/Services/ProjectServicesProvider.cs index c90c72fba4..63214da5da 100644 --- a/backend/FwLite/FwLiteShared/Services/ProjectServicesProvider.cs +++ b/backend/FwLite/FwLiteShared/Services/ProjectServicesProvider.cs @@ -85,9 +85,9 @@ public Task OpenCrdtProject(string code) } // Persist the origin server's signed-in user before the UI reads identity off the project context, so it's - // right from the first render instead of racing the background sync. GetCachedUser reads the local MSAL - // account cache only — no token acquisition, no network — to keep project open fast; the token refresh - // happens later in the background sync. Null means signed-out: keep the persisted last-known-good value. + // right from the first render instead of racing the background sync. GetCachedUser is the fast local read + // (see its docs); the token refresh happens later in the background sync. Null means signed-out: keep the + // persisted last-known-good value. private async Task ResolveOriginUser(LexboxServer? server, CurrentProjectService currentProjectService, ProjectData projectData) From a4a18a174c354923227b33e7aca36476ed37af01 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Mon, 27 Jul 2026 17:48:02 +0200 Subject: [PATCH 8/9] Trim review-pass comments; note GetCachedUser is authority-scoped GetAccountsAsync filters by the app's OIDC authority, so the per-server client only sees that server's accounts (addresses the FirstOrDefault cross-server concern). No behavior change. Co-Authored-By: Claude Opus 4.8 --- .../FwLiteShared.Tests/Auth/OAuthClientIsSignedInTests.cs | 3 +-- backend/FwLite/FwLiteShared/Auth/OAuthClient.cs | 8 +++----- .../FwLiteShared/Projects/CombinedProjectsService.cs | 5 ++--- .../FwLiteShared/Services/ProjectServicesProvider.cs | 6 ++---- 4 files changed, 8 insertions(+), 14 deletions(-) diff --git a/backend/FwLite/FwLiteShared.Tests/Auth/OAuthClientIsSignedInTests.cs b/backend/FwLite/FwLiteShared.Tests/Auth/OAuthClientIsSignedInTests.cs index e4a7da33b8..2ddce694f0 100644 --- a/backend/FwLite/FwLiteShared.Tests/Auth/OAuthClientIsSignedInTests.cs +++ b/backend/FwLite/FwLiteShared.Tests/Auth/OAuthClientIsSignedInTests.cs @@ -39,8 +39,7 @@ public async Task ReturnsFalse_WhenNoAccounts() (await client.IsSignedIn()).Should().BeFalse(); } - // GetCachedUser is the same purely-local read: the strict mock only stubs GetAccountsAsync, so these also - // prove it never reaches for a token (which would keep project-open off the network). + // Strict mock only stubs GetAccountsAsync, so these also prove GetCachedUser never reaches for a token. [Fact] public async Task GetCachedUser_ReturnsAccountIdentity() { diff --git a/backend/FwLite/FwLiteShared/Auth/OAuthClient.cs b/backend/FwLite/FwLiteShared/Auth/OAuthClient.cs index f8518b92f4..c493ad51cd 100644 --- a/backend/FwLite/FwLiteShared/Auth/OAuthClient.cs +++ b/backend/FwLite/FwLiteShared/Auth/OAuthClient.cs @@ -192,15 +192,13 @@ public async ValueTask IsSignedIn() } /// - /// The signed-in user's identity read straight from the local MSAL account cache (no token acquisition, - /// so no network). Use when you only need who the user is (not a usable token), e.g. to keep a hot path fast. - /// Like this is optimistic: an account can stay cached after its refresh token has - /// expired, so a non-null result means "was signed in", not "can get a token right now". Null when no - /// account is cached. To get who the user is *and* a usable token, use . + /// Who's signed in, from the local MSAL cache (no network). Optimistic like : + /// non-null means "was signed in", not "can get a token now". Null if none; for a token too use . /// public async ValueTask GetCachedUser() { await ConfigureCache(); + // GetAccountsAsync is authority-scoped (WithOidcAuthority), so this is already the origin server's account. var account = (await _application.GetAccountsAsync()).FirstOrDefault(); return account?.Username is null ? null : new LexboxUser(account.Username, account.HomeAccountId.ObjectId); } diff --git a/backend/FwLite/FwLiteShared/Projects/CombinedProjectsService.cs b/backend/FwLite/FwLiteShared/Projects/CombinedProjectsService.cs index bc0b9de1fb..d3080cf9a9 100644 --- a/backend/FwLite/FwLiteShared/Projects/CombinedProjectsService.cs +++ b/backend/FwLite/FwLiteShared/Projects/CombinedProjectsService.cs @@ -91,9 +91,8 @@ private async Task UpdateProjectServerInfo(LexboxServer server, FieldWorksLitePr } } - // A project's user/role belong to its origin server. The same GUID can live on more than one server - // (shared history) and GetProject(Guid) matches on GUID alone, so a non-origin server must not stamp its - // user here: that mislabels the current user and, until the next sync, the CRDT commit author. + // The same GUID can live on multiple servers and GetProject matches on GUID alone, so only the origin + // server may stamp user/role: otherwise the current user (and CRDT commit author, until next sync) is wrong. internal static bool ServerOwnsProject(ProjectData localProjectData, LexboxServer server) => localProjectData.ServerId == server.Id; diff --git a/backend/FwLite/FwLiteShared/Services/ProjectServicesProvider.cs b/backend/FwLite/FwLiteShared/Services/ProjectServicesProvider.cs index 63214da5da..de24321b30 100644 --- a/backend/FwLite/FwLiteShared/Services/ProjectServicesProvider.cs +++ b/backend/FwLite/FwLiteShared/Services/ProjectServicesProvider.cs @@ -84,10 +84,8 @@ public Task OpenCrdtProject(string code) }); } - // Persist the origin server's signed-in user before the UI reads identity off the project context, so it's - // right from the first render instead of racing the background sync. GetCachedUser is the fast local read - // (see its docs); the token refresh happens later in the background sync. Null means signed-out: keep the - // persisted last-known-good value. + // Persist the origin user before the UI reads identity, so it's right from first render instead of racing + // the background sync. Fast local read (see GetCachedUser); null means signed-out, so keep the persisted value. private async Task ResolveOriginUser(LexboxServer? server, CurrentProjectService currentProjectService, ProjectData projectData) From 38fa4ecdc21cb6d5ff1b5de98af54c169ca9ca75 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Mon, 27 Jul 2026 17:55:59 +0200 Subject: [PATCH 9/9] Don't let a cached-user read failure block project open GetCachedUser reads the MSAL cache on the synchronous open path, so a cache-read failure would fail OpenCrdtProject. Wrap it: log and keep the persisted origin user, matching the existing signed-out fallback. Co-Authored-By: Claude Opus 4.8 --- .../Services/ProjectServicesProvider.cs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/backend/FwLite/FwLiteShared/Services/ProjectServicesProvider.cs b/backend/FwLite/FwLiteShared/Services/ProjectServicesProvider.cs index de24321b30..1544b7000c 100644 --- a/backend/FwLite/FwLiteShared/Services/ProjectServicesProvider.cs +++ b/backend/FwLite/FwLiteShared/Services/ProjectServicesProvider.cs @@ -18,7 +18,8 @@ public class ProjectServicesProvider( IServiceProvider serviceProvider, LexboxProjectService lexboxProjectService, OAuthClientFactory oAuthClientFactory, - IEnumerable projectProviders + IEnumerable projectProviders, + ILogger logger ): IAsyncDisposable { private IProjectProvider? FwDataProjectProvider => @@ -91,7 +92,17 @@ private async Task ResolveOriginUser(LexboxServer? server, ProjectData projectData) { if (server is null) return projectData; - var currentUser = await oAuthClientFactory.GetClient(server).GetCachedUser(); + LexboxUser? currentUser; + try + { + currentUser = await oAuthClientFactory.GetClient(server).GetCachedUser(); + } + catch (Exception e) + { + // Best-effort: a cache-read failure must not block opening the project. + logger.LogWarning(e, "Failed to read cached user for {ProjectName}; keeping persisted origin user", projectData.Name); + return projectData; + } if (currentUser is null) return projectData; await currentProjectService.UpdateLastUser(currentUser.Name, currentUser.Id); return await currentProjectService.GetProjectData();