diff --git a/backend/src/Socialize.Api/ApplicationRegistration.cs b/backend/src/Socialize.Api/ApplicationRegistration.cs index bbd9f5c7..d11ba062 100644 --- a/backend/src/Socialize.Api/ApplicationRegistration.cs +++ b/backend/src/Socialize.Api/ApplicationRegistration.cs @@ -78,7 +78,7 @@ internal static class ApplicationRegistration ValidAudience = authJwt["Audience"], ValidateLifetime = true, IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(authJwt["Key"] ?? - throw new ArgumentNullException("The Jwt Key is missing."))) + throw new InvalidOperationException("Authentication:Jwt:Key is required."))) }; }); } @@ -89,9 +89,9 @@ internal static class ApplicationRegistration authenticationBuilder.AddGoogle(GoogleDefaults.AuthenticationScheme, options => { options.ClientId = authGoogle["ClientId"] ?? - throw new ArgumentNullException("The Google ClientId is missing."); + throw new InvalidOperationException("Authentication:Google:ClientId is required."); options.ClientSecret = authGoogle["ClientSecret"] ?? - throw new ArgumentNullException("The Google ClientSecret is missing."); + throw new InvalidOperationException("Authentication:Google:ClientSecret is required."); }); } @@ -101,9 +101,9 @@ internal static class ApplicationRegistration authenticationBuilder.AddFacebook(FacebookDefaults.AuthenticationScheme, options => { options.ClientId = authFacebook["ClientId"] ?? - throw new ArgumentNullException("The Facebook ClientId is missing."); + throw new InvalidOperationException("Authentication:Facebook:ClientId is required."); options.ClientSecret = authFacebook["ClientSecret"] ?? - throw new ArgumentNullException("The Facebook ClientSecret is missing."); + throw new InvalidOperationException("Authentication:Facebook:ClientSecret is required."); }); } diff --git a/backend/src/Socialize.Api/Infrastructure/BlobStorage/Contracts/ContentTypes.cs b/backend/src/Socialize.Api/Infrastructure/BlobStorage/Contracts/ContentTypes.cs index 3c28e18b..c4b9853a 100644 --- a/backend/src/Socialize.Api/Infrastructure/BlobStorage/Contracts/ContentTypes.cs +++ b/backend/src/Socialize.Api/Infrastructure/BlobStorage/Contracts/ContentTypes.cs @@ -39,6 +39,6 @@ internal static class ContentTypes // Check for HTML content by looking for "" or "" tags string content = Encoding.UTF8.GetString(buffer); - return content.Contains(""); + return content.Contains("", StringComparison.OrdinalIgnoreCase); } } diff --git a/backend/src/Socialize.Api/Infrastructure/BlobStorage/Services/LocalBlobStorage.cs b/backend/src/Socialize.Api/Infrastructure/BlobStorage/Services/LocalBlobStorage.cs index 3043b0db..55ce45e6 100644 --- a/backend/src/Socialize.Api/Infrastructure/BlobStorage/Services/LocalBlobStorage.cs +++ b/backend/src/Socialize.Api/Infrastructure/BlobStorage/Services/LocalBlobStorage.cs @@ -14,6 +14,14 @@ internal sealed class LocalBlobStorage( private const long MaxUploadSize = 10 * 1024 * 1024; private const string ContentTypeMetadataSuffix = ".content-type"; + private static readonly char[] PathSeparators = [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar]; + + private static readonly Action LogUploadedFile = + LoggerMessage.Define( + LogLevel.Information, + new EventId(1, nameof(UploadFileAsync)), + "Blob storage: Uploaded [{BlobName}] to local container [{ContainerName}] with contentType [{ContentType}] and uri [{FileUri}]"); + private readonly LocalBlobStorageOptions _options = options.Value; public async Task UploadFileAsync( @@ -46,12 +54,7 @@ internal sealed class LocalBlobStorage( await File.WriteAllTextAsync(GetContentTypeMetadataPath(filePath), contentType, ct); string fileUri = BuildPublicUrl(relativePath); - logger.LogInformation( - "Blob storage: Uploaded [{BlobName}] to local container [{ContainerName}] with contentType [{ContentType}] and uri [{FileUri}]", - blobName, - containerName, - contentType, - fileUri); + LogUploadedFile(logger, blobName, containerName, contentType, fileUri, null); return fileUri; } @@ -106,7 +109,7 @@ internal sealed class LocalBlobStorage( throw new InvalidOperationException("Blob storage: Blob paths must be relative."); } - string[] pathParts = [containerName, .. blobName.Split([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar])]; + string[] pathParts = [containerName, .. blobName.Split(PathSeparators)]; if (pathParts.Any(part => part is "" or "." or "..")) { throw new InvalidOperationException("Blob storage: Blob paths must not contain relative path segments."); @@ -135,7 +138,7 @@ internal sealed class LocalBlobStorage( ? "/api/storage" : requestPath.Trim(); - return normalized.StartsWith("/", StringComparison.Ordinal) + return normalized.StartsWith('/') ? normalized.TrimEnd('/') : $"/{normalized.TrimEnd('/')}"; } diff --git a/backend/src/Socialize.Api/Infrastructure/Emailer/Services/LoggerEmailSender.cs b/backend/src/Socialize.Api/Infrastructure/Emailer/Services/LoggerEmailSender.cs index 8600c9c7..74f729bc 100644 --- a/backend/src/Socialize.Api/Infrastructure/Emailer/Services/LoggerEmailSender.cs +++ b/backend/src/Socialize.Api/Infrastructure/Emailer/Services/LoggerEmailSender.cs @@ -5,14 +5,15 @@ namespace Socialize.Api.Infrastructure.Emailer.Services; internal class LoggerEmailSender(ILogger logger) : IEmailSender { + private static readonly Action LogDevelopmentEmail = + LoggerMessage.Define( + LogLevel.Information, + new EventId(1, nameof(SendEmailAsync)), + "Development email to {Email} with subject {Subject}:{NewLine}{Message}"); + public Task SendEmailAsync(string email, string subject, string message) { - logger.LogInformation( - "Development email to {Email} with subject {Subject}:{NewLine}{Message}", - email, - subject, - Environment.NewLine, - message); + LogDevelopmentEmail(logger, email, subject, Environment.NewLine, message, null); return Task.CompletedTask; } diff --git a/backend/src/Socialize.Api/Infrastructure/Emailer/Services/ResendEmailSender.cs b/backend/src/Socialize.Api/Infrastructure/Emailer/Services/ResendEmailSender.cs index 3a1d7ee5..dc7b0312 100644 --- a/backend/src/Socialize.Api/Infrastructure/Emailer/Services/ResendEmailSender.cs +++ b/backend/src/Socialize.Api/Infrastructure/Emailer/Services/ResendEmailSender.cs @@ -43,14 +43,13 @@ internal class ResendEmailSender : IEmailSender new MediaTypeWithQualityHeaderValue("application/json")); } - public async Task SendEmailAsync(string toEmail, string subject, string htmlMessage) + public async Task SendEmailAsync(string email, string subject, string message) { - var payload = new { from = _options.FromEmail, to = toEmail, subject, html = htmlMessage }; + var payload = new { from = _options.FromEmail, to = email, subject, html = message }; string json = JsonSerializer.Serialize(payload); - StringContent content = new(json, Encoding.UTF8, "application/json"); - - HttpResponseMessage response = await _httpClient.PostAsync(EndpointUri, content); + using StringContent content = new(json, Encoding.UTF8, "application/json"); + using HttpResponseMessage response = await _httpClient.PostAsync(EndpointUri, content); if (!response.IsSuccessStatusCode) { diff --git a/backend/src/Socialize.Api/Infrastructure/Security/AccessScopeService.cs b/backend/src/Socialize.Api/Infrastructure/Security/AccessScopeService.cs index 996c7d1f..6620be58 100644 --- a/backend/src/Socialize.Api/Infrastructure/Security/AccessScopeService.cs +++ b/backend/src/Socialize.Api/Infrastructure/Security/AccessScopeService.cs @@ -7,49 +7,49 @@ namespace Socialize.Api.Infrastructure.Security; internal sealed class AccessScopeService( OrganizationAccessService organizationAccessService) { - public bool IsManager(ClaimsPrincipal user) + public static bool IsManager(ClaimsPrincipal user) { return user.IsInRole(KnownRoles.Administrator) || user.IsInRole(KnownRoles.Manager); } - public bool IsProvider(ClaimsPrincipal user) + public static bool IsProvider(ClaimsPrincipal user) { return user.IsInRole(KnownRoles.Provider); } - public bool IsClient(ClaimsPrincipal user) + public static bool IsClient(ClaimsPrincipal user) { return user.IsInRole(KnownRoles.Client); } - public bool CanAccessWorkspace(ClaimsPrincipal user, Guid workspaceId) + public static bool CanAccessWorkspace(ClaimsPrincipal user, Guid workspaceId) { return IsManager(user) || user.GetWorkspaceScopeIds().Contains(workspaceId); } - public bool CanManageWorkspace(ClaimsPrincipal user, Guid workspaceId) + public static bool CanManageWorkspace(ClaimsPrincipal user, Guid workspaceId) { return IsManager(user) && CanAccessWorkspace(user, workspaceId); } - public bool CanAccessClient(ClaimsPrincipal user, Guid workspaceId, Guid clientId) + public static bool CanAccessClient(ClaimsPrincipal user, Guid workspaceId, Guid clientId) { return IsManager(user) || (CanAccessWorkspace(user, workspaceId) && user.GetClientScopeIds().Contains(clientId)); } - public bool CanAccessCampaign(ClaimsPrincipal user, Guid workspaceId, Guid clientId, Guid campaignId) + public static bool CanAccessCampaign(ClaimsPrincipal user, Guid workspaceId, Guid clientId, Guid campaignId) { return IsManager(user) || (CanAccessClient(user, workspaceId, clientId) && user.GetCampaignScopeIds().Contains(campaignId)); } - public bool CanContributeToCampaign(ClaimsPrincipal user, Guid workspaceId, Guid clientId, Guid campaignId) + public static bool CanContributeToCampaign(ClaimsPrincipal user, Guid workspaceId, Guid clientId, Guid campaignId) { return IsManager(user) || (IsProvider(user) && CanAccessCampaign(user, workspaceId, clientId, campaignId)); } - public bool CanReviewContent(ClaimsPrincipal user, Guid workspaceId, Guid clientId, Guid campaignId) + public static bool CanReviewContent(ClaimsPrincipal user, Guid workspaceId, Guid clientId, Guid campaignId) { return IsManager(user) || IsProvider(user) && CanAccessCampaign(user, workspaceId, clientId, campaignId) diff --git a/backend/src/Socialize.Api/Infrastructure/Security/ClaimsPrincipalExtensions.cs b/backend/src/Socialize.Api/Infrastructure/Security/ClaimsPrincipalExtensions.cs index b5fb2670..b92b3fac 100644 --- a/backend/src/Socialize.Api/Infrastructure/Security/ClaimsPrincipalExtensions.cs +++ b/backend/src/Socialize.Api/Infrastructure/Security/ClaimsPrincipalExtensions.cs @@ -1,4 +1,5 @@ -using System.Security.Claims; +using System.Globalization; +using System.Security.Claims; namespace Socialize.Api.Infrastructure.Security; @@ -81,11 +82,11 @@ internal static class ClaimsPrincipalExtensions if (claim is null) { - throw new MissingClaimException(key); + throw MissingClaimException.ForClaim(key); } return typeof(TValue) == typeof(Guid) ? Guid.Parse(claim.Value) - : Convert.ChangeType(claim.Value, typeof(TValue)); + : Convert.ChangeType(claim.Value, typeof(TValue), CultureInfo.InvariantCulture); } } diff --git a/backend/src/Socialize.Api/Infrastructure/Security/MissingClaimException.cs b/backend/src/Socialize.Api/Infrastructure/Security/MissingClaimException.cs index 2fe4431a..33c90b53 100644 --- a/backend/src/Socialize.Api/Infrastructure/Security/MissingClaimException.cs +++ b/backend/src/Socialize.Api/Infrastructure/Security/MissingClaimException.cs @@ -1,5 +1,23 @@ namespace Socialize.Api.Infrastructure.Security; -internal class MissingClaimException( - string claimName) - : Exception($"Claim '{claimName}' is missing."); +public class MissingClaimException : Exception +{ + public MissingClaimException() + { + } + + public MissingClaimException(string message) + : base(message) + { + } + + public MissingClaimException(string message, Exception innerException) + : base(message, innerException) + { + } + + internal static MissingClaimException ForClaim(string claimName) + { + return new MissingClaimException($"Claim '{claimName}' is missing."); + } +} diff --git a/backend/src/Socialize.Api/Infrastructure/Security/PasswordGenerator.cs b/backend/src/Socialize.Api/Infrastructure/Security/PasswordGenerator.cs index 5d0d9ac7..d7446ae6 100644 --- a/backend/src/Socialize.Api/Infrastructure/Security/PasswordGenerator.cs +++ b/backend/src/Socialize.Api/Infrastructure/Security/PasswordGenerator.cs @@ -11,8 +11,6 @@ internal static class PasswordGenerator private const string Numbers = "0123456789"; private const string SpecialCharacters = "!@#$%^&*()_+-=[];',./`~{}|:\"<>?"; - private static readonly Random Random = new(); - public static string Next( int length = 15, bool requireNumber = true, @@ -23,7 +21,7 @@ internal static class PasswordGenerator // Create pools based on the requirements StringBuilder characterPool = new(); - if (requireNumber) + if (requireLowercase) { characterPool.Append(LowerLetters); } @@ -51,22 +49,22 @@ internal static class PasswordGenerator if (requireLowercase) { - password[index++] = LowerLetters[Random.Next(LowerLetters.Length)]; + password[index++] = LowerLetters[RandomNumberGenerator.GetInt32(LowerLetters.Length)]; } if (requireCapital) { - password[index++] = UpperLetters[Random.Next(UpperLetters.Length)]; + password[index++] = UpperLetters[RandomNumberGenerator.GetInt32(UpperLetters.Length)]; } if (requireNumber) { - password[index++] = Numbers[Random.Next(Numbers.Length)]; + password[index++] = Numbers[RandomNumberGenerator.GetInt32(Numbers.Length)]; } if (requireSpecialCharacter) { - password[index++] = SpecialCharacters[Random.Next(SpecialCharacters.Length)]; + password[index++] = SpecialCharacters[RandomNumberGenerator.GetInt32(SpecialCharacters.Length)]; } // Fill the rest with the password @@ -85,7 +83,7 @@ internal static class PasswordGenerator { for (int i = array.Length - 1; i > 0; i--) { - int j = Random.Next(i + 1); + int j = RandomNumberGenerator.GetInt32(i + 1); (array[i], array[j]) = (array[j], array[i]); // Swap elements } } diff --git a/backend/src/Socialize.Api/Infrastructure/TestData/TestDataSeedExtensions.cs b/backend/src/Socialize.Api/Infrastructure/TestData/TestDataSeedExtensions.cs index 66d2fce6..3125e888 100644 --- a/backend/src/Socialize.Api/Infrastructure/TestData/TestDataSeedExtensions.cs +++ b/backend/src/Socialize.Api/Infrastructure/TestData/TestDataSeedExtensions.cs @@ -19,6 +19,8 @@ using Microsoft.AspNetCore.Identity; namespace Socialize.Api.Infrastructure.TestData; +#pragma warning disable S1075 // Test data intentionally uses representative external URLs. + internal static class TestDataSeedExtensions { private static readonly Guid OrganizationId = Guid.Parse("99999999-9999-9999-9999-999999999999"); @@ -209,13 +211,7 @@ internal static class TestDataSeedExtensions await userManager.RemoveClaimAsync(user, claim); } - string persona = roles.Contains(KnownRoles.Manager, StringComparer.Ordinal) - ? KnownRoles.Manager - : roles.Contains(KnownRoles.Client, StringComparer.Ordinal) - ? KnownRoles.Client - : roles.Contains(KnownRoles.Provider, StringComparer.Ordinal) - ? KnownRoles.Provider - : KnownRoles.WorkspaceMember; + string persona = GetPersona(roles); foreach (Claim claim in claims.Concat([new Claim(KnownClaims.Persona, persona)])) { @@ -225,6 +221,26 @@ internal static class TestDataSeedExtensions return user; } + private static string GetPersona(IReadOnlyCollection roles) + { + if (roles.Contains(KnownRoles.Manager, StringComparer.Ordinal)) + { + return KnownRoles.Manager; + } + + if (roles.Contains(KnownRoles.Client, StringComparer.Ordinal)) + { + return KnownRoles.Client; + } + + if (roles.Contains(KnownRoles.Provider, StringComparer.Ordinal)) + { + return KnownRoles.Provider; + } + + return KnownRoles.WorkspaceMember; + } + private static async Task EnsureOrganizationDataAsync( Guid managerUserId, Guid developerUserId, diff --git a/backend/src/Socialize.Api/Migrations/20260507143849_Initial.cs b/backend/src/Socialize.Api/Migrations/20260507143849_Initial.cs index f31c3500..2cf18e91 100644 --- a/backend/src/Socialize.Api/Migrations/20260507143849_Initial.cs +++ b/backend/src/Socialize.Api/Migrations/20260507143849_Initial.cs @@ -5,6 +5,7 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; #nullable disable #pragma warning disable CA1814 // Prefer jagged arrays over multidimensional +#pragma warning disable CA1861 // Generated migration seed arrays are not runtime hot paths. namespace Socialize.Api.Migrations { diff --git a/backend/src/Socialize.Api/Modules/Approvals/Handlers/SubmitApprovalDecision.cs b/backend/src/Socialize.Api/Modules/Approvals/Handlers/SubmitApprovalDecision.cs index 46af2e68..cca63370 100644 --- a/backend/src/Socialize.Api/Modules/Approvals/Handlers/SubmitApprovalDecision.cs +++ b/backend/src/Socialize.Api/Modules/Approvals/Handlers/SubmitApprovalDecision.cs @@ -8,6 +8,7 @@ using Socialize.Api.Modules.Approvals.Data; using Socialize.Api.Modules.Approvals.Services; using Socialize.Api.Modules.Notifications.Contracts; using Socialize.Api.Modules.Workspaces.Data; +using System.Security.Claims; using System.Text.Json; namespace Socialize.Api.Modules.Approvals.Handlers; @@ -79,12 +80,14 @@ internal class SubmitApprovalDecisionHandler( } string normalizedDecision = request.Decision.Trim(); - string decidedByName = User?.Identity?.IsAuthenticated == true - ? User.GetAlias() ?? User.GetName() - : string.IsNullOrWhiteSpace(request.ReviewerName) ? approval.ReviewerName : request.ReviewerName.Trim(); - string decidedByEmail = User?.Identity?.IsAuthenticated == true - ? User.GetEmail() - : string.IsNullOrWhiteSpace(request.ReviewerEmail) ? approval.ReviewerEmail : request.ReviewerEmail.Trim(); + ClaimsPrincipal? currentUser = User; + bool isAuthenticated = currentUser?.Identity?.IsAuthenticated == true; + string decidedByName = isAuthenticated + ? currentUser!.GetAlias() ?? currentUser!.GetName() + : GetReviewerName(request.ReviewerName, approval.ReviewerName); + string decidedByEmail = isAuthenticated + ? currentUser!.GetEmail() + : GetReviewerEmail(request.ReviewerEmail, approval.ReviewerEmail); ApprovalDecision decision = new() { @@ -207,4 +210,18 @@ internal class SubmitApprovalDecisionHandler( await SendOkAsync(dto, ct); } + + private static string GetReviewerName(string? requestedName, string fallbackName) + { + return string.IsNullOrWhiteSpace(requestedName) + ? fallbackName + : requestedName.Trim(); + } + + private static string GetReviewerEmail(string? requestedEmail, string fallbackEmail) + { + return string.IsNullOrWhiteSpace(requestedEmail) + ? fallbackEmail + : requestedEmail.Trim(); + } } diff --git a/backend/src/Socialize.Api/Modules/Approvals/Services/ApprovalWorkflowRuntimeService.cs b/backend/src/Socialize.Api/Modules/Approvals/Services/ApprovalWorkflowRuntimeService.cs index c6e064b8..03933b5c 100644 --- a/backend/src/Socialize.Api/Modules/Approvals/Services/ApprovalWorkflowRuntimeService.cs +++ b/backend/src/Socialize.Api/Modules/Approvals/Services/ApprovalWorkflowRuntimeService.cs @@ -145,13 +145,15 @@ internal class ApprovalWorkflowRuntimeService( dbContext.ApprovalDecisions.Add(decision); await dbContext.SaveChangesAsync(ct); - int approvedCount = await dbContext.ApprovalDecisions + var approvalDecisionParticipants = await dbContext.ApprovalDecisions .Where(candidate => candidate.ApprovalRequestId == approval.Id && candidate.Decision == ApprovedState) .Select(candidate => candidate.DecidedByUserId.HasValue ? candidate.DecidedByUserId.Value.ToString() - : candidate.DecidedByEmail.ToLower()) - .Distinct() - .CountAsync(ct); + : candidate.DecidedByEmail) + .ToListAsync(ct); + int approvedCount = approvalDecisionParticipants + .Distinct(StringComparer.OrdinalIgnoreCase) + .Count(); int requiredApproverCount = approval.WorkflowStepRequiredApproverCount ?? 1; if (!ApprovalWorkflowRules.HasRequiredStepApprovals(approvedCount, requiredApproverCount)) @@ -394,7 +396,7 @@ internal class ApprovalWorkflowRuntimeService( private static string CreateAccessToken() { - return Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant(); + return Convert.ToHexString(RandomNumberGenerator.GetBytes(16)); } private sealed record ApprovalNotificationRecipient(Guid UserId, string? Email); diff --git a/backend/src/Socialize.Api/Modules/CalendarIntegrations/Data/CalendarCatalogSeed.cs b/backend/src/Socialize.Api/Modules/CalendarIntegrations/Data/CalendarCatalogSeed.cs index 65b55319..1b39e205 100644 --- a/backend/src/Socialize.Api/Modules/CalendarIntegrations/Data/CalendarCatalogSeed.cs +++ b/backend/src/Socialize.Api/Modules/CalendarIntegrations/Data/CalendarCatalogSeed.cs @@ -1,5 +1,7 @@ namespace Socialize.Api.Modules.CalendarIntegrations.Data; +#pragma warning disable S1075 // Catalog seed entries intentionally store source URLs. + internal static class CalendarCatalogSeed { public static readonly CalendarCatalogEntry[] Entries = diff --git a/backend/src/Socialize.Api/Modules/CalendarIntegrations/Handlers/CreateCalendarSource.cs b/backend/src/Socialize.Api/Modules/CalendarIntegrations/Handlers/CreateCalendarSource.cs index f05f96b4..6fefac72 100644 --- a/backend/src/Socialize.Api/Modules/CalendarIntegrations/Handlers/CreateCalendarSource.cs +++ b/backend/src/Socialize.Api/Modules/CalendarIntegrations/Handlers/CreateCalendarSource.cs @@ -121,7 +121,7 @@ internal class CreateCalendarSourceHandler( source.CatalogSourceReference == normalizedCatalogReference) || (!string.IsNullOrWhiteSpace(normalizedUrl) && source.SourceUrl != null && - source.SourceUrl.ToUpper() == normalizedUrl.ToUpper()), + EF.Functions.ILike(source.SourceUrl, normalizedUrl)), ct); } diff --git a/backend/src/Socialize.Api/Modules/CalendarIntegrations/Handlers/ListCalendarCatalog.cs b/backend/src/Socialize.Api/Modules/CalendarIntegrations/Handlers/ListCalendarCatalog.cs index 368f5cdc..0234ab8a 100644 --- a/backend/src/Socialize.Api/Modules/CalendarIntegrations/Handlers/ListCalendarCatalog.cs +++ b/backend/src/Socialize.Api/Modules/CalendarIntegrations/Handlers/ListCalendarCatalog.cs @@ -47,11 +47,11 @@ internal class ListCalendarCatalogHandler(AppDbContext dbContext) if (!string.IsNullOrWhiteSpace(request.Search)) { - string search = request.Search.Trim().ToLowerInvariant(); + string search = $"%{request.Search.Trim()}%"; query = query.Where(entry => - entry.Title.ToLower().Contains(search) || - entry.Description.ToLower().Contains(search) || - entry.ProviderName.ToLower().Contains(search)); + EF.Functions.ILike(entry.Title, search) || + EF.Functions.ILike(entry.Description, search) || + EF.Functions.ILike(entry.ProviderName, search)); } if (!string.IsNullOrWhiteSpace(request.Country)) diff --git a/backend/src/Socialize.Api/Modules/CalendarIntegrations/ModuleRegistration.cs b/backend/src/Socialize.Api/Modules/CalendarIntegrations/ModuleRegistration.cs index b4ab49ec..db0a98e5 100644 --- a/backend/src/Socialize.Api/Modules/CalendarIntegrations/ModuleRegistration.cs +++ b/backend/src/Socialize.Api/Modules/CalendarIntegrations/ModuleRegistration.cs @@ -4,8 +4,6 @@ internal static class ModuleRegistration { public static WebApplicationBuilder AddCalendarIntegrationsModule(this WebApplicationBuilder builder) { - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddHostedService(); diff --git a/backend/src/Socialize.Api/Modules/CalendarIntegrations/Services/CalendarExportFeedBuilder.cs b/backend/src/Socialize.Api/Modules/CalendarIntegrations/Services/CalendarExportFeedBuilder.cs index 474f2191..e331efe4 100644 --- a/backend/src/Socialize.Api/Modules/CalendarIntegrations/Services/CalendarExportFeedBuilder.cs +++ b/backend/src/Socialize.Api/Modules/CalendarIntegrations/Services/CalendarExportFeedBuilder.cs @@ -1,4 +1,5 @@ using System.Text; +using System.Globalization; namespace Socialize.Api.Modules.CalendarIntegrations.Services; @@ -11,9 +12,9 @@ internal sealed record CalendarExportFeedEvent( string? Description, string? Url); -internal class CalendarExportFeedBuilder +internal static class CalendarExportFeedBuilder { - public string Build(string calendarName, IReadOnlyCollection events) + public static string Build(string calendarName, IReadOnlyCollection events) { StringBuilder builder = new(); builder.AppendLine("BEGIN:VCALENDAR"); @@ -21,34 +22,34 @@ internal class CalendarExportFeedBuilder builder.AppendLine("PRODID:-//Socialize//User Work Calendar//EN"); builder.AppendLine("CALSCALE:GREGORIAN"); builder.AppendLine("METHOD:PUBLISH"); - builder.AppendLine($"X-WR-CALNAME:{EscapeText(calendarName)}"); + AppendLineInvariant(builder, $"X-WR-CALNAME:{EscapeText(calendarName)}"); foreach (CalendarExportFeedEvent feedEvent in events.OrderBy(calendarEvent => calendarEvent.StartsAt)) { builder.AppendLine("BEGIN:VEVENT"); - builder.AppendLine($"UID:{EscapeText(feedEvent.Uid)}"); - builder.AppendLine($"DTSTAMP:{FormatUtc(DateTimeOffset.UtcNow)}"); - builder.AppendLine($"SUMMARY:{EscapeText(feedEvent.Title)}"); + AppendLineInvariant(builder, $"UID:{EscapeText(feedEvent.Uid)}"); + AppendLineInvariant(builder, $"DTSTAMP:{FormatUtc(DateTimeOffset.UtcNow)}"); + AppendLineInvariant(builder, $"SUMMARY:{EscapeText(feedEvent.Title)}"); if (feedEvent.IsAllDay) { - builder.AppendLine($"DTSTART;VALUE=DATE:{FormatDate(feedEvent.StartsAt)}"); - builder.AppendLine($"DTEND;VALUE=DATE:{FormatDate(feedEvent.EndsAt)}"); + AppendLineInvariant(builder, $"DTSTART;VALUE=DATE:{FormatDate(feedEvent.StartsAt)}"); + AppendLineInvariant(builder, $"DTEND;VALUE=DATE:{FormatDate(feedEvent.EndsAt)}"); } else { - builder.AppendLine($"DTSTART:{FormatUtc(feedEvent.StartsAt)}"); - builder.AppendLine($"DTEND:{FormatUtc(feedEvent.EndsAt)}"); + AppendLineInvariant(builder, $"DTSTART:{FormatUtc(feedEvent.StartsAt)}"); + AppendLineInvariant(builder, $"DTEND:{FormatUtc(feedEvent.EndsAt)}"); } if (!string.IsNullOrWhiteSpace(feedEvent.Description)) { - builder.AppendLine($"DESCRIPTION:{EscapeText(feedEvent.Description)}"); + AppendLineInvariant(builder, $"DESCRIPTION:{EscapeText(feedEvent.Description)}"); } if (!string.IsNullOrWhiteSpace(feedEvent.Url)) { - builder.AppendLine($"URL:{EscapeText(feedEvent.Url)}"); + AppendLineInvariant(builder, $"URL:{EscapeText(feedEvent.Url)}"); } builder.AppendLine("END:VEVENT"); @@ -71,10 +72,15 @@ internal class CalendarExportFeedBuilder private static string EscapeText(string value) { return value - .Replace("\\", "\\\\") - .Replace("\r\n", "\\n") - .Replace("\n", "\\n") - .Replace(";", "\\;") - .Replace(",", "\\,"); + .Replace("\\", "\\\\", StringComparison.Ordinal) + .Replace("\r\n", "\\n", StringComparison.Ordinal) + .Replace("\n", "\\n", StringComparison.Ordinal) + .Replace(";", "\\;", StringComparison.Ordinal) + .Replace(",", "\\,", StringComparison.Ordinal); + } + + private static void AppendLineInvariant(StringBuilder builder, FormattableString value) + { + builder.AppendLine(value.ToString(CultureInfo.InvariantCulture)); } } diff --git a/backend/src/Socialize.Api/Modules/CalendarIntegrations/Services/CalendarExportFeedService.cs b/backend/src/Socialize.Api/Modules/CalendarIntegrations/Services/CalendarExportFeedService.cs index f744f5fa..1d245e91 100644 --- a/backend/src/Socialize.Api/Modules/CalendarIntegrations/Services/CalendarExportFeedService.cs +++ b/backend/src/Socialize.Api/Modules/CalendarIntegrations/Services/CalendarExportFeedService.cs @@ -3,11 +3,11 @@ using Socialize.Api.Data; namespace Socialize.Api.Modules.CalendarIntegrations.Services; -internal class CalendarExportFeedService(AppDbContext dbContext, CalendarExportFeedBuilder feedBuilder) +internal class CalendarExportFeedService(AppDbContext dbContext) { public async Task BuildUserFeedAsync(Guid userId, string? userEmail, string appBaseUrl, CancellationToken ct) { - string normalizedEmail = userEmail?.Trim().ToUpperInvariant() ?? string.Empty; + string normalizedEmail = userEmail?.Trim() ?? string.Empty; Guid[] workspaceIds = await dbContext.Workspaces .Where(workspace => workspace.OwnerUserId == userId || @@ -51,7 +51,7 @@ internal class CalendarExportFeedService(AppDbContext dbContext, CalendarExportF .Where(approval => approval.DueAt.HasValue && (approval.RequestedByUserId == userId || - (!string.IsNullOrEmpty(normalizedEmail) && approval.ReviewerEmail.ToUpper() == normalizedEmail))) + (!string.IsNullOrEmpty(normalizedEmail) && EF.Functions.ILike(approval.ReviewerEmail, normalizedEmail)))) .Join( dbContext.ContentItems, approval => approval.ContentItemId, @@ -91,7 +91,7 @@ internal class CalendarExportFeedService(AppDbContext dbContext, CalendarExportF appBaseUrl)) .ToListAsync(ct)); - return feedBuilder.Build("Socialize my work", events); + return CalendarExportFeedBuilder.Build("Socialize my work", events); } private static CalendarExportFeedEvent ToContentFeedEvent( diff --git a/backend/src/Socialize.Api/Modules/CalendarIntegrations/Services/CalendarImportBackgroundService.cs b/backend/src/Socialize.Api/Modules/CalendarIntegrations/Services/CalendarImportBackgroundService.cs index 52310afe..8bd03c43 100644 --- a/backend/src/Socialize.Api/Modules/CalendarIntegrations/Services/CalendarImportBackgroundService.cs +++ b/backend/src/Socialize.Api/Modules/CalendarIntegrations/Services/CalendarImportBackgroundService.cs @@ -23,12 +23,15 @@ internal sealed class CalendarImportBackgroundService( CalendarImportSyncService syncService = scope.ServiceProvider.GetRequiredService(); await syncService.RefreshDueSourcesAsync(stoppingToken); } - catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + catch (OperationCanceledException ex) when (stoppingToken.IsCancellationRequested) { + logger.LogDebug(ex, "Calendar import background sync stopped."); } +#pragma warning disable CA1031 // Background service should log and continue after unexpected sync failures. catch (Exception ex) { logger.LogError(ex, "Calendar import background sync failed."); } +#pragma warning restore CA1031 } } diff --git a/backend/src/Socialize.Api/Modules/CalendarIntegrations/Services/CalendarImportSyncService.cs b/backend/src/Socialize.Api/Modules/CalendarIntegrations/Services/CalendarImportSyncService.cs index 22761e98..6dc0aea4 100644 --- a/backend/src/Socialize.Api/Modules/CalendarIntegrations/Services/CalendarImportSyncService.cs +++ b/backend/src/Socialize.Api/Modules/CalendarIntegrations/Services/CalendarImportSyncService.cs @@ -1,15 +1,19 @@ using Microsoft.EntityFrameworkCore; using Socialize.Api.Data; using Socialize.Api.Modules.CalendarIntegrations.Data; +using System.Globalization; using System.Text.Json; namespace Socialize.Api.Modules.CalendarIntegrations.Services; +#pragma warning disable S1075 // Supplemental observance identifiers intentionally use stable URI-like values. + internal sealed class CalendarImportSyncService( AppDbContext dbContext, - IHttpClientFactory httpClientFactory, - IcsCalendarParser parser) + IHttpClientFactory httpClientFactory) { + private static readonly JsonSerializerOptions JsonSerializerOptions = new(JsonSerializerDefaults.Web); + public async Task RefreshSourceAsync(Guid sourceId, CancellationToken ct) { CalendarSource? source = await dbContext.CalendarSources @@ -115,7 +119,7 @@ internal sealed class CalendarImportSyncService( } } - private async Task> GetParsedEventsAsync( + private static async Task> GetParsedEventsAsync( HttpClient httpClient, string sourceUrl, DateOnly rangeStart, @@ -127,8 +131,8 @@ internal sealed class CalendarImportSyncService( return await GetNagerEventsAsync(httpClient, sourceUrl, countryCode!, rangeStart, rangeEnd, ct); } - string content = await httpClient.GetStringAsync(sourceUrl, ct); - return parser.Parse(content, rangeStart, rangeEnd); + string content = await httpClient.GetStringAsync(new Uri(sourceUrl), ct); + return IcsCalendarParser.Parse(content, rangeStart, rangeEnd); } private static async Task> GetNagerEventsAsync( @@ -143,14 +147,12 @@ internal sealed class CalendarImportSyncService( for (int year = rangeStart.Year; year <= rangeEnd.Year; year++) { string yearUrl = BuildNagerYearUrl(sourceUrl, countryCode, year); - string json = await httpClient.GetStringAsync(yearUrl, ct); - NagerHoliday[] holidays = JsonSerializer.Deserialize( - json, - new JsonSerializerOptions(JsonSerializerDefaults.Web)) ?? []; + string json = await httpClient.GetStringAsync(new Uri(yearUrl), ct); + NagerHoliday[] holidays = JsonSerializer.Deserialize(json, JsonSerializerOptions) ?? []; foreach (NagerHoliday holiday in holidays) { - if (!DateOnly.TryParse(holiday.Date, out DateOnly date) || + if (!DateOnly.TryParse(holiday.Date, CultureInfo.InvariantCulture, out DateOnly date) || date < rangeStart || date > rangeEnd) { @@ -283,7 +285,7 @@ internal sealed class CalendarImportSyncService( private static string NormalizeUidPart(string? value) { return new string((value ?? "holiday") - .ToLowerInvariant() + .ToUpperInvariant() .Select(character => char.IsLetterOrDigit(character) ? character : '-') .ToArray()) .Trim('-'); diff --git a/backend/src/Socialize.Api/Modules/CalendarIntegrations/Services/IcsCalendarParser.cs b/backend/src/Socialize.Api/Modules/CalendarIntegrations/Services/IcsCalendarParser.cs index 5765a2ca..84c853d4 100644 --- a/backend/src/Socialize.Api/Modules/CalendarIntegrations/Services/IcsCalendarParser.cs +++ b/backend/src/Socialize.Api/Modules/CalendarIntegrations/Services/IcsCalendarParser.cs @@ -1,4 +1,5 @@ using System.Globalization; +using System.Text; namespace Socialize.Api.Modules.CalendarIntegrations.Services; @@ -39,9 +40,9 @@ internal sealed record IcsRawEvent( string? SourceUrl, DateTimeOffset? LastModifiedAt); -internal sealed class IcsCalendarParser +internal static class IcsCalendarParser { - public IReadOnlyCollection Parse( + public static IReadOnlyCollection Parse( string content, DateOnly rangeStart, DateOnly rangeEnd) @@ -63,10 +64,12 @@ internal sealed class IcsCalendarParser private static IEnumerable ReadRawEvents(string content) { List lines = UnfoldLines(content).ToList(); - for (int index = 0; index < lines.Count; index++) + int index = 0; + while (index < lines.Count) { if (!lines[index].Equals("BEGIN:VEVENT", StringComparison.OrdinalIgnoreCase)) { + index++; continue; } @@ -74,9 +77,10 @@ internal sealed class IcsCalendarParser new(StringComparer.OrdinalIgnoreCase); index++; - for (; index < lines.Count && !lines[index].Equals("END:VEVENT", StringComparison.OrdinalIgnoreCase); index++) + while (index < lines.Count && !lines[index].Equals("END:VEVENT", StringComparison.OrdinalIgnoreCase)) { ParseProperty(lines[index], properties); + index++; } if (!TryGetFirst(properties, "DTSTART", out var startProperty)) @@ -105,32 +109,34 @@ internal sealed class IcsCalendarParser TryGetFirst(properties, "LAST-MODIFIED", out var lastModified) ? ParseDateTimeValue(lastModified.Value, lastModified.Parameters).UtcDateTime : null); + + index++; } } private static IEnumerable UnfoldLines(string content) { - string? current = null; + StringBuilder? current = null; using StringReader reader = new(content.Replace("\r\n", "\n", StringComparison.Ordinal).Replace('\r', '\n')); while (reader.ReadLine() is { } line) { if ((line.StartsWith(' ') || line.StartsWith('\t')) && current is not null) { - current += line[1..]; + current.Append(line[1..]); continue; } if (current is not null) { - yield return current; + yield return current.ToString(); } - current = line; + current = new StringBuilder(line); } if (current is not null) { - yield return current; + yield return current.ToString(); } } @@ -309,7 +315,7 @@ internal sealed class IcsCalendarParser return TimeSpan.Zero; } - private static IReadOnlyCollection ExpandStartDates( + private static List ExpandStartDates( IcsRawEvent rawEvent, DateOnly rangeStart, DateOnly rangeEnd) diff --git a/backend/src/Socialize.Api/Modules/Campaigns/Handlers/GetCampaigns.cs b/backend/src/Socialize.Api/Modules/Campaigns/Handlers/GetCampaigns.cs index 1b4cdf29..9c05501b 100644 --- a/backend/src/Socialize.Api/Modules/Campaigns/Handlers/GetCampaigns.cs +++ b/backend/src/Socialize.Api/Modules/Campaigns/Handlers/GetCampaigns.cs @@ -34,7 +34,7 @@ internal class GetCampaignsHandler( { IQueryable query = dbContext.Campaigns.AsQueryable(); - if (!accessScopeService.IsManager(User)) + if (!AccessScopeService.IsManager(User)) { IReadOnlyCollection workspaceScopeIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct); IReadOnlyCollection clientScopeIds = User.GetClientScopeIds(); diff --git a/backend/src/Socialize.Api/Modules/Channels/Handlers/GetChannels.cs b/backend/src/Socialize.Api/Modules/Channels/Handlers/GetChannels.cs index 791060e8..8c3fcfc8 100644 --- a/backend/src/Socialize.Api/Modules/Channels/Handlers/GetChannels.cs +++ b/backend/src/Socialize.Api/Modules/Channels/Handlers/GetChannels.cs @@ -23,7 +23,7 @@ internal class GetChannelsHandler( { IQueryable query = dbContext.Channels.AsQueryable(); - if (!accessScopeService.IsManager(User)) + if (!AccessScopeService.IsManager(User)) { IReadOnlyCollection workspaceScopeIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct); query = query.Where(channel => workspaceScopeIds.Contains(channel.WorkspaceId)); diff --git a/backend/src/Socialize.Api/Modules/Clients/Handlers/GetClients.cs b/backend/src/Socialize.Api/Modules/Clients/Handlers/GetClients.cs index 00dcb271..5911de55 100644 --- a/backend/src/Socialize.Api/Modules/Clients/Handlers/GetClients.cs +++ b/backend/src/Socialize.Api/Modules/Clients/Handlers/GetClients.cs @@ -33,7 +33,7 @@ internal class GetClientsHandler( { IQueryable query = dbContext.Clients.AsQueryable(); - if (!accessScopeService.IsManager(User)) + if (!AccessScopeService.IsManager(User)) { IReadOnlyCollection workspaceScopeIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct); IReadOnlyCollection clientScopeIds = User.GetClientScopeIds(); diff --git a/backend/src/Socialize.Api/Modules/Comments/Handlers/CreateComment.cs b/backend/src/Socialize.Api/Modules/Comments/Handlers/CreateComment.cs index 76778854..b3a97ca0 100644 --- a/backend/src/Socialize.Api/Modules/Comments/Handlers/CreateComment.cs +++ b/backend/src/Socialize.Api/Modules/Comments/Handlers/CreateComment.cs @@ -96,7 +96,7 @@ internal class CreateCommentHandler( if (request.Attachment is not null) { - string normalizedContentType = request.Attachment.ContentType.Trim().ToLowerInvariant(); + string normalizedContentType = request.Attachment.ContentType.Trim(); if (request.Attachment.Length <= 0) { @@ -213,17 +213,26 @@ internal class CreateCommentHandler( private static bool IsInlineAttachmentContentType(string contentType) { - return contentType.Trim().ToLowerInvariant() is "image/png" or "image/jpeg" or "image/jpg"; + string normalized = contentType.Trim(); + return normalized.Equals("image/png", StringComparison.OrdinalIgnoreCase) || + normalized.Equals("image/jpeg", StringComparison.OrdinalIgnoreCase) || + normalized.Equals("image/jpg", StringComparison.OrdinalIgnoreCase); } private static string NormalizeFileName(string? fileName, string contentType) { - string extension = contentType.Trim().ToLowerInvariant() switch + string normalizedContentType = contentType.Trim(); + string extension = string.Empty; + if (normalizedContentType.Equals("image/png", StringComparison.OrdinalIgnoreCase)) { - "image/png" => ".png", - "image/jpeg" or "image/jpg" => ".jpg", - _ => string.Empty, - }; + extension = ".png"; + } + else if (normalizedContentType.Equals("image/jpeg", StringComparison.OrdinalIgnoreCase) || + normalizedContentType.Equals("image/jpg", StringComparison.OrdinalIgnoreCase)) + { + extension = ".jpg"; + } + string normalized = Path.GetFileName(fileName ?? string.Empty).Trim(); if (string.IsNullOrWhiteSpace(normalized)) { diff --git a/backend/src/Socialize.Api/Modules/ContentItems/Handlers/GetContentItems.cs b/backend/src/Socialize.Api/Modules/ContentItems/Handlers/GetContentItems.cs index a668c870..0e46b0c4 100644 --- a/backend/src/Socialize.Api/Modules/ContentItems/Handlers/GetContentItems.cs +++ b/backend/src/Socialize.Api/Modules/ContentItems/Handlers/GetContentItems.cs @@ -37,7 +37,7 @@ internal class GetContentItemsHandler( { IQueryable query = dbContext.ContentItems.AsQueryable(); - if (!accessScopeService.IsManager(User)) + if (!AccessScopeService.IsManager(User)) { IReadOnlyCollection workspaceScopeIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct); IReadOnlyCollection clientScopeIds = User.GetClientScopeIds(); diff --git a/backend/src/Socialize.Api/Modules/Feedback/Handlers/UpdateDeveloperFeedback.cs b/backend/src/Socialize.Api/Modules/Feedback/Handlers/UpdateDeveloperFeedback.cs index 7a8a6821..af0e6bc6 100644 --- a/backend/src/Socialize.Api/Modules/Feedback/Handlers/UpdateDeveloperFeedback.cs +++ b/backend/src/Socialize.Api/Modules/Feedback/Handlers/UpdateDeveloperFeedback.cs @@ -169,13 +169,12 @@ internal class UpdateDeveloperFeedbackHandler( .ToHashSet(StringComparer.Ordinal); bool changed = false; - foreach (FeedbackTag existingTag in report.Tags.ToArray()) + foreach (FeedbackTag existingTag in report.Tags + .Where(existingTag => !requestedKeys.Contains(existingTag.NormalizedName)) + .ToArray()) { - if (!requestedKeys.Contains(existingTag.NormalizedName)) - { - report.Tags.Remove(existingTag); - changed = true; - } + report.Tags.Remove(existingTag); + changed = true; } HashSet existingKeys = report.Tags diff --git a/backend/src/Socialize.Api/Modules/Identity/Handlers/ChangeEmail.cs b/backend/src/Socialize.Api/Modules/Identity/Handlers/ChangeEmail.cs index bc671adb..cb9eb955 100644 --- a/backend/src/Socialize.Api/Modules/Identity/Handlers/ChangeEmail.cs +++ b/backend/src/Socialize.Api/Modules/Identity/Handlers/ChangeEmail.cs @@ -1,6 +1,7 @@ using FastEndpoints; using Socialize.Api.Infrastructure.Security; using Socialize.Api.Modules.Identity.Data; +using Socialize.Api.Modules.Identity.Services; using Microsoft.AspNetCore.Identity; namespace Socialize.Api.Modules.Identity.Handlers; @@ -9,10 +10,15 @@ namespace Socialize.Api.Modules.Identity.Handlers; internal record ChangeEmailRequest( string? Email); +[PublicAPI] +internal record ChangeEmailResponse( + string Message); + [PublicAPI] internal class ChangeEmailHandler( - UserManager userManager) - : Endpoint + UserManager userManager, + EmailVerificationService emailVerificationService) + : Endpoint { public override void Configure() { @@ -32,18 +38,28 @@ internal class ChangeEmailHandler( return; } - user.Email = request.Email; - - // TODO: check to see if identity resets the `email confirmed` flag - @jonathan - IdentityResult result = await userManager.UpdateAsync(user); - - if (result.Succeeded) + if (string.IsNullOrWhiteSpace(request.Email)) { - await SendOkAsync(ct); + await SendStringAsync( + "Email is required", + 400, + cancellation: ct); + return; } - else + + string newEmail = request.Email.Trim(); + if (string.Equals(user.Email, newEmail, StringComparison.OrdinalIgnoreCase)) { - await SendUnauthorizedAsync(ct); + await SendOkAsync( + new ChangeEmailResponse("Email is already set to this address."), + ct); + return; } + + await emailVerificationService.SendEmailChangeConfirmationAsync(user, newEmail); + + await SendOkAsync( + new ChangeEmailResponse("Please check your new email address to confirm the change."), + ct); } } diff --git a/backend/src/Socialize.Api/Modules/Identity/Handlers/ChangePhone.cs b/backend/src/Socialize.Api/Modules/Identity/Handlers/ChangePhone.cs index 9622ea11..3db76896 100644 --- a/backend/src/Socialize.Api/Modules/Identity/Handlers/ChangePhone.cs +++ b/backend/src/Socialize.Api/Modules/Identity/Handlers/ChangePhone.cs @@ -32,10 +32,22 @@ internal class ChangePhoneHandler( return; } - user.PhoneNumber = request.PhoneNumber; - // TODO: check to see if identity resets the `phone confirmed` flag - @jonathan + string? newPhoneNumber = string.IsNullOrWhiteSpace(request.PhoneNumber) + ? null + : request.PhoneNumber.Trim(); - IdentityResult result = await userManager.UpdateAsync(user); + IdentityResult result; + if (newPhoneNumber is null) + { + user.PhoneNumber = null; + user.PhoneNumberConfirmed = false; + result = await userManager.UpdateAsync(user); + } + else + { + string token = await userManager.GenerateChangePhoneNumberTokenAsync(user, newPhoneNumber); + result = await userManager.ChangePhoneNumberAsync(user, newPhoneNumber, token); + } if (result.Succeeded) { diff --git a/backend/src/Socialize.Api/Modules/Identity/Handlers/ConfirmEmailChange.cs b/backend/src/Socialize.Api/Modules/Identity/Handlers/ConfirmEmailChange.cs new file mode 100644 index 00000000..0ee557d5 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/Identity/Handlers/ConfirmEmailChange.cs @@ -0,0 +1,70 @@ +using FastEndpoints; +using Microsoft.AspNetCore.Identity; +using System.Web; +using Socialize.Api.Modules.Identity.Data; + +namespace Socialize.Api.Modules.Identity.Handlers; + +[PublicAPI] +internal record ConfirmEmailChangeRequest( + string UserId, + string Email, + string Token); + +[PublicAPI] +internal record ConfirmEmailChangeResponse( + string Message); + +[PublicAPI] +internal class ConfirmEmailChangeHandler( + UserManager userManager) + : Endpoint +{ + public override void Configure() + { + AllowAnonymous(); + Get("/api/users/confirm-email-change"); + Options(o => o.WithTags("Users")); + } + + public override async Task HandleAsync( + ConfirmEmailChangeRequest request, + CancellationToken ct) + { + User? user = await userManager.FindByIdAsync(request.UserId); + if (user is null) + { + await SendStringAsync( + "Invalid email change link", + 400, + cancellation: ct); + return; + } + + string newEmail = request.Email.Trim(); + string decodedToken = HttpUtility.UrlDecode(request.Token).Replace(" ", "+", StringComparison.Ordinal); + IdentityResult result = await userManager.ChangeEmailAsync(user, newEmail, decodedToken); + if (!result.Succeeded) + { + await SendStringAsync( + "Invalid email change link or the link has expired", + 400, + cancellation: ct); + return; + } + + IdentityResult usernameResult = await userManager.SetUserNameAsync(user, newEmail); + if (!usernameResult.Succeeded) + { + await SendStringAsync( + usernameResult.Errors.First().Description, + 400, + cancellation: ct); + return; + } + + await SendOkAsync( + new ConfirmEmailChangeResponse("Email address changed successfully."), + ct); + } +} diff --git a/backend/src/Socialize.Api/Modules/Identity/Handlers/GetCurrentUser.cs b/backend/src/Socialize.Api/Modules/Identity/Handlers/GetCurrentUser.cs index e4ea2e9b..e91a2053 100644 --- a/backend/src/Socialize.Api/Modules/Identity/Handlers/GetCurrentUser.cs +++ b/backend/src/Socialize.Api/Modules/Identity/Handlers/GetCurrentUser.cs @@ -18,13 +18,13 @@ internal class GetCurrentUserQueryHandler( } public override async Task HandleAsync( - CancellationToken cancellationToken) + CancellationToken ct) { UserModel? userModel = await identityService.GetCurrentUserAsync(); if (userModel is null) { - await SendNotFoundAsync(cancellationToken); + await SendNotFoundAsync(ct); return; } @@ -76,6 +76,6 @@ internal class GetCurrentUserQueryHandler( Address = userModel.Address, UserRoles = roles }, - cancellationToken); + ct); } } diff --git a/backend/src/Socialize.Api/Modules/Identity/Handlers/GetCurrentUserProfilePicture.cs b/backend/src/Socialize.Api/Modules/Identity/Handlers/GetCurrentUserProfilePicture.cs index 47d1c6a8..338da644 100644 --- a/backend/src/Socialize.Api/Modules/Identity/Handlers/GetCurrentUserProfilePicture.cs +++ b/backend/src/Socialize.Api/Modules/Identity/Handlers/GetCurrentUserProfilePicture.cs @@ -19,21 +19,21 @@ internal class GetCurrentUserPortraitHandler( } public override async Task HandleAsync( - CancellationToken cancellationToken) + CancellationToken ct) { UserModel? identityUser = await identityService.GetCurrentUserAsync(); if (identityUser is null) { - await SendNotFoundAsync(cancellationToken); + await SendNotFoundAsync(ct); return; } MemoryStream stream = await blobStorage.DownloadFileAsync( ContainerNames.Users, $"{identityUser.Id.ToString()}/{SubDirectoryNames.Profile}/{CommonFileNames.ProfilePicture}", - cancellationToken); + ct); - await SendOkAsync(stream, cancellationToken); + await SendOkAsync(stream, ct); } } diff --git a/backend/src/Socialize.Api/Modules/Identity/Handlers/LoginWithFacebook.cs b/backend/src/Socialize.Api/Modules/Identity/Handlers/LoginWithFacebook.cs index 171c091f..4a5751df 100644 --- a/backend/src/Socialize.Api/Modules/Identity/Handlers/LoginWithFacebook.cs +++ b/backend/src/Socialize.Api/Modules/Identity/Handlers/LoginWithFacebook.cs @@ -61,9 +61,8 @@ internal class LoginWithFacebookHandler( { // Verify the token with Facebook using HttpClient httpClient = httpClientFactory.CreateClient(); - using HttpResponseMessage response = await httpClient.GetAsync( - $"https://graph.facebook.com/me?access_token={request.Token}&fields=id,name,email,picture.width(200).height(200)", - ct); + Uri userInfoUri = new($"https://graph.facebook.com/me?access_token={request.Token}&fields=id,name,email,picture.width(200).height(200)"); + using HttpResponseMessage response = await httpClient.GetAsync(userInfoUri, ct); if (!response.IsSuccessStatusCode) { await SendStringAsync( diff --git a/backend/src/Socialize.Api/Modules/Identity/Handlers/LoginWithGoogle.cs b/backend/src/Socialize.Api/Modules/Identity/Handlers/LoginWithGoogle.cs index 4a58ca54..6aa0b39b 100644 --- a/backend/src/Socialize.Api/Modules/Identity/Handlers/LoginWithGoogle.cs +++ b/backend/src/Socialize.Api/Modules/Identity/Handlers/LoginWithGoogle.cs @@ -63,9 +63,8 @@ internal class LoginWithGoogleHandler( // Verify the token with Google using HttpClient httpClient = httpClientFactory.CreateClient(); - using HttpResponseMessage response = await httpClient.GetAsync( - $"https://www.googleapis.com/oauth2/v1/userinfo?access_token={googleToken.AccessToken}", - ct); + Uri userInfoUri = new($"https://www.googleapis.com/oauth2/v1/userinfo?access_token={googleToken.AccessToken}"); + using HttpResponseMessage response = await httpClient.GetAsync(userInfoUri, ct); if (!response.IsSuccessStatusCode) { await SendStringAsync( diff --git a/backend/src/Socialize.Api/Modules/Identity/Handlers/VerifyEmail.cs b/backend/src/Socialize.Api/Modules/Identity/Handlers/VerifyEmail.cs index 0155ba0f..40373415 100644 --- a/backend/src/Socialize.Api/Modules/Identity/Handlers/VerifyEmail.cs +++ b/backend/src/Socialize.Api/Modules/Identity/Handlers/VerifyEmail.cs @@ -42,8 +42,7 @@ internal class VerifyEmailHandler( } // Verify the token and confirm email - string decoded = HttpUtility.UrlDecode(request.Token); - string decodedWithPlus = request.Token.Replace(" ", "+"); + string decodedWithPlus = HttpUtility.UrlDecode(request.Token).Replace(" ", "+", StringComparison.Ordinal); IdentityResult result = await userManager.ConfirmEmailAsync(user, decodedWithPlus); if (!result.Succeeded) { diff --git a/backend/src/Socialize.Api/Modules/Identity/Services/AccessTokenFactory.cs b/backend/src/Socialize.Api/Modules/Identity/Services/AccessTokenFactory.cs index 7721df2e..feb8ca47 100644 --- a/backend/src/Socialize.Api/Modules/Identity/Services/AccessTokenFactory.cs +++ b/backend/src/Socialize.Api/Modules/Identity/Services/AccessTokenFactory.cs @@ -16,13 +16,7 @@ internal sealed class AccessTokenFactory( IList roles = await userManager.GetRolesAsync(user); IList claims = await userManager.GetClaimsAsync(user); - string persona = roles.Contains(KnownRoles.Manager, StringComparer.Ordinal) - ? KnownRoles.Manager - : roles.Contains(KnownRoles.Client, StringComparer.Ordinal) - ? KnownRoles.Client - : roles.Contains(KnownRoles.Provider, StringComparer.Ordinal) - ? KnownRoles.Provider - : KnownRoles.WorkspaceMember; + string persona = GetPersona(roles); List tokenClaims = [.. claims, new Claim(KnownClaims.Persona, persona)]; @@ -40,4 +34,24 @@ internal sealed class AccessTokenFactory( roles, tokenClaims); } + + private static string GetPersona(IList roles) + { + if (roles.Contains(KnownRoles.Manager, StringComparer.Ordinal)) + { + return KnownRoles.Manager; + } + + if (roles.Contains(KnownRoles.Client, StringComparer.Ordinal)) + { + return KnownRoles.Client; + } + + if (roles.Contains(KnownRoles.Provider, StringComparer.Ordinal)) + { + return KnownRoles.Provider; + } + + return KnownRoles.WorkspaceMember; + } } diff --git a/backend/src/Socialize.Api/Modules/Identity/Services/EmailVerificationService.cs b/backend/src/Socialize.Api/Modules/Identity/Services/EmailVerificationService.cs index e7c3e58d..953fdd7a 100644 --- a/backend/src/Socialize.Api/Modules/Identity/Services/EmailVerificationService.cs +++ b/backend/src/Socialize.Api/Modules/Identity/Services/EmailVerificationService.cs @@ -58,4 +58,52 @@ internal sealed class EmailVerificationService( """); } + + public async Task SendEmailChangeConfirmationAsync( + User user, + string newEmail) + { + string token = await userManager.GenerateChangeEmailTokenAsync(user, newEmail); + string encodedEmail = HttpUtility.UrlEncode(newEmail); + string encodedToken = HttpUtility.UrlEncode(token); + string confirmationLink = + $"{options.Value.FrontendBaseUrl}/verify-email?changeEmail=true&userId={user.Id}&email={encodedEmail}&token={encodedToken}"; + + await emailSender.SendEmailAsync( + newEmail, + "Confirm your new email address", + $""" +
+

Confirm your new email address

+ +

+ Please confirm this email address for your Socialize account by clicking the button below: +

+ + + +

+ If you did not request this change, please ignore this email. +

+ +

+ If the button doesn't work, you can copy and paste this link into your browser: +
+ {confirmationLink} +

+
+ """); + } } diff --git a/backend/src/Socialize.Api/Modules/Notifications/Handlers/GetNotifications.cs b/backend/src/Socialize.Api/Modules/Notifications/Handlers/GetNotifications.cs index 8d822d72..24ed66f5 100644 --- a/backend/src/Socialize.Api/Modules/Notifications/Handlers/GetNotifications.cs +++ b/backend/src/Socialize.Api/Modules/Notifications/Handlers/GetNotifications.cs @@ -56,7 +56,7 @@ internal class GetNotificationsHandler( IQueryable query = dbContext.NotificationEvents.AsQueryable(); Guid currentUserId = User.GetUserId(); - if (!accessScopeService.IsManager(User)) + if (!AccessScopeService.IsManager(User)) { IReadOnlyCollection workspaceScopeIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct); query = query.Where(notificationEvent => diff --git a/backend/src/Socialize.Api/Modules/Organizations/Services/OrganizationAccessService.cs b/backend/src/Socialize.Api/Modules/Organizations/Services/OrganizationAccessService.cs index 7da0692d..c3e8caeb 100644 --- a/backend/src/Socialize.Api/Modules/Organizations/Services/OrganizationAccessService.cs +++ b/backend/src/Socialize.Api/Modules/Organizations/Services/OrganizationAccessService.cs @@ -9,7 +9,7 @@ namespace Socialize.Api.Modules.Organizations.Services; internal sealed class OrganizationAccessService( AppDbContext dbContext) { - public bool IsGlobalManager(ClaimsPrincipal user) + public static bool IsGlobalManager(ClaimsPrincipal user) { return user.IsInRole(KnownRoles.Administrator) || user.IsInRole(KnownRoles.Manager); } diff --git a/backend/src/Socialize.Api/Modules/Workspaces/Handlers/CreateWorkspaceInvite.cs b/backend/src/Socialize.Api/Modules/Workspaces/Handlers/CreateWorkspaceInvite.cs index 55e85e26..04c0fcc6 100644 --- a/backend/src/Socialize.Api/Modules/Workspaces/Handlers/CreateWorkspaceInvite.cs +++ b/backend/src/Socialize.Api/Modules/Workspaces/Handlers/CreateWorkspaceInvite.cs @@ -4,6 +4,7 @@ using Socialize.Api.Data; using Socialize.Api.Infrastructure.Security; using Socialize.Api.Modules.Identity.Contracts; using Socialize.Api.Modules.Workspaces.Data; +using System.Globalization; namespace Socialize.Api.Modules.Workspaces.Handlers; @@ -59,7 +60,9 @@ internal class CreateWorkspaceInviteHandler( return; } - string normalizedEmail = request.Email.Trim().ToLowerInvariant(); +#pragma warning disable CA1308 // Email addresses are conventionally normalized to lowercase for storage and lookup. + string normalizedEmail = request.Email.Trim().ToLower(CultureInfo.InvariantCulture); +#pragma warning restore CA1308 string normalizedRole = request.Role.Trim(); bool duplicateInvite = await dbContext.WorkspaceInvites.AnyAsync( diff --git a/backend/src/Socialize.Api/Program.cs b/backend/src/Socialize.Api/Program.cs index 988c5115..a53c173f 100644 --- a/backend/src/Socialize.Api/Program.cs +++ b/backend/src/Socialize.Api/Program.cs @@ -22,6 +22,8 @@ using Socialize.Api.Modules.Organizations; using Socialize.Api.Modules.Workspaces; +const string SeededTestDataMessage = "Seeded test data."; + var builder = WebApplication.CreateBuilder(args); bool seedTestData = args.Any(arg => string.Equals(arg, "seed-testdata", StringComparison.OrdinalIgnoreCase)); @@ -94,7 +96,7 @@ if (seedTestData) await app.UseAppDataAsync(); await app.UseIdentityModuleAsync(); await app.Services.SeedTestDataAsync(); - Console.WriteLine("Seeded test data."); + Console.WriteLine(SeededTestDataMessage); return; } diff --git a/backend/src/Socialize.Api/Socialize.Api.csproj b/backend/src/Socialize.Api/Socialize.Api.csproj index 6c8e1009..f017c668 100644 --- a/backend/src/Socialize.Api/Socialize.Api.csproj +++ b/backend/src/Socialize.Api/Socialize.Api.csproj @@ -9,7 +9,7 @@ true AllEnabledByDefault - false + true $(NoWarn);CA2007 false diff --git a/backend/tests/Socialize.Tests/CalendarIntegrations/CalendarExportFeedTests.cs b/backend/tests/Socialize.Tests/CalendarIntegrations/CalendarExportFeedTests.cs index 9af6d26d..bb3d4742 100644 --- a/backend/tests/Socialize.Tests/CalendarIntegrations/CalendarExportFeedTests.cs +++ b/backend/tests/Socialize.Tests/CalendarIntegrations/CalendarExportFeedTests.cs @@ -29,8 +29,7 @@ public class CalendarExportFeedTests [Fact] public void Build_emits_valid_ics_for_user_work_without_sensitive_discussion_details() { - CalendarExportFeedBuilder builder = new(); - string ics = builder.Build( + string ics = CalendarExportFeedBuilder.Build( "Socialize my work", [ new CalendarExportFeedEvent( diff --git a/backend/tests/Socialize.Tests/CalendarIntegrations/IcsCalendarParserTests.cs b/backend/tests/Socialize.Tests/CalendarIntegrations/IcsCalendarParserTests.cs index 609170df..9239af89 100644 --- a/backend/tests/Socialize.Tests/CalendarIntegrations/IcsCalendarParserTests.cs +++ b/backend/tests/Socialize.Tests/CalendarIntegrations/IcsCalendarParserTests.cs @@ -4,8 +4,6 @@ namespace Socialize.Tests.CalendarIntegrations; public class IcsCalendarParserTests { - private readonly IcsCalendarParser _parser = new(); - [Fact] public void Parse_preserves_all_day_calendar_dates() { @@ -20,7 +18,7 @@ public class IcsCalendarParserTests END:VCALENDAR """; - ParsedCalendarEvent calendarEvent = Assert.Single(_parser.Parse( + ParsedCalendarEvent calendarEvent = Assert.Single(IcsCalendarParser.Parse( ics, new DateOnly(2026, 12, 1), new DateOnly(2026, 12, 31))); @@ -45,7 +43,7 @@ public class IcsCalendarParserTests END:VCALENDAR """; - ParsedCalendarEvent calendarEvent = Assert.Single(_parser.Parse( + ParsedCalendarEvent calendarEvent = Assert.Single(IcsCalendarParser.Parse( ics, new DateOnly(2026, 5, 1), new DateOnly(2026, 5, 31))); @@ -70,7 +68,7 @@ public class IcsCalendarParserTests END:VCALENDAR """; - ParsedCalendarEvent calendarEvent = Assert.Single(_parser.Parse( + ParsedCalendarEvent calendarEvent = Assert.Single(IcsCalendarParser.Parse( ics, new DateOnly(2026, 5, 1), new DateOnly(2026, 5, 31))); @@ -96,7 +94,7 @@ public class IcsCalendarParserTests END:VCALENDAR """; - IReadOnlyCollection events = _parser.Parse( + IReadOnlyCollection events = IcsCalendarParser.Parse( ics, new DateOnly(2026, 1, 1), new DateOnly(2027, 12, 31)); @@ -122,7 +120,7 @@ public class IcsCalendarParserTests END:VCALENDAR """; - ParsedCalendarEvent calendarEvent = Assert.Single(_parser.Parse( + ParsedCalendarEvent calendarEvent = Assert.Single(IcsCalendarParser.Parse( ics, new DateOnly(2026, 5, 1), new DateOnly(2026, 5, 31))); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0cd9c34a..831b9433 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -41,6 +41,7 @@ "eslint": "^10.3.0", "eslint-plugin-tailwindcss": "^3.18.3", "eslint-plugin-vue": "^10.9.1", + "openapi-typescript": "^7.13.0", "prettier": "^3.8.3", "rollup-plugin-visualizer": "^7.0.1", "tailwindcss": "^4.2.4", @@ -48,6 +49,21 @@ "vue-eslint-parser": "^10.4.0" } }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/generator": { "version": "7.29.1", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", @@ -123,6 +139,7 @@ "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -134,6 +151,7 @@ "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -144,6 +162,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -172,6 +191,7 @@ "version": "4.12.2", "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, "license": "MIT", "engines": { "node": "^12.0.0 || ^14.0.0 || >=16.0.0" @@ -181,6 +201,7 @@ "version": "0.23.5", "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@eslint/object-schema": "^3.0.5", @@ -195,6 +216,7 @@ "version": "0.5.5", "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.5.tgz", "integrity": "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@eslint/core": "^1.2.1" @@ -207,6 +229,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@types/json-schema": "^7.0.15" @@ -219,6 +242,7 @@ "version": "3.0.5", "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, "license": "Apache-2.0", "engines": { "node": "^20.19.0 || ^22.13.0 || >=24" @@ -228,6 +252,7 @@ "version": "0.7.1", "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.1.tgz", "integrity": "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@eslint/core": "^1.2.1", @@ -241,6 +266,7 @@ "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@humanfs/types": "^0.15.0" @@ -253,6 +279,7 @@ "version": "0.16.8", "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@humanfs/core": "^0.19.2", @@ -267,6 +294,7 @@ "version": "0.15.0", "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=18.18.0" @@ -276,6 +304,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=12.22" @@ -289,6 +318,7 @@ "version": "0.4.3", "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=18.18" @@ -1090,6 +1120,7 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1143,7 +1174,7 @@ "version": "0.128.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.128.0.tgz", "integrity": "sha512-huv1Y/LzBJkBVHt3OlC7u0zHBW9qXf1FdD7sGmc1rXc2P1mTwHssYv7jyGx5KAACSCH+9B3Bhn6Z9luHRvf7pQ==", - "devOptional": true, + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/Boshen" @@ -1162,6 +1193,113 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@redocly/ajv": { + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz", + "integrity": "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js-replace": "^1.0.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@redocly/ajv/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/@redocly/config": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.22.0.tgz", + "integrity": "sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@redocly/openapi-core": { + "version": "1.34.14", + "resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.14.tgz", + "integrity": "sha512-y+xFx+Zz54Xhr8jUdnLENYnt7Y7GEDL6Q03ga7rTtX8DVwefX9H+hQEPgJp1nda7vdH+wJ9/HBVvyfBuW9x6rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/ajv": "8.11.2", + "@redocly/config": "0.22.0", + "colorette": "1.4.0", + "https-proxy-agent": "7.0.6", + "js-levenshtein": "1.1.6", + "js-yaml": "4.1.1", + "minimatch": "5.1.9", + "pluralize": "8.0.0", + "yaml-ast-parser": "0.0.43" + }, + "engines": { + "node": ">=18.17.0", + "npm": ">=9.5.0" + } + }, + "node_modules/@redocly/openapi-core/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/@redocly/openapi-core/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@redocly/openapi-core/node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@redocly/openapi-core/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@redocly/openapi-core/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.0-rc.18", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.18.tgz", @@ -1169,6 +1307,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1185,6 +1324,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1201,6 +1341,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1217,6 +1358,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1233,6 +1375,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1249,6 +1392,7 @@ "cpu": [ "arm64" ], + "dev": true, "libc": [ "glibc" ], @@ -1268,6 +1412,7 @@ "cpu": [ "arm64" ], + "dev": true, "libc": [ "musl" ], @@ -1287,6 +1432,7 @@ "cpu": [ "ppc64" ], + "dev": true, "libc": [ "glibc" ], @@ -1306,6 +1452,7 @@ "cpu": [ "s390x" ], + "dev": true, "libc": [ "glibc" ], @@ -1325,6 +1472,7 @@ "cpu": [ "x64" ], + "dev": true, "libc": [ "glibc" ], @@ -1344,6 +1492,7 @@ "cpu": [ "x64" ], + "dev": true, "libc": [ "musl" ], @@ -1363,6 +1512,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1379,6 +1529,7 @@ "cpu": [ "wasm32" ], + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1397,6 +1548,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1413,6 +1565,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1824,6 +1977,7 @@ "version": "0.10.2", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1834,6 +1988,7 @@ "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, "license": "MIT" }, "node_modules/@types/estree": { @@ -1846,6 +2001,7 @@ "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, "license": "MIT" }, "node_modules/@types/lodash": { @@ -2536,6 +2692,7 @@ "version": "6.15.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", @@ -2548,6 +2705,16 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/ansi-regex": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", @@ -2574,6 +2741,13 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, "node_modules/arrify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz", @@ -2770,6 +2944,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/change-case": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz", + "integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==", + "dev": true, + "license": "MIT" + }, "node_modules/chokidar": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", @@ -2806,6 +2987,13 @@ "node": ">=20" } }, + "node_modules/colorette": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz", + "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==", + "dev": true, + "license": "MIT" + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -2854,6 +3042,7 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -2919,6 +3108,7 @@ "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, "license": "MIT" }, "node_modules/default-browser": { @@ -2977,7 +3167,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -3104,6 +3294,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -3137,6 +3328,7 @@ "version": "10.3.0", "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.3.0.tgz", "integrity": "sha512-XbEXaRva5cF0ZQB8w6MluHA0kZZfV2DuCMJ3ozyEOHLwDpZX2Lmm/7Pp0xdJmI0GL1W05VH5VwIFHEm1Vcw2gw==", + "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", @@ -3290,6 +3482,7 @@ "version": "9.1.2", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, "license": "BSD-2-Clause", "dependencies": { "@types/esrecurse": "^4.3.1", @@ -3320,6 +3513,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, "license": "Apache-2.0", "engines": { "node": "^20.19.0 || ^22.13.0 || >=24" @@ -3332,6 +3526,7 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, "license": "MIT", "engines": { "node": ">= 4" @@ -3341,6 +3536,7 @@ "version": "11.2.0", "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, "license": "BSD-2-Clause", "dependencies": { "acorn": "^8.16.0", @@ -3358,6 +3554,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, "license": "Apache-2.0", "engines": { "node": "^20.19.0 || ^22.13.0 || >=24" @@ -3383,6 +3580,7 @@ "version": "1.7.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, "license": "BSD-3-Clause", "dependencies": { "estraverse": "^5.1.0" @@ -3395,6 +3593,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, "license": "BSD-2-Clause", "dependencies": { "estraverse": "^5.2.0" @@ -3452,6 +3651,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, "license": "MIT" }, "node_modules/fast-diff": { @@ -3493,12 +3693,14 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, "license": "MIT" }, "node_modules/fast-levenshtein": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, "license": "MIT" }, "node_modules/fast-printf": { @@ -3552,6 +3754,7 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, "license": "MIT", "dependencies": { "flat-cache": "^4.0.0" @@ -3576,6 +3779,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, "license": "MIT", "dependencies": { "locate-path": "^6.0.0", @@ -3592,6 +3796,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, "license": "MIT", "dependencies": { "flatted": "^3.2.9", @@ -3605,6 +3810,7 @@ "version": "3.4.2", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, "license": "ISC" }, "node_modules/follow-redirects": { @@ -3647,6 +3853,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -3759,6 +3966,7 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.3" @@ -3981,11 +4189,25 @@ "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.8.19" } }, + "node_modules/index-to-position": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", + "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-docker": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", @@ -4108,18 +4330,49 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, "license": "ISC" }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", - "devOptional": true, + "dev": true, "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/js-levenshtein": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz", + "integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==", + "dev": true, + "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/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -4145,18 +4398,21 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, "license": "MIT" }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, "license": "MIT" }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, "license": "MIT" }, "node_modules/json5": { @@ -4240,6 +4496,7 @@ "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, "license": "MIT", "dependencies": { "json-buffer": "3.0.1" @@ -4249,6 +4506,7 @@ "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, "license": "MIT", "dependencies": { "prelude-ls": "^1.2.1", @@ -4262,7 +4520,7 @@ "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", - "devOptional": true, + "dev": true, "license": "MPL-2.0", "dependencies": { "detect-libc": "^2.0.3" @@ -4295,6 +4553,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -4315,6 +4574,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -4335,6 +4595,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -4355,6 +4616,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -4375,6 +4637,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -4395,6 +4658,7 @@ "cpu": [ "arm64" ], + "dev": true, "libc": [ "glibc" ], @@ -4418,6 +4682,7 @@ "cpu": [ "arm64" ], + "dev": true, "libc": [ "musl" ], @@ -4441,6 +4706,7 @@ "cpu": [ "x64" ], + "dev": true, "libc": [ "glibc" ], @@ -4464,6 +4730,7 @@ "cpu": [ "x64" ], + "dev": true, "libc": [ "musl" ], @@ -4487,6 +4754,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -4507,6 +4775,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -4541,6 +4810,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, "license": "MIT", "dependencies": { "p-locate": "^5.0.0" @@ -4793,6 +5063,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, "license": "MIT" }, "node_modules/node-fetch": { @@ -4870,10 +5141,42 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/openapi-typescript": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.13.0.tgz", + "integrity": "sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/openapi-core": "^1.34.6", + "ansi-colors": "^4.1.3", + "change-case": "^5.4.4", + "parse-json": "^8.3.0", + "supports-color": "^10.2.2", + "yargs-parser": "^21.1.1" + }, + "bin": { + "openapi-typescript": "bin/cli.js" + }, + "peerDependencies": { + "typescript": "^5.x" + } + }, + "node_modules/openapi-typescript/node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, "license": "MIT", "dependencies": { "deep-is": "^0.1.3", @@ -4891,6 +5194,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, "license": "MIT", "dependencies": { "yocto-queue": "^0.1.0" @@ -4906,6 +5210,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, "license": "MIT", "dependencies": { "p-limit": "^3.0.2" @@ -4917,10 +5222,29 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse-json": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", + "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "index-to-position": "^1.1.0", + "type-fest": "^4.39.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -4930,6 +5254,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -4997,6 +5322,16 @@ "pathe": "^2.0.3" } }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/postcss": { "version": "8.5.14", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", @@ -5056,6 +5391,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.8.0" @@ -5109,6 +5445,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -5187,6 +5524,16 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -5207,7 +5554,7 @@ "version": "1.0.0-rc.18", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.18.tgz", "integrity": "sha512-phmyKBpuBdRYDf4hgyynGAYn/rDDe+iZXKVJ7WX5b1zQzpLkP5oJRPGsfJuHdzPMlyyEO/4sPW6yfSx2gf7lVg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@oxc-project/types": "=0.128.0", @@ -5241,7 +5588,7 @@ "version": "1.0.0-rc.18", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.18.tgz", "integrity": "sha512-CUY5Mnhe64xQBGZEEXQ5WyZwsc1JU3vAZLIxtrsBt3LO6UOb+C8GunVKqe9sT8NeWb4lqSaoJtp2xo6GxT1MNw==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/rollup-plugin-visualizer": { @@ -5369,6 +5716,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -5381,6 +5729,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -5538,6 +5887,19 @@ "node": ">=16" } }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, "node_modules/synckit": { "version": "0.11.12", "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz", @@ -5657,6 +6019,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, "license": "0BSD", "optional": true }, @@ -5664,6 +6027,7 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, "license": "MIT", "dependencies": { "prelude-ls": "^1.2.1" @@ -5738,11 +6102,19 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, "license": "BSD-2-Clause", "dependencies": { "punycode": "^2.1.0" } }, + "node_modules/uri-js-replace": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.1.tgz", + "integrity": "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==", + "dev": true, + "license": "MIT" + }, "node_modules/url-template": { "version": "2.0.8", "resolved": "https://registry.npmjs.org/url-template/-/url-template-2.0.8.tgz", @@ -5795,7 +6167,7 @@ "version": "8.0.11", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.11.tgz", "integrity": "sha512-Jz1mxtUBR5xTT65VOdJZUUeoyLtqljmFkiUXhPTLZka3RDc9vpi/xXkyrnsdRcm2lIi3l3GPMnAidTsEGIj3Ow==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", @@ -6147,6 +6519,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -6162,6 +6535,7 @@ "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -6243,6 +6617,13 @@ "url": "https://github.com/sponsors/eemeli" } }, + "node_modules/yaml-ast-parser": { + "version": "0.0.43", + "resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz", + "integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/yaml-eslint-parser": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/yaml-eslint-parser/-/yaml-eslint-parser-1.3.2.tgz", @@ -6291,6 +6672,7 @@ "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=10" diff --git a/frontend/package.json b/frontend/package.json index 94340a50..8330d6ff 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -36,8 +36,8 @@ "vuetify": "^4.0.6" }, "devDependencies": { - "@types/webpack-env": "^1.18.8", "@tailwindcss/vite": "^4.2.4", + "@types/webpack-env": "^1.18.8", "@typescript-eslint/eslint-plugin": "^8.59.2", "@typescript-eslint/parser": "^8.59.2", "@vitejs/plugin-vue": "^6.0.6", @@ -45,6 +45,7 @@ "eslint": "^10.3.0", "eslint-plugin-tailwindcss": "^3.18.3", "eslint-plugin-vue": "^10.9.1", + "openapi-typescript": "^7.13.0", "prettier": "^3.8.3", "rollup-plugin-visualizer": "^7.0.1", "tailwindcss": "^4.2.4", diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts index 35f25f64..1a0f0478 100644 --- a/frontend/src/api/schema.d.ts +++ b/frontend/src/api/schema.d.ts @@ -308,6 +308,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/users/confirm-email-change": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["SocializeApiModulesIdentityHandlersConfirmEmailChangeHandler"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/users/forgot-password": { parameters: { query?: never; @@ -1262,6 +1278,9 @@ export interface components { /** Format: date-time */ birthDate?: string; }; + SocializeApiModulesIdentityHandlersChangeEmailResponse: { + message?: string; + }; SocializeApiModulesIdentityHandlersChangeEmailRequest: { email?: string | null; }; @@ -1279,6 +1298,10 @@ export interface components { /** Format: binary */ file: string; }; + SocializeApiModulesIdentityHandlersConfirmEmailChangeResponse: { + message?: string; + }; + SocializeApiModulesIdentityHandlersConfirmEmailChangeRequest: Record; SocializeApiModulesIdentityHandlersForgotPasswordRequest: { email?: string; }; @@ -2529,12 +2552,14 @@ export interface operations { }; }; responses: { - /** @description No Content */ - 204: { + /** @description Success */ + 200: { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["SocializeApiModulesIdentityHandlersChangeEmailResponse"]; + }; }; /** @description Unauthorized */ 401: { @@ -2670,6 +2695,30 @@ export interface operations { }; }; }; + SocializeApiModulesIdentityHandlersConfirmEmailChangeHandler: { + parameters: { + query: { + userId: string; + email: string; + token: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SocializeApiModulesIdentityHandlersConfirmEmailChangeResponse"]; + }; + }; + }; + }; SocializeApiModulesIdentityHandlersForgotPasswordHandler: { parameters: { query?: never; diff --git a/frontend/src/features/auth/views/VerifyEmailView.vue b/frontend/src/features/auth/views/VerifyEmailView.vue index 5ab84440..969e084f 100644 --- a/frontend/src/features/auth/views/VerifyEmailView.vue +++ b/frontend/src/features/auth/views/VerifyEmailView.vue @@ -24,8 +24,10 @@ icon="mdi-check-circle" size="64" > -

{{ t('success.title') }}

-

{{ t('success.message') }}

+

+ {{ t(isEmailChange ? 'success.emailChangeTitle' : 'success.title') }} +

+

{{ t(isEmailChange ? 'success.emailChangeMessage' : 'success.message') }}

{ const userId = route.query.userId; const token = route.query.token; + const email = route.query.email; + isEmailChange.value = route.query.changeEmail === 'true'; // Populate resend email field if it was in the URL if (route.query.email) { @@ -190,7 +195,7 @@ } // Check if we have the required parameters - if (!userId || !token) { + if (!userId || !token || (isEmailChange.value && !email)) { isLoading.value = false; if (route.query.email || route.query.pending) { showResendOnly.value = true; @@ -201,8 +206,10 @@ } try { - // Call the verification endpoint - await clientApi.get(`/api/users/verify-email?userId=${userId}&token=${token}`); + const endpoint = isEmailChange.value + ? `/api/users/confirm-email-change?userId=${encodeURIComponent(userId)}&email=${encodeURIComponent(email)}&token=${encodeURIComponent(token)}` + : `/api/users/verify-email?userId=${encodeURIComponent(userId)}&token=${encodeURIComponent(token)}`; + await clientApi.get(endpoint); verificationSuccess.value = true; } catch (error) { console.error('Email verification failed:', error); @@ -252,6 +259,8 @@ "success": { "title": "Email Verified Successfully!", "message": "Your email has been verified. You can now log in to your account.", + "emailChangeTitle": "Email Changed Successfully!", + "emailChangeMessage": "Your account email address has been updated. Use the new email the next time you log in.", "goToLogin": "Go to Login" }, "error": { @@ -279,6 +288,8 @@ "success": { "title": "Email vérifié avec succès !", "message": "Votre email a été vérifié. Vous pouvez maintenant vous connecter à votre compte.", + "emailChangeTitle": "Email modifié avec succès !", + "emailChangeMessage": "L'adresse email de votre compte a été mise à jour. Utilisez le nouvel email lors de votre prochaine connexion.", "goToLogin": "Aller à la connexion" }, "error": { diff --git a/frontend/src/features/user-profile/stores/userProfileStore.js b/frontend/src/features/user-profile/stores/userProfileStore.js index 22476987..2e9f49b9 100644 --- a/frontend/src/features/user-profile/stores/userProfileStore.js +++ b/frontend/src/features/user-profile/stores/userProfileStore.js @@ -155,12 +155,12 @@ export const useUserProfileStore = defineStore( try { const client = useClient() - await client.post( + const response = await client.post( `/api/users/email`, { email: email }) - value.value.email = email; + return response.data; } catch (updateError) { console.error(updateError) error.value = 'Failed to update profile.' diff --git a/frontend/src/features/user-profile/views/UserSettingsView.vue b/frontend/src/features/user-profile/views/UserSettingsView.vue index 4c468f73..9e88c236 100644 --- a/frontend/src/features/user-profile/views/UserSettingsView.vue +++ b/frontend/src/features/user-profile/views/UserSettingsView.vue @@ -69,12 +69,19 @@ await userProfileStore.changeAlias(nextAlias || null); } + let emailChangeRequested = false; if (nextEmail !== (user.email ?? '')) { await userProfileStore.changeEmail(nextEmail); + emailChangeRequested = true; } - settingsStatus.value = t('userSettings.saved'); - syncFormFromUser(userProfileStore.user); + settingsStatus.value = emailChangeRequested + ? t('userSettings.emailConfirmationSent') + : t('userSettings.saved'); + + if (!emailChangeRequested) { + syncFormFromUser(userProfileStore.user); + } } catch (error) { console.error('Failed to update user settings:', error); settingsError.value = t('userSettings.errors.saveFailed'); diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index fdc9f274..87059d33 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -936,6 +936,7 @@ "accountDetailsDescription": "Edit the profile details other workspace members see.", "saveDetails": "Save details", "saved": "Profile details saved", + "emailConfirmationSent": "Profile details saved. Check your new email address to confirm the email change.", "portraitSaved": "Portrait saved", "calendarFeed": { "title": "Private calendar feed", diff --git a/frontend/src/locales/fr.json b/frontend/src/locales/fr.json index 67f6135e..2d3c0502 100644 --- a/frontend/src/locales/fr.json +++ b/frontend/src/locales/fr.json @@ -936,6 +936,7 @@ "accountDetailsDescription": "Modifiez les informations de profil visibles par les autres membres.", "saveDetails": "Enregistrer les détails", "saved": "Informations de profil enregistrées", + "emailConfirmationSent": "Informations de profil enregistrées. Consultez votre nouvelle adresse email pour confirmer le changement.", "portraitSaved": "Portrait enregistré", "calendarFeed": { "title": "Flux calendrier privé", diff --git a/shared/openapi/openapi.json b/shared/openapi/openapi.json index 50e71b7a..e8ea5a88 100644 --- a/shared/openapi/openapi.json +++ b/shared/openapi/openapi.json @@ -7,7 +7,7 @@ }, "servers": [ { - "url": "http://localhost:5080" + "url": "http://localhost:5081" } ], "paths": { @@ -854,8 +854,15 @@ "x-position": 1 }, "responses": { - "204": { - "description": "No Content" + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SocializeApiModulesIdentityHandlersChangeEmailResponse" + } + } + } }, "401": { "description": "Unauthorized" @@ -1017,6 +1024,53 @@ ] } }, + "/api/users/confirm-email-change": { + "get": { + "tags": [ + "Users", + "Api" + ], + "operationId": "SocializeApiModulesIdentityHandlersConfirmEmailChangeHandler", + "parameters": [ + { + "name": "userId", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "email", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "token", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SocializeApiModulesIdentityHandlersConfirmEmailChangeResponse" + } + } + } + } + } + } + }, "/api/users/forgot-password": { "post": { "tags": [ @@ -4269,6 +4323,15 @@ } } }, + "SocializeApiModulesIdentityHandlersChangeEmailResponse": { + "type": "object", + "additionalProperties": false, + "properties": { + "message": { + "type": "string" + } + } + }, "SocializeApiModulesIdentityHandlersChangeEmailRequest": { "type": "object", "additionalProperties": false, @@ -4327,6 +4390,19 @@ } } }, + "SocializeApiModulesIdentityHandlersConfirmEmailChangeResponse": { + "type": "object", + "additionalProperties": false, + "properties": { + "message": { + "type": "string" + } + } + }, + "SocializeApiModulesIdentityHandlersConfirmEmailChangeRequest": { + "type": "object", + "additionalProperties": false + }, "SocializeApiModulesIdentityHandlersForgotPasswordRequest": { "type": "object", "additionalProperties": false,