tabminal 3.0.27 → 3.0.28

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/public/app.js CHANGED
@@ -6,7 +6,9 @@ import { SearchAddon } from 'https://cdn.jsdelivr.net/npm/@xterm/addon-search@0.
6
6
  import { ProgressAddon } from 'https://cdn.jsdelivr.net/npm/@xterm/addon-progress@0.3.0-beta.197/+esm';
7
7
  import { LigaturesAddon } from 'https://cdn.jsdelivr.net/npm/@xterm/addon-ligatures@0.11.0-beta.197/+esm';
8
8
  import DOMPurify from 'https://cdn.jsdelivr.net/npm/dompurify@3.3.3/+esm';
9
- import {
9
+
10
+ const LOCAL_MODULE_VERSION = new URL(import.meta.url).search;
11
+ const {
10
12
  normalizeBaseUrl,
11
13
  getServerEndpointKeyFromUrl,
12
14
  getUrlHostname,
@@ -14,21 +16,38 @@ import {
14
16
  isAccessRedirectResponse,
15
17
  buildAccessLoginUrl,
16
18
  isLikelyAccessLoginResponse,
17
- buildTokenStorageKey,
19
+ buildAuthStateStorageKey,
18
20
  makeSessionKey,
19
21
  splitSessionKey,
20
22
  hashPassword
21
- } from './modules/url-auth.js';
22
- import {
23
+ } = await import(`./modules/url-auth.js${LOCAL_MODULE_VERSION}`);
24
+ const {
23
25
  shortenPath,
24
26
  getEnvValue,
25
27
  getDisplayHost,
26
28
  renderSessionHostMeta
27
- } from './modules/session-meta.js';
28
- import {
29
+ } = await import(`./modules/session-meta.js${LOCAL_MODULE_VERSION}`);
30
+ const {
29
31
  NotificationManager,
30
32
  ToastManager
31
- } from './modules/notifications.js';
33
+ } = await import(`./modules/notifications.js${LOCAL_MODULE_VERSION}`);
34
+
35
+ const DEPRECATED_AUTH_TOKEN_STORAGE_PREFIX = 'tabminal_auth_token:';
36
+
37
+ function clearDeprecatedPasswordHashAuthStorage() {
38
+ try {
39
+ for (let index = localStorage.length - 1; index >= 0; index -= 1) {
40
+ const key = localStorage.key(index) || '';
41
+ if (key.startsWith(DEPRECATED_AUTH_TOKEN_STORAGE_PREFIX)) {
42
+ localStorage.removeItem(key);
43
+ }
44
+ }
45
+ } catch {
46
+ // Ignore storage failures; deprecated tokens are never read.
47
+ }
48
+ }
49
+
50
+ clearDeprecatedPasswordHashAuthStorage();
32
51
 
33
52
  // Detect Mobile/Tablet (focus on touch capability for font sizing)
34
53
  // Logic: If the device supports touch, we assume it needs larger fonts (14px)
@@ -56,6 +75,17 @@ const addServerCancel = document.getElementById('add-server-cancel');
56
75
  const addServerTitle = addServerModal?.querySelector('h2') || null;
57
76
  const addServerDescription = addServerModal?.querySelector('p') || null;
58
77
  const addServerSubmitButton = addServerForm?.querySelector('button[type="submit"]') || null;
78
+ const authSessionsModal = document.getElementById('auth-sessions-modal');
79
+ const authSessionsTitle = document.getElementById('auth-sessions-title');
80
+ const authSessionsDescription = document.getElementById(
81
+ 'auth-sessions-description'
82
+ );
83
+ const authSessionsList = document.getElementById('auth-sessions-list');
84
+ const authSessionsError = document.getElementById('auth-sessions-error');
85
+ const authSessionsClose = document.getElementById('auth-sessions-close');
86
+ const authSessionsRevokeOthers = document.getElementById(
87
+ 'auth-sessions-revoke-others'
88
+ );
59
89
  const agentSetupModal = document.getElementById('agent-setup-modal');
60
90
  const agentSetupForm = document.getElementById('agent-setup-form');
61
91
  const agentSetupTitle = document.getElementById('agent-setup-title');
@@ -152,6 +182,7 @@ const MARKDOWN_PREVIEW_HIGHLIGHT_CSS_URL = 'https://cdn.jsdelivr.net/npm/highlig
152
182
  const MARKDOWN_PREVIEW_KATEX_CSS_URL = 'https://cdn.jsdelivr.net/npm/katex@0.16.45/dist/katex.min.css';
153
183
  const CLOSE_ICON_SVG = '<svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>';
154
184
  const AGENT_ICON_SVG = '<svg viewBox="0 0 24 24" width="17" height="17" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round"><rect x="7" y="7" width="10" height="10" rx="2"></rect><path d="M9 7V5"></path><path d="M15 7V5"></path><path d="M12 17v2"></path><path d="M5 12H3"></path><path d="M21 12h-2"></path><path d="M9 11h.01"></path><path d="M15 11h.01"></path><path d="M9.5 14c.7.67 1.53 1 2.5 1s1.8-.33 2.5-1"></path></svg>';
185
+ const AUTH_SESSIONS_ICON_SVG = '<svg viewBox="0 0 24 24" width="15" height="15" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round"><path d="M7 8.5a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z"></path><path d="M2.5 16.5a4.5 4.5 0 0 1 9 0"></path><path d="M17 10.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Z"></path><path d="M13.5 16.5a3.5 3.5 0 0 1 7 0"></path></svg>';
155
186
  const TERMINAL_TAB_ICON_SVG = '<svg viewBox="0 0 24 24" width="15" height="15" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"></rect><path d="m8 10 3 2-3 2"></path><path d="M13 15h4"></path></svg>';
156
187
  const MANAGED_TERMINAL_ICON_SVG = '<svg viewBox="0 0 24 24" width="15" height="15" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"></rect><path d="M7 12h.01"></path><path d="M12 9v6"></path><path d="M9 12h6"></path><path d="M18 8v2"></path><path d="M19 9h-2"></path></svg>';
157
188
  const BELL_ICON_SVG = '<svg viewBox="0 0 24 24" width="15" height="15" stroke="currentColor" stroke-width="2.1" fill="none" stroke-linecap="round" stroke-linejoin="round"><path d="M12 4.5a4.5 4.5 0 0 0-4.5 4.5v2.4c0 1.2-.41 2.37-1.17 3.3L5 16.5h14l-1.33-1.8a5.66 5.66 0 0 1-1.17-3.3V9A4.5 4.5 0 0 0 12 4.5"></path><path d="M10.25 19a1.75 1.75 0 0 0 3.5 0"></path></svg>';
@@ -854,6 +885,52 @@ class AuthManager {
854
885
  }
855
886
  }
856
887
 
888
+ function readStoredAuthState(serverId) {
889
+ const authStateKey = buildAuthStateStorageKey(serverId);
890
+ let authState = null;
891
+ try {
892
+ const raw = localStorage.getItem(authStateKey);
893
+ if (raw) {
894
+ const parsed = JSON.parse(raw);
895
+ if (parsed && typeof parsed === 'object') {
896
+ authState = parsed;
897
+ }
898
+ }
899
+ } catch (error) {
900
+ console.warn('Failed to parse stored auth state', error);
901
+ }
902
+ return authState;
903
+ }
904
+
905
+ function normalizeAuthState(value) {
906
+ const auth = value && typeof value === 'object' ? value : {};
907
+ return {
908
+ accessToken: typeof auth.accessToken === 'string'
909
+ ? auth.accessToken.trim()
910
+ : '',
911
+ accessTokenExpiresAt: typeof auth.accessTokenExpiresAt === 'string'
912
+ ? auth.accessTokenExpiresAt.trim()
913
+ : '',
914
+ refreshToken: typeof auth.refreshToken === 'string'
915
+ ? auth.refreshToken.trim()
916
+ : '',
917
+ refreshTokenExpiresAt: typeof auth.refreshTokenExpiresAt === 'string'
918
+ ? auth.refreshTokenExpiresAt.trim()
919
+ : ''
920
+ };
921
+ }
922
+
923
+ function isIsoExpired(value, leewayMs = 0) {
924
+ if (!value) {
925
+ return true;
926
+ }
927
+ const timestamp = Date.parse(value);
928
+ if (!Number.isFinite(timestamp)) {
929
+ return true;
930
+ }
931
+ return timestamp <= (Date.now() + leewayMs);
932
+ }
933
+
857
934
  class ServerClient {
858
935
  constructor(data, { isPrimary = false } = {}) {
859
936
  this.id = data.id;
@@ -877,38 +954,56 @@ class ServerClient {
877
954
  this.accessLoginUrl = '';
878
955
  this.expandedPaths = new Set();
879
956
  this.modelStore = new Map();
880
- const key = buildTokenStorageKey(this.id);
881
- const persistedToken = typeof data.token === 'string' ? data.token : '';
882
- if (this.isPrimary) {
883
- this.token = persistedToken || localStorage.getItem(key) || '';
884
- if (this.token) {
885
- localStorage.setItem(key, this.token);
886
- }
957
+ this.token = '';
958
+ this.accessTokenExpiresAt = '';
959
+ this.refreshToken = '';
960
+ this.refreshTokenExpiresAt = '';
961
+ this.refreshPromise = null;
962
+ this.bootstrapPromise = null;
963
+ this.loadStoredAuth();
964
+ }
965
+
966
+ loadStoredAuth() {
967
+ const normalizedAuthState = normalizeAuthState(
968
+ readStoredAuthState(this.id)
969
+ );
970
+ this.token = normalizedAuthState.accessToken;
971
+ this.accessTokenExpiresAt = normalizedAuthState.accessTokenExpiresAt;
972
+ this.refreshToken = normalizedAuthState.refreshToken;
973
+ this.refreshTokenExpiresAt = normalizedAuthState.refreshTokenExpiresAt;
974
+ this.syncAuthPersistence();
975
+ this.updateAuthFlags();
976
+ }
977
+
978
+ syncAuthPersistence() {
979
+ const authStateKey = buildAuthStateStorageKey(this.id);
980
+ const authState = {
981
+ accessToken: this.token,
982
+ accessTokenExpiresAt: this.accessTokenExpiresAt,
983
+ refreshToken: this.refreshToken,
984
+ refreshTokenExpiresAt: this.refreshTokenExpiresAt
985
+ };
986
+ if (authState.accessToken || authState.refreshToken) {
987
+ localStorage.setItem(authStateKey, JSON.stringify(authState));
887
988
  } else {
888
- this.token = persistedToken;
889
- localStorage.removeItem(key);
989
+ localStorage.removeItem(authStateKey);
890
990
  }
891
- this.isAuthenticated = !!this.token;
892
- this.needsLogin = !this.isAuthenticated;
893
991
  }
894
992
 
895
- setToken(token) {
896
- const normalizedToken = typeof token === 'string' ? token.trim() : '';
897
- this.token = normalizedToken;
898
- this.isAuthenticated = !!this.token;
993
+ updateAuthFlags() {
994
+ this.isAuthenticated = !!(this.token || this.refreshToken);
899
995
  this.needsLogin = !this.isAuthenticated;
900
996
  this.nextSyncAt = 0;
997
+ }
901
998
 
902
- const key = buildTokenStorageKey(this.id);
903
- if (this.isPrimary) {
904
- if (this.token) {
905
- localStorage.setItem(key, this.token);
906
- } else {
907
- localStorage.removeItem(key);
908
- }
909
- } else {
910
- localStorage.removeItem(key);
911
- }
999
+ applyAuthState(authState) {
1000
+ const normalized = normalizeAuthState(authState);
1001
+ this.token = normalized.accessToken;
1002
+ this.accessTokenExpiresAt = normalized.accessTokenExpiresAt;
1003
+ this.refreshToken = normalized.refreshToken;
1004
+ this.refreshTokenExpiresAt = normalized.refreshTokenExpiresAt;
1005
+ this.syncAuthPersistence();
1006
+ this.updateAuthFlags();
912
1007
  }
913
1008
 
914
1009
  toJSON() {
@@ -916,12 +1011,12 @@ class ServerClient {
916
1011
  id: this.id,
917
1012
  host: this.host,
918
1013
  baseUrl: this.baseUrl,
919
- token: this.token
1014
+ token: ''
920
1015
  };
921
1016
  }
922
1017
 
923
1018
  getHeaders() {
924
- return this.token ? { 'Authorization': this.token } : {};
1019
+ return this.token ? { 'Authorization': `Bearer ${this.token}` } : {};
925
1020
  }
926
1021
 
927
1022
  resolveUrl(path) {
@@ -960,12 +1055,24 @@ class ServerClient {
960
1055
  }
961
1056
 
962
1057
  async login(password) {
963
- const hashed = await hashPassword(password);
964
- await this.loginWithToken(hashed);
965
- }
966
-
967
- async loginWithToken(token) {
968
- this.setToken(token);
1058
+ const passwordHash = await hashPassword(password);
1059
+ const response = await this.fetchWithoutAuth('/api/auth/login', {
1060
+ method: 'POST',
1061
+ headers: { 'Content-Type': 'application/json' },
1062
+ body: JSON.stringify({ passwordHash })
1063
+ });
1064
+ if (!response.ok) {
1065
+ const data = await response.json().catch(() => ({}));
1066
+ if (response.status === 403) {
1067
+ throw new Error(
1068
+ data.error
1069
+ || 'Service locked due to too many failed attempts.'
1070
+ );
1071
+ }
1072
+ throw new Error(data.error || 'Authentication required.');
1073
+ }
1074
+ const authState = await response.json();
1075
+ this.applyAuthState(authState);
969
1076
  this.needsAccessLogin = false;
970
1077
  this.accessLoginUrl = '';
971
1078
  renderServerControls();
@@ -973,25 +1080,158 @@ class ServerClient {
973
1080
  this.startHeartbeat();
974
1081
  }
975
1082
 
1083
+ async bootstrapAuth() {
1084
+ if (this.bootstrapPromise) {
1085
+ return this.bootstrapPromise;
1086
+ }
1087
+ this.bootstrapPromise = (async () => {
1088
+ if (this.token && !isIsoExpired(this.accessTokenExpiresAt, 30_000)) {
1089
+ this.updateAuthFlags();
1090
+ return true;
1091
+ }
1092
+ if (this.refreshToken) {
1093
+ const refreshed = await this.refreshAuth();
1094
+ if (refreshed) {
1095
+ return true;
1096
+ }
1097
+ }
1098
+ this.updateAuthFlags();
1099
+ return false;
1100
+ })().finally(() => {
1101
+ this.bootstrapPromise = null;
1102
+ });
1103
+ return this.bootstrapPromise;
1104
+ }
1105
+
1106
+ async refreshAuth({ allowStorageReload = true } = {}) {
1107
+ if (!this.refreshToken) {
1108
+ return false;
1109
+ }
1110
+ if (this.refreshPromise) {
1111
+ return this.refreshPromise;
1112
+ }
1113
+ this.refreshPromise = (async () => {
1114
+ try {
1115
+ const response = await this.fetchWithoutAuth('/api/auth/refresh', {
1116
+ method: 'POST',
1117
+ headers: { 'Content-Type': 'application/json' },
1118
+ body: JSON.stringify({
1119
+ refreshToken: this.refreshToken
1120
+ })
1121
+ });
1122
+ if (response.ok) {
1123
+ const authState = await response.json();
1124
+ this.applyAuthState(authState);
1125
+ this.needsAccessLogin = false;
1126
+ this.accessLoginUrl = '';
1127
+ return true;
1128
+ }
1129
+ if (
1130
+ allowStorageReload
1131
+ && response.status === 401
1132
+ && this.loadAuthStateFromStorage()
1133
+ ) {
1134
+ return this.refreshAuth({ allowStorageReload: false });
1135
+ }
1136
+ return false;
1137
+ } catch {
1138
+ return false;
1139
+ }
1140
+ })().finally(() => {
1141
+ this.refreshPromise = null;
1142
+ });
1143
+ return this.refreshPromise;
1144
+ }
1145
+
1146
+ loadAuthStateFromStorage() {
1147
+ const previousRefreshToken = this.refreshToken;
1148
+ const previousAccessToken = this.token;
1149
+ const storedAuthState = readStoredAuthState(this.id);
1150
+ if (!storedAuthState) {
1151
+ return false;
1152
+ }
1153
+ const nextState = normalizeAuthState(storedAuthState);
1154
+ const changed = (
1155
+ nextState.refreshToken !== previousRefreshToken
1156
+ || nextState.accessToken !== previousAccessToken
1157
+ );
1158
+ if (!changed) {
1159
+ return false;
1160
+ }
1161
+ this.applyAuthState(nextState);
1162
+ return true;
1163
+ }
1164
+
1165
+ async ensureActiveAccessToken() {
1166
+ if (this.token && !isIsoExpired(this.accessTokenExpiresAt, 30_000)) {
1167
+ return true;
1168
+ }
1169
+ if (this.refreshToken) {
1170
+ return this.refreshAuth();
1171
+ }
1172
+ return false;
1173
+ }
1174
+
1175
+ async getAuthSessions() {
1176
+ const response = await this.fetch('/api/auth/sessions');
1177
+ const data = await response.json();
1178
+ return Array.isArray(data?.sessions) ? data.sessions : [];
1179
+ }
1180
+
1181
+ async revokeAuthSession(sessionId) {
1182
+ const response = await this.fetch(
1183
+ `/api/auth/sessions/${encodeURIComponent(sessionId)}`,
1184
+ { method: 'DELETE' }
1185
+ );
1186
+ return response.ok;
1187
+ }
1188
+
1189
+ async revokeOtherAuthSessions() {
1190
+ const response = await this.fetch('/api/auth/logout-others', {
1191
+ method: 'POST'
1192
+ });
1193
+ return response.ok;
1194
+ }
1195
+
1196
+ async logoutCurrentAuthSession() {
1197
+ try {
1198
+ await this.fetchWithoutAuth('/api/auth/logout', {
1199
+ method: 'POST',
1200
+ headers: {
1201
+ ...this.getHeaders(),
1202
+ 'Content-Type': 'application/json'
1203
+ },
1204
+ body: JSON.stringify({
1205
+ refreshToken: this.refreshToken || ''
1206
+ })
1207
+ });
1208
+ } catch {
1209
+ // Local logout should still complete if the network is gone.
1210
+ }
1211
+ this.clearAuth();
1212
+ renderServerControls();
1213
+ if (this.isPrimary) {
1214
+ auth.showLoginModal('Signed out.');
1215
+ }
1216
+ }
1217
+
976
1218
  clearAuth() {
977
- this.setToken('');
1219
+ this.token = '';
1220
+ this.accessTokenExpiresAt = '';
1221
+ this.refreshToken = '';
1222
+ this.refreshTokenExpiresAt = '';
1223
+ this.syncAuthPersistence();
1224
+ this.updateAuthFlags();
978
1225
  this.needsAccessLogin = false;
979
1226
  this.accessLoginUrl = '';
980
1227
  this.agentStateLoaded = false;
981
1228
  this.stopHeartbeat();
982
- if (!this.isPrimary) {
983
- syncServerList().catch(() => {});
984
- }
985
1229
  }
986
1230
 
987
- async fetch(path, options = {}) {
988
- const headers = {
989
- ...options.headers,
990
- ...this.getHeaders()
991
- };
1231
+ async fetchWithoutAuth(path, options = {}) {
992
1232
  const response = await fetch(this.resolveUrl(path), {
993
1233
  ...options,
994
- headers,
1234
+ headers: { ...(options.headers || {}) },
995
1235
  credentials: options.credentials || 'include',
996
1236
  redirect: options.redirect || (this.isPrimary ? 'follow' : 'manual')
997
1237
  });
@@ -1001,6 +1241,34 @@ class ServerClient {
1001
1241
  error.code = 'ACCESS_REDIRECT';
1002
1242
  throw error;
1003
1243
  }
1244
+ return response;
1245
+ }
1246
+
1247
+ async fetch(path, options = {}) {
1248
+ const hadAccess = await this.ensureActiveAccessToken();
1249
+ if (!hadAccess) {
1250
+ this.handleUnauthorized();
1251
+ throw new Error('Unauthorized');
1252
+ }
1253
+
1254
+ const requestWithCurrentToken = async () => {
1255
+ const headers = {
1256
+ ...options.headers,
1257
+ ...this.getHeaders()
1258
+ };
1259
+ return this.fetchWithoutAuth(path, {
1260
+ ...options,
1261
+ headers
1262
+ });
1263
+ };
1264
+
1265
+ let response = await requestWithCurrentToken();
1266
+ if (response.status === 401) {
1267
+ const refreshed = await this.refreshAuth();
1268
+ if (refreshed) {
1269
+ response = await requestWithCurrentToken();
1270
+ }
1271
+ }
1004
1272
  if (response.status === 401) {
1005
1273
  this.handleUnauthorized();
1006
1274
  throw new Error('Unauthorized');
@@ -1022,7 +1290,7 @@ class ServerClient {
1022
1290
  renderServerControls();
1023
1291
  auth.showLoginModal(message || 'Authentication required.');
1024
1292
  } else {
1025
- // Keep sub-host token untouched; only stop sync and require manual reconnect.
1293
+ this.clearAuth();
1026
1294
  this.stopHeartbeat();
1027
1295
  this.nextSyncAt = 0;
1028
1296
  setStatus(this, 'reconnecting');
@@ -8743,6 +9011,7 @@ class Session {
8743
9011
  this.boundTerminalClaimTextarea = null;
8744
9012
  this.boundTerminalClaimHandler = null;
8745
9013
  this.wrapperElement = null;
9014
+ this.connectPromise = null;
8746
9015
  this._createTerminals();
8747
9016
 
8748
9017
  this.connect();
@@ -9224,46 +9493,68 @@ class Session {
9224
9493
  connect() {
9225
9494
  if (!this.server.isAuthenticated) return;
9226
9495
 
9227
- // Prevent duplicate connection attempts
9228
- if (this.socket && (this.socket.readyState === WebSocket.OPEN || this.socket.readyState === WebSocket.CONNECTING)) {
9496
+ if (
9497
+ this.socket
9498
+ && (
9499
+ this.socket.readyState === WebSocket.OPEN
9500
+ || this.socket.readyState === WebSocket.CONNECTING
9501
+ )
9502
+ ) {
9229
9503
  return;
9230
9504
  }
9231
-
9232
- const endpoint = this.server.resolveWsUrl(this.id, this.server.token);
9233
- try {
9234
- this.socket = new WebSocket(endpoint);
9235
- } catch (error) {
9236
- const hostName = getDisplayHost(this.server);
9237
- console.error(`[WS] Failed to connect ${hostName}:`, error);
9238
- setStatus(this.server, 'reconnecting');
9239
- if (error?.name === 'SecurityError') {
9240
- alert(
9241
- `${hostName} WebSocket blocked in HTTPS context. `
9242
- + 'Use HTTPS/WSS endpoint for this host.',
9243
- { type: 'warning', title: 'Connection' }
9244
- );
9245
- }
9505
+ if (this.connectPromise) {
9246
9506
  return;
9247
9507
  }
9248
9508
 
9249
- this.socket.addEventListener('open', () => {
9250
- this.reconnectAttempts = 0;
9251
- if (state.activeSessionKey === this.key) this.reportResize();
9252
- });
9509
+ this.connectPromise = (async () => {
9510
+ const hasAccess = await this.server.ensureActiveAccessToken();
9511
+ if (!hasAccess) {
9512
+ return;
9513
+ }
9253
9514
 
9254
- this.socket.addEventListener('message', (event) => {
9515
+ const endpoint = this.server.resolveWsUrl(
9516
+ this.id,
9517
+ this.server.token
9518
+ );
9255
9519
  try {
9256
- this.handleMessage(JSON.parse(event.data));
9257
- } catch { /* ignore */ }
9258
- });
9520
+ this.socket = new WebSocket(endpoint);
9521
+ } catch (error) {
9522
+ const hostName = getDisplayHost(this.server);
9523
+ console.error(`[WS] Failed to connect ${hostName}:`, error);
9524
+ setStatus(this.server, 'reconnecting');
9525
+ if (error?.name === 'SecurityError') {
9526
+ alert(
9527
+ `${hostName} WebSocket blocked in HTTPS context. `
9528
+ + 'Use HTTPS/WSS endpoint for this host.',
9529
+ { type: 'warning', title: 'Connection' }
9530
+ );
9531
+ }
9532
+ return;
9533
+ }
9259
9534
 
9260
- this.socket.addEventListener('close', () => {
9261
- // We rely on the global heartbeat (syncSessions) to handle reconnection.
9262
- // This event listener just allows the socket to be garbage collected.
9263
- });
9264
-
9265
- this.socket.addEventListener('error', () => {
9266
- // Often fires on 401 or connection refused
9535
+ this.socket.addEventListener('open', () => {
9536
+ this.reconnectAttempts = 0;
9537
+ if (state.activeSessionKey === this.key) this.reportResize();
9538
+ });
9539
+
9540
+ this.socket.addEventListener('message', (event) => {
9541
+ try {
9542
+ this.handleMessage(JSON.parse(event.data));
9543
+ } catch {
9544
+ // Ignore malformed websocket payloads.
9545
+ }
9546
+ });
9547
+
9548
+ this.socket.addEventListener('close', () => {
9549
+ // We rely on the global heartbeat (syncSessions) to handle reconnection.
9550
+ // This event listener just allows the socket to be garbage collected.
9551
+ });
9552
+
9553
+ this.socket.addEventListener('error', () => {
9554
+ // Often fires on 401 or connection refused.
9555
+ });
9556
+ })().finally(() => {
9557
+ this.connectPromise = null;
9267
9558
  });
9268
9559
  }
9269
9560
 
@@ -9593,6 +9884,7 @@ class AgentTab {
9593
9884
  this.resumeSessions = [];
9594
9885
  this.resumeSessionsLoadedAt = 0;
9595
9886
  this.resumeSessionsPromise = null;
9887
+ this.connectPromise = null;
9596
9888
  this.update(data);
9597
9889
  this.connect();
9598
9890
  }
@@ -9786,26 +10078,38 @@ class AgentTab {
9786
10078
  ) {
9787
10079
  return;
9788
10080
  }
10081
+ if (this.connectPromise) {
10082
+ return;
10083
+ }
9789
10084
 
9790
- const endpoint = this.server.resolveAgentWsUrl(
9791
- this.id,
9792
- this.server.token
9793
- );
9794
- this.socket = new WebSocket(endpoint);
9795
- this.socket.addEventListener('message', (event) => {
9796
- try {
9797
- this.handleMessage(JSON.parse(event.data));
9798
- } catch {
9799
- // Ignore malformed agent payloads.
9800
- }
9801
- });
9802
- this.socket.addEventListener('close', () => {
9803
- this.socket = null;
9804
- if (this.status === 'running') {
9805
- this.status = 'disconnected';
9806
- this.busy = false;
9807
- this.notifyUi();
10085
+ this.connectPromise = (async () => {
10086
+ const hasAccess = await this.server.ensureActiveAccessToken();
10087
+ if (!hasAccess) {
10088
+ return;
9808
10089
  }
10090
+
10091
+ const endpoint = this.server.resolveAgentWsUrl(
10092
+ this.id,
10093
+ this.server.token
10094
+ );
10095
+ this.socket = new WebSocket(endpoint);
10096
+ this.socket.addEventListener('message', (event) => {
10097
+ try {
10098
+ this.handleMessage(JSON.parse(event.data));
10099
+ } catch {
10100
+ // Ignore malformed agent payloads.
10101
+ }
10102
+ });
10103
+ this.socket.addEventListener('close', () => {
10104
+ this.socket = null;
10105
+ if (this.status === 'running') {
10106
+ this.status = 'disconnected';
10107
+ this.busy = false;
10108
+ this.notifyUi();
10109
+ }
10110
+ });
10111
+ })().finally(() => {
10112
+ this.connectPromise = null;
9809
10113
  });
9810
10114
  }
9811
10115
 
@@ -14486,13 +14790,11 @@ function createServerClient(data, { isPrimary = false } = {}) {
14486
14790
  if (data.host !== undefined) {
14487
14791
  existing.host = host;
14488
14792
  }
14489
- if (typeof data.token === 'string') {
14490
- existing.setToken(data.token);
14491
- }
14492
14793
  resetServerEndpoint(existing, normalized);
14493
14794
  if (isPrimary) {
14494
14795
  existing.isPrimary = true;
14495
14796
  }
14797
+ existing.loadStoredAuth(data);
14496
14798
  return existing;
14497
14799
  }
14498
14800
  const safeId = typeof id === 'string' ? id.trim() : '';
@@ -15675,7 +15977,7 @@ async function removeServer(serverId, { persist = true } = {}) {
15675
15977
  }
15676
15978
 
15677
15979
  state.servers.delete(serverId);
15678
- localStorage.removeItem(buildTokenStorageKey(serverId));
15980
+ localStorage.removeItem(buildAuthStateStorageKey(serverId));
15679
15981
  if (persist) {
15680
15982
  await syncServerList();
15681
15983
  }
@@ -15883,6 +16185,178 @@ function moveConfirmModalFocus(delta) {
15883
16185
  buttons[nextIndex].focus({ preventScroll: true });
15884
16186
  }
15885
16187
 
16188
+ const authSessionsModalState = {
16189
+ server: null,
16190
+ loading: false,
16191
+ sessions: []
16192
+ };
16193
+
16194
+ function formatAuthSessionTime(value) {
16195
+ const raw = typeof value === 'string' ? value.trim() : '';
16196
+ if (!raw) return 'Unknown';
16197
+ const date = new Date(raw);
16198
+ if (Number.isNaN(date.getTime())) return 'Unknown';
16199
+ return date.toLocaleString();
16200
+ }
16201
+
16202
+ function summarizeAuthUserAgent(value) {
16203
+ const raw = typeof value === 'string' ? value.trim() : '';
16204
+ if (!raw) return 'Unknown client';
16205
+ const platform = /iphone|ipad|ios/i.test(raw)
16206
+ ? 'iOS'
16207
+ : (/android/i.test(raw) ? 'Android' : '');
16208
+ const browser = /firefox/i.test(raw)
16209
+ ? 'Firefox'
16210
+ : (/edg\//i.test(raw)
16211
+ ? 'Edge'
16212
+ : (/chrome|crios/i.test(raw)
16213
+ ? 'Chrome'
16214
+ : (/safari/i.test(raw) ? 'Safari' : 'Browser')));
16215
+ const label = [platform, browser].filter(Boolean).join(' ');
16216
+ return label || raw.slice(0, 80);
16217
+ }
16218
+
16219
+ function closeAuthSessionsModal() {
16220
+ if (!authSessionsModal) return;
16221
+ authSessionsModal.style.display = 'none';
16222
+ authSessionsModalState.server = null;
16223
+ authSessionsModalState.loading = false;
16224
+ authSessionsModalState.sessions = [];
16225
+ if (authSessionsError) {
16226
+ authSessionsError.textContent = '';
16227
+ }
16228
+ }
16229
+
16230
+ function isAuthSessionsModalOpen() {
16231
+ return !!(
16232
+ authSessionsModal
16233
+ && authSessionsModal.style.display === 'flex'
16234
+ );
16235
+ }
16236
+
16237
+ function renderAuthSessionsModal() {
16238
+ const server = authSessionsModalState.server;
16239
+ if (
16240
+ !authSessionsModal
16241
+ || !authSessionsTitle
16242
+ || !authSessionsDescription
16243
+ || !authSessionsList
16244
+ ) {
16245
+ return;
16246
+ }
16247
+ authSessionsTitle.textContent = 'Login sessions';
16248
+ authSessionsDescription.textContent = server
16249
+ ? `Active sessions for ${getDisplayHost(server)}.`
16250
+ : '';
16251
+ authSessionsList.innerHTML = '';
16252
+
16253
+ if (authSessionsModalState.loading) {
16254
+ const row = document.createElement('div');
16255
+ row.className = 'auth-session-empty';
16256
+ row.textContent = 'Loading sessions...';
16257
+ authSessionsList.appendChild(row);
16258
+ } else if (!authSessionsModalState.sessions.length) {
16259
+ const row = document.createElement('div');
16260
+ row.className = 'auth-session-empty';
16261
+ row.textContent = 'No login sessions found.';
16262
+ authSessionsList.appendChild(row);
16263
+ } else {
16264
+ for (const session of authSessionsModalState.sessions) {
16265
+ const row = document.createElement('div');
16266
+ row.className = 'auth-session-row';
16267
+ if (session.current) {
16268
+ row.classList.add('current');
16269
+ }
16270
+
16271
+ const info = document.createElement('div');
16272
+ info.className = 'auth-session-info';
16273
+
16274
+ const title = document.createElement('div');
16275
+ title.className = 'auth-session-title';
16276
+ title.textContent = session.current
16277
+ ? 'Current session'
16278
+ : summarizeAuthUserAgent(session.userAgent);
16279
+ info.appendChild(title);
16280
+
16281
+ const meta = document.createElement('div');
16282
+ meta.className = 'auth-session-meta';
16283
+ meta.textContent = [
16284
+ `Last used: ${formatAuthSessionTime(session.lastSeenAt)}`,
16285
+ `Expires: ${formatAuthSessionTime(session.refreshExpiresAt)}`,
16286
+ `ID: ${String(session.id || '').slice(0, 8)}`
16287
+ ].join(' · ');
16288
+ info.appendChild(meta);
16289
+ row.appendChild(info);
16290
+
16291
+ const button = document.createElement('button');
16292
+ button.type = 'button';
16293
+ button.className = 'auth-session-revoke danger-button';
16294
+ button.textContent = session.current ? 'Log out' : 'Revoke';
16295
+ button.onclick = async () => {
16296
+ if (!server) return;
16297
+ const confirmed = await showConfirmModal({
16298
+ title: session.current ? 'Log out?' : 'Revoke session?',
16299
+ message: session.current
16300
+ ? 'This will sign out this browser.'
16301
+ : 'This device will be signed out on its next request.',
16302
+ confirmLabel: session.current ? 'Log out' : 'Revoke',
16303
+ danger: true,
16304
+ returnFocus: button
16305
+ });
16306
+ if (!confirmed) return;
16307
+ if (session.current) {
16308
+ await server.logoutCurrentAuthSession();
16309
+ closeAuthSessionsModal();
16310
+ return;
16311
+ }
16312
+ await server.revokeAuthSession(session.id);
16313
+ await loadAuthSessionsModal(server);
16314
+ };
16315
+ row.appendChild(button);
16316
+ authSessionsList.appendChild(row);
16317
+ }
16318
+ }
16319
+
16320
+ if (authSessionsRevokeOthers) {
16321
+ const otherCount = authSessionsModalState.sessions.filter(
16322
+ (session) => !session.current
16323
+ ).length;
16324
+ authSessionsRevokeOthers.disabled = (
16325
+ authSessionsModalState.loading
16326
+ || !server
16327
+ || otherCount === 0
16328
+ );
16329
+ }
16330
+ }
16331
+
16332
+ async function loadAuthSessionsModal(server) {
16333
+ if (!server) return;
16334
+ authSessionsModalState.server = server;
16335
+ authSessionsModalState.loading = true;
16336
+ authSessionsModalState.sessions = [];
16337
+ if (authSessionsError) {
16338
+ authSessionsError.textContent = '';
16339
+ }
16340
+ renderAuthSessionsModal();
16341
+ try {
16342
+ authSessionsModalState.sessions = await server.getAuthSessions();
16343
+ } catch (error) {
16344
+ console.error(error);
16345
+ if (authSessionsError) {
16346
+ authSessionsError.textContent = 'Failed to load login sessions.';
16347
+ }
16348
+ } finally {
16349
+ authSessionsModalState.loading = false;
16350
+ renderAuthSessionsModal();
16351
+ }
16352
+ }
16353
+
16354
+ async function openAuthSessionsModal(server) {
16355
+ if (!authSessionsModal || !server) return;
16356
+ authSessionsModal.style.display = 'flex';
16357
+ await loadAuthSessionsModal(server);
16358
+ }
16359
+
15886
16360
  function renderServerControls() {
15887
16361
  updateDocumentTitle();
15888
16362
  if (!serverControlsEl) return;
@@ -15972,6 +16446,24 @@ function renderServerControls() {
15972
16446
  };
15973
16447
 
15974
16448
  row.appendChild(mainButton);
16449
+ if (server.isAuthenticated && !requiresReconnectAction) {
16450
+ row.classList.add('has-auth-sessions');
16451
+ const sessionsButton = document.createElement('button');
16452
+ sessionsButton.type = 'button';
16453
+ sessionsButton.className = 'server-auth-button';
16454
+ sessionsButton.setAttribute(
16455
+ 'aria-label',
16456
+ `Manage login sessions for ${hostName}`
16457
+ );
16458
+ sessionsButton.title = `Manage login sessions for ${hostName}`;
16459
+ sessionsButton.innerHTML = AUTH_SESSIONS_ICON_SVG;
16460
+ sessionsButton.onclick = async (event) => {
16461
+ event.preventDefault();
16462
+ event.stopPropagation();
16463
+ await openAuthSessionsModal(server);
16464
+ };
16465
+ row.appendChild(sessionsButton);
16466
+ }
15975
16467
  if (!server.isPrimary) {
15976
16468
  const deleteButton = document.createElement('button');
15977
16469
  deleteButton.type = 'button';
@@ -16063,6 +16555,26 @@ document.addEventListener('visibilitychange', () => {
16063
16555
  }
16064
16556
  editorManager.updateTreeAutoRefresh();
16065
16557
  });
16558
+ window.addEventListener('storage', (event) => {
16559
+ if (!event.key) {
16560
+ return;
16561
+ }
16562
+ if (event.key.startsWith(DEPRECATED_AUTH_TOKEN_STORAGE_PREFIX)) {
16563
+ clearDeprecatedPasswordHashAuthStorage();
16564
+ return;
16565
+ }
16566
+ const authStatePrefix = 'tabminal_auth_state:';
16567
+ if (!event.key.startsWith(authStatePrefix)) {
16568
+ return;
16569
+ }
16570
+ const serverId = event.key.slice(authStatePrefix.length);
16571
+ const server = state.servers.get(serverId);
16572
+ if (!server) {
16573
+ return;
16574
+ }
16575
+ server.loadStoredAuth();
16576
+ renderServerControls();
16577
+ });
16066
16578
  // #endregion
16067
16579
 
16068
16580
  // #region Toast Manager
@@ -16405,27 +16917,15 @@ if (
16405
16917
  }
16406
16918
 
16407
16919
  try {
16408
- let authToken = '';
16409
- if (password) {
16410
- authToken = await hashPassword(password);
16411
- } else {
16412
- const candidates = [];
16413
- if (mode === 'reconnect' && server) {
16414
- candidates.push(server);
16415
- }
16416
- candidates.push(getActiveServer(), getMainServer());
16417
- const inheritedServer = candidates.find(item => item?.token) || null;
16418
- authToken = inheritedServer?.token || '';
16419
- if (!authToken) {
16420
- addServerError.textContent = 'No inherited password available.';
16421
- if (createdNewServer && !server.isPrimary) {
16422
- await removeServer(server.id, { persist: false });
16423
- }
16424
- return;
16920
+ if (!password) {
16921
+ addServerError.textContent = 'Password required.';
16922
+ if (createdNewServer && !server.isPrimary) {
16923
+ await removeServer(server.id, { persist: false });
16425
16924
  }
16925
+ return;
16426
16926
  }
16427
16927
 
16428
- await server.loginWithToken(authToken);
16928
+ await server.login(password);
16429
16929
  await fetchExpandedPaths(server);
16430
16930
  await syncServer(server);
16431
16931
  server.startHeartbeat();
@@ -16460,6 +16960,61 @@ if (
16460
16960
  });
16461
16961
  }
16462
16962
 
16963
+ if (
16964
+ authSessionsModal
16965
+ && authSessionsClose
16966
+ && authSessionsRevokeOthers
16967
+ ) {
16968
+ authSessionsClose.addEventListener('click', () => {
16969
+ closeAuthSessionsModal();
16970
+ });
16971
+
16972
+ authSessionsModal.addEventListener('click', (event) => {
16973
+ if (event.target === authSessionsModal) {
16974
+ closeAuthSessionsModal();
16975
+ }
16976
+ });
16977
+
16978
+ authSessionsModal.addEventListener('keydown', (event) => {
16979
+ if (event.key === 'Escape') {
16980
+ event.preventDefault();
16981
+ closeAuthSessionsModal();
16982
+ }
16983
+ });
16984
+
16985
+ document.addEventListener('keydown', (event) => {
16986
+ if (event.key !== 'Escape' || !isAuthSessionsModalOpen()) {
16987
+ return;
16988
+ }
16989
+ event.preventDefault();
16990
+ event.stopImmediatePropagation();
16991
+ closeAuthSessionsModal();
16992
+ }, true);
16993
+
16994
+ authSessionsRevokeOthers.addEventListener('click', async () => {
16995
+ const server = authSessionsModalState.server;
16996
+ if (!server) return;
16997
+ const confirmed = await showConfirmModal({
16998
+ title: 'Log out other sessions?',
16999
+ message: 'All other devices will be signed out.',
17000
+ confirmLabel: 'Log out others',
17001
+ danger: true,
17002
+ returnFocus: authSessionsRevokeOthers
17003
+ });
17004
+ if (!confirmed) return;
17005
+ try {
17006
+ await server.revokeOtherAuthSessions();
17007
+ await loadAuthSessionsModal(server);
17008
+ } catch (error) {
17009
+ console.error(error);
17010
+ if (authSessionsError) {
17011
+ authSessionsError.textContent =
17012
+ 'Failed to revoke other sessions.';
17013
+ }
17014
+ }
17015
+ });
17016
+ }
17017
+
16463
17018
  if (
16464
17019
  confirmModal
16465
17020
  && confirmModalCancel
@@ -16558,6 +17113,7 @@ if (loginForm && passwordInput) {
16558
17113
  await initApp();
16559
17114
  } catch (err) {
16560
17115
  console.error(err);
17116
+ loginError.textContent = err?.message || 'Authentication failed.';
16561
17117
  }
16562
17118
  });
16563
17119
  }
@@ -16640,6 +17196,8 @@ async function initApp() {
16640
17196
  const mainServer = getMainServer();
16641
17197
  if (!mainServer) return;
16642
17198
 
17199
+ await mainServer.bootstrapAuth();
17200
+
16643
17201
  if (!mainServer.isAuthenticated) {
16644
17202
  auth.showLoginModal();
16645
17203
  return;
@@ -16649,6 +17207,7 @@ async function initApp() {
16649
17207
  await hydrateServerRegistry();
16650
17208
 
16651
17209
  for (const server of state.servers.values()) {
17210
+ await server.bootstrapAuth();
16652
17211
  if (!server.isAuthenticated) continue;
16653
17212
  await fetchExpandedPaths(server);
16654
17213
  await syncServer(server);