strapi-plugin-magic-sessionmanager 2.0.1 โ†’ 2.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) 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/dist/server/index.js +91 -2
  27. package/dist/server/index.mjs +91 -2
  28. package/package.json +3 -1
  29. package/server/jsconfig.json +10 -0
  30. package/server/src/bootstrap.js +297 -0
  31. package/server/src/config/index.js +20 -0
  32. package/server/src/content-types/index.js +9 -0
  33. package/server/src/content-types/session/schema.json +76 -0
  34. package/server/src/controllers/controller.js +11 -0
  35. package/server/src/controllers/index.js +11 -0
  36. package/server/src/controllers/license.js +266 -0
  37. package/server/src/controllers/session.js +362 -0
  38. package/server/src/controllers/settings.js +122 -0
  39. package/server/src/destroy.js +18 -0
  40. package/server/src/index.js +23 -0
  41. package/server/src/middlewares/index.js +5 -0
  42. package/server/src/middlewares/last-seen.js +56 -0
  43. package/server/src/policies/index.js +3 -0
  44. package/server/src/register.js +32 -0
  45. package/server/src/routes/admin.js +149 -0
  46. package/server/src/routes/content-api.js +51 -0
  47. package/server/src/routes/index.js +9 -0
  48. package/server/src/services/geolocation.js +180 -0
  49. package/server/src/services/index.js +13 -0
  50. package/server/src/services/license-guard.js +308 -0
  51. package/server/src/services/notifications.js +319 -0
  52. package/server/src/services/service.js +7 -0
  53. package/server/src/services/session.js +345 -0
  54. package/server/src/utils/getClientIp.js +118 -0
@@ -0,0 +1,319 @@
1
+ /**
2
+ * Notifications Service (ADVANCED Feature)
3
+ * Send email alerts for session events
4
+ */
5
+
6
+ module.exports = ({ strapi }) => ({
7
+ /**
8
+ * Get email templates from database settings
9
+ * Falls back to default hardcoded templates if not found
10
+ */
11
+ async getEmailTemplates() {
12
+ try {
13
+ // Try to load templates from database
14
+ const pluginStore = strapi.store({
15
+ type: 'plugin',
16
+ name: 'magic-sessionmanager',
17
+ });
18
+
19
+ const settings = await pluginStore.get({ key: 'settings' });
20
+
21
+ if (settings?.emailTemplates && Object.keys(settings.emailTemplates).length > 0) {
22
+ // Check if templates have content
23
+ const hasContent = Object.values(settings.emailTemplates).some(
24
+ template => template.html || template.text
25
+ );
26
+
27
+ if (hasContent) {
28
+ strapi.log.debug('[magic-sessionmanager/notifications] Using templates from database');
29
+ return settings.emailTemplates;
30
+ }
31
+ }
32
+ } catch (err) {
33
+ strapi.log.warn('[magic-sessionmanager/notifications] Could not load templates from DB, using defaults:', err.message);
34
+ }
35
+
36
+ // Default fallback templates
37
+ strapi.log.debug('[magic-sessionmanager/notifications] Using default fallback templates');
38
+ return {
39
+ suspiciousLogin: {
40
+ subject: '๐Ÿšจ Suspicious Login Alert - Session Manager',
41
+ html: `
42
+ <html>
43
+ <body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
44
+ <div style="max-width: 600px; margin: 0 auto; padding: 20px; background-color: #f9fafb; border-radius: 10px;">
45
+ <h2 style="color: #dc2626;">๐Ÿšจ Suspicious Login Detected</h2>
46
+ <p>A potentially suspicious login was detected for your account.</p>
47
+
48
+ <div style="background: white; padding: 15px; border-radius: 8px; margin: 20px 0;">
49
+ <h3 style="margin-top: 0;">Account Information:</h3>
50
+ <ul>
51
+ <li><strong>Email:</strong> {{user.email}}</li>
52
+ <li><strong>Username:</strong> {{user.username}}</li>
53
+ </ul>
54
+
55
+ <h3>Login Details:</h3>
56
+ <ul>
57
+ <li><strong>Time:</strong> {{session.loginTime}}</li>
58
+ <li><strong>IP Address:</strong> {{session.ipAddress}}</li>
59
+ <li><strong>Location:</strong> {{geo.city}}, {{geo.country}}</li>
60
+ <li><strong>Timezone:</strong> {{geo.timezone}}</li>
61
+ <li><strong>Device:</strong> {{session.userAgent}}</li>
62
+ </ul>
63
+
64
+ <h3 style="color: #dc2626;">Security Alert:</h3>
65
+ <ul>
66
+ <li>VPN Detected: {{reason.isVpn}}</li>
67
+ <li>Proxy Detected: {{reason.isProxy}}</li>
68
+ <li>Threat Detected: {{reason.isThreat}}</li>
69
+ <li>Security Score: {{reason.securityScore}}/100</li>
70
+ </ul>
71
+ </div>
72
+
73
+ <p>If this was you, you can safely ignore this email. If you don't recognize this activity, please secure your account immediately.</p>
74
+
75
+ <hr style="border: none; border-top: 1px solid #e5e7eb; margin: 20px 0;"/>
76
+ <p style="color: #666; font-size: 12px;">This is an automated security notification from Magic Session Manager.</p>
77
+ </div>
78
+ </body>
79
+ </html>`,
80
+ text: `๐Ÿšจ Suspicious Login Detected\n\nA potentially suspicious login was detected for your account.\n\nAccount: {{user.email}}\nUsername: {{user.username}}\n\nLogin Details:\n- Time: {{session.loginTime}}\n- IP: {{session.ipAddress}}\n- Location: {{geo.city}}, {{geo.country}}\n\nSecurity: VPN={{reason.isVpn}}, Proxy={{reason.isProxy}}, Threat={{reason.isThreat}}, Score={{reason.securityScore}}/100`,
81
+ },
82
+ newLocation: {
83
+ subject: '๐Ÿ“ New Location Login Detected',
84
+ html: `<h2>๐Ÿ“ New Location Login</h2><p>Account: {{user.email}}</p><p>Time: {{session.loginTime}}</p><p>Location: {{geo.city}}, {{geo.country}}</p><p>IP: {{session.ipAddress}}</p>`,
85
+ text: `๐Ÿ“ New Location Login\n\nAccount: {{user.email}}\nTime: {{session.loginTime}}\nLocation: {{geo.city}}, {{geo.country}}\nIP: {{session.ipAddress}}`,
86
+ },
87
+ vpnProxy: {
88
+ subject: 'โš ๏ธ VPN/Proxy Login Detected',
89
+ html: `<h2>โš ๏ธ VPN/Proxy Detected</h2><p>Account: {{user.email}}</p><p>Time: {{session.loginTime}}</p><p>IP: {{session.ipAddress}}</p><p>VPN: {{reason.isVpn}}, Proxy: {{reason.isProxy}}</p>`,
90
+ text: `โš ๏ธ VPN/Proxy Detected\n\nAccount: {{user.email}}\nTime: {{session.loginTime}}\nIP: {{session.ipAddress}}\nVPN: {{reason.isVpn}}, Proxy: {{reason.isProxy}}`,
91
+ },
92
+ };
93
+ },
94
+
95
+ /**
96
+ * Replace template variables with actual values
97
+ */
98
+ replaceVariables(template, data) {
99
+ let result = template;
100
+
101
+ // User variables
102
+ result = result.replace(/\{\{user\.email\}\}/g, data.user?.email || 'N/A');
103
+ result = result.replace(/\{\{user\.username\}\}/g, data.user?.username || 'N/A');
104
+
105
+ // Session variables
106
+ result = result.replace(/\{\{session\.loginTime\}\}/g,
107
+ data.session?.loginTime ? new Date(data.session.loginTime).toLocaleString() : 'N/A');
108
+ result = result.replace(/\{\{session\.ipAddress\}\}/g, data.session?.ipAddress || 'N/A');
109
+ result = result.replace(/\{\{session\.userAgent\}\}/g, data.session?.userAgent || 'N/A');
110
+
111
+ // Geo variables
112
+ result = result.replace(/\{\{geo\.city\}\}/g, data.geoData?.city || 'Unknown');
113
+ result = result.replace(/\{\{geo\.country\}\}/g, data.geoData?.country || 'Unknown');
114
+ result = result.replace(/\{\{geo\.timezone\}\}/g, data.geoData?.timezone || 'Unknown');
115
+
116
+ // Reason variables
117
+ result = result.replace(/\{\{reason\.isVpn\}\}/g, data.reason?.isVpn ? 'Yes' : 'No');
118
+ result = result.replace(/\{\{reason\.isProxy\}\}/g, data.reason?.isProxy ? 'Yes' : 'No');
119
+ result = result.replace(/\{\{reason\.isThreat\}\}/g, data.reason?.isThreat ? 'Yes' : 'No');
120
+ result = result.replace(/\{\{reason\.securityScore\}\}/g, data.reason?.securityScore || '0');
121
+
122
+ return result;
123
+ },
124
+
125
+ /**
126
+ * Send suspicious login alert
127
+ * @param {Object} params - { user, session, reason, geoData }
128
+ */
129
+ async sendSuspiciousLoginAlert({ user, session, reason, geoData }) {
130
+ try {
131
+ // Get templates from database (or defaults)
132
+ const templates = await this.getEmailTemplates();
133
+ const template = templates.suspiciousLogin;
134
+
135
+ // Prepare data for variable replacement
136
+ const data = { user, session, reason, geoData };
137
+
138
+ // Replace variables in template
139
+ const htmlContent = this.replaceVariables(template.html, data);
140
+ const textContent = this.replaceVariables(template.text, data);
141
+
142
+ await strapi.plugins['email'].services.email.send({
143
+ to: user.email,
144
+ subject: template.subject,
145
+ html: htmlContent,
146
+ text: textContent,
147
+ });
148
+
149
+ strapi.log.info(`[magic-sessionmanager/notifications] Suspicious login alert sent to ${user.email}`);
150
+ return true;
151
+ } catch (err) {
152
+ strapi.log.error('[magic-sessionmanager/notifications] Error sending email:', err);
153
+ return false;
154
+ }
155
+ },
156
+
157
+ /**
158
+ * Send new location login alert
159
+ * @param {Object} params - { user, session, geoData }
160
+ */
161
+ async sendNewLocationAlert({ user, session, geoData }) {
162
+ try {
163
+ // Get templates from database (or defaults)
164
+ const templates = await this.getEmailTemplates();
165
+ const template = templates.newLocation;
166
+
167
+ // Prepare data for variable replacement
168
+ const data = { user, session, geoData, reason: {} };
169
+
170
+ // Replace variables in template
171
+ const htmlContent = this.replaceVariables(template.html, data);
172
+ const textContent = this.replaceVariables(template.text, data);
173
+
174
+ await strapi.plugins['email'].services.email.send({
175
+ to: user.email,
176
+ subject: template.subject,
177
+ html: htmlContent,
178
+ text: textContent,
179
+ });
180
+
181
+ strapi.log.info(`[magic-sessionmanager/notifications] New location alert sent to ${user.email}`);
182
+ return true;
183
+ } catch (err) {
184
+ strapi.log.error('[magic-sessionmanager/notifications] Error sending new location email:', err);
185
+ return false;
186
+ }
187
+ },
188
+
189
+ /**
190
+ * Send VPN/Proxy login alert
191
+ * @param {Object} params - { user, session, reason, geoData }
192
+ */
193
+ async sendVpnProxyAlert({ user, session, reason, geoData }) {
194
+ try {
195
+ // Get templates from database (or defaults)
196
+ const templates = await this.getEmailTemplates();
197
+ const template = templates.vpnProxy;
198
+
199
+ // Prepare data for variable replacement
200
+ const data = { user, session, reason, geoData };
201
+
202
+ // Replace variables in template
203
+ const htmlContent = this.replaceVariables(template.html, data);
204
+ const textContent = this.replaceVariables(template.text, data);
205
+
206
+ await strapi.plugins['email'].services.email.send({
207
+ to: user.email,
208
+ subject: template.subject,
209
+ html: htmlContent,
210
+ text: textContent,
211
+ });
212
+
213
+ strapi.log.info(`[magic-sessionmanager/notifications] VPN/Proxy alert sent to ${user.email}`);
214
+ return true;
215
+ } catch (err) {
216
+ strapi.log.error('[magic-sessionmanager/notifications] Error sending VPN/Proxy email:', err);
217
+ return false;
218
+ }
219
+ },
220
+
221
+ /**
222
+ * Send webhook notification
223
+ * @param {Object} params - { event, data, webhookUrl }
224
+ */
225
+ async sendWebhook({ event, data, webhookUrl }) {
226
+ try {
227
+ const payload = {
228
+ event,
229
+ timestamp: new Date().toISOString(),
230
+ data,
231
+ source: 'magic-sessionmanager',
232
+ };
233
+
234
+ const response = await fetch(webhookUrl, {
235
+ method: 'POST',
236
+ headers: {
237
+ 'Content-Type': 'application/json',
238
+ 'User-Agent': 'Strapi-Magic-SessionManager-Webhook/1.0',
239
+ },
240
+ body: JSON.stringify(payload),
241
+ });
242
+
243
+ if (response.ok) {
244
+ strapi.log.info(`[magic-sessionmanager/notifications] Webhook sent: ${event}`);
245
+ return true;
246
+ } else {
247
+ strapi.log.warn(`[magic-sessionmanager/notifications] Webhook failed: ${response.status}`);
248
+ return false;
249
+ }
250
+ } catch (err) {
251
+ strapi.log.error('[magic-sessionmanager/notifications] Webhook error:', err);
252
+ return false;
253
+ }
254
+ },
255
+
256
+ /**
257
+ * Format webhook for Discord
258
+ * @param {Object} params - { event, session, user, geoData }
259
+ */
260
+ formatDiscordWebhook({ event, session, user, geoData }) {
261
+ const embed = {
262
+ title: this.getEventTitle(event),
263
+ color: this.getEventColor(event),
264
+ fields: [
265
+ { name: '๐Ÿ‘ค User', value: `${user.email}\n${user.username || 'N/A'}`, inline: true },
266
+ { name: '๐ŸŒ IP', value: session.ipAddress, inline: true },
267
+ { name: '๐Ÿ“… Time', value: new Date(session.loginTime).toLocaleString(), inline: false },
268
+ ],
269
+ timestamp: new Date().toISOString(),
270
+ footer: { text: 'Magic Session Manager' },
271
+ };
272
+
273
+ if (geoData) {
274
+ embed.fields.push({
275
+ name: '๐Ÿ“ Location',
276
+ value: `${geoData.country_flag} ${geoData.city}, ${geoData.country}`,
277
+ inline: true,
278
+ });
279
+
280
+ if (geoData.isVpn || geoData.isProxy || geoData.isThreat) {
281
+ const warnings = [];
282
+ if (geoData.isVpn) warnings.push('VPN');
283
+ if (geoData.isProxy) warnings.push('Proxy');
284
+ if (geoData.isThreat) warnings.push('Threat');
285
+
286
+ embed.fields.push({
287
+ name: 'โš ๏ธ Security',
288
+ value: `${warnings.join(', ')} detected\nScore: ${geoData.securityScore}/100`,
289
+ inline: true,
290
+ });
291
+ }
292
+ }
293
+
294
+ return { embeds: [embed] };
295
+ },
296
+
297
+ getEventTitle(event) {
298
+ const titles = {
299
+ 'login.suspicious': '๐Ÿšจ Suspicious Login',
300
+ 'login.new_location': '๐Ÿ“ New Location Login',
301
+ 'login.vpn': '๐Ÿ”ด VPN Login Detected',
302
+ 'login.threat': 'โ›” Threat IP Login',
303
+ 'session.terminated': '๐Ÿ”ด Session Terminated',
304
+ };
305
+ return titles[event] || '๐Ÿ“Š Session Event';
306
+ },
307
+
308
+ getEventColor(event) {
309
+ const colors = {
310
+ 'login.suspicious': 0xFF0000, // Red
311
+ 'login.new_location': 0xFFA500, // Orange
312
+ 'login.vpn': 0xFF6B6B, // Light Red
313
+ 'login.threat': 0x8B0000, // Dark Red
314
+ 'session.terminated': 0x808080, // Gray
315
+ };
316
+ return colors[event] || 0x5865F2; // Discord Blue
317
+ },
318
+ });
319
+
@@ -0,0 +1,7 @@
1
+ const service = ({ strapi }) => ({
2
+ getWelcomeMessage() {
3
+ return 'Welcome to Strapi ๐Ÿš€';
4
+ },
5
+ });
6
+
7
+ export default service;
@@ -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
+ });