cynx-lib 1.3.84 → 1.3.86
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/gen.js +77 -77
- package/package.json +5 -1
- package/ui/components/Drawer.jsx +208 -208
- package/ui/components/Pagination.jsx +145 -145
- package/ui/components/PrivacyBlur.jsx +603 -603
- package/utils/index.cjs +5 -0
- package/utils/index.js +22 -20
- package/utils/utils/duration.js +139 -139
- package/utils/utils/oidc.js +214 -214
- package/utils/utils/realtime.js +1634 -1634
- package/utils/utils/session.cjs +87 -9
- package/utils/utils/session.js +81 -8
- package/utils/utils/session_manager.cjs +490 -0
- package/utils/utils/session_manager.js +549 -0
- package/utils/utils/storage.js +393 -393
- package/utils/utils/ui.js +111 -111
- package/vue/components/Badge.vue +79 -79
- package/vue/components/Button.vue +209 -209
- package/vue/components/Checkbox.vue +103 -103
- package/vue/components/CircularProgress.vue +383 -383
- package/vue/components/Divider.vue +94 -94
- package/vue/components/Drawer.vue +243 -243
- package/vue/components/InfoBadge.vue +223 -223
- package/vue/components/Input.vue +341 -341
- package/vue/components/LinearProgress.vue +268 -268
- package/vue/components/Modal.vue +226 -226
- package/vue/components/Pagination.vue +199 -199
- package/vue/components/Select.vue +713 -713
- package/vue/components/Sidebar.vue +955 -955
- package/vue/components/Skeleton.vue +91 -91
- package/vue/components/Spinner.vue +263 -263
- package/vue/components/StatusTag.vue +89 -89
- package/vue/components/TabBar.vue +176 -176
- package/vue/components/Table.vue +388 -388
- package/vue/components/TimePicker.vue +399 -399
- package/vue/components/Toggle.vue +115 -115
- package/vue/components/XTable.vue +119 -119
- package/vue/composables/useAutoSkeletonRows.ts +71 -71
- package/vue/index.d.ts +67 -67
- package/vue/index.js +86 -86
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cynx/utils — Universal Enterprise Session Manager (session_manager.js)
|
|
3
|
+
*
|
|
4
|
+
* Universal PostgreSQL and in-memory session management engine with dual-token architecture:
|
|
5
|
+
* - v2: Compound token format (`st_${sessionId}$${secretCode}`) for O(1) indexed lookups
|
|
6
|
+
* and safe non-secret Real-Time / WebSocket event targeting.
|
|
7
|
+
* - v1: Legacy flat token format (`st_${hex}`) for seamless backward compatibility.
|
|
8
|
+
*
|
|
9
|
+
* STRICT NO EMOJIS!
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { genUid, getRandomBytes } from './uid.js';
|
|
13
|
+
import { parseDurationMs } from './duration.js';
|
|
14
|
+
import { deriveDeviceName, genSessionToken } from './session.js';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Format a compound v2 token: prefix + sessionId + '$' + secretCode
|
|
18
|
+
* Example: "st_2SuO33NNKhXd$d9e1807390014a5b6c7d8e9f01234567"
|
|
19
|
+
*
|
|
20
|
+
* @param {string} sessionId Unique session row identifier (ID)
|
|
21
|
+
* @param {string} secret Cryptographically secure random secret
|
|
22
|
+
* @param {string} [prefix='st_'] Token prefix
|
|
23
|
+
* @returns {string}
|
|
24
|
+
*/
|
|
25
|
+
export function formatToken(sessionId, secret, prefix = 'st_') {
|
|
26
|
+
return `${prefix}${sessionId}$${secret}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Parse an incoming token string.
|
|
31
|
+
* Automatically recognizes v2 compound tokens and legacy v1 flat tokens.
|
|
32
|
+
*
|
|
33
|
+
* @param {string} token Raw token string or Bearer header
|
|
34
|
+
* @param {string} [prefix='st_'] Expected token prefix
|
|
35
|
+
* @returns {{ version: number, sessionId: string|null, secret: string|null, prefix: string, raw: string, isValid: boolean }}
|
|
36
|
+
*/
|
|
37
|
+
export function parseToken(token, prefix = 'st_') {
|
|
38
|
+
if (!token || typeof token !== 'string') {
|
|
39
|
+
return { version: 0, sessionId: null, secret: null, prefix, raw: '', isValid: false };
|
|
40
|
+
}
|
|
41
|
+
const clean = token.replace(/^Bearer\s+/i, '').trim();
|
|
42
|
+
if (!clean) {
|
|
43
|
+
return { version: 0, sessionId: null, secret: null, prefix, raw: '', isValid: false };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (clean.includes('$')) {
|
|
47
|
+
const parts = clean.split('$');
|
|
48
|
+
const head = parts[0];
|
|
49
|
+
const secret = parts.slice(1).join('$');
|
|
50
|
+
const sessionId = head.startsWith(prefix) ? head.slice(prefix.length) : head;
|
|
51
|
+
return {
|
|
52
|
+
version: 2,
|
|
53
|
+
sessionId,
|
|
54
|
+
secret,
|
|
55
|
+
prefix,
|
|
56
|
+
raw: clean,
|
|
57
|
+
isValid: Boolean(sessionId && secret),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Legacy v1 token (flat string without '$')
|
|
62
|
+
return {
|
|
63
|
+
version: 1,
|
|
64
|
+
sessionId: null,
|
|
65
|
+
secret: clean,
|
|
66
|
+
prefix,
|
|
67
|
+
raw: clean,
|
|
68
|
+
isValid: true,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Extract public sessionId from token if available.
|
|
74
|
+
* Returns null for legacy v1 tokens.
|
|
75
|
+
*
|
|
76
|
+
* @param {string} token
|
|
77
|
+
* @param {string} [prefix='st_']
|
|
78
|
+
* @returns {string|null}
|
|
79
|
+
*/
|
|
80
|
+
export function getSessionId(token, prefix = 'st_') {
|
|
81
|
+
const parsed = parseToken(token, prefix);
|
|
82
|
+
return parsed.sessionId;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Universally extract session token from HTTP Request object (Express, Fastify, Next.js, Node).
|
|
87
|
+
* Inspects Authorization Bearer header, x-auth-token, x-session-token, and query parameter.
|
|
88
|
+
*
|
|
89
|
+
* @param {object} req HTTP request object
|
|
90
|
+
* @returns {string} Clean session token string or empty string
|
|
91
|
+
*/
|
|
92
|
+
export function extractToken(req) {
|
|
93
|
+
if (!req) return '';
|
|
94
|
+
const headers = req.headers || {};
|
|
95
|
+
const auth = headers.authorization || headers.Authorization || '';
|
|
96
|
+
if (typeof auth === 'string' && auth.startsWith('Bearer ')) {
|
|
97
|
+
return auth.split(' ')[1].trim();
|
|
98
|
+
}
|
|
99
|
+
const custom = headers['x-auth-token'] || headers['x-session-token'] || headers['x-token'] || '';
|
|
100
|
+
if (typeof custom === 'string' && custom.trim()) {
|
|
101
|
+
return custom.trim();
|
|
102
|
+
}
|
|
103
|
+
if (req.query && req.query.token) {
|
|
104
|
+
return String(req.query.token).trim();
|
|
105
|
+
}
|
|
106
|
+
return '';
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Universal Session Manager Engine.
|
|
111
|
+
*/
|
|
112
|
+
export class SessionManager {
|
|
113
|
+
constructor(options = {}) {
|
|
114
|
+
this.options = {
|
|
115
|
+
query: options.query || null,
|
|
116
|
+
sessionsTable: options.sessionsTable || 'user_sessions',
|
|
117
|
+
usersTable: options.usersTable || 'users',
|
|
118
|
+
rolesTable: options.rolesTable || 'roles',
|
|
119
|
+
settingsTable: options.settingsTable || 'system_settings',
|
|
120
|
+
superRole: (options.superRole || 'superadmin').toLowerCase(),
|
|
121
|
+
tokenPrefix: options.tokenPrefix || 'st_',
|
|
122
|
+
tokenVersion: options.tokenVersion || 2,
|
|
123
|
+
secretBytes: options.secretBytes || 32,
|
|
124
|
+
idLength: options.idLength || 12,
|
|
125
|
+
defaultAbsoluteTimeoutMs: options.defaultAbsoluteTimeoutMs || 30 * 24 * 60 * 60 * 1000,
|
|
126
|
+
inactivityCacheTtlMs: options.inactivityCacheTtlMs || 30000,
|
|
127
|
+
jwtSecret: options.jwtSecret || (typeof process !== 'undefined' ? process.env.JWT_SECRET : ''),
|
|
128
|
+
...options,
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
this._inactTimeoutMs = null;
|
|
132
|
+
this._inactFetchedAt = 0;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async _query(sql, params = []) {
|
|
136
|
+
if (typeof this.options.query !== 'function') {
|
|
137
|
+
throw new Error('[cynx/session] Database query function not configured in SessionManager.');
|
|
138
|
+
}
|
|
139
|
+
return this.options.query(sql, params);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
parseToken(token) {
|
|
143
|
+
return parseToken(token, this.options.tokenPrefix);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
getSessionId(token) {
|
|
147
|
+
return getSessionId(token, this.options.tokenPrefix);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
extractToken(req) {
|
|
151
|
+
return extractToken(req);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
formatToken(sessionId, secret) {
|
|
155
|
+
return formatToken(sessionId, secret, this.options.tokenPrefix);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async getInactivityTimeoutMs() {
|
|
159
|
+
const now = Date.now();
|
|
160
|
+
if (this._inactTimeoutMs !== null && now - this._inactFetchedAt < this.options.inactivityCacheTtlMs) {
|
|
161
|
+
return this._inactTimeoutMs;
|
|
162
|
+
}
|
|
163
|
+
try {
|
|
164
|
+
const sql = `SELECT value FROM ${this.options.settingsTable} WHERE key = 'session_inactivity_timeout'`;
|
|
165
|
+
const sRes = await this._query(sql).catch(() => ({ rows: [] }));
|
|
166
|
+
if (sRes.rows && sRes.rows.length > 0) {
|
|
167
|
+
let val = sRes.rows[0].value;
|
|
168
|
+
if (typeof val === 'string') {
|
|
169
|
+
try { val = JSON.parse(val); } catch {}
|
|
170
|
+
}
|
|
171
|
+
this._inactTimeoutMs = parseDurationMs(val, 0);
|
|
172
|
+
} else {
|
|
173
|
+
this._inactTimeoutMs = 0;
|
|
174
|
+
}
|
|
175
|
+
} catch {
|
|
176
|
+
this._inactTimeoutMs = 0;
|
|
177
|
+
}
|
|
178
|
+
this._inactFetchedAt = now;
|
|
179
|
+
return this._inactTimeoutMs;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
clearSettingsCache() {
|
|
183
|
+
this._inactTimeoutMs = null;
|
|
184
|
+
this._inactFetchedAt = 0;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Create and persist a new session for a user.
|
|
189
|
+
*
|
|
190
|
+
* @param {string|number} userId
|
|
191
|
+
* @param {object} [req] HTTP request object for device and IP detection
|
|
192
|
+
* @param {object} [options]
|
|
193
|
+
* @returns {Promise<string>} Generated session token
|
|
194
|
+
*/
|
|
195
|
+
async createSession(userId, req = null, options = {}) {
|
|
196
|
+
const id = genUid(this.options.idLength);
|
|
197
|
+
const userAgent = (req?.headers ? req.headers['user-agent'] || '' : '').slice(0, 500);
|
|
198
|
+
const ipAddress = (req?.headers ? req.headers['x-forwarded-for'] || req.socket?.remoteAddress || '' : '').slice(0, 45);
|
|
199
|
+
const deviceName = deriveDeviceName(req, userAgent);
|
|
200
|
+
|
|
201
|
+
let token;
|
|
202
|
+
if (this.options.tokenVersion === 2) {
|
|
203
|
+
const bytes = getRandomBytes(this.options.secretBytes);
|
|
204
|
+
let hex = '';
|
|
205
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
206
|
+
hex += bytes[i].toString(16).padStart(2, '0');
|
|
207
|
+
}
|
|
208
|
+
token = formatToken(id, hex, this.options.tokenPrefix);
|
|
209
|
+
} else {
|
|
210
|
+
token = genSessionToken(this.options.secretBytes);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
let absoluteMs = this.options.defaultAbsoluteTimeoutMs;
|
|
214
|
+
try {
|
|
215
|
+
const sRes = await this._query(`SELECT value FROM ${this.options.settingsTable} WHERE key = 'session_absolute_timeout'`);
|
|
216
|
+
if (sRes.rows && sRes.rows.length > 0) {
|
|
217
|
+
let val = sRes.rows[0].value;
|
|
218
|
+
if (typeof val === 'string') {
|
|
219
|
+
try { val = JSON.parse(val); } catch {}
|
|
220
|
+
}
|
|
221
|
+
const parsed = parseDurationMs(val, absoluteMs);
|
|
222
|
+
if (parsed > 0) absoluteMs = parsed;
|
|
223
|
+
}
|
|
224
|
+
} catch {}
|
|
225
|
+
|
|
226
|
+
const expiresAt = new Date(Date.now() + absoluteMs);
|
|
227
|
+
|
|
228
|
+
const insertSql = `
|
|
229
|
+
INSERT INTO ${this.options.sessionsTable} (id, user_id, token, user_agent, device_name, ip_address, is_active, expires_at)
|
|
230
|
+
VALUES ($1, $2, $3, $4, $5, $6, true, $7)
|
|
231
|
+
`;
|
|
232
|
+
await this._query(insertSql, [id, userId, token, userAgent, deviceName, ipAddress, expiresAt]);
|
|
233
|
+
|
|
234
|
+
return token;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Validate a session token against PostgreSQL database and users table.
|
|
239
|
+
* Seamlessly resolves v2 compound tokens (O(1) lookup by primary key id) and v1 tokens.
|
|
240
|
+
*
|
|
241
|
+
* @param {string} token
|
|
242
|
+
* @param {object} [options] Options: { updateActivity: boolean }
|
|
243
|
+
* @returns {Promise<object|null>}
|
|
244
|
+
*/
|
|
245
|
+
async validateSession(token, options = {}) {
|
|
246
|
+
if (!token || typeof token !== 'string') return null;
|
|
247
|
+
const cleanToken = token.replace(/^Bearer\s+/i, '').trim();
|
|
248
|
+
if (!cleanToken) return null;
|
|
249
|
+
|
|
250
|
+
try {
|
|
251
|
+
const parsed = parseToken(cleanToken, this.options.tokenPrefix);
|
|
252
|
+
let sessionRes;
|
|
253
|
+
|
|
254
|
+
if (parsed.version === 2 && parsed.sessionId) {
|
|
255
|
+
// v2: Ultra-fast O(1) Primary Key lookup by id
|
|
256
|
+
const sql = `
|
|
257
|
+
SELECT s.id as session_id, s.user_id, s.token, s.is_active as session_active,
|
|
258
|
+
s.expires_at, s.last_activity, s.device_name, s.ip_address,
|
|
259
|
+
u.id as user_id, u.username, u.full_name, u.role, u.is_active as user_active,
|
|
260
|
+
COALESCE(u.is_disabled, false) as user_disabled,
|
|
261
|
+
u.employee_id, u.leader_id,
|
|
262
|
+
u.permissions as user_permissions,
|
|
263
|
+
r.permissions as role_permissions
|
|
264
|
+
FROM ${this.options.sessionsTable} s
|
|
265
|
+
JOIN ${this.options.usersTable} u ON u.id = s.user_id
|
|
266
|
+
LEFT JOIN ${this.options.rolesTable} r ON r.id = u.role OR r.name = u.role OR LOWER(r.name) = LOWER(u.role)
|
|
267
|
+
WHERE s.id = $1
|
|
268
|
+
`;
|
|
269
|
+
sessionRes = await this._query(sql, [parsed.sessionId]).catch(() => ({ rows: [] }));
|
|
270
|
+
} else {
|
|
271
|
+
// v1 (legacy): Direct token string matching
|
|
272
|
+
const sql = `
|
|
273
|
+
SELECT s.id as session_id, s.user_id, s.token, s.is_active as session_active,
|
|
274
|
+
s.expires_at, s.last_activity, s.device_name, s.ip_address,
|
|
275
|
+
u.id as user_id, u.username, u.full_name, u.role, u.is_active as user_active,
|
|
276
|
+
COALESCE(u.is_disabled, false) as user_disabled,
|
|
277
|
+
u.employee_id, u.leader_id,
|
|
278
|
+
u.permissions as user_permissions,
|
|
279
|
+
r.permissions as role_permissions
|
|
280
|
+
FROM ${this.options.sessionsTable} s
|
|
281
|
+
JOIN ${this.options.usersTable} u ON u.id = s.user_id
|
|
282
|
+
LEFT JOIN ${this.options.rolesTable} r ON r.id = u.role OR r.name = u.role OR LOWER(r.name) = LOWER(u.role)
|
|
283
|
+
WHERE s.token = $1
|
|
284
|
+
`;
|
|
285
|
+
sessionRes = await this._query(sql, [cleanToken]).catch(() => ({ rows: [] }));
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (sessionRes.rows && sessionRes.rows.length > 0) {
|
|
289
|
+
const row = sessionRes.rows[0];
|
|
290
|
+
|
|
291
|
+
// Security check: verify that row.token matches cleanToken
|
|
292
|
+
if (row.token !== cleanToken) {
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const now = new Date();
|
|
297
|
+
|
|
298
|
+
// Check active status & expiration
|
|
299
|
+
if (!row.session_active || !row.user_active || row.user_disabled || new Date(row.expires_at) < now) {
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Check inactivity timeout
|
|
304
|
+
const inactTimeoutMs = await this.getInactivityTimeoutMs();
|
|
305
|
+
if (inactTimeoutMs > 0 && row.last_activity) {
|
|
306
|
+
const lastAct = new Date(row.last_activity).getTime();
|
|
307
|
+
if (now.getTime() - lastAct > inactTimeoutMs) {
|
|
308
|
+
this._query(`UPDATE ${this.options.sessionsTable} SET is_active = false WHERE id = $1`, [row.session_id]).catch(() => {});
|
|
309
|
+
return null;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Asynchronously update last_activity
|
|
314
|
+
if (options.updateActivity !== false) {
|
|
315
|
+
this._query(`UPDATE ${this.options.sessionsTable} SET last_activity = NOW() WHERE id = $1`, [row.session_id]).catch(() => {});
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return {
|
|
319
|
+
valid: true,
|
|
320
|
+
userId: row.user_id,
|
|
321
|
+
sessionId: row.session_id,
|
|
322
|
+
token: cleanToken,
|
|
323
|
+
role: row.role,
|
|
324
|
+
employeeId: row.employee_id || null,
|
|
325
|
+
leaderId: row.leader_id || null,
|
|
326
|
+
userPermissions: row.user_permissions || {},
|
|
327
|
+
rolePermissions: row.role_permissions || {},
|
|
328
|
+
user: {
|
|
329
|
+
id: row.user_id,
|
|
330
|
+
username: row.username,
|
|
331
|
+
fullName: row.full_name,
|
|
332
|
+
role: row.role,
|
|
333
|
+
employeeId: row.employee_id,
|
|
334
|
+
leaderId: row.leader_id,
|
|
335
|
+
},
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Legacy JWT fallback if enabled
|
|
340
|
+
if (this.options.jwtSecret) {
|
|
341
|
+
try {
|
|
342
|
+
const jwtModule = typeof require === 'function' ? require('jsonwebtoken') : null;
|
|
343
|
+
if (jwtModule) {
|
|
344
|
+
const decoded = jwtModule.verify(cleanToken, this.options.jwtSecret);
|
|
345
|
+
if (decoded && (decoded.userId || decoded.id)) {
|
|
346
|
+
const uId = decoded.userId || decoded.id;
|
|
347
|
+
const uRes = await this._query(
|
|
348
|
+
`SELECT u.id, u.username, u.full_name, u.role, u.is_active, COALESCE(u.is_disabled, false) as is_disabled,
|
|
349
|
+
u.employee_id, u.leader_id, u.permissions as user_permissions, r.permissions as role_permissions
|
|
350
|
+
FROM ${this.options.usersTable} u
|
|
351
|
+
LEFT JOIN ${this.options.rolesTable} r ON r.id = u.role OR r.name = u.role OR LOWER(r.name) = LOWER(u.role)
|
|
352
|
+
WHERE u.id = $1`,
|
|
353
|
+
[uId]
|
|
354
|
+
).catch(() => ({ rows: [] }));
|
|
355
|
+
|
|
356
|
+
if (uRes.rows && uRes.rows.length > 0 && uRes.rows[0].is_active && !uRes.rows[0].is_disabled) {
|
|
357
|
+
const uRow = uRes.rows[0];
|
|
358
|
+
return {
|
|
359
|
+
valid: true,
|
|
360
|
+
userId: uRow.id,
|
|
361
|
+
sessionId: null,
|
|
362
|
+
token: cleanToken,
|
|
363
|
+
role: uRow.role || decoded.role,
|
|
364
|
+
employeeId: uRow.employee_id || null,
|
|
365
|
+
leaderId: uRow.leader_id || null,
|
|
366
|
+
userPermissions: uRow.user_permissions || {},
|
|
367
|
+
rolePermissions: uRow.role_permissions || {},
|
|
368
|
+
user: {
|
|
369
|
+
id: uRow.id,
|
|
370
|
+
username: uRow.username,
|
|
371
|
+
fullName: uRow.full_name,
|
|
372
|
+
role: uRow.role || decoded.role,
|
|
373
|
+
employeeId: uRow.employee_id,
|
|
374
|
+
leaderId: uRow.leader_id,
|
|
375
|
+
},
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
} catch {}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
return null;
|
|
384
|
+
} catch (err) {
|
|
385
|
+
console.warn('[SessionManager validateSession error]:', err.message);
|
|
386
|
+
return null;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Lock a session screen.
|
|
392
|
+
*
|
|
393
|
+
* @param {string} sessionIdOrToken Session ID or Token
|
|
394
|
+
* @returns {Promise<{ ok: boolean, sessionId: string, userId: string, token: string } | null>}
|
|
395
|
+
*/
|
|
396
|
+
async lockSession(sessionIdOrToken) {
|
|
397
|
+
if (!sessionIdOrToken) return null;
|
|
398
|
+
const parsed = parseToken(sessionIdOrToken, this.options.tokenPrefix);
|
|
399
|
+
const idToUse = parsed.sessionId || sessionIdOrToken;
|
|
400
|
+
|
|
401
|
+
const sql = `
|
|
402
|
+
UPDATE ${this.options.sessionsTable}
|
|
403
|
+
SET is_locked = true, locked_at = NOW()
|
|
404
|
+
WHERE (id = $1 OR token = $1) AND is_active = true
|
|
405
|
+
RETURNING id, user_id, token
|
|
406
|
+
`;
|
|
407
|
+
const r = await this._query(sql, [idToUse]);
|
|
408
|
+
if (r.rows && r.rows.length > 0) {
|
|
409
|
+
const row = r.rows[0];
|
|
410
|
+
return { ok: true, sessionId: row.id, userId: row.user_id, token: row.token };
|
|
411
|
+
}
|
|
412
|
+
return null;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Unlock a session screen.
|
|
417
|
+
*
|
|
418
|
+
* @param {string} sessionIdOrToken Session ID or Token
|
|
419
|
+
* @returns {Promise<{ ok: boolean, sessionId: string, userId: string, token: string } | null>}
|
|
420
|
+
*/
|
|
421
|
+
async unlockSession(sessionIdOrToken) {
|
|
422
|
+
if (!sessionIdOrToken) return null;
|
|
423
|
+
const parsed = parseToken(sessionIdOrToken, this.options.tokenPrefix);
|
|
424
|
+
const idToUse = parsed.sessionId || sessionIdOrToken;
|
|
425
|
+
|
|
426
|
+
const sql = `
|
|
427
|
+
UPDATE ${this.options.sessionsTable}
|
|
428
|
+
SET is_locked = false, locked_at = NULL
|
|
429
|
+
WHERE (id = $1 OR token = $1) AND is_active = true
|
|
430
|
+
RETURNING id, user_id, token
|
|
431
|
+
`;
|
|
432
|
+
const r = await this._query(sql, [idToUse]);
|
|
433
|
+
if (r.rows && r.rows.length > 0) {
|
|
434
|
+
const row = r.rows[0];
|
|
435
|
+
return { ok: true, sessionId: row.id, userId: row.user_id, token: row.token };
|
|
436
|
+
}
|
|
437
|
+
return null;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Terminate/revoke a single session.
|
|
442
|
+
*
|
|
443
|
+
* @param {string} sessionIdOrToken Session ID or Token
|
|
444
|
+
* @returns {Promise<{ ok: boolean, sessionId: string, userId: string, token: string } | null>}
|
|
445
|
+
*/
|
|
446
|
+
async terminateSession(sessionIdOrToken) {
|
|
447
|
+
if (!sessionIdOrToken) return null;
|
|
448
|
+
const parsed = parseToken(sessionIdOrToken, this.options.tokenPrefix);
|
|
449
|
+
const idToUse = parsed.sessionId || sessionIdOrToken;
|
|
450
|
+
|
|
451
|
+
const sql = `
|
|
452
|
+
UPDATE ${this.options.sessionsTable}
|
|
453
|
+
SET is_active = false
|
|
454
|
+
WHERE id = $1 OR token = $1
|
|
455
|
+
RETURNING id, user_id, token
|
|
456
|
+
`;
|
|
457
|
+
const r = await this._query(sql, [idToUse]);
|
|
458
|
+
if (r.rows && r.rows.length > 0) {
|
|
459
|
+
const row = r.rows[0];
|
|
460
|
+
return { ok: true, sessionId: row.id, userId: row.user_id, token: row.token };
|
|
461
|
+
}
|
|
462
|
+
return null;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Terminate all other sessions except the current one.
|
|
467
|
+
*
|
|
468
|
+
* @param {string} currentSessionIdOrToken Current session ID or token to preserve
|
|
469
|
+
* @param {string|number} [currentUserId]
|
|
470
|
+
* @param {boolean} [protectSuperAdmin=true]
|
|
471
|
+
* @returns {Promise<Array<{ sessionId: string, userId: string, token: string }>>}
|
|
472
|
+
*/
|
|
473
|
+
async terminateOtherSessions(currentSessionIdOrToken, currentUserId = null, protectSuperAdmin = true) {
|
|
474
|
+
const parsed = parseToken(currentSessionIdOrToken, this.options.tokenPrefix);
|
|
475
|
+
const preserveId = parsed.sessionId || currentSessionIdOrToken;
|
|
476
|
+
|
|
477
|
+
let whereClause = `id != $1 AND token != $1`;
|
|
478
|
+
let params = [preserveId];
|
|
479
|
+
|
|
480
|
+
if (currentUserId) {
|
|
481
|
+
whereClause += ` AND user_id = $2`;
|
|
482
|
+
params.push(currentUserId);
|
|
483
|
+
} else if (protectSuperAdmin) {
|
|
484
|
+
whereClause += ` AND user_id NOT IN (SELECT id FROM ${this.options.usersTable} WHERE LOWER(COALESCE(role, '')) = '${this.options.superRole}' OR LOWER(username) = '${this.options.superRole}')`;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const sql = `
|
|
488
|
+
UPDATE ${this.options.sessionsTable}
|
|
489
|
+
SET is_active = false
|
|
490
|
+
WHERE ${whereClause} AND is_active = true
|
|
491
|
+
RETURNING id, user_id, token
|
|
492
|
+
`;
|
|
493
|
+
const r = await this._query(sql, params);
|
|
494
|
+
return (r.rows || []).map(row => ({
|
|
495
|
+
sessionId: row.id,
|
|
496
|
+
userId: row.user_id,
|
|
497
|
+
token: row.token,
|
|
498
|
+
}));
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Terminate all active sessions for a specific user.
|
|
503
|
+
*
|
|
504
|
+
* @param {string|number} userId
|
|
505
|
+
* @returns {Promise<Array<{ sessionId: string, userId: string, token: string }>>}
|
|
506
|
+
*/
|
|
507
|
+
async terminateUserSessions(userId) {
|
|
508
|
+
if (!userId) return [];
|
|
509
|
+
const sql = `
|
|
510
|
+
UPDATE ${this.options.sessionsTable}
|
|
511
|
+
SET is_active = false
|
|
512
|
+
WHERE user_id = $1 AND is_active = true
|
|
513
|
+
RETURNING id, user_id, token
|
|
514
|
+
`;
|
|
515
|
+
const r = await this._query(sql, [userId]);
|
|
516
|
+
return (r.rows || []).map(row => ({
|
|
517
|
+
sessionId: row.id,
|
|
518
|
+
userId: row.user_id,
|
|
519
|
+
token: row.token,
|
|
520
|
+
}));
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Aliases for backward compatibility.
|
|
525
|
+
*/
|
|
526
|
+
async revokeSession(tokenOrId) {
|
|
527
|
+
return this.terminateSession(tokenOrId);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
async revokeAllUserSessions(userId) {
|
|
531
|
+
return this.terminateUserSessions(userId);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Universal Factory.
|
|
537
|
+
*/
|
|
538
|
+
export function createSessionManager(options) {
|
|
539
|
+
return new SessionManager(options);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
export default {
|
|
543
|
+
SessionManager,
|
|
544
|
+
createSessionManager,
|
|
545
|
+
formatToken,
|
|
546
|
+
parseToken,
|
|
547
|
+
getSessionId,
|
|
548
|
+
extractToken,
|
|
549
|
+
};
|