fix: confirm email changes and enforce clean backend build
Some checks failed
deploy-socialize / deploy (push) Has been cancelled
deploy-socialize / image (push) Has been cancelled

This commit is contained in:
2026-05-07 14:39:22 -04:00
parent 9022fa7d93
commit 57abe57bc7
54 changed files with 974 additions and 206 deletions

View File

@@ -78,7 +78,7 @@ internal static class ApplicationRegistration
ValidAudience = authJwt["Audience"], ValidAudience = authJwt["Audience"],
ValidateLifetime = true, ValidateLifetime = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(authJwt["Key"] ?? 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 => authenticationBuilder.AddGoogle(GoogleDefaults.AuthenticationScheme, options =>
{ {
options.ClientId = authGoogle["ClientId"] ?? options.ClientId = authGoogle["ClientId"] ??
throw new ArgumentNullException("The Google ClientId is missing."); throw new InvalidOperationException("Authentication:Google:ClientId is required.");
options.ClientSecret = authGoogle["ClientSecret"] ?? 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 => authenticationBuilder.AddFacebook(FacebookDefaults.AuthenticationScheme, options =>
{ {
options.ClientId = authFacebook["ClientId"] ?? options.ClientId = authFacebook["ClientId"] ??
throw new ArgumentNullException("The Facebook ClientId is missing."); throw new InvalidOperationException("Authentication:Facebook:ClientId is required.");
options.ClientSecret = authFacebook["ClientSecret"] ?? options.ClientSecret = authFacebook["ClientSecret"] ??
throw new ArgumentNullException("The Facebook ClientSecret is missing."); throw new InvalidOperationException("Authentication:Facebook:ClientSecret is required.");
}); });
} }

View File

@@ -39,6 +39,6 @@ internal static class ContentTypes
// Check for HTML content by looking for "<!DOCTYPE html>" or "<html>" tags // Check for HTML content by looking for "<!DOCTYPE html>" or "<html>" tags
string content = Encoding.UTF8.GetString(buffer); string content = Encoding.UTF8.GetString(buffer);
return content.Contains("<!DOCTYPE html>"); return content.Contains("<!DOCTYPE html>", StringComparison.OrdinalIgnoreCase);
} }
} }

View File

@@ -14,6 +14,14 @@ internal sealed class LocalBlobStorage(
private const long MaxUploadSize = 10 * 1024 * 1024; private const long MaxUploadSize = 10 * 1024 * 1024;
private const string ContentTypeMetadataSuffix = ".content-type"; private const string ContentTypeMetadataSuffix = ".content-type";
private static readonly char[] PathSeparators = [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar];
private static readonly Action<ILogger, string, string, string, string, Exception?> LogUploadedFile =
LoggerMessage.Define<string, string, string, string>(
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; private readonly LocalBlobStorageOptions _options = options.Value;
public async Task<string> UploadFileAsync( public async Task<string> UploadFileAsync(
@@ -46,12 +54,7 @@ internal sealed class LocalBlobStorage(
await File.WriteAllTextAsync(GetContentTypeMetadataPath(filePath), contentType, ct); await File.WriteAllTextAsync(GetContentTypeMetadataPath(filePath), contentType, ct);
string fileUri = BuildPublicUrl(relativePath); string fileUri = BuildPublicUrl(relativePath);
logger.LogInformation( LogUploadedFile(logger, blobName, containerName, contentType, fileUri, null);
"Blob storage: Uploaded [{BlobName}] to local container [{ContainerName}] with contentType [{ContentType}] and uri [{FileUri}]",
blobName,
containerName,
contentType,
fileUri);
return fileUri; return fileUri;
} }
@@ -106,7 +109,7 @@ internal sealed class LocalBlobStorage(
throw new InvalidOperationException("Blob storage: Blob paths must be relative."); 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 "..")) if (pathParts.Any(part => part is "" or "." or ".."))
{ {
throw new InvalidOperationException("Blob storage: Blob paths must not contain relative path segments."); throw new InvalidOperationException("Blob storage: Blob paths must not contain relative path segments.");
@@ -135,7 +138,7 @@ internal sealed class LocalBlobStorage(
? "/api/storage" ? "/api/storage"
: requestPath.Trim(); : requestPath.Trim();
return normalized.StartsWith("/", StringComparison.Ordinal) return normalized.StartsWith('/')
? normalized.TrimEnd('/') ? normalized.TrimEnd('/')
: $"/{normalized.TrimEnd('/')}"; : $"/{normalized.TrimEnd('/')}";
} }

View File

@@ -5,14 +5,15 @@ namespace Socialize.Api.Infrastructure.Emailer.Services;
internal class LoggerEmailSender(ILogger<IEmailSender> logger) internal class LoggerEmailSender(ILogger<IEmailSender> logger)
: IEmailSender : IEmailSender
{ {
private static readonly Action<ILogger, string, string, string, string, Exception?> LogDevelopmentEmail =
LoggerMessage.Define<string, string, string, string>(
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) public Task SendEmailAsync(string email, string subject, string message)
{ {
logger.LogInformation( LogDevelopmentEmail(logger, email, subject, Environment.NewLine, message, null);
"Development email to {Email} with subject {Subject}:{NewLine}{Message}",
email,
subject,
Environment.NewLine,
message);
return Task.CompletedTask; return Task.CompletedTask;
} }

View File

@@ -43,14 +43,13 @@ internal class ResendEmailSender : IEmailSender
new MediaTypeWithQualityHeaderValue("application/json")); 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); string json = JsonSerializer.Serialize(payload);
StringContent content = new(json, Encoding.UTF8, "application/json"); using StringContent content = new(json, Encoding.UTF8, "application/json");
using HttpResponseMessage response = await _httpClient.PostAsync(EndpointUri, content);
HttpResponseMessage response = await _httpClient.PostAsync(EndpointUri, content);
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
{ {

View File

@@ -7,49 +7,49 @@ namespace Socialize.Api.Infrastructure.Security;
internal sealed class AccessScopeService( internal sealed class AccessScopeService(
OrganizationAccessService organizationAccessService) OrganizationAccessService organizationAccessService)
{ {
public bool IsManager(ClaimsPrincipal user) public static bool IsManager(ClaimsPrincipal user)
{ {
return user.IsInRole(KnownRoles.Administrator) || user.IsInRole(KnownRoles.Manager); 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); return user.IsInRole(KnownRoles.Provider);
} }
public bool IsClient(ClaimsPrincipal user) public static bool IsClient(ClaimsPrincipal user)
{ {
return user.IsInRole(KnownRoles.Client); 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); 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); 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) return IsManager(user)
|| (CanAccessWorkspace(user, workspaceId) && user.GetClientScopeIds().Contains(clientId)); || (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) return IsManager(user)
|| (CanAccessClient(user, workspaceId, clientId) && user.GetCampaignScopeIds().Contains(campaignId)); || (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)); 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) return IsManager(user)
|| IsProvider(user) && CanAccessCampaign(user, workspaceId, clientId, campaignId) || IsProvider(user) && CanAccessCampaign(user, workspaceId, clientId, campaignId)

View File

@@ -1,4 +1,5 @@
using System.Security.Claims; using System.Globalization;
using System.Security.Claims;
namespace Socialize.Api.Infrastructure.Security; namespace Socialize.Api.Infrastructure.Security;
@@ -81,11 +82,11 @@ internal static class ClaimsPrincipalExtensions
if (claim is null) if (claim is null)
{ {
throw new MissingClaimException(key); throw MissingClaimException.ForClaim(key);
} }
return typeof(TValue) == typeof(Guid) return typeof(TValue) == typeof(Guid)
? Guid.Parse(claim.Value) ? Guid.Parse(claim.Value)
: Convert.ChangeType(claim.Value, typeof(TValue)); : Convert.ChangeType(claim.Value, typeof(TValue), CultureInfo.InvariantCulture);
} }
} }

View File

@@ -1,5 +1,23 @@
namespace Socialize.Api.Infrastructure.Security; namespace Socialize.Api.Infrastructure.Security;
internal class MissingClaimException( public class MissingClaimException : Exception
string claimName) {
: Exception($"Claim '{claimName}' is missing."); 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.");
}
}

View File

@@ -11,8 +11,6 @@ internal static class PasswordGenerator
private const string Numbers = "0123456789"; private const string Numbers = "0123456789";
private const string SpecialCharacters = "!@#$%^&*()_+-=[];',./`~{}|:\"<>?"; private const string SpecialCharacters = "!@#$%^&*()_+-=[];',./`~{}|:\"<>?";
private static readonly Random Random = new();
public static string Next( public static string Next(
int length = 15, int length = 15,
bool requireNumber = true, bool requireNumber = true,
@@ -23,7 +21,7 @@ internal static class PasswordGenerator
// Create pools based on the requirements // Create pools based on the requirements
StringBuilder characterPool = new(); StringBuilder characterPool = new();
if (requireNumber) if (requireLowercase)
{ {
characterPool.Append(LowerLetters); characterPool.Append(LowerLetters);
} }
@@ -51,22 +49,22 @@ internal static class PasswordGenerator
if (requireLowercase) if (requireLowercase)
{ {
password[index++] = LowerLetters[Random.Next(LowerLetters.Length)]; password[index++] = LowerLetters[RandomNumberGenerator.GetInt32(LowerLetters.Length)];
} }
if (requireCapital) if (requireCapital)
{ {
password[index++] = UpperLetters[Random.Next(UpperLetters.Length)]; password[index++] = UpperLetters[RandomNumberGenerator.GetInt32(UpperLetters.Length)];
} }
if (requireNumber) if (requireNumber)
{ {
password[index++] = Numbers[Random.Next(Numbers.Length)]; password[index++] = Numbers[RandomNumberGenerator.GetInt32(Numbers.Length)];
} }
if (requireSpecialCharacter) if (requireSpecialCharacter)
{ {
password[index++] = SpecialCharacters[Random.Next(SpecialCharacters.Length)]; password[index++] = SpecialCharacters[RandomNumberGenerator.GetInt32(SpecialCharacters.Length)];
} }
// Fill the rest with the password // Fill the rest with the password
@@ -85,7 +83,7 @@ internal static class PasswordGenerator
{ {
for (int i = array.Length - 1; i > 0; i--) 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 (array[i], array[j]) = (array[j], array[i]); // Swap elements
} }
} }

View File

@@ -19,6 +19,8 @@ using Microsoft.AspNetCore.Identity;
namespace Socialize.Api.Infrastructure.TestData; namespace Socialize.Api.Infrastructure.TestData;
#pragma warning disable S1075 // Test data intentionally uses representative external URLs.
internal static class TestDataSeedExtensions internal static class TestDataSeedExtensions
{ {
private static readonly Guid OrganizationId = Guid.Parse("99999999-9999-9999-9999-999999999999"); 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); await userManager.RemoveClaimAsync(user, claim);
} }
string persona = roles.Contains(KnownRoles.Manager, StringComparer.Ordinal) string persona = GetPersona(roles);
? KnownRoles.Manager
: roles.Contains(KnownRoles.Client, StringComparer.Ordinal)
? KnownRoles.Client
: roles.Contains(KnownRoles.Provider, StringComparer.Ordinal)
? KnownRoles.Provider
: KnownRoles.WorkspaceMember;
foreach (Claim claim in claims.Concat([new Claim(KnownClaims.Persona, persona)])) foreach (Claim claim in claims.Concat([new Claim(KnownClaims.Persona, persona)]))
{ {
@@ -225,6 +221,26 @@ internal static class TestDataSeedExtensions
return user; return user;
} }
private static string GetPersona(IReadOnlyCollection<string> 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( private static async Task EnsureOrganizationDataAsync(
Guid managerUserId, Guid managerUserId,
Guid developerUserId, Guid developerUserId,

View File

@@ -5,6 +5,7 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable #nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional #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 namespace Socialize.Api.Migrations
{ {

View File

@@ -8,6 +8,7 @@ using Socialize.Api.Modules.Approvals.Data;
using Socialize.Api.Modules.Approvals.Services; using Socialize.Api.Modules.Approvals.Services;
using Socialize.Api.Modules.Notifications.Contracts; using Socialize.Api.Modules.Notifications.Contracts;
using Socialize.Api.Modules.Workspaces.Data; using Socialize.Api.Modules.Workspaces.Data;
using System.Security.Claims;
using System.Text.Json; using System.Text.Json;
namespace Socialize.Api.Modules.Approvals.Handlers; namespace Socialize.Api.Modules.Approvals.Handlers;
@@ -79,12 +80,14 @@ internal class SubmitApprovalDecisionHandler(
} }
string normalizedDecision = request.Decision.Trim(); string normalizedDecision = request.Decision.Trim();
string decidedByName = User?.Identity?.IsAuthenticated == true ClaimsPrincipal? currentUser = User;
? User.GetAlias() ?? User.GetName() bool isAuthenticated = currentUser?.Identity?.IsAuthenticated == true;
: string.IsNullOrWhiteSpace(request.ReviewerName) ? approval.ReviewerName : request.ReviewerName.Trim(); string decidedByName = isAuthenticated
string decidedByEmail = User?.Identity?.IsAuthenticated == true ? currentUser!.GetAlias() ?? currentUser!.GetName()
? User.GetEmail() : GetReviewerName(request.ReviewerName, approval.ReviewerName);
: string.IsNullOrWhiteSpace(request.ReviewerEmail) ? approval.ReviewerEmail : request.ReviewerEmail.Trim(); string decidedByEmail = isAuthenticated
? currentUser!.GetEmail()
: GetReviewerEmail(request.ReviewerEmail, approval.ReviewerEmail);
ApprovalDecision decision = new() ApprovalDecision decision = new()
{ {
@@ -207,4 +210,18 @@ internal class SubmitApprovalDecisionHandler(
await SendOkAsync(dto, ct); 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();
}
} }

View File

@@ -145,13 +145,15 @@ internal class ApprovalWorkflowRuntimeService(
dbContext.ApprovalDecisions.Add(decision); dbContext.ApprovalDecisions.Add(decision);
await dbContext.SaveChangesAsync(ct); await dbContext.SaveChangesAsync(ct);
int approvedCount = await dbContext.ApprovalDecisions var approvalDecisionParticipants = await dbContext.ApprovalDecisions
.Where(candidate => candidate.ApprovalRequestId == approval.Id && candidate.Decision == ApprovedState) .Where(candidate => candidate.ApprovalRequestId == approval.Id && candidate.Decision == ApprovedState)
.Select(candidate => candidate.DecidedByUserId.HasValue .Select(candidate => candidate.DecidedByUserId.HasValue
? candidate.DecidedByUserId.Value.ToString() ? candidate.DecidedByUserId.Value.ToString()
: candidate.DecidedByEmail.ToLower()) : candidate.DecidedByEmail)
.Distinct() .ToListAsync(ct);
.CountAsync(ct); int approvedCount = approvalDecisionParticipants
.Distinct(StringComparer.OrdinalIgnoreCase)
.Count();
int requiredApproverCount = approval.WorkflowStepRequiredApproverCount ?? 1; int requiredApproverCount = approval.WorkflowStepRequiredApproverCount ?? 1;
if (!ApprovalWorkflowRules.HasRequiredStepApprovals(approvedCount, requiredApproverCount)) if (!ApprovalWorkflowRules.HasRequiredStepApprovals(approvedCount, requiredApproverCount))
@@ -394,7 +396,7 @@ internal class ApprovalWorkflowRuntimeService(
private static string CreateAccessToken() 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); private sealed record ApprovalNotificationRecipient(Guid UserId, string? Email);

View File

@@ -1,5 +1,7 @@
namespace Socialize.Api.Modules.CalendarIntegrations.Data; namespace Socialize.Api.Modules.CalendarIntegrations.Data;
#pragma warning disable S1075 // Catalog seed entries intentionally store source URLs.
internal static class CalendarCatalogSeed internal static class CalendarCatalogSeed
{ {
public static readonly CalendarCatalogEntry[] Entries = public static readonly CalendarCatalogEntry[] Entries =

View File

@@ -121,7 +121,7 @@ internal class CreateCalendarSourceHandler(
source.CatalogSourceReference == normalizedCatalogReference) || source.CatalogSourceReference == normalizedCatalogReference) ||
(!string.IsNullOrWhiteSpace(normalizedUrl) && (!string.IsNullOrWhiteSpace(normalizedUrl) &&
source.SourceUrl != null && source.SourceUrl != null &&
source.SourceUrl.ToUpper() == normalizedUrl.ToUpper()), EF.Functions.ILike(source.SourceUrl, normalizedUrl)),
ct); ct);
} }

View File

@@ -47,11 +47,11 @@ internal class ListCalendarCatalogHandler(AppDbContext dbContext)
if (!string.IsNullOrWhiteSpace(request.Search)) if (!string.IsNullOrWhiteSpace(request.Search))
{ {
string search = request.Search.Trim().ToLowerInvariant(); string search = $"%{request.Search.Trim()}%";
query = query.Where(entry => query = query.Where(entry =>
entry.Title.ToLower().Contains(search) || EF.Functions.ILike(entry.Title, search) ||
entry.Description.ToLower().Contains(search) || EF.Functions.ILike(entry.Description, search) ||
entry.ProviderName.ToLower().Contains(search)); EF.Functions.ILike(entry.ProviderName, search));
} }
if (!string.IsNullOrWhiteSpace(request.Country)) if (!string.IsNullOrWhiteSpace(request.Country))

View File

@@ -4,8 +4,6 @@ internal static class ModuleRegistration
{ {
public static WebApplicationBuilder AddCalendarIntegrationsModule(this WebApplicationBuilder builder) public static WebApplicationBuilder AddCalendarIntegrationsModule(this WebApplicationBuilder builder)
{ {
builder.Services.AddSingleton<Services.IcsCalendarParser>();
builder.Services.AddSingleton<Services.CalendarExportFeedBuilder>();
builder.Services.AddScoped<Services.CalendarExportFeedService>(); builder.Services.AddScoped<Services.CalendarExportFeedService>();
builder.Services.AddScoped<Services.CalendarImportSyncService>(); builder.Services.AddScoped<Services.CalendarImportSyncService>();
builder.Services.AddHostedService<Services.CalendarImportBackgroundService>(); builder.Services.AddHostedService<Services.CalendarImportBackgroundService>();

View File

@@ -1,4 +1,5 @@
using System.Text; using System.Text;
using System.Globalization;
namespace Socialize.Api.Modules.CalendarIntegrations.Services; namespace Socialize.Api.Modules.CalendarIntegrations.Services;
@@ -11,9 +12,9 @@ internal sealed record CalendarExportFeedEvent(
string? Description, string? Description,
string? Url); string? Url);
internal class CalendarExportFeedBuilder internal static class CalendarExportFeedBuilder
{ {
public string Build(string calendarName, IReadOnlyCollection<CalendarExportFeedEvent> events) public static string Build(string calendarName, IReadOnlyCollection<CalendarExportFeedEvent> events)
{ {
StringBuilder builder = new(); StringBuilder builder = new();
builder.AppendLine("BEGIN:VCALENDAR"); builder.AppendLine("BEGIN:VCALENDAR");
@@ -21,34 +22,34 @@ internal class CalendarExportFeedBuilder
builder.AppendLine("PRODID:-//Socialize//User Work Calendar//EN"); builder.AppendLine("PRODID:-//Socialize//User Work Calendar//EN");
builder.AppendLine("CALSCALE:GREGORIAN"); builder.AppendLine("CALSCALE:GREGORIAN");
builder.AppendLine("METHOD:PUBLISH"); 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)) foreach (CalendarExportFeedEvent feedEvent in events.OrderBy(calendarEvent => calendarEvent.StartsAt))
{ {
builder.AppendLine("BEGIN:VEVENT"); builder.AppendLine("BEGIN:VEVENT");
builder.AppendLine($"UID:{EscapeText(feedEvent.Uid)}"); AppendLineInvariant(builder, $"UID:{EscapeText(feedEvent.Uid)}");
builder.AppendLine($"DTSTAMP:{FormatUtc(DateTimeOffset.UtcNow)}"); AppendLineInvariant(builder, $"DTSTAMP:{FormatUtc(DateTimeOffset.UtcNow)}");
builder.AppendLine($"SUMMARY:{EscapeText(feedEvent.Title)}"); AppendLineInvariant(builder, $"SUMMARY:{EscapeText(feedEvent.Title)}");
if (feedEvent.IsAllDay) if (feedEvent.IsAllDay)
{ {
builder.AppendLine($"DTSTART;VALUE=DATE:{FormatDate(feedEvent.StartsAt)}"); AppendLineInvariant(builder, $"DTSTART;VALUE=DATE:{FormatDate(feedEvent.StartsAt)}");
builder.AppendLine($"DTEND;VALUE=DATE:{FormatDate(feedEvent.EndsAt)}"); AppendLineInvariant(builder, $"DTEND;VALUE=DATE:{FormatDate(feedEvent.EndsAt)}");
} }
else else
{ {
builder.AppendLine($"DTSTART:{FormatUtc(feedEvent.StartsAt)}"); AppendLineInvariant(builder, $"DTSTART:{FormatUtc(feedEvent.StartsAt)}");
builder.AppendLine($"DTEND:{FormatUtc(feedEvent.EndsAt)}"); AppendLineInvariant(builder, $"DTEND:{FormatUtc(feedEvent.EndsAt)}");
} }
if (!string.IsNullOrWhiteSpace(feedEvent.Description)) if (!string.IsNullOrWhiteSpace(feedEvent.Description))
{ {
builder.AppendLine($"DESCRIPTION:{EscapeText(feedEvent.Description)}"); AppendLineInvariant(builder, $"DESCRIPTION:{EscapeText(feedEvent.Description)}");
} }
if (!string.IsNullOrWhiteSpace(feedEvent.Url)) if (!string.IsNullOrWhiteSpace(feedEvent.Url))
{ {
builder.AppendLine($"URL:{EscapeText(feedEvent.Url)}"); AppendLineInvariant(builder, $"URL:{EscapeText(feedEvent.Url)}");
} }
builder.AppendLine("END:VEVENT"); builder.AppendLine("END:VEVENT");
@@ -71,10 +72,15 @@ internal class CalendarExportFeedBuilder
private static string EscapeText(string value) private static string EscapeText(string value)
{ {
return value return value
.Replace("\\", "\\\\") .Replace("\\", "\\\\", StringComparison.Ordinal)
.Replace("\r\n", "\\n") .Replace("\r\n", "\\n", StringComparison.Ordinal)
.Replace("\n", "\\n") .Replace("\n", "\\n", StringComparison.Ordinal)
.Replace(";", "\\;") .Replace(";", "\\;", StringComparison.Ordinal)
.Replace(",", "\\,"); .Replace(",", "\\,", StringComparison.Ordinal);
}
private static void AppendLineInvariant(StringBuilder builder, FormattableString value)
{
builder.AppendLine(value.ToString(CultureInfo.InvariantCulture));
} }
} }

View File

@@ -3,11 +3,11 @@ using Socialize.Api.Data;
namespace Socialize.Api.Modules.CalendarIntegrations.Services; namespace Socialize.Api.Modules.CalendarIntegrations.Services;
internal class CalendarExportFeedService(AppDbContext dbContext, CalendarExportFeedBuilder feedBuilder) internal class CalendarExportFeedService(AppDbContext dbContext)
{ {
public async Task<string> BuildUserFeedAsync(Guid userId, string? userEmail, string appBaseUrl, CancellationToken ct) public async Task<string> 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 Guid[] workspaceIds = await dbContext.Workspaces
.Where(workspace => .Where(workspace =>
workspace.OwnerUserId == userId || workspace.OwnerUserId == userId ||
@@ -51,7 +51,7 @@ internal class CalendarExportFeedService(AppDbContext dbContext, CalendarExportF
.Where(approval => .Where(approval =>
approval.DueAt.HasValue && approval.DueAt.HasValue &&
(approval.RequestedByUserId == userId || (approval.RequestedByUserId == userId ||
(!string.IsNullOrEmpty(normalizedEmail) && approval.ReviewerEmail.ToUpper() == normalizedEmail))) (!string.IsNullOrEmpty(normalizedEmail) && EF.Functions.ILike(approval.ReviewerEmail, normalizedEmail))))
.Join( .Join(
dbContext.ContentItems, dbContext.ContentItems,
approval => approval.ContentItemId, approval => approval.ContentItemId,
@@ -91,7 +91,7 @@ internal class CalendarExportFeedService(AppDbContext dbContext, CalendarExportF
appBaseUrl)) appBaseUrl))
.ToListAsync(ct)); .ToListAsync(ct));
return feedBuilder.Build("Socialize my work", events); return CalendarExportFeedBuilder.Build("Socialize my work", events);
} }
private static CalendarExportFeedEvent ToContentFeedEvent( private static CalendarExportFeedEvent ToContentFeedEvent(

View File

@@ -23,12 +23,15 @@ internal sealed class CalendarImportBackgroundService(
CalendarImportSyncService syncService = scope.ServiceProvider.GetRequiredService<CalendarImportSyncService>(); CalendarImportSyncService syncService = scope.ServiceProvider.GetRequiredService<CalendarImportSyncService>();
await syncService.RefreshDueSourcesAsync(stoppingToken); 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) catch (Exception ex)
{ {
logger.LogError(ex, "Calendar import background sync failed."); logger.LogError(ex, "Calendar import background sync failed.");
} }
#pragma warning restore CA1031
} }
} }

View File

@@ -1,15 +1,19 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data; using Socialize.Api.Data;
using Socialize.Api.Modules.CalendarIntegrations.Data; using Socialize.Api.Modules.CalendarIntegrations.Data;
using System.Globalization;
using System.Text.Json; using System.Text.Json;
namespace Socialize.Api.Modules.CalendarIntegrations.Services; namespace Socialize.Api.Modules.CalendarIntegrations.Services;
#pragma warning disable S1075 // Supplemental observance identifiers intentionally use stable URI-like values.
internal sealed class CalendarImportSyncService( internal sealed class CalendarImportSyncService(
AppDbContext dbContext, AppDbContext dbContext,
IHttpClientFactory httpClientFactory, IHttpClientFactory httpClientFactory)
IcsCalendarParser parser)
{ {
private static readonly JsonSerializerOptions JsonSerializerOptions = new(JsonSerializerDefaults.Web);
public async Task RefreshSourceAsync(Guid sourceId, CancellationToken ct) public async Task RefreshSourceAsync(Guid sourceId, CancellationToken ct)
{ {
CalendarSource? source = await dbContext.CalendarSources CalendarSource? source = await dbContext.CalendarSources
@@ -115,7 +119,7 @@ internal sealed class CalendarImportSyncService(
} }
} }
private async Task<IReadOnlyCollection<ParsedCalendarEvent>> GetParsedEventsAsync( private static async Task<IReadOnlyCollection<ParsedCalendarEvent>> GetParsedEventsAsync(
HttpClient httpClient, HttpClient httpClient,
string sourceUrl, string sourceUrl,
DateOnly rangeStart, DateOnly rangeStart,
@@ -127,8 +131,8 @@ internal sealed class CalendarImportSyncService(
return await GetNagerEventsAsync(httpClient, sourceUrl, countryCode!, rangeStart, rangeEnd, ct); return await GetNagerEventsAsync(httpClient, sourceUrl, countryCode!, rangeStart, rangeEnd, ct);
} }
string content = await httpClient.GetStringAsync(sourceUrl, ct); string content = await httpClient.GetStringAsync(new Uri(sourceUrl), ct);
return parser.Parse(content, rangeStart, rangeEnd); return IcsCalendarParser.Parse(content, rangeStart, rangeEnd);
} }
private static async Task<IReadOnlyCollection<ParsedCalendarEvent>> GetNagerEventsAsync( private static async Task<IReadOnlyCollection<ParsedCalendarEvent>> GetNagerEventsAsync(
@@ -143,14 +147,12 @@ internal sealed class CalendarImportSyncService(
for (int year = rangeStart.Year; year <= rangeEnd.Year; year++) for (int year = rangeStart.Year; year <= rangeEnd.Year; year++)
{ {
string yearUrl = BuildNagerYearUrl(sourceUrl, countryCode, year); string yearUrl = BuildNagerYearUrl(sourceUrl, countryCode, year);
string json = await httpClient.GetStringAsync(yearUrl, ct); string json = await httpClient.GetStringAsync(new Uri(yearUrl), ct);
NagerHoliday[] holidays = JsonSerializer.Deserialize<NagerHoliday[]>( NagerHoliday[] holidays = JsonSerializer.Deserialize<NagerHoliday[]>(json, JsonSerializerOptions) ?? [];
json,
new JsonSerializerOptions(JsonSerializerDefaults.Web)) ?? [];
foreach (NagerHoliday holiday in holidays) 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 < rangeStart ||
date > rangeEnd) date > rangeEnd)
{ {
@@ -283,7 +285,7 @@ internal sealed class CalendarImportSyncService(
private static string NormalizeUidPart(string? value) private static string NormalizeUidPart(string? value)
{ {
return new string((value ?? "holiday") return new string((value ?? "holiday")
.ToLowerInvariant() .ToUpperInvariant()
.Select(character => char.IsLetterOrDigit(character) ? character : '-') .Select(character => char.IsLetterOrDigit(character) ? character : '-')
.ToArray()) .ToArray())
.Trim('-'); .Trim('-');

View File

@@ -1,4 +1,5 @@
using System.Globalization; using System.Globalization;
using System.Text;
namespace Socialize.Api.Modules.CalendarIntegrations.Services; namespace Socialize.Api.Modules.CalendarIntegrations.Services;
@@ -39,9 +40,9 @@ internal sealed record IcsRawEvent(
string? SourceUrl, string? SourceUrl,
DateTimeOffset? LastModifiedAt); DateTimeOffset? LastModifiedAt);
internal sealed class IcsCalendarParser internal static class IcsCalendarParser
{ {
public IReadOnlyCollection<ParsedCalendarEvent> Parse( public static IReadOnlyCollection<ParsedCalendarEvent> Parse(
string content, string content,
DateOnly rangeStart, DateOnly rangeStart,
DateOnly rangeEnd) DateOnly rangeEnd)
@@ -63,10 +64,12 @@ internal sealed class IcsCalendarParser
private static IEnumerable<IcsRawEvent> ReadRawEvents(string content) private static IEnumerable<IcsRawEvent> ReadRawEvents(string content)
{ {
List<string> lines = UnfoldLines(content).ToList(); List<string> 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)) if (!lines[index].Equals("BEGIN:VEVENT", StringComparison.OrdinalIgnoreCase))
{ {
index++;
continue; continue;
} }
@@ -74,9 +77,10 @@ internal sealed class IcsCalendarParser
new(StringComparer.OrdinalIgnoreCase); new(StringComparer.OrdinalIgnoreCase);
index++; 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); ParseProperty(lines[index], properties);
index++;
} }
if (!TryGetFirst(properties, "DTSTART", out var startProperty)) if (!TryGetFirst(properties, "DTSTART", out var startProperty))
@@ -105,32 +109,34 @@ internal sealed class IcsCalendarParser
TryGetFirst(properties, "LAST-MODIFIED", out var lastModified) TryGetFirst(properties, "LAST-MODIFIED", out var lastModified)
? ParseDateTimeValue(lastModified.Value, lastModified.Parameters).UtcDateTime ? ParseDateTimeValue(lastModified.Value, lastModified.Parameters).UtcDateTime
: null); : null);
index++;
} }
} }
private static IEnumerable<string> UnfoldLines(string content) private static IEnumerable<string> UnfoldLines(string content)
{ {
string? current = null; StringBuilder? current = null;
using StringReader reader = new(content.Replace("\r\n", "\n", StringComparison.Ordinal).Replace('\r', '\n')); using StringReader reader = new(content.Replace("\r\n", "\n", StringComparison.Ordinal).Replace('\r', '\n'));
while (reader.ReadLine() is { } line) while (reader.ReadLine() is { } line)
{ {
if ((line.StartsWith(' ') || line.StartsWith('\t')) && current is not null) if ((line.StartsWith(' ') || line.StartsWith('\t')) && current is not null)
{ {
current += line[1..]; current.Append(line[1..]);
continue; continue;
} }
if (current is not null) if (current is not null)
{ {
yield return current; yield return current.ToString();
} }
current = line; current = new StringBuilder(line);
} }
if (current is not null) if (current is not null)
{ {
yield return current; yield return current.ToString();
} }
} }
@@ -309,7 +315,7 @@ internal sealed class IcsCalendarParser
return TimeSpan.Zero; return TimeSpan.Zero;
} }
private static IReadOnlyCollection<DateOnly> ExpandStartDates( private static List<DateOnly> ExpandStartDates(
IcsRawEvent rawEvent, IcsRawEvent rawEvent,
DateOnly rangeStart, DateOnly rangeStart,
DateOnly rangeEnd) DateOnly rangeEnd)

View File

@@ -34,7 +34,7 @@ internal class GetCampaignsHandler(
{ {
IQueryable<Campaign> query = dbContext.Campaigns.AsQueryable(); IQueryable<Campaign> query = dbContext.Campaigns.AsQueryable();
if (!accessScopeService.IsManager(User)) if (!AccessScopeService.IsManager(User))
{ {
IReadOnlyCollection<Guid> workspaceScopeIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct); IReadOnlyCollection<Guid> workspaceScopeIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct);
IReadOnlyCollection<Guid> clientScopeIds = User.GetClientScopeIds(); IReadOnlyCollection<Guid> clientScopeIds = User.GetClientScopeIds();

View File

@@ -23,7 +23,7 @@ internal class GetChannelsHandler(
{ {
IQueryable<Channel> query = dbContext.Channels.AsQueryable(); IQueryable<Channel> query = dbContext.Channels.AsQueryable();
if (!accessScopeService.IsManager(User)) if (!AccessScopeService.IsManager(User))
{ {
IReadOnlyCollection<Guid> workspaceScopeIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct); IReadOnlyCollection<Guid> workspaceScopeIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct);
query = query.Where(channel => workspaceScopeIds.Contains(channel.WorkspaceId)); query = query.Where(channel => workspaceScopeIds.Contains(channel.WorkspaceId));

View File

@@ -33,7 +33,7 @@ internal class GetClientsHandler(
{ {
IQueryable<Client> query = dbContext.Clients.AsQueryable(); IQueryable<Client> query = dbContext.Clients.AsQueryable();
if (!accessScopeService.IsManager(User)) if (!AccessScopeService.IsManager(User))
{ {
IReadOnlyCollection<Guid> workspaceScopeIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct); IReadOnlyCollection<Guid> workspaceScopeIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct);
IReadOnlyCollection<Guid> clientScopeIds = User.GetClientScopeIds(); IReadOnlyCollection<Guid> clientScopeIds = User.GetClientScopeIds();

View File

@@ -96,7 +96,7 @@ internal class CreateCommentHandler(
if (request.Attachment is not null) if (request.Attachment is not null)
{ {
string normalizedContentType = request.Attachment.ContentType.Trim().ToLowerInvariant(); string normalizedContentType = request.Attachment.ContentType.Trim();
if (request.Attachment.Length <= 0) if (request.Attachment.Length <= 0)
{ {
@@ -213,17 +213,26 @@ internal class CreateCommentHandler(
private static bool IsInlineAttachmentContentType(string contentType) 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) 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", extension = ".png";
"image/jpeg" or "image/jpg" => ".jpg", }
_ => string.Empty, else if (normalizedContentType.Equals("image/jpeg", StringComparison.OrdinalIgnoreCase) ||
}; normalizedContentType.Equals("image/jpg", StringComparison.OrdinalIgnoreCase))
{
extension = ".jpg";
}
string normalized = Path.GetFileName(fileName ?? string.Empty).Trim(); string normalized = Path.GetFileName(fileName ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(normalized)) if (string.IsNullOrWhiteSpace(normalized))
{ {

View File

@@ -37,7 +37,7 @@ internal class GetContentItemsHandler(
{ {
IQueryable<ContentItem> query = dbContext.ContentItems.AsQueryable(); IQueryable<ContentItem> query = dbContext.ContentItems.AsQueryable();
if (!accessScopeService.IsManager(User)) if (!AccessScopeService.IsManager(User))
{ {
IReadOnlyCollection<Guid> workspaceScopeIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct); IReadOnlyCollection<Guid> workspaceScopeIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct);
IReadOnlyCollection<Guid> clientScopeIds = User.GetClientScopeIds(); IReadOnlyCollection<Guid> clientScopeIds = User.GetClientScopeIds();

View File

@@ -169,13 +169,12 @@ internal class UpdateDeveloperFeedbackHandler(
.ToHashSet(StringComparer.Ordinal); .ToHashSet(StringComparer.Ordinal);
bool changed = false; 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<string> existingKeys = report.Tags HashSet<string> existingKeys = report.Tags

View File

@@ -1,6 +1,7 @@
using FastEndpoints; using FastEndpoints;
using Socialize.Api.Infrastructure.Security; using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Identity.Data; using Socialize.Api.Modules.Identity.Data;
using Socialize.Api.Modules.Identity.Services;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
namespace Socialize.Api.Modules.Identity.Handlers; namespace Socialize.Api.Modules.Identity.Handlers;
@@ -9,10 +10,15 @@ namespace Socialize.Api.Modules.Identity.Handlers;
internal record ChangeEmailRequest( internal record ChangeEmailRequest(
string? Email); string? Email);
[PublicAPI]
internal record ChangeEmailResponse(
string Message);
[PublicAPI] [PublicAPI]
internal class ChangeEmailHandler( internal class ChangeEmailHandler(
UserManager userManager) UserManager userManager,
: Endpoint<ChangeEmailRequest> EmailVerificationService emailVerificationService)
: Endpoint<ChangeEmailRequest, ChangeEmailResponse>
{ {
public override void Configure() public override void Configure()
{ {
@@ -32,18 +38,28 @@ internal class ChangeEmailHandler(
return; return;
} }
user.Email = request.Email; if (string.IsNullOrWhiteSpace(request.Email))
// TODO: check to see if identity resets the `email confirmed` flag - @jonathan
IdentityResult result = await userManager.UpdateAsync(user);
if (result.Succeeded)
{ {
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);
} }
} }

View File

@@ -32,10 +32,22 @@ internal class ChangePhoneHandler(
return; return;
} }
user.PhoneNumber = request.PhoneNumber; string? newPhoneNumber = string.IsNullOrWhiteSpace(request.PhoneNumber)
// TODO: check to see if identity resets the `phone confirmed` flag - @jonathan ? 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) if (result.Succeeded)
{ {

View File

@@ -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<ConfirmEmailChangeRequest, ConfirmEmailChangeResponse>
{
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);
}
}

View File

@@ -18,13 +18,13 @@ internal class GetCurrentUserQueryHandler(
} }
public override async Task HandleAsync( public override async Task HandleAsync(
CancellationToken cancellationToken) CancellationToken ct)
{ {
UserModel? userModel = await identityService.GetCurrentUserAsync(); UserModel? userModel = await identityService.GetCurrentUserAsync();
if (userModel is null) if (userModel is null)
{ {
await SendNotFoundAsync(cancellationToken); await SendNotFoundAsync(ct);
return; return;
} }
@@ -76,6 +76,6 @@ internal class GetCurrentUserQueryHandler(
Address = userModel.Address, Address = userModel.Address,
UserRoles = roles UserRoles = roles
}, },
cancellationToken); ct);
} }
} }

View File

@@ -19,21 +19,21 @@ internal class GetCurrentUserPortraitHandler(
} }
public override async Task HandleAsync( public override async Task HandleAsync(
CancellationToken cancellationToken) CancellationToken ct)
{ {
UserModel? identityUser = await identityService.GetCurrentUserAsync(); UserModel? identityUser = await identityService.GetCurrentUserAsync();
if (identityUser is null) if (identityUser is null)
{ {
await SendNotFoundAsync(cancellationToken); await SendNotFoundAsync(ct);
return; return;
} }
MemoryStream stream = await blobStorage.DownloadFileAsync( MemoryStream stream = await blobStorage.DownloadFileAsync(
ContainerNames.Users, ContainerNames.Users,
$"{identityUser.Id.ToString()}/{SubDirectoryNames.Profile}/{CommonFileNames.ProfilePicture}", $"{identityUser.Id.ToString()}/{SubDirectoryNames.Profile}/{CommonFileNames.ProfilePicture}",
cancellationToken); ct);
await SendOkAsync(stream, cancellationToken); await SendOkAsync(stream, ct);
} }
} }

View File

@@ -61,9 +61,8 @@ internal class LoginWithFacebookHandler(
{ {
// Verify the token with Facebook // Verify the token with Facebook
using HttpClient httpClient = httpClientFactory.CreateClient(); using HttpClient httpClient = httpClientFactory.CreateClient();
using HttpResponseMessage response = await httpClient.GetAsync( Uri userInfoUri = new($"https://graph.facebook.com/me?access_token={request.Token}&fields=id,name,email,picture.width(200).height(200)");
$"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);
ct);
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
{ {
await SendStringAsync( await SendStringAsync(

View File

@@ -63,9 +63,8 @@ internal class LoginWithGoogleHandler(
// Verify the token with Google // Verify the token with Google
using HttpClient httpClient = httpClientFactory.CreateClient(); using HttpClient httpClient = httpClientFactory.CreateClient();
using HttpResponseMessage response = await httpClient.GetAsync( Uri userInfoUri = new($"https://www.googleapis.com/oauth2/v1/userinfo?access_token={googleToken.AccessToken}");
$"https://www.googleapis.com/oauth2/v1/userinfo?access_token={googleToken.AccessToken}", using HttpResponseMessage response = await httpClient.GetAsync(userInfoUri, ct);
ct);
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
{ {
await SendStringAsync( await SendStringAsync(

View File

@@ -42,8 +42,7 @@ internal class VerifyEmailHandler(
} }
// Verify the token and confirm email // Verify the token and confirm email
string decoded = HttpUtility.UrlDecode(request.Token); string decodedWithPlus = HttpUtility.UrlDecode(request.Token).Replace(" ", "+", StringComparison.Ordinal);
string decodedWithPlus = request.Token.Replace(" ", "+");
IdentityResult result = await userManager.ConfirmEmailAsync(user, decodedWithPlus); IdentityResult result = await userManager.ConfirmEmailAsync(user, decodedWithPlus);
if (!result.Succeeded) if (!result.Succeeded)
{ {

View File

@@ -16,13 +16,7 @@ internal sealed class AccessTokenFactory(
IList<string> roles = await userManager.GetRolesAsync(user); IList<string> roles = await userManager.GetRolesAsync(user);
IList<Claim> claims = await userManager.GetClaimsAsync(user); IList<Claim> claims = await userManager.GetClaimsAsync(user);
string persona = roles.Contains(KnownRoles.Manager, StringComparer.Ordinal) string persona = GetPersona(roles);
? KnownRoles.Manager
: roles.Contains(KnownRoles.Client, StringComparer.Ordinal)
? KnownRoles.Client
: roles.Contains(KnownRoles.Provider, StringComparer.Ordinal)
? KnownRoles.Provider
: KnownRoles.WorkspaceMember;
List<Claim> tokenClaims = [.. claims, new Claim(KnownClaims.Persona, persona)]; List<Claim> tokenClaims = [.. claims, new Claim(KnownClaims.Persona, persona)];
@@ -40,4 +34,24 @@ internal sealed class AccessTokenFactory(
roles, roles,
tokenClaims); tokenClaims);
} }
private static string GetPersona(IList<string> 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;
}
} }

View File

@@ -58,4 +58,52 @@ internal sealed class EmailVerificationService(
</div> </div>
"""); """);
} }
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",
$"""
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;">
<h1 style="color: #2c3e50; margin-bottom: 20px;">Confirm your new email address</h1>
<p style="font-size: 16px; line-height: 1.5; margin-bottom: 25px;">
Please confirm this email address for your Socialize account by clicking the button below:
</p>
<div style="text-align: center; margin: 30px 0;">
<a href='{confirmationLink}'
style="background-color: #3498db;
color: white;
text-decoration: none;
padding: 12px 24px;
border-radius: 4px;
font-weight: bold;
display: inline-block;
box-shadow: 0 2px 5px rgba(0,0,0,0.1);">
Confirm Email Address
</a>
</div>
<p style="font-size: 14px; color: #7f8c8d; margin-top: 30px;">
If you did not request this change, please ignore this email.
</p>
<p style="font-size: 14px; color: #7f8c8d; margin-top: 20px;">
If the button doesn't work, you can copy and paste this link into your browser:
<br>
<a href='{confirmationLink}' style="color: #3498db; word-break: break-all;">{confirmationLink}</a>
</p>
</div>
""");
}
} }

View File

@@ -56,7 +56,7 @@ internal class GetNotificationsHandler(
IQueryable<NotificationEvent> query = dbContext.NotificationEvents.AsQueryable(); IQueryable<NotificationEvent> query = dbContext.NotificationEvents.AsQueryable();
Guid currentUserId = User.GetUserId(); Guid currentUserId = User.GetUserId();
if (!accessScopeService.IsManager(User)) if (!AccessScopeService.IsManager(User))
{ {
IReadOnlyCollection<Guid> workspaceScopeIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct); IReadOnlyCollection<Guid> workspaceScopeIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct);
query = query.Where(notificationEvent => query = query.Where(notificationEvent =>

View File

@@ -9,7 +9,7 @@ namespace Socialize.Api.Modules.Organizations.Services;
internal sealed class OrganizationAccessService( internal sealed class OrganizationAccessService(
AppDbContext dbContext) AppDbContext dbContext)
{ {
public bool IsGlobalManager(ClaimsPrincipal user) public static bool IsGlobalManager(ClaimsPrincipal user)
{ {
return user.IsInRole(KnownRoles.Administrator) || user.IsInRole(KnownRoles.Manager); return user.IsInRole(KnownRoles.Administrator) || user.IsInRole(KnownRoles.Manager);
} }

View File

@@ -4,6 +4,7 @@ using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security; using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Identity.Contracts; using Socialize.Api.Modules.Identity.Contracts;
using Socialize.Api.Modules.Workspaces.Data; using Socialize.Api.Modules.Workspaces.Data;
using System.Globalization;
namespace Socialize.Api.Modules.Workspaces.Handlers; namespace Socialize.Api.Modules.Workspaces.Handlers;
@@ -59,7 +60,9 @@ internal class CreateWorkspaceInviteHandler(
return; 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(); string normalizedRole = request.Role.Trim();
bool duplicateInvite = await dbContext.WorkspaceInvites.AnyAsync( bool duplicateInvite = await dbContext.WorkspaceInvites.AnyAsync(

View File

@@ -22,6 +22,8 @@ using Socialize.Api.Modules.Organizations;
using Socialize.Api.Modules.Workspaces; using Socialize.Api.Modules.Workspaces;
const string SeededTestDataMessage = "Seeded test data.";
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
bool seedTestData = args.Any(arg => string.Equals(arg, "seed-testdata", StringComparison.OrdinalIgnoreCase)); bool seedTestData = args.Any(arg => string.Equals(arg, "seed-testdata", StringComparison.OrdinalIgnoreCase));
@@ -94,7 +96,7 @@ if (seedTestData)
await app.UseAppDataAsync(); await app.UseAppDataAsync();
await app.UseIdentityModuleAsync(); await app.UseIdentityModuleAsync();
await app.Services.SeedTestDataAsync(); await app.Services.SeedTestDataAsync();
Console.WriteLine("Seeded test data."); Console.WriteLine(SeededTestDataMessage);
return; return;
} }

View File

@@ -9,7 +9,7 @@
<!-- Enable code analysis --> <!-- Enable code analysis -->
<EnableNETAnalyzers>true</EnableNETAnalyzers> <EnableNETAnalyzers>true</EnableNETAnalyzers>
<AnalysisMode>AllEnabledByDefault</AnalysisMode> <AnalysisMode>AllEnabledByDefault</AnalysisMode>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors> <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<WarningsAsErrors /> <WarningsAsErrors />
<NoWarn>$(NoWarn);CA2007</NoWarn> <!-- disable ConfigureAwait warning - not present in ASP.NET Core --> <NoWarn>$(NoWarn);CA2007</NoWarn> <!-- disable ConfigureAwait warning - not present in ASP.NET Core -->
<EnableDefaultEmbeddedResourceItems>false</EnableDefaultEmbeddedResourceItems> <EnableDefaultEmbeddedResourceItems>false</EnableDefaultEmbeddedResourceItems>

View File

@@ -29,8 +29,7 @@ public class CalendarExportFeedTests
[Fact] [Fact]
public void Build_emits_valid_ics_for_user_work_without_sensitive_discussion_details() public void Build_emits_valid_ics_for_user_work_without_sensitive_discussion_details()
{ {
CalendarExportFeedBuilder builder = new(); string ics = CalendarExportFeedBuilder.Build(
string ics = builder.Build(
"Socialize my work", "Socialize my work",
[ [
new CalendarExportFeedEvent( new CalendarExportFeedEvent(

View File

@@ -4,8 +4,6 @@ namespace Socialize.Tests.CalendarIntegrations;
public class IcsCalendarParserTests public class IcsCalendarParserTests
{ {
private readonly IcsCalendarParser _parser = new();
[Fact] [Fact]
public void Parse_preserves_all_day_calendar_dates() public void Parse_preserves_all_day_calendar_dates()
{ {
@@ -20,7 +18,7 @@ public class IcsCalendarParserTests
END:VCALENDAR END:VCALENDAR
"""; """;
ParsedCalendarEvent calendarEvent = Assert.Single(_parser.Parse( ParsedCalendarEvent calendarEvent = Assert.Single(IcsCalendarParser.Parse(
ics, ics,
new DateOnly(2026, 12, 1), new DateOnly(2026, 12, 1),
new DateOnly(2026, 12, 31))); new DateOnly(2026, 12, 31)));
@@ -45,7 +43,7 @@ public class IcsCalendarParserTests
END:VCALENDAR END:VCALENDAR
"""; """;
ParsedCalendarEvent calendarEvent = Assert.Single(_parser.Parse( ParsedCalendarEvent calendarEvent = Assert.Single(IcsCalendarParser.Parse(
ics, ics,
new DateOnly(2026, 5, 1), new DateOnly(2026, 5, 1),
new DateOnly(2026, 5, 31))); new DateOnly(2026, 5, 31)));
@@ -70,7 +68,7 @@ public class IcsCalendarParserTests
END:VCALENDAR END:VCALENDAR
"""; """;
ParsedCalendarEvent calendarEvent = Assert.Single(_parser.Parse( ParsedCalendarEvent calendarEvent = Assert.Single(IcsCalendarParser.Parse(
ics, ics,
new DateOnly(2026, 5, 1), new DateOnly(2026, 5, 1),
new DateOnly(2026, 5, 31))); new DateOnly(2026, 5, 31)));
@@ -96,7 +94,7 @@ public class IcsCalendarParserTests
END:VCALENDAR END:VCALENDAR
"""; """;
IReadOnlyCollection<ParsedCalendarEvent> events = _parser.Parse( IReadOnlyCollection<ParsedCalendarEvent> events = IcsCalendarParser.Parse(
ics, ics,
new DateOnly(2026, 1, 1), new DateOnly(2026, 1, 1),
new DateOnly(2027, 12, 31)); new DateOnly(2027, 12, 31));
@@ -122,7 +120,7 @@ public class IcsCalendarParserTests
END:VCALENDAR END:VCALENDAR
"""; """;
ParsedCalendarEvent calendarEvent = Assert.Single(_parser.Parse( ParsedCalendarEvent calendarEvent = Assert.Single(IcsCalendarParser.Parse(
ics, ics,
new DateOnly(2026, 5, 1), new DateOnly(2026, 5, 1),
new DateOnly(2026, 5, 31))); new DateOnly(2026, 5, 31)));

File diff suppressed because it is too large Load Diff

View File

@@ -36,8 +36,8 @@
"vuetify": "^4.0.6" "vuetify": "^4.0.6"
}, },
"devDependencies": { "devDependencies": {
"@types/webpack-env": "^1.18.8",
"@tailwindcss/vite": "^4.2.4", "@tailwindcss/vite": "^4.2.4",
"@types/webpack-env": "^1.18.8",
"@typescript-eslint/eslint-plugin": "^8.59.2", "@typescript-eslint/eslint-plugin": "^8.59.2",
"@typescript-eslint/parser": "^8.59.2", "@typescript-eslint/parser": "^8.59.2",
"@vitejs/plugin-vue": "^6.0.6", "@vitejs/plugin-vue": "^6.0.6",
@@ -45,6 +45,7 @@
"eslint": "^10.3.0", "eslint": "^10.3.0",
"eslint-plugin-tailwindcss": "^3.18.3", "eslint-plugin-tailwindcss": "^3.18.3",
"eslint-plugin-vue": "^10.9.1", "eslint-plugin-vue": "^10.9.1",
"openapi-typescript": "^7.13.0",
"prettier": "^3.8.3", "prettier": "^3.8.3",
"rollup-plugin-visualizer": "^7.0.1", "rollup-plugin-visualizer": "^7.0.1",
"tailwindcss": "^4.2.4", "tailwindcss": "^4.2.4",

View File

@@ -308,6 +308,22 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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": { "/api/users/forgot-password": {
parameters: { parameters: {
query?: never; query?: never;
@@ -1262,6 +1278,9 @@ export interface components {
/** Format: date-time */ /** Format: date-time */
birthDate?: string; birthDate?: string;
}; };
SocializeApiModulesIdentityHandlersChangeEmailResponse: {
message?: string;
};
SocializeApiModulesIdentityHandlersChangeEmailRequest: { SocializeApiModulesIdentityHandlersChangeEmailRequest: {
email?: string | null; email?: string | null;
}; };
@@ -1279,6 +1298,10 @@ export interface components {
/** Format: binary */ /** Format: binary */
file: string; file: string;
}; };
SocializeApiModulesIdentityHandlersConfirmEmailChangeResponse: {
message?: string;
};
SocializeApiModulesIdentityHandlersConfirmEmailChangeRequest: Record<string, never>;
SocializeApiModulesIdentityHandlersForgotPasswordRequest: { SocializeApiModulesIdentityHandlersForgotPasswordRequest: {
email?: string; email?: string;
}; };
@@ -2529,12 +2552,14 @@ export interface operations {
}; };
}; };
responses: { responses: {
/** @description No Content */ /** @description Success */
204: { 200: {
headers: { headers: {
[name: string]: unknown; [name: string]: unknown;
}; };
content?: never; content: {
"application/json": components["schemas"]["SocializeApiModulesIdentityHandlersChangeEmailResponse"];
};
}; };
/** @description Unauthorized */ /** @description Unauthorized */
401: { 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: { SocializeApiModulesIdentityHandlersForgotPasswordHandler: {
parameters: { parameters: {
query?: never; query?: never;

View File

@@ -24,8 +24,10 @@
icon="mdi-check-circle" icon="mdi-check-circle"
size="64" size="64"
></v-icon> ></v-icon>
<h1 class="text-2xl font-bold text-green-600">{{ t('success.title') }}</h1> <h1 class="text-2xl font-bold text-green-600">
<p>{{ t('success.message') }}</p> {{ t(isEmailChange ? 'success.emailChangeTitle' : 'success.title') }}
</h1>
<p>{{ t(isEmailChange ? 'success.emailChangeMessage' : 'success.message') }}</p>
<v-btn <v-btn
color="primary" color="primary"
@click="goToLogin" @click="goToLogin"
@@ -172,6 +174,7 @@
const verificationSuccess = ref(false); const verificationSuccess = ref(false);
const errorMessage = ref(''); const errorMessage = ref('');
const showResendOnly = ref(false); const showResendOnly = ref(false);
const isEmailChange = ref(false);
// Resend verification state // Resend verification state
const resendEmail = ref(''); const resendEmail = ref('');
@@ -183,6 +186,8 @@
onMounted(async () => { onMounted(async () => {
const userId = route.query.userId; const userId = route.query.userId;
const token = route.query.token; 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 // Populate resend email field if it was in the URL
if (route.query.email) { if (route.query.email) {
@@ -190,7 +195,7 @@
} }
// Check if we have the required parameters // Check if we have the required parameters
if (!userId || !token) { if (!userId || !token || (isEmailChange.value && !email)) {
isLoading.value = false; isLoading.value = false;
if (route.query.email || route.query.pending) { if (route.query.email || route.query.pending) {
showResendOnly.value = true; showResendOnly.value = true;
@@ -201,8 +206,10 @@
} }
try { try {
// Call the verification endpoint const endpoint = isEmailChange.value
await clientApi.get(`/api/users/verify-email?userId=${userId}&token=${token}`); ? `/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; verificationSuccess.value = true;
} catch (error) { } catch (error) {
console.error('Email verification failed:', error); console.error('Email verification failed:', error);
@@ -252,6 +259,8 @@
"success": { "success": {
"title": "Email Verified Successfully!", "title": "Email Verified Successfully!",
"message": "Your email has been verified. You can now log in to your account.", "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" "goToLogin": "Go to Login"
}, },
"error": { "error": {
@@ -279,6 +288,8 @@
"success": { "success": {
"title": "Email vérifié avec succès !", "title": "Email vérifié avec succès !",
"message": "Votre email a été vérifié. Vous pouvez maintenant vous connecter à votre compte.", "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" "goToLogin": "Aller à la connexion"
}, },
"error": { "error": {

View File

@@ -155,12 +155,12 @@ export const useUserProfileStore = defineStore(
try { try {
const client = useClient() const client = useClient()
await client.post( const response = await client.post(
`/api/users/email`, `/api/users/email`,
{ {
email: email email: email
}) })
value.value.email = email; return response.data;
} catch (updateError) { } catch (updateError) {
console.error(updateError) console.error(updateError)
error.value = 'Failed to update profile.' error.value = 'Failed to update profile.'

View File

@@ -69,12 +69,19 @@
await userProfileStore.changeAlias(nextAlias || null); await userProfileStore.changeAlias(nextAlias || null);
} }
let emailChangeRequested = false;
if (nextEmail !== (user.email ?? '')) { if (nextEmail !== (user.email ?? '')) {
await userProfileStore.changeEmail(nextEmail); await userProfileStore.changeEmail(nextEmail);
emailChangeRequested = true;
} }
settingsStatus.value = t('userSettings.saved'); settingsStatus.value = emailChangeRequested
syncFormFromUser(userProfileStore.user); ? t('userSettings.emailConfirmationSent')
: t('userSettings.saved');
if (!emailChangeRequested) {
syncFormFromUser(userProfileStore.user);
}
} catch (error) { } catch (error) {
console.error('Failed to update user settings:', error); console.error('Failed to update user settings:', error);
settingsError.value = t('userSettings.errors.saveFailed'); settingsError.value = t('userSettings.errors.saveFailed');

View File

@@ -936,6 +936,7 @@
"accountDetailsDescription": "Edit the profile details other workspace members see.", "accountDetailsDescription": "Edit the profile details other workspace members see.",
"saveDetails": "Save details", "saveDetails": "Save details",
"saved": "Profile details saved", "saved": "Profile details saved",
"emailConfirmationSent": "Profile details saved. Check your new email address to confirm the email change.",
"portraitSaved": "Portrait saved", "portraitSaved": "Portrait saved",
"calendarFeed": { "calendarFeed": {
"title": "Private calendar feed", "title": "Private calendar feed",

View File

@@ -936,6 +936,7 @@
"accountDetailsDescription": "Modifiez les informations de profil visibles par les autres membres.", "accountDetailsDescription": "Modifiez les informations de profil visibles par les autres membres.",
"saveDetails": "Enregistrer les détails", "saveDetails": "Enregistrer les détails",
"saved": "Informations de profil enregistrées", "saved": "Informations de profil enregistrées",
"emailConfirmationSent": "Informations de profil enregistrées. Consultez votre nouvelle adresse email pour confirmer le changement.",
"portraitSaved": "Portrait enregistré", "portraitSaved": "Portrait enregistré",
"calendarFeed": { "calendarFeed": {
"title": "Flux calendrier privé", "title": "Flux calendrier privé",

View File

@@ -7,7 +7,7 @@
}, },
"servers": [ "servers": [
{ {
"url": "http://localhost:5080" "url": "http://localhost:5081"
} }
], ],
"paths": { "paths": {
@@ -854,8 +854,15 @@
"x-position": 1 "x-position": 1
}, },
"responses": { "responses": {
"204": { "200": {
"description": "No Content" "description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SocializeApiModulesIdentityHandlersChangeEmailResponse"
}
}
}
}, },
"401": { "401": {
"description": "Unauthorized" "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": { "/api/users/forgot-password": {
"post": { "post": {
"tags": [ "tags": [
@@ -4269,6 +4323,15 @@
} }
} }
}, },
"SocializeApiModulesIdentityHandlersChangeEmailResponse": {
"type": "object",
"additionalProperties": false,
"properties": {
"message": {
"type": "string"
}
}
},
"SocializeApiModulesIdentityHandlersChangeEmailRequest": { "SocializeApiModulesIdentityHandlersChangeEmailRequest": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
@@ -4327,6 +4390,19 @@
} }
} }
}, },
"SocializeApiModulesIdentityHandlersConfirmEmailChangeResponse": {
"type": "object",
"additionalProperties": false,
"properties": {
"message": {
"type": "string"
}
}
},
"SocializeApiModulesIdentityHandlersConfirmEmailChangeRequest": {
"type": "object",
"additionalProperties": false
},
"SocializeApiModulesIdentityHandlersForgotPasswordRequest": { "SocializeApiModulesIdentityHandlersForgotPasswordRequest": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,