refactor(auth): cleanup auth module and streamline the registration flow
This commit is contained in:
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
58
backend/Modules/Identity/Handlers/ResendVerification.cs
Normal file
58
backend/Modules/Identity/Handlers/ResendVerification.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
60
backend/Modules/Identity/Handlers/VerifyEmail.cs
Normal file
60
backend/Modules/Identity/Handlers/VerifyEmail.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
@@ -18,19 +19,19 @@ public class StripeWebhookEndpoint(
|
|||||||
{
|
{
|
||||||
Post("/api/stripe");
|
Post("/api/stripe");
|
||||||
AllowAnonymous();
|
AllowAnonymous();
|
||||||
Options(o => o.WithTags( "Webhooks"));
|
Options(o => o.WithTags("Webhooks"));
|
||||||
}
|
}
|
||||||
|
|
||||||
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":
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 été 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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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",
|
||||||
257
frontend/src/views/auth/RegisterView.vue
Normal file
257
frontend/src/views/auth/RegisterView.vue
Normal 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>
|
||||||
219
frontend/src/views/auth/VerifyEmailView.vue
Normal file
219
frontend/src/views/auth/VerifyEmailView.vue
Normal 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>
|
||||||
@@ -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,132 +67,197 @@
|
|||||||
<!-- 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();
|
||||||
const brandingStore = useBrandingStore();
|
const brandingStore = useBrandingStore();
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
const isLoading = ref(true);
|
// Fetch album data
|
||||||
const isSaving = ref(false);
|
const isLoadingAlbum = ref(false);
|
||||||
const isLoggedIn = true;
|
const isLoading = ref(true);
|
||||||
const isEditMode = ref(false);
|
const isSaving = ref(false);
|
||||||
const showEditButtons = ref(false);
|
const isLoggedIn = true;
|
||||||
|
const isEditMode = 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([]);
|
||||||
// Add these refs with your other refs
|
// Add these refs with your other refs
|
||||||
const showAlbumViewer = ref(false);
|
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) {
|
||||||
toast.info('Calling your contact');
|
toast.info('Calling your contact');
|
||||||
// Remove formatting and create tel: link
|
// Remove formatting and create tel: link
|
||||||
const cleanPhone = phoneNumber.value.replace(/\D/g, '');
|
const cleanPhone = phoneNumber.value.replace(/\D/g, '');
|
||||||
window.location.href = `tel:+1${cleanPhone}`;
|
window.location.href = `tel:+1${cleanPhone}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendEmail() {
|
function sendEmail() {
|
||||||
if (email.value) {
|
if (email.value) {
|
||||||
window.location.href = `mailto:${email.value}`;
|
window.location.href = `mailto:${email.value}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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() === '') {
|
||||||
@@ -185,33 +275,33 @@ const canSave = computed(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
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(() => {
|
||||||
return photos.value.map(photo => photo.image.originalUrl);
|
return photos.value.map(photo => photo.image.originalUrl);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Computed property to check if there are images
|
// Computed property to check if there are images
|
||||||
const hasImages = computed(() => {
|
const hasImages = computed(() => {
|
||||||
// Only consider it has images if there are actual image URLs (not empty strings)
|
// Only consider it has images if there are actual image URLs (not empty strings)
|
||||||
return photos.value.length > 0;
|
return photos.value.length > 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
// 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,31 +310,59 @@ 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);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Activer/désactiver le mode édition
|
// Activer/désactiver le mode édition
|
||||||
function toggleEditMode() {
|
function toggleEditMode() {
|
||||||
isEditMode.value = !isEditMode.value;
|
isEditMode.value = !isEditMode.value;
|
||||||
if (isEditMode.value) {
|
if (isEditMode.value) {
|
||||||
// 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
|
||||||
|
await fetchAlbumData();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true, deep: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
async function fetchAlbumData() {
|
||||||
|
if (isLoadingAlbum.value) {
|
||||||
|
console.log('Album data already loading, skipping duplicate request');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch album data
|
|
||||||
async function fetchAlbumData() {
|
|
||||||
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,37 +381,35 @@ 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Charger les données au montage
|
// Charger les données au montage
|
||||||
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
|
// Update images from Album component
|
||||||
await fetchAlbumData();
|
function updateImages(newImages) {
|
||||||
});
|
|
||||||
|
|
||||||
// Update images from Album component
|
|
||||||
function updateImages(newImages) {
|
|
||||||
photos.value = newImages;
|
photos.value = newImages;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveChanges() {
|
async function saveChanges() {
|
||||||
if (!brandingStore.value?.id) {
|
if (!brandingStore.value?.id) {
|
||||||
console.error("L'ID du créateur est manquant !");
|
console.error("L'ID du créateur est manquant !");
|
||||||
return;
|
return;
|
||||||
@@ -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,100 +525,99 @@ 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelEdit() {
|
function cancelEdit() {
|
||||||
// Restaurer les valeurs d'origine
|
// Restaurer les valeurs d'origine
|
||||||
editableDescription.value = description.value;
|
editableDescription.value = description.value;
|
||||||
editableVideoUrl.value = videoUrl.value;
|
editableVideoUrl.value = videoUrl.value;
|
||||||
|
|
||||||
// Désactiver le mode édition
|
// Désactiver le mode édition
|
||||||
isEditMode.value = false;
|
isEditMode.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add this function to handle photo clicks
|
// Add this function to handle photo clicks
|
||||||
function handlePhotoClick(index) {
|
function handlePhotoClick(index) {
|
||||||
selectedPhotoIndex.value = index;
|
selectedPhotoIndex.value = index;
|
||||||
showAlbumViewer.value = true;
|
showAlbumViewer.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.content-section {
|
.content-section {
|
||||||
@apply w-full overflow-hidden;
|
@apply w-full overflow-hidden;
|
||||||
@apply cursor-pointer;
|
@apply cursor-pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-container {
|
.video-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding-top: 31.25%;
|
padding-top: 31.25%;
|
||||||
/* Reduced from 56.25% to make it shorter while maintaining aspect ratio */
|
/* Reduced from 56.25% to make it shorter while maintaining aspect ratio */
|
||||||
max-height: 40vh;
|
max-height: 40vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-frame {
|
.video-frame {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
border: none;
|
border: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Add responsive breakpoints */
|
/* Add responsive breakpoints */
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.video-container {
|
.video-container {
|
||||||
padding-top: 35%;
|
padding-top: 35%;
|
||||||
max-height: 35vh;
|
max-height: 35vh;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
@media (min-width: 1024px) {
|
||||||
.video-container {
|
.video-container {
|
||||||
padding-top: 30%;
|
padding-top: 30%;
|
||||||
max-height: 38vh;
|
max-height: 38vh;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.contact-info {
|
.contact-info {
|
||||||
@apply flex flex-col items-center gap-3;
|
@apply flex flex-col items-center gap-3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.contact-capsule {
|
.contact-capsule {
|
||||||
@apply flex items-center gap-2 px-2 py-1 bg-hSurface ;
|
@apply flex items-center gap-2 px-2 py-1 bg-hSurface;
|
||||||
@apply rounded-xl cursor-pointer transition-all duration-200;
|
@apply rounded-xl cursor-pointer transition-all duration-200;
|
||||||
@apply hover:shadow-md min-w-fit;
|
@apply hover:shadow-md min-w-fit;
|
||||||
@apply border border-hutopyPrimary;
|
@apply border border-hutopyPrimary;
|
||||||
}
|
}
|
||||||
|
|
||||||
.contact-capsule:hover {
|
.contact-capsule:hover {
|
||||||
@apply transform scale-105;
|
@apply transform scale-105;
|
||||||
}
|
}
|
||||||
|
|
||||||
.contact-icon {
|
.contact-icon {
|
||||||
@apply text-hutopyPrimary;
|
@apply text-hutopyPrimary;
|
||||||
@apply text-xl
|
@apply text-xl;
|
||||||
}
|
}
|
||||||
|
|
||||||
.contact-text {
|
.contact-text {
|
||||||
@apply text-hOnSurface font-medium text-base;
|
@apply text-hOnSurface font-medium text-base;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Formatting styles for description */
|
/* Formatting styles for description */
|
||||||
.text-justify {
|
.text-justify {
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Add some spacing between paragraphs */
|
/* Add some spacing between paragraphs */
|
||||||
.text-justify p {
|
.text-justify p {
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<i18n>
|
<i18n>
|
||||||
|
|||||||
@@ -1,62 +1,73 @@
|
|||||||
<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();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
|
||||||
const userProfileStore = useUserProfileStore();
|
const userProfileStore = useUserProfileStore();
|
||||||
const creatorProfileStore = useCreatorProfileStore();
|
const creatorProfileStore = useCreatorProfileStore();
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
function toggleLanguage() {
|
function toggleLanguage() {
|
||||||
const languages = ['fr', 'en', 'es'];
|
const languages = ['fr', 'en', 'es'];
|
||||||
const currentIndex = languages.indexOf(locale.value);
|
const currentIndex = languages.indexOf(locale.value);
|
||||||
const nextIndex = (currentIndex + 1) % languages.length;
|
const nextIndex = (currentIndex + 1) % languages.length;
|
||||||
languageStore.setLocale(languages[nextIndex]);
|
languageStore.setLocale(languages[nextIndex]);
|
||||||
}
|
}
|
||||||
|
|
||||||
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,70 +98,71 @@ 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>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.side-container {
|
.side-container {
|
||||||
@apply bg-hSurface text-hOnSurface;
|
@apply bg-hSurface text-hOnSurface;
|
||||||
@apply lg:fixed lg:max-h-screen;
|
@apply lg:fixed lg:max-h-screen;
|
||||||
@apply flex;
|
@apply flex;
|
||||||
@apply lg:flex-col lg:w-64 lg:max-w-64;
|
@apply lg:flex-col lg:w-64 lg:max-w-64;
|
||||||
@apply h-16 lg:h-screen;
|
@apply h-16 lg:h-screen;
|
||||||
@apply lg:border-r-2 lg:border-[#2d282d];
|
@apply lg:border-r-2 lg:border-[#2d282d];
|
||||||
}
|
}
|
||||||
|
|
||||||
.side-logo {
|
.side-logo {
|
||||||
@apply flex flex-grow;
|
@apply flex flex-grow;
|
||||||
@apply items-center justify-start p-4;
|
@apply items-center justify-start p-4;
|
||||||
@apply lg:items-start lg:justify-center lg:pt-4;
|
@apply lg:items-start lg:justify-center lg:pt-4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.side-menu {
|
.side-menu {
|
||||||
@apply flex gap-4 p-6;
|
@apply flex gap-4 p-6;
|
||||||
@apply items-center lg:items-stretch;
|
@apply items-center lg:items-stretch;
|
||||||
@apply flex-row-reverse lg:flex-col;
|
@apply flex-row-reverse lg:flex-col;
|
||||||
}
|
}
|
||||||
|
|
||||||
.side-menu-portrait {
|
.side-menu-portrait {
|
||||||
@apply w-10 h-10;
|
@apply w-10 h-10;
|
||||||
@apply -ml-1;
|
@apply -ml-1;
|
||||||
@apply flex items-center justify-start;
|
@apply flex items-center justify-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.side-menu-items {
|
.side-menu-items {
|
||||||
@apply flex gap-2;
|
@apply flex gap-2;
|
||||||
@apply flex-row;
|
@apply flex-row;
|
||||||
@apply lg:w-full lg:flex-col;
|
@apply lg:w-full lg:flex-col;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-label {
|
.profile-label {
|
||||||
@apply ml-5;
|
@apply ml-5;
|
||||||
@apply text-lg font-sans capitalize;
|
@apply text-lg font-sans capitalize;
|
||||||
@apply font-semibold;
|
@apply font-semibold;
|
||||||
@apply hidden lg:inline;
|
@apply hidden lg:inline;
|
||||||
@apply min-w-40 truncate;
|
@apply min-w-40 truncate;
|
||||||
}
|
}
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
@apply text-nowrap;
|
@apply text-nowrap;
|
||||||
@apply ml-4;
|
@apply ml-4;
|
||||||
@apply hidden lg:inline;
|
@apply hidden lg:inline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-item-action {
|
.menu-item-action {
|
||||||
@apply bg-hSurface text-hOnSurface hover:mix-blend-screen;
|
@apply bg-hSurface text-hOnSurface hover:mix-blend-screen;
|
||||||
@apply capitalize;
|
@apply capitalize;
|
||||||
@apply flex items-center gap-3 p-2 rounded-full md:rounded-full;
|
@apply flex items-center gap-3 p-2 rounded-full md:rounded-full;
|
||||||
@@ -158,7 +173,7 @@ function handleLogout() {
|
|||||||
i {
|
i {
|
||||||
@apply text-xl;
|
@apply text-xl;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<i18n>
|
<i18n>
|
||||||
|
|||||||
Reference in New Issue
Block a user