security + reponsive

This commit is contained in:
2026-07-17 14:16:37 +07:00
parent 00e271fada
commit 57fc826ebf
16 changed files with 2124 additions and 2596 deletions

View File

@@ -150,6 +150,89 @@ textarea {
font-size: 1rem;
}
/* Keep the asset and consumable toolbars compact so the data remains
visible on tablets and phones. */
.asset-borrows-page .compact-page-actions,
.consumable-exports-page .compact-page-actions,
.assets-page .asset-header-actions,
.consumables-page .consumable-header-actions {
display: flex;
width: 100%;
max-width: 100%;
flex-flow: row nowrap;
gap: 0.5rem;
overflow-x: auto;
overscroll-behavior-x: contain;
padding: 0.125rem 0.125rem 0.375rem;
scrollbar-width: thin;
-webkit-overflow-scrolling: touch;
}
.asset-borrows-page .compact-page-actions > button,
.consumable-exports-page .compact-page-actions > button,
.assets-page .asset-header-actions > button,
.consumables-page .consumable-header-actions > button {
width: auto;
min-width: max-content;
min-height: 2.5rem;
flex: 0 0 auto;
padding-top: 0.5rem;
padding-bottom: 0.5rem;
white-space: nowrap;
}
.compact-page-filters {
display: grid !important;
align-items: end !important;
gap: 0.625rem !important;
padding: 0.625rem;
border: 1px solid #e2e8f0;
border-radius: 0.75rem;
background: rgb(248 250 252 / 0.82);
}
.compact-page-filters.asset-borrow-filter-bar {
grid-template-columns: minmax(10rem, 0.35fr) minmax(0, 1fr);
}
.compact-page-filters.asset-filter-bar {
grid-template-columns: minmax(9rem, 0.3fr) minmax(14rem, 1fr) max-content;
}
.compact-page-filters.consumable-filter-bar {
grid-template-columns: minmax(8rem, 0.25fr) minmax(8rem, 0.25fr) minmax(14rem, 1fr);
}
.compact-page-filters.consumable-export-filter-bar {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.compact-page-filters > div {
width: auto;
min-width: 0;
flex-direction: column;
align-items: stretch;
gap: 0.25rem;
}
.compact-page-filters input,
.compact-page-filters select {
width: 100%;
min-width: 0;
min-height: 2.5rem;
font-size: 0.875rem;
line-height: 1.25rem;
}
.compact-page-filters .compact-filter-action {
width: auto;
min-width: max-content;
min-height: 2.5rem;
align-self: end;
justify-content: center;
white-space: nowrap;
}
.dashboard-stats,
.apps-stats {
gap: 0.75rem;
@@ -374,6 +457,29 @@ textarea {
grid-template-columns: minmax(0, 1fr) !important;
}
.compact-page-filters.asset-borrow-filter-bar {
grid-template-columns: minmax(0, 1fr);
}
.compact-page-filters.asset-filter-bar {
grid-template-columns: minmax(0, 1fr) max-content;
}
.compact-page-filters.asset-filter-bar .compact-filter-search {
grid-column: 1 / -1;
grid-row: 2;
}
.compact-page-filters.consumable-filter-bar,
.compact-page-filters.consumable-export-filter-bar {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.compact-page-filters.consumable-filter-bar .compact-filter-search,
.compact-page-filters.consumable-export-filter-bar .compact-filter-search {
grid-column: 1 / -1;
}
#mainContent table.mobile-card-table:not(.keep-table-mobile) > tbody {
padding: 0.625rem;
}

View File

@@ -1,6 +1,27 @@
// VaultSentinel - Account Management Application
// Main JavaScript functionality
const authenticatedFetch = window.fetch.bind(window);
let authRedirectInProgress = false;
window.fetch = async (input, init = {}) => {
const response = await authenticatedFetch(input, {
...init,
credentials: init.credentials || 'same-origin'
});
const requestUrl = typeof input === 'string' ? input : String(input?.url || '');
const isAuthRequest = requestUrl.includes('/api/auth/login')
|| requestUrl.includes('/api/auth/session')
|| requestUrl.includes('/api/auth/logout');
if (response.status === 401 && !isAuthRequest && !authRedirectInProgress) {
authRedirectInProgress = true;
localStorage.removeItem('currentUser');
window.location.replace('../pages/login.html?reason=session-expired');
}
return response;
};
const APP_TIME_ZONE = 'Asia/Ho_Chi_Minh';
const APP_DATE_FORMATTER = new Intl.DateTimeFormat('vi-VN', {
timeZone: APP_TIME_ZONE,
@@ -220,10 +241,7 @@ class AccountManager {
}
getAuthHeaders(includeJson = false) {
const headers = {
'x-user-id': String(this.getUserId()),
'x-user-role': this.getCurrentUserRole()
};
const headers = {};
if (includeJson) {
headers['Content-Type'] = 'application/json';
@@ -463,6 +481,15 @@ class AccountManager {
}
}
async fetchAccountSecret(accountId) {
const response = await fetch(`${this.apiBase}/accounts/${accountId}/secret`, { cache: 'no-store' });
const data = await response.json();
if (!response.ok || !data.success) {
throw new Error(data.message || 'Unable to reveal stored credential');
}
return String(data.password || '');
}
async fetchUsers() {
try {
const res = await fetch(`${this.apiBase}/users`);
@@ -2502,11 +2529,17 @@ class AccountManager {
return `${value.slice(0, 3)}*****`;
}
handleLogout() {
async handleLogout() {
if (confirm('Are you sure you want to logout?')) {
this.saveToStorage('currentUser', null);
localStorage.clear();
window.location.href = '../pages/login.html';
try {
await fetch(`${this.apiBase}/auth/logout`, { method: 'POST' });
} catch (error) {
console.error('Logout request failed:', error);
} finally {
this.saveToStorage('currentUser', null);
localStorage.removeItem('currentUser');
window.location.replace('../pages/login.html');
}
}
}
@@ -2643,13 +2676,14 @@ class AccountManager {
<tbody class="divide-y divide-slate-100 accounts-table-body">
${pageInfo.data.map(acc => {
const isOwnAccount = acc.UserId == currentUserId;
const canAccessAccount = isOwnAccount || this.getCurrentUserRole() === 'admin';
const accountUsername = acc.AccountUsername || '-';
const displayAccountUsername = isOwnAccount
const displayAccountUsername = canAccessAccount
? accountUsername
: this.maskForeignAccountUsername(accountUsername);
const createdDate = this.formatDateTime(acc.CreatedDate);
const updatedDate = this.formatDateTime(acc.UpdatedDate);
const actionContent = isOwnAccount
const actionContent = canAccessAccount
? `<button class="p-1.5 text-slate-400 transition-colors view-account hover:text-slate-600" data-account-id="${acc.AccountId}" title="View Details">
<span class="material-symbols-outlined text-lg">info</span>
</button>
@@ -4244,7 +4278,7 @@ class AccountManager {
<h1 class="text-2xl font-extrabold text-on-surface tracking-tight">Mượn/Trả tài sản</h1>
<p class="text-sm text-on-surface-variant">Theo dõi trạng thái đơn mượn và đơn trả tài sản.</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<div class="compact-page-actions flex flex-wrap items-center gap-2">
<button
id="addAssetBorrowRequestBtn"
class="bg-primary text-on-primary px-4 py-2 rounded-lg text-xs font-bold flex items-center gap-1.5 transition-all active:scale-95 hover:bg-primary-dim"
@@ -4272,7 +4306,7 @@ class AccountManager {
</div>
</div>
<div class="page-filters flex items-center gap-3 mb-4 shrink-0">
<div class="page-filters compact-page-filters asset-borrow-filter-bar flex items-center gap-3 mb-4 shrink-0">
<div class="flex items-center gap-1.5">
<span class="text-[10px] font-bold uppercase text-on-surface-variant">Danh mục</span>
<select id="assetBorrowTypeFilter" class="bg-surface-container-low border-slate-200 rounded-md text-[11px] py-1 px-2 pr-6 focus:ring-1 focus:ring-primary shadow-sm">
@@ -4281,7 +4315,7 @@ class AccountManager {
<option value="return" ${this.assetBorrowTypeFilter === 'return' ? 'selected' : ''}>Trả tài sản</option>
</select>
</div>
<div class="flex items-center gap-1.5 flex-1">
<div class="compact-filter-search flex items-center gap-1.5 flex-1">
<span class="text-[10px] font-bold uppercase text-on-surface-variant">Tìm kiếm</span>
<input
id="assetBorrowSearch"
@@ -5330,7 +5364,7 @@ class AccountManager {
</div>
` : ''}
<div class="page-filters flex items-center gap-3 mb-4">
<div class="page-filters compact-page-filters consumable-filter-bar flex items-center gap-3 mb-4">
<div class="flex items-center gap-1.5">
<span class="text-[10px] font-bold uppercase text-on-surface-variant">Tháng</span>
<select id="consumableMonthFilter" class="bg-surface-container-low border-slate-200 rounded-md text-[11px] py-1 px-2 pr-6 focus:ring-1 focus:ring-primary shadow-sm">
@@ -5346,7 +5380,7 @@ class AccountManager {
<option value="out_of_stock" ${this.consumableStatusFilter === 'out_of_stock' ? 'selected' : ''}>Hết tồn</option>
</select>
</div>
<div class="flex items-center gap-1.5 flex-1">
<div class="compact-filter-search flex items-center gap-1.5 flex-1">
<span class="text-[10px] font-bold uppercase text-on-surface-variant">Tìm kiếm</span>
<input id="consumableSearch" class="flex-1 bg-surface-container-low border-slate-200 rounded-md text-[11px] py-1 px-2 focus:ring-1 focus:ring-primary shadow-sm" placeholder="Mã, tên, model, lý do xuất..." value="${this.escapeHtml(this.consumableSearchTerm)}">
</div>
@@ -5478,7 +5512,7 @@ class AccountManager {
<h1 class="text-2xl font-extrabold text-on-surface tracking-tight">Mượn / xuất / trả VTTH</h1>
<p class="text-sm text-on-surface-variant">${pageDescription}</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<div class="compact-page-actions flex flex-wrap items-center gap-2">
<button id="exportConsumableHistoryBtn" class="border border-slate-300 text-slate-700 px-3 py-2 rounded-lg text-xs font-bold flex items-center gap-1.5 transition-all active:scale-95 ${canManageAssets ? 'hover:bg-slate-100' : 'opacity-50 cursor-not-allowed'}" ${canManageAssets ? '' : 'disabled'}>
<span class="material-symbols-outlined text-base">download</span>
Xuất Excel
@@ -5490,7 +5524,7 @@ class AccountManager {
</div>
</div>
<div class="page-filters flex items-center gap-3 mb-4 shrink-0">
<div class="page-filters compact-page-filters consumable-export-filter-bar flex items-center gap-3 mb-4 shrink-0">
<div class="flex items-center gap-1.5">
<span class="text-[10px] font-bold uppercase text-on-surface-variant">Người nhận</span>
<select id="consumableExportRecipientFilter" class="bg-surface-container-low border-slate-200 rounded-md text-[11px] py-1 px-2 pr-6 focus:ring-1 focus:ring-primary shadow-sm">
@@ -5509,7 +5543,7 @@ class AccountManager {
<span class="text-[10px] font-bold uppercase text-on-surface-variant">Ngày</span>
<input id="consumableExportDateFilter" type="date" value="${this.escapeHtml(this.consumableExportDateFilter)}" class="bg-surface-container-low border-slate-200 rounded-md text-[11px] py-1 px-2 focus:ring-1 focus:ring-primary shadow-sm">
</div>
<div class="flex items-center gap-1.5 flex-1">
<div class="compact-filter-search flex items-center gap-1.5 flex-1">
<span class="text-[10px] font-bold uppercase text-on-surface-variant">Tìm kiếm</span>
<input
id="consumableExportSearch"
@@ -5891,7 +5925,7 @@ class AccountManager {
</div>
</div>
<div class="page-filters flex items-center gap-3 mb-4">
<div class="page-filters compact-page-filters asset-filter-bar flex items-center gap-3 mb-4">
<div class="flex items-center gap-1.5">
<span class="text-[10px] font-bold uppercase text-on-surface-variant">Trạng thái</span>
<select id="assetStatusFilter" class="bg-surface-container-low border-slate-200 rounded-md text-[11px] py-1 px-2 pr-6 focus:ring-1 focus:ring-primary shadow-sm">
@@ -5901,11 +5935,11 @@ class AccountManager {
<option value="exported">Đã xuất</option>
</select>
</div>
<div class="flex items-center gap-1.5 flex-1">
<div class="compact-filter-search flex items-center gap-1.5 flex-1">
<span class="text-[10px] font-bold uppercase text-on-surface-variant">Tìm kiếm</span>
<input id="assetSearch" class="flex-1 bg-surface-container-low border-slate-200 rounded-md text-[11px] py-1 px-2 focus:ring-1 focus:ring-primary shadow-sm" placeholder="Mã, tên, model, serial, dự án, vị trí...">
</div>
<button id="bulkDeleteAssetsBtn" class="border border-red-200 text-red-600 px-3 py-1.5 rounded-md text-[11px] font-bold flex items-center gap-1.5 transition-colors ${(selectedCount === 0 || !canManageAssets) ? 'opacity-50 cursor-not-allowed' : 'hover:bg-red-50'}" ${(selectedCount === 0 || !canManageAssets) ? 'disabled' : ''}>
<button id="bulkDeleteAssetsBtn" class="compact-filter-action border border-red-200 text-red-600 px-3 py-1.5 rounded-md text-[11px] font-bold flex items-center gap-1.5 transition-colors ${(selectedCount === 0 || !canManageAssets) ? 'opacity-50 cursor-not-allowed' : 'hover:bg-red-50'}" ${(selectedCount === 0 || !canManageAssets) ? 'disabled' : ''}>
<span class="material-symbols-outlined text-base">delete_sweep</span>
Xóa đã chọn (<span id="selectedAssetCount">${selectedCount}</span>)
</button>
@@ -9181,13 +9215,14 @@ class AccountManager {
this.accountPage = pageInfo.current;
tbody.innerHTML = pageInfo.data.map(acc => {
const isOwnAccount = acc.UserId == currentUserId;
const canAccessAccount = isOwnAccount || this.getCurrentUserRole() === 'admin';
const accountUsername = acc.AccountUsername || '-';
const displayAccountUsername = isOwnAccount
const displayAccountUsername = canAccessAccount
? accountUsername
: this.maskForeignAccountUsername(accountUsername);
const createdDate = this.formatDateTime(acc.CreatedDate);
const updatedDate = this.formatDateTime(acc.UpdatedDate);
const actionContent = isOwnAccount
const actionContent = canAccessAccount
? `<button class="p-1.5 text-slate-400 transition-colors view-account hover:text-slate-600" data-account-id="${acc.AccountId}" title="View Details">
<span class="material-symbols-outlined text-lg">info</span>
</button>
@@ -9325,15 +9360,31 @@ class AccountManager {
const passwordEl = document.getElementById('viewAccountPassword');
const toggleBtn = document.querySelector('.toggle-password');
const toggleIcon = document.getElementById('toggleIcon');
const storedPwd = account?.AccountPassword || '';
passwordEl.dataset.password = storedPwd;
passwordEl.textContent = storedPwd ? '********' : '(no password stored)';
passwordEl.dataset.password = '';
passwordEl.dataset.loaded = 'false';
passwordEl.textContent = account?.PasswordAvailable ? '********' : '(no password stored)';
passwordEl.dataset.visible = 'false';
if (toggleIcon) toggleIcon.textContent = 'visibility';
// Rebind toggle each time modal opens to keep state fresh
if (toggleBtn) {
toggleBtn.onclick = () => {
toggleBtn.disabled = !account?.PasswordAvailable;
toggleBtn.onclick = async () => {
if (passwordEl.dataset.loaded !== 'true') {
try {
toggleBtn.disabled = true;
const password = await this.fetchAccountSecret(accountId);
passwordEl.dataset.password = password;
passwordEl.dataset.loaded = 'true';
this.currentViewAccount = { ...account, AccountPassword: password };
} catch (error) {
this.notifyFailure(error.message || 'Unable to reveal stored credential');
return;
} finally {
toggleBtn.disabled = false;
}
}
const currentPwd = passwordEl.dataset.password || '';
const isVisible = passwordEl.dataset.visible === 'true';
if (isVisible) {
@@ -9388,10 +9439,17 @@ class AccountManager {
// Edit Account listeners
document.querySelectorAll('.edit-account').forEach(btn => {
btn.addEventListener('click', (e) => {
btn.addEventListener('click', async (e) => {
if (btn.disabled) return; // Don't edit others' accounts
const accountId = Number(btn.dataset.accountId);
const account = this.accounts.find(a => a.AccountId === accountId);
let accountPassword = '';
try {
accountPassword = account?.PasswordAvailable ? await this.fetchAccountSecret(accountId) : '';
} catch (error) {
this.notifyFailure(error.message || 'Unable to load stored credential');
return;
}
// Populate form with existing data
const form = document.getElementById('accountForm');
if (form) {
@@ -9400,7 +9458,7 @@ class AccountManager {
const ownerInput = form.querySelector('#accountOwner');
const serviceSelect = form.querySelector('#accountService');
if (userInput) userInput.value = account?.AccountUsername || '';
if (passInput) passInput.value = account?.AccountPassword || '';
if (passInput) passInput.value = accountPassword;
if (ownerInput) ownerInput.value = this.currentUser?.Username || this.currentUser?.username || '';
if (serviceSelect) serviceSelect.value = account?.AppId || '';
}
@@ -9413,8 +9471,18 @@ class AccountManager {
// Edit from View modal
document.querySelectorAll('.edit-account-from-view').forEach(btn => {
btn.addEventListener('click', () => {
const account = this.currentViewAccount;
btn.addEventListener('click', async () => {
let account = this.currentViewAccount;
if (account?.PasswordAvailable && !account?.AccountPassword) {
try {
const password = await this.fetchAccountSecret(account.AccountId);
account = { ...account, AccountPassword: password };
this.currentViewAccount = account;
} catch (error) {
this.notifyFailure(error.message || 'Unable to load stored credential');
return;
}
}
const form = document.getElementById('accountForm');
if (form) {
const userInput = form.querySelector('#accountUsername');
@@ -9451,7 +9519,21 @@ class AccountManager {
const urlVal = app?.Url || app?.url;
if (urlEl) {
if (urlVal) {
urlEl.innerHTML = `<a href="${urlVal}" target="_blank" class="text-primary underline">${urlVal}</a>`;
try {
const parsedUrl = new URL(urlVal);
if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
throw new Error('Unsupported URL protocol');
}
const link = document.createElement('a');
link.href = parsedUrl.toString();
link.target = '_blank';
link.rel = 'noopener noreferrer';
link.className = 'text-primary underline';
link.textContent = urlVal;
urlEl.replaceChildren(link);
} catch (error) {
urlEl.textContent = '-';
}
} else {
urlEl.textContent = '-';
}
@@ -10044,7 +10126,7 @@ class AccountManager {
<div>
<label class="block text-xs font-semibold text-slate-600 dark:text-slate-300 mb-1">New password</label>
<div class="flex items-center gap-2">
<input type="password" id="profileNewPassword" placeholder="Enter new password" class="flex-1 px-3 py-2 border border-outline-variant/30 rounded-lg bg-white dark:bg-slate-900">
<input type="password" id="profileNewPassword" placeholder="At least 12 characters" minlength="12" maxlength="256" class="flex-1 px-3 py-2 border border-outline-variant/30 rounded-lg bg-white dark:bg-slate-900">
<button type="button" data-password-toggle="profileNewPassword" class="shrink-0 px-2.5 py-2 border border-outline-variant/30 rounded-lg text-slate-500 hover:text-slate-700 hover:bg-slate-100 dark:hover:text-slate-200 dark:hover:bg-slate-800" aria-label="Show new password">
<span class="material-symbols-outlined text-base" id="profileNewPasswordIcon">visibility</span>
</button>
@@ -10054,7 +10136,7 @@ class AccountManager {
<div>
<label class="block text-xs font-semibold text-slate-600 dark:text-slate-300 mb-1">Confirm new password</label>
<div class="flex items-center gap-2">
<input type="password" id="profileConfirmPassword" placeholder="Confirm new password" class="flex-1 px-3 py-2 border border-outline-variant/30 rounded-lg bg-white dark:bg-slate-900">
<input type="password" id="profileConfirmPassword" placeholder="Confirm new password" minlength="12" maxlength="256" class="flex-1 px-3 py-2 border border-outline-variant/30 rounded-lg bg-white dark:bg-slate-900">
<button type="button" data-password-toggle="profileConfirmPassword" class="shrink-0 px-2.5 py-2 border border-outline-variant/30 rounded-lg text-slate-500 hover:text-slate-700 hover:bg-slate-100 dark:hover:text-slate-200 dark:hover:bg-slate-800" aria-label="Show confirm password">
<span class="material-symbols-outlined text-base" id="profileConfirmPasswordIcon">visibility</span>
</button>
@@ -10144,8 +10226,14 @@ class AccountManager {
return;
}
if (newPassword && !currentPassword) {
this.notifyFailure('Current password is required to change password');
if (newPassword && (newPassword.length < 12 || newPassword.length > 256)) {
this.notifyFailure('New password must be between 12 and 256 characters');
return;
}
const emailChanged = email.toLowerCase() !== String(this.currentUser?.Email || '').toLowerCase();
if ((newPassword || emailChanged) && !currentPassword) {
this.notifyFailure('Current password is required to change password or email');
return;
}
@@ -10167,6 +10255,13 @@ class AccountManager {
return;
}
if (data.sessionEnded) {
localStorage.removeItem('currentUser');
this.notifySuccess(data.message || 'Profile updated. Please sign in again.');
setTimeout(() => window.location.replace('../pages/login.html'), 700);
return;
}
if (data.user) {
this.currentUser = {
...this.currentUser,
@@ -10563,7 +10658,7 @@ class AccountManager {
<div>
<label class="block text-sm font-medium mb-1">${user ? 'New Password' : 'Password'}</label>
<div class="flex items-center gap-2">
<input type="password" id="userPassword" placeholder="${user ? 'Leave blank to keep current password' : 'Password'}" class="flex-1 px-3 py-2 border border-outline-variant/30 rounded-lg bg-surface-container-low dark:bg-slate-800" ${user ? '' : 'required'}>
<input type="password" id="userPassword" placeholder="${user ? 'Leave blank to keep current password' : 'At least 12 characters'}" minlength="12" maxlength="256" class="flex-1 px-3 py-2 border border-outline-variant/30 rounded-lg bg-surface-container-low dark:bg-slate-800" ${user ? '' : 'required'}>
<button type="button" id="userPasswordToggle" class="p-2 rounded-lg border border-outline-variant/30 hover:bg-slate-100 dark:hover:bg-slate-700" title="Show/Hide password">
<span class="material-symbols-outlined text-base" id="userPasswordToggleIcon">visibility</span>
</button>
@@ -10654,6 +10749,11 @@ class AccountManager {
return;
}
if (password && (password.length < 12 || password.length > 256)) {
this.notifyFailure('Password must be between 12 and 256 characters');
return;
}
const method = userId ? 'PUT' : 'POST';
const url = userId ? `${this.apiBase}/users/${userId}` : `${this.apiBase}/users`;
@@ -10740,13 +10840,9 @@ class AccountManager {
</div>
<div>
<label class="block text-sm font-medium mb-1">Password</label>
<div class="flex items-center gap-2">
<div id="userDetailPassword" data-visible="false" class="flex-1 px-3 py-2 border border-outline-variant/30 rounded-lg bg-surface-container-low dark:bg-slate-800">********</div>
<button type="button" id="userDetailPasswordToggle" class="p-2 rounded-lg border border-outline-variant/30 hover:bg-slate-100 dark:hover:bg-slate-700" title="Show/Hide password">
<span class="material-symbols-outlined text-base" id="userDetailPasswordToggleIcon">visibility</span>
</button>
<div class="px-3 py-2 border border-outline-variant/30 rounded-lg bg-surface-container-low dark:bg-slate-800 text-sm text-slate-500">
Passwords are one-way hashed and cannot be displayed. Use Edit to reset the password.
</div>
<p class="text-xs text-slate-500 mt-1">Mật khẩu text thường (không phải hash) nếu tài khoản đã được lưu theo chuẩn mới.</p>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
@@ -10777,8 +10873,6 @@ class AccountManager {
document.body.appendChild(container);
}
const passwordValue = user?.Password || '';
const hasReadablePassword = user?.PasswordAvailable === true || Boolean(passwordValue);
const usernameEl = document.getElementById('userDetailUsername');
const fullNameEl = document.getElementById('userDetailFullName');
const emailEl = document.getElementById('userDetailEmail');
@@ -10786,9 +10880,6 @@ class AccountManager {
const statusEl = document.getElementById('userDetailStatus');
const createdDateEl = document.getElementById('userDetailCreatedDate');
const lastLoginEl = document.getElementById('userDetailLastLogin');
const passwordEl = document.getElementById('userDetailPassword');
const passwordToggleBtn = document.getElementById('userDetailPasswordToggle');
const passwordToggleIcon = document.getElementById('userDetailPasswordToggleIcon');
const editBtn = document.getElementById('userDetailEditBtn');
const detailsModal = document.getElementById('userDetailsModal');
@@ -10799,35 +10890,6 @@ class AccountManager {
if (statusEl) statusEl.textContent = user?.IsActive ? 'Active' : 'Inactive';
if (createdDateEl) createdDateEl.textContent = this.formatDateTime(user?.CreatedDate);
if (lastLoginEl) lastLoginEl.textContent = this.formatDateTime(user?.LastLogin);
if (passwordEl) {
passwordEl.dataset.password = passwordValue;
passwordEl.dataset.visible = 'false';
passwordEl.textContent = hasReadablePassword ? '********' : '(khong the hien thi - can reset password 1 lan)';
}
if (passwordToggleBtn && passwordEl) {
passwordToggleBtn.addEventListener('click', () => {
if (!hasReadablePassword) {
return;
}
const isVisible = passwordEl.dataset.visible === 'true';
if (isVisible) {
passwordEl.textContent = '********';
passwordEl.dataset.visible = 'false';
if (passwordToggleIcon) passwordToggleIcon.textContent = 'visibility';
} else {
passwordEl.textContent = passwordValue;
passwordEl.dataset.visible = 'true';
if (passwordToggleIcon) passwordToggleIcon.textContent = 'visibility_off';
}
});
}
if (passwordToggleBtn && !hasReadablePassword) {
passwordToggleBtn.disabled = true;
passwordToggleBtn.classList.add('opacity-50', 'cursor-not-allowed');
}
if (editBtn) {
editBtn.addEventListener('click', () => {
closeUserDetailsModal();
@@ -11148,6 +11210,18 @@ function closeProfileModal() {
// Initialize app when DOM is ready
let app;
document.addEventListener('DOMContentLoaded', () => {
app = new AccountManager();
document.addEventListener('DOMContentLoaded', async () => {
try {
const response = await fetch('/api/auth/session', { cache: 'no-store' });
const data = await response.json();
if (!response.ok || !data.success || !data.user) {
throw new Error('Session is not valid');
}
localStorage.setItem('currentUser', JSON.stringify(data.user));
app = new AccountManager();
} catch (error) {
localStorage.removeItem('currentUser');
window.location.replace('../pages/login.html?reason=session-expired');
}
});

View File

@@ -119,7 +119,7 @@
</div>
<div>
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">URL</label>
<input type="text" id="appUrl" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3" placeholder="https://example.com or 172.20.235.176">
<input type="url" id="appUrl" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3" placeholder="https://app.example.internal">
</div>
<div>
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Status</label>

View File

@@ -12,7 +12,7 @@
<!-- Notiflix Notify -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/notiflix@3.2.7/dist/notiflix-3.2.7.min.css" />
<script src="https://cdn.jsdelivr.net/npm/notiflix@3.2.7/dist/notiflix-aio-3.2.7.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js"></script>
<script src="https://cdn.sheetjs.com/xlsx-0.20.3/package/dist/xlsx.full.min.js"></script>
<style>
.material-symbols-outlined {
font-family: 'Material Symbols Outlined';
@@ -267,7 +267,7 @@
}
</style>
<link rel="stylesheet" href="../css/responsive.css?v=20260717-1" />
<link rel="stylesheet" href="../css/responsive.css?v=20260717-2" />
</head>
<body class="app-shell bg-background text-on-surface antialiased flex h-screen w-screen">
<!-- SideNavBar -->

View File

@@ -194,6 +194,8 @@
id="resetPassword"
name="resetPassword"
placeholder="Enter a new password"
minlength="12"
maxlength="256"
required
class="w-full pl-10 pr-4 py-2.5 bg-surface-container-low border border-outline-variant/30 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent transition-all text-sm font-medium"
/>
@@ -211,6 +213,8 @@
id="resetConfirmPassword"
name="resetConfirmPassword"
placeholder="Re-enter new password"
minlength="12"
maxlength="256"
required
class="w-full pl-10 pr-4 py-2.5 bg-surface-container-low border border-outline-variant/30 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent transition-all text-sm font-medium"
/>
@@ -294,6 +298,8 @@
id="regPassword"
name="password"
placeholder="Create a password"
minlength="12"
maxlength="256"
required
class="w-full pl-10 pr-4 py-2.5 bg-surface-container-low border border-outline-variant/30 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent transition-all text-sm font-medium"
/>
@@ -314,7 +320,7 @@
<!-- Footer -->
<!-- <div class="mt-8 pt-6 border-t border-outline-variant/10 text-center">
<p class="text-[10px] text-on-surface-variant/60">Default credentials for demo: admin / admin</p>
<p class="text-[10px] text-on-surface-variant/60">Use the account provided by your administrator.</p>
</div> -->
</div>
@@ -419,13 +425,37 @@
return 'login';
};
document.addEventListener('DOMContentLoaded', () => {
const initialMode = getInitialMode();
const currentUser = localStorage.getItem('currentUser');
if (currentUser && initialMode !== 'reset') {
window.location.href = './index.html';
return;
document.addEventListener('DOMContentLoaded', async () => {
let initialMode = getInitialMode();
try {
const configResponse = await fetch('/api/auth/config', { cache: 'no-store' });
const config = await configResponse.json();
if (configResponse.ok && config.allowSelfRegistration === false) {
registerTab.classList.add('hidden');
if (initialMode === 'register') {
initialMode = 'login';
}
}
} catch (error) {
registerTab.classList.add('hidden');
if (initialMode === 'register') {
initialMode = 'login';
}
}
if (initialMode !== 'reset') {
try {
const sessionResponse = await fetch('/api/auth/session', { cache: 'no-store' });
const sessionData = await sessionResponse.json();
if (sessionResponse.ok && sessionData.success && sessionData.user) {
localStorage.setItem('currentUser', JSON.stringify(sessionData.user));
window.location.replace('./index.html');
return;
}
} catch (error) {
console.debug('No active session');
}
}
localStorage.removeItem('currentUser');
setMode(initialMode);
@@ -490,7 +520,7 @@
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ username, password })
body: JSON.stringify({ username, password, remember: rememberCheckbox.checked })
});
const data = await response.json();
@@ -579,8 +609,8 @@
return;
}
if (!newPassword || newPassword.length < 6) {
resetPasswordErrorMessage.textContent = 'New password must be at least 6 characters.';
if (!newPassword || newPassword.length < 12 || newPassword.length > 256) {
resetPasswordErrorMessage.textContent = 'New password must be between 12 and 256 characters.';
resetPasswordErrorMessage.classList.remove('hidden');
return;
}