feat: Introduce dedicated Blazor pages and DTOs for user authentication and account management, updating localization and styling.

This commit is contained in:
Ho Ngoc Hai
2026-01-19 12:39:44 +07:00
parent 8de0a7e7c0
commit 0ca15836a4
15 changed files with 1597 additions and 203 deletions

View File

@@ -1,201 +0,0 @@
@page "/auth"
@inject HttpClient Http
@inject NavigationManager Navigation
@*
EN: Authentication page with Register and Login forms.
VI: Trang xác thực với form Đăng ký và Đăng nhập.
*@
<PageTitle>Authentication / Xác thực</PageTitle>
<div class="auth-container">
<div class="auth-tabs">
<button class="tab-btn @(activeTab == "login" ? "active" : "")" @onclick="@(() => activeTab = "login")">
Login / Đăng nhập
</button>
<button class="tab-btn @(activeTab == "register" ? "active" : "")" @onclick="@(() => activeTab = "register")">
Register / Đăng ký
</button>
</div>
@* ═══════════════════════════════════════════════════════════════════════════════
EN: Login Form
VI: Form đăng nhập
═══════════════════════════════════════════════════════════════════════════════ *@
@if (activeTab == "login")
{
<section class="glass-card auth-card">
<h2>Welcome Back / Chào mừng trở lại</h2>
<p class="subtitle">Sign in to your account / Đăng nhập vào tài khoản</p>
<EditForm Model="@loginModel" OnValidSubmit="HandleLogin" FormName="Login">
<DataAnnotationsValidator />
<div class="form-group">
<label for="login-email">Email *</label>
<InputText id="login-email" @bind-Value="loginModel.Email" class="form-input" placeholder="email@example.com" />
<ValidationMessage For="() => loginModel.Email" class="validation-message" />
</div>
<div class="form-group">
<label for="login-password">Password / Mật khẩu *</label>
<InputText id="login-password" @bind-Value="loginModel.Password" type="password" class="form-input" placeholder="••••••••" />
<ValidationMessage For="() => loginModel.Password" class="validation-message" />
</div>
<div class="form-group checkbox-group">
<InputCheckbox id="remember-me" @bind-Value="loginModel.RememberMe" class="form-checkbox" />
<label for="remember-me">Remember me / Ghi nhớ đăng nhập</label>
</div>
<button type="submit" class="btn btn-primary btn-full" disabled="@isSubmitting">
@if (isSubmitting)
{
<span class="spinner"></span>
}
Sign In / Đăng nhập
</button>
</EditForm>
</section>
}
@* ═══════════════════════════════════════════════════════════════════════════════
EN: Register Form
VI: Form đăng ký
═══════════════════════════════════════════════════════════════════════════════ *@
@if (activeTab == "register")
{
<section class="glass-card auth-card">
<h2>Create Account / Tạo tài khoản</h2>
<p class="subtitle">Join us today / Tham gia với chúng tôi</p>
<EditForm Model="@registerModel" OnValidSubmit="HandleRegister" FormName="Register">
<DataAnnotationsValidator />
<ValidationSummary class="validation-summary" />
<div class="form-group">
<label for="reg-name">Display Name / Tên hiển thị *</label>
<InputText id="reg-name" @bind-Value="registerModel.DisplayName" class="form-input" placeholder="John Doe" />
<ValidationMessage For="() => registerModel.DisplayName" class="validation-message" />
</div>
<div class="form-group">
<label for="reg-email">Email *</label>
<InputText id="reg-email" @bind-Value="registerModel.Email" class="form-input" placeholder="email@example.com" />
<ValidationMessage For="() => registerModel.Email" class="validation-message" />
</div>
<div class="form-group">
<label for="reg-password">Password / Mật khẩu *</label>
<InputText id="reg-password" @bind-Value="registerModel.Password" type="password" class="form-input" placeholder="••••••••" />
<ValidationMessage For="() => registerModel.Password" class="validation-message" />
<small class="form-hint">Min 8 chars, uppercase, lowercase, digit / Tối thiểu 8 ký tự, chữ hoa, chữ thường, số</small>
</div>
<div class="form-group">
<label for="reg-confirm">Confirm Password / Xác nhận mật khẩu *</label>
<InputText id="reg-confirm" @bind-Value="registerModel.ConfirmPassword" type="password" class="form-input" placeholder="••••••••" />
<ValidationMessage For="() => registerModel.ConfirmPassword" class="validation-message" />
</div>
<button type="submit" class="btn btn-primary btn-full" disabled="@isSubmitting">
@if (isSubmitting)
{
<span class="spinner"></span>
}
Create Account / Tạo tài khoản
</button>
</EditForm>
</section>
}
@if (!string.IsNullOrEmpty(message))
{
<div class="alert @(success ? "alert-success" : "alert-error")">
@message
</div>
}
</div>
@code {
private string activeTab = "login";
private LoginDto loginModel = new();
private RegisterDto registerModel = new();
private bool isSubmitting = false;
private string message = "";
private bool success = false;
private async Task HandleLogin()
{
isSubmitting = true;
message = "";
try
{
var response = await Http.PostAsJsonAsync("api/Auth/login", loginModel);
if (response.IsSuccessStatusCode)
{
var result = await response.Content.ReadFromJsonAsync<ApiResponse<UserProfileDto>>();
if (result?.Success == true)
{
success = true;
message = $"Welcome back, {result.Data?.DisplayName}! / Chào mừng trở lại, {result.Data?.DisplayName}!";
}
}
else
{
success = false;
message = "Login failed. Please check your credentials. / Đăng nhập thất bại.";
}
}
catch (Exception ex)
{
success = false;
message = $"Error: {ex.Message}";
}
finally
{
isSubmitting = false;
}
}
private async Task HandleRegister()
{
isSubmitting = true;
message = "";
try
{
var response = await Http.PostAsJsonAsync("api/Auth/register", registerModel);
if (response.IsSuccessStatusCode)
{
var result = await response.Content.ReadFromJsonAsync<ApiResponse<UserProfileDto>>();
if (result?.Success == true)
{
success = true;
message = "Account created successfully! / Tạo tài khoản thành công!";
activeTab = "login";
registerModel = new();
}
}
else
{
success = false;
var content = await response.Content.ReadAsStringAsync();
message = $"Registration failed: {content}";
}
}
catch (Exception ex)
{
success = false;
message = $"Error: {ex.Message}";
}
finally
{
isSubmitting = false;
}
}
}

View File

@@ -0,0 +1,108 @@
@page "/forgot-password"
@using WebClientBase.Shared.DTOs
@using WebClientBase.Shared
@inject HttpClient Http
@inject IStringLocalizer<ForgotPassword> L
@*
EN: Forgot password page.
VI: Trang quên mật khẩu.
*@
<PageTitle>@L["Auth_ForgotPassword_Title"]</PageTitle>
<div class="auth-container">
<section class="auth-card">
<h1 class="auth-title">@L["Auth_ForgotPassword_Title"]</h1>
<p class="auth-subtitle">@L["Auth_ForgotPassword_Subtitle"]</p>
<EditForm Model="@forgotPasswordModel" OnValidSubmit="HandleForgotPassword" FormName="ForgotPasswordForm">
<DataAnnotationsValidator />
<div class="form-group">
<label for="forgot-email">@L["Auth_ForgotPassword_Email"] *</label>
<InputText id="forgot-email"
@bind-Value="forgotPasswordModel.Email"
class="form-input"
placeholder="email@example.com"
autocomplete="email" />
<ValidationMessage For="() => forgotPasswordModel.Email" class="validation-message" />
</div>
<button type="submit" class="btn-primary btn-full" disabled="@isSubmitting">
@if (isSubmitting)
{
<span class="spinner-small"></span>
<span>@L["Common_Loading"]</span>
}
else
{
@L["Auth_ForgotPassword_Submit"]
}
</button>
</EditForm>
@if (!string.IsNullOrEmpty(message))
{
<div class="alert @(success ? "alert-success" : "alert-error")">
@message
</div>
}
<div class="auth-footer">
<a href="/login" class="link-primary">@L["Auth_ForgotPassword_BackToLogin"]</a>
</div>
</section>
</div>
@code {
private ForgotPasswordDto forgotPasswordModel = new();
private bool isSubmitting = false;
private string message = "";
private bool success = false;
/// <summary>
/// EN: Handle forgot password form submission.
/// VI: Xử lý submit form quên mật khẩu.
/// </summary>
private async Task HandleForgotPassword()
{
isSubmitting = true;
message = "";
try
{
var response = await Http.PostAsJsonAsync("api/auth/forgot-password", forgotPasswordModel);
if (response.IsSuccessStatusCode)
{
var result = await response.Content.ReadFromJsonAsync<ApiResponse<object>>();
if (result?.Success == true)
{
success = true;
message = L["Auth_ForgotPassword_Success"];
forgotPasswordModel = new(); // Clear form
}
else
{
success = false;
message = result?.Error ?? L["Auth_ForgotPassword_Error"];
}
}
else
{
success = false;
message = L["Auth_ForgotPassword_Error"];
}
}
catch (Exception ex)
{
success = false;
message = $"{L["Common_Error"]}: {ex.Message}";
}
finally
{
isSubmitting = false;
}
}
}

View File

@@ -0,0 +1,136 @@
@page "/login"
@using WebClientBase.Shared.DTOs
@using WebClientBase.Shared
@inject HttpClient Http
@inject NavigationManager Navigation
@inject IStringLocalizer<Login> L
@*
EN: Login page with email/password authentication.
VI: Trang đăng nhập với xác thực email/mật khẩu.
*@
<PageTitle>@L["Auth_Login_Title"]</PageTitle>
<div class="auth-container">
<section class="auth-card">
<h1 class="auth-title">@L["Auth_Login_Title"]</h1>
<p class="auth-subtitle">@L["Auth_Login_Subtitle"]</p>
<EditForm Model="@loginModel" OnValidSubmit="HandleLogin" FormName="LoginForm">
<DataAnnotationsValidator />
<div class="form-group">
<label for="login-email">@L["Auth_Login_Email"] *</label>
<InputText id="login-email"
@bind-Value="loginModel.Email"
class="form-input"
placeholder="email@example.com"
autocomplete="email" />
<ValidationMessage For="() => loginModel.Email" class="validation-message" />
</div>
<div class="form-group">
<label for="login-password">@L["Auth_Login_Password"] *</label>
<InputText id="login-password"
@bind-Value="loginModel.Password"
type="password"
class="form-input"
placeholder="••••••••"
autocomplete="current-password" />
<ValidationMessage For="() => loginModel.Password" class="validation-message" />
</div>
<div class="form-actions-row">
<div class="checkbox-group">
<InputCheckbox id="remember-me"
@bind-Value="loginModel.RememberMe"
class="form-checkbox" />
<label for="remember-me" class="checkbox-label">@L["Auth_Login_RememberMe"]</label>
</div>
<a href="/forgot-password" class="link-secondary">@L["Auth_Login_ForgotPassword"]</a>
</div>
<button type="submit" class="btn-primary btn-full" disabled="@isSubmitting">
@if (isSubmitting)
{
<span class="spinner-small"></span>
<span>@L["Common_Loading"]</span>
}
else
{
@L["Auth_Login_Submit"]
}
</button>
</EditForm>
@if (!string.IsNullOrEmpty(message))
{
<div class="alert @(success ? "alert-success" : "alert-error")">
@message
</div>
}
<div class="auth-footer">
<span>@L["Auth_Login_NoAccount"]</span>
<a href="/register" class="link-primary">@L["Auth_Login_RegisterLink"]</a>
</div>
</section>
</div>
@code {
private LoginDto loginModel = new();
private bool isSubmitting = false;
private string message = "";
private bool success = false;
/// <summary>
/// EN: Handle login form submission.
/// VI: Xử lý submit form đăng nhập.
/// </summary>
private async Task HandleLogin()
{
isSubmitting = true;
message = "";
try
{
var response = await Http.PostAsJsonAsync("api/auth/login", loginModel);
if (response.IsSuccessStatusCode)
{
var result = await response.Content.ReadFromJsonAsync<ApiResponse<UserProfileDto>>();
if (result?.Success == true && result.Data != null)
{
success = true;
message = string.Format(L["Auth_Login_Success"], result.Data.DisplayName);
// EN: Redirect to home after 1 second
// VI: Chuyển hướng về trang chủ sau 1 giây
await Task.Delay(1000);
Navigation.NavigateTo("/");
}
else
{
success = false;
message = L["Auth_Login_Error"];
}
}
else
{
success = false;
message = L["Auth_Login_Error"];
}
}
catch (Exception ex)
{
success = false;
message = $"{L["Common_Error"]}: {ex.Message}";
}
finally
{
isSubmitting = false;
}
}
}

View File

@@ -0,0 +1,295 @@
@page "/profile"
@using WebClientBase.Shared.DTOs
@using WebClientBase.Shared
@inject HttpClient Http
@inject NavigationManager Navigation
@inject IStringLocalizer<Profile> L
@*
EN: User profile management page (requires authentication).
VI: Trang quản lý hồ sơ người dùng (yêu cầu xác thực).
*@
<PageTitle>@L["Auth_Profile_Title"]</PageTitle>
<div class="profile-container">
<div class="profile-header">
<h1 class="profile-title">@L["Auth_Profile_Title"]</h1>
<p class="profile-subtitle">@L["Auth_Profile_Subtitle"]</p>
</div>
@if (isLoading)
{
<div class="loading-state">
<span class="spinner"></span>
<p>@L["Common_Loading"]</p>
</div>
}
else if (userProfile != null)
{
<div class="profile-content">
<!-- Personal Information Section -->
<section class="profile-section">
<h2 class="section-title">@L["Auth_Profile_PersonalInfo"]</h2>
<EditForm Model="@userProfile" OnValidSubmit="HandleUpdateProfile" FormName="ProfileForm">
<DataAnnotationsValidator />
<div class="form-group">
<label for="profile-name">@L["Auth_Register_DisplayName"]</label>
<InputText id="profile-name"
@bind-Value="userProfile.DisplayName"
class="form-input"
disabled="@(!isEditingProfile)" />
</div>
<div class="form-group">
<label for="profile-email">@L["Common_Email"]</label>
<InputText id="profile-email"
@bind-Value="userProfile.Email"
class="form-input"
disabled />
</div>
<div class="form-group">
<label>@L["Auth_Profile_MemberSince"]</label>
<p class="text-secondary">@userProfile.CreatedAt.ToString("MMMM dd, yyyy")</p>
</div>
@if (!isEditingProfile)
{
<button type="button" class="btn-secondary" @onclick="@(() => isEditingProfile = true)">
@L["Auth_Profile_EditProfile"]
</button>
}
else
{
<div class="btn-group">
<button type="submit" class="btn-primary" disabled="@isSubmitting">
@L["Auth_Profile_SaveChanges"]
</button>
<button type="button" class="btn-secondary" @onclick="CancelEditProfile">
@L["Common_Cancel"]
</button>
</div>
}
</EditForm>
</section>
<!-- Security Section -->
<section class="profile-section">
<h2 class="section-title">@L["Auth_Profile_Security"]</h2>
<EditForm Model="@changePasswordModel" OnValidSubmit="HandleChangePassword" FormName="ChangePasswordForm">
<DataAnnotationsValidator />
<div class="form-group">
<label for="current-password">@L["Auth_Profile_CurrentPassword"] *</label>
<InputText id="current-password"
@bind-Value="changePasswordModel.CurrentPassword"
type="password"
class="form-input"
placeholder="••••••••" />
<ValidationMessage For="() => changePasswordModel.CurrentPassword" class="validation-message" />
</div>
<div class="form-group">
<label for="new-password">@L["Auth_Profile_NewPassword"] *</label>
<InputText id="new-password"
@bind-Value="changePasswordModel.NewPassword"
type="password"
class="form-input"
placeholder="••••••••" />
<small class="form-hint">@L["Auth_Register_PasswordHint"]</small>
<ValidationMessage For="() => changePasswordModel.NewPassword" class="validation-message" />
</div>
<div class="form-group">
<label for="confirm-new-password">@L["Auth_Profile_ConfirmPassword"] *</label>
<InputText id="confirm-new-password"
@bind-Value="changePasswordModel.ConfirmPassword"
type="password"
class="form-input"
placeholder="••••••••" />
<ValidationMessage For="() => changePasswordModel.ConfirmPassword" class="validation-message" />
</div>
<button type="submit" class="btn-primary" disabled="@isSubmitting">
@if (isSubmitting)
{
<span class="spinner-small"></span>
<span>@L["Common_Loading"]</span>
}
else
{
@L["Auth_Profile_UpdatePassword"]
}
</button>
</EditForm>
</section>
@if (!string.IsNullOrEmpty(message))
{
<div class="alert @(success ? "alert-success" : "alert-error")">
@message
</div>
}
<div class="profile-actions">
<button class="btn-secondary" @onclick="HandleLogout">
@L["Auth_Profile_Logout"]
</button>
</div>
</div>
}
</div>
@code {
private UserProfileDto? userProfile;
private ChangePasswordDto changePasswordModel = new();
private bool isLoading = true;
private bool isEditingProfile = false;
private bool isSubmitting = false;
private string message = "";
private bool success = false;
protected override async Task OnInitializedAsync()
{
await LoadUserProfile();
}
/// <summary>
/// EN: Load user profile data.
/// VI: Tải dữ liệu hồ sơ người dùng.
/// </summary>
private async Task LoadUserProfile()
{
try
{
var response = await Http.GetAsync("api/auth/profile");
if (response.IsSuccessStatusCode)
{
var result = await response.Content.ReadFromJsonAsync<ApiResponse<UserProfileDto>>();
userProfile = result?.Data;
}
else
{
// EN: Redirect to login if unauthorized
// VI: Chuyển hướng đến đăng nhập nếu chưa xác thực
Navigation.NavigateTo("/login");
}
}
catch (Exception ex)
{
message = $"{L["Common_Error"]}: {ex.Message}";
success = false;
}
finally
{
isLoading = false;
}
}
/// <summary>
/// EN: Handle profile update.
/// VI: Xử lý cập nhật hồ sơ.
/// </summary>
private async Task HandleUpdateProfile()
{
isSubmitting = true;
message = "";
try
{
var response = await Http.PutAsJsonAsync("api/auth/profile", userProfile);
if (response.IsSuccessStatusCode)
{
success = true;
message = L["Auth_Profile_Success"];
isEditingProfile = false;
}
else
{
success = false;
message = L["Auth_Profile_Error"];
}
}
catch (Exception ex)
{
success = false;
message = $"{L["Common_Error"]}: {ex.Message}";
}
finally
{
isSubmitting = false;
}
}
/// <summary>
/// EN: Handle password change.
/// VI: Xử lý đổi mật khẩu.
/// </summary>
private async Task HandleChangePassword()
{
isSubmitting = true;
message = "";
try
{
var response = await Http.PostAsJsonAsync("api/auth/change-password", changePasswordModel);
if (response.IsSuccessStatusCode)
{
success = true;
message = L["Auth_Profile_PasswordSuccess"];
changePasswordModel = new(); // Clear form
}
else
{
success = false;
message = L["Auth_Profile_Error"];
}
}
catch (Exception ex)
{
success = false;
message = $"{L["Common_Error"]}: {ex.Message}";
}
finally
{
isSubmitting = false;
}
}
/// <summary>
/// EN: Cancel profile editing.
/// VI: Hủy chỉnh sửa hồ sơ.
/// </summary>
private void CancelEditProfile()
{
isEditingProfile = false;
// Reload to reset changes
LoadUserProfile();
}
/// <summary>
/// EN: Handle user logout.
/// VI: Xử lý đăng xuất.
/// </summary>
private async Task HandleLogout()
{
try
{
await Http.PostAsync("api/auth/logout", null);
Navigation.NavigateTo("/login");
}
catch
{
// EN: Even if logout fails, navigate to login
// VI: Ngay cả khi đăng xuất thất bại, chuyển về đăng nhập
Navigation.NavigateTo("/login");
}
}
}

View File

@@ -0,0 +1,155 @@
@page "/register"
@using WebClientBase.Shared.DTOs
@using WebClientBase.Shared
@inject HttpClient Http
@inject NavigationManager Navigation
@inject IStringLocalizer<Register> L
@*
EN: User registration page.
VI: Trang đăng ký người dùng.
*@
<PageTitle>@L["Auth_Register_Title"]</PageTitle>
<div class="auth-container">
<section class="auth-card">
<h1 class="auth-title">@L["Auth_Register_Title"]</h1>
<p class="auth-subtitle">@L["Auth_Register_Subtitle"]</p>
<EditForm Model="@registerModel" OnValidSubmit="HandleRegister" FormName="RegisterForm">
<DataAnnotationsValidator />
<div class="form-group">
<label for="reg-name">@L["Auth_Register_DisplayName"] *</label>
<InputText id="reg-name"
@bind-Value="registerModel.DisplayName"
class="form-input"
placeholder="John Doe"
autocomplete="name" />
<ValidationMessage For="() => registerModel.DisplayName" class="validation-message" />
</div>
<div class="form-group">
<label for="reg-email">@L["Auth_Register_Email"] *</label>
<InputText id="reg-email"
@bind-Value="registerModel.Email"
class="form-input"
placeholder="email@example.com"
autocomplete="email" />
<ValidationMessage For="() => registerModel.Email" class="validation-message" />
</div>
<div class="form-group">
<label for="reg-password">@L["Auth_Register_Password"] *</label>
<InputText id="reg-password"
@bind-Value="registerModel.Password"
type="password"
class="form-input"
placeholder="••••••••"
autocomplete="new-password" />
<small class="form-hint">@L["Auth_Register_PasswordHint"]</small>
<ValidationMessage For="() => registerModel.Password" class="validation-message" />
</div>
<div class="form-group">
<label for="reg-confirm">@L["Auth_Register_ConfirmPassword"] *</label>
<InputText id="reg-confirm"
@bind-Value="registerModel.ConfirmPassword"
type="password"
class="form-input"
placeholder="••••••••"
autocomplete="new-password" />
<ValidationMessage For="() => registerModel.ConfirmPassword" class="validation-message" />
</div>
<div class="checkbox-group mb-6">
<InputCheckbox id="accept-terms"
@bind-Value="registerModel.AcceptTerms"
class="form-checkbox" />
<label for="accept-terms" class="checkbox-label">@L["Auth_Register_Terms"]</label>
</div>
<button type="submit" class="btn-primary btn-full" disabled="@isSubmitting">
@if (isSubmitting)
{
<span class="spinner-small"></span>
<span>@L["Common_Loading"]</span>
}
else
{
@L["Auth_Register_Submit"]
}
</button>
</EditForm>
@if (!string.IsNullOrEmpty(message))
{
<div class="alert @(success ? "alert-success" : "alert-error")">
@message
</div>
}
<div class="auth-footer">
<span>@L["Auth_Register_HaveAccount"]</span>
<a href="/login" class="link-primary">@L["Auth_Register_LoginLink"]</a>
</div>
</section>
</div>
@code {
private RegisterDto registerModel = new();
private bool isSubmitting = false;
private string message = "";
private bool success = false;
/// <summary>
/// EN: Handle registration form submission.
/// VI: Xử lý submit form đăng ký.
/// </summary>
private async Task HandleRegister()
{
isSubmitting = true;
message = "";
try
{
var response = await Http.PostAsJsonAsync("api/auth/register", registerModel);
if (response.IsSuccessStatusCode)
{
var result = await response.Content.ReadFromJsonAsync<ApiResponse<UserProfileDto>>();
if (result?.Success == true)
{
success = true;
message = L["Auth_Register_Success"];
// EN: Redirect to login after 2 seconds
// VI: Chuyển hướng đến đăng nhập sau 2 giây
await Task.Delay(2000);
Navigation.NavigateTo("/login");
}
else
{
success = false;
message = result?.Error ?? L["Auth_Register_Error"];
}
}
else
{
success = false;
var content = await response.Content.ReadAsStringAsync();
message = $"{L["Auth_Register_Error"]}: {content}";
}
}
catch (Exception ex)
{
success = false;
message = $"{L["Common_Error"]}: {ex.Message}";
}
finally
{
isSubmitting = false;
}
}
}

View File

@@ -0,0 +1,164 @@
@page "/reset-password"
@using WebClientBase.Shared.DTOs
@using WebClientBase.Shared
@inject HttpClient Http
@inject NavigationManager Navigation
@inject IStringLocalizer<ResetPassword> L
@*
EN: Reset password page with token validation.
VI: Trang đặt lại mật khẩu với xác thực token.
*@
<PageTitle>@L["Auth_ResetPassword_Title"]</PageTitle>
<div class="auth-container">
<section class="auth-card">
<h1 class="auth-title">@L["Auth_ResetPassword_Title"]</h1>
<p class="auth-subtitle">@L["Auth_ResetPassword_Subtitle"]</p>
@if (invalidToken)
{
<div class="alert alert-error">
@L["Auth_ResetPassword_InvalidToken"]
</div>
<div class="auth-footer">
<a href="/forgot-password" class="link-primary">@L["Auth_ForgotPassword_Title"]</a>
</div>
}
else
{
<EditForm Model="@resetPasswordModel" OnValidSubmit="HandleResetPassword" FormName="ResetPasswordForm">
<DataAnnotationsValidator />
<div class="form-group">
<label for="new-password">@L["Auth_ResetPassword_NewPassword"] *</label>
<InputText id="new-password"
@bind-Value="resetPasswordModel.NewPassword"
type="password"
class="form-input"
placeholder="••••••••"
autocomplete="new-password" />
<small class="form-hint">@L["Auth_ResetPassword_PasswordHint"]</small>
<ValidationMessage For="() => resetPasswordModel.NewPassword" class="validation-message" />
</div>
<div class="form-group">
<label for="confirm-password">@L["Auth_ResetPassword_ConfirmPassword"] *</label>
<InputText id="confirm-password"
@bind-Value="resetPasswordModel.ConfirmPassword"
type="password"
class="form-input"
placeholder="••••••••"
autocomplete="new-password" />
<ValidationMessage For="() => resetPasswordModel.ConfirmPassword" class="validation-message" />
</div>
<button type="submit" class="btn-primary btn-full" disabled="@isSubmitting">
@if (isSubmitting)
{
<span class="spinner-small"></span>
<span>@L["Common_Loading"]</span>
}
else
{
@L["Auth_ResetPassword_Submit"]
}
</button>
</EditForm>
@if (!string.IsNullOrEmpty(message))
{
<div class="alert @(success ? "alert-success" : "alert-error")">
@message
</div>
}
}
</section>
</div>
@code {
private ResetPasswordDto resetPasswordModel = new();
private bool isSubmitting = false;
private string message = "";
private bool success = false;
private bool invalidToken = false;
protected override void OnInitialized()
{
// EN: Parse query parameters for token and email
// VI: Phân tích query parameters cho token và email
var uri = new Uri(Navigation.Uri);
var query = uri.Query;
if (!string.IsNullOrEmpty(query))
{
var queryParams = System.Web.HttpUtility.ParseQueryString(query);
var token = queryParams["token"];
var email = queryParams["email"];
if (!string.IsNullOrEmpty(token) && !string.IsNullOrEmpty(email))
{
resetPasswordModel.Token = token;
resetPasswordModel.Email = email;
}
else
{
invalidToken = true;
}
}
else
{
invalidToken = true;
}
}
/// <summary>
/// EN: Handle reset password form submission.
/// VI: Xử lý submit form đặt lại mật khẩu.
/// </summary>
private async Task HandleResetPassword()
{
isSubmitting = true;
message = "";
try
{
var response = await Http.PostAsJsonAsync("api/auth/reset-password", resetPasswordModel);
if (response.IsSuccessStatusCode)
{
var result = await response.Content.ReadFromJsonAsync<ApiResponse<object>>();
if (result?.Success == true)
{
success = true;
message = L["Auth_ResetPassword_Success"];
// EN: Redirect to login after 2 seconds
// VI: Chuyển hướng đến đăng nhập sau 2 giây
await Task.Delay(2000);
Navigation.NavigateTo("/login");
}
else
{
success = false;
message = result?.Error ?? L["Auth_ResetPassword_Error"];
}
}
else
{
success = false;
message = L["Auth_ResetPassword_Error"];
}
}
catch (Exception ex)
{
success = false;
message = $"{L["Common_Error"]}: {ex.Message}";
}
finally
{
isSubmitting = false;
}
}
}

View File

@@ -0,0 +1,124 @@
@page "/verify-email"
@using WebClientBase.Shared
@inject HttpClient Http
@inject NavigationManager Navigation
@inject IStringLocalizer<VerifyEmail> L
@*
EN: Email verification page.
VI: Trang xác minh email.
*@
<PageTitle>@L["Auth_VerifyEmail_Title"]</PageTitle>
<div class="auth-container">
<section class="auth-card text-center">
<h1 class="auth-title">@L["Auth_VerifyEmail_Title"]</h1>
@if (isVerifying)
{
<div class="loading-state">
<span class="spinner"></span>
<p class="mt-4">@L["Auth_VerifyEmail_Verifying"]</p>
</div>
}
else if (success)
{
<div class="success-state">
<svg class="checkmark" xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 6L9 17l-5-5"/>
</svg>
<p class="mt-4 text-lg">@L["Auth_VerifyEmail_Success"]</p>
<button class="btn-primary mt-6" @onclick="@(() => Navigation.NavigateTo("/login"))">
@L["Auth_VerifyEmail_GoToLogin"]
</button>
</div>
}
else
{
<div class="alert alert-error">
@message
</div>
<div class="mt-6">
<a href="/login" class="link-primary">@L["Auth_VerifyEmail_GoToLogin"]</a>
</div>
}
</section>
</div>
@code {
private bool isVerifying = true;
private bool success = false;
private string message = "";
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
await VerifyEmailAsync();
}
}
/// <summary>
/// EN: Verify email using token from query parameters.
/// VI: Xác minh email sử dụng token từ query parameters.
/// </summary>
private async Task VerifyEmailAsync()
{
try
{
// EN: Parse query parameters
// VI: Phân tích query parameters
var uri = new Uri(Navigation.Uri);
var query = uri.Query;
string? token = null;
string? userId = null;
if (!string.IsNullOrEmpty(query))
{
var queryParams = System.Web.HttpUtility.ParseQueryString(query);
token = queryParams["token"];
userId = queryParams["userId"];
}
if (string.IsNullOrEmpty(token) || string.IsNullOrEmpty(userId))
{
success = false;
message = L["Auth_VerifyEmail_InvalidToken"];
isVerifying = false;
StateHasChanged();
return;
}
// EN: Call verification endpoint
// VI: Gọi endpoint xác minh
var response = await Http.PostAsJsonAsync("api/auth/verify-email", new {
Token = token.ToString(),
UserId = userId.ToString()
});
if (response.IsSuccessStatusCode)
{
var result = await response.Content.ReadFromJsonAsync<ApiResponse<object>>();
success = result?.Success == true;
message = success ? L["Auth_VerifyEmail_Success"] : (result?.Error ?? L["Auth_VerifyEmail_Error"]);
}
else
{
success = false;
message = L["Auth_VerifyEmail_Error"];
}
}
catch (Exception ex)
{
success = false;
message = $"{L["Common_Error"]}: {ex.Message}";
}
finally
{
isVerifying = false;
StateHasChanged();
}
}
}

View File

@@ -365,4 +365,386 @@ h6 {
color: var(--primitive-neutral-50) !important;
/* Force White */
border-color: var(--primitive-neutral-700) !important;
}
/* ═════════════════════════════════════════════════════════════════════════
6. AUTHENTICATION COMPONENTS
═════════════════════════════════════════════════════════════════════════ */
/* Auth Container */
.auth-container {
display: flex;
align-items: center;
justify-content: center;
min-height: calc(100vh - 64px);
padding: var(--space-6);
}
/* Auth Card */
.auth-card {
background-color: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--border-radius-xl);
padding: var(--space-12);
width: 100%;
max-width: 480px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
.auth-title {
font-size: 2rem;
font-weight: 700;
margin-bottom: var(--space-2);
color: var(--text-primary);
}
.auth-subtitle {
color: var(--text-secondary);
margin-bottom: var(--space-8);
}
.auth-footer {
margin-top: var(--space-6);
text-align: center;
color: var(--text-secondary);
display: flex;
gap: var(--space-2);
align-items: center;
justify-content: center;
}
/* Form Components */
.form-group {
margin-bottom: var(--space-6);
}
.form-group label {
display: block;
font-weight: 500;
margin-bottom: var(--space-2);
color: var(--text-primary);
}
.form-input {
width: 100%;
padding: var(--space-3) var(--space-4);
border: 1px solid var(--border-subtle);
border-radius: var(--border-radius-base);
background-color: var(--bg-surface);
color: var(--text-primary);
font-family: var(--font-body);
font-size: 1rem;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.form-input:hover {
border-color: var(--border-strong);
}
.form-input:focus {
outline: none;
border-color: var(--action-primary-bg);
box-shadow: 0 0 0 3px rgba(24, 24, 27, 0.1);
}
[data-theme="dark"] .form-input:focus {
box-shadow: 0 0 0 3px rgba(250, 250, 250, 0.1);
}
.form-input:disabled {
background-color: var(--bg-surface-hover);
cursor: not-allowed;
opacity: 0.6;
}
.form-hint {
display: block;
margin-top: var(--space-1);
font-size: 0.875rem;
color: var(--text-tertiary);
}
.form-checkbox {
width: 1rem;
height: 1rem;
border: 1px solid var(--border-strong);
border-radius: 3px;
cursor: pointer;
}
.checkbox-group {
display: flex;
align-items: center;
gap: var(--space-2);
}
.checkbox-label {
margin-bottom: 0;
font-weight: 400;
cursor: pointer;
user-select: none;
}
.form-actions-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-6);
}
/* Buttons */
.btn-primary,
.btn-secondary {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
padding: var(--space-3) var(--space-6);
border-radius: var(--border-radius-base);
font-weight: 600;
font-size: 1rem;
cursor: pointer;
transition: all 0.2s ease;
border: none;
font-family: var(--font-body);
}
.btn-primary {
background-color: var(--action-primary-bg);
color: var(--action-primary-text);
}
.btn-primary:hover:not(:disabled) {
background-color: var(--action-primary-bg-hover);
}
.btn-secondary {
background-color: var(--action-secondary-bg);
color: var(--action-secondary-text);
border: 1px solid var(--action-secondary-border);
}
.btn-secondary:hover:not(:disabled) {
background-color: var(--action-secondary-bg-hover);
}
.btn-primary:disabled,
.btn-secondary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-full {
width: 100%;
}
.btn-group {
display: flex;
gap: var(--space-3);
}
/* Links */
.link-primary {
color: var(--action-primary-bg);
text-decoration: none;
font-weight: 500;
transition: opacity 0.2s ease;
}
.link-primary:hover {
opacity: 0.8;
text-decoration: underline;
}
.link-secondary {
color: var(--text-secondary);
text-decoration: none;
font-size: 0.875rem;
transition: color 0.2s ease;
}
.link-secondary:hover {
color: var(--text-primary);
text-decoration: underline;
}
/* Validation */
.validation-message {
display: block;
margin-top: var(--space-1);
font-size: 0.875rem;
color: #ef4444;
}
/* Alerts */
.alert {
padding: var(--space-4);
border-radius: var(--border-radius-base);
margin-top: var(--space-6);
font-size: 0.875rem;
}
.alert-success {
background-color: #d1fae5;
color: #065f46;
border: 1px solid #6ee7b7;
}
.alert-error {
background-color: #fee2e2;
color: #991b1b;
border: 1px solid #fca5a5;
}
[data-theme="dark"] .alert-success {
background-color: #064e3b;
color: #6ee7b7;
border-color: #059669;
}
[data-theme="dark"] .alert-error {
background-color: #7f1d1d;
color: #fca5a5;
border-color: #dc2626;
}
/* Spinners */
.spinner {
display: inline-block;
width: 40px;
height: 40px;
border: 4px solid var(--border-subtle);
border-top-color: var(--action-primary-bg);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.spinner-small {
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: currentColor;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Loading State */
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--space-12);
}
/* Success State (for verification pages) */
.success-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--space-8);
}
.checkmark {
color: #10b981;
stroke-width: 3;
}
/* Profile Page Specific */
.profile-container {
max-width: 800px;
margin: 0 auto;
padding: var(--space-8) var(--space-6);
}
.profile-header {
margin-bottom: var(--space-12);
}
.profile-title {
font-size: 2.5rem;
font-weight: 700;
margin-bottom: var(--space-2);
}
.profile-subtitle {
color: var(--text-secondary);
font-size: 1.125rem;
}
.profile-content {
display: flex;
flex-direction: column;
gap: var(--space-8);
}
.profile-section {
background-color: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--border-radius-xl);
padding: var(--space-8);
}
.section-title {
font-size: 1.5rem;
font-weight: 600;
margin-bottom: var(--space-6);
color: var(--text-primary);
}
.profile-actions {
margin-top: var(--space-8);
padding-top: var(--space-8);
border-top: 1px solid var(--border-subtle);
}
/* Utility Classes */
.text-center {
text-align: center;
}
.text-secondary {
color: var(--text-secondary);
}
.text-lg {
font-size: 1.125rem;
}
.mt-4 {
margin-top: var(--space-4);
}
.mt-6 {
margin-top: var(--space-6);
}
.mb-6 {
margin-bottom: var(--space-6);
}
.mr-2 {
margin-right: var(--space-2);
}
.theme-toggle {
background: none;
border: none;
cursor: pointer;
padding: var(--space-2);
color: var(--text-primary);
transition: opacity 0.2s ease;
}
.theme-toggle:hover {
opacity: 0.7;
}

View File

@@ -19,5 +19,79 @@
"Solution_POS_Title": "Multi-Industry POS",
"Solution_POS_Desc": "Unified commerce for retail and services. Manage inventory, sales, and staff from a single dashboard.",
"LearnMore": "Learn more",
"FooterCopyright": "© 2024 GoodGo Enterprise. All rights reserved."
"FooterCopyright": "© 2024 GoodGo Enterprise. All rights reserved.",
"Auth_Login_Title": "Welcome Back",
"Auth_Login_Subtitle": "Sign in to your account",
"Auth_Login_Email": "Email",
"Auth_Login_Password": "Password",
"Auth_Login_RememberMe": "Remember me",
"Auth_Login_ForgotPassword": "Forgot password?",
"Auth_Login_Submit": "Sign In",
"Auth_Login_NoAccount": "Don't have an account?",
"Auth_Login_RegisterLink": "Register here",
"Auth_Login_Success": "Welcome back, {0}!",
"Auth_Login_Error": "Login failed. Please check your credentials.",
"Auth_Register_Title": "Create Account",
"Auth_Register_Subtitle": "Join us today",
"Auth_Register_DisplayName": "Display Name",
"Auth_Register_Email": "Email",
"Auth_Register_Password": "Password",
"Auth_Register_ConfirmPassword": "Confirm Password",
"Auth_Register_PasswordHint": "Min 8 chars, uppercase, lowercase, digit",
"Auth_Register_Terms": "I agree to the Terms of Service and Privacy Policy",
"Auth_Register_Submit": "Create Account",
"Auth_Register_HaveAccount": "Already have an account?",
"Auth_Register_LoginLink": "Login here",
"Auth_Register_Success": "Account created successfully! Please check your email to verify your account.",
"Auth_Register_Error": "Registration failed. Please try again.",
"Auth_ForgotPassword_Title": "Forgot Password",
"Auth_ForgotPassword_Subtitle": "Enter your email to receive a password reset link",
"Auth_ForgotPassword_Email": "Email",
"Auth_ForgotPassword_Submit": "Send Reset Link",
"Auth_ForgotPassword_BackToLogin": "Back to Login",
"Auth_ForgotPassword_Success": "Password reset link sent! Please check your email.",
"Auth_ForgotPassword_Error": "Failed to send reset link. Please try again.",
"Auth_ResetPassword_Title": "Reset Password",
"Auth_ResetPassword_Subtitle": "Enter your new password",
"Auth_ResetPassword_NewPassword": "New Password",
"Auth_ResetPassword_ConfirmPassword": "Confirm Password",
"Auth_ResetPassword_PasswordHint": "Min 8 chars, uppercase, lowercase, digit",
"Auth_ResetPassword_Submit": "Reset Password",
"Auth_ResetPassword_Success": "Password reset successfully! You can now login.",
"Auth_ResetPassword_Error": "Failed to reset password. The link may have expired.",
"Auth_ResetPassword_InvalidToken": "Invalid or expired reset token.",
"Auth_VerifyEmail_Title": "Email Verification",
"Auth_VerifyEmail_Verifying": "Verifying your email...",
"Auth_VerifyEmail_Success": "Email verified successfully! You can now login.",
"Auth_VerifyEmail_Error": "Email verification failed. The link may have expired.",
"Auth_VerifyEmail_InvalidToken": "Invalid or expired verification token.",
"Auth_VerifyEmail_GoToLogin": "Go to Login",
"Auth_Profile_Title": "My Profile",
"Auth_Profile_Subtitle": "Manage your account information",
"Auth_Profile_PersonalInfo": "Personal Information",
"Auth_Profile_Security": "Security",
"Auth_Profile_MemberSince": "Member since",
"Auth_Profile_EditProfile": "Edit Profile",
"Auth_Profile_SaveChanges": "Save Changes",
"Auth_Profile_ChangePassword": "Change Password",
"Auth_Profile_CurrentPassword": "Current Password",
"Auth_Profile_NewPassword": "New Password",
"Auth_Profile_ConfirmPassword": "Confirm New Password",
"Auth_Profile_UpdatePassword": "Update Password",
"Auth_Profile_Logout": "Logout",
"Auth_Profile_Success": "Profile updated successfully!",
"Auth_Profile_PasswordSuccess": "Password changed successfully!",
"Auth_Profile_Error": "Failed to update profile. Please try again.",
"Validation_Required": "This field is required",
"Validation_EmailInvalid": "Please enter a valid email address",
"Validation_PasswordTooShort": "Password must be at least 8 characters",
"Validation_PasswordMismatch": "Passwords do not match",
"Validation_PasswordRequirements": "Password must contain uppercase, lowercase, number, and special character",
"Validation_DisplayNameTooShort": "Display name must be at least 2 characters",
"Validation_TermsRequired": "You must accept the Terms of Service",
"Common_Email": "Email",
"Common_Password": "Password",
"Common_Loading": "Loading...",
"Common_Success": "Success",
"Common_Error": "Error"
}

View File

@@ -19,5 +19,79 @@
"Solution_POS_Title": "POS Đa Ngành",
"Solution_POS_Desc": "Thương mại hợp nhất cho bán lẻ và dịch vụ. Quản lý tồn kho, bán hàng và nhân viên từ một bảng điều khiển.",
"LearnMore": "Tìm hiểu thêm",
"FooterCopyright": "© 2024 GoodGo Enterprise. Bảo lưu mọi quyền."
"FooterCopyright": "© 2024 GoodGo Enterprise. Bảo lưu mọi quyền.",
"Auth_Login_Title": "Chào Mừng Trở Lại",
"Auth_Login_Subtitle": "Đăng nhập vào tài khoản của bạn",
"Auth_Login_Email": "Email",
"Auth_Login_Password": "Mật khẩu",
"Auth_Login_RememberMe": "Ghi nhớ đăng nhập",
"Auth_Login_ForgotPassword": "Quên mật khẩu?",
"Auth_Login_Submit": "Đăng nhập",
"Auth_Login_NoAccount": "Chưa có tài khoản?",
"Auth_Login_RegisterLink": "Đăng ký tại đây",
"Auth_Login_Success": "Chào mừng trở lại, {0}!",
"Auth_Login_Error": "Đăng nhập thất bại. Vui lòng kiểm tra thông tin đăng nhập.",
"Auth_Register_Title": "Tạo Tài Khoản",
"Auth_Register_Subtitle": "Tham gia với chúng tôi ngay hôm nay",
"Auth_Register_DisplayName": "Tên hiển thị",
"Auth_Register_Email": "Email",
"Auth_Register_Password": "Mật khẩu",
"Auth_Register_ConfirmPassword": "Xác nhận mật khẩu",
"Auth_Register_PasswordHint": "Tối thiểu 8 ký tự, chữ hoa, chữ thường, số",
"Auth_Register_Terms": "Tôi đồng ý với Điều khoản Dịch vụ và Chính sách Bảo mật",
"Auth_Register_Submit": "Tạo tài khoản",
"Auth_Register_HaveAccount": "Đã có tài khoản?",
"Auth_Register_LoginLink": "Đăng nhập tại đây",
"Auth_Register_Success": "Tạo tài khoản thành công! Vui lòng kiểm tra email để xác minh tài khoản.",
"Auth_Register_Error": "Đăng ký thất bại. Vui lòng thử lại.",
"Auth_ForgotPassword_Title": "Quên Mật Khẩu",
"Auth_ForgotPassword_Subtitle": "Nhập email của bạn để nhận liên kết đặt lại mật khẩu",
"Auth_ForgotPassword_Email": "Email",
"Auth_ForgotPassword_Submit": "Gửi Liên Kết Đặt Lại",
"Auth_ForgotPassword_BackToLogin": "Quay lại Đăng nhập",
"Auth_ForgotPassword_Success": "Liên kết đặt lại mật khẩu đã được gửi! Vui lòng kiểm tra email.",
"Auth_ForgotPassword_Error": "Không thể gửi liên kết đặt lại. Vui lòng thử lại.",
"Auth_ResetPassword_Title": "Đặt Lại Mật Khẩu",
"Auth_ResetPassword_Subtitle": "Nhập mật khẩu mới của bạn",
"Auth_ResetPassword_NewPassword": "Mật khẩu mới",
"Auth_ResetPassword_ConfirmPassword": "Xác nhận mật khẩu",
"Auth_ResetPassword_PasswordHint": "Tối thiểu 8 ký tự, chữ hoa, chữ thường, số",
"Auth_ResetPassword_Submit": "Đặt Lại Mật Khẩu",
"Auth_ResetPassword_Success": "Đặt lại mật khẩu thành công! Bây giờ bạn có thể đăng nhập.",
"Auth_ResetPassword_Error": "Không thể đặt lại mật khẩu. Liên kết có thể đã hết hạn.",
"Auth_ResetPassword_InvalidToken": "Mã xác thực không hợp lệ hoặc đã hết hạn.",
"Auth_VerifyEmail_Title": "Xác Minh Email",
"Auth_VerifyEmail_Verifying": "Đang xác minh email của bạn...",
"Auth_VerifyEmail_Success": "Xác minh email thành công! Bây giờ bạn có thể đăng nhập.",
"Auth_VerifyEmail_Error": "Xác minh email thất bại. Liên kết có thể đã hết hạn.",
"Auth_VerifyEmail_InvalidToken": "Mã xác thực không hợp lệ hoặc đã hết hạn.",
"Auth_VerifyEmail_GoToLogin": "Đi đến Đăng nhập",
"Auth_Profile_Title": "Hồ Sơ Của Tôi",
"Auth_Profile_Subtitle": "Quản lý thông tin tài khoản của bạn",
"Auth_Profile_PersonalInfo": "Thông Tin Cá Nhân",
"Auth_Profile_Security": "Bảo Mật",
"Auth_Profile_MemberSince": "Thành viên từ",
"Auth_Profile_EditProfile": "Chỉnh Sửa Hồ Sơ",
"Auth_Profile_SaveChanges": "Lưu Thay Đổi",
"Auth_Profile_ChangePassword": "Đổi Mật Khẩu",
"Auth_Profile_CurrentPassword": "Mật khẩu hiện tại",
"Auth_Profile_NewPassword": "Mật khẩu mới",
"Auth_Profile_ConfirmPassword": "Xác nhận mật khẩu mới",
"Auth_Profile_UpdatePassword": "Cập Nhật Mật Khẩu",
"Auth_Profile_Logout": "Đăng xuất",
"Auth_Profile_Success": "Cập nhật hồ sơ thành công!",
"Auth_Profile_PasswordSuccess": "Đổi mật khẩu thành công!",
"Auth_Profile_Error": "Không thể cập nhật hồ sơ. Vui lòng thử lại.",
"Validation_Required": "Trường này là bắt buộc",
"Validation_EmailInvalid": "Vui lòng nhập địa chỉ email hợp lệ",
"Validation_PasswordTooShort": "Mật khẩu phải có ít nhất 8 ký tự",
"Validation_PasswordMismatch": "Mật khẩu không khớp",
"Validation_PasswordRequirements": "Mật khẩu phải chứa chữ hoa, chữ thường, số và ký tự đặc biệt",
"Validation_DisplayNameTooShort": "Tên hiển thị phải có ít nhất 2 ký tự",
"Validation_TermsRequired": "Bạn phải chấp nhận Điều khoản Dịch vụ",
"Common_Email": "Email",
"Common_Password": "Mật khẩu",
"Common_Loading": "Đang tải...",
"Common_Success": "Thành công",
"Common_Error": "Lỗi"
}

View File

@@ -12,6 +12,17 @@
}
]
},
"auth-route": {
"ClusterId": "iam-cluster",
"Match": {
"Path": "/api/auth/{**catch-all}"
},
"Transforms": [
{
"PathPattern": "/api/v1/auth/{**catch-all}"
}
]
},
"merchant-route": {
"ClusterId": "merchant-cluster",
"Match": {

View File

@@ -0,0 +1,23 @@
using System.ComponentModel.DataAnnotations;
namespace WebClientBase.Shared.DTOs;
/// <summary>
/// EN: Change password DTO for authenticated users.
/// VI: DTO cho đổi mật khẩu của người dùng đã xác thực.
/// </summary>
public class ChangePasswordDto
{
[Required(ErrorMessage = "Current password is required")]
public string CurrentPassword { get; set; } = string.Empty;
[Required(ErrorMessage = "New password is required")]
[MinLength(8, ErrorMessage = "Password must be at least 8 characters")]
[RegularExpression(@"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^\da-zA-Z]).+$",
ErrorMessage = "Password must contain uppercase, lowercase, digit, and special character")]
public string NewPassword { get; set; } = string.Empty;
[Required(ErrorMessage = "Confirm password is required")]
[Compare(nameof(NewPassword), ErrorMessage = "Passwords do not match")]
public string ConfirmPassword { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,14 @@
using System.ComponentModel.DataAnnotations;
namespace WebClientBase.Shared.DTOs;
/// <summary>
/// EN: Forgot password request DTO.
/// VI: DTO cho yêu cầu quên mật khẩu.
/// </summary>
public class ForgotPasswordDto
{
[Required(ErrorMessage = "Email is required")]
[EmailAddress(ErrorMessage = "Invalid email address")]
public string Email { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,27 @@
using System.ComponentModel.DataAnnotations;
namespace WebClientBase.Shared.DTOs;
/// <summary>
/// EN: Reset password DTO.
/// VI: DTO cho đặt lại mật khẩu.
/// </summary>
public class ResetPasswordDto
{
[Required(ErrorMessage = "Token is required")]
public string Token { get; set; } = string.Empty;
[Required(ErrorMessage = "Email is required")]
[EmailAddress(ErrorMessage = "Invalid email address")]
public string Email { get; set; } = string.Empty;
[Required(ErrorMessage = "Password is required")]
[MinLength(8, ErrorMessage = "Password must be at least 8 characters")]
[RegularExpression(@"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^\da-zA-Z]).+$",
ErrorMessage = "Password must contain uppercase, lowercase, digit, and special character")]
public string NewPassword { get; set; } = string.Empty;
[Required(ErrorMessage = "Confirm password is required")]
[Compare(nameof(NewPassword), ErrorMessage = "Passwords do not match")]
public string ConfirmPassword { get; set; } = string.Empty;
}

View File

@@ -41,6 +41,13 @@ public class RegisterDto
[Required(ErrorMessage = "Tên là bắt buộc / Name is required")]
[StringLength(50, MinimumLength = 2, ErrorMessage = "Tên phải từ 2-50 ký tự / Name must be 2-50 chars")]
public string DisplayName { get; set; } = string.Empty;
/// <summary>
/// EN: Accept terms of service.
/// VI: Chấp nhận điều khoản dịch vụ.
/// </summary>
[Range(typeof(bool), "true", "true", ErrorMessage = "Bạn phải chấp nhận Điều khoản dịch vụ / You must accept the Terms of Service")]
public bool AcceptTerms { get; set; }
}
/// <summary>
@@ -82,4 +89,5 @@ public class UserProfileDto
public string DisplayName { get; set; } = string.Empty;
public string? AvatarUrl { get; set; }
public DateTime CreatedAt { get; set; }
public bool EmailVerified { get; set; }
}