mirror of
https://github.com/ghostersk/gowebmail.git
synced 2026-09-13 23:30:37 +01:00
fix received message - read status
This commit is contained in:
@@ -273,6 +273,7 @@ func main() {
|
||||
api.HandleFunc("/accounts/sort-order", h.API.SetAccountSortOrder).Methods("PUT")
|
||||
api.HandleFunc("/ui-prefs", h.API.GetUIPrefs).Methods("GET")
|
||||
api.HandleFunc("/ui-prefs", h.API.SetUIPrefs).Methods("PUT")
|
||||
api.HandleFunc("/login-history", h.API.ListMyLoginHistory).Methods("GET")
|
||||
|
||||
// Search
|
||||
api.HandleFunc("/search", h.API.Search).Methods("GET")
|
||||
|
||||
@@ -925,6 +925,67 @@ func (d *DB) ListAuditLogs(page, pageSize int, eventFilter string) (*models.Audi
|
||||
}, rows.Err()
|
||||
}
|
||||
|
||||
// ListLoginHistory returns a user's own login attempts (success + failure) — used by the
|
||||
// Settings > Security "Login History" viewer. Always scoped to userID so a user can only ever
|
||||
// see their own attempts, unlike the admin-only ListAuditLogs above. success nil means both;
|
||||
// true/false filters to just successful/failed attempts. ip is a substring match. dateFrom/
|
||||
// dateTo are inclusive "YYYY-MM-DD HH:MM:SS" bounds (caller pads a plain date to a full day).
|
||||
func (d *DB) ListLoginHistory(userID int64, page, pageSize int, dateFrom, dateTo string, success *bool, ip string, sortAsc bool) (*models.AuditPage, error) {
|
||||
offset := (page - 1) * pageSize
|
||||
where := " WHERE a.user_id=? AND a.event IN ('login','login_fail')"
|
||||
args := []interface{}{userID}
|
||||
if success != nil {
|
||||
if *success {
|
||||
where += " AND a.event='login'"
|
||||
} else {
|
||||
where += " AND a.event='login_fail'"
|
||||
}
|
||||
}
|
||||
if dateFrom != "" {
|
||||
where += " AND a.created_at>=?"
|
||||
args = append(args, dateFrom)
|
||||
}
|
||||
if dateTo != "" {
|
||||
where += " AND a.created_at<=?"
|
||||
args = append(args, dateTo)
|
||||
}
|
||||
if ip != "" {
|
||||
where += " AND a.ip_address LIKE ?"
|
||||
args = append(args, "%"+ip+"%")
|
||||
}
|
||||
|
||||
var total int
|
||||
d.sql.QueryRow(`SELECT COUNT(*) FROM audit_log a`+where, args...).Scan(&total)
|
||||
|
||||
order := "DESC"
|
||||
if sortAsc {
|
||||
order = "ASC"
|
||||
}
|
||||
args = append(args, pageSize, offset)
|
||||
rows, err := d.sql.Query(`
|
||||
SELECT a.id, a.event, a.detail, a.ip_address, a.user_agent, a.created_at
|
||||
FROM audit_log a`+where+`
|
||||
ORDER BY a.created_at `+order+` LIMIT ? OFFSET ?`, args...,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var logs []models.AuditLog
|
||||
for rows.Next() {
|
||||
l := models.AuditLog{UserID: &userID}
|
||||
if err := rows.Scan(&l.ID, &l.Event, &l.Detail, &l.IPAddress, &l.UserAgent, &l.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
logs = append(logs, l)
|
||||
}
|
||||
return &models.AuditPage{
|
||||
Logs: logs, Total: total, Page: page, PageSize: pageSize,
|
||||
HasMore: offset+len(logs) < total,
|
||||
}, rows.Err()
|
||||
}
|
||||
|
||||
// ---- Email Accounts ----
|
||||
|
||||
func (d *DB) CreateAccount(a *models.EmailAccount) error {
|
||||
|
||||
@@ -463,11 +463,14 @@ func (c *Client) FetchMessages(mailboxName string, days int) ([]*gomailModels.Me
|
||||
}
|
||||
|
||||
func (c *Client) fetchBySeqSet(seqSet *imap.SeqSet) ([]*gomailModels.Message, error) {
|
||||
// Fetch FetchRFC822 (full raw message) so we can properly parse MIME
|
||||
// Full raw message, needed for proper MIME parsing — fetched via BODY.PEEK[] (not the
|
||||
// plain RFC822/BODY[] item) so reading it during a background sync doesn't implicitly
|
||||
// mark the message \Seen on the server before the user has actually opened it.
|
||||
peekBody := &imap.BodySectionName{Peek: true}
|
||||
items := []imap.FetchItem{
|
||||
imap.FetchUid, imap.FetchEnvelope,
|
||||
imap.FetchFlags, imap.FetchBodyStructure,
|
||||
imap.FetchRFC822, // full message including headers – needed for proper MIME parsing
|
||||
peekBody.FetchItem(),
|
||||
}
|
||||
|
||||
ch := make(chan *imap.Message, 64)
|
||||
@@ -491,10 +494,11 @@ func (c *Client) fetchBySeqSet(seqSet *imap.SeqSet) ([]*gomailModels.Message, er
|
||||
|
||||
// fetchByUIDSet fetches messages by UID set (used when UIDs are returned from UidSearch).
|
||||
func (c *Client) fetchByUIDSet(seqSet *imap.SeqSet) ([]*gomailModels.Message, error) {
|
||||
peekBody := &imap.BodySectionName{Peek: true} // see fetchBySeqSet — avoids implicitly marking \Seen
|
||||
items := []imap.FetchItem{
|
||||
imap.FetchUid, imap.FetchEnvelope,
|
||||
imap.FetchFlags, imap.FetchBodyStructure,
|
||||
imap.FetchRFC822,
|
||||
peekBody.FetchItem(),
|
||||
}
|
||||
|
||||
ch := make(chan *imap.Message, 64)
|
||||
@@ -1603,10 +1607,11 @@ func (c *Client) FetchNewMessages(mailboxName string, afterUID uint32) ([]*gomai
|
||||
seqSet := new(imap.SeqSet)
|
||||
seqSet.AddRange(afterUID+1, ^uint32(0)) // afterUID+1 to * (max)
|
||||
|
||||
peekBody := &imap.BodySectionName{Peek: true} // see fetchBySeqSet — avoids implicitly marking \Seen
|
||||
items := []imap.FetchItem{
|
||||
imap.FetchUid, imap.FetchEnvelope,
|
||||
imap.FetchFlags, imap.FetchBodyStructure,
|
||||
imap.FetchRFC822,
|
||||
peekBody.FetchItem(),
|
||||
}
|
||||
|
||||
ch := make(chan *imap.Message, 64)
|
||||
|
||||
@@ -1268,6 +1268,46 @@ func (h *APIHandler) Search(w http.ResponseWriter, r *http.Request) {
|
||||
h.writeJSON(w, result)
|
||||
}
|
||||
|
||||
// ---- Login history (per-user, Settings > Security) ----
|
||||
|
||||
// ListMyLoginHistory returns the authenticated user's own login attempts — never any other
|
||||
// user's, unlike the admin-only audit log viewer (AdminHandler.ListAuditLogs).
|
||||
func (h *APIHandler) ListMyLoginHistory(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r)
|
||||
page := queryInt(r, "page", 1)
|
||||
pageSize := queryInt(r, "page_size", 25)
|
||||
if pageSize > 100 {
|
||||
pageSize = 100
|
||||
}
|
||||
|
||||
var success *bool
|
||||
switch r.URL.Query().Get("success") {
|
||||
case "true":
|
||||
b := true
|
||||
success = &b
|
||||
case "false":
|
||||
b := false
|
||||
success = &b
|
||||
}
|
||||
|
||||
var dateFrom, dateTo string
|
||||
if v := r.URL.Query().Get("date_from"); v != "" {
|
||||
dateFrom = v + " 00:00:00"
|
||||
}
|
||||
if v := r.URL.Query().Get("date_to"); v != "" {
|
||||
dateTo = v + " 23:59:59"
|
||||
}
|
||||
ip := r.URL.Query().Get("ip")
|
||||
sortAsc := r.URL.Query().Get("sort") == "asc"
|
||||
|
||||
result, err := h.db.ListLoginHistory(userID, page, pageSize, dateFrom, dateTo, success, ip, sortAsc)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to load login history")
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, result)
|
||||
}
|
||||
|
||||
// ---- Sync interval (per-user) ----
|
||||
|
||||
func (h *APIHandler) GetSyncInterval(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
+107
-8
@@ -2524,6 +2524,7 @@ async function openSettings() {
|
||||
loadSyncInterval();
|
||||
loadRemoteImagePolicy();
|
||||
loadRemoteWhitelist();
|
||||
loadNotificationSetting();
|
||||
renderMFAPanel();
|
||||
loadIPRules();
|
||||
populateSettingsAccountSelects();
|
||||
@@ -2962,6 +2963,56 @@ function saveRemoteImagePolicy() {
|
||||
if (S.currentMessage) renderMessageDetail(S.currentMessage, false);
|
||||
}
|
||||
|
||||
// ── Notifications (Settings > General) ──────────────────────────────────────
|
||||
function loadNotificationSetting() {
|
||||
const cb = document.getElementById('notifications-toggle');
|
||||
if (cb) cb.checked = uiPrefsGet('notificationsEnabled', false);
|
||||
updateNotificationStatus();
|
||||
}
|
||||
|
||||
function updateNotificationStatus() {
|
||||
const el = document.getElementById('notifications-status');
|
||||
if (!el) return;
|
||||
if (!('Notification' in window)) { el.textContent = 'Not supported in this browser.'; return; }
|
||||
if (Notification.permission === 'denied') { el.textContent = 'Blocked for this site in your browser settings — allow it there, then try again.'; return; }
|
||||
el.textContent = '';
|
||||
}
|
||||
|
||||
// Turning this on is the ONLY place the browser's notification-permission prompt gets
|
||||
// triggered (see startPoller) — never on page load unasked. Turning it off can't revoke a
|
||||
// permission the browser already granted, so it just stops the poller from using it.
|
||||
async function toggleNotifications(enabled) {
|
||||
const cb = document.getElementById('notifications-toggle');
|
||||
if (!enabled) {
|
||||
uiPrefsSet('notificationsEnabled', false);
|
||||
POLLER.notifGranted = false;
|
||||
updateNotificationStatus();
|
||||
return;
|
||||
}
|
||||
if (!('Notification' in window)) {
|
||||
toast('Notifications are not supported in this browser', 'error');
|
||||
if (cb) cb.checked = false;
|
||||
return;
|
||||
}
|
||||
if (Notification.permission === 'denied') {
|
||||
toast('Notifications are blocked for this site in your browser settings', 'error');
|
||||
if (cb) cb.checked = false;
|
||||
updateNotificationStatus();
|
||||
return;
|
||||
}
|
||||
const perm = Notification.permission === 'default' ? await Notification.requestPermission() : Notification.permission;
|
||||
if (perm === 'granted') {
|
||||
uiPrefsSet('notificationsEnabled', true);
|
||||
POLLER.notifGranted = true;
|
||||
toast('Notifications enabled', 'success');
|
||||
} else {
|
||||
if (cb) cb.checked = false;
|
||||
uiPrefsSet('notificationsEnabled', false);
|
||||
toast('Notification permission was not granted', 'error');
|
||||
}
|
||||
updateNotificationStatus();
|
||||
}
|
||||
|
||||
async function loadRemoteWhitelist() {
|
||||
const el = document.getElementById('remote-whitelist-list');
|
||||
const r = await api('GET', '/remote-content-whitelist');
|
||||
@@ -3060,6 +3111,57 @@ async function saveIPRules() {
|
||||
else toast(r?.error || 'Save failed', 'error');
|
||||
}
|
||||
|
||||
// ── Login History (Settings > Security) ─────────────────────────────────────
|
||||
// Server-side filtered/sorted/paginated — always scoped to the logged-in user by the
|
||||
// GET /api/login-history handler itself (never a param this code could tamper with).
|
||||
const LH = { page: 1, hasMore: false };
|
||||
|
||||
function openLoginHistory() {
|
||||
openModal('login-history-modal');
|
||||
loadLoginHistory(1);
|
||||
}
|
||||
|
||||
async function loadLoginHistory(page) {
|
||||
LH.page = page;
|
||||
const params = new URLSearchParams({ page, page_size: 25 });
|
||||
const dateFrom = document.getElementById('lh-date-from').value;
|
||||
const dateTo = document.getElementById('lh-date-to').value;
|
||||
const status = document.getElementById('lh-status').value;
|
||||
const ip = document.getElementById('lh-ip').value.trim();
|
||||
const sort = document.getElementById('lh-sort').value;
|
||||
if (dateFrom) params.set('date_from', dateFrom);
|
||||
if (dateTo) params.set('date_to', dateTo);
|
||||
if (status) params.set('success', status);
|
||||
if (ip) params.set('ip', ip);
|
||||
if (sort) params.set('sort', sort);
|
||||
|
||||
const tbody = document.getElementById('lh-table-body');
|
||||
tbody.innerHTML = '<tr><td colspan="4" style="text-align:center;padding:24px"><span class="spinner-inline"></span></td></tr>';
|
||||
const r = await api('GET', '/login-history?' + params.toString());
|
||||
if (!r) {
|
||||
tbody.innerHTML = '<tr><td colspan="4" style="text-align:center;color:var(--danger);padding:20px">Failed to load login history.</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
LH.hasMore = !!r.has_more;
|
||||
const logs = r.logs || [];
|
||||
tbody.innerHTML = logs.length ? logs.map(l => `
|
||||
<tr>
|
||||
<td style="font-family:monospace;font-size:11px;color:var(--muted)">${new Date(l.created_at).toLocaleString()}</td>
|
||||
<td><span class="badge ${l.event==='login'?'green':'red'}">${l.event==='login'?'Success':'Failed'}</span></td>
|
||||
<td style="font-family:monospace;font-size:11px">${esc(l.ip_address||'—')}</td>
|
||||
<td style="color:var(--muted);font-size:12px">${esc(l.detail||'')}</td>
|
||||
</tr>`).join('') : '<tr><td colspan="4" style="text-align:center;color:var(--muted);padding:24px">No login attempts found.</td></tr>';
|
||||
|
||||
document.getElementById('lh-page-info').textContent = `Page ${page} · ${r.total||0} total`;
|
||||
document.getElementById('lh-prev-btn').disabled = page <= 1;
|
||||
document.getElementById('lh-next-btn').disabled = !LH.hasMore;
|
||||
}
|
||||
|
||||
function loginHistoryPrevPage() { if (LH.page > 1) loadLoginHistory(LH.page - 1); }
|
||||
function loginHistoryNextPage() { if (LH.hasMore) loadLoginHistory(LH.page + 1); }
|
||||
const debouncedLoadLoginHistory = debounce(() => loadLoginHistory(1), 400);
|
||||
|
||||
async function doLogout() { await fetch('/auth/logout',{method:'POST'}); location.href='/auth/login'; }
|
||||
|
||||
// ── Context menu helper ────────────────────────────────────────────────────
|
||||
@@ -3134,14 +3236,11 @@ const POLLER = {
|
||||
};
|
||||
|
||||
async function startPoller() {
|
||||
// Request browser notification permission (non-blocking)
|
||||
if ('Notification' in window && Notification.permission === 'default') {
|
||||
Notification.requestPermission().then(p => {
|
||||
POLLER.notifGranted = p === 'granted';
|
||||
});
|
||||
} else if ('Notification' in window) {
|
||||
POLLER.notifGranted = Notification.permission === 'granted';
|
||||
}
|
||||
// Notification permission is only ever requested from the explicit Settings > General
|
||||
// toggle (toggleNotifications) — never on load — so this just reflects whatever the user
|
||||
// already opted into and the browser already granted.
|
||||
POLLER.notifGranted = uiPrefsGet('notificationsEnabled', false)
|
||||
&& 'Notification' in window && Notification.permission === 'granted';
|
||||
|
||||
POLLER.active = true;
|
||||
schedulePoll();
|
||||
|
||||
+109
-34
@@ -381,6 +381,59 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Login History modal ────────────────────────────────────────────────── -->
|
||||
<div class="modal-overlay" id="login-history-modal" role="dialog" aria-modal="true" aria-hidden="true" aria-labelledby="login-history-modal-title">
|
||||
<div class="modal" style="width:min(1000px,92vw);max-width:none;max-height:90vh;display:flex;flex-direction:column">
|
||||
<h2 id="login-history-modal-title">Login History</h2>
|
||||
<p>Login attempts for your account only.</p>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:10px;align-items:flex-end;margin-bottom:14px">
|
||||
<div class="modal-field" style="margin-bottom:0">
|
||||
<label for="lh-date-from">From</label>
|
||||
<input type="date" id="lh-date-from" onchange="loadLoginHistory(1)">
|
||||
</div>
|
||||
<div class="modal-field" style="margin-bottom:0">
|
||||
<label for="lh-date-to">To</label>
|
||||
<input type="date" id="lh-date-to" onchange="loadLoginHistory(1)">
|
||||
</div>
|
||||
<div class="modal-field" style="margin-bottom:0">
|
||||
<label for="lh-status">Status</label>
|
||||
<select id="lh-status" onchange="loadLoginHistory(1)">
|
||||
<option value="">All</option>
|
||||
<option value="true">Success</option>
|
||||
<option value="false">Failed</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="modal-field" style="margin-bottom:0;flex:1;min-width:160px">
|
||||
<label for="lh-ip">IP contains</label>
|
||||
<input type="text" id="lh-ip" placeholder="e.g. 192.168" oninput="debouncedLoadLoginHistory()">
|
||||
</div>
|
||||
<div class="modal-field" style="margin-bottom:0">
|
||||
<label for="lh-sort">Sort by date</label>
|
||||
<select id="lh-sort" onchange="loadLoginHistory(1)">
|
||||
<option value="desc">Newest first</option>
|
||||
<option value="asc">Oldest first</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div style="flex:1;overflow-y:auto;border:1px solid var(--border);border-radius:8px;min-height:200px">
|
||||
<table class="data-table">
|
||||
<thead><tr><th>Time</th><th>Status</th><th>IP Address</th><th>Detail</th></tr></thead>
|
||||
<tbody id="lh-table-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:12px">
|
||||
<span id="lh-page-info" style="font-size:12px;color:var(--muted)"></span>
|
||||
<div style="display:flex;gap:8px">
|
||||
<button class="btn-secondary" id="lh-prev-btn" onclick="loginHistoryPrevPage()">Previous</button>
|
||||
<button class="btn-secondary" id="lh-next-btn" onclick="loginHistoryNextPage()">Next</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="modal-cancel" onclick="closeModal('login-history-modal')">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Add Account Modal ──────────────────────────────────────────────────── -->
|
||||
<div class="modal-overlay" id="add-account-modal" role="dialog" aria-modal="true" aria-hidden="true" aria-labelledby="add-account-modal-title">
|
||||
<div class="modal">
|
||||
@@ -541,6 +594,8 @@
|
||||
<div style="display:flex;align-items:stretch;min-height:0;flex:1;border-top:1px solid var(--border)">
|
||||
<div class="settings-nav" role="tablist" aria-label="Settings sections">
|
||||
<button data-tab="accounts" class="active" role="tab" aria-selected="true" onclick="showSettingsTab('accounts')">Accounts</button>
|
||||
<button data-tab="general" role="tab" aria-selected="false" onclick="showSettingsTab('general')">General</button>
|
||||
<button data-tab="security" role="tab" aria-selected="false" onclick="showSettingsTab('security')">Security</button>
|
||||
<button data-tab="account" role="tab" aria-selected="false" onclick="showSettingsTab('account')">Profile</button>
|
||||
<button data-tab="rules" role="tab" aria-selected="false" onclick="showSettingsTab('rules')">Rules</button>
|
||||
<button data-tab="signatures" role="tab" aria-selected="false" onclick="showSettingsTab('signatures')">Signatures</button>
|
||||
@@ -560,29 +615,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-panel" data-tab="account" role="tabpanel">
|
||||
<div class="settings-group">
|
||||
<div class="settings-group-title">Profile</div>
|
||||
<div class="modal-field">
|
||||
<label>Username</label>
|
||||
<div style="display:flex;gap:8px">
|
||||
<input type="text" id="profile-username" placeholder="New username" style="flex:1">
|
||||
<button class="btn-primary" onclick="updateProfile('username')">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-field">
|
||||
<label>Email Address</label>
|
||||
<div style="display:flex;gap:8px">
|
||||
<input type="email" id="profile-email" placeholder="New email address" style="flex:1">
|
||||
<button class="btn-primary" onclick="updateProfile('email')">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-field">
|
||||
<label>Current Password <span style="color:var(--muted);font-size:11px">(required to confirm changes)</span></label>
|
||||
<input type="password" id="profile-confirm-pw" placeholder="Enter your current password">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-panel" data-tab="general" role="tabpanel">
|
||||
<div class="settings-group">
|
||||
<div class="settings-group-title">Email Sync</div>
|
||||
<div style="font-size:13px;color:var(--muted);margin-bottom:12px">How often to automatically check all your accounts for new mail.</div>
|
||||
@@ -619,19 +652,17 @@
|
||||
</div>
|
||||
|
||||
<div class="settings-group">
|
||||
<div class="settings-group-title">Change Password</div>
|
||||
<div class="modal-field"><label>Current Password</label><input type="password" id="cur-pw"></div>
|
||||
<div class="modal-field"><label>New Password</label><input type="password" id="new-pw" placeholder="Min. 8 characters"></div>
|
||||
<button class="btn-primary" onclick="changePassword()">Update Password</button>
|
||||
</div>
|
||||
|
||||
<div class="settings-group">
|
||||
<div class="settings-group-title" style="display:flex;align-items:center;gap:10px">
|
||||
Two-Factor Authentication <span id="mfa-badge"></span>
|
||||
</div>
|
||||
<div id="mfa-panel">Loading...</div>
|
||||
<div class="settings-group-title">Notifications</div>
|
||||
<div style="font-size:13px;color:var(--muted);margin-bottom:12px">Show a browser notification when new mail arrives, even while GoWebMail is in a background tab.</div>
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;font-size:13px;color:var(--text)">
|
||||
<input type="checkbox" id="notifications-toggle" onchange="toggleNotifications(this.checked)" style="width:auto">
|
||||
Enable desktop notifications
|
||||
</label>
|
||||
<div id="notifications-status" style="font-size:12px;color:var(--muted);margin-top:8px"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-panel" data-tab="security" role="tabpanel">
|
||||
<div class="settings-group">
|
||||
<div class="settings-group-title">IP Access Rules</div>
|
||||
<div style="font-size:13px;color:var(--muted);margin-bottom:14px">
|
||||
@@ -652,6 +683,50 @@
|
||||
</div>
|
||||
<button class="btn-primary" onclick="saveIPRules()">Save IP Rules</button>
|
||||
</div>
|
||||
|
||||
<div class="settings-group">
|
||||
<div class="settings-group-title">Login History</div>
|
||||
<div style="font-size:13px;color:var(--muted);margin-bottom:12px">View login attempts for your account only — successful and failed, with timestamps and source IPs.</div>
|
||||
<button class="btn-secondary" onclick="openLoginHistory()">View Login History</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-panel" data-tab="account" role="tabpanel">
|
||||
<div class="settings-group">
|
||||
<div class="settings-group-title">Profile</div>
|
||||
<div class="modal-field">
|
||||
<label>Username</label>
|
||||
<div style="display:flex;gap:8px">
|
||||
<input type="text" id="profile-username" placeholder="New username" style="flex:1">
|
||||
<button class="btn-primary" onclick="updateProfile('username')">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-field">
|
||||
<label>Email Address</label>
|
||||
<div style="display:flex;gap:8px">
|
||||
<input type="email" id="profile-email" placeholder="New email address" style="flex:1">
|
||||
<button class="btn-primary" onclick="updateProfile('email')">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-field">
|
||||
<label>Current Password <span style="color:var(--muted);font-size:11px">(required to confirm changes)</span></label>
|
||||
<input type="password" id="profile-confirm-pw" placeholder="Enter your current password">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-group">
|
||||
<div class="settings-group-title">Change Password</div>
|
||||
<div class="modal-field"><label>Current Password</label><input type="password" id="cur-pw"></div>
|
||||
<div class="modal-field"><label>New Password</label><input type="password" id="new-pw" placeholder="Min. 8 characters"></div>
|
||||
<button class="btn-primary" onclick="changePassword()">Update Password</button>
|
||||
</div>
|
||||
|
||||
<div class="settings-group">
|
||||
<div class="settings-group-title" style="display:flex;align-items:center;gap:10px">
|
||||
Two-Factor Authentication <span id="mfa-badge"></span>
|
||||
</div>
|
||||
<div id="mfa-panel">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-panel" data-tab="rules" role="tabpanel">
|
||||
|
||||
Reference in New Issue
Block a user