1import { WebComponent } from './Base/WebComponent.js'; 2 3/** 4 * @class UserSessionsElement 5 * @extends WebComponent 6 * 7 * @classdesc Paginated active-session table with per-row revoke and revoke-all. Works in two modes: 8 * 9 * User mode (mode="user"): auto-fetches on connect; reads current-selector attribute 10 * to badge the current device as "Current" instead of showing 11 * the revoke icon. 12 * Admin mode (mode="admin"): waits for loadForUser(acct_id); no "Current" badge; both 13 * single-revoke and revoke-all require an inline confirmation. 14 * 15 * Attributes: 16 * mode "user" (default) | "admin" 17 * current-selector Refresh token selector for the active device (user mode only) 18 * 19 * Methods: 20 * loadForUser(acct_id) Resets state and fetches sessions for the given user (admin mode) 21 */ 22class UserSessionsElement extends WebComponent { 23 constructor() { 24 super(); 25 this.loadBaseCSS(); 26 this.setState({ 27 status: 'idle', 28 rows: [], 29 total: 0, 30 totalPages: 1, 31 page: 1, 32 errorMsg: '', 33 acct_id: null, 34 pendingRevoke: null, 35 }); 36 } 37 38 get mode() { return this.getAttribute('mode') || 'user'; } 39 get currentSelector() { return this.getAttribute('current-selector') || ''; } 40 41 connectedCallback() { 42 super.connectedCallback(); 43 if (this.mode === 'user') { 44 this._fetch(1); 45 } 46 } 47 48 /** 49 * Resets state for a new user and fetches their sessions. 50 * @param {string|number} acct_id 51 */ 52 loadForUser(acct_id) { 53 this.setState({ 54 status: 'loading', rows: [], total: 0, totalPages: 1, page: 1, 55 errorMsg: '', acct_id, pendingRevoke: null, 56 }); 57 this._fetch(1, acct_id); 58 } 59 60 async _fetch(page, acct_id) { 61 const target = acct_id !== undefined ? acct_id : this.state.acct_id; 62 this.setState({ status: 'loading', page, pendingRevoke: null }); 63 try { 64 const formData = new FormData(); 65 formData.append('limit', '10'); 66 formData.append('page', String(page)); 67 if (this.mode === 'admin') { 68 formData.append('post_type', 'get_user_sessions'); 69 formData.append('user_acct_id', String(target)); 70 } else { 71 formData.append('post_type', 'get_sessions'); 72 } 73 const endpoint = this.mode === 'admin' 74 ? '/funcs/admin/test-2017-12-29.php' 75 : '/funcs/common/UserSessionRoute.php'; 76 const res = await fetch(endpoint, { method: 'POST', body: formData }); 77 if (!res.ok) throw new Error('Server error (' + res.status + ')'); 78 const data = await res.json(); 79 if (!data.success) throw new Error(data.err_msg || 'Failed to load sessions'); 80 this.setState({ 81 status: 'loaded', rows: data.rows, total: data.total, 82 totalPages: data.total_pages, page, errorMsg: '', 83 }); 84 } catch(err) { 85 this.setState({ status: 'error', errorMsg: err.message }); 86 } 87 } 88 89 async _doRevoke(selector) { 90 const { page, acct_id } = this.state; 91 this.setState({ pendingRevoke: null, status: 'loading' }); 92 try { 93 const formData = new FormData(); 94 formData.append('selector', selector); 95 if (this.mode === 'admin') { 96 formData.append('post_type', 'revoke_user_session'); 97 formData.append('user_acct_id', String(acct_id)); 98 } else { 99 formData.append('post_type', 'revoke_session'); 100 } 101 const endpoint = this.mode === 'admin' 102 ? '/funcs/admin/test-2017-12-29.php' 103 : '/funcs/common/UserSessionRoute.php'; 104 const res = await fetch(endpoint, { method: 'POST', body: formData }); 105 if (!res.ok) throw new Error('Server error (' + res.status + ')'); 106 const data = await res.json(); 107 if (!data.success) throw new Error(data.err_msg || 'Failed to revoke session'); 108 this._fetch(page, acct_id); 109 } catch(err) { 110 this.setState({ status: 'error', errorMsg: err.message }); 111 } 112 } 113 114 async _doRevokeAll() { 115 const { acct_id } = this.state; 116 this.setState({ pendingRevoke: null, status: 'loading' }); 117 try { 118 const formData = new FormData(); 119 if (this.mode === 'admin') { 120 formData.append('post_type', 'revoke_all_user_sessions'); 121 formData.append('user_acct_id', String(acct_id)); 122 } else { 123 formData.append('post_type', 'revoke_all_sessions'); 124 } 125 const endpoint = this.mode === 'admin' 126 ? '/funcs/admin/test-2017-12-29.php' 127 : '/funcs/common/UserSessionRoute.php'; 128 const res = await fetch(endpoint, { method: 'POST', body: formData }); 129 if (!res.ok) throw new Error('Server error (' + res.status + ')'); 130 const data = await res.json(); 131 if (!data.success) throw new Error(data.err_msg || 'Failed to revoke all sessions'); 132 this._fetch(1, acct_id); 133 } catch(err) { 134 this.setState({ status: 'error', errorMsg: err.message }); 135 } 136 } 137 138 _formatDate(ts) { 139 const d = new Date(Number(ts) * 1000); 140 return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) 141 + ' ' + d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', timeZoneName: 'short' }); 142 } 143 144 #trashIcon() { 145 return '<svg width="14" height="14" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">' 146 + '<path d="M6.66667 12C6.84348 12 7.01305 11.9298 7.13807 11.8047C7.2631 11.6797 7.33333 11.5101 7.33333 11.3333V7.33333C7.33333 7.15652 7.2631 6.98695 7.13807 6.86193C7.01305 6.73691 6.84348 6.66667 6.66667 6.66667C6.48986 6.66667 6.32029 6.73691 6.19526 6.86193C6.07024 6.98695 6 7.15652 6 7.33333V11.3333C6 11.5101 6.07024 11.6797 6.19526 11.8047C6.32029 11.9298 6.48986 12 6.66667 12ZM13.3333 4H10.6667V3.33333C10.6667 2.8029 10.456 2.29419 10.0809 1.91912C9.70581 1.54405 9.1971 1.33333 8.66667 1.33333H7.33333C6.8029 1.33333 6.29419 1.54405 5.91912 1.91912C5.54405 2.29419 5.33333 2.8029 5.33333 3.33333V4H2.66667C2.48986 4 2.32029 4.07024 2.19526 4.19526C2.07024 4.32029 2 4.48986 2 4.66667C2 4.84348 2.07024 5.01305 2.19526 5.13807C2.32029 5.2631 2.48986 5.33333 2.66667 5.33333H3.33333V12.6667C3.33333 13.1971 3.54405 13.7058 3.91912 14.0809C4.29419 14.456 4.8029 14.6667 5.33333 14.6667H10.6667C11.1971 14.6667 11.7058 14.456 12.0809 14.0809C12.456 13.7058 12.6667 13.1971 12.6667 12.6667V5.33333H13.3333C13.5101 5.33333 13.6797 5.2631 13.8047 5.13807C13.9298 5.01305 14 4.84348 14 4.66667C14 4.48986 13.9298 4.32029 13.8047 4.19526C13.6797 4.07024 13.5101 4 13.3333 4ZM6.66667 3.33333C6.66667 3.15652 6.7369 2.98695 6.86193 2.86193C6.98695 2.7369 7.15652 2.66667 7.33333 2.66667H8.66667C8.84348 2.66667 9.01305 2.7369 9.13807 2.86193C9.2631 2.98695 9.33333 3.15652 9.33333 3.33333V4H6.66667V3.33333ZM11.3333 12.6667C11.3333 12.8435 11.2631 13.013 11.1381 13.1381C11.013 13.2631 10.8435 13.3333 10.6667 13.3333H5.33333C5.15652 13.3333 4.98695 13.2631 4.86193 13.1381C4.7369 13.013 4.66667 12.8435 4.66667 12.6667V5.33333H11.3333V12.6667ZM9.33333 12C9.51014 12 9.67971 11.9298 9.80474 11.8047C9.92976 11.6797 10 11.5101 10 11.3333V7.33333C10 7.15652 9.92976 6.98695 9.80474 6.86193C9.67971 6.73691 9.51014 6.66667 9.33333 6.66667C9.15652 6.66667 8.98695 6.73691 8.86193 6.86193C8.7369 6.98695 8.66667 7.15652 8.66667 7.33333V11.3333C8.66667 11.5101 8.7369 11.6797 8.86193 11.8047C8.98695 11.9298 9.15652 12 9.33333 12Z" fill="#FD5D6B"/>'
147 + '</svg>'; 148 } 149 150 render() { 151 const { status, rows, page, totalPages, errorMsg, pendingRevoke } = this.state; 152 const isUser = this.mode === 'user'; 153 const currentSelector = isUser ? this.currentSelector : ''; 154 155 const styles = '<style>' 156 + ':host{display:block;position:relative;}' 157 + '.material-icons{font-family:\'Material Icons\';font-weight:normal;font-style:normal;font-size:24px;line-height:1;letter-spacing:normal;text-transform:none;display:inline-block;white-space:nowrap;direction:ltr;-webkit-font-smoothing:antialiased;}' 158 + '.confirm-overlay{position:absolute;inset:0;background:rgba(255,255,255,0.93);display:flex;align-items:center;justify-content:center;z-index:10;border-radius:4px;}' 159 + '.confirm-card{background:#fff;border:1px solid #e0e0e0;border-radius:6px;padding:1.25rem 1.5rem;max-width:340px;width:100%;box-shadow:0 2px 12px rgba(0,0,0,0.1);}' 160 + '.confirm-title{margin:0 0 0.5rem;font-size:15px;font-weight:600;color:#222;}' 161 + '.confirm-msg{margin:0 0 1rem;font-size:14px;color:#555;}' 162 + '.confirm-actions{display:flex;gap:0.5rem;justify-content:flex-end;}' 163 + '</style>'; 164 165 let bodyHtml = ''; 166 if (status === 'idle' || status === 'loading') { 167 bodyHtml = '<p style="padding:0.5rem 0; color:#888;">Loading...</p>'; 168 } else if (status === 'error') { 169 bodyHtml = '<p style="padding:0.5rem 0; color:var(--color-red,#c0392b);">' + errorMsg + '</p>'; 170 } else if (!rows.length) { 171 bodyHtml = '<p style="padding:0.5rem 0; color:#888;">No active sessions.</p>'; 172 } else { 173 bodyHtml = '<list-table></list-table>' 174 + '<div style="display:flex;justify-content:flex-end;margin-top:0.75rem;">' 175 + '<button-element variant="negative" class="revoke-all-btn">Revoke all sessions</button-element>' 176 + '</div>'; 177 } 178 179 let overlayHtml = ''; 180 if (pendingRevoke !== null) { 181 const isAll = pendingRevoke.type === 'all'; 182 const title = isAll ? 'Revoke all sessions' : 'Revoke session'; 183 const msg = isAll 184 ? (isUser ? 'This will sign you out on all other devices. Your current session will be preserved.' : 'This will sign the account out on every device.') 185 : 'Revoke this session? That device will be signed out.'; 186 overlayHtml = '<div class="confirm-overlay">' 187 + '<div class="confirm-card">' 188 + '<p class="confirm-title">' + title + '</p>' 189 + '<p class="confirm-msg">' + msg + '</p>' 190 + '<div class="confirm-actions">' 191 + '<button-element class="confirm-cancel-btn" variant="negative-secondary">Cancel</button-element>' 192 + '<button-element class="confirm-proceed-btn" variant="negative">Revoke</button-element>' 193 + '</div>' 194 + '</div>' 195 + '</div>'; 196 } 197 198 this.loadTemplate(styles + bodyHtml + overlayHtml); 199 200 if (status === 'loaded' && rows.length) { 201 const listTable = this.shadowRoot.querySelector('list-table'); 202 const trashIcon = this.#trashIcon(); 203 204 listTable.setCheckbox(false); 205 listTable.setActions(false); 206 listTable.setColumns([ 207 { 208 key: 'client_summary', 209 label: 'Device', 210 render: (row) => { 211 const isMirror = !isUser && row.is_mirror == 1; 212 const mirrorBadge = isMirror 213 ? '<span style="background:#fff3cd;color:#856404;font-size:10px;font-weight:600;padding:1px 5px;border-radius:3px;margin-left:5px;vertical-align:middle;">MIRROR</span>' 214 : ''; 215 return '<span style="font-size:12px;">' + (row.client_summary || '-') + mirrorBadge + '</span>'; 216 }, 217 }, 218 { 219 key: 'ip', 220 label: 'IP', 221 render: (row) => '<span style="color:#919197;font-size:12px;">' + html_entities(row.ip || '-') + '</span>', 222 }, 223 { 224 key: 'timestamp', 225 label: 'Started', 226 render: (row) => '<span style="font-size:12px;white-space:nowrap;">' + this._formatDate(row.timestamp) + '</span>', 227 }, 228 { 229 key: 'expiration', 230 label: 'Expires', 231 render: (row) => '<span style="font-size:12px;white-space:nowrap;">' + this._formatDate(row.expiration) + '</span>', 232 }, 233 { 234 key: 'action', 235 label: '', 236 render: (row) => { 237 const isCurrent = isUser && row.selector === currentSelector; 238 if (isCurrent) return '<span class="quiet" style="font-size:12px;">Current</span>'; 239 return '<a href="#" class="session-revoke-btn" data-selector="' + html_entities(row.selector) + '" style="display:inline-flex;align-items:center;padding:4px;opacity:0.75;" title="Revoke session">' + trashIcon + '</a>'; 240 }, 241 }, 242 ]); 243 244 if (totalPages > 1) { 245 listTable.setPagination({ currentPage: page, totalPages, rowsPerPage: 10 }); 246 } 247 248 listTable.setData(rows); 249 250 this.shadowRoot.querySelectorAll('.session-revoke-btn').forEach((btn) => { 251 btn.addEventListener('click', (e) => { 252 e.preventDefault(); 253 this.setState({ pendingRevoke: { type: 'single', selector: btn.dataset.selector } }); 254 }); 255 }); 256 257 const revokeAllBtn = this.shadowRoot.querySelector('.revoke-all-btn'); 258 if (revokeAllBtn) { 259 revokeAllBtn.addEventListener('click', () => { 260 this.setState({ pendingRevoke: { type: 'all' } }); 261 }); 262 } 263 264 listTable.addEventListener('page-change', (e) => { 265 this._fetch(e.detail.page, this.state.acct_id); 266 }); 267 } 268 269 const cancelBtn = this.shadowRoot.querySelector('.confirm-cancel-btn'); 270 const proceedBtn = this.shadowRoot.querySelector('.confirm-proceed-btn'); 271 if (cancelBtn) { 272 cancelBtn.addEventListener('click', () => { 273 this.setState({ pendingRevoke: null }); 274 }); 275 } 276 if (proceedBtn) { 277 proceedBtn.addEventListener('click', () => { 278 const pending = this.state.pendingRevoke; 279 if (!pending) return; 280 if (pending.type === 'all') { 281 this._doRevokeAll(); 282 } else { 283 this._doRevoke(pending.selector); 284 } 285 }); 286 } 287 } 288} 289 290customElements.define('user-sessions-element', UserSessionsElement);
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.