diff --git a/.vscode/settings.json b/.vscode/settings.json index 554e206347..6ef755f219 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -59,6 +59,7 @@ "langtags", "ldml", "letsencrypt", + "Lexbox", "Linq", "maint", "Memberwise", diff --git a/Backend.Tests/Controllers/LexboxControllerTests.cs b/Backend.Tests/Controllers/LexboxControllerTests.cs new file mode 100644 index 0000000000..79cba011fe --- /dev/null +++ b/Backend.Tests/Controllers/LexboxControllerTests.cs @@ -0,0 +1,175 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Security.Claims; +using System.Threading.Tasks; +using Backend.Tests.Mocks; +using BackendFramework.Controllers; +using BackendFramework.Models; +using BackendFramework.Services; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using NUnit.Framework; + +namespace Backend.Tests.Controllers +{ + internal sealed class LexboxControllerTests : IDisposable + { + private PermissionServiceMock _permissionService = null!; + private LexboxController _lexboxController = null!; + + private const string UserId = "LexboxControllerTestsUserId"; + + public void Dispose() + { + _lexboxController?.Dispose(); + GC.SuppressFinalize(this); + } + + [SetUp] + public void Setup() + { + var configValues = new Dictionary { { "LexboxAuth:PostLoginRedirect", "/" } }; + var configuration = new ConfigurationBuilder().AddInMemoryCollection(configValues).Build(); + var lexboxAuthService = new LexboxAuthService(configuration); + var httpClient = new HttpClient(new Mock().Object); + var httpClientFactory = new Mock(); + httpClientFactory.Setup(x => x.CreateClient(It.IsAny())).Returns(httpClient); + var lexboxQueryService = new LexboxQueryService(httpClientFactory.Object); + _permissionService = new PermissionServiceMock(); + _lexboxController = new LexboxController(lexboxAuthService, lexboxQueryService, _permissionService); + } + + [Test] + public async Task TestGetAuthStatusUnauthorizedReturnsForbid() + { + _lexboxController.ControllerContext.HttpContext = PermissionServiceMock.UnauthorizedHttpContext(); + + var result = await _lexboxController.GetAuthStatus(); + + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public async Task TestGetAuthStatusReturnsLexboxUserWhenLoggedIn() + { + var claims = new List { new("sub", "lex-1"), new("name", "Lex Name"), new("user", "Lex User") }; + var authResult = AuthenticateResult.Success(new AuthenticationTicket( + new ClaimsPrincipal(new ClaimsIdentity(claims, "LexboxCookie")), "LexboxCookie")); + _lexboxController.ControllerContext.HttpContext = GetAuthContext(authResult); + + var result = await _lexboxController.GetAuthStatus() as OkObjectResult; + + Assert.That(result, Is.Not.Null); + var authStatus = result.Value as LexboxAuthStatus; + Assert.That(authStatus, Is.Not.Null); + Assert.That(authStatus.IsLoggedIn, Is.True); + Assert.That(authStatus.LoggedInAs, Is.EqualTo("Lex User")); + Assert.That(authStatus.UserId, Is.EqualTo("lex-1")); + } + + [Test] + public async Task TestGetAuthStatusReturnsLoggedOutWhenNotAuthenticatedByLexboxCookie() + { + _lexboxController.ControllerContext.HttpContext = GetAuthContext(AuthenticateResult.NoResult()); + + var result = await _lexboxController.GetAuthStatus() as OkObjectResult; + + Assert.That(result, Is.Not.Null); + var authStatus = result.Value as LexboxAuthStatus; + Assert.That(authStatus, Is.Not.Null); + Assert.That(authStatus.IsLoggedIn, Is.False); + Assert.That(authStatus.LoggedInAs, Is.Null); + Assert.That(authStatus.UserId, Is.Null); + } + + [Test] + public void TestGetAuthStatusThrowsWhenSubClaimMissing() + { + var claims = new List { new("user", "Lex User") }; + var authResult = AuthenticateResult.Success(new AuthenticationTicket( + new ClaimsPrincipal(new ClaimsIdentity(claims, "LexboxCookie")), "LexboxCookie")); + _lexboxController.ControllerContext.HttpContext = GetAuthContext(authResult); + + Assert.ThrowsAsync(_lexboxController.GetAuthStatus); + } + + [Test] + public async Task TestGetAuthStatusFallsBackToUserIdWhenDisplayNameClaimsMissing() + { + var claims = new List { new("sub", "lex-1") }; + var authResult = AuthenticateResult.Success(new AuthenticationTicket( + new ClaimsPrincipal(new ClaimsIdentity(claims, "LexboxCookie")), "LexboxCookie")); + _lexboxController.ControllerContext.HttpContext = GetAuthContext(authResult); + + var result = await _lexboxController.GetAuthStatus() as OkObjectResult; + + Assert.That(result, Is.Not.Null); + var authStatus = result.Value as LexboxAuthStatus; + Assert.That(authStatus, Is.Not.Null); + Assert.That(authStatus.IsLoggedIn, Is.True); + Assert.That(authStatus.LoggedInAs, Is.EqualTo("lex-1")); + Assert.That(authStatus.UserId, Is.EqualTo("lex-1")); + } + + [Test] + public async Task TestGetAuthStatusUsesNameClaimWhenUserClaimMissing() + { + var claims = new List { new("sub", "lex-1"), new("name", "Lex Name") }; + var authResult = AuthenticateResult.Success(new AuthenticationTicket( + new ClaimsPrincipal(new ClaimsIdentity(claims, "LexboxCookie")), "LexboxCookie")); + _lexboxController.ControllerContext.HttpContext = GetAuthContext(authResult); + + var result = await _lexboxController.GetAuthStatus() as OkObjectResult; + + Assert.That(result, Is.Not.Null); + var authStatus = result.Value as LexboxAuthStatus; + Assert.That(authStatus, Is.Not.Null); + Assert.That(authStatus.IsLoggedIn, Is.True); + Assert.That(authStatus.LoggedInAs, Is.EqualTo("Lex Name")); + Assert.That(authStatus.UserId, Is.EqualTo("lex-1")); + } + + [Test] + public async Task TestGenerateLoginChallengesAndReturnsEmpty() + { + var authService = new AuthenticationServiceMock(AuthenticateResult.NoResult()); + _lexboxController.ControllerContext.HttpContext = GetAuthContext(authService); + Assert.That(authService.ChallengeCallCount, Is.Zero); + + var result = await _lexboxController.GenerateLogin(); + + Assert.That(result, Is.InstanceOf()); + Assert.That(authService.ChallengeCallCount, Is.EqualTo(1)); + } + + [Test] + public async Task TestLogOutReturnsNoContent() + { + var claims = new List { new("sub", "lex-1"), new("user", "Lex User") }; + var authResult = AuthenticateResult.Success(new AuthenticationTicket( + new ClaimsPrincipal(new ClaimsIdentity(claims, "LexboxCookie")), "LexboxCookie")); + _lexboxController.ControllerContext.HttpContext = GetAuthContext(authResult); + + var result = await _lexboxController.LogOut(); + + Assert.That(result, Is.InstanceOf()); + } + + private static HttpContext GetAuthContext(AuthenticateResult authenticateResult) + => GetAuthContext(new AuthenticationServiceMock(authenticateResult)); + + private static HttpContext GetAuthContext(IAuthenticationService authenticationService) + { + var context = PermissionServiceMock.HttpContextWithUserId(UserId); + var services = new ServiceCollection(); + services.AddSingleton(authenticationService); + context.RequestServices = services.BuildServiceProvider(); + return context; + } + } +} diff --git a/Backend.Tests/Mocks/AuthenticationServiceMock.cs b/Backend.Tests/Mocks/AuthenticationServiceMock.cs new file mode 100644 index 0000000000..061f78bf7e --- /dev/null +++ b/Backend.Tests/Mocks/AuthenticationServiceMock.cs @@ -0,0 +1,30 @@ +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; + +namespace Backend.Tests.Mocks +{ + internal sealed class AuthenticationServiceMock(AuthenticateResult authenticateResult) : IAuthenticationService + { + internal int ChallengeCallCount { get; private set; } + + public Task AuthenticateAsync(HttpContext context, string? scheme) + => Task.FromResult(authenticateResult); + + public Task ChallengeAsync(HttpContext context, string? scheme, AuthenticationProperties? properties) + { + ChallengeCallCount++; + return Task.CompletedTask; + } + + public Task ForbidAsync(HttpContext context, string? scheme, AuthenticationProperties? properties) + => Task.CompletedTask; + + public Task SignInAsync(HttpContext context, string? scheme, ClaimsPrincipal principal, + AuthenticationProperties? properties) => Task.CompletedTask; + + public Task SignOutAsync(HttpContext context, string? scheme, AuthenticationProperties? properties) + => Task.CompletedTask; + } +} diff --git a/Backend/BackendFramework.csproj b/Backend/BackendFramework.csproj index 5c8479cefc..e7b4fc66da 100644 --- a/Backend/BackendFramework.csproj +++ b/Backend/BackendFramework.csproj @@ -18,9 +18,10 @@ NU1701 - - - + + + + diff --git a/Backend/Controllers/LexboxController.cs b/Backend/Controllers/LexboxController.cs new file mode 100644 index 0000000000..799c0c43a3 --- /dev/null +++ b/Backend/Controllers/LexboxController.cs @@ -0,0 +1,140 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using BackendFramework.Interfaces; +using BackendFramework.Models; +using BackendFramework.Otel; +using BackendFramework.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace BackendFramework.Controllers +{ + [Produces("application/json")] + [Route("v1/lexbox")] + public class LexboxController(ILexboxAuthService lexboxAuthService, ILexboxQueryService lexboxQueryService, + IPermissionService permissionService) : Controller + { + private readonly ILexboxAuthService _lexboxAuthService = lexboxAuthService; + private readonly ILexboxQueryService _lexboxQueryService = lexboxQueryService; + private readonly IPermissionService _permissionService = permissionService; + + private const string otelTagName = "otel.LexboxController"; + + /// Gets authentication status for the current request. + [HttpGet("auth-status", Name = "GetAuthStatus")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(LexboxAuthStatus))] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task GetAuthStatus() + { + using var activity = OtelService.StartActivityWithTag(otelTagName, "getting auth status"); + + if (!_permissionService.IsCurrentUserAuthenticated(HttpContext)) + { + return Forbid(); + } + + return Ok(await _lexboxAuthService.GetAuthStatus(HttpContext)); + } + + /// Generates a redirect to Lexbox login for OIDC sign-in. + [HttpGet("login", Name = "GenerateLogin")] + [ProducesResponseType(StatusCodes.Status302Found)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task GenerateLogin() + { + using var activity = OtelService.StartActivityWithTag(otelTagName, "generating login"); + + if (!_permissionService.IsCurrentUserAuthenticated(HttpContext)) + { + return Forbid(); + } + + try + { + await _lexboxAuthService.Challenge(HttpContext); + return new EmptyResult(); + } + catch (Exception ex) + { + return Problem(title: "Lexbox OIDC challenge failed", detail: ex.Message, + statusCode: StatusCodes.Status500InternalServerError); + } + } + + /// Signs out the current user from Lexbox cookie and OIDC. + [HttpPost("logout", Name = "LogOut")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task LogOut() + { + using var activity = OtelService.StartActivityWithTag(otelTagName, "logging out"); + + await _lexboxAuthService.SignOut(HttpContext); + + // TODO: Consider if we also need to sign out of the OIDC scheme here. + // await HttpContext.SignOutAsync(LexboxOidcScheme) + // is a no-op since it doesn't handle the redirect. + + return NoContent(); + } + + /// Gets Lexbox projects for the signed-in Lexbox user. + [Authorize(AuthenticationSchemes = LexboxAuthService.LexboxCookieScheme)] + [HttpGet("projects", Name = "GetProjects")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List))] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status502BadGateway)] + public async Task GetProjects() + { + using var activity = OtelService.StartActivityWithTag(otelTagName, "getting Lexbox projects"); + + var accessToken = await _lexboxAuthService.TryGetAccessToken(HttpContext); + if (string.IsNullOrEmpty(accessToken)) + { + return Unauthorized(); + } + + try + { + List projects = await _lexboxQueryService.GetMyProjectsAsync(accessToken); + return Ok(projects); + } + catch (LexboxQueryException ex) + { + return Problem(title: ex.Title, detail: ex.Message, + statusCode: StatusCodes.Status502BadGateway); + } + } + + /// Gets entries from a Lexbox project. + [Authorize(AuthenticationSchemes = LexboxAuthService.LexboxCookieScheme)] + [HttpGet("entries/{projectCode}/{vernacularLang}", Name = "GetEntries")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List))] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status502BadGateway)] + public async Task GetEntries(string projectCode, string vernacularLang) + { + using var activity = OtelService.StartActivityWithTag(otelTagName, "getting Lexbox entries"); + + var accessToken = await _lexboxAuthService.TryGetAccessToken(HttpContext); + if (string.IsNullOrEmpty(accessToken)) + { + return Unauthorized(); + } + + try + { + List entries = + await _lexboxQueryService.GetProjectEntriesAsync(accessToken, projectCode, vernacularLang); + return Ok(entries); + } + catch (LexboxQueryException ex) + { + return Problem(title: ex.Title, detail: ex.Message, + statusCode: StatusCodes.Status502BadGateway); + } + } + + } +} diff --git a/Backend/Interfaces/ILexboxAuthService.cs b/Backend/Interfaces/ILexboxAuthService.cs new file mode 100644 index 0000000000..3bde1aa493 --- /dev/null +++ b/Backend/Interfaces/ILexboxAuthService.cs @@ -0,0 +1,14 @@ +using System.Threading.Tasks; +using BackendFramework.Models; +using Microsoft.AspNetCore.Http; + +namespace BackendFramework.Interfaces +{ + public interface ILexboxAuthService + { + Task Challenge(HttpContext httpContext); + Task GetAuthStatus(HttpContext httpContext); + Task SignOut(HttpContext httpContext); + Task TryGetAccessToken(HttpContext httpContext); + } +} diff --git a/Backend/Interfaces/ILexboxQueryService.cs b/Backend/Interfaces/ILexboxQueryService.cs new file mode 100644 index 0000000000..d9a62b7d2d --- /dev/null +++ b/Backend/Interfaces/ILexboxQueryService.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using BackendFramework.Models; + +namespace BackendFramework.Interfaces +{ + public interface ILexboxQueryService + { + Task> GetMyProjectsAsync(string accessToken); + Task> GetProjectEntriesAsync(string accessToken, string projectCode, string vernacularLang); + } +} diff --git a/Backend/Models/LexboxAuthStatus.cs b/Backend/Models/LexboxAuthStatus.cs new file mode 100644 index 0000000000..3d31593149 --- /dev/null +++ b/Backend/Models/LexboxAuthStatus.cs @@ -0,0 +1,27 @@ +namespace BackendFramework.Models +{ + public class LexboxAuthStatus + { + public bool IsLoggedIn { get; set; } + public string? LoggedInAs { get; set; } + public string? UserId { get; set; } + + public static LexboxAuthStatus LoggedOut() => new() + { + IsLoggedIn = false + }; + + public static LexboxAuthStatus LoggedIn(LexboxAuthUser user) => new() + { + IsLoggedIn = true, + LoggedInAs = user.DisplayName, + UserId = user.UserId + }; + } + + public class LexboxAuthUser + { + public required string UserId { get; init; } + public required string DisplayName { get; init; } + } +} diff --git a/Backend/Models/LexboxQuery.cs b/Backend/Models/LexboxQuery.cs new file mode 100644 index 0000000000..53caf7e237 --- /dev/null +++ b/Backend/Models/LexboxQuery.cs @@ -0,0 +1,240 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace BackendFramework.Models +{ + public sealed class LexboxQuery + { + public const string QueryUrl = "https://lexbox.org/api/graphql"; + public const string MiniLcmBaseUrl = "https://lexbox.org/api/mini-lcm"; + public const string LfClassicBaseUrl = "https://lexbox.org/api/lfclassic"; + public const string MyProjectsQuery = @"query { + myProjects { + code + description + flexProjectMetadata { + flexModelVersion + langProjectId + lexEntryCount + projectId + writingSystems { + analysisWss { + isActive + isDefault + tag + } + vernacularWss { + isActive + isDefault + tag + } + } + } + id + isConfidential + name + parentId + projectOrigin + repoSizeInKb + resetStatus + retentionPolicy + type + userCount + } +}"; + + public required string Query { get; init; } + } + + public sealed class LexboxQueryResponse + { + public T? Data { get; init; } + public LexboxQueryError[]? Errors { get; init; } + } + + public sealed class LexboxQueryError + { + public string? Message { get; init; } + } + + public sealed class LexboxProject(LexboxProjectDto dto) + { + public List AnalysisWsTags { get; init; } = + WsIdDto.GetActiveTags(dto.FlexProjectMetadata?.WritingSystems?.AnalysisWss ?? []).ToList(); + public string Code { get; init; } = dto.Code; + public string? Description { get; init; } = dto.Description; + public Guid Id { get; init; } = dto.Id; + public bool? IsConfidential { get; init; } = dto.IsConfidential; + public string Name { get; init; } = dto.Name; + public string Type { get; init; } = dto.Type; + public List VernacularWsTags { get; init; } = + WsIdDto.GetActiveTags(dto.FlexProjectMetadata?.WritingSystems?.VernacularWss ?? []).ToList(); + } + + public sealed class MyProjectsData + { + public List MyProjects { get; init; } = []; + } + + public sealed class LexboxProjectDto + { + public string Code { get; init; } = ""; + public string? Description { get; init; } + public ProjectMetadataDto? FlexProjectMetadata { get; init; } + public Guid Id { get; init; } + public bool? IsConfidential { get; init; } + public string Name { get; init; } = ""; + public Guid? ParentId { get; init; } + public string ProjectOrigin { get; init; } = ""; + public int? RepoSizeInKb { get; init; } + public string ResetStatus { get; init; } = ""; + public string RetentionPolicy { get; init; } = ""; + public string Type { get; init; } = ""; + public int UserCount { get; init; } + } + + public sealed class ProjectMetadataDto + { + public int? FlexModelVersion { get; init; } + public Guid? LangProjectId { get; init; } + public int? LexEntryCount { get; init; } + public Guid ProjectId { get; init; } + public ProjectWritingSystemsDto? WritingSystems { get; init; } + } + + public sealed class ProjectWritingSystemsDto + { + public List AnalysisWss { get; init; } = []; + public List VernacularWss { get; init; } = []; + } + + public sealed class WsIdDto + { + public bool IsActive { get; init; } + public bool IsDefault { get; init; } + public string Tag { get; init; } = ""; + + public static List GetActiveTags(List wsList) + { + return wsList.Where(ws => ws.IsActive).OrderByDescending(ws => ws.IsDefault).Select(ws => ws.Tag).ToList(); + } + } + + public sealed class LexboxQueryException(string title, string detail) : Exception(detail) + { + public string Title { get; } = title; + } + + public sealed class LexboxEntry + { + public Dictionary CitationForm { get; init; } = []; + public DateTime? DeletedAt { get; init; } + public Guid Id { get; init; } + public Dictionary LexemeForm { get; init; } = []; + public Dictionary Note { get; init; } = []; + public List Senses { get; init; } = []; + + public Word? ToWord(string vernacularLang, IEnumerable? analysisLangs = null) + { + if (DeletedAt is not null) + { + // Ignore any entry that was deleted. + return null; + } + + CitationForm.TryGetValue(vernacularLang, out var vernacular); + var usingCitationForm = !string.IsNullOrWhiteSpace(vernacular); + if (!usingCitationForm) + { + LexemeForm.TryGetValue(vernacularLang, out vernacular); + } + vernacular = vernacular?.Trim(); + if (string.IsNullOrEmpty(vernacular)) + { + // Ignore any entry with no citation/lexeme form in specified vernacular language. + return null; + } + + var noteLang = (analysisLangs ?? []).FirstOrDefault(Note.ContainsKey) ?? Note.Keys.FirstOrDefault() ?? ""; + var noteText = noteLang.Length > 0 && Note.TryGetValue(noteLang, out var noteRich) + ? noteRich.GetPlainText() + : ""; + + return new Word + { + Guid = Id, + Id = Guid.NewGuid().ToString(), + Note = new Note(noteLang, noteText), + Senses = Senses + .Select(s => s.ToSense(analysisLangs)).Where(s => s is not null).OfType().ToList(), + UsingCitationForm = usingCitationForm, + Vernacular = vernacular, + }; + } + } + + public sealed class LexboxSense + { + public Dictionary Definition { get; init; } = []; + public DateTime? DeletedAt { get; init; } + public Dictionary Gloss { get; init; } = []; + public Guid Id { get; init; } + public LexboxPartOfSpeech? PartOfSpeech { get; init; } + public List SemanticDomains { get; init; } = []; + + public Sense? ToSense(IEnumerable? langs = null) + { + // Ignore any sense that was deleted. + return DeletedAt is null ? null : new() + { + Definitions = Definition + .Select(kvp => new Definition { Language = kvp.Key, Text = kvp.Value.GetPlainText() }).ToList(), + Glosses = Gloss.Select(kvp => new Gloss { Def = kvp.Value, Language = kvp.Key }).ToList(), + GrammaticalInfo = PartOfSpeech?.ToGrammaticalInfo(langs) ?? new GrammaticalInfo(), + Guid = Id, + SemanticDomains = SemanticDomains.Select(sd => sd.ToSemanticDomain(langs)).ToList(), + }; + } + } + + public sealed class LexboxPartOfSpeech + { + public Guid Id { get; init; } + public Dictionary Name { get; init; } = []; + + public GrammaticalInfo ToGrammaticalInfo(IEnumerable? langs = null) + { + var resolvedLang = (langs ?? []).FirstOrDefault(Name.ContainsKey) ?? Name.Keys.FirstOrDefault() ?? ""; + var name = resolvedLang.Length > 0 && Name.TryGetValue(resolvedLang, out var langName) ? langName : ""; + return new GrammaticalInfo(name); + } + } + + public sealed class LexboxSemanticDomain + { + public string Code { get; init; } = ""; + public Guid Id { get; init; } + public Dictionary Name { get; init; } = []; + + public SemanticDomain ToSemanticDomain(IEnumerable? langs = null) + { + var resolvedLang = (langs ?? []).FirstOrDefault(Name.ContainsKey) ?? Name.Keys.FirstOrDefault() ?? ""; + var name = resolvedLang.Length > 0 && Name.TryGetValue(resolvedLang, out var langName) ? langName : ""; + return new SemanticDomain { Guid = Id.ToString(), Id = Code, Lang = resolvedLang, Name = name }; + } + } + + public sealed class LexboxRichString + { + public List Spans { get; init; } = []; + + public string GetPlainText() => string.Concat(Spans.Select(s => s.Text)); + } + + public sealed class LexboxRichSpan + { + public string Text { get; init; } = ""; + } + +} diff --git a/Backend/Services/LexboxAuthService.cs b/Backend/Services/LexboxAuthService.cs new file mode 100644 index 0000000000..8a6df185c4 --- /dev/null +++ b/Backend/Services/LexboxAuthService.cs @@ -0,0 +1,78 @@ +using System; +using System.Security.Claims; +using System.Threading.Tasks; +using BackendFramework.Helper; +using BackendFramework.Interfaces; +using BackendFramework.Models; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; + +namespace BackendFramework.Services +{ + public sealed class LexboxAuthService(IConfiguration configuration) : ILexboxAuthService + { + private readonly IConfiguration _configuration = configuration; + + public const string LexboxCookieScheme = "LexboxCookie"; + private const string LexboxOidcScheme = "LexboxOidc"; + private const string PostLoginRedirectConfigKey = "LexboxAuth:PostLoginRedirect"; + + public async Task Challenge(HttpContext httpContext) + { + var redirectUrl = NormalizeReturnUrl(_configuration[PostLoginRedirectConfigKey]) + ?? Domain.FrontendDomain + "/app/auth-success"; + await httpContext.ChallengeAsync(LexboxOidcScheme, new() { RedirectUri = redirectUrl }); + } + + public async Task GetAuthStatus(HttpContext httpContext) + { + var result = await httpContext.AuthenticateAsync(LexboxCookieScheme); + if (!result.Succeeded || result.Principal is null) + { + // Clear any stale or undecryptable cookie (e.g. after a server restart loses Data Protection keys) + if (httpContext.Request.Cookies.ContainsKey("lexbox_auth")) + { + await httpContext.SignOutAsync(LexboxCookieScheme); + } + return LexboxAuthStatus.LoggedOut(); + } + + return LexboxAuthStatus.LoggedIn(GetUserFromClaims(result.Principal)); + } + + public async Task SignOut(HttpContext httpContext) + { + await httpContext.SignOutAsync(LexboxCookieScheme); + } + + public async Task TryGetAccessToken(HttpContext httpContext) + { + var result = await httpContext.AuthenticateAsync(LexboxCookieScheme); + return result.Properties?.GetTokenValue("access_token"); + } + + private static string? NormalizeReturnUrl(string? url) + { + url = url?.Trim(); + return string.IsNullOrEmpty(url) || !Uri.TryCreate(url, UriKind.RelativeOrAbsolute, out var uri) + ? null + : uri.ToString(); + } + + private static LexboxAuthUser GetUserFromClaims(ClaimsPrincipal principal) + { + // https://github.com/sillsdev/languageforge-lexbox/blob/develop/backend/LexCore/Auth/LexAuthConstants.cs + var userId = principal.FindFirst("sub")?.Value?.Trim(); // LexAuthConstants.IdClaimType + if (string.IsNullOrEmpty(userId)) + { + throw new InvalidOperationException("Missing required Lexbox 'sub' claim."); + } + + var displayName = principal.FindFirst("user")?.Value // LexAuthConstants.UsernameClaimType + ?? principal.FindFirst("name")?.Value; // LexAuthConstants.NameClaimType + + return new LexboxAuthUser { DisplayName = displayName ?? userId, UserId = userId }; + } + } +} diff --git a/Backend/Services/LexboxQueryService.cs b/Backend/Services/LexboxQueryService.cs new file mode 100644 index 0000000000..20f15dc02f --- /dev/null +++ b/Backend/Services/LexboxQueryService.cs @@ -0,0 +1,78 @@ +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Threading.Tasks; +using BackendFramework.Interfaces; +using BackendFramework.Models; + +namespace BackendFramework.Services +{ + public sealed class LexboxQueryService(IHttpClientFactory httpClientFactory) : ILexboxQueryService + { + private readonly IHttpClientFactory _httpClientFactory = httpClientFactory; + + public async Task> GetMyProjectsAsync(string accessToken) + { + var httpClient = _httpClientFactory.CreateClient(); + httpClient.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", accessToken); + + var response = await httpClient.PostAsJsonAsync(LexboxQuery.QueryUrl, + new LexboxQuery { Query = LexboxQuery.MyProjectsQuery }); + + if (!response.IsSuccessStatusCode) + { + var responseBody = await response.Content.ReadAsStringAsync(); + throw new LexboxQueryException("Lexbox GraphQL request failed", + $"Status: {(int)response.StatusCode} {response.ReasonPhrase}" + + (string.IsNullOrEmpty(responseBody) ? "" : $"\nBody: {responseBody}")); + } + + var graph = await response.Content.ReadFromJsonAsync>() + ?? throw new LexboxQueryException("Lexbox GraphQL response was empty", ""); + + if (graph.Errors is { Length: > 0 }) + { + var errorText = string.Join("; ", + graph.Errors.Select(e => e.Message).Where(m => !string.IsNullOrEmpty(m))); + throw new LexboxQueryException("Lexbox GraphQL returned errors", errorText); + } + + return graph.Data?.MyProjects?.Select(p => new LexboxProject(p)).ToList() ?? []; + } + + public async Task> GetProjectEntriesAsync( + string accessToken, string projectCode, string vernacularLang) + { + var httpClient = _httpClientFactory.CreateClient(); + httpClient.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", accessToken); + + var url = $"{LexboxQuery.LfClassicBaseUrl}/{projectCode}/entries"; + var response = await httpClient.GetAsync(url); + + if (response.StatusCode == System.Net.HttpStatusCode.NotFound) + { + throw new LexboxQueryException("Language Forge project not found", + $"Project '{projectCode}' was not found in Language Forge."); + } + + if (!response.IsSuccessStatusCode) + { + var responseBody = await response.Content.ReadAsStringAsync(); + throw new LexboxQueryException("Project entries request failed", + $"Status: {(int)response.StatusCode} {response.ReasonPhrase}" + + (string.IsNullOrEmpty(responseBody) ? "" : $"\nBody: {responseBody}")); + } + + var lexboxEntries = await response.Content.ReadFromJsonAsync>() ?? []; + return lexboxEntries + .Where(e => e.DeletedAt is null) + .Select(e => e.ToWord(vernacularLang)) + .OfType() + .ToList(); + } + } +} diff --git a/Backend/Startup.cs b/Backend/Startup.cs index 642e1fd5eb..0f35d5c182 100644 --- a/Backend/Startup.cs +++ b/Backend/Startup.cs @@ -1,6 +1,9 @@ using System; using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Net.Http; using System.Text.Json.Serialization; +using System.Threading.Tasks; using BackendFramework.Contexts; using BackendFramework.Helper; using BackendFramework.Interfaces; @@ -133,6 +136,13 @@ public void ConfigureServices(IServiceCollection services) } var key = ASCII.GetBytes(secretKey); + + var lexboxAuthConfig = Configuration.GetSection("LexboxAuth"); + + // Authorization endpoint needs to be defined because discovery silently fails in dev. + var lexboxAuthorizationEndpoint = lexboxAuthConfig["AuthorizationEndpoint"]?.Trim(); + var lexboxPrompt = lexboxAuthConfig["Prompt"]?.Trim(); + services.AddAuthentication(x => { x.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; @@ -144,10 +154,69 @@ public void ConfigureServices(IServiceCollection services) x.SaveToken = true; x.TokenValidationParameters = new TokenValidationParameters { - ValidateIssuerSigningKey = true, IssuerSigningKey = new SymmetricSecurityKey(key), + ValidateAudience = false, ValidateIssuer = false, - ValidateAudience = false + ValidateIssuerSigningKey = true + }; + }) + .AddCookie("LexboxCookie", options => + { + options.Cookie.HttpOnly = true; + options.Cookie.IsEssential = true; + options.Cookie.Name = "lexbox_auth"; + options.Cookie.SameSite = Microsoft.AspNetCore.Http.SameSiteMode.Lax; + options.Cookie.SecurePolicy = Microsoft.AspNetCore.Http.CookieSecurePolicy.SameAsRequest; + options.ExpireTimeSpan = TimeSpan.FromHours(1); + options.SlidingExpiration = true; + }) + .AddOpenIdConnect("LexboxOidc", options => + { + lexboxAuthConfig.Bind(options); + + // Keep claim names (e.g. "sub", "name") as-is, rather than remapping to URI-based ClaimTypes. + options.MapInboundClaims = false; + + // Discovery isn't working (at least in dev), so manually fetch the keys. + options.TokenValidationParameters.IssuerSigningKeyResolver = (_, _, kid, _) => + { + var jwksUrl = "https://lexbox.org/.well-known/jwks"; + // Task.Run avoids sync-over-async deadlock by running on a thread-pool thread with no + // synchronization context. + var response = Task.Run(() => new HttpClient().GetStringAsync(jwksUrl)) + .GetAwaiter().GetResult(); + var keys = new JsonWebKeySet(response).GetSigningKeys(); + return keys.Where(x => x.KeyId == kid); + }; + + options.Events.OnRedirectToIdentityProvider = context => + { + if (string.IsNullOrWhiteSpace(context.ProtocolMessage.IssuerAddress) + && !string.IsNullOrEmpty(lexboxAuthorizationEndpoint)) + { + context.ProtocolMessage.IssuerAddress = lexboxAuthorizationEndpoint; + } + + if (!string.IsNullOrEmpty(lexboxPrompt)) + { + context.ProtocolMessage.Prompt = lexboxPrompt; + } + + return Task.CompletedTask; + }; + + options.Events.OnRemoteFailure = ctx => + { + _logger.LogError(ctx.Failure, "[OIDC] Remote failure: {Message}", ctx.Failure?.Message); + ctx.HandleResponse(); + ctx.Response.Redirect("/error"); + return Task.CompletedTask; + }; + + options.Events.OnAuthenticationFailed = ctx => + { + _logger.LogError(ctx.Exception, "[OIDC] Authentication failed: {Message}", ctx.Exception?.Message); + return Task.CompletedTask; }; }); @@ -232,6 +301,9 @@ public void ConfigureServices(IServiceCollection services) // Register concrete types for dependency injection + // HttpClientFactory for Lexbox and OpenTelemetry instrumentation + services.AddHttpClient(); + // Mongo context for use in repo contexts services.AddSingleton(); @@ -255,6 +327,10 @@ public void ConfigureServices(IServiceCollection services) services.AddTransient(); services.AddTransient(); + // Lexbox types + services.AddTransient(); + services.AddTransient(); + // Lift Service - Singleton to avoid initializing the Sldr multiple times, // also to avoid leaking LanguageTag data services.AddSingleton(); @@ -294,7 +370,6 @@ public void ConfigureServices(IServiceCollection services) services.AddTransient(); // OpenTelemetry - services.AddHttpClient(); services.AddMemoryCache(); services.AddHttpContextAccessor(); services.AddTransient(); diff --git a/Backend/appsettings.Development.json b/Backend/appsettings.Development.json index e203e9407e..31223bf7ef 100644 --- a/Backend/appsettings.Development.json +++ b/Backend/appsettings.Development.json @@ -1,4 +1,7 @@ { + "LexboxAuth": { + "GetClaimsFromUserInfoEndpoint": false + }, "Logging": { "LogLevel": { "Default": "Debug", diff --git a/Backend/appsettings.json b/Backend/appsettings.json index b42b2f478f..e0c14a6519 100644 --- a/Backend/appsettings.json +++ b/Backend/appsettings.json @@ -1,4 +1,18 @@ { + "LexboxAuth": { + "Authority": "https://lexbox.org", + "AuthorizationEndpoint": "https://lexbox.org/api/oauth/open-id-auth", + "CallbackPath": "/v1/auth/oauth-callback", + "ClientId": "the-combine", + "GetClaimsFromUserInfoEndpoint": true, + "Prompt": "select_account", + "RequireHttpsMetadata": true, + "ResponseType": "code", + "SaveTokens": true, + "Scope": ["lexboxapi", "openid", "profile"], + "SignInScheme": "LexboxCookie", + "UsePkce": true + }, "MongoDB": { "ConnectionString": "mongodb://localhost:27017/?replicaSet=rs0", "ContainerConnectionString": "mongodb://database:27017/?replicaSet=rs0", diff --git a/src/api/.openapi-generator/FILES b/src/api/.openapi-generator/FILES index d838240e16..5c64f783d6 100644 --- a/src/api/.openapi-generator/FILES +++ b/src/api/.openapi-generator/FILES @@ -7,6 +7,7 @@ api/avatar-api.ts api/banner-api.ts api/email-verify-api.ts api/invite-api.ts +api/lexbox-api.ts api/lift-api.ts api/merge-api.ts api/password-reset-api.ts @@ -38,6 +39,8 @@ models/gloss.ts models/gram-cat-group.ts models/grammatical-info.ts models/index.ts +models/lexbox-auth-status.ts +models/lexbox-project.ts models/merge-source-word.ts models/merge-undo-ids.ts models/merge-words.ts diff --git a/src/api/api.ts b/src/api/api.ts index d48242a9ed..25587ab6f6 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -17,6 +17,7 @@ export * from "./api/avatar-api"; export * from "./api/banner-api"; export * from "./api/email-verify-api"; export * from "./api/invite-api"; +export * from "./api/lexbox-api"; export * from "./api/lift-api"; export * from "./api/merge-api"; export * from "./api/password-reset-api"; diff --git a/src/api/api/lexbox-api.ts b/src/api/api/lexbox-api.ts new file mode 100644 index 0000000000..8fc1f04770 --- /dev/null +++ b/src/api/api/lexbox-api.ts @@ -0,0 +1,533 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * BackendFramework + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import globalAxios, { AxiosPromise, AxiosInstance } from "axios"; +import { Configuration } from "../configuration"; +// Some imports not used depending on template conditions +// @ts-ignore +import { + DUMMY_BASE_URL, + assertParamExists, + setApiKeyToObject, + setBasicAuthToObject, + setBearerAuthToObject, + setOAuthToObject, + setSearchParams, + serializeDataIfNeeded, + toPathString, + createRequestFunction, +} from "../common"; +// @ts-ignore +import { + BASE_PATH, + COLLECTION_FORMATS, + RequestArgs, + BaseAPI, + RequiredError, +} from "../base"; +// @ts-ignore +import { LexboxAuthStatus } from "../models"; +// @ts-ignore +import { LexboxProject } from "../models"; +// @ts-ignore +import { Word } from "../models"; +/** + * LexboxApi - axios parameter creator + * @export + */ +export const LexboxApiAxiosParamCreator = function ( + configuration?: Configuration +) { + return { + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + generateLogin: async (options: any = {}): Promise => { + const localVarPath = `/v1/lexbox/login`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { + method: "GET", + ...baseOptions, + ...options, + }; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + setSearchParams(localVarUrlObj, localVarQueryParameter, options.query); + let headersFromBaseOptions = + baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = { + ...localVarHeaderParameter, + ...headersFromBaseOptions, + ...options.headers, + }; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getAuthStatus: async (options: any = {}): Promise => { + const localVarPath = `/v1/lexbox/auth-status`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { + method: "GET", + ...baseOptions, + ...options, + }; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + setSearchParams(localVarUrlObj, localVarQueryParameter, options.query); + let headersFromBaseOptions = + baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = { + ...localVarHeaderParameter, + ...headersFromBaseOptions, + ...options.headers, + }; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {string} projectCode + * @param {string} vernacularLang + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getEntries: async ( + projectCode: string, + vernacularLang: string, + options: any = {} + ): Promise => { + // verify required parameter 'projectCode' is not null or undefined + assertParamExists("getEntries", "projectCode", projectCode); + // verify required parameter 'vernacularLang' is not null or undefined + assertParamExists("getEntries", "vernacularLang", vernacularLang); + const localVarPath = `/v1/lexbox/entries/{projectCode}/{vernacularLang}` + .replace(`{${"projectCode"}}`, encodeURIComponent(String(projectCode))) + .replace( + `{${"vernacularLang"}}`, + encodeURIComponent(String(vernacularLang)) + ); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { + method: "GET", + ...baseOptions, + ...options, + }; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + setSearchParams(localVarUrlObj, localVarQueryParameter, options.query); + let headersFromBaseOptions = + baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = { + ...localVarHeaderParameter, + ...headersFromBaseOptions, + ...options.headers, + }; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getProjects: async (options: any = {}): Promise => { + const localVarPath = `/v1/lexbox/projects`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { + method: "GET", + ...baseOptions, + ...options, + }; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + setSearchParams(localVarUrlObj, localVarQueryParameter, options.query); + let headersFromBaseOptions = + baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = { + ...localVarHeaderParameter, + ...headersFromBaseOptions, + ...options.headers, + }; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + logOut: async (options: any = {}): Promise => { + const localVarPath = `/v1/lexbox/logout`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { + method: "POST", + ...baseOptions, + ...options, + }; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + setSearchParams(localVarUrlObj, localVarQueryParameter, options.query); + let headersFromBaseOptions = + baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = { + ...localVarHeaderParameter, + ...headersFromBaseOptions, + ...options.headers, + }; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + }; +}; + +/** + * LexboxApi - functional programming interface + * @export + */ +export const LexboxApiFp = function (configuration?: Configuration) { + const localVarAxiosParamCreator = LexboxApiAxiosParamCreator(configuration); + return { + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async generateLogin( + options?: any + ): Promise< + (axios?: AxiosInstance, basePath?: string) => AxiosPromise + > { + const localVarAxiosArgs = + await localVarAxiosParamCreator.generateLogin(options); + return createRequestFunction( + localVarAxiosArgs, + globalAxios, + BASE_PATH, + configuration + ); + }, + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getAuthStatus( + options?: any + ): Promise< + ( + axios?: AxiosInstance, + basePath?: string + ) => AxiosPromise + > { + const localVarAxiosArgs = + await localVarAxiosParamCreator.getAuthStatus(options); + return createRequestFunction( + localVarAxiosArgs, + globalAxios, + BASE_PATH, + configuration + ); + }, + /** + * + * @param {string} projectCode + * @param {string} vernacularLang + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getEntries( + projectCode: string, + vernacularLang: string, + options?: any + ): Promise< + (axios?: AxiosInstance, basePath?: string) => AxiosPromise> + > { + const localVarAxiosArgs = await localVarAxiosParamCreator.getEntries( + projectCode, + vernacularLang, + options + ); + return createRequestFunction( + localVarAxiosArgs, + globalAxios, + BASE_PATH, + configuration + ); + }, + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getProjects( + options?: any + ): Promise< + ( + axios?: AxiosInstance, + basePath?: string + ) => AxiosPromise> + > { + const localVarAxiosArgs = + await localVarAxiosParamCreator.getProjects(options); + return createRequestFunction( + localVarAxiosArgs, + globalAxios, + BASE_PATH, + configuration + ); + }, + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async logOut( + options?: any + ): Promise< + (axios?: AxiosInstance, basePath?: string) => AxiosPromise + > { + const localVarAxiosArgs = await localVarAxiosParamCreator.logOut(options); + return createRequestFunction( + localVarAxiosArgs, + globalAxios, + BASE_PATH, + configuration + ); + }, + }; +}; + +/** + * LexboxApi - factory interface + * @export + */ +export const LexboxApiFactory = function ( + configuration?: Configuration, + basePath?: string, + axios?: AxiosInstance +) { + const localVarFp = LexboxApiFp(configuration); + return { + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + generateLogin(options?: any): AxiosPromise { + return localVarFp + .generateLogin(options) + .then((request) => request(axios, basePath)); + }, + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getAuthStatus(options?: any): AxiosPromise { + return localVarFp + .getAuthStatus(options) + .then((request) => request(axios, basePath)); + }, + /** + * + * @param {string} projectCode + * @param {string} vernacularLang + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getEntries( + projectCode: string, + vernacularLang: string, + options?: any + ): AxiosPromise> { + return localVarFp + .getEntries(projectCode, vernacularLang, options) + .then((request) => request(axios, basePath)); + }, + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getProjects(options?: any): AxiosPromise> { + return localVarFp + .getProjects(options) + .then((request) => request(axios, basePath)); + }, + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + logOut(options?: any): AxiosPromise { + return localVarFp + .logOut(options) + .then((request) => request(axios, basePath)); + }, + }; +}; + +/** + * Request parameters for getEntries operation in LexboxApi. + * @export + * @interface LexboxApiGetEntriesRequest + */ +export interface LexboxApiGetEntriesRequest { + /** + * + * @type {string} + * @memberof LexboxApiGetEntries + */ + readonly projectCode: string; + + /** + * + * @type {string} + * @memberof LexboxApiGetEntries + */ + readonly vernacularLang: string; +} + +/** + * LexboxApi - object-oriented interface + * @export + * @class LexboxApi + * @extends {BaseAPI} + */ +export class LexboxApi extends BaseAPI { + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof LexboxApi + */ + public generateLogin(options?: any) { + return LexboxApiFp(this.configuration) + .generateLogin(options) + .then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof LexboxApi + */ + public getAuthStatus(options?: any) { + return LexboxApiFp(this.configuration) + .getAuthStatus(options) + .then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @param {LexboxApiGetEntriesRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof LexboxApi + */ + public getEntries( + requestParameters: LexboxApiGetEntriesRequest, + options?: any + ) { + return LexboxApiFp(this.configuration) + .getEntries( + requestParameters.projectCode, + requestParameters.vernacularLang, + options + ) + .then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof LexboxApi + */ + public getProjects(options?: any) { + return LexboxApiFp(this.configuration) + .getProjects(options) + .then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof LexboxApi + */ + public logOut(options?: any) { + return LexboxApiFp(this.configuration) + .logOut(options) + .then((request) => request(this.axios, this.basePath)); + } +} diff --git a/src/api/models/index.ts b/src/api/models/index.ts index 343ad523ca..b5cdbf1a4f 100644 --- a/src/api/models/index.ts +++ b/src/api/models/index.ts @@ -12,6 +12,8 @@ export * from "./flag"; export * from "./gloss"; export * from "./gram-cat-group"; export * from "./grammatical-info"; +export * from "./lexbox-auth-status"; +export * from "./lexbox-project"; export * from "./merge-source-word"; export * from "./merge-undo-ids"; export * from "./merge-words"; diff --git a/src/api/models/lexbox-auth-status.ts b/src/api/models/lexbox-auth-status.ts new file mode 100644 index 0000000000..9978cd014a --- /dev/null +++ b/src/api/models/lexbox-auth-status.ts @@ -0,0 +1,39 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * BackendFramework + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +/** + * + * @export + * @interface LexboxAuthStatus + */ +export interface LexboxAuthStatus { + /** + * + * @type {boolean} + * @memberof LexboxAuthStatus + */ + isLoggedIn?: boolean; + /** + * + * @type {string} + * @memberof LexboxAuthStatus + */ + loggedInAs?: string | null; + /** + * + * @type {string} + * @memberof LexboxAuthStatus + */ + userId?: string | null; +} diff --git a/src/api/models/lexbox-project.ts b/src/api/models/lexbox-project.ts new file mode 100644 index 0000000000..2f47a84090 --- /dev/null +++ b/src/api/models/lexbox-project.ts @@ -0,0 +1,69 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * BackendFramework + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +/** + * + * @export + * @interface LexboxProject + */ +export interface LexboxProject { + /** + * + * @type {Array} + * @memberof LexboxProject + */ + analysisWsTags?: Array | null; + /** + * + * @type {string} + * @memberof LexboxProject + */ + code?: string | null; + /** + * + * @type {string} + * @memberof LexboxProject + */ + description?: string | null; + /** + * + * @type {string} + * @memberof LexboxProject + */ + id?: string; + /** + * + * @type {boolean} + * @memberof LexboxProject + */ + isConfidential?: boolean | null; + /** + * + * @type {string} + * @memberof LexboxProject + */ + name?: string | null; + /** + * + * @type {string} + * @memberof LexboxProject + */ + type?: string | null; + /** + * + * @type {Array} + * @memberof LexboxProject + */ + vernacularWsTags?: Array | null; +} diff --git a/src/backend/index.ts b/src/backend/index.ts index 0109e66a3b..54cc2b0621 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -9,6 +9,8 @@ import { BannerType, ChartRootData, EmailInviteStatus, + LexboxAuthStatus, + LexboxProject, MergeUndoIds, MergeWords, Permission, @@ -39,10 +41,8 @@ import { FileWithSpeakerId } from "types/word"; import { Bcp47Code } from "types/writingSystem"; import { convertGoalToEdit } from "utilities/goalUtilities"; -export const baseURL = `${RuntimeConfig.getInstance().baseUrl()}`; -const apiBaseURL = `${baseURL}/v1`; -const config_parameters: Api.ConfigurationParameters = { basePath: baseURL }; -const config = new Api.Configuration(config_parameters); +const basePath = RuntimeConfig.getInstance().baseUrl(); +const config = new Api.Configuration({ basePath }); /** A list of URL patterns for which user analytics should not be collected. */ const authenticationUrls = [ @@ -56,6 +56,7 @@ const authenticationUrls = [ /** A list of URL patterns for which the frontend explicitly handles errors * and the blanket error pop-ups should be suppressed.*/ const whiteListedErrorUrls = [ + "/auth/status", "/merge/retrievedups", "/speakers/create", "/speakers/update/", @@ -65,7 +66,8 @@ const whiteListedErrorUrls = [ ]; // Create an axios instance to allow for attaching interceptors to it. -const axiosInstance = axios.create({ baseURL: apiBaseURL }); +const baseURL = `${basePath}/v1`; +const axiosInstance = axios.create({ baseURL, withCredentials: true }); axiosInstance.interceptors.request.use((config: InternalAxiosRequestConfig) => { const consent = LocalStorage.getCurrentUser()?.analyticsOn; const url = config.url; @@ -118,6 +120,7 @@ const avatarApi = new Api.AvatarApi(config, BASE_PATH, axiosInstance); const bannerApi = new Api.BannerApi(config, BASE_PATH, axiosInstance); const emailVerifyApi = new Api.EmailVerifyApi(config, BASE_PATH, axiosInstance); const inviteApi = new Api.InviteApi(config, BASE_PATH, axiosInstance); +const lexboxApi = new Api.LexboxApi(config, BASE_PATH, axiosInstance); const liftApi = new Api.LiftApi(config, BASE_PATH, axiosInstance); const mergeApi = new Api.MergeApi(config, BASE_PATH, axiosInstance); const passwordResetApi = new Api.PasswordResetApi( @@ -176,7 +179,7 @@ export async function deleteAudio( * Note: Backend doesn't need wordId to find the file, * but it's still required in the url and helpful for analytics. */ export function getAudioUrl(wordId: string, fileName: string): string { - return `${apiBaseURL}/projects/${LocalStorage.getProjectId()}/words/${wordId}/audio/download/${fileName}`; + return `${baseURL}/projects/${LocalStorage.getProjectId()}/words/${wordId}/audio/download/${fileName}`; } /* AvatarController.cs */ @@ -263,6 +266,32 @@ export async function validateInviteToken( ).data; } +/* LexboxController.cs */ + +export async function getLexboxAuthStatus(): Promise { + return (await lexboxApi.getAuthStatus(defaultOptions())).data; +} + +export function getLexboxLoginUrl(): string { + return `${baseURL}/auth/lexbox-login`; +} + +export async function getLexboxProjects(): Promise { + return (await lexboxApi.getProjects(defaultOptions())).data; +} + +export async function getLexboxEntries( + projectCode: string, + vernacularLang: string +): Promise { + const params = { projectCode, vernacularLang }; + return (await lexboxApi.getEntries(params, defaultOptions())).data; +} + +export async function logoutLexboxUser(): Promise { + await lexboxApi.logOut(defaultOptions()); +} + /* LiftController.cs */ /** Upload a LIFT file during project creation to get vernacular ws options. */ @@ -640,7 +669,7 @@ export async function uploadConsent( /** Use of the returned url acts as an HttpGet. */ export function getConsentUrl(speaker: Speaker): string { - return `${apiBaseURL}/projects/${speaker.projectId}/speakers/consent/${speaker.id}`; + return `${baseURL}/projects/${speaker.projectId}/speakers/consent/${speaker.id}`; } /** Returns the string to display the image inline in Base64 ; @@ -36,6 +36,8 @@ const failureMethodName = "Failure"; /** Matches `CombineHub.MethodSuccess` in Backend/Helper/CombineHub.cs */ const successMethodName = "Success"; +const baseUrl = RuntimeConfig.getInstance().baseUrl(); + /** A central hub for monitoring export status on SignalR */ export default function SignalRHub(props: SignalRHubProps): ReactElement { const { connect, failureAction, successAction, url } = props; @@ -66,7 +68,7 @@ export default function SignalRHub(props: SignalRHubProps): ReactElement { useEffect(() => { if (!disconnect && reconnect) { const newConnection = new HubConnectionBuilder() - .withUrl(`${baseURL}/${url}`) + .withUrl(`${baseUrl}/${url}`) .withAutomaticReconnect() .build(); setReconnect(false); diff --git a/src/components/Lexbox/LexboxLogin.tsx b/src/components/Lexbox/LexboxLogin.tsx new file mode 100644 index 0000000000..96b29b9a41 --- /dev/null +++ b/src/components/Lexbox/LexboxLogin.tsx @@ -0,0 +1,113 @@ +import AccountCircleIcon from "@mui/icons-material/AccountCircle"; +import LogoutIcon from "@mui/icons-material/Logout"; +import { + Button, + ListItemIcon, + ListItemText, + Menu, + MenuItem, +} from "@mui/material"; +import { type ReactElement, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { toast } from "react-toastify"; + +import { type LexboxAuthStatus } from "api/models"; +import { + getLexboxAuthStatus, + getLexboxLoginUrl, + logoutLexboxUser, +} from "backend"; +import LoadingButton from "components/Buttons/LoadingButton"; + +interface LexboxLoginProps { + text?: string; + onStatusChange?: (loggedIn: boolean) => void; +} + +export default function LexboxLogin(props: LexboxLoginProps): ReactElement { + const [actionLoading, setActionLoading] = useState(false); + const [isLoggedIn, setIsLoggedIn] = useState(false); + const [menuAnchor, setMenuAnchor] = useState(null); + const [status, setStatus] = useState(); + const [statusLoading, setStatusLoading] = useState(true); + + const { t } = useTranslation(); + + const loadStatus = async (): Promise => { + setStatusLoading(true); + try { + setStatus(await getLexboxAuthStatus()); + } catch (err) { + console.error("Failed to load auth status", err); + setStatus(undefined); + } finally { + setStatusLoading(false); + } + }; + + useEffect(() => { + loadStatus(); + }, []); + + useEffect(() => { + setIsLoggedIn(status?.isLoggedIn ?? false); + }, [status?.isLoggedIn]); + + useEffect(() => { + props.onStatusChange?.(isLoggedIn); + }, [props.onStatusChange, isLoggedIn]); + + const handleLogin = (): void => { + if (!window.open(getLexboxLoginUrl())) { + toast.error("Failed to open login window"); + } + }; + + const handleLogout = async (): Promise => { + setActionLoading(true); + try { + await logoutLexboxUser(); + await loadStatus(); + } finally { + setActionLoading(false); + setMenuAnchor(null); + } + }; + + if (!status?.isLoggedIn) { + return ( + + {props.text ?? t("login.login")} + + ); + } + + return ( + <> + + + setMenuAnchor(null)} + open={Boolean(menuAnchor)} + > + + + + + + {t("userMenu.logout")} + + + + ); +} diff --git a/src/components/Lexbox/LexboxProjectsDialog.tsx b/src/components/Lexbox/LexboxProjectsDialog.tsx new file mode 100644 index 0000000000..8dfdfe30d0 --- /dev/null +++ b/src/components/Lexbox/LexboxProjectsDialog.tsx @@ -0,0 +1,163 @@ +import { + Box, + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + List, + ListItemButton, + ListItemText, + Radio, + Typography, +} from "@mui/material"; +import { type ReactElement, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { type LexboxProject } from "api/models"; +import { getLexboxProjects } from "backend"; +import LoadingButton from "components/Buttons/LoadingButton"; +import LexboxLogin from "components/Lexbox/LexboxLogin"; + +interface LexboxProjectsDialogProps { + chooseProject: (project: LexboxProject) => void; + onClose: () => void; + open: boolean; +} + +export default function LexboxProjectsDialog( + props: LexboxProjectsDialogProps +): ReactElement { + const [error, setError] = useState(); + const [isLoggedIn, setIsLoggedIn] = useState(false); + const [loading, setLoading] = useState(false); + const [projects, setProjects] = useState([]); + const [selected, setSelected] = useState(); + + const { t } = useTranslation(); + + const loadProjects = async (): Promise => { + setLoading(true); + setError(undefined); + setSelected(undefined); + try { + setProjects(await getLexboxProjects()); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + setProjects([]); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + if (!props.open || !isLoggedIn) { + return; + } + + loadProjects(); + const handleFocus = (): void => void loadProjects(); + window.addEventListener("focus", handleFocus); + return () => window.removeEventListener("focus", handleFocus); + }, [isLoggedIn, props.open]); + + const handleConfirm = (): void => { + if (selected) { + props.chooseProject(selected); + props.onClose(); + } + }; + + const handleLogout = (): void => { + setProjects([]); + setSelected(undefined); + setError(undefined); + }; + + useEffect(() => { + if (!isLoggedIn) { + handleLogout(); + } + }, [isLoggedIn]); + + const projectContent = (): ReactElement => { + if (loading) { + return ( + + + + ); + } + if (error) { + return {error}; + } + if (!isLoggedIn) { + return ( + + {t("Log in to Lexbox to see your projects.")} + + ); + } + if (!projects.length) { + return ( + {t("No Lexbox projects found.")} + ); + } + return ( + + {projects.map((project) => ( + setSelected(project)} + selected={selected?.id === project.id} + > + + + {`${t("Vernacular languages: ")}${project.vernacularWsTags?.join(", ") || t("None")}`} +
+ {`${t("Analysis languages: ")}${project.analysisWsTags?.join(", ") || t("None")}`} + + } + /> +
+ ))} +
+ ); + }; + + return ( + { + if (reason !== "backdropClick") { + props.onClose(); + } + }} + open={props.open} + > + {t("Import from Lexbox")} + + + {projectContent()} + + + + + {t("buttons.confirm")} + + + + ); +} diff --git a/src/components/Lexbox/tests/LexboxLogin.test.tsx b/src/components/Lexbox/tests/LexboxLogin.test.tsx new file mode 100644 index 0000000000..22b4f43255 --- /dev/null +++ b/src/components/Lexbox/tests/LexboxLogin.test.tsx @@ -0,0 +1,68 @@ +import "@testing-library/jest-dom"; +import { act, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import LexboxLogin from "components/Lexbox/LexboxLogin"; + +jest.mock("backend", () => ({ + getLexboxAuthStatus: () => mockGetLexboxAuthStatus(), + getLexboxLoginUrl: () => mockGetLexboxLoginUrl(), + logoutLexboxUser: () => mockLogoutLexboxUser(), +})); + +const mockGetLexboxAuthStatus = jest.fn(); +const mockGetLexboxLoginUrl = jest.fn(); +const mockLogoutLexboxUser = jest.fn(); + +const testUrl = "not-a-valid-url"; + +describe("LexboxLogin", () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(window, "open").mockImplementation(() => null); + mockGetLexboxLoginUrl.mockReturnValue(testUrl); + }); + + it("redirects to Lexbox login when logged out", async () => { + mockGetLexboxAuthStatus.mockResolvedValue({ isLoggedIn: false }); + + await act(async () => { + render(); + }); + + const loginButton = await screen.findByRole("button", { name: /login/i }); + await waitFor(() => expect(mockGetLexboxAuthStatus).toHaveBeenCalled()); + await waitFor(() => expect(loginButton).toBeEnabled()); + + await userEvent.click(loginButton); + + expect(mockGetLexboxLoginUrl).toHaveBeenCalledTimes(1); + expect(window.open).toHaveBeenCalledWith(testUrl); + }); + + it("shows logged-in menu and logs out", async () => { + mockGetLexboxAuthStatus + .mockResolvedValueOnce({ isLoggedIn: true, loggedInAs: "Lex User" }) + .mockResolvedValueOnce({ isLoggedIn: false }); + + const onStatusChange = jest.fn(); + + await act(async () => { + render(); + }); + + const userButton = await screen.findByRole("button", { + name: "Lex User", + }); + + await userEvent.click(userButton); + + const logoutItem = await screen.findByRole("menuitem", { name: /logout/i }); + + await userEvent.click(logoutItem); + + expect(mockGetLexboxLoginUrl).not.toHaveBeenCalled(); + expect(mockLogoutLexboxUser).toHaveBeenCalledTimes(1); + expect(onStatusChange).toHaveBeenCalledWith(false); + }); +}); diff --git a/src/components/ProjectScreen/CreateProject.tsx b/src/components/ProjectScreen/CreateProject.tsx index dc6320085e..6448fcedef 100644 --- a/src/components/ProjectScreen/CreateProject.tsx +++ b/src/components/ProjectScreen/CreateProject.tsx @@ -1,5 +1,7 @@ import { Cancel } from "@mui/icons-material"; import { + Box, + Button, Card, CardContent, Grid2, @@ -18,18 +20,23 @@ import { useState, } from "react"; import { Trans, useTranslation } from "react-i18next"; +import { toast } from "react-toastify"; -import { type WritingSystem } from "api/models"; -import { projectDuplicateCheck, uploadLiftAndGetWritingSystems } from "backend"; +import { LexboxProject, type WritingSystem } from "api/models"; +import { + getLexboxEntries, + projectDuplicateCheck, + uploadLiftAndGetWritingSystems, +} from "backend"; import FileInputButton from "components/Buttons/FileInputButton"; import LoadingDoneButton from "components/Buttons/LoadingDoneButton"; import LanguagePicker from "components/LanguagePicker"; +import LexboxProjectsDialog from "components/Lexbox/LexboxProjectsDialog"; import { asyncCreateProject, asyncFinishProject, } from "components/ProjectScreen/CreateProjectActions"; import { useAppDispatch } from "rootRedux/hooks"; -import theme from "types/theme"; import { newWritingSystem } from "types/writingSystem"; import { NormalizedTextField } from "utilities/fontComponents"; @@ -61,6 +68,10 @@ export default function CreateProject(): ReactElement { const [analysisLang, setAnalysisLang] = useState(newWritingSystem(undBcp47)); const [error, setError] = useState({ empty: false, nameTaken: false }); const [languageData, setLanguageData] = useState(); + const [lexboxDialogOpen, setLexboxDialogOpen] = useState(false); + const [lexboxProject, setLexboxProject] = useState< + LexboxProject | undefined + >(); const [loading, setLoading] = useState(false); const [name, setName] = useState(""); const [success, setSuccess] = useState(false); @@ -126,6 +137,7 @@ export default function CreateProject(): ReactElement { }; const updateLanguageData = async (langData?: File): Promise => { + setLexboxProject(undefined); const langOptions = langData ? await uploadLiftAndGetWritingSystems(langData) : []; @@ -136,6 +148,16 @@ export default function CreateProject(): ReactElement { } }; + const updateLexboxProject = (project?: LexboxProject): void => { + setLanguageData(undefined); + setLexboxProject(project); + const vernLangs = project?.vernacularWsTags ?? []; + setVernLangOptions(vernLangs.map((lang) => newWritingSystem(lang))); + if (vernLangs.length) { + setVernLang(newWritingSystem(vernLangs[0])); + } + }; + /** A selector listing the vernacular writing systems in the user's upload. */ const vernLangSelect = (): ReactElement => { const langs = vernLangOptions; @@ -202,6 +224,19 @@ export default function CreateProject(): ReactElement { await dispatch(asyncFinishProject(trimmedName, vernLang)).then(() => setSuccess(true) ); + } else if (lexboxProject?.code) { + try { + console.info( + "Project entries:", + await getLexboxEntries(lexboxProject.code, vernLang.bcp47) + ); + } catch (e) { + console.error("Error fetching Lexbox entries:", e); + } + toast.error( + "Creating project from Lexbox import is not yet implemented." + ); + setLoading(false); } else { await dispatch( asyncCreateProject(trimmedName, vernLang, [analysisLang]) @@ -224,7 +259,7 @@ export default function CreateProject(): ReactElement { value={name} onChange={updateName} variant="outlined" - style={{ width: "100%", marginBottom: theme.spacing(2) }} + sx={{ mb: 2, width: "100%" }} margin="normal" error={error["empty"] || error["nameTaken"]} helperText={ @@ -234,17 +269,8 @@ export default function CreateProject(): ReactElement { /> {/* File upload */} -
- + + {t(CreateProjectTextId.Upload)} {/* Uploaded file name and remove button */} {languageData && ( - + {t(CreateProjectTextId.UploadSelected, { val: languageData.name, })} @@ -274,13 +300,52 @@ export default function CreateProject(): ReactElement { )} -
+ + + {/* Lexbox import */} + + + {t("Import from Lexbox?")} + + + {/* Uploaded file name and remove button */} + {lexboxProject && ( + + {t( + `Project selected: ${lexboxProject.name} (${lexboxProject.code})` + )} + updateLexboxProject()}> + + + + )} + { + updateLexboxProject(project); + setLexboxDialogOpen(false); + }} + onClose={() => setLexboxDialogOpen(false)} + open={lexboxDialogOpen} + /> + {/* Don't render language pickers until project creation begins. */} - {!!(name || languageData || vernLang.name || analysisLang.name) && ( + {!!( + name || + languageData || + lexboxProject || + vernLang.name || + analysisLang.name + ) && ( <> {/* Vernacular language picker */} - + {t(CreateProjectTextId.LangVernacular)} {vernLangSelect()} @@ -297,10 +362,10 @@ export default function CreateProject(): ReactElement { )} {/* Analysis language picker */} - + {t(CreateProjectTextId.LangAnalysis)} - {languageData ? ( + {languageData || lexboxProject ? ( {t(CreateProjectTextId.LangAnalysisInfo)} @@ -319,11 +384,7 @@ export default function CreateProject(): ReactElement { )} {/* Form submission button */} - + ({ })); jest.mock("backend", () => ({ + getLexboxAuthStatus: jest.fn(), projectDuplicateCheck: () => mockProjectDuplicateCheck(), uploadLiftAndGetWritingSystems: () => mockUploadLiftAndGetWritingSystems(), }));