diff --git a/backend/FwHeadless/Services/SyncHostedService.cs b/backend/FwHeadless/Services/SyncHostedService.cs index fb8f88b62f..1ed4b6df43 100644 --- a/backend/FwHeadless/Services/SyncHostedService.cs +++ b/backend/FwHeadless/Services/SyncHostedService.cs @@ -229,6 +229,7 @@ static async Task SetupCrdtProject(string crdtFile, Id: projectId, Path: projectFolder, FwProjectId: fwProjectId, + Role: UserProjectRole.Editor, Domain: new Uri(lexboxUrl))); } diff --git a/backend/FwLite/FwLiteShared/Projects/CombinedProjectsService.cs b/backend/FwLite/FwLiteShared/Projects/CombinedProjectsService.cs index 128640aa08..55793745c2 100644 --- a/backend/FwLite/FwLiteShared/Projects/CombinedProjectsService.cs +++ b/backend/FwLite/FwLiteShared/Projects/CombinedProjectsService.cs @@ -1,6 +1,7 @@ using FwLiteShared.Auth; using FwLiteShared.Sync; using LcmCrdt; +using LexCore.Entities; using Microsoft.Extensions.DependencyInjection; using Microsoft.JSInterop; using MiniLcm.Models; @@ -14,6 +15,7 @@ public record ProjectModel( bool Crdt, bool Fwdata, bool Lexbox = false, + ProjectRole Role = ProjectRole.Editor, LexboxServer? Server = null, Guid? Id = null) { @@ -54,18 +56,31 @@ private async Task ServerProjects(LexboxServer server, bool forc { if (forceRefresh) lexboxProjectService.InvalidateProjectsCache(server); var lexboxProjects = await lexboxProjectService.GetLexboxProjects(server); + await UpdateProjectServerInfo(lexboxProjects, await lexboxProjectService.GetLexboxUser(server)); var projectModels = lexboxProjects.Select(p => new ProjectModel( p.Name, p.Code, Crdt: p.IsCrdtProject, Fwdata: false, Lexbox: true, + Role: p.Role, server, p.Id)) .ToArray(); return projectModels; } + private async Task UpdateProjectServerInfo(FieldWorksLiteProject[] lexboxProjects, LexboxUser? lexboxUser) + { + foreach (var serverProject in lexboxProjects) + { + var localProject = crdtProjectsService.GetProject(serverProject.Id); + if (localProject?.Data is null) continue; + await crdtProjectsService.UpdateProjectServerInfo(localProject, lexboxUser?.Name, lexboxUser?.Id, ToRole(serverProject.Role)); + } + } + + [JSInvokable] public async Task ServerProjects(string serverId, bool forceRefresh) { @@ -87,6 +102,7 @@ public async ValueTask> LocalProjects() true, false, p.Data?.OriginDomain is not null, + p.Data is null ? ProjectRole.Unknown : FromRole(p.Data.Role), lexboxProjectService.GetServer(p.Data), p.Data?.Id)); //basically populate projects and indicate if they are lexbox or fwdata @@ -109,6 +125,24 @@ public async ValueTask> 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 + }; + public async Task DownloadProject(string code, LexboxServer server) { var serverProjects = await ServerProjects(server, false); @@ -133,7 +167,8 @@ await crdtProjectsService.CreateProject(new(project.Name, }, SeedNewProjectData: false, AuthenticatedUser: currentUser?.Name, - AuthenticatedUserId: currentUser?.Id)); + AuthenticatedUserId: currentUser?.Id, + Role: ToRole(project.Role))); } [JSInvokable] diff --git a/backend/FwLite/FwLiteShared/Projects/LexboxProjectService.cs b/backend/FwLite/FwLiteShared/Projects/LexboxProjectService.cs index d0fd51587b..bc8b5da07d 100644 --- a/backend/FwLite/FwLiteShared/Projects/LexboxProjectService.cs +++ b/backend/FwLite/FwLiteShared/Projects/LexboxProjectService.cs @@ -1,8 +1,9 @@ -using System.Net.Http.Json; +using System.Net.Http.Json; using FwLiteShared.Auth; using FwLiteShared.Events; using FwLiteShared.Sync; using LcmCrdt; +using LexCore.Entities; using LexCore.Sync; using Microsoft.AspNetCore.SignalR.Client; using Microsoft.Extensions.Caching.Memory; @@ -52,8 +53,6 @@ public void Dispose() onAuthChangedSubscription.Dispose(); } - public record LexboxProject(Guid Id, string Code, string Name, bool IsFwDataProject, bool IsCrdtProject); - public LexboxServer[] Servers() { return options.Value.LexboxServers; @@ -65,7 +64,7 @@ public LexboxServer[] Servers() return Servers().FirstOrDefault(s => s.Id == projectData.ServerId); } - public async Task GetLexboxProjects(LexboxServer server) + public async Task GetLexboxProjects(LexboxServer server) { return await cache.GetOrCreateAsync(CacheKey(server), async entry => @@ -75,7 +74,7 @@ public async Task GetLexboxProjects(LexboxServer server) if (httpClient is null) return []; try { - return await httpClient.GetFromJsonAsync("api/crdt/listProjects") ?? []; + return await httpClient.GetFromJsonAsync("api/crdt/listProjects") ?? []; } catch (Exception e) { @@ -85,6 +84,11 @@ public async Task GetLexboxProjects(LexboxServer server) }) ?? []; } + public async Task GetLexboxUser(LexboxServer server) + { + return await clientFactory.GetClient(server).GetCurrentUser(); + } + private static string CacheKey(LexboxServer server) { return $"Projects|{server.Authority.Authority}"; diff --git a/backend/FwLite/FwLiteShared/Services/MiniLcmJsInvokable.cs b/backend/FwLite/FwLiteShared/Services/MiniLcmJsInvokable.cs index a533d016d3..3603e179cb 100644 --- a/backend/FwLite/FwLiteShared/Services/MiniLcmJsInvokable.cs +++ b/backend/FwLite/FwLiteShared/Services/MiniLcmJsInvokable.cs @@ -21,9 +21,13 @@ public MiniLcmFeatures SupportedFeatures() { var isCrdtProject = project.DataFormat == ProjectDataFormat.Harmony; var isFwDataProject = project.DataFormat == ProjectDataFormat.FwData; - return new(History: isCrdtProject, Write: true, OpenWithFlex: isFwDataProject, Feedback: true, Sync: SupportsSync); + return new(History: isCrdtProject, Write: CanWrite, OpenWithFlex: isFwDataProject, Feedback: true, Sync: SupportsSync); } + private bool CanWrite => + project is not CrdtProject crdt || + (crdt.Data?.Role ?? UserProjectRole.Editor) is UserProjectRole.Editor or UserProjectRole.Manager; + //todo move info notify wrapper factory private void OnDataChanged() { diff --git a/backend/FwLite/FwLiteShared/TypeGen/ReinforcedFwLiteTypingConfig.cs b/backend/FwLite/FwLiteShared/TypeGen/ReinforcedFwLiteTypingConfig.cs index 006478d437..7720b98037 100644 --- a/backend/FwLite/FwLiteShared/TypeGen/ReinforcedFwLiteTypingConfig.cs +++ b/backend/FwLite/FwLiteShared/TypeGen/ReinforcedFwLiteTypingConfig.cs @@ -7,6 +7,7 @@ using FwLiteShared.Projects; using FwLiteShared.Services; using LcmCrdt; +using LexCore.Entities; using LexCore.Sync; using Microsoft.JSInterop; using MiniLcm; @@ -111,6 +112,8 @@ private static void ConfigureFwLiteSharedTypes(ConfigurationBuilder builder) builder.ExportAsEnum().UseString(); builder.ExportAsEnum().UseString(); builder.ExportAsEnum(); + builder.ExportAsEnum().UseString(); + builder.ExportAsEnum().UseString(); var serviceTypes = Enum.GetValues() //lcm has it's own dedicated export, config is not a service just a object, and testing needs a custom export below .Where(s => s is not (DotnetService.MiniLcmApi or DotnetService.FwLiteConfig or DotnetService.TroubleshootingService)) diff --git a/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyDbModel.verified.txt b/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyDbModel.verified.txt index aabceadda6..2fe16d5e88 100644 --- a/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyDbModel.verified.txt +++ b/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyDbModel.verified.txt @@ -9,6 +9,9 @@ LastUserName (string) Name (string) Required OriginDomain (string) + Role (UserProjectRole) Required ValueGenerated.OnAdd + Annotations: + Relational:DefaultValue: Editor Keys: Id PK Annotations: diff --git a/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs b/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs index 33b6ee441b..a1b4b350b3 100644 --- a/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs +++ b/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs @@ -1,3 +1,4 @@ +using System.Data; using System.Linq.Expressions; using FluentValidation; using Gridify; @@ -57,6 +58,7 @@ private CommitMetadata NewMetadata() } private async Task AddChange(IChange change) { + AssertWritable(); var commit = await dataModel.AddChange(ClientId, change, commitMetadata: NewMetadata()); return commit; } @@ -66,12 +68,19 @@ private async Task AddChanges(IEnumerable changes) await AddChanges(changes.Chunk(100)); } + private void AssertWritable() + { + if (ProjectData.IsReadonly) + 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."); + } + /// /// use when making a large number of changes at once /// /// private async Task AddChanges(IEnumerable changeChunks) { + AssertWritable(); await using var transaction = await dbContext.Database.BeginTransactionAsync(); foreach (var chunk in changeChunks) { @@ -334,7 +343,7 @@ public async Task MoveComplexFormComponent(ComplexFormComponent component, Betwe { var betweenIds = new BetweenPosition(between.Previous?.Id, between.Next?.Id); var order = await OrderPicker.PickOrder(ComplexFormComponents.Where(s => s.ComplexFormEntryId == component.ComplexFormEntryId), betweenIds); - await dataModel.AddChange(ClientId, new Changes.SetOrderChange(component.Id, order)); + await AddChange(new Changes.SetOrderChange(component.Id, order)); } public async Task DeleteComplexFormComponent(ComplexFormComponent complexFormComponent) @@ -620,7 +629,7 @@ public async Task CreateSense(Guid entryId, Sense sense, BetweenPosition? throw new InvalidOperationException($"Part of speech must exist when creating a sense (could not find GUID {sense.PartOfSpeechId.Value})"); sense.Order = await OrderPicker.PickOrder(Senses.Where(s => s.EntryId == entryId), between); - await dataModel.AddChanges(ClientId, await CreateSenseChanges(entryId, sense).ToArrayAsync()); + await AddChanges(await CreateSenseChanges(entryId, sense).ToArrayAsync()); return await GetSense(entryId, sense.Id) ?? throw new NullReferenceException("unable to find sense " + sense.Id); } @@ -630,7 +639,7 @@ public async Task UpdateSense(Guid entryId, { var sense = await GetSense(entryId, senseId); if (sense is null) throw new NullReferenceException($"unable to find sense with id {senseId}"); - await dataModel.AddChanges(ClientId, [..sense.ToChanges(update.Patch)]); + await AddChanges([..sense.ToChanges(update.Patch)]); return await GetSense(entryId, senseId) ?? throw new NullReferenceException("unable to find sense with id " + senseId); } diff --git a/backend/FwLite/LcmCrdt/CrdtProject.cs b/backend/FwLite/LcmCrdt/CrdtProject.cs index dabccdb8ac..2aa4c10af5 100644 --- a/backend/FwLite/LcmCrdt/CrdtProject.cs +++ b/backend/FwLite/LcmCrdt/CrdtProject.cs @@ -27,7 +27,8 @@ public CrdtProject(string code, string dbPath, IMemoryCache memoryCache) : this( /// 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) +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) { @@ -35,4 +36,13 @@ public record ProjectData(string Name, string Code, Guid Id, string? OriginDomai } public string? ServerId => OriginDomain is not null ? new Uri(OriginDomain).Authority : null; + public bool IsReadonly => Role is not UserProjectRole.Editor and not UserProjectRole.Manager; +} + +public enum UserProjectRole +{ + Unknown, + Manager, + Editor, + Observer } diff --git a/backend/FwLite/LcmCrdt/CrdtProjectsService.cs b/backend/FwLite/LcmCrdt/CrdtProjectsService.cs index 0fc53d72cd..1ce8cfb435 100644 --- a/backend/FwLite/LcmCrdt/CrdtProjectsService.cs +++ b/backend/FwLite/LcmCrdt/CrdtProjectsService.cs @@ -46,6 +46,21 @@ public async ValueTask EnsureProjectDataCacheIsLoaded() await Task.WhenAll(tasks); } + public async ValueTask UpdateProjectServerInfo(CrdtProject project, + string? userName, + string? userId, + UserProjectRole role) + { + if (project.Data?.LastUserName == userName && project.Data?.LastUserId == userId && project.Data?.Role == role) return; + await using var scope = provider.CreateAsyncScope(); + var scopedServices = scope.ServiceProvider; + var currentProjectService = scopedServices.GetRequiredService(); + await currentProjectService.SetupProjectContext(project); + + await currentProjectService.UpdateLastUser(userName, userId); + await currentProjectService.UpdateUserRole(role); + } + public IEnumerable ListProjects() { return Directory.EnumerateFiles(config.Value.ProjectPath, "*.sqlite").Select(file => @@ -82,7 +97,8 @@ public record CreateProjectRequest( string? Path = null, Guid? FwProjectId = null, string? AuthenticatedUser = null, - string? AuthenticatedUserId = null); + string? AuthenticatedUserId = null, + UserProjectRole? Role = null); public async Task CreateExampleProject(string name) { @@ -121,7 +137,7 @@ public async Task CreateProject(CreateProjectRequest request) code, request.Id ?? Guid.NewGuid(), ProjectData.GetOriginDomain(request.Domain), - Guid.NewGuid(), request.FwProjectId, request.AuthenticatedUser, request.AuthenticatedUserId); + Guid.NewGuid(), request.FwProjectId, request.AuthenticatedUser, request.AuthenticatedUserId, request.Role ?? UserProjectRole.Editor); crdtProject.Data = projectData; await InitProjectDb(db, projectData); await currentProjectService.RefreshProjectData(); diff --git a/backend/FwLite/LcmCrdt/CurrentProjectService.cs b/backend/FwLite/LcmCrdt/CurrentProjectService.cs index a07f32b637..dc899be14f 100644 --- a/backend/FwLite/LcmCrdt/CurrentProjectService.cs +++ b/backend/FwLite/LcmCrdt/CurrentProjectService.cs @@ -135,4 +135,12 @@ await DbContext.ProjectData.ExecuteUpdateAsync(calls => calls await RefreshProjectData(); } } + + public async Task UpdateUserRole(UserProjectRole role) + { + if (ProjectData.Role == role) return; + await DbContext.ProjectData.ExecuteUpdateAsync(calls => calls + .SetProperty(p => p.Role, role)); + await RefreshProjectData(); + } } diff --git a/backend/FwLite/LcmCrdt/LcmCrdtDbContext.cs b/backend/FwLite/LcmCrdt/LcmCrdtDbContext.cs index a9c000a372..fa624178ff 100644 --- a/backend/FwLite/LcmCrdt/LcmCrdtDbContext.cs +++ b/backend/FwLite/LcmCrdt/LcmCrdtDbContext.cs @@ -25,6 +25,8 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) var projectDataModel = modelBuilder.Entity(); projectDataModel.HasKey(p => p.Id); projectDataModel.Ignore(p => p.ServerId); + //setting default value to handle migration + projectDataModel.Property(p => p.Role).HasConversion>().HasDefaultValue(UserProjectRole.Editor); } protected override void ConfigureConventions(ModelConfigurationBuilder builder) diff --git a/backend/FwLite/LcmCrdt/Migrations/20250530084107_AddProjectDataRole.Designer.cs b/backend/FwLite/LcmCrdt/Migrations/20250530084107_AddProjectDataRole.Designer.cs new file mode 100644 index 0000000000..debe8580bf --- /dev/null +++ b/backend/FwLite/LcmCrdt/Migrations/20250530084107_AddProjectDataRole.Designer.cs @@ -0,0 +1,646 @@ +// +using System; +using System.Collections.Generic; +using LcmCrdt; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace LcmCrdt.Migrations +{ + [DbContext(typeof(LcmCrdtDbContext))] + [Migration("20250530084107_AddProjectDataRole")] + partial class AddProjectDataRole + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.15"); + + modelBuilder.Entity("LcmCrdt.ProjectData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ClientId") + .HasColumnType("TEXT"); + + b.Property("Code") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FwProjectId") + .HasColumnType("TEXT"); + + b.Property("LastUserId") + .HasColumnType("TEXT"); + + b.Property("LastUserName") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OriginDomain") + .HasColumnType("TEXT"); + + b.Property("Role") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Editor"); + + b.HasKey("Id"); + + b.ToTable("ProjectData"); + }); + + modelBuilder.Entity("MiniLcm.Models.ComplexFormComponent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ComplexFormEntryId") + .HasColumnType("TEXT"); + + b.Property("ComplexFormHeadword") + .HasColumnType("TEXT"); + + b.Property("ComponentEntryId") + .HasColumnType("TEXT"); + + b.Property("ComponentHeadword") + .HasColumnType("TEXT"); + + b.Property("ComponentSenseId") + .HasColumnType("TEXT") + .HasColumnName("ComponentSenseId"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("REAL"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ComponentEntryId"); + + b.HasIndex("ComponentSenseId"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.HasIndex("ComplexFormEntryId", "ComponentEntryId") + .IsUnique() + .HasFilter("ComponentSenseId IS NULL"); + + b.HasIndex("ComplexFormEntryId", "ComponentEntryId", "ComponentSenseId") + .IsUnique() + .HasFilter("ComponentSenseId IS NOT NULL"); + + b.ToTable("ComplexFormComponents", (string)null); + }); + + modelBuilder.Entity("MiniLcm.Models.ComplexFormType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("ComplexFormType"); + }); + + modelBuilder.Entity("MiniLcm.Models.Entry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CitationForm") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("ComplexFormTypes") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("LexemeForm") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("LiteralMeaning") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Note") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("PublishIn") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("Entry"); + }); + + modelBuilder.Entity("MiniLcm.Models.ExampleSentence", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("REAL"); + + b.Property("Reference") + .HasColumnType("TEXT"); + + b.Property("SenseId") + .HasColumnType("TEXT"); + + b.Property("Sentence") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.Property("Translation") + .IsRequired() + .HasColumnType("jsonb"); + + b.HasKey("Id"); + + b.HasIndex("SenseId"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("ExampleSentence"); + }); + + modelBuilder.Entity("MiniLcm.Models.PartOfSpeech", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Predefined") + .HasColumnType("INTEGER"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("PartOfSpeech"); + }); + + modelBuilder.Entity("MiniLcm.Models.Publication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("Publication"); + }); + + modelBuilder.Entity("MiniLcm.Models.SemanticDomain", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Code") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Predefined") + .HasColumnType("INTEGER"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("SemanticDomain"); + }); + + modelBuilder.Entity("MiniLcm.Models.Sense", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("EntryId") + .HasColumnType("TEXT"); + + b.Property("Gloss") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Order") + .HasColumnType("REAL"); + + b.Property("PartOfSpeechId") + .HasColumnType("TEXT"); + + b.Property("SemanticDomains") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EntryId"); + + b.HasIndex("PartOfSpeechId"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("Sense"); + }); + + modelBuilder.Entity("MiniLcm.Models.WritingSystem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Abbreviation") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("Exemplars") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Font") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("REAL"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("WsId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.HasIndex("WsId", "Type") + .IsUnique(); + + b.ToTable("WritingSystem"); + }); + + modelBuilder.Entity("SIL.Harmony.Commit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ClientId") + .HasColumnType("TEXT"); + + b.Property("Hash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Metadata") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("ParentHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.ComplexProperty>("HybridDateTime", "SIL.Harmony.Commit.HybridDateTime#HybridDateTime", b1 => + { + b1.IsRequired(); + + b1.Property("Counter") + .HasColumnType("INTEGER") + .HasColumnName("Counter"); + + b1.Property("DateTime") + .HasColumnType("TEXT") + .HasColumnName("DateTime"); + }); + + b.HasKey("Id"); + + b.ToTable("Commits", (string)null); + }); + + modelBuilder.Entity("SIL.Harmony.Core.ChangeEntity", b => + { + b.Property("CommitId") + .HasColumnType("TEXT"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("Change") + .HasColumnType("jsonb"); + + b.Property("EntityId") + .HasColumnType("TEXT"); + + b.HasKey("CommitId", "Index"); + + b.ToTable("ChangeEntities", (string)null); + }); + + modelBuilder.Entity("SIL.Harmony.Db.ObjectSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CommitId") + .HasColumnType("TEXT"); + + b.Property("Entity") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("EntityId") + .HasColumnType("TEXT"); + + b.Property("EntityIsDeleted") + .HasColumnType("INTEGER"); + + b.Property("IsRoot") + .HasColumnType("INTEGER"); + + b.Property("References") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TypeName") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EntityId"); + + b.HasIndex("CommitId", "EntityId") + .IsUnique(); + + b.ToTable("Snapshots", (string)null); + }); + + modelBuilder.Entity("MiniLcm.Models.ComplexFormComponent", b => + { + b.HasOne("MiniLcm.Models.Entry", null) + .WithMany("Components") + .HasForeignKey("ComplexFormEntryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MiniLcm.Models.Entry", null) + .WithMany("ComplexForms") + .HasForeignKey("ComponentEntryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MiniLcm.Models.Sense", null) + .WithMany() + .HasForeignKey("ComponentSenseId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.ComplexFormComponent", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.ComplexFormType", b => + { + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.ComplexFormType", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.Entry", b => + { + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.Entry", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.ExampleSentence", b => + { + b.HasOne("MiniLcm.Models.Sense", null) + .WithMany("ExampleSentences") + .HasForeignKey("SenseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.ExampleSentence", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.PartOfSpeech", b => + { + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.PartOfSpeech", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.Publication", b => + { + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.Publication", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.SemanticDomain", b => + { + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.SemanticDomain", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.Sense", b => + { + b.HasOne("MiniLcm.Models.Entry", null) + .WithMany("Senses") + .HasForeignKey("EntryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MiniLcm.Models.PartOfSpeech", "PartOfSpeech") + .WithMany() + .HasForeignKey("PartOfSpeechId"); + + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.Sense", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("PartOfSpeech"); + }); + + modelBuilder.Entity("MiniLcm.Models.WritingSystem", b => + { + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.WritingSystem", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("SIL.Harmony.Core.ChangeEntity", b => + { + b.HasOne("SIL.Harmony.Commit", null) + .WithMany("ChangeEntities") + .HasForeignKey("CommitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SIL.Harmony.Db.ObjectSnapshot", b => + { + b.HasOne("SIL.Harmony.Commit", "Commit") + .WithMany("Snapshots") + .HasForeignKey("CommitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Commit"); + }); + + modelBuilder.Entity("MiniLcm.Models.Entry", b => + { + b.Navigation("ComplexForms"); + + b.Navigation("Components"); + + b.Navigation("Senses"); + }); + + modelBuilder.Entity("MiniLcm.Models.Sense", b => + { + b.Navigation("ExampleSentences"); + }); + + modelBuilder.Entity("SIL.Harmony.Commit", b => + { + b.Navigation("ChangeEntities"); + + b.Navigation("Snapshots"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/FwLite/LcmCrdt/Migrations/20250530084107_AddProjectDataRole.cs b/backend/FwLite/LcmCrdt/Migrations/20250530084107_AddProjectDataRole.cs new file mode 100644 index 0000000000..7c0570ddf8 --- /dev/null +++ b/backend/FwLite/LcmCrdt/Migrations/20250530084107_AddProjectDataRole.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace LcmCrdt.Migrations +{ + /// + public partial class AddProjectDataRole : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Role", + table: "ProjectData", + type: "TEXT", + nullable: false, + defaultValue: "Editor"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Role", + table: "ProjectData"); + } + } +} diff --git a/backend/FwLite/LcmCrdt/Migrations/LcmCrdtDbContextModelSnapshot.cs b/backend/FwLite/LcmCrdt/Migrations/LcmCrdtDbContextModelSnapshot.cs index 2e25eba3c2..3c7692da4f 100644 --- a/backend/FwLite/LcmCrdt/Migrations/LcmCrdtDbContextModelSnapshot.cs +++ b/backend/FwLite/LcmCrdt/Migrations/LcmCrdtDbContextModelSnapshot.cs @@ -47,6 +47,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("OriginDomain") .HasColumnType("TEXT"); + b.Property("Role") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Editor"); + b.HasKey("Id"); b.ToTable("ProjectData"); diff --git a/backend/FwLite/Taskfile.yml b/backend/FwLite/Taskfile.yml index e74e8863f5..7f54577a2b 100644 --- a/backend/FwLite/Taskfile.yml +++ b/backend/FwLite/Taskfile.yml @@ -40,6 +40,13 @@ tasks: cmds: - dotnet ef migrations has-pending-model-changes {{.CLI_ARGS}} + remove-last-migration: + desc: "This will remove the last migration, don't remove migrations that have been pushed to production, but you can remove ones you created locally." + deps: [ tool-restore ] + dir: ./LcmCrdt + cmds: + - dotnet ef migrations remove + maui-windows: label: Run Maui Windows, requires vite dev server to be running, use task fw-lite-win in root dir: ./FwLiteMaui diff --git a/backend/LexBoxApi/Controllers/CrdtController.cs b/backend/LexBoxApi/Controllers/CrdtController.cs index 37bcd872a3..d2724fa819 100644 --- a/backend/LexBoxApi/Controllers/CrdtController.cs +++ b/backend/LexBoxApi/Controllers/CrdtController.cs @@ -35,7 +35,7 @@ LexAuthService lexAuthService [HttpGet("{projectId}/get")] public async Task> GetSyncState(Guid projectId) { - await permissionService.AssertCanSyncProject(projectId); + await permissionService.AssertCanDownloadProject(projectId); return await crdtCommitService.GetSyncState(projectId); } @@ -61,7 +61,7 @@ public record ChangesResult(IAsyncEnumerable MissingFromClient, Sy public async Task> Changes(Guid projectId, [FromBody] SyncState clientHeads) { - await permissionService.AssertCanSyncProject(projectId); + await permissionService.AssertCanDownloadProject(projectId); var localState = await crdtCommitService.GetSyncState(projectId); return new ChangesResult(crdtCommitService.GetMissingCommits(projectId, localState, clientHeads), localState); } @@ -75,16 +75,19 @@ public async Task> CountChanges(Guid projectId, return await crdtCommitService.ApproximatelyCountMissingCommits(projectId, localState, clientHeads); } - public record FwLiteProject(Guid Id, string Code, string Name, bool IsFwDataProject, bool IsCrdtProject); - [HttpGet("listProjects")] - public async Task> ListProjects() + public async Task> ListProjects() { var myProjects = await projectService.UserProjects(loggedInContext.User.Id) .Where(p => p.Type == ProjectType.FLEx) - .Select(p => new FwLiteProject(p.Id, p.Code, p.Name, p.LastCommit != null, dbContext.Set().Any(c => c.ProjectId == p.Id))) + .Select(p => new FieldWorksLiteProject(p.Id, + p.Code, + p.Name, + p.LastCommit != null, + dbContext.Set().Any(c => c.ProjectId == p.Id), + p.Users.Where(u => u.UserId == loggedInContext.User.Id).Select(m => m.Role).FirstOrDefault())) .ToArrayAsync(); - if (loggedInContext.User.IsOutOfSyncWithMyProjects(myProjects.Select(p => p.Id).ToArray())) + if (loggedInContext.User.IsOutOfSyncWithMyProjects(myProjects)) { await lexAuthService.RefreshUser(LexAuthConstants.ProjectsClaimType); } diff --git a/backend/LexBoxApi/Controllers/LegacyProjectApiController.cs b/backend/LexBoxApi/Controllers/LegacyProjectApiController.cs index 3c0143da88..7752cc7d20 100644 --- a/backend/LexBoxApi/Controllers/LegacyProjectApiController.cs +++ b/backend/LexBoxApi/Controllers/LegacyProjectApiController.cs @@ -51,7 +51,9 @@ public async Task> Projects(string userName, Pr { user.Salt, user.PasswordHash, - projects = user.Projects.Select(member => new LegacyApiProject(member.Project!.Code, + //FLEx does not support the observer role, so if a user is an observer we need to exclude it from the list of projects + projects = user.Projects.Where(m => m.Role != ProjectRole.Observer) + .Select(member => new LegacyApiProject(member.Project!.Code, member.Project.Name, //it seems this is largely ignored by the client as it uses the LF domain instead "http://public.languagedepot.org", @@ -78,7 +80,7 @@ private string RoleToString(ProjectRole role) => //this needs to be ugly so that projectable will work :( role == ProjectRole.Manager ? "manager" : role == ProjectRole.Editor ? "editor" - : "unknown"; + : "unknown";//fieldworks doesn't know about or support observers } public record LegacyApiProject(string Identifier, string Name, string Repository, string Role); diff --git a/backend/LexBoxApi/GraphQL/LexAuthUserOutOfSyncExtensions.cs b/backend/LexBoxApi/GraphQL/LexAuthUserOutOfSyncExtensions.cs index 895764bfbb..468542b0b2 100644 --- a/backend/LexBoxApi/GraphQL/LexAuthUserOutOfSyncExtensions.cs +++ b/backend/LexBoxApi/GraphQL/LexAuthUserOutOfSyncExtensions.cs @@ -19,6 +19,17 @@ public static bool IsOutOfSyncWithMyProjects(this LexAuthUser user, ICollection< return user.Projects.Select(p => p.ProjectId).Intersect(projectIds).Count() != projectIds.Count; } + public static bool IsOutOfSyncWithMyProjects(this LexAuthUser user, ICollection projects) + { + if (user.IsAdmin) return false; // admins don't have projects in their token + if (user.Projects.Length != projects.Count) return true; // different number of projects + return projects.Any(p => + { + var tokenMembership = user.Projects.SingleOrDefault(p2 => p2.ProjectId == p.Id); + return p.Role != tokenMembership?.Role; + }); + } + public static bool IsOutOfSyncWithMyOrgs(this LexAuthUser user, List myOrgs) { if (user.IsAdmin) return false; // admins don't have orgs in their token diff --git a/backend/LexBoxApi/Hub/CrdtProjectChangeHub.cs b/backend/LexBoxApi/Hub/CrdtProjectChangeHub.cs index 402c4c82b2..a79bc00fe1 100644 --- a/backend/LexBoxApi/Hub/CrdtProjectChangeHub.cs +++ b/backend/LexBoxApi/Hub/CrdtProjectChangeHub.cs @@ -11,7 +11,7 @@ public class CrdtProjectChangeHub(IPermissionService permissionService) : Hub CanSyncProject(Guid projectId) + { + if (User is null) return false; + if (User.Role == UserRole.admin) return true; + if (User.IsProjectMember(projectId, ProjectRole.Editor, ProjectRole.Manager)) return true; + // Org managers can sync any project owned by their org(s) + return await ManagesOrgThatOwnsProject(projectId); + } + + public async ValueTask CanDownloadProject(Guid projectId) { if (User is null) return false; if (User.Role == UserRole.admin) return true; @@ -71,6 +80,11 @@ public async ValueTask AssertCanSyncProject(Guid projectId) if (!await CanSyncProject(projectId)) throw new UnauthorizedAccessException(); } + public async ValueTask AssertCanDownloadProject(Guid projectId) + { + if (!await CanDownloadProject(projectId)) throw new UnauthorizedAccessException(); + } + public async ValueTask CanViewProject(Guid projectId, LexAuthUser? overrideUser = null) { var user = overrideUser ?? User; diff --git a/backend/LexCore/Auth/LexAuthUser.cs b/backend/LexCore/Auth/LexAuthUser.cs index 4af332ae3e..e58f243687 100644 --- a/backend/LexCore/Auth/LexAuthUser.cs +++ b/backend/LexCore/Auth/LexAuthUser.cs @@ -208,6 +208,7 @@ public string ProjectsJson { ProjectRole.Manager => "m", ProjectRole.Editor => "e", + ProjectRole.Observer => "o", _ => "u" }; @@ -219,16 +220,17 @@ public string ProjectsJson //will be empty for admins if (string.IsNullOrEmpty(value)) { - Projects = Array.Empty(); + Projects = []; return; } Projects = value.Split(",").SelectMany(p => { - if (string.IsNullOrEmpty(p)) return Array.Empty(); + if (string.IsNullOrEmpty(p)) return []; var role = p[0] switch { 'm' => ProjectRole.Manager, 'e' => ProjectRole.Editor, + 'o' => ProjectRole.Observer, _ => ProjectRole.Unknown }; return p[2..].Split("|").Select(Guid.Parse).Select(pId => new AuthUserProject(role, pId)); @@ -298,15 +300,19 @@ public ClaimsPrincipal GetPrincipal(string authenticationType) LexAuthConstants.RoleClaimType)); } - public bool IsProjectMember(Guid projectId, ProjectRole? role = null) + public bool IsProjectMember(Guid projectId, params Span roles) { if (Projects is null) return false; - if (role is not null) + if (roles.IsEmpty) return Projects.Any(p => p.ProjectId == projectId); + + foreach (var role in roles) { - return Projects.Any(p => p.ProjectId == projectId && p.Role == role); + var hasRole = Projects.Any(p => p.ProjectId == projectId && p.Role == role); + if (hasRole) return true; } - return Projects.Any(p => p.ProjectId == projectId); + + return false; } public bool HasFeature(FeatureFlag feature) diff --git a/backend/LexCore/Entities/Project.cs b/backend/LexCore/Entities/Project.cs index 66c7720955..9f8edec763 100644 --- a/backend/LexCore/Entities/Project.cs +++ b/backend/LexCore/Entities/Project.cs @@ -1,5 +1,6 @@ using System.ComponentModel.DataAnnotations.Schema; using System.Linq.Expressions; +using System.Text.Json.Serialization; using EntityFrameworkCore.Projectables; using LexCore.ServiceInterfaces; @@ -73,6 +74,14 @@ public Task GetHasHarmonyCommits(IIsHarmonyProjectDataLoader loader) } } +public record FieldWorksLiteProject( + Guid Id, + string Code, + string Name, + bool IsFwDataProject, + bool IsCrdtProject, + [property:JsonConverter(typeof(JsonStringEnumConverter))]ProjectRole Role); + public enum ProjectMigrationStatus { //default value diff --git a/backend/LexCore/Entities/ProjectRole.cs b/backend/LexCore/Entities/ProjectRole.cs index 682a99ad89..51856309ba 100644 --- a/backend/LexCore/Entities/ProjectRole.cs +++ b/backend/LexCore/Entities/ProjectRole.cs @@ -6,5 +6,5 @@ public enum ProjectRole // Admin = 1, Manager = 2, Editor = 3, - // Observer = 4, + Observer = 4, } diff --git a/backend/LexCore/ServiceInterfaces/IPermissionService.cs b/backend/LexCore/ServiceInterfaces/IPermissionService.cs index f954af3d3b..0321467d07 100644 --- a/backend/LexCore/ServiceInterfaces/IPermissionService.cs +++ b/backend/LexCore/ServiceInterfaces/IPermissionService.cs @@ -10,6 +10,7 @@ public interface IPermissionService ValueTask CanSyncProject(Guid projectId); ValueTask AssertCanSyncProject(string projectCode); ValueTask AssertCanSyncProject(Guid projectId); + ValueTask AssertCanDownloadProject(Guid projectId); ValueTask CanViewProject(Guid projectId, LexAuthUser? overrideUser = null); ValueTask AssertCanViewProject(Guid projectId, LexAuthUser? overrideUser = null); ValueTask CanViewProject(string projectCode, LexAuthUser? overrideUser = null); diff --git a/backend/LexData/Entities/UserEntityConfiguration.cs b/backend/LexData/Entities/UserEntityConfiguration.cs index 501149b536..0ff6b06955 100644 --- a/backend/LexData/Entities/UserEntityConfiguration.cs +++ b/backend/LexData/Entities/UserEntityConfiguration.cs @@ -69,8 +69,8 @@ public static void AssertHasVerifiedEmailForRole(this User user, ProjectRole for // Users bulk-created by admins might not have email addresses // Users who self-registered must verify email in all cases if (user.CreatedById is null) throw new ProjectMembersMustBeVerified("Member must verify email first"); - // Only project editors (basic role) are allowed not to have verified email addresses - if (forRole != ProjectRole.Editor) throw new ProjectMembersMustBeVerifiedForRole("Member must verify email before taking on this role", forRole); + // Only project editors and observers are allowed not to have verified email addresses + if (forRole is not ProjectRole.Editor and not ProjectRole.Observer) throw new ProjectMembersMustBeVerifiedForRole("Member must verify email before taking on this role", forRole); } public static void AssertHasVerifiedEmailForOrgRole(this User user, OrgRole forRole = OrgRole.Unknown) diff --git a/deployment/Taskfile.yml b/deployment/Taskfile.yml index 02d7fcfc72..b41be0add1 100644 --- a/deployment/Taskfile.yml +++ b/deployment/Taskfile.yml @@ -76,6 +76,9 @@ tasks: local-api-forward: cmds: - kubectl port-forward service/lexbox 5158:5158 -n languagedepot --context docker-desktop + local-fw-headless-forward: + cmds: + - kubectl port-forward service/fw-headless 5275:8081 -n languagedepot --context docker-desktop staging-db-forward: cmds: diff --git a/frontend/schema.graphql b/frontend/schema.graphql index eb050a867c..729a256d3c 100644 --- a/frontend/schema.graphql +++ b/frontend/schema.graphql @@ -215,7 +215,6 @@ type LeaveProjectPayload { } type LexAuthUser { - isProjectMember(projectId: UUID! role: ProjectRole): Boolean! hasFeature(feature: FeatureFlag!): Boolean! hasScope(scope: LexboxAuthScope!): Boolean! id: UUID! @@ -1271,6 +1270,7 @@ enum ProjectRole { UNKNOWN MANAGER EDITOR + OBSERVER } enum ProjectType { diff --git a/frontend/src/lib/components/Projects/FormatUserProjectRole.svelte b/frontend/src/lib/components/Projects/FormatUserProjectRole.svelte index 641754a232..d60c0d3842 100644 --- a/frontend/src/lib/components/Projects/FormatUserProjectRole.svelte +++ b/frontend/src/lib/components/Projects/FormatUserProjectRole.svelte @@ -1,6 +1,19 @@ + + {_role ?? $t('unknown')} diff --git a/frontend/src/lib/forms/ProjectRoleSelect.svelte b/frontend/src/lib/forms/ProjectRoleSelect.svelte index 9fde918bcb..2f469bcdc2 100644 --- a/frontend/src/lib/forms/ProjectRoleSelect.svelte +++ b/frontend/src/lib/forms/ProjectRoleSelect.svelte @@ -7,9 +7,10 @@ interface Props extends Omit { id?: string; value: ProjectRole; + showObserver?: boolean; } - let { id = 'role', value = $bindable(), ...rest }: Props = $props(); + let { id = 'role', value = $bindable(), showObserver = false, ...rest }: Props = $props(); diff --git a/frontend/src/lib/i18n/locales/en.json b/frontend/src/lib/i18n/locales/en.json index a5a450b163..2272706aec 100644 --- a/frontend/src/lib/i18n/locales/en.json +++ b/frontend/src/lib/i18n/locales/en.json @@ -603,7 +603,9 @@ If you don't see a dialog or already closed it, click the button below:", "editor": "Editor", "editor_description": "Editor (can send/receive)", "manager": "Manager", - "manager_description": "Manager (can send/receive & add new users)" + "manager_description": "Manager (can send/receive & add new users)", + "observer": "Viewer", + "observer_description": "Viewer (FieldWorks Lite only)" }, "system_role": { "label": "Role", diff --git a/frontend/src/lib/user.ts b/frontend/src/lib/user.ts index d0fcf2b963..e92140f910 100644 --- a/frontend/src/lib/user.ts +++ b/frontend/src/lib/user.ts @@ -1,12 +1,21 @@ -import {browser} from '$app/environment'; -import {redirect, type Cookies} from '@sveltejs/kit'; -import {jwtDecode} from 'jwt-decode'; -import {deleteCookie, getCookie} from './util/cookies'; -import {hash} from '$lib/util/hash'; -import {ensureErrorIsTraced, errorSourceTag} from './otel'; +import { browser } from '$app/environment'; +import { type Cookies, redirect } from '@sveltejs/kit'; +import { jwtDecode } from 'jwt-decode'; +import { deleteCookie, getCookie } from './util/cookies'; +import { hash } from '$lib/util/hash'; +import { ensureErrorIsTraced, errorSourceTag } from './otel'; import zxcvbn from 'zxcvbn'; -import {type AuthUserProject, type AuthUserOrg, ProjectRole, UserRole, type CreateGuestUserByAdminInput, type OrgRole, LexboxAudience as GqlLexboxAudience, FeatureFlag} from './gql/types'; -import {_createGuestUserByAdmin} from '../routes/(authenticated)/admin/+page'; +import { + type AuthUserOrg, + type AuthUserProject, + type CreateGuestUserByAdminInput, + FeatureFlag, + LexboxAudience as GqlLexboxAudience, + type OrgRole, + ProjectRole, + UserRole +} from './gql/types'; +import { _createGuestUserByAdmin } from '../routes/(authenticated)/admin/+page'; type LoginError = 'BadCredentials' | 'Locked'; type LoginResult = { @@ -237,6 +246,9 @@ function projectsStringToProjects(projectsString: string | undefined): AuthUserP case 'e': role = ProjectRole.Editor; break; + case 'o': + role = ProjectRole.Observer; + break; } //substring to remove the first character which is the role code plus the colon projects.push(...pString.substring(2).split('|').map(id => ({projectId: stringToUuid(id), role}))); diff --git a/frontend/src/lib/util/enums.ts b/frontend/src/lib/util/enums.ts deleted file mode 100644 index 71e3082bb3..0000000000 --- a/frontend/src/lib/util/enums.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { ProjectRole } from '$lib/gql/types'; - -export function toProjectRoleEnum(index: number): ProjectRole { - return toEnum(PROJECT_ROLES, index); -} - -const PROJECT_ROLES = { - 0: ProjectRole.Editor, - 2: ProjectRole.Manager, - 3: ProjectRole.Editor, -} as const; - -function toEnum(_enum: Record, key: K): T { - const value = _enum[key]; - if (value === undefined) { - throw new RangeError(`Enum key out of bounds: ${String(key)} (${Object.keys(_enum).join()}).`); - } - return value; -} diff --git a/frontend/src/routes/(authenticated)/project/[project_code]/+page.svelte b/frontend/src/routes/(authenticated)/project/[project_code]/+page.svelte index a7a5ccece6..ba854feeb8 100644 --- a/frontend/src/routes/(authenticated)/project/[project_code]/+page.svelte +++ b/frontend/src/routes/(authenticated)/project/[project_code]/+page.svelte @@ -57,6 +57,7 @@ import CrdtSyncButton from './CrdtSyncButton.svelte'; import { _askToJoinProject } from '../create/+page'; // TODO: Should we duplicate this function in the project_code/+page.ts file, rather than importing it from elsewhere? import { Duration } from '$lib/util/time'; + import {hasFeatureFlag} from '$lib/user'; interface Props { data: PageData; @@ -145,17 +146,19 @@ project.organizations?.map((o) => user.orgs?.find((org) => org.orgId === o.id)?.role).filter((r) => !!r) ?? [], ); let projectRole = $derived(project?.users.find((u) => u.user.id == user.id)?.role); + let userIsOrgAdmin = $derived(orgRoles.some((role) => role === OrgRole.Admin)); // Mirrors PermissionService.CanViewProjectMembers() in C# let canViewOtherMembers = $derived( user.isAdmin || projectRole == ProjectRole.Manager || (projectRole && !project.isConfidential) || // public by default for members (non-members shouldn't even be here) - orgRoles.some((role) => role === OrgRole.Admin), + userIsOrgAdmin, ); // Almost mirrors PermissionService.CanAskToJoinProject() in C#, but admins won't be shown the "ask to join" button let canAskToJoinProject = $derived(!user.isAdmin && !projectRole && orgRoles.some((_) => true)); + let canSyncProject = $derived(user.isAdmin || projectRole == ProjectRole.Manager || projectRole == ProjectRole.Editor || userIsOrgAdmin); let resetProjectModal: ResetProjectModal | undefined = $state(); async function resetProject(): Promise { @@ -363,67 +366,69 @@ {/if} {$t('project_page.join_project.label')} - {:else if project && project.type === ProjectType.FlEx && !isEmpty} - - - {:else} - - - {#snippet content()} -
-
-
-

- {$t('project_page.get_project.instructions_header', { - type: project.type, - mode: 'normal', - isEmpty: isEmpty.toString(), - })} -

- {#if project.type === ProjectType.WeSay} - {#if isEmpty} + {:else if canSyncProject} + {#if project && project.type === ProjectType.FlEx && !isEmpty} + + + {:else} + + + {#snippet content()} +
+
+
+

+ {$t('project_page.get_project.instructions_header', { + type: project.type, + mode: 'normal', + isEmpty: isEmpty.toString(), + })} +

+ {#if project.type === ProjectType.WeSay} + {#if isEmpty} + + {:else} + + {/if} + {:else if isEmpty} {:else} {/if} - {:else if isEmpty} - - {:else} - - {/if} +
+
-
-
- {/snippet} - + {/snippet} + + {/if} {/if} {/snippet} {#snippet title()} @@ -586,6 +591,7 @@ canManage && (member.user?.id !== userId || user.isAdmin)} canManageList={canManage} diff --git a/frontend/src/routes/(authenticated)/project/[project_code]/AddProjectMember.svelte b/frontend/src/routes/(authenticated)/project/[project_code]/AddProjectMember.svelte index f92b941b8b..fb09919da3 100644 --- a/frontend/src/routes/(authenticated)/project/[project_code]/AddProjectMember.svelte +++ b/frontend/src/routes/(authenticated)/project/[project_code]/AddProjectMember.svelte @@ -22,7 +22,7 @@ .trim() .min(1, $t('project_page.add_user.empty_user_field')) .refine((value) => !value.includes('@') || isEmail(value), { message: $t('form.invalid_email') }), - role: z.enum([ProjectRole.Editor, ProjectRole.Manager]).default(ProjectRole.Editor), + role: z.enum([ProjectRole.Editor, ProjectRole.Manager, ProjectRole.Observer]).default(ProjectRole.Editor), canInvite: z.boolean().default(false), }); // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents @@ -99,7 +99,7 @@ }} exclude={project.users.map((m) => m.user.id)} /> - + {/snippet} {#snippet extraActions()} {$t('project_page.change_role_modal.title', { name })} {/snippet} {#snippet children({ errors })} - + {/snippet} {#snippet submitText()} {$t('project_page.change_role')} diff --git a/frontend/src/routes/(authenticated)/project/[project_code]/MembersList.svelte b/frontend/src/routes/(authenticated)/project/[project_code]/MembersList.svelte index 17d35d0eb0..b8e5b4b330 100644 --- a/frontend/src/routes/(authenticated)/project/[project_code]/MembersList.svelte +++ b/frontend/src/routes/(authenticated)/project/[project_code]/MembersList.svelte @@ -22,12 +22,14 @@ import type { UUID } from 'crypto'; import PlainInput from '$lib/forms/PlainInput.svelte'; import { DialogResponse } from '$lib/components/modals'; + import {formatProjectRole} from '$lib/components/Projects/FormatUserProjectRole.svelte'; interface Props { members?: Member[]; canManageMember: (member: Member) => boolean; canManageList: boolean; projectId: string; + showObserver?: boolean; canViewOtherMembers: boolean; extraButtons?: Snippet; children?: Snippet; @@ -40,6 +42,7 @@ canManageMember, canManageList, projectId, + showObserver, canViewOtherMembers, extraButtons, children, @@ -84,7 +87,7 @@ notifySuccess( $t(notification, { name: member.user.name, - role: role.toLowerCase(), + role: formatProjectRole(role, $t), }), ); } @@ -165,5 +168,5 @@ {@render children?.()} - +
diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Projects/IProjectModel.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Projects/IProjectModel.ts index 64aa9262b2..b6b3ddce48 100644 --- a/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Projects/IProjectModel.ts +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Projects/IProjectModel.ts @@ -3,6 +3,7 @@ // Changes to this file may cause incorrect behavior and will be lost if // the code is regenerated. +import type {ProjectRole} from '../../LexCore/Entities/ProjectRole'; import type {ILexboxServer} from '../Auth/ILexboxServer'; export interface IProjectModel @@ -12,6 +13,7 @@ export interface IProjectModel crdt: boolean; fwdata: boolean; lexbox: boolean; + role: ProjectRole; server?: ILexboxServer; id?: string; apiEndpoint?: string; 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 64908add3b..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 @@ -3,6 +3,8 @@ // Changes to this file may cause incorrect behavior and will be lost if // the code is regenerated. +import type {UserProjectRole} from './UserProjectRole'; + export interface IProjectData { name: string; @@ -13,6 +15,8 @@ export interface IProjectData fwProjectId?: string; lastUserName?: string; lastUserId?: string; + role: UserProjectRole; serverId?: string; + isReadonly: boolean; } /* eslint-enable */ diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/UserProjectRole.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/UserProjectRole.ts new file mode 100644 index 0000000000..8a3d165936 --- /dev/null +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/UserProjectRole.ts @@ -0,0 +1,12 @@ +/* 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. + +export enum UserProjectRole { + Unknown = "Unknown", + Manager = "Manager", + Editor = "Editor", + Observer = "Observer" +} +/* eslint-enable */ diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/LexCore/Entities/ProjectRole.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/LexCore/Entities/ProjectRole.ts new file mode 100644 index 0000000000..97f7c71f60 --- /dev/null +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/LexCore/Entities/ProjectRole.ts @@ -0,0 +1,12 @@ +/* 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. + +export enum ProjectRole { + Unknown = "Unknown", + Manager = "Manager", + Editor = "Editor", + Observer = "Observer" +} +/* eslint-enable */ diff --git a/frontend/viewer/src/project/NewEntryButton.svelte b/frontend/viewer/src/project/NewEntryButton.svelte index eb73e5e13f..741223be86 100644 --- a/frontend/viewer/src/project/NewEntryButton.svelte +++ b/frontend/viewer/src/project/NewEntryButton.svelte @@ -13,6 +13,7 @@ import { crossfade } from 'svelte/transition'; import { Button } from '$lib/components/ui/button'; import { t } from 'svelte-i18n-lingui'; + import {useFeatures} from '$lib/services/feature-service'; $effect(() => { instances[id] = active; @@ -31,6 +32,7 @@ active?: boolean; } = $props(); + const features = useFeatures(); const id = crypto.randomUUID(); const isActive = $derived( // explicitly active @@ -39,7 +41,7 @@ instances[id] === undefined && !Object.values(instances).some(_active => _active)); -{#if isActive} +{#if isActive && features.write}
{/if}
- +
{/if}