diff --git a/.github/workflows/develop-ui.yaml b/.github/workflows/develop-ui.yaml index f1ceb5a70..dcabd4793 100644 --- a/.github/workflows/develop-ui.yaml +++ b/.github/workflows/develop-ui.yaml @@ -62,8 +62,10 @@ jobs: cache-dependency-path: './frontend/pnpm-lock.yaml' - run: pnpm install - run: pnpm run -r build # We need code/types generated by the build (e.g. gql/generated) - - run: pnpm run -r check --output machine - - run: pnpm run -r lint:report + - id: svelte_check + run: pnpm run -r --no-bail check --output machine + - run: pnpm run -r --no-bail lint:report + if: ${{ always() && steps.svelte_check.outcome != 'cancelled' }} - name: Annotate Code Linting REsults uses: ataylorme/eslint-annotate-action@d57a1193d4c59cbfbf3f86c271f42612f9dbd9e9 # v3.0.0 if: always() diff --git a/backend/FwLite/FwLiteDesktop/FwLiteDesktopKernel.cs b/backend/FwLite/FwLiteDesktop/FwLiteDesktopKernel.cs index bcb4ab704..0d7727ee2 100644 --- a/backend/FwLite/FwLiteDesktop/FwLiteDesktopKernel.cs +++ b/backend/FwLite/FwLiteDesktop/FwLiteDesktopKernel.cs @@ -1,4 +1,5 @@ -using FwLiteDesktop.ServerBridge; +using Windows.ApplicationModel; +using FwLiteDesktop.ServerBridge; using LcmCrdt; using LocalWebApp.Auth; using Microsoft.Extensions.Configuration; @@ -15,21 +16,25 @@ public static void AddFwLiteDesktopServices(this IServiceCollection services, ILoggingBuilder logging) { services.AddSingleton(); + configuration.AddJsonFile("appsettings.json", optional: true); string environment = "Production"; #if DEBUG environment = "Development"; #endif + var defaultDataPath = IsPackagedApp ? FileSystem.AppDataDirectory : Directory.GetCurrentDirectory(); + var baseDataPath = Path.GetFullPath(configuration.GetSection("FwLiteDesktop").GetValue("BaseDataDir") ?? defaultDataPath); + Directory.CreateDirectory(baseDataPath); var serverManager = new ServerManager(environment, webAppBuilder => { - webAppBuilder.Logging.AddFile(Path.Combine(FileSystem.AppDataDirectory, "web-app.log")); + webAppBuilder.Logging.AddFile(Path.Combine(baseDataPath, "web-app.log")); webAppBuilder.Services.Configure(config => { - config.ProjectPath = FileSystem.AppDataDirectory; + config.ProjectPath = baseDataPath; }); webAppBuilder.Services.Configure(config => { - config.CacheFileName = Path.Combine(FileSystem.AppDataDirectory, "msal.cache"); + config.CacheFileName = Path.Combine(baseDataPath, "msal.cache"); config.SystemWebViewLogin = true; }); }); @@ -39,10 +44,27 @@ public static void AddFwLiteDesktopServices(this IServiceCollection services, services.AddSingleton(_ => _.GetRequiredService().WebServices.GetRequiredService()); configuration.Add(source => source.ServerManager = serverManager); services.AddOptions().BindConfiguration("LocalWebApp"); - logging.AddFile(Path.Combine(FileSystem.AppDataDirectory, "app.log")); + logging.AddFile(Path.Combine(baseDataPath, "app.log")); logging.AddConsole(); #if DEBUG logging.AddDebug(); #endif } + + static readonly Lazy IsPackagedAppLazy = new(static () => + { + try + { + if (Package.Current != null) + return true; + } + catch + { + // no-op + } + + return false; + }); + + public static bool IsPackagedApp => IsPackagedAppLazy.Value; } diff --git a/backend/FwLite/FwLiteDesktop/MauiProgram.cs b/backend/FwLite/FwLiteDesktop/MauiProgram.cs index 2e623f658..2dfbdb6fd 100644 --- a/backend/FwLite/FwLiteDesktop/MauiProgram.cs +++ b/backend/FwLite/FwLiteDesktop/MauiProgram.cs @@ -35,7 +35,6 @@ public static MauiApp CreateMauiApp() }); })); - Directory.CreateDirectory(FileSystem.AppDataDirectory); builder.Services.AddFwLiteDesktopServices(builder.Configuration, builder.Logging); holder.App = builder.Build(); diff --git a/backend/FwLite/FwLiteProjectSync.Tests/EntrySyncTests.cs b/backend/FwLite/FwLiteProjectSync.Tests/EntrySyncTests.cs index 523f73526..d1b264839 100644 --- a/backend/FwLite/FwLiteProjectSync.Tests/EntrySyncTests.cs +++ b/backend/FwLite/FwLiteProjectSync.Tests/EntrySyncTests.cs @@ -9,7 +9,7 @@ namespace FwLiteProjectSync.Tests; public class EntrySyncTests : IClassFixture { - private readonly AutoFaker _autoFaker = new(builder => builder.WithOverride(new MultiStringOverride()).WithOverride(new ObjectWithIdOverride())); + private static readonly AutoFaker AutoFaker = new(builder => builder.WithOverride(new MultiStringOverride()).WithOverride(new ObjectWithIdOverride())); public EntrySyncTests(SyncFixture fixture) { _fixture = fixture; @@ -20,8 +20,8 @@ public EntrySyncTests(SyncFixture fixture) [Fact] public async Task CanSyncRandomEntries() { - var createdEntry = await _fixture.CrdtApi.CreateEntry(await _autoFaker.EntryReadyForCreation(_fixture.CrdtApi)); - var after = await _autoFaker.EntryReadyForCreation(_fixture.CrdtApi, entryId: createdEntry.Id); + var createdEntry = await _fixture.CrdtApi.CreateEntry(await AutoFaker.EntryReadyForCreation(_fixture.CrdtApi)); + var after = await AutoFaker.EntryReadyForCreation(_fixture.CrdtApi, entryId: createdEntry.Id); await EntrySync.Sync(after, createdEntry, _fixture.CrdtApi); var actual = await _fixture.CrdtApi.GetEntry(after.Id); actual.Should().NotBeNull(); diff --git a/backend/FwLite/FwLiteProjectSync.Tests/FwLiteProjectSync.Tests.csproj b/backend/FwLite/FwLiteProjectSync.Tests/FwLiteProjectSync.Tests.csproj index ea6572e46..1ae1063f4 100644 --- a/backend/FwLite/FwLiteProjectSync.Tests/FwLiteProjectSync.Tests.csproj +++ b/backend/FwLite/FwLiteProjectSync.Tests/FwLiteProjectSync.Tests.csproj @@ -21,7 +21,7 @@ - + all diff --git a/backend/FwLite/FwLiteProjectSync.Tests/UpdateDiffTests.cs b/backend/FwLite/FwLiteProjectSync.Tests/UpdateDiffTests.cs index 9d1feffa2..2d1eb2058 100644 --- a/backend/FwLite/FwLiteProjectSync.Tests/UpdateDiffTests.cs +++ b/backend/FwLite/FwLiteProjectSync.Tests/UpdateDiffTests.cs @@ -9,7 +9,7 @@ namespace FwLiteProjectSync.Tests; public class UpdateDiffTests { - private readonly AutoFaker _autoFaker = new(new AutoFakerConfig() + private static readonly AutoFaker AutoFaker = new(new AutoFakerConfig() { Overrides = [new MultiStringOverride(), new WritingSystemIdOverride()] }); @@ -18,7 +18,7 @@ public class UpdateDiffTests public void EntryDiffShouldUpdateAllFields() { var before = new Entry(); - var after = _autoFaker.Generate(); + var after = AutoFaker.Generate(); var entryDiffToUpdate = EntrySync.EntryDiffToUpdate(before, after); ArgumentNullException.ThrowIfNull(entryDiffToUpdate); entryDiffToUpdate.Apply(before); @@ -33,7 +33,7 @@ public void EntryDiffShouldUpdateAllFields() public async Task SenseDiffShouldUpdateAllFields() { var before = new Sense(); - var after = _autoFaker.Generate(); + var after = AutoFaker.Generate(); var senseDiffToUpdate = await SenseSync.SenseDiffToUpdate(before, after); ArgumentNullException.ThrowIfNull(senseDiffToUpdate); senseDiffToUpdate.Apply(before); @@ -44,7 +44,7 @@ public async Task SenseDiffShouldUpdateAllFields() public void ExampleSentenceDiffShouldUpdateAllFields() { var before = new ExampleSentence(); - var after = _autoFaker.Generate(); + var after = AutoFaker.Generate(); var exampleSentenceDiffToUpdate = ExampleSentenceSync.DiffToUpdate(before, after); ArgumentNullException.ThrowIfNull(exampleSentenceDiffToUpdate); exampleSentenceDiffToUpdate.Apply(before); diff --git a/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.cs b/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.cs index c08c3812c..359edff20 100644 --- a/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.cs +++ b/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.cs @@ -17,7 +17,7 @@ namespace LcmCrdt.Tests; public class DataModelSnapshotTests : IAsyncLifetime { - private static AutoFaker _faker = new AutoFaker(new AutoFakerConfig() + private static readonly AutoFaker Faker = new AutoFaker(new AutoFakerConfig() { Overrides = [new MultiStringOverride(), new WritingSystemIdOverride()] }); @@ -90,7 +90,7 @@ public void VerifyIObjectWithIdsMatchAdapterGetObjectTypeName() foreach (var jsonDerivedType in types) { var typeDiscriminator = jsonDerivedType.TypeDiscriminator.Should().BeOfType().Subject; - var obj = _faker.Generate(jsonDerivedType.DerivedType); + var obj = Faker.Generate(jsonDerivedType.DerivedType); new MiniLcmCrdtAdapter((IObjectWithId)obj).GetObjectTypeName().Should().Be(typeDiscriminator); } } diff --git a/backend/FwLite/LcmCrdt.Tests/EntityCopyMethodTests.cs b/backend/FwLite/LcmCrdt.Tests/EntityCopyMethodTests.cs index 395615d46..3caf45d35 100644 --- a/backend/FwLite/LcmCrdt.Tests/EntityCopyMethodTests.cs +++ b/backend/FwLite/LcmCrdt.Tests/EntityCopyMethodTests.cs @@ -9,7 +9,7 @@ namespace LcmCrdt.Tests; public class EntityCopyMethodTests { - private readonly AutoFaker _autoFaker = new(new AutoFakerConfig() + private static readonly AutoFaker AutoFaker = new(new AutoFakerConfig() { Overrides = [new MultiStringOverride(), new WritingSystemIdOverride()] }); @@ -35,7 +35,7 @@ public static IEnumerable GetEntityTypes() public void EntityCopyMethodShouldCopyAllFields(Type type) { type.IsAssignableTo(typeof(IObjectWithId)).Should().BeTrue(); - var entity = (IObjectWithId) _autoFaker.Generate(type); + var entity = (IObjectWithId) AutoFaker.Generate(type); var copy = entity.Copy(); copy.Should().BeEquivalentTo(entity, options => options.IncludingAllRuntimeProperties()); } diff --git a/backend/FwLite/LcmCrdt.Tests/LcmCrdt.Tests.csproj b/backend/FwLite/LcmCrdt.Tests/LcmCrdt.Tests.csproj index 2b9260c62..d955b734a 100644 --- a/backend/FwLite/LcmCrdt.Tests/LcmCrdt.Tests.csproj +++ b/backend/FwLite/LcmCrdt.Tests/LcmCrdt.Tests.csproj @@ -17,7 +17,7 @@ - + diff --git a/backend/FwLite/LcmCrdt/CurrentProjectService.cs b/backend/FwLite/LcmCrdt/CurrentProjectService.cs index f2c69dd05..e99edde78 100644 --- a/backend/FwLite/LcmCrdt/CurrentProjectService.cs +++ b/backend/FwLite/LcmCrdt/CurrentProjectService.cs @@ -27,7 +27,7 @@ public async ValueTask GetProjectData() private static string CacheKey(CrdtProject project) { - return project.DbPath + "|ProjectData"; + return project.Name + "|ProjectData"; } private static string CacheKey(Guid projectId) @@ -57,7 +57,7 @@ private void RemoveProjectDataCache() memoryCache.Remove(CacheKey(Project)); } - public async Task SetProjectSyncOrigin(Uri domain, Guid? id) + public async Task SetProjectSyncOrigin(Uri? domain, Guid? id) { var originDomain = ProjectData.GetOriginDomain(domain); if (id is null) diff --git a/backend/FwLite/LocalWebApp/Auth/AuthConfig.cs b/backend/FwLite/LocalWebApp/Auth/AuthConfig.cs index 4e11f8706..542aae2db 100644 --- a/backend/FwLite/LocalWebApp/Auth/AuthConfig.cs +++ b/backend/FwLite/LocalWebApp/Auth/AuthConfig.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations; +using LcmCrdt; namespace LocalWebApp.Auth; @@ -15,6 +16,13 @@ public LexboxServer GetServerByAuthority(string authority) { return LexboxServers.FirstOrDefault(s => s.Authority.Authority == authority) ?? throw new ArgumentException($"Server {authority} not found"); } + + public LexboxServer GetServer(ProjectData projectData) + { + var originDomain = projectData.OriginDomain; + if (string.IsNullOrEmpty(originDomain)) throw new InvalidOperationException("No origin domain in project data"); + return GetServerByAuthority(new Uri(originDomain).Authority); + } public LexboxServer GetServer(string serverName) { return LexboxServers.FirstOrDefault(s => s.DisplayName == serverName) ?? throw new ArgumentException($"Server {serverName} not found"); diff --git a/backend/FwLite/LocalWebApp/Auth/AuthHelpers.cs b/backend/FwLite/LocalWebApp/Auth/AuthHelpers.cs index 14022c72f..b33738f06 100644 --- a/backend/FwLite/LocalWebApp/Auth/AuthHelpers.cs +++ b/backend/FwLite/LocalWebApp/Auth/AuthHelpers.cs @@ -1,6 +1,7 @@ using System.Net.Http.Headers; using System.Security.Cryptography; using LocalWebApp.Routes; +using LocalWebApp.Services; using Microsoft.Extensions.Options; using Microsoft.Identity.Client; using Microsoft.Identity.Client.Extensions.Msal; @@ -21,7 +22,8 @@ public class AuthHelpers private readonly IHttpMessageHandlerFactory _httpMessageHandlerFactory; private readonly OAuthService _oAuthService; private readonly UrlContext _urlContext; - private readonly Uri _authority; + private readonly LexboxServer _lexboxServer; + private readonly LexboxProjectService _lexboxProjectService; private readonly ILogger _logger; private readonly IPublicClientApplication _application; AuthenticationResult? _authResult; @@ -32,14 +34,16 @@ public AuthHelpers(LoggerAdapter loggerAdapter, LinkGenerator linkGenerator, OAuthService oAuthService, UrlContext urlContext, - Uri authority, + LexboxServer lexboxServer, + LexboxProjectService lexboxProjectService, ILogger logger, IHostEnvironment hostEnvironment) { _httpMessageHandlerFactory = httpMessageHandlerFactory; _oAuthService = oAuthService; _urlContext = urlContext; - _authority = authority; + _lexboxServer = lexboxServer; + _lexboxProjectService = lexboxProjectService; _logger = logger; (var hostUrl, _isRedirectHostGuess) = urlContext.GetUrl(); _redirectHost = HostString.FromUriComponent(hostUrl); @@ -56,7 +60,7 @@ public AuthHelpers(LoggerAdapter loggerAdapter, .WithLogging(loggerAdapter, hostEnvironment.IsDevelopment()) .WithHttpClientFactory(new HttpClientFactoryAdapter(httpMessageHandlerFactory)) .WithRedirectUri(redirectUri) - .WithOidcAuthority(authority.ToString()) + .WithOidcAuthority(lexboxServer.Authority.ToString()) .Build(); _ = MsalCacheHelper.CreateAsync(BuildCacheProperties(options.Value.CacheFileName)).ContinueWith( task => @@ -109,9 +113,10 @@ public HttpClient GetHttpClient() } } - public async Task SignIn(CancellationToken cancellation = default) + public async Task SignIn(string returnUrl, CancellationToken cancellation = default) { - return await _oAuthService.SubmitLoginRequest(_application, cancellation); + InvalidateProjectCache(); + return await _oAuthService.SubmitLoginRequest(_application, returnUrl, cancellation); } public async Task Logout() @@ -122,6 +127,12 @@ public async Task Logout() { await _application.RemoveAsync(account); } + InvalidateProjectCache(); + } + + private void InvalidateProjectCache() + { + _lexboxProjectService.InvalidateProjectsCache(_lexboxServer); } private async ValueTask GetAuth() @@ -177,7 +188,7 @@ await _application var handler = _httpMessageHandlerFactory.CreateHandler(AuthHttpClientName); var client = new HttpClient(handler, false); - client.BaseAddress = _authority; + client.BaseAddress = _lexboxServer.Authority; client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", auth.AccessToken); return client; } diff --git a/backend/FwLite/LocalWebApp/Auth/AuthHelpersFactory.cs b/backend/FwLite/LocalWebApp/Auth/AuthHelpersFactory.cs index bc5b2489b..d9fc64aac 100644 --- a/backend/FwLite/LocalWebApp/Auth/AuthHelpersFactory.cs +++ b/backend/FwLite/LocalWebApp/Auth/AuthHelpersFactory.cs @@ -1,33 +1,34 @@ using System.Collections.Concurrent; using LcmCrdt; +using LocalWebApp.Services; +using Microsoft.Extensions.Options; namespace LocalWebApp.Auth; public class AuthHelpersFactory( IServiceProvider provider, ProjectContext projectContext, + IOptions options, IHttpContextAccessor contextAccessor) { private readonly ConcurrentDictionary _helpers = new(); - private string AuthorityKey(Uri authority) => - authority.GetComponents(UriComponents.HostAndPort, UriFormat.Unescaped); + private string AuthorityKey(LexboxServer server) => "AuthHelper|" + server.Authority.Authority; /// - /// gets an Auth Helper for the given authority + /// gets an Auth Helper for the given server /// - /// should include scheme, host and port, no path - public AuthHelpers GetHelper(Uri authority) + public AuthHelpers GetHelper(LexboxServer server) { - var helper = _helpers.GetOrAdd(AuthorityKey(authority), - static (host, arg) => ActivatorUtilities.CreateInstance(arg.provider, arg.authority), - (authority, provider)); + var helper = _helpers.GetOrAdd(AuthorityKey(server), + static (host, arg) => ActivatorUtilities.CreateInstance(arg.provider, arg.server), + (server, provider)); //an auth helper can get created based on the server host, however in development that will not be the same as the client host //so we need to recreate it if the host is not valid if (!helper.IsHostUrlValid()) { - _helpers.TryRemove(AuthorityKey(authority), out _); - return GetHelper(authority); + _helpers.TryRemove(AuthorityKey(server), out _); + return GetHelper(server); } return helper; @@ -40,12 +41,7 @@ public AuthHelpers GetHelper(ProjectData project) { var originDomain = project.OriginDomain; if (string.IsNullOrEmpty(originDomain)) throw new InvalidOperationException("No origin domain in project data"); - return GetHelper(new Uri(originDomain)); - } - - public AuthHelpers GetHelper(LexboxServer server) - { - return GetHelper(server.Authority); + return GetHelper(options.Value.GetServer(project)); } /// diff --git a/backend/FwLite/LocalWebApp/Auth/OAuthService.cs b/backend/FwLite/LocalWebApp/Auth/OAuthService.cs index 60069a70f..fe9d9c94a 100644 --- a/backend/FwLite/LocalWebApp/Auth/OAuthService.cs +++ b/backend/FwLite/LocalWebApp/Auth/OAuthService.cs @@ -12,14 +12,16 @@ namespace LocalWebApp.Auth; public class OAuthService(ILogger logger, IHostApplicationLifetime applicationLifetime, IOptions options) : BackgroundService { public record SignInResult(Uri? AuthUri, bool HandledBySystemWebView); - public async Task SubmitLoginRequest(IPublicClientApplication application, CancellationToken cancellation) + public async Task SubmitLoginRequest(IPublicClientApplication application, + string returnUrl, + CancellationToken cancellation) { if (options.Value.SystemWebViewLogin) { await HandleSystemWebViewLogin(application, cancellation); return new(null, true); } - var request = new OAuthLoginRequest(application); + var request = new OAuthLoginRequest(application, returnUrl); if (!_requestChannel.Writer.TryWrite(request)) { throw new InvalidOperationException("Only one request at a time"); @@ -40,7 +42,7 @@ private async Task HandleSystemWebViewLogin(IPublicClientApplication application .ExecuteAsync(cancellation); } - public async Task FinishLoginRequest(Uri uri, CancellationToken cancellation = default) + public async Task<(AuthenticationResult, string ClientReturnUrl)> FinishLoginRequest(Uri uri, CancellationToken cancellation = default) { var queryString = HttpUtility.ParseQueryString(uri.Query); var state = queryString.Get("state") ?? throw new InvalidOperationException("State is null"); @@ -48,7 +50,7 @@ public async Task FinishLoginRequest(Uri uri, Cancellation throw new InvalidOperationException("Invalid state"); //step 5 request.SetReturnUri(uri); - return await request.GetAuthenticationResult(applicationLifetime.ApplicationStopping.Merge(cancellation)); + return (await request.GetAuthenticationResult(applicationLifetime.ApplicationStopping.Merge(cancellation)), request.ClientReturnUrl); //step 8 } @@ -90,7 +92,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) /// instead we have to do this so we can use the currently open browser, redirect it to the auth url passed in here and then once it's done and the callback comes to our server, /// send that call to here so that MSAL can pull out the access token /// -public class OAuthLoginRequest(IPublicClientApplication app) : ICustomWebUi +public class OAuthLoginRequest(IPublicClientApplication app, string clientReturnUrl) : ICustomWebUi { public IPublicClientApplication Application { get; } = app; public string? State { get; private set; } @@ -124,4 +126,8 @@ public void SetException(Exception e) } public Task GetAuthenticationResult(CancellationToken cancellation) => _resultTcs.Task.WaitAsync(cancellation); + /// + /// url to return the client to once the login is finished + /// + public string ClientReturnUrl { get; } = clientReturnUrl; } diff --git a/backend/FwLite/LocalWebApp/Hubs/MiniLcmApiHubBase.cs b/backend/FwLite/LocalWebApp/Hubs/MiniLcmApiHubBase.cs index 36fa19493..5f477f6e8 100644 --- a/backend/FwLite/LocalWebApp/Hubs/MiniLcmApiHubBase.cs +++ b/backend/FwLite/LocalWebApp/Hubs/MiniLcmApiHubBase.cs @@ -65,9 +65,9 @@ public virtual async Task CreateEntry(Entry entry) return newEntry; } - public virtual async Task UpdateEntry(Guid id, JsonPatchDocument update) + public virtual async Task UpdateEntry(Entry before, Entry after) { - var entry = await miniLcmApi.UpdateEntry(id, new UpdateObjectInput(update)); + var entry = await miniLcmApi.UpdateEntry(before, after); await NotifyEntryUpdated(entry); return entry; } diff --git a/backend/FwLite/LocalWebApp/LocalAppKernel.cs b/backend/FwLite/LocalWebApp/LocalAppKernel.cs index 2ad02e7ca..aa27e512d 100644 --- a/backend/FwLite/LocalWebApp/LocalAppKernel.cs +++ b/backend/FwLite/LocalWebApp/LocalAppKernel.cs @@ -21,7 +21,7 @@ public static IServiceCollection AddLocalAppServices(this IServiceCollection ser services.AddAuthHelpers(environment); services.AddSingleton(); services.AddScoped(); - services.AddScoped(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/backend/FwLite/LocalWebApp/Routes/AuthRoutes.cs b/backend/FwLite/LocalWebApp/Routes/AuthRoutes.cs index 2cd52d1e8..21f521741 100644 --- a/backend/FwLite/LocalWebApp/Routes/AuthRoutes.cs +++ b/backend/FwLite/LocalWebApp/Routes/AuthRoutes.cs @@ -1,6 +1,7 @@ using System.Security.AccessControl; using System.Web; using LocalWebApp.Auth; +using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; namespace LocalWebApp.Routes; @@ -23,12 +24,13 @@ public static IEndpointConventionBuilder MapAuthRoutes(this WebApplication app) }); }); group.MapGet("/login/{authority}", - async (AuthHelpersFactory factory, string authority, IOptions options) => + async (AuthHelpersFactory factory, string authority, IOptions options, [FromHeader] string referer) => { - var result = await factory.GetHelper(options.Value.GetServerByAuthority(authority)).SignIn(); + var returnUrl = new Uri(referer).PathAndQuery; + var result = await factory.GetHelper(options.Value.GetServerByAuthority(authority)).SignIn(returnUrl); if (result.HandledBySystemWebView) { - return Results.Redirect("/"); + return Results.Redirect(returnUrl); } if (result.AuthUri is null) throw new InvalidOperationException("AuthUri is null"); @@ -43,8 +45,8 @@ public static IEndpointConventionBuilder MapAuthRoutes(this WebApplication app) context.Request.Path); uriBuilder.Query = context.Request.QueryString.ToUriComponent(); - await oAuthService.FinishLoginRequest(uriBuilder.Uri); - return Results.Redirect("/"); + var (_, returnUrl) = await oAuthService.FinishLoginRequest(uriBuilder.Uri); + return Results.Redirect(returnUrl); }).WithName(CallbackRoute); group.MapGet("/me/{authority}", async (AuthHelpersFactory factory, string authority, IOptions options) => diff --git a/backend/FwLite/LocalWebApp/Routes/ProjectRoutes.cs b/backend/FwLite/LocalWebApp/Routes/ProjectRoutes.cs index 1d945394c..62316ba1b 100644 --- a/backend/FwLite/LocalWebApp/Routes/ProjectRoutes.cs +++ b/backend/FwLite/LocalWebApp/Routes/ProjectRoutes.cs @@ -4,6 +4,7 @@ using LocalWebApp.Auth; using LocalWebApp.Hubs; using LocalWebApp.Services; +using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; using MiniLcm; using MiniLcm.Models; @@ -24,7 +25,9 @@ public static IEndpointConventionBuilder MapProjectRoutes(this WebApplication ap foreach (var server in options.Value.LexboxServers) { var lexboxProjects = await lexboxProjectService.GetLexboxProjects(server); - serversProjects.Add(server.Authority.Authority, lexboxProjects.Select(p => new ProjectModel(p.Name, false, false, true, server.Authority.Authority, p.Id)).ToArray()); + serversProjects.Add(server.Authority.Authority, lexboxProjects.Select(p => new ProjectModel + (p.Name, Crdt: p.IsCrdtProject, Fwdata: false, Lexbox: true, server.Authority.Authority, p.Id)) + .ToArray()); } return serversProjects; @@ -77,34 +80,37 @@ public static IEndpointConventionBuilder MapProjectRoutes(this WebApplication ap SyncService syncService, IOptions options, CurrentProjectService currentProjectService, - string serverAuthority) => + string serverAuthority, + [FromQuery] Guid lexboxProjectId) => { var server = options.Value.GetServerByAuthority(serverAuthority); - var foundProjectGuid = - await lexboxProjectService.GetLexboxProjectId(server, currentProjectService.ProjectData.Name); - if (foundProjectGuid is null) - return Results.BadRequest( - $"Project code {currentProjectService.ProjectData.Name} not found on lexbox"); - await currentProjectService.SetProjectSyncOrigin(server.Authority, foundProjectGuid); - await syncService.ExecuteSync(); + await currentProjectService.SetProjectSyncOrigin(server.Authority, lexboxProjectId); + try + { + await syncService.ExecuteSync(); + } + catch + { + await currentProjectService.SetProjectSyncOrigin(null, null); + throw; + } + lexboxProjectService.InvalidateProjectsCache(server); return TypedResults.Ok(); }); - group.MapPost("/download/crdt/{serverAuthority}/{newProjectName}", + group.MapPost("/download/crdt/{serverAuthority}/{projectId}", async (LexboxProjectService lexboxProjectService, IOptions options, ProjectsService projectService, - string newProjectName, + Guid projectId, + [FromQuery] string projectName, string serverAuthority ) => { - if (!ProjectName().IsMatch(newProjectName)) + if (!ProjectName().IsMatch(projectName)) return Results.BadRequest("Project name is invalid"); var server = options.Value.GetServerByAuthority(serverAuthority); - var foundProjectGuid = await lexboxProjectService.GetLexboxProjectId(server,newProjectName); - if (foundProjectGuid is null) - return Results.BadRequest($"Project code {newProjectName} not found on lexbox"); - await projectService.CreateProject(new(newProjectName, - foundProjectGuid.Value, + await projectService.CreateProject(new(projectName, + projectId, server.Authority, async (provider, project) => { diff --git a/backend/FwLite/LocalWebApp/Services/LexboxProjectService.cs b/backend/FwLite/LocalWebApp/Services/LexboxProjectService.cs index 6cc650852..c392ce503 100644 --- a/backend/FwLite/LocalWebApp/Services/LexboxProjectService.cs +++ b/backend/FwLite/LocalWebApp/Services/LexboxProjectService.cs @@ -12,23 +12,39 @@ public class LexboxProjectService( ILogger logger, IHttpMessageHandlerFactory httpMessageHandlerFactory, BackgroundSyncService backgroundSyncService, + IOptions options, IMemoryCache cache) { - public record LexboxCrdtProject(Guid Id, string Name); + public record LexboxProject(Guid Id, string Code, string Name, bool IsFwDataProject, bool IsCrdtProject); - public async Task GetLexboxProjects(LexboxServer server) + public LexboxServer[] Servers() { - var httpClient = await helpersFactory.GetHelper(server).CreateClient(); - if (httpClient is null) return []; - try - { - return await httpClient.GetFromJsonAsync("api/crdt/listProjects") ?? []; - } - catch (HttpRequestException e) - { - logger.LogError(e, "Error getting lexbox projects"); - return []; - } + return options.Value.LexboxServers; + } + + public async Task GetLexboxProjects(LexboxServer server) + { + return await cache.GetOrCreateAsync(CacheKey(server), + async entry => + { + entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5); + var httpClient = await helpersFactory.GetHelper(server).CreateClient(); + if (httpClient is null) return []; + try + { + return await httpClient.GetFromJsonAsync("api/crdt/listProjects") ?? []; + } + catch (HttpRequestException e) + { + logger.LogError(e, "Error getting lexbox projects"); + return []; + } + }) ?? []; + } + + private static string CacheKey(LexboxServer server) + { + return $"Projects|{server.Authority.Authority}"; } public async Task GetLexboxProjectId(LexboxServer server, string code) @@ -46,29 +62,34 @@ public async Task GetLexboxProjects(LexboxServer server) } } + public void InvalidateProjectsCache(LexboxServer server) + { + cache.Remove(CacheKey(server)); + } + public async Task ListenForProjectChanges(ProjectData projectData, CancellationToken stoppingToken) { if (string.IsNullOrEmpty(projectData.OriginDomain)) return; - var lexboxConnection = await StartLexboxProjectChangeListener(projectData.OriginDomain, stoppingToken); + var lexboxConnection = await StartLexboxProjectChangeListener(options.Value.GetServer(projectData), stoppingToken); if (lexboxConnection is null) return; await lexboxConnection.SendAsync("ListenForProjectChanges", projectData.Id, stoppingToken); } - private string CacheKey(string originDomain) => $"LexboxProjectChangeListener|{originDomain}"; + private static string HubConnectionCacheKey(LexboxServer server) => $"LexboxProjectChangeListener|{server.Authority.Authority}"; - public async Task StartLexboxProjectChangeListener(string originDomain, + public async Task StartLexboxProjectChangeListener(LexboxServer server, CancellationToken stoppingToken) { HubConnection? connection; - if (cache.TryGetValue(CacheKey(originDomain), out connection) && connection is not null) + if (cache.TryGetValue(HubConnectionCacheKey(server), out connection) && connection is not null) { return connection; } - if (await helpersFactory.GetHelper(new Uri(originDomain)).GetCurrentToken() is null) + if (await helpersFactory.GetHelper(server).GetCurrentToken() is null) { - logger.LogWarning("Unable to create signalR client, user is not authenticated to {OriginDomain}", originDomain); + logger.LogWarning("Unable to create signalR client, user is not authenticated to {OriginDomain}", server.Authority); return null; } @@ -76,7 +97,7 @@ public async Task ListenForProjectChanges(ProjectData projectData, CancellationT //todo bridge logging to the aspnet logger .ConfigureLogging(logging => logging.AddConsole()) .WithAutomaticReconnect() - .WithUrl($"{originDomain}/api/hub/crdt/project-changes", + .WithUrl($"{server.Authority}api/hub/crdt/project-changes", connectionOptions => { connectionOptions.HttpMessageHandlerFactory = handler => @@ -85,7 +106,7 @@ public async Task ListenForProjectChanges(ProjectData projectData, CancellationT return httpMessageHandlerFactory.CreateHandler(AuthHelpers.AuthHttpClientName); }; connectionOptions.AccessTokenProvider = - async () => await helpersFactory.GetHelper(new Uri(originDomain)).GetCurrentToken(); + async () => await helpersFactory.GetHelper(server).GetCurrentToken(); }) .Build(); @@ -105,10 +126,10 @@ public async Task ListenForProjectChanges(ProjectData projectData, CancellationT connection.Closed += async (exception) => { - cache.Remove(CacheKey(originDomain)); + cache.Remove(HubConnectionCacheKey(server)); await connection.DisposeAsync(); }; - cache.CreateEntry(CacheKey(originDomain)).SetValue(connection).RegisterPostEvictionCallback( + cache.CreateEntry(HubConnectionCacheKey(server)).SetValue(connection).RegisterPostEvictionCallback( static (key, value, reason, state) => { if (value is HubConnection con) diff --git a/backend/FwLite/LocalWebApp/appsettings.json b/backend/FwLite/LocalWebApp/appsettings.json index 8171300d2..7b9b2c05a 100644 --- a/backend/FwLite/LocalWebApp/appsettings.json +++ b/backend/FwLite/LocalWebApp/appsettings.json @@ -8,9 +8,17 @@ } }, "AllowedHosts": "*", + "FwLiteDesktop": { + // uncomment the following line to configure FW Lite where to store it's data. This includes log files + // this is useful for testing and running off a flash drive. + // Defaults + // installed: AppData\Local\Packages\FwLiteDesktop_01zkby2q2skmg\LocalState + // portable: current directory + //"BaseDataDir": "C:\\FwLiteData" + }, "LcmCrdt": { // uncomment the following line to set the path where sqlite files are loaded and saved. - // by default windows uses AppData\Local\SIL\FwLiteDesktop + // not used in windows, use BaseDataPath above instead //"ProjectPath": "" }, "FwDataBridge": { diff --git a/backend/FwLite/MiniLcm.Tests/MiniLcm.Tests.csproj b/backend/FwLite/MiniLcm.Tests/MiniLcm.Tests.csproj index 86bcc41df..83030857f 100644 --- a/backend/FwLite/MiniLcm.Tests/MiniLcm.Tests.csproj +++ b/backend/FwLite/MiniLcm.Tests/MiniLcm.Tests.csproj @@ -20,7 +20,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/backend/FwLite/MiniLcm.Tests/MiniLcmTestBase.cs b/backend/FwLite/MiniLcm.Tests/MiniLcmTestBase.cs index f4ee8de99..1b5601be2 100644 --- a/backend/FwLite/MiniLcm.Tests/MiniLcmTestBase.cs +++ b/backend/FwLite/MiniLcm.Tests/MiniLcmTestBase.cs @@ -6,7 +6,7 @@ namespace MiniLcm.Tests; public abstract class MiniLcmTestBase : IAsyncLifetime { - protected readonly AutoFaker AutoFaker = new(builder => + protected static readonly AutoFaker AutoFaker = new(builder => builder.WithOverride(new MultiStringOverride(["en"])) .WithOverride(new ObjectWithIdOverride()) ); diff --git a/backend/LexBoxApi/Controllers/CrdtController.cs b/backend/LexBoxApi/Controllers/CrdtController.cs index 6a601377d..4051d2b52 100644 --- a/backend/LexBoxApi/Controllers/CrdtController.cs +++ b/backend/LexBoxApi/Controllers/CrdtController.cs @@ -1,8 +1,10 @@ using System.Text.Json.Serialization; using SIL.Harmony.Core; +using LexBoxApi.Auth; using LexBoxApi.Auth.Attributes; using LexBoxApi.Hub; using LexBoxApi.Services; +using LexCore.Entities; using LexCore.ServiceInterfaces; using LexCore.Sync; using LexData; @@ -21,14 +23,17 @@ public class CrdtController( LexBoxDbContext dbContext, IHubContext hubContext, IPermissionService permissionService, + LoggedInContext loggedInContext, ProjectService projectService, FwHeadlessClient fwHeadlessClient) : ControllerBase { + private DbSet ServerCommits => dbContext.Set(); + [HttpGet("{projectId}/get")] public async Task> GetSyncState(Guid projectId) { await permissionService.AssertCanSyncProject(projectId); - return await dbContext.Set().Where(c => c.ProjectId == projectId).GetSyncState(); + return await ServerCommits.Where(c => c.ProjectId == projectId).GetSyncState(); } [HttpPost("{projectId}/add")] @@ -52,24 +57,25 @@ public record ChangesResult(IAsyncEnumerable MissingFromClient, Sy [JsonIgnore]//just to ensure type safety IEnumerable IChangesResult.MissingFromClient => MissingFromClient.ToBlockingEnumerable(); } + [HttpPost("{projectId}/changes")] public async Task> Changes(Guid projectId, [FromBody] SyncState clientHeads) { await permissionService.AssertCanSyncProject(projectId); - var commits = dbContext.Set().Where(c => c.ProjectId == projectId); + var commits = ServerCommits.Where(c => c.ProjectId == projectId); var localState = await commits.GetSyncState(); return new ChangesResult(commits.GetMissingCommits(localState, clientHeads), localState); } - public record LexboxCrdtProject(Guid Id, string Name); + public record FwLiteProject(Guid Id, string Code, string Name, bool IsFwDataProject, bool IsCrdtProject); [HttpGet("listProjects")] - public async Task> ListProjects() + public async Task> ListProjects() { - return await dbContext.Projects - .Where(p => dbContext.Set().Any(c => c.ProjectId == p.Id)) - .Select(p => new LexboxCrdtProject(p.Id, p.Code)) + return 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, ServerCommits.Any(c => c.ProjectId == p.Id))) .ToArrayAsync(); } diff --git a/backend/LexBoxApi/Controllers/IntegrationController.cs b/backend/LexBoxApi/Controllers/IntegrationController.cs index 84819a63e..64de785a6 100644 --- a/backend/LexBoxApi/Controllers/IntegrationController.cs +++ b/backend/LexBoxApi/Controllers/IntegrationController.cs @@ -35,7 +35,7 @@ public class IntegrationController( [ProducesResponseType(StatusCodes.Status302Found)] public async Task OpenWithFlex(Guid projectId) { - if (!await permissionService.CanSyncProjectAsync(projectId)) return Unauthorized(); + if (!await permissionService.CanSyncProject(projectId)) return Unauthorized(); var project = await lexBoxDbContext.Projects.FirstOrDefaultAsync(p => p.Id == projectId); if (project is null) return NotFound(); var repoId = await hgService.GetRepositoryIdentifier(project); diff --git a/backend/LexBoxApi/GraphQL/LexQueries.cs b/backend/LexBoxApi/GraphQL/LexQueries.cs index 3a3f0b10d..f2326d2ef 100644 --- a/backend/LexBoxApi/GraphQL/LexQueries.cs +++ b/backend/LexBoxApi/GraphQL/LexQueries.cs @@ -2,6 +2,7 @@ using LexBoxApi.Auth; using LexBoxApi.Auth.Attributes; using LexBoxApi.GraphQL.CustomTypes; +using LexBoxApi.Services; using LexCore.Auth; using LexCore.Entities; using LexCore.ServiceInterfaces; @@ -19,11 +20,11 @@ public class LexQueries public async Task> MyProjects( LexAuthService lexAuthService, LoggedInContext loggedInContext, - LexBoxDbContext dbContext, + ProjectService projectService, IResolverContext context) { var userId = loggedInContext.User.Id; - var myProjects = await dbContext.Projects.Where(p => p.Users.Select(u => u.UserId).Contains(userId)) + var myProjects = await projectService.UserProjects(userId) .AsNoTracking().Project(context).ToListAsync(); if (loggedInContext.User.IsOutOfSyncWithMyProjects(myProjects)) @@ -229,7 +230,7 @@ public IQueryable UsersInMyOrg(LexBoxDbContext context, LoggedInContext lo } }); // Members and non-members alike can see all public projects plus their own - org.Projects = org.Projects?.Where(p => p.IsConfidential == false || permissionService.CanSyncProject(p.Id))?.ToList() ?? []; + org.Projects = org.Projects?.Where(p => p.IsConfidential == false || permissionService.IsProjectMember(p.Id, updatedUser))?.ToList() ?? []; if (!permissionService.IsOrgMember(orgId, updatedUser)) { // Non-members also cannot see membership, only org admins diff --git a/backend/LexBoxApi/Services/HgService.cs b/backend/LexBoxApi/Services/HgService.cs index 7155108c1..32e328943 100644 --- a/backend/LexBoxApi/Services/HgService.cs +++ b/backend/LexBoxApi/Services/HgService.cs @@ -271,8 +271,19 @@ private string[] GetWsList(System.Xml.XmlElement root, string tagName) { var result = await ExecuteHgCommandServerCommand(code, "flexmodelversion", token); var text = await result.ReadAsStringAsync(token); - var json = JsonDocument.Parse(text); - return json.RootElement.GetProperty("modelversion").GetInt32(); + if (string.IsNullOrWhiteSpace(text)) return null; + try + { + var json = JsonDocument.Parse(text); + if (json.RootElement.TryGetProperty("modelversion", out var modelversion) && modelversion.TryGetInt32(out int version)) return version; + _logger.LogError("Invalid JSON {text} in GetModelVersionOfFlexProject, should have one property \"modelversion\" that's a number", text); + return null; + } + catch (JsonException e) + { + _logger.LogError("Malformed JSON {text} in GetModelVersionOfFlexProject: {error}", text, e.ToString()); + return null; + } } public Task RevertRepo(ProjectCode code, string revHash) diff --git a/backend/LexBoxApi/Services/PermissionService.cs b/backend/LexBoxApi/Services/PermissionService.cs index 64e9bf3e2..983461be7 100644 --- a/backend/LexBoxApi/Services/PermissionService.cs +++ b/backend/LexBoxApi/Services/PermissionService.cs @@ -20,7 +20,7 @@ private async ValueTask ManagesOrgThatOwnsProject(Guid projectId, LexAuthU // Org admins can view, edit, and sync all projects, even confidential ones var managedOrgIds = user.Orgs.Where(o => o.Role == OrgRole.Admin).Select(o => o.OrgId).ToHashSet(); var projectOrgIds = await projectService.LookupProjectOrgIds(projectId); - if (projectOrgIds.Any(oId => managedOrgIds.Contains(oId))) return true; + if (projectOrgIds.Any(managedOrgIds.Contains)) return true; } return false; } @@ -31,7 +31,7 @@ private async ValueTask IsMemberOfOrgThatOwnsProject(Guid projectId) { var memberOfOrgIds = User.Orgs.Select(o => o.OrgId).ToHashSet(); var projectOrgIds = await projectService.LookupProjectOrgIds(projectId); - if (projectOrgIds.Any(oId => memberOfOrgIds.Contains(oId))) return true; + if (projectOrgIds.Any(memberOfOrgIds.Contains)) return true; } return false; } @@ -40,20 +40,21 @@ public async ValueTask CanSyncProject(string projectCode) { if (User is null) return false; if (User.Role == UserRole.admin) return true; - return await CanSyncProjectAsync(await projectService.LookupProjectId(projectCode)); + return await CanSyncProject(await projectService.LookupProjectId(projectCode)); } - public bool CanSyncProject(Guid projectId) + public bool IsProjectMember(Guid projectId, LexAuthUser? overrideUser = null) { - if (User is null) return false; - if (User.Role == UserRole.admin) return true; - if (User.Projects is null) return false; - return User.IsProjectMember(projectId); + var user = overrideUser ?? User; + if (user is null) return false; + return user.IsProjectMember(projectId); } - public async ValueTask CanSyncProjectAsync(Guid projectId) + public async ValueTask CanSyncProject(Guid projectId) { - if (CanSyncProject(projectId)) return true; + if (User is null) return false; + if (User.Role == UserRole.admin) return true; + if (User.IsProjectMember(projectId)) return true; // Org managers can sync any project owned by their org(s) return await ManagesOrgThatOwnsProject(projectId); } @@ -65,7 +66,7 @@ public async ValueTask AssertCanSyncProject(string projectCode) public async ValueTask AssertCanSyncProject(Guid projectId) { - if (!await CanSyncProjectAsync(projectId)) throw new UnauthorizedAccessException(); + if (!await CanSyncProject(projectId)) throw new UnauthorizedAccessException(); } public async ValueTask CanViewProject(Guid projectId, LexAuthUser? overrideUser = null) diff --git a/backend/LexBoxApi/Services/ProjectService.cs b/backend/LexBoxApi/Services/ProjectService.cs index 298f9faff..095f43582 100644 --- a/backend/LexBoxApi/Services/ProjectService.cs +++ b/backend/LexBoxApi/Services/ProjectService.cs @@ -1,6 +1,7 @@ using System.Data.Common; using LexBoxApi.Models.Project; using LexBoxApi.Services.Email; +using LexCore.Auth; using LexCore.Config; using LexCore.Entities; using LexCore.Exceptions; @@ -38,6 +39,7 @@ public async Task CreateProject(CreateProjectInput input) IsConfidential = isConfidentialIsUntrustworthy ? null : input.IsConfidential, Organizations = theOrg is not null ? [theOrg] : [], Users = input.ProjectManagerId.HasValue ? [new() { UserId = input.ProjectManagerId.Value, Role = ProjectRole.Manager }] : [], + FlexProjectMetadata = input.Type == ProjectType.FLEx ? new() : null }); // Also delete draft project, if any await dbContext.DraftProjects.Where(dp => dp.Id == projectId).ExecuteDeleteAsync(); @@ -302,4 +304,9 @@ public async Task ResetLexEntryCount(string projectCode) } return count; } + + public IQueryable UserProjects(Guid userId) + { + return dbContext.Projects.Where(p => p.Users.Select(u => u.UserId).Contains(userId)); + } } diff --git a/backend/LexCore/Auth/LexAuthUser.cs b/backend/LexCore/Auth/LexAuthUser.cs index 85b94925f..3e5cc040c 100644 --- a/backend/LexCore/Auth/LexAuthUser.cs +++ b/backend/LexCore/Auth/LexAuthUser.cs @@ -237,6 +237,8 @@ public ClaimsPrincipal GetPrincipal(string authenticationType) public bool IsProjectMember(Guid projectId, ProjectRole? role = null) { + if (Projects is null) return false; + if (role is not null) { return Projects.Any(p => p.ProjectId == projectId && p.Role == role); diff --git a/backend/LexCore/ServiceInterfaces/IPermissionService.cs b/backend/LexCore/ServiceInterfaces/IPermissionService.cs index aea787a1f..f575a4af3 100644 --- a/backend/LexCore/ServiceInterfaces/IPermissionService.cs +++ b/backend/LexCore/ServiceInterfaces/IPermissionService.cs @@ -6,15 +6,8 @@ namespace LexCore.ServiceInterfaces; public interface IPermissionService { ValueTask CanSyncProject(string projectCode); - /// - /// Does NOT check permissions for org managers, because that requires async DB access. - /// Use CanSyncProject(projectCode) or CanSyncProjectAsync(projectId) if org manager permissions also need to be checked. - /// - bool CanSyncProject(Guid projectId); - /// - /// Does all the checks from CanSyncProject, plus allows org managers to access the org as well. - /// - ValueTask CanSyncProjectAsync(Guid projectId); + bool IsProjectMember(Guid projectId, LexAuthUser? overrideUser = null); + ValueTask CanSyncProject(Guid projectId); ValueTask AssertCanSyncProject(string projectCode); ValueTask AssertCanSyncProject(Guid projectId); ValueTask CanViewProject(Guid projectId, LexAuthUser? overrideUser = null); diff --git a/frontend/Taskfile.yml b/frontend/Taskfile.yml index 1c911f094..4c37bd215 100644 --- a/frontend/Taskfile.yml +++ b/frontend/Taskfile.yml @@ -36,9 +36,10 @@ tasks: desc: "Runs the frontend code checks done in CI. Note: the app must be built." aliases: [ sc, svelte-check ] deps: [ install ] + ignore_error: true cmds: - - pnpm run check - - pnpm run lint + - pnpm run {{.CLI_ARGS}} --no-bail check + - pnpm run {{.CLI_ARGS}} --no-bail lint svelte: desc: "Directory independent access to pnpm. E.g. `task svelte -- install`" diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 30589ae61..b5631569c 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -297,6 +297,9 @@ importers: fast-json-patch: specifier: ^3.1.1 version: 3.1.1 + just-throttle: + specifier: ^4.2.0 + version: 4.2.0 postcss: specifier: 'catalog:' version: 8.4.47 @@ -3954,6 +3957,9 @@ packages: just-order-by@1.0.0: resolution: {integrity: sha512-m83kcBMoX43jRLDzR6J7NzIpEEpMmMmh0xwVSMKpXObIFh6ejxpQ02HXc9gCq5cFWHbL5gZ3yRHRGYgMGpoUnA==} + just-throttle@4.2.0: + resolution: {integrity: sha512-/iAZv1953JcExpvsywaPKjSzfTiCLqeguUTE6+VmK15mOcwxBx7/FHrVvS4WEErMR03TRazH8kcBSHqMagYIYg==} + jwt-decode@4.0.0: resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==} engines: {node: '>=18'} @@ -8656,7 +8662,7 @@ snapshots: sirv: 3.0.0 tinyglobby: 0.2.9 tinyrainbow: 1.2.0 - vitest: 2.1.4(@types/node@20.12.12)(@vitest/ui@2.1.4)(happy-dom@15.7.4) + vitest: 2.1.4(@types/node@22.7.3)(@vitest/ui@2.1.4)(happy-dom@15.7.4) '@vitest/utils@2.1.4': dependencies: @@ -10235,6 +10241,8 @@ snapshots: just-order-by@1.0.0: {} + just-throttle@4.2.0: {} + jwt-decode@4.0.0: {} keyv@4.5.4: diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts index 96bebe6a5..060542562 100644 --- a/frontend/src/hooks.server.ts +++ b/frontend/src/hooks.server.ts @@ -1,13 +1,13 @@ -import { loadI18n, pickBestLocale } from '$lib/i18n'; -import { AUTH_COOKIE_NAME, getUser, isAuthn } from '$lib/user' -import { apiVersion } from '$lib/util/version'; -import { redirect, type Handle, type HandleFetch, type HandleServerError, type RequestEvent, type ResolveOptions } from '@sveltejs/kit' -import { ensureErrorIsTraced, traceRequest, traceFetch } from '$lib/otel/otel.server' -import { env } from '$env/dynamic/private'; -import { getErrorMessage, validateFetchResponse } from './hooks.shared'; -import { setViewMode } from './routes/(authenticated)/shared'; +import {loadI18n, pickBestLocale} from '$lib/i18n'; +import {AUTH_COOKIE_NAME, getUser, isAuthn} from '$lib/user'; +import {apiVersion} from '$lib/util/version'; +import {redirect, type Handle, type HandleFetch, type HandleServerError, type RequestEvent, type ResolveOptions} from '@sveltejs/kit'; +import {ensureErrorIsTraced, traceRequest, traceFetch} from '$lib/otel/otel.server'; +import {env} from '$env/dynamic/private'; +import {getErrorMessage, validateFetchResponse} from './hooks.shared'; +import {setViewMode} from './routes/(authenticated)/shared'; import * as setCookieParser from 'set-cookie-parser'; -import { AUTHENTICATED_ROOT, UNAUTHENTICATED_ROOT } from './routes'; +import {AUTHENTICATED_ROOT, UNAUTHENTICATED_ROOT} from './routes'; const PUBLIC_ROUTE_ROOTS = [ UNAUTHENTICATED_ROOT, @@ -29,7 +29,7 @@ async function initI18n(event: RequestEvent): Promise { } // eslint-disable-next-line func-style -export const handle: Handle = ({ event, resolve }) => { +export const handle: Handle = ({event, resolve}) => { console.log(`HTTP request: ${event.request.method} ${event.request.url}`); event.locals.getUser = () => getUser(event.cookies); return traceRequest(event, async () => { @@ -39,14 +39,21 @@ export const handle: Handle = ({ event, resolve }) => { filterSerializedResponseHeaders: () => true, } - const { cookies, route: { id: routeId } } = event; + const {cookies, route: {id: routeId}} = event; if (!routeId) { redirect(307, '/'); } else if (PUBLIC_ROUTE_ROOTS.includes(getRoot(routeId))) { - return resolve(event, options); + const response = await resolve(event, options); + if (routeId.endsWith('/logout')) { + response.headers.set('Clear-Site-Data', '"cache"'); + } + return response; } else if (!isAuthn(cookies)) { const relativePath = event.url.href.substring(event.url.origin.length); - redirect(307, `/login?ReturnUrl=${encodeURIComponent(relativePath)}`); + if (relativePath !== '/') + redirect(307, `/login?ReturnUrl=${encodeURIComponent(relativePath)}`); + else + redirect(307, '/login'); } //when at home if (routeId == `/${AUTHENTICATED_ROOT}`) { @@ -54,7 +61,7 @@ export const handle: Handle = ({ event, resolve }) => { } return resolve(event, options); - }) + }); }; // eslint-disable-next-line func-style diff --git a/frontend/src/routes/(authenticated)/admin/+page.svelte b/frontend/src/routes/(authenticated)/admin/+page.svelte index 541ed2529..c9e600101 100644 --- a/frontend/src/routes/(authenticated)/admin/+page.svelte +++ b/frontend/src/routes/(authenticated)/admin/+page.svelte @@ -52,6 +52,7 @@ $: tab = $queryParamValues.tab; const loadingUsers = derived(navigating, (nav) => { + if (!nav?.to?.route.id?.endsWith('/admin')) return false; const fromUrl = nav?.from?.url; return fromUrl && userFilterKeys.some((key) => (fromUrl.searchParams.get(key) ?? defaultQueryParamValues[key])?.toString() !== $queryParamValues[key]); diff --git a/frontend/src/routes/(authenticated)/logout/+server.ts b/frontend/src/routes/(authenticated)/logout/+server.ts deleted file mode 100644 index 002fcbf1f..000000000 --- a/frontend/src/routes/(authenticated)/logout/+server.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type {RequestEvent} from './$types' -import { logout } from '$lib/user' -import { redirect } from '@sveltejs/kit' - -export function GET({cookies}: RequestEvent) : void { - logout(cookies) - - redirect(303, '/login'); -} diff --git a/frontend/src/routes/(authenticated)/project/[project_code]/viewer/lfClassicLexboxApi.ts b/frontend/src/routes/(authenticated)/project/[project_code]/viewer/lfClassicLexboxApi.ts index ee96d87cd..fca833cb2 100644 --- a/frontend/src/routes/(authenticated)/project/[project_code]/viewer/lfClassicLexboxApi.ts +++ b/frontend/src/routes/(authenticated)/project/[project_code]/viewer/lfClassicLexboxApi.ts @@ -120,7 +120,7 @@ export class LfClassicLexboxApi implements LexboxApiClient { throw new Error('Method not implemented.'); } - UpdateEntry(_guid: string, _update: JsonPatch): Promise { + UpdateEntry(_before: IEntry, _after: IEntry): Promise { throw new Error('Method not implemented.'); } diff --git a/frontend/src/routes/(unauthenticated)/logout/+server.ts b/frontend/src/routes/(unauthenticated)/logout/+server.ts new file mode 100644 index 000000000..9ffc801f5 --- /dev/null +++ b/frontend/src/routes/(unauthenticated)/logout/+server.ts @@ -0,0 +1,9 @@ +import type {RequestEvent} from './$types'; +import {logout} from '$lib/user'; +import {redirect} from '@sveltejs/kit'; + +export function GET({cookies}: RequestEvent): void { + logout(cookies); + + redirect(303, '/login'); +} diff --git a/frontend/tests/components/authenticatedDrawer.ts b/frontend/tests/components/authenticatedDrawer.ts new file mode 100644 index 000000000..b9e18a505 --- /dev/null +++ b/frontend/tests/components/authenticatedDrawer.ts @@ -0,0 +1,19 @@ +import {type Locator, type Page} from '@playwright/test'; +import {BaseComponent} from './baseComponent'; +import {LoginPage} from '../pages/loginPage'; + +export class AuthenticatedDrawer extends BaseComponent { + + get logoutLink(): Locator { + return this.componentLocator.getByRole('link', { name: 'Log out' }); + } + + constructor(page: Page) { + super(page, page.locator(`.drawer .drawer-side:has-text("Log out")`)); + } + + async logout(): Promise { + await this.logoutLink.click(); + return new LoginPage(this.page).waitFor(); + } +} diff --git a/frontend/tests/emailWorkflow.test.ts b/frontend/tests/emailWorkflow.test.ts index 988c85934..7b735d3b0 100644 --- a/frontend/tests/emailWorkflow.test.ts +++ b/frontend/tests/emailWorkflow.test.ts @@ -16,7 +16,7 @@ const userIdsToDelete: string[] = []; test.afterEach(async ({ page }) => { if (userIdsToDelete.length > 0) { - await loginAs(page.request, 'admin', defaultPassword); + await loginAs(page.request, 'admin'); for (const userId of userIdsToDelete) { await deleteUser(page.request, userId); } @@ -110,7 +110,7 @@ test('forgot password', async ({ page, tempUser }) => { test('register via new-user invitation email', async ({ page, mailboxFactory }) => { test.setTimeout(TEST_TIMEOUT_2X); - await loginAs(page.request, 'admin', defaultPassword); + await loginAs(page.request, 'admin'); const adminPage = await new AdminDashboardPage(page).goto(); const projectPage = await adminPage.openProject('Sena 3', 'sena-3'); diff --git a/frontend/tests/errorHandling.test.ts b/frontend/tests/errorHandling.test.ts index c7d6c0385..935e372ce 100644 --- a/frontend/tests/errorHandling.test.ts +++ b/frontend/tests/errorHandling.test.ts @@ -58,7 +58,7 @@ test('catch fetch 500 and error dialog', async ({ page }) => { //we want to verify that once we get the 500 in GQL we can still navigate to another page test('client-side gql 500 does not break the application', async ({ page }) => { - await loginAs(page.request, 'admin', testEnv.defaultPassword); + await loginAs(page.request, 'admin'); await new SandboxPage(page).goto(); // Create promise first before triggering the action const responsePromise = page.waitForResponse('/api/graphql'); @@ -72,7 +72,7 @@ test('client-side gql 500 does not break the application', async ({ page }) => { }); test('server-side gql 500 does not kill the server', async ({ page }) => { - await loginAs(page.request, 'admin', testEnv.defaultPassword); + await loginAs(page.request, 'admin'); await new SandboxPage(page).goto({ urlEnd: '?ssr-gql-500', expectErrorResponse: true }); await expect(page.locator(':text-matches("Unexpected Execution Error", "g")').first()).toBeVisible(); // we've verified that a 500 occured, now we verify that the server is still alive @@ -89,7 +89,7 @@ test('server page load 401 is redirected to login', async ({ context }) => { test('client page load 401 is redirected to login', async ({ page }) => { // TODO: Move this to a setup script as recommended by https://playwright.dev/docs/auth - await loginAs(page.request, 'admin', testEnv.defaultPassword); + await loginAs(page.request, 'admin'); const adminDashboardPage = await new AdminDashboardPage(page).goto(); // Now mess up the login cookie and watch the redirect @@ -122,14 +122,14 @@ test('can catch 403 errors from goto in new tab', async ({ page, context }) => { }); test('page load 403 is redirected to home', async ({ page }) => { - await loginAs(page.request, 'manager', testEnv.defaultPassword); + await loginAs(page.request, 'manager'); await new SandboxPage(page).goto(); await page.getByText('Goto page load 403', {exact: true}).click(); await new UserDashboardPage(page).waitFor(); }); test('page load 403 in new tab is redirected to home', async ({ page }) => { - await loginAs(page.request, 'manager', testEnv.defaultPassword); + await loginAs(page.request, 'manager'); await new SandboxPage(page).goto(); const pagePromise = page.context().waitForEvent('page'); await page.getByText('Goto page load 403 new tab').click(); diff --git a/frontend/tests/fixtures.ts b/frontend/tests/fixtures.ts index a1266df15..9221c4b26 100644 --- a/frontend/tests/fixtures.ts +++ b/frontend/tests/fixtures.ts @@ -108,7 +108,7 @@ export const test = base.extend({ }); await use(tempUser); const context = await browser.newContext(); - await loginAs(context.request, 'admin', testEnv.defaultPassword); + await loginAs(context.request, 'admin'); await deleteUser(context.request, tempUser.id); await context.close(); }, diff --git a/frontend/tests/logout.test.ts b/frontend/tests/logout.test.ts new file mode 100644 index 000000000..f67c92f22 --- /dev/null +++ b/frontend/tests/logout.test.ts @@ -0,0 +1,12 @@ +import {AdminDashboardPage} from './pages/adminDashboardPage'; +import {loginAs} from './utils/authHelpers'; +import {test} from './fixtures'; + +test('Back button after logout redirects back to login page', async ({page}) => { + await loginAs(page.request, 'admin'); + const adminPage = await new AdminDashboardPage(page).goto(); + const drawer = await adminPage.openDrawer(); + const loginPage = await drawer.logout(); + await page.goBack(); + await loginPage.waitFor(); +}); diff --git a/frontend/tests/pages/authenticatedBasePage.ts b/frontend/tests/pages/authenticatedBasePage.ts index 3a7154646..f71a1cf99 100644 --- a/frontend/tests/pages/authenticatedBasePage.ts +++ b/frontend/tests/pages/authenticatedBasePage.ts @@ -1,21 +1,32 @@ -import type { Locator, Page } from '@playwright/test'; -import { BasePage } from './basePage'; -import { EmailVerificationAlert } from '../components/emailVerificationAlert'; +import type {Locator, Page} from '@playwright/test'; + +import {AuthenticatedDrawer} from '../components/authenticatedDrawer'; +import {BasePage} from './basePage'; +import {EmailVerificationAlert} from '../components/emailVerificationAlert'; export class AuthenticatedBasePage extends BasePage { readonly emailVerificationAlert: EmailVerificationAlert; + private drawerToggle: Locator; + constructor(page: Page, locator: Locator | Locator[], url?: string) { + const drawerToggle = page.locator('label .i-mdi-account-circle'); if (Array.isArray(locator)) { - locator = [page.locator('label .i-mdi-account-circle'), ...locator]; + locator = [drawerToggle, ...locator]; } else { - locator = [page.locator('label .i-mdi-account-circle'), locator]; + locator = [drawerToggle, locator]; } super(page, locator, url); + this.drawerToggle = drawerToggle; this.emailVerificationAlert = new EmailVerificationAlert(page); } clickHome(): Promise { return this.page.locator('.breadcrumbs').getByRole('link', {name: 'Home'}).click(); } + + async openDrawer(): Promise { + await this.drawerToggle.click(); + return new AuthenticatedDrawer(this.page).waitFor(); + } } diff --git a/frontend/tests/recreateProject.test.ts b/frontend/tests/recreateProject.test.ts index 9c0817997..667b24a3a 100644 --- a/frontend/tests/recreateProject.test.ts +++ b/frontend/tests/recreateProject.test.ts @@ -9,7 +9,7 @@ import { expect } from '@playwright/test'; test('delete and recreate project', async ({ page, uniqueTestId }) => { // Step 1: Login - await loginAs(page.request, 'admin', testEnv.defaultPassword); + await loginAs(page.request, 'admin'); const adminDashboard = await new AdminDashboardPage(page).goto(); // Step 2: Create a new project diff --git a/frontend/tests/resetProject.test.ts b/frontend/tests/resetProject.test.ts index 359909e7a..f90d90b5d 100644 --- a/frontend/tests/resetProject.test.ts +++ b/frontend/tests/resetProject.test.ts @@ -23,7 +23,7 @@ test('reset project and upload .zip file', async ({ page, tempProject, tempDir } const allZeroHash = '0000000000000000000000000000000000000000'; // Step 1: Populate project with known initial state - await loginAs(page.request, 'admin', testEnv.defaultPassword); + await loginAs(page.request, 'admin'); const adminDashboardPage = await new AdminDashboardPage(page).goto(); await adminDashboardPage.clickProject(tempProject.name); const projectPage = await new ProjectPage(page, tempProject.name, tempProject.code).waitFor(); diff --git a/frontend/tests/utils/authHelpers.ts b/frontend/tests/utils/authHelpers.ts index 97df271d7..b51d5c0ee 100644 --- a/frontend/tests/utils/authHelpers.ts +++ b/frontend/tests/utils/authHelpers.ts @@ -1,12 +1,12 @@ -import { expect, type APIRequestContext, type Page } from '@playwright/test'; -import { serverBaseUrl } from '../envVars'; -import { RegisterPage } from '../pages/registerPage'; -import { UserDashboardPage } from '../pages/userDashboardPage'; -import type { UUID } from 'crypto'; -import { executeGql } from './gqlHelpers'; -import { LoginPage } from '../pages/loginPage'; +import {expect, type APIRequestContext, type Page} from '@playwright/test'; +import {defaultPassword, serverBaseUrl} from '../envVars'; +import {RegisterPage} from '../pages/registerPage'; +import {UserDashboardPage} from '../pages/userDashboardPage'; +import type {UUID} from 'crypto'; +import {executeGql} from './gqlHelpers'; +import {LoginPage} from '../pages/loginPage'; -export async function loginAs(api: APIRequestContext, emailOrUsername: string, password: string): Promise { +export async function loginAs(api: APIRequestContext, emailOrUsername: string, password: string = defaultPassword): Promise { const loginData = { emailOrUsername: emailOrUsername, password: password, diff --git a/frontend/tests/viewerPage.test.ts b/frontend/tests/viewerPage.test.ts index 9c1c01fa2..0237b33dd 100644 --- a/frontend/tests/viewerPage.test.ts +++ b/frontend/tests/viewerPage.test.ts @@ -1,5 +1,3 @@ -import * as testEnv from './envVars'; - import {UserDashboardPage} from './pages/userDashboardPage'; import {ViewerPage} from './pages/viewerPage'; import {expect} from '@playwright/test'; @@ -10,7 +8,7 @@ test.describe('Viewer Page', () => { test('navigate to viewer', async ({page}) => { // Step 1: Login - await loginAs(page.request, 'editor', testEnv.defaultPassword); + await loginAs(page.request, 'editor'); const userDashboard = await new UserDashboardPage(page).goto(); // Step 2: Click through to viewer @@ -21,7 +19,7 @@ test.describe('Viewer Page', () => { test('find entry', async ({page}) => { // Step 1: Login to viewer - await loginAs(page.request, 'editor', testEnv.defaultPassword); + await loginAs(page.request, 'editor'); const viewerPage = await new ViewerPage(page, 'Sena 3', 'sena-3').goto(); await viewerPage.dismissAboutDialog(); @@ -51,7 +49,7 @@ test.describe('Viewer Page', () => { test('entry details', async ({page}) => { // Step 1: Login to viewer at entry "thembe" - await loginAs(page.request, 'editor', testEnv.defaultPassword); + await loginAs(page.request, 'editor'); const viewerPage = await new ViewerPage(page, 'Sena 3', 'sena-3') .goto({urlEnd: '?entryId=49cc9257-90c7-4fe0-a9e0-2c8d72aa5e2b&search=animal'}); await viewerPage.dismissAboutDialog(); diff --git a/frontend/viewer/package.json b/frontend/viewer/package.json index b5275e8a1..f25291346 100644 --- a/frontend/viewer/package.json +++ b/frontend/viewer/package.json @@ -55,6 +55,7 @@ "@microsoft/signalr": "^8.0.0", "autoprefixer": "^10.4.19", "fast-json-patch": "^3.1.1", + "just-throttle": "^4.2.0", "postcss": "catalog:", "svelte-exmarkdown": "^3.0.5", "svelte-preprocess": "catalog:", diff --git a/frontend/viewer/src/HomeView.svelte b/frontend/viewer/src/HomeView.svelte index 90e60321c..59d9c6b06 100644 --- a/frontend/viewer/src/HomeView.svelte +++ b/frontend/viewer/src/HomeView.svelte @@ -5,6 +5,8 @@ mdiBookEditOutline, mdiBookPlusOutline, mdiBookSyncOutline, mdiCloudSync, + mdiLogin, + mdiLogout, mdiTestTube, } from '@mdi/js'; import {links} from 'svelte-routing'; @@ -31,6 +33,7 @@ let importing = ''; async function importFwDataProject(name: string) { + if (importing) return; importing = name; await projectsService.importFwDataProject(name); await refreshProjects(); @@ -81,14 +84,10 @@ name: 'fwdata', header: 'FieldWorks', }, - ...($isDev - ? [ - { - name: 'crdt', - header: 'CRDT', - }, - ] - : []), + { + name: 'crdt', + header: 'CRDT', + }, ...(servers.find(s => s.loggedIn) ? [ { @@ -104,10 +103,6 @@ if (project.id) { matches = projects.find(p => p.id == project.id && p.serverAuthority == project.serverAuthority); } - //for now the local project list does not include the id, so fallback to the name - if (!matches) { - matches = projects.find(p => p.name === project.name && p.serverAuthority == project.serverAuthority); - } return matches; } @@ -152,7 +147,7 @@

loading...

{:then projects} $isDev || p.fwdata).sort((p1, p2) => p1.name.localeCompare(p2.name))} + data={projects.filter((p) => p.fwdata || p.crdt).sort((p1, p2) => p1.name.localeCompare(p2.name))} classes={{ th: 'p-4' }}> {#each data ?? [] as project, rowIndex} @@ -185,6 +180,7 @@ size="md" loading={importing === project.name} icon={mdiBookArrowLeftOutline} + disabled={!!importing} on:click={() => importFwDataProject(project.name)} > Import @@ -223,19 +219,19 @@ {#each servers as server}
-
+

{server.displayName}

{#if server.loggedInAs} -

{server.loggedInAs}

+

{server.loggedInAs}

{/if} {#if server.loggedIn} - + {:else} - + {/if}
- {@const serverProjects = remoteProjects[server.authority] ?? []} + {@const serverProjects = remoteProjects[server.authority]?.filter(p => p.crdt) ?? []} {#each serverProjects as project} {@const localProject = matchesProject(projects, project)}
diff --git a/frontend/viewer/src/ProjectView.svelte b/frontend/viewer/src/ProjectView.svelte index 0f3e53f5b..af535ba5e 100644 --- a/frontend/viewer/src/ProjectView.svelte +++ b/frontend/viewer/src/ProjectView.svelte @@ -37,6 +37,7 @@ import {useEventBus} from './lib/services/event-bus'; import AboutDialog from './lib/about/AboutDialog.svelte'; import { initProjectCommands, type NewEntryDialogOptions } from './lib/commands'; + import throttle from 'just-throttle'; export let loading = false; @@ -196,11 +197,25 @@ } } + function onEntryDeleted(event: CustomEvent<{entry: IEntry}>) { + const _entries = $entries!; + const deletedEntry = event.detail.entry; + const deletedIndex = _entries.findIndex(e => e.id === deletedEntry.id); + $selectedEntry = _entries[deletedIndex + 1]; + + if (deletedIndex >= 0 && deletedIndex < _entries.length) { + _entries.splice(deletedIndex, 1); + $entries = _entries; + } + } + function navigateToEntry(entry: IEntry, searchText?: string) { // this is to ensure that the selected entry is in the list of entries, otherwise it won't be selected $search = searchText ?? ''; $selectedIndexExemplar = null; $selectedEntry = entry; + // This just forces and flushes a refresh. + // todo: The refresh should only be necessary if $search or $selectedIndexExemplar were actually changed refreshEntries(); pickedEntry = true; } @@ -215,11 +230,11 @@ let editorElem: HTMLElement | undefined; let spaceForEditorStyle: string = ''; - const updateSpaceForEditor = makeDebouncer(() => { + const updateSpaceForEditor = throttle(() => { if (!editorElem) return; const availableHeight = getAvailableHeightForElement(editorElem); spaceForEditorStyle = `--space-for-editor: ${availableHeight}px`; - }, 30).debounce; + }, 20, { leading: false, trailing: true }); $: editorElem && updateSpaceForEditor(); onMount(() => { @@ -344,10 +359,7 @@ $selectedEntry = $selectedEntry; $entries = $entries; }} - on:delete={_ => { - $selectedEntry = undefined; - refreshEntries(); - }} /> + on:delete={onEntryDeleted} /> {:else}
No entry selected diff --git a/frontend/viewer/src/app.postcss b/frontend/viewer/src/app.postcss index 09816a4d2..1c0e90f5a 100644 --- a/frontend/viewer/src/app.postcss +++ b/frontend/viewer/src/app.postcss @@ -57,7 +57,7 @@ .side-scroller { height: calc(var(--space-for-editor, 100vh) - 32px); - transition: height 0.1s ease-out, opacity 0.2s ease-out; + transition: height 0.05s ease-out, opacity 0.2s ease-out; position: sticky; top: 16px; } diff --git a/frontend/viewer/src/lib/DictionaryEntry.svelte b/frontend/viewer/src/lib/DictionaryEntry.svelte index e2de23d24..939a539da 100644 --- a/frontend/viewer/src/lib/DictionaryEntry.svelte +++ b/frontend/viewer/src/lib/DictionaryEntry.svelte @@ -16,7 +16,7 @@ import { derived } from 'svelte/store'; import type { IEntry } from './mini-lcm'; - import { headword, pickBestAlternative } from './utils'; + import { headword } from './utils'; import {useWritingSystems} from './writing-systems'; import { usePartsOfSpeech } from './parts-of-speech'; diff --git a/frontend/viewer/src/lib/Editor.svelte b/frontend/viewer/src/lib/Editor.svelte index dec8bec88..71bf2492f 100644 --- a/frontend/viewer/src/lib/Editor.svelte +++ b/frontend/viewer/src/lib/Editor.svelte @@ -2,9 +2,7 @@ import type {IEntry, IExampleSentence, ISense} from './mini-lcm'; import EntryEditor from './entry-editor/object-editors/EntryEditor.svelte'; import {createEventDispatcher, getContext} from 'svelte'; - import jsonPatch from 'fast-json-patch'; import {useLexboxApi} from './services/service-provider'; - import {isEmptyId} from './utils'; import type { SaveHandler } from './services/save-event-service'; import {useViewSettings} from './services/view-service'; @@ -27,26 +25,8 @@ const viewSettings = useViewSettings(); - function withoutSenses(entry: IEntry): Omit { - let {senses, ...rest} = entry; - return rest; - } - function withoutExamples(sense: ISense): Omit { - let {exampleSentences, ...rest} = sense; - return rest; - } - async function onChange(e: { entry: IEntry, sense?: ISense, example?: IExampleSentence }) { await updateEntry(e.entry); - if (e.sense !== undefined) { - await updateSense(e.sense); - detectSenseIndexChanges(e.entry, e.sense); - if (e.example !== undefined) { - await updateExample(e.sense.id, e.example); - detectExampleIndexChanges(e.entry, e.sense, e.example); - } - } - dispatch('change', {entry: e.entry}); updateInitialEntry(); } @@ -66,60 +46,7 @@ async function updateEntry(updatedEntry: IEntry) { if (entry.id != updatedEntry.id) throw new Error('Entry id mismatch'); - let operations = jsonPatch.compare(withoutSenses(initialEntry), withoutSenses(updatedEntry)); - if (operations.length == 0) return; - console.debug('updateEntry', operations); - await saveHandler(() => lexboxApi.UpdateEntry(updatedEntry.id, operations)); - } - - async function updateSense(updatedSense: ISense) { - if (isEmptyId(updatedSense.id)) { - updatedSense.id = crypto.randomUUID(); - await saveHandler(() => lexboxApi.CreateSense(entry.id, updatedSense)); - return; - } - const initialSense = initialEntry.senses.find(s => s.id === updatedSense.id); - if (!initialSense) throw new Error('Sense not found in initial entry'); - let operations = jsonPatch.compare(withoutExamples(initialSense), withoutExamples(updatedSense)); - if (operations.length == 0) return; - console.debug('updateSense', operations); - await saveHandler(() => lexboxApi.UpdateSense(entry.id, updatedSense.id, operations)); - } - - async function updateExample(senseId: string, updatedExample: IExampleSentence) { - const initialSense = initialEntry.senses.find(s => s.id === senseId); - if (!initialSense) throw new Error('Sense not found in initial entry'); - if (isEmptyId(updatedExample.id)) { - updatedExample.id = crypto.randomUUID(); - await saveHandler(() => lexboxApi.CreateExampleSentence(entry.id, senseId, updatedExample)); - return; - } - const initialExample = initialSense.exampleSentences.find(e => e.id === updatedExample.id); - if (!initialExample) throw new Error('Example not found in initial sense'); - let operations = jsonPatch.compare(initialExample, updatedExample); - if (operations.length == 0) return; - console.debug('updateExample', operations); - await saveHandler(() => lexboxApi.UpdateExampleSentence(entry.id, senseId, updatedExample.id, operations)); - } - - function detectSenseIndexChanges(entry: IEntry, sense: ISense) { - const initialIndex = initialEntry.senses.findIndex(s => s.id === sense.id); - if (initialIndex === -1) return; - const currentIndex = entry.senses.findIndex(s => s.id === sense.id); - if (currentIndex === -1) return; - if (initialIndex !== currentIndex) { - // todo figure out how to send this to the server - } - } - - function detectExampleIndexChanges(entry: IEntry, sense: ISense, example: IExampleSentence) { - const initialIndex = initialEntry.senses.find(s => s.id == sense.id)?.exampleSentences.findIndex(s => s.id === example.id); - if (initialIndex === -1 || initialIndex === undefined) return; - const currentIndex = sense.exampleSentences.findIndex(s => s.id === example.id); - if (currentIndex === -1) return; - if (initialIndex !== currentIndex) { - // todo figure out how to send this to the server - } + await saveHandler(() => lexboxApi.UpdateEntry(initialEntry, updatedEntry)); } diff --git a/frontend/viewer/src/lib/SyncConfig.svelte b/frontend/viewer/src/lib/SyncConfig.svelte index c038879be..b92793273 100644 --- a/frontend/viewer/src/lib/SyncConfig.svelte +++ b/frontend/viewer/src/lib/SyncConfig.svelte @@ -5,6 +5,7 @@ import {writable} from 'svelte/store'; import {type ServerStatus, useProjectsService} from './services/projects-service'; import {getContext} from 'svelte'; + import {AppNotification} from './notifications/notifications'; const projectsService = useProjectsService(); let projectName = getContext('project-name'); @@ -15,6 +16,7 @@ throw error; }); }); + let isUploaded = false; let projectServer = writable(null, set => { projectsService.getProjectServer(projectName).then(server => { @@ -36,13 +38,20 @@ }; let uploading = false; + let targetProjectId: string | null = null; + async function serverProjectsForUpload(serverAuthority: string) { + const remoteProjects = await projectsService.fetchRemoteProjects(); + return remoteProjects[serverAuthority].filter(p => !p.crdt); + } + async function upload() { if (!$projectServer) return; + if (!targetProjectId) return; uploading = true; //todo if not logged in then login - await projectsService.uploadCrdtProject($projectServer, projectName); + const success = await projectsService.uploadCrdtProject($projectServer, projectName, targetProjectId); uploading = false; - isUploaded = true; + if (success) isUploaded = true; } @@ -59,16 +68,34 @@ fieldActions={(elem) => /* a hack to disable typing/filtering */ {elem.readOnly = true; return [];}} search={() => /* a hack to always show all options */ Promise.resolve()}> -{:else if isUploaded} +{:else if isUploaded && server.loggedIn} {/if} {#if $projectServer && !isUploaded && server.loggedIn} - -{:else if $projectServer && !isUploaded && !server.loggedIn} - +{/if} +{#if server && !server.loggedIn} + {#if isUploaded} + Your login has expired to sync with {server.displayName}. Please login again. + {/if} {/if} diff --git a/frontend/viewer/src/lib/complex-form-types.ts b/frontend/viewer/src/lib/complex-form-types.ts index 89c2d9eb7..e13cb1778 100644 --- a/frontend/viewer/src/lib/complex-form-types.ts +++ b/frontend/viewer/src/lib/complex-form-types.ts @@ -8,6 +8,9 @@ export function useComplexFormTypes(): Readable { complexFormTypesStore = writable(null, (set) => { useLexboxApi().GetComplexFormTypes().then(complexFormTypes => { set(complexFormTypes); + }).catch(error => { + console.error('Failed to load parts of speech', error); + throw error; }); }); } diff --git a/frontend/viewer/src/lib/entry-editor/inputs/CrdtMultiOptionField.svelte b/frontend/viewer/src/lib/entry-editor/inputs/CrdtMultiOptionField.svelte index 37bc66da1..d01305493 100644 --- a/frontend/viewer/src/lib/entry-editor/inputs/CrdtMultiOptionField.svelte +++ b/frontend/viewer/src/lib/entry-editor/inputs/CrdtMultiOptionField.svelte @@ -19,12 +19,27 @@ let append: HTMLElement; $: sortedOptions = options.toSorted((a, b) => a.label.localeCompare(b.label)); + + function preserveSortOrder(unsortedValue: string[] | undefined): void { + unsortedValue?.sort((a, b) => { + let aIndex = value.findIndex(v => v === a); + if (aIndex < 0 ) aIndex = unsortedValue.findIndex(v => v === a) + unsortedValue.length; // it's new, so it should be after the existing ones + let bIndex = value.findIndex(v => v === b); + if (bIndex < 0) bIndex = unsortedValue.findIndex(v => v === b) + unsortedValue.length; // it's new, so it should be after the existing ones + return aIndex - bIndex; + }); + } dispatch('change', { value: e.detail.value})} bind:value bind:unsavedChanges let:editorValue let:onEditorValueChange viewMergeButtonPortal={append}> - onEditorValueChange(e.detail.value ?? [], true)} + on:change={(e) => { + const newValue = [...e.detail.value ?? []]; + // on changes, the order of the value reverts to the order of the options (because they're sorted in the UI?), + // so we have to "undo" that + if (preserveOrder) preserveSortOrder(newValue); + onEditorValueChange(newValue ?? [], true) + }} value={editorValue} disabled={readonly} options={sortedOptions} @@ -34,7 +49,7 @@ // sorted by order of selection ? value?.map(v => options.find(o => o.value === v)?.label).filter(label => !!label).join(', ') // sorted according to the order of options (e.g. alphabetical or by semantic domain) - : options.map(o => o.label).join(', ')) || 'None'; + : options.map(o => o.label).join(', ')) ?? 'None'; }} infiniteScroll clearSearchOnOpen={false} diff --git a/frontend/viewer/src/lib/generated-signalr-client/TypedSignalR.Client/index.ts b/frontend/viewer/src/lib/generated-signalr-client/TypedSignalR.Client/index.ts index 7bc72a381..bd972d5e6 100644 --- a/frontend/viewer/src/lib/generated-signalr-client/TypedSignalR.Client/index.ts +++ b/frontend/viewer/src/lib/generated-signalr-client/TypedSignalR.Client/index.ts @@ -3,10 +3,10 @@ /* tslint:disable */ import type {ComplexFormType, Entry, ExampleSentence, PartOfSpeech, QueryOptions, SemanticDomain, Sense, WritingSystem, WritingSystems} from '../../mini-lcm'; -import type { ILexboxApiHub, ILexboxClient } from './Lexbox.ClientServer.Hubs'; +import type {ILexboxApiHub, ILexboxClient} from './Lexbox.ClientServer.Hubs'; -import { HubConnection } from '@microsoft/signalr'; -import type { JsonOperation } from '../Lexbox.ClientServer.Hubs'; +import {HubConnection} from '@microsoft/signalr'; +import type {JsonOperation} from '../Lexbox.ClientServer.Hubs'; import type {WritingSystemType} from '../../services/lexbox-api'; // components @@ -174,8 +174,8 @@ class ILexboxApiHub_HubProxy implements ILexboxApiHub { return await this.connection.invoke("CreateEntry", entry); } - public readonly UpdateEntry = async (id: string, update: JsonOperation[]): Promise => { - return await this.connection.invoke("UpdateEntry", id, update); + public readonly UpdateEntry = async (before: Entry, after: Entry): Promise => { + return await this.connection.invoke("UpdateEntry", before, after); } public readonly DeleteEntry = async (id: string): Promise => { diff --git a/frontend/viewer/src/lib/in-memory-api-service.ts b/frontend/viewer/src/lib/in-memory-api-service.ts index 70878dbb5..6e6226e9a 100644 --- a/frontend/viewer/src/lib/in-memory-api-service.ts +++ b/frontend/viewer/src/lib/in-memory-api-service.ts @@ -1,4 +1,6 @@ -import {entries, projectName, writingSystems} from './entry-data'; +/* eslint-disable @typescript-eslint/naming-convention */ + +import {entries, projectName, writingSystems} from './entry-data'; import type { IEntry, IExampleSentence, @@ -123,10 +125,9 @@ export class InMemoryApiService implements LexboxApiClient { return Promise.resolve(entry); } - UpdateEntry(guid: string, update: JsonPatch): Promise { - const entry = entries.find(e => e.id === guid)!; - applyPatch(entry, update); - return Promise.resolve(entry); + UpdateEntry(_before: IEntry, after: IEntry): Promise { + entries.splice(entries.findIndex(e => e.id === after.id), 1, after); + return Promise.resolve(after); } CreateSense(entryGuid: string, sense: ISense): Promise { diff --git a/frontend/viewer/src/lib/layout/EntryList.svelte b/frontend/viewer/src/lib/layout/EntryList.svelte index 2f3d2af29..d886bcc8b 100644 --- a/frontend/viewer/src/lib/layout/EntryList.svelte +++ b/frontend/viewer/src/lib/layout/EntryList.svelte @@ -17,22 +17,26 @@ export let search: string; export let expand: boolean; + const selectedEntry = getContext>('selectedEntry'); + let lastScrolledTo: string | undefined = undefined; + $: { - entries; + const selectedId = $selectedEntry?.id; + const selectedDifferentEntry = selectedId && selectedId !== lastScrolledTo; + // wait until the new entries have been rendered setTimeout(() => { - const selected = scrollContainerElem?.querySelector('.selected-entry'); - selected?.scrollIntoView({block: 'nearest'}); + const selectedEntryElem = scrollContainerElem?.querySelector('.selected-entry'); + if (selectedEntryElem) { + if (selectedDifferentEntry) { + lastScrolledTo = selectedId; + selectedEntryElem?.scrollIntoView({block: 'nearest'}); + } + } }); } - const selectedEntry = getContext>('selectedEntry'); - let scrollContainerElem: HTMLDivElement; - $: { - entries; - if (scrollContainerElem) scrollContainerElem.scrollTop = 0; - } const standardPageSize = 50; $: perPage = (!$selectedEntry || !entries) diff --git a/frontend/viewer/src/lib/search-bar/SearchBar.svelte b/frontend/viewer/src/lib/search-bar/SearchBar.svelte index 1bd9220cf..f60d9358e 100644 --- a/frontend/viewer/src/lib/search-bar/SearchBar.svelte +++ b/frontend/viewer/src/lib/search-bar/SearchBar.svelte @@ -3,9 +3,9 @@ import { Button, Dialog, Field, Icon, ListItem, ProgressCircle, TextField } from 'svelte-ux'; import { firstDefOrGlossVal, headword } from '../utils'; import { useLexboxApi } from '../services/service-provider'; - import { derived, writable, type Writable } from 'svelte/store'; + import { derived, type Writable } from 'svelte/store'; import { deriveAsync } from '../utils/time'; - import {createEventDispatcher, getContext, onDestroy, onMount} from 'svelte'; + import {createEventDispatcher, getContext, onDestroy} from 'svelte'; import type { IEntry } from '../mini-lcm'; import {useSearch} from './search'; diff --git a/frontend/viewer/src/lib/services/lexbox-api.ts b/frontend/viewer/src/lib/services/lexbox-api.ts index e968a01ad..8226378f4 100644 --- a/frontend/viewer/src/lib/services/lexbox-api.ts +++ b/frontend/viewer/src/lib/services/lexbox-api.ts @@ -37,7 +37,7 @@ export interface LexboxApi { GetEntry(guid: string): Promise; CreateEntry(entry: IEntry): Promise; - UpdateEntry(guid: string, update: JsonPatch): Promise; + UpdateEntry(before: IEntry, after: IEntry): Promise; DeleteEntry(guid: string): Promise; CreateSense(entryGuid: string, sense: ISense): Promise; diff --git a/frontend/viewer/src/lib/services/projects-service.ts b/frontend/viewer/src/lib/services/projects-service.ts index 32090a542..9ea029b69 100644 --- a/frontend/viewer/src/lib/services/projects-service.ts +++ b/frontend/viewer/src/lib/services/projects-service.ts @@ -28,23 +28,35 @@ export class ProjectService { return {error: undefined}; } - async importFwDataProject(name: string) { - await fetch(`/api/import/fwdata/${name}`, { + async importFwDataProject(name: string): Promise { + const r = await fetch(`/api/import/fwdata/${name}`, { method: 'POST', }); + if (!r.ok) { + AppNotification.display(`Failed to import FieldWorks project ${name}: ${r.statusText} (${r.status})`, 'error', 'long'); + console.error(`Failed to import FieldWorks project ${name}: ${r.statusText} (${r.status})`, r, await r.text()) + } + return r.ok; } async downloadCrdtProject(project: Project) { - const r = await fetch(`/api/download/crdt/${project.serverAuthority}/${project.name}`, {method: 'POST'}); - if (r.status !== 200) { - AppNotification.display(`Failed to download project, status code ${r.status}`, 'error'); - console.error(`Failed to download project ${project.name}`, r) + const r = await fetch(`/api/download/crdt/${project.serverAuthority}/${project.id}?projectName=${project.name}`, {method: 'POST'}); + if (!r.ok) { + AppNotification.display(`Failed to download project ${project.name}: ${r.statusText} (${r.status})`, 'error', 'long'); + console.error(`Failed to download project ${project.name}: ${r.statusText} (${r.status})`, r, await r.text()) } + return r.ok; } - async uploadCrdtProject(server: string, projectName: string) { - await fetch(`/api/upload/crdt/${server}/${projectName}`, {method: 'POST'}); + async uploadCrdtProject(server: string, projectName: string, lexboxProjectId: string): Promise { + const r = await fetch(`/api/upload/crdt/${server}/${projectName}?lexboxProjectId=${lexboxProjectId}`, {method: 'POST'}); + if (!r.ok) { + AppNotification.display(`Failed to upload project ${projectName}: ${r.statusText} (${r.status})`, 'error', 'long'); + console.error(`Failed to upload project ${projectName}: ${r.statusText} (${r.status})`, r, await r.text()) + } + return r.ok; } + async getProjectServer(projectName: string): Promise { const projects = await this.fetchProjects(); //todo project server is always null from local projects` diff --git a/frontend/viewer/src/lib/services/service-provider.ts b/frontend/viewer/src/lib/services/service-provider.ts index 6564a740c..7deed7a15 100644 --- a/frontend/viewer/src/lib/services/service-provider.ts +++ b/frontend/viewer/src/lib/services/service-provider.ts @@ -4,9 +4,10 @@ import {openSearch} from '../search-bar/search'; declare global { interface Lexbox { - // eslint-disable-next-line @typescript-eslint/naming-convention + /* eslint-disable @typescript-eslint/naming-convention */ ServiceProvider: LexboxServiceProvider; - Search: { openSearch: (search: string) => void }; + Search: {openSearch: (search: string) => void}; + /* eslint-enable @typescript-eslint/naming-convention */ } interface Window {