feature: update message for login feature
This commit is contained in:
@@ -14,7 +14,7 @@ class AuthController extends Controller
|
|||||||
|
|
||||||
public function showLoginForm()
|
public function showLoginForm()
|
||||||
{
|
{
|
||||||
if (Auth::check()) {
|
if (Auth::check() && !session('dialog_first_login')) {
|
||||||
return redirect($this->authService->getRedirectRouteForUser(Auth::user()));
|
return redirect($this->authService->getRedirectRouteForUser(Auth::user()));
|
||||||
}
|
}
|
||||||
return view('login');
|
return view('login');
|
||||||
@@ -22,20 +22,51 @@ class AuthController extends Controller
|
|||||||
|
|
||||||
public function login(Request $request)
|
public function login(Request $request)
|
||||||
{
|
{
|
||||||
$credentials = $request->validate([
|
// Custom validation check: treat all validation errors as "Invalid email/password"
|
||||||
|
$validator = \Illuminate\Support\Facades\Validator::make($request->all(), [
|
||||||
'mail' => 'required|email|max:255',
|
'mail' => 'required|email|max:255',
|
||||||
'password' => 'required|string|max:100',
|
'password' => 'required|string|max:100',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$result = $this->authService->attemptLogin($credentials, $request);
|
if ($validator->fails()) {
|
||||||
|
|
||||||
if (!$result['success']) {
|
|
||||||
return back()->withInput($request->only('mail'))->withErrors([
|
return back()->withInput($request->only('mail'))->withErrors([
|
||||||
$result['error_key'] => $result['error_msg'],
|
'mail' => 'Thông tin đăng nhập không đúng. Vui lòng nhập lại',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return redirect($this->authService->getRedirectRouteForUser($result['user']));
|
$credentials = $validator->validated();
|
||||||
|
$email = \Illuminate\Support\Str::lower($credentials['mail']);
|
||||||
|
$throttleKey = 'login-attempts:' . $email;
|
||||||
|
|
||||||
|
// Brute-force check (lock after 5 failed attempts)
|
||||||
|
if (\Illuminate\Support\Facades\RateLimiter::tooManyAttempts($throttleKey, 5)) {
|
||||||
|
return back()->withInput($request->only('mail'))->with('dialog_error_brute_force', 'Tài khoản của bạn tạm thời bị khóa do nhập sai mật khẩu quá 5 lần. Vui lòng thử lại sau 15 phút.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $this->authService->attemptLogin($credentials, $request);
|
||||||
|
|
||||||
|
if (!$result['success']) {
|
||||||
|
if ($result['error_key'] === 'permission') {
|
||||||
|
return back()->withInput($request->only('mail'))->with('dialog_error_permission', $result['error_msg']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For invalid password / email (counts towards brute-force attempts)
|
||||||
|
\Illuminate\Support\Facades\RateLimiter::hit($throttleKey, 900); // 15 minutes lockout
|
||||||
|
|
||||||
|
return back()->withInput($request->only('mail'))->withErrors([
|
||||||
|
'mail' => $result['error_msg'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear rate limiter on success
|
||||||
|
\Illuminate\Support\Facades\RateLimiter::clear($throttleKey);
|
||||||
|
|
||||||
|
$user = $result['user'];
|
||||||
|
if ($user->first_login == \App\Models\User::FIRST_LOGIN_TRUE) {
|
||||||
|
return redirect()->route('login')->with('dialog_first_login', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect($this->authService->getRedirectRouteForUser($user));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function logout(Request $request)
|
public function logout(Request $request)
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ class AuthService implements AuthServiceInterface
|
|||||||
// Manual MD5 password check
|
// Manual MD5 password check
|
||||||
$user = User::where('mail', $credentials['mail'])->first();
|
$user = User::where('mail', $credentials['mail'])->first();
|
||||||
if (!$user || $user->pass !== md5($credentials['password'])) {
|
if (!$user || $user->pass !== md5($credentials['password'])) {
|
||||||
return ['success' => false, 'error_key' => 'mail', 'error_msg' => __('auth.failed')];
|
return ['success' => false, 'error_key' => 'mail', 'error_msg' => 'Thông tin đăng nhập không đúng. Vui lòng nhập lại'];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log the user in manually
|
// Log the user in manually
|
||||||
@@ -22,7 +22,7 @@ class AuthService implements AuthServiceInterface
|
|||||||
$request->session()->regenerate();
|
$request->session()->regenerate();
|
||||||
if ($user->status != User::STATUS_ACTIVE) {
|
if ($user->status != User::STATUS_ACTIVE) {
|
||||||
Auth::logout();
|
Auth::logout();
|
||||||
return ['success' => false, 'error_key' => 'mail', 'error_msg' => __('auth.inactive')];
|
return ['success' => false, 'error_key' => 'permission', 'error_msg' => 'Bạn không có quyền truy cập. Vui lòng liên hệ quản trị viên'];
|
||||||
}
|
}
|
||||||
|
|
||||||
return ['success' => true, 'user' => $user];
|
return ['success' => true, 'user' => $user];
|
||||||
@@ -41,6 +41,10 @@ class AuthService implements AuthServiceInterface
|
|||||||
return route('user.change_password');
|
return route('user.change_password');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($user->role == User::ROLE_ADMIN) {
|
||||||
|
return route('admin.dashboard');
|
||||||
|
}
|
||||||
|
|
||||||
return route('user.dashboard');
|
return route('user.dashboard');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+6
-1
@@ -25,5 +25,10 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
]);
|
]);
|
||||||
})
|
})
|
||||||
->withExceptions(function (Exceptions $exceptions): void {
|
->withExceptions(function (Exceptions $exceptions): void {
|
||||||
//
|
$exceptions->render(function (\Illuminate\Auth\AuthenticationException $e, \Illuminate\Http\Request $request) {
|
||||||
|
if ($request->expectsJson()) {
|
||||||
|
return response()->json(['message' => $e->getMessage()], 401);
|
||||||
|
}
|
||||||
|
return redirect()->guest(route('login'))->with('session_expired', true);
|
||||||
|
});
|
||||||
})->create();
|
})->create();
|
||||||
|
|||||||
@@ -19,9 +19,9 @@ The following reusable/common components are created and used across all display
|
|||||||
|
|
||||||
| Case ID | Feature | Display Case | Notification Type | Message | Screenshot Path |
|
| Case ID | Feature | Display Case | Notification Type | Message | Screenshot Path |
|
||||||
| :--- | :--- | :--- | :--- | :--- | :--- |
|
| :--- | :--- | :--- | :--- | :--- | :--- |
|
||||||
| **01** | Authentication | Login failed | Toast (Error) | Đăng nhập thất bại. Vui lòng kiểm tra lại Email hoặc Mật khẩu. | [case_01_login_failed.png](case_01_login_failed.png) |
|
| **01** | Authentication | Login failed | Inline (Error) | Thông tin đăng nhập không đúng. Vui lòng nhập lại | [case_01_login_failed.png](case_01_login_failed.png) |
|
||||||
| **02** | Authorization | Access denied | Error Dialog | Tài khoản của bạn không có quyền truy cập vào trang quản trị này. Vui lòng liên hệ với quản trị viên nếu đây là một sự nhầm lẫn. | [case_02_access_denied.png](case_02_access_denied.png) |
|
| **02** | Authorization | Access denied / disabled | Error Dialog | Bạn không có quyền truy cập. Vui lòng liên hệ quản trị viên | [case_02_access_denied.png](case_02_access_denied.png) |
|
||||||
| **03** | Authentication | First login (change password required) | Info Dialog | Chào mừng bạn đến với hệ thống Thankcard! Đây là lần đầu tiên bạn đăng nhập, vui lòng đổi mật khẩu mới để tiếp tục sử dụng hệ thống. | [case_03_first_login.png](case_03_first_login.png) |
|
| **03** | Authentication | First login | Info Dialog | Vui lòng đổi mật khẩu trước khi tiếp tục sử dụng hệ thống. | [case_03_first_login.png](case_03_first_login.png) |
|
||||||
| **04** | User Management | Delete user confirmation | Confirm Dialog (Danger) | Bạn có chắc chắn muốn xóa tài khoản của nhân viên Nguyễn Văn A (MSNV: NV0001)? Hành động này không thể hoàn tác và tất cả dữ liệu liên quan sẽ bị xóa. | [case_04_delete_user_confirmation.png](case_04_delete_user_confirmation.png) |
|
| **04** | User Management | Delete user confirmation | Confirm Dialog (Danger) | Bạn có chắc chắn muốn xóa tài khoản của nhân viên Nguyễn Văn A (MSNV: NV0001)? Hành động này không thể hoàn tác và tất cả dữ liệu liên quan sẽ bị xóa. | [case_04_delete_user_confirmation.png](case_04_delete_user_confirmation.png) |
|
||||||
| **05** | User Management | Create user success | Success Dialog | Tài khoản nhân viên mới đã được tạo thành công trong hệ thống. Email kích hoạt đã được gửi tới người dùng. | [case_05_create_user_success.png](case_05_create_user_success.png) |
|
| **05** | User Management | Create user success | Success Dialog | Tài khoản nhân viên mới đã được tạo thành công trong hệ thống. Email kích hoạt đã được gửi tới người dùng. | [case_05_create_user_success.png](case_05_create_user_success.png) |
|
||||||
| **06** | User Management | Email already exists | Error Dialog | Địa chỉ email 'nguyenvana@runsystem.net' đã được đăng ký bởi một nhân viên khác. Vui lòng sử dụng địa chỉ email khác. | [case_06_email_already_exists.png](case_06_email_already_exists.png) |
|
| **06** | User Management | Email already exists | Error Dialog | Địa chỉ email 'nguyenvana@runsystem.net' đã được đăng ký bởi một nhân viên khác. Vui lòng sử dụng địa chỉ email khác. | [case_06_email_already_exists.png](case_06_email_already_exists.png) |
|
||||||
|
|||||||
+2
-2
@@ -9,6 +9,6 @@ return [
|
|||||||
'forgot_password' => 'Quên mật khẩu?',
|
'forgot_password' => 'Quên mật khẩu?',
|
||||||
'system_name' => 'Hệ thống Thank Card',
|
'system_name' => 'Hệ thống Thank Card',
|
||||||
'developed_by' => 'Được phát triển bởi GMO-Z.com RUNSYSTEM',
|
'developed_by' => 'Được phát triển bởi GMO-Z.com RUNSYSTEM',
|
||||||
'failed' => 'Email hoặc mật khẩu không chính xác.',
|
'failed' => 'Thông tin đăng nhập không đúng. Vui lòng nhập lại',
|
||||||
'inactive' => 'Tài khoản của bạn đã bị khóa hoặc bạn đã nghỉ việc.',
|
'inactive' => 'Bạn không có quyền truy cập. Vui lòng liên hệ quản trị viên',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@props(['id', 'title' => 'Đã xảy ra lỗi', 'message' => '', 'buttonText' => 'Đóng'])
|
@props(['id', 'title' => 'Đã xảy ra lỗi', 'message' => '', 'buttonText' => 'Đóng', 'onclick' => null])
|
||||||
|
|
||||||
<div id="{{ $id }}" class="fixed inset-0 z-[100] hidden flex-col items-center justify-center outline-none opacity-0 transition-opacity duration-300" aria-labelledby="modal-title-{{ $id }}" role="dialog" aria-modal="true">
|
<div id="{{ $id }}" class="fixed inset-0 z-[100] hidden flex-col items-center justify-center outline-none opacity-0 transition-opacity duration-300" aria-labelledby="modal-title-{{ $id }}" role="dialog" aria-modal="true">
|
||||||
<div class="absolute inset-0 bg-slate-900/60 backdrop-blur-sm cursor-pointer" onclick="closeModal('{{ $id }}')"></div>
|
<div class="absolute inset-0 bg-slate-900/60 backdrop-blur-sm cursor-pointer" onclick="closeModal('{{ $id }}')"></div>
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
<p class="text-slate-500 text-sm leading-relaxed">{{ $message }}</p>
|
<p class="text-slate-500 text-sm leading-relaxed">{{ $message }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="px-6 py-4 bg-slate-50 flex justify-center border-t border-slate-100">
|
<div class="px-6 py-4 bg-slate-50 flex justify-center border-t border-slate-100">
|
||||||
<button type="button" onclick="closeModal('{{ $id }}')" class="w-full sm:w-auto px-6 py-2 bg-red-600 hover:bg-red-700 text-white text-sm font-semibold rounded-lg shadow-sm transition-all duration-200 cursor-pointer text-center">
|
<button type="button" onclick="{{ $onclick ?? "closeModal('{$id}')" }}" class="w-full sm:w-auto px-6 py-2 bg-red-600 hover:bg-red-700 text-white text-sm font-semibold rounded-lg shadow-sm transition-all duration-200 cursor-pointer text-center">
|
||||||
{{ $buttonText }}
|
{{ $buttonText }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@props(['id', 'title' => 'Thông tin', 'message' => '', 'buttonText' => 'Đóng'])
|
@props(['id', 'title' => 'Thông tin', 'message' => '', 'buttonText' => 'Đóng', 'onclick' => null])
|
||||||
|
|
||||||
<div id="{{ $id }}" class="fixed inset-0 z-[100] hidden flex-col items-center justify-center outline-none opacity-0 transition-opacity duration-300" aria-labelledby="modal-title-{{ $id }}" role="dialog" aria-modal="true">
|
<div id="{{ $id }}" class="fixed inset-0 z-[100] hidden flex-col items-center justify-center outline-none opacity-0 transition-opacity duration-300" aria-labelledby="modal-title-{{ $id }}" role="dialog" aria-modal="true">
|
||||||
<div class="absolute inset-0 bg-slate-900/60 backdrop-blur-sm cursor-pointer" onclick="closeModal('{{ $id }}')"></div>
|
<div class="absolute inset-0 bg-slate-900/60 backdrop-blur-sm cursor-pointer" onclick="closeModal('{{ $id }}')"></div>
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
<p class="text-slate-500 text-sm leading-relaxed">{{ $message }}</p>
|
<p class="text-slate-500 text-sm leading-relaxed">{{ $message }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="px-6 py-4 bg-slate-50 flex justify-center border-t border-slate-100">
|
<div class="px-6 py-4 bg-slate-50 flex justify-center border-t border-slate-100">
|
||||||
<button type="button" onclick="closeModal('{{ $id }}')" class="w-full sm:w-auto px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-semibold rounded-lg shadow-sm transition-all duration-200 cursor-pointer text-center">
|
<button type="button" onclick="{{ $onclick ?? "closeModal('{$id}')" }}" class="w-full sm:w-auto px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-semibold rounded-lg shadow-sm transition-all duration-200 cursor-pointer text-center">
|
||||||
{{ $buttonText }}
|
{{ $buttonText }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@props(['id', 'title' => 'Cảnh báo', 'message' => '', 'buttonText' => 'Đã hiểu'])
|
@props(['id', 'title' => 'Cảnh báo', 'message' => '', 'buttonText' => 'Đã hiểu', 'onclick' => null])
|
||||||
|
|
||||||
<div id="{{ $id }}" class="fixed inset-0 z-[100] hidden flex-col items-center justify-center outline-none opacity-0 transition-opacity duration-300" aria-labelledby="modal-title-{{ $id }}" role="dialog" aria-modal="true">
|
<div id="{{ $id }}" class="fixed inset-0 z-[100] hidden flex-col items-center justify-center outline-none opacity-0 transition-opacity duration-300" aria-labelledby="modal-title-{{ $id }}" role="dialog" aria-modal="true">
|
||||||
<div class="absolute inset-0 bg-slate-900/60 backdrop-blur-sm cursor-pointer" onclick="closeModal('{{ $id }}')"></div>
|
<div class="absolute inset-0 bg-slate-900/60 backdrop-blur-sm cursor-pointer" onclick="closeModal('{{ $id }}')"></div>
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
<p class="text-slate-500 text-sm leading-relaxed">{{ $message }}</p>
|
<p class="text-slate-500 text-sm leading-relaxed">{{ $message }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="px-6 py-4 bg-slate-50 flex justify-center border-t border-slate-100">
|
<div class="px-6 py-4 bg-slate-50 flex justify-center border-t border-slate-100">
|
||||||
<button type="button" onclick="closeModal('{{ $id }}')" class="w-full sm:w-auto px-6 py-2 bg-amber-500 hover:bg-amber-600 text-white text-sm font-semibold rounded-lg shadow-sm transition-all duration-200 cursor-pointer text-center">
|
<button type="button" onclick="{{ $onclick ?? "closeModal('{$id}')" }}" class="w-full sm:w-auto px-6 py-2 bg-amber-500 hover:bg-amber-600 text-white text-sm font-semibold rounded-lg shadow-sm transition-all duration-200 cursor-pointer text-center">
|
||||||
{{ $buttonText }}
|
{{ $buttonText }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -65,7 +65,7 @@
|
|||||||
|
|
||||||
<!-- Form -->
|
<!-- Form -->
|
||||||
<div class="w-full max-w-[380px] mx-auto flex-1 relative z-10">
|
<div class="w-full max-w-[380px] mx-auto flex-1 relative z-10">
|
||||||
<form action="{{ route('login') }}" method="POST" class="space-y-5">
|
<form action="{{ route('login') }}" method="POST" class="space-y-5" novalidate>
|
||||||
@csrf
|
@csrf
|
||||||
|
|
||||||
<div class="min-h-[40px] flex items-center justify-center transition-all duration-300" aria-live="polite">
|
<div class="min-h-[40px] flex items-center justify-center transition-all duration-300" aria-live="polite">
|
||||||
@@ -180,5 +180,91 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Dialogs -->
|
||||||
|
@if(session('dialog_error_permission'))
|
||||||
|
<x-error-dialog id="modal-error-permission" title="Truy cập bị từ chối" message="{{ session('dialog_error_permission') }}" buttonText="Đồng ý" />
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if(session('session_expired'))
|
||||||
|
<x-warning-dialog id="modal-session-expired" title="Phiên làm việc hết hạn" message="Phiên đăng nhập đã hết hạn. Vui lòng đăng nhập lại." buttonText="OK" onclick="handleSessionExpiredOk()" />
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if(session('dialog_first_login'))
|
||||||
|
<x-info-dialog id="modal-first-login" title="Yêu cầu đổi mật khẩu" message="Vui lòng đổi mật khẩu trước khi tiếp tục sử dụng hệ thống." buttonText="OK" onclick="handleFirstLoginOk()" />
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if(session('dialog_error_brute_force'))
|
||||||
|
<x-error-dialog id="modal-error-brute-force" title="Tài khoản bị khóa" message="{{ session('dialog_error_brute_force') }}" buttonText="Đồng ý" />
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<script>
|
||||||
|
window.openModal = function(id) {
|
||||||
|
const modal = document.getElementById(id);
|
||||||
|
if (!modal) return;
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
modal.classList.remove('hidden');
|
||||||
|
modal.classList.add('flex');
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
modal.classList.remove('opacity-0');
|
||||||
|
modal.classList.add('opacity-100');
|
||||||
|
const panel = modal.querySelector('.modal-panel');
|
||||||
|
if (panel) {
|
||||||
|
panel.classList.remove('scale-95', 'translate-y-4');
|
||||||
|
panel.classList.add('scale-100', 'translate-y-0');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
modal.setAttribute('tabindex', '-1');
|
||||||
|
modal.focus();
|
||||||
|
const escHandler = function(e) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
closeModal(id);
|
||||||
|
modal.removeEventListener('keydown', escHandler);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
modal.addEventListener('keydown', escHandler);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.closeModal = function(id) {
|
||||||
|
const modal = document.getElementById(id);
|
||||||
|
if (!modal) return;
|
||||||
|
modal.classList.remove('opacity-100');
|
||||||
|
modal.classList.add('opacity-0');
|
||||||
|
const panel = modal.querySelector('.modal-panel');
|
||||||
|
if (panel) {
|
||||||
|
panel.classList.remove('scale-100', 'translate-y-0');
|
||||||
|
panel.classList.add('scale-95', 'translate-y-4');
|
||||||
|
}
|
||||||
|
setTimeout(() => {
|
||||||
|
modal.classList.add('hidden');
|
||||||
|
modal.classList.remove('flex');
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
}, 300);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.handleSessionExpiredOk = function() {
|
||||||
|
closeModal('modal-session-expired');
|
||||||
|
window.location.href = "{{ route('login') }}";
|
||||||
|
};
|
||||||
|
|
||||||
|
window.handleFirstLoginOk = function() {
|
||||||
|
closeModal('modal-first-login');
|
||||||
|
window.location.href = "{{ route('user.change_password') }}";
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
@if(session('dialog_error_permission'))
|
||||||
|
openModal('modal-error-permission');
|
||||||
|
@endif
|
||||||
|
@if(session('session_expired'))
|
||||||
|
openModal('modal-session-expired');
|
||||||
|
@endif
|
||||||
|
@if(session('dialog_first_login'))
|
||||||
|
openModal('modal-first-login');
|
||||||
|
@endif
|
||||||
|
@if(session('dialog_error_brute_force'))
|
||||||
|
openModal('modal-error-brute-force');
|
||||||
|
@endif
|
||||||
|
});
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -261,13 +261,13 @@
|
|||||||
<!-- All Case Modals/Toasts rendered as Reusable components -->
|
<!-- All Case Modals/Toasts rendered as Reusable components -->
|
||||||
|
|
||||||
<!-- Case 1: Login failed -->
|
<!-- Case 1: Login failed -->
|
||||||
<x-toast id="toast-case-1" type="error" message="Đăng nhập thất bại. Vui lòng kiểm tra lại Email hoặc Mật khẩu." />
|
<x-toast id="toast-case-1" type="error" message="Thông tin đăng nhập không đúng. Vui lòng nhập lại" />
|
||||||
|
|
||||||
<!-- Case 2: Access denied -->
|
<!-- Case 2: Access denied -->
|
||||||
<x-error-dialog id="modal-case-2" title="Truy cập bị từ chối" message="Tài khoản của bạn không có quyền truy cập vào trang quản trị này. Vui lòng liên hệ với quản trị viên nếu đây là một sự nhầm lẫn." buttonText="Đồng ý" />
|
<x-error-dialog id="modal-case-2" title="Truy cập bị từ chối" message="Bạn không có quyền truy cập. Vui lòng liên hệ quản trị viên" buttonText="Đồng ý" />
|
||||||
|
|
||||||
<!-- Case 3: First login (change password required) -->
|
<!-- Case 3: First login (change password required) -->
|
||||||
<x-info-dialog id="modal-case-3" title="Yêu cầu đổi mật khẩu" message="Chào mừng bạn đến với hệ thống Thankcard! Đây là lần đầu tiên bạn đăng nhập, vui lòng đổi mật khẩu mới để tiếp tục sử dụng hệ thống." buttonText="Đổi mật khẩu ngay" />
|
<x-info-dialog id="modal-case-3" title="Yêu cầu đổi mật khẩu" message="Vui lòng đổi mật khẩu trước khi tiếp tục sử dụng hệ thống." buttonText="OK" />
|
||||||
|
|
||||||
<!-- Case 4: Delete user confirmation -->
|
<!-- Case 4: Delete user confirmation -->
|
||||||
<x-confirm-dialog id="modal-case-4" title="Xóa người dùng" message="Bạn có chắc chắn muốn xóa tài khoản của nhân viên Nguyễn Văn A (MSNV: NV0001)? Hành động này không thể hoàn tác và tất cả dữ liệu liên quan sẽ bị xóa." confirmText="Xóa vĩnh viễn" cancelText="Hủy bỏ" type="danger" />
|
<x-confirm-dialog id="modal-case-4" title="Xóa người dùng" message="Bạn có chắc chắn muốn xóa tài khoản của nhân viên Nguyễn Văn A (MSNV: NV0001)? Hành động này không thể hoàn tác và tất cả dữ liệu liên quan sẽ bị xóa." confirmText="Xóa vĩnh viễn" cancelText="Hủy bỏ" type="danger" />
|
||||||
|
|||||||
@@ -2,54 +2,60 @@
|
|||||||
@section('title', 'Đổi Mật Khẩu')
|
@section('title', 'Đổi Mật Khẩu')
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
<div class="form-container mt-10">
|
<div class="max-w-[480px] mx-auto bg-white rounded-[24px] shadow-[0_10px_30px_rgba(15,23,42,0.05)] p-8 mt-6">
|
||||||
<div class="form-header">
|
<div class="text-center mb-8">
|
||||||
<h3 class="form-header-title">Đổi Mật Khẩu</h3>
|
<div class="w-12 h-12 bg-blue-50 text-[#3462f7] rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" stroke-width="2.2" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-[20px] font-extrabold text-[#1a2b49]">Đổi mật khẩu</h3>
|
||||||
|
<p class="text-[13px] text-gray-500 font-medium mt-1">Cập nhật mật khẩu mới để bảo vệ tài khoản</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form action="{{ route('user.update_password') }}" method="POST">
|
<form action="{{ route('user.update_password') }}" method="POST">
|
||||||
@csrf
|
@csrf
|
||||||
<div class="form-body">
|
<div class="space-y-6">
|
||||||
@if(Auth::user()->first_login == \App\Models\User::FIRST_LOGIN_TRUE)
|
@if(Auth::user()->first_login == \App\Models\User::FIRST_LOGIN_TRUE)
|
||||||
<div class="mb-6 p-4 bg-orange-50 border border-orange-200 text-orange-800 rounded-lg text-sm font-medium leading-relaxed flex items-start gap-2 shadow-sm">
|
<div class="p-4 bg-amber-50/70 border border-amber-100 text-amber-800 rounded-2xl text-xs font-medium leading-relaxed flex items-start gap-2 shadow-sm">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6 shrink-0 text-orange-500 mt-0.5">
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-5 h-5 shrink-0 text-amber-600">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||||
</svg>
|
</svg>
|
||||||
<span>Đây là lần đầu tiên bạn đăng nhập vào hệ thống. Để đảm bảo an toàn, hệ thống yêu cầu bạn đổi mật khẩu trước khi sử dụng các chức năng.</span>
|
<span>Đây là lần đầu tiên bạn đăng nhập. Vui lòng đổi mật khẩu để tiếp tục sử dụng hệ thống.</span>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="flex flex-col">
|
||||||
<label class="form-label" for="password">Mật khẩu mới <span class="text-red-500">*</span></label>
|
<label class="text-[13px] font-bold text-gray-700 mb-1.5" for="password">Mật khẩu mới <span class="text-red-500">*</span></label>
|
||||||
<div class="form-input-group">
|
<div class="relative flex items-center">
|
||||||
<span class="form-input-icon">
|
<span class="absolute left-4 text-gray-400">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5">
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-5 h-5">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0V10.5m-2.812 10.5h14.625c.621 0 1.125-.504 1.125-1.125V11.25c0-.621-.504-1.125-1.125-1.125H3.75c-.621 0-1.125.504-1.125 1.125v7.875c0 .621.504 1.125 1.125 1.125z" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0V10.5m-2.812 10.5h14.625c.621 0 1.125-.504 1.125-1.125V11.25c0-.621-.504-1.125-1.125-1.125H3.75c-.621 0-1.125.504-1.125 1.125v7.875c0 .621.504 1.125 1.125 1.125z" />
|
||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
<input type="password" name="password" id="password" class="form-input form-input-with-icon" required placeholder="Nhập mật khẩu mới">
|
<input type="password" name="password" id="password" class="w-full h-[48px] pl-12 pr-4 bg-[#F8FAFC] border border-gray-200 rounded-xl outline-none text-[#1a2b49] text-[14px] placeholder-gray-400 focus:border-[#3462f7] focus:bg-white focus:ring-4 focus:ring-[#3462f7]/10 hover:border-gray-300 transition-all duration-200" required placeholder="Nhập mật khẩu mới">
|
||||||
</div>
|
</div>
|
||||||
@error('password')<span class="error-text">{{ $message }}</span>@enderror
|
@error('password')<span class="text-red-500 text-[12px] font-semibold mt-1.5 block">{{ $message }}</span>@enderror
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="flex flex-col">
|
||||||
<label class="form-label" for="password_confirmation">Xác nhận lại mật khẩu mới <span class="text-red-500">*</span></label>
|
<label class="text-[13px] font-bold text-gray-700 mb-1.5" for="password_confirmation">Xác nhận lại mật khẩu mới <span class="text-red-500">*</span></label>
|
||||||
<div class="form-input-group">
|
<div class="relative flex items-center">
|
||||||
<span class="form-input-icon">
|
<span class="absolute left-4 text-gray-400">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5">
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-5 h-5">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
<input type="password" name="password_confirmation" id="password_confirmation" class="form-input form-input-with-icon" required placeholder="Nhập lại mật khẩu mới">
|
<input type="password" name="password_confirmation" id="password_confirmation" class="w-full h-[48px] pl-12 pr-4 bg-[#F8FAFC] border border-gray-200 rounded-xl outline-none text-[#1a2b49] text-[14px] placeholder-gray-400 focus:border-[#3462f7] focus:bg-white focus:ring-4 focus:ring-[#3462f7]/10 hover:border-gray-300 transition-all duration-200" required placeholder="Nhập lại mật khẩu mới">
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-footer">
|
<div class="flex items-center gap-3 pt-4">
|
||||||
@if(Auth::user()->first_login != \App\Models\User::FIRST_LOGIN_TRUE)
|
@if(Auth::user()->first_login != \App\Models\User::FIRST_LOGIN_TRUE)
|
||||||
<a href="{{ route('user.dashboard') }}" class="btn-secondary">Hủy bỏ</a>
|
<a href="{{ route('user.dashboard') }}" class="cursor-pointer flex-1 flex items-center justify-center h-[46px] rounded-xl border border-gray-200 bg-white hover:bg-gray-50 text-gray-600 text-[14px] font-bold shadow-sm transition-all duration-200">Hủy bỏ</a>
|
||||||
@endif
|
@endif
|
||||||
<button type="submit" class="btn-primary">Xác Nhận Đổi Mật Khẩu</button>
|
<button type="submit" class="cursor-pointer flex-1 flex items-center justify-center h-[46px] rounded-xl bg-[#3462f7] hover:bg-[#254edb] text-white text-[14px] font-bold shadow-md shadow-blue-500/10 transition-all duration-200">Xác nhận đổi mật khẩu</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,244 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class LoginNotificationTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
RateLimiter::clear('login-attempts:test@example.com');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test Case 1 & 7: Invalid email/password and validation cases
|
||||||
|
*/
|
||||||
|
public function test_invalid_email_or_password_displays_inline_error(): void
|
||||||
|
{
|
||||||
|
// 1. Incorrect password
|
||||||
|
$user = User::create([
|
||||||
|
'msnv' => 5001,
|
||||||
|
'name' => 'Test User',
|
||||||
|
'mail' => 'test@example.com',
|
||||||
|
'pass' => md5('password123'),
|
||||||
|
'departments' => 1,
|
||||||
|
'role' => User::ROLE_MEMBER,
|
||||||
|
'status' => User::STATUS_ACTIVE,
|
||||||
|
'card' => 10,
|
||||||
|
'flag_send' => User::FLAG_SEND_ENABLED,
|
||||||
|
'first_login' => User::FIRST_LOGIN_FALSE,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->post('/login', [
|
||||||
|
'mail' => 'test@example.com',
|
||||||
|
'password' => 'wrong_password',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertRedirect();
|
||||||
|
$response->assertSessionHasErrors(['mail' => 'Thông tin đăng nhập không đúng. Vui lòng nhập lại']);
|
||||||
|
|
||||||
|
// 2. Empty fields
|
||||||
|
$response2 = $this->post('/login', [
|
||||||
|
'mail' => '',
|
||||||
|
'password' => '',
|
||||||
|
]);
|
||||||
|
$response2->assertRedirect();
|
||||||
|
$response2->assertSessionHasErrors(['mail' => 'Thông tin đăng nhập không đúng. Vui lòng nhập lại']);
|
||||||
|
|
||||||
|
// 3. Invalid email format
|
||||||
|
$response3 = $this->post('/login', [
|
||||||
|
'mail' => 'invalid-email-format',
|
||||||
|
'password' => 'password123',
|
||||||
|
]);
|
||||||
|
$response3->assertRedirect();
|
||||||
|
$response3->assertSessionHasErrors(['mail' => 'Thông tin đăng nhập không đúng. Vui lòng nhập lại']);
|
||||||
|
|
||||||
|
// 4. Email too long
|
||||||
|
$response4 = $this->post('/login', [
|
||||||
|
'mail' => str_repeat('a', 256) . '@example.com',
|
||||||
|
'password' => 'password123',
|
||||||
|
]);
|
||||||
|
$response4->assertRedirect();
|
||||||
|
$response4->assertSessionHasErrors(['mail' => 'Thông tin đăng nhập không đúng. Vui lòng nhập lại']);
|
||||||
|
|
||||||
|
// 5. Password too long
|
||||||
|
$response5 = $this->post('/login', [
|
||||||
|
'mail' => 'test@example.com',
|
||||||
|
'password' => str_repeat('p', 101),
|
||||||
|
]);
|
||||||
|
$response5->assertRedirect();
|
||||||
|
$response5->assertSessionHasErrors(['mail' => 'Thông tin đăng nhập không đúng. Vui lòng nhập lại']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test Case 2: Account disabled / no access permission
|
||||||
|
*/
|
||||||
|
public function test_inactive_user_displays_permission_error_dialog(): void
|
||||||
|
{
|
||||||
|
$user = User::create([
|
||||||
|
'msnv' => 5002,
|
||||||
|
'name' => 'Inactive User',
|
||||||
|
'mail' => 'test@example.com',
|
||||||
|
'pass' => md5('password123'),
|
||||||
|
'departments' => 1,
|
||||||
|
'role' => User::ROLE_MEMBER,
|
||||||
|
'status' => User::STATUS_INACTIVE, // 0
|
||||||
|
'card' => 10,
|
||||||
|
'flag_send' => User::FLAG_SEND_ENABLED,
|
||||||
|
'first_login' => User::FIRST_LOGIN_FALSE,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->post('/login', [
|
||||||
|
'mail' => 'test@example.com',
|
||||||
|
'password' => 'password123',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertRedirect();
|
||||||
|
$response->assertSessionHas('dialog_error_permission', 'Bạn không có quyền truy cập. Vui lòng liên hệ quản trị viên');
|
||||||
|
$this->assertFalse(\Illuminate\Support\Facades\Auth::check());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test Case 3: Session expired
|
||||||
|
*/
|
||||||
|
public function test_session_expired_redirects_with_session_expired_flash(): void
|
||||||
|
{
|
||||||
|
$response = $this->get('/my-page'); // Requires auth
|
||||||
|
|
||||||
|
$response->assertRedirect(route('login'));
|
||||||
|
$response->assertSessionHas('session_expired', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test Case 4: Login success
|
||||||
|
*/
|
||||||
|
public function test_login_success_redirects_correctly(): void
|
||||||
|
{
|
||||||
|
// 1. Member redirect to My Page
|
||||||
|
$member = User::create([
|
||||||
|
'msnv' => 5003,
|
||||||
|
'name' => 'Member User',
|
||||||
|
'mail' => 'member@example.com',
|
||||||
|
'pass' => md5('password123'),
|
||||||
|
'departments' => 1,
|
||||||
|
'role' => User::ROLE_MEMBER,
|
||||||
|
'status' => User::STATUS_ACTIVE,
|
||||||
|
'card' => 10,
|
||||||
|
'flag_send' => User::FLAG_SEND_ENABLED,
|
||||||
|
'first_login' => User::FIRST_LOGIN_FALSE,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->post('/login', [
|
||||||
|
'mail' => 'member@example.com',
|
||||||
|
'password' => 'password123',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertRedirect(route('user.dashboard'));
|
||||||
|
$this->assertTrue(\Illuminate\Support\Facades\Auth::check());
|
||||||
|
$this->assertEquals($member->id, \Illuminate\Support\Facades\Auth::user()->id);
|
||||||
|
|
||||||
|
\Illuminate\Support\Facades\Auth::logout();
|
||||||
|
|
||||||
|
// 2. Admin redirect to Admin User List
|
||||||
|
$admin = User::create([
|
||||||
|
'msnv' => 5004,
|
||||||
|
'name' => 'Admin User',
|
||||||
|
'mail' => 'admin@example.com',
|
||||||
|
'pass' => md5('password123'),
|
||||||
|
'departments' => 1,
|
||||||
|
'role' => User::ROLE_ADMIN,
|
||||||
|
'status' => User::STATUS_ACTIVE,
|
||||||
|
'card' => 10,
|
||||||
|
'flag_send' => User::FLAG_SEND_ENABLED,
|
||||||
|
'first_login' => User::FIRST_LOGIN_FALSE,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response2 = $this->post('/login', [
|
||||||
|
'mail' => 'admin@example.com',
|
||||||
|
'password' => 'password123',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response2->assertRedirect(route('admin.dashboard'));
|
||||||
|
$this->assertTrue(\Illuminate\Support\Facades\Auth::check());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test Case 5: First login
|
||||||
|
*/
|
||||||
|
public function test_first_login_redirects_to_login_page_with_first_login_dialog(): void
|
||||||
|
{
|
||||||
|
$user = User::create([
|
||||||
|
'msnv' => 5005,
|
||||||
|
'name' => 'First Login User',
|
||||||
|
'mail' => 'first@example.com',
|
||||||
|
'pass' => md5('password123'),
|
||||||
|
'departments' => 1,
|
||||||
|
'role' => User::ROLE_MEMBER,
|
||||||
|
'status' => User::STATUS_ACTIVE,
|
||||||
|
'card' => 10,
|
||||||
|
'flag_send' => User::FLAG_SEND_ENABLED,
|
||||||
|
'first_login' => User::FIRST_LOGIN_TRUE,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->post('/login', [
|
||||||
|
'mail' => 'first@example.com',
|
||||||
|
'password' => 'password123',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertRedirect(route('login'));
|
||||||
|
$response->assertSessionHas('dialog_first_login', true);
|
||||||
|
$this->assertTrue(\Illuminate\Support\Facades\Auth::check());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test Case 6: Brute-force protection
|
||||||
|
*/
|
||||||
|
public function test_brute_force_protection_locks_after_5_failed_attempts(): void
|
||||||
|
{
|
||||||
|
$user = User::create([
|
||||||
|
'msnv' => 5006,
|
||||||
|
'name' => 'Lockout User',
|
||||||
|
'mail' => 'test@example.com',
|
||||||
|
'pass' => md5('password123'),
|
||||||
|
'departments' => 1,
|
||||||
|
'role' => User::ROLE_MEMBER,
|
||||||
|
'status' => User::STATUS_ACTIVE,
|
||||||
|
'card' => 10,
|
||||||
|
'flag_send' => User::FLAG_SEND_ENABLED,
|
||||||
|
'first_login' => User::FIRST_LOGIN_FALSE,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// First 5 attempts fail
|
||||||
|
for ($i = 0; $i < 5; $i++) {
|
||||||
|
$response = $this->post('/login', [
|
||||||
|
'mail' => 'test@example.com',
|
||||||
|
'password' => 'wrong_password',
|
||||||
|
]);
|
||||||
|
$response->assertSessionHasErrors(['mail' => 'Thông tin đăng nhập không đúng. Vui lòng nhập lại']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6th attempt is blocked by brute force protection
|
||||||
|
$response6 = $this->post('/login', [
|
||||||
|
'mail' => 'test@example.com',
|
||||||
|
'password' => 'wrong_password',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response6->assertSessionHas('dialog_error_brute_force', 'Tài khoản của bạn tạm thời bị khóa do nhập sai mật khẩu quá 5 lần. Vui lòng thử lại sau 15 phút.');
|
||||||
|
|
||||||
|
// 7th attempt with CORRECT password is also blocked
|
||||||
|
$response7 = $this->post('/login', [
|
||||||
|
'mail' => 'test@example.com',
|
||||||
|
'password' => 'password123',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response7->assertSessionHas('dialog_error_brute_force', 'Tài khoản của bạn tạm thời bị khóa do nhập sai mật khẩu quá 5 lần. Vui lòng thử lại sau 15 phút.');
|
||||||
|
$this->assertFalse(\Illuminate\Support\Facades\Auth::check());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user