security + reponsive
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
232
public/js/app.js
232
public/js/app.js
@@ -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');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user