diff --git a/backend/Modules/Identity/DependencyInjection.cs b/backend/Modules/Identity/DependencyInjection.cs index 08582fe..b2b8a1c 100644 --- a/backend/Modules/Identity/DependencyInjection.cs +++ b/backend/Modules/Identity/DependencyInjection.cs @@ -16,7 +16,7 @@ public static class DependencyInjection builder.Services.Configure( builder.Configuration.GetRequiredSection(JwtOptions.SectionName)); - + builder.Services.AddAuthentication() .AddBearerToken(IdentityConstants.BearerScheme); @@ -35,35 +35,36 @@ public static class DependencyInjection // Scoped services builder.Services.AddScoped(); - builder.Services.AddTransient(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); return builder; } public static async Task UseIdentityModuleAsync( this IApplicationBuilder app, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default) { - var scopeFactory = app.ApplicationServices.GetRequiredService(); - using var scope = scopeFactory.CreateScope(); - await using var context = scope.ServiceProvider.GetRequiredService(); - await context.Database.MigrateAsync(cancellationToken: cancellationToken); - - var roleManager = scope.ServiceProvider.GetRequiredService>(); + IServiceScopeFactory scopeFactory = app.ApplicationServices.GetRequiredService(); + using IServiceScope scope = scopeFactory.CreateScope(); + await using IdentityDbContext context = scope.ServiceProvider.GetRequiredService(); + await context.Database.MigrateAsync(cancellationToken); + + RoleManager roleManager = scope.ServiceProvider.GetRequiredService>(); await TrySeedAsync(roleManager); - + return app; } - + private static async Task TrySeedAsync(RoleManager roleManager) { - var administratorRole = new Role(KnownRoles.Administrator); + Role administratorRole = new(KnownRoles.Administrator); if (roleManager.Roles.All(r => r.Name != administratorRole.Name)) { await roleManager.CreateAsync(administratorRole); } - var roleCreator = new Role(KnownRoles.Creator); + Role roleCreator = new(KnownRoles.Creator); if (roleManager.Roles.All(r => r.Name != roleCreator.Name)) { await roleManager.CreateAsync(roleCreator); diff --git a/backend/Modules/Identity/Handlers/ForgotPassword.cs b/backend/Modules/Identity/Handlers/ForgotPassword.cs index e958aad..7618059 100644 --- a/backend/Modules/Identity/Handlers/ForgotPassword.cs +++ b/backend/Modules/Identity/Handlers/ForgotPassword.cs @@ -1,4 +1,3 @@ -using System.Text; using System.Web; using Hutopy.Infrastructure.Configuration; using Hutopy.Infrastructure.Emailer.Contracts; @@ -30,8 +29,8 @@ public class ForgotPasswordHandler( CancellationToken ct) { // Find user by email - var user = await userManager.FindByEmailAsync(request.Email); - + User? user = await userManager.FindByEmailAsync(request.Email); + // Always return OK even if user not found to prevent email enumeration if (user is null) { @@ -40,26 +39,54 @@ public class ForgotPasswordHandler( } // Generate password reset token - var token = await userManager.GeneratePasswordResetTokenAsync(user); - + string token = await userManager.GeneratePasswordResetTokenAsync(user); + // URL encode the token as it may contain characters that are not URL safe - var encodedToken = HttpUtility.UrlEncode(token); - + string encodedToken = HttpUtility.UrlEncode(token); + // Build reset link - var resetLink = $"{options.Value.FrontendBaseUrl}/reset-password?email={HttpUtility.UrlEncode(request.Email)}&token={encodedToken}"; - - // TODO: Write a better email template - var subject = "Reset Your Password"; - var message = new StringBuilder() - .AppendLine("

Reset Your Password

") - .AppendLine("

Please click the link below to reset your password:

") - .AppendLine($"

Reset Password

") - .AppendLine("

If you did not request a password reset, please ignore this email.

") - .ToString(); - + string resetLink = + $"{options.Value.FrontendBaseUrl}/reset-password?email={HttpUtility.UrlEncode(request.Email)}&token={encodedToken}"; + + // Create a styled email message + string subject = "Reset your Hutopy password"; + string message = $""" +
+

Reset Your Hutopy Password

+ +

+ Please click the button below to reset your password: +

+ + + +

+ If you did not request a password reset, please ignore this email. +

+ +

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

+
+ """; + // Send email await emailSender.SendEmailAsync(request.Email, subject, message); - + await SendOkAsync(ct); } } diff --git a/backend/Modules/Identity/Handlers/Login.cs b/backend/Modules/Identity/Handlers/Login.cs index ba125bc..2224647 100644 --- a/backend/Modules/Identity/Handlers/Login.cs +++ b/backend/Modules/Identity/Handlers/Login.cs @@ -32,8 +32,8 @@ public class LoginHandler( LoginRequest request, CancellationToken ct) { - // Find user by email - var user = await userManager.FindByEmailAsync(request.Email); + // Find the user by email + User? user = await userManager.FindByEmailAsync(request.Email); if (user is null) { await SendStringAsync( @@ -44,7 +44,7 @@ public class LoginHandler( } // Verify password - var isPasswordValid = await userManager.CheckPasswordAsync(user, request.Password); + bool isPasswordValid = await userManager.CheckPasswordAsync(user, request.Password); if (!isPasswordValid) { await SendStringAsync( @@ -54,26 +54,36 @@ public class LoginHandler( return; } - // Generate new refresh token + // Check if the email is confirmed + if (!user.EmailConfirmed) + { + await SendStringAsync( + "Email not verified. Please check your email for verification instructions.", + 401, + cancellation: ct); + return; + } + + // Generate a new refresh token user.RefreshToken = RefreshTokenGenerator.Next(); user.RefreshTokenExpiryTime = DateTime.UtcNow.Add(jwtOptions.Value.RefreshTokenLifetime); await userManager.UpdateAsync(user); // Generate JWT token - var accessToken = JwtTokenHelper.GenerateJwtToken( - expiresIn: jwtOptions.Value.Lifetime, - issuer: jwtOptions.Value.Issuer, - audience: jwtOptions.Value.Audience, - key: jwtOptions.Value.Key, - userId: user.Id.ToString(), - email: user.Email ?? string.Empty, - alias: user.Alias, - firstname: user.Firstname ?? string.Empty, - lastname: user.Lastname ?? string.Empty, - portraitUrl: user.PortraitUrl); + string accessToken = JwtTokenHelper.GenerateJwtToken( + jwtOptions.Value.Lifetime, + jwtOptions.Value.Issuer, + jwtOptions.Value.Audience, + jwtOptions.Value.Key, + user.Id.ToString(), + user.Email ?? string.Empty, + user.Alias, + user.Firstname ?? string.Empty, + user.Lastname ?? string.Empty, + user.PortraitUrl); await SendOkAsync( new LoginResponse(accessToken, user.RefreshToken), - cancellation: ct); + ct); } } diff --git a/backend/Modules/Identity/Handlers/LoginWithFacebook.cs b/backend/Modules/Identity/Handlers/LoginWithFacebook.cs index 6810b34..c4a0a4f 100644 --- a/backend/Modules/Identity/Handlers/LoginWithFacebook.cs +++ b/backend/Modules/Identity/Handlers/LoginWithFacebook.cs @@ -3,6 +3,7 @@ using System.Text.Json.Serialization; using Hutopy.Infrastructure.Security; using Hutopy.Modules.Identity.Configuration; using Hutopy.Modules.Identity.Data; +using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Options; namespace Hutopy.Modules.Identity.Handlers; @@ -56,8 +57,8 @@ public class LoginWithFacebookHandler( CancellationToken ct) { // Verify the token with Facebook - using var httpClient = httpClientFactory.CreateClient(); - using var response = await httpClient.GetAsync( + 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); if (!response.IsSuccessStatusCode) @@ -70,8 +71,8 @@ public class LoginWithFacebookHandler( } // Extract the user info (email, name, profile picture) - var content = await response.Content.ReadAsStringAsync(ct); - var userInfo = JsonSerializer.Deserialize(content); + string content = await response.Content.ReadAsStringAsync(ct); + FacebookUserInfo? userInfo = JsonSerializer.Deserialize(content); if (userInfo is null || string.IsNullOrEmpty(userInfo.Id)) { await SendStringAsync( @@ -82,23 +83,24 @@ public class LoginWithFacebookHandler( } // Check if user exists or create a new one - var user = await userManager.FindByEmailAsync(userInfo.Email!); + User? user = await userManager.FindByEmailAsync(userInfo.Email!); if (user is null) { - var generatedPassword = PasswordGenerator.Next(); - var generatedUser = new User + string generatedPassword = PasswordGenerator.Next(); + User generatedUser = new() { UserName = userInfo.Email ?? $"fb_{userInfo.Id}", Email = userInfo.Email, + EmailConfirmed = true, Firstname = userInfo.Name.Split(' ').FirstOrDefault() ?? "", Lastname = userInfo.Name.Split(' ').Skip(1).FirstOrDefault() ?? "", Alias = userInfo.Name, PortraitUrl = userInfo.Picture.Picture.Url, - FacebookId = userInfo.Id, // Storing Facebook ID + FacebookId = userInfo.Id // Storing Facebook ID }; - var result = await userManager.CreateAsync( + IdentityResult result = await userManager.CreateAsync( generatedUser, generatedPassword); @@ -115,27 +117,27 @@ public class LoginWithFacebookHandler( } // Generate refresh token - var refreshToken = RefreshTokenGenerator.Next(); + string refreshToken = RefreshTokenGenerator.Next(); // Store refresh token in user's properties user.RefreshToken = refreshToken; user.RefreshTokenExpiryTime = DateTime.UtcNow.Add(jwtOptions.Value.RefreshTokenLifetime); await userManager.UpdateAsync(user); - var accessToken = JwtTokenHelper.GenerateJwtToken( - expiresIn: jwtOptions.Value.Lifetime, - issuer: jwtOptions.Value.Issuer, - audience: jwtOptions.Value.Audience, - key: jwtOptions.Value.Key, - userId: user.Id.ToString(), - email: user.Email ?? string.Empty, - alias: user.Alias, - firstname: user.Firstname ?? string.Empty, - lastname: user.Lastname ?? string.Empty, - portraitUrl: user.PortraitUrl); + string accessToken = JwtTokenHelper.GenerateJwtToken( + jwtOptions.Value.Lifetime, + jwtOptions.Value.Issuer, + jwtOptions.Value.Audience, + jwtOptions.Value.Key, + user.Id.ToString(), + user.Email ?? string.Empty, + user.Alias, + user.Firstname ?? string.Empty, + user.Lastname ?? string.Empty, + user.PortraitUrl); await SendOkAsync( new LoginWithFacebookResponse(accessToken, refreshToken), - cancellation: ct); + ct); } } diff --git a/backend/Modules/Identity/Handlers/LoginWithGoogle.cs b/backend/Modules/Identity/Handlers/LoginWithGoogle.cs index deabebd..46cb0f1 100644 --- a/backend/Modules/Identity/Handlers/LoginWithGoogle.cs +++ b/backend/Modules/Identity/Handlers/LoginWithGoogle.cs @@ -3,11 +3,12 @@ using System.Text.Json.Serialization; using Hutopy.Infrastructure.Security; using Hutopy.Modules.Identity.Configuration; using Hutopy.Modules.Identity.Data; +using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Options; namespace Hutopy.Modules.Identity.Handlers; -class GoogleToken +internal class GoogleToken { [JsonPropertyName("access_token")] public required string AccessToken { get; init; } [JsonPropertyName("token_type")] public required string TokenType { get; init; } @@ -55,11 +56,11 @@ public class LoginWithGoogleHandler( LoginWithGoogleRequest request, CancellationToken ct) { - var googleToken = JsonSerializer.Deserialize(request.Token)!; + GoogleToken googleToken = JsonSerializer.Deserialize(request.Token)!; // Verify the token with Google - using var httpClient = httpClientFactory.CreateClient(); - using var response = await httpClient.GetAsync( + using HttpClient httpClient = httpClientFactory.CreateClient(); + using HttpResponseMessage response = await httpClient.GetAsync( $"https://www.googleapis.com/oauth2/v1/userinfo?access_token={googleToken.AccessToken}", ct); if (!response.IsSuccessStatusCode) @@ -72,8 +73,8 @@ public class LoginWithGoogleHandler( } // Extract the user info (email, name, etc.). - var content = await response.Content.ReadAsStringAsync(ct); - var userInfo = JsonSerializer.Deserialize(content); + string content = await response.Content.ReadAsStringAsync(ct); + GoogleUserInfo? userInfo = JsonSerializer.Deserialize(content); if (userInfo is null || !userInfo.VerifiedEmail || string.IsNullOrEmpty(userInfo.Email)) @@ -85,17 +86,18 @@ public class LoginWithGoogleHandler( return; } - // Check if user exists or create a new one - var user = await userManager.FindByEmailAsync(userInfo.Email); + // Check if the user exists or create a new one + User? user = await userManager.FindByEmailAsync(userInfo.Email); if (user is null) { - var generatedPassword = PasswordGenerator.Next(); - var refreshToken = RefreshTokenGenerator.Next(); - var generatedUser = new User + string generatedPassword = PasswordGenerator.Next(); + string refreshToken = RefreshTokenGenerator.Next(); + User generatedUser = new() { UserName = userInfo.Email, Email = userInfo.Email, + EmailConfirmed = true, Firstname = userInfo.GivenName, Lastname = userInfo.FamilyName, Alias = userInfo.Name, @@ -105,7 +107,7 @@ public class LoginWithGoogleHandler( RefreshTokenExpiryTime = DateTime.UtcNow.Add(jwtOptions.Value.RefreshTokenLifetime) }; - var result = await userManager.CreateAsync( + IdentityResult result = await userManager.CreateAsync( generatedUser, generatedPassword); @@ -121,25 +123,25 @@ public class LoginWithGoogleHandler( user = generatedUser; } - // Generate new refresh token + // Generate the new refresh token user.RefreshToken = RefreshTokenGenerator.Next(); user.RefreshTokenExpiryTime = DateTime.UtcNow.Add(jwtOptions.Value.RefreshTokenLifetime); await userManager.UpdateAsync(user); - var accessToken = JwtTokenHelper.GenerateJwtToken( - expiresIn: jwtOptions.Value.Lifetime, - issuer: jwtOptions.Value.Issuer, - audience: jwtOptions.Value.Audience, - key: jwtOptions.Value.Key, - userId: user.Id.ToString(), - email: user.Email ?? string.Empty, - alias: user.Alias, - firstname: user.Firstname ?? string.Empty, - lastname: user.Lastname ?? string.Empty, - portraitUrl: user.PortraitUrl); + string accessToken = JwtTokenHelper.GenerateJwtToken( + jwtOptions.Value.Lifetime, + jwtOptions.Value.Issuer, + jwtOptions.Value.Audience, + jwtOptions.Value.Key, + user.Id.ToString(), + user.Email ?? string.Empty, + user.Alias, + user.Firstname ?? string.Empty, + user.Lastname ?? string.Empty, + user.PortraitUrl); await SendOkAsync( new LoginWithGoogleResponse(accessToken, user.RefreshToken), - cancellation: ct); + ct); } } diff --git a/backend/Modules/Identity/Handlers/Register.cs b/backend/Modules/Identity/Handlers/Register.cs index dff4edb..1e0bad1 100644 --- a/backend/Modules/Identity/Handlers/Register.cs +++ b/backend/Modules/Identity/Handlers/Register.cs @@ -1,7 +1,6 @@ -using Hutopy.Infrastructure.Security; -using Hutopy.Modules.Identity.Configuration; using Hutopy.Modules.Identity.Data; -using Microsoft.Extensions.Options; +using Hutopy.Modules.Identity.Services; +using Microsoft.AspNetCore.Identity; namespace Hutopy.Modules.Identity.Handlers; @@ -13,13 +12,12 @@ public record RegisterRequest( [PublicAPI] public record RegisterResponse( - string AccessToken, - string RefreshToken); + string Message); [PublicAPI] public class RegisterHandler( UserManager userManager, - IOptionsSnapshot jwtOptions) + EmailVerificationService emailVerificationService) : Endpoint { public override void Configure() @@ -34,7 +32,7 @@ public class RegisterHandler( CancellationToken ct) { // Check if the user already exists - var existingUser = await userManager.FindByEmailAsync(request.Email); + User? existingUser = await userManager.FindByEmailAsync(request.Email); if (existingUser is not null) { await SendStringAsync( @@ -44,27 +42,22 @@ public class RegisterHandler( return; } - // Create a refresh token - var refreshToken = RefreshTokenGenerator.Next(); - // Split the name into firstname and lastname (if provided) - var nameParts = request.Name.Split(' ', 2); - var firstname = nameParts[0]; - var lastname = nameParts.Length > 1 ? nameParts[1] : string.Empty; + string[] nameParts = request.Name.Split(' ', 2); + string firstname = nameParts[0]; + string lastname = nameParts.Length > 1 ? nameParts[1] : string.Empty; // Create a new user - var user = new User + User user = new() { UserName = request.Email, Email = request.Email, Firstname = firstname, Lastname = lastname, - Alias = request.Name, - RefreshToken = refreshToken, - RefreshTokenExpiryTime = DateTime.UtcNow.Add(jwtOptions.Value.RefreshTokenLifetime) + Alias = request.Name }; - var result = await userManager.CreateAsync( + IdentityResult result = await userManager.CreateAsync( user, request.Password); @@ -77,21 +70,10 @@ public class RegisterHandler( return; } - // Generate JWT token - var accessToken = JwtTokenHelper.GenerateJwtToken( - expiresIn: jwtOptions.Value.Lifetime, - issuer: jwtOptions.Value.Issuer, - audience: jwtOptions.Value.Audience, - key: jwtOptions.Value.Key, - userId: user.Id.ToString(), - email: user.Email ?? string.Empty, - alias: user.Alias, - firstname: user.Firstname ?? string.Empty, - lastname: user.Lastname ?? string.Empty, - portraitUrl: user.PortraitUrl); + await emailVerificationService.SendVerificationEmailAsync(user); await SendOkAsync( - new RegisterResponse(accessToken, user.RefreshToken), - cancellation: ct); + new RegisterResponse("Registration successful! Please check your email to verify your account."), + ct); } } diff --git a/backend/Modules/Identity/Handlers/ResendVerification.cs b/backend/Modules/Identity/Handlers/ResendVerification.cs new file mode 100644 index 0000000..cd9b79e --- /dev/null +++ b/backend/Modules/Identity/Handlers/ResendVerification.cs @@ -0,0 +1,58 @@ +using Hutopy.Modules.Identity.Data; +using Hutopy.Modules.Identity.Services; + +namespace Hutopy.Modules.Identity.Handlers; + +[PublicAPI] +public record ResendVerificationRequest( + string Email); + +[PublicAPI] +public record ResendVerificationResponse( + string Message); + +[PublicAPI] +public class ResendVerificationHandler( + EmailVerificationService emailWriter, + UserManager userManager) + : Endpoint +{ + public override void Configure() + { + AllowAnonymous(); + Post("/api/users/resend-verification"); + Options(o => o.WithTags("Users")); + } + + public override async Task HandleAsync( + ResendVerificationRequest request, + CancellationToken ct) + { + // Find a user by email + User? user = await userManager.FindByEmailAsync(request.Email); + if (user is null) + { + // Don't reveal that the user doesn't exist + await SendOkAsync( + new ResendVerificationResponse( + "If your email exists in our system, a verification link has been sent."), + ct); + return; + } + + // Check if the email is already confirmed + if (user.EmailConfirmed) + { + await SendOkAsync( + new ResendVerificationResponse("Your email is already verified. You can log in."), + ct); + return; + } + + await emailWriter.SendVerificationEmailAsync(user); + + await SendOkAsync( + new ResendVerificationResponse("If your email exists in our system, a verification link has been sent."), + ct); + } +} diff --git a/backend/Modules/Identity/Handlers/VerifyEmail.cs b/backend/Modules/Identity/Handlers/VerifyEmail.cs new file mode 100644 index 0000000..0cce005 --- /dev/null +++ b/backend/Modules/Identity/Handlers/VerifyEmail.cs @@ -0,0 +1,60 @@ +using System.Web; +using Hutopy.Modules.Identity.Data; +using Microsoft.AspNetCore.Identity; + +namespace Hutopy.Modules.Identity.Handlers; + +[PublicAPI] +public record VerifyEmailRequest( + string UserId, + string Token); + +[PublicAPI] +public record VerifyEmailResponse( + string Message); + +[PublicAPI] +public class VerifyEmailHandler( + UserManager userManager) + : Endpoint +{ + public override void Configure() + { + AllowAnonymous(); + Get("/api/users/verify-email"); + Options(o => o.WithTags("Users")); + } + + public override async Task HandleAsync( + VerifyEmailRequest request, + CancellationToken ct) + { + // Find user by ID + User? user = await userManager.FindByIdAsync(request.UserId); + if (user is null) + { + await SendStringAsync( + "Invalid verification link", + 400, + cancellation: ct); + return; + } + + // Verify the token and confirm email + string decoded = HttpUtility.UrlDecode(request.Token); + string decodedWithPlus = request.Token.Replace(" ", "+"); + IdentityResult result = await userManager.ConfirmEmailAsync(user, decodedWithPlus); + if (!result.Succeeded) + { + await SendStringAsync( + "Invalid verification link or the link has expired", + 400, + cancellation: ct); + return; + } + + await SendOkAsync( + new VerifyEmailResponse("Email verification successful! You can now log in."), + ct); + } +} diff --git a/backend/Modules/Identity/Services/EmailVerificationService.cs b/backend/Modules/Identity/Services/EmailVerificationService.cs new file mode 100644 index 0000000..2440bbf --- /dev/null +++ b/backend/Modules/Identity/Services/EmailVerificationService.cs @@ -0,0 +1,61 @@ +using System.Web; +using Hutopy.Infrastructure.Configuration; +using Hutopy.Infrastructure.Emailer.Contracts; +using Hutopy.Modules.Identity.Data; +using Microsoft.Extensions.Options; + +namespace Hutopy.Modules.Identity.Services; + +[PublicAPI] +public sealed class EmailVerificationService( + IOptionsSnapshot options, + UserManager userManager, + IEmailSender emailSender) +{ + public async Task SendVerificationEmailAsync( + User user) + { + // Generate email confirmation token + string token = await userManager.GenerateEmailConfirmationTokenAsync(user); + string encodedToken = HttpUtility.UrlEncode(token); + string verificationLink = $"{options.Value.FrontendBaseUrl}/verify-email?userId={user.Id}&token={encodedToken}"; + + // Send verification email + await emailSender.SendEmailAsync( + user.Email!, + "Verify your email address", + $""" +
+

Welcome to Hutopy!

+ +

+ Please verify your email address by clicking the button below: +

+ + + +

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

+ +

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

+
+ """); + } +} diff --git a/backend/Modules/Memberships/Handlers/HandleStripe.cs b/backend/Modules/Memberships/Handlers/HandleStripe.cs index 9753670..03bf198 100644 --- a/backend/Modules/Memberships/Handlers/HandleStripe.cs +++ b/backend/Modules/Memberships/Handlers/HandleStripe.cs @@ -3,6 +3,7 @@ using Hutopy.Infrastructure.Payments.Stripe.Configuration; using Hutopy.Modules.Memberships.Contracts; using Hutopy.Modules.Tipping.Contracts; using Microsoft.Extensions.Options; +using Microsoft.Extensions.Primitives; using Stripe; using Stripe.Checkout; @@ -18,19 +19,19 @@ public class StripeWebhookEndpoint( { Post("/api/stripe"); AllowAnonymous(); - Options(o => o.WithTags( "Webhooks")); + Options(o => o.WithTags("Webhooks")); } public override async Task HandleAsync(CancellationToken ct) { - using var streamReader = new StreamReader(HttpContext.Request.Body); - var json = await streamReader.ReadToEndAsync(ct); + using StreamReader streamReader = new(HttpContext.Request.Body); + string json = await streamReader.ReadToEndAsync(ct); - var signatureHeader = HttpContext.Request.Headers["Stripe-Signature"]; - var stripeEvent = EventUtility.ConstructEvent(json, signatureHeader, options.Value.WebhookSecret); + StringValues signatureHeader = HttpContext.Request.Headers["Stripe-Signature"]; + Event? stripeEvent = EventUtility.ConstructEvent(json, signatureHeader, options.Value.WebhookSecret); - var stripeSession = stripeEvent.Data.Object as Session; - var stripeSubscription = stripeEvent.Data.Object as Subscription; + Session? stripeSession = stripeEvent.Data.Object as Session; + Subscription? stripeSubscription = stripeEvent.Data.Object as Subscription; switch (stripeEvent.Type) { @@ -41,11 +42,23 @@ public class StripeWebhookEndpoint( // Check if this is a one-time tip case "payment" when stripeSession.PaymentIntentId != null && stripeSession.PaymentIntent.Status == "paid": + // Get the customer email from the appropriate place + string customerEmail = stripeSession.CustomerDetails?.Email ?? + stripeSession.Customer?.Email ?? + ""; + + // Get the receipt URL, preferring the one directly on the charge if available + string receiptUrl = stripeSession.PaymentIntent?.Charges?.Data.FirstOrDefault()?.ReceiptUrl ?? + stripeSession.Invoice?.HostedInvoiceUrl ?? + ""; + await tipPaymentNotifier.NotifyPaymentSucceedAsync( stripeSession.Id, - stripeSession.Invoice.HostedInvoiceUrl, + receiptUrl, + customerEmail, ct); break; + // Check if this is a subscription case "subscription" when stripeSession.SubscriptionId != null: await membershipNotifier.NotifyPaymentSucceedAsync( @@ -53,13 +66,13 @@ public class StripeWebhookEndpoint( stripeSession.Invoice.HostedInvoiceUrl, stripeSession.Invoice.Total, stripeSession.Invoice.Currency, - cancellationToken: ct); + ct); break; } break; case "invoice.payment_succeeded": - var invoice = (stripeEvent.Data.Object as Invoice); + Invoice? invoice = stripeEvent.Data.Object as Invoice; Debug.Assert(invoice != null); Debug.Assert(invoice.Subscription != null); await membershipNotifier.NotifyPaymentSucceedAsync( @@ -67,7 +80,7 @@ public class StripeWebhookEndpoint( invoice.HostedInvoiceUrl, invoice.Total, invoice.Currency, - cancellationToken: ct); + ct); break; case "customer.subscription.updated": diff --git a/backend/Modules/Tipping/Contracts/ITipPaymentNotifier.cs b/backend/Modules/Tipping/Contracts/ITipPaymentNotifier.cs index e38a08e..79f1d53 100644 --- a/backend/Modules/Tipping/Contracts/ITipPaymentNotifier.cs +++ b/backend/Modules/Tipping/Contracts/ITipPaymentNotifier.cs @@ -2,5 +2,9 @@ namespace Hutopy.Modules.Tipping.Contracts; public interface ITipPaymentNotifier { - Task NotifyPaymentSucceedAsync(string stripeId, string invoiceUrl, CancellationToken ct); + Task NotifyPaymentSucceedAsync( + string stripeId, + string invoiceUrl, + string customerEmail, + CancellationToken ct); } diff --git a/backend/Modules/Tipping/Services/TipPaymentNotifier.cs b/backend/Modules/Tipping/Services/TipPaymentNotifier.cs index 2c0bdbf..38ee0bd 100644 --- a/backend/Modules/Tipping/Services/TipPaymentNotifier.cs +++ b/backend/Modules/Tipping/Services/TipPaymentNotifier.cs @@ -1,3 +1,5 @@ +using Hutopy.Infrastructure.Emailer.Contracts; +using Hutopy.Modules.Creators.Contracts; using Hutopy.Modules.Tipping.Contracts; using Hutopy.Modules.Tipping.Data; @@ -5,27 +7,104 @@ namespace Hutopy.Modules.Tipping.Services; public class TipPaymentNotifier( TippingDbContext dbContext, + IEmailSender emailSender, + ICreatorLookup creatorLookup, ILogger logger) : ITipPaymentNotifier { public async Task NotifyPaymentSucceedAsync( string sessionId, - string invoiceUrl, + string receiptUrl, + string customerEmail, CancellationToken ct) { - var tip = await dbContext.Tips.SingleOrDefaultAsync( + Tip? tip = await dbContext.Tips.SingleOrDefaultAsync( t => t.StripeSessionId == sessionId, - cancellationToken: ct); + ct); if (tip is not null) { tip.Status = TipStatus.Paid; - tip.StripeInvoiceUrl = invoiceUrl; + tip.StripeInvoiceUrl = receiptUrl; // Store the receipt URL await dbContext.SaveChangesAsync(ct); + + // Look up creator information + CreatorReference? creator = await creatorLookup.GetCreatorAsync(tip.CreatorId, ct); + + if (!string.IsNullOrEmpty(customerEmail)) + { + await SendTipConfirmationEmailAsync( + customerEmail, + creator?.Name ?? "le créateur", + tip.Amount, + tip.Currency, + receiptUrl); // Pass the receipt URL + } } else { logger.LogError("Tip with session ID {SessionId} not found", sessionId); } } + + private async Task SendTipConfirmationEmailAsync( + string email, + string creatorUsername, + decimal amount, + string currency, + string receiptUrl) // Add receipt URL parameter + { + string subject = $"Merci pour votre soutien à {creatorUsername}"; + string message = $""" +
+

{creatorUsername} vous remercie !

+ +

+ Votre paiement de {amount} {currency} a été traité avec succès. +

+ +
+

+ Ce reçu confirme votre soutien à {creatorUsername}. Merci de contribuer à son travail ! +

+
+ + {(string.IsNullOrEmpty(receiptUrl) ? "" : $""" + + """)} + +

+ Cet email sert de reçu pour votre transaction. Nous vous conseillons de le conserver pour vos archives. +

+ +

+ Merci d'utiliser Hutopy pour soutenir vos créateurs préférés ! +

+
+ """; + + try + { + await emailSender.SendEmailAsync(email, subject, message); + logger.LogInformation("Tip confirmation email sent to {Email} for tip to {Creator}", email, + creatorUsername); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to send tip confirmation email to {Email}", email); + // Don't throw the exception as this should not fail the payment processing + } + } } diff --git a/frontend/src/router/router.js b/frontend/src/router/router.js index ef86e77..ecb0684 100644 --- a/frontend/src/router/router.js +++ b/frontend/src/router/router.js @@ -3,7 +3,8 @@ import { createRouter, createWebHistory } from 'vue-router'; import CreatorHome from '@/views/creators/CreatorHome.vue'; import CreatorLayout from '@/views/creators/CreatorLayout.vue'; -const LoginView = () => import('@/views/LoginView.vue'); + +const LoginView = () => import('@/views/auth/LoginView.vue'); const About = () => import('@/views/documentation/About.vue'); const ContentPolicy = () => import('@/views/documentation/ContentPolicy.vue'); @@ -14,151 +15,158 @@ const HelpAndContact = () => import('@/views/documentation/HelpAndContact.vue'); const Pricing = () => import('@/views/documentation/Pricing.vue'); const TermsAndConditions = () => import('@/views/documentation/TermsAndConditions.vue'); const ProfilePage = () => import('@/views/profile/ProfilePage.vue'); -const PaymentCompleted = () => import('@/views/PaymentCompleted.vue'); -const PaymentFailed = () => import('@/views/PaymentFailed.vue'); +const PaymentCompleted = () => import('@/views/creators/PaymentCompleted.vue'); +const PaymentFailed = () => import('@/views/creators/PaymentFailed.vue'); const Landing = () => import('@/views/main/Landing.vue'); const CreateCreator = () => import('@/views/creators/CreateCreator.vue'); -const RegisterView = () => import('@/views/RegisterView.vue'); -const ForgotPasswordView = () => import('@/views/ForgotPasswordView.vue'); -const ResetPasswordView = () => import('@/views/ResetPasswordView.vue'); +const RegisterView = () => import('@/views/auth/RegisterView.vue'); +const ForgotPasswordView = () => import('@/views/auth/ForgotPasswordView.vue'); +const ResetPasswordView = () => import('@/views/auth/ResetPasswordView.vue'); +const VerifyEmailView = () => import('@/views/auth/VerifyEmailView.vue'); const routes = [ - { - path: '/landing', - name: 'landing', - component: Landing, - }, - { - path: '/', - redirect: { name: 'landing' }, - }, - { - path: '/@:creator', - component: CreatorLayout, - children: [ - { - path: '', - name: 'creator', - component: CreatorHome, - }, - { - path: 'tip-completed', - name: 'PaymentCompleted', - component: PaymentCompleted, - }, - { - path: 'tip-cancelled', - name: 'PaymentFailed', - component: PaymentFailed, - } - ], - }, - { - path: '/documents', - component: DocumentationLayout, - children: [ - { - path: 'helpandcontact', - name: 'helpandcontact', - component: HelpAndContact, - }, - { - path: 'termsandconditions', - name: 'termsandconditions', - component: TermsAndConditions, - }, - { - path: 'contentpolicy', - name: 'contentpolicy', - component: ContentPolicy, - }, - { - path: 'faq', - name: 'FAQ', - component: FAQ, - }, - { - path: 'guideforcreators', - name: 'guideforcreators', - component: CreatorGuide, - }, - { - path: 'about', - name: 'about', - component: About, - }, - { - path: 'pricing', - name: 'pricing', - component: Pricing, - }, - ], - }, - { - path: '/login', - name: 'login', - component: LoginView, - meta: { notAuthenticated: true }, - props: (route) => ({ returnUrl: route.query.returnUrl || '/landing' }) - }, - { - path: '/profile', - name: 'profile', - component: ProfilePage, - meta: { requiresAuth: true }, - }, - { - path: '/create-creator', - name: 'create-creator', - component: CreateCreator, - meta: { requiresAuth: true }, - }, - { - path: '/register', - name: 'register', - component: RegisterView, - meta: { requiresAuth: false } - }, - { - path: '/forgot-password', - name: 'forgot-password', - component: ForgotPasswordView, - meta: { notAuthenticated: true } - }, - { - path: '/reset-password', - name: 'reset-password', - component: ResetPasswordView, - meta: { notAuthenticated: true }, - props: (route) => ({ email: route.query.email, token: route.query.token }) - } + { + path: '/landing', + name: 'landing', + component: Landing, + }, + { + path: '/', + redirect: { name: 'landing' }, + }, + { + path: '/@:creator', + component: CreatorLayout, + children: [ + { + path: '', + name: 'creator', + component: CreatorHome, + }, + { + path: 'tip-completed', + name: 'PaymentCompleted', + component: PaymentCompleted, + }, + { + path: 'tip-cancelled', + name: 'PaymentFailed', + component: PaymentFailed, + }, + ], + }, + { + path: '/documents', + component: DocumentationLayout, + children: [ + { + path: 'helpandcontact', + name: 'helpandcontact', + component: HelpAndContact, + }, + { + path: 'termsandconditions', + name: 'termsandconditions', + component: TermsAndConditions, + }, + { + path: 'contentpolicy', + name: 'contentpolicy', + component: ContentPolicy, + }, + { + path: 'faq', + name: 'FAQ', + component: FAQ, + }, + { + path: 'guideforcreators', + name: 'guideforcreators', + component: CreatorGuide, + }, + { + path: 'about', + name: 'about', + component: About, + }, + { + path: 'pricing', + name: 'pricing', + component: Pricing, + }, + ], + }, + { + path: '/login', + name: 'login', + component: LoginView, + meta: { notAuthenticated: true }, + props: route => ({ returnUrl: route.query.returnUrl || '/landing' }), + }, + { + path: '/profile', + name: 'profile', + component: ProfilePage, + meta: { requiresAuth: true }, + }, + { + path: '/create-creator', + name: 'create-creator', + component: CreateCreator, + meta: { requiresAuth: true }, + }, + { + path: '/register', + name: 'register', + component: RegisterView, + meta: { requiresAuth: false }, + }, + { + path: '/forgot-password', + name: 'forgot-password', + component: ForgotPasswordView, + meta: { notAuthenticated: true }, + }, + { + path: '/reset-password', + name: 'reset-password', + component: ResetPasswordView, + meta: { notAuthenticated: true }, + props: route => ({ email: route.query.email, token: route.query.token }), + }, + { + path: '/verify-email', + name: 'verify-email', + component: VerifyEmailView, + meta: { notAuthenticated: true }, + }, ]; const router = createRouter({ - history: createWebHistory(import.meta.env.BASE_URL), - routes, + history: createWebHistory(import.meta.env.BASE_URL), + routes, }); // Navigation guards router.beforeEach((to, from, next) => { - const authStore = useAuthStore(); + const authStore = useAuthStore(); - if (to.matched.some((record) => record.meta.requiresAuth)) { - if (!authStore.isAuthenticated) { - next({ - name: 'login', - query: { returnUrl: to.fullPath } - }); + if (to.matched.some(record => record.meta.requiresAuth)) { + if (!authStore.isAuthenticated) { + next({ + name: 'login', + query: { returnUrl: to.fullPath }, + }); + } else { + next(); + } + } else if (to.matched.some(record => record.meta.notAuthenticated)) { + if (authStore.isAuthenticated) next({ name: 'landing' }); + else next(); } else { - next(); + next(); } - } else if (to.matched.some((record) => record.meta.notAuthenticated)) { - if (authStore.isAuthenticated) next({ name: 'landing' }); - else next(); - } else { - next(); - } }); export default router; diff --git a/frontend/src/stores/authStore.js b/frontend/src/stores/authStore.js index 1b1663e..b1ad7eb 100644 --- a/frontend/src/stores/authStore.js +++ b/frontend/src/stores/authStore.js @@ -4,280 +4,273 @@ import { useRouter } from 'vue-router'; import { useClient } from '@/plugins/api.js'; import { useSessionStorage } from '@vueuse/core'; import { jwtDecode } from 'jwt-decode'; -import { formatDuration } from "@/internal_time_ago.js"; +import { formatDuration } from '@/internal_time_ago.js'; export const useAuthStore = defineStore('auth', () => { - const clientApi = useClient(); - const router = useRouter(); + const clientApi = useClient(); + const router = useRouter(); - const isRefreshing = ref(false); - let refreshPromise = null; + const isRefreshing = ref(false); + let refreshPromise = null; - const accessToken = useSessionStorage('auth-accessToken', undefined); - const refreshToken = useSessionStorage('auth-refreshToken', undefined); - const tokenClaims = useSessionStorage('auth-tokenClaims', null, { - serializer: { - read: (v) => (v ? JSON.parse(v) : null), - write: (v) => (v ? JSON.stringify(v) : null) - } - }); + const accessToken = useSessionStorage('auth-accessToken', undefined); + const refreshToken = useSessionStorage('auth-refreshToken', undefined); + const tokenClaims = useSessionStorage('auth-tokenClaims', null, { + serializer: { + read: v => (v ? JSON.parse(v) : null), + write: v => (v ? JSON.stringify(v) : null), + }, + }); - const isAuthenticated = computed(() => !!accessToken.value); - const userId = computed(() => tokenClaims.value?.sub); + const isAuthenticated = computed(() => !!accessToken.value); + const userId = computed(() => tokenClaims.value?.sub); - function updateTokens(data) { - if (!data?.accessToken || !data?.refreshToken) { - throw new Error('Invalid token data'); - } - accessToken.value = data.accessToken; - refreshToken.value = data.refreshToken; - const claims = getClaimsFromToken(data.accessToken); - tokenClaims.value = claims; - console.log('Tokens updated, user ID:', claims?.sub); - } - - function cleanTokens() { - console.log('cleanTokens called - clearing stored tokens'); - accessToken.value = undefined; - refreshToken.value = undefined; - tokenClaims.value = null; - } - - async function logout(redirectTo = '/landing') { - console.log('logout called, redirecting to:', redirectTo); - try { - // Optionally call logout endpoint if you have one - // await clientApi.post('api/users/logout'); - } catch (error) { - console.error('Logout failed:', error); - } finally { - cleanTokens(); - await router.push(redirectTo); - } - } - - async function login(email, password) { - console.log('login called with email:', email); - if (!email || !password) { - throw new Error('Email and password are required'); - } - - try { - const response = await clientApi.post('api/users/login', { - email: email.trim(), - password: password - }); - - if (!response.data?.accessToken || !response.data?.refreshToken) { - throw new Error('Invalid login response'); - } - - updateTokens(response.data); - console.log('login successful'); - return true; - } catch (error) { - console.error('Login failed:', error); - cleanTokens(); - throw error; - } - } - - async function loginWithGoogle(accessTokenParam) { - console.log('loginWithGoogle called'); - if (!accessTokenParam) { - throw new Error('Google access token is required'); - } - - try { - const response = await clientApi.post('api/users/login-with-google', { - token: accessTokenParam - }); - - if (!response.data?.accessToken || !response.data?.refreshToken) { - throw new Error('Invalid Google login response'); - } - - updateTokens(response.data); - console.log('Google login successful'); - return true; - } catch (error) { - console.error('Google login failed:', error); - cleanTokens(); - throw error; - } - } - - async function loginWithFacebook(authResponse) { - console.log('loginWithFacebook called'); - if (!authResponse?.accessToken) { - throw new Error('Facebook access token is required'); - } - - try { - const response = await clientApi.post('api/users/login-with-facebook', { - token: authResponse.accessToken - }); - - if (!response.data?.accessToken || !response.data?.refreshToken) { - throw new Error('Invalid Facebook login response'); - } - - updateTokens(response.data); - console.log('Facebook login successful'); - return true; - } catch (error) { - console.error('Facebook login failed:', error); - cleanTokens(); - throw error; - } - } - - async function refresh() { - console.log('refresh called'); - - if (!refreshToken.value) { - cleanTokens(); // Clear tokens first - throw new Error('No refresh token available'); - } - - if (isRefreshing.value && refreshPromise) { - console.log('Already refreshing, returning existing refreshPromise'); - return refreshPromise; - } - - try { - isRefreshing.value = true; - refreshPromise = (async () => { - try { - console.log('Sending refresh request...'); - - const response = await clientApi.post('api/users/refresh', { - refreshToken: refreshToken.value - }); - - if (!response.data?.accessToken || !response.data?.refreshToken) { - throw new Error('Invalid refresh response'); - } - - updateTokens({ - accessToken: response.data.accessToken, - refreshToken: response.data.refreshToken - }); - - console.log('Token refresh successful'); - return true; - } catch (error) { - console.error('Token refresh failed:', error); - cleanTokens(); - - const currentRoute = router.currentRoute.value; - const returnUrl = currentRoute.fullPath; - - // Handle navigation - router.push({ - name: 'login', - query: { returnUrl } - }).catch(navError => { - console.error('Navigation error after token refresh failure:', navError); - }); - - throw error; // Re-throw to notify callers + function updateTokens(data) { + if (!data?.accessToken || !data?.refreshToken) { + throw new Error('Invalid token data'); } - })(); - - return await refreshPromise; - } catch (error) { - throw error; - } finally { - // Ensure these are always reset, even if an error is thrown - isRefreshing.value = false; - refreshPromise = null; - } - } - - function getClaimsFromToken(token) { - if (!token) return null; - try { - return jwtDecode(token); - } catch (error) { - console.error('Failed to decode token:', error); - return null; - } - } - - function isTokenExpiringSoon(token) { - if (!token) { - console.log('No token provided, considered expiring soon'); - return true; + accessToken.value = data.accessToken; + refreshToken.value = data.refreshToken; + const claims = getClaimsFromToken(data.accessToken); + tokenClaims.value = claims; + console.log('Tokens updated, user ID:', claims?.sub); } - const claims = getClaimsFromToken(token); - if (!claims || !claims.exp) { - console.log('No valid claims found, considered expiring soon'); - return true; + function cleanTokens() { + console.log('cleanTokens called - clearing stored tokens'); + accessToken.value = undefined; + refreshToken.value = undefined; + tokenClaims.value = null; } - const expirationTime = claims.exp * 1000; // Convert to milliseconds - const currentTime = Date.now(); - const fiveMinutesInMs = 2 * 60 * 1000; // 2 minutes for demonstration - - // Calculate time remaining (can be negative if already expired) - const timeRemainingMs = expirationTime - currentTime; - - // Token is expiring soon if less than 2 minutes remaining or already expired - const isExpiring = timeRemainingMs < fiveMinutesInMs; - - // Determine the sign for display purposes - const formattedTimeRemaining = timeRemainingMs < 0 - ? `-${formatDuration(Math.abs(timeRemainingMs))}` - : formatDuration(timeRemainingMs); - - if (isExpiring) { - console.log(`Token expiration check; is token expired: ${isExpiring}`, { - expirationTime: new Date(expirationTime).toLocaleString(), - currentTime: new Date(currentTime).toLocaleString(), - timeRemaining: formattedTimeRemaining - }); + async function logout() { + cleanTokens(); + await router.push('/'); } - return isExpiring; - } + async function login(email, password) { + console.log('login called with email:', email); + if (!email || !password) { + throw new Error('Email and password are required'); + } - async function changePassword(newPassword) { - console.log('changePassword called'); - if (!isAuthenticated.value) { - throw new Error('User must be authenticated to change password'); + try { + const response = await clientApi.post('api/users/login', { + email: email.trim(), + password: password, + }); + + if (!response.data?.accessToken || !response.data?.refreshToken) { + throw new Error('Invalid login response'); + } + + updateTokens(response.data); + console.log('login successful'); + return true; + } catch (error) { + console.error('Login failed:', error); + cleanTokens(); + throw error; + } } - if (!newPassword) { - throw new Error('New password is required'); + async function loginWithGoogle(accessTokenParam) { + console.log('loginWithGoogle called'); + if (!accessTokenParam) { + throw new Error('Google access token is required'); + } + + try { + const response = await clientApi.post('api/users/login-with-google', { + token: accessTokenParam, + }); + + if (!response.data?.accessToken || !response.data?.refreshToken) { + throw new Error('Invalid Google login response'); + } + + updateTokens(response.data); + console.log('Google login successful'); + return true; + } catch (error) { + console.error('Google login failed:', error); + cleanTokens(); + throw error; + } } - try { - const response = await clientApi.post('api/users/set-password', { - newPassword - }); + async function loginWithFacebook(authResponse) { + console.log('loginWithFacebook called'); + if (!authResponse?.accessToken) { + throw new Error('Facebook access token is required'); + } - console.log('Password changed successfully'); - return true; - } catch (error) { - console.error('Password change failed:', error); - throw error; + try { + const response = await clientApi.post('api/users/login-with-facebook', { + token: authResponse.accessToken, + }); + + if (!response.data?.accessToken || !response.data?.refreshToken) { + throw new Error('Invalid Facebook login response'); + } + + updateTokens(response.data); + console.log('Facebook login successful'); + return true; + } catch (error) { + console.error('Facebook login failed:', error); + cleanTokens(); + throw error; + } } - } - return { - accessToken, - refreshToken, - isAuthenticated, - userId, - isRefreshing, - login, - loginWithGoogle, - loginWithFacebook, - logout, - refresh, - isTokenExpiringSoon, - changePassword - }; + async function refresh() { + console.log('refresh called'); + + if (!refreshToken.value) { + cleanTokens(); // Clear tokens first + throw new Error('No refresh token available'); + } + + if (isRefreshing.value && refreshPromise) { + console.log('Already refreshing, returning existing refreshPromise'); + return refreshPromise; + } + + try { + isRefreshing.value = true; + refreshPromise = (async () => { + try { + console.log('Sending refresh request...'); + + const response = await clientApi.post('api/users/refresh', { + refreshToken: refreshToken.value, + }); + + if (!response.data?.accessToken || !response.data?.refreshToken) { + throw new Error('Invalid refresh response'); + } + + updateTokens({ + accessToken: response.data.accessToken, + refreshToken: response.data.refreshToken, + }); + + console.log('Token refresh successful'); + return true; + } catch (error) { + console.error('Token refresh failed:', error); + cleanTokens(); + + const currentRoute = router.currentRoute.value; + const returnUrl = currentRoute.fullPath; + + // Handle navigation + router + .push({ + name: 'login', + query: { returnUrl }, + }) + .catch(navError => { + console.error('Navigation error after token refresh failure:', navError); + }); + + throw error; // Re-throw to notify callers + } + })(); + + return await refreshPromise; + } catch (error) { + throw error; + } finally { + // Ensure these are always reset, even if an error is thrown + isRefreshing.value = false; + refreshPromise = null; + } + } + + function getClaimsFromToken(token) { + if (!token) return null; + try { + return jwtDecode(token); + } catch (error) { + console.error('Failed to decode token:', error); + return null; + } + } + + function isTokenExpiringSoon(token) { + if (!token) { + console.log('No token provided, considered expiring soon'); + return true; + } + + const claims = getClaimsFromToken(token); + if (!claims || !claims.exp) { + console.log('No valid claims found, considered expiring soon'); + return true; + } + + const expirationTime = claims.exp * 1000; // Convert to milliseconds + const currentTime = Date.now(); + const fiveMinutesInMs = 2 * 60 * 1000; // 2 minutes for demonstration + + // Calculate time remaining (can be negative if already expired) + const timeRemainingMs = expirationTime - currentTime; + + // Token is expiring soon if less than 2 minutes remaining or already expired + const isExpiring = timeRemainingMs < fiveMinutesInMs; + + // Determine the sign for display purposes + const formattedTimeRemaining = + timeRemainingMs < 0 ? `-${formatDuration(Math.abs(timeRemainingMs))}` : formatDuration(timeRemainingMs); + + if (isExpiring) { + console.log(`Token expiration check; is token expired: ${isExpiring}`, { + expirationTime: new Date(expirationTime).toLocaleString(), + currentTime: new Date(currentTime).toLocaleString(), + timeRemaining: formattedTimeRemaining, + }); + } + + return isExpiring; + } + + async function changePassword(newPassword) { + console.log('changePassword called'); + if (!isAuthenticated.value) { + throw new Error('User must be authenticated to change password'); + } + + if (!newPassword) { + throw new Error('New password is required'); + } + + try { + const response = await clientApi.post('api/users/set-password', { + newPassword, + }); + + console.log('Password changed successfully'); + return true; + } catch (error) { + console.error('Password change failed:', error); + throw error; + } + } + + return { + accessToken, + refreshToken, + isAuthenticated, + userId, + isRefreshing, + login, + loginWithGoogle, + loginWithFacebook, + logout, + refresh, + isTokenExpiringSoon, + changePassword, + }; }); diff --git a/frontend/src/views/RegisterView.vue b/frontend/src/views/RegisterView.vue deleted file mode 100644 index 7ec9688..0000000 --- a/frontend/src/views/RegisterView.vue +++ /dev/null @@ -1,167 +0,0 @@ - - - - - - - -{ - "en": { - "title": "Create your account", - "alt": "Hutopy Registration", - "name": "Full Name", - "email": "Email", - "password": "Password", - "confirmPassword": "Confirm Password", - "passwordRequirements": "Password must be at least 8 characters", - "register": "Register", - "alreadyHaveAccount": "Already have an account?", - "signIn": "Sign in", - "passwordsDoNotMatch": "Passwords do not match", - "registrationFailed": "Registration failed. Please try again." - }, - "fr": { - "title": "Créer votre compte", - "alt": "Inscription Hutopy", - "name": "Nom complet", - "email": "Email", - "password": "Mot de passe", - "confirmPassword": "Confirmer le mot de passe", - "passwordRequirements": "Le mot de passe doit comporter au moins 8 caractères", - "register": "S'inscrire", - "alreadyHaveAccount": "Vous avez déjà un compte?", - "signIn": "Se connecter", - "passwordsDoNotMatch": "Les mots de passe ne correspondent pas", - "registrationFailed": "L'inscription a échoué. Veuillez réessayer." - }, - "es": { - "title": "Crea tu cuenta", - "alt": "Registro de Hutopy", - "name": "Nombre completo", - "email": "Correo electrónico", - "password": "Contraseña", - "confirmPassword": "Confirmar contraseña", - "passwordRequirements": "La contraseña debe tener al menos 8 caracteres", - "register": "Registrarse", - "alreadyHaveAccount": "¿Ya tienes una cuenta?", - "signIn": "Iniciar sesión", - "passwordsDoNotMatch": "Las contraseñas no coinciden", - "registrationFailed": "El registro falló. Por favor, inténtelo de nuevo." - } -} - diff --git a/frontend/src/views/ForgotPasswordView.vue b/frontend/src/views/auth/ForgotPasswordView.vue similarity index 100% rename from frontend/src/views/ForgotPasswordView.vue rename to frontend/src/views/auth/ForgotPasswordView.vue diff --git a/frontend/src/views/LoginView.vue b/frontend/src/views/auth/LoginView.vue similarity index 91% rename from frontend/src/views/LoginView.vue rename to frontend/src/views/auth/LoginView.vue index 3c28921..4561027 100644 --- a/frontend/src/views/LoginView.vue +++ b/frontend/src/views/auth/LoginView.vue @@ -44,6 +44,12 @@ + +
{{ t('noAccount') }} @@ -113,6 +119,10 @@ async function googleCallback(token) { function forgotPassword() { router.push('/forgot-password'); } + +function resendVerification() { + router.push('/verify-email'); +} + + +{ + "en": { + "title": "Create your account", + "alt": "Hutopy Registration", + "name": "Full Name", + "email": "Email", + "password": "Password", + "confirmPassword": "Confirm Password", + "passwordRequirements": "Password must be at least 8 characters", + "register": "Register", + "alreadyHaveAccount": "Already have an account?", + "signIn": "Sign in", + "passwordsDoNotMatch": "Passwords do not match", + "registrationFailed": "Registration failed. Please try again.", + "success": { + "title": "Registration Successful!", + "message": "Please check your email to verify your account. We've sent a verification link to:", + "backToLogin": "Back to Login", + "resendVerification": "Didn't receive the email? Resend verification" + } + }, + "fr": { + "title": "Créer votre compte", + "alt": "Inscription Hutopy", + "name": "Nom complet", + "email": "Email", + "password": "Mot de passe", + "confirmPassword": "Confirmer le mot de passe", + "passwordRequirements": "Le mot de passe doit comporter au moins 8 caractères", + "register": "S'inscrire", + "alreadyHaveAccount": "Vous avez déjà un compte?", + "signIn": "Se connecter", + "passwordsDoNotMatch": "Les mots de passe ne correspondent pas", + "registrationFailed": "L'inscription a échoué. Veuillez réessayer.", + "success": { + "title": "Inscription réussie!", + "message": "Veuillez vérifier votre email pour activer votre compte. Nous avons envoyé un lien de vérification à:", + "backToLogin": "Retour à la connexion", + "resendVerification": "Vous n'avez pas reçu l'email? Renvoyer la vérification" + } + }, + "es": { + "title": "Crea tu cuenta", + "alt": "Registro de Hutopy", + "name": "Nombre completo", + "email": "Correo electrónico", + "password": "Contraseña", + "confirmPassword": "Confirmar contraseña", + "passwordRequirements": "La contraseña debe tener al menos 8 caracteres", + "register": "Registrarse", + "alreadyHaveAccount": "¿Ya tienes una cuenta?", + "signIn": "Iniciar sesión", + "passwordsDoNotMatch": "Las contraseñas no coinciden", + "registrationFailed": "El registro falló. Por favor, inténtelo de nuevo.", + "success": { + "title": "¡Registro exitoso!", + "message": "Por favor revisa tu correo electrónico para verificar tu cuenta. Hemos enviado un enlace de verificación a:", + "backToLogin": "Volver al inicio de sesión", + "resendVerification": "¿No recibiste el correo? Reenviar verificación" + } + } +} + diff --git a/frontend/src/views/ResetPasswordView.vue b/frontend/src/views/auth/ResetPasswordView.vue similarity index 100% rename from frontend/src/views/ResetPasswordView.vue rename to frontend/src/views/auth/ResetPasswordView.vue diff --git a/frontend/src/views/auth/VerifyEmailView.vue b/frontend/src/views/auth/VerifyEmailView.vue new file mode 100644 index 0000000..e7a0ce3 --- /dev/null +++ b/frontend/src/views/auth/VerifyEmailView.vue @@ -0,0 +1,219 @@ + + + + + +{ + "en": { + "verifying": "Verifying your email...", + "success": { + "title": "Email Verified Successfully!", + "message": "Your email has been verified. You can now log in to your account.", + "goToLogin": "Go to Login" + }, + "error": { + "title": "Verification Failed", + "defaultMessage": "We couldn't verify your email. The link may be invalid or expired.", + "missingParams": "Missing required verification parameters.", + "goToLogin": "Go to Login" + }, + "resend": { + "title": "Resend Verification Email", + "emailLabel": "Email", + "button": "Resend Verification Email", + "success": "Verification email sent successfully. Please check your inbox.", + "error": "Failed to send verification email. Please try again.", + "invalidEmail": "Please enter a valid email address." + } + }, + "fr": { + "verifying": "Vérification de votre email...", + "success": { + "title": "Email vérifié avec succès !", + "message": "Votre email a été vérifié. Vous pouvez maintenant vous connecter à votre compte.", + "goToLogin": "Aller à la connexion" + }, + "error": { + "title": "Échec de la vérification", + "defaultMessage": "Nous n'avons pas pu vérifier votre email. Le lien peut être invalide ou expiré.", + "missingParams": "Paramètres de vérification requis manquants.", + "goToLogin": "Aller à la connexion" + }, + "resend": { + "title": "Renvoyer l'email de vérification", + "emailLabel": "Email", + "button": "Renvoyer l'email de vérification", + "success": "Email de vérification envoyé avec succès. Veuillez vérifier votre boîte de réception.", + "error": "Échec de l'envoi de l'email de vérification. Veuillez réessayer.", + "invalidEmail": "Veuillez entrer une adresse email valide." + } + }, + "es": { + "verifying": "Verificando tu correo electrónico...", + "success": { + "title": "¡Correo electrónico verificado con éxito!", + "message": "Tu correo electrónico ha sido verificado. Ahora puedes iniciar sesión en tu cuenta.", + "goToLogin": "Ir al inicio de sesión" + }, + "error": { + "title": "Falló la verificación", + "defaultMessage": "No pudimos verificar tu correo electrónico. El enlace puede ser inválido o estar caducado.", + "missingParams": "Faltan parámetros de verificación requeridos.", + "goToLogin": "Ir al inicio de sesión" + }, + "resend": { + "title": "Reenviar correo de verificación", + "emailLabel": "Correo electrónico", + "button": "Reenviar correo de verificación", + "success": "Correo de verificación enviado con éxito. Por favor revisa tu bandeja de entrada.", + "error": "Error al enviar el correo de verificación. Por favor, inténtelo de nuevo.", + "invalidEmail": "Por favor, introduce una dirección de correo electrónico válida." + } + } +} + diff --git a/frontend/src/views/creators/AboutCreator.vue b/frontend/src/views/creators/AboutCreator.vue index 869c963..a22678f 100644 --- a/frontend/src/views/creators/AboutCreator.vue +++ b/frontend/src/views/creators/AboutCreator.vue @@ -1,600 +1,716 @@ { - "en": { - "edit": "Edit", - "save": "Save", - "cancel": "Cancel", - "creator": { - "sections": { - "about": { - "title": "About", - "description": "Description", - "contactInfo": "Contact Information", - "characters": "characters", - "formattingHint": "Tip: Use line breaks and emojis to make your description more engaging!" - }, - "photos": { - "title": "Photos", - "image": "Image" + "en": { + "edit": "Edit", + "save": "Save", + "cancel": "Cancel", + "creator": { + "sections": { + "about": { + "title": "About", + "description": "Description", + "contactInfo": "Contact Information", + "characters": "characters", + "formattingHint": "Tip: Use line breaks and emojis to make your description more engaging!" + }, + "photos": { + "title": "Photos", + "image": "Image" + } + }, + "fields": { + "videoUrl": "Video URL", + "phoneNumber": "Phone Number", + "email": "Email" + }, + "validation": { + "invalidYoutubeUrl": "Please enter a valid YouTube URL or video ID", + "descriptionTooLong": "Description cannot exceed 2000 characters", + "descriptionRequired": "Description is required" + } } - }, - "fields": { - "videoUrl": "Video URL", - "phoneNumber": "Phone Number", - "email": "Email" - }, - "validation": { - "invalidYoutubeUrl": "Please enter a valid YouTube URL or video ID", - "descriptionTooLong": "Description cannot exceed 2000 characters", - "descriptionRequired": "Description is required" - } - } - }, - "fr": { - "edit": "Modifier", - "save": "Enregistrer", - "cancel": "Annuler", - "creator": { - "sections": { - "about": { - "title": "À propos", - "description": "Description", - "contactInfo": "Informations de contact", - "characters": "caractères", - "formattingHint": "Astuce : Utilisez des sauts de ligne et des émojis pour rendre votre description plus attrayante !" - }, - "photos": { - "title": "Photos", - "image": "Image" + }, + "fr": { + "edit": "Modifier", + "save": "Enregistrer", + "cancel": "Annuler", + "creator": { + "sections": { + "about": { + "title": "À propos", + "description": "Description", + "contactInfo": "Informations de contact", + "characters": "caractères", + "formattingHint": "Astuce : Utilisez des sauts de ligne et des émojis pour rendre votre description plus attrayante !" + }, + "photos": { + "title": "Photos", + "image": "Image" + } + }, + "fields": { + "videoUrl": "URL de la vidéo", + "phoneNumber": "Numéro de téléphone", + "email": "Email" + }, + "validation": { + "invalidYoutubeUrl": "Veuillez entrer une URL YouTube ou un ID de vidéo valide", + "descriptionTooLong": "La description ne peut pas dépasser 2000 caractères", + "descriptionRequired": "La description est obligatoire" + } } - }, - "fields": { - "videoUrl": "URL de la vidéo", - "phoneNumber": "Numéro de téléphone", - "email": "Email" - }, - "validation": { - "invalidYoutubeUrl": "Veuillez entrer une URL YouTube ou un ID de vidéo valide", - "descriptionTooLong": "La description ne peut pas dépasser 2000 caractères", - "descriptionRequired": "La description est obligatoire" - } - } - }, - "es": { - "edit": "Editar", - "save": "Guardar", - "cancel": "Cancelar", - "creator": { - "sections": { - "about": { - "title": "Acerca de", - "description": "Descripción", - "contactInfo": "Información de contacto", - "characters": "caracteres", - "formattingHint": "Consejo: ¡Usa saltos de línea y emojis para hacer tu descripción más atractiva!" - }, - "photos": { - "title": "Fotos", - "image": "Imagen" + }, + "es": { + "edit": "Editar", + "save": "Guardar", + "cancel": "Cancelar", + "creator": { + "sections": { + "about": { + "title": "Acerca de", + "description": "Descripción", + "contactInfo": "Información de contacto", + "characters": "caracteres", + "formattingHint": "Consejo: ¡Usa saltos de línea y emojis para hacer tu descripción más atractiva!" + }, + "photos": { + "title": "Fotos", + "image": "Imagen" + } + }, + "fields": { + "videoUrl": "URL del video", + "phoneNumber": "Número de teléfono", + "email": "Correo electrónico" + }, + "validation": { + "invalidYoutubeUrl": "Por favor, introduce una URL de YouTube o un ID de video válido", + "descriptionTooLong": "La descripción no puede exceder los 2000 caracteres", + "descriptionRequired": "La descripción es obligatoria" + } } - }, - "fields": { - "videoUrl": "URL del video", - "phoneNumber": "Número de teléfono", - "email": "Correo electrónico" - }, - "validation": { - "invalidYoutubeUrl": "Por favor, introduce una URL de YouTube o un ID de video válido", - "descriptionTooLong": "La descripción no puede exceder los 2000 caracteres", - "descriptionRequired": "La descripción es obligatoria" - } } - } } diff --git a/frontend/src/views/PaymentCompleted.vue b/frontend/src/views/creators/PaymentCompleted.vue similarity index 100% rename from frontend/src/views/PaymentCompleted.vue rename to frontend/src/views/creators/PaymentCompleted.vue diff --git a/frontend/src/views/PaymentFailed.vue b/frontend/src/views/creators/PaymentFailed.vue similarity index 100% rename from frontend/src/views/PaymentFailed.vue rename to frontend/src/views/creators/PaymentFailed.vue diff --git a/frontend/src/views/main/SideBar.vue b/frontend/src/views/main/SideBar.vue index e30ba9a..13c8ce0 100644 --- a/frontend/src/views/main/SideBar.vue +++ b/frontend/src/views/main/SideBar.vue @@ -1,191 +1,206 @@  { - "en": { - "sidebar": { - "myPage": "My Page", - "myProfile": "My Profile", - "signIn": "Sign In", - "signOut": "Sign Out" + "en": { + "sidebar": { + "myPage": "My Page", + "myProfile": "My Profile", + "signIn": "Sign In", + "signOut": "Sign Out" + } + }, + "fr": { + "sidebar": { + "myPage": "Ma Page", + "myProfile": "Mon Profil", + "signIn": "Se Connecter", + "signOut": "Se Déconnecter" + } + }, + "es": { + "sidebar": { + "myPage": "Mi Página", + "myProfile": "Mi Perfil", + "signIn": "Iniciar Sesión", + "signOut": "Cerrar Sesión" + } } - }, - "fr": { - "sidebar": { - "myPage": "Ma Page", - "myProfile": "Mon Profil", - "signIn": "Se Connecter", - "signOut": "Se Déconnecter" - } - }, - "es": { - "sidebar": { - "myPage": "Mi Página", - "myProfile": "Mi Perfil", - "signIn": "Iniciar Sesión", - "signOut": "Cerrar Sesión" - } - } }