mbkauthe 4.3.2 → 4.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -112,11 +112,28 @@ export {
112
112
  validateSessionAndRole, authenticate, reloadSessionUser,
113
113
  strictValidateSession, strictValidateSessionAndRole
114
114
  } from "./lib/middleware/auth.js";
115
- export { renderError, getUserContext, renderPage } from "#response.js";
115
+ export {
116
+ sessionConfig,
117
+ corsMiddleware,
118
+ sessionRestorationMiddleware,
119
+ sessionCookieSyncMiddleware
120
+ } from "./lib/middleware/index.js";
121
+ export { validateTokenScope } from "./lib/middleware/scopeValidator.js";
122
+ export { renderError, getUserContext, renderPage, proxycall } from "#response.js";
116
123
  export { dblogin } from "#pool.js";
124
+ export { getLatestVersion } from "./lib/routes/misc.js";
125
+ export { checkTrustedDevice, completeLoginProcess } from "./lib/routes/auth.js";
117
126
  export {
118
127
  ErrorCodes, ErrorMessages, getErrorByCode,
119
128
  createErrorResponse, logError
120
129
  } from "./lib/utils/errors.js";
130
+ export {
131
+ encryptSessionId, decryptSessionId, cachedCookieOptions, cachedClearCookieOptions,
132
+ DEVICE_TRUST_DURATION_DAYS, DEVICE_TRUST_DURATION_MS,
133
+ generateDeviceToken, hashDeviceToken, getDeviceTokenCookieOptions,
134
+ getCookieOptions, getClearCookieOptions, clearSessionCookies,
135
+ readAccountListFromCookie, upsertAccountListCookie, removeAccountFromCookie, clearAccountListCookie
136
+ } from "./lib/config/cookies.js";
137
+ export { hashPassword, hashApiToken } from "./lib/config/security.js";
121
138
  export { mbkautheVar } from "#config.js";
122
139
  export default router;
@@ -148,7 +148,6 @@ export const getDeviceTokenCookieOptions = () => ({
148
148
  export const clearSessionCookies = (res) => {
149
149
  res.clearCookie("mbkauthe.sid", cachedClearCookieOptions);
150
150
  res.clearCookie("sessionId", cachedClearCookieOptions);
151
- res.clearCookie("username", cachedClearCookieOptions);
152
151
  res.clearCookie("fullName", cachedClearCookieOptions);
153
152
  res.clearCookie("device_token", cachedClearCookieOptions);
154
153
  };
@@ -183,7 +182,8 @@ const parseAccountList = (raw, req) => {
183
182
  .map(item => ({
184
183
  sessionId: typeof item.sessionId === 'string' ? item.sessionId : null,
185
184
  username: typeof item.username === 'string' ? item.username : null,
186
- fullName: typeof item.fullName === 'string' ? item.fullName : null
185
+ fullName: typeof item.fullName === 'string' ? item.fullName : null,
186
+ image: typeof item.image === 'string' ? item.image : null
187
187
  }))
188
188
  .filter(item => item.sessionId && item.username)
189
189
  .slice(0, MAX_REMEMBERED_ACCOUNTS);
@@ -196,9 +196,17 @@ const parseAccountList = (raw, req) => {
196
196
  const writeAccountList = (res, list, req) => {
197
197
  const sanitized = Array.isArray(list) ? list.slice(0, MAX_REMEMBERED_ACCOUNTS) : [];
198
198
 
199
+ // Clean and limit fields to safe values (limit image URL length)
200
+ const cleaned = sanitized.map(item => ({
201
+ sessionId: item && item.sessionId ? item.sessionId : null,
202
+ username: item && item.username ? item.username : null,
203
+ fullName: item && item.fullName ? item.fullName : null,
204
+ image: (item && typeof item.image === 'string' && item.image.length <= 2048) ? item.image : null
205
+ })).filter(i => i && i.sessionId && i.username);
206
+
199
207
  // Create payload with fingerprint
200
208
  const payload = {
201
- accounts: sanitized,
209
+ accounts: cleaned,
202
210
  fingerprint: generateFingerprint(req)
203
211
  };
204
212
 
@@ -221,7 +229,7 @@ export const upsertAccountListCookie = (req, res, entry) => {
221
229
  if (!entry || !entry.sessionId || !entry.username) return;
222
230
  const current = readAccountListFromCookie(req);
223
231
  const filtered = current.filter(item => item.sessionId !== entry.sessionId && item.username !== entry.username);
224
- const next = [{ sessionId: entry.sessionId, username: entry.username, fullName: entry.fullName || entry.username }, ...filtered];
232
+ const next = [{ sessionId: entry.sessionId, username: entry.username, fullName: entry.fullName || entry.username, image: entry.image || null }, ...filtered];
225
233
  writeAccountList(res, next, req);
226
234
  };
227
235
 
@@ -421,16 +421,14 @@ async function reloadSessionUser(req, res) {
421
421
  // Persist session changes
422
422
  await new Promise((resolve, reject) => req.session.save(err => err ? reject(err) : resolve()));
423
423
 
424
- // Sync cookies for client UI (non-httpOnly)
424
+ // Sync cookies for client UI (sessionId + fullName)
425
425
  try {
426
- res.cookie('username', req.session.user.username, { ...cachedCookieOptions, httpOnly: false });
427
426
  res.cookie('fullName', req.session.user.fullname || req.session.user.username, { ...cachedCookieOptions, httpOnly: false });
428
427
  const encryptedSid = encryptSessionId(req.session.user.sessionId);
429
428
  if (encryptedSid) {
430
429
  res.cookie('sessionId', encryptedSid, cachedCookieOptions);
431
430
  }
432
431
  } catch (cookieErr) {
433
- // Ignore cookie setting errors, session is still refreshed
434
432
  console.error('[mbkauthe] Error syncing cookies during reload:', cookieErr);
435
433
  }
436
434
 
@@ -96,7 +96,7 @@ export async function sessionRestorationMiddleware(req, res, next) {
96
96
  };
97
97
 
98
98
  // Use cached FullName from client cookie when available to avoid extra DB queries
99
- if (req.cookies.fullName && typeof req.cookies.fullName === 'string') {
99
+ if (req.cookies && req.cookies.fullName && typeof req.cookies.fullName === 'string') {
100
100
  req.session.user.fullname = req.cookies.fullName;
101
101
  } else {
102
102
  // Fallback: attempt to fetch FullName from Users to populate session
@@ -130,9 +130,7 @@ export function sessionCookieSyncMiddleware(req, res, next) {
130
130
 
131
131
  // Only set cookies if they're missing or different
132
132
  if (currentDecryptedId !== req.session.user.sessionId) {
133
- res.cookie("username", req.session.user.username, { ...cachedCookieOptions, httpOnly: false });
134
133
  res.cookie("fullName", req.session.user.fullname || req.session.user.username, { ...cachedCookieOptions, httpOnly: false });
135
-
136
134
  const encryptedSessionId = encryptSessionId(req.session.user.sessionId);
137
135
  if (encryptedSessionId) {
138
136
  res.cookie("sessionId", encryptedSessionId, cachedCookieOptions);
@@ -252,18 +252,20 @@ export async function completeLoginProcess(req, res, user, redirectUrl = null, t
252
252
  // Clear profile picture cache to fetch fresh data
253
253
  clearProfilePicCache(req, username);
254
254
 
255
- // Attempt to fetch FullName from Users and store it in session for display purposes
255
+ // Attempt to fetch FullName and Image from Users and store it in session for display purposes
256
+ let loginProfileImage = null;
256
257
  try {
257
258
  const profileResult = await dblogin.query({
258
- name: 'login-get-fullname',
259
- text: 'SELECT "FullName" FROM "Users" WHERE "UserName" = $1 LIMIT 1',
259
+ name: 'login-get-fullname-and-image',
260
+ text: 'SELECT "FullName", "Image" FROM "Users" WHERE "UserName" = $1 LIMIT 1',
260
261
  values: [username]
261
262
  });
262
- if (profileResult.rows.length > 0 && profileResult.rows[0].FullName) {
263
- req.session.user.fullname = profileResult.rows[0].FullName;
263
+ if (profileResult.rows.length > 0) {
264
+ if (profileResult.rows[0].FullName) req.session.user.fullname = profileResult.rows[0].FullName;
265
+ if (profileResult.rows[0].Image && profileResult.rows[0].Image.trim() !== '') loginProfileImage = profileResult.rows[0].Image;
264
266
  }
265
267
  } catch (profileErr) {
266
- console.error("[mbkauthe] Error fetching FullName for user:", profileErr);
268
+ console.error("[mbkauthe] Error fetching FullName/Image for user:", profileErr);
267
269
  }
268
270
 
269
271
  if (req.session.preAuthUser) {
@@ -276,12 +278,12 @@ export async function completeLoginProcess(req, res, user, redirectUrl = null, t
276
278
  return res.status(500).json({ success: false, message: "Internal Server Error" });
277
279
  }
278
280
 
279
- // Expose DB session id and display name to client for UI (fullName falls back to username)
281
+ // Expose DB session id to client
280
282
  const encryptedSessionId = encryptSessionId(dbSessionId);
281
283
  if (encryptedSessionId) {
282
284
  res.cookie("sessionId", encryptedSessionId, cachedCookieOptions);
283
285
  }
284
- res.cookie("username", username, { ...cachedCookieOptions, httpOnly: false });
286
+ // Cache display name client-side to avoid extra DB lookups
285
287
  res.cookie("fullName", req.session.user.fullname || username, { ...cachedCookieOptions, httpOnly: false });
286
288
  // Record which method was used to login (client-visible badge)
287
289
  if (method && typeof method === 'string') {
@@ -296,7 +298,8 @@ export async function completeLoginProcess(req, res, user, redirectUrl = null, t
296
298
  upsertAccountListCookie(req, res, {
297
299
  sessionId: dbSessionId,
298
300
  username,
299
- fullName: req.session.user.fullname || username
301
+ fullName: req.session.user.fullname || username,
302
+ image: loginProfileImage || null
300
303
  });
301
304
 
302
305
  // Handle trusted device if requested (token no longer stored in DB as token_hash)
@@ -677,18 +680,20 @@ router.get("/api/account-sessions", LoginLimit, async (req, res) => {
677
680
  }
678
681
 
679
682
  let fullName = acct.fullName || acct.username;
680
- if (!acct.fullName) {
683
+ let image = acct.image || null;
684
+ if (!acct.fullName || !acct.image) {
681
685
  try {
682
686
  const prof = await dblogin.query({
683
- name: 'multi-session-fullname',
684
- text: 'SELECT "FullName" FROM "Users" WHERE "UserName" = $1 LIMIT 1',
687
+ name: 'multi-session-fullname-image',
688
+ text: 'SELECT "FullName", "Image" FROM "Users" WHERE "UserName" = $1 LIMIT 1',
685
689
  values: [row.UserName]
686
690
  });
687
- if (prof.rows.length > 0 && prof.rows[0].FullName) {
688
- fullName = prof.rows[0].FullName;
691
+ if (prof.rows.length > 0) {
692
+ if (!acct.fullName && prof.rows[0].FullName) fullName = prof.rows[0].FullName;
693
+ if (!acct.image && prof.rows[0].Image && prof.rows[0].Image.trim() !== '') image = prof.rows[0].Image;
689
694
  }
690
695
  } catch (profileErr) {
691
- console.error('[mbkauthe] Error fetching fullname for account list:', profileErr);
696
+ console.error('[mbkauthe] Error fetching fullname/image for account list:', profileErr);
692
697
  }
693
698
  }
694
699
 
@@ -696,6 +701,7 @@ router.get("/api/account-sessions", LoginLimit, async (req, res) => {
696
701
  sessionId: row.sid,
697
702
  username: row.UserName,
698
703
  fullName,
704
+ image,
699
705
  isCurrent: currentSessionId && row.sid === currentSessionId
700
706
  });
701
707
  } catch (err) {
@@ -729,15 +735,19 @@ router.post("/api/switch-session", LoginLimit, async (req, res) => {
729
735
  }
730
736
 
731
737
  let fullName = row.UserName;
738
+ let switchProfileImage = null;
732
739
  try {
733
740
  const prof = await dblogin.query({
734
- name: 'multi-session-switch-fullname',
735
- text: 'SELECT "FullName" FROM "Users" WHERE "UserName" = $1 LIMIT 1',
741
+ name: 'multi-session-switch-fullname-image',
742
+ text: 'SELECT "FullName", "Image" FROM "Users" WHERE "UserName" = $1 LIMIT 1',
736
743
  values: [row.UserName]
737
744
  });
738
- if (prof.rows.length > 0 && prof.rows[0].FullName) fullName = prof.rows[0].FullName;
745
+ if (prof.rows.length > 0) {
746
+ if (prof.rows[0].FullName) fullName = prof.rows[0].FullName;
747
+ if (prof.rows[0].Image && prof.rows[0].Image.trim() !== '') switchProfileImage = prof.rows[0].Image;
748
+ }
739
749
  } catch (profileErr) {
740
- console.error('[mbkauthe] Error fetching fullname during switch:', profileErr);
750
+ console.error('[mbkauthe] Error fetching fullname/image during switch:', profileErr);
741
751
  }
742
752
 
743
753
  // Regenerate session to avoid fixation
@@ -759,14 +769,13 @@ router.post("/api/switch-session", LoginLimit, async (req, res) => {
759
769
 
760
770
  await new Promise((resolve, reject) => req.session.save(err => err ? reject(err) : resolve()));
761
771
 
762
- // Sync cookies for client UI and remember list
763
- res.cookie('username', row.UserName, { ...cachedCookieOptions, httpOnly: false });
772
+ // Sync sessionId cookie and remember list
764
773
  res.cookie('fullName', fullName, { ...cachedCookieOptions, httpOnly: false });
765
774
  const encryptedSid = encryptSessionId(row.sid);
766
775
  if (encryptedSid) {
767
776
  res.cookie('sessionId', encryptedSid, cachedCookieOptions);
768
777
  }
769
- upsertAccountListCookie(req, res, { sessionId: row.sid, username: row.UserName, fullName });
778
+ upsertAccountListCookie(req, res, { sessionId: row.sid, username: row.UserName, fullName, image: switchProfileImage || null });
770
779
 
771
780
  const safeRedirect = typeof redirect === 'string' && redirect.startsWith('/') && !redirect.startsWith('//')
772
781
  ? redirect
@@ -153,35 +153,59 @@ router.get('/user/profilepic', async (req, res) => {
153
153
  router.get('/test', validateSession, LoginLimit, async (req, res) => {
154
154
  if (req.session?.user) {
155
155
  return res.send(`
156
- <head>
157
- <script src="/mbkauthe/main.js"></script>
156
+ <head>
157
+ <meta name="viewport" content="width=device-width,initial-scale=1">
158
+ <script src="/mbkauthe/main.js"></script>
158
159
  <style>
159
- body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; background: #f5f5f5; }
160
- .card { background: white; border-radius: 8px; padding: 25px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); margin-bottom: 20px; }
161
- .success { color: #16a085; border-left: 4px solid #16a085; padding-left: 15px; }
162
- .user-info { background: #ecf0f1; padding: 15px; border-radius: 4px; font-family: monospace; font-size: 14px; }
163
- button { background: #e74c3c; color: white; border: none; padding: 10px 20px; border-radius: 4px; cursor: pointer; margin: 10px 5px; }
164
- button:hover { background: #c0392b; }
165
- a { color: #3498db; text-decoration: none; margin: 0 10px; padding: 8px 12px; border: 1px solid #3498db; border-radius: 4px; display: inline-block; }
166
- a:hover { background: #3498db; color: white; }
160
+ :root{--bg:#f7fafc;--card:#ffffff;--muted:#6b7280;--accent:#2563eb;--success:#059669;--danger:#ef4444;--radius:12px}
161
+ html,body{height:100%;margin:0;font-family:Inter, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial}
162
+ body{background:linear-gradient(180deg, #f0f4ff 0%, var(--bg) 100%);display:flex;align-items:center;justify-content:center;padding:32px}
163
+ .container{width:100%;max-width:920px}
164
+ .card{background:var(--card);border-radius:var(--radius);padding:22px;box-shadow:0 6px 24px rgba(16,24,40,0.06);display:grid;grid-template-columns:120px 1fr;gap:20px;align-items:start}
165
+ .avatar{width:96px;height:96px;border-radius:50%;background:linear-gradient(135deg,#e6eefc,#fff);display:flex;align-items:center;justify-content:center;font-weight:700;color:var(--accent);font-size:28px;border:2px solid rgba(37,99,235,0.08);position:relative;overflow:hidden}
166
+ .avatar img{width:100%;height:100%;object-fit:cover;display:block}
167
+ .avatar .initials{position:absolute;inset:0;display:none;align-items:center;justify-content:center;font-weight:700;color:var(--accent);font-size:28px;background:linear-gradient(135deg,#eef2ff,#fff)}
168
+ .meta{display:flex;flex-direction:column;gap:8px}
169
+ .status{color:var(--success);font-weight:600;display:flex;align-items:center;gap:8px}
170
+ .user-title{font-size:18px;font-weight:700;margin:0}
171
+ .user-sub{color:var(--muted);margin:0;font-size:13px}
172
+ .details{background:#fbfdff;border-radius:10px;padding:12px;font-family:monospace;font-size:13px;color:#111;display:flex;flex-direction:column;gap:6px}
173
+ .actions{margin-top:14px;display:flex;flex-wrap:wrap;gap:8px}
174
+ .btn{display:inline-flex;align-items:center;gap:8px;padding:9px 14px;border-radius:10px;border:1px solid transparent;cursor:pointer;text-decoration:none}
175
+ .btn-primary{background:var(--accent);color:white}
176
+ .btn-outline{background:transparent;border-color:rgba(37,99,235,0.12);color:var(--accent)}
177
+ .btn-danger{background:var(--danger);color:#fff}
178
+ a{color:inherit}
179
+ @media (max-width:640px){.card{grid-template-columns:1fr;align-items:stretch}.avatar{width:80px;height:80px}}
167
180
  </style>
168
181
  </head>
169
- <div class="card">
170
- <p class="success">✅ Authentication successful! User is logged in.</p>
171
- <p>Welcome, <strong>${req.session.user.username}</strong>! Your role: <strong>${req.session.user.role}</strong></p>
172
- <div class="user-info">
173
- Username: ${req.session.user.username}<br>
174
- Role: ${req.session.user.role}<br>
175
- Full Name: ${req.session.user.fullname || 'N/A'}<br>
176
- User ID: ${req.session.user.id}<br>
177
- Session ID: ${req.session.user.sessionId.slice(0, 5)}... <br>
178
- Allowed Apps: ${Array.isArray(req.session.user.allowedApps) ? req.session.user.allowedApps.join(', ') : 'N/A'}
182
+ <div class="container">
183
+ <div class="card" role="region" aria-label="User Session">
184
+ <div class="avatar" aria-hidden="true">
185
+ <img src="/mbkauthe/user/profilepic?u=${encodeURIComponent(req.session.user.username)}" alt="Avatar for ${req.session.user.username}" title="${req.session.user.fullname || req.session.user.username}" loading="lazy" decoding="async" width="96" height="96" onerror="this.style.display='none';var s=this.nextElementSibling; if(s) s.style.display='flex';" />
186
+ <div class="initials" aria-hidden="true" style="display:none">${(req.session.user.fullname && req.session.user.fullname[0]) || req.session.user.username[0]}</div>
187
+ </div>
188
+ <div class="meta">
189
+ <div>
190
+ <div class="status">✅ Authentication successful</div>
191
+ <h3 class="user-title">${req.session.user.username} <small style="color:var(--muted);font-weight:600">· ${req.session.user.role}</small></h3>
192
+ <p class="user-sub">ID: ${req.session.user.id} · Session: ${req.session.user.sessionId.slice(0,8)}…</p>
193
+ </div>
194
+
195
+ <div class="details" aria-live="polite">
196
+ <div>Full Name: ${req.session.user.fullname || 'N/A'}</div>
197
+ <div>Allowed Apps: ${Array.isArray(req.session.user.allowedApps) ? req.session.user.allowedApps.join(', ') : 'N/A'}</div>
198
+ </div>
199
+
200
+ <div class="actions">
201
+ <button class="btn btn-primary" onclick="logout()" aria-label="Log out">Logout</button>
202
+ <a class="btn btn-outline" href="https://portal.mbktech.org/">Web Portal</a>
203
+ <a class="btn btn-outline" href="https://portal.mbktech.org/user/settings">User Settings</a>
204
+ <a class="btn btn-outline" href="/mbkauthe/info">Info Page</a>
205
+ <a class="btn btn-outline" href="/mbkauthe/login">Login Page</a>
206
+ </div>
207
+ </div>
179
208
  </div>
180
- <button onclick="logout()">Logout</button>
181
- <a href="https://portal.mbktech.org/">Web Portal</a>
182
- <a href="https://portal.mbktech.org/user/settings">User Settings</a>
183
- <a href="/mbkauthe/info">Info Page</a>
184
- <a href="/mbkauthe/login">Login Page</a>
185
209
  </div>
186
210
  `);
187
211
  }
@@ -1,15 +1,15 @@
1
1
  import { mbkautheVar, packageJson } from "#config.js";
2
2
 
3
3
  export function getUserContext(req) {
4
- const user = req?.session?.user || {};
5
- return {
6
- userLoggedIn: !!user.username,
7
- isuserlogin: !!user.username,
8
- username: user.username || 'N/A',
9
- fullname: user.fullname || 'N/A',
10
- role: user.role || 'N/A',
11
- allowedApps: Array.isArray(user.allowedApps) ? user.allowedApps : [],
12
- };
4
+ const user = req?.session?.user || {};
5
+ return {
6
+ userLoggedIn: !!user.username,
7
+ isuserlogin: !!user.username,
8
+ username: user.username || 'N/A',
9
+ fullname: user.fullname || 'N/A',
10
+ role: user.role || 'N/A',
11
+ allowedApps: Array.isArray(user.allowedApps) ? user.allowedApps : [],
12
+ };
13
13
  }
14
14
 
15
15
  // Helper function to render error pages consistently
@@ -42,4 +42,42 @@ export async function renderPage(req, res, fileLocation, layout = true, data = {
42
42
  ...(layout === false ? { layout: false } : {}),
43
43
  };
44
44
  return res.render(fileLocation, renderOptions);
45
+ }
46
+
47
+ export async function proxycall(req, res, url, method = 'GET', headerOption = {}) {
48
+ const controller = new AbortController();
49
+ const timeout = setTimeout(() => controller.abort(), 30000);
50
+
51
+ try {
52
+ const sessionCookie = req.cookies?.sessionId;
53
+
54
+ const headers = { ...headerOption };
55
+
56
+ if (sessionCookie && !headers.Cookie) {
57
+ headers.Cookie = `sessionId=${sessionCookie}`;
58
+ }
59
+
60
+ const body = ['GET', 'HEAD'].includes(method) ? undefined : typeof req.body === 'string' || req.body instanceof Buffer ? req.body : JSON.stringify(req.body);
61
+
62
+ if (body && !headers['Content-Type']) {
63
+ headers['Content-Type'] = 'application/json';
64
+ }
65
+
66
+ const response = await fetch(url, { method, headers, body, signal: controller.signal });
67
+
68
+ response.headers.forEach((value, key) => {
69
+ res.setHeader(key, value);
70
+ });
71
+
72
+ const contentType = response.headers.get('content-type');
73
+ const data = contentType?.includes('application/json') ? await response.json() : await response.text();
74
+
75
+ return res.status(response.status).send(data);
76
+
77
+ } catch (err) {
78
+ console.error('Proxy error:', err);
79
+ return res.status(err.name === 'AbortError' ? 504 : 500).json({ error: 'Proxy request failed' });
80
+ } finally {
81
+ clearTimeout(timeout);
82
+ }
45
83
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mbkauthe",
3
- "version": "4.3.2",
3
+ "version": "4.3.3",
4
4
  "description": "MBKTech's reusable authentication system for Node.js applications.",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -147,6 +147,27 @@
147
147
  font-size: 18px;
148
148
  flex-shrink: 0;
149
149
  box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3);
150
+ position: relative;
151
+ overflow: hidden;
152
+ }
153
+
154
+ .avatar img {
155
+ width: 100%;
156
+ height: 100%;
157
+ object-fit: cover;
158
+ display: block;
159
+ border-radius: 50%;
160
+ }
161
+
162
+ .avatar .initials {
163
+ position: absolute;
164
+ inset: 0;
165
+ display: flex;
166
+ align-items: center;
167
+ justify-content: center;
168
+ font-weight: 600;
169
+ font-size: 18px;
170
+ background: linear-gradient(135deg, rgba(255,255,255,0.02), rgba(255,255,255,0));
150
171
  }
151
172
 
152
173
  .account-info {
@@ -367,15 +388,44 @@
367
388
  item.role = 'button';
368
389
  item.tabIndex = 0;
369
390
 
370
- item.innerHTML = `
371
- <div class="avatar">${initial}</div>
372
- <div class="account-info">
373
- <div class="account-name">${acct.fullName || acct.username}</div>
374
- <div class="account-email">${acct.username}</div>
375
- </div>
376
- ${isCurrent ? '<div class="current-badge">Current</div>' : ''}
391
+ // Build avatar with image (if available) and initials fallback to avoid HTML injection
392
+ const avatar = document.createElement('div');
393
+ avatar.className = 'avatar';
394
+
395
+ const initials = document.createElement('div');
396
+ initials.className = 'initials';
397
+ initials.textContent = initial;
398
+ initials.style.display = acct.image ? 'none' : 'flex';
399
+
400
+ if (acct.image) {
401
+ const img = document.createElement('img');
402
+ img.className = 'avatar-img';
403
+ img.src = acct.image;
404
+ img.alt = `Avatar for ${acct.username}`;
405
+ img.loading = 'lazy';
406
+ img.decoding = 'async';
407
+ img.onerror = function () { img.style.display = 'none'; initials.style.display = 'flex'; };
408
+ avatar.appendChild(img);
409
+ }
410
+
411
+ avatar.appendChild(initials);
412
+
413
+ const info = document.createElement('div');
414
+ info.className = 'account-info';
415
+ info.innerHTML = `
416
+ <div class="account-name">${acct.fullName || acct.username}</div>
417
+ <div class="account-email">${acct.username}</div>
377
418
  `;
378
419
 
420
+ item.appendChild(avatar);
421
+ item.appendChild(info);
422
+ if (isCurrent) {
423
+ const badge = document.createElement('div');
424
+ badge.className = 'current-badge';
425
+ badge.textContent = 'Current';
426
+ item.appendChild(badge);
427
+ };
428
+
379
429
  item.addEventListener('click', () => {
380
430
  if (!isCurrent) {
381
431
  switchSession(acct.sessionId);
@@ -177,7 +177,7 @@
177
177
  /* prefers-color-scheme fallbacks scoped to component */
178
178
  @media (prefers-color-scheme: dark) {
179
179
  .mbkauthe-profile-dropdown {
180
- --mbk-bg: #0b1220;
180
+ --mbk-bg: #0a2125;
181
181
  --mbk-border: rgba(255, 255, 255, 0.06);
182
182
  --mbk-item-hover: rgba(255, 255, 255, 0.03);
183
183
  --mbk-text: #e6eef6;
@@ -14,7 +14,7 @@
14
14
  --warning: #ffd166;
15
15
  --danger: #ff7675;
16
16
  --border-radius: 8px;
17
- --box-shadow: 0 4px 20px rgba(33, 150, 243, 0.15);
17
+ --box-shadow: 0 4px 10px rgba(33, 150, 243, 0.15);
18
18
  --transition: all 0.3s cubic-bezier(.4, 0, .2, 1);
19
19
  }
20
20