Files
ManagerAccount/public/js/app.js
2026-03-31 14:14:01 +07:00

1120 lines
56 KiB
JavaScript

// VaultSentinel - Account Management Application
// Main JavaScript functionality
class AccountManager {
constructor() {
// Check if user is logged in
const currentUser = this.loadFromStorage('currentUser');
if (!currentUser) {
window.location.href = '../pages/login.html';
return;
}
this.currentUser = currentUser;
this.accounts = [];
this.applications = [];
this.apiBase = '/api';
this.currentPage = 'dashboard';
this.accountSearchTerm = '';
this.applicationSearchTerm = '';
this.accountServiceFilter = '';
this.configureNotifications();
this.initPromise = this.init();
this.pendingAccountAppId = undefined;
}
configureNotifications() {
if (window.Notiflix?.Notify) {
Notiflix.Notify.init({
position: 'right-top',
timeout: 2500,
clickToClose: true,
pauseOnHover: true,
distance: '12px',
fontSize: '14px'
});
}
}
notifySuccess(message) {
if (window.Notiflix?.Notify) {
Notiflix.Notify.success(message);
} else {
alert(message);
}
}
notifyFailure(message) {
if (window.Notiflix?.Notify) {
Notiflix.Notify.failure(message);
} else {
alert(message);
}
}
notifyWarning(message) {
if (window.Notiflix?.Notify) {
Notiflix.Notify.warning(message);
} else {
alert(message);
}
}
getUserId() {
const u = this.currentUser;
const detected = u?.UserId ?? u?.userId ?? u?.id ?? u?.ID ?? u?.userid ?? u?.user_id ?? u?.user?.UserId ?? u?.user?.userId;
// Fallback: if only username/role exist (no id), use default admin id = 1
return detected ?? 1;
}
async init() {
await this.fetchApplications();
await this.fetchAccounts();
this.setupEventListeners();
this.loadModals(); // Load modals từ file riêng
// Single-page navigation based on hash
this.handleRoute(location.hash || '#dashboard');
window.addEventListener('hashchange', () => this.handleRoute(location.hash));
}
handleRoute(hash) {
const route = (hash || '#dashboard').replace('#', '') || 'dashboard';
this.renderView(route);
}
renderView(page) {
this.currentPage = page;
const mainContent = document.getElementById('mainContent');
if (!mainContent) return;
if (page === 'applications') {
mainContent.innerHTML = this.getApplicationsContent();
this.setupAccountRowListeners();
this.setupAddButtonListeners();
this.setupFilters();
} else if (page === 'accounts') {
mainContent.innerHTML = this.getAccountsContent();
this.setupAccountRowListeners();
this.setupAddButtonListeners();
this.setupFilters();
} else {
mainContent.innerHTML = this.renderDashboard();
}
this.restoreSearchFocus();
this.setActiveNav(page);
}
setActiveNav(page) {
document.querySelectorAll('[data-nav]').forEach(link => {
const isActive = link.dataset.nav === page;
link.classList.toggle('border-l-4', isActive);
link.classList.toggle('border-blue-600', isActive);
link.classList.toggle('bg-slate-200/80', isActive);
link.classList.toggle('dark:bg-slate-800', isActive);
link.classList.toggle('text-slate-900', isActive);
link.classList.toggle('dark:text-slate-50', isActive);
link.classList.toggle('font-bold', isActive);
});
}
async fetchApplications() {
const res = await fetch(`${this.apiBase}/applications`);
const data = await res.json();
if (data.success) {
this.applications = data.data;
} else {
console.error('Load applications failed:', data.message);
}
}
async fetchAccounts() {
const userId = this.getUserId();
if (!userId) return;
const res = await fetch(`${this.apiBase}/accounts/user/${userId}`);
const data = await res.json();
if (data.success) {
this.accounts = data.data;
} else {
console.error('Load accounts failed:', data.message);
}
}
async loadModals() {
try {
const existingContainer = document.getElementById('modalsContainer');
if (existingContainer && existingContainer.children.length) {
return;
}
const response = await fetch('../modals.html');
const modalsHTML = await response.text();
const container = existingContainer || document.createElement('div');
if (!container.id) container.id = 'modalsContainer';
container.innerHTML = modalsHTML;
if (!container.parentElement) {
document.body.appendChild(container);
}
this.setupFormListeners();
this.setupAccountRowListeners();
this.setupAddButtonListeners();
this.setupFilters();
} catch (error) {
console.error('Lỗi load modals:', error);
}
}
restoreSearchFocus() {
const accountSearch = document.getElementById('accountSearch');
const appSearch = document.getElementById('appSearch');
if (accountSearch && accountSearch.dataset.focused === 'true') {
const pos = accountSearch.selectionStart || accountSearch.value.length;
accountSearch.focus();
accountSearch.setSelectionRange(pos, pos);
}
if (appSearch && appSearch.dataset.focused === 'true') {
const pos = appSearch.selectionStart || appSearch.value.length;
appSearch.focus();
appSearch.setSelectionRange(pos, pos);
}
}
setupEventListeners() {
// Modal close buttons
document.querySelectorAll('[data-close-modal]').forEach(btn => {
btn.addEventListener('click', () => this.closeModals());
});
// Close with Escape key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
this.closeModals();
}
});
// Form submissions
const accountForm = document.getElementById('accountForm');
if (accountForm) {
accountForm.addEventListener('submit', (e) => this.handleAccountSubmit(e));
}
const appForm = document.getElementById('appForm');
if (appForm) {
appForm.addEventListener('submit', (e) => this.handleAppSubmit(e));
}
// Logout button
const logoutBtn = document.getElementById('logoutBtn');
if (logoutBtn) {
logoutBtn.addEventListener('click', () => this.handleLogout());
}
// Update account display
this.updateAccountDisplay();
// Account table row clicks
this.setupAccountRowListeners();
this.setupFilters();
}
setupFormListeners() {
const accountForm = document.getElementById('accountForm');
if (accountForm) {
accountForm.addEventListener('submit', (e) => this.handleAccountSubmit(e));
}
const appForm = document.getElementById('appForm');
if (appForm) {
appForm.addEventListener('submit', (e) => this.handleAppSubmit(e));
}
// Close when clicking backdrop outside modal content
document.querySelectorAll('.modal-backdrop').forEach(backdrop => {
backdrop.addEventListener('click', (evt) => {
if (evt.target === backdrop) {
this.closeModals();
}
});
});
}
updateAccountDisplay() {
// Use the logged-in user from constructor
const usernameEl = document.getElementById('accountUsername');
const roleEl = document.getElementById('accountRole');
if (usernameEl) usernameEl.textContent = this.currentUser?.username || this.currentUser?.Username || 'User';
if (roleEl) roleEl.textContent = this.currentUser?.role || this.currentUser?.Role || 'Administrator';
}
getFilteredAccounts() {
const svcFilter = this.accountServiceFilter || '';
const search = (this.accountSearchTerm || '').toLowerCase();
return this.accounts.filter(acc => {
const matchesService = !svcFilter || String(acc.AppId) === String(svcFilter);
if (!matchesService) return false;
if (!search) return true;
const hay = [acc.AccountUsername, acc.Email, acc.AppName, acc.AppType].map(v => (v || '').toLowerCase());
return hay.some(val => val.includes(search));
});
}
getFilteredApplications() {
const search = (this.applicationSearchTerm || '').toLowerCase();
if (!search) return this.applications;
return this.applications.filter(app => {
const hay = [app.Name, app.Type, app.Description, app.Url, app.Icon].map(v => (v || '').toLowerCase());
return hay.some(val => val.includes(search));
});
}
handleLogout() {
if (confirm('Are you sure you want to logout?')) {
this.saveToStorage('currentUser', null);
localStorage.clear();
window.location.href = '../pages/login.html';
}
}
renderDashboard() {
return `
<div class="flex-1 flex flex-col p-6 space-y-6 min-h-0 overflow-auto">
<!-- Title and Stats -->
<div class="flex items-end justify-between shrink-0">
<div>
<h1 class="text-2xl font-extrabold text-on-surface tracking-tight leading-none">System Overview</h1>
<p class="text-xs text-on-surface-variant font-medium mt-1">Account & Service Management</p>
</div>
<div class="flex gap-2">
<a href="./accounts.html" class="py-1.5 px-3 bg-primary text-on-primary rounded-lg text-xs font-bold flex items-center gap-1.5 shadow-sm active:scale-95 duration-100">
<span class="material-symbols-outlined text-sm">add</span>
Add Account
</a>
</div>
</div>
<!-- Metric Grid -->
<div class="grid grid-cols-4 gap-4 shrink-0">
<div class="bg-surface-container-lowest p-4 rounded-xl border border-outline-variant/15 flex flex-col">
<span class="text-[10px] font-bold text-on-surface-variant uppercase tracking-wider mb-2">Applications</span>
<div class="flex items-baseline justify-between">
<span class="text-2xl font-black text-on-surface">${this.applications.length}</span>
<span class="text-[10px] font-bold text-on-surface-variant">${this.applications.filter(a => (a.Status || a.status) === 'online').length} Active</span>
</div>
</div>
<div class="bg-surface-container-lowest p-4 rounded-xl border border-outline-variant/15 flex flex-col">
<span class="text-[10px] font-bold text-on-surface-variant uppercase tracking-wider mb-2">Total Accounts</span>
<div class="flex items-baseline justify-between">
<span class="text-2xl font-black text-on-surface">${this.accounts.length}</span>
<span class="text-[10px] font-bold text-on-surface-variant/40">Managed</span>
</div>
</div>
<div class="bg-surface-container-lowest p-4 rounded-xl border border-outline-variant/15 flex flex-col">
<span class="text-[10px] font-bold text-on-surface-variant uppercase tracking-wider mb-2">Last Updated</span>
<div class="flex items-baseline justify-between">
<span class="text-sm font-black text-on-surface">${new Date().toLocaleDateString()}</span>
</div>
</div>
<div class="bg-primary-container/10 p-4 rounded-xl border border-primary/20 flex flex-col">
<span class="text-[10px] font-bold text-primary uppercase tracking-wider mb-2">Status</span>
<div class="flex items-baseline justify-between">
<span class="text-lg font-black text-primary">Operational</span>
<span class="material-symbols-outlined text-primary text-sm">check_circle</span>
</div>
</div>
</div>
<!-- Recent Activity -->
<div class="bg-surface-container-lowest rounded-xl p-5 border border-outline-variant/15 flex flex-col flex-1 min-h-0">
<div class="flex items-center justify-between mb-4 shrink-0">
<h3 class="text-sm font-bold text-on-surface flex items-center gap-2">
<span class="material-symbols-outlined text-primary text-base">history</span>
Recent Accounts
</h3>
</div>
${this.accounts.length > 0 ? `
<div class="flex-1 overflow-y-auto space-y-2">
${this.accounts.slice(-5).reverse().map(acc => {
const username = acc.AccountUsername || acc.username || '-';
const service = acc.AppName || acc.service || '-';
const owner = acc.Email || acc.owner || this.currentUser?.Username || this.currentUser?.username || '-';
return `
<div class="flex gap-3 p-3 bg-surface-container-low/50 rounded-lg border-l-2 border-primary/50">
<div class="flex-1 min-w-0">
<p class="text-[11px] font-bold text-on-surface truncate">${username}</p>
<p class="text-[9px] text-on-surface-variant">${service}${owner}</p>
</div>
</div>
`;}).join('')}
</div>
` : `
<div class="flex-1 flex items-center justify-center text-center">
<p class="text-sm text-on-surface-variant">No accounts yet. <a href="./accounts.html" class="text-primary font-bold">Create one</a></p>
</div>
`}
</div>
</div>
`;
}
getAccountsContent() {
const filteredAccounts = this.getFilteredAccounts();
return `
<div class="p-4 md:p-6 flex flex-col h-full overflow-hidden">
<!-- Page Header -->
<div class="flex items-center justify-between mb-4 shrink-0">
<div>
<h1 class="text-xl font-extrabold text-on-surface tracking-tight">Accounts Management</h1>
<p class="text-[10px] text-on-surface-variant uppercase font-semibold tracking-widest mt-0.5">Administrative Access Control</p>
</div>
<button id="addAccountBtn" class="bg-primary hover:bg-primary-dim text-on-primary px-3 py-1.5 rounded-lg text-xs font-bold shadow-sm flex items-center gap-1.5 transition-all active:scale-95">
<span class="material-symbols-outlined text-base">person_add</span>
Add Account
</button>
</div>
<!-- Filters -->
<div class="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">Service</span>
<select id="serviceFilter" 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">
<option value="">All Services</option>
${this.applications.map(app => `<option value="${app.AppId}">${app.Name}</option>`).join('')}
</select>
</div>
<div class="flex items-center gap-1.5 flex-1">
<span class="text-[10px] font-bold uppercase text-on-surface-variant">Search</span>
<input id="accountSearch" 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="Search by owner, username, service">
</div>
</div>
<!-- Accounts Table -->
<div class="flex-1 bg-white rounded-xl border border-slate-200 shadow-sm flex flex-col overflow-hidden min-h-0">
${filteredAccounts.length > 0 ? `
<div class="overflow-y-auto overflow-x-auto flex-1">
<table class="w-full text-left border-collapse w-full">
<thead class="sticky top-0 z-10">
<tr class="bg-slate-50 border-b border-slate-200">
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">Owner</th>
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">Username</th>
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">Service</th>
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500 text-right">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100 accounts-table-body">
${filteredAccounts.map(acc => `
<tr class="hover:bg-slate-50/80 transition-colors group account-row" data-account-id="${acc.AccountId}">
<td class="px-4 py-3 text-sm font-medium text-slate-900">${acc.Email || '-'}</td>
<td class="px-4 py-3 text-sm text-slate-600">${acc.AccountUsername || '-'}</td>
<td class="px-4 py-3 text-sm">
<span class="px-2 py-1 bg-blue-100 text-blue-700 rounded text-xs font-semibold">${acc.AppName || '-'}</span>
</td>
<td class="px-4 py-3 text-right">
<button class="p-1.5 text-slate-400 hover:text-slate-600 transition-colors view-account" data-account-id="${acc.AccountId}" title="View Details">
<span class="material-symbols-outlined text-lg">info</span>
</button>
<button class="p-1.5 text-slate-400 hover:text-primary transition-colors edit-account" data-account-id="${acc.AccountId}">
<span class="material-symbols-outlined text-lg">edit</span>
</button>
<button class="p-1.5 text-slate-400 hover:text-error transition-colors delete-account" data-account-id="${acc.AccountId}">
<span class="material-symbols-outlined text-lg">delete</span>
</button>
</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
` : `
<div class="flex-1 flex items-center justify-center text-center">
<div>
<p class="text-sm text-on-surface-variant mb-4">No accounts yet. Create one to get started.</p>
<button id="addAccountBtn" class="bg-primary hover:bg-primary-dim text-on-primary px-4 py-2 rounded-lg text-sm font-bold">
<span class="material-symbols-outlined text-base inline mr-2">person_add</span>
Add First Account
</button>
</div>
</div>
`}
</div>
</div>
`;
}
getApplicationsContent() {
const filteredApps = this.getFilteredApplications();
return `
<div class="flex flex-col p-6 overflow-hidden h-full">
<!-- Header Section -->
<div class="flex items-center justify-between gap-6 mb-6 shrink-0">
<div>
<h1 class="text-2xl font-extrabold text-on-surface tracking-tight">Applications</h1>
<p class="text-sm text-on-surface-variant">Manage and monitor active infrastructure services.</p>
</div>
<button id="addAppBtn" class="bg-primary hover:bg-primary-dim text-on-primary px-4 py-2 rounded-xl font-bold flex items-center gap-2 transition-all active:scale-95 shadow-sm">
<span class="material-symbols-outlined">add</span>
Add New
</button>
</div>
<!-- Dashboard Stats -->
<div class="grid grid-cols-3 gap-4 mb-6 shrink-0">
<div class="bg-surface-container-lowest px-4 py-3 rounded-xl shadow-sm border border-outline-variant/5 flex items-center gap-4">
<div class="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center text-primary">
<span class="material-symbols-outlined text-xl">lan</span>
</div>
<div>
<p class="text-[10px] font-bold text-on-surface-variant uppercase tracking-wider">Active</p>
<p class="text-lg font-black text-on-surface">${this.applications.filter(a => a.status === 'online').length}</p>
</div>
</div>
<div class="bg-surface-container-lowest px-4 py-3 rounded-xl shadow-sm border border-outline-variant/5 flex items-center gap-4">
<div class="w-10 h-10 rounded-lg bg-tertiary/10 flex items-center justify-center text-tertiary">
<span class="material-symbols-outlined text-xl">bolt</span>
</div>
<div>
<p class="text-[10px] font-bold text-on-surface-variant uppercase tracking-wider">Total</p>
<p class="text-lg font-black text-on-surface">${this.applications.length}</p>
</div>
</div>
<div class="bg-surface-container-lowest px-4 py-3 rounded-xl shadow-sm border border-outline-variant/5 flex items-center gap-4">
<div class="w-10 h-10 rounded-lg bg-secondary/10 flex items-center justify-center text-secondary">
<span class="material-symbols-outlined text-xl">database</span>
</div>
<div>
<p class="text-[10px] font-bold text-on-surface-variant uppercase tracking-wider">Health</p>
<p class="text-lg font-black text-on-surface">99.9%</p>
</div>
</div>
</div>
<!-- Applications List -->
<div class="bg-surface-container-lowest rounded-xl shadow-sm border border-outline-variant/10 overflow-hidden flex flex-col flex-1 min-h-0">
<div class="px-4 py-3 border-b border-outline-variant/10 flex items-center gap-2 bg-surface-container-low/40">
<span class="text-[10px] font-bold uppercase text-on-surface-variant">Search</span>
<input id="appSearch" class="flex-1 bg-white border border-slate-200 rounded-md text-[11px] py-1 px-2 focus:ring-1 focus:ring-primary shadow-sm" placeholder="Search by name, type, description, url">
</div>
<div class="overflow-y-auto flex-1">
<table class="w-full text-left border-collapse">
<thead class="sticky top-0 bg-surface-container-lowest z-10">
<tr class="bg-surface-container-low/30 border-b border-outline-variant/10">
<th class="px-6 py-2.5 text-[10px] font-bold text-on-surface-variant uppercase tracking-widest">Name</th>
<th class="px-6 py-2.5 text-[10px] font-bold text-on-surface-variant uppercase tracking-widest">Type</th>
<th class="px-6 py-2.5 text-[10px] font-bold text-on-surface-variant uppercase tracking-widest">Description</th>
<th class="px-6 py-2.5 text-[10px] font-bold text-on-surface-variant uppercase tracking-widest">URL</th>
<th class="px-6 py-2.5 text-[10px] font-bold text-on-surface-variant uppercase tracking-widest">Status</th>
<th class="px-6 py-2.5 text-[10px] font-bold text-on-surface-variant uppercase tracking-widest text-right">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-outline-variant/5 apps-table-body">
${filteredApps.map(app => `
<tr class="hover:bg-surface-container-low/30 transition-colors group">
<td class="px-6 py-3">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded bg-surface-container-highest flex items-center justify-center text-primary">
<span class="material-symbols-outlined text-sm">${app.Icon || 'apps'}</span>
</div>
<span class="font-bold text-sm text-on-surface">${app.Name}</span>
</div>
</td>
<td class="px-6 py-3">
<span class="px-2 py-0.5 rounded-full text-[9px] font-black uppercase bg-surface-container-highest text-on-surface-variant">${app.Type}</span>
</td>
<td class="px-6 py-3 text-sm text-on-surface-variant max-w-xs truncate" title="${app.Description || ''}">${app.Description || '-'}</td>
<td class="px-6 py-3 text-sm text-primary max-w-xs truncate">${(app.Url || app.url) ? `<a href="${app.Url || app.url}" target="_blank" class="underline">${app.Url || app.url}</a>` : '-'}</td>
<td class="px-6 py-3">
<div class="flex items-center gap-1.5">
<div class="w-1.5 h-1.5 rounded-full ${(app.Status || app.status) === 'online' ? 'bg-primary' : 'bg-error'} ring-2 ${(app.Status || app.status) === 'online' ? 'ring-primary/20' : 'ring-error/20'}"></div>
<span class="text-xs font-medium ${(app.Status || app.status) === 'online' ? 'text-on-primary-fixed-variant' : 'text-error'}">${(app.Status || app.status) === 'online' ? 'Online' : 'Offline'}</span>
</div>
</td>
<td class="px-6 py-3 text-right">
<div class="flex items-center justify-end gap-1">
<button class="p-1.5 text-on-surface-variant hover:text-on-surface transition-colors view-app" data-app-id="${app.AppId}" title="View Details">
<span class="material-symbols-outlined text-lg">info</span>
</button>
<button class="p-1.5 text-on-surface-variant hover:text-primary transition-colors edit-app" data-app-id="${app.AppId}">
<span class="material-symbols-outlined text-lg">edit</span>
</button>
<button class="p-1.5 text-on-surface-variant hover:text-error transition-colors delete-app" data-app-id="${app.AppId}">
<span class="material-symbols-outlined text-lg">delete</span>
</button>
</div>
</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
</div>
</div>
`;
}
renderAccountsTableBody() {
const tbody = document.querySelector('.accounts-table-body');
if (!tbody) return;
const filteredAccounts = this.getFilteredAccounts();
tbody.innerHTML = filteredAccounts.map(acc => `
<tr class="hover:bg-slate-50/80 transition-colors group account-row" data-account-id="${acc.AccountId}">
<td class="px-4 py-3 text-sm font-medium text-slate-900">${acc.Email || '-'}</td>
<td class="px-4 py-3 text-sm text-slate-600">${acc.AccountUsername || '-'}</td>
<td class="px-4 py-3 text-sm">
<span class="px-2 py-1 bg-blue-100 text-blue-700 rounded text-xs font-semibold">${acc.AppName || '-'}</span>
</td>
<td class="px-4 py-3 text-right">
<button class="p-1.5 text-slate-400 hover:text-slate-600 transition-colors view-account" data-account-id="${acc.AccountId}" title="View Details">
<span class="material-symbols-outlined text-lg">info</span>
</button>
<button class="p-1.5 text-slate-400 hover:text-primary transition-colors edit-account" data-account-id="${acc.AccountId}">
<span class="material-symbols-outlined text-lg">edit</span>
</button>
<button class="p-1.5 text-slate-400 hover:text-error transition-colors delete-account" data-account-id="${acc.AccountId}">
<span class="material-symbols-outlined text-lg">delete</span>
</button>
</td>
</tr>
`).join('');
this.setupAccountRowListeners();
}
renderApplicationsTableBody() {
const tbody = document.querySelector('.apps-table-body');
if (!tbody) return;
const filteredApps = this.getFilteredApplications();
tbody.innerHTML = filteredApps.map(app => `
<tr class="hover:bg-surface-container-low/30 transition-colors group">
<td class="px-6 py-3">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded bg-surface-container-highest flex items-center justify-center text-primary">
<span class="material-symbols-outlined text-sm">${app.Icon || 'apps'}</span>
</div>
<span class="font-bold text-sm text-on-surface">${app.Name}</span>
</div>
</td>
<td class="px-6 py-3">
<span class="px-2 py-0.5 rounded-full text-[9px] font-black uppercase bg-surface-container-highest text-on-surface-variant">${app.Type}</span>
</td>
<td class="px-6 py-3 text-sm text-on-surface-variant max-w-xs truncate" title="${app.Description || ''}">${app.Description || '-'}</td>
<td class="px-6 py-3 text-sm text-primary max-w-xs truncate">${(app.Url || app.url) ? `<a href="${app.Url || app.url}" target="_blank" class="underline">${app.Url || app.url}</a>` : '-'}</td>
<td class="px-6 py-3">
<div class="flex items-center gap-1.5">
<div class="w-1.5 h-1.5 rounded-full ${(app.Status || app.status) === 'online' ? 'bg-primary' : 'bg-error'} ring-2 ${(app.Status || app.status) === 'online' ? 'ring-primary/20' : 'ring-error/20'}"></div>
<span class="text-xs font-medium ${(app.Status || app.status) === 'online' ? 'text-on-primary-fixed-variant' : 'text-error'}">${(app.Status || app.status) === 'online' ? 'Online' : 'Offline'}</span>
</div>
</td>
<td class="px-6 py-3 text-right">
<div class="flex items-center justify-end gap-1">
<button class="p-1.5 text-on-surface-variant hover:text-on-surface transition-colors view-app" data-app-id="${app.AppId}" title="View Details">
<span class="material-symbols-outlined text-lg">info</span>
</button>
<button class="p-1.5 text-on-surface-variant hover:text-primary transition-colors edit-app" data-app-id="${app.AppId}">
<span class="material-symbols-outlined text-lg">edit</span>
</button>
<button class="p-1.5 text-on-surface-variant hover:text-error transition-colors delete-app" data-app-id="${app.AppId}">
<span class="material-symbols-outlined text-lg">delete</span>
</button>
</div>
</td>
</tr>
`).join('');
this.setupAccountRowListeners();
}
setupAccountRowListeners() {
// View Account listeners
document.querySelectorAll('.view-account').forEach(btn => {
btn.addEventListener('click', (e) => {
const accountId = Number(btn.dataset.accountId);
const account = this.accounts.find(a => a.AccountId === accountId);
this.currentViewAccountId = accountId;
this.currentViewAccount = account;
document.getElementById('viewAccountService').textContent = account?.AppName || '-';
document.getElementById('viewAccountOwner').textContent = account?.Email || '-';
document.getElementById('viewAccountUsername').textContent = account?.AccountUsername || '-';
const passwordEl = document.getElementById('viewAccountPassword');
passwordEl.dataset.password = account?.AccountPassword || '';
passwordEl.textContent = '••••••••';
passwordEl.dataset.visible = 'false';
document.getElementById('toggleIcon').textContent = 'visibility';
document.getElementById('viewAccountModal').classList.add('open');
});
});
// Delete Account listeners - show confirmation modal
document.querySelectorAll('.delete-account').forEach(btn => {
btn.addEventListener('click', (e) => {
const accountId = Number(btn.dataset.accountId);
const account = this.accounts.find(a => a.AccountId === accountId);
this.pendingDeleteAccountId = accountId;
document.getElementById('deleteAccountUsername').textContent = account?.AccountUsername || '';
document.getElementById('deleteAccountModal').classList.add('open');
});
});
// Confirm Delete Account
document.querySelectorAll('.confirm-delete-account').forEach(btn => {
btn.addEventListener('click', () => {
if (this.pendingDeleteAccountId !== undefined) {
fetch(`${this.apiBase}/accounts/${this.pendingDeleteAccountId}`, { method: 'DELETE' })
.then(res => res.json())
.then(data => {
if (data.success) {
this.notifySuccess('Account deleted successfully');
this.closeModals();
this.refreshAccountsUI();
} else {
this.notifyFailure(data.message || 'Delete account failed');
}
})
.catch(err => {
console.error(err);
this.notifyFailure('Delete account failed');
});
}
});
});
// Edit Account listeners
document.querySelectorAll('.edit-account').forEach(btn => {
btn.addEventListener('click', (e) => {
const accountId = Number(btn.dataset.accountId);
const account = this.accounts.find(a => a.AccountId === accountId);
// Populate form with existing data
const form = document.getElementById('accountForm');
if (form) {
const userInput = form.querySelector('#accountUsername');
const passInput = form.querySelector('#accountPassword');
const ownerInput = form.querySelector('#accountOwner');
const serviceSelect = form.querySelector('#accountService');
if (userInput) userInput.value = account?.AccountUsername || '';
if (passInput) passInput.value = account?.AccountPassword || '';
if (ownerInput) ownerInput.value = this.currentUser?.Username || this.currentUser?.username || '';
if (serviceSelect) serviceSelect.value = account?.AppId || '';
}
this.pendingAccountAppId = account?.AppId;
this.editingAccountId = account?.AccountId;
this.closeModals();
this.openAccountModal();
});
});
// Edit from View modal
document.querySelectorAll('.edit-account-from-view').forEach(btn => {
btn.addEventListener('click', () => {
const account = this.currentViewAccount;
const form = document.getElementById('accountForm');
if (form) {
const userInput = form.querySelector('#accountUsername');
const passInput = form.querySelector('#accountPassword');
const ownerInput = form.querySelector('#accountOwner');
const serviceSelect = form.querySelector('#accountService');
if (userInput) userInput.value = account?.AccountUsername || '';
if (passInput) passInput.value = account?.AccountPassword || '';
if (ownerInput) ownerInput.value = this.currentUser?.Username || this.currentUser?.username || '';
if (serviceSelect) serviceSelect.value = account?.AppId || '';
}
this.pendingAccountAppId = account?.AppId;
this.editingAccountId = account?.AccountId;
this.closeModals();
this.openAccountModal();
});
});
// Toggle Password Visibility
document.querySelectorAll('.toggle-password').forEach(btn => {
btn.addEventListener('click', () => {
const passwordEl = document.getElementById('viewAccountPassword');
const toggleIcon = document.getElementById('toggleIcon');
const storedPwd = passwordEl.dataset.password || this.currentViewAccount?.AccountPassword || '';
const isVisible = passwordEl.dataset.visible === 'true';
if (isVisible) {
passwordEl.textContent = '••••••••';
passwordEl.dataset.visible = 'false';
toggleIcon.textContent = 'visibility';
} else {
passwordEl.textContent = storedPwd || '(no password stored)';
passwordEl.dataset.visible = 'true';
toggleIcon.textContent = 'visibility_off';
}
});
});
// View App listeners
document.querySelectorAll('.view-app').forEach(btn => {
btn.addEventListener('click', (e) => {
const appId = Number(btn.dataset.appId);
const app = this.applications.find(a => a.AppId === appId);
this.currentViewAppId = appId;
document.getElementById('viewAppName').textContent = app?.Name || '-';
document.getElementById('viewAppType').textContent = app?.Type || '-';
const iconVal = app?.Icon || app?.icon || 'apps';
const iconSymbolEl = document.getElementById('viewAppIconSymbol');
const iconNameEl = document.getElementById('viewAppIconName');
if (iconSymbolEl) iconSymbolEl.textContent = iconVal;
if (iconNameEl) iconNameEl.textContent = iconVal;
document.getElementById('viewAppDescription').textContent = app?.Description || '-';
const urlEl = document.getElementById('viewAppUrl');
const urlVal = app?.Url || app?.url;
if (urlEl) {
if (urlVal) {
urlEl.innerHTML = `<a href="${urlVal}" target="_blank" class="text-primary underline">${urlVal}</a>`;
} else {
urlEl.textContent = '-';
}
}
const statusValue = app?.Status || app?.status;
document.getElementById('viewAppStatus').textContent = statusValue === 'online' ? 'Online' : 'Offline';
document.getElementById('viewAppModal').classList.add('open');
});
});
// Delete App listeners - show confirmation modal
document.querySelectorAll('.delete-app').forEach(btn => {
btn.addEventListener('click', (e) => {
const appId = Number(btn.dataset.appId);
const app = this.applications.find(a => a.AppId === appId);
this.pendingDeleteAppId = appId;
document.getElementById('deleteAppName').textContent = app?.Name || '';
document.getElementById('deleteAppModal').classList.add('open');
});
});
// Confirm Delete App
document.querySelectorAll('.confirm-delete-app').forEach(btn => {
btn.addEventListener('click', () => {
if (this.pendingDeleteAppId !== undefined) {
fetch(`${this.apiBase}/applications/${this.pendingDeleteAppId}`, { method: 'DELETE' })
.then(res => res.json())
.then(data => {
if (data.success) {
this.notifySuccess('Application deleted successfully');
this.closeModals();
this.refreshApplicationsUI();
} else {
this.notifyFailure(data.message || 'Delete application failed');
}
})
.catch(err => {
console.error(err);
this.notifyFailure('Delete application failed');
});
}
});
});
// Edit App listeners
document.querySelectorAll('.edit-app').forEach(btn => {
btn.addEventListener('click', (e) => {
const appId = Number(btn.dataset.appId);
const app = this.applications.find(a => a.AppId === appId);
document.getElementById('appName').value = app?.Name || '';
document.getElementById('appType').value = app?.Type || '';
document.getElementById('appStatus').value = app?.Status || 'online';
document.getElementById('appDescription').value = app?.Description || '';
document.getElementById('appIcon').value = app?.Icon || app?.icon || '';
document.getElementById('appUrl').value = app?.Url || app?.url || '';
this.editingAppId = app?.AppId;
this.closeModals();
this.openAppModal();
});
});
// Edit App from View modal
document.querySelectorAll('.edit-app-from-view').forEach(btn => {
btn.addEventListener('click', () => {
const appId = this.currentViewAppId;
const app = this.applications.find(a => a.AppId === appId);
document.getElementById('appName').value = app?.Name || '';
document.getElementById('appType').value = app?.Type || '';
document.getElementById('appStatus').value = app?.Status || 'online';
document.getElementById('appDescription').value = app?.Description || '';
document.getElementById('appIcon').value = app?.Icon || app?.icon || '';
document.getElementById('appUrl').value = app?.Url || '';
this.editingAppId = app?.AppId;
this.closeModals();
this.openAppModal();
});
});
}
setupAddButtonListeners() {
// Add Account button
document.querySelectorAll('#addAccountBtn').forEach(btn => {
btn.addEventListener('click', () => {
this.editingAccountId = undefined;
this.pendingAccountAppId = undefined;
this.openAccountModal();
});
});
// Add Application button
document.querySelectorAll('#addAppBtn').forEach(btn => {
btn.addEventListener('click', () => {
this.editingAppId = undefined;
this.openAppModal();
});
});
}
setupFilters() {
const serviceFilter = document.getElementById('serviceFilter');
if (serviceFilter) {
serviceFilter.value = this.accountServiceFilter || '';
serviceFilter.addEventListener('change', (e) => {
this.accountServiceFilter = e.target.value;
this.renderAccountsTableBody();
});
}
const accountSearch = document.getElementById('accountSearch');
if (accountSearch) {
accountSearch.value = this.accountSearchTerm;
const handleAccountSearch = event => {
this.accountSearchTerm = event.target.value.toLowerCase();
this.renderAccountsTableBody();
};
accountSearch.addEventListener('input', handleAccountSearch);
// Restore focus/selection after renders to avoid typing interruptions
accountSearch.addEventListener('focus', () => {
accountSearch.dataset.focused = 'true';
});
accountSearch.addEventListener('blur', () => {
accountSearch.dataset.focused = 'false';
});
}
const appSearch = document.getElementById('appSearch');
if (appSearch) {
appSearch.value = this.applicationSearchTerm;
const handleApplicationSearch = event => {
this.applicationSearchTerm = event.target.value.toLowerCase();
this.renderApplicationsTableBody();
};
appSearch.addEventListener('input', handleApplicationSearch);
// Restore focus/selection after renders to avoid typing interruptions
appSearch.addEventListener('focus', () => {
appSearch.dataset.focused = 'true';
});
appSearch.addEventListener('blur', () => {
appSearch.dataset.focused = 'false';
});
}
}
async handleAccountSubmit(e) {
e.preventDefault();
const accountForm = document.getElementById('accountForm');
const userId = this.getUserId();
const appId = Number(accountForm?.querySelector('#accountService')?.value || 0);
const accountUsername = (accountForm?.querySelector('#accountUsername')?.value || '').trim();
const accountPassword = (accountForm?.querySelector('#accountPassword')?.value || '').trim();
const accountEmail = ((accountForm?.querySelector('#accountOwner')?.value || '').trim()) || this.currentUser?.Username || this.currentUser?.username || '';
if (!accountForm) {
this.notifyFailure('Account form not found.');
return;
}
if (!userId) {
this.notifyFailure('User is not authenticated. Please login again.');
return;
}
if (!appId) {
this.notifyWarning('Please select a service.');
return;
}
if (!accountUsername) {
this.notifyWarning('Please enter a username.');
return;
}
if (!accountPassword) {
this.notifyWarning('Please enter a password.');
return;
}
const payload = {
userId,
appId,
accountUsername,
accountPassword,
email: accountEmail,
accessLevel: 'user',
notes: ''
};
const isEdit = this.editingAccountId !== undefined;
const url = isEdit ? `${this.apiBase}/accounts/${this.editingAccountId}` : `${this.apiBase}/accounts`;
const method = isEdit ? 'PUT' : 'POST';
fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
}).then(res => res.json()).then(data => {
if (data.success) {
this.editingAccountId = undefined;
this.pendingAccountAppId = undefined;
this.notifySuccess(isEdit ? 'Account updated successfully' : 'Account created successfully');
this.closeModals();
this.refreshAccountsUI();
} else {
this.notifyFailure(data.message || 'Save account failed');
}
}).catch(err => {
console.error(err);
this.notifyFailure('Save account failed');
});
}
async refreshAccountsUI() {
await this.fetchAccounts();
if (this.currentPage === 'accounts') {
this.renderView('accounts');
}
}
async handleAppSubmit(e) {
e.preventDefault();
const payload = {
name: document.getElementById('appName').value,
type: document.getElementById('appType').value,
status: document.getElementById('appStatus').value,
icon: (document.getElementById('appIcon')?.value || 'apps').trim() || 'apps',
description: document.getElementById('appDescription')?.value || '',
url: (document.getElementById('appUrl')?.value || '').trim()
};
const isEdit = this.editingAppId !== undefined;
const url = isEdit ? `${this.apiBase}/applications/${this.editingAppId}` : `${this.apiBase}/applications`;
const method = isEdit ? 'PUT' : 'POST';
fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
}).then(res => res.json()).then(data => {
if (data.success) {
this.editingAppId = undefined;
this.notifySuccess(isEdit ? 'Application updated successfully' : 'Application created successfully');
this.closeModals();
this.refreshApplicationsUI();
} else {
this.notifyFailure(data.message || 'Save application failed');
}
}).catch(err => {
console.error(err);
this.notifyFailure('Save application failed');
});
}
async refreshApplicationsUI() {
await this.fetchApplications();
if (this.currentPage === 'applications') {
this.renderView('applications');
}
}
openAccountModal() {
// Refresh service options so newly added applications appear
const serviceSelect = document.getElementById('accountService');
if (serviceSelect) {
serviceSelect.innerHTML = `<option value="">Select a service</option>` +
this.applications.map(app => `<option value="${app.AppId}">${app.Name}</option>`).join('');
if (this.editingAccountId !== undefined && this.pendingAccountAppId) {
serviceSelect.value = this.pendingAccountAppId;
}
}
if (this.editingAccountId === undefined) {
const form = document.getElementById('accountForm');
if (form) {
const serviceSelect = form.querySelector('#accountService');
const ownerInput = form.querySelector('#accountOwner');
const userInput = form.querySelector('#accountUsername');
const passInput = form.querySelector('#accountPassword');
if (serviceSelect) serviceSelect.value = '';
if (ownerInput) ownerInput.value = this.currentUser?.Username || this.currentUser?.username || '';
if (userInput) userInput.value = '';
if (passInput) passInput.value = '';
}
}
document.getElementById('accountModal').classList.add('open');
}
openAppModal() {
if (this.editingAppId === undefined) {
document.getElementById('appName').value = '';
document.getElementById('appType').value = '';
document.getElementById('appStatus').value = 'online';
const iconInput = document.getElementById('appIcon');
const desc = document.getElementById('appDescription');
const url = document.getElementById('appUrl');
if (iconInput) iconInput.value = '';
if (desc) desc.value = '';
if (url) url.value = '';
}
document.getElementById('appModal').classList.add('open');
}
closeModals() {
document.querySelectorAll('.modal-backdrop').forEach(modal => {
modal.classList.remove('open');
});
}
loadFromStorage(key) {
const data = localStorage.getItem(key);
return data ? JSON.parse(data) : null;
}
saveToStorage(key, data) {
localStorage.setItem(key, JSON.stringify(data));
}
}
// Global modal close functions
function closeAllModals() {
document.querySelectorAll('.modal-backdrop').forEach(modal => {
modal.classList.remove('open');
});
}
function closeAccountModal() {
document.getElementById('accountModal').classList.remove('open');
}
function closeViewAccountModal() {
document.getElementById('viewAccountModal').classList.remove('open');
}
function closeDeleteAccountModal() {
document.getElementById('deleteAccountModal').classList.remove('open');
}
function closeAppModal() {
document.getElementById('appModal').classList.remove('open');
}
function closeViewAppModal() {
document.getElementById('viewAppModal').classList.remove('open');
}
function closeDeleteAppModal() {
document.getElementById('deleteAppModal').classList.remove('open');
}
// Initialize app when DOM is ready
let app;
document.addEventListener('DOMContentLoaded', () => {
app = new AccountManager();
});