confirm email

This commit is contained in:
2026-04-02 15:23:32 +07:00
parent 6c2e89dc93
commit 5a7bf191d0
11 changed files with 893 additions and 39 deletions

View File

@@ -94,6 +94,19 @@ class AccountManager {
return this.getCurrentUserRole() === 'admin';
}
getAuthHeaders(includeJson = false) {
const headers = {
'x-user-id': String(this.getUserId()),
'x-user-role': this.getCurrentUserRole()
};
if (includeJson) {
headers['Content-Type'] = 'application/json';
}
return headers;
}
async init() {
await this.fetchApplications();
await this.fetchAccounts();
@@ -282,6 +295,11 @@ class AccountManager {
logoutBtn.addEventListener('click', () => this.handleLogout());
}
const profileBtn = document.getElementById('profileBtn');
if (profileBtn) {
profileBtn.addEventListener('click', () => this.openProfileModal());
}
// Update account display
this.updateAccountDisplay();
@@ -1247,6 +1265,218 @@ class AccountManager {
});
}
async openProfileModal() {
try {
const response = await fetch(`${this.apiBase}/users/me`, {
headers: this.getAuthHeaders(false)
});
const data = await response.json();
if (!data.success || !data.data) {
this.notifyFailure(data.message || 'Cannot load profile');
return;
}
this.renderProfileModal(data.data);
} catch (err) {
console.error(err);
this.notifyFailure('Cannot load profile');
}
}
renderProfileModal(profile) {
const isVerified = profile?.EmailVerified === true || profile?.EmailVerified === 1;
const html = `
<div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50 modal-backdrop open" id="profileModal" style="display:flex;align-items:center;justify-content:center;padding:16px;">
<div class="bg-white dark:bg-slate-900 rounded-lg p-6 modal-content" style="width:min(720px,calc(100vw - 32px));max-height:calc(100vh - 32px);overflow:auto;">
<h2 class="text-xl font-bold text-slate-900 dark:text-slate-50">My Profile</h2>
<p class="text-xs text-slate-500 mt-1 mb-4">Update personal info and password in one place.</p>
<form id="profileForm" class="space-y-5">
<div class="rounded-xl border border-outline-variant/20 bg-surface-container-low/40 dark:bg-slate-800/40 p-4 space-y-3">
<div>
<label class="block text-sm font-medium mb-1">Username</label>
<input type="text" value="${profile?.Username || ''}" readonly class="w-full px-3 py-2 border border-outline-variant/30 rounded-lg bg-surface-container-low dark:bg-slate-800 opacity-80">
</div>
<div>
<label class="block text-sm font-medium mb-1">Full Name</label>
<input type="text" id="profileFullName" value="${profile?.FullName || ''}" class="w-full px-3 py-2 border border-outline-variant/30 rounded-lg bg-white dark:bg-slate-900" required>
</div>
<div>
<label class="block text-sm font-medium mb-1">Email</label>
<input type="email" id="profileEmail" value="${profile?.Email || ''}" class="w-full px-3 py-2 border border-outline-variant/30 rounded-lg bg-white dark:bg-slate-900" required>
<p class="text-xs mt-1 ${isVerified ? 'text-green-600' : 'text-amber-600'}">
${isVerified ? 'Email is confirmed' : 'Email is not confirmed yet'}
</p>
</div>
</div>
<div class="rounded-xl border border-outline-variant/20 bg-surface-container-low/40 dark:bg-slate-800/40 p-4">
<label class="block text-sm font-semibold mb-3">Change Password</label>
<div class="space-y-3">
<div>
<label class="block text-xs font-semibold text-slate-600 dark:text-slate-300 mb-1">Current password</label>
<div class="flex items-center gap-2">
<input type="password" id="profileCurrentPassword" placeholder="Enter current password" 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="profileCurrentPassword" 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 current password">
<span class="material-symbols-outlined text-base" id="profileCurrentPasswordIcon">visibility</span>
</button>
</div>
</div>
<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">
<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>
</div>
</div>
<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">
<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>
</div>
</div>
<p class="text-xs text-slate-500">Leave password fields empty if you only want to update profile info.</p>
</div>
</div>
<div class="flex gap-3">
<button type="button" onclick="closeProfileModal()" class="flex-1 px-4 py-2 bg-outline/10 hover:bg-outline/20 rounded-lg transition-colors">Cancel</button>
<button type="submit" class="flex-1 px-4 py-2 bg-primary hover:bg-primary-dim text-on-primary font-bold rounded-lg transition-colors">Save</button>
</div>
</form>
</div>
</div>
`;
const containerId = 'profileModalContainer';
let container = document.getElementById(containerId);
if (!container) {
container = document.createElement('div');
container.id = containerId;
document.body.appendChild(container);
}
container.innerHTML = html;
const form = document.getElementById('profileForm');
if (form) {
form.addEventListener('submit', (e) => this.saveProfile(e));
}
this.setupProfilePasswordToggles();
const modal = document.getElementById('profileModal');
if (modal) {
modal.addEventListener('click', (e) => {
if (e.target === modal) {
closeProfileModal();
}
});
}
}
setupProfilePasswordToggles() {
document.querySelectorAll('[data-password-toggle]').forEach((toggleBtn) => {
if (toggleBtn.dataset.bound === 'true') {
return;
}
toggleBtn.addEventListener('click', () => {
const inputId = toggleBtn.dataset.passwordToggle;
if (!inputId) return;
const input = document.getElementById(inputId);
const icon = document.getElementById(`${inputId}Icon`);
if (!input) return;
const isHidden = input.type === 'password';
input.type = isHidden ? 'text' : 'password';
if (icon) {
icon.textContent = isHidden ? 'visibility_off' : 'visibility';
}
});
toggleBtn.dataset.bound = 'true';
});
}
async saveProfile(e) {
e.preventDefault();
const fullname = document.getElementById('profileFullName')?.value.trim() || '';
const email = document.getElementById('profileEmail')?.value.trim() || '';
const currentPassword = document.getElementById('profileCurrentPassword')?.value || '';
const newPassword = document.getElementById('profileNewPassword')?.value || '';
const confirmPassword = document.getElementById('profileConfirmPassword')?.value || '';
if (!fullname || !email) {
this.notifyFailure('Full name and email are required');
return;
}
if (newPassword && newPassword !== confirmPassword) {
this.notifyFailure('New password and confirm password do not match');
return;
}
if (newPassword && !currentPassword) {
this.notifyFailure('Current password is required to change password');
return;
}
try {
const response = await fetch(`${this.apiBase}/users/me`, {
method: 'PUT',
headers: this.getAuthHeaders(true),
body: JSON.stringify({
fullname,
email,
currentPassword,
newPassword
})
});
const data = await response.json();
if (!data.success) {
this.notifyFailure(data.message || 'Update profile failed');
return;
}
if (data.user) {
this.currentUser = {
...this.currentUser,
...data.user,
role: data.user.role || data.user.Role || this.currentUser.role || this.currentUser.Role
};
this.saveToStorage('currentUser', this.currentUser);
this.updateAccountDisplay();
}
closeProfileModal();
this.notifySuccess(data.message || 'Profile updated');
if (data.verificationRequired && data.emailSent === false) {
if (data.verificationPreviewUrl) {
this.notifyWarning(`Email confirmation link (dev): ${data.verificationPreviewUrl}`);
} else {
this.notifyWarning('Email changed but verification email could not be sent.');
}
}
} catch (err) {
console.error(err);
this.notifyFailure('Update profile failed');
}
}
loadFromStorage(key) {
const data = localStorage.getItem(key);
return data ? JSON.parse(data) : null;
@@ -1618,7 +1848,7 @@ class AccountManager {
try {
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json', 'x-user-role': this.getCurrentUserRole() },
headers: this.getAuthHeaders(true),
body: JSON.stringify(payload)
});
@@ -1643,7 +1873,7 @@ class AccountManager {
async viewUserDetails(userId) {
try {
const response = await fetch(`${this.apiBase}/users/${userId}`, {
headers: { 'x-user-role': this.getCurrentUserRole() }
headers: this.getAuthHeaders(false)
});
const data = await response.json();
@@ -1804,7 +2034,7 @@ class AccountManager {
try {
const response = await fetch(`${this.apiBase}/users/${userId}`, {
method: 'DELETE',
headers: { 'x-user-role': this.getCurrentUserRole() }
headers: this.getAuthHeaders(false)
});
const data = await response.json();
@@ -1873,6 +2103,13 @@ function closeUserDetailsModal() {
}
}
function closeProfileModal() {
const profileContainer = document.getElementById('profileModalContainer');
if (profileContainer) {
profileContainer.innerHTML = '';
}
}
// Initialize app when DOM is ready
let app;
document.addEventListener('DOMContentLoaded', () => {