tabminal 3.0.26 → 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/AGENTS.md +13 -7
- package/package.json +1 -1
- package/public/app.js +682 -123
- package/public/index.html +28 -0
- package/public/modules/url-auth.js +57 -35
- package/public/styles.css +113 -0
- package/src/auth.mjs +471 -37
- package/src/persistence.mjs +75 -0
- package/src/server.mjs +99 -1
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
|
-
|
|
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
|
-
|
|
19
|
+
buildAuthStateStorageKey,
|
|
18
20
|
makeSessionKey,
|
|
19
21
|
splitSessionKey,
|
|
20
22
|
hashPassword
|
|
21
|
-
}
|
|
22
|
-
|
|
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
|
-
}
|
|
28
|
-
|
|
29
|
+
} = await import(`./modules/session-meta.js${LOCAL_MODULE_VERSION}`);
|
|
30
|
+
const {
|
|
29
31
|
NotificationManager,
|
|
30
32
|
ToastManager
|
|
31
|
-
}
|
|
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
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
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
|
-
|
|
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
|
-
|
|
896
|
-
|
|
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
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
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:
|
|
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
|
|
964
|
-
await this.
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
9228
|
-
|
|
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.
|
|
9250
|
-
|
|
9251
|
-
if (
|
|
9252
|
-
|
|
9509
|
+
this.connectPromise = (async () => {
|
|
9510
|
+
const hasAccess = await this.server.ensureActiveAccessToken();
|
|
9511
|
+
if (!hasAccess) {
|
|
9512
|
+
return;
|
|
9513
|
+
}
|
|
9253
9514
|
|
|
9254
|
-
|
|
9515
|
+
const endpoint = this.server.resolveWsUrl(
|
|
9516
|
+
this.id,
|
|
9517
|
+
this.server.token
|
|
9518
|
+
);
|
|
9255
9519
|
try {
|
|
9256
|
-
this.
|
|
9257
|
-
} catch {
|
|
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
|
-
|
|
9261
|
-
|
|
9262
|
-
|
|
9263
|
-
|
|
9264
|
-
|
|
9265
|
-
|
|
9266
|
-
|
|
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
|
-
|
|
9791
|
-
this.
|
|
9792
|
-
|
|
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(
|
|
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
|
-
|
|
16409
|
-
|
|
16410
|
-
|
|
16411
|
-
|
|
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.
|
|
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);
|