diff --git a/Fontend/.vscode/settings.json b/Fontend/.vscode/settings.json
new file mode 100644
index 0000000..ae8fece
--- /dev/null
+++ b/Fontend/.vscode/settings.json
@@ -0,0 +1,5 @@
+{
+ "liveServer.settings.port": 3000,
+ "liveServer.settings.host": "localhost",
+ "liveServer.settings.CustomBrowser": "chrome"
+}
diff --git a/Fontend/app.js b/Fontend/app.js
new file mode 100644
index 0000000..c9cd3e0
--- /dev/null
+++ b/Fontend/app.js
@@ -0,0 +1,706 @@
+const $ = (selector) => document.querySelector(selector);
+const $$ = (selector) => document.querySelectorAll(selector);
+
+const DEFAULT_BASE_URL = 'https://grandpa-discharge-isolated.ngrok-free.dev';
+const STORAGE_KEYS = { baseUrl: 'sp_base_url', token: 'sp_token', user: 'sp_user' };
+
+const apiList = [
+ { key: 'register', method: 'POST', path: '/api/auth/register', auth: false, body: ['userName', 'email', 'password'] },
+ { key: 'login', method: 'POST', path: '/api/auth/login', auth: false, body: ['loginIdentifier', 'password'] },
+ { key: 'forgot', method: 'POST', path: '/api/auth/forgot-password', auth: false, body: ['email'] },
+ { key: 'reset', method: 'POST', path: '/api/auth/reset-password', auth: false, body: ['email', 'otp', 'newPassword'] },
+ { key: 'me', method: 'GET', path: '/api/auth/me', auth: true, body: [] },
+ { key: 'changePassword', method: 'POST', path: '/api/auth/change-password', auth: true, body: ['currentPassword', 'newPassword'] },
+ { key: 'users', method: 'GET', path: '/api/admin/users', auth: true, admin: true, body: [] },
+ { key: 'loginLogs', method: 'GET', path: '/api/admin/login-logs', auth: true, admin: true, body: [] },
+ { key: 'sessions', method: 'GET', path: '/api/admin/sessions', auth: true, admin: true, body: [] },
+ { key: 'passwordLogs', method: 'GET', path: '/api/admin/password-change-logs', auth: true, admin: true, body: [] }
+];
+
+const endpoints = Object.fromEntries(apiList.map((api) => [api.key, api.path]));
+
+const state = {
+ baseUrl: (localStorage.getItem(STORAGE_KEYS.baseUrl) || localStorage.getItem('baseUrl') || DEFAULT_BASE_URL).replace(/\/$/, ''),
+ token: localStorage.getItem(STORAGE_KEYS.token) || localStorage.getItem('token') || '',
+ user: safeJson(localStorage.getItem(STORAGE_KEYS.user) || localStorage.getItem('user')),
+ adminTab: 'users',
+ adminPage: { users: 1, loginLogs: 1, sessions: 1, passwordLogs: 1 },
+ adminData: { users: [], loginLogs: [], sessions: [], passwordLogs: [] },
+ isLoading: false,
+ loginLockTimer: null,
+ loginCountdown: 0
+};
+
+const LOGIN_LOCK_KEY = 'sp_login_lock_state';
+const LOGIN_FAIL_COUNT_KEY = 'sp_login_fail_count_state';
+const TEMP_LOCK_SECONDS = 15;
+const MAX_FAILED_ATTEMPTS = 5;
+
+function safeJson(value) {
+ try { return value ? JSON.parse(value) : null; } catch { return null; }
+}
+
+function setResponse(data) {
+ const el = $('#apiResponse');
+ if (!el) return;
+ el.textContent = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
+}
+
+function setLastRequest(method, path, body) {
+ const el = $('#lastRequest');
+ if (!el) return;
+ const bodyText = body ? ` | Body: ${JSON.stringify(body)}` : '';
+ el.textContent = `${method} ${state.baseUrl}${path}${bodyText}`;
+}
+
+function showToast(message, isError = false) {
+ const toast = $('#toast');
+ if (!toast) {
+ alert(message);
+ return;
+ }
+ toast.textContent = message;
+ toast.className = `toast ${isError ? 'error' : ''}`;
+ clearTimeout(showToast.timer);
+ showToast.timer = setTimeout(() => toast.classList.add('hidden'), 3500);
+}
+
+function setLoading(loading) {
+ state.isLoading = loading;
+ $$('button, input').forEach((el) => { el.disabled = loading; });
+}
+
+async function apiRequest(keyOrPath, { method = 'GET', body = null, auth = true } = {}) {
+ const path = endpoints[keyOrPath] || keyOrPath;
+ const payload = body && typeof body === 'object' ? cleanBody(body) : null;
+ setLastRequest(method, path, payload);
+
+ const headers = { Accept: 'application/json', 'ngrok-skip-browser-warning': 'true' };
+ if (payload) headers['Content-Type'] = 'application/json';
+ if (auth && state.token) headers.Authorization = `Bearer ${state.token}`;
+
+ const fetchOptions = { method, headers, mode: 'cors' };
+ if (payload) fetchOptions.body = JSON.stringify(payload);
+
+ setLoading(true);
+ let responseData = null;
+ try {
+ const res = await fetch(state.baseUrl + path, fetchOptions);
+ const text = await res.text();
+ responseData = parseResponse(text, res.ok, res.status);
+ setResponse(responseData);
+ if (!res.ok || responseData.success === false) {
+ const err = new Error(responseData.message || `HTTP ${res.status}`);
+ err.response = responseData;
+ err.status = res.status;
+ throw err;
+ }
+ return responseData;
+ } catch (error) {
+ const message = normalizeFetchError(error);
+ const err = new Error(message);
+ err.response = error.response || responseData || { success: false, message, data: null };
+ err.status = error.status;
+ setResponse(err.response);
+ throw err;
+ } finally {
+ setLoading(false);
+ }
+}
+
+function cleanBody(data) {
+ const result = {};
+ Object.entries(data).forEach(([key, value]) => {
+ const text = typeof value === 'string' ? value.trim() : value;
+ if (text !== '') result[key] = text;
+ });
+ return result;
+}
+
+function parseResponse(text, ok, status) {
+ try { return text ? JSON.parse(text) : { success: ok, message: `HTTP ${status}`, data: null }; }
+ catch { return { success: ok, message: text || `HTTP ${status}`, data: null }; }
+}
+
+function normalizeFetchError(error) {
+ if (String(error.message).includes('Failed to fetch')) {
+ return 'Không gọi được API. Với Live Server hãy chạy port 3000 hoặc 5173, kiểm tra ngrok còn sống và backend đã bật CORS.';
+ }
+ return error.message || 'Có lỗi khi gọi API.';
+}
+
+function getFormData(form) {
+ return cleanBody(Object.fromEntries(new FormData(form).entries()));
+}
+
+function getValue(obj, ...names) {
+ if (!obj || typeof obj !== 'object') return undefined;
+ for (const name of names) {
+ if (Object.prototype.hasOwnProperty.call(obj, name)) return obj[name];
+ }
+ const lowerMap = Object.fromEntries(Object.keys(obj).map((key) => [key.toLowerCase(), key]));
+ for (const name of names) {
+ const realKey = lowerMap[String(name).toLowerCase()];
+ if (realKey) return obj[realKey];
+ }
+ return undefined;
+}
+
+function saveSession(loginData) {
+ const data = loginData.data || {};
+ state.token = data.token || data.accessToken || '';
+ state.user = {
+ id: getValue(data, 'userId', 'id'),
+ userName: getValue(data, 'userName', 'username', 'name'),
+ email: getValue(data, 'email'),
+ role: getValue(data, 'role') || 'USER'
+ };
+ localStorage.setItem(STORAGE_KEYS.token, state.token);
+ localStorage.setItem(STORAGE_KEYS.user, JSON.stringify(state.user));
+ localStorage.setItem('token', state.token);
+ localStorage.setItem('user', JSON.stringify(state.user));
+ updateAuthUi();
+}
+
+function logout() {
+ state.token = '';
+ state.user = null;
+ localStorage.removeItem(STORAGE_KEYS.token);
+ localStorage.removeItem(STORAGE_KEYS.user);
+ localStorage.removeItem('token');
+ localStorage.removeItem('user');
+ updateAuthUi();
+ openPage('loginPage');
+ setResponse('Đã xóa token ở frontend. Backend không có API logout trong danh sách bạn gửi.');
+}
+
+function updateAuthUi() {
+ const isLoggedIn = Boolean(state.token);
+ const role = (state.user?.role || 'GUEST').toUpperCase();
+ const isAdmin = role === 'ADMIN';
+
+ $('#authStatus').textContent = isLoggedIn ? `${state.user?.userName || 'User'} đã đăng nhập` : 'Chưa đăng nhập';
+ $('#roleBadge').textContent = role;
+ $('#roleBadge').classList.toggle('muted', !isLoggedIn);
+ $('#logoutBtn').classList.toggle('hidden', !isLoggedIn);
+
+ // Khi đã đăng nhập thì ẩn Đăng nhập / Đăng ký.
+ $$('.guest-only').forEach((btn) => btn.classList.toggle('hidden', isLoggedIn));
+
+ // Trang thường chỉ hiện sau khi đăng nhập.
+ $$('.protected').forEach((btn) => btn.classList.toggle('hidden', !isLoggedIn));
+
+ // ADMIN không hiện chức năng đổi mật khẩu ở thanh trái.
+ $$('.user-only').forEach((btn) => btn.classList.toggle('hidden', !isLoggedIn || isAdmin));
+
+ // Chỉ ADMIN mới hiện quản trị.
+ $$('.admin-only').forEach((btn) => btn.classList.toggle('hidden', !isAdmin));
+
+ // Khi đã đăng nhập mà đang ở trang đăng nhập/đăng ký/quên mật khẩu thì chuyển sang trang phù hợp.
+ const activePageId = $('.page.active')?.id;
+ if (isLoggedIn && ['loginPage', 'registerPage', 'forgotPage', 'resetPage', 'changePasswordPage'].includes(activePageId) && isAdmin) {
+ openPage('adminPage');
+ } else if (isLoggedIn && ['loginPage', 'registerPage', 'forgotPage', 'resetPage'].includes(activePageId)) {
+ openPage('profilePage');
+ }
+}
+
+function openPage(pageId) {
+ const role = (state.user?.role || '').toUpperCase();
+ const isLoggedIn = Boolean(state.token);
+
+ if (isLoggedIn && ['loginPage', 'registerPage', 'forgotPage', 'resetPage'].includes(pageId)) {
+ pageId = role === 'ADMIN' ? 'adminPage' : 'profilePage';
+ }
+
+ if (pageId === 'changePasswordPage' && role === 'ADMIN') {
+ showToast('Tài khoản ADMIN không có chức năng đổi mật khẩu ở giao diện này.', true);
+ pageId = 'adminPage';
+ }
+
+ if ((pageId === 'forgotPage' || pageId === 'resetPage') && role === 'ADMIN') {
+ showToast('Tài khoản ADMIN không sử dụng chức năng quên/đặt lại mật khẩu.', true);
+ return;
+ }
+ $$('.page').forEach((page) => page.classList.remove('active'));
+ const page = $('#' + pageId);
+ if (page) page.classList.add('active');
+ $$('.nav-item').forEach((btn) => btn.classList.toggle('active', btn.dataset.page === pageId));
+ if (pageId === 'profilePage' && state.token) loadProfile();
+ if (pageId === 'adminPage' && state.token) loadAdminData();
+}
+
+function formatDate(value) {
+ if (!value) return 'Chưa có';
+ const text = String(value);
+ // Chỉ format chuỗi ngày giờ ISO như 2026-07-02T21:13:32.
+ // Không xử lý mọi chuỗi có chữ T để tránh mất chữ đầu trong tên như "Tuan".
+ const isIsoDateTime = /^\d{4}-\d{2}-\d{2}T/.test(text);
+ return isIsoDateTime ? text.replace('T', ' ').slice(0, 19) : text;
+}
+
+function renderProfile(user = {}) {
+ const isActive = getValue(user, 'isActive', 'active');
+ const rows = [
+ ['ID', getValue(user, 'id', 'userId')],
+ ['UserName', getValue(user, 'userName', 'username', 'name')],
+ ['Email', getValue(user, 'email')],
+ ['Role', getValue(user, 'role')],
+ ['Active', isActive === true ? 'Đang hoạt động' : isActive === false ? 'Bị khóa' : 'Không rõ'],
+ ['Failed Attempts', getValue(user, 'failedAttempts', 'failed_attempts')],
+ ['Locked Until', getValue(user, 'lockedUntil', 'locked_until') || 'Không'],
+ ['Last Login', formatDate(getValue(user, 'lastLoginAt', 'lastLogin', 'last_login_at'))],
+ ['Password Changed', formatDate(getValue(user, 'passwordChangedAt', 'passwordChanged', 'password_changed_at'))],
+ ['Created At', formatDate(getValue(user, 'createdAt', 'created_at'))]
+ ];
+ const profileView = $('#profileView');
+ if (!profileView) return;
+ profileView.innerHTML = rows.map(([label, value]) => `
${label}${value ?? ''}
`).join('');
+}
+
+const ADMIN_PAGE_SIZE = 15;
+
+function isAdminRow(row) {
+ return String(getValue(row, 'role') || '').toUpperCase() === 'ADMIN';
+}
+
+function getAllLoginLockStates() {
+ return safeJson(localStorage.getItem(LOGIN_LOCK_KEY)) || {};
+}
+
+function normalizeLockKey(value) {
+ return String(value ?? '').trim().toLowerCase();
+}
+
+function getUserIdentifiers(row = {}) {
+ return [
+ getValue(row, 'id', 'userId'),
+ getValue(row, 'userName', 'username', 'name'),
+ getValue(row, 'email'),
+ getValue(row, 'loginIdentifier')
+ ].map(normalizeLockKey).filter(Boolean);
+}
+
+function getLocalLockForUser(row = {}) {
+ const allLocks = getAllLoginLockStates();
+ const keys = getUserIdentifiers(row);
+ for (const key of keys) {
+ if (allLocks[key]) return allLocks[key];
+ }
+ return null;
+}
+
+// Backend hiện chỉ khóa tạm 15 giây sau khi sai mật khẩu.
+// Frontend không còn chức năng khóa vĩnh viễn hoặc yêu cầu ADMIN mở khóa.
+function isTemporaryLockedUser(row) {
+ const lockedUntil = getValue(row, 'lockedUntil', 'locked_until');
+ const lockedUntilTime = lockedUntil ? new Date(lockedUntil).getTime() : 0;
+ return Number.isFinite(lockedUntilTime) && lockedUntilTime > Date.now();
+}
+
+function enrichAdminUser(row) {
+ return row;
+}
+
+function clearLocalLockForUser(row = {}) {
+ const allLocks = getAllLoginLockStates();
+ getUserIdentifiers(row).forEach((key) => delete allLocks[key]);
+ localStorage.setItem(LOGIN_LOCK_KEY, JSON.stringify(allLocks));
+}
+
+function getVisibleAdminRows(rows) {
+ if (!Array.isArray(rows)) return [];
+ if (state.adminTab !== 'users') return rows;
+ return rows.filter((row) => !isAdminRow(row));
+}
+
+function renderTable(rows) {
+ const adminTable = $('#adminTable');
+ if (!adminTable) return;
+
+ const visibleRows = getVisibleAdminRows(rows);
+
+ if (!Array.isArray(visibleRows) || visibleRows.length === 0) {
+ adminTable.innerHTML = 'Không có dữ liệu.
';
+ return;
+ }
+
+ const hiddenKeys = ['passwordHash', 'password', 'hash'];
+ const keys = Object.keys(visibleRows[0]).filter((key) => !key.startsWith('_') && !hiddenKeys.some((hidden) => key.toLowerCase().includes(hidden.toLowerCase())));
+ const currentPage = state.adminPage[state.adminTab] || 1;
+ const totalPages = Math.max(1, Math.ceil(visibleRows.length / ADMIN_PAGE_SIZE));
+ const safePage = Math.min(Math.max(currentPage, 1), totalPages);
+ state.adminPage[state.adminTab] = safePage;
+
+ const start = (safePage - 1) * ADMIN_PAGE_SIZE;
+ const pageRows = visibleRows.slice(start, start + ADMIN_PAGE_SIZE);
+
+ const tableHtml = `${keys.map((k) => `| ${k} | `).join('')}
${pageRows.map((row) => `${keys.map((k) => `| ${formatCell(row[k])} | `).join('')}
`).join('')}
`;
+
+ const paginationHtml = visibleRows.length > ADMIN_PAGE_SIZE
+ ? ``
+ : ``;
+
+ adminTable.innerHTML = tableHtml + paginationHtml;
+}
+
+function formatCell(value) {
+ if (value === null || value === undefined) return '';
+ if (typeof value === 'boolean') return value ? 'true' : 'false';
+ if (typeof value === 'object') return JSON.stringify(value);
+ return formatDate(value);
+}
+
+async function loadProfile() {
+ try {
+ const res = await apiRequest('me');
+ const userData = res.data || {};
+ state.user = {
+ ...state.user,
+ id: getValue(userData, 'id', 'userId') ?? state.user?.id,
+ userName: getValue(userData, 'userName', 'username', 'name') ?? state.user?.userName,
+ email: getValue(userData, 'email') ?? state.user?.email,
+ role: getValue(userData, 'role') ?? state.user?.role
+ };
+ localStorage.setItem(STORAGE_KEYS.user, JSON.stringify(state.user));
+ localStorage.setItem('user', JSON.stringify(state.user));
+ updateAuthUi();
+ renderProfile(userData);
+ } catch (error) { showToast(error.message, true); }
+}
+
+async function loadAdminData() {
+ if ((state.user?.role || '').toUpperCase() !== 'ADMIN') {
+ showToast('Bạn cần đăng nhập tài khoản ADMIN để xem mục này.', true);
+ return;
+ }
+ try {
+ const res = await apiRequest(state.adminTab);
+ state.adminData[state.adminTab] = Array.isArray(res.data) ? res.data : [];
+ renderTable(state.adminData[state.adminTab]);
+ } catch (error) { showToast(error.message, true); }
+}
+
+function bindEvents() {
+ localStorage.setItem(STORAGE_KEYS.baseUrl, state.baseUrl);
+
+ $$('.nav-item').forEach((btn) => btn.addEventListener('click', () => openPage(btn.dataset.page)));
+ $$('.forgot-link').forEach((btn) => btn.addEventListener('click', () => openPage('forgotPage')));
+ $$('.reset-link').forEach((btn) => btn.addEventListener('click', () => openPage('resetPage')));
+ $$('.tab').forEach((btn) => btn.addEventListener('click', () => {
+ $$('.tab').forEach((tab) => tab.classList.remove('active'));
+ btn.classList.add('active');
+ state.adminTab = btn.dataset.admin;
+ state.adminPage[state.adminTab] = 1;
+ loadAdminData();
+ }));
+
+
+ $('#loginForm')?.addEventListener('submit', handleLogin);
+ $('#loginForm input[name="loginIdentifier"]')?.addEventListener('input', (e) => {
+ if (state.loginLockTimer) clearInterval(state.loginLockTimer);
+ state.loginLockTimer = null;
+ const submitBtn = getLoginSubmitButton();
+ if (submitBtn) {
+ submitBtn.disabled = false;
+ submitBtn.textContent = 'Đăng nhập';
+ }
+ setLoginMessage('');
+ restoreLoginCountdownIfNeeded(e.target.value);
+ });
+ $('#registerForm')?.addEventListener('submit', handleRegister);
+ $('#forgotForm')?.addEventListener('submit', handleForgot);
+ $('#resetForm')?.addEventListener('submit', handleReset);
+ $('#changePasswordForm')?.addEventListener('submit', handleChangePassword);
+ $('#loadProfile')?.addEventListener('click', loadProfile);
+ $('#refreshAdmin')?.addEventListener('click', loadAdminData);
+ $('#logoutBtn')?.addEventListener('click', logout);
+ $('#clearResponse')?.addEventListener('click', () => setResponse('Chưa có dữ liệu.'));
+ $('#adminTable')?.addEventListener('click', (e) => {
+ const pageBtn = e.target.closest('.page-btn');
+ if (pageBtn) {
+ const total = state.adminData[state.adminTab]?.length || 0;
+ const totalPages = Math.max(1, Math.ceil(total / ADMIN_PAGE_SIZE));
+ const action = pageBtn.dataset.pageAction;
+ if (action === 'prev') state.adminPage[state.adminTab] = Math.max(1, (state.adminPage[state.adminTab] || 1) - 1);
+ if (action === 'next') state.adminPage[state.adminTab] = Math.min(totalPages, (state.adminPage[state.adminTab] || 1) + 1);
+ renderTable(state.adminData[state.adminTab] || []);
+ }
+ });
+}
+
+
+
+
+function getAllLoginLocks() {
+ return safeJson(localStorage.getItem(LOGIN_LOCK_KEY)) || {};
+}
+
+function getLoginLockState(identifier = '') {
+ const all = getAllLoginLocks();
+ return all[normalizeLockKey(identifier)] || null;
+}
+
+function saveLoginLockState(identifier, value) {
+ const key = normalizeLockKey(identifier);
+ if (!key) return;
+ const all = getAllLoginLocks();
+ all[key] = value;
+ localStorage.setItem(LOGIN_LOCK_KEY, JSON.stringify(all));
+}
+
+function clearLoginLockState(identifier) {
+ const key = normalizeLockKey(identifier);
+ if (!key) return;
+ const all = getAllLoginLocks();
+ delete all[key];
+ localStorage.setItem(LOGIN_LOCK_KEY, JSON.stringify(all));
+}
+
+function getAllLoginFailCounts() {
+ return safeJson(localStorage.getItem(LOGIN_FAIL_COUNT_KEY)) || {};
+}
+
+function getLoginFailCount(identifier = '') {
+ const key = normalizeLockKey(identifier);
+ if (!key) return 0;
+ const all = getAllLoginFailCounts();
+ return Number(all[key] || 0);
+}
+
+function saveLoginFailCount(identifier, count) {
+ const key = normalizeLockKey(identifier);
+ if (!key) return;
+ const all = getAllLoginFailCounts();
+ all[key] = Math.max(0, Number(count) || 0);
+ localStorage.setItem(LOGIN_FAIL_COUNT_KEY, JSON.stringify(all));
+}
+
+function clearLoginFailCount(identifier) {
+ const key = normalizeLockKey(identifier);
+ if (!key) return;
+ const all = getAllLoginFailCounts();
+ delete all[key];
+ localStorage.setItem(LOGIN_FAIL_COUNT_KEY, JSON.stringify(all));
+}
+
+function setLoginMessage(message = '', isError = false) {
+ const box = $('#loginMessage');
+ if (!box) return;
+ box.textContent = message;
+ box.className = `inline-message ${isError ? 'error' : ''} ${message ? '' : 'hidden'}`;
+}
+
+function getLoginSubmitButton() {
+ return $('#loginForm button[type="submit"]');
+}
+
+function extractSecondsFromMessage(message = '') {
+ const text = String(message || '').toLowerCase();
+ const match = text.match(/(\d+)\s*(giây|giay|s|sec|second)/i);
+ if (!match) return null;
+ const seconds = Number(match[1]);
+ return Number.isFinite(seconds) && seconds > 0 ? seconds : null;
+}
+
+function extractLockUntilMs(response = {}) {
+ const data = response.data && typeof response.data === 'object' ? response.data : {};
+ const rawLockedUntil = getValue(response, 'lockedUntil', 'locked_until') || getValue(data, 'lockedUntil', 'locked_until');
+ const rawSeconds = getValue(response, 'retryAfterSeconds', 'remainingSeconds', 'lockSeconds', 'lockedSeconds', 'retryAfter')
+ ?? getValue(data, 'retryAfterSeconds', 'remainingSeconds', 'lockSeconds', 'lockedSeconds', 'retryAfter');
+
+ if (rawLockedUntil) {
+ const time = new Date(rawLockedUntil).getTime();
+ if (Number.isFinite(time) && time > Date.now()) return time;
+ }
+
+ const seconds = Number(rawSeconds) || extractSecondsFromMessage(response.message);
+ if (Number.isFinite(seconds) && seconds > 0) return Date.now() + seconds * 1000;
+
+ return null;
+}
+
+function startLoginCountdownUntil(lockedUntilMs, identifier = '', messagePrefix = 'Bạn đã nhập sai 5 lần. Tài khoản bị khóa tạm 15 giây') {
+ clearInterval(state.loginLockTimer);
+ const submitBtn = getLoginSubmitButton();
+
+ const tick = () => {
+ const remaining = Math.ceil((Number(lockedUntilMs) - Date.now()) / 1000);
+ if (remaining <= 0) {
+ clearInterval(state.loginLockTimer);
+ state.loginLockTimer = null;
+ state.loginCountdown = 0;
+ if (submitBtn) {
+ submitBtn.disabled = false;
+ submitBtn.textContent = 'Đăng nhập';
+ }
+ clearLoginLockState(identifier);
+ clearLoginFailCount(identifier);
+ setLoginMessage('Đã hết 15 giây khóa tạm. Bạn có thể bấm Đăng nhập lại. Nếu sai tiếp 5 lần, tài khoản sẽ tiếp tục bị khóa 15 giây.', false);
+ return;
+ }
+
+ state.loginCountdown = remaining;
+ if (submitBtn) {
+ submitBtn.disabled = true;
+ submitBtn.textContent = `Đợi ${remaining}s`;
+ }
+ setLoginMessage(`${messagePrefix}. Còn ${remaining}s.`, true);
+ };
+
+ tick();
+ state.loginLockTimer = setInterval(tick, 1000);
+}
+
+function restoreLoginCountdownIfNeeded(identifier = '') {
+ const lock = getLoginLockState(identifier);
+ if (!lock || !lock.lockedUntil) return false;
+
+ const lockedUntilMs = Number(lock.lockedUntil);
+ if (Number.isFinite(lockedUntilMs) && lockedUntilMs > Date.now()) {
+ startLoginCountdownUntil(lockedUntilMs, identifier, lock.message || 'Tài khoản đang bị khóa tạm');
+ return true;
+ }
+
+ clearLoginLockState(identifier);
+ return false;
+}
+
+function handleLoginFailure(error, identifier) {
+ const response = error.response || {};
+ const data = response.data && typeof response.data === 'object' ? response.data : {};
+ const failedAttemptsFromApi = getValue(response, 'failedAttempts', 'failed_attempts') ?? getValue(data, 'failedAttempts', 'failed_attempts');
+ const remainingAttemptsFromApi = getValue(response, 'remainingAttempts', 'remaining_attempts') ?? getValue(data, 'remainingAttempts', 'remaining_attempts');
+ const lockedUntilMs = extractLockUntilMs(response);
+
+ if (lockedUntilMs) {
+ const lockMessage = response.message || error.message || 'Bạn đã nhập sai 5 lần. Tài khoản bị khóa tạm 15 giây';
+ saveLoginLockState(identifier, { lockedUntil: lockedUntilMs, message: lockMessage });
+ saveLoginFailCount(identifier, MAX_FAILED_ATTEMPTS);
+ startLoginCountdownUntil(lockedUntilMs, identifier, lockMessage);
+ return;
+ }
+
+ // Trường hợp backend chỉ trả status 423/429 hoặc chỉ trả message khóa 15 giây nhưng chưa trả lockedUntil.
+ const errorText = `${response.message || ''} ${error.message || ''}`.toLowerCase();
+ const looksLikeTemporaryLock = error.status === 423 || error.status === 429 ||
+ ((errorText.includes('khóa') || errorText.includes('khoa') || errorText.includes('lock')) &&
+ (errorText.includes('15') || errorText.includes('giây') || errorText.includes('giay') || errorText.includes('thử lại') || errorText.includes('thu lai')));
+
+ if (looksLikeTemporaryLock) {
+ const fallbackSeconds = extractSecondsFromMessage(errorText) || TEMP_LOCK_SECONDS;
+ const fallbackUntil = Date.now() + fallbackSeconds * 1000;
+ const lockMessage = response.message || error.message || `Bạn đã nhập sai 5 lần. Tài khoản bị khóa tạm ${fallbackSeconds} giây`;
+ saveLoginLockState(identifier, { lockedUntil: fallbackUntil, message: lockMessage });
+ saveLoginFailCount(identifier, MAX_FAILED_ATTEMPTS);
+ startLoginCountdownUntil(fallbackUntil, identifier, lockMessage);
+ return;
+ }
+
+ const apiFailed = Number(failedAttemptsFromApi);
+ const localFailed = Number.isFinite(apiFailed) && apiFailed > 0
+ ? apiFailed
+ : getLoginFailCount(identifier) + 1;
+
+ // Backend khóa sau mỗi 5 lần sai. Nếu backend chưa trả lockedUntil ở lần thứ 5, frontend vẫn khóa nút 15s để đúng giao diện yêu cầu.
+ if (localFailed >= MAX_FAILED_ATTEMPTS) {
+ const fallbackUntil = Date.now() + TEMP_LOCK_SECONDS * 1000;
+ const lockMessage = `Bạn đã nhập sai ${MAX_FAILED_ATTEMPTS} lần. Tài khoản bị khóa tạm ${TEMP_LOCK_SECONDS} giây`;
+ saveLoginFailCount(identifier, MAX_FAILED_ATTEMPTS);
+ saveLoginLockState(identifier, { lockedUntil: fallbackUntil, message: lockMessage });
+ startLoginCountdownUntil(fallbackUntil, identifier, lockMessage);
+ return;
+ }
+
+ saveLoginFailCount(identifier, localFailed);
+ const remaining = Number.isFinite(Number(remainingAttemptsFromApi))
+ ? Number(remainingAttemptsFromApi)
+ : Math.max(0, MAX_FAILED_ATTEMPTS - localFailed);
+
+ setLoginMessage(`Sai tài khoản hoặc mật khẩu lần ${localFailed}/${MAX_FAILED_ATTEMPTS}. Còn ${remaining} lần thử, sai đủ ${MAX_FAILED_ATTEMPTS} lần sẽ khóa tạm ${TEMP_LOCK_SECONDS} giây.`, true);
+}
+
+
+async function handleLogin(e) {
+ e.preventDefault();
+ const body = getFormData(e.target);
+ const identifier = body.loginIdentifier || body.email || body.userName || '';
+
+ // Nếu đang trong 15 giây khóa tạm, frontend chỉ đếm ngược và KHÔNG gọi API.
+ if (state.loginLockTimer || restoreLoginCountdownIfNeeded(identifier)) return;
+
+ try {
+ setLoginMessage('');
+ const submitBtn = getLoginSubmitButton();
+ if (submitBtn) submitBtn.textContent = 'Đăng nhập';
+ const res = await apiRequest('login', { method: 'POST', body, auth: false });
+ clearLoginLockState(identifier);
+ clearLoginFailCount(identifier);
+ saveSession(res);
+ showToast(res.message || 'Đăng nhập thành công');
+ openPage('profilePage');
+ } catch (error) {
+ showToast(error.message, true);
+ handleLoginFailure(error, identifier);
+ }
+}
+
+async function handleRegister(e) {
+ e.preventDefault();
+ try {
+ const res = await apiRequest('register', { method: 'POST', body: getFormData(e.target), auth: false });
+ showToast(res.message || 'Đăng ký thành công');
+ openPage('loginPage');
+ } catch (error) { showToast(error.message, true); }
+}
+
+async function handleForgot(e) {
+ e.preventDefault();
+ if ((state.user?.role || '').toUpperCase() === 'ADMIN') {
+ showToast('Tài khoản ADMIN không được dùng quên mật khẩu.', true);
+ return;
+ }
+ try {
+ const body = getFormData(e.target);
+ const res = await apiRequest('forgot', { method: 'POST', body, auth: false });
+ if ($('#resetForm')?.email) $('#resetForm').email.value = body.email;
+ showToast(res.message || 'Đã gửi OTP');
+ openPage('resetPage');
+ } catch (error) { showToast(error.message, true); }
+}
+
+async function handleReset(e) {
+ e.preventDefault();
+ if ((state.user?.role || '').toUpperCase() === 'ADMIN') {
+ showToast('Tài khoản ADMIN không được đặt lại mật khẩu ở đây.', true);
+ return;
+ }
+ try {
+ const body = getFormData(e.target);
+ const res = await apiRequest('reset', { method: 'POST', body, auth: false });
+ showToast(res.message || 'Đặt lại mật khẩu thành công');
+ openPage('loginPage');
+ } catch (error) { showToast(error.message, true); }
+}
+
+async function handleChangePassword(e) {
+ e.preventDefault();
+ if (!state.token) {
+ showToast('Bạn cần đăng nhập trước khi đổi mật khẩu.', true);
+ openPage('loginPage');
+ return;
+ }
+ try {
+ const res = await apiRequest('changePassword', { method: 'POST', body: getFormData(e.target) });
+ showToast(res.message || 'Đổi mật khẩu thành công');
+ e.target.reset();
+ logout();
+ } catch (error) { showToast(error.message, true); }
+}
+
+bindEvents();
+updateAuthUi();
diff --git a/Fontend/index.html b/Fontend/index.html
new file mode 100644
index 0000000..ef21c61
--- /dev/null
+++ b/Fontend/index.html
@@ -0,0 +1,159 @@
+
+
+
+
+
+ Security Password Frontend
+
+
+
+
+
+
+
+
+
+
An toàn bảo mật thông tin
+
Hệ thống Authentication
+
+
+
+
+
+
+
+
+
+
+
+
Quên mật khẩu
+
Nhập email tài khoản USER để nhận OTP
+
+
+
+
+
+
+
Đặt lại mật khẩu
+
Nhập OTP đã nhận và mật khẩu mới. Chức năng này chỉ dành cho tài khoản USER.
+
+
+
+
+
+
+
+
Thông tin tài khoản
+
+
+
+
+
+
+
+
+
Đổi mật khẩu khi đã đăng nhập
+
+
+
+
+
+
+
+
+
Quản trị hệ thống
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Fontend/package.json b/Fontend/package.json
new file mode 100644
index 0000000..1d4669f
--- /dev/null
+++ b/Fontend/package.json
@@ -0,0 +1,8 @@
+{
+ "scripts": {
+ "dev": "vite --host 0.0.0.0 --port 5173"
+ },
+ "devDependencies": {
+ "vite": "latest"
+ }
+}
diff --git a/Fontend/styles.css b/Fontend/styles.css
new file mode 100644
index 0000000..6bab9b9
--- /dev/null
+++ b/Fontend/styles.css
@@ -0,0 +1,166 @@
+*{box-sizing:border-box}body{margin:0;font-family:Inter,Arial,Helvetica,sans-serif;background:#0b1020;color:#e5e7eb}.app-shell{display:flex;min-height:100vh}.sidebar{width:300px;background:#0f172a;border-right:1px solid #253047;padding:24px;position:fixed;inset:0 auto 0 0}.brand{display:flex;gap:12px;align-items:center;margin-bottom:26px}.brand-icon{width:50px;height:50px;border-radius:18px;background:linear-gradient(135deg,#2563eb,#7c3aed);display:grid;place-items:center;font-size:25px}.brand h1{font-size:20px;margin:0}.brand p,.desc{color:#94a3b8;margin:6px 0 0;line-height:1.5}.nav{display:grid;gap:10px}.nav-item,.tab,button{border:0;border-radius:13px;padding:12px 14px;background:#1e293b;color:#e5e7eb;cursor:pointer;font-weight:800}.nav-item{text-align:left}.nav-item.active,.tab.active,button[type=submit]{background:linear-gradient(135deg,#2563eb,#7c3aed)}button:hover{filter:brightness(1.1)}.auth-card{position:absolute;left:24px;right:24px;bottom:24px;padding:18px;border:1px solid #334155;border-radius:18px;background:#111827;display:grid;gap:10px}.small-label,.eyebrow{color:#38bdf8;text-transform:uppercase;font-size:12px;letter-spacing:.08em}.badge{display:inline-block;width:max-content;padding:6px 11px;border-radius:999px;background:#16a34a;color:white;font-size:12px}.badge.muted{background:#475569}.danger{background:#dc2626!important}.ghost{background:transparent;border:1px solid #475569}.secondary{background:#334155}.hidden{display:none!important}.content{margin-left:300px;width:calc(100% - 300px);padding:28px}.hero{display:flex;justify-content:space-between;gap:22px;align-items:center;padding:28px;border-radius:26px;background:radial-gradient(circle at top left,#1d4ed8,#111827 48%,#0b1020);border:1px solid #334155;margin-bottom:22px}.hero h2{font-size:32px;margin:8px 0}.api-box{min-width:340px;background:rgba(15,23,42,.76);border:1px solid #475569;border-radius:18px;padding:16px;display:grid;gap:10px}.api-box input,input{width:100%;border:1px solid #334155;background:#020617;color:#e5e7eb;border-radius:12px;padding:13px 14px;outline:none}input:focus{border-color:#38bdf8}.panel{background:#111827;border:1px solid #263244;border-radius:22px;padding:24px;margin-bottom:20px;box-shadow:0 18px 45px rgba(0,0,0,.18)}.two-col{display:grid;grid-template-columns:1fr 1fr;gap:24px}.auth-panel{max-width:720px;margin-inline:auto}.auth-form{max-width:560px}.form-link{margin-top:2px;text-align:left}.grid-link{grid-column:1/-1;margin-top:0}.link-button{background:transparent!important;color:#93c5fd;padding:0;text-decoration:underline}.link-button:hover{color:#bfdbfe;filter:none}.grid-form{display:grid;grid-template-columns:repeat(3,1fr);gap:16px;align-items:end}.page{display:none}.page.active{display:block}.form{display:grid;gap:12px;margin-top:16px}.form label,.api-box label{font-weight:800;color:#cbd5e1;font-size:14px}.section-head{display:flex;justify-content:space-between;gap:16px;align-items:flex-start}.section-head.compact{margin-bottom:4px}.section-head h3,.panel h3{margin:0 0 6px;font-size:22px}.security-note{padding:20px;border-radius:18px;background:#0b1220;border:1px solid #334155}.security-note h4{margin-top:0}.quick{display:block;width:100%;margin:10px 0;background:#334155}.endpoint-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(310px,1fr));gap:12px;margin-top:18px}.endpoint-grid>div{display:grid;grid-template-columns:auto 1fr;gap:7px 11px;align-items:center;padding:13px;border:1px solid rgba(255,255,255,.1);border-radius:15px;background:rgba(255,255,255,.04)}.endpoint-grid code{color:#f8fafc;overflow-wrap:anywhere}.endpoint-grid small{grid-column:2;color:#94a3b8}.method{display:inline-block;min-width:54px;text-align:center;font-size:12px;font-weight:900;padding:6px 8px;border-radius:8px;letter-spacing:.4px}.method.post{background:rgba(59,130,246,.18);color:#93c5fd;border:1px solid rgba(59,130,246,.35)}.method.get{background:rgba(34,197,94,.16);color:#86efac;border:1px solid rgba(34,197,94,.35)}.profile-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:12px}.info-card,.empty{padding:15px;border-radius:15px;background:#0b1220;border:1px solid #334155}.info-card span{display:block;color:#94a3b8;font-size:13px;margin-bottom:7px}.tabs{display:flex;gap:10px;flex-wrap:wrap;margin:18px 0}.table-wrap{overflow:auto;border-radius:16px;border:1px solid #334155}table{width:100%;border-collapse:collapse;background:#0f172a}th,td{padding:12px 14px;border-bottom:1px solid #263244;text-align:left;white-space:nowrap}th{background:#1f2937;color:#38bdf8}pre{white-space:pre-wrap;word-break:break-word;background:#020617;border:1px solid #334155;border-radius:16px;padding:18px;max-height:390px;overflow:auto}.toast{position:fixed;right:28px;top:28px;z-index:10;padding:14px 18px;border-radius:14px;background:#16a34a;color:white;font-weight:800;box-shadow:0 10px 30px rgba(0,0,0,.35)}.toast.error{background:#dc2626}@media(max-width:920px){.sidebar{position:static;width:100%;height:auto}.app-shell{display:block}.content{margin-left:0;width:100%;padding:16px}.hero,.two-col{display:grid;grid-template-columns:1fr}.grid-form{grid-template-columns:1fr}.api-box{min-width:0}.auth-card{position:static;margin-top:18px}.section-head{display:grid}.endpoint-grid{grid-template-columns:1fr}}
+
+/* FINAL FIX: chống tràn chữ/input và tách form quên/đặt lại mật khẩu */
+input,
+.form input,
+.auth-form input,
+.profile-grid,
+.info-card,
+.info-card strong,
+.info-card span,
+table,
+td,
+th {
+ min-width: 0;
+ max-width: 100%;
+ box-sizing: border-box;
+}
+
+.info-card strong,
+td,
+th {
+ overflow-wrap: anywhere;
+ word-break: break-word;
+ white-space: normal;
+}
+
+.profile-grid {
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
+}
+
+.auth-panel {
+ width: 100%;
+ max-width: 760px;
+}
+
+.auth-form {
+ width: 100%;
+ max-width: 100%;
+}
+
+.form-link {
+ width: 100%;
+ text-align: left;
+}
+
+.link-button {
+ display: inline-block;
+ margin-left: 0;
+}
+
+@media (max-width: 900px) {
+ .sidebar { position: static; width: 100%; }
+ .app-shell { display: block; }
+ .content { margin-left: 0; width: 100%; padding: 16px; }
+ .two-col, .grid-form { grid-template-columns: 1fr; }
+}
+
+
+/* FIX V3: bỏ quên mật khẩu ở sidebar, giữ dữ liệu chữ thường/chữ hoa nguyên bản và làm bảng admin dễ đọc hơn */
+#adminTable table {
+ min-width: 1100px;
+ table-layout: auto;
+}
+
+#adminTable th,
+#adminTable td {
+ white-space: normal;
+ word-break: normal;
+ overflow-wrap: anywhere;
+ vertical-align: middle;
+}
+
+#adminTable th:nth-child(2),
+#adminTable td:nth-child(2) {
+ min-width: 110px;
+}
+
+#adminTable th:nth-child(3),
+#adminTable td:nth-child(3) {
+ min-width: 220px;
+}
+
+#adminTable th:nth-child(4),
+#adminTable td:nth-child(4) {
+ min-width: 90px;
+}
+
+/* FIX V4: nút quên/đặt lại mật khẩu và chống vỡ chữ trong bảng */
+.form-link{
+ display:flex;
+ align-items:center;
+ justify-content:flex-start;
+ gap:18px;
+ flex-wrap:wrap;
+}
+.link-button{
+ width:auto !important;
+ padding:0 !important;
+ background:transparent !important;
+ border:0 !important;
+ color:#9ed0ff !important;
+ text-decoration:underline;
+ text-align:left;
+ font-weight:700;
+}
+.table-wrap table th,
+.table-wrap table td{
+ word-break:normal !important;
+ overflow-wrap:anywhere;
+ white-space:normal;
+ min-width:92px;
+}
+.table-wrap table th:first-child,
+.table-wrap table td:first-child{
+ min-width:48px;
+}
+.table-wrap table th:nth-child(2),
+.table-wrap table td:nth-child(2),
+.table-wrap table th:nth-child(4),
+.table-wrap table td:nth-child(4){
+ min-width:110px;
+ overflow-wrap:normal;
+ word-break:keep-all !important;
+}
+.table-wrap table th:nth-child(3),
+.table-wrap table td:nth-child(3){
+ min-width:220px;
+}
+
+/* Thông báo lỗi đăng nhập và bộ đếm khóa tạm 15 giây */
+.inline-message{
+ width:100%;
+ box-sizing:border-box;
+ padding:12px 14px;
+ border-radius:12px;
+ background:#12351f;
+ border:1px solid #16a34a;
+ color:#d1fae5;
+ font-weight:700;
+ overflow-wrap:anywhere;
+}
+.inline-message.error{
+ background:#3a1111;
+ border-color:#dc2626;
+ color:#fecaca;
+}
+.pagination{
+ display:flex;
+ align-items:center;
+ justify-content:space-between;
+ gap:12px;
+ padding:14px 0 4px;
+ flex-wrap:wrap;
+ color:#475569;
+ font-weight:700;
+}
+.pagination.single{
+ justify-content:flex-end;
+}
+.page-btn:disabled{
+ opacity:.45;
+ cursor:not-allowed;
+}
+.table-wrap td:last-child{
+ white-space:nowrap;
+}
diff --git a/Fontend/test.html b/Fontend/test.html
deleted file mode 100644
index e69de29..0000000