fix: confirm email changes and enforce clean backend build
This commit is contained in:
@@ -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.");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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('/')}";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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>();
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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('-');
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -169,14 +169,13 @@ 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))
|
||||||
if (!requestedKeys.Contains(existingTag.NormalizedName))
|
.ToArray())
|
||||||
{
|
{
|
||||||
report.Tags.Remove(existingTag);
|
report.Tags.Remove(existingTag);
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
HashSet<string> existingKeys = report.Tags
|
HashSet<string> existingKeys = report.Tags
|
||||||
.Select(tag => tag.NormalizedName)
|
.Select(tag => tag.NormalizedName)
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
""");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 =>
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)));
|
||||||
|
|||||||
396
frontend/package-lock.json
generated
396
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||||
|
|||||||
55
frontend/src/api/schema.d.ts
vendored
55
frontend/src/api/schema.d.ts
vendored
@@ -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;
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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.'
|
||||||
|
|||||||
@@ -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
|
||||||
|
? t('userSettings.emailConfirmationSent')
|
||||||
|
: t('userSettings.saved');
|
||||||
|
|
||||||
|
if (!emailChangeRequested) {
|
||||||
syncFormFromUser(userProfileStore.user);
|
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');
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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é",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user