Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backend/FwLite/FwLiteShared/Events/IFwEvent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ namespace FwLiteShared.Events;
[JsonDerivedType(typeof(AppUpdateEvent), nameof(AppUpdateEvent))]
[JsonDerivedType(typeof(AppUpdateProgressEvent), nameof(AppUpdateProgressEvent))]
[JsonDerivedType(typeof(UserNotificationEvent), nameof(UserNotificationEvent))]
[JsonDerivedType(typeof(ProjectRoleChangedEvent), nameof(ProjectRoleChangedEvent))]
public interface IFwEvent
{
FwEventType Type { get; }
Expand All @@ -29,4 +30,5 @@ public enum FwEventType
AppUpdate,
AppUpdateProgress,
UserNotification,
ProjectRoleChanged,
}
13 changes: 13 additions & 0 deletions backend/FwLite/FwLiteShared/Events/ProjectRoleChangedEvent.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using LcmCrdt;

namespace FwLiteShared.Events;

/// <summary>
/// The current user's role on the project changed (e.g. refreshed from the server during sync).
/// </summary>
public class ProjectRoleChangedEvent(UserProjectRole role) : IFwEvent
{
public FwEventType Type => FwEventType.ProjectRoleChanged;
public bool IsGlobal => false;
public UserProjectRole Role { get; init; } = role;
}
32 changes: 9 additions & 23 deletions backend/FwLite/FwLiteShared/Projects/CombinedProjectsService.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Text.Json.Serialization;
using FwLiteShared.Auth;
using FwLiteShared.Events;
using FwLiteShared.KeepAwake;
using FwLiteShared.Sync;
using LcmCrdt;
Expand Down Expand Up @@ -45,7 +46,8 @@ public class CombinedProjectsService(LexboxProjectService lexboxProjectService,
CrdtProjectsService crdtProjectsService,
IEnumerable<IProjectProvider> projectProviders,
OAuthClientFactory oAuthClientFactory,
IKeepAwake keepAwake)
IKeepAwake keepAwake,
ProjectEventBus projectEventBus)
{
private IProjectProvider? FwDataProjectProvider => projectProviders.FirstOrDefault(p => p.DataFormat == ProjectDataFormat.FwData);
[JSInvokable]
Expand Down Expand Up @@ -89,7 +91,9 @@ private async Task UpdateProjectServerInfo(FieldWorksLiteProject[] lexboxProject
{
var localProject = crdtProjectsService.GetProject(serverProject.Id);
if (localProject?.Data is null) continue;
await crdtProjectsService.UpdateProjectServerInfo(localProject, lexboxUser?.Name, lexboxUser?.Id, ToRole(serverProject.Role));
var role = serverProject.Role.ToUserProjectRole();
if (await crdtProjectsService.UpdateProjectServerInfo(localProject, lexboxUser?.Name, lexboxUser?.Id, role))
projectEventBus.PublishEvent(localProject, new ProjectRoleChangedEvent(role));
}
}

Expand All @@ -114,7 +118,7 @@ public IReadOnlyCollection<ProjectModel> LocalProjects()
true,
false,
p.Data?.OriginDomain is not null,
p.Data is null ? ProjectRole.Unknown : FromRole(p.Data.Role),
p.Data is null ? ProjectRole.Unknown : p.Data.Role.ToProjectRole(),
lexboxProjectService.GetServer(p.Data),
p.Data?.Id));
//basically populate projects and indicate if they are lexbox or fwdata
Expand All @@ -137,24 +141,6 @@ public IReadOnlyCollection<ProjectModel> LocalProjects()
return projects.Values;
}

private UserProjectRole ToRole(ProjectRole role) =>
role switch
{
ProjectRole.Observer => UserProjectRole.Observer,
ProjectRole.Editor => UserProjectRole.Editor,
ProjectRole.Manager => UserProjectRole.Manager,
_ => UserProjectRole.Unknown
};

private ProjectRole FromRole(UserProjectRole role) =>
role switch
{
UserProjectRole.Observer => ProjectRole.Observer,
UserProjectRole.Editor => ProjectRole.Editor,
UserProjectRole.Manager => ProjectRole.Manager,
_ => ProjectRole.Unknown
};

[JSInvokable]
public async Task<DownloadProjectByCodeResult> DownloadProjectByCode(string code, LexboxServer server, UserProjectRole? userRole = null)
{
Expand All @@ -169,7 +155,7 @@ public async Task<DownloadProjectByCodeResult> DownloadProjectByCode(string code
{
var (status, projectId) = await lexboxProjectService.GetLexboxProjectId(server, code);
if (status != DownloadProjectByCodeResult.Success) return status;
var role = userRole.HasValue ? FromRole(userRole.Value) : ProjectRole.Editor;
var role = userRole?.ToProjectRole() ?? ProjectRole.Editor;
project = new ProjectModel(
Name: code,
Code: code,
Expand Down Expand Up @@ -207,7 +193,7 @@ await keepAwake.RunAsync(
},
AuthenticatedUser: currentUser?.Name,
AuthenticatedUserId: currentUser?.Id,
Role: ToRole(project.Role)))));
Role: project.Role.ToUserProjectRole()))));
}

[JSInvokable]
Expand Down
25 changes: 25 additions & 0 deletions backend/FwLite/FwLiteShared/Projects/ProjectRoleMapping.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using LcmCrdt;
using LexCore.Entities;

namespace FwLiteShared.Projects;

public static class ProjectRoleMapping
{
public static UserProjectRole ToUserProjectRole(this ProjectRole role) =>
role switch
{
ProjectRole.Observer => UserProjectRole.Observer,
ProjectRole.Editor => UserProjectRole.Editor,
ProjectRole.Manager => UserProjectRole.Manager,
_ => UserProjectRole.Unknown
};

public static ProjectRole ToProjectRole(this UserProjectRole role) =>
role switch
{
UserProjectRole.Observer => ProjectRole.Observer,
UserProjectRole.Editor => ProjectRole.Editor,
UserProjectRole.Manager => ProjectRole.Manager,
_ => ProjectRole.Unknown
};
}
5 changes: 2 additions & 3 deletions backend/FwLite/FwLiteShared/Services/MiniLcmJsInvokable.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,8 @@ public MiniLcmFeatures SupportedFeatures()
return new(History: isCrdtProject, Write: CanWrite, OpenWithFlex: isFwDataProject, Feedback: true, Sync: SupportsSync, Audio: true, CustomViews: isCrdtProject, Comments: isCrdtProject);
}

private bool CanWrite =>
project is not CrdtProject crdt ||
(crdt.Data?.Role ?? UserProjectRole.Editor) is UserProjectRole.Editor or UserProjectRole.Manager;
// Not CrdtProject.Data: that's a snapshot from when the project was opened, and a sync can update the role afterwards.
private bool CanWrite => api is not CrdtMiniLcmApi crdtApi || !crdtApi.ProjectData.IsReadonly;

//todo move info notify wrapper factory
private void OnDataChanged()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,8 @@ private static async Task<string> ExtractErrorMessage(HttpResponseMessage respon
[JSInvokable]
public Task<SyncResults> ExecuteSync(bool skipNotifications)
{
return Task.Run(async () => await syncService.ExecuteSync(skipNotifications));
// Only the sync dialog calls this, so it's a sync the user asked for
return Task.Run(async () => await syncService.ExecuteSync(skipNotifications, refreshServerProjects: true));
}

[JSInvokable]
Expand Down
26 changes: 25 additions & 1 deletion backend/FwLite/FwLiteShared/Sync/SyncService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@ public async Task<SyncResults> SafeExecuteSync(bool skipNotifications = false)
}
}

public async Task<SyncResults> ExecuteSync(bool skipNotifications = false)
/// <param name="refreshServerProjects">Bypass the cached server project list, so a sync the user asked for picks up a role change right away.</param>
public async Task<SyncResults> ExecuteSync(bool skipNotifications = false, bool refreshServerProjects = false)
{
var project = await currentProjectService.GetProjectData();
if (string.IsNullOrEmpty(project.OriginDomain))
Expand Down Expand Up @@ -109,6 +110,7 @@ public async Task<SyncResults> ExecuteSync(bool skipNotifications = false)
UpdateSyncStatus(SyncStatus.Success);
await ApplySyncedCommentReadStatus(syncResults, project.LastUserId);
await syncRepository.UpdateSyncDate(syncDate);
await TryRefreshUserRole(server, project, refreshServerProjects);
// 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
// short-circuits and this is effectively a no-op.
Expand All @@ -134,6 +136,28 @@ public async Task<SyncResults> ExecuteSync(bool skipNotifications = false)
}
}

// The role can change on the server at any time; the project list is cached, so most syncs don't hit the server.
// Best-effort: the sync has already succeeded, so a failure here must not fail it or skip what follows.
private async Task TryRefreshUserRole(LexboxServer server, ProjectData project, bool refreshServerProjects)
{
try
{
if (refreshServerProjects) lexboxProjectService.InvalidateProjectsCache(server);
var serverProject = (await lexboxProjectService.GetLexboxProjects(server)).Projects
.FirstOrDefault(p => p.Id == project.Id);
// Not listed: either the fetch failed (an empty list) or the user lost access. Keep the current role,
// downgrading on a failed fetch would lock users out whenever the list can't be loaded.
if (serverProject is null) return;
var role = serverProject.Role.ToUserProjectRole();
if (await currentProjectService.UpdateUserRole(role))
changeEventBus.PublishEvent(currentProjectService.Project, new ProjectRoleChangedEvent(role));
}
catch (Exception e)
{
logger.LogWarning(e, "Failed to refresh the user's role after sync");
}
}

private async Task TryEnsureProjectChangeListener(ProjectData project, CancellationToken cancellationToken = default)
{
try
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,6 @@ private static void ConfigureFwLiteSharedTypes(ConfigurationBuilder builder)
typeof(PendingCommits),
typeof(LexboxServer),
typeof(CrdtProject),
typeof(ProjectData),
typeof(IProjectIdentifier),
typeof(FwLiteConfig),
typeof(HistoryLineItem),
Expand All @@ -202,6 +201,11 @@ private static void ConfigureFwLiteSharedTypes(ConfigurationBuilder builder)
typeof(HarmonyResource<LcmFileMetadata>),
typeof(CameraResult),
], exportBuilder => exportBuilder.WithPublicProperties());
builder.ExportAsInterface<ProjectData>().WithPublicProperties(propertyBuilder =>
{
if (propertyBuilder.Member.GetCustomAttribute<JsonIgnoreAttribute>() is { Condition: JsonIgnoreCondition.Always })
propertyBuilder.Ignore();
});

builder.ExportAsClass<ChangeType>().WithCodeGenerator<ChangeTypesCodeGenerator>();
builder.ExportAsInterface<ActivityChangeType>()
Expand Down
30 changes: 30 additions & 0 deletions backend/FwLite/LcmCrdt.Tests/UpdateUserRoleTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
using LcmCrdt.Project;
using LcmCrdt.Tests.MiniLcmTests;

namespace LcmCrdt.Tests;

public class UpdateUserRoleTests(MiniLcmApiFixture fixture) : IClassFixture<MiniLcmApiFixture>
{
[Fact]
public async Task UpdateUserRole_ReportsWhetherTheRoleChanged()
{
var projectService = fixture.GetService<CurrentProjectService>();
await projectService.UpdateUserRole(UserProjectRole.Editor);

(await projectService.UpdateUserRole(UserProjectRole.Editor)).Should().BeFalse("the role is unchanged");
(await projectService.UpdateUserRole(UserProjectRole.Observer)).Should().BeTrue();
}

[Fact]
public async Task UpdateUserRole_UpdatesTheSharedProjectDataCache()
{
// MiniLcmJsInvokable reads write access from this cache, so it must see a role change made in another scope (sync)
var projectService = fixture.GetService<CurrentProjectService>();
await projectService.UpdateUserRole(UserProjectRole.Editor);

await projectService.UpdateUserRole(UserProjectRole.Observer);

fixture.GetService<ProjectDataCache>().CachedProjectData(projectService.Project)!.Role
.Should().Be(UserProjectRole.Observer);
}
}
2 changes: 2 additions & 0 deletions backend/FwLite/LcmCrdt/CrdtProject.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ public record ProjectData(string Name, string Code, Guid Id, string? OriginDomai
}

public string? ServerId => Uri.TryCreate(OriginDomain, UriKind.Absolute, out var uri) ? uri.Authority : null;
// Not sent to the viewer: it reads permissions from the project's features, which stay current when the role changes
[JsonIgnore]
public bool IsReadonly => Role is not UserProjectRole.Editor and not UserProjectRole.Manager;
}

Expand Down
9 changes: 6 additions & 3 deletions backend/FwLite/LcmCrdt/CrdtProjectsService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -85,17 +85,20 @@ public async ValueTask EnsureProjectDataCacheIsLoaded()
await Task.WhenAll(tasks);
}

public async ValueTask UpdateProjectServerInfo(CrdtProject project,
/// <returns>true if the role changed</returns>
public async ValueTask<bool> UpdateProjectServerInfo(CrdtProject project,
string? userName,
string? userId,
UserProjectRole role)
{
if (project.Data?.LastUserName == userName && project.Data?.LastUserId == userId && project.Data?.Role == role) return;
if (project.Data?.LastUserName == userName && project.Data?.LastUserId == userId && project.Data?.Role == role) return false;
var roleChanged = false;
await ExecInProject(project, async (scopedServices, currentProjectService) =>
{
await currentProjectService.UpdateLastUser(userName, userId);
await currentProjectService.UpdateUserRole(role);
roleChanged = await currentProjectService.UpdateUserRole(role);
});
return roleChanged;
}

public IEnumerable<CrdtProject> ListProjects()
Expand Down
11 changes: 6 additions & 5 deletions backend/FwLite/LcmCrdt/CurrentProjectService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ public async ValueTask<ProjectData> SetupProjectContext(CrdtProject project)
_project = project;
//migrate will only execute once with a static which tracks if we've already migrated
await MigrateDb();
return project.Data = await RefreshProjectData();
return await RefreshProjectData();
}

public async ValueTask<ProjectData> SetupProjectContext(string projectCode)
Expand All @@ -87,8 +87,7 @@ public async ValueTask<ProjectData> SetupProjectContext(string projectCode)

public async ValueTask<ProjectData> RefreshProjectData()
{
var projectData = await GetProjectData(true);
return projectData;
return Project.Data = await GetProjectData(true);
}

private static readonly ConcurrentDictionary<string, Lazy<Task>> MigrationTasks = [];
Expand Down Expand Up @@ -153,12 +152,14 @@ await dbContext.ProjectData.ExecuteUpdateAsync(calls => calls
}
}

public async Task UpdateUserRole(UserProjectRole role)
/// <returns>true if the role changed</returns>
public async Task<bool> UpdateUserRole(UserProjectRole role)
{
if (ProjectData.Role == role) return;
if (ProjectData.Role == role) return false;
await using var dbContext = await DbContextFactory.CreateDbContextAsync();
await dbContext.ProjectData.ExecuteUpdateAsync(calls => calls
.SetProperty(p => p.Role, role));
await RefreshProjectData();
return true;
}
}
3 changes: 3 additions & 0 deletions frontend/viewer/src/DotnetProjectView.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
} from '$lib/dotnet-types/generated-types/FwLiteShared/Services/ISyncServiceJsInvokable';
import {initProjectContext} from '$project/project-context.svelte';
import {initProjectStorage} from '$lib/storage';
import {useEventBus} from '$lib/services/event-bus';
import type {IMediaFilesServiceJsInvokable} from '$lib/dotnet-types/generated-types/FwLiteShared/Services/IMediaFilesServiceJsInvokable';

const projectServicesProvider = useProjectServicesProvider();
Expand All @@ -26,6 +27,8 @@
const code = untrack(() => _code);
projectContext.projectCode = code;
projectContext.inParatext = untrack(() => paratext);
// Before openCrdtProject (in onMount): opening triggers a sync that can change the role.
projectContext.watchProjectRole(useEventBus());

initProjectStorage(code);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export enum FwEventType {
Sync = "Sync",
AppUpdate = "AppUpdate",
AppUpdateProgress = "AppUpdateProgress",
UserNotification = "UserNotification"
UserNotification = "UserNotification",
ProjectRoleChanged = "ProjectRoleChanged"
}
/* eslint-enable */
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/* eslint-disable */
// This code was generated by a Reinforced.Typings tool.
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.

import type {IFwEvent} from './IFwEvent';
import type {FwEventType} from './FwEventType';
import type {UserProjectRole} from '../../LcmCrdt/UserProjectRole';

export interface IProjectRoleChangedEvent extends IFwEvent
{
type: FwEventType;
isGlobal: boolean;
role: UserProjectRole;
}
/* eslint-enable */
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export * from './IEntriesChangedEvent';
export * from './IFwEvent';
export * from './IJsEventListener';
export * from './IProjectEvent';
export * from './IProjectRoleChangedEvent';
export * from './ISyncEvent';
export * from './IUserNotificationEvent';
export * from './UserNotificationDuration';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,5 @@ export interface IProjectData
lastUserId?: string;
role: UserProjectRole;
serverId?: string;
isReadonly: boolean;
}
/* eslint-enable */
Loading
Loading