From 0ca15836a4bc32344b7afa63a537530f6eaf2772 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Mon, 19 Jan 2026 12:39:44 +0700 Subject: [PATCH] feat: Introduce dedicated Blazor pages and DTOs for user authentication and account management, updating localization and styling. --- .../src/WebClientBase.Client/Pages/Auth.razor | 201 --------- .../Pages/Auth/ForgotPassword.razor | 108 +++++ .../Pages/Auth/Login.razor | 136 +++++++ .../Pages/Auth/Profile.razor | 295 ++++++++++++++ .../Pages/Auth/Register.razor | 155 +++++++ .../Pages/Auth/ResetPassword.razor | 164 ++++++++ .../Pages/Auth/VerifyEmail.razor | 124 ++++++ .../WebClientBase.Client/wwwroot/css/app.css | 382 ++++++++++++++++++ .../wwwroot/locales/en-US.json | 76 +++- .../wwwroot/locales/vi-VN.json | 76 +++- .../src/WebClientBase.Server/yarp.json | 11 + .../DTOs/ChangePasswordDto.cs | 23 ++ .../DTOs/ForgotPasswordDto.cs | 14 + .../DTOs/ResetPasswordDto.cs | 27 ++ .../src/WebClientBase.Shared/DTOs/UserDto.cs | 8 + 15 files changed, 1597 insertions(+), 203 deletions(-) delete mode 100644 apps/web-client-base-net/src/WebClientBase.Client/Pages/Auth.razor create mode 100644 apps/web-client-base-net/src/WebClientBase.Client/Pages/Auth/ForgotPassword.razor create mode 100644 apps/web-client-base-net/src/WebClientBase.Client/Pages/Auth/Login.razor create mode 100644 apps/web-client-base-net/src/WebClientBase.Client/Pages/Auth/Profile.razor create mode 100644 apps/web-client-base-net/src/WebClientBase.Client/Pages/Auth/Register.razor create mode 100644 apps/web-client-base-net/src/WebClientBase.Client/Pages/Auth/ResetPassword.razor create mode 100644 apps/web-client-base-net/src/WebClientBase.Client/Pages/Auth/VerifyEmail.razor create mode 100644 apps/web-client-base-net/src/WebClientBase.Shared/DTOs/ChangePasswordDto.cs create mode 100644 apps/web-client-base-net/src/WebClientBase.Shared/DTOs/ForgotPasswordDto.cs create mode 100644 apps/web-client-base-net/src/WebClientBase.Shared/DTOs/ResetPasswordDto.cs diff --git a/apps/web-client-base-net/src/WebClientBase.Client/Pages/Auth.razor b/apps/web-client-base-net/src/WebClientBase.Client/Pages/Auth.razor deleted file mode 100644 index e7c4ec5d..00000000 --- a/apps/web-client-base-net/src/WebClientBase.Client/Pages/Auth.razor +++ /dev/null @@ -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. -*@ - -Authentication / Xác thực - -
-
- - -
- - @* ═══════════════════════════════════════════════════════════════════════════════ - EN: Login Form - VI: Form đăng nhập - ═══════════════════════════════════════════════════════════════════════════════ *@ - @if (activeTab == "login") - { -
-

Welcome Back / Chào mừng trở lại

-

Sign in to your account / Đăng nhập vào tài khoản

- - - - -
- - - -
- -
- - - -
- -
- - -
- - -
-
- } - - @* ═══════════════════════════════════════════════════════════════════════════════ - EN: Register Form - VI: Form đăng ký - ═══════════════════════════════════════════════════════════════════════════════ *@ - @if (activeTab == "register") - { -
-

Create Account / Tạo tài khoản

-

Join us today / Tham gia với chúng tôi

- - - - - -
- - - -
- -
- - - -
- -
- - - - Min 8 chars, uppercase, lowercase, digit / Tối thiểu 8 ký tự, chữ hoa, chữ thường, số -
- -
- - - -
- - -
-
- } - - @if (!string.IsNullOrEmpty(message)) - { -
- @message -
- } -
- -@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>(); - 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>(); - 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; - } - } -} diff --git a/apps/web-client-base-net/src/WebClientBase.Client/Pages/Auth/ForgotPassword.razor b/apps/web-client-base-net/src/WebClientBase.Client/Pages/Auth/ForgotPassword.razor new file mode 100644 index 00000000..0a9c11ab --- /dev/null +++ b/apps/web-client-base-net/src/WebClientBase.Client/Pages/Auth/ForgotPassword.razor @@ -0,0 +1,108 @@ +@page "/forgot-password" +@using WebClientBase.Shared.DTOs +@using WebClientBase.Shared +@inject HttpClient Http +@inject IStringLocalizer L + +@* + EN: Forgot password page. + VI: Trang quên mật khẩu. +*@ + +@L["Auth_ForgotPassword_Title"] + +
+
+

@L["Auth_ForgotPassword_Title"]

+

@L["Auth_ForgotPassword_Subtitle"]

+ + + + +
+ + + +
+ + +
+ + @if (!string.IsNullOrEmpty(message)) + { +
+ @message +
+ } + + +
+
+ +@code { + private ForgotPasswordDto forgotPasswordModel = new(); + private bool isSubmitting = false; + private string message = ""; + private bool success = false; + + /// + /// EN: Handle forgot password form submission. + /// VI: Xử lý submit form quên mật khẩu. + /// + 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>(); + 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; + } + } +} diff --git a/apps/web-client-base-net/src/WebClientBase.Client/Pages/Auth/Login.razor b/apps/web-client-base-net/src/WebClientBase.Client/Pages/Auth/Login.razor new file mode 100644 index 00000000..095c4c11 --- /dev/null +++ b/apps/web-client-base-net/src/WebClientBase.Client/Pages/Auth/Login.razor @@ -0,0 +1,136 @@ +@page "/login" +@using WebClientBase.Shared.DTOs +@using WebClientBase.Shared +@inject HttpClient Http +@inject NavigationManager Navigation +@inject IStringLocalizer L + +@* + EN: Login page with email/password authentication. + VI: Trang đăng nhập với xác thực email/mật khẩu. +*@ + +@L["Auth_Login_Title"] + +
+
+

@L["Auth_Login_Title"]

+

@L["Auth_Login_Subtitle"]

+ + + + +
+ + + +
+ +
+ + + +
+ +
+
+ + +
+ + @L["Auth_Login_ForgotPassword"] +
+ + +
+ + @if (!string.IsNullOrEmpty(message)) + { +
+ @message +
+ } + + +
+
+ +@code { + private LoginDto loginModel = new(); + private bool isSubmitting = false; + private string message = ""; + private bool success = false; + + /// + /// EN: Handle login form submission. + /// VI: Xử lý submit form đăng nhập. + /// + 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>(); + 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; + } + } +} diff --git a/apps/web-client-base-net/src/WebClientBase.Client/Pages/Auth/Profile.razor b/apps/web-client-base-net/src/WebClientBase.Client/Pages/Auth/Profile.razor new file mode 100644 index 00000000..d52b5868 --- /dev/null +++ b/apps/web-client-base-net/src/WebClientBase.Client/Pages/Auth/Profile.razor @@ -0,0 +1,295 @@ +@page "/profile" +@using WebClientBase.Shared.DTOs +@using WebClientBase.Shared +@inject HttpClient Http +@inject NavigationManager Navigation +@inject IStringLocalizer 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). +*@ + +@L["Auth_Profile_Title"] + +
+
+

@L["Auth_Profile_Title"]

+

@L["Auth_Profile_Subtitle"]

+
+ + @if (isLoading) + { +
+ +

@L["Common_Loading"]

+
+ } + else if (userProfile != null) + { +
+ +
+

@L["Auth_Profile_PersonalInfo"]

+ + + + +
+ + +
+ +
+ + +
+ +
+ +

@userProfile.CreatedAt.ToString("MMMM dd, yyyy")

+
+ + @if (!isEditingProfile) + { + + } + else + { +
+ + +
+ } +
+
+ + +
+

@L["Auth_Profile_Security"]

+ + + + +
+ + + +
+ +
+ + + @L["Auth_Register_PasswordHint"] + +
+ +
+ + + +
+ + +
+
+ + @if (!string.IsNullOrEmpty(message)) + { +
+ @message +
+ } + +
+ +
+
+ } +
+ +@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(); + } + + /// + /// EN: Load user profile data. + /// VI: Tải dữ liệu hồ sơ người dùng. + /// + private async Task LoadUserProfile() + { + try + { + var response = await Http.GetAsync("api/auth/profile"); + + if (response.IsSuccessStatusCode) + { + var result = await response.Content.ReadFromJsonAsync>(); + 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; + } + } + + /// + /// EN: Handle profile update. + /// VI: Xử lý cập nhật hồ sơ. + /// + 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; + } + } + + /// + /// EN: Handle password change. + /// VI: Xử lý đổi mật khẩu. + /// + 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; + } + } + + /// + /// EN: Cancel profile editing. + /// VI: Hủy chỉnh sửa hồ sơ. + /// + private void CancelEditProfile() + { + isEditingProfile = false; + // Reload to reset changes + LoadUserProfile(); + } + + /// + /// EN: Handle user logout. + /// VI: Xử lý đăng xuất. + /// + 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"); + } + } +} diff --git a/apps/web-client-base-net/src/WebClientBase.Client/Pages/Auth/Register.razor b/apps/web-client-base-net/src/WebClientBase.Client/Pages/Auth/Register.razor new file mode 100644 index 00000000..039b113f --- /dev/null +++ b/apps/web-client-base-net/src/WebClientBase.Client/Pages/Auth/Register.razor @@ -0,0 +1,155 @@ +@page "/register" +@using WebClientBase.Shared.DTOs +@using WebClientBase.Shared +@inject HttpClient Http +@inject NavigationManager Navigation +@inject IStringLocalizer L + +@* + EN: User registration page. + VI: Trang đăng ký người dùng. +*@ + +@L["Auth_Register_Title"] + +
+
+

@L["Auth_Register_Title"]

+

@L["Auth_Register_Subtitle"]

+ + + + +
+ + + +
+ +
+ + + +
+ +
+ + + @L["Auth_Register_PasswordHint"] + +
+ +
+ + + +
+ +
+ + +
+ + +
+ + @if (!string.IsNullOrEmpty(message)) + { +
+ @message +
+ } + + +
+
+ +@code { + private RegisterDto registerModel = new(); + private bool isSubmitting = false; + private string message = ""; + private bool success = false; + + /// + /// EN: Handle registration form submission. + /// VI: Xử lý submit form đăng ký. + /// + 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>(); + 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; + } + } +} diff --git a/apps/web-client-base-net/src/WebClientBase.Client/Pages/Auth/ResetPassword.razor b/apps/web-client-base-net/src/WebClientBase.Client/Pages/Auth/ResetPassword.razor new file mode 100644 index 00000000..6e3a8fe1 --- /dev/null +++ b/apps/web-client-base-net/src/WebClientBase.Client/Pages/Auth/ResetPassword.razor @@ -0,0 +1,164 @@ +@page "/reset-password" +@using WebClientBase.Shared.DTOs +@using WebClientBase.Shared +@inject HttpClient Http +@inject NavigationManager Navigation +@inject IStringLocalizer L + +@* + EN: Reset password page with token validation. + VI: Trang đặt lại mật khẩu với xác thực token. +*@ + +@L["Auth_ResetPassword_Title"] + +
+
+

@L["Auth_ResetPassword_Title"]

+

@L["Auth_ResetPassword_Subtitle"]

+ + @if (invalidToken) + { +
+ @L["Auth_ResetPassword_InvalidToken"] +
+ + } + else + { + + + +
+ + + @L["Auth_ResetPassword_PasswordHint"] + +
+ +
+ + + +
+ + +
+ + @if (!string.IsNullOrEmpty(message)) + { +
+ @message +
+ } + } +
+
+ +@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; + } + } + + /// + /// EN: Handle reset password form submission. + /// VI: Xử lý submit form đặt lại mật khẩu. + /// + 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>(); + 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; + } + } +} diff --git a/apps/web-client-base-net/src/WebClientBase.Client/Pages/Auth/VerifyEmail.razor b/apps/web-client-base-net/src/WebClientBase.Client/Pages/Auth/VerifyEmail.razor new file mode 100644 index 00000000..0dae577e --- /dev/null +++ b/apps/web-client-base-net/src/WebClientBase.Client/Pages/Auth/VerifyEmail.razor @@ -0,0 +1,124 @@ +@page "/verify-email" +@using WebClientBase.Shared +@inject HttpClient Http +@inject NavigationManager Navigation +@inject IStringLocalizer L + +@* + EN: Email verification page. + VI: Trang xác minh email. +*@ + +@L["Auth_VerifyEmail_Title"] + +
+
+

@L["Auth_VerifyEmail_Title"]

+ + @if (isVerifying) + { +
+ +

@L["Auth_VerifyEmail_Verifying"]

+
+ } + else if (success) + { +
+ + + +

@L["Auth_VerifyEmail_Success"]

+ +
+ } + else + { +
+ @message +
+ + } +
+
+ +@code { + private bool isVerifying = true; + private bool success = false; + private string message = ""; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + await VerifyEmailAsync(); + } + } + + /// + /// EN: Verify email using token from query parameters. + /// VI: Xác minh email sử dụng token từ query parameters. + /// + 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>(); + 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(); + } + } +} diff --git a/apps/web-client-base-net/src/WebClientBase.Client/wwwroot/css/app.css b/apps/web-client-base-net/src/WebClientBase.Client/wwwroot/css/app.css index fe3f4655..39fc41df 100644 --- a/apps/web-client-base-net/src/WebClientBase.Client/wwwroot/css/app.css +++ b/apps/web-client-base-net/src/WebClientBase.Client/wwwroot/css/app.css @@ -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; } \ No newline at end of file diff --git a/apps/web-client-base-net/src/WebClientBase.Client/wwwroot/locales/en-US.json b/apps/web-client-base-net/src/WebClientBase.Client/wwwroot/locales/en-US.json index d9b08373..5eabd2a5 100644 --- a/apps/web-client-base-net/src/WebClientBase.Client/wwwroot/locales/en-US.json +++ b/apps/web-client-base-net/src/WebClientBase.Client/wwwroot/locales/en-US.json @@ -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" } \ No newline at end of file diff --git a/apps/web-client-base-net/src/WebClientBase.Client/wwwroot/locales/vi-VN.json b/apps/web-client-base-net/src/WebClientBase.Client/wwwroot/locales/vi-VN.json index 630b053d..de50ae34 100644 --- a/apps/web-client-base-net/src/WebClientBase.Client/wwwroot/locales/vi-VN.json +++ b/apps/web-client-base-net/src/WebClientBase.Client/wwwroot/locales/vi-VN.json @@ -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" } \ No newline at end of file diff --git a/apps/web-client-base-net/src/WebClientBase.Server/yarp.json b/apps/web-client-base-net/src/WebClientBase.Server/yarp.json index d6f1d03e..cfcc2d7e 100644 --- a/apps/web-client-base-net/src/WebClientBase.Server/yarp.json +++ b/apps/web-client-base-net/src/WebClientBase.Server/yarp.json @@ -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": { diff --git a/apps/web-client-base-net/src/WebClientBase.Shared/DTOs/ChangePasswordDto.cs b/apps/web-client-base-net/src/WebClientBase.Shared/DTOs/ChangePasswordDto.cs new file mode 100644 index 00000000..c1563a3d --- /dev/null +++ b/apps/web-client-base-net/src/WebClientBase.Shared/DTOs/ChangePasswordDto.cs @@ -0,0 +1,23 @@ +using System.ComponentModel.DataAnnotations; + +namespace WebClientBase.Shared.DTOs; + +/// +/// 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. +/// +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; +} diff --git a/apps/web-client-base-net/src/WebClientBase.Shared/DTOs/ForgotPasswordDto.cs b/apps/web-client-base-net/src/WebClientBase.Shared/DTOs/ForgotPasswordDto.cs new file mode 100644 index 00000000..6d9cfda5 --- /dev/null +++ b/apps/web-client-base-net/src/WebClientBase.Shared/DTOs/ForgotPasswordDto.cs @@ -0,0 +1,14 @@ +using System.ComponentModel.DataAnnotations; + +namespace WebClientBase.Shared.DTOs; + +/// +/// EN: Forgot password request DTO. +/// VI: DTO cho yêu cầu quên mật khẩu. +/// +public class ForgotPasswordDto +{ + [Required(ErrorMessage = "Email is required")] + [EmailAddress(ErrorMessage = "Invalid email address")] + public string Email { get; set; } = string.Empty; +} diff --git a/apps/web-client-base-net/src/WebClientBase.Shared/DTOs/ResetPasswordDto.cs b/apps/web-client-base-net/src/WebClientBase.Shared/DTOs/ResetPasswordDto.cs new file mode 100644 index 00000000..3f123fe2 --- /dev/null +++ b/apps/web-client-base-net/src/WebClientBase.Shared/DTOs/ResetPasswordDto.cs @@ -0,0 +1,27 @@ +using System.ComponentModel.DataAnnotations; + +namespace WebClientBase.Shared.DTOs; + +/// +/// EN: Reset password DTO. +/// VI: DTO cho đặt lại mật khẩu. +/// +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; +} diff --git a/apps/web-client-base-net/src/WebClientBase.Shared/DTOs/UserDto.cs b/apps/web-client-base-net/src/WebClientBase.Shared/DTOs/UserDto.cs index 43b99a11..ff1af83d 100644 --- a/apps/web-client-base-net/src/WebClientBase.Shared/DTOs/UserDto.cs +++ b/apps/web-client-base-net/src/WebClientBase.Shared/DTOs/UserDto.cs @@ -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; + + /// + /// EN: Accept terms of service. + /// VI: Chấp nhận điều khoản dịch vụ. + /// + [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; } } /// @@ -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; } }