From 8ca083e87e77e786c6893947af69e41b51b465ed Mon Sep 17 00:00:00 2001 From: Chaturna Kasturiratna Date: Sun, 22 Mar 2026 16:42:05 +0800 Subject: [PATCH 01/36] refactor[SCRUM-30]: Update canvas services and frontend structure - Introduced ICanvasCreationService and ICanvasProviderResolver for improved canvas management. - Refactored CanvasesController to utilize the new services for canvas creation and handling. - Updated CanvasGraphService to resolve providers using the new resolver. - Modified frontend routing to streamline user navigation and removed unused pages. - Enhanced CSS for better layout and styling consistency across components. - Removed obsolete tests and added new tests for the updated services and controller logic. --- .../Controllers/CanvasesControllerTests.cs | 46 +- .../Services/CanvasCreationServiceTests.cs | 283 +++++ .../Services/CanvasGraphServiceTests.cs | 4 +- .../Services/CanvasServiceTests.cs | 24 - .../Controllers/CanvasesController.cs | 10 +- backend/Coliee.API/Program.cs | 2 + .../Services/CanvasCreationService.cs | 161 +++ .../Coliee.API/Services/CanvasGraphService.cs | 37 +- .../Services/CanvasProviderResolution.cs | 7 + .../Services/CanvasProviderResolver.cs | 47 + backend/Coliee.API/Services/CanvasService.cs | 33 - .../Services/ICanvasCreationService.cs | 8 + .../Services/ICanvasProviderResolver.cs | 6 + backend/Coliee.API/Services/ICanvasService.cs | 1 - .../obj/Coliee.API.csproj.nuget.dgspec.json | 33 +- .../obj/Coliee.API.csproj.nuget.g.props | 10 +- backend/Coliee.API/obj/project.assets.json | 31 +- backend/Coliee.API/obj/project.nuget.cache | 89 +- docker-compose.yml | 3 +- frontend/frontend_test_result.txt | Bin 3096 -> 0 bytes frontend/src/App.tsx | 6 +- frontend/src/components/PublicRoute.test.tsx | 57 + frontend/src/components/auth/AuthCard.tsx | 14 + frontend/src/components/auth/AuthIcons.tsx | 24 + frontend/src/components/auth/AuthModal.tsx | 67 ++ frontend/src/components/auth/LoginForm.tsx | 188 +++ .../src/components/auth/PublicAuthNavbar.tsx | 26 + frontend/src/components/auth/RegisterForm.tsx | 226 ++++ .../src/components/canvas/CanvasGraphView.tsx | 289 +++-- .../public/PublicCanvasGraphPreview.tsx | 167 +++ .../public/PublicWorkspacePreview.tsx | 58 + frontend/src/index.css | 1047 ++++++++++++++++- frontend/src/pages/HomePage.test.tsx | 244 +++- frontend/src/pages/HomePage.tsx | 161 ++- frontend/src/pages/LoginPage.tsx | 220 +--- frontend/src/pages/RegisterPage.test.tsx | 84 +- frontend/src/pages/RegisterPage.tsx | 177 +-- frontend/src/pages/SettingsPage.tsx | 621 ++++++---- frontend/src/pages/WelcomePage.test.tsx | 143 +++ frontend/src/pages/WelcomePage.tsx | 71 +- 40 files changed, 3614 insertions(+), 1111 deletions(-) create mode 100644 backend/Coliee.API.Tests/Services/CanvasCreationServiceTests.cs create mode 100644 backend/Coliee.API/Services/CanvasCreationService.cs create mode 100644 backend/Coliee.API/Services/CanvasProviderResolution.cs create mode 100644 backend/Coliee.API/Services/CanvasProviderResolver.cs create mode 100644 backend/Coliee.API/Services/ICanvasCreationService.cs create mode 100644 backend/Coliee.API/Services/ICanvasProviderResolver.cs delete mode 100644 frontend/frontend_test_result.txt create mode 100644 frontend/src/components/PublicRoute.test.tsx create mode 100644 frontend/src/components/auth/AuthCard.tsx create mode 100644 frontend/src/components/auth/AuthIcons.tsx create mode 100644 frontend/src/components/auth/AuthModal.tsx create mode 100644 frontend/src/components/auth/LoginForm.tsx create mode 100644 frontend/src/components/auth/PublicAuthNavbar.tsx create mode 100644 frontend/src/components/auth/RegisterForm.tsx create mode 100644 frontend/src/components/public/PublicCanvasGraphPreview.tsx create mode 100644 frontend/src/components/public/PublicWorkspacePreview.tsx create mode 100644 frontend/src/pages/WelcomePage.test.tsx diff --git a/backend/Coliee.API.Tests/Controllers/CanvasesControllerTests.cs b/backend/Coliee.API.Tests/Controllers/CanvasesControllerTests.cs index 2144c24..811d268 100644 --- a/backend/Coliee.API.Tests/Controllers/CanvasesControllerTests.cs +++ b/backend/Coliee.API.Tests/Controllers/CanvasesControllerTests.cs @@ -21,7 +21,7 @@ public async Task GetCanvases_ReturnsOrderedCanvasSummaries() new CanvasSummaryResponse { Id = 1, Title = "Older" } ] }; - var controller = CreateController(service, new FakeCanvasGraphService()); + var controller = CreateController(new FakeCanvasCreationService(), service, new FakeCanvasGraphService()); var result = await controller.GetCanvases(); @@ -34,14 +34,16 @@ public async Task GetCanvases_ReturnsOrderedCanvasSummaries() public async Task CreateCanvas_ReturnsCreatedStatus() { var service = new FakeCanvasService + { }; + var creationService = new FakeCanvasCreationService { CreateResult = new CanvasSummaryResponse { Id = 10, - Title = "Launch checklist" + Title = "Launch Checklist" } }; - var controller = CreateController(service, new FakeCanvasGraphService()); + var controller = CreateController(creationService, service, new FakeCanvasGraphService()); var result = await controller.CreateCanvas(new CreateCanvasRequest { FirstPrompt = "Launch checklist" }); @@ -49,17 +51,18 @@ public async Task CreateCanvas_ReturnsCreatedStatus() Assert.Equal(201, objectResult.StatusCode); var payload = Assert.IsType(objectResult.Value); Assert.Equal(10, payload.Id); - Assert.Equal("Launch checklist", payload.Title); + Assert.Equal("Launch Checklist", payload.Title); } [Fact] public async Task CreateCanvas_WhenServiceRejectsPrompt_ReturnsBadRequest() { - var service = new FakeCanvasService + var service = new FakeCanvasService(); + var creationService = new FakeCanvasCreationService { ExceptionToThrow = new CanvasOperationException("A first prompt is required to create a canvas.", 400) }; - var controller = CreateController(service, new FakeCanvasGraphService()); + var controller = CreateController(creationService, service, new FakeCanvasGraphService()); var result = await controller.CreateCanvas(new CreateCanvasRequest { FirstPrompt = " " }); @@ -78,7 +81,7 @@ public async Task RenameCanvas_ReturnsUpdatedCanvas() Title = "Renamed canvas" } }; - var controller = CreateController(service, new FakeCanvasGraphService()); + var controller = CreateController(new FakeCanvasCreationService(), service, new FakeCanvasGraphService()); var result = await controller.RenameCanvas(10, new RenameCanvasRequest { Title = "Renamed canvas" }); @@ -94,7 +97,7 @@ public async Task RenameCanvas_WhenServiceRejectsTitle_ReturnsBadRequest() { ExceptionToThrow = new CanvasOperationException("A canvas title is required.", 400) }; - var controller = CreateController(service, new FakeCanvasGraphService()); + var controller = CreateController(new FakeCanvasCreationService(), service, new FakeCanvasGraphService()); var result = await controller.RenameCanvas(10, new RenameCanvasRequest { Title = " " }); @@ -105,7 +108,7 @@ public async Task RenameCanvas_WhenServiceRejectsTitle_ReturnsBadRequest() [Fact] public async Task DeleteCanvas_ReturnsNoContent() { - var controller = CreateController(new FakeCanvasService(), new FakeCanvasGraphService()); + var controller = CreateController(new FakeCanvasCreationService(), new FakeCanvasService(), new FakeCanvasGraphService()); var result = await controller.DeleteCanvas(10); @@ -119,7 +122,7 @@ public async Task DeleteCanvas_WhenForbidden_ReturnsForbidden() { DeleteExceptionToThrow = new CanvasOperationException("You do not have access to that canvas.", 403) }; - var controller = CreateController(service, new FakeCanvasGraphService()); + var controller = CreateController(new FakeCanvasCreationService(), service, new FakeCanvasGraphService()); var result = await controller.DeleteCanvas(10); @@ -134,7 +137,7 @@ public async Task DeleteCanvas_WhenMissing_ReturnsNotFound() { DeleteExceptionToThrow = new CanvasOperationException("Canvas not found.", 404) }; - var controller = CreateController(service, new FakeCanvasGraphService()); + var controller = CreateController(new FakeCanvasCreationService(), service, new FakeCanvasGraphService()); var result = await controller.DeleteCanvas(999); @@ -158,7 +161,7 @@ public async Task GetCanvasGraph_ReturnsGraphPayload() ] } }; - var controller = CreateController(new FakeCanvasService(), graphService); + var controller = CreateController(new FakeCanvasCreationService(), new FakeCanvasService(), graphService); var result = await controller.GetCanvasGraph(10); @@ -175,7 +178,7 @@ public async Task ExpandCanvasNode_WhenProviderMissing_ReturnsBadRequestWithCode { LlmExceptionToThrow = new LlmChatException("Missing provider", LlmProviderErrorCodes.MissingKey, 400) }; - var controller = CreateController(new FakeCanvasService(), graphService); + var controller = CreateController(new FakeCanvasCreationService(), new FakeCanvasService(), graphService); var result = await controller.ExpandCanvasNode(10, 42, new ExpandCanvasNodeRequest { Prompt = "Hello" }); @@ -183,9 +186,9 @@ public async Task ExpandCanvasNode_WhenProviderMissing_ReturnsBadRequestWithCode Assert.Equal(400, objectResult.StatusCode); } - private static CanvasesController CreateController(ICanvasService service, ICanvasGraphService graphService, int userId = 1) + private static CanvasesController CreateController(ICanvasCreationService creationService, ICanvasService service, ICanvasGraphService graphService, int userId = 1) { - var controller = new CanvasesController(service, graphService); + var controller = new CanvasesController(creationService, service, graphService); var claims = new List { new Claim(ClaimTypes.NameIdentifier, userId.ToString()), @@ -202,13 +205,10 @@ private static CanvasesController CreateController(ICanvasService service, ICanv return controller; } - private sealed class FakeCanvasService : ICanvasService + private sealed class FakeCanvasCreationService : ICanvasCreationService { - public IReadOnlyList Items { get; set; } = []; public CanvasSummaryResponse CreateResult { get; set; } = new(); - public CanvasSummaryResponse RenameResult { get; set; } = new(); public Exception? ExceptionToThrow { get; set; } - public Exception? DeleteExceptionToThrow { get; set; } public Task CreateCanvasAsync(int userId, string firstPrompt) { @@ -217,6 +217,14 @@ public Task CreateCanvasAsync(int userId, string firstPro return Task.FromResult(CreateResult); } + } + + private sealed class FakeCanvasService : ICanvasService + { + public IReadOnlyList Items { get; set; } = []; + public CanvasSummaryResponse RenameResult { get; set; } = new(); + public Exception? ExceptionToThrow { get; set; } + public Exception? DeleteExceptionToThrow { get; set; } public Task> GetCanvasesAsync(int userId) { diff --git a/backend/Coliee.API.Tests/Services/CanvasCreationServiceTests.cs b/backend/Coliee.API.Tests/Services/CanvasCreationServiceTests.cs new file mode 100644 index 0000000..660a1d4 --- /dev/null +++ b/backend/Coliee.API.Tests/Services/CanvasCreationServiceTests.cs @@ -0,0 +1,283 @@ +using Coliee.API.DTOs; +using Coliee.API.Data; +using Coliee.API.Models; +using Coliee.API.Services; +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Coliee.API.Tests.Services; + +public class CanvasCreationServiceTests +{ + [Fact] + public async Task CreateCanvas_GeneratesShortSemanticTitleAndSeedsGraph() + { + var repository = new InMemoryCanvasRepository(); + var graphService = new FakeCanvasGraphService(repository); + var providerResolver = new FakeCanvasProviderResolver(); + var service = new CanvasCreationService(repository, graphService, providerResolver, NullLogger.Instance); + + var response = await service.CreateCanvasAsync(1, " Plan launch\nchecklist for the Q2 release "); + + response.Title.Should().Be("Launch Checklist For Q2 Release"); + graphService.LastPrompt.Should().Be("Plan launch checklist for the Q2 release"); + repository.GetChildNodeCount(response.Id).Should().BeGreaterThan(0); + } + + [Fact] + public async Task CreateCanvas_WhitespaceOnlyPrompt_ThrowsBadRequest() + { + var repository = new InMemoryCanvasRepository(); + var service = new CanvasCreationService( + repository, + new FakeCanvasGraphService(repository), + new FakeCanvasProviderResolver(), + NullLogger.Instance); + + var action = async () => await service.CreateCanvasAsync(1, " "); + + var exception = await action.Should().ThrowAsync(); + exception.Which.StatusCode.Should().Be(400); + } + + [Fact] + public async Task CreateCanvas_WhenNoProviderConfigured_ThrowsMissingKeyWithoutPersistingCanvas() + { + var repository = new InMemoryCanvasRepository(); + var service = new CanvasCreationService( + repository, + new FakeCanvasGraphService(repository), + new FakeCanvasProviderResolver + { + ExceptionToThrow = new LlmChatException("Add a provider API key in Settings before creating a canvas.", LlmProviderErrorCodes.MissingKey, 400) + }, + NullLogger.Instance); + + var action = async () => await service.CreateCanvasAsync(1, "Launch checklist"); + + var exception = await action.Should().ThrowAsync(); + exception.Which.Code.Should().Be(LlmProviderErrorCodes.MissingKey); + (await repository.GetCanvasesAsync(1)).Should().BeEmpty(); + } + + [Fact] + public async Task CreateCanvas_WhenInitialExpansionFails_RollsBackCanvas() + { + var repository = new InMemoryCanvasRepository(); + var service = new CanvasCreationService( + repository, + new FakeCanvasGraphService(repository) + { + ExceptionToThrow = new LlmChatException("Provider unavailable.", LlmProviderErrorCodes.ProviderError, 502) + }, + new FakeCanvasProviderResolver(), + NullLogger.Instance); + + var action = async () => await service.CreateCanvasAsync(1, "Launch checklist"); + + await action.Should().ThrowAsync(); + (await repository.GetCanvasesAsync(1)).Should().BeEmpty(); + } + + private sealed class FakeCanvasProviderResolver : ICanvasProviderResolver + { + public Exception? ExceptionToThrow { get; set; } + + public Task ResolveAsync(int userId, string? providerOverride) + { + if (ExceptionToThrow != null) + throw ExceptionToThrow; + + return Task.FromResult(new CanvasProviderResolution( + LlmProviderNames.OpenAi, + [new LlmProviderKeyRecord { Provider = LlmProviderNames.OpenAi, EncryptedApiKey = "enc", IsDefault = true }])); + } + } + + private sealed class FakeCanvasGraphService : ICanvasGraphService + { + private readonly ICanvasRepository _canvasRepository; + public Exception? ExceptionToThrow { get; set; } + public string? LastPrompt { get; private set; } + + public FakeCanvasGraphService(ICanvasRepository canvasRepository) + { + _canvasRepository = canvasRepository; + } + + public Task GetCanvasGraphAsync(int userId, int canvasId) + { + throw new NotImplementedException(); + } + + public async Task ExpandCanvasNodeAsync(int userId, int canvasId, int nodeId, string prompt, string? providerOverride) + { + if (ExceptionToThrow != null) + throw ExceptionToThrow; + + LastPrompt = prompt; + await _canvasRepository.AddChildCanvasNodesAsync(userId, canvasId, nodeId, + [ + new CanvasNodeCreateInput + { + Kind = "response", + Title = "Launch Sequencing", + Content = "Test child", + SourcePrompt = prompt, + Depth = 1 + } + ]); + + return new CanvasGraphResponse + { + CanvasId = canvasId, + Mode = "graph", + RootNodeId = nodeId, + Nodes = + [ + new CanvasGraphNodeResponse + { + Id = nodeId, + ParentNodeId = null, + Kind = "main", + Title = "Main Node", + Content = string.Empty, + Depth = 0, + SiblingOrder = 0 + }, + new CanvasGraphNodeResponse + { + Id = nodeId + 1, + ParentNodeId = nodeId, + Kind = "response", + Title = "Launch Sequencing", + Content = "Test child", + SourcePrompt = prompt, + Depth = 1, + SiblingOrder = 0 + } + ] + }; + } + } + + private sealed class InMemoryCanvasRepository : ICanvasRepository + { + private readonly List _items = []; + private readonly List _nodes = []; + private int _nextId = 1; + private int _nextNodeId = 1; + + public Task CreateCanvasAsync(int userId, string title) + { + var now = DateTime.UtcNow.AddMinutes(_nextId); + var canvas = new Canvas + { + Id = _nextId++, + UserId = userId, + Title = title, + CreatedAt = now, + UpdatedAt = now + }; + _items.Add(canvas); + _nodes.Add(new CanvasNode + { + Id = _nextNodeId++, + CanvasId = canvas.Id, + ParentNodeId = null, + Kind = "main", + Title = "Main Node", + Content = string.Empty, + Depth = 0, + SiblingOrder = 0, + CreatedAt = now, + UpdatedAt = now + }); + return Task.FromResult(canvas); + } + + public Task> GetCanvasesAsync(int userId) + { + return Task.FromResult>(_items.Where(item => item.UserId == userId).ToList()); + } + + public Task GetCanvasAsync(int userId, int canvasId) + { + return Task.FromResult(_items.FirstOrDefault(item => item.UserId == userId && item.Id == canvasId)); + } + + public Task GetCanvasByIdAsync(int canvasId) + { + return Task.FromResult(_items.FirstOrDefault(item => item.Id == canvasId)); + } + + public Task RenameCanvasAsync(int userId, int canvasId, string title) + { + var canvas = _items.First(item => item.UserId == userId && item.Id == canvasId); + canvas.Title = title; + canvas.UpdatedAt = DateTime.UtcNow; + return Task.FromResult(canvas); + } + + public Task DeleteCanvasAsync(int userId, int canvasId) + { + _items.RemoveAll(item => item.UserId == userId && item.Id == canvasId); + _nodes.RemoveAll(item => item.CanvasId == canvasId); + return Task.CompletedTask; + } + + public Task HasLegacyMainNodeMessagesAsync(int userId, int canvasId) + { + return Task.FromResult(false); + } + + public Task EnsureRootCanvasNodeAsync(int userId, int canvasId) + { + return Task.FromResult(_nodes.First(item => item.CanvasId == canvasId && item.ParentNodeId == null)); + } + + public Task> GetCanvasNodesAsync(int userId, int canvasId) + { + return Task.FromResult>(_nodes.Where(item => item.CanvasId == canvasId).ToList()); + } + + public Task GetCanvasNodeAsync(int userId, int canvasId, int nodeId) + { + return Task.FromResult(_nodes.FirstOrDefault(item => item.CanvasId == canvasId && item.Id == nodeId)); + } + + public Task> AddChildCanvasNodesAsync(int userId, int canvasId, int parentNodeId, IReadOnlyList nodes) + { + var now = DateTime.UtcNow; + var inserted = nodes.Select((node, index) => + { + var created = new CanvasNode + { + Id = _nextNodeId++, + CanvasId = canvasId, + ParentNodeId = parentNodeId, + Kind = node.Kind, + Title = node.Title, + Content = node.Content, + SourcePrompt = node.SourcePrompt, + Depth = node.Depth, + SiblingOrder = index, + CreatedAt = now, + UpdatedAt = now + }; + _nodes.Add(created); + return created; + }).ToList(); + + var canvas = _items.First(item => item.Id == canvasId); + canvas.UpdatedAt = now; + return Task.FromResult>(inserted); + } + + public int GetChildNodeCount(int canvasId) + { + return _nodes.Count(item => item.CanvasId == canvasId && item.ParentNodeId != null); + } + } +} diff --git a/backend/Coliee.API.Tests/Services/CanvasGraphServiceTests.cs b/backend/Coliee.API.Tests/Services/CanvasGraphServiceTests.cs index 3a73ae2..5b58f12 100644 --- a/backend/Coliee.API.Tests/Services/CanvasGraphServiceTests.cs +++ b/backend/Coliee.API.Tests/Services/CanvasGraphServiceTests.cs @@ -106,6 +106,7 @@ private static TestCanvasGraphService CreateService(InMemoryGraphStore repositor return new TestCanvasGraphService( repository, repository, + new CanvasProviderResolver(repository, NullLogger.Instance), new StubEncryptionService(), clients, NullLogger.Instance); @@ -118,10 +119,11 @@ private sealed class TestCanvasGraphService : CanvasGraphService public TestCanvasGraphService( ICanvasRepository canvasRepository, ILlmRepository llmRepository, + ICanvasProviderResolver canvasProviderResolver, ILlmEncryptionService encryptionService, IEnumerable providerClients, Microsoft.Extensions.Logging.ILogger logger) - : base(canvasRepository, llmRepository, encryptionService, providerClients, logger) + : base(canvasRepository, llmRepository, canvasProviderResolver, encryptionService, providerClients, logger) { _llmService = new LlmService( canvasRepository, diff --git a/backend/Coliee.API.Tests/Services/CanvasServiceTests.cs b/backend/Coliee.API.Tests/Services/CanvasServiceTests.cs index 96d8948..2bac038 100644 --- a/backend/Coliee.API.Tests/Services/CanvasServiceTests.cs +++ b/backend/Coliee.API.Tests/Services/CanvasServiceTests.cs @@ -9,30 +9,6 @@ namespace Coliee.API.Tests.Services; public class CanvasServiceTests { - [Fact] - public async Task CreateCanvas_TrimsAndGeneratesReadableTitle() - { - var repository = new InMemoryCanvasRepository(); - var service = new CanvasService(repository, NullLogger.Instance); - - var response = await service.CreateCanvasAsync(1, " Plan launch\nchecklist for the Q2 release "); - - response.Title.Should().Be("Plan launch checklist for the Q2 release"); - response.Id.Should().BeGreaterThan(0); - } - - [Fact] - public async Task CreateCanvas_WhitespaceOnlyPrompt_ThrowsBadRequest() - { - var repository = new InMemoryCanvasRepository(); - var service = new CanvasService(repository, NullLogger.Instance); - - var action = async () => await service.CreateCanvasAsync(1, " "); - - var exception = await action.Should().ThrowAsync(); - exception.Which.StatusCode.Should().Be(400); - } - [Fact] public async Task GetCanvases_ReturnsItemsOrderedByMostRecentlyUpdated() { diff --git a/backend/Coliee.API/Controllers/CanvasesController.cs b/backend/Coliee.API/Controllers/CanvasesController.cs index 1c79c90..18b3930 100644 --- a/backend/Coliee.API/Controllers/CanvasesController.cs +++ b/backend/Coliee.API/Controllers/CanvasesController.cs @@ -11,11 +11,13 @@ namespace Coliee.API.Controllers; [Authorize] public class CanvasesController : ControllerBase { + private readonly ICanvasCreationService _canvasCreationService; private readonly ICanvasService _canvasService; private readonly ICanvasGraphService _canvasGraphService; - public CanvasesController(ICanvasService canvasService, ICanvasGraphService canvasGraphService) + public CanvasesController(ICanvasCreationService canvasCreationService, ICanvasService canvasService, ICanvasGraphService canvasGraphService) { + _canvasCreationService = canvasCreationService; _canvasService = canvasService; _canvasGraphService = canvasGraphService; } @@ -34,13 +36,17 @@ public async Task CreateCanvas([FromBody] CreateCanvasRequest req try { - var canvas = await _canvasService.CreateCanvasAsync(GetUserId(), request.FirstPrompt); + var canvas = await _canvasCreationService.CreateCanvasAsync(GetUserId(), request.FirstPrompt); return StatusCode(StatusCodes.Status201Created, canvas); } catch (CanvasOperationException ex) { return StatusCode(ex.StatusCode, new { message = ex.Message }); } + catch (LlmChatException ex) + { + return StatusCode(ex.StatusCode, new { message = ex.Message, code = ex.Code }); + } } [HttpPatch("{canvasId:int}")] diff --git a/backend/Coliee.API/Program.cs b/backend/Coliee.API/Program.cs index 48ea546..6e54ddc 100644 --- a/backend/Coliee.API/Program.cs +++ b/backend/Coliee.API/Program.cs @@ -20,6 +20,8 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/backend/Coliee.API/Services/CanvasCreationService.cs b/backend/Coliee.API/Services/CanvasCreationService.cs new file mode 100644 index 0000000..c264661 --- /dev/null +++ b/backend/Coliee.API/Services/CanvasCreationService.cs @@ -0,0 +1,161 @@ +using System.Globalization; +using System.Text.RegularExpressions; +using Coliee.API.Data; +using Coliee.API.DTOs; +using Microsoft.AspNetCore.Http; + +namespace Coliee.API.Services; + +public partial class CanvasCreationService : ICanvasCreationService +{ + private const int TitleMaxLength = 80; + private readonly ICanvasRepository _canvasRepository; + private readonly ICanvasGraphService _canvasGraphService; + private readonly ICanvasProviderResolver _canvasProviderResolver; + private readonly ILogger _logger; + + public CanvasCreationService( + ICanvasRepository canvasRepository, + ICanvasGraphService canvasGraphService, + ICanvasProviderResolver canvasProviderResolver, + ILogger logger) + { + _canvasRepository = canvasRepository; + _canvasGraphService = canvasGraphService; + _canvasProviderResolver = canvasProviderResolver; + _logger = logger; + } + + public async Task CreateCanvasAsync(int userId, string firstPrompt) + { + var normalizedPrompt = NormalizePrompt(firstPrompt); + if (string.IsNullOrWhiteSpace(normalizedPrompt)) + throw new CanvasOperationException("A first prompt is required to create a canvas.", StatusCodes.Status400BadRequest); + + await _canvasProviderResolver.ResolveAsync(userId, null); + + var title = BuildTitle(normalizedPrompt); + var canvas = await _canvasRepository.CreateCanvasAsync(userId, title); + + try + { + var rootNode = await _canvasRepository.EnsureRootCanvasNodeAsync(userId, canvas.Id); + await _canvasGraphService.ExpandCanvasNodeAsync(userId, canvas.Id, rootNode.Id, normalizedPrompt, null); + + var persistedCanvas = await _canvasRepository.GetCanvasAsync(userId, canvas.Id) ?? canvas; + _logger.LogInformation("Created and seeded canvas {CanvasId} for user {UserId}.", canvas.Id, userId); + return MapCanvas(persistedCanvas); + } + catch (Exception ex) when (ex is CanvasOperationException or LlmChatException) + { + try + { + await _canvasRepository.DeleteCanvasAsync(userId, canvas.Id); + } + catch (Exception rollbackEx) + { + _logger.LogError(rollbackEx, "Failed to rollback canvas {CanvasId} for user {UserId} after seeded create failed.", canvas.Id, userId); + } + + throw; + } + } + + private static CanvasSummaryResponse MapCanvas(Models.Canvas canvas) + { + return new CanvasSummaryResponse + { + Id = canvas.Id, + Title = canvas.Title, + CreatedAt = canvas.CreatedAt, + UpdatedAt = canvas.UpdatedAt + }; + } + + private static string NormalizePrompt(string firstPrompt) + { + return WhitespaceRegex().Replace(firstPrompt.Trim(), " "); + } + + private static string BuildTitle(string normalizedPrompt) + { + var stripped = StripLeadingLowSignalText(normalizedPrompt); + var words = TokenRegex().Matches(stripped).Select(match => match.Value).ToList(); + if (words.Count == 0) + words = TokenRegex().Matches(normalizedPrompt).Select(match => match.Value).ToList(); + + var selectedWords = new List(); + foreach (var word in words) + { + var lower = word.ToLowerInvariant(); + if (selectedWords.Count == 0 && LeadingStopWords().Contains(lower)) + continue; + + if (AlwaysIgnoredWords().Contains(lower)) + continue; + + selectedWords.Add(FormatTitleWord(word)); + if (selectedWords.Count == 6) + break; + } + + if (selectedWords.Count == 0) + return "New Canvas"; + + var title = string.Join(" ", selectedWords).Trim().TrimEnd('.', '!', '?', ',', ';', ':'); + if (title.Length <= TitleMaxLength) + return title; + + var truncated = title[..TitleMaxLength].TrimEnd(); + var splitIndex = truncated.LastIndexOf(' '); + if (splitIndex >= TitleMaxLength / 2) + truncated = truncated[..splitIndex]; + + return truncated.TrimEnd('.', '!', '?', ',', ';', ':'); + } + + private static string StripLeadingLowSignalText(string prompt) + { + var stripped = prompt.Trim(); + foreach (var pattern in LeadingPhrasePatterns()) + stripped = pattern.Replace(stripped, string.Empty, 1).Trim(); + + return stripped; + } + + private static string FormatTitleWord(string word) + { + if (word.All(char.IsUpper)) + return word; + + return CultureInfo.InvariantCulture.TextInfo.ToTitleCase(word.ToLowerInvariant()); + } + + private static HashSet LeadingStopWords() + { + return ["a", "an", "the", "my", "our", "this", "that", "these", "those"]; + } + + private static HashSet AlwaysIgnoredWords() + { + return ["a", "an", "the"]; + } + + private static Regex[] LeadingPhrasePatterns() + { + return + [ + new Regex(@"^(help me|can you|could you|would you|please)\s+", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), + new Regex(@"^(i need to|i want to|i'm trying to|im trying to)\s+", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), + new Regex(@"^(create|make|build|draft|outline|brainstorm|plan|write)\s+(a|an|the)?\s*canvas\s+(for|about|on)\s+", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), + new Regex(@"^(create|make|build|draft|outline|brainstorm|plan|write)\s+", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), + new Regex(@"^(how should we|how do we|what should we|what do we)\s+", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant) + ]; + } + + [GeneratedRegex(@"\s+")] + private static partial Regex WhitespaceRegex(); + + [GeneratedRegex(@"[A-Za-z0-9][A-Za-z0-9'&/-]*")] + private static partial Regex TokenRegex(); +} diff --git a/backend/Coliee.API/Services/CanvasGraphService.cs b/backend/Coliee.API/Services/CanvasGraphService.cs index 4a1c697..7cbb932 100644 --- a/backend/Coliee.API/Services/CanvasGraphService.cs +++ b/backend/Coliee.API/Services/CanvasGraphService.cs @@ -12,6 +12,7 @@ public partial class CanvasGraphService : ICanvasGraphService private const int NodeTitleMaxLength = 60; private readonly ICanvasRepository _canvasRepository; private readonly ILlmRepository _llmRepository; + private readonly ICanvasProviderResolver _canvasProviderResolver; private readonly ILlmEncryptionService _encryptionService; private readonly IReadOnlyDictionary _providerClients; private readonly ILogger _logger; @@ -19,12 +20,14 @@ public partial class CanvasGraphService : ICanvasGraphService public CanvasGraphService( ICanvasRepository canvasRepository, ILlmRepository llmRepository, + ICanvasProviderResolver canvasProviderResolver, ILlmEncryptionService encryptionService, IEnumerable providerClients, ILogger logger) { _canvasRepository = canvasRepository; _llmRepository = llmRepository; + _canvasProviderResolver = canvasProviderResolver; _encryptionService = encryptionService; _providerClients = providerClients.ToDictionary(client => client.Provider, StringComparer.Ordinal); _logger = logger; @@ -50,8 +53,9 @@ public async Task ExpandCanvasNodeAsync(int userId, int can if (string.IsNullOrWhiteSpace(trimmedPrompt)) throw new LlmChatException("Prompt content is required.", LlmProviderErrorCodes.ProviderError, StatusCodes.Status400BadRequest); - var providers = await _llmRepository.GetProviderKeysAsync(userId); - var selectedProvider = ResolveProvider(providers, providerOverride); + var resolution = await _canvasProviderResolver.ResolveAsync(userId, providerOverride); + var providers = resolution.Providers; + var selectedProvider = resolution.Provider; var providerRecord = providers.First(item => item.Provider == selectedProvider); var decryptedApiKey = _encryptionService.Decrypt(providerRecord.EncryptedApiKey); @@ -131,35 +135,6 @@ private ILlmProviderClient GetProviderClient(string provider) throw new InvalidOperationException($"No provider client is registered for '{provider}'."); } - private string ResolveProvider(IReadOnlyList providers, string? providerOverride) - { - if (providers.Count == 0) - { - _logger.LogWarning("Rejected graph node expansion because no provider keys are configured."); - throw new LlmChatException( - "Add a provider API key in Settings before sending a message.", - LlmProviderErrorCodes.MissingKey, - StatusCodes.Status400BadRequest); - } - - if (!string.IsNullOrWhiteSpace(providerOverride)) - { - var normalizedOverride = NormalizeProvider(providerOverride); - if (!providers.Any(item => item.Provider == normalizedOverride)) - { - throw new LlmChatException( - "That provider is not configured yet.", - LlmProviderErrorCodes.MissingKey, - StatusCodes.Status400BadRequest); - } - - return normalizedOverride; - } - - return providers.FirstOrDefault(item => item.IsDefault)?.Provider - ?? providers.First().Provider; - } - private static IReadOnlyList BuildProviderMessages( IReadOnlyList allNodes, CanvasNode selectedNode, diff --git a/backend/Coliee.API/Services/CanvasProviderResolution.cs b/backend/Coliee.API/Services/CanvasProviderResolution.cs new file mode 100644 index 0000000..49cbd81 --- /dev/null +++ b/backend/Coliee.API/Services/CanvasProviderResolution.cs @@ -0,0 +1,7 @@ +using Coliee.API.Models; + +namespace Coliee.API.Services; + +public sealed record CanvasProviderResolution( + string Provider, + IReadOnlyList Providers); diff --git a/backend/Coliee.API/Services/CanvasProviderResolver.cs b/backend/Coliee.API/Services/CanvasProviderResolver.cs new file mode 100644 index 0000000..8444c2b --- /dev/null +++ b/backend/Coliee.API/Services/CanvasProviderResolver.cs @@ -0,0 +1,47 @@ +using Coliee.API.Data; +using Microsoft.AspNetCore.Http; + +namespace Coliee.API.Services; + +public class CanvasProviderResolver : ICanvasProviderResolver +{ + private readonly ILlmRepository _llmRepository; + private readonly ILogger _logger; + + public CanvasProviderResolver(ILlmRepository llmRepository, ILogger logger) + { + _llmRepository = llmRepository; + _logger = logger; + } + + public async Task ResolveAsync(int userId, string? providerOverride) + { + var providers = await _llmRepository.GetProviderKeysAsync(userId); + if (providers.Count == 0) + { + _logger.LogWarning("Rejected canvas generation because no provider keys are configured for user {UserId}.", userId); + throw new LlmChatException( + "Add a provider API key in Settings before creating a canvas.", + LlmProviderErrorCodes.MissingKey, + StatusCodes.Status400BadRequest); + } + + if (!string.IsNullOrWhiteSpace(providerOverride)) + { + var normalizedOverride = providerOverride.Trim().ToLowerInvariant(); + if (!providers.Any(item => item.Provider == normalizedOverride)) + { + throw new LlmChatException( + "That provider is not configured yet.", + LlmProviderErrorCodes.MissingKey, + StatusCodes.Status400BadRequest); + } + + return new CanvasProviderResolution(normalizedOverride, providers); + } + + return new CanvasProviderResolution( + providers.FirstOrDefault(item => item.IsDefault)?.Provider ?? providers.First().Provider, + providers); + } +} diff --git a/backend/Coliee.API/Services/CanvasService.cs b/backend/Coliee.API/Services/CanvasService.cs index 456aaaa..e8d33dd 100644 --- a/backend/Coliee.API/Services/CanvasService.cs +++ b/backend/Coliee.API/Services/CanvasService.cs @@ -1,8 +1,6 @@ using System.Text.RegularExpressions; using Coliee.API.Data; using Coliee.API.DTOs; -using Microsoft.AspNetCore.Http; - namespace Coliee.API.Services; public partial class CanvasService : ICanvasService @@ -17,19 +15,6 @@ public CanvasService(ICanvasRepository canvasRepository, ILogger _logger = logger; } - public async Task CreateCanvasAsync(int userId, string firstPrompt) - { - var normalizedPrompt = NormalizePrompt(firstPrompt); - if (string.IsNullOrWhiteSpace(normalizedPrompt)) - throw new CanvasOperationException("A first prompt is required to create a canvas.", StatusCodes.Status400BadRequest); - - var title = BuildTitle(normalizedPrompt); - var canvas = await _canvasRepository.CreateCanvasAsync(userId, title); - _logger.LogInformation("Created canvas {CanvasId} for user {UserId}.", canvas.Id, userId); - - return MapCanvas(canvas); - } - public async Task> GetCanvasesAsync(int userId) { var canvases = await _canvasRepository.GetCanvasesAsync(userId); @@ -70,11 +55,6 @@ private static CanvasSummaryResponse MapCanvas(Models.Canvas canvas) }; } - private static string NormalizePrompt(string firstPrompt) - { - return WhitespaceRegex().Replace(firstPrompt.Trim(), " "); - } - private static string NormalizeTitle(string title) { return WhitespaceRegex().Replace(title.Trim(), " "); @@ -93,19 +73,6 @@ private async Task EnsureCanvasAccessAsync(int userId, int canvasId) throw new CanvasOperationException("You do not have access to that canvas.", StatusCodes.Status403Forbidden); } - private static string BuildTitle(string normalizedPrompt) - { - if (normalizedPrompt.Length <= TitleMaxLength) - return normalizedPrompt; - - var truncated = normalizedPrompt[..TitleMaxLength].TrimEnd(); - var splitIndex = truncated.LastIndexOf(' '); - if (splitIndex >= TitleMaxLength / 2) - truncated = truncated[..splitIndex]; - - return $"{truncated.TrimEnd()}..."; - } - [GeneratedRegex(@"\s+")] private static partial Regex WhitespaceRegex(); } diff --git a/backend/Coliee.API/Services/ICanvasCreationService.cs b/backend/Coliee.API/Services/ICanvasCreationService.cs new file mode 100644 index 0000000..2dde67b --- /dev/null +++ b/backend/Coliee.API/Services/ICanvasCreationService.cs @@ -0,0 +1,8 @@ +using Coliee.API.DTOs; + +namespace Coliee.API.Services; + +public interface ICanvasCreationService +{ + Task CreateCanvasAsync(int userId, string firstPrompt); +} diff --git a/backend/Coliee.API/Services/ICanvasProviderResolver.cs b/backend/Coliee.API/Services/ICanvasProviderResolver.cs new file mode 100644 index 0000000..8d743f6 --- /dev/null +++ b/backend/Coliee.API/Services/ICanvasProviderResolver.cs @@ -0,0 +1,6 @@ +namespace Coliee.API.Services; + +public interface ICanvasProviderResolver +{ + Task ResolveAsync(int userId, string? providerOverride); +} diff --git a/backend/Coliee.API/Services/ICanvasService.cs b/backend/Coliee.API/Services/ICanvasService.cs index 8d0ff9d..8aee3d1 100644 --- a/backend/Coliee.API/Services/ICanvasService.cs +++ b/backend/Coliee.API/Services/ICanvasService.cs @@ -4,7 +4,6 @@ namespace Coliee.API.Services; public interface ICanvasService { - Task CreateCanvasAsync(int userId, string firstPrompt); Task> GetCanvasesAsync(int userId); Task RenameCanvasAsync(int userId, int canvasId, string title); Task DeleteCanvasAsync(int userId, int canvasId); diff --git a/backend/Coliee.API/obj/Coliee.API.csproj.nuget.dgspec.json b/backend/Coliee.API/obj/Coliee.API.csproj.nuget.dgspec.json index dde00fd..1130f0d 100644 --- a/backend/Coliee.API/obj/Coliee.API.csproj.nuget.dgspec.json +++ b/backend/Coliee.API/obj/Coliee.API.csproj.nuget.dgspec.json @@ -1,20 +1,20 @@ { "format": 1, "restore": { - "/Users/chaturnakasturiratna/Documents/GitHub/Coliee/backend/Coliee.API/Coliee.API.csproj": {} + "/app/Coliee.API.csproj": {} }, "projects": { - "/Users/chaturnakasturiratna/Documents/GitHub/Coliee/backend/Coliee.API/Coliee.API.csproj": { + "/app/Coliee.API.csproj": { "version": "1.0.0", "restore": { - "projectUniqueName": "/Users/chaturnakasturiratna/Documents/GitHub/Coliee/backend/Coliee.API/Coliee.API.csproj", + "projectUniqueName": "/app/Coliee.API.csproj", "projectName": "Coliee.API", - "projectPath": "/Users/chaturnakasturiratna/Documents/GitHub/Coliee/backend/Coliee.API/Coliee.API.csproj", - "packagesPath": "/Users/chaturnakasturiratna/.nuget/packages/", - "outputPath": "/Users/chaturnakasturiratna/Documents/GitHub/Coliee/backend/Coliee.API/obj/", + "projectPath": "/app/Coliee.API.csproj", + "packagesPath": "/root/.nuget/packages/", + "outputPath": "/app/obj/", "projectStyle": "PackageReference", "configFilePaths": [ - "/Users/chaturnakasturiratna/.nuget/NuGet/NuGet.Config" + "/root/.nuget/NuGet/NuGet.Config" ], "originalTargetFrameworks": [ "net8.0" @@ -37,8 +37,7 @@ "enableAudit": "true", "auditLevel": "low", "auditMode": "direct" - }, - "SdkAnalysisLevel": "10.0.200" + } }, "frameworks": { "net8.0": { @@ -88,20 +87,6 @@ ], "assetTargetFallback": true, "warn": true, - "downloadDependencies": [ - { - "name": "Microsoft.AspNetCore.App.Ref", - "version": "[8.0.25, 8.0.25]" - }, - { - "name": "Microsoft.NETCore.App.Host.osx-arm64", - "version": "[8.0.25, 8.0.25]" - }, - { - "name": "Microsoft.NETCore.App.Ref", - "version": "[8.0.25, 8.0.25]" - } - ], "frameworkReferences": { "Microsoft.AspNetCore.App": { "privateAssets": "none" @@ -110,7 +95,7 @@ "privateAssets": "all" } }, - "runtimeIdentifierGraphPath": "/usr/local/share/dotnet/sdk/10.0.201/PortableRuntimeIdentifierGraph.json" + "runtimeIdentifierGraphPath": "/usr/share/dotnet/sdk/8.0.419/PortableRuntimeIdentifierGraph.json" } } } diff --git a/backend/Coliee.API/obj/Coliee.API.csproj.nuget.g.props b/backend/Coliee.API/obj/Coliee.API.csproj.nuget.g.props index 4aaffb6..3c2a381 100644 --- a/backend/Coliee.API/obj/Coliee.API.csproj.nuget.g.props +++ b/backend/Coliee.API/obj/Coliee.API.csproj.nuget.g.props @@ -4,19 +4,19 @@ True NuGet $(MSBuildThisFileDirectory)project.assets.json - /Users/chaturnakasturiratna/.nuget/packages/ - /Users/chaturnakasturiratna/.nuget/packages/ + /root/.nuget/packages/ + /root/.nuget/packages/ PackageReference - 7.0.0 + 6.11.1 - + - /Users/chaturnakasturiratna/.nuget/packages/microsoft.extensions.apidescription.server/6.0.5 + /root/.nuget/packages/microsoft.extensions.apidescription.server/6.0.5 \ No newline at end of file diff --git a/backend/Coliee.API/obj/project.assets.json b/backend/Coliee.API/obj/project.assets.json index 14c86d8..7cca932 100644 --- a/backend/Coliee.API/obj/project.assets.json +++ b/backend/Coliee.API/obj/project.assets.json @@ -2017,19 +2017,19 @@ ] }, "packageFolders": { - "/Users/chaturnakasturiratna/.nuget/packages/": {} + "/root/.nuget/packages/": {} }, "project": { "version": "1.0.0", "restore": { - "projectUniqueName": "/Users/chaturnakasturiratna/Documents/GitHub/Coliee/backend/Coliee.API/Coliee.API.csproj", + "projectUniqueName": "/app/Coliee.API.csproj", "projectName": "Coliee.API", - "projectPath": "/Users/chaturnakasturiratna/Documents/GitHub/Coliee/backend/Coliee.API/Coliee.API.csproj", - "packagesPath": "/Users/chaturnakasturiratna/.nuget/packages/", - "outputPath": "/Users/chaturnakasturiratna/Documents/GitHub/Coliee/backend/Coliee.API/obj/", + "projectPath": "/app/Coliee.API.csproj", + "packagesPath": "/root/.nuget/packages/", + "outputPath": "/app/obj/", "projectStyle": "PackageReference", "configFilePaths": [ - "/Users/chaturnakasturiratna/.nuget/NuGet/NuGet.Config" + "/root/.nuget/NuGet/NuGet.Config" ], "originalTargetFrameworks": [ "net8.0" @@ -2052,8 +2052,7 @@ "enableAudit": "true", "auditLevel": "low", "auditMode": "direct" - }, - "SdkAnalysisLevel": "10.0.200" + } }, "frameworks": { "net8.0": { @@ -2103,20 +2102,6 @@ ], "assetTargetFallback": true, "warn": true, - "downloadDependencies": [ - { - "name": "Microsoft.AspNetCore.App.Ref", - "version": "[8.0.25, 8.0.25]" - }, - { - "name": "Microsoft.NETCore.App.Host.osx-arm64", - "version": "[8.0.25, 8.0.25]" - }, - { - "name": "Microsoft.NETCore.App.Ref", - "version": "[8.0.25, 8.0.25]" - } - ], "frameworkReferences": { "Microsoft.AspNetCore.App": { "privateAssets": "none" @@ -2125,7 +2110,7 @@ "privateAssets": "all" } }, - "runtimeIdentifierGraphPath": "/usr/local/share/dotnet/sdk/10.0.201/PortableRuntimeIdentifierGraph.json" + "runtimeIdentifierGraphPath": "/usr/share/dotnet/sdk/8.0.419/PortableRuntimeIdentifierGraph.json" } } } diff --git a/backend/Coliee.API/obj/project.nuget.cache b/backend/Coliee.API/obj/project.nuget.cache index 12304d9..755c2fd 100644 --- a/backend/Coliee.API/obj/project.nuget.cache +++ b/backend/Coliee.API/obj/project.nuget.cache @@ -1,53 +1,50 @@ { "version": 2, - "dgSpecHash": "zaS2aLFguuY=", + "dgSpecHash": "16Q8mMrvyFM=", "success": true, - "projectFilePath": "/Users/chaturnakasturiratna/Documents/GitHub/Coliee/backend/Coliee.API/Coliee.API.csproj", + "projectFilePath": "/app/Coliee.API.csproj", "expectedPackageFiles": [ - "/Users/chaturnakasturiratna/.nuget/packages/bcrypt.net-next/4.1.0/bcrypt.net-next.4.1.0.nupkg.sha512", - "/Users/chaturnakasturiratna/.nuget/packages/bouncycastle.cryptography/2.6.2/bouncycastle.cryptography.2.6.2.nupkg.sha512", - "/Users/chaturnakasturiratna/.nuget/packages/google.apis/1.68.0/google.apis.1.68.0.nupkg.sha512", - "/Users/chaturnakasturiratna/.nuget/packages/google.apis.auth/1.68.0/google.apis.auth.1.68.0.nupkg.sha512", - "/Users/chaturnakasturiratna/.nuget/packages/google.apis.core/1.68.0/google.apis.core.1.68.0.nupkg.sha512", - "/Users/chaturnakasturiratna/.nuget/packages/google.protobuf/3.32.0/google.protobuf.3.32.0.nupkg.sha512", - "/Users/chaturnakasturiratna/.nuget/packages/k4os.compression.lz4/1.3.8/k4os.compression.lz4.1.3.8.nupkg.sha512", - "/Users/chaturnakasturiratna/.nuget/packages/k4os.compression.lz4.streams/1.3.8/k4os.compression.lz4.streams.1.3.8.nupkg.sha512", - "/Users/chaturnakasturiratna/.nuget/packages/k4os.hash.xxhash/1.0.8/k4os.hash.xxhash.1.0.8.nupkg.sha512", - "/Users/chaturnakasturiratna/.nuget/packages/mailkit/4.15.0/mailkit.4.15.0.nupkg.sha512", - "/Users/chaturnakasturiratna/.nuget/packages/microsoft.aspnetcore.authentication.jwtbearer/8.0.0/microsoft.aspnetcore.authentication.jwtbearer.8.0.0.nupkg.sha512", - "/Users/chaturnakasturiratna/.nuget/packages/microsoft.aspnetcore.openapi/8.0.24/microsoft.aspnetcore.openapi.8.0.24.nupkg.sha512", - "/Users/chaturnakasturiratna/.nuget/packages/microsoft.extensions.apidescription.server/6.0.5/microsoft.extensions.apidescription.server.6.0.5.nupkg.sha512", - "/Users/chaturnakasturiratna/.nuget/packages/microsoft.extensions.dependencyinjection.abstractions/8.0.0/microsoft.extensions.dependencyinjection.abstractions.8.0.0.nupkg.sha512", - "/Users/chaturnakasturiratna/.nuget/packages/microsoft.extensions.logging.abstractions/8.0.0/microsoft.extensions.logging.abstractions.8.0.0.nupkg.sha512", - "/Users/chaturnakasturiratna/.nuget/packages/microsoft.identitymodel.abstractions/8.16.0/microsoft.identitymodel.abstractions.8.16.0.nupkg.sha512", - "/Users/chaturnakasturiratna/.nuget/packages/microsoft.identitymodel.jsonwebtokens/8.16.0/microsoft.identitymodel.jsonwebtokens.8.16.0.nupkg.sha512", - "/Users/chaturnakasturiratna/.nuget/packages/microsoft.identitymodel.logging/8.16.0/microsoft.identitymodel.logging.8.16.0.nupkg.sha512", - "/Users/chaturnakasturiratna/.nuget/packages/microsoft.identitymodel.protocols/7.0.3/microsoft.identitymodel.protocols.7.0.3.nupkg.sha512", - "/Users/chaturnakasturiratna/.nuget/packages/microsoft.identitymodel.protocols.openidconnect/7.0.3/microsoft.identitymodel.protocols.openidconnect.7.0.3.nupkg.sha512", - "/Users/chaturnakasturiratna/.nuget/packages/microsoft.identitymodel.tokens/8.16.0/microsoft.identitymodel.tokens.8.16.0.nupkg.sha512", - "/Users/chaturnakasturiratna/.nuget/packages/microsoft.openapi/1.6.14/microsoft.openapi.1.6.14.nupkg.sha512", - "/Users/chaturnakasturiratna/.nuget/packages/mimekit/4.15.0/mimekit.4.15.0.nupkg.sha512", - "/Users/chaturnakasturiratna/.nuget/packages/mysql.data/9.6.0/mysql.data.9.6.0.nupkg.sha512", - "/Users/chaturnakasturiratna/.nuget/packages/newtonsoft.json/13.0.3/newtonsoft.json.13.0.3.nupkg.sha512", - "/Users/chaturnakasturiratna/.nuget/packages/swashbuckle.aspnetcore/6.6.2/swashbuckle.aspnetcore.6.6.2.nupkg.sha512", - "/Users/chaturnakasturiratna/.nuget/packages/swashbuckle.aspnetcore.swagger/6.6.2/swashbuckle.aspnetcore.swagger.6.6.2.nupkg.sha512", - "/Users/chaturnakasturiratna/.nuget/packages/swashbuckle.aspnetcore.swaggergen/6.6.2/swashbuckle.aspnetcore.swaggergen.6.6.2.nupkg.sha512", - "/Users/chaturnakasturiratna/.nuget/packages/swashbuckle.aspnetcore.swaggerui/6.6.2/swashbuckle.aspnetcore.swaggerui.6.6.2.nupkg.sha512", - "/Users/chaturnakasturiratna/.nuget/packages/system.codedom/7.0.0/system.codedom.7.0.0.nupkg.sha512", - "/Users/chaturnakasturiratna/.nuget/packages/system.configuration.configurationmanager/8.0.0/system.configuration.configurationmanager.8.0.0.nupkg.sha512", - "/Users/chaturnakasturiratna/.nuget/packages/system.diagnostics.eventlog/8.0.0/system.diagnostics.eventlog.8.0.0.nupkg.sha512", - "/Users/chaturnakasturiratna/.nuget/packages/system.formats.asn1/8.0.1/system.formats.asn1.8.0.1.nupkg.sha512", - "/Users/chaturnakasturiratna/.nuget/packages/system.identitymodel.tokens.jwt/8.16.0/system.identitymodel.tokens.jwt.8.16.0.nupkg.sha512", - "/Users/chaturnakasturiratna/.nuget/packages/system.io.pipelines/6.0.3/system.io.pipelines.6.0.3.nupkg.sha512", - "/Users/chaturnakasturiratna/.nuget/packages/system.management/7.0.2/system.management.7.0.2.nupkg.sha512", - "/Users/chaturnakasturiratna/.nuget/packages/system.security.cryptography.pkcs/8.0.1/system.security.cryptography.pkcs.8.0.1.nupkg.sha512", - "/Users/chaturnakasturiratna/.nuget/packages/system.security.cryptography.protecteddata/8.0.0/system.security.cryptography.protecteddata.8.0.0.nupkg.sha512", - "/Users/chaturnakasturiratna/.nuget/packages/system.security.permissions/8.0.0/system.security.permissions.8.0.0.nupkg.sha512", - "/Users/chaturnakasturiratna/.nuget/packages/system.windows.extensions/8.0.0/system.windows.extensions.8.0.0.nupkg.sha512", - "/Users/chaturnakasturiratna/.nuget/packages/zstdsharp.port/0.8.6/zstdsharp.port.0.8.6.nupkg.sha512", - "/Users/chaturnakasturiratna/.nuget/packages/microsoft.netcore.app.ref/8.0.25/microsoft.netcore.app.ref.8.0.25.nupkg.sha512", - "/Users/chaturnakasturiratna/.nuget/packages/microsoft.aspnetcore.app.ref/8.0.25/microsoft.aspnetcore.app.ref.8.0.25.nupkg.sha512", - "/Users/chaturnakasturiratna/.nuget/packages/microsoft.netcore.app.host.osx-arm64/8.0.25/microsoft.netcore.app.host.osx-arm64.8.0.25.nupkg.sha512" + "/root/.nuget/packages/bcrypt.net-next/4.1.0/bcrypt.net-next.4.1.0.nupkg.sha512", + "/root/.nuget/packages/bouncycastle.cryptography/2.6.2/bouncycastle.cryptography.2.6.2.nupkg.sha512", + "/root/.nuget/packages/google.apis/1.68.0/google.apis.1.68.0.nupkg.sha512", + "/root/.nuget/packages/google.apis.auth/1.68.0/google.apis.auth.1.68.0.nupkg.sha512", + "/root/.nuget/packages/google.apis.core/1.68.0/google.apis.core.1.68.0.nupkg.sha512", + "/root/.nuget/packages/google.protobuf/3.32.0/google.protobuf.3.32.0.nupkg.sha512", + "/root/.nuget/packages/k4os.compression.lz4/1.3.8/k4os.compression.lz4.1.3.8.nupkg.sha512", + "/root/.nuget/packages/k4os.compression.lz4.streams/1.3.8/k4os.compression.lz4.streams.1.3.8.nupkg.sha512", + "/root/.nuget/packages/k4os.hash.xxhash/1.0.8/k4os.hash.xxhash.1.0.8.nupkg.sha512", + "/root/.nuget/packages/mailkit/4.15.0/mailkit.4.15.0.nupkg.sha512", + "/root/.nuget/packages/microsoft.aspnetcore.authentication.jwtbearer/8.0.0/microsoft.aspnetcore.authentication.jwtbearer.8.0.0.nupkg.sha512", + "/root/.nuget/packages/microsoft.aspnetcore.openapi/8.0.24/microsoft.aspnetcore.openapi.8.0.24.nupkg.sha512", + "/root/.nuget/packages/microsoft.extensions.apidescription.server/6.0.5/microsoft.extensions.apidescription.server.6.0.5.nupkg.sha512", + "/root/.nuget/packages/microsoft.extensions.dependencyinjection.abstractions/8.0.0/microsoft.extensions.dependencyinjection.abstractions.8.0.0.nupkg.sha512", + "/root/.nuget/packages/microsoft.extensions.logging.abstractions/8.0.0/microsoft.extensions.logging.abstractions.8.0.0.nupkg.sha512", + "/root/.nuget/packages/microsoft.identitymodel.abstractions/8.16.0/microsoft.identitymodel.abstractions.8.16.0.nupkg.sha512", + "/root/.nuget/packages/microsoft.identitymodel.jsonwebtokens/8.16.0/microsoft.identitymodel.jsonwebtokens.8.16.0.nupkg.sha512", + "/root/.nuget/packages/microsoft.identitymodel.logging/8.16.0/microsoft.identitymodel.logging.8.16.0.nupkg.sha512", + "/root/.nuget/packages/microsoft.identitymodel.protocols/7.0.3/microsoft.identitymodel.protocols.7.0.3.nupkg.sha512", + "/root/.nuget/packages/microsoft.identitymodel.protocols.openidconnect/7.0.3/microsoft.identitymodel.protocols.openidconnect.7.0.3.nupkg.sha512", + "/root/.nuget/packages/microsoft.identitymodel.tokens/8.16.0/microsoft.identitymodel.tokens.8.16.0.nupkg.sha512", + "/root/.nuget/packages/microsoft.openapi/1.6.14/microsoft.openapi.1.6.14.nupkg.sha512", + "/root/.nuget/packages/mimekit/4.15.0/mimekit.4.15.0.nupkg.sha512", + "/root/.nuget/packages/mysql.data/9.6.0/mysql.data.9.6.0.nupkg.sha512", + "/root/.nuget/packages/newtonsoft.json/13.0.3/newtonsoft.json.13.0.3.nupkg.sha512", + "/root/.nuget/packages/swashbuckle.aspnetcore/6.6.2/swashbuckle.aspnetcore.6.6.2.nupkg.sha512", + "/root/.nuget/packages/swashbuckle.aspnetcore.swagger/6.6.2/swashbuckle.aspnetcore.swagger.6.6.2.nupkg.sha512", + "/root/.nuget/packages/swashbuckle.aspnetcore.swaggergen/6.6.2/swashbuckle.aspnetcore.swaggergen.6.6.2.nupkg.sha512", + "/root/.nuget/packages/swashbuckle.aspnetcore.swaggerui/6.6.2/swashbuckle.aspnetcore.swaggerui.6.6.2.nupkg.sha512", + "/root/.nuget/packages/system.codedom/7.0.0/system.codedom.7.0.0.nupkg.sha512", + "/root/.nuget/packages/system.configuration.configurationmanager/8.0.0/system.configuration.configurationmanager.8.0.0.nupkg.sha512", + "/root/.nuget/packages/system.diagnostics.eventlog/8.0.0/system.diagnostics.eventlog.8.0.0.nupkg.sha512", + "/root/.nuget/packages/system.formats.asn1/8.0.1/system.formats.asn1.8.0.1.nupkg.sha512", + "/root/.nuget/packages/system.identitymodel.tokens.jwt/8.16.0/system.identitymodel.tokens.jwt.8.16.0.nupkg.sha512", + "/root/.nuget/packages/system.io.pipelines/6.0.3/system.io.pipelines.6.0.3.nupkg.sha512", + "/root/.nuget/packages/system.management/7.0.2/system.management.7.0.2.nupkg.sha512", + "/root/.nuget/packages/system.security.cryptography.pkcs/8.0.1/system.security.cryptography.pkcs.8.0.1.nupkg.sha512", + "/root/.nuget/packages/system.security.cryptography.protecteddata/8.0.0/system.security.cryptography.protecteddata.8.0.0.nupkg.sha512", + "/root/.nuget/packages/system.security.permissions/8.0.0/system.security.permissions.8.0.0.nupkg.sha512", + "/root/.nuget/packages/system.windows.extensions/8.0.0/system.windows.extensions.8.0.0.nupkg.sha512", + "/root/.nuget/packages/zstdsharp.port/0.8.6/zstdsharp.port.0.8.6.nupkg.sha512" ], "logs": [] } \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index e734f8f..ff8c109 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -54,7 +54,7 @@ services: - "3000:5173" volumes: - ./frontend:/app - - /app/node_modules + - frontend-node-modules:/app/node_modules environment: - CHOKIDAR_USEPOLLING=true - API_URL=http://backend:8080 @@ -64,3 +64,4 @@ services: volumes: mysql-data: + frontend-node-modules: diff --git a/frontend/frontend_test_result.txt b/frontend/frontend_test_result.txt deleted file mode 100644 index 777366dc232d767fb60c2caea4e00e9c3502262c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3096 zcmcJRTW`}q5QXO%iT_|#2&IB1y@QOnRBZ}HgiBH3fkKnIZV0(53D63apALLyH;$c< zhNOV3wbwJdXJ^jN&g{=0*X^1;u)t2OYi(;;-(ub;_Q;kkKWKrI_IM|wzgTT33z+$t zIe|R`p^BzuFRel^20?h5sb0^{7`4Qc%uv6@tR=J`k;cqBVO%mQwhr2^L+RRmbTO7z zSg(Oh)~E?Vnmp}{7Fb9-#gYbpp%vV-?Rp2G#~}8QMZD{vslNk81a1?pSdi>72*U5% z6Ew$2wi(qx8uJu> z4fdgg{ zhc4k!>!iLUtS&HGh*wAQtOR(XC$a9k{hvqp^CN3kFnGk>HasbYtFYF!bK8TZQ~Jr6 z>{n6u99`dD6SpyYQFBokv)|I?Xes{m`1Bl2kAaYN`dFnCSG7fFQ;Pr2-rH?t`&goA zlov(LN5r?oUkzC+V^82>W!J@Q15aPktGMWrrShT3T8f@7ES!Ms{S`ko;yZSyqsdwo zaQ*14zka5-FSRnupYn{dK-<28u9(($+qP)w+BqxR0opoEBb9mD8ISy$hm5*m-_R<~ zl^b*t#goHER|(;rm`#vf40gpJ5yq5 z(^Zww@v~xUQ<>_(kt(Pv?c~_WGICj+l_#UM_G*k=#)j}!VbfYWA0r!7*$tRkvDJxe zOwZq{pz}n5kDGsgzldB0_tnnZ)?;kocxH8(u5p{qKoDP3f$ z69qEQ=w9gN(;Sl@af=bI?lFq95Oy~v@~eB`81 t{W)3dDmMEJrd(Qa_=??SS~z4_QKlP;zbz{s0xI_^dy#i>t{cC``~vDK`=0;+ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index cd07610..62cee1d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,7 +1,5 @@ import { Routes, Route, Navigate } from 'react-router-dom' import WelcomePage from './pages/WelcomePage' -import LoginPage from './pages/LoginPage' -import RegisterPage from './pages/RegisterPage' import VerifyEmailPage from './pages/VerifyEmailPage' import ForgotPasswordPage from './pages/ForgotPasswordPage' import VerifyOtpPage from './pages/VerifyOtpPage' @@ -20,10 +18,10 @@ function App() { } /> + } /> + } /> diff --git a/frontend/src/components/PublicRoute.test.tsx b/frontend/src/components/PublicRoute.test.tsx new file mode 100644 index 0000000..085f1f5 --- /dev/null +++ b/frontend/src/components/PublicRoute.test.tsx @@ -0,0 +1,57 @@ +import { render, screen } from '@testing-library/react' +import { MemoryRouter, Route, Routes } from 'react-router-dom' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import PublicRoute from './PublicRoute' + +const mockUseAuth = vi.fn() + +vi.mock('../context/AuthContext', async () => { + const actual = await vi.importActual('../context/AuthContext') + + return { + ...actual, + useAuth: () => mockUseAuth(), + } +}) + +function renderPublicRoute(initialEntry = '/') { + return render( + + + +
Public landing
+ + } + /> + Home route} /> +
+
+ ) +} + +describe('PublicRoute', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('PublicRoute_LoggedOutUser_CanAccessPublicPage', () => { + mockUseAuth.mockReturnValue({ isAuthenticated: false }) + + renderPublicRoute() + + expect(screen.getByText('Public landing')).toBeInTheDocument() + expect(screen.queryByText('Home route')).not.toBeInTheDocument() + }) + + it('PublicRoute_AuthenticatedUser_RedirectsToHome', () => { + mockUseAuth.mockReturnValue({ isAuthenticated: true }) + + renderPublicRoute() + + expect(screen.getByText('Home route')).toBeInTheDocument() + expect(screen.queryByText('Public landing')).not.toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/auth/AuthCard.tsx b/frontend/src/components/auth/AuthCard.tsx new file mode 100644 index 0000000..85842b5 --- /dev/null +++ b/frontend/src/components/auth/AuthCard.tsx @@ -0,0 +1,14 @@ +import type { ReactNode } from 'react' + +interface AuthCardProps { + children: ReactNode + className?: string +} + +export default function AuthCard({ children, className = '' }: AuthCardProps) { + return ( +
+ {children} +
+ ) +} diff --git a/frontend/src/components/auth/AuthIcons.tsx b/frontend/src/components/auth/AuthIcons.tsx new file mode 100644 index 0000000..1cb40e8 --- /dev/null +++ b/frontend/src/components/auth/AuthIcons.tsx @@ -0,0 +1,24 @@ +export function Eye() { + return ( + + + + ) +} + +export function EyeOff() { + return ( + + + + + + + ) +} + +export function RabbitSVG() { + return ( + C + ) +} diff --git a/frontend/src/components/auth/AuthModal.tsx b/frontend/src/components/auth/AuthModal.tsx new file mode 100644 index 0000000..e9f34ac --- /dev/null +++ b/frontend/src/components/auth/AuthModal.tsx @@ -0,0 +1,67 @@ +import { useEffect } from 'react' +import { useNavigate } from 'react-router-dom' +import AuthCard from './AuthCard' +import LoginForm from './LoginForm' +import RegisterForm from './RegisterForm' + +export type AuthModalMode = 'login' | 'register' + +interface AuthModalProps { + mode: AuthModalMode + onClose: () => void + onChangeMode: (mode: AuthModalMode) => void +} + +export default function AuthModal({ mode, onClose, onChangeMode }: AuthModalProps) { + const navigate = useNavigate() + + useEffect(() => { + function handleKeyDown(event: KeyboardEvent) { + if (event.key === 'Escape') onClose() + } + + document.body.classList.add('auth-modal-open') + window.addEventListener('keydown', handleKeyDown) + return () => { + document.body.classList.remove('auth-modal-open') + window.removeEventListener('keydown', handleKeyDown) + } + }, [onClose]) + + return ( +
+ +
event.stopPropagation()}> +
+ Coliee AI + +
+ + {mode === 'login' ? ( + onChangeMode('register')} + onForgotPassword={() => { + onClose() + navigate('/forgot-password') + }} + onSuccess={onClose} + /> + ) : ( + onChangeMode('login')} + onSuccess={onClose} + /> + )} +
+
+
+ ) +} diff --git a/frontend/src/components/auth/LoginForm.tsx b/frontend/src/components/auth/LoginForm.tsx new file mode 100644 index 0000000..fd1d029 --- /dev/null +++ b/frontend/src/components/auth/LoginForm.tsx @@ -0,0 +1,188 @@ +import { useState, type FormEvent } from 'react' +import { AxiosError } from 'axios' +import { GoogleLogin, type CredentialResponse } from '@react-oauth/google' +import { useNavigate } from 'react-router-dom' +import { login, loginWithGoogle, getEmailVerificationRequired, getErrorMessage } from '../../services/authService' +import { useAuth } from '../../context/AuthContext' +import { Eye, EyeOff } from './AuthIcons' + +interface FieldErrors { + email?: string + password?: string +} + +interface LockoutData { + message: string + lockedUntil: string +} + +interface LoginFormProps { + onSwitchToRegister: () => void + onForgotPassword?: () => void + onSuccess?: () => void +} + +export default function LoginForm({ onSwitchToRegister, onForgotPassword, onSuccess }: LoginFormProps) { + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [showPw, setShowPw] = useState(false) + const [fieldErrors, setFieldErrors] = useState({}) + const [apiError, setApiError] = useState('') + const [lockedUntil, setLockedUntil] = useState(null) + const [loading, setLoading] = useState(false) + + const navigate = useNavigate() + const { login: authLogin } = useAuth() + + function validate(): boolean { + const errors: FieldErrors = {} + if (!email.trim()) errors.email = 'Email is required' + else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim())) errors.email = 'Enter a valid email address' + if (!password) errors.password = 'Password is required' + setFieldErrors(errors) + return !Object.keys(errors).length + } + + async function handleGoogleSuccess(credentialResponse: CredentialResponse) { + setApiError('') + setLockedUntil(null) + if (!credentialResponse.credential) return + + setLoading(true) + try { + const data = await loginWithGoogle(credentialResponse.credential) + authLogin(data.token, data.refreshToken) + onSuccess?.() + navigate('/') + } catch (err: unknown) { + setApiError(getErrorMessage(err)) + } finally { + setLoading(false) + } + } + + async function handleSubmit(ev: FormEvent) { + ev.preventDefault() + setApiError('') + setLockedUntil(null) + if (!validate()) return + + setLoading(true) + try { + const data = await login(email.trim(), password) + authLogin(data.token, data.refreshToken) + onSuccess?.() + navigate('/') + } catch (err: unknown) { + const verificationRequired = getEmailVerificationRequired(err) + if (verificationRequired) { + navigate('/verify-email', { + state: { + email: verificationRequired.email, + nextResendAt: verificationRequired.nextResendAt, + cooldownSeconds: verificationRequired.cooldownSeconds, + resendAttemptsRemaining: verificationRequired.resendAttemptsRemaining, + cooldownSecondsRemaining: verificationRequired.cooldownSecondsRemaining, + message: verificationRequired.message, + } + }) + return + } + + if (err instanceof AxiosError && err.response?.status === 429) { + const body = err.response.data as LockoutData + setLockedUntil(body.lockedUntil ?? null) + setApiError('Account locked due to too many failed attempts.') + } else { + setApiError(getErrorMessage(err)) + } + } finally { + setLoading(false) + } + } + + return ( + <> +
+

Welcome back

+
+ + {apiError && ( +
+ {apiError} +
+ )} + {lockedUntil && ( +
+ 🔒 Try again after {new Date(lockedUntil).toLocaleTimeString()} +
+ )} + +
+ setApiError('Google Sign-In failed or was cancelled.')} + useOneTap + /> +
+
+
+ OR +
+
+ +
+
+ { setEmail(e.target.value); setFieldErrors(p => ({ ...p, email: undefined })) }} + placeholder="Email address" + className={`auth-input${fieldErrors.email ? ' auth-input--error' : ''}`} + autoComplete="email" + disabled={loading} + /> + {fieldErrors.email &&

{fieldErrors.email}

} +
+ +
+ { setPassword(e.target.value); setFieldErrors(p => ({ ...p, password: undefined })) }} + placeholder="Password" + className={`auth-input${fieldErrors.password ? ' auth-input--error' : ''}`} + autoComplete="current-password" + disabled={loading} + /> + + {fieldErrors.password &&

{fieldErrors.password}

} +
+ + +
+ +
+ Don't have an account?{' '} + +
+ +
+ + ) +} diff --git a/frontend/src/components/auth/PublicAuthNavbar.tsx b/frontend/src/components/auth/PublicAuthNavbar.tsx new file mode 100644 index 0000000..4519b7c --- /dev/null +++ b/frontend/src/components/auth/PublicAuthNavbar.tsx @@ -0,0 +1,26 @@ +import { useNavigate } from 'react-router-dom' + +interface PublicAuthNavbarProps { + actionLabel?: string + actionHref?: string +} + +export default function PublicAuthNavbar({ actionLabel, actionHref }: PublicAuthNavbarProps) { + const navigate = useNavigate() + + return ( + + ) +} diff --git a/frontend/src/components/auth/RegisterForm.tsx b/frontend/src/components/auth/RegisterForm.tsx new file mode 100644 index 0000000..665af69 --- /dev/null +++ b/frontend/src/components/auth/RegisterForm.tsx @@ -0,0 +1,226 @@ +import { useState, type FormEvent } from 'react' +import { GoogleLogin, type CredentialResponse } from '@react-oauth/google' +import { useNavigate } from 'react-router-dom' +import { register, loginWithGoogle, getErrorMessage } from '../../services/authService' +import { useAuth } from '../../context/AuthContext' +import type { EmailVerificationOtpResponse } from '../../types' +import { Eye, EyeOff } from './AuthIcons' + +interface FieldErrors { + email?: string + password?: string + confirmPassword?: string +} + +interface RegisterFormProps { + onSwitchToLogin: () => void + onSuccess?: () => void +} + +export default function RegisterForm({ onSwitchToLogin, onSuccess }: RegisterFormProps) { + const [displayName, setDisplayName] = useState('') + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [showPw, setShowPw] = useState(false) + const [showCpw, setShowCpw] = useState(false) + const [fieldErrors, setFieldErrors] = useState({}) + const [apiError, setApiError] = useState('') + const [loading, setLoading] = useState(false) + const [success, setSuccess] = useState(false) + + const navigate = useNavigate() + const { login: authLogin } = useAuth() + + function validate(): boolean { + const errors: FieldErrors = {} + if (!email.trim()) { + errors.email = 'Email is required' + } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim())) { + errors.email = 'Enter a valid email address' + } + + if (!password) { + errors.password = 'Password is required' + } else { + if (password.length < 8) errors.password = 'At least 8 characters' + else if (!/[A-Z]/.test(password)) errors.password = 'Needs uppercase' + else if (!/[a-z]/.test(password)) errors.password = 'Needs lowercase' + else if (!/[0-9]/.test(password)) errors.password = 'Needs a number' + else if (!/[^A-Za-z0-9]/.test(password)) errors.password = 'Needs a special character' + } + + if (!confirmPassword) errors.confirmPassword = 'Please confirm your password' + else if (password !== confirmPassword) errors.confirmPassword = 'Passwords do not match' + + setFieldErrors(errors) + return !Object.keys(errors).length + } + + async function handleSubmit(ev: FormEvent) { + ev.preventDefault() + setApiError('') + if (!validate()) return + + setLoading(true) + try { + const data: EmailVerificationOtpResponse = await register(email.trim(), password, displayName.trim() || undefined) + setSuccess(true) + onSuccess?.() + setTimeout(() => navigate('/verify-email', { + state: { + email: email.trim(), + nextResendAt: data.nextResendAt, + cooldownSeconds: data.cooldownSeconds, + resendAttemptsRemaining: data.resendAttemptsRemaining, + message: data.message, + } + }), 1400) + } catch (err: unknown) { + setApiError(getErrorMessage(err)) + } finally { + setLoading(false) + } + } + + async function handleGoogleSuccess(credentialResponse: CredentialResponse) { + setApiError('') + if (!credentialResponse.credential) return + + setLoading(true) + try { + const data = await loginWithGoogle(credentialResponse.credential) + authLogin(data.token, data.refreshToken) + onSuccess?.() + navigate('/') + } catch (err: unknown) { + setApiError(getErrorMessage(err)) + } finally { + setLoading(false) + } + } + + return ( + <> +
+

Create an account

+
+ + {apiError && ( +
+ {apiError} +
+ )} + {success && ( +
+ Account created! Check your email for a verification code… +
+ )} + +
+ setApiError('Google Sign-Up failed or was cancelled.')} + /> +
+
+
+ OR +
+
+ +
+
+ setDisplayName(e.target.value)} + placeholder="Your Name" + className="auth-input" + autoComplete="nickname" + disabled={loading || success} + /> +
+ +
+ { setEmail(e.target.value); setFieldErrors(p => ({ ...p, email: undefined })) }} + placeholder="Email Address" + className={`auth-input${fieldErrors.email ? ' auth-input--error' : ''}`} + autoComplete="email" + disabled={loading || success} + /> + {fieldErrors.email &&

{fieldErrors.email}

} +
+ +
+ { setPassword(e.target.value); setFieldErrors(p => ({ ...p, password: undefined })) }} + placeholder="Password" + className={`auth-input${fieldErrors.password ? ' auth-input--error' : ''}`} + autoComplete="new-password" + disabled={loading || success} + /> + + {fieldErrors.password &&

{fieldErrors.password}

} + + {password.length > 0 && ( +
+
= 8 ? 'met' : ''}`}> + {password.length >= 8 ? '✓' : '○'} 8+ characters +
+
+ {/[A-Z]/.test(password) ? '✓' : '○'} Uppercase letter +
+
+ {/[a-z]/.test(password) ? '✓' : '○'} Lowercase letter +
+
+ {/[0-9]/.test(password) ? '✓' : '○'} Number +
+
+ {/[^A-Za-z0-9]/.test(password) ? '✓' : '○'} Special character +
+
+ )} +
+ +
+ { setConfirmPassword(e.target.value); setFieldErrors(p => ({ ...p, confirmPassword: undefined })) }} + placeholder="Re-enter Password" + className={`auth-input${fieldErrors.confirmPassword ? ' auth-input--error' : ''}`} + autoComplete="new-password" + disabled={loading || success} + /> + + {fieldErrors.confirmPassword &&

{fieldErrors.confirmPassword}

} +
+ + +
+ +
+ Already have an account?{' '} + +
+ + ) +} diff --git a/frontend/src/components/canvas/CanvasGraphView.tsx b/frontend/src/components/canvas/CanvasGraphView.tsx index b8205a0..c21dd51 100644 --- a/frontend/src/components/canvas/CanvasGraphView.tsx +++ b/frontend/src/components/canvas/CanvasGraphView.tsx @@ -1,34 +1,26 @@ +import { useEffect, useRef } from 'react' import { Background, Controls, Handle, Position, ReactFlow, ReactFlowProvider, type Edge, type Node, type NodeProps } from '@xyflow/react' import type { CanvasGraphNode, CanvasGraphResponse, LlmProviderId } from '../../types' interface CanvasGraphViewProps { graph: CanvasGraphResponse - selectedNodeId: number | null - draft: string - providerOverride: LlmProviderId | '' + activeNodeId: number | null + draftsByNodeId: Record + providerOverridesByNodeId: Record sending: boolean noProvidersConfigured: boolean - onSelectNode: (nodeId: number) => void - onDraftChange: (value: string) => void - onProviderOverrideChange: (value: LlmProviderId | '') => void + onOpenNode: (nodeId: number) => void + onCloseNode: () => void + onDraftChange: (nodeId: number, value: string) => void + onProviderOverrideChange: (nodeId: number, value: LlmProviderId | '') => void onSubmit: (nodeId: number) => void onOpenSettings: () => void } -interface GraphNodeData { +interface GraphNodeData extends Record { node: CanvasGraphNode childCount: number - draft: string - providerOverride: LlmProviderId | '' - sending: boolean - selected: boolean - noProvidersConfigured: boolean - defaultProvider: LlmProviderId | null - availableProviders: LlmProviderId[] - onDraftChange: (value: string) => void - onProviderOverrideChange: (value: LlmProviderId | '') => void - onSubmit: () => void - onOpenSettings: () => void + active: boolean } function getProviderLabel(provider: LlmProviderId): string { @@ -49,12 +41,16 @@ function summarizeContent(content: string): string { function GraphNodeCard({ data }: NodeProps>) { const isRoot = data.node.kind === 'main' - const isEmptyMainNode = isRoot && !data.node.content.trim() && data.childCount === 0 - const showComposer = data.selected || isEmptyMainNode const preview = summarizeContent(data.node.content) + const hasContent = Boolean(data.node.content.trim()) + const hint = hasContent + ? 'Click to view details or continue from this node.' + : isRoot + ? 'Click to start from the main node.' + : 'Click to continue from this node.' return ( -
+
{!isRoot && } @@ -64,85 +60,13 @@ function GraphNodeCard({ data }: NodeProps>) {

{data.node.title}

- {data.node.content && ( + {hasContent && (

- {showComposer ? data.node.content : preview} + {preview}

)} - {!showComposer && ( -
Click to continue from this node.
- )} - - {showComposer && ( -
event.stopPropagation()} - onSubmit={event => { - event.preventDefault() - data.onSubmit() - }} - > - -