feat(auth): adds local account authentication
This commit is contained in:
66
backend/src/Web/Features/Users/Handlers/ForgotPassword.cs
Normal file
66
backend/src/Web/Features/Users/Handlers/ForgotPassword.cs
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
using Hutopy.Web.Features.Users;
|
||||||
|
using Hutopy.Web.Features.Users.Data;
|
||||||
|
using Hutopy.Web.Features.Users.Services;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using System.Text;
|
||||||
|
using System.Web;
|
||||||
|
|
||||||
|
namespace Hutopy.Web.Features.Users.Handlers;
|
||||||
|
|
||||||
|
[PublicAPI]
|
||||||
|
public record ForgotPasswordRequest(
|
||||||
|
string Email);
|
||||||
|
|
||||||
|
[PublicAPI]
|
||||||
|
public class ForgotPasswordHandler(
|
||||||
|
IdentityUserManager userManager,
|
||||||
|
IEmailSender emailSender,
|
||||||
|
ILogger<ForgotPasswordHandler> logger,
|
||||||
|
IOptionsSnapshot<WebsiteOptions> options)
|
||||||
|
: Endpoint<ForgotPasswordRequest>
|
||||||
|
{
|
||||||
|
public override void Configure()
|
||||||
|
{
|
||||||
|
AllowAnonymous();
|
||||||
|
Post("/api/users/forgot-password");
|
||||||
|
Options(o => o.WithTags("Users"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task HandleAsync(
|
||||||
|
ForgotPasswordRequest request,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
// Find user by email
|
||||||
|
var user = await userManager.FindByEmailAsync(request.Email);
|
||||||
|
|
||||||
|
// Always return OK even if user not found to prevent email enumeration
|
||||||
|
if (user is null)
|
||||||
|
{
|
||||||
|
await SendOkAsync(ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate password reset token
|
||||||
|
var token = await userManager.GeneratePasswordResetTokenAsync(user);
|
||||||
|
|
||||||
|
// URL encode the token as it may contain characters that are not URL safe
|
||||||
|
var encodedToken = HttpUtility.UrlEncode(token);
|
||||||
|
|
||||||
|
// Build reset link
|
||||||
|
var resetLink = $"{options.FrontendBaseUrl;}/reset-password?email={HttpUtility.UrlEncode(request.Email)}&token={encodedToken}";
|
||||||
|
|
||||||
|
// TODO: Write a better email template
|
||||||
|
var subject = "Reset Your Password";
|
||||||
|
var message = new StringBuilder()
|
||||||
|
.AppendLine("<h1>Reset Your Password</h1>")
|
||||||
|
.AppendLine("<p>Please click the link below to reset your password:</p>")
|
||||||
|
.AppendLine($"<p><a href=\"{resetLink}\">Reset Password</a></p>")
|
||||||
|
.AppendLine("<p>If you did not request a password reset, please ignore this email.</p>")
|
||||||
|
.ToString();
|
||||||
|
|
||||||
|
// Send email
|
||||||
|
await emailSender.SendEmailAsync(request.Email, subject, message);
|
||||||
|
|
||||||
|
await SendOkAsync(ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
78
backend/src/Web/Features/Users/Handlers/Login.cs
Normal file
78
backend/src/Web/Features/Users/Handlers/Login.cs
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
using Hutopy.Web.Common.Security;
|
||||||
|
using Hutopy.Web.Features.Users.Data;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace Hutopy.Web.Features.Users.Handlers;
|
||||||
|
|
||||||
|
[PublicAPI]
|
||||||
|
public record LoginRequest(
|
||||||
|
string Email,
|
||||||
|
string Password);
|
||||||
|
|
||||||
|
[PublicAPI]
|
||||||
|
public record LoginResponse(
|
||||||
|
string AccessToken,
|
||||||
|
string RefreshToken);
|
||||||
|
|
||||||
|
[PublicAPI]
|
||||||
|
public class LoginHandler(
|
||||||
|
IdentityUserManager userManager,
|
||||||
|
IOptionsSnapshot<JwtOptions> jwtOptions)
|
||||||
|
: Endpoint<LoginRequest, LoginResponse>
|
||||||
|
{
|
||||||
|
public override void Configure()
|
||||||
|
{
|
||||||
|
AllowAnonymous();
|
||||||
|
Post("/api/users/login");
|
||||||
|
Options(o => o.WithTags("Users"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task HandleAsync(
|
||||||
|
LoginRequest request,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
// Find user by email
|
||||||
|
var user = await userManager.FindByEmailAsync(request.Email);
|
||||||
|
if (user is null)
|
||||||
|
{
|
||||||
|
await SendStringAsync(
|
||||||
|
"Invalid email or password",
|
||||||
|
401,
|
||||||
|
cancellation: ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify password
|
||||||
|
var isPasswordValid = await userManager.CheckPasswordAsync(user, request.Password);
|
||||||
|
if (!isPasswordValid)
|
||||||
|
{
|
||||||
|
await SendStringAsync(
|
||||||
|
"Invalid email or password",
|
||||||
|
401,
|
||||||
|
cancellation: ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new refresh token
|
||||||
|
user.RefreshToken = RefreshTokenGenerator.Next();
|
||||||
|
user.RefreshTokenExpiryTime = DateTime.UtcNow.Add(jwtOptions.Value.RefreshTokenLifetime);
|
||||||
|
await userManager.UpdateAsync(user);
|
||||||
|
|
||||||
|
// Generate JWT token
|
||||||
|
var accessToken = JwtTokenHelper.GenerateJwtToken(
|
||||||
|
expiresIn: jwtOptions.Value.Lifetime,
|
||||||
|
issuer: jwtOptions.Value.Issuer,
|
||||||
|
audience: jwtOptions.Value.Audience,
|
||||||
|
key: jwtOptions.Value.Key,
|
||||||
|
userId: user.Id.ToString(),
|
||||||
|
email: user.Email ?? string.Empty,
|
||||||
|
alias: user.Alias,
|
||||||
|
firstname: user.Firstname ?? string.Empty,
|
||||||
|
lastname: user.Lastname ?? string.Empty,
|
||||||
|
portraitUrl: user.PortraitUrl);
|
||||||
|
|
||||||
|
await SendOkAsync(
|
||||||
|
new LoginResponse(accessToken, user.RefreshToken),
|
||||||
|
cancellation: ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,6 @@
|
|||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using Hutopy.Web.Common.Security;
|
using Hutopy.Web.Common.Security;
|
||||||
using Hutopy.Web.Features.Users.Data;
|
using Hutopy.Web.Features.Users.Data;
|
||||||
using Microsoft.AspNetCore.Identity;
|
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using IdentityUser = Hutopy.Web.Features.Users.Data.IdentityUser;
|
using IdentityUser = Hutopy.Web.Features.Users.Data.IdentityUser;
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using Hutopy.Web.Common.Security;
|
using Hutopy.Web.Common.Security;
|
||||||
using Hutopy.Web.Features.Users.Data;
|
using Hutopy.Web.Features.Users.Data;
|
||||||
using Microsoft.AspNetCore.Identity;
|
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using IdentityUser = Hutopy.Web.Features.Users.Data.IdentityUser;
|
using IdentityUser = Hutopy.Web.Features.Users.Data.IdentityUser;
|
||||||
|
|
||||||
|
|||||||
97
backend/src/Web/Features/Users/Handlers/Register.cs
Normal file
97
backend/src/Web/Features/Users/Handlers/Register.cs
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
using Hutopy.Web.Common.Security;
|
||||||
|
using Hutopy.Web.Features.Users.Data;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using IdentityUser = Hutopy.Web.Features.Users.Data.IdentityUser;
|
||||||
|
|
||||||
|
namespace Hutopy.Web.Features.Users.Handlers;
|
||||||
|
|
||||||
|
[PublicAPI]
|
||||||
|
public record RegisterRequest(
|
||||||
|
string Email,
|
||||||
|
string Password,
|
||||||
|
string Name);
|
||||||
|
|
||||||
|
[PublicAPI]
|
||||||
|
public record RegisterResponse(
|
||||||
|
string AccessToken,
|
||||||
|
string RefreshToken);
|
||||||
|
|
||||||
|
[PublicAPI]
|
||||||
|
public class RegisterHandler(
|
||||||
|
IdentityUserManager userManager,
|
||||||
|
IOptionsSnapshot<JwtOptions> jwtOptions)
|
||||||
|
: Endpoint<RegisterRequest, RegisterResponse>
|
||||||
|
{
|
||||||
|
public override void Configure()
|
||||||
|
{
|
||||||
|
AllowAnonymous();
|
||||||
|
Post("/api/users/register");
|
||||||
|
Options(o => o.WithTags("Users"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task HandleAsync(
|
||||||
|
RegisterRequest request,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
// Check if user already exists
|
||||||
|
var existingUser = await userManager.FindByEmailAsync(request.Email);
|
||||||
|
if (existingUser is not null)
|
||||||
|
{
|
||||||
|
await SendStringAsync(
|
||||||
|
"A user with this email already exists",
|
||||||
|
400,
|
||||||
|
cancellation: ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create refresh token
|
||||||
|
var refreshToken = RefreshTokenGenerator.Next();
|
||||||
|
|
||||||
|
// Split name into firstname and lastname (if provided)
|
||||||
|
var nameParts = request.Name.Split(' ', 2);
|
||||||
|
var firstname = nameParts[0];
|
||||||
|
var lastname = nameParts.Length > 1 ? nameParts[1] : string.Empty;
|
||||||
|
|
||||||
|
// Create new user
|
||||||
|
var user = new IdentityUser
|
||||||
|
{
|
||||||
|
UserName = request.Email,
|
||||||
|
Email = request.Email,
|
||||||
|
Firstname = firstname,
|
||||||
|
Lastname = lastname,
|
||||||
|
Alias = request.Name,
|
||||||
|
RefreshToken = refreshToken,
|
||||||
|
RefreshTokenExpiryTime = DateTime.UtcNow.Add(jwtOptions.Value.RefreshTokenLifetime)
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await userManager.CreateAsync(
|
||||||
|
user,
|
||||||
|
request.Password);
|
||||||
|
|
||||||
|
if (!result.Succeeded)
|
||||||
|
{
|
||||||
|
await SendStringAsync(
|
||||||
|
result.Errors.First().Description,
|
||||||
|
400,
|
||||||
|
cancellation: ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate JWT token
|
||||||
|
var accessToken = JwtTokenHelper.GenerateJwtToken(
|
||||||
|
expiresIn: jwtOptions.Value.Lifetime,
|
||||||
|
issuer: jwtOptions.Value.Issuer,
|
||||||
|
audience: jwtOptions.Value.Audience,
|
||||||
|
key: jwtOptions.Value.Key,
|
||||||
|
userId: user.Id.ToString(),
|
||||||
|
email: user.Email ?? string.Empty,
|
||||||
|
alias: user.Alias,
|
||||||
|
firstname: user.Firstname ?? string.Empty,
|
||||||
|
lastname: user.Lastname ?? string.Empty,
|
||||||
|
portraitUrl: user.PortraitUrl);
|
||||||
|
|
||||||
|
await SendOkAsync(
|
||||||
|
new RegisterResponse(accessToken, user.RefreshToken),
|
||||||
|
cancellation: ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
55
backend/src/Web/Features/Users/Handlers/ResetPassword.cs
Normal file
55
backend/src/Web/Features/Users/Handlers/ResetPassword.cs
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
using Hutopy.Web.Features.Users.Data;
|
||||||
|
|
||||||
|
namespace Hutopy.Web.Features.Users.Handlers;
|
||||||
|
|
||||||
|
[PublicAPI]
|
||||||
|
public record ResetPasswordRequest(
|
||||||
|
string Email,
|
||||||
|
string Token,
|
||||||
|
string NewPassword);
|
||||||
|
|
||||||
|
[PublicAPI]
|
||||||
|
public class ResetPasswordHandler(
|
||||||
|
IdentityUserManager userManager)
|
||||||
|
: Endpoint<ResetPasswordRequest>
|
||||||
|
{
|
||||||
|
public override void Configure()
|
||||||
|
{
|
||||||
|
AllowAnonymous();
|
||||||
|
Post("/api/users/reset-password");
|
||||||
|
Options(o => o.WithTags("Users"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task HandleAsync(
|
||||||
|
ResetPasswordRequest request,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
// Find user by email
|
||||||
|
var user = await userManager.FindByEmailAsync(request.Email);
|
||||||
|
if (user is null)
|
||||||
|
{
|
||||||
|
await SendStringAsync(
|
||||||
|
"Invalid request",
|
||||||
|
400,
|
||||||
|
cancellation: ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset password with token
|
||||||
|
var result = await userManager.ResetPasswordAsync(
|
||||||
|
user,
|
||||||
|
request.Token,
|
||||||
|
request.NewPassword);
|
||||||
|
|
||||||
|
if (!result.Succeeded)
|
||||||
|
{
|
||||||
|
await SendStringAsync(
|
||||||
|
"Invalid or expired token",
|
||||||
|
400,
|
||||||
|
cancellation: ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await SendOkAsync(ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
50
backend/src/Web/Features/Users/Handlers/SetPassword.cs
Normal file
50
backend/src/Web/Features/Users/Handlers/SetPassword.cs
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
using Hutopy.Web.Common.Security;
|
||||||
|
using Hutopy.Web.Features.Users.Data;
|
||||||
|
|
||||||
|
namespace Hutopy.Web.Features.Users.Handlers;
|
||||||
|
|
||||||
|
[PublicAPI]
|
||||||
|
public record SetPasswordRequest(
|
||||||
|
string NewPassword);
|
||||||
|
|
||||||
|
[PublicAPI]
|
||||||
|
public class SetPasswordHandler(
|
||||||
|
IdentityUserManager userManager)
|
||||||
|
: Endpoint<SetPasswordRequest>
|
||||||
|
{
|
||||||
|
public override void Configure()
|
||||||
|
{
|
||||||
|
Post("/api/users/set-password");
|
||||||
|
Options(o => o.WithTags("Users"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task HandleAsync(
|
||||||
|
SetPasswordRequest request,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
// Get current user id from claims
|
||||||
|
var userId = User.GetUserId().ToString();
|
||||||
|
|
||||||
|
// Get user from database
|
||||||
|
var user = await userManager.FindByIdAsync(userId);
|
||||||
|
if (user is null)
|
||||||
|
{
|
||||||
|
await SendForbiddenAsync(ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var resetToken = await userManager.GeneratePasswordResetTokenAsync(user);
|
||||||
|
var result = await userManager.ResetPasswordAsync(user, resetToken, request.NewPassword);
|
||||||
|
|
||||||
|
if (!result.Succeeded)
|
||||||
|
{
|
||||||
|
await SendStringAsync(
|
||||||
|
result.Errors.First().Description,
|
||||||
|
400,
|
||||||
|
cancellation: ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await SendOkAsync(ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
29
backend/src/Web/Features/Users/Services/IEmailSender.cs
Normal file
29
backend/src/Web/Features/Users/Services/IEmailSender.cs
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace Hutopy.Web.Features.Users.Services;
|
||||||
|
|
||||||
|
public interface IEmailSender
|
||||||
|
{
|
||||||
|
Task SendEmailAsync(string email, string subject, string message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class EmailSender(ILogger<IEmailSender> logger)
|
||||||
|
: IEmailSender
|
||||||
|
{
|
||||||
|
|
||||||
|
public async Task SendEmailAsync(string email, string subject, string message)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
logger.LogInformation("Sending email to {Email} with subject: {Subject}", email, subject);
|
||||||
|
// TODO: Implement actual email sending logic
|
||||||
|
await Task.Delay(1000);
|
||||||
|
logger.LogInformation("Email sent successfully to {Email}", email);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Failed to send email to {Email}", email);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
backend/src/Web/Features/Users/WebsiteOptions.cs
Normal file
10
backend/src/Web/Features/Users/WebsiteOptions.cs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace Hutopy.Web.Features.Users;
|
||||||
|
|
||||||
|
public class WebsiteOptions
|
||||||
|
{
|
||||||
|
public const string SectionName = "Website";
|
||||||
|
|
||||||
|
public string FrontendBaseUrl { get; set; } = "https://localhost:5173";
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ using Hutopy.Web.Features.Messages;
|
|||||||
using Hutopy.Web.Features.Messages.Data;
|
using Hutopy.Web.Features.Messages.Data;
|
||||||
using Hutopy.Web.Features.Users;
|
using Hutopy.Web.Features.Users;
|
||||||
using Hutopy.Web.Features.Users.Data;
|
using Hutopy.Web.Features.Users.Data;
|
||||||
|
using Hutopy.Web.Features.Users.Services;
|
||||||
using Microsoft.AspNetCore.HttpOverrides;
|
using Microsoft.AspNetCore.HttpOverrides;
|
||||||
using NSwag;
|
using NSwag;
|
||||||
using NSwag.Generation.AspNetCore.Processors;
|
using NSwag.Generation.AspNetCore.Processors;
|
||||||
@@ -36,6 +37,8 @@ builder.Services.AddCors(options =>
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
builder.Services.AddTransient<IEmailSender, EmailSender>();
|
||||||
|
|
||||||
// Add services to the container.
|
// Add services to the container.
|
||||||
builder.Services.AddWebServices();
|
builder.Services.AddWebServices();
|
||||||
builder.Services.AddAuthorizationAndAuthentication(builder.Configuration);
|
builder.Services.AddAuthorizationAndAuthentication(builder.Configuration);
|
||||||
@@ -84,6 +87,7 @@ builder.AddMembershipModule(
|
|||||||
o => o.MigrationsHistoryTable("__EFMigrationsHistory", MembershipDbContext.SchemaName)));
|
o => o.MigrationsHistoryTable("__EFMigrationsHistory", MembershipDbContext.SchemaName)));
|
||||||
|
|
||||||
builder.Services.Configure<JwtOptions>(builder.Configuration.GetRequiredSection(JwtOptions.SectionName));
|
builder.Services.Configure<JwtOptions>(builder.Configuration.GetRequiredSection(JwtOptions.SectionName));
|
||||||
|
builder.Services.Configure<WebsiteOptions>(builder.Configuration.GetRequiredSection(WebsiteOptions.SectionName));
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
|
|||||||
@@ -6,5 +6,14 @@
|
|||||||
"SecretKey": "sk_test_51OoveVDrRyqXtNdBaOs1DFFja0XhrQtJoAo83uSySMuqw4Wyt9NsuugrIHRqet9a50cr5GvolpTP8EZuTSttcgYx00gOUPNDoI",
|
"SecretKey": "sk_test_51OoveVDrRyqXtNdBaOs1DFFja0XhrQtJoAo83uSySMuqw4Wyt9NsuugrIHRqet9a50cr5GvolpTP8EZuTSttcgYx00gOUPNDoI",
|
||||||
"WebhookSecret": "whsec_cee07ef14cf784850cab63567048b5326fec7fd29c03f4659476524f8299aff1",
|
"WebhookSecret": "whsec_cee07ef14cf784850cab63567048b5326fec7fd29c03f4659476524f8299aff1",
|
||||||
"HutopyRate": 0.05
|
"HutopyRate": 0.05
|
||||||
|
},
|
||||||
|
"Website": {
|
||||||
|
"FrontendBaseUrl": "https://localhost:5173"
|
||||||
|
},
|
||||||
|
"Authentication": {
|
||||||
|
"Jwt": {
|
||||||
|
"Lifetime": "00:05:00",
|
||||||
|
"RefreshTokenLifetime": "0.00:30:00"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,5 +4,8 @@
|
|||||||
},
|
},
|
||||||
"Stripe": {
|
"Stripe": {
|
||||||
"HutopyRate": 0.05
|
"HutopyRate": 0.05
|
||||||
|
},
|
||||||
|
"Website": {
|
||||||
|
"FrontendBaseUrl": "https://hutopy.ca"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -16,6 +16,9 @@ import LoginView from '../views/LoginView.vue';
|
|||||||
import PaymentCompleted from '../views/PaymentCompleted.vue';
|
import PaymentCompleted from '../views/PaymentCompleted.vue';
|
||||||
import Landing from '../views/main/Landing.vue';
|
import Landing from '../views/main/Landing.vue';
|
||||||
import CreateCreator from "@/views/creators/CreateCreator.vue";
|
import CreateCreator from "@/views/creators/CreateCreator.vue";
|
||||||
|
import RegisterView from "@/views/RegisterView.vue";
|
||||||
|
import ForgotPasswordView from "@/views/ForgotPasswordView.vue";
|
||||||
|
import ResetPasswordView from "@/views/ResetPasswordView.vue";
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
{
|
{
|
||||||
@@ -108,6 +111,25 @@ const routes = [
|
|||||||
component: CreateCreator,
|
component: CreateCreator,
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/register',
|
||||||
|
name: 'register',
|
||||||
|
component: RegisterView,
|
||||||
|
meta: { requiresAuth: false }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/forgot-password',
|
||||||
|
name: 'forgot-password',
|
||||||
|
component: ForgotPasswordView,
|
||||||
|
meta: { notAuthenticated: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/reset-password',
|
||||||
|
name: 'reset-password',
|
||||||
|
component: ResetPasswordView,
|
||||||
|
meta: { notAuthenticated: true },
|
||||||
|
props: (route) => ({ email: route.query.email, token: route.query.token })
|
||||||
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
@@ -136,4 +158,4 @@ router.beforeEach((to, from, next) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
@@ -244,6 +244,29 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
return isExpiring;
|
return isExpiring;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function changePassword(newPassword) {
|
||||||
|
console.log('changePassword called');
|
||||||
|
if (!isAuthenticated.value) {
|
||||||
|
throw new Error('User must be authenticated to change password');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!newPassword) {
|
||||||
|
throw new Error('New password is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await clientApi.post('api/users/set-password', {
|
||||||
|
newPassword
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Password changed successfully');
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Password change failed:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
accessToken,
|
accessToken,
|
||||||
refreshToken,
|
refreshToken,
|
||||||
@@ -255,6 +278,7 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
loginWithFacebook,
|
loginWithFacebook,
|
||||||
logout,
|
logout,
|
||||||
refresh,
|
refresh,
|
||||||
isTokenExpiringSoon
|
isTokenExpiringSoon,
|
||||||
|
changePassword
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
201
frontend/src/views/ForgotPasswordView.vue
Normal file
201
frontend/src/views/ForgotPasswordView.vue
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex min-h-full justify-center items-center w-full p-4">
|
||||||
|
<div class="flex flex-col gap-10 w-full max-w-[512px]">
|
||||||
|
<h1 class="text-2xl font-bold text-center">
|
||||||
|
{{ t('title') }}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p class="text-center text-gray-600 dark:text-gray-400">
|
||||||
|
{{ t('description') }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form @submit.prevent="handleForgotPassword" class="card">
|
||||||
|
<div class="card-content">
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div class="form-field">
|
||||||
|
<label for="email" class="form-label">{{ t('email') }}</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
v-model="email"
|
||||||
|
type="email"
|
||||||
|
class="form-input"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="primary w-full"
|
||||||
|
:disabled="isLoading"
|
||||||
|
>
|
||||||
|
<span v-if="isLoading" class="loading-spinner mr-2"></span>
|
||||||
|
{{ t('resetPassword') }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="text-center mt-4">
|
||||||
|
<router-link to="/login" class="text-sm text-blue-500">
|
||||||
|
{{ t('backToLogin') }}
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Success message -->
|
||||||
|
<div v-if="showSuccessMessage" class="notification success">
|
||||||
|
{{ t('resetEmailSent') }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error message -->
|
||||||
|
<div v-if="showErrorMessage" class="notification error">
|
||||||
|
{{ errorMessage }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { useClient } from '@/plugins/api.js';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const router = useRouter();
|
||||||
|
const clientApi = useClient();
|
||||||
|
|
||||||
|
const email = ref('');
|
||||||
|
const isLoading = ref(false);
|
||||||
|
const showSuccessMessage = ref(false);
|
||||||
|
const showErrorMessage = ref(false);
|
||||||
|
const errorMessage = ref('');
|
||||||
|
|
||||||
|
async function handleForgotPassword() {
|
||||||
|
// Reset notification states
|
||||||
|
showSuccessMessage.value = false;
|
||||||
|
showErrorMessage.value = false;
|
||||||
|
|
||||||
|
if (!email.value) {
|
||||||
|
errorMessage.value = t('emailRequired');
|
||||||
|
showErrorMessage.value = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Call password reset API
|
||||||
|
await clientApi.post('api/users/forgot-password', {
|
||||||
|
email: email.value.trim()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show success message
|
||||||
|
showSuccessMessage.value = true;
|
||||||
|
|
||||||
|
// Clear the form
|
||||||
|
email.value = '';
|
||||||
|
|
||||||
|
// Redirect to login after a short delay
|
||||||
|
setTimeout(() => {
|
||||||
|
router.push('/login');
|
||||||
|
}, 3000);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Password reset request failed:', error);
|
||||||
|
errorMessage.value = error.response?.data?.message || t('resetRequestFailed');
|
||||||
|
showErrorMessage.value = true;
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.card {
|
||||||
|
@apply bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-content {
|
||||||
|
@apply p-6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field {
|
||||||
|
@apply flex flex-col mb-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
@apply block mb-2 text-sm font-medium text-gray-700 dark:text-gray-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input {
|
||||||
|
@apply bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg
|
||||||
|
focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5
|
||||||
|
dark:bg-gray-700 dark:border-gray-600 dark:text-white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary {
|
||||||
|
@apply bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg text-sm px-5 py-2.5
|
||||||
|
focus:outline-none focus:ring-4 focus:ring-blue-300 disabled:opacity-50 disabled:cursor-not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification {
|
||||||
|
@apply fixed bottom-4 right-4 p-4 mb-4 rounded-lg text-sm;
|
||||||
|
animation: fade-in 0.3s ease-in, fade-out 0.3s ease-out 5s forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success {
|
||||||
|
@apply bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
@apply bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spinner {
|
||||||
|
@apply inline-block h-4 w-4 animate-spin rounded-full border-2 border-solid border-current border-r-transparent align-[-0.125em] motion-reduce:animate-[spin_1.5s_linear_infinite];
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fade-in {
|
||||||
|
from { opacity: 0; transform: translateY(10px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fade-out {
|
||||||
|
from { opacity: 1; }
|
||||||
|
to { opacity: 0; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<i18n>
|
||||||
|
{
|
||||||
|
"en": {
|
||||||
|
"title": "Forgot Password",
|
||||||
|
"description": "Enter your email address and we'll send you a link to reset your password.",
|
||||||
|
"email": "Email",
|
||||||
|
"resetPassword": "Reset Password",
|
||||||
|
"backToLogin": "Back to Login",
|
||||||
|
"resetEmailSent": "Password reset email sent. Please check your inbox.",
|
||||||
|
"resetRequestFailed": "Failed to request password reset. Please try again.",
|
||||||
|
"emailRequired": "Email is required."
|
||||||
|
},
|
||||||
|
"fr": {
|
||||||
|
"title": "Mot de passe oublié",
|
||||||
|
"description": "Entrez votre adresse e-mail et nous vous enverrons un lien pour réinitialiser votre mot de passe.",
|
||||||
|
"email": "Email",
|
||||||
|
"resetPassword": "Réinitialiser le mot de passe",
|
||||||
|
"backToLogin": "Retour à la connexion",
|
||||||
|
"resetEmailSent": "Email de réinitialisation du mot de passe envoyé. Veuillez vérifier votre boîte de réception.",
|
||||||
|
"resetRequestFailed": "Échec de la demande de réinitialisation du mot de passe. Veuillez réessayer.",
|
||||||
|
"emailRequired": "L'email est requis."
|
||||||
|
},
|
||||||
|
"es": {
|
||||||
|
"title": "Olvidé mi contraseña",
|
||||||
|
"description": "Ingrese su dirección de correo electrónico y le enviaremos un enlace para restablecer su contraseña.",
|
||||||
|
"email": "Correo electrónico",
|
||||||
|
"resetPassword": "Restablecer contraseña",
|
||||||
|
"backToLogin": "Volver al inicio de sesión",
|
||||||
|
"resetEmailSent": "Correo electrónico de restablecimiento de contraseña enviado. Por favor revise su bandeja de entrada.",
|
||||||
|
"resetRequestFailed": "No se pudo solicitar el restablecimiento de contraseña. Por favor, inténtelo de nuevo.",
|
||||||
|
"emailRequired": "El correo electrónico es obligatorio."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</i18n>
|
||||||
@@ -1,3 +1,83 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex min-h-full justify-center items-center w-full p-4">
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-10 w-full max-w-[512px]">
|
||||||
|
<h1 class="text-2xl font-bold login-text text-center">
|
||||||
|
{{ t('title') }}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<google-login :callback="googleCallback"
|
||||||
|
popup-type="TOKEN">
|
||||||
|
<button class="secondary">
|
||||||
|
<v-icon class="mr-2">mdi-google</v-icon>
|
||||||
|
{{ t('continueWithGoogle') }}
|
||||||
|
</button>
|
||||||
|
</google-login>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center my-4">
|
||||||
|
<div class="flex-grow h-[1px] bg-gray-200"></div>
|
||||||
|
<span class="px-3 text-gray-300 uppercase text-sm font-semibold">{{ t('orContinueWith') }}</span>
|
||||||
|
<div class="flex-grow h-[1px] bg-gray-200"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add email/password form -->
|
||||||
|
<v-form @submit.prevent="handleLocalLogin">
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<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
|
||||||
|
>
|
||||||
|
<template v-slot:append-inner>
|
||||||
|
<v-icon
|
||||||
|
@click="showPassword = !showPassword"
|
||||||
|
class="visibility-toggle"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{{ showPassword ? 'mdi-eye-off' : 'mdi-eye' }}
|
||||||
|
</v-icon>
|
||||||
|
</template>
|
||||||
|
</v-text-field>
|
||||||
|
|
||||||
|
<v-btn type="submit" color="primary" block>
|
||||||
|
{{ t('signIn') }}
|
||||||
|
</v-btn>
|
||||||
|
|
||||||
|
<div class="text-center">
|
||||||
|
<a @click="forgotPassword" class="text-sm text-blue-500 cursor-pointer">
|
||||||
|
{{ t('forgotPassword') }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-center mt-4">
|
||||||
|
{{ t('noAccount') }}
|
||||||
|
<router-link to="/register" class="text-blue-500">
|
||||||
|
{{ t('register') }}
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-form>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error notification -->
|
||||||
|
<v-snackbar v-model="errorSnackBar" color="error">
|
||||||
|
{{ t('loginFailed') }}
|
||||||
|
</v-snackbar>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import {ref} from 'vue';
|
import {ref} from 'vue';
|
||||||
import {GoogleLogin} from "vue3-google-login";
|
import {GoogleLogin} from "vue3-google-login";
|
||||||
@@ -9,7 +89,11 @@ const {t} = useI18n();
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
|
const email = ref('');
|
||||||
|
const password = ref('');
|
||||||
const errorSnackBar = ref(false);
|
const errorSnackBar = ref(false);
|
||||||
|
const showPassword = ref(false);
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
returnUrl: {
|
returnUrl: {
|
||||||
type: String,
|
type: String,
|
||||||
@@ -17,6 +101,16 @@ const props = defineProps({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function handleLocalLogin() {
|
||||||
|
try {
|
||||||
|
await authStore.login(email.value, password.value);
|
||||||
|
await router.push(props.returnUrl);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Login failed:', error);
|
||||||
|
errorSnackBar.value = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function googleCallback(token) {
|
async function googleCallback(token) {
|
||||||
try {
|
try {
|
||||||
const response = await authStore.loginWithGoogle(JSON.stringify(token));
|
const response = await authStore.loginWithGoogle(JSON.stringify(token));
|
||||||
@@ -31,47 +125,72 @@ async function googleCallback(token) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function forgotPassword() {
|
||||||
|
router.push('/forgot-password');
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="flex min-h-full justify-center items-center p-20 w-full">
|
|
||||||
<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="text-2xl font-bold login-text text-center ">
|
|
||||||
{{ t('title') }}
|
|
||||||
</h1>
|
|
||||||
<div class="flex flex-col gap-4">
|
|
||||||
<google-login :callback="googleCallback"
|
|
||||||
popup-type="TOKEN">
|
|
||||||
<button class="secondary">
|
|
||||||
<v-icon left>mdi-google</v-icon>
|
|
||||||
Google
|
|
||||||
</button>
|
|
||||||
</google-login>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
<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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode support if needed */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.custom-divider {
|
||||||
|
background-color: rgb(75, 85, 99); /* Equivalent to gray-600 */
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<i18n>
|
<i18n>
|
||||||
{
|
{
|
||||||
"en": {
|
"en": {
|
||||||
"title": "Sign in to Hutopy",
|
"title": "Sign in",
|
||||||
"alt": "Hutopy Login"
|
"alt": "Login",
|
||||||
|
"email": "Email",
|
||||||
|
"password": "Password",
|
||||||
|
"signIn": "Connect",
|
||||||
|
"forgotPassword": "Forgot password?",
|
||||||
|
"orContinueWith": "Or",
|
||||||
|
"noAccount": "Don't have an account?",
|
||||||
|
"register": "Register",
|
||||||
|
"loginFailed": "Login failed. Please check your credentials.",
|
||||||
|
"continueWithGoogle": "Continue with Google"
|
||||||
},
|
},
|
||||||
"fr": {
|
"fr": {
|
||||||
"title": "Connectez-vous à Hutopy",
|
"title": "Se connecter",
|
||||||
"alt": "Connexion Hutopy"
|
"alt": "Connexion",
|
||||||
|
"email": "Email",
|
||||||
|
"password": "Mot de passe",
|
||||||
|
"signIn": "Connexion",
|
||||||
|
"forgotPassword": "Mot de passe oublié?",
|
||||||
|
"orContinueWith": "Ou",
|
||||||
|
"noAccount": "Vous n'avez pas de compte?",
|
||||||
|
"register": "S'inscrire",
|
||||||
|
"loginFailed": "Échec de la connexion. Veuillez vérifier vos identifiants.",
|
||||||
|
"continueWithGoogle": "Continuer avec Google"
|
||||||
},
|
},
|
||||||
"es": {
|
"es": {
|
||||||
"title": "Iniciar sesión en Hutopy",
|
"title": "Iniciar sesión",
|
||||||
"alt": "Inicio de sesión Hutopy"
|
"alt": "Inicio de sesión",
|
||||||
|
"email": "Correo electrónico",
|
||||||
|
"password": "Contraseña",
|
||||||
|
"signIn": "Conéctate",
|
||||||
|
"forgotPassword": "¿Olvidó su contraseña?",
|
||||||
|
"orContinueWith": "o",
|
||||||
|
"noAccount": "¿No tiene una cuenta?",
|
||||||
|
"register": "Registrarse",
|
||||||
|
"loginFailed": "Error de inicio de sesión. Por favor, compruebe sus credenciales.",
|
||||||
|
"continueWithGoogle": "Continuar con Google"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</i18n>
|
</i18n>
|
||||||
195
frontend/src/views/RegisterView.vue
Normal file
195
frontend/src/views/RegisterView.vue
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex min-h-full justify-center items-center p-20 w-full">
|
||||||
|
<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="text-2xl font-bold login-text text-center ">
|
||||||
|
{{ 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"
|
||||||
|
>
|
||||||
|
{{ showPassword ? 'mdi-eye-off' : 'mdi-eye' }}
|
||||||
|
</v-icon>
|
||||||
|
</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"
|
||||||
|
>
|
||||||
|
{{ showConfirmPassword ? 'mdi-eye-off' : 'mdi-eye' }}
|
||||||
|
</v-icon>
|
||||||
|
</template>
|
||||||
|
</v-text-field>
|
||||||
|
|
||||||
|
<v-btn type="submit" color="primary" block :loading="isLoading">
|
||||||
|
{{ t('register') }}
|
||||||
|
</v-btn>
|
||||||
|
|
||||||
|
<div class="text-center mt-4">
|
||||||
|
{{ 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';
|
||||||
|
|
||||||
|
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>
|
||||||
254
frontend/src/views/ResetPasswordView.vue
Normal file
254
frontend/src/views/ResetPasswordView.vue
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex min-h-full justify-center items-center w-full p-4">
|
||||||
|
<div class="flex flex-col gap-10 w-full max-w-[512px]">
|
||||||
|
<h1 class="text-2xl font-bold text-center">
|
||||||
|
{{ t('title') }}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<form @submit.prevent="handleResetPassword" class="card">
|
||||||
|
<div class="card-content">
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div class="form-field">
|
||||||
|
<label for="password" class="form-label">{{ t('newPassword') }}</label>
|
||||||
|
<div class="relative">
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
v-model="password"
|
||||||
|
:type="showPassword ? 'text' : 'password'"
|
||||||
|
class="form-input"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="showPassword = !showPassword"
|
||||||
|
class="password-toggle"
|
||||||
|
>
|
||||||
|
<v-icon size="small">{{ showPassword ? 'mdi-eye-off' : 'mdi-eye' }}</v-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-500 mt-1">{{ t('passwordRequirements') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-field">
|
||||||
|
<label for="confirmPassword" class="form-label">{{ t('confirmPassword') }}</label>
|
||||||
|
<div class="relative">
|
||||||
|
<input
|
||||||
|
id="confirmPassword"
|
||||||
|
v-model="confirmPassword"
|
||||||
|
:type="showConfirmPassword ? 'text' : 'password'"
|
||||||
|
class="form-input"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="showConfirmPassword = !showConfirmPassword"
|
||||||
|
class="password-toggle"
|
||||||
|
>
|
||||||
|
<v-icon size="small">{{ showConfirmPassword ? 'mdi-eye-off' : 'mdi-eye' }}</v-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="errorMessage" class="error-message">
|
||||||
|
{{ errorMessage }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="primary w-full"
|
||||||
|
:disabled="isLoading"
|
||||||
|
>
|
||||||
|
<span v-if="isLoading" class="loading-spinner mr-2"></span>
|
||||||
|
{{ t('resetPassword') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Success message -->
|
||||||
|
<div v-if="success" class="success-message">
|
||||||
|
{{ t('passwordResetSuccess') }}
|
||||||
|
<div class="mt-4">
|
||||||
|
<router-link to="/login" class="text-blue-500">
|
||||||
|
{{ t('proceedToLogin') }}
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useRouter, useRoute } from 'vue-router';
|
||||||
|
import { useClient } from '@/plugins/api.js';
|
||||||
|
import { VIcon } from 'vuetify/components';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const router = useRouter();
|
||||||
|
const route = useRoute();
|
||||||
|
const clientApi = useClient();
|
||||||
|
|
||||||
|
const email = ref('');
|
||||||
|
const token = ref('');
|
||||||
|
const password = ref('');
|
||||||
|
const confirmPassword = ref('');
|
||||||
|
const showPassword = ref(false);
|
||||||
|
const showConfirmPassword = ref(false);
|
||||||
|
const isLoading = ref(false);
|
||||||
|
const errorMessage = ref('');
|
||||||
|
const success = ref(false);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// Get email and token from URL query parameters
|
||||||
|
email.value = route.query.email || '';
|
||||||
|
token.value = route.query.token || '';
|
||||||
|
|
||||||
|
// Validate that we have both email and token
|
||||||
|
if (!email.value || !token.value) {
|
||||||
|
errorMessage.value = t('invalidResetLink');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleResetPassword() {
|
||||||
|
// Reset error message
|
||||||
|
errorMessage.value = '';
|
||||||
|
|
||||||
|
// Validate passwords match
|
||||||
|
if (password.value !== confirmPassword.value) {
|
||||||
|
errorMessage.value = t('passwordsDoNotMatch');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate password length
|
||||||
|
if (password.value.length < 8) {
|
||||||
|
errorMessage.value = t('passwordTooShort');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate that we have email and token
|
||||||
|
if (!email.value || !token.value) {
|
||||||
|
errorMessage.value = t('invalidResetLink');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Call password reset API
|
||||||
|
await clientApi.post('api/users/reset-password', {
|
||||||
|
email: email.value,
|
||||||
|
token: token.value,
|
||||||
|
newPassword: password.value
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show success message
|
||||||
|
success.value = true;
|
||||||
|
|
||||||
|
// Clear form fields
|
||||||
|
password.value = '';
|
||||||
|
confirmPassword.value = '';
|
||||||
|
|
||||||
|
// Redirect to login after a delay
|
||||||
|
setTimeout(() => {
|
||||||
|
router.push('/login');
|
||||||
|
}, 5000);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Password reset failed:', error);
|
||||||
|
errorMessage.value = error.response?.data || t('resetFailed');
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.card {
|
||||||
|
@apply bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-content {
|
||||||
|
@apply p-6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field {
|
||||||
|
@apply flex flex-col mb-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
@apply block mb-2 text-sm font-medium text-gray-700 dark:text-gray-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input {
|
||||||
|
@apply bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg
|
||||||
|
focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5
|
||||||
|
dark:bg-gray-700 dark:border-gray-600 dark:text-white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary {
|
||||||
|
@apply bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg text-sm px-5 py-2.5
|
||||||
|
focus:outline-none focus:ring-4 focus:ring-blue-300 disabled:opacity-50 disabled:cursor-not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
@apply p-4 mb-4 text-sm text-red-800 rounded-lg bg-red-100 dark:bg-red-900 dark:text-red-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-message {
|
||||||
|
@apply p-4 mb-4 text-sm text-green-800 rounded-lg bg-green-100 dark:bg-green-900 dark:text-green-300 text-center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-toggle {
|
||||||
|
@apply absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.loading-spinner {
|
||||||
|
@apply inline-block h-4 w-4 animate-spin rounded-full border-2 border-solid border-current border-r-transparent align-[-0.125em] motion-reduce:animate-[spin_1.5s_linear_infinite];
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<i18n>
|
||||||
|
{
|
||||||
|
"en": {
|
||||||
|
"title": "Reset Your Password",
|
||||||
|
"newPassword": "New Password",
|
||||||
|
"confirmPassword": "Confirm Password",
|
||||||
|
"passwordRequirements": "Password must be at least 8 characters",
|
||||||
|
"resetPassword": "Reset Password",
|
||||||
|
"passwordResetSuccess": "Your password has been reset successfully!",
|
||||||
|
"proceedToLogin": "Proceed to Login",
|
||||||
|
"passwordsDoNotMatch": "Passwords do not match",
|
||||||
|
"passwordTooShort": "Password must be at least 8 characters long",
|
||||||
|
"resetFailed": "Password reset failed. Please try again or request a new reset link.",
|
||||||
|
"invalidResetLink": "Invalid or expired reset link. Please request a new password reset."
|
||||||
|
},
|
||||||
|
"fr": {
|
||||||
|
"title": "Réinitialiser Votre Mot de Passe",
|
||||||
|
"newPassword": "Nouveau Mot de Passe",
|
||||||
|
"confirmPassword": "Confirmer le Mot de Passe",
|
||||||
|
"passwordRequirements": "Le mot de passe doit comporter au moins 8 caractères",
|
||||||
|
"resetPassword": "Réinitialiser le Mot de Passe",
|
||||||
|
"passwordResetSuccess": "Votre mot de passe a été réinitialisé avec succès!",
|
||||||
|
"proceedToLogin": "Procéder à la Connexion",
|
||||||
|
"passwordsDoNotMatch": "Les mots de passe ne correspondent pas",
|
||||||
|
"passwordTooShort": "Le mot de passe doit comporter au moins 8 caractères",
|
||||||
|
"resetFailed": "Échec de la réinitialisation du mot de passe. Veuillez réessayer ou demander un nouveau lien de réinitialisation.",
|
||||||
|
"invalidResetLink": "Lien de réinitialisation invalide ou expiré. Veuillez demander une nouvelle réinitialisation de mot de passe."
|
||||||
|
},
|
||||||
|
"es": {
|
||||||
|
"title": "Restablecer su Contraseña",
|
||||||
|
"newPassword": "Nueva Contraseña",
|
||||||
|
"confirmPassword": "Confirmar Contraseña",
|
||||||
|
"passwordRequirements": "La contraseña debe tener al menos 8 caracteres",
|
||||||
|
"resetPassword": "Restablecer Contraseña",
|
||||||
|
"passwordResetSuccess": "¡Su contraseña ha sido restablecida con éxito!",
|
||||||
|
"proceedToLogin": "Proceder al Inicio de Sesión",
|
||||||
|
"passwordsDoNotMatch": "Las contraseñas no coinciden",
|
||||||
|
"passwordTooShort": "La contraseña debe tener al menos 8 caracteres",
|
||||||
|
"resetFailed": "Error al restablecer la contraseña. Inténtelo de nuevo o solicite un nuevo enlace de restablecimiento.",
|
||||||
|
"invalidResetLink": "Enlace de restablecimiento inválido o caducado. Solicite un nuevo restablecimiento de contraseña."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</i18n>
|
||||||
@@ -3,10 +3,12 @@ import {ref, markRaw} from 'vue';
|
|||||||
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 {useClient} from '@/plugins/api.js';
|
import {useClient} from '@/plugins/api.js';
|
||||||
|
import {useRouter} from 'vue-router';
|
||||||
import SocialsDialog from './creators/SocialsDialog.vue';
|
import SocialsDialog from './creators/SocialsDialog.vue';
|
||||||
import AliasDialog from "@/views/profile/account/AliasDialog.vue";
|
import AliasDialog from "@/views/profile/account/AliasDialog.vue";
|
||||||
import FullnameDialog from "@/views/profile/account/FullnameDialog.vue";
|
import FullnameDialog from "@/views/profile/account/FullnameDialog.vue";
|
||||||
import EmailDialog from "@/views/profile/account/EmailDialog.vue";
|
import EmailDialog from "@/views/profile/account/EmailDialog.vue";
|
||||||
|
import ChangePasswordDialog from "@/views/profile/account/ChangePasswordDialog.vue";
|
||||||
import ChangeStripeIdDialog from '@/views/profile/creators/ChangeStripeIdDialog.vue';
|
import ChangeStripeIdDialog from '@/views/profile/creators/ChangeStripeIdDialog.vue';
|
||||||
import ChangeNameDialog from '@/views/profile/creators/ChangeNameDialog.vue';
|
import ChangeNameDialog from '@/views/profile/creators/ChangeNameDialog.vue';
|
||||||
import ChangeSlugDialog from '@/views/profile/creators/ChangeSlugDialog.vue';
|
import ChangeSlugDialog from '@/views/profile/creators/ChangeSlugDialog.vue';
|
||||||
@@ -25,6 +27,7 @@ import {useI18n} from 'vue-i18n';
|
|||||||
import QRCodeVue from 'qrcode.vue';
|
import QRCodeVue from 'qrcode.vue';
|
||||||
|
|
||||||
const {t} = useI18n();
|
const {t} = useI18n();
|
||||||
|
const router = useRouter();
|
||||||
const userProfileStore = useUserProfileStore()
|
const userProfileStore = useUserProfileStore()
|
||||||
const creatorProfileStore = useCreatorProfileStore();
|
const creatorProfileStore = useCreatorProfileStore();
|
||||||
const baseURL = window.location.origin;
|
const baseURL = window.location.origin;
|
||||||
@@ -102,6 +105,7 @@ const deleteDialogShown = ref(false);
|
|||||||
|
|
||||||
const componentsMap = {
|
const componentsMap = {
|
||||||
EmailDialog: markRaw(EmailDialog),
|
EmailDialog: markRaw(EmailDialog),
|
||||||
|
ChangePasswordDialog: markRaw(ChangePasswordDialog),
|
||||||
SocialsDialog: markRaw(SocialsDialog),
|
SocialsDialog: markRaw(SocialsDialog),
|
||||||
ChangeSlugDialog: markRaw(ChangeSlugDialog),
|
ChangeSlugDialog: markRaw(ChangeSlugDialog),
|
||||||
ChangeNameDialog: markRaw(ChangeNameDialog),
|
ChangeNameDialog: markRaw(ChangeNameDialog),
|
||||||
@@ -251,6 +255,14 @@ async function deconfigureStripe() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<button class="action" @click="openDialog('ChangePasswordDialog')">
|
||||||
|
<span class="label">{{ t('changePassword') }}</span>
|
||||||
|
<span class="value">••••••••</span>
|
||||||
|
<span class="chevron"><v-icon>mdi-chevron-right</v-icon></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-if="creatorProfileStore.hasCreator">
|
<template v-if="creatorProfileStore.hasCreator">
|
||||||
@@ -665,6 +677,7 @@ async function deconfigureStripe() {
|
|||||||
"fullName": "Full Name",
|
"fullName": "Full Name",
|
||||||
"alias": "Alias",
|
"alias": "Alias",
|
||||||
"email": "Email",
|
"email": "Email",
|
||||||
|
"changePassword": "Set Password",
|
||||||
"creatorInfo": "Creator Information",
|
"creatorInfo": "Creator Information",
|
||||||
"dangerZone": "Danger Zone",
|
"dangerZone": "Danger Zone",
|
||||||
"dangerZoneWarning": "The actions below can have significant impacts on your creator page. Please proceed with caution.",
|
"dangerZoneWarning": "The actions below can have significant impacts on your creator page. Please proceed with caution.",
|
||||||
@@ -693,6 +706,7 @@ async function deconfigureStripe() {
|
|||||||
"fullName": "Nom Complet",
|
"fullName": "Nom Complet",
|
||||||
"alias": "Alias",
|
"alias": "Alias",
|
||||||
"email": "Email",
|
"email": "Email",
|
||||||
|
"changePassword": "Définir le mot de passe",
|
||||||
"creatorInfo": "Informations du Créateur",
|
"creatorInfo": "Informations du Créateur",
|
||||||
"dangerZone": "Zone de Danger",
|
"dangerZone": "Zone de Danger",
|
||||||
"dangerZoneWarning": "Les actions ci-dessous peuvent avoir des impacts significatifs sur votre page de créateur. Veuillez procéder avec précaution.",
|
"dangerZoneWarning": "Les actions ci-dessous peuvent avoir des impacts significatifs sur votre page de créateur. Veuillez procéder avec précaution.",
|
||||||
@@ -721,6 +735,7 @@ async function deconfigureStripe() {
|
|||||||
"fullName": "Nombre Completo",
|
"fullName": "Nombre Completo",
|
||||||
"alias": "Alias",
|
"alias": "Alias",
|
||||||
"email": "Correo Electrónico",
|
"email": "Correo Electrónico",
|
||||||
|
"changePassword": "Establecer contraseña",
|
||||||
"creatorInfo": "Información del Creador",
|
"creatorInfo": "Información del Creador",
|
||||||
"dangerZone": "Zona de Peligro",
|
"dangerZone": "Zona de Peligro",
|
||||||
"dangerZoneWarning": "Las acciones a continuación pueden tener impactos significativos en tu página de creador. Por favor, procede con precaución.",
|
"dangerZoneWarning": "Las acciones a continuación pueden tener impactos significativos en tu página de creador. Por favor, procede con precaución.",
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="card dialog">
|
|
||||||
<div class="card-title">
|
|
||||||
{{ t('title') }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card-content">
|
|
||||||
<v-text-field
|
|
||||||
variant="outlined"
|
|
||||||
v-model="address"
|
|
||||||
:label="t('label')"
|
|
||||||
></v-text-field>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card-actions">
|
|
||||||
<button class="secondary" @click="requestClose">
|
|
||||||
{{ t('cancel') }}
|
|
||||||
</button>
|
|
||||||
<button class="primary" @click="requestSave">
|
|
||||||
{{ t('save') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import {ref} from 'vue';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
|
||||||
const props = defineProps(['address'])
|
|
||||||
const emit = defineEmits(['close', 'save'])
|
|
||||||
|
|
||||||
const address = ref(props.address);
|
|
||||||
|
|
||||||
const requestClose = () => emit('close')
|
|
||||||
const requestSave = () => emit('save', address.value)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<i18n>
|
|
||||||
{
|
|
||||||
"en": {
|
|
||||||
"title": "Address",
|
|
||||||
"label": "Your address"
|
|
||||||
},
|
|
||||||
"fr": {
|
|
||||||
"title": "Adresse",
|
|
||||||
"label": "Votre adresse"
|
|
||||||
},
|
|
||||||
"es": {
|
|
||||||
"title": "Dirección",
|
|
||||||
"label": "Tu dirección"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</i18n>
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="card dialog">
|
|
||||||
<div class="card-title">
|
|
||||||
{{ t('title') }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card-content">
|
|
||||||
<v-text-field
|
|
||||||
variant="outlined"
|
|
||||||
v-model="birthDate"
|
|
||||||
:label="t('label')"
|
|
||||||
></v-text-field>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card-actions">
|
|
||||||
<button class="secondary" @click="requestClose">
|
|
||||||
{{ t('cancel') }}
|
|
||||||
</button>
|
|
||||||
<button class="primary" @click="requestSave">
|
|
||||||
{{ t('save') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import {ref} from 'vue';
|
|
||||||
import {useI18n} from 'vue-i18n';
|
|
||||||
|
|
||||||
const {t} = useI18n();
|
|
||||||
const props = defineProps(['birthDate'])
|
|
||||||
const emit = defineEmits(['close', 'save'])
|
|
||||||
|
|
||||||
const birthDate = ref(props.birthDate)
|
|
||||||
|
|
||||||
const requestClose = () => emit('close')
|
|
||||||
const requestSave = () => emit('save', birthDate.value)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<i18n>
|
|
||||||
{
|
|
||||||
"en": {
|
|
||||||
"title": "Birthday",
|
|
||||||
"label": "Your birthday"
|
|
||||||
},
|
|
||||||
"fr": {
|
|
||||||
"title": "Date de naissance",
|
|
||||||
"label": "Votre date de naissance"
|
|
||||||
},
|
|
||||||
"es": {
|
|
||||||
"title": "Fecha de nacimiento",
|
|
||||||
"label": "Tu fecha de nacimiento"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</i18n>
|
|
||||||
183
frontend/src/views/profile/account/ChangePasswordDialog.vue
Normal file
183
frontend/src/views/profile/account/ChangePasswordDialog.vue
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
<template>
|
||||||
|
<div class="card dialog">
|
||||||
|
|
||||||
|
<div class="card-title">
|
||||||
|
{{ t('changePassword') }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-content">
|
||||||
|
<p class="description mb-4">{{ t('passwordDescription') }}</p>
|
||||||
|
|
||||||
|
<v-text-field
|
||||||
|
v-model="newPassword"
|
||||||
|
:label="t('newPassword')"
|
||||||
|
:type="showNewPassword ? 'text' : 'password'"
|
||||||
|
variant="outlined"
|
||||||
|
required
|
||||||
|
:hint="t('passwordRequirements')"
|
||||||
|
>
|
||||||
|
<template v-slot:append-inner>
|
||||||
|
<v-icon
|
||||||
|
@click="showNewPassword = !showNewPassword"
|
||||||
|
class="visibility-toggle"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{{ showNewPassword ? 'mdi-eye-off' : 'mdi-eye' }}
|
||||||
|
</v-icon>
|
||||||
|
</template>
|
||||||
|
</v-text-field>
|
||||||
|
|
||||||
|
<v-text-field
|
||||||
|
v-model="confirmPassword"
|
||||||
|
:label="t('confirmPassword')"
|
||||||
|
:type="showConfirmPassword ? 'text' : 'password'"
|
||||||
|
variant="outlined"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<template v-slot:append-inner>
|
||||||
|
<v-icon
|
||||||
|
@click="showConfirmPassword = !showConfirmPassword"
|
||||||
|
class="visibility-toggle"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{{ showConfirmPassword ? 'mdi-eye-off' : 'mdi-eye' }}
|
||||||
|
</v-icon>
|
||||||
|
</template>
|
||||||
|
</v-text-field>
|
||||||
|
|
||||||
|
<div v-if="errorMessage" class="error-message mb-4">
|
||||||
|
{{ errorMessage }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-actions">
|
||||||
|
<button class="secondary" @click="$emit('closeRequested')">
|
||||||
|
{{ t('cancel') }}
|
||||||
|
</button>
|
||||||
|
<button class="primary" @click="handleChangePassword" :disabled="isLoading">
|
||||||
|
{{ t('save') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { useAuthStore } from '@/stores/authStore.js';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
const emit = defineEmits(['closeRequested']);
|
||||||
|
|
||||||
|
const newPassword = ref('');
|
||||||
|
const confirmPassword = ref('');
|
||||||
|
const isLoading = ref(false);
|
||||||
|
const errorMessage = ref('');
|
||||||
|
const showNewPassword = ref(false);
|
||||||
|
const showConfirmPassword = ref(false);
|
||||||
|
|
||||||
|
async function handleChangePassword() {
|
||||||
|
// Clear previous error
|
||||||
|
errorMessage.value = '';
|
||||||
|
|
||||||
|
// Validate passwords match
|
||||||
|
if (newPassword.value !== confirmPassword.value) {
|
||||||
|
errorMessage.value = t('passwordsDoNotMatch');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate password length
|
||||||
|
if (newPassword.value.length < 8) {
|
||||||
|
errorMessage.value = t('passwordTooShort');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Pass empty string for current password since we're already authenticated
|
||||||
|
// This will use the set-password endpoint for OAuth users
|
||||||
|
await authStore.changePassword(newPassword.value);
|
||||||
|
|
||||||
|
// Success - close dialog
|
||||||
|
emit('closeRequested');
|
||||||
|
|
||||||
|
// You could also emit a success event if needed
|
||||||
|
// emit('success');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to change password:', error);
|
||||||
|
// Use error message from response if available, or the error message itself, or fallback
|
||||||
|
errorMessage.value = error.response?.data || error.message || t('passwordUpdateFailed');
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
|
||||||
|
.dialog {
|
||||||
|
@apply max-w-md mx-auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
@apply text-red-500 text-sm mt-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visibility-toggle {
|
||||||
|
@apply cursor-pointer;
|
||||||
|
@apply transition-opacity duration-300;
|
||||||
|
@apply opacity-60 hover:opacity-100;
|
||||||
|
@apply absolute right-2 top-1/2 transform -translate-y-1/2;
|
||||||
|
@apply z-10;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Override Vuetify's default padding to accommodate our icon */
|
||||||
|
:deep(.v-field__append-inner) {
|
||||||
|
padding-inline-start: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<i18n>
|
||||||
|
{
|
||||||
|
"en": {
|
||||||
|
"changePassword": "Set Password",
|
||||||
|
"newPassword": "New Password",
|
||||||
|
"confirmPassword": "Confirm New Password",
|
||||||
|
"passwordRequirements": "Password must be at least 8 characters",
|
||||||
|
"passwordDescription": "Setting a password allows you to log in directly with your email and password, even if you originally signed up with Google.",
|
||||||
|
"save": "Save",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"passwordsDoNotMatch": "New passwords do not match",
|
||||||
|
"passwordTooShort": "Password must be at least 8 characters long",
|
||||||
|
"passwordUpdateFailed": "Failed to update password. Please try again."
|
||||||
|
},
|
||||||
|
"fr": {
|
||||||
|
"changePassword": "Définir le mot de passe",
|
||||||
|
"newPassword": "Nouveau mot de passe",
|
||||||
|
"confirmPassword": "Confirmer le nouveau mot de passe",
|
||||||
|
"passwordRequirements": "Le mot de passe doit comporter au moins 8 caractères",
|
||||||
|
"passwordDescription": "La définition d'un mot de passe vous permet de vous connecter directement avec votre email et mot de passe, même si vous vous êtes initialement inscrit avec Google.",
|
||||||
|
"save": "Enregistrer",
|
||||||
|
"cancel": "Annuler",
|
||||||
|
"passwordsDoNotMatch": "Les nouveaux mots de passe ne correspondent pas",
|
||||||
|
"passwordTooShort": "Le mot de passe doit comporter au moins 8 caractères",
|
||||||
|
"passwordUpdateFailed": "Échec de la mise à jour du mot de passe. Veuillez réessayer."
|
||||||
|
},
|
||||||
|
"es": {
|
||||||
|
"changePassword": "Establecer contraseña",
|
||||||
|
"newPassword": "Nueva contraseña",
|
||||||
|
"confirmPassword": "Confirmar nueva contraseña",
|
||||||
|
"passwordRequirements": "La contraseña debe tener al menos 8 caracteres",
|
||||||
|
"passwordDescription": "Establecer una contraseña le permite iniciar sesión directamente con su correo electrónico y contraseña, incluso si originalmente se registró con Google.",
|
||||||
|
"save": "Guardar",
|
||||||
|
"cancel": "Cancelar",
|
||||||
|
"passwordsDoNotMatch": "Las nuevas contraseñas no coinciden",
|
||||||
|
"passwordTooShort": "La contraseña debe tener al menos 8 caracteres",
|
||||||
|
"passwordUpdateFailed": "Error al actualizar la contraseña. Por favor, inténtelo de nuevo."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</i18n>
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="card dialog">
|
|
||||||
<div class="card-title">
|
|
||||||
{{ t('title') }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card-content">
|
|
||||||
<v-text-field
|
|
||||||
variant="outlined"
|
|
||||||
v-model="phone"
|
|
||||||
:label="t('label')"
|
|
||||||
></v-text-field>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card-actions">
|
|
||||||
<button class="secondary" @click="requestClose">
|
|
||||||
{{ t('cancel') }}
|
|
||||||
</button>
|
|
||||||
<button class="primary" @click="requestSave">
|
|
||||||
{{ t('save') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import {ref} from 'vue';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
|
||||||
const props = defineProps(['phone'])
|
|
||||||
const emit = defineEmits(['close', 'save'])
|
|
||||||
|
|
||||||
const phone = ref(props.phone)
|
|
||||||
|
|
||||||
const requestClose = () => emit('close')
|
|
||||||
const requestSave = () => emit('save', phone.value)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<i18n>
|
|
||||||
{
|
|
||||||
"en": {
|
|
||||||
"title": "Phone Number",
|
|
||||||
"label": "Your phone number"
|
|
||||||
},
|
|
||||||
"fr": {
|
|
||||||
"title": "Numéro de téléphone",
|
|
||||||
"label": "Votre numéro de téléphone"
|
|
||||||
},
|
|
||||||
"es": {
|
|
||||||
"title": "Número de teléfono",
|
|
||||||
"label": "Tu número de teléfono"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</i18n>
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="card dialog">
|
|
||||||
<div class="card-title">
|
|
||||||
{{ t('title') }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card-content">
|
|
||||||
<img
|
|
||||||
:src="portraitData"
|
|
||||||
class="mb-5 w-full transition duration-200 ease-in-out transform"
|
|
||||||
:alt="t('preview')"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<v-file-input
|
|
||||||
@change="onSelectedFileChanged"
|
|
||||||
v-model="selectedFile"
|
|
||||||
variant="outlined"
|
|
||||||
accept="image/*"
|
|
||||||
:label="t('label')"
|
|
||||||
></v-file-input>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card-actions">
|
|
||||||
<button class="secondary" @click="requestClose">
|
|
||||||
{{ t('cancel') }}
|
|
||||||
</button>
|
|
||||||
<button class="primary" @click="requestSave">
|
|
||||||
{{ t('save') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import {ref} from 'vue';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
|
||||||
const props = defineProps(['portraitUrl'])
|
|
||||||
const emit = defineEmits(['close', 'save'])
|
|
||||||
|
|
||||||
const portraitData = ref(props.portraitUrl)
|
|
||||||
|
|
||||||
const selectedFile = ref({})
|
|
||||||
const onSelectedFileChanged = () => {
|
|
||||||
if (selectedFile.value) {
|
|
||||||
const reader = new FileReader()
|
|
||||||
reader.onload = (event) => {
|
|
||||||
portraitData.value = event.target.result
|
|
||||||
}
|
|
||||||
reader.readAsDataURL(selectedFile.value)
|
|
||||||
} else {
|
|
||||||
portraitData.value = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const requestClose = () => emit('close')
|
|
||||||
const requestSave = () => emit('save', selectedFile.value)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<i18n>
|
|
||||||
{
|
|
||||||
"en": {
|
|
||||||
"title": "Profile Picture",
|
|
||||||
"preview": "Profile picture preview",
|
|
||||||
"label": "Choose a profile picture"
|
|
||||||
},
|
|
||||||
"fr": {
|
|
||||||
"title": "Photo de profil",
|
|
||||||
"preview": "Aperçu de la photo de profil",
|
|
||||||
"label": "Choisir une photo de profil"
|
|
||||||
},
|
|
||||||
"es": {
|
|
||||||
"title": "Foto de perfil",
|
|
||||||
"preview": "Vista previa de la foto de perfil",
|
|
||||||
"label": "Elegir una foto de perfil"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</i18n>
|
|
||||||
Reference in New Issue
Block a user