refactor(auth): cleanup auth module and streamline the registration flow

This commit is contained in:
2025-06-18 16:50:11 -04:00
parent 25b94d3e02
commit cdcfe8d7e2
24 changed files with 2140 additions and 1387 deletions

View File

@@ -35,7 +35,8 @@ public static class DependencyInjection
// Scoped services // Scoped services
builder.Services.AddScoped<IdentityService>(); builder.Services.AddScoped<IdentityService>();
builder.Services.AddTransient<IUserLookup, UserLookup>(); builder.Services.AddScoped<EmailVerificationService>();
builder.Services.AddScoped<IUserLookup, UserLookup>();
return builder; return builder;
} }
@@ -44,12 +45,12 @@ public static class DependencyInjection
this IApplicationBuilder app, this IApplicationBuilder app,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
var scopeFactory = app.ApplicationServices.GetRequiredService<IServiceScopeFactory>(); IServiceScopeFactory scopeFactory = app.ApplicationServices.GetRequiredService<IServiceScopeFactory>();
using var scope = scopeFactory.CreateScope(); using IServiceScope scope = scopeFactory.CreateScope();
await using var context = scope.ServiceProvider.GetRequiredService<IdentityDbContext>(); await using IdentityDbContext context = scope.ServiceProvider.GetRequiredService<IdentityDbContext>();
await context.Database.MigrateAsync(cancellationToken: cancellationToken); await context.Database.MigrateAsync(cancellationToken);
var roleManager = scope.ServiceProvider.GetRequiredService<RoleManager<Role>>(); RoleManager<Role> roleManager = scope.ServiceProvider.GetRequiredService<RoleManager<Role>>();
await TrySeedAsync(roleManager); await TrySeedAsync(roleManager);
return app; return app;
@@ -57,13 +58,13 @@ public static class DependencyInjection
private static async Task TrySeedAsync(RoleManager<Role> roleManager) private static async Task TrySeedAsync(RoleManager<Role> roleManager)
{ {
var administratorRole = new Role(KnownRoles.Administrator); Role administratorRole = new(KnownRoles.Administrator);
if (roleManager.Roles.All(r => r.Name != administratorRole.Name)) if (roleManager.Roles.All(r => r.Name != administratorRole.Name))
{ {
await roleManager.CreateAsync(administratorRole); await roleManager.CreateAsync(administratorRole);
} }
var roleCreator = new Role(KnownRoles.Creator); Role roleCreator = new(KnownRoles.Creator);
if (roleManager.Roles.All(r => r.Name != roleCreator.Name)) if (roleManager.Roles.All(r => r.Name != roleCreator.Name))
{ {
await roleManager.CreateAsync(roleCreator); await roleManager.CreateAsync(roleCreator);

View File

@@ -1,4 +1,3 @@
using System.Text;
using System.Web; using System.Web;
using Hutopy.Infrastructure.Configuration; using Hutopy.Infrastructure.Configuration;
using Hutopy.Infrastructure.Emailer.Contracts; using Hutopy.Infrastructure.Emailer.Contracts;
@@ -30,7 +29,7 @@ public class ForgotPasswordHandler(
CancellationToken ct) CancellationToken ct)
{ {
// Find user by email // 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 // Always return OK even if user not found to prevent email enumeration
if (user is null) if (user is null)
@@ -40,22 +39,50 @@ public class ForgotPasswordHandler(
} }
// Generate password reset token // 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 // 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 // Build reset link
var resetLink = $"{options.Value.FrontendBaseUrl}/reset-password?email={HttpUtility.UrlEncode(request.Email)}&token={encodedToken}"; string resetLink =
$"{options.Value.FrontendBaseUrl}/reset-password?email={HttpUtility.UrlEncode(request.Email)}&token={encodedToken}";
// TODO: Write a better email template // Create a styled email message
var subject = "Reset Your Password"; string subject = "Reset your Hutopy password";
var message = new StringBuilder() string message = $"""
.AppendLine("<h1>Reset Your Password</h1>") <div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;">
.AppendLine("<p>Please click the link below to reset your password:</p>") <h1 style="color: #2c3e50; margin-bottom: 20px;">Reset Your Hutopy Password</h1>
.AppendLine($"<p><a href=\"{resetLink}\">Reset Password</a></p>")
.AppendLine("<p>If you did not request a password reset, please ignore this email.</p>") <p style="font-size: 16px; line-height: 1.5; margin-bottom: 25px;">
.ToString(); Please click the button below to reset your password:
</p>
<div style="text-align: center; margin: 30px 0;">
<a href='{resetLink}'
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);">
Reset Password
</a>
</div>
<p style="font-size: 14px; color: #7f8c8d; margin-top: 30px;">
If you did not request a password reset, 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='{resetLink}' style="color: #3498db; word-break: break-all;">{resetLink}</a>
</p>
</div>
""";
// Send email // Send email
await emailSender.SendEmailAsync(request.Email, subject, message); await emailSender.SendEmailAsync(request.Email, subject, message);

View File

@@ -32,8 +32,8 @@ public class LoginHandler(
LoginRequest request, LoginRequest request,
CancellationToken ct) CancellationToken ct)
{ {
// Find user by email // Find the user by email
var user = await userManager.FindByEmailAsync(request.Email); User? user = await userManager.FindByEmailAsync(request.Email);
if (user is null) if (user is null)
{ {
await SendStringAsync( await SendStringAsync(
@@ -44,7 +44,7 @@ public class LoginHandler(
} }
// Verify password // Verify password
var isPasswordValid = await userManager.CheckPasswordAsync(user, request.Password); bool isPasswordValid = await userManager.CheckPasswordAsync(user, request.Password);
if (!isPasswordValid) if (!isPasswordValid)
{ {
await SendStringAsync( await SendStringAsync(
@@ -54,26 +54,36 @@ public class LoginHandler(
return; 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.RefreshToken = RefreshTokenGenerator.Next();
user.RefreshTokenExpiryTime = DateTime.UtcNow.Add(jwtOptions.Value.RefreshTokenLifetime); user.RefreshTokenExpiryTime = DateTime.UtcNow.Add(jwtOptions.Value.RefreshTokenLifetime);
await userManager.UpdateAsync(user); await userManager.UpdateAsync(user);
// Generate JWT token // Generate JWT token
var accessToken = JwtTokenHelper.GenerateJwtToken( string accessToken = JwtTokenHelper.GenerateJwtToken(
expiresIn: jwtOptions.Value.Lifetime, jwtOptions.Value.Lifetime,
issuer: jwtOptions.Value.Issuer, jwtOptions.Value.Issuer,
audience: jwtOptions.Value.Audience, jwtOptions.Value.Audience,
key: jwtOptions.Value.Key, jwtOptions.Value.Key,
userId: user.Id.ToString(), user.Id.ToString(),
email: user.Email ?? string.Empty, user.Email ?? string.Empty,
alias: user.Alias, user.Alias,
firstname: user.Firstname ?? string.Empty, user.Firstname ?? string.Empty,
lastname: user.Lastname ?? string.Empty, user.Lastname ?? string.Empty,
portraitUrl: user.PortraitUrl); user.PortraitUrl);
await SendOkAsync( await SendOkAsync(
new LoginResponse(accessToken, user.RefreshToken), new LoginResponse(accessToken, user.RefreshToken),
cancellation: ct); ct);
} }
} }

View File

@@ -3,6 +3,7 @@ using System.Text.Json.Serialization;
using Hutopy.Infrastructure.Security; using Hutopy.Infrastructure.Security;
using Hutopy.Modules.Identity.Configuration; using Hutopy.Modules.Identity.Configuration;
using Hutopy.Modules.Identity.Data; using Hutopy.Modules.Identity.Data;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
namespace Hutopy.Modules.Identity.Handlers; namespace Hutopy.Modules.Identity.Handlers;
@@ -56,8 +57,8 @@ public class LoginWithFacebookHandler(
CancellationToken ct) CancellationToken ct)
{ {
// Verify the token with Facebook // Verify the token with Facebook
using var httpClient = httpClientFactory.CreateClient(); using HttpClient httpClient = httpClientFactory.CreateClient();
using var response = await httpClient.GetAsync( using HttpResponseMessage response = await httpClient.GetAsync(
$"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)",
ct); ct);
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
@@ -70,8 +71,8 @@ public class LoginWithFacebookHandler(
} }
// Extract the user info (email, name, profile picture) // Extract the user info (email, name, profile picture)
var content = await response.Content.ReadAsStringAsync(ct); string content = await response.Content.ReadAsStringAsync(ct);
var userInfo = JsonSerializer.Deserialize<FacebookUserInfo>(content); FacebookUserInfo? userInfo = JsonSerializer.Deserialize<FacebookUserInfo>(content);
if (userInfo is null || string.IsNullOrEmpty(userInfo.Id)) if (userInfo is null || string.IsNullOrEmpty(userInfo.Id))
{ {
await SendStringAsync( await SendStringAsync(
@@ -82,23 +83,24 @@ public class LoginWithFacebookHandler(
} }
// Check if user exists or create a new one // 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) if (user is null)
{ {
var generatedPassword = PasswordGenerator.Next(); string generatedPassword = PasswordGenerator.Next();
var generatedUser = new User User generatedUser = new()
{ {
UserName = userInfo.Email ?? $"fb_{userInfo.Id}", UserName = userInfo.Email ?? $"fb_{userInfo.Id}",
Email = userInfo.Email, Email = userInfo.Email,
EmailConfirmed = true,
Firstname = userInfo.Name.Split(' ').FirstOrDefault() ?? "", Firstname = userInfo.Name.Split(' ').FirstOrDefault() ?? "",
Lastname = userInfo.Name.Split(' ').Skip(1).FirstOrDefault() ?? "", Lastname = userInfo.Name.Split(' ').Skip(1).FirstOrDefault() ?? "",
Alias = userInfo.Name, Alias = userInfo.Name,
PortraitUrl = userInfo.Picture.Picture.Url, 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, generatedUser,
generatedPassword); generatedPassword);
@@ -115,27 +117,27 @@ public class LoginWithFacebookHandler(
} }
// Generate refresh token // Generate refresh token
var refreshToken = RefreshTokenGenerator.Next(); string refreshToken = RefreshTokenGenerator.Next();
// Store refresh token in user's properties // Store refresh token in user's properties
user.RefreshToken = refreshToken; user.RefreshToken = refreshToken;
user.RefreshTokenExpiryTime = DateTime.UtcNow.Add(jwtOptions.Value.RefreshTokenLifetime); user.RefreshTokenExpiryTime = DateTime.UtcNow.Add(jwtOptions.Value.RefreshTokenLifetime);
await userManager.UpdateAsync(user); await userManager.UpdateAsync(user);
var accessToken = JwtTokenHelper.GenerateJwtToken( string accessToken = JwtTokenHelper.GenerateJwtToken(
expiresIn: jwtOptions.Value.Lifetime, jwtOptions.Value.Lifetime,
issuer: jwtOptions.Value.Issuer, jwtOptions.Value.Issuer,
audience: jwtOptions.Value.Audience, jwtOptions.Value.Audience,
key: jwtOptions.Value.Key, jwtOptions.Value.Key,
userId: user.Id.ToString(), user.Id.ToString(),
email: user.Email ?? string.Empty, user.Email ?? string.Empty,
alias: user.Alias, user.Alias,
firstname: user.Firstname ?? string.Empty, user.Firstname ?? string.Empty,
lastname: user.Lastname ?? string.Empty, user.Lastname ?? string.Empty,
portraitUrl: user.PortraitUrl); user.PortraitUrl);
await SendOkAsync( await SendOkAsync(
new LoginWithFacebookResponse(accessToken, refreshToken), new LoginWithFacebookResponse(accessToken, refreshToken),
cancellation: ct); ct);
} }
} }

View File

@@ -3,11 +3,12 @@ using System.Text.Json.Serialization;
using Hutopy.Infrastructure.Security; using Hutopy.Infrastructure.Security;
using Hutopy.Modules.Identity.Configuration; using Hutopy.Modules.Identity.Configuration;
using Hutopy.Modules.Identity.Data; using Hutopy.Modules.Identity.Data;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
namespace Hutopy.Modules.Identity.Handlers; namespace Hutopy.Modules.Identity.Handlers;
class GoogleToken internal class GoogleToken
{ {
[JsonPropertyName("access_token")] public required string AccessToken { get; init; } [JsonPropertyName("access_token")] public required string AccessToken { get; init; }
[JsonPropertyName("token_type")] public required string TokenType { get; init; } [JsonPropertyName("token_type")] public required string TokenType { get; init; }
@@ -55,11 +56,11 @@ public class LoginWithGoogleHandler(
LoginWithGoogleRequest request, LoginWithGoogleRequest request,
CancellationToken ct) CancellationToken ct)
{ {
var googleToken = JsonSerializer.Deserialize<GoogleToken>(request.Token)!; GoogleToken googleToken = JsonSerializer.Deserialize<GoogleToken>(request.Token)!;
// Verify the token with Google // Verify the token with Google
using var httpClient = httpClientFactory.CreateClient(); using HttpClient httpClient = httpClientFactory.CreateClient();
using var response = await httpClient.GetAsync( using HttpResponseMessage response = await httpClient.GetAsync(
$"https://www.googleapis.com/oauth2/v1/userinfo?access_token={googleToken.AccessToken}", $"https://www.googleapis.com/oauth2/v1/userinfo?access_token={googleToken.AccessToken}",
ct); ct);
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
@@ -72,8 +73,8 @@ public class LoginWithGoogleHandler(
} }
// Extract the user info (email, name, etc.). // Extract the user info (email, name, etc.).
var content = await response.Content.ReadAsStringAsync(ct); string content = await response.Content.ReadAsStringAsync(ct);
var userInfo = JsonSerializer.Deserialize<GoogleUserInfo>(content); GoogleUserInfo? userInfo = JsonSerializer.Deserialize<GoogleUserInfo>(content);
if (userInfo is null if (userInfo is null
|| !userInfo.VerifiedEmail || !userInfo.VerifiedEmail
|| string.IsNullOrEmpty(userInfo.Email)) || string.IsNullOrEmpty(userInfo.Email))
@@ -85,17 +86,18 @@ public class LoginWithGoogleHandler(
return; return;
} }
// Check if user exists or create a new one // Check if the 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) if (user is null)
{ {
var generatedPassword = PasswordGenerator.Next(); string generatedPassword = PasswordGenerator.Next();
var refreshToken = RefreshTokenGenerator.Next(); string refreshToken = RefreshTokenGenerator.Next();
var generatedUser = new User User generatedUser = new()
{ {
UserName = userInfo.Email, UserName = userInfo.Email,
Email = userInfo.Email, Email = userInfo.Email,
EmailConfirmed = true,
Firstname = userInfo.GivenName, Firstname = userInfo.GivenName,
Lastname = userInfo.FamilyName, Lastname = userInfo.FamilyName,
Alias = userInfo.Name, Alias = userInfo.Name,
@@ -105,7 +107,7 @@ public class LoginWithGoogleHandler(
RefreshTokenExpiryTime = DateTime.UtcNow.Add(jwtOptions.Value.RefreshTokenLifetime) RefreshTokenExpiryTime = DateTime.UtcNow.Add(jwtOptions.Value.RefreshTokenLifetime)
}; };
var result = await userManager.CreateAsync( IdentityResult result = await userManager.CreateAsync(
generatedUser, generatedUser,
generatedPassword); generatedPassword);
@@ -121,25 +123,25 @@ public class LoginWithGoogleHandler(
user = generatedUser; user = generatedUser;
} }
// Generate new refresh token // Generate the new refresh token
user.RefreshToken = RefreshTokenGenerator.Next(); user.RefreshToken = RefreshTokenGenerator.Next();
user.RefreshTokenExpiryTime = DateTime.UtcNow.Add(jwtOptions.Value.RefreshTokenLifetime); user.RefreshTokenExpiryTime = DateTime.UtcNow.Add(jwtOptions.Value.RefreshTokenLifetime);
await userManager.UpdateAsync(user); await userManager.UpdateAsync(user);
var accessToken = JwtTokenHelper.GenerateJwtToken( string accessToken = JwtTokenHelper.GenerateJwtToken(
expiresIn: jwtOptions.Value.Lifetime, jwtOptions.Value.Lifetime,
issuer: jwtOptions.Value.Issuer, jwtOptions.Value.Issuer,
audience: jwtOptions.Value.Audience, jwtOptions.Value.Audience,
key: jwtOptions.Value.Key, jwtOptions.Value.Key,
userId: user.Id.ToString(), user.Id.ToString(),
email: user.Email ?? string.Empty, user.Email ?? string.Empty,
alias: user.Alias, user.Alias,
firstname: user.Firstname ?? string.Empty, user.Firstname ?? string.Empty,
lastname: user.Lastname ?? string.Empty, user.Lastname ?? string.Empty,
portraitUrl: user.PortraitUrl); user.PortraitUrl);
await SendOkAsync( await SendOkAsync(
new LoginWithGoogleResponse(accessToken, user.RefreshToken), new LoginWithGoogleResponse(accessToken, user.RefreshToken),
cancellation: ct); ct);
} }
} }

View File

@@ -1,7 +1,6 @@
using Hutopy.Infrastructure.Security;
using Hutopy.Modules.Identity.Configuration;
using Hutopy.Modules.Identity.Data; using Hutopy.Modules.Identity.Data;
using Microsoft.Extensions.Options; using Hutopy.Modules.Identity.Services;
using Microsoft.AspNetCore.Identity;
namespace Hutopy.Modules.Identity.Handlers; namespace Hutopy.Modules.Identity.Handlers;
@@ -13,13 +12,12 @@ public record RegisterRequest(
[PublicAPI] [PublicAPI]
public record RegisterResponse( public record RegisterResponse(
string AccessToken, string Message);
string RefreshToken);
[PublicAPI] [PublicAPI]
public class RegisterHandler( public class RegisterHandler(
UserManager userManager, UserManager userManager,
IOptionsSnapshot<JwtOptions> jwtOptions) EmailVerificationService emailVerificationService)
: Endpoint<RegisterRequest, RegisterResponse> : Endpoint<RegisterRequest, RegisterResponse>
{ {
public override void Configure() public override void Configure()
@@ -34,7 +32,7 @@ public class RegisterHandler(
CancellationToken ct) CancellationToken ct)
{ {
// Check if the user already exists // 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) if (existingUser is not null)
{ {
await SendStringAsync( await SendStringAsync(
@@ -44,27 +42,22 @@ public class RegisterHandler(
return; return;
} }
// Create a refresh token
var refreshToken = RefreshTokenGenerator.Next();
// Split the name into firstname and lastname (if provided) // Split the name into firstname and lastname (if provided)
var nameParts = request.Name.Split(' ', 2); string[] nameParts = request.Name.Split(' ', 2);
var firstname = nameParts[0]; string firstname = nameParts[0];
var lastname = nameParts.Length > 1 ? nameParts[1] : string.Empty; string lastname = nameParts.Length > 1 ? nameParts[1] : string.Empty;
// Create a new user // Create a new user
var user = new User User user = new()
{ {
UserName = request.Email, UserName = request.Email,
Email = request.Email, Email = request.Email,
Firstname = firstname, Firstname = firstname,
Lastname = lastname, Lastname = lastname,
Alias = request.Name, Alias = request.Name
RefreshToken = refreshToken,
RefreshTokenExpiryTime = DateTime.UtcNow.Add(jwtOptions.Value.RefreshTokenLifetime)
}; };
var result = await userManager.CreateAsync( IdentityResult result = await userManager.CreateAsync(
user, user,
request.Password); request.Password);
@@ -77,21 +70,10 @@ public class RegisterHandler(
return; return;
} }
// Generate JWT token await emailVerificationService.SendVerificationEmailAsync(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);
await SendOkAsync( await SendOkAsync(
new RegisterResponse(accessToken, user.RefreshToken), new RegisterResponse("Registration successful! Please check your email to verify your account."),
cancellation: ct); ct);
} }
} }

View File

@@ -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<ResendVerificationRequest, ResendVerificationResponse>
{
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);
}
}

View File

@@ -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<VerifyEmailRequest, VerifyEmailResponse>
{
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);
}
}

View File

@@ -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<WebsiteOptions> 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",
$"""
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;">
<h1 style="color: #2c3e50; margin-bottom: 20px;">Welcome to Hutopy!</h1>
<p style="font-size: 16px; line-height: 1.5; margin-bottom: 25px;">
Please verify your email address by clicking the button below:
</p>
<div style="text-align: center; margin: 30px 0;">
<a href='{verificationLink}'
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);">
Verify Email Address
</a>
</div>
<p style="font-size: 14px; color: #7f8c8d; margin-top: 30px;">
If you did not request this, 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='{verificationLink}' style="color: #3498db; word-break: break-all;">{verificationLink}</a>
</p>
</div>
""");
}
}

View File

@@ -3,6 +3,7 @@ using Hutopy.Infrastructure.Payments.Stripe.Configuration;
using Hutopy.Modules.Memberships.Contracts; using Hutopy.Modules.Memberships.Contracts;
using Hutopy.Modules.Tipping.Contracts; using Hutopy.Modules.Tipping.Contracts;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Microsoft.Extensions.Primitives;
using Stripe; using Stripe;
using Stripe.Checkout; using Stripe.Checkout;
@@ -23,14 +24,14 @@ public class StripeWebhookEndpoint(
public override async Task HandleAsync(CancellationToken ct) public override async Task HandleAsync(CancellationToken ct)
{ {
using var streamReader = new StreamReader(HttpContext.Request.Body); using StreamReader streamReader = new(HttpContext.Request.Body);
var json = await streamReader.ReadToEndAsync(ct); string json = await streamReader.ReadToEndAsync(ct);
var signatureHeader = HttpContext.Request.Headers["Stripe-Signature"]; StringValues signatureHeader = HttpContext.Request.Headers["Stripe-Signature"];
var stripeEvent = EventUtility.ConstructEvent(json, signatureHeader, options.Value.WebhookSecret); Event? stripeEvent = EventUtility.ConstructEvent(json, signatureHeader, options.Value.WebhookSecret);
var stripeSession = stripeEvent.Data.Object as Session; Session? stripeSession = stripeEvent.Data.Object as Session;
var stripeSubscription = stripeEvent.Data.Object as Subscription; Subscription? stripeSubscription = stripeEvent.Data.Object as Subscription;
switch (stripeEvent.Type) switch (stripeEvent.Type)
{ {
@@ -41,11 +42,23 @@ public class StripeWebhookEndpoint(
// Check if this is a one-time tip // Check if this is a one-time tip
case "payment" when stripeSession.PaymentIntentId != null case "payment" when stripeSession.PaymentIntentId != null
&& stripeSession.PaymentIntent.Status == "paid": && 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( await tipPaymentNotifier.NotifyPaymentSucceedAsync(
stripeSession.Id, stripeSession.Id,
stripeSession.Invoice.HostedInvoiceUrl, receiptUrl,
customerEmail,
ct); ct);
break; break;
// Check if this is a subscription // Check if this is a subscription
case "subscription" when stripeSession.SubscriptionId != null: case "subscription" when stripeSession.SubscriptionId != null:
await membershipNotifier.NotifyPaymentSucceedAsync( await membershipNotifier.NotifyPaymentSucceedAsync(
@@ -53,13 +66,13 @@ public class StripeWebhookEndpoint(
stripeSession.Invoice.HostedInvoiceUrl, stripeSession.Invoice.HostedInvoiceUrl,
stripeSession.Invoice.Total, stripeSession.Invoice.Total,
stripeSession.Invoice.Currency, stripeSession.Invoice.Currency,
cancellationToken: ct); ct);
break; break;
} }
break; break;
case "invoice.payment_succeeded": 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 != null);
Debug.Assert(invoice.Subscription != null); Debug.Assert(invoice.Subscription != null);
await membershipNotifier.NotifyPaymentSucceedAsync( await membershipNotifier.NotifyPaymentSucceedAsync(
@@ -67,7 +80,7 @@ public class StripeWebhookEndpoint(
invoice.HostedInvoiceUrl, invoice.HostedInvoiceUrl,
invoice.Total, invoice.Total,
invoice.Currency, invoice.Currency,
cancellationToken: ct); ct);
break; break;
case "customer.subscription.updated": case "customer.subscription.updated":

View File

@@ -2,5 +2,9 @@ namespace Hutopy.Modules.Tipping.Contracts;
public interface ITipPaymentNotifier public interface ITipPaymentNotifier
{ {
Task NotifyPaymentSucceedAsync(string stripeId, string invoiceUrl, CancellationToken ct); Task NotifyPaymentSucceedAsync(
string stripeId,
string invoiceUrl,
string customerEmail,
CancellationToken ct);
} }

View File

@@ -1,3 +1,5 @@
using Hutopy.Infrastructure.Emailer.Contracts;
using Hutopy.Modules.Creators.Contracts;
using Hutopy.Modules.Tipping.Contracts; using Hutopy.Modules.Tipping.Contracts;
using Hutopy.Modules.Tipping.Data; using Hutopy.Modules.Tipping.Data;
@@ -5,27 +7,104 @@ namespace Hutopy.Modules.Tipping.Services;
public class TipPaymentNotifier( public class TipPaymentNotifier(
TippingDbContext dbContext, TippingDbContext dbContext,
IEmailSender emailSender,
ICreatorLookup creatorLookup,
ILogger<TipPaymentNotifier> logger) ILogger<TipPaymentNotifier> logger)
: ITipPaymentNotifier : ITipPaymentNotifier
{ {
public async Task NotifyPaymentSucceedAsync( public async Task NotifyPaymentSucceedAsync(
string sessionId, string sessionId,
string invoiceUrl, string receiptUrl,
string customerEmail,
CancellationToken ct) CancellationToken ct)
{ {
var tip = await dbContext.Tips.SingleOrDefaultAsync( Tip? tip = await dbContext.Tips.SingleOrDefaultAsync(
t => t.StripeSessionId == sessionId, t => t.StripeSessionId == sessionId,
cancellationToken: ct); ct);
if (tip is not null) if (tip is not null)
{ {
tip.Status = TipStatus.Paid; tip.Status = TipStatus.Paid;
tip.StripeInvoiceUrl = invoiceUrl; tip.StripeInvoiceUrl = receiptUrl; // Store the receipt URL
await dbContext.SaveChangesAsync(ct); 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 else
{ {
logger.LogError("Tip with session ID {SessionId} not found", sessionId); 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 = $"""
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;">
<h1 style="color: #2c3e50; margin-bottom: 20px;">{creatorUsername} vous remercie !</h1>
<p style="font-size: 16px; line-height: 1.5; margin-bottom: 15px;">
Votre paiement de <strong>{amount} {currency}</strong> a é traité avec succès.
</p>
<div style="background-color: #f8f9fa; border-radius: 4px; padding: 20px; margin: 30px 0; border-left: 4px solid #3498db;">
<p style="font-size: 16px; margin: 0; line-height: 1.5;">
Ce reçu confirme votre soutien à <strong>{creatorUsername}</strong>. Merci de contribuer à son travail !
</p>
</div>
{(string.IsNullOrEmpty(receiptUrl) ? "" : $"""
<div style="text-align: center; margin: 30px 0;">
<a href='{receiptUrl}'
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);">
Voir le reçu
</a>
</div>
""")}
<p style="font-size: 14px; color: #7f8c8d; margin-top: 30px;">
Cet email sert de reçu pour votre transaction. Nous vous conseillons de le conserver pour vos archives.
</p>
<p style="font-size: 14px; color: #7f8c8d; margin-top: 20px; text-align: center; border-top: 1px solid #eee; padding-top: 20px;">
Merci d'utiliser Hutopy pour soutenir vos créateurs préférés !
</p>
</div>
""";
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
}
}
} }

View File

@@ -3,7 +3,8 @@ import { createRouter, createWebHistory } from 'vue-router';
import CreatorHome from '@/views/creators/CreatorHome.vue'; import CreatorHome from '@/views/creators/CreatorHome.vue';
import CreatorLayout from '@/views/creators/CreatorLayout.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 About = () => import('@/views/documentation/About.vue');
const ContentPolicy = () => import('@/views/documentation/ContentPolicy.vue'); const ContentPolicy = () => import('@/views/documentation/ContentPolicy.vue');
@@ -14,14 +15,15 @@ const HelpAndContact = () => import('@/views/documentation/HelpAndContact.vue');
const Pricing = () => import('@/views/documentation/Pricing.vue'); const Pricing = () => import('@/views/documentation/Pricing.vue');
const TermsAndConditions = () => import('@/views/documentation/TermsAndConditions.vue'); const TermsAndConditions = () => import('@/views/documentation/TermsAndConditions.vue');
const ProfilePage = () => import('@/views/profile/ProfilePage.vue'); const ProfilePage = () => import('@/views/profile/ProfilePage.vue');
const PaymentCompleted = () => import('@/views/PaymentCompleted.vue'); const PaymentCompleted = () => import('@/views/creators/PaymentCompleted.vue');
const PaymentFailed = () => import('@/views/PaymentFailed.vue'); const PaymentFailed = () => import('@/views/creators/PaymentFailed.vue');
const Landing = () => import('@/views/main/Landing.vue'); const Landing = () => import('@/views/main/Landing.vue');
const CreateCreator = () => import('@/views/creators/CreateCreator.vue'); const CreateCreator = () => import('@/views/creators/CreateCreator.vue');
const RegisterView = () => import('@/views/RegisterView.vue'); const RegisterView = () => import('@/views/auth/RegisterView.vue');
const ForgotPasswordView = () => import('@/views/ForgotPasswordView.vue'); const ForgotPasswordView = () => import('@/views/auth/ForgotPasswordView.vue');
const ResetPasswordView = () => import('@/views/ResetPasswordView.vue'); const ResetPasswordView = () => import('@/views/auth/ResetPasswordView.vue');
const VerifyEmailView = () => import('@/views/auth/VerifyEmailView.vue');
const routes = [ const routes = [
{ {
@@ -51,7 +53,7 @@ const routes = [
path: 'tip-cancelled', path: 'tip-cancelled',
name: 'PaymentFailed', name: 'PaymentFailed',
component: PaymentFailed, component: PaymentFailed,
} },
], ],
}, },
{ {
@@ -100,7 +102,7 @@ const routes = [
name: 'login', name: 'login',
component: LoginView, component: LoginView,
meta: { notAuthenticated: true }, meta: { notAuthenticated: true },
props: (route) => ({ returnUrl: route.query.returnUrl || '/landing' }) props: route => ({ returnUrl: route.query.returnUrl || '/landing' }),
}, },
{ {
path: '/profile', path: '/profile',
@@ -118,21 +120,27 @@ const routes = [
path: '/register', path: '/register',
name: 'register', name: 'register',
component: RegisterView, component: RegisterView,
meta: { requiresAuth: false } meta: { requiresAuth: false },
}, },
{ {
path: '/forgot-password', path: '/forgot-password',
name: 'forgot-password', name: 'forgot-password',
component: ForgotPasswordView, component: ForgotPasswordView,
meta: { notAuthenticated: true } meta: { notAuthenticated: true },
}, },
{ {
path: '/reset-password', path: '/reset-password',
name: 'reset-password', name: 'reset-password',
component: ResetPasswordView, component: ResetPasswordView,
meta: { notAuthenticated: true }, meta: { notAuthenticated: true },
props: (route) => ({ email: route.query.email, token: route.query.token }) props: route => ({ email: route.query.email, token: route.query.token }),
} },
{
path: '/verify-email',
name: 'verify-email',
component: VerifyEmailView,
meta: { notAuthenticated: true },
},
]; ];
const router = createRouter({ const router = createRouter({
@@ -144,16 +152,16 @@ const router = createRouter({
router.beforeEach((to, from, next) => { router.beforeEach((to, from, next) => {
const authStore = useAuthStore(); const authStore = useAuthStore();
if (to.matched.some((record) => record.meta.requiresAuth)) { if (to.matched.some(record => record.meta.requiresAuth)) {
if (!authStore.isAuthenticated) { if (!authStore.isAuthenticated) {
next({ next({
name: 'login', name: 'login',
query: { returnUrl: to.fullPath } query: { returnUrl: to.fullPath },
}); });
} else { } else {
next(); next();
} }
} else if (to.matched.some((record) => record.meta.notAuthenticated)) { } else if (to.matched.some(record => record.meta.notAuthenticated)) {
if (authStore.isAuthenticated) next({ name: 'landing' }); if (authStore.isAuthenticated) next({ name: 'landing' });
else next(); else next();
} else { } else {

View File

@@ -4,7 +4,7 @@ import { useRouter } from 'vue-router';
import { useClient } from '@/plugins/api.js'; import { useClient } from '@/plugins/api.js';
import { useSessionStorage } from '@vueuse/core'; import { useSessionStorage } from '@vueuse/core';
import { jwtDecode } from 'jwt-decode'; import { jwtDecode } from 'jwt-decode';
import { formatDuration } from "@/internal_time_ago.js"; import { formatDuration } from '@/internal_time_ago.js';
export const useAuthStore = defineStore('auth', () => { export const useAuthStore = defineStore('auth', () => {
const clientApi = useClient(); const clientApi = useClient();
@@ -17,9 +17,9 @@ export const useAuthStore = defineStore('auth', () => {
const refreshToken = useSessionStorage('auth-refreshToken', undefined); const refreshToken = useSessionStorage('auth-refreshToken', undefined);
const tokenClaims = useSessionStorage('auth-tokenClaims', null, { const tokenClaims = useSessionStorage('auth-tokenClaims', null, {
serializer: { serializer: {
read: (v) => (v ? JSON.parse(v) : null), read: v => (v ? JSON.parse(v) : null),
write: (v) => (v ? JSON.stringify(v) : null) write: v => (v ? JSON.stringify(v) : null),
} },
}); });
const isAuthenticated = computed(() => !!accessToken.value); const isAuthenticated = computed(() => !!accessToken.value);
@@ -43,17 +43,9 @@ export const useAuthStore = defineStore('auth', () => {
tokenClaims.value = null; tokenClaims.value = null;
} }
async function logout(redirectTo = '/landing') { async function logout() {
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(); cleanTokens();
await router.push(redirectTo); await router.push('/');
}
} }
async function login(email, password) { async function login(email, password) {
@@ -65,7 +57,7 @@ export const useAuthStore = defineStore('auth', () => {
try { try {
const response = await clientApi.post('api/users/login', { const response = await clientApi.post('api/users/login', {
email: email.trim(), email: email.trim(),
password: password password: password,
}); });
if (!response.data?.accessToken || !response.data?.refreshToken) { if (!response.data?.accessToken || !response.data?.refreshToken) {
@@ -90,7 +82,7 @@ export const useAuthStore = defineStore('auth', () => {
try { try {
const response = await clientApi.post('api/users/login-with-google', { const response = await clientApi.post('api/users/login-with-google', {
token: accessTokenParam token: accessTokenParam,
}); });
if (!response.data?.accessToken || !response.data?.refreshToken) { if (!response.data?.accessToken || !response.data?.refreshToken) {
@@ -115,7 +107,7 @@ export const useAuthStore = defineStore('auth', () => {
try { try {
const response = await clientApi.post('api/users/login-with-facebook', { const response = await clientApi.post('api/users/login-with-facebook', {
token: authResponse.accessToken token: authResponse.accessToken,
}); });
if (!response.data?.accessToken || !response.data?.refreshToken) { if (!response.data?.accessToken || !response.data?.refreshToken) {
@@ -152,7 +144,7 @@ export const useAuthStore = defineStore('auth', () => {
console.log('Sending refresh request...'); console.log('Sending refresh request...');
const response = await clientApi.post('api/users/refresh', { const response = await clientApi.post('api/users/refresh', {
refreshToken: refreshToken.value refreshToken: refreshToken.value,
}); });
if (!response.data?.accessToken || !response.data?.refreshToken) { if (!response.data?.accessToken || !response.data?.refreshToken) {
@@ -161,7 +153,7 @@ export const useAuthStore = defineStore('auth', () => {
updateTokens({ updateTokens({
accessToken: response.data.accessToken, accessToken: response.data.accessToken,
refreshToken: response.data.refreshToken refreshToken: response.data.refreshToken,
}); });
console.log('Token refresh successful'); console.log('Token refresh successful');
@@ -174,10 +166,12 @@ export const useAuthStore = defineStore('auth', () => {
const returnUrl = currentRoute.fullPath; const returnUrl = currentRoute.fullPath;
// Handle navigation // Handle navigation
router.push({ router
.push({
name: 'login', name: 'login',
query: { returnUrl } query: { returnUrl },
}).catch(navError => { })
.catch(navError => {
console.error('Navigation error after token refresh failure:', navError); console.error('Navigation error after token refresh failure:', navError);
}); });
@@ -228,15 +222,14 @@ export const useAuthStore = defineStore('auth', () => {
const isExpiring = timeRemainingMs < fiveMinutesInMs; const isExpiring = timeRemainingMs < fiveMinutesInMs;
// Determine the sign for display purposes // Determine the sign for display purposes
const formattedTimeRemaining = timeRemainingMs < 0 const formattedTimeRemaining =
? `-${formatDuration(Math.abs(timeRemainingMs))}` timeRemainingMs < 0 ? `-${formatDuration(Math.abs(timeRemainingMs))}` : formatDuration(timeRemainingMs);
: formatDuration(timeRemainingMs);
if (isExpiring) { if (isExpiring) {
console.log(`Token expiration check; is token expired: ${isExpiring}`, { console.log(`Token expiration check; is token expired: ${isExpiring}`, {
expirationTime: new Date(expirationTime).toLocaleString(), expirationTime: new Date(expirationTime).toLocaleString(),
currentTime: new Date(currentTime).toLocaleString(), currentTime: new Date(currentTime).toLocaleString(),
timeRemaining: formattedTimeRemaining timeRemaining: formattedTimeRemaining,
}); });
} }
@@ -255,7 +248,7 @@ export const useAuthStore = defineStore('auth', () => {
try { try {
const response = await clientApi.post('api/users/set-password', { const response = await clientApi.post('api/users/set-password', {
newPassword newPassword,
}); });
console.log('Password changed successfully'); console.log('Password changed successfully');
@@ -278,6 +271,6 @@ export const useAuthStore = defineStore('auth', () => {
logout, logout,
refresh, refresh,
isTokenExpiringSoon, isTokenExpiringSoon,
changePassword changePassword,
}; };
}); });

View File

@@ -1,167 +0,0 @@
<template>
<div class="flex min-h-full w-full items-center justify-center p-20">
<div class="card justify-items-center">
<img :alt="t('alt')" src="/images/hutopymedia/loginpage/hutopylogin.svg" />
<div class="flex flex-col gap-10">
<h1 class="login-text text-center text-2xl font-bold ">
{{ t('title') }}
</h1>
<v-form @submit.prevent="handleRegister">
<div class="flex flex-col gap-4">
<v-text-field v-model="name" :label="t('name')" required></v-text-field>
<v-text-field v-model="email" :label="t('email')" type="email" required></v-text-field>
<v-text-field v-model="password" :label="t('password')" :type="showPassword ? 'text' : 'password'" required
:hint="t('passwordRequirements')">
<template v-slot:append-inner>
<v-icon @click="showPassword = !showPassword" class="visibility-toggle" size="small"
:icon="showPassword ? mdiEyeOff : mdiEye" />
</template>
</v-text-field>
<v-text-field v-model="confirmPassword" :label="t('confirmPassword')"
:type="showConfirmPassword ? 'text' : 'password'" required>
<template v-slot:append-inner>
<v-icon @click="showConfirmPassword = !showConfirmPassword" class="visibility-toggle" size="small"
:icon="showConfirmPassword ? mdiEyeOff : mdiEye" />
</template>
</v-text-field>
<v-btn type="submit" color="primary" block :loading="isLoading">
{{ t('register') }}
</v-btn>
<div class="mt-4 text-center">
{{ t('alreadyHaveAccount') }}
<router-link to="/login" class="text-blue-500">
{{ t('signIn') }}
</router-link>
</div>
</div>
</v-form>
</div>
</div>
<v-snackbar v-model="errorSnackBar" color="error">
{{ errorMessage }}
</v-snackbar>
</div>
</template>
<script setup>
import { ref } from 'vue';
import { useClient } from '@/plugins/api.js';
import { useAuthStore } from '@/stores/authStore.js';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { mdiEye, mdiEyeOff } from '@mdi/js';
const { t } = useI18n();
const router = useRouter();
const authStore = useAuthStore();
const clientApi = useClient();
const name = ref('');
const email = ref('');
const password = ref('');
const confirmPassword = ref('');
const isLoading = ref(false);
const errorSnackBar = ref(false);
const errorMessage = ref('');
const showPassword = ref(false);
const showConfirmPassword = ref(false);
async function handleRegister() {
if (password.value !== confirmPassword.value) {
errorMessage.value = t('passwordsDoNotMatch');
errorSnackBar.value = true;
return;
}
isLoading.value = true;
try {
// Register the user
const response = await clientApi.post('api/users/register', {
name: name.value,
email: email.value.trim(),
password: password.value
});
// If registration is successful, log them in
await authStore.login(email.value, password.value);
// Redirect to home or welcome page
await router.push('/landing');
} catch (error) {
console.error('Registration failed:', error);
errorMessage.value = error.response?.data?.message || t('registrationFailed');
errorSnackBar.value = true;
} finally {
isLoading.value = false;
}
}
</script>
<style scoped>
.visibility-toggle {
@apply cursor-pointer;
@apply transition-opacity duration-300;
@apply opacity-60 hover:opacity-100;
@apply z-10;
}
/* Override Vuetify's default padding to accommodate our icon */
:deep(.v-field__append-inner) {
padding-inline-start: 0;
}
</style>
<i18n>
{
"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."
}
}
</i18n>

View File

@@ -44,6 +44,12 @@
</a> </a>
</div> </div>
<div class="mt-2 text-center">
<a @click="resendVerification" class="cursor-pointer text-sm text-blue-500">
{{ t('resendVerification') }}
</a>
</div>
<div class="mt-4 text-center"> <div class="mt-4 text-center">
{{ t('noAccount') }} {{ t('noAccount') }}
<router-link to="/register" class="text-blue-500"> <router-link to="/register" class="text-blue-500">
@@ -113,6 +119,10 @@ async function googleCallback(token) {
function forgotPassword() { function forgotPassword() {
router.push('/forgot-password'); router.push('/forgot-password');
} }
function resendVerification() {
router.push('/verify-email');
}
</script> </script>
<style scoped> <style scoped>
@@ -146,6 +156,7 @@ function forgotPassword() {
"password": "Password", "password": "Password",
"signIn": "Connect", "signIn": "Connect",
"forgotPassword": "Forgot password?", "forgotPassword": "Forgot password?",
"resendVerification": "Resend verification email",
"orContinueWith": "Or", "orContinueWith": "Or",
"noAccount": "Don't have an account?", "noAccount": "Don't have an account?",
"register": "Register", "register": "Register",
@@ -159,6 +170,7 @@ function forgotPassword() {
"password": "Mot de passe", "password": "Mot de passe",
"signIn": "Connexion", "signIn": "Connexion",
"forgotPassword": "Mot de passe oublié?", "forgotPassword": "Mot de passe oublié?",
"resendVerification": "Renvoyer l'email de vérification",
"orContinueWith": "Ou", "orContinueWith": "Ou",
"noAccount": "Vous n'avez pas de compte?", "noAccount": "Vous n'avez pas de compte?",
"register": "S'inscrire", "register": "S'inscrire",
@@ -172,6 +184,7 @@ function forgotPassword() {
"password": "Contraseña", "password": "Contraseña",
"signIn": "Conéctate", "signIn": "Conéctate",
"forgotPassword": "¿Olvidó su contraseña?", "forgotPassword": "¿Olvidó su contraseña?",
"resendVerification": "Reenviar correo de verificación",
"orContinueWith": "o", "orContinueWith": "o",
"noAccount": "¿No tiene una cuenta?", "noAccount": "¿No tiene una cuenta?",
"register": "Registrarse", "register": "Registrarse",

View File

@@ -0,0 +1,257 @@
<template>
<div class="flex min-h-full w-full items-center justify-center p-20">
<!-- Show verification message on success -->
<div
v-if="registrationSuccess"
class="card justify-items-center"
>
<img
:alt="t('alt')"
src="/images/hutopymedia/loginpage/hutopylogin.svg"
/>
<div class="flex flex-col gap-10 text-center">
<h1 class="login-text text-2xl font-bold text-green-600">
{{ t('success.title') }}
</h1>
<div class="text-hOnSurface">
<p>{{ t('success.message') }}</p>
<p class="mt-2 font-medium">{{ userEmail }}</p>
</div>
<div class="mt-4 flex flex-col gap-2">
<router-link
class="text-blue-500 hover:underline"
to="/login"
>
{{ t('success.backToLogin') }}
</router-link>
<router-link
class="text-blue-500 hover:underline"
:to="{ path: '/verify-email', query: { email: userEmail } }"
>
{{ t('success.resendVerification') }}
</router-link>
</div>
</div>
</div>
<!-- Show registration form -->
<div
v-else
class="card justify-items-center"
>
<img
:alt="t('alt')"
src="/images/hutopymedia/loginpage/hutopylogin.svg"
/>
<div class="flex flex-col gap-10">
<h1 class="login-text text-center text-2xl font-bold">
{{ t('title') }}
</h1>
<v-form @submit.prevent="handleRegister">
<div class="flex flex-col gap-4">
<v-text-field
v-model="name"
:label="t('name')"
required
></v-text-field>
<v-text-field
v-model="email"
:label="t('email')"
required
type="email"
></v-text-field>
<v-text-field
v-model="password"
:hint="t('passwordRequirements')"
:label="t('password')"
:type="showPassword ? 'text' : 'password'"
required
>
<template v-slot:append-inner>
<v-icon
:icon="showPassword ? mdiEyeOff : mdiEye"
class="visibility-toggle"
size="small"
@click="showPassword = !showPassword"
/>
</template>
</v-text-field>
<v-text-field
v-model="confirmPassword"
:label="t('confirmPassword')"
:type="showConfirmPassword ? 'text' : 'password'"
required
>
<template v-slot:append-inner>
<v-icon
:icon="showConfirmPassword ? mdiEyeOff : mdiEye"
class="visibility-toggle"
size="small"
@click="showConfirmPassword = !showConfirmPassword"
/>
</template>
</v-text-field>
<v-btn
:loading="isLoading"
block
color="primary"
type="submit"
>
{{ t('register') }}
</v-btn>
<!-- Error message displayed as block text below submit button -->
<div
v-if="errorMessage"
class="mt-2 p-3 bg-red-50 border border-red-200 rounded text-red-700 text-sm"
>
{{ errorMessage }}
</div>
<div class="mt-4 text-center">
{{ t('alreadyHaveAccount') }}
<router-link
class="text-blue-500"
to="/login"
>
{{ t('signIn') }}
</router-link>
</div>
</div>
</v-form>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue';
import { useClient } from '@/plugins/api.js';
import { useI18n } from 'vue-i18n';
import { mdiEye, mdiEyeOff } from '@mdi/js';
const { t } = useI18n();
const clientApi = useClient();
const name = ref('');
const email = ref('');
const password = ref('');
const confirmPassword = ref('');
const isLoading = ref(false);
const errorMessage = ref('');
const showPassword = ref(false);
const showConfirmPassword = ref(false);
const registrationSuccess = ref(false);
const userEmail = ref('');
async function handleRegister() {
if (password.value !== confirmPassword.value) {
errorMessage.value = t('passwordsDoNotMatch');
return;
}
isLoading.value = true;
errorMessage.value = '';
try {
await clientApi.post('api/users/register', {
name: name.value,
email: email.value.trim(),
password: password.value,
});
// On success, show verification message
userEmail.value = email.value.trim();
registrationSuccess.value = true;
} catch (error) {
console.error('Registration failed:', error);
errorMessage.value = error.response?.data?.message || t('registrationFailed');
} finally {
isLoading.value = false;
}
}
</script>
<style scoped>
.visibility-toggle {
@apply cursor-pointer;
@apply transition-opacity duration-300;
@apply opacity-60 hover:opacity-100;
@apply z-10;
}
/* Override Vuetify's default padding to accommodate our icon */
:deep(.v-field__append-inner) {
padding-inline-start: 0;
}
</style>
<i18n>
{
"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"
}
}
}
</i18n>

View File

@@ -0,0 +1,219 @@
<template>
<div class="flex min-h-full w-full items-center justify-center p-4">
<div class="flex w-full max-w-[512px] flex-col gap-10 text-center">
<!-- Loading state while verification is in progress -->
<div v-if="isLoading" class="flex flex-col items-center gap-4">
<v-progress-circular indeterminate color="primary" size="64"></v-progress-circular>
<h2 class="text-xl font-medium">{{ t('verifying') }}</h2>
</div>
<!-- Success state -->
<div v-else-if="verificationSuccess" class="flex flex-col items-center gap-6">
<v-icon icon="mdi-check-circle" color="green" size="64"></v-icon>
<h1 class="text-2xl font-bold text-green-600">{{ t('success.title') }}</h1>
<p>{{ t('success.message') }}</p>
<v-btn color="primary" @click="goToLogin">{{ t('success.goToLogin') }}</v-btn>
</div>
<!-- Error state -->
<div v-else class="flex flex-col items-center gap-6">
<v-icon icon="mdi-alert-circle" color="error" size="64"></v-icon>
<h1 class="text-2xl font-bold text-red-600">{{ t('error.title') }}</h1>
<p>{{ errorMessage || t('error.defaultMessage') }}</p>
<div class="mt-4 flex flex-col gap-4 w-full">
<v-btn color="primary" @click="goToLogin">{{ t('error.goToLogin') }}</v-btn>
<v-divider class="my-4"></v-divider>
<!-- Resend verification email section -->
<h2 class="text-xl font-medium">{{ t('resend.title') }}</h2>
<v-form @submit.prevent="handleResendVerification" class="w-full">
<div class="flex flex-col gap-4">
<v-text-field
v-model="resendEmail"
:label="t('resend.emailLabel')"
type="email"
required
:error-messages="resendEmailError"
></v-text-field>
<v-btn
type="submit"
color="secondary"
block
:loading="resendLoading"
>
{{ t('resend.button') }}
</v-btn>
<!-- Resend success message -->
<div v-if="resendSuccess" class="mt-2 p-3 bg-green-50 border border-green-200 rounded text-green-700 text-sm">
{{ t('resend.success') }}
</div>
<!-- Resend error message -->
<div v-if="resendError" class="mt-2 p-3 bg-red-50 border border-red-200 rounded text-red-700 text-sm">
{{ resendError }}
</div>
</div>
</v-form>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { useClient } from '@/plugins/api.js';
import { useI18n } from 'vue-i18n';
import { useRouter, useRoute } from 'vue-router';
const { t } = useI18n();
const router = useRouter();
const route = useRoute();
const clientApi = useClient();
// Verification state
const isLoading = ref(true);
const verificationSuccess = ref(false);
const errorMessage = ref('');
// Resend verification state
const resendEmail = ref('');
const resendEmailError = ref('');
const resendLoading = ref(false);
const resendSuccess = ref(false);
const resendError = ref('');
onMounted(async () => {
const userId = route.query.userId;
const token = route.query.token;
// Populate resend email field if it was in the URL
if (route.query.email) {
resendEmail.value = route.query.email;
}
// Check if we have the required parameters
if (!userId || !token) {
isLoading.value = false;
errorMessage.value = t('error.missingParams');
return;
}
try {
// Call the verification endpoint
await clientApi.get(`/api/users/verify-email?userId=${userId}&token=${token}`);
verificationSuccess.value = true;
} catch (error) {
console.error('Email verification failed:', error);
errorMessage.value = error.response?.data?.message || t('error.defaultMessage');
} finally {
isLoading.value = false;
}
});
async function handleResendVerification() {
// Reset states
resendEmailError.value = '';
resendSuccess.value = false;
resendError.value = '';
// Simple email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(resendEmail.value)) {
resendEmailError.value = t('resend.invalidEmail');
return;
}
resendLoading.value = true;
try {
await clientApi.post('/api/users/resend-verification', {
email: resendEmail.value.trim()
});
resendSuccess.value = true;
} catch (error) {
console.error('Resend verification failed:', error);
resendError.value = error.response?.data?.message || t('resend.error');
} finally {
resendLoading.value = false;
}
}
function goToLogin() {
router.push('/login');
}
</script>
<i18n>
{
"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."
}
}
}
</i18n>

View File

@@ -1,23 +1,42 @@
<template> <template>
<div class="relative p-4" <div
class="relative p-4"
@mouseenter="showEditButtons = isLoggedIn && creatorProfileStore.creator?.id === brandingStore.value.id" @mouseenter="showEditButtons = isLoggedIn && creatorProfileStore.creator?.id === brandingStore.value.id"
@mouseleave="showEditButtons = false"> @mouseleave="showEditButtons = false"
>
<!-- Edit buttons with absolute positioning --> <!-- Edit buttons with absolute positioning -->
<div v-if="showEditButtons || isEditMode" class="absolute right-4 top-4 flex gap-2"> <div
v-if="showEditButtons || isEditMode"
class="absolute right-4 top-4 flex gap-2"
>
<!-- Edit button with pencil icon --> <!-- Edit button with pencil icon -->
<button v-if="!isEditMode" :title="t('edit')" <button
v-if="!isEditMode"
:title="t('edit')"
class="flex size-12 items-center justify-center rounded-full bg-hutopyPrimary shadow-lg" class="flex size-12 items-center justify-center rounded-full bg-hutopyPrimary shadow-lg"
@click="toggleEditMode()"> @click="toggleEditMode()"
<v-icon :icon="mdiPencil" large /> >
<v-icon
:icon="mdiPencil"
large
/>
</button> </button>
<!-- Save button --> <!-- Save button -->
<button v-if="isEditMode" :disabled="isSaving || !canSave" :title="t('save')" <button
class="flex size-12 items-center justify-center rounded-full bg-hutopyPrimary shadow-lg" @click="saveChanges()"> v-if="isEditMode"
:disabled="isSaving || !canSave"
:title="t('save')"
class="flex size-12 items-center justify-center rounded-full bg-hutopyPrimary shadow-lg"
@click="saveChanges()"
>
<template v-if="isSaving"> <template v-if="isSaving">
<v-progress-circular indeterminate size="20" width="2" color="white" /> <v-progress-circular
color="white"
indeterminate
size="20"
width="2"
/>
</template> </template>
<template v-else> <template v-else>
<v-icon :icon="mdiCheck" /> <v-icon :icon="mdiCheck" />
@@ -25,15 +44,21 @@
</button> </button>
<!-- Cancel button --> <!-- Cancel button -->
<button v-if="isEditMode" :title="t('cancel')" <button
class="flex size-12 items-center justify-center rounded-full bg-red-500 shadow-lg" @click="cancelEdit"> v-if="isEditMode"
<v-icon :icon="mdiClose" large /> :title="t('cancel')"
class="flex size-12 items-center justify-center rounded-full bg-red-500 shadow-lg"
@click="cancelEdit"
>
<v-icon
:icon="mdiClose"
large
/>
</button> </button>
</div> </div>
<!-- MainPage --> <!-- MainPage -->
<div class="flex flex-col"> <div class="flex flex-col">
<h1 class="mb-4 flex justify-start text-center text-2xl font-bold"> <h1 class="mb-4 flex justify-start text-center text-2xl font-bold">
{{ t('creator.sections.about.title') }} {{ t('creator.sections.about.title') }}
</h1> </h1>
@@ -42,83 +67,144 @@
<!-- Description Section --> <!-- Description Section -->
<div> <div>
<div v-if="!isEditMode"> <div v-if="!isEditMode">
<p v-if="description" class="mb-6 whitespace-pre-line text-justify text-lg"> <p
v-if="description"
class="mb-6 whitespace-pre-line text-justify text-lg"
>
{{ description }} {{ description }}
</p> </p>
</div> </div>
<v-textarea v-if="isEditMode" v-model="editableDescription" :counter="2000" :error-messages="descriptionError" <v-textarea
:label="t('creator.sections.about.description')" :rules="[ v-if="isEditMode"
v-model="editableDescription"
:counter="2000"
:error-messages="descriptionError"
:label="t('creator.sections.about.description')"
:rules="[
v => !!v || t('creator.validation.descriptionRequired'), v => !!v || t('creator.validation.descriptionRequired'),
v => v.length <= 2000 || t('creator.validation.descriptionTooLong') v => v.length <= 2000 || t('creator.validation.descriptionTooLong'),
]" auto-grow class="w-full p-2 py-6" rows="5" variant="outlined"></v-textarea> ]"
auto-grow
class="w-full p-2 py-6"
rows="5"
variant="outlined"
></v-textarea>
</div> </div>
<!-- Video Section --> <!-- Video Section -->
<div v-if="videoUrl || isEditMode" :class="['content-section', { <div
v-if="videoUrl || isEditMode"
:class="[
'content-section',
{
'rounded-t-xl': hasImages && !isEditMode, 'rounded-t-xl': hasImages && !isEditMode,
'rounded-xl': !hasImages && !isEditMode 'rounded-xl': !hasImages && !isEditMode,
}]"> },
<div v-if="!isEditMode && videoUrl" class="video-container"> ]"
<iframe :src="youtubeEmbedUrl" >
<div
v-if="!isEditMode && videoUrl"
class="video-container"
>
<iframe
:src="youtubeEmbedUrl"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen class="video-frame" title="YouTube video player"> allowfullscreen
</iframe> class="video-frame"
title="YouTube video player"
></iframe>
</div> </div>
<div v-if="isEditMode"> <div v-if="isEditMode">
<v-text-field v-model="editableVideoUrl" :error-messages="videoUrlError" <v-text-field
:label="t('creator.fields.videoUrl')" class="w-full p-2" type="text" variant="outlined" /> v-model="editableVideoUrl"
:error-messages="videoUrlError"
:label="t('creator.fields.videoUrl')"
class="w-full p-2"
type="text"
variant="outlined"
/>
</div> </div>
</div> </div>
<!-- Photos Section using Album component --> <!-- Photos Section using Album component -->
<div> <div>
<!-- Use AlbumView for display mode --> <!-- Use AlbumView for display mode -->
<AlbumView v-if="!isEditMode && hasImages" :class="['content-section', { <AlbumView
v-if="!isEditMode && hasImages"
:class="[
'content-section',
{
'rounded-b-xl': videoUrl && !isEditMode, 'rounded-b-xl': videoUrl && !isEditMode,
'rounded-xl': !videoUrl && !isEditMode 'rounded-xl': !videoUrl && !isEditMode,
}]" :images="thumbnailUrls" @photo-click="handlePhotoClick" /> },
]"
:images="thumbnailUrls"
@photo-click="handlePhotoClick"
/>
<AlbumViewer v-model="showAlbumViewer" :images="originalUrls" :start-index="selectedPhotoIndex" /> <AlbumViewer
v-model="showAlbumViewer"
:images="originalUrls"
:start-index="selectedPhotoIndex"
/>
<!-- Use AlbumEditor for edit mode --> <!-- Use AlbumEditor for edit mode -->
<AlbumEditor v-if="isEditMode" :images="photos" @update:images="updateImages" /> <AlbumEditor
v-if="isEditMode"
:images="photos"
@update:images="updateImages"
/>
</div> </div>
<!-- Contact Information Section --> <!-- Contact Information Section -->
<div v-if="phoneNumber || email" class="contact-info mt-6"> <div
v-if="phoneNumber || email"
class="contact-info mt-6"
>
<!-- Phone Number --> <!-- Phone Number -->
<div v-if="phoneNumber" class="contact-capsule" @click="callPhone"> <div
<v-icon :icon="mdiPhone" class="contact-icon" /> v-if="phoneNumber"
class="contact-capsule"
@click="callPhone"
>
<v-icon
:icon="mdiPhone"
class="contact-icon"
/>
<span class="contact-text">{{ phoneNumber }}</span> <span class="contact-text">{{ phoneNumber }}</span>
</div> </div>
<!-- Email --> <!-- Email -->
<div v-if="email" class="contact-capsule" @click="sendEmail"> <div
<v-icon :icon="mdiEmail" class="contact-icon" /> v-if="email"
class="contact-capsule"
@click="sendEmail"
>
<v-icon
:icon="mdiEmail"
class="contact-icon"
/>
<span class="contact-text">{{ email }}</span> <span class="contact-text">{{ email }}</span>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { onMounted, ref, computed, watch } from "vue"; import { computed, onMounted, ref, watch } from 'vue';
import { useClient } from "@/plugins/api.js"; import { useClient } from '@/plugins/api.js';
import { useBrandingStore } from "@/stores/brandingStore.js"; import { useBrandingStore } from '@/stores/brandingStore.js';
import { useCreatorProfileStore } from "@/stores/creatorProfileStore.js"; import { useCreatorProfileStore } from '@/stores/creatorProfileStore.js';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { buildEmbedUrl, isValidYouTubeUrlOrId, extractVideoId } from '@/utils/youtube'; import { buildEmbedUrl, extractVideoId, isValidYouTubeUrlOrId } from '@/utils/youtube';
import AlbumEditor from "@/views/creators/AlbumEditor.vue"; import AlbumEditor from '@/views/creators/AlbumEditor.vue';
import AlbumView from "@/views/creators/AlbumView.vue"; import AlbumView from '@/views/creators/AlbumView.vue';
import AlbumViewer from './AlbumViewer.vue'; import AlbumViewer from './AlbumViewer.vue';
import { useToast } from 'vue-toastification'; import { useToast } from 'vue-toastification';
import { mdiPencil, mdiCheck, mdiClose, mdiPhone, mdiEmail } from '@mdi/js'; import { mdiCheck, mdiClose, mdiEmail, mdiPencil, mdiPhone } from '@mdi/js';
const { t } = useI18n(); const { t } = useI18n();
const creatorProfileStore = useCreatorProfileStore(); const creatorProfileStore = useCreatorProfileStore();
@@ -126,6 +212,8 @@ const brandingStore = useBrandingStore();
const client = useClient(); const client = useClient();
const toast = useToast(); const toast = useToast();
// Fetch album data
const isLoadingAlbum = ref(false);
const isLoading = ref(true); const isLoading = ref(true);
const isSaving = ref(false); const isSaving = ref(false);
const isLoggedIn = true; const isLoggedIn = true;
@@ -133,10 +221,10 @@ const isEditMode = ref(false);
const showEditButtons = ref(false); const showEditButtons = ref(false);
// Variables réactives pour les données // Variables réactives pour les données
const description = ref(""); const description = ref('');
const videoUrl = ref(""); const videoUrl = ref('');
const phoneNumber = ref(""); const phoneNumber = ref('');
const email = ref(""); const email = ref('');
const photos = ref([]); //before was thumbnailUrls const photos = ref([]); //before was thumbnailUrls
const albumId = ref(null); const albumId = ref(null);
const originalPhotos = ref([]); const originalPhotos = ref([]);
@@ -145,10 +233,10 @@ const showAlbumViewer = ref(false);
const selectedPhotoIndex = ref(0); const selectedPhotoIndex = ref(0);
// Editable fields // Editable fields
const editableDescription = ref(""); const editableDescription = ref('');
const editableVideoUrl = ref(""); const editableVideoUrl = ref('');
const videoUrlError = ref(""); const videoUrlError = ref('');
const descriptionError = ref(""); const descriptionError = ref('');
function callPhone() { function callPhone() {
if (phoneNumber.value) { if (phoneNumber.value) {
@@ -167,7 +255,9 @@ function sendEmail() {
// Computed property to check if we can save // Computed property to check if we can save
const canSave = computed(() => { const canSave = computed(() => {
if (isSaving.value == true) { return false; } if (isSaving.value == true) {
return false;
}
// Check if description is empty or only whitespace // Check if description is empty or only whitespace
if (!editableDescription.value || editableDescription.value.trim() === '') { if (!editableDescription.value || editableDescription.value.trim() === '') {
@@ -188,8 +278,8 @@ const canSave = computed(() => {
}); });
const thumbnailUrls = computed(() => { const thumbnailUrls = computed(() => {
return photos.value.map(photo => photo.image.thumbnailUrl) return photos.value.map(photo => photo.image.thumbnailUrl);
}) });
// Add this computed property to get the original image URLs // Add this computed property to get the original image URLs
const originalUrls = computed(() => { const originalUrls = computed(() => {
@@ -204,14 +294,14 @@ const hasImages = computed(() => {
// Computed property for YouTube embed URL // Computed property for YouTube embed URL
const youtubeEmbedUrl = computed(() => { const youtubeEmbedUrl = computed(() => {
if (!videoUrl.value) return ""; if (!videoUrl.value) return '';
return buildEmbedUrl(videoUrl.value); return buildEmbedUrl(videoUrl.value);
}); });
// Validate video URL // Validate video URL
function validateVideoUrl(url) { function validateVideoUrl(url) {
if (!url) { if (!url) {
videoUrlError.value = ""; videoUrlError.value = '';
return true; return true;
} }
@@ -220,12 +310,12 @@ function validateVideoUrl(url) {
return false; return false;
} }
videoUrlError.value = ""; videoUrlError.value = '';
return true; return true;
} }
// Watch for changes in editableVideoUrl // Watch for changes in editableVideoUrl
watch(editableVideoUrl, (newValue) => { watch(editableVideoUrl, newValue => {
validateVideoUrl(newValue); validateVideoUrl(newValue);
}); });
@@ -236,15 +326,43 @@ function toggleEditMode() {
// Charger les valeurs pour l'édition // Charger les valeurs pour l'édition
editableDescription.value = description.value; editableDescription.value = description.value;
editableVideoUrl.value = videoUrl.value; editableVideoUrl.value = videoUrl.value;
videoUrlError.value = ""; videoUrlError.value = '';
} }
} }
watch(
() => ({
id: brandingStore.value?.id,
presentation: brandingStore.value?.presentation,
}),
async ({ id, presentation }, previousValue) => {
// Only proceed if we have both id and presentation, and the id has changed
if (id && presentation && id !== previousValue?.id) {
console.log('Watcher triggered: Loading data for creator ID:', id);
// Load presentation data
description.value = presentation.description || '';
videoUrl.value = presentation.videoUrl || '';
phoneNumber.value = presentation.phoneNumber || '';
email.value = presentation.email || '';
// Fetch album data // Fetch album data
await fetchAlbumData();
}
},
{ immediate: true, deep: true }
);
async function fetchAlbumData() { async function fetchAlbumData() {
if (isLoadingAlbum.value) {
console.log('Album data already loading, skipping duplicate request');
return;
}
console.log('in fetchAlbumData()'); console.log('in fetchAlbumData()');
if (!brandingStore.value?.id) return; if (!brandingStore.value?.id) return;
isLoadingAlbum.value = true;
const creatorId = brandingStore.value.id; const creatorId = brandingStore.value.id;
try { try {
@@ -263,15 +381,16 @@ async function fetchAlbumData() {
})); }));
albumId.value = creatorId; albumId.value = creatorId;
} else { } else {
// Initialize with empty array instead of empty slots // Initialize with an empty array instead of empty slots
console.log('WOW! You found how to get here! Take a look at the stack!'); console.log('WOW! You found how to get here! Take a look at the stack!');
photos.value = []; photos.value = [];
originalPhotos.value = []; originalPhotos.value = [];
} }
} } catch (error) {
catch (error) {
photos.value = []; photos.value = [];
originalPhotos.value = []; originalPhotos.value = [];
} finally {
isLoadingAlbum.value = false;
} }
} }
@@ -279,13 +398,10 @@ async function fetchAlbumData() {
onMounted(async () => { onMounted(async () => {
if (!brandingStore.value?.presentation) return; if (!brandingStore.value?.presentation) return;
description.value = brandingStore.value.presentation.description || ""; description.value = brandingStore.value.presentation.description || '';
videoUrl.value = brandingStore.value.presentation.videoUrl || ""; videoUrl.value = brandingStore.value.presentation.videoUrl || '';
phoneNumber.value = brandingStore.value.presentation.phoneNumber || ""; phoneNumber.value = brandingStore.value.presentation.phoneNumber || '';
email.value = brandingStore.value.presentation.email || ""; email.value = brandingStore.value.presentation.email || '';
// Fetch album data
await fetchAlbumData();
}); });
// Update images from Album component // Update images from Album component
@@ -323,14 +439,14 @@ async function saveChanges() {
const presentationResponse = await client.post( const presentationResponse = await client.post(
`/api/creators/${brandingStore.value.id}/presentation-infos`, `/api/creators/${brandingStore.value.id}/presentation-infos`,
{ {
description: editableDescription.value || "", description: editableDescription.value || '',
videoUrl: editableVideoUrl.value || null videoUrl: editableVideoUrl.value || null,
} }
); );
// Mettre à jour les valeurs locales pour refléter les changements // Mettre à jour les valeurs locales pour refléter les changements
description.value = editableDescription.value; description.value = editableDescription.value;
videoUrl.value = extractVideoId(editableVideoUrl.value) || ""; videoUrl.value = extractVideoId(editableVideoUrl.value) || '';
// Check for deleted photos // Check for deleted photos
const photosOriginalUrls = photos.value.map(photo => photo.image.originalUrl); const photosOriginalUrls = photos.value.map(photo => photo.image.originalUrl);
@@ -338,7 +454,9 @@ async function saveChanges() {
// If the photo URL is not in the current images array, it was deleted // If the photo URL is not in the current images array, it was deleted
return !photosOriginalUrls.includes(originalPhoto.originalUrl); return !photosOriginalUrls.includes(originalPhoto.originalUrl);
}); });
const newImages = photos.value.filter(photo => photo && photo.image && photo.image.originalUrl.startsWith('data:')); const newImages = photos.value.filter(
photo => photo && photo.image && photo.image.originalUrl.startsWith('data:')
);
console.log('originalPhotos', originalPhotos.value); console.log('originalPhotos', originalPhotos.value);
console.log('photos', photos.value); console.log('photos', photos.value);
@@ -351,12 +469,12 @@ async function saveChanges() {
// Create the Album if we do not have one yet // Create the Album if we do not have one yet
if (albumId.value == null) { if (albumId.value == null) {
console.log('We do not have an album yet') console.log('We do not have an album yet');
try { try {
await client.post('/api/albums', { await client.post('/api/albums', {
albumId: brandingStore.value.id, albumId: brandingStore.value.id,
title: `${brandingStore.value.name}'s Album`, title: `${brandingStore.value.name}'s Album`,
description: "Photo album for the creator" description: 'Photo album for the creator',
}); });
albumId.value = brandingStore.value.id; albumId.value = brandingStore.value.id;
} catch (error) { } catch (error) {
@@ -370,7 +488,7 @@ async function saveChanges() {
try { try {
await client.delete(`/api/albums/${albumId.value}/photos/${photo.id}`); await client.delete(`/api/albums/${albumId.value}/photos/${photo.id}`);
} catch (error) { } catch (error) {
console.error("Error deleting photo:", error); console.error('Error deleting photo:', error);
} }
} }
@@ -392,14 +510,13 @@ async function saveChanges() {
await client.post(`/api/albums/${albumId.value}/photos`, formData, { await client.post(`/api/albums/${albumId.value}/photos`, formData, {
headers: { headers: {
'Content-Type': 'multipart/form-data' 'Content-Type': 'multipart/form-data',
}, },
params: { params: {
photoId: photoId photoId: photoId,
} },
}); });
imageData.isUploading = false; imageData.isUploading = false;
} }
// Refresh album data after changes // Refresh album data after changes
@@ -408,7 +525,7 @@ async function saveChanges() {
isEditMode.value = false; isEditMode.value = false;
} catch (error) { } catch (error) {
console.error("Erreur lors de la sauvegarde :", error); console.error('Erreur lors de la sauvegarde :', error);
} finally { } finally {
isLoading.value = false; isLoading.value = false;
} }
@@ -428,7 +545,6 @@ function handlePhotoClick(index) {
selectedPhotoIndex.value = index; selectedPhotoIndex.value = index;
showAlbumViewer.value = true; showAlbumViewer.value = true;
} }
</script> </script>
<style scoped> <style scoped>
@@ -486,7 +602,7 @@ function handlePhotoClick(index) {
.contact-icon { .contact-icon {
@apply text-hutopyPrimary; @apply text-hutopyPrimary;
@apply text-xl @apply text-xl;
} }
.contact-text { .contact-text {

View File

@@ -1,11 +1,11 @@
<script setup> <script setup>
import { useI18n } from "vue-i18n"; import { useI18n } from 'vue-i18n';
import { useAuthStore } from "@/stores/authStore.js"; import { useAuthStore } from '@/stores/authStore.js';
import { useCreatorProfileStore } from "@/stores/creatorProfileStore.js"; import { useCreatorProfileStore } from '@/stores/creatorProfileStore.js';
import { useUserProfileStore } from "@/stores/userProfileStore.js"; import { useUserProfileStore } from '@/stores/userProfileStore.js';
import { useLanguageStore } from "@/stores/languageStore.js"; import { useLanguageStore } from '@/stores/languageStore.js';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import { mdiFileAccountOutline, mdiAccount, mdiLogin, mdiTranslateVariant, mdiLogout } from '@mdi/js'; import { mdiAccount, mdiFileAccountOutline, mdiLogin, mdiLogout, mdiTranslateVariant } from '@mdi/js';
const { locale, t } = useI18n(); const { locale, t } = useI18n();
const languageStore = useLanguageStore(); const languageStore = useLanguageStore();
@@ -23,40 +23,51 @@ function toggleLanguage() {
} }
function handleLogout() { function handleLogout() {
// Check if current route requires authentication authStore.logout();
const requiresAuth = route.matched.some(record => record.meta.requiresAuth);
// If on a protected page, redirect to landing, otherwise stay on current page
const redirectTo = requiresAuth ? '/landing' : route.fullPath;
authStore.logout(redirectTo);
} }
</script> </script>
<template> <template>
<nav class="side-container"> <nav class="side-container">
<div class="side-logo"> <div class="side-logo">
<router-link to="/@hutopy"> <router-link to="/@hutopy">
<img src="/images/hutopy-logo.png" alt="hutopy logo" height="50"> <img
alt="hutopy logo"
height="50"
src="/images/hutopy-logo.png"
/>
</router-link> </router-link>
</div> </div>
<div class="side-menu"> <div class="side-menu">
<div
<div v-if="authStore.isAuthenticated" class="side-menu-portrait"> v-if="authStore.isAuthenticated"
<img :src="userProfileStore.portraitUrl" alt="Profile Image" referrerpolicy="no-referrer" class="rounded-full"> class="side-menu-portrait"
>
<img
:src="userProfileStore.portraitUrl"
alt="Profile Image"
class="rounded-full"
referrerpolicy="no-referrer"
/>
<span class="profile-label">{{ userProfileStore.alias }}</span> <span class="profile-label">{{ userProfileStore.alias }}</span>
</div> </div>
<div class="side-menu-items"> <div class="side-menu-items">
<template v-if="authStore.isAuthenticated"> <template v-if="authStore.isAuthenticated">
<router-link v-if="creatorProfileStore.hasCreator" :to="`/@${creatorProfileStore.creator.slug}`"> <router-link
v-if="creatorProfileStore.hasCreator"
:to="`/@${creatorProfileStore.creator.slug}`"
>
<button class="menu-item-action"> <button class="menu-item-action">
<v-icon :icon="mdiFileAccountOutline" /> <v-icon :icon="mdiFileAccountOutline" />
<span class="label">{{ t('sidebar.myPage') }}</span> <span class="label">{{ t('sidebar.myPage') }}</span>
</button> </button>
</router-link> </router-link>
<router-link v-else to="/create-creator"> <router-link
v-else
to="/create-creator"
>
<button class="menu-item-action"> <button class="menu-item-action">
<v-icon :icon="mdiFileAccountOutline" /> <v-icon :icon="mdiFileAccountOutline" />
<span class="label">{{ t('sidebar.myPage') }}</span> <span class="label">{{ t('sidebar.myPage') }}</span>
@@ -73,7 +84,10 @@ function handleLogout() {
</router-link> </router-link>
</template> </template>
<button class="menu-item-action" @click="toggleLanguage"> <button
class="menu-item-action"
@click="toggleLanguage"
>
<v-icon :icon="mdiTranslateVariant" /> <v-icon :icon="mdiTranslateVariant" />
<span class="label">{{ locale }}</span> <span class="label">{{ locale }}</span>
</button> </button>
@@ -84,16 +98,17 @@ function handleLogout() {
<v-icon :icon="mdiLogin" /> <v-icon :icon="mdiLogin" />
<span class="label">{{ t('sidebar.signIn') }}</span> <span class="label">{{ t('sidebar.signIn') }}</span>
</button> </button>
</router-link> </router-link>
</template> </template>
<div v-else> <div v-else>
<button class="menu-item-action" @click="handleLogout"> <button
class="menu-item-action"
@click="handleLogout"
>
<v-icon :icon="mdiLogout" /> <v-icon :icon="mdiLogout" />
<span class="label">{{ t('sidebar.signOut') }}</span> <span class="label">{{ t('sidebar.signOut') }}</span>
</button> </button>
</div> </div>
</div> </div>
</div> </div>
</nav> </nav>