diff --git a/.vscode/launch.json b/.vscode/launch.json index 564675f..55ad750 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -13,7 +13,7 @@ "pipeCwd": "${workspaceFolder}", "quoteArgs": false }, - "preLaunchTask": "docker-compose up (backend only)", + "preLaunchTask": "docker-compose up (debug backend only)", "sourceFileMap": { "/src/src/ParagonPlayground.Api": "${workspaceFolder}/src/ParagonPlayground/backend/src/ParagonPlayground.Api", "/src/src/ParagonPlayground.Domain": "${workspaceFolder}/src/ParagonPlayground/backend/src/ParagonPlayground.Domain", @@ -39,8 +39,7 @@ }, { "name": "Attach to All", - "type": "compound", - "preLaunchTask": "docker-compose up", + "preLaunchTask": "docker-compose up (debug)", "configurations": ["Attach to Backend", "Attach to Frontend"] } ], diff --git a/.vscode/settings.json b/.vscode/settings.json index d917517..c3e8bb8 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,6 @@ { - "FSharp.suggestGitignore": false + "FSharp.suggestGitignore": false, + "cSpell.words": [ + "useparagon" + ] } \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 7dd5533..7700eb8 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -45,6 +45,28 @@ "group": "none", "detail": "Restart all services" }, + { + "label": "docker-compose up (debug)", + "type": "shell", + "command": "docker compose -f docker-compose.debug.yml up -d --build", + "options": { + "cwd": "${workspaceFolder}/src/ParagonPlayground" + }, + "problemMatcher": [], + "group": "none", + "detail": "Build and start all services using the debug stage (compiled binary, debugger-ready)" + }, + { + "label": "docker-compose up (debug backend only)", + "type": "shell", + "command": "docker compose -f docker-compose.debug.yml up -d --build mongodb backend nginx", + "options": { + "cwd": "${workspaceFolder}/src/ParagonPlayground" + }, + "problemMatcher": [], + "group": "none", + "detail": "Backend only in debug mode (no frontend container)" + }, { "label": "build backend", "type": "shell", diff --git a/src/ParagonPlayground/backend/Dockerfile b/src/ParagonPlayground/backend/Dockerfile index 6c42d05..1268419 100644 --- a/src/ParagonPlayground/backend/Dockerfile +++ b/src/ParagonPlayground/backend/Dockerfile @@ -1,22 +1,8 @@ -FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +# === Development stage === +# Hot reload via dotnet watch with source volume mount +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS development WORKDIR /src - -COPY ParagonPlayground.slnx ./ -COPY Directory.Build.props ./ -COPY src/Directory.Build.props src/ -COPY src/Directory.Packages.props src/ -COPY src/ParagonPlayground.Domain/ParagonPlayground.Domain.csproj src/ParagonPlayground.Domain/ -COPY src/ParagonPlayground.Infrastructure/ParagonPlayground.Infrastructure.csproj src/ParagonPlayground.Infrastructure/ -COPY src/ParagonPlayground.Api/ParagonPlayground.Api.csproj src/ParagonPlayground.Api/ -RUN dotnet restore src/ParagonPlayground.Api/ParagonPlayground.Api.csproj - COPY . . -RUN dotnet publish src/ParagonPlayground.Api/ParagonPlayground.Api.csproj -c Debug -o /app - -FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime -WORKDIR /app -EXPOSE 8080 -COPY --from=build /app . RUN apt-get update && \ apt-get install -y unzip curl && \ @@ -24,4 +10,30 @@ RUN apt-get update && \ apt-get clean && \ rm -rf /var/lib/apt/lists/* +EXPOSE 8080 +ENV ASPNETCORE_URLS=http://+:8080 +ENV ASPNETCORE_ENVIRONMENT=Development +CMD ["dotnet", "watch", "run", "--project", "src/ParagonPlayground.Api/ParagonPlayground.Api.csproj", "--no-launch-profile"] + +# === Build stage === +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src +COPY . . +RUN dotnet publish src/ParagonPlayground.Api/ParagonPlayground.Api.csproj -c Debug -o /app + +# === Debug stage === +# Compiled binary with vsdbg for debugger attachment +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS debug +WORKDIR /app +EXPOSE 8080 + +RUN apt-get update && \ + apt-get install -y unzip curl && \ + curl -sSL https://aka.ms/getvsdbgsh | /bin/sh /dev/stdin -v latest -l /vsdbg && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* + +COPY --from=build /app . +ENV ASPNETCORE_URLS=http://+:8080 +ENV ASPNETCORE_ENVIRONMENT=Development ENTRYPOINT ["dotnet", "ParagonPlayground.Api.dll"] diff --git a/src/ParagonPlayground/backend/src/Directory.Packages.props b/src/ParagonPlayground/backend/src/Directory.Packages.props index f222313..31378c7 100644 --- a/src/ParagonPlayground/backend/src/Directory.Packages.props +++ b/src/ParagonPlayground/backend/src/Directory.Packages.props @@ -13,6 +13,8 @@ + + diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Auth/AuthPolicies.cs b/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Auth/AuthPolicies.cs new file mode 100644 index 0000000..6f92064 --- /dev/null +++ b/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Auth/AuthPolicies.cs @@ -0,0 +1,36 @@ +namespace ParagonPlayground.Api.Auth; + +internal static class PolicyNames +{ + internal const string AdminOnly = "AdminOnly"; +} + +internal static class RoleNames +{ + internal const string Admin = "admin"; +} + +internal static class SessionAuthDefaults +{ + internal const string Scheme = "Session"; +} + +internal static class ClaimNames +{ + internal const string OrganizationId = "OrganizationId"; + + internal const string OrganizationName = "OrganizationName"; + + internal const string OrganizationSlug = "OrganizationSlug"; +} + +internal static class EndpointAuthorizationExtensions +{ + internal static RouteHandlerBuilder RequireAdmin(this RouteHandlerBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + _ = builder.RequireAuthorization(PolicyNames.AdminOnly); + return builder; + } +} \ No newline at end of file diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Auth/SessionAuthenticationHandler.cs b/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Auth/SessionAuthenticationHandler.cs new file mode 100644 index 0000000..59a8c8d --- /dev/null +++ b/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Auth/SessionAuthenticationHandler.cs @@ -0,0 +1,80 @@ +using System.Security.Claims; +using System.Text.Encodings.Web; + +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Options; + +using ParagonPlayground.Api.Context; +using ParagonPlayground.Api.Infrastructure; +using ParagonPlayground.Infrastructure.Data; +using ParagonPlayground.Infrastructure.Services; + +namespace ParagonPlayground.Api.Auth; + +internal sealed class SessionAuthenticationHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder, + CookieService cookieService, + SessionRepository sessionRepository, + UserRepository userRepository, + OrganizationRepository organizationRepository +) : AuthenticationHandler(options, logger, encoder) +{ + private readonly CookieService _cookieService = cookieService; + private readonly SessionRepository _sessionRepository = sessionRepository; + private readonly UserRepository _userRepository = userRepository; + private readonly OrganizationRepository _organizationRepository = organizationRepository; + + protected override async Task HandleAuthenticateAsync() + { + var sessionToken = _cookieService.GetSessionToken(Context); + + if (string.IsNullOrEmpty(sessionToken)) + { + return AuthenticateResult.NoResult(); + } + + var tokenHash = TokenHelper.HashToken(sessionToken); + var session = await _sessionRepository.GetByTokenHashAsync(tokenHash, Context.RequestAborted); + + if (session is null || session.ExpiresAt <= DateTime.UtcNow) + { + return AuthenticateResult.Fail("Session token is invalid or expired."); + } + + var user = await _userRepository.GetByIdAsync(session.UserId, Context.RequestAborted); + + if (user is null) + { + return AuthenticateResult.Fail("The user associated with this session no longer exists."); + } + + var organization = await _organizationRepository.GetByIdAsync(user.OrganizationId, Context.RequestAborted); + + if (organization is null) + { + return AuthenticateResult.Fail("The organization associated with this session no longer exists."); + } + + Context.SetSession(session); + Context.SetSessionToken(sessionToken); + Context.SetUser(user); + Context.SetOrganization(organization); + + var identity = new ClaimsIdentity( + [ + new Claim(ClaimTypes.NameIdentifier, user.Id), + new Claim(ClaimTypes.Name, user.DisplayName), + new Claim(ClaimTypes.Email, user.Email), + new Claim(ClaimTypes.Role, user.Role), + new Claim(ClaimNames.OrganizationId, organization.Id), + new Claim(ClaimNames.OrganizationName, organization.Name), + new Claim(ClaimNames.OrganizationSlug, organization.Slug), + ], + Scheme.Name + ); + + return AuthenticateResult.Success(new AuthenticationTicket(new ClaimsPrincipal(identity), Scheme.Name)); + } +} \ No newline at end of file diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Context/ContextKeys.cs b/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Context/ContextKeys.cs index 3a5ca48..803078e 100644 --- a/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Context/ContextKeys.cs +++ b/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Context/ContextKeys.cs @@ -6,4 +6,4 @@ internal static class ContextKeys internal const string SessionToken = nameof(SessionToken); internal const string User = nameof(User); internal const string Organization = nameof(Organization); -} +} \ No newline at end of file diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Context/HttpContextExtensions.cs b/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Context/HttpContextExtensions.cs index 20f1ae8..8edc8d1 100644 --- a/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Context/HttpContextExtensions.cs +++ b/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Context/HttpContextExtensions.cs @@ -29,9 +29,16 @@ internal static class HttpContextExtensions context.Items[ContextKeys.User] = user; } - internal static User? GetUser(this HttpContext context) + internal static User GetUser(this HttpContext context) { - return context.Items[ContextKeys.User] as User; + return context.Items[ContextKeys.User] as User + ?? throw new NotAuthenticatedException("The authenticated user is not available for this request."); + } + + internal static bool TryGetUser(this HttpContext context, out User? user) + { + user = context.Items[ContextKeys.User] as User; + return user is not null; } internal static void SetOrganization(this HttpContext context, Organization? org) @@ -39,8 +46,15 @@ internal static class HttpContextExtensions context.Items[ContextKeys.Organization] = org; } - internal static Organization? GetOrganization(this HttpContext context) + internal static Organization GetOrganization(this HttpContext context) { - return context.Items[ContextKeys.Organization] as Organization; + return context.Items[ContextKeys.Organization] as Organization + ?? throw new NotAuthenticatedException("The authenticated organization is not available for this request."); } -} + + internal static bool TryGetOrganization(this HttpContext context, out Organization? organization) + { + organization = context.Items[ContextKeys.Organization] as Organization; + return organization is not null; + } +} \ No newline at end of file diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Context/NotAuthenticatedException.cs b/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Context/NotAuthenticatedException.cs new file mode 100644 index 0000000..f2f9ee7 --- /dev/null +++ b/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Context/NotAuthenticatedException.cs @@ -0,0 +1,16 @@ +namespace ParagonPlayground.Api.Context; + +internal sealed class NotAuthenticatedException : Exception +{ + public NotAuthenticatedException() + { + } + + public NotAuthenticatedException(string message) : base(message) + { + } + + public NotAuthenticatedException(string message, Exception innerException) : base(message, innerException) + { + } +} \ No newline at end of file diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Endpoints/AuthEndpoints.cs b/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Endpoints/AuthEndpoints.cs index 90b4d75..6d2ee6d 100644 --- a/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Endpoints/AuthEndpoints.cs +++ b/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Endpoints/AuthEndpoints.cs @@ -16,8 +16,8 @@ internal static class AuthEndpoints internal static RouteGroupBuilder MapAuthEndpoints(this RouteGroupBuilder group) { _ = group.MapPost("/login", LoginAsync); - _ = group.MapPost("/logout", LogoutAsync); - _ = group.MapGet("/me", Me); + _ = group.MapPost("/logout", LogoutAsync).RequireAuthorization(); + _ = group.MapGet("/me", Me).RequireAuthorization(); return group; } @@ -38,7 +38,7 @@ internal static class AuthEndpoints if (user is null || passwordService.Verify(request.Password, user.PasswordHash) is false) { - return Results.Json(new { error = "Invalid email or password" }, statusCode: StatusCodes.Status401Unauthorized); + return Results.Problem(detail: "Invalid email or password", statusCode: StatusCodes.Status401Unauthorized); } var org = await orgRepo.GetByIdAsync(user.OrganizationId, ct); @@ -65,6 +65,7 @@ internal static class AuthEndpoints Id = user.Id, Email = user.Email, DisplayName = user.DisplayName, + Role = user.Role, OrganizationId = org?.Id ?? string.Empty, OrganizationName = org?.Name ?? string.Empty, OrganizationSlug = org?.Slug ?? string.Empty, @@ -88,18 +89,13 @@ internal static class AuthEndpoints cookieService.ClearSessionCookie(context); cookieService.ClearXsrfCookie(context); - return Results.Ok(new { message = "Logged out" }); + return Results.NoContent(); } private static IResult Me(HttpContext context) { var user = context.GetUser(); - if (user is null) - { - return Results.Json(new { error = "Not authenticated" }, statusCode: StatusCodes.Status401Unauthorized); - } - var org = context.GetOrganization(); return Results.Ok(new UserResponse @@ -107,10 +103,11 @@ internal static class AuthEndpoints Id = user.Id, Email = user.Email, DisplayName = user.DisplayName, + Role = user.Role, OrganizationId = org?.Id ?? string.Empty, OrganizationName = org?.Name ?? string.Empty, OrganizationSlug = org?.Slug ?? string.Empty, }); } -} +} \ No newline at end of file diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Endpoints/IntegrationEndpoints.cs b/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Endpoints/IntegrationEndpoints.cs new file mode 100644 index 0000000..0ae3e8e --- /dev/null +++ b/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Endpoints/IntegrationEndpoints.cs @@ -0,0 +1,214 @@ +using MongoDB.Bson; + +using ParagonPlayground.Api.Auth; +using ParagonPlayground.Api.Context; +using ParagonPlayground.Api.Services; +using ParagonPlayground.Domain.DTOs; +using ParagonPlayground.Domain.Entities; +using ParagonPlayground.Infrastructure.Data; + +namespace ParagonPlayground.Api.Endpoints; + +internal static class IntegrationEndpoints +{ + internal static RouteGroupBuilder MapIntegrationEndpoints(this RouteGroupBuilder group) + { + _ = group.MapGet("/config", GetConfig); + _ = group.MapPut("/config", PutConfig).RequireAdmin(); + _ = group.MapGet("/credentials", GetCredentials); + _ = group.MapGet("/credentials/org", GetOrgCredentials).RequireAdmin(); + _ = group.MapPost("/credentials", PostCredential); + _ = group.MapDelete("/credentials/org", PurgeOrgCredentials).RequireAdmin(); + _ = group.MapDelete("/credentials/{credentialId}", DeleteCredential); + _ = group.RequireAuthorization(); + return group; + } + + private static async Task GetConfig( + HttpContext context, + OrganizationIntegrationRepository repo, + CancellationToken ct + ) + { + var org = context.GetOrganization(); + + var config = await repo.GetByOrganizationIdAsync(org.Id, ct); + + if (config is null) + { + return Results.Problem(detail: "Integration configuration not found", statusCode: StatusCodes.Status404NotFound); + } + + return Results.Ok(new IntegrationConfigResponse + { + Id = config.Id, + OrganizationId = config.OrganizationId, + ConnectionMode = config.ConnectionMode, + SharePointSiteUrl = config.SharePointSiteUrl, + SharePointSiteId = config.SharePointSiteId, + SharePointFolderPath = config.SharePointFolderPath, + UpdatedAt = config.UpdatedAt, + }); + } + + private static async Task PutConfig( + IntegrationConfigRequest request, + HttpContext context, + OrganizationIntegrationRepository repo, + UserCredentialRepository credRepo, + ParagonService paragon, + CancellationToken ct + ) + { + var user = context.GetUser(); + var org = context.GetOrganization(); + + var config = await repo.GetByOrganizationIdAsync(org.Id, ct); + + config ??= new OrganizationIntegration + { + Id = ObjectId.GenerateNewId().ToString(), + OrganizationId = org.Id, + }; + + config.ConnectionMode = request.ConnectionMode; + config.SharePointFolderPath = request.SharePointFolderPath?.Trim(); + config.UpdatedAt = DateTime.UtcNow; + + if (string.IsNullOrWhiteSpace(request.SharePointSiteUrl) is false) + { + config.SharePointSiteUrl = request.SharePointSiteUrl.Trim(); + + var credentials = await credRepo.GetByUserIdAsync(user.Id, ct); + var spCredential = credentials.FirstOrDefault(c => + c.IntegrationType.Equals("sharepoint", StringComparison.OrdinalIgnoreCase) + ); + + if (spCredential is not null && paragon.IsConfigured) + { + var jwt = paragon.GenerateToken(org.Id, spCredential.CredentialId); + + config.SharePointSiteId = await paragon.ResolveSiteUrlAsync( + jwt, + spCredential.CredentialId, + config.SharePointSiteUrl, + ct + ); + } + } + + await repo.UpsertAsync(config, ct); + + return Results.Ok(new IntegrationConfigResponse + { + Id = config.Id, + OrganizationId = config.OrganizationId, + ConnectionMode = config.ConnectionMode, + SharePointSiteUrl = config.SharePointSiteUrl, + SharePointSiteId = config.SharePointSiteId, + SharePointFolderPath = config.SharePointFolderPath, + UpdatedAt = config.UpdatedAt, + }); + } + + private static async Task GetCredentials( + HttpContext context, + UserCredentialRepository repo, + CancellationToken ct + ) + { + var user = context.GetUser(); + + var credentials = await repo.GetByUserIdAsync(user.Id, ct); + + return Results.Ok(credentials.Select(c => new CredentialResponse + { + Id = c.Id, + CredentialId = c.CredentialId, + IntegrationType = c.IntegrationType, + ConnectedAt = c.ConnectedAt, + })); + } + + private static async Task GetOrgCredentials( + HttpContext context, + UserCredentialRepository repo, + CancellationToken ct + ) + { + var org = context.GetOrganization(); + + var credentials = await repo.GetByOrganizationIdAsync(org.Id, ct); + + return Results.Ok(credentials.Select(c => new CredentialResponse + { + Id = c.Id, + CredentialId = c.CredentialId, + IntegrationType = c.IntegrationType, + ConnectedAt = c.ConnectedAt, + })); + } + + private static async Task PostCredential( + CredentialRequest request, + HttpContext context, + UserCredentialRepository repo, + CancellationToken ct + ) + { + var user = context.GetUser(); + var org = context.GetOrganization(); + + var credential = new UserCredential + { + Id = ObjectId.GenerateNewId().ToString(), + UserId = user.Id, + OrganizationId = org.Id, + CredentialId = request.CredentialId, + IntegrationType = request.IntegrationType, + ConnectedAt = DateTime.UtcNow, + }; + + await repo.CreateAsync(credential, ct); + + return Results.Created($"/api/integration/credentials/{credential.Id}", new CredentialResponse + { + Id = credential.Id, + CredentialId = credential.CredentialId, + IntegrationType = credential.IntegrationType, + ConnectedAt = credential.ConnectedAt, + }); + } + + private static async Task PurgeOrgCredentials( + HttpContext context, + UserCredentialRepository repo, + CancellationToken ct + ) + { + var org = context.GetOrganization(); + + _ = await repo.DeleteByOrganizationIdAsync(org.Id, ct); + + return Results.NoContent(); + } + + private static async Task DeleteCredential( + string credentialId, + HttpContext context, + UserCredentialRepository repo, + CancellationToken ct + ) + { + var user = context.GetUser(); + + var deleted = await repo.DeleteByCredentialIdAsync(credentialId, user.Id, ct); + + if (deleted is false) + { + return Results.Problem(detail: "Credential not found", statusCode: StatusCodes.Status404NotFound); + } + + return Results.NoContent(); + } +} \ No newline at end of file diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Endpoints/ParagonEndpoints.cs b/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Endpoints/ParagonEndpoints.cs new file mode 100644 index 0000000..f60bbfc --- /dev/null +++ b/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Endpoints/ParagonEndpoints.cs @@ -0,0 +1,46 @@ +using ParagonPlayground.Api.Context; +using ParagonPlayground.Api.Services; +using ParagonPlayground.Domain.DTOs; +using ParagonPlayground.Infrastructure.Data; + +namespace ParagonPlayground.Api.Endpoints; + +internal static class ParagonEndpoints +{ + internal static RouteGroupBuilder MapParagonEndpoints(this RouteGroupBuilder group) + { + _ = group.MapGet("/token", GenerateToken); + _ = group.RequireAuthorization(); + return group; + } + + private static async Task GenerateToken( + HttpContext context, + ParagonService paragon, + UserCredentialRepository credRepo, + CancellationToken ct + ) + { + var user = context.GetUser(); + var org = context.GetOrganization(); + + if (paragon.IsConfigured is false) + { + return Results.Problem( + detail: "Paragon integration is not configured. Set Paragon:ProjectId and Paragon:SigningKey.", + statusCode: StatusCodes.Status400BadRequest + ); + } + + var credentials = await credRepo.GetByUserIdAsync(user.Id, ct); + var spCredential = credentials.FirstOrDefault(c => c.IntegrationType.Equals("sharepoint", StringComparison.OrdinalIgnoreCase)); + + var jwt = paragon.GenerateToken(org.Id, spCredential?.CredentialId); + + return Results.Ok(new ParagonTokenResponse + { + ParagonJwt = jwt, + ProjectId = paragon.ProjectId, + }); + } +} \ No newline at end of file diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Endpoints/StorageEndpoints.cs b/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Endpoints/StorageEndpoints.cs new file mode 100644 index 0000000..c3be926 --- /dev/null +++ b/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Endpoints/StorageEndpoints.cs @@ -0,0 +1,329 @@ +using System.Text.Json; + +using MongoDB.Bson; + +using ParagonPlayground.Api.Context; +using ParagonPlayground.Api.Services; +using ParagonPlayground.Domain.DTOs; +using ParagonPlayground.Domain.Entities; +using ParagonPlayground.Infrastructure.Data; + +namespace ParagonPlayground.Api.Endpoints; + +internal static class StorageEndpoints +{ + internal static RouteGroupBuilder MapStorageEndpoints(this RouteGroupBuilder group) + { + _ = group.MapGet("/", ListItems); + _ = group.MapPost("/folders", CreateFolder); + _ = group.MapPost("/files", UploadFile); + _ = group.MapDelete("/{id}", DeleteItem); + _ = group.MapGet("/{id}/download", GetDownloadUrls); + _ = group.MapGet("/{id}/content", ProxyContent); + _ = group.RequireAuthorization(); + return group; + } + + private static async Task ListItems( + string? parentId, + HttpContext context, + StorageItemRepository repo, + UserRepository userRepo, + CancellationToken ct + ) + { + var org = context.GetOrganization(); + + var items = await repo.GetByParentIdAsync(org.Id, parentId, ct); + var userIds = items.Select(i => i.CreatedByUserId).Distinct().ToList(); + var users = new Dictionary(); + + foreach (var uid in userIds) + { + var u = await userRepo.GetByIdAsync(uid, ct); + users[uid] = u?.DisplayName ?? "Unknown"; + } + + return Results.Ok(items.Select(i => new StorageItemResponse + { + Id = i.Id, + Name = i.Name, + IsFolder = i.IsFolder, + ParentId = i.ParentId, + ContentType = i.ContentType, + FileSize = i.FileSize, + SharePointWebUrl = i.SharePointWebUrl, + CreatedByUserId = i.CreatedByUserId, + CreatedByDisplayName = users.GetValueOrDefault(i.CreatedByUserId, "Unknown"), + CreatedAt = i.CreatedAt, + })); + } + + private static async Task CreateFolder( + CreateFolderRequest request, + HttpContext context, + StorageItemRepository repo, + CancellationToken ct + ) + { + var user = context.GetUser(); + var org = context.GetOrganization(); + + if (string.IsNullOrWhiteSpace(request.Name)) + { + return Results.Problem(detail: "Folder name is required", statusCode: StatusCodes.Status400BadRequest); + } + + var item = new StorageItem + { + Id = ObjectId.GenerateNewId().ToString(), + OrganizationId = org.Id, + Name = request.Name.Trim(), + IsFolder = true, + ParentId = request.ParentId, + CreatedByUserId = user.Id, + CreatedAt = DateTime.UtcNow, + }; + + await repo.CreateAsync(item, ct); + + return Results.Created($"/api/storage/{item.Id}", new StorageItemResponse + { + Id = item.Id, + Name = item.Name, + IsFolder = true, + ParentId = item.ParentId, + CreatedByUserId = item.CreatedByUserId, + CreatedByDisplayName = user.DisplayName, + CreatedAt = item.CreatedAt, + }); + } + + private static async Task UploadFile( + HttpContext context, + StorageItemRepository storageRepo, + UserCredentialRepository credRepo, + OrganizationIntegrationRepository configRepo, + ParagonService paragon, + UserRepository userRepo, + CancellationToken ct + ) + { + var user = context.GetUser(); + var org = context.GetOrganization(); + + var file = context.Request.Form.Files.Count > 0 ? context.Request.Form.Files[0] : null; + + if (file is null || file.Length is 0) + { + return Results.Problem(detail: "File is required", statusCode: StatusCodes.Status400BadRequest); + } + + var parentId = (string?)context.Request.Form["parentId"]; + + var config = await configRepo.GetByOrganizationIdAsync(org.Id, ct); + + if (config is null || string.IsNullOrWhiteSpace(config.SharePointSiteId)) + { + return Results.Problem( + detail: "SharePoint integration is not configured. Ask an admin to set it up.", + statusCode: StatusCodes.Status400BadRequest + ); + } + + var credentials = await credRepo.GetByUserIdAsync(user.Id, ct); + var spCredential = credentials.FirstOrDefault(c => + c.IntegrationType.Equals("sharepoint", StringComparison.OrdinalIgnoreCase) + ); + + if (spCredential is null) + { + return Results.Problem( + detail: "No SharePoint credential found. Connect to SharePoint first.", + statusCode: StatusCodes.Status400BadRequest + ); + } + + if (paragon.IsConfigured is false) + { + return Results.Problem( + detail: "Paragon integration is not configured on the server.", + statusCode: StatusCodes.Status500InternalServerError + ); + } + + var jwt = paragon.GenerateToken(org.Id, spCredential.CredentialId); + + string sharePointResponse; + + await using (var stream = file.OpenReadStream()) + { + sharePointResponse = await paragon.UploadFileAsync( + jwt, + spCredential.CredentialId, + config.SharePointSiteId, + config.SharePointFolderPath ?? "", + file.FileName, + stream, + file.ContentType, + ct + ); + } + + string? driveItemId = null; + string? webUrl = null; + + try + { + using var doc = JsonDocument.Parse(sharePointResponse); + + if (doc.RootElement.TryGetProperty("output", out var output)) + { + driveItemId = output.TryGetProperty("id", out var id) ? id.GetString() : null; + webUrl = output.TryGetProperty("webUrl", out var wu) ? wu.GetString() : null; + } + } + catch (JsonException) + { + // Response parsing is best-effort; store what we have + } + + var storageItem = new StorageItem + { + Id = ObjectId.GenerateNewId().ToString(), + OrganizationId = org.Id, + Name = file.FileName, + IsFolder = false, + ParentId = parentId, + ContentType = file.ContentType, + FileSize = file.Length, + SharePointSiteId = config.SharePointSiteId, + SharePointDriveItemId = driveItemId, + SharePointWebUrl = webUrl, + CreatedByUserId = user.Id, + CreatedAt = DateTime.UtcNow, + }; + + await storageRepo.CreateAsync(storageItem, ct); + + return Results.Created($"/api/storage/{storageItem.Id}", new StorageItemResponse + { + Id = storageItem.Id, + Name = storageItem.Name, + IsFolder = false, + ParentId = storageItem.ParentId, + ContentType = storageItem.ContentType, + FileSize = storageItem.FileSize, + SharePointWebUrl = storageItem.SharePointWebUrl, + CreatedByUserId = storageItem.CreatedByUserId, + CreatedByDisplayName = user.DisplayName, + CreatedAt = storageItem.CreatedAt, + }); + } + + private static async Task DeleteItem( + string id, + HttpContext context, + StorageItemRepository repo, + CancellationToken ct + ) + { + var org = context.GetOrganization(); + + var item = await repo.GetByIdAsync(id, ct); + + if (item is null || item.OrganizationId != org.Id) + { + return Results.Problem(detail: "Item not found", statusCode: StatusCodes.Status404NotFound); + } + + await repo.DeleteAsync(id, ct); + + return Results.NoContent(); + } + + private static async Task GetDownloadUrls( + string id, + HttpContext context, + StorageItemRepository repo, + CancellationToken ct + ) + { + var org = context.GetOrganization(); + + var item = await repo.GetByIdAsync(id, ct); + + if (item is null || item.OrganizationId != org.Id || item.IsFolder) + { + return Results.Problem(detail: "Item not found", statusCode: StatusCodes.Status404NotFound); + } + + var baseUrl = $"{context.Request.Scheme}://{context.Request.Host}"; + + return Results.Ok(new DownloadResponse + { + SharePointUrl = item.SharePointWebUrl, + ProxyUrl = $"{baseUrl}/api/storage/{item.Id}/content", + }); + } + + private static async Task ProxyContent( + string id, + HttpContext context, + StorageItemRepository storageRepo, + UserCredentialRepository credRepo, + ParagonService paragon, + CancellationToken ct + ) + { + var user = context.GetUser(); + var org = context.GetOrganization(); + + var item = await storageRepo.GetByIdAsync(id, ct); + + if (item is null || item.OrganizationId != org.Id || item.IsFolder) + { + return Results.Problem(detail: "Item not found", statusCode: StatusCodes.Status404NotFound); + } + + if (string.IsNullOrEmpty(item.SharePointDriveItemId) || string.IsNullOrEmpty(item.SharePointSiteId)) + { + return Results.Problem( + detail: "No SharePoint reference available for this file.", + statusCode: StatusCodes.Status400BadRequest + ); + } + + var credentials = await credRepo.GetByUserIdAsync(user.Id, ct); + var spCredential = credentials.FirstOrDefault(c => + c.IntegrationType.Equals("sharepoint", StringComparison.OrdinalIgnoreCase) + ); + + if (spCredential is null) + { + return Results.Problem( + detail: "No SharePoint credential found.", + statusCode: StatusCodes.Status400BadRequest + ); + } + + if (paragon.IsConfigured is false) + { + return Results.Problem( + detail: "Paragon integration is not configured.", + statusCode: StatusCodes.Status500InternalServerError + ); + } + + var jwt = paragon.GenerateToken(org.Id, spCredential.CredentialId); + var fileStream = await paragon.DownloadFileAsync( + jwt, + spCredential.CredentialId, + item.SharePointSiteId, + item.SharePointDriveItemId, + ct + ); + + return Results.Stream(fileStream, item.ContentType ?? "application/octet-stream", item.Name); + } +} \ No newline at end of file diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Infrastructure/TokenHelper.cs b/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Infrastructure/TokenHelper.cs index 79778ca..7d4168f 100644 --- a/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Infrastructure/TokenHelper.cs +++ b/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Infrastructure/TokenHelper.cs @@ -14,4 +14,4 @@ internal static class TokenHelper { return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token))); } -} +} \ No newline at end of file diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Middleware/NotAuthenticatedExceptionHandler.cs b/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Middleware/NotAuthenticatedExceptionHandler.cs new file mode 100644 index 0000000..490b670 --- /dev/null +++ b/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Middleware/NotAuthenticatedExceptionHandler.cs @@ -0,0 +1,50 @@ +using Microsoft.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.Mvc; + +using ParagonPlayground.Api.Context; + +namespace ParagonPlayground.Api.Middleware; + +internal sealed class NotAuthenticatedExceptionHandler(ILogger logger) : IExceptionHandler +{ + private static readonly Action LogUnauthorizedAccess = + LoggerMessage.Define( + LogLevel.Warning, + new EventId(1, "NotAuthenticatedException"), + "Session state was accessed without an authenticated request. Path: {Path}" + ); + + private readonly ILogger _logger = logger; + + public ValueTask TryHandleAsync( + HttpContext httpContext, + Exception exception, + CancellationToken cancellationToken + ) + { + if (exception is not NotAuthenticatedException) + { + return ValueTask.FromResult(false); + } + + LogUnauthorizedAccess(_logger, httpContext.Request.Path.ToString(), exception); + + httpContext.Response.StatusCode = StatusCodes.Status401Unauthorized; + + if (httpContext.RequestServices.GetService() is { } problemDetailsService) + { + return problemDetailsService.TryWriteAsync(new ProblemDetailsContext + { + HttpContext = httpContext, + ProblemDetails = new ProblemDetails + { + Status = StatusCodes.Status401Unauthorized, + Title = "Unauthorized", + Detail = exception.Message, + }, + }); + } + + return ValueTask.FromResult(false); + } +} \ No newline at end of file diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Middleware/SessionAuthMiddleware.cs b/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Middleware/SessionAuthMiddleware.cs deleted file mode 100644 index 330a20d..0000000 --- a/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Middleware/SessionAuthMiddleware.cs +++ /dev/null @@ -1,52 +0,0 @@ -using ParagonPlayground.Api.Context; -using ParagonPlayground.Api.Infrastructure; -using ParagonPlayground.Infrastructure.Data; -using ParagonPlayground.Infrastructure.Services; - -namespace ParagonPlayground.Api.Middleware; - -internal class SessionAuthMiddleware(RequestDelegate next) -{ - private readonly RequestDelegate _next = next; - - public async Task InvokeAsync( - HttpContext context, - SessionRepository sessionRepo, - UserRepository userRepo, - OrganizationRepository orgRepo, - CookieService cookieService - ) - { - var ct = context.RequestAborted; - var sessionToken = cookieService.GetSessionToken(context); - - if (string.IsNullOrEmpty(sessionToken) is false) - { - var tokenHash = TokenHelper.HashToken(sessionToken); - var session = await sessionRepo.GetByTokenHashAsync(tokenHash, ct); - - if (session is not null && session.ExpiresAt > DateTime.UtcNow) - { - context.SetSession(session); - context.SetSessionToken(sessionToken); - - var user = await userRepo.GetByIdAsync(session.UserId, ct); - - if (user is not null) - { - context.SetUser(user); - - var org = await orgRepo.GetByIdAsync(user.OrganizationId, ct); - - if (org is not null) - { - context.SetOrganization(org); - } - } - } - } - - await _next(context); - } - -} \ No newline at end of file diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Options/ParagonOptions.cs b/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Options/ParagonOptions.cs new file mode 100644 index 0000000..59b4771 --- /dev/null +++ b/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Options/ParagonOptions.cs @@ -0,0 +1,12 @@ +namespace ParagonPlayground.Api.Options; + +internal class ParagonOptions +{ + public const string SectionName = "Paragon"; + + public string ProjectId { get; set; } = string.Empty; + + public string SigningKey { get; set; } = string.Empty; + + public string ProxyBaseUrl { get; set; } = "https://proxy.useparagon.com"; +} \ No newline at end of file diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Api/ParagonPlayground.Api.csproj b/src/ParagonPlayground/backend/src/ParagonPlayground.Api/ParagonPlayground.Api.csproj index 3ce35f6..02e676f 100644 --- a/src/ParagonPlayground/backend/src/ParagonPlayground.Api/ParagonPlayground.Api.csproj +++ b/src/ParagonPlayground/backend/src/ParagonPlayground.Api/ParagonPlayground.Api.csproj @@ -2,6 +2,8 @@ + + diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Program.cs b/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Program.cs index b98036b..b37bf2e 100644 --- a/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Program.cs +++ b/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Program.cs @@ -1,17 +1,22 @@ +using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.HttpOverrides; +using Microsoft.Extensions.Options; +using ParagonPlayground.Api.Auth; using ParagonPlayground.Api.Endpoints; using ParagonPlayground.Api.Middleware; using ParagonPlayground.Api.Options; +using ParagonPlayground.Api.Services; using ParagonPlayground.Infrastructure.Data; using ParagonPlayground.Infrastructure.Services; var builder = WebApplication.CreateBuilder(args); builder.Services.Configure(builder.Configuration.GetSection(MongoDbOptions.SectionName)); + builder.Services.AddSingleton(static sp => { - var opts = sp.GetRequiredService>().Value; + var opts = sp.GetRequiredService>().Value; return new MongoDbContext(opts.ConnectionString, opts.DatabaseName); }); @@ -20,6 +25,29 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +builder.Services.Configure(builder.Configuration.GetSection(ParagonOptions.SectionName)); + +builder.Services.AddHttpClient() + .AddStandardResilienceHandler(); + +builder.Services.AddProblemDetails(); +builder.Services.AddExceptionHandler(); + +builder.Services + .AddAuthentication(SessionAuthDefaults.Scheme) + .AddScheme( + SessionAuthDefaults.Scheme, + _ => { } + ); + +builder.Services + .AddAuthorizationBuilder() + .AddPolicy(PolicyNames.AdminOnly, policy => policy.RequireRole(RoleNames.Admin)); builder.Services.Configure(static options => { @@ -29,8 +57,14 @@ builder.Services.Configure(static options => var app = builder.Build(); app.UseForwardedHeaders(); +app.UseExceptionHandler(); +app.UseStatusCodePages(); app.UseMiddleware(); -app.UseMiddleware(); +app.UseAuthentication(); +app.UseAuthorization(); app.MapGroup("/api/auth").MapAuthEndpoints(); +app.MapGroup("/api/paragon").MapParagonEndpoints(); +app.MapGroup("/api/integration").MapIntegrationEndpoints(); +app.MapGroup("/api/storage").MapStorageEndpoints(); app.Run(); \ No newline at end of file diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Services/ParagonApiClient.cs b/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Services/ParagonApiClient.cs new file mode 100644 index 0000000..fef60fc --- /dev/null +++ b/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Services/ParagonApiClient.cs @@ -0,0 +1,107 @@ +using System.Net.Http.Headers; +using System.Text.Json; + +using Microsoft.Extensions.Options; + +using ParagonPlayground.Api.Options; + +namespace ParagonPlayground.Api.Services; + +internal sealed class ParagonApiClient +{ + private readonly HttpClient _httpClient; + private readonly string _projectId; + + public ParagonApiClient(HttpClient httpClient, IOptions options) + { + _httpClient = httpClient; + _projectId = options.Value.ProjectId; + _httpClient.BaseAddress = new Uri(options.Value.ProxyBaseUrl.TrimEnd('/') + "/"); + } + + public async Task UploadFileAsync( + string jwt, + string credentialId, + string siteId, + string folderPath, + string fileName, + Stream fileStream, + string contentType, + CancellationToken ct + ) + { + var url = $"projects/{_projectId}/sdk/proxy/sharepoint" + + $"/sites/{siteId}/drive/root:/{folderPath.Trim('/')}/{fileName}:/content"; + + using var ms = new MemoryStream(); + await fileStream.CopyToAsync(ms, ct).ConfigureAwait(false); + ms.Position = 0; + + using var request = new HttpRequestMessage(HttpMethod.Put, url) + { + Content = new StreamContent(ms), + }; + + _ = request.Headers.TryAddWithoutValidation("Authorization", $"Bearer {jwt}"); + _ = request.Headers.TryAddWithoutValidation("X-Paragon-Credential", credentialId); + request.Content.Headers.ContentType = new MediaTypeHeaderValue(contentType); + + using var response = await _httpClient.SendAsync(request, ct).ConfigureAwait(false); + _ = response.EnsureSuccessStatusCode(); + + return await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + } + + public async Task DownloadFileAsync( + string jwt, + string credentialId, + string siteId, + string driveItemId, + CancellationToken ct + ) + { + var url = $"projects/{_projectId}/sdk/proxy/sharepoint" + + $"/sites/{siteId}/drive/items/{driveItemId}/content"; + + using var request = new HttpRequestMessage(HttpMethod.Get, url); + + _ = request.Headers.TryAddWithoutValidation("Authorization", $"Bearer {jwt}"); + _ = request.Headers.TryAddWithoutValidation("X-Paragon-Credential", credentialId); + + var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false); + _ = response.EnsureSuccessStatusCode(); + + return await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false); + } + + public async Task ResolveSiteUrlAsync( + string jwt, + string credentialId, + string siteUrl, + CancellationToken ct + ) + { + var uri = new Uri(siteUrl); + var segments = uri.AbsolutePath.TrimEnd('/') + .Split('/', StringSplitOptions.RemoveEmptyEntries); + var encodedPath = segments.Length > 0 + ? ":/" + string.Join("/", segments.Select(Uri.EscapeDataString)) + : ""; + + var proxyUrl = $"projects/{_projectId}/sdk/proxy/sharepoint/sites/{uri.Host}{encodedPath}"; + + using var request = new HttpRequestMessage(HttpMethod.Get, proxyUrl); + _ = request.Headers.TryAddWithoutValidation("Authorization", $"Bearer {jwt}"); + _ = request.Headers.TryAddWithoutValidation("X-Paragon-Credential", credentialId); + + using var response = await _httpClient.SendAsync(request, ct).ConfigureAwait(false); + _ = response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + using var doc = JsonDocument.Parse(json); + + var output = doc.RootElement.GetProperty("output"); + var idProp = output.GetProperty("id"); + return idProp.GetString() ?? ""; + } +} \ No newline at end of file diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Services/ParagonService.cs b/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Services/ParagonService.cs new file mode 100644 index 0000000..35720dc --- /dev/null +++ b/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Services/ParagonService.cs @@ -0,0 +1,106 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text.Json; + +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; + +using ParagonPlayground.Api.Options; + +namespace ParagonPlayground.Api.Services; + +internal sealed class ParagonService(IOptions options, ParagonApiClient apiClient) +{ + private readonly ParagonOptions _options = options.Value; + private readonly ParagonApiClient _apiClient = apiClient; + + public string ProjectId => _options.ProjectId; + + public bool IsConfigured => + string.IsNullOrEmpty(_options.ProjectId) is false + && string.IsNullOrEmpty(_options.SigningKey) is false; + + public string GenerateToken(string organizationId, string? credentialId = null) + { + if (IsConfigured is false) + { + throw new InvalidOperationException("Paragon is not configured. Set Paragon:ProjectId and Paragon:SigningKey."); + } + + using var rsa = RSA.Create(); + rsa.ImportFromPem(_options.SigningKey); + + var key = new RsaSecurityKey(rsa.ExportParameters(true)) { KeyId = "paragon" }; + var signingCredentials = new SigningCredentials(key, SecurityAlgorithms.RsaSha256); + + var now = DateTime.UtcNow; + + var permissions = credentialId is not null + ? new Dictionary + { + ["integration:sharepoint"] = new Dictionary + { + [$"credential:{credentialId}"] = true, + }, + } + : (object)new Dictionary + { + ["integration:sharepoint"] = new Dictionary + { + ["credential:*"] = new[] { "credential:write" }, + }, + }; + + var claims = new[] + { + new Claim("sub", $"org:{organizationId}"), + new Claim("aud", $"useparagon.com/{_options.ProjectId}"), + new Claim("urn:useparagon:connect:permissions", JsonSerializer.Serialize(permissions)), + }; + + var token = new JwtSecurityToken( + claims: claims, + notBefore: now, + expires: now.AddHours(1), + signingCredentials: signingCredentials + ); + + return new JwtSecurityTokenHandler().WriteToken(token); + } + + public Task UploadFileAsync( + string jwt, + string credentialId, + string siteId, + string folderPath, + string fileName, + Stream fileStream, + string contentType, + CancellationToken ct + ) + { + return _apiClient.UploadFileAsync(jwt, credentialId, siteId, folderPath, fileName, fileStream, contentType, ct); + } + + public Task ResolveSiteUrlAsync( + string jwt, + string credentialId, + string siteUrl, + CancellationToken ct + ) + { + return _apiClient.ResolveSiteUrlAsync(jwt, credentialId, siteUrl, ct); + } + + public Task DownloadFileAsync( + string jwt, + string credentialId, + string siteId, + string driveItemId, + CancellationToken ct + ) + { + return _apiClient.DownloadFileAsync(jwt, credentialId, siteId, driveItemId, ct); + } +} \ No newline at end of file diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Cli/Commands/ProvisionUserCommand.cs b/src/ParagonPlayground/backend/src/ParagonPlayground.Cli/Commands/ProvisionUserCommand.cs index 0f47dda..29c9919 100644 --- a/src/ParagonPlayground/backend/src/ParagonPlayground.Cli/Commands/ProvisionUserCommand.cs +++ b/src/ParagonPlayground/backend/src/ParagonPlayground.Cli/Commands/ProvisionUserCommand.cs @@ -14,7 +14,8 @@ namespace ParagonPlayground.Cli.Commands; internal class ProvisionUserCommand( OrganizationRepository orgRepo, UserRepository userRepo, - PasswordService passwordService) : AsyncCommand + PasswordService passwordService +) : AsyncCommand { internal class Settings : CommandSettings { @@ -33,6 +34,10 @@ internal class ProvisionUserCommand( [Description("Organization slug")] [CommandOption("-o|--org-slug")] public required string OrgSlug { get; set; } + + [Description("User role (admin or member)")] + [CommandOption("-r|--role")] + public string Role { get; set; } = "member"; } protected override async Task ExecuteAsync(CommandContext context, Settings settings, CancellationToken cancellationToken) @@ -60,6 +65,7 @@ internal class ProvisionUserCommand( DisplayName = settings.Name, PasswordHash = passwordService.Hash(settings.Password), OrganizationId = org.Id, + Role = settings.Role, CreatedAt = DateTime.UtcNow, }; diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Cli/Commands/SeedCommand.cs b/src/ParagonPlayground/backend/src/ParagonPlayground.Cli/Commands/SeedCommand.cs index 5c0ea9f..f7e8361 100644 --- a/src/ParagonPlayground/backend/src/ParagonPlayground.Cli/Commands/SeedCommand.cs +++ b/src/ParagonPlayground/backend/src/ParagonPlayground.Cli/Commands/SeedCommand.cs @@ -41,11 +41,11 @@ internal class SeedCommand( var users = new[] { - (Email: "alice@acme.com", Name: "Alice", Password: "password123"), - (Email: "bob@acme.com", Name: "Bob", Password: "password123"), + (Email: "alice@acme.com", Name: "Alice", Password: "password123", Role: "admin"), + (Email: "bob@acme.com", Name: "Bob", Password: "password123", Role: "member"), }; - foreach (var (email, name, password) in users) + foreach (var (email, name, password, role) in users) { var existing = await userRepo.GetByEmailAsync(email, cancellationToken); @@ -58,6 +58,7 @@ internal class SeedCommand( DisplayName = name, PasswordHash = passwordService.Hash(password), OrganizationId = org.Id, + Role = role, CreatedAt = DateTime.UtcNow, }; @@ -67,7 +68,16 @@ internal class SeedCommand( } else { - AnsiConsole.MarkupLine($"[yellow]User '{email}' already exists[/]"); + if (existing.Role != role) + { + existing.Role = role; + await userRepo.ReplaceAsync(existing, cancellationToken); + AnsiConsole.MarkupLine($"[yellow]User '{email}' role updated to '{role}'[/]"); + } + else + { + AnsiConsole.MarkupLine($"[yellow]User '{email}' already exists[/]"); + } } } diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Domain/.editorconfig b/src/ParagonPlayground/backend/src/ParagonPlayground.Domain/.editorconfig new file mode 100644 index 0000000..43b12ee --- /dev/null +++ b/src/ParagonPlayground/backend/src/ParagonPlayground.Domain/.editorconfig @@ -0,0 +1,2 @@ +[*.{cs,vb}] +dotnet_diagnostic.CA1056.severity = none diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Domain/DTOs/CreateFolderRequest.cs b/src/ParagonPlayground/backend/src/ParagonPlayground.Domain/DTOs/CreateFolderRequest.cs new file mode 100644 index 0000000..d62d579 --- /dev/null +++ b/src/ParagonPlayground/backend/src/ParagonPlayground.Domain/DTOs/CreateFolderRequest.cs @@ -0,0 +1,11 @@ +namespace ParagonPlayground.Domain.DTOs; + +/// Request to create a new virtual folder. +public class CreateFolderRequest +{ + /// Folder name. + public string Name { get; set; } = string.Empty; + + /// Optional parent folder ID (null for root). + public string? ParentId { get; set; } +} \ No newline at end of file diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Domain/DTOs/CredentialRequest.cs b/src/ParagonPlayground/backend/src/ParagonPlayground.Domain/DTOs/CredentialRequest.cs new file mode 100644 index 0000000..17dfc05 --- /dev/null +++ b/src/ParagonPlayground/backend/src/ParagonPlayground.Domain/DTOs/CredentialRequest.cs @@ -0,0 +1,11 @@ +namespace ParagonPlayground.Domain.DTOs; + +/// Request to store a new Paragon credential mapping. +public class CredentialRequest +{ + /// Paragon credential ID from the integration install flow. + public string CredentialId { get; set; } = string.Empty; + + /// Integration type (e.g. "sharepoint"). + public string IntegrationType { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Domain/DTOs/CredentialResponse.cs b/src/ParagonPlayground/backend/src/ParagonPlayground.Domain/DTOs/CredentialResponse.cs new file mode 100644 index 0000000..385c554 --- /dev/null +++ b/src/ParagonPlayground/backend/src/ParagonPlayground.Domain/DTOs/CredentialResponse.cs @@ -0,0 +1,17 @@ +namespace ParagonPlayground.Domain.DTOs; + +/// Stored Paragon credential for the current user. +public class CredentialResponse +{ + /// Unique identifier. + public string Id { get; set; } = string.Empty; + + /// Paragon credential ID. + public string CredentialId { get; set; } = string.Empty; + + /// Integration type (e.g. "sharepoint"). + public string IntegrationType { get; set; } = string.Empty; + + /// Timestamp when the credential was connected. + public DateTime ConnectedAt { get; set; } +} \ No newline at end of file diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Domain/DTOs/DownloadResponse.cs b/src/ParagonPlayground/backend/src/ParagonPlayground.Domain/DTOs/DownloadResponse.cs new file mode 100644 index 0000000..c4d65a2 --- /dev/null +++ b/src/ParagonPlayground/backend/src/ParagonPlayground.Domain/DTOs/DownloadResponse.cs @@ -0,0 +1,11 @@ +namespace ParagonPlayground.Domain.DTOs; + +/// Download URLs for a stored file. +public class DownloadResponse +{ + /// Direct SharePoint web URL (opens in SharePoint). + public string? SharePointUrl { get; set; } + + /// App-proxied download URL (streams through the backend via Paragon). + public string? ProxyUrl { get; set; } +} \ No newline at end of file diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Domain/DTOs/IntegrationConfigRequest.cs b/src/ParagonPlayground/backend/src/ParagonPlayground.Domain/DTOs/IntegrationConfigRequest.cs new file mode 100644 index 0000000..31e97fa --- /dev/null +++ b/src/ParagonPlayground/backend/src/ParagonPlayground.Domain/DTOs/IntegrationConfigRequest.cs @@ -0,0 +1,14 @@ +namespace ParagonPlayground.Domain.DTOs; + +/// Request to create or update the organization's integration configuration. +public class IntegrationConfigRequest +{ + /// Connection mode: "default" (ISV-provided app) or "byo" (user-configured OAuth). + public string ConnectionMode { get; set; } = "default"; + + /// Target SharePoint site URL (e.g. https://contoso.sharepoint.com/sites/MySite). + public string? SharePointSiteUrl { get; set; } + + /// Target folder path within the SharePoint site. + public string? SharePointFolderPath { get; set; } +} \ No newline at end of file diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Domain/DTOs/IntegrationConfigResponse.cs b/src/ParagonPlayground/backend/src/ParagonPlayground.Domain/DTOs/IntegrationConfigResponse.cs new file mode 100644 index 0000000..f117ae2 --- /dev/null +++ b/src/ParagonPlayground/backend/src/ParagonPlayground.Domain/DTOs/IntegrationConfigResponse.cs @@ -0,0 +1,26 @@ +namespace ParagonPlayground.Domain.DTOs; + +/// Organization's Paragon/SharePoint integration configuration. +public class IntegrationConfigResponse +{ + /// Unique identifier. + public string Id { get; set; } = string.Empty; + + /// Organization this config belongs to. + public string OrganizationId { get; set; } = string.Empty; + + /// Connection mode: "default" (ISV-provided app) or "byo" (user-configured OAuth). + public string ConnectionMode { get; set; } = "default"; + + /// Full SharePoint site URL (e.g. https://contoso.sharepoint.com/sites/MySite). + public string? SharePointSiteUrl { get; set; } + + /// Resolved SharePoint site ID (e.g. contoso.sharepoint.com,guid,guid). + public string? SharePointSiteId { get; set; } + + /// Target folder path within SharePoint. + public string? SharePointFolderPath { get; set; } + + /// Timestamp of last update. + public DateTime UpdatedAt { get; set; } +} \ No newline at end of file diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Domain/DTOs/ParagonTokenResponse.cs b/src/ParagonPlayground/backend/src/ParagonPlayground.Domain/DTOs/ParagonTokenResponse.cs new file mode 100644 index 0000000..56a0629 --- /dev/null +++ b/src/ParagonPlayground/backend/src/ParagonPlayground.Domain/DTOs/ParagonTokenResponse.cs @@ -0,0 +1,11 @@ +namespace ParagonPlayground.Domain.DTOs; + +/// Response containing a signed Paragon JWT and project ID. +public class ParagonTokenResponse +{ + /// Signed JWT for authenticating with the Paragon SDK. + public string ParagonJwt { get; set; } = string.Empty; + + /// Paragon project ID for SDK initialization. + public string ProjectId { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Domain/DTOs/StorageItemResponse.cs b/src/ParagonPlayground/backend/src/ParagonPlayground.Domain/DTOs/StorageItemResponse.cs new file mode 100644 index 0000000..ea55bef --- /dev/null +++ b/src/ParagonPlayground/backend/src/ParagonPlayground.Domain/DTOs/StorageItemResponse.cs @@ -0,0 +1,35 @@ +namespace ParagonPlayground.Domain.DTOs; + +/// File or folder returned by the storage API. +public class StorageItemResponse +{ + /// Unique identifier. + public string Id { get; set; } = string.Empty; + + /// Display name. + public string Name { get; set; } = string.Empty; + + /// True for folders, false for files. + public bool IsFolder { get; set; } + + /// Parent folder ID (null for root items). + public string? ParentId { get; set; } + + /// MIME type (null for folders). + public string? ContentType { get; set; } + + /// File size in bytes (0 for folders). + public long FileSize { get; set; } + + /// Direct SharePoint web URL (null for folders). + public string? SharePointWebUrl { get; set; } + + /// User who created this item. + public string CreatedByUserId { get; set; } = string.Empty; + + /// Display name of the creator. + public string CreatedByDisplayName { get; set; } = string.Empty; + + /// Timestamp when the item was created. + public DateTime CreatedAt { get; set; } +} \ No newline at end of file diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Domain/DTOs/UserResponse.cs b/src/ParagonPlayground/backend/src/ParagonPlayground.Domain/DTOs/UserResponse.cs index 318eb66..1cfd2a3 100644 --- a/src/ParagonPlayground/backend/src/ParagonPlayground.Domain/DTOs/UserResponse.cs +++ b/src/ParagonPlayground/backend/src/ParagonPlayground.Domain/DTOs/UserResponse.cs @@ -20,4 +20,7 @@ public class UserResponse /// URL-friendly slug of the user's organization. public string OrganizationSlug { get; set; } = string.Empty; + + /// User's role within the organization ("admin" or "member"). + public string Role { get; set; } = "member"; } \ No newline at end of file diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Domain/Entities/OrganizationIntegration.cs b/src/ParagonPlayground/backend/src/ParagonPlayground.Domain/Entities/OrganizationIntegration.cs new file mode 100644 index 0000000..66d5f8f --- /dev/null +++ b/src/ParagonPlayground/backend/src/ParagonPlayground.Domain/Entities/OrganizationIntegration.cs @@ -0,0 +1,26 @@ +namespace ParagonPlayground.Domain.Entities; + +/// Per-organization Paragon/SharePoint integration configuration. +public class OrganizationIntegration +{ + /// Unique identifier (MongoDB ObjectId). + public string Id { get; set; } = string.Empty; + + /// Organization this config belongs to. + public string OrganizationId { get; set; } = string.Empty; + + /// Connection mode: "default" (ISV-provided app) or "byo" (user-configured OAuth). + public string ConnectionMode { get; set; } = "default"; + + /// Full SharePoint site URL (e.g. https://contoso.sharepoint.com/sites/MySite). + public string? SharePointSiteUrl { get; set; } + + /// Resolved SharePoint site ID (e.g. contoso.sharepoint.com,guid,guid). + public string? SharePointSiteId { get; set; } + + /// Target folder path within the SharePoint site. + public string? SharePointFolderPath { get; set; } + + /// Timestamp of the last configuration update. + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} \ No newline at end of file diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Domain/Entities/StorageItem.cs b/src/ParagonPlayground/backend/src/ParagonPlayground.Domain/Entities/StorageItem.cs new file mode 100644 index 0000000..448b6d2 --- /dev/null +++ b/src/ParagonPlayground/backend/src/ParagonPlayground.Domain/Entities/StorageItem.cs @@ -0,0 +1,41 @@ +namespace ParagonPlayground.Domain.Entities; + +/// Represents a file or folder in the virtual storage tree (independent of SharePoint structure). +public class StorageItem +{ + /// Unique identifier (MongoDB ObjectId). + public string Id { get; set; } = string.Empty; + + /// Organization this item belongs to. + public string OrganizationId { get; set; } = string.Empty; + + /// Display name of the file or folder. + public string Name { get; set; } = string.Empty; + + /// True if this is a folder, false if it's a file. + public bool IsFolder { get; set; } + + /// Parent folder ID (null for root-level items). + public string? ParentId { get; set; } + + /// MIME type of the file (null for folders). + public string? ContentType { get; set; } + + /// File size in bytes (0 for folders). + public long FileSize { get; set; } + + /// SharePoint site ID where the file was uploaded (null for folders). + public string? SharePointSiteId { get; set; } + + /// SharePoint drive item ID (null for folders). + public string? SharePointDriveItemId { get; set; } + + /// SharePoint web URL for direct access (null for folders). + public string? SharePointWebUrl { get; set; } + + /// User who created this item. + public string CreatedByUserId { get; set; } = string.Empty; + + /// Timestamp when the item was created. + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} \ No newline at end of file diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Domain/Entities/User.cs b/src/ParagonPlayground/backend/src/ParagonPlayground.Domain/Entities/User.cs index 6374e67..de14c10 100644 --- a/src/ParagonPlayground/backend/src/ParagonPlayground.Domain/Entities/User.cs +++ b/src/ParagonPlayground/backend/src/ParagonPlayground.Domain/Entities/User.cs @@ -18,6 +18,9 @@ public class User /// Identifier of the organization this user belongs to. public string OrganizationId { get; set; } = string.Empty; + /// Role within the organization: "admin" or "member". + public string Role { get; set; } = "member"; + /// Timestamp when the user was created. public DateTime CreatedAt { get; set; } = DateTime.UtcNow; } \ No newline at end of file diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Domain/Entities/UserCredential.cs b/src/ParagonPlayground/backend/src/ParagonPlayground.Domain/Entities/UserCredential.cs new file mode 100644 index 0000000..54837c4 --- /dev/null +++ b/src/ParagonPlayground/backend/src/ParagonPlayground.Domain/Entities/UserCredential.cs @@ -0,0 +1,23 @@ +namespace ParagonPlayground.Domain.Entities; + +/// Maps an app user to their Paragon integration credential. +public class UserCredential +{ + /// Unique identifier (MongoDB ObjectId). + public string Id { get; set; } = string.Empty; + + /// App user who owns this credential. + public string UserId { get; set; } = string.Empty; + + /// Organization the user belongs to. + public string OrganizationId { get; set; } = string.Empty; + + /// Paragon credential ID from the integration install flow. + public string CredentialId { get; set; } = string.Empty; + + /// Integration type (e.g. "sharepoint"). + public string IntegrationType { get; set; } = string.Empty; + + /// Timestamp when the credential was connected. + public DateTime ConnectedAt { get; set; } = DateTime.UtcNow; +} \ No newline at end of file diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Infrastructure/Data/MongoDbContext.cs b/src/ParagonPlayground/backend/src/ParagonPlayground.Infrastructure/Data/MongoDbContext.cs index 4e3d833..4e1d8b0 100644 --- a/src/ParagonPlayground/backend/src/ParagonPlayground.Infrastructure/Data/MongoDbContext.cs +++ b/src/ParagonPlayground/backend/src/ParagonPlayground.Infrastructure/Data/MongoDbContext.cs @@ -22,6 +22,18 @@ public sealed class MongoDbContext : IDisposable public IMongoCollection Sessions => _database.GetCollection("Sessions"); + /// Storage items collection. + public IMongoCollection StorageItems => + _database.GetCollection("StorageItems"); + + /// User credentials collection. + public IMongoCollection UserCredentials => + _database.GetCollection("UserCredentials"); + + /// Organization integrations collection. + public IMongoCollection OrganizationIntegrations => + _database.GetCollection("OrganizationIntegrations"); + /// Initializes a new MongoDbContext and connects to the specified database. public MongoDbContext(string connectionString, string databaseName) { diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Infrastructure/Data/OrganizationIntegrationRepository.cs b/src/ParagonPlayground/backend/src/ParagonPlayground.Infrastructure/Data/OrganizationIntegrationRepository.cs new file mode 100644 index 0000000..a3926a8 --- /dev/null +++ b/src/ParagonPlayground/backend/src/ParagonPlayground.Infrastructure/Data/OrganizationIntegrationRepository.cs @@ -0,0 +1,38 @@ +using MongoDB.Driver; + +using ParagonPlayground.Domain.Entities; + +namespace ParagonPlayground.Infrastructure.Data; + +/// Repository for organization integration configuration data access. +public class OrganizationIntegrationRepository(MongoDbContext context) +{ + private readonly MongoDbContext _context = context; + + /// Finds the integration config for an organization. + public async Task GetByOrganizationIdAsync( + string organizationId, + CancellationToken ct + ) + { + return await _context.OrganizationIntegrations + .Find(c => c.OrganizationId == organizationId) + .FirstOrDefaultAsync(ct) + .ConfigureAwait(false); + } + + /// Creates or replaces the integration config for an organization. + public async Task UpsertAsync(OrganizationIntegration config, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(config); + + var filter = Builders.Filter + .Eq(c => c.OrganizationId, config.OrganizationId); + + var options = new ReplaceOptions() { IsUpsert = true }; + + _ = await _context.OrganizationIntegrations + .ReplaceOneAsync(filter, config, options, ct) + .ConfigureAwait(false); + } +} \ No newline at end of file diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Infrastructure/Data/OrganizationRepository.cs b/src/ParagonPlayground/backend/src/ParagonPlayground.Infrastructure/Data/OrganizationRepository.cs index 9754c6c..3a195de 100644 --- a/src/ParagonPlayground/backend/src/ParagonPlayground.Infrastructure/Data/OrganizationRepository.cs +++ b/src/ParagonPlayground/backend/src/ParagonPlayground.Infrastructure/Data/OrganizationRepository.cs @@ -12,18 +12,26 @@ public class OrganizationRepository(MongoDbContext context) /// Finds an organization by its URL slug. public async Task GetBySlugAsync(string slug, CancellationToken cancellationToken) { - return await _context.Organizations.Find(o => o.Slug == slug).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false); + return await _context.Organizations + .Find(o => o.Slug == slug) + .FirstOrDefaultAsync(cancellationToken) + .ConfigureAwait(false); } /// Finds an organization by its unique identifier. public async Task GetByIdAsync(string id, CancellationToken cancellationToken) { - return await _context.Organizations.Find(o => o.Id == id).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false); + return await _context.Organizations + .Find(o => o.Id == id) + .FirstOrDefaultAsync(cancellationToken) + .ConfigureAwait(false); } /// Creates a new organization in the database. public async Task CreateAsync(Organization organization, CancellationToken cancellationToken) { - await _context.Organizations.InsertOneAsync(organization, cancellationToken: cancellationToken).ConfigureAwait(false); + await _context.Organizations + .InsertOneAsync(organization, cancellationToken: cancellationToken) + .ConfigureAwait(false); } } \ No newline at end of file diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Infrastructure/Data/SessionRepository.cs b/src/ParagonPlayground/backend/src/ParagonPlayground.Infrastructure/Data/SessionRepository.cs index ec5a358..ceb4619 100644 --- a/src/ParagonPlayground/backend/src/ParagonPlayground.Infrastructure/Data/SessionRepository.cs +++ b/src/ParagonPlayground/backend/src/ParagonPlayground.Infrastructure/Data/SessionRepository.cs @@ -12,24 +12,33 @@ public class SessionRepository(MongoDbContext context) /// Finds a session by its token hash. public async Task GetByTokenHashAsync(string tokenHash, CancellationToken cancellationToken) { - return await _context.Sessions.Find(s => s.TokenHash == tokenHash).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false); + return await _context.Sessions + .Find(s => s.TokenHash == tokenHash) + .FirstOrDefaultAsync(cancellationToken) + .ConfigureAwait(false); } /// Creates a new session in the database. public async Task CreateAsync(Session session, CancellationToken cancellationToken) { - await _context.Sessions.InsertOneAsync(session, new InsertOneOptions(), cancellationToken).ConfigureAwait(false); + await _context.Sessions + .InsertOneAsync(session, new InsertOneOptions(), cancellationToken) + .ConfigureAwait(false); } /// Deletes a session by its unique identifier. public async Task DeleteAsync(string id, CancellationToken cancellationToken) { - _ = await _context.Sessions.DeleteOneAsync(s => s.Id == id, cancellationToken).ConfigureAwait(false); + _ = await _context.Sessions + .DeleteOneAsync(s => s.Id == id, cancellationToken) + .ConfigureAwait(false); } /// Deletes all sessions for a given user. public async Task DeleteByUserIdAsync(string userId, CancellationToken cancellationToken) { - _ = await _context.Sessions.DeleteManyAsync(s => s.UserId == userId, cancellationToken).ConfigureAwait(false); + _ = await _context.Sessions + .DeleteManyAsync(s => s.UserId == userId, cancellationToken) + .ConfigureAwait(false); } } \ No newline at end of file diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Infrastructure/Data/StorageItemRepository.cs b/src/ParagonPlayground/backend/src/ParagonPlayground.Infrastructure/Data/StorageItemRepository.cs new file mode 100644 index 0000000..c1909f3 --- /dev/null +++ b/src/ParagonPlayground/backend/src/ParagonPlayground.Infrastructure/Data/StorageItemRepository.cs @@ -0,0 +1,51 @@ +using MongoDB.Driver; + +using ParagonPlayground.Domain.Entities; + +namespace ParagonPlayground.Infrastructure.Data; + +/// Repository for storage item (file/folder) data access. +public class StorageItemRepository(MongoDbContext context) +{ + private readonly MongoDbContext _context = context; + + /// Lists items in a folder (or root items when parentId is null). + public async Task> GetByParentIdAsync( + string organizationId, string? parentId, CancellationToken ct) + { + var filter = Builders.Filter.Eq(i => i.OrganizationId, organizationId) + & Builders.Filter.Eq(i => i.ParentId, parentId); + + return await _context.StorageItems.Find(filter) + .SortByDescending(i => i.IsFolder) + .ThenBy(i => i.Name) + .ToListAsync(ct) + .ConfigureAwait(false); + } + + /// Finds a storage item by ID. + public async Task GetByIdAsync(string id, CancellationToken ct) + { + return await _context.StorageItems.Find(i => i.Id == id) + .FirstOrDefaultAsync(ct) + .ConfigureAwait(false); + } + + /// Creates a new storage item. + public async Task CreateAsync(StorageItem item, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(item); + + await _context.StorageItems + .InsertOneAsync(item, cancellationToken: ct) + .ConfigureAwait(false); + } + + /// Deletes a storage item by ID. + public async Task DeleteAsync(string id, CancellationToken ct) + { + _ = await _context.StorageItems + .DeleteOneAsync(i => i.Id == id, ct) + .ConfigureAwait(false); + } +} \ No newline at end of file diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Infrastructure/Data/UserCredentialRepository.cs b/src/ParagonPlayground/backend/src/ParagonPlayground.Infrastructure/Data/UserCredentialRepository.cs new file mode 100644 index 0000000..006c167 --- /dev/null +++ b/src/ParagonPlayground/backend/src/ParagonPlayground.Infrastructure/Data/UserCredentialRepository.cs @@ -0,0 +1,64 @@ +using MongoDB.Driver; + +using ParagonPlayground.Domain.Entities; + +namespace ParagonPlayground.Infrastructure.Data; + +/// Repository for user credential data access. +public class UserCredentialRepository(MongoDbContext context) +{ + private readonly MongoDbContext _context = context; + + /// Finds all credentials for a given user. + public async Task> GetByUserIdAsync(string userId, CancellationToken ct) + { + return await _context.UserCredentials + .Find(c => c.UserId == userId) + .ToListAsync(ct) + .ConfigureAwait(false); + } + + /// Finds all credentials for a given organization. + public async Task> GetByOrganizationIdAsync(string organizationId, CancellationToken ct) + { + return await _context.UserCredentials + .Find(c => c.OrganizationId == organizationId) + .ToListAsync(ct) + .ConfigureAwait(false); + } + + /// Stores a new credential mapping. + public async Task CreateAsync(UserCredential credential, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(credential); + + await _context.UserCredentials + .InsertOneAsync(credential, cancellationToken: ct) + .ConfigureAwait(false); + } + + /// Deletes all credentials for a given organization. + public async Task DeleteByOrganizationIdAsync(string organizationId, CancellationToken ct) + { + var result = await _context.UserCredentials + .DeleteManyAsync(c => c.OrganizationId == organizationId, ct) + .ConfigureAwait(false); + + return result.DeletedCount; + } + + /// Deletes a credential by its Paragon credential ID for a given user. + public async Task DeleteByCredentialIdAsync(string credentialId, string userId, CancellationToken ct) + { + var filter = Builders.Filter.And( + Builders.Filter.Eq(c => c.CredentialId, credentialId), + Builders.Filter.Eq(c => c.UserId, userId) + ); + + var result = await _context.UserCredentials + .DeleteOneAsync(filter, ct) + .ConfigureAwait(false); + + return result.DeletedCount > 0; + } +} \ No newline at end of file diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Infrastructure/Data/UserRepository.cs b/src/ParagonPlayground/backend/src/ParagonPlayground.Infrastructure/Data/UserRepository.cs index 340065b..ffc5652 100644 --- a/src/ParagonPlayground/backend/src/ParagonPlayground.Infrastructure/Data/UserRepository.cs +++ b/src/ParagonPlayground/backend/src/ParagonPlayground.Infrastructure/Data/UserRepository.cs @@ -18,12 +18,27 @@ public class UserRepository(MongoDbContext context) /// Finds a user by their unique identifier. public async Task GetByIdAsync(string id, CancellationToken cancellationToken) { - return await _context.Users.Find(u => u.Id == id).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false); + return await _context.Users + .Find(u => u.Id == id) + .FirstOrDefaultAsync(cancellationToken) + .ConfigureAwait(false); } /// Creates a new user in the database. public async Task CreateAsync(User user, CancellationToken cancellationToken) { - await _context.Users.InsertOneAsync(user, new InsertOneOptions(), cancellationToken).ConfigureAwait(false); + await _context.Users + .InsertOneAsync(user, new InsertOneOptions(), cancellationToken) + .ConfigureAwait(false); + } + + /// Replaces an existing user document. + public async Task ReplaceAsync(User user, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(user); + + _ = await _context.Users + .ReplaceOneAsync(u => u.Id == user.Id, user, cancellationToken: cancellationToken) + .ConfigureAwait(false); } } \ No newline at end of file diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Infrastructure/Services/CookieService.cs b/src/ParagonPlayground/backend/src/ParagonPlayground.Infrastructure/Services/CookieService.cs index 2910f4c..26bfdda 100644 --- a/src/ParagonPlayground/backend/src/ParagonPlayground.Infrastructure/Services/CookieService.cs +++ b/src/ParagonPlayground/backend/src/ParagonPlayground.Infrastructure/Services/CookieService.cs @@ -91,4 +91,4 @@ public class CookieService && string.IsNullOrEmpty(headerToken) is false && cookieToken == headerToken; } -} +} \ No newline at end of file diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Infrastructure/Services/PasswordService.cs b/src/ParagonPlayground/backend/src/ParagonPlayground.Infrastructure/Services/PasswordService.cs index 4e8a7be..562833c 100644 --- a/src/ParagonPlayground/backend/src/ParagonPlayground.Infrastructure/Services/PasswordService.cs +++ b/src/ParagonPlayground/backend/src/ParagonPlayground.Infrastructure/Services/PasswordService.cs @@ -14,4 +14,4 @@ public class PasswordService { return BCrypt.Net.BCrypt.Verify(password, hash); } -} +} \ No newline at end of file diff --git a/src/ParagonPlayground/docker-compose.debug.yml b/src/ParagonPlayground/docker-compose.debug.yml new file mode 100644 index 0000000..233e636 --- /dev/null +++ b/src/ParagonPlayground/docker-compose.debug.yml @@ -0,0 +1,52 @@ +services: + mongodb: + image: mongo:7 + ports: + - "27017:27017" + volumes: + - mongo_data:/data/db + healthcheck: + test: echo 'db.runCommand("ping").ok' | mongosh --quiet + interval: 10s + retries: 5 + start_period: 20s + backend: + container_name: paragonplayground-backend + build: + context: ./backend + dockerfile: Dockerfile + target: debug + ports: + - "5000:8080" + environment: + - MongoDb__ConnectionString=mongodb://mongodb:27017 + - MongoDb__DatabaseName=paragon_playground + - ASPNETCORE_ENVIRONMENT=Development + - ASPNETCORE_URLS=http://+:8080 + depends_on: + mongodb: + condition: service_healthy + frontend: + container_name: paragonplayground-frontend + build: + context: ./frontend + dockerfile: Dockerfile + ports: + - "3000:3000" + volumes: + - ./frontend/src:/app/src + depends_on: + - backend + nginx: + image: nginx:alpine + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf + - ./certs:/etc/nginx/certs + depends_on: + - backend + - frontend +volumes: + mongo_data: diff --git a/src/ParagonPlayground/docker-compose.dev.yml b/src/ParagonPlayground/docker-compose.dev.yml index 346ce4a..9cf132d 100644 --- a/src/ParagonPlayground/docker-compose.dev.yml +++ b/src/ParagonPlayground/docker-compose.dev.yml @@ -15,8 +15,11 @@ services: build: context: ./backend dockerfile: Dockerfile + target: development ports: - "5000:8080" + volumes: + - ./backend:/src environment: - MongoDb__ConnectionString=mongodb://mongodb:27017 - MongoDb__DatabaseName=paragon_playground diff --git a/src/ParagonPlayground/frontend/.prettierrc b/src/ParagonPlayground/frontend/.prettierrc index 6c900fd..45812bb 100644 --- a/src/ParagonPlayground/frontend/.prettierrc +++ b/src/ParagonPlayground/frontend/.prettierrc @@ -1,8 +1,12 @@ { - "semi": false, + "semi": true, "singleQuote": true, "trailingComma": "all", "printWidth": 100, "tabWidth": 2, - "arrowParens": "always" + "arrowParens": "always", + "bracketSpacing": true, + "vueIndentScriptAndStyle": true, + "endOfLine": "auto", + "singleAttributePerLine": true } diff --git a/src/ParagonPlayground/frontend/eslint.config.js b/src/ParagonPlayground/frontend/eslint.config.js index 149d927..bca5c69 100644 --- a/src/ParagonPlayground/frontend/eslint.config.js +++ b/src/ParagonPlayground/frontend/eslint.config.js @@ -1,7 +1,8 @@ -import js from '@eslint/js' -import tseslint from 'typescript-eslint' -import pluginVue from 'eslint-plugin-vue' -import prettier from 'eslint-config-prettier' +import js from '@eslint/js'; +import tseslint from 'typescript-eslint'; +import pluginVue from 'eslint-plugin-vue'; +import prettier from 'eslint-config-prettier'; +import globals from 'globals'; export default tseslint.config( js.configs.recommended, @@ -13,10 +14,21 @@ export default tseslint.config( parserOptions: { parser: tseslint.parser, }, + globals: { + ...globals.browser, + }, + }, + }, + { + files: ['*.ts', '**/*.ts'], + languageOptions: { + globals: { + ...globals.browser, + }, }, }, prettier, { ignores: ['dist/', 'node_modules/'], }, -) +); diff --git a/src/ParagonPlayground/frontend/index.html b/src/ParagonPlayground/frontend/index.html index 5c1d54e..e9a5387 100644 --- a/src/ParagonPlayground/frontend/index.html +++ b/src/ParagonPlayground/frontend/index.html @@ -2,11 +2,17 @@ - + paragon-playground
- + diff --git a/src/ParagonPlayground/frontend/package-lock.json b/src/ParagonPlayground/frontend/package-lock.json index 6d13134..5b91aa7 100644 --- a/src/ParagonPlayground/frontend/package-lock.json +++ b/src/ParagonPlayground/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "paragon-playground", "version": "0.0.0", "dependencies": { + "@useparagon/connect": "^2.5.0", "vue": "^3.5.39", "vue-router": "^4.6.4" }, @@ -19,13 +20,281 @@ "eslint": "^10.8.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-vue": "^10.10.0", + "globals": "^17.8.0", "prettier": "^3.9.6", "typescript": "~6.0.2", "typescript-eslint": "^8.65.0", "vite": "^8.1.1", + "vite-plugin-vue-devtools": "^8.2.1", "vue-tsc": "^3.3.5" } }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.29.7.tgz", + "integrity": "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.29.7.tgz", + "integrity": "sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-member-expression-to-functions": "^7.29.7", + "@babel/helper-optimise-call-expression": "^7.29.7", + "@babel/helper-replace-supers": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", + "@babel/traverse": "^7.29.7", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.29.7.tgz", + "integrity": "sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.29.7.tgz", + "integrity": "sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.29.7.tgz", + "integrity": "sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-member-expression-to-functions": "^7.29.7", + "@babel/helper-optimise-call-expression": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.29.7.tgz", + "integrity": "sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-string-parser": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", @@ -44,14 +313,38 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/parser": { + "node_modules/@babel/helper-validator-option": { "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", - "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, "license": "MIT", "dependencies": { + "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, "bin": { "parser": "bin/babel-parser.js" }, @@ -59,10 +352,159 @@ "node": ">=6.0.0" } }, - "node_modules/@babel/types": { + "node_modules/@babel/plugin-proposal-decorators": { "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", - "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.29.7.tgz", + "integrity": "sha512-EtU0Hi3GvrTqD56xKmZvV/uCXK2ZbwVNPNLAquVItcAZpUhkXwWlo3Fmj0c2LxgSf2I8IDULeAepwNP1OefLXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-syntax-decorators": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-decorators": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.29.7.tgz", + "integrity": "sha512-9MTTLbF39X6sqM92JPEsoI7++26hjZvzkxKZy64aMhWLH2mPkJ/Q3AV4QLmls3R14FpSpkOwQQfUh962JGQxxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.29.7.tgz", + "integrity": "sha512-zGYcYfq/WmZ4V+kBIXQon9dSSc8ircGZqw9ZaNhhGj9nZkeBu1jHLBDQqYYi5WA9uawvA2sIMbry2nCFhf5Djg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.29.7.tgz", + "integrity": "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.29.7.tgz", + "integrity": "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typescript": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.29.7.tgz", + "integrity": "sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", + "@babel/plugin-syntax-typescript": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.29.7", @@ -303,12 +745,55 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "license": "MIT" }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.0.tgz", @@ -341,6 +826,13 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", @@ -929,6 +1421,28 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@useparagon/connect": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@useparagon/connect/-/connect-2.5.0.tgz", + "integrity": "sha512-6IzHzyMOMIGFi5oei6Ie9GdibUjPhdiNMXoQr2Kq1GaJQ+oJ774+u4zX7pPOTEwnLP0aMOGo3jaUTM1+DZKIEg==", + "license": "MIT", + "dependencies": { + "clone-deep": "^4.0.1", + "hash.js": "^1.1.7", + "jwt-decode": "^3.1.2", + "lodash": "^4.17.23", + "tslib": "2.3.1" + }, + "peerDependencies": { + "react": "^17 || ^18 || ^19" + } + }, + "node_modules/@useparagon/connect/node_modules/tslib": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz", + "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==", + "license": "0BSD" + }, "node_modules/@vitejs/plugin-vue": { "version": "6.0.8", "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.8.tgz", @@ -975,6 +1489,59 @@ "vscode-uri": "^3.0.8" } }, + "node_modules/@vue/babel-helper-vue-transform-on": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@vue/babel-helper-vue-transform-on/-/babel-helper-vue-transform-on-1.5.0.tgz", + "integrity": "sha512-0dAYkerNhhHutHZ34JtTl2czVQHUNWv6xEbkdF5W+Yrv5pCWsqjeORdOgbtW2I9gWlt+wBmVn+ttqN9ZxR5tzA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vue/babel-plugin-jsx": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@vue/babel-plugin-jsx/-/babel-plugin-jsx-1.5.0.tgz", + "integrity": "sha512-mneBhw1oOqCd2247O0Yw/mRwC9jIGACAJUlawkmMBiNmL4dGA2eMzuNZVNqOUfYTa6vqmND4CtOPzmEEEqLKFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.0", + "@babel/types": "^7.28.2", + "@vue/babel-helper-vue-transform-on": "1.5.0", + "@vue/babel-plugin-resolve-type": "1.5.0", + "@vue/shared": "^3.5.18" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + } + } + }, + "node_modules/@vue/babel-plugin-resolve-type": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@vue/babel-plugin-resolve-type/-/babel-plugin-resolve-type-1.5.0.tgz", + "integrity": "sha512-Wm/60o+53JwJODm4Knz47dxJnLDJ9FnKnGZJbUUf8nQRAtt6P+undLUAVU3Ha33LxOJe6IPoifRQ6F/0RrU31w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/parser": "^7.28.0", + "@vue/compiler-sfc": "^3.5.18" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/@vue/compiler-core": { "version": "3.5.40", "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.40.tgz", @@ -1031,6 +1598,40 @@ "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", "license": "MIT" }, + "node_modules/@vue/devtools-core": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/@vue/devtools-core/-/devtools-core-8.2.1.tgz", + "integrity": "sha512-s/VfAY9oDTb/kFEWmy461jaFde2MIV1RO/gi1vwM+PAZBZ/Pc2Ndu3BNBdZUze8QDUuyYvElbEEGA83syjJfzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^8.2.1", + "@vue/devtools-shared": "^8.2.1" + }, + "peerDependencies": { + "vue": "^3.0.0" + } + }, + "node_modules/@vue/devtools-kit": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-8.2.1.tgz", + "integrity": "sha512-FIGIuq3AWReEpbAHY/cRGeHDfI0qOb8OCQ3YjbEAX04uaxIDbGc9rhkbVcG7rnfHPXE3RsU5KrWOu9V/okd8AQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^8.2.1", + "birpc": "^2.6.1", + "hookable": "^5.5.3", + "perfect-debounce": "^2.0.0" + } + }, + "node_modules/@vue/devtools-shared": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-8.2.1.tgz", + "integrity": "sha512-Fkac7lUdGReh6pVOi3AYPRGe82LQqRmAfThW7RRligOAP0ZA/Z1z9XLHDM9dv34pV2HRc79DK8uKPeG2fLnA/g==", + "dev": true, + "license": "MIT" + }, "node_modules/@vue/language-core": { "version": "3.3.8", "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.3.8.tgz", @@ -1161,6 +1762,16 @@ "dev": true, "license": "MIT" }, + "node_modules/ansis": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.3.1.tgz", + "integrity": "sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + } + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -1171,6 +1782,29 @@ "node": "18 || 20 || >=22" } }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.9", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.9.tgz", + "integrity": "sha512-cp447VUsGS07+n1Dqf7YSQ8maeJrjEhaDxTm1ZefbqDtypHBC5GzGMQbklR6IPR13Y8OAJRHZWEMtZipJLCttg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/birpc": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", + "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -1191,6 +1825,98 @@ "node": "20 || >=22" } }, + "node_modules/browserslist": { + "version": "4.28.7", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", + "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.44", + "caniuse-lite": "^1.0.30001806", + "electron-to-chromium": "^1.5.393", + "node-releases": "^2.0.51", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001806", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/clone-deep": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", + "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==", + "license": "MIT", + "dependencies": { + "is-plain-object": "^2.0.4", + "kind-of": "^6.0.2", + "shallow-clone": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -1250,6 +1976,49 @@ "dev": true, "license": "MIT" }, + "node_modules/default-browser": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", + "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -1260,6 +2029,13 @@ "node": ">=8" } }, + "node_modules/electron-to-chromium": { + "version": "1.5.399", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.399.tgz", + "integrity": "sha512-lEcqhErbHjXRvd41rnWLpzbyU/IXfIYo7QwaFWmxGeLiLyY2TBCdHnWY88vB+p3ubnihRypDm66panXl7TylLA==", + "dev": true, + "license": "ISC" + }, "node_modules/entities": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", @@ -1272,6 +2048,26 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/error-stack-parser-es": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", + "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -1599,6 +2395,16 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -1612,6 +2418,36 @@ "node": ">=10.13.0" } }, + "node_modules/globals": { + "version": "17.8.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.8.0.tgz", + "integrity": "sha512-Zz/LMDZScFmkakeL2cTHzf+PbWKdpU3uclqkZT7TjDG58j5WPt0PpA+n9uPI24fZtlw07q0OtEi84K+umsRzqQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/hash.js": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", + "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "minimalistic-assert": "^1.0.1" + } + }, + "node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", + "dev": true, + "license": "MIT" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -1632,6 +2468,28 @@ "node": ">=0.8.19" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -1655,6 +2513,66 @@ "node": ">=0.10.0" } }, + "node_modules/is-in-ssh": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-in-ssh/-/is-in-ssh-1.0.0.tgz", + "integrity": "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "license": "MIT", + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -1662,6 +2580,35 @@ "dev": true, "license": "ISC" }, + "node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -1683,6 +2630,25 @@ "dev": true, "license": "MIT" }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jwt-decode": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz", + "integrity": "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A==", + "license": "MIT" + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -1693,6 +2659,22 @@ "json-buffer": "3.0.1" } }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/kolorist": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz", + "integrity": "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==", + "dev": true, + "license": "MIT" + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -1996,6 +2978,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -2005,6 +3003,12 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, "node_modules/minimatch": { "version": "10.2.6", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", @@ -2021,6 +3025,16 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -2060,6 +3074,16 @@ "dev": true, "license": "MIT" }, + "node_modules/node-releases": { + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/nth-check": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", @@ -2073,6 +3097,48 @@ "url": "https://github.com/fb55/nth-check?sponsor=1" } }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/ohash": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/open": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/open/-/open-11.0.0.tgz", + "integrity": "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "default-browser": "^5.4.0", + "define-lazy-prop": "^3.0.0", + "is-in-ssh": "^1.0.0", + "is-inside-container": "^1.0.0", + "powershell-utils": "^0.1.0", + "wsl-utils": "^0.3.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -2150,6 +3216,20 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/perfect-debounce": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", + "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2211,6 +3291,19 @@ "node": ">=4" } }, + "node_modules/powershell-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz", + "integrity": "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -2247,6 +3340,16 @@ "node": ">=6" } }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/rolldown": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", @@ -2281,6 +3384,19 @@ "@rolldown/binding-win32-x64-msvc": "1.1.5" } }, + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/semver": { "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", @@ -2294,6 +3410,18 @@ "node": ">=10" } }, + "node_modules/shallow-clone": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", + "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==", + "license": "MIT", + "dependencies": { + "kind-of": "^6.0.2" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -2317,6 +3445,21 @@ "node": ">=8" } }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2343,6 +3486,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -2422,6 +3575,54 @@ "dev": true, "license": "MIT" }, + "node_modules/unplugin-utils": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/unplugin-utils/-/unplugin-utils-0.3.2.tgz", + "integrity": "sha512-xVToRh2CTmLk2HnEG7ac4rl1MJTT3RFkpS8B++/SnB0kXvuaavD+n3m/vrzyWQOdJNSZQACnbz01pnppbwV5BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -2517,6 +3718,120 @@ } } }, + "node_modules/vite-dev-rpc": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/vite-dev-rpc/-/vite-dev-rpc-2.0.0.tgz", + "integrity": "sha512-yKwbTwdHKSD2k/aGqyWpPHepo45OQc8lH3/6IfT4ZqeKE26ooKvi4WIEKzqWav8v+9Is8u1k8q54hvOmqASazA==", + "dev": true, + "license": "MIT", + "dependencies": { + "birpc": "^4.0.0", + "vite-hot-client": "^2.2.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vite": "^2.9.0 || ^3.0.0-0 || ^4.0.0-0 || ^5.0.0-0 || ^6.0.1 || ^7.0.0-0 || ^8.0.0" + } + }, + "node_modules/vite-dev-rpc/node_modules/birpc": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-4.0.0.tgz", + "integrity": "sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/vite-hot-client": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/vite-hot-client/-/vite-hot-client-2.2.0.tgz", + "integrity": "sha512-76Zs9zrHbH7M7wqeyooGQKdX+yg0pQ0xuQ1PbFp4z5a0Lzn2e5IPFoCswnmqZ4GiwqB4Jo3WcDAMO9jARTJl8w==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vite": "^2.6.0 || ^3.0.0 || ^4.0.0 || ^5.0.0-0 || ^6.0.0-0 || ^7.0.0-0 || ^8.0.0" + } + }, + "node_modules/vite-plugin-inspect": { + "version": "11.4.1", + "resolved": "https://registry.npmjs.org/vite-plugin-inspect/-/vite-plugin-inspect-11.4.1.tgz", + "integrity": "sha512-ShOFe2PURXGvRS5OrgmOLZOCwDTD7dEBVt0tMpFPKb9AsvqXKCRGM8QgKrUbRbJYFXScHvDPpGRd28rYidC0tA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansis": "^4.3.0", + "error-stack-parser-es": "^1.0.5", + "obug": "^2.1.1", + "ohash": "^2.0.11", + "open": "^11.0.0", + "perfect-debounce": "^2.1.0", + "sirv": "^3.0.2", + "unplugin-utils": "^0.3.1", + "vite-dev-rpc": "^2.0.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vite": "^6.0.0 || ^7.0.0-0 || ^8.0.0-0" + }, + "peerDependenciesMeta": { + "@nuxt/kit": { + "optional": true + } + } + }, + "node_modules/vite-plugin-vue-devtools": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/vite-plugin-vue-devtools/-/vite-plugin-vue-devtools-8.2.1.tgz", + "integrity": "sha512-5JLxXWWCo5lJMw16/xVeNvJ8k2zLwZPf1vITLzya/2IePrCBeGe/p/iAokgXHZpEi39fcYtPXmO8SaKeXmqCAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/devtools-core": "^8.2.1", + "@vue/devtools-kit": "^8.2.1", + "@vue/devtools-shared": "^8.2.1", + "sirv": "^3.0.2", + "vite-plugin-inspect": "^11.3.3", + "vite-plugin-vue-inspector": "^6.0.0" + }, + "engines": { + "node": ">=v14.21.3" + }, + "peerDependencies": { + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/vite-plugin-vue-inspector": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/vite-plugin-vue-inspector/-/vite-plugin-vue-inspector-6.0.0.tgz", + "integrity": "sha512-OpyITJLgZNibxlrik1EmRtvXHDjLRxNPsWkGFTERZs2LgMEdG4W0WoFt5GIgp3a3jRou+eJR8U1zOBk/XQgEbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.23.0", + "@babel/plugin-proposal-decorators": "^7.23.0", + "@babel/plugin-syntax-import-attributes": "^7.22.5", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-transform-typescript": "^7.22.15", + "@vue/babel-plugin-jsx": "^1.1.5", + "@vue/compiler-dom": "^3.3.4", + "kolorist": "^1.8.0", + "magic-string": "^0.30.4" + }, + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, "node_modules/vscode-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", @@ -2628,6 +3943,23 @@ "node": ">=0.10.0" } }, + "node_modules/wsl-utils": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.3.1.tgz", + "integrity": "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0", + "powershell-utils": "^0.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", @@ -2638,6 +3970,13 @@ "node": ">=18" } }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/src/ParagonPlayground/frontend/package.json b/src/ParagonPlayground/frontend/package.json index 3727eee..1fba01a 100644 --- a/src/ParagonPlayground/frontend/package.json +++ b/src/ParagonPlayground/frontend/package.json @@ -12,6 +12,7 @@ "format": "prettier --write ." }, "dependencies": { + "@useparagon/connect": "^2.5.0", "vue": "^3.5.39", "vue-router": "^4.6.4" }, @@ -23,10 +24,12 @@ "eslint": "^10.8.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-vue": "^10.10.0", + "globals": "^17.8.0", "prettier": "^3.9.6", "typescript": "~6.0.2", "typescript-eslint": "^8.65.0", "vite": "^8.1.1", + "vite-plugin-vue-devtools": "^8.2.1", "vue-tsc": "^3.3.5" } } diff --git a/src/ParagonPlayground/frontend/src/App.vue b/src/ParagonPlayground/frontend/src/App.vue index c048298..8431537 100644 --- a/src/ParagonPlayground/frontend/src/App.vue +++ b/src/ParagonPlayground/frontend/src/App.vue @@ -1,6 +1,111 @@ + + diff --git a/src/ParagonPlayground/frontend/src/components/AppIcon.vue b/src/ParagonPlayground/frontend/src/components/AppIcon.vue new file mode 100644 index 0000000..97b5157 --- /dev/null +++ b/src/ParagonPlayground/frontend/src/components/AppIcon.vue @@ -0,0 +1,49 @@ + + + diff --git a/src/ParagonPlayground/frontend/src/composables/useCurrentUser.ts b/src/ParagonPlayground/frontend/src/composables/useCurrentUser.ts new file mode 100644 index 0000000..3afebc7 --- /dev/null +++ b/src/ParagonPlayground/frontend/src/composables/useCurrentUser.ts @@ -0,0 +1,22 @@ +import { ref } from 'vue'; +import { me, type UserResponse } from '../services/auth'; + +const currentUser = ref(null); + +export function useCurrentUser() { + async function fetchCurrentUser(): Promise { + try { + currentUser.value = await me(); + } catch { + currentUser.value = null; + } + + return currentUser.value; + } + + function clearCurrentUser() { + currentUser.value = null; + } + + return { currentUser, fetchCurrentUser, clearCurrentUser }; +} diff --git a/src/ParagonPlayground/frontend/src/main.ts b/src/ParagonPlayground/frontend/src/main.ts index e69d4b7..a83ce57 100644 --- a/src/ParagonPlayground/frontend/src/main.ts +++ b/src/ParagonPlayground/frontend/src/main.ts @@ -2,6 +2,9 @@ import { createApp } from 'vue'; import App from './App.vue'; import router from './router'; +import './styles/tokens.css'; +import './styles/main.css'; + const app = createApp(App); app.use(router); app.mount('#app'); diff --git a/src/ParagonPlayground/frontend/src/router/index.ts b/src/ParagonPlayground/frontend/src/router/index.ts index e7a89a5..c40ad1a 100644 --- a/src/ParagonPlayground/frontend/src/router/index.ts +++ b/src/ParagonPlayground/frontend/src/router/index.ts @@ -1,28 +1,90 @@ import { createRouter, createWebHistory } from 'vue-router'; import LoginPage from '../views/LoginPage.vue'; import DashboardPage from '../views/DashboardPage.vue'; -import { me } from '../services/auth'; +import IntegrationsPage from '../views/IntegrationsPage.vue'; +import FileExplorerPage from '../views/FileExplorerPage.vue'; +import SettingsPage from '../views/SettingsPage.vue'; +import ErrorPage from '../views/ErrorPage.vue'; +import { useCurrentUser } from '../composables/useCurrentUser'; const router = createRouter({ history: createWebHistory(), routes: [ - { path: '/login', name: 'login', component: LoginPage }, + { + path: '/login', + name: 'login', + component: LoginPage, + meta: { public: true }, + }, { path: '/', name: 'dashboard', component: DashboardPage, meta: { requiresAuth: true }, }, + { + path: '/integrations', + name: 'integrations', + component: IntegrationsPage, + meta: { requiresAuth: true, requiresAdmin: true }, + }, + { + path: '/settings', + name: 'settings', + component: SettingsPage, + meta: { requiresAuth: true }, + }, + { + path: '/files/:pathMatch(.*)*', + name: 'files', + component: FileExplorerPage, + meta: { requiresAuth: true }, + }, + { + path: '/forbidden', + name: 'forbidden', + component: ErrorPage, + props: { + status: '403', + heading: 'Forbidden', + title: "You don't have access to this area", + message: + "Your account doesn't have permission to view this page. Contact your admin if you believe this is a mistake.", + }, + meta: { requiresAuth: true }, + }, + { + path: '/:pathMatch(.*)*', + name: 'not-found', + component: ErrorPage, + props: { + status: '404', + heading: 'Page Not Found', + title: "We couldn't find that page", + message: 'The URL may be incorrect, or the page may have been moved or removed.', + }, + meta: { requiresAuth: true }, + }, ], }); router.beforeEach(async (to) => { - if (to.meta.requiresAuth) { - try { - await me(); - } catch { - return { name: 'login' }; - } + const { currentUser, fetchCurrentUser } = useCurrentUser(); + + if (currentUser.value === null) { + await fetchCurrentUser(); + } + + if (to.meta.public) { + return currentUser.value ? { name: 'dashboard' } : true; + } + + if (currentUser.value === null) { + return { name: 'login' }; + } + + if (to.meta.requiresAdmin && currentUser.value.role !== 'admin') { + return { name: 'forbidden' }; } }); diff --git a/src/ParagonPlayground/frontend/src/services/api.ts b/src/ParagonPlayground/frontend/src/services/api.ts index 0f2e118..aad60cb 100644 --- a/src/ParagonPlayground/frontend/src/services/api.ts +++ b/src/ParagonPlayground/frontend/src/services/api.ts @@ -11,7 +11,7 @@ export async function api(path: string, init?: RequestInit): Promise { }; const xsrf = getXsrfToken(); - + if (xsrf) { headers['X-XSRF-Token'] = xsrf; } @@ -23,13 +23,13 @@ export async function api(path: string, init?: RequestInit): Promise { }); if (!res.ok) { - const body = await res.json().catch(() => ({ error: res.statusText })); - throw new Error(body.error ?? `HTTP ${res.status}`); + const body = await res.json().catch(() => ({})); + throw new Error(body.detail ?? body.title ?? res.statusText); } if (res.status === 204) { return undefined as T; } - + return res.json(); } diff --git a/src/ParagonPlayground/frontend/src/services/auth.ts b/src/ParagonPlayground/frontend/src/services/auth.ts index 3c589a5..1733d06 100644 --- a/src/ParagonPlayground/frontend/src/services/auth.ts +++ b/src/ParagonPlayground/frontend/src/services/auth.ts @@ -4,6 +4,7 @@ export interface UserResponse { id: string; email: string; displayName: string; + role: string; organizationId: string; organizationName: string; organizationSlug: string; diff --git a/src/ParagonPlayground/frontend/src/services/integration.ts b/src/ParagonPlayground/frontend/src/services/integration.ts new file mode 100644 index 0000000..df3c78b --- /dev/null +++ b/src/ParagonPlayground/frontend/src/services/integration.ts @@ -0,0 +1,76 @@ +import { api } from './api'; + +export interface ParagonTokenResponse { + paragonJwt: string; + projectId: string; +} + +export interface IntegrationConfig { + id: string; + organizationId: string; + connectionMode: string; + sharePointSiteUrl: string | null; + sharePointSiteId: string | null; + sharePointFolderPath: string | null; + updatedAt: string; +} + +export interface IntegrationConfigRequest { + connectionMode: string; + sharePointSiteUrl: string | null; + sharePointFolderPath: string | null; +} + +export interface CredentialResponse { + id: string; + credentialId: string; + integrationType: string; + connectedAt: string; +} + +export interface CredentialRequest { + credentialId: string; + integrationType: string; +} + +export async function getParagonToken(): Promise { + return api('/paragon/token'); +} + +export async function getConfig(): Promise { + return api('/integration/config'); +} + +export async function updateConfig(config: IntegrationConfigRequest): Promise { + return api('/integration/config', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(config), + }); +} + +export async function getCredentials(): Promise { + return api('/integration/credentials'); +} + +export async function getOrgCredentials(): Promise { + return api('/integration/credentials/org'); +} + +export async function saveCredential(req: CredentialRequest): Promise { + return api('/integration/credentials', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(req), + }); +} + +export async function deleteCredential(credentialId: string): Promise { + await api(`/integration/credentials/${encodeURIComponent(credentialId)}`, { + method: 'DELETE', + }); +} + +export async function purgeOrgCredentials(): Promise { + await api('/integration/credentials/org', { method: 'DELETE' }); +} diff --git a/src/ParagonPlayground/frontend/src/services/storage.ts b/src/ParagonPlayground/frontend/src/services/storage.ts new file mode 100644 index 0000000..db4e6dc --- /dev/null +++ b/src/ParagonPlayground/frontend/src/services/storage.ts @@ -0,0 +1,60 @@ +import { api } from './api'; + +export interface StorageItem { + id: string; + name: string; + isFolder: boolean; + parentId: string | null; + contentType: string | null; + fileSize: number; + sharePointWebUrl: string | null; + createdByUserId: string; + createdByDisplayName: string; + createdAt: string; +} + +export interface CreateFolderRequest { + name: string; + parentId: string | null; +} + +export interface DownloadResponse { + sharePointUrl: string | null; + proxyUrl: string | null; +} + +export async function getItems(parentId?: string | null): Promise { + const params = parentId ? `?parentId=${encodeURIComponent(parentId)}` : ''; + return api(`/storage${params}`); +} + +export async function createFolder(req: CreateFolderRequest): Promise { + return api('/storage/folders', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(req), + }); +} + +export async function uploadFile(file: File, parentId?: string | null): Promise { + const form = new FormData(); + form.append('file', file); + if (parentId) { + form.append('parentId', parentId); + } + + return api('/storage/files', { + method: 'POST', + body: form, + }); +} + +export async function deleteItem(id: string): Promise { + await api(`/storage/${encodeURIComponent(id)}`, { + method: 'DELETE', + }); +} + +export async function getDownloadUrls(id: string): Promise { + return api(`/storage/${encodeURIComponent(id)}/download`); +} diff --git a/src/ParagonPlayground/frontend/src/styles/main.css b/src/ParagonPlayground/frontend/src/styles/main.css new file mode 100644 index 0000000..6af6cc3 --- /dev/null +++ b/src/ParagonPlayground/frontend/src/styles/main.css @@ -0,0 +1,249 @@ +* { + box-sizing: border-box; +} + +:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 2px; +} + +body { + margin: 0; + background: var(--color-bg); + color: var(--color-text); + font-family: + system-ui, + -apple-system, + 'Segoe UI', + Roboto, + sans-serif; + font-size: var(--text-base); + line-height: var(--leading-normal); +} + +h1, +h2, +h3 { + line-height: var(--leading-tight); +} + +h1 { + font-size: var(--text-xl); + font-weight: 650; +} + +h2 { + font-size: var(--text-lg); + font-weight: 650; +} + +h3 { + font-size: var(--text-base); + font-weight: 600; +} + +input, +select, +button { + font: inherit; +} + +input, +select { + padding: 0.5rem 0.625rem; + border: 1px solid var(--color-border); + border-radius: var(--radius); + font-size: var(--text-sm); + background: var(--color-surface); + color: var(--color-text); +} + +input:focus, +select:focus { + border-color: var(--color-accent); +} + +.page { + max-width: 800px; + margin: 0 auto; + padding: 1.5rem 1rem 3rem; +} + +.page-header { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 1rem; + margin-bottom: 1.5rem; +} + +.page-header h1 { + margin: 0; + color: var(--color-accent-strong); +} + +.section { + border-top: 1px solid var(--color-border); + padding: 1.25rem 0; +} + +.section h2 { + margin: 0 0 0.5rem; +} + +.nav-links { + display: flex; + align-items: center; + gap: 1rem; +} + +.nav-links a { + color: var(--color-text-secondary); + text-decoration: none; + font-size: var(--text-sm); +} + +.btn { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.5rem 0.875rem; + border-radius: var(--radius); + font-size: var(--text-sm); + font-weight: 500; + cursor: pointer; + border: 1px solid var(--color-border); + background: var(--color-surface); + color: var(--color-text); + transition: + background-color 0.12s, + border-color 0.12s, + color 0.12s; +} + +.btn:hover:not(:disabled) { + background: var(--color-surface-subtle); +} + +.btn:active:not(:disabled) { + transform: translateY(1px); +} + +.btn:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.btn-primary { + background: var(--color-accent); + color: var(--color-on-accent); + border-color: transparent; +} + +.btn-primary:hover:not(:disabled) { + background: var(--color-accent-strong); +} + +.btn-secondary { + border-color: var(--color-accent); + color: var(--color-accent); +} + +.btn-secondary:hover:not(:disabled) { + background: var(--color-accent-soft); +} + +.btn-danger { + background: var(--color-danger); + color: var(--color-on-danger); + border-color: transparent; +} + +.btn-small { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.25rem 0.5rem; + border-radius: var(--radius); + font-size: var(--text-xs); + cursor: pointer; + border: 1px solid var(--color-border); + background: var(--color-surface); + color: var(--color-text-secondary); + transition: + background-color 0.12s, + color 0.12s, + border-color 0.12s; +} + +.btn-small:hover:not(:disabled) { + background: var(--color-surface-subtle); + color: var(--color-accent); + border-color: var(--color-accent); +} + +.btn-small:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.btn-small-danger { + background: var(--color-danger-soft); + color: var(--color-danger); + border-color: transparent; +} + +.btn-small-danger:hover:not(:disabled) { + background: var(--color-danger); + color: var(--color-on-danger); + border-color: transparent; +} + +.alert { + padding: 0.625rem 0.875rem; + border-left: 3px solid var(--color-border); + margin-bottom: 1rem; + font-size: var(--text-sm); +} + +.alert.error { + background: var(--color-danger-soft); + color: var(--color-danger); + border-left-color: var(--color-danger); +} + +.alert.success { + background: var(--color-success-soft); + color: var(--color-success); + border-left-color: var(--color-success); +} + +.alert.warning { + background: var(--color-warning-soft); + color: var(--color-warning); + border-left-color: var(--color-warning); +} + +.loading { + text-align: center; + color: var(--color-text-secondary); + padding: 3rem 1rem; + font-size: var(--text-sm); +} + +.empty { + text-align: center; + color: var(--color-text-muted); + padding: 3rem 1rem; + font-size: var(--text-sm); +} + +.help-text { + color: var(--color-text-secondary); + font-size: var(--text-sm); + margin: 0 0 1rem; +} + +.text-muted { + color: var(--color-text-muted); +} diff --git a/src/ParagonPlayground/frontend/src/styles/tokens.css b/src/ParagonPlayground/frontend/src/styles/tokens.css new file mode 100644 index 0000000..ddcacf5 --- /dev/null +++ b/src/ParagonPlayground/frontend/src/styles/tokens.css @@ -0,0 +1,62 @@ +:root { + --color-bg: #ffffff; + --color-surface: #ffffff; + --color-surface-subtle: #fafafa; + + --color-text: #1b1c1f; + --color-text-secondary: #5b5d63; + --color-text-muted: #90939b; + + --color-border: #e2e3e7; + + --color-accent: #b39cd0; + --color-accent-strong: #a088c0; + --color-accent-soft: rgba(179, 156, 208, 0.15); + --color-on-accent: #1b1c1f; + + --color-danger: #d47373; + --color-danger-soft: rgba(212, 115, 115, 0.15); + --color-on-danger: #1b1c1f; + --color-success: #7ec8c0; + --color-success-soft: rgba(126, 200, 192, 0.15); + --color-warning: #cc6b2c; + --color-warning-soft: rgba(204, 107, 44, 0.15); + + --radius: 0.375rem; + + --text-xs: 0.75rem; + --text-sm: 0.875rem; + --text-base: 1rem; + --text-lg: 1.125rem; + --text-xl: 1.375rem; + + --leading-tight: 1.3; + --leading-normal: 1.5; +} + +@media (prefers-color-scheme: dark) { + :root { + --color-bg: #161718; + --color-surface: #1c1d1f; + --color-surface-subtle: #222326; + + --color-text: #e7e5ef; + --color-text-secondary: #a5a3b3; + --color-text-muted: #7a7891; + + --color-border: #2e2b3d; + + --color-accent: #b39cd0; + --color-accent-strong: #a088c0; + --color-accent-soft: rgba(179, 156, 208, 0.18); + --color-on-accent: #171622; + + --color-danger: #d47373; + --color-danger-soft: rgba(212, 115, 115, 0.15); + --color-on-danger: #171622; + --color-success: #7ec8c0; + --color-success-soft: rgba(126, 200, 192, 0.15); + --color-warning: #cc6b2c; + --color-warning-soft: rgba(204, 107, 44, 0.15); + } +} diff --git a/src/ParagonPlayground/frontend/src/utils/utils.ts b/src/ParagonPlayground/frontend/src/utils/utils.ts new file mode 100644 index 0000000..7dfbcae --- /dev/null +++ b/src/ParagonPlayground/frontend/src/utils/utils.ts @@ -0,0 +1,17 @@ +export function formatLocaleDate(iso: string): string { + return new Date(iso).toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + }); +} + +export function formatLocaleDateWithTime(iso: string): string { + return new Date(iso).toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +} diff --git a/src/ParagonPlayground/frontend/src/views/DashboardPage.vue b/src/ParagonPlayground/frontend/src/views/DashboardPage.vue index e700b97..7fc76b5 100644 --- a/src/ParagonPlayground/frontend/src/views/DashboardPage.vue +++ b/src/ParagonPlayground/frontend/src/views/DashboardPage.vue @@ -1,108 +1,93 @@ diff --git a/src/ParagonPlayground/frontend/src/views/ErrorPage.vue b/src/ParagonPlayground/frontend/src/views/ErrorPage.vue new file mode 100644 index 0000000..15417e0 --- /dev/null +++ b/src/ParagonPlayground/frontend/src/views/ErrorPage.vue @@ -0,0 +1,47 @@ + + + + + diff --git a/src/ParagonPlayground/frontend/src/views/FileExplorerPage.vue b/src/ParagonPlayground/frontend/src/views/FileExplorerPage.vue new file mode 100644 index 0000000..c59ee79 --- /dev/null +++ b/src/ParagonPlayground/frontend/src/views/FileExplorerPage.vue @@ -0,0 +1,490 @@ + + + + + diff --git a/src/ParagonPlayground/frontend/src/views/IntegrationsPage.vue b/src/ParagonPlayground/frontend/src/views/IntegrationsPage.vue new file mode 100644 index 0000000..6baf2bc --- /dev/null +++ b/src/ParagonPlayground/frontend/src/views/IntegrationsPage.vue @@ -0,0 +1,320 @@ + + + + + diff --git a/src/ParagonPlayground/frontend/src/views/LoginPage.vue b/src/ParagonPlayground/frontend/src/views/LoginPage.vue index 75f804a..84d722f 100644 --- a/src/ParagonPlayground/frontend/src/views/LoginPage.vue +++ b/src/ParagonPlayground/frontend/src/views/LoginPage.vue @@ -1,40 +1,53 @@ diff --git a/src/ParagonPlayground/frontend/src/views/SettingsPage.vue b/src/ParagonPlayground/frontend/src/views/SettingsPage.vue new file mode 100644 index 0000000..82ad6fb --- /dev/null +++ b/src/ParagonPlayground/frontend/src/views/SettingsPage.vue @@ -0,0 +1,325 @@ + + + + + diff --git a/src/ParagonPlayground/frontend/tsconfig.json b/src/ParagonPlayground/frontend/tsconfig.json index 1ffef60..d32ff68 100644 --- a/src/ParagonPlayground/frontend/tsconfig.json +++ b/src/ParagonPlayground/frontend/tsconfig.json @@ -1,7 +1,4 @@ { "files": [], - "references": [ - { "path": "./tsconfig.app.json" }, - { "path": "./tsconfig.node.json" } - ] + "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }] } diff --git a/src/ParagonPlayground/frontend/vite.config.ts b/src/ParagonPlayground/frontend/vite.config.ts index afa7734..10cf5a1 100644 --- a/src/ParagonPlayground/frontend/vite.config.ts +++ b/src/ParagonPlayground/frontend/vite.config.ts @@ -1,10 +1,14 @@ -import { defineConfig } from 'vite' -import vue from '@vitejs/plugin-vue' +import { defineConfig } from 'vite'; +import vue from '@vitejs/plugin-vue'; +import vueDevTools from 'vite-plugin-vue-devtools'; -export default defineConfig({ - plugins: [vue()], +export default defineConfig(({ command }) => ({ + plugins: [vue(), ...(command === 'serve' ? [vueDevTools()] : [])], server: { port: 3000, + watch: { + usePolling: true, + }, proxy: { '/api': { target: 'http://localhost:5000', @@ -12,4 +16,4 @@ export default defineConfig({ }, }, }, -}) +})); diff --git a/src/ParagonPlayground/nginx/nginx.conf b/src/ParagonPlayground/nginx/nginx.conf index 07c11a8..581deae 100644 --- a/src/ParagonPlayground/nginx/nginx.conf +++ b/src/ParagonPlayground/nginx/nginx.conf @@ -6,6 +6,7 @@ server { ssl_certificate_key /etc/nginx/certs/_wildcard.paragonplayground.localhost-key.pem; location /api/ { + client_max_body_size 0; proxy_pass http://backend:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr;