Skip to content

Commit 0cdfd99

Browse files
hahn-kevmyieye
andauthored
add observer role for fw lite projects (#1710)
* add the observer role and let observers download fw lite projects * update FW lite to respect the user role * update user and role info when fetching projects from the server * make frontend respect the write feature * don't return projects where the user is an observer from the legacy project api * throw when trying to write to a readonly project. Ensure Role has the default value Editor. * always show observer role for beta users --------- Co-authored-by: Tim Haasdyk <tim_haasdyk@sil.org>
1 parent 56eeb94 commit 0cdfd99

44 files changed

Lines changed: 1023 additions & 136 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

backend/FwHeadless/Services/SyncHostedService.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,7 @@ static async Task<CrdtProject> SetupCrdtProject(string crdtFile,
229229
Id: projectId,
230230
Path: projectFolder,
231231
FwProjectId: fwProjectId,
232+
Role: UserProjectRole.Editor,
232233
Domain: new Uri(lexboxUrl)));
233234
}
234235

backend/FwLite/FwLiteShared/Projects/CombinedProjectsService.cs

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using FwLiteShared.Auth;
22
using FwLiteShared.Sync;
33
using LcmCrdt;
4+
using LexCore.Entities;
45
using Microsoft.Extensions.DependencyInjection;
56
using Microsoft.JSInterop;
67
using MiniLcm.Models;
@@ -14,6 +15,7 @@ public record ProjectModel(
1415
bool Crdt,
1516
bool Fwdata,
1617
bool Lexbox = false,
18+
ProjectRole Role = ProjectRole.Editor,
1719
LexboxServer? Server = null,
1820
Guid? Id = null)
1921
{
@@ -54,18 +56,31 @@ private async Task<ProjectModel[]> ServerProjects(LexboxServer server, bool forc
5456
{
5557
if (forceRefresh) lexboxProjectService.InvalidateProjectsCache(server);
5658
var lexboxProjects = await lexboxProjectService.GetLexboxProjects(server);
59+
await UpdateProjectServerInfo(lexboxProjects, await lexboxProjectService.GetLexboxUser(server));
5760
var projectModels = lexboxProjects.Select(p => new ProjectModel(
5861
p.Name,
5962
p.Code,
6063
Crdt: p.IsCrdtProject,
6164
Fwdata: false,
6265
Lexbox: true,
66+
Role: p.Role,
6367
server,
6468
p.Id))
6569
.ToArray();
6670
return projectModels;
6771
}
6872

73+
private async Task UpdateProjectServerInfo(FieldWorksLiteProject[] lexboxProjects, LexboxUser? lexboxUser)
74+
{
75+
foreach (var serverProject in lexboxProjects)
76+
{
77+
var localProject = crdtProjectsService.GetProject(serverProject.Id);
78+
if (localProject?.Data is null) continue;
79+
await crdtProjectsService.UpdateProjectServerInfo(localProject, lexboxUser?.Name, lexboxUser?.Id, ToRole(serverProject.Role));
80+
}
81+
}
82+
83+
6984
[JSInvokable]
7085
public async Task<ProjectModel[]> ServerProjects(string serverId, bool forceRefresh)
7186
{
@@ -87,6 +102,7 @@ public async ValueTask<IReadOnlyCollection<ProjectModel>> LocalProjects()
87102
true,
88103
false,
89104
p.Data?.OriginDomain is not null,
105+
p.Data is null ? ProjectRole.Unknown : FromRole(p.Data.Role),
90106
lexboxProjectService.GetServer(p.Data),
91107
p.Data?.Id));
92108
//basically populate projects and indicate if they are lexbox or fwdata
@@ -109,6 +125,24 @@ public async ValueTask<IReadOnlyCollection<ProjectModel>> LocalProjects()
109125
return projects.Values;
110126
}
111127

128+
private UserProjectRole ToRole(ProjectRole role) =>
129+
role switch
130+
{
131+
ProjectRole.Observer => UserProjectRole.Observer,
132+
ProjectRole.Editor => UserProjectRole.Editor,
133+
ProjectRole.Manager => UserProjectRole.Manager,
134+
_ => UserProjectRole.Unknown
135+
};
136+
137+
private ProjectRole FromRole(UserProjectRole role) =>
138+
role switch
139+
{
140+
UserProjectRole.Observer => ProjectRole.Observer,
141+
UserProjectRole.Editor => ProjectRole.Editor,
142+
UserProjectRole.Manager => ProjectRole.Manager,
143+
_ => ProjectRole.Unknown
144+
};
145+
112146
public async Task DownloadProject(string code, LexboxServer server)
113147
{
114148
var serverProjects = await ServerProjects(server, false);
@@ -133,7 +167,8 @@ await crdtProjectsService.CreateProject(new(project.Name,
133167
},
134168
SeedNewProjectData: false,
135169
AuthenticatedUser: currentUser?.Name,
136-
AuthenticatedUserId: currentUser?.Id));
170+
AuthenticatedUserId: currentUser?.Id,
171+
Role: ToRole(project.Role)));
137172
}
138173

139174
[JSInvokable]

backend/FwLite/FwLiteShared/Projects/LexboxProjectService.cs

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
using System.Net.Http.Json;
1+
using System.Net.Http.Json;
22
using FwLiteShared.Auth;
33
using FwLiteShared.Events;
44
using FwLiteShared.Sync;
55
using LcmCrdt;
6+
using LexCore.Entities;
67
using LexCore.Sync;
78
using Microsoft.AspNetCore.SignalR.Client;
89
using Microsoft.Extensions.Caching.Memory;
@@ -52,8 +53,6 @@ public void Dispose()
5253
onAuthChangedSubscription.Dispose();
5354
}
5455

55-
public record LexboxProject(Guid Id, string Code, string Name, bool IsFwDataProject, bool IsCrdtProject);
56-
5756
public LexboxServer[] Servers()
5857
{
5958
return options.Value.LexboxServers;
@@ -65,7 +64,7 @@ public LexboxServer[] Servers()
6564
return Servers().FirstOrDefault(s => s.Id == projectData.ServerId);
6665
}
6766

68-
public async Task<LexboxProject[]> GetLexboxProjects(LexboxServer server)
67+
public async Task<FieldWorksLiteProject[]> GetLexboxProjects(LexboxServer server)
6968
{
7069
return await cache.GetOrCreateAsync(CacheKey(server),
7170
async entry =>
@@ -75,7 +74,7 @@ public async Task<LexboxProject[]> GetLexboxProjects(LexboxServer server)
7574
if (httpClient is null) return [];
7675
try
7776
{
78-
return await httpClient.GetFromJsonAsync<LexboxProject[]>("api/crdt/listProjects") ?? [];
77+
return await httpClient.GetFromJsonAsync<FieldWorksLiteProject[]>("api/crdt/listProjects") ?? [];
7978
}
8079
catch (Exception e)
8180
{
@@ -85,6 +84,11 @@ public async Task<LexboxProject[]> GetLexboxProjects(LexboxServer server)
8584
}) ?? [];
8685
}
8786

87+
public async Task<LexboxUser?> GetLexboxUser(LexboxServer server)
88+
{
89+
return await clientFactory.GetClient(server).GetCurrentUser();
90+
}
91+
8892
private static string CacheKey(LexboxServer server)
8993
{
9094
return $"Projects|{server.Authority.Authority}";

backend/FwLite/FwLiteShared/Services/MiniLcmJsInvokable.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,13 @@ public MiniLcmFeatures SupportedFeatures()
2121
{
2222
var isCrdtProject = project.DataFormat == ProjectDataFormat.Harmony;
2323
var isFwDataProject = project.DataFormat == ProjectDataFormat.FwData;
24-
return new(History: isCrdtProject, Write: true, OpenWithFlex: isFwDataProject, Feedback: true, Sync: SupportsSync);
24+
return new(History: isCrdtProject, Write: CanWrite, OpenWithFlex: isFwDataProject, Feedback: true, Sync: SupportsSync);
2525
}
2626

27+
private bool CanWrite =>
28+
project is not CrdtProject crdt ||
29+
(crdt.Data?.Role ?? UserProjectRole.Editor) is UserProjectRole.Editor or UserProjectRole.Manager;
30+
2731
//todo move info notify wrapper factory
2832
private void OnDataChanged()
2933
{

backend/FwLite/FwLiteShared/TypeGen/ReinforcedFwLiteTypingConfig.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
using FwLiteShared.Projects;
88
using FwLiteShared.Services;
99
using LcmCrdt;
10+
using LexCore.Entities;
1011
using LexCore.Sync;
1112
using Microsoft.JSInterop;
1213
using MiniLcm;
@@ -111,6 +112,8 @@ private static void ConfigureFwLiteSharedTypes(ConfigurationBuilder builder)
111112
builder.ExportAsEnum<FwLitePlatform>().UseString();
112113
builder.ExportAsEnum<ProjectSyncStatusEnum>().UseString();
113114
builder.ExportAsEnum<ProjectDataFormat>();
115+
builder.ExportAsEnum<UserProjectRole>().UseString();
116+
builder.ExportAsEnum<ProjectRole>().UseString();
114117
var serviceTypes = Enum.GetValues<DotnetService>()
115118
//lcm has it's own dedicated export, config is not a service just a object, and testing needs a custom export below
116119
.Where(s => s is not (DotnetService.MiniLcmApi or DotnetService.FwLiteConfig or DotnetService.TroubleshootingService))

backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyDbModel.verified.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@
99
LastUserName (string)
1010
Name (string) Required
1111
OriginDomain (string)
12+
Role (UserProjectRole) Required ValueGenerated.OnAdd
13+
Annotations:
14+
Relational:DefaultValue: Editor
1215
Keys:
1316
Id PK
1417
Annotations:

backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System.Data;
12
using System.Linq.Expressions;
23
using FluentValidation;
34
using Gridify;
@@ -57,6 +58,7 @@ private CommitMetadata NewMetadata()
5758
}
5859
private async Task<Commit> AddChange(IChange change)
5960
{
61+
AssertWritable();
6062
var commit = await dataModel.AddChange(ClientId, change, commitMetadata: NewMetadata());
6163
return commit;
6264
}
@@ -66,12 +68,19 @@ private async Task AddChanges(IEnumerable<IChange> changes)
6668
await AddChanges(changes.Chunk(100));
6769
}
6870

71+
private void AssertWritable()
72+
{
73+
if (ProjectData.IsReadonly)
74+
throw new ReadOnlyException($"project is readonly because you are logged in with the {ProjectData.Role} role. If your role recently changed, try refreshing the server project list on the home page.");
75+
}
76+
6977
/// <summary>
7078
/// use when making a large number of changes at once
7179
/// </summary>
7280
/// <param name="changeChunks"></param>
7381
private async Task AddChanges(IEnumerable<IChange[]> changeChunks)
7482
{
83+
AssertWritable();
7584
await using var transaction = await dbContext.Database.BeginTransactionAsync();
7685
foreach (var chunk in changeChunks)
7786
{
@@ -334,7 +343,7 @@ public async Task MoveComplexFormComponent(ComplexFormComponent component, Betwe
334343
{
335344
var betweenIds = new BetweenPosition(between.Previous?.Id, between.Next?.Id);
336345
var order = await OrderPicker.PickOrder(ComplexFormComponents.Where(s => s.ComplexFormEntryId == component.ComplexFormEntryId), betweenIds);
337-
await dataModel.AddChange(ClientId, new Changes.SetOrderChange<ComplexFormComponent>(component.Id, order));
346+
await AddChange(new Changes.SetOrderChange<ComplexFormComponent>(component.Id, order));
338347
}
339348

340349
public async Task DeleteComplexFormComponent(ComplexFormComponent complexFormComponent)
@@ -636,7 +645,7 @@ public async Task<Sense> CreateSense(Guid entryId, Sense sense, BetweenPosition?
636645
throw new InvalidOperationException($"Part of speech must exist when creating a sense (could not find GUID {sense.PartOfSpeechId.Value})");
637646

638647
sense.Order = await OrderPicker.PickOrder(Senses.Where(s => s.EntryId == entryId), between);
639-
await dataModel.AddChanges(ClientId, await CreateSenseChanges(entryId, sense).ToArrayAsync());
648+
await AddChanges(await CreateSenseChanges(entryId, sense).ToArrayAsync());
640649
return await GetSense(entryId, sense.Id) ?? throw new NullReferenceException("unable to find sense " + sense.Id);
641650
}
642651

@@ -646,7 +655,7 @@ public async Task<Sense> UpdateSense(Guid entryId,
646655
{
647656
var sense = await GetSense(entryId, senseId);
648657
if (sense is null) throw new NullReferenceException($"unable to find sense with id {senseId}");
649-
await dataModel.AddChanges(ClientId, [..sense.ToChanges(update.Patch)]);
658+
await AddChanges([..sense.ToChanges(update.Patch)]);
650659
return await GetSense(entryId, senseId) ?? throw new NullReferenceException("unable to find sense with id " + senseId);
651660
}
652661

backend/FwLite/LcmCrdt/CrdtProject.cs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,22 @@ public CrdtProject(string code, string dbPath, IMemoryCache memoryCache) : this(
2727
/// <param name="OriginDomain">Server to sync with, null if not synced</param>
2828
/// <param name="ClientId">Unique id for this client machine</param>
2929
/// <param name="FwProjectId">FieldWorks project id, aka LangProjectId</param>
30-
public record ProjectData(string Name, string Code, Guid Id, string? OriginDomain, Guid ClientId, Guid? FwProjectId = null, string? LastUserName = null, string? LastUserId = null)
30+
public record ProjectData(string Name, string Code, Guid Id, string? OriginDomain, Guid ClientId, Guid? FwProjectId = null, string? LastUserName = null, string? LastUserId = null,
31+
UserProjectRole Role = UserProjectRole.Unknown)
3132
{
3233
public static string? GetOriginDomain(Uri? uri)
3334
{
3435
return uri?.GetComponents(UriComponents.SchemeAndServer, UriFormat.Unescaped);
3536
}
3637

3738
public string? ServerId => OriginDomain is not null ? new Uri(OriginDomain).Authority : null;
39+
public bool IsReadonly => Role is not UserProjectRole.Editor and not UserProjectRole.Manager;
40+
}
41+
42+
public enum UserProjectRole
43+
{
44+
Unknown,
45+
Manager,
46+
Editor,
47+
Observer
3848
}

backend/FwLite/LcmCrdt/CrdtProjectsService.cs

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,21 @@ public async ValueTask EnsureProjectDataCacheIsLoaded()
4646
await Task.WhenAll(tasks);
4747
}
4848

49+
public async ValueTask UpdateProjectServerInfo(CrdtProject project,
50+
string? userName,
51+
string? userId,
52+
UserProjectRole role)
53+
{
54+
if (project.Data?.LastUserName == userName && project.Data?.LastUserId == userId && project.Data?.Role == role) return;
55+
await using var scope = provider.CreateAsyncScope();
56+
var scopedServices = scope.ServiceProvider;
57+
var currentProjectService = scopedServices.GetRequiredService<CurrentProjectService>();
58+
await currentProjectService.SetupProjectContext(project);
59+
60+
await currentProjectService.UpdateLastUser(userName, userId);
61+
await currentProjectService.UpdateUserRole(role);
62+
}
63+
4964
public IEnumerable<CrdtProject> ListProjects()
5065
{
5166
return Directory.EnumerateFiles(config.Value.ProjectPath, "*.sqlite").Select(file =>
@@ -82,7 +97,8 @@ public record CreateProjectRequest(
8297
string? Path = null,
8398
Guid? FwProjectId = null,
8499
string? AuthenticatedUser = null,
85-
string? AuthenticatedUserId = null);
100+
string? AuthenticatedUserId = null,
101+
UserProjectRole? Role = null);
86102

87103
public async Task<CrdtProject> CreateExampleProject(string name)
88104
{
@@ -121,7 +137,7 @@ public async Task<CrdtProject> CreateProject(CreateProjectRequest request)
121137
code,
122138
request.Id ?? Guid.NewGuid(),
123139
ProjectData.GetOriginDomain(request.Domain),
124-
Guid.NewGuid(), request.FwProjectId, request.AuthenticatedUser, request.AuthenticatedUserId);
140+
Guid.NewGuid(), request.FwProjectId, request.AuthenticatedUser, request.AuthenticatedUserId, request.Role ?? UserProjectRole.Editor);
125141
crdtProject.Data = projectData;
126142
await InitProjectDb(db, projectData);
127143
await currentProjectService.RefreshProjectData();

backend/FwLite/LcmCrdt/CurrentProjectService.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,4 +135,12 @@ await DbContext.ProjectData.ExecuteUpdateAsync(calls => calls
135135
await RefreshProjectData();
136136
}
137137
}
138+
139+
public async Task UpdateUserRole(UserProjectRole role)
140+
{
141+
if (ProjectData.Role == role) return;
142+
await DbContext.ProjectData.ExecuteUpdateAsync(calls => calls
143+
.SetProperty(p => p.Role, role));
144+
await RefreshProjectData();
145+
}
138146
}

0 commit comments

Comments
 (0)