strapi-plugin-magic-sessionmanager 2.0.0 → 2.0.2

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.
Files changed (52) hide show
  1. package/admin/jsconfig.json +10 -0
  2. package/admin/src/components/Initializer.jsx +11 -0
  3. package/admin/src/components/LicenseGuard.jsx +591 -0
  4. package/admin/src/components/OnlineUsersWidget.jsx +208 -0
  5. package/admin/src/components/PluginIcon.jsx +8 -0
  6. package/admin/src/components/SessionDetailModal.jsx +445 -0
  7. package/admin/src/components/SessionInfoCard.jsx +151 -0
  8. package/admin/src/components/SessionInfoPanel.jsx +375 -0
  9. package/admin/src/components/index.jsx +5 -0
  10. package/admin/src/hooks/useLicense.js +103 -0
  11. package/admin/src/index.js +137 -0
  12. package/admin/src/pages/ActiveSessions.jsx +12 -0
  13. package/admin/src/pages/Analytics.jsx +735 -0
  14. package/admin/src/pages/App.jsx +12 -0
  15. package/admin/src/pages/HomePage.jsx +1248 -0
  16. package/admin/src/pages/License.jsx +603 -0
  17. package/admin/src/pages/Settings.jsx +1497 -0
  18. package/admin/src/pages/SettingsNew.jsx +1204 -0
  19. package/admin/src/pages/index.jsx +3 -0
  20. package/admin/src/pluginId.js +3 -0
  21. package/admin/src/translations/de.json +20 -0
  22. package/admin/src/translations/en.json +20 -0
  23. package/admin/src/utils/getTranslation.js +5 -0
  24. package/admin/src/utils/index.js +2 -0
  25. package/admin/src/utils/parseUserAgent.js +79 -0
  26. package/package.json +3 -1
  27. package/server/jsconfig.json +10 -0
  28. package/server/src/bootstrap.js +297 -0
  29. package/server/src/config/index.js +20 -0
  30. package/server/src/content-types/index.js +9 -0
  31. package/server/src/content-types/session/schema.json +76 -0
  32. package/server/src/controllers/controller.js +11 -0
  33. package/server/src/controllers/index.js +11 -0
  34. package/server/src/controllers/license.js +266 -0
  35. package/server/src/controllers/session.js +362 -0
  36. package/server/src/controllers/settings.js +122 -0
  37. package/server/src/destroy.js +18 -0
  38. package/server/src/index.js +21 -0
  39. package/server/src/middlewares/index.js +5 -0
  40. package/server/src/middlewares/last-seen.js +56 -0
  41. package/server/src/policies/index.js +3 -0
  42. package/server/src/register.js +32 -0
  43. package/server/src/routes/admin.js +149 -0
  44. package/server/src/routes/content-api.js +51 -0
  45. package/server/src/routes/index.js +9 -0
  46. package/server/src/services/geolocation.js +180 -0
  47. package/server/src/services/index.js +13 -0
  48. package/server/src/services/license-guard.js +308 -0
  49. package/server/src/services/notifications.js +319 -0
  50. package/server/src/services/service.js +7 -0
  51. package/server/src/services/session.js +345 -0
  52. package/server/src/utils/getClientIp.js +118 -0
@@ -0,0 +1,345 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Session Service
5
+ * Uses plugin::magic-sessionmanager.session content type with relation to users
6
+ * All session tracking happens in the Session collection
7
+ *
8
+ * TODO: For production multi-instance deployments, use Redis for:
9
+ * - Session store instead of DB
10
+ * - Rate limiting locks
11
+ * - Distributed session state
12
+ */
13
+ module.exports = ({ strapi }) => ({
14
+ /**
15
+ * Create a new session record
16
+ * @param {Object} params - { userId, ip, userAgent, token }
17
+ * @returns {Promise<Object>} Created session
18
+ */
19
+ async createSession({ userId, ip = 'unknown', userAgent = 'unknown', token }) {
20
+ try {
21
+ const now = new Date();
22
+
23
+ const session = await strapi.entityService.create('plugin::magic-sessionmanager.session', {
24
+ data: {
25
+ user: userId,
26
+ ipAddress: ip.substring(0, 45),
27
+ userAgent: userAgent.substring(0, 500),
28
+ loginTime: now,
29
+ lastActive: now,
30
+ isActive: true,
31
+ token: token, // Store JWT for logout matching
32
+ },
33
+ });
34
+
35
+ strapi.log.info(`[magic-sessionmanager] ✅ Session ${session.id} created for user ${userId}`);
36
+
37
+ return session;
38
+ } catch (err) {
39
+ strapi.log.error('[magic-sessionmanager] Error creating session:', err);
40
+ throw err;
41
+ }
42
+ },
43
+
44
+ /**
45
+ * Terminate a session or all sessions for a user
46
+ * @param {Object} params - { sessionId | userId }
47
+ * @returns {Promise<void>}
48
+ */
49
+ async terminateSession({ sessionId, userId }) {
50
+ try {
51
+ const now = new Date();
52
+
53
+ if (sessionId) {
54
+ await strapi.entityService.update('plugin::magic-sessionmanager.session', sessionId, {
55
+ data: {
56
+ isActive: false,
57
+ logoutTime: now,
58
+ },
59
+ });
60
+
61
+ strapi.log.info(`[magic-sessionmanager] Session ${sessionId} terminated`);
62
+ } else if (userId) {
63
+ // Find all active sessions for user
64
+ const activeSessions = await strapi.entityService.findMany('plugin::magic-sessionmanager.session', {
65
+ filters: {
66
+ user: { id: userId },
67
+ isActive: true,
68
+ },
69
+ });
70
+
71
+ // Terminate all active sessions
72
+ for (const session of activeSessions) {
73
+ await strapi.entityService.update('plugin::magic-sessionmanager.session', session.id, {
74
+ data: {
75
+ isActive: false,
76
+ logoutTime: now,
77
+ },
78
+ });
79
+ }
80
+
81
+ strapi.log.info(`[magic-sessionmanager] All sessions terminated for user ${userId}`);
82
+ }
83
+ } catch (err) {
84
+ strapi.log.error('[magic-sessionmanager] Error terminating session:', err);
85
+ throw err;
86
+ }
87
+ },
88
+
89
+ /**
90
+ * Get ALL sessions (active + inactive) with accurate online status
91
+ * @returns {Promise<Array>} All sessions with enhanced data
92
+ */
93
+ async getAllSessions() {
94
+ try {
95
+ const sessions = await strapi.entityService.findMany('plugin::magic-sessionmanager.session', {
96
+ populate: { user: { fields: ['id', 'email', 'username'] } },
97
+ sort: { loginTime: 'desc' },
98
+ limit: 1000, // Reasonable limit
99
+ });
100
+
101
+ // Get inactivity timeout from config (default: 15 minutes)
102
+ const config = strapi.config.get('plugin::magic-sessionmanager') || {};
103
+ const inactivityTimeout = config.inactivityTimeout || 15 * 60 * 1000; // 15 min in ms
104
+
105
+ // Enhance sessions with accurate online status
106
+ const now = new Date();
107
+ const enhancedSessions = sessions.map(session => {
108
+ const lastActiveTime = session.lastActive ? new Date(session.lastActive) : new Date(session.loginTime);
109
+ const timeSinceActive = now - lastActiveTime;
110
+
111
+ // Session is "truly active" if within timeout window AND isActive is true
112
+ const isTrulyActive = session.isActive && (timeSinceActive < inactivityTimeout);
113
+
114
+ // Remove sensitive token field for security
115
+ const { token, ...sessionWithoutToken } = session;
116
+
117
+ return {
118
+ ...sessionWithoutToken,
119
+ isTrulyActive,
120
+ minutesSinceActive: Math.floor(timeSinceActive / 1000 / 60),
121
+ };
122
+ });
123
+
124
+ return enhancedSessions;
125
+ } catch (err) {
126
+ strapi.log.error('[magic-sessionmanager] Error getting all sessions:', err);
127
+ throw err;
128
+ }
129
+ },
130
+
131
+ /**
132
+ * Get all active sessions with accurate online status
133
+ * @returns {Promise<Array>} Active sessions with user data and online status
134
+ */
135
+ async getActiveSessions() {
136
+ try {
137
+ const sessions = await strapi.entityService.findMany('plugin::magic-sessionmanager.session', {
138
+ filters: { isActive: true },
139
+ populate: { user: { fields: ['id', 'email', 'username'] } },
140
+ sort: { loginTime: 'desc' },
141
+ });
142
+
143
+ // Get inactivity timeout from config (default: 15 minutes)
144
+ const config = strapi.config.get('plugin::magic-sessionmanager') || {};
145
+ const inactivityTimeout = config.inactivityTimeout || 15 * 60 * 1000; // 15 min in ms
146
+
147
+ // Enhance sessions with accurate online status
148
+ const now = new Date();
149
+ const enhancedSessions = sessions.map(session => {
150
+ const lastActiveTime = session.lastActive ? new Date(session.lastActive) : new Date(session.loginTime);
151
+ const timeSinceActive = now - lastActiveTime;
152
+
153
+ // Session is "truly active" if within timeout window
154
+ const isTrulyActive = timeSinceActive < inactivityTimeout;
155
+
156
+ // Remove sensitive token field for security
157
+ const { token, ...sessionWithoutToken } = session;
158
+
159
+ return {
160
+ ...sessionWithoutToken,
161
+ isTrulyActive,
162
+ minutesSinceActive: Math.floor(timeSinceActive / 1000 / 60),
163
+ };
164
+ });
165
+
166
+ // Only return truly active sessions
167
+ return enhancedSessions.filter(s => s.isTrulyActive);
168
+ } catch (err) {
169
+ strapi.log.error('[magic-sessionmanager] Error getting active sessions:', err);
170
+ throw err;
171
+ }
172
+ },
173
+
174
+ /**
175
+ * Get all sessions for a specific user
176
+ * @param {number} userId
177
+ * @returns {Promise<Array>} User's sessions with accurate online status
178
+ */
179
+ async getUserSessions(userId) {
180
+ try {
181
+ const sessions = await strapi.entityService.findMany('plugin::magic-sessionmanager.session', {
182
+ filters: { user: { id: userId } },
183
+ sort: { loginTime: 'desc' },
184
+ });
185
+
186
+ // Get inactivity timeout from config (default: 15 minutes)
187
+ const config = strapi.config.get('plugin::magic-sessionmanager') || {};
188
+ const inactivityTimeout = config.inactivityTimeout || 15 * 60 * 1000; // 15 min in ms
189
+
190
+ // Enhance sessions with accurate online status
191
+ const now = new Date();
192
+ const enhancedSessions = sessions.map(session => {
193
+ const lastActiveTime = session.lastActive ? new Date(session.lastActive) : new Date(session.loginTime);
194
+ const timeSinceActive = now - lastActiveTime;
195
+
196
+ // Session is "truly active" if:
197
+ // 1. isActive = true AND
198
+ // 2. lastActive is within timeout window
199
+ const isTrulyActive = session.isActive && (timeSinceActive < inactivityTimeout);
200
+
201
+ // Remove sensitive token field for security
202
+ const { token, ...sessionWithoutToken } = session;
203
+
204
+ return {
205
+ ...sessionWithoutToken,
206
+ isTrulyActive,
207
+ minutesSinceActive: Math.floor(timeSinceActive / 1000 / 60),
208
+ };
209
+ });
210
+
211
+ return enhancedSessions;
212
+ } catch (err) {
213
+ strapi.log.error('[magic-sessionmanager] Error getting user sessions:', err);
214
+ throw err;
215
+ }
216
+ },
217
+
218
+ /**
219
+ * Update lastActive timestamp on session (rate-limited to avoid DB noise)
220
+ * @param {Object} params - { userId, sessionId }
221
+ * @returns {Promise<void>}
222
+ */
223
+ async touch({ userId, sessionId }) {
224
+ try {
225
+ const now = new Date();
226
+ const config = strapi.config.get('plugin::magic-sessionmanager') || {};
227
+ const rateLimit = config.lastSeenRateLimit || 30000;
228
+
229
+ // Update session lastActive only
230
+ if (sessionId) {
231
+ const session = await strapi.entityService.findOne('plugin::magic-sessionmanager.session', sessionId);
232
+
233
+ if (session && session.lastActive) {
234
+ const lastActiveTime = new Date(session.lastActive).getTime();
235
+ const currentTime = now.getTime();
236
+
237
+ if (currentTime - lastActiveTime > rateLimit) {
238
+ await strapi.entityService.update('plugin::magic-sessionmanager.session', sessionId, {
239
+ data: { lastActive: now },
240
+ });
241
+ }
242
+ } else if (session) {
243
+ // First time or null
244
+ await strapi.entityService.update('plugin::magic-sessionmanager.session', sessionId, {
245
+ data: { lastActive: now },
246
+ });
247
+ }
248
+ }
249
+ } catch (err) {
250
+ strapi.log.debug('[magic-sessionmanager] Error touching session:', err.message);
251
+ // Don't throw - this is a non-critical operation
252
+ }
253
+ },
254
+
255
+ /**
256
+ * Cleanup inactive sessions - set isActive to false for sessions older than inactivityTimeout
257
+ * Should be called on bootstrap to clean up stale sessions
258
+ */
259
+ async cleanupInactiveSessions() {
260
+ try {
261
+ // Get inactivity timeout from config (default: 15 minutes)
262
+ const config = strapi.config.get('plugin::magic-sessionmanager') || {};
263
+ const inactivityTimeout = config.inactivityTimeout || 15 * 60 * 1000; // 15 min in ms
264
+
265
+ // Calculate cutoff time
266
+ const now = new Date();
267
+ const cutoffTime = new Date(now.getTime() - inactivityTimeout);
268
+
269
+ strapi.log.info(`[magic-sessionmanager] 🧹 Cleaning up sessions inactive since before ${cutoffTime.toISOString()}`);
270
+
271
+ // Find all active sessions
272
+ const activeSessions = await strapi.entityService.findMany('plugin::magic-sessionmanager.session', {
273
+ filters: { isActive: true },
274
+ fields: ['id', 'lastActive', 'loginTime'],
275
+ });
276
+
277
+ // Deactivate old sessions
278
+ let deactivatedCount = 0;
279
+ for (const session of activeSessions) {
280
+ const lastActiveTime = session.lastActive ? new Date(session.lastActive) : new Date(session.loginTime);
281
+
282
+ if (lastActiveTime < cutoffTime) {
283
+ await strapi.entityService.update('plugin::magic-sessionmanager.session', session.id, {
284
+ data: { isActive: false },
285
+ });
286
+ deactivatedCount++;
287
+ }
288
+ }
289
+
290
+ strapi.log.info(`[magic-sessionmanager] ✅ Cleanup complete: ${deactivatedCount} sessions deactivated`);
291
+ return deactivatedCount;
292
+ } catch (err) {
293
+ strapi.log.error('[magic-sessionmanager] Error cleaning up inactive sessions:', err);
294
+ throw err;
295
+ }
296
+ },
297
+
298
+ /**
299
+ * Delete a single session from database
300
+ * WARNING: This permanently deletes the record!
301
+ * @param {number} sessionId - Session ID to delete
302
+ * @returns {Promise<boolean>} Success status
303
+ */
304
+ async deleteSession(sessionId) {
305
+ try {
306
+ await strapi.entityService.delete('plugin::magic-sessionmanager.session', sessionId);
307
+ strapi.log.info(`[magic-sessionmanager] 🗑️ Session ${sessionId} permanently deleted`);
308
+ return true;
309
+ } catch (err) {
310
+ strapi.log.error('[magic-sessionmanager] Error deleting session:', err);
311
+ throw err;
312
+ }
313
+ },
314
+
315
+ /**
316
+ * Delete all inactive sessions from database
317
+ * WARNING: This permanently deletes records!
318
+ * @returns {Promise<number>} Number of deleted sessions
319
+ */
320
+ async deleteInactiveSessions() {
321
+ try {
322
+ strapi.log.info('[magic-sessionmanager] 🗑️ Deleting all inactive sessions...');
323
+
324
+ // Find all inactive sessions
325
+ const inactiveSessions = await strapi.entityService.findMany('plugin::magic-sessionmanager.session', {
326
+ filters: { isActive: false },
327
+ fields: ['id'],
328
+ });
329
+
330
+ let deletedCount = 0;
331
+
332
+ // Delete each inactive session
333
+ for (const session of inactiveSessions) {
334
+ await strapi.entityService.delete('plugin::magic-sessionmanager.session', session.id);
335
+ deletedCount++;
336
+ }
337
+
338
+ strapi.log.info(`[magic-sessionmanager] ✅ Deleted ${deletedCount} inactive sessions`);
339
+ return deletedCount;
340
+ } catch (err) {
341
+ strapi.log.error('[magic-sessionmanager] Error deleting inactive sessions:', err);
342
+ throw err;
343
+ }
344
+ },
345
+ });
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Extract real client IP address from request
3
+ * Handles proxies, load balancers, and various header formats
4
+ *
5
+ * Priority order:
6
+ * 1. CF-Connecting-IP (Cloudflare)
7
+ * 2. True-Client-IP (Akamai, Cloudflare)
8
+ * 3. X-Real-IP (nginx)
9
+ * 4. X-Forwarded-For (standard proxy header)
10
+ * 5. X-Client-IP
11
+ * 6. X-Cluster-Client-IP
12
+ * 7. ctx.request.ip (Koa default)
13
+ */
14
+
15
+ const getClientIp = (ctx) => {
16
+ try {
17
+ const headers = ctx.request.headers || ctx.request.header || {};
18
+
19
+ // 1. Cloudflare
20
+ if (headers['cf-connecting-ip']) {
21
+ return cleanIp(headers['cf-connecting-ip']);
22
+ }
23
+
24
+ // 2. True-Client-IP (Akamai, Cloudflare Enterprise)
25
+ if (headers['true-client-ip']) {
26
+ return cleanIp(headers['true-client-ip']);
27
+ }
28
+
29
+ // 3. X-Real-IP (nginx proxy_pass)
30
+ if (headers['x-real-ip']) {
31
+ return cleanIp(headers['x-real-ip']);
32
+ }
33
+
34
+ // 4. X-Forwarded-For (most common)
35
+ // Format: "client, proxy1, proxy2"
36
+ // We want the FIRST IP (the actual client)
37
+ if (headers['x-forwarded-for']) {
38
+ const forwardedIps = headers['x-forwarded-for'].split(',');
39
+ const clientIp = forwardedIps[0].trim();
40
+ if (clientIp && !isPrivateIp(clientIp)) {
41
+ return cleanIp(clientIp);
42
+ }
43
+ }
44
+
45
+ // 5. X-Client-IP
46
+ if (headers['x-client-ip']) {
47
+ return cleanIp(headers['x-client-ip']);
48
+ }
49
+
50
+ // 6. X-Cluster-Client-IP (Rackspace, Riverbed)
51
+ if (headers['x-cluster-client-ip']) {
52
+ return cleanIp(headers['x-cluster-client-ip']);
53
+ }
54
+
55
+ // 7. Forwarded (RFC 7239)
56
+ if (headers['forwarded']) {
57
+ const match = headers['forwarded'].match(/for=([^;,\s]+)/);
58
+ if (match && match[1]) {
59
+ return cleanIp(match[1].replace(/"/g, ''));
60
+ }
61
+ }
62
+
63
+ // 8. Fallback to Koa's ctx.request.ip
64
+ if (ctx.request.ip) {
65
+ return cleanIp(ctx.request.ip);
66
+ }
67
+
68
+ // 9. Last resort
69
+ return 'unknown';
70
+
71
+ } catch (error) {
72
+ console.error('[getClientIp] Error extracting IP:', error);
73
+ return 'unknown';
74
+ }
75
+ };
76
+
77
+ /**
78
+ * Clean IP address (remove IPv6 prefix, port, etc.)
79
+ */
80
+ const cleanIp = (ip) => {
81
+ if (!ip) return 'unknown';
82
+
83
+ // Remove port if present
84
+ ip = ip.split(':')[0];
85
+
86
+ // Remove IPv6 prefix (::ffff:)
87
+ if (ip.startsWith('::ffff:')) {
88
+ ip = ip.substring(7);
89
+ }
90
+
91
+ // Trim whitespace
92
+ ip = ip.trim();
93
+
94
+ return ip || 'unknown';
95
+ };
96
+
97
+ /**
98
+ * Check if IP is private/local
99
+ */
100
+ const isPrivateIp = (ip) => {
101
+ if (!ip) return true;
102
+
103
+ // Private IP ranges
104
+ if (ip === '127.0.0.1' || ip === 'localhost' || ip === '::1') return true;
105
+ if (ip.startsWith('192.168.')) return true;
106
+ if (ip.startsWith('10.')) return true;
107
+ if (ip.startsWith('172.')) {
108
+ const second = parseInt(ip.split('.')[1]);
109
+ if (second >= 16 && second <= 31) return true;
110
+ }
111
+ if (ip.startsWith('fc00:') || ip.startsWith('fd00:')) return true;
112
+ if (ip.startsWith('fe80:')) return true;
113
+
114
+ return false;
115
+ };
116
+
117
+ module.exports = getClientIp;
118
+