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

@@ -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();
}
}

View File

@@ -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);

View File

@@ -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 =

View File

@@ -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);
}

View File

@@ -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))

View File

@@ -4,8 +4,6 @@ internal static class ModuleRegistration
{
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.CalendarImportSyncService>();
builder.Services.AddHostedService<Services.CalendarImportBackgroundService>();

View File

@@ -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<CalendarExportFeedEvent> events)
public static string Build(string calendarName, IReadOnlyCollection<CalendarExportFeedEvent> 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));
}
}

View File

@@ -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<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
.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(

View File

@@ -23,12 +23,15 @@ internal sealed class CalendarImportBackgroundService(
CalendarImportSyncService syncService = scope.ServiceProvider.GetRequiredService<CalendarImportSyncService>();
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
}
}

View File

@@ -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<IReadOnlyCollection<ParsedCalendarEvent>> GetParsedEventsAsync(
private static async Task<IReadOnlyCollection<ParsedCalendarEvent>> 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<IReadOnlyCollection<ParsedCalendarEvent>> 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<NagerHoliday[]>(
json,
new JsonSerializerOptions(JsonSerializerDefaults.Web)) ?? [];
string json = await httpClient.GetStringAsync(new Uri(yearUrl), ct);
NagerHoliday[] holidays = JsonSerializer.Deserialize<NagerHoliday[]>(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('-');

View File

@@ -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<ParsedCalendarEvent> Parse(
public static IReadOnlyCollection<ParsedCalendarEvent> Parse(
string content,
DateOnly rangeStart,
DateOnly rangeEnd)
@@ -63,10 +64,12 @@ internal sealed class IcsCalendarParser
private static IEnumerable<IcsRawEvent> ReadRawEvents(string content)
{
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))
{
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<string> 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<DateOnly> ExpandStartDates(
private static List<DateOnly> ExpandStartDates(
IcsRawEvent rawEvent,
DateOnly rangeStart,
DateOnly rangeEnd)

View File

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

View File

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

View File

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

View File

@@ -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))
{

View File

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

View File

@@ -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<string> existingKeys = report.Tags

View File

@@ -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<ChangeEmailRequest>
UserManager userManager,
EmailVerificationService emailVerificationService)
: Endpoint<ChangeEmailRequest, ChangeEmailResponse>
{
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);
}
}

View File

@@ -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)
{

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(
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);
}
}

View File

@@ -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);
}
}

View File

@@ -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(

View File

@@ -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(

View File

@@ -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)
{

View File

@@ -16,13 +16,7 @@ internal sealed class AccessTokenFactory(
IList<string> roles = await userManager.GetRolesAsync(user);
IList<Claim> 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<Claim> tokenClaims = [.. claims, new Claim(KnownClaims.Persona, persona)];
@@ -40,4 +34,24 @@ internal sealed class AccessTokenFactory(
roles,
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>
""");
}
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();
Guid currentUserId = User.GetUserId();
if (!accessScopeService.IsManager(User))
if (!AccessScopeService.IsManager(User))
{
IReadOnlyCollection<Guid> workspaceScopeIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct);
query = query.Where(notificationEvent =>

View File

@@ -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);
}

View File

@@ -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(