strapi-plugin-magic-sessionmanager 4.5.3 → 4.5.5

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.
@@ -9981,17 +9981,28 @@ function mountFailedLoginLockout({ strapi: strapi2, log }) {
9981
9981
  });
9982
9982
  log.info("[SUCCESS] Failed-login lockout middleware mounted");
9983
9983
  }
9984
+ function isJwtIssuingPath(path2, method) {
9985
+ if (!path2) return false;
9986
+ const get2 = method === "GET";
9987
+ const post = method === "POST";
9988
+ if (post && path2 === "/api/auth/local") return true;
9989
+ if (post && path2 === "/api/auth/local/register") return true;
9990
+ if (post && path2 === "/api/auth/reset-password") return true;
9991
+ if ((get2 || post) && path2 === "/api/auth/email-confirmation") return true;
9992
+ if (get2 && /^\/api\/auth\/[a-z0-9-]+\/callback$/i.test(path2)) return true;
9993
+ if ((get2 || post) && path2.startsWith("/api/magic-link/login")) return true;
9994
+ if (post && path2 === "/api/magic-link/verify-mfa-totp") return true;
9995
+ if (post && path2 === "/api/magic-link/otp/verify") return true;
9996
+ if (post && path2 === "/api/magic-link/login-totp") return true;
9997
+ if ((get2 || post) && path2.startsWith("/api/passwordless/")) return true;
9998
+ return false;
9999
+ }
9984
10000
  function mountLoginInterceptor({ strapi: strapi2, log, sessionService }) {
9985
10001
  strapi2.server.use(async (ctx, next) => {
9986
10002
  await next();
9987
- const isAuthLocal = ctx.path === "/api/auth/local" && ctx.method === "POST";
9988
- const isMagicLinkLogin = ctx.path.includes("/magic-link/login") && (ctx.method === "GET" || ctx.method === "POST");
9989
- const isMagicLinkMFA = ctx.path.includes("/magic-link/verify-mfa-totp") && ctx.method === "POST";
9990
- const isMagicLinkOTP = ctx.path.includes("/magic-link/otp/verify") && ctx.method === "POST";
9991
- const isMagicLink = isMagicLinkLogin || isMagicLinkMFA || isMagicLinkOTP;
9992
- if (!((isAuthLocal || isMagicLink) && ctx.status === 200 && ctx.body && ctx.body.jwt && ctx.body.user)) {
9993
- return;
9994
- }
10003
+ if (!isJwtIssuingPath(ctx.path, ctx.method)) return;
10004
+ if (ctx.status !== 200) return;
10005
+ if (!ctx.body || !ctx.body.jwt || !ctx.body.user) return;
9995
10006
  try {
9996
10007
  const user = ctx.body.user;
9997
10008
  const ip = getClientIp(ctx);
@@ -10192,11 +10203,19 @@ function mountRefreshTokenInterceptor({ strapi: strapi2, log }) {
10192
10203
  log.info("[SUCCESS] Refresh token interceptor middleware mounted");
10193
10204
  }
10194
10205
  async function ensureContentApiPermissions(strapi2, log) {
10206
+ const PERMISSIONS_VERSION = 2;
10195
10207
  try {
10196
10208
  const pluginStore = strapi2.store({ type: "plugin", name: "magic-sessionmanager" });
10197
- const alreadyInitialized = await pluginStore.get({ key: "contentApiPermissionsInitialized" });
10198
- if (alreadyInitialized === true) {
10199
- log.debug("Content-API permissions already initialized (skipping auto-setup)");
10209
+ const storedVersion = await pluginStore.get({ key: "contentApiPermissionsVersion" });
10210
+ if (storedVersion === void 0) {
10211
+ const legacyFlag = await pluginStore.get({ key: "contentApiPermissionsInitialized" });
10212
+ if (legacyFlag === true) {
10213
+ await pluginStore.set({ key: "contentApiPermissionsVersion", value: 1 });
10214
+ }
10215
+ }
10216
+ const effectiveVersion = await pluginStore.get({ key: "contentApiPermissionsVersion" });
10217
+ if (effectiveVersion >= PERMISSIONS_VERSION) {
10218
+ log.debug("Content-API permissions already at current version (skipping auto-setup)");
10200
10219
  return;
10201
10220
  }
10202
10221
  const ROLE_UID = "plugin::users-permissions.role";
@@ -10214,6 +10233,7 @@ async function ensureContentApiPermissions(strapi2, log) {
10214
10233
  const requiredActions = [
10215
10234
  "plugin::magic-sessionmanager.session.logout",
10216
10235
  "plugin::magic-sessionmanager.session.logoutAll",
10236
+ "plugin::magic-sessionmanager.session.logoutOthers",
10217
10237
  "plugin::magic-sessionmanager.session.getOwnSessions",
10218
10238
  "plugin::magic-sessionmanager.session.getUserSessions",
10219
10239
  "plugin::magic-sessionmanager.session.getCurrentSession",
@@ -10230,7 +10250,7 @@ async function ensureContentApiPermissions(strapi2, log) {
10230
10250
  const existingActions = existingPermissions.map((p) => p.action);
10231
10251
  const missingActions = requiredActions.filter((action) => !existingActions.includes(action));
10232
10252
  if (missingActions.length === 0) {
10233
- await pluginStore.set({ key: "contentApiPermissionsInitialized", value: true });
10253
+ await pluginStore.set({ key: "contentApiPermissionsVersion", value: PERMISSIONS_VERSION });
10234
10254
  log.debug("Content-API permissions already configured");
10235
10255
  return;
10236
10256
  }
@@ -10240,7 +10260,7 @@ async function ensureContentApiPermissions(strapi2, log) {
10240
10260
  });
10241
10261
  log.info(`[PERMISSION] Enabled ${action} for authenticated users`);
10242
10262
  }
10243
- await pluginStore.set({ key: "contentApiPermissionsInitialized", value: true });
10263
+ await pluginStore.set({ key: "contentApiPermissionsVersion", value: PERMISSIONS_VERSION });
10244
10264
  log.info("[SUCCESS] Content-API permissions configured for authenticated users");
10245
10265
  } catch (err) {
10246
10266
  log.warn("Could not auto-configure permissions:", err.message);
@@ -10699,7 +10719,17 @@ var contentApi$1 = {
10699
10719
  config: {
10700
10720
  auth: { strategies: ["users-permissions"] },
10701
10721
  middlewares: writeRateLimit,
10702
- description: "Logout from all devices (requires JWT)"
10722
+ description: "Logout from ALL devices including the current one (requires JWT)"
10723
+ }
10724
+ },
10725
+ {
10726
+ method: "POST",
10727
+ path: "/logout-others",
10728
+ handler: "session.logoutOthers",
10729
+ config: {
10730
+ auth: { strategies: ["users-permissions"] },
10731
+ middlewares: writeRateLimit,
10732
+ description: "Logout from all OTHER devices, keep current session alive (requires JWT)"
10703
10733
  }
10704
10734
  },
10705
10735
  // ================== SESSION QUERIES ==================
@@ -10746,17 +10776,26 @@ var contentApi$1 = {
10746
10776
  }
10747
10777
  ]
10748
10778
  };
10779
+ const PLUGIN_ACCESS_ACTION = "plugin::magic-sessionmanager.access";
10780
+ const adminPolicy = () => [
10781
+ "admin::isAuthenticatedAdmin",
10782
+ {
10783
+ name: "admin::hasPermissions",
10784
+ config: { actions: [PLUGIN_ACCESS_ACTION] }
10785
+ }
10786
+ ];
10749
10787
  const isDevEnvironment = (() => {
10750
10788
  const env2 = (process.env.NODE_ENV || "development").toLowerCase();
10751
10789
  return env2 !== "production" && env2 !== "staging";
10752
10790
  })();
10753
10791
  const baseRoutes = [
10792
+ // ============================ SESSIONS ============================
10754
10793
  {
10755
10794
  method: "GET",
10756
10795
  path: "/sessions",
10757
10796
  handler: "session.getAllSessionsAdmin",
10758
10797
  config: {
10759
- policies: ["admin::isAuthenticatedAdmin"],
10798
+ policies: adminPolicy(),
10760
10799
  description: "Get all sessions - active and inactive (admin)"
10761
10800
  }
10762
10801
  },
@@ -10765,7 +10804,7 @@ const baseRoutes = [
10765
10804
  path: "/sessions/active",
10766
10805
  handler: "session.getActiveSessions",
10767
10806
  config: {
10768
- policies: ["admin::isAuthenticatedAdmin"],
10807
+ policies: adminPolicy(),
10769
10808
  description: "Get only active sessions (admin)"
10770
10809
  }
10771
10810
  },
@@ -10774,8 +10813,8 @@ const baseRoutes = [
10774
10813
  path: "/user/:userId/sessions",
10775
10814
  handler: "session.getUserSessions",
10776
10815
  config: {
10777
- policies: ["admin::isAuthenticatedAdmin"],
10778
- description: "Get user sessions (admin)"
10816
+ policies: adminPolicy(),
10817
+ description: "Get sessions for a specific user (admin)"
10779
10818
  }
10780
10819
  },
10781
10820
  {
@@ -10783,7 +10822,7 @@ const baseRoutes = [
10783
10822
  path: "/sessions/:sessionId/terminate",
10784
10823
  handler: "session.terminateSingleSession",
10785
10824
  config: {
10786
- policies: ["admin::isAuthenticatedAdmin"],
10825
+ policies: adminPolicy(),
10787
10826
  description: "Terminate a specific session (admin)"
10788
10827
  }
10789
10828
  },
@@ -10792,7 +10831,7 @@ const baseRoutes = [
10792
10831
  path: "/sessions/:sessionId",
10793
10832
  handler: "session.deleteSession",
10794
10833
  config: {
10795
- policies: ["admin::isAuthenticatedAdmin"],
10834
+ policies: adminPolicy(),
10796
10835
  description: "Delete a single session permanently (admin)"
10797
10836
  }
10798
10837
  },
@@ -10801,7 +10840,7 @@ const baseRoutes = [
10801
10840
  path: "/sessions/clean-inactive",
10802
10841
  handler: "session.cleanInactiveSessions",
10803
10842
  config: {
10804
- policies: ["admin::isAuthenticatedAdmin"],
10843
+ policies: adminPolicy(),
10805
10844
  description: "Delete all inactive sessions from database (admin)"
10806
10845
  }
10807
10846
  },
@@ -10810,7 +10849,7 @@ const baseRoutes = [
10810
10849
  path: "/user/:userId/terminate-all",
10811
10850
  handler: "session.terminateAllUserSessions",
10812
10851
  config: {
10813
- policies: ["admin::isAuthenticatedAdmin"],
10852
+ policies: adminPolicy(),
10814
10853
  description: "Terminate all sessions for a user (admin)"
10815
10854
  }
10816
10855
  },
@@ -10819,56 +10858,74 @@ const baseRoutes = [
10819
10858
  path: "/user/:userId/toggle-block",
10820
10859
  handler: "session.toggleUserBlock",
10821
10860
  config: {
10822
- policies: ["admin::isAuthenticatedAdmin"],
10861
+ policies: adminPolicy(),
10823
10862
  description: "Toggle user blocked status (admin)"
10824
10863
  }
10825
10864
  },
10865
+ // ============================ LICENSE ============================
10826
10866
  {
10827
10867
  method: "GET",
10828
10868
  path: "/license/status",
10829
10869
  handler: "license.getStatus",
10830
- config: { policies: ["admin::isAuthenticatedAdmin"] }
10870
+ config: {
10871
+ policies: adminPolicy(),
10872
+ description: "Get license status (admin)"
10873
+ }
10831
10874
  },
10832
10875
  {
10833
10876
  method: "POST",
10834
10877
  path: "/license/auto-create",
10835
10878
  handler: "license.autoCreate",
10836
- config: { policies: ["admin::isAuthenticatedAdmin"] }
10879
+ config: {
10880
+ policies: adminPolicy(),
10881
+ description: "Auto-create license for current admin (admin)"
10882
+ }
10837
10883
  },
10838
10884
  {
10839
10885
  method: "POST",
10840
10886
  path: "/license/create",
10841
10887
  handler: "license.createAndActivate",
10842
- config: { policies: ["admin::isAuthenticatedAdmin"] }
10888
+ config: {
10889
+ policies: adminPolicy(),
10890
+ description: "Create and activate a new license (admin)"
10891
+ }
10843
10892
  },
10844
10893
  {
10845
10894
  method: "POST",
10846
10895
  path: "/license/ping",
10847
10896
  handler: "license.ping",
10848
- config: { policies: ["admin::isAuthenticatedAdmin"] }
10897
+ config: {
10898
+ policies: adminPolicy(),
10899
+ description: "Ping the license server (admin)"
10900
+ }
10849
10901
  },
10850
10902
  {
10851
10903
  method: "POST",
10852
10904
  path: "/license/store-key",
10853
10905
  handler: "license.storeKey",
10854
- config: { policies: ["admin::isAuthenticatedAdmin"] }
10906
+ config: {
10907
+ policies: adminPolicy(),
10908
+ description: "Store a license key (admin)"
10909
+ }
10855
10910
  },
10911
+ // ============================ GEOLOCATION ============================
10856
10912
  {
10857
10913
  method: "GET",
10858
10914
  path: "/geolocation/:ipAddress",
10859
10915
  handler: "session.getIpGeolocation",
10860
10916
  config: {
10861
- policies: ["admin::isAuthenticatedAdmin"],
10862
- description: "Get IP geolocation data (Premium feature)"
10917
+ policies: adminPolicy(),
10918
+ description: "Get IP geolocation data (Premium feature, admin)"
10863
10919
  }
10864
10920
  },
10921
+ // ============================ SETTINGS ============================
10865
10922
  {
10866
10923
  method: "GET",
10867
10924
  path: "/settings",
10868
10925
  handler: "settings.getSettings",
10869
10926
  config: {
10870
- policies: ["admin::isAuthenticatedAdmin"],
10871
- description: "Get plugin settings"
10927
+ policies: adminPolicy(),
10928
+ description: "Get plugin settings (admin)"
10872
10929
  }
10873
10930
  },
10874
10931
  {
@@ -10876,8 +10933,8 @@ const baseRoutes = [
10876
10933
  path: "/settings",
10877
10934
  handler: "settings.updateSettings",
10878
10935
  config: {
10879
- policies: ["admin::isAuthenticatedAdmin"],
10880
- description: "Update plugin settings"
10936
+ policies: adminPolicy(),
10937
+ description: "Update plugin settings (admin)"
10881
10938
  }
10882
10939
  }
10883
10940
  ];
@@ -10887,8 +10944,8 @@ const devOnlyRoutes = [
10887
10944
  path: "/sessions/:sessionId/simulate-timeout",
10888
10945
  handler: "session.simulateTimeout",
10889
10946
  config: {
10890
- policies: ["admin::isAuthenticatedAdmin"],
10891
- description: "Simulate session timeout (dev-only)"
10947
+ policies: adminPolicy(),
10948
+ description: "Simulate session timeout (dev-only, admin)"
10892
10949
  }
10893
10950
  }
10894
10951
  ];
@@ -11251,7 +11308,14 @@ var session$3 = {
11251
11308
  }
11252
11309
  },
11253
11310
  /**
11254
- * Terminates all sessions of the authenticated user.
11311
+ * Terminates EVERY session of the authenticated user — including the
11312
+ * current one. After this call the caller will also be logged out on
11313
+ * the next request (their own JWT is rejected by the JWT-verify wrapper
11314
+ * with reason=manual).
11315
+ *
11316
+ * For the "log me out everywhere ELSE but keep me here" flow, use
11317
+ * `/logout-others` below.
11318
+ *
11255
11319
  * @route POST /api/magic-sessionmanager/logout-all
11256
11320
  */
11257
11321
  async logoutAll(ctx) {
@@ -11261,14 +11325,82 @@ var session$3 = {
11261
11325
  return ctx.unauthorized("Authentication required");
11262
11326
  }
11263
11327
  const sessionService = strapi.plugin("magic-sessionmanager").service("session");
11264
- await sessionService.terminateSession({ userId: userDocId, reason: "manual" });
11265
- strapi.log.info(`[magic-sessionmanager] User ${userDocId} logged out from all devices`);
11266
- ctx.body = { message: "Logged out from all devices successfully" };
11328
+ const { terminatedCount } = await sessionService.terminateSession({
11329
+ userId: userDocId,
11330
+ reason: "manual"
11331
+ });
11332
+ strapi.log.info(
11333
+ `[magic-sessionmanager] User ${userDocId} logged out from all devices (${terminatedCount})`
11334
+ );
11335
+ ctx.body = {
11336
+ message: "Logged out from all devices successfully",
11337
+ terminatedCount
11338
+ };
11267
11339
  } catch (err) {
11268
11340
  strapi.log.error("[magic-sessionmanager] Logout-all error:", err);
11269
11341
  ctx.throw(500, "Error during logout");
11270
11342
  }
11271
11343
  },
11344
+ /**
11345
+ * Terminates every session of the authenticated user EXCEPT the current
11346
+ * one. This is the "kick everyone else off my account" flow — the
11347
+ * caller stays logged in on the device they are using.
11348
+ *
11349
+ * If no "current session" record can be located for the caller's JWT
11350
+ * (edge case: user logged in before the session-manager was installed,
11351
+ * or right at the end of the grace window) we fall back to terminating
11352
+ * ALL sessions so the user still gets the safety effect they asked for,
11353
+ * and we report `fellBackToLogoutAll: true` so the client can adjust
11354
+ * its success message.
11355
+ *
11356
+ * @route POST /api/magic-sessionmanager/logout-others
11357
+ */
11358
+ async logoutOthers(ctx) {
11359
+ try {
11360
+ const userDocId = await resolveAuthUserDocId(ctx);
11361
+ const token = extractBearerToken$1(ctx);
11362
+ if (!userDocId || !token) {
11363
+ return ctx.unauthorized("Authentication required");
11364
+ }
11365
+ const currentTokenHash = hashToken$2(token);
11366
+ const currentSession = await strapi.documents(SESSION_UID$2).findFirst({
11367
+ filters: { user: { documentId: userDocId }, tokenHash: currentTokenHash },
11368
+ fields: ["documentId"]
11369
+ });
11370
+ const sessionService = strapi.plugin("magic-sessionmanager").service("session");
11371
+ if (!currentSession) {
11372
+ const { terminatedCount: terminatedCount2 } = await sessionService.terminateSession({
11373
+ userId: userDocId,
11374
+ reason: "manual"
11375
+ });
11376
+ strapi.log.warn(
11377
+ `[magic-sessionmanager] logoutOthers fell back to logoutAll for user ${userDocId} (no current session match)`
11378
+ );
11379
+ ctx.body = {
11380
+ message: "All sessions terminated (could not preserve current session)",
11381
+ terminatedCount: terminatedCount2,
11382
+ fellBackToLogoutAll: true
11383
+ };
11384
+ return;
11385
+ }
11386
+ const { terminatedCount } = await sessionService.terminateSession({
11387
+ userId: userDocId,
11388
+ exceptSessionId: currentSession.documentId,
11389
+ reason: "manual"
11390
+ });
11391
+ strapi.log.info(
11392
+ `[magic-sessionmanager] User ${userDocId} logged out ${terminatedCount} other device(s)`
11393
+ );
11394
+ ctx.body = {
11395
+ message: terminatedCount === 0 ? "No other active sessions to terminate" : `${terminatedCount} other session(s) terminated`,
11396
+ terminatedCount,
11397
+ currentSessionPreserved: true
11398
+ };
11399
+ } catch (err) {
11400
+ strapi.log.error("[magic-sessionmanager] Logout-others error:", err);
11401
+ ctx.throw(500, "Error terminating other sessions");
11402
+ }
11403
+ },
11272
11404
  /**
11273
11405
  * Returns the session associated with the current JWT.
11274
11406
  *
@@ -12123,9 +12255,9 @@ var session$1 = ({ strapi: strapi2 }) => {
12123
12255
  }
12124
12256
  },
12125
12257
  /**
12126
- * Terminates a single session or all sessions of a user with a typed
12127
- * reason so the JWT-verify wrapper and downstream middleware can
12128
- * communicate the cause to the client (and we can show sensible UI).
12258
+ * Terminates a single session, all sessions of a user, or all sessions
12259
+ * of a user EXCEPT one with a typed reason so the JWT-verify wrapper
12260
+ * can communicate the cause to the client.
12129
12261
  *
12130
12262
  * Supported reasons:
12131
12263
  * - 'manual': user clicked logout, or admin terminated a session
@@ -12139,12 +12271,18 @@ var session$1 = ({ strapi: strapi2 }) => {
12139
12271
  * to work, while new code relies on `terminationReason`.
12140
12272
  *
12141
12273
  * @param {Object} params
12142
- * @param {string} [params.sessionId]
12143
- * @param {string|number} [params.userId]
12274
+ * @param {string} [params.sessionId] Terminate exactly this session
12275
+ * @param {string|number} [params.userId] Terminate every active session
12276
+ * of this user …
12277
+ * @param {string} [params.exceptSessionId] … except for this one. Only
12278
+ * meaningful together with
12279
+ * `userId`. Used by
12280
+ * /logout-other-devices so
12281
+ * the caller stays logged in.
12144
12282
  * @param {'manual'|'idle'|'expired'|'blocked'} [params.reason='manual']
12145
- * @returns {Promise<void>}
12283
+ * @returns {Promise<{terminatedCount: number}>}
12146
12284
  */
12147
- async terminateSession({ sessionId, userId, reason = "manual" }) {
12285
+ async terminateSession({ sessionId, userId, exceptSessionId = null, reason = "manual" }) {
12148
12286
  try {
12149
12287
  const now = /* @__PURE__ */ new Date();
12150
12288
  const validReasons = ["manual", "idle", "expired", "blocked"];
@@ -12162,29 +12300,46 @@ var session$1 = ({ strapi: strapi2 }) => {
12162
12300
  });
12163
12301
  if (!existing) {
12164
12302
  log.warn(`Session ${sessionId} not found for termination`);
12165
- return;
12303
+ return { terminatedCount: 0 };
12166
12304
  }
12167
12305
  await strapi2.documents(SESSION_UID$1).update({
12168
12306
  documentId: sessionId,
12169
12307
  data: updateData
12170
12308
  });
12171
12309
  log.info(`Session ${sessionId} terminated (reason: ${finalReason})`);
12172
- } else if (userId) {
12310
+ return { terminatedCount: 1 };
12311
+ }
12312
+ if (userId) {
12173
12313
  const userDocumentId = await resolveUserDocumentId$1(strapi2, userId);
12174
- if (!userDocumentId) return;
12314
+ if (!userDocumentId) return { terminatedCount: 0 };
12315
+ const filters2 = { user: { documentId: userDocumentId }, isActive: true };
12316
+ if (exceptSessionId) {
12317
+ filters2.documentId = { $ne: exceptSessionId };
12318
+ }
12175
12319
  const activeSessions = await strapi2.documents(SESSION_UID$1).findMany({
12176
- filters: { user: { documentId: userDocumentId }, isActive: true },
12320
+ filters: filters2,
12177
12321
  fields: ["documentId"],
12178
12322
  limit: MAX_SESSIONS_QUERY
12179
12323
  });
12324
+ let terminatedCount = 0;
12180
12325
  for (const session2 of activeSessions) {
12181
- await strapi2.documents(SESSION_UID$1).update({
12182
- documentId: session2.documentId,
12183
- data: updateData
12184
- });
12326
+ try {
12327
+ await strapi2.documents(SESSION_UID$1).update({
12328
+ documentId: session2.documentId,
12329
+ data: updateData
12330
+ });
12331
+ terminatedCount++;
12332
+ } catch (err) {
12333
+ log.debug(`Failed to terminate session ${session2.documentId}:`, err.message);
12334
+ }
12185
12335
  }
12186
- log.info(`All sessions terminated for user ${userDocumentId} (reason: ${finalReason})`);
12336
+ const label = exceptSessionId ? "OTHER sessions" : "ALL sessions";
12337
+ log.info(
12338
+ `${label} terminated for user ${userDocumentId} (reason: ${finalReason}, count: ${terminatedCount})`
12339
+ );
12340
+ return { terminatedCount };
12187
12341
  }
12342
+ return { terminatedCount: 0 };
12188
12343
  } catch (err) {
12189
12344
  log.error("Error terminating session:", err);
12190
12345
  throw err;
@@ -12544,7 +12699,7 @@ var session$1 = ({ strapi: strapi2 }) => {
12544
12699
  }
12545
12700
  };
12546
12701
  };
12547
- const version$1 = "4.5.2";
12702
+ const version$1 = "4.5.4";
12548
12703
  const require$$2 = {
12549
12704
  version: version$1
12550
12705
  };
@@ -9968,17 +9968,28 @@ function mountFailedLoginLockout({ strapi: strapi2, log }) {
9968
9968
  });
9969
9969
  log.info("[SUCCESS] Failed-login lockout middleware mounted");
9970
9970
  }
9971
+ function isJwtIssuingPath(path2, method) {
9972
+ if (!path2) return false;
9973
+ const get2 = method === "GET";
9974
+ const post = method === "POST";
9975
+ if (post && path2 === "/api/auth/local") return true;
9976
+ if (post && path2 === "/api/auth/local/register") return true;
9977
+ if (post && path2 === "/api/auth/reset-password") return true;
9978
+ if ((get2 || post) && path2 === "/api/auth/email-confirmation") return true;
9979
+ if (get2 && /^\/api\/auth\/[a-z0-9-]+\/callback$/i.test(path2)) return true;
9980
+ if ((get2 || post) && path2.startsWith("/api/magic-link/login")) return true;
9981
+ if (post && path2 === "/api/magic-link/verify-mfa-totp") return true;
9982
+ if (post && path2 === "/api/magic-link/otp/verify") return true;
9983
+ if (post && path2 === "/api/magic-link/login-totp") return true;
9984
+ if ((get2 || post) && path2.startsWith("/api/passwordless/")) return true;
9985
+ return false;
9986
+ }
9971
9987
  function mountLoginInterceptor({ strapi: strapi2, log, sessionService }) {
9972
9988
  strapi2.server.use(async (ctx, next) => {
9973
9989
  await next();
9974
- const isAuthLocal = ctx.path === "/api/auth/local" && ctx.method === "POST";
9975
- const isMagicLinkLogin = ctx.path.includes("/magic-link/login") && (ctx.method === "GET" || ctx.method === "POST");
9976
- const isMagicLinkMFA = ctx.path.includes("/magic-link/verify-mfa-totp") && ctx.method === "POST";
9977
- const isMagicLinkOTP = ctx.path.includes("/magic-link/otp/verify") && ctx.method === "POST";
9978
- const isMagicLink = isMagicLinkLogin || isMagicLinkMFA || isMagicLinkOTP;
9979
- if (!((isAuthLocal || isMagicLink) && ctx.status === 200 && ctx.body && ctx.body.jwt && ctx.body.user)) {
9980
- return;
9981
- }
9990
+ if (!isJwtIssuingPath(ctx.path, ctx.method)) return;
9991
+ if (ctx.status !== 200) return;
9992
+ if (!ctx.body || !ctx.body.jwt || !ctx.body.user) return;
9982
9993
  try {
9983
9994
  const user = ctx.body.user;
9984
9995
  const ip = getClientIp(ctx);
@@ -10179,11 +10190,19 @@ function mountRefreshTokenInterceptor({ strapi: strapi2, log }) {
10179
10190
  log.info("[SUCCESS] Refresh token interceptor middleware mounted");
10180
10191
  }
10181
10192
  async function ensureContentApiPermissions(strapi2, log) {
10193
+ const PERMISSIONS_VERSION = 2;
10182
10194
  try {
10183
10195
  const pluginStore = strapi2.store({ type: "plugin", name: "magic-sessionmanager" });
10184
- const alreadyInitialized = await pluginStore.get({ key: "contentApiPermissionsInitialized" });
10185
- if (alreadyInitialized === true) {
10186
- log.debug("Content-API permissions already initialized (skipping auto-setup)");
10196
+ const storedVersion = await pluginStore.get({ key: "contentApiPermissionsVersion" });
10197
+ if (storedVersion === void 0) {
10198
+ const legacyFlag = await pluginStore.get({ key: "contentApiPermissionsInitialized" });
10199
+ if (legacyFlag === true) {
10200
+ await pluginStore.set({ key: "contentApiPermissionsVersion", value: 1 });
10201
+ }
10202
+ }
10203
+ const effectiveVersion = await pluginStore.get({ key: "contentApiPermissionsVersion" });
10204
+ if (effectiveVersion >= PERMISSIONS_VERSION) {
10205
+ log.debug("Content-API permissions already at current version (skipping auto-setup)");
10187
10206
  return;
10188
10207
  }
10189
10208
  const ROLE_UID = "plugin::users-permissions.role";
@@ -10201,6 +10220,7 @@ async function ensureContentApiPermissions(strapi2, log) {
10201
10220
  const requiredActions = [
10202
10221
  "plugin::magic-sessionmanager.session.logout",
10203
10222
  "plugin::magic-sessionmanager.session.logoutAll",
10223
+ "plugin::magic-sessionmanager.session.logoutOthers",
10204
10224
  "plugin::magic-sessionmanager.session.getOwnSessions",
10205
10225
  "plugin::magic-sessionmanager.session.getUserSessions",
10206
10226
  "plugin::magic-sessionmanager.session.getCurrentSession",
@@ -10217,7 +10237,7 @@ async function ensureContentApiPermissions(strapi2, log) {
10217
10237
  const existingActions = existingPermissions.map((p) => p.action);
10218
10238
  const missingActions = requiredActions.filter((action) => !existingActions.includes(action));
10219
10239
  if (missingActions.length === 0) {
10220
- await pluginStore.set({ key: "contentApiPermissionsInitialized", value: true });
10240
+ await pluginStore.set({ key: "contentApiPermissionsVersion", value: PERMISSIONS_VERSION });
10221
10241
  log.debug("Content-API permissions already configured");
10222
10242
  return;
10223
10243
  }
@@ -10227,7 +10247,7 @@ async function ensureContentApiPermissions(strapi2, log) {
10227
10247
  });
10228
10248
  log.info(`[PERMISSION] Enabled ${action} for authenticated users`);
10229
10249
  }
10230
- await pluginStore.set({ key: "contentApiPermissionsInitialized", value: true });
10250
+ await pluginStore.set({ key: "contentApiPermissionsVersion", value: PERMISSIONS_VERSION });
10231
10251
  log.info("[SUCCESS] Content-API permissions configured for authenticated users");
10232
10252
  } catch (err) {
10233
10253
  log.warn("Could not auto-configure permissions:", err.message);
@@ -10686,7 +10706,17 @@ var contentApi$1 = {
10686
10706
  config: {
10687
10707
  auth: { strategies: ["users-permissions"] },
10688
10708
  middlewares: writeRateLimit,
10689
- description: "Logout from all devices (requires JWT)"
10709
+ description: "Logout from ALL devices including the current one (requires JWT)"
10710
+ }
10711
+ },
10712
+ {
10713
+ method: "POST",
10714
+ path: "/logout-others",
10715
+ handler: "session.logoutOthers",
10716
+ config: {
10717
+ auth: { strategies: ["users-permissions"] },
10718
+ middlewares: writeRateLimit,
10719
+ description: "Logout from all OTHER devices, keep current session alive (requires JWT)"
10690
10720
  }
10691
10721
  },
10692
10722
  // ================== SESSION QUERIES ==================
@@ -10733,17 +10763,26 @@ var contentApi$1 = {
10733
10763
  }
10734
10764
  ]
10735
10765
  };
10766
+ const PLUGIN_ACCESS_ACTION = "plugin::magic-sessionmanager.access";
10767
+ const adminPolicy = () => [
10768
+ "admin::isAuthenticatedAdmin",
10769
+ {
10770
+ name: "admin::hasPermissions",
10771
+ config: { actions: [PLUGIN_ACCESS_ACTION] }
10772
+ }
10773
+ ];
10736
10774
  const isDevEnvironment = (() => {
10737
10775
  const env2 = (process.env.NODE_ENV || "development").toLowerCase();
10738
10776
  return env2 !== "production" && env2 !== "staging";
10739
10777
  })();
10740
10778
  const baseRoutes = [
10779
+ // ============================ SESSIONS ============================
10741
10780
  {
10742
10781
  method: "GET",
10743
10782
  path: "/sessions",
10744
10783
  handler: "session.getAllSessionsAdmin",
10745
10784
  config: {
10746
- policies: ["admin::isAuthenticatedAdmin"],
10785
+ policies: adminPolicy(),
10747
10786
  description: "Get all sessions - active and inactive (admin)"
10748
10787
  }
10749
10788
  },
@@ -10752,7 +10791,7 @@ const baseRoutes = [
10752
10791
  path: "/sessions/active",
10753
10792
  handler: "session.getActiveSessions",
10754
10793
  config: {
10755
- policies: ["admin::isAuthenticatedAdmin"],
10794
+ policies: adminPolicy(),
10756
10795
  description: "Get only active sessions (admin)"
10757
10796
  }
10758
10797
  },
@@ -10761,8 +10800,8 @@ const baseRoutes = [
10761
10800
  path: "/user/:userId/sessions",
10762
10801
  handler: "session.getUserSessions",
10763
10802
  config: {
10764
- policies: ["admin::isAuthenticatedAdmin"],
10765
- description: "Get user sessions (admin)"
10803
+ policies: adminPolicy(),
10804
+ description: "Get sessions for a specific user (admin)"
10766
10805
  }
10767
10806
  },
10768
10807
  {
@@ -10770,7 +10809,7 @@ const baseRoutes = [
10770
10809
  path: "/sessions/:sessionId/terminate",
10771
10810
  handler: "session.terminateSingleSession",
10772
10811
  config: {
10773
- policies: ["admin::isAuthenticatedAdmin"],
10812
+ policies: adminPolicy(),
10774
10813
  description: "Terminate a specific session (admin)"
10775
10814
  }
10776
10815
  },
@@ -10779,7 +10818,7 @@ const baseRoutes = [
10779
10818
  path: "/sessions/:sessionId",
10780
10819
  handler: "session.deleteSession",
10781
10820
  config: {
10782
- policies: ["admin::isAuthenticatedAdmin"],
10821
+ policies: adminPolicy(),
10783
10822
  description: "Delete a single session permanently (admin)"
10784
10823
  }
10785
10824
  },
@@ -10788,7 +10827,7 @@ const baseRoutes = [
10788
10827
  path: "/sessions/clean-inactive",
10789
10828
  handler: "session.cleanInactiveSessions",
10790
10829
  config: {
10791
- policies: ["admin::isAuthenticatedAdmin"],
10830
+ policies: adminPolicy(),
10792
10831
  description: "Delete all inactive sessions from database (admin)"
10793
10832
  }
10794
10833
  },
@@ -10797,7 +10836,7 @@ const baseRoutes = [
10797
10836
  path: "/user/:userId/terminate-all",
10798
10837
  handler: "session.terminateAllUserSessions",
10799
10838
  config: {
10800
- policies: ["admin::isAuthenticatedAdmin"],
10839
+ policies: adminPolicy(),
10801
10840
  description: "Terminate all sessions for a user (admin)"
10802
10841
  }
10803
10842
  },
@@ -10806,56 +10845,74 @@ const baseRoutes = [
10806
10845
  path: "/user/:userId/toggle-block",
10807
10846
  handler: "session.toggleUserBlock",
10808
10847
  config: {
10809
- policies: ["admin::isAuthenticatedAdmin"],
10848
+ policies: adminPolicy(),
10810
10849
  description: "Toggle user blocked status (admin)"
10811
10850
  }
10812
10851
  },
10852
+ // ============================ LICENSE ============================
10813
10853
  {
10814
10854
  method: "GET",
10815
10855
  path: "/license/status",
10816
10856
  handler: "license.getStatus",
10817
- config: { policies: ["admin::isAuthenticatedAdmin"] }
10857
+ config: {
10858
+ policies: adminPolicy(),
10859
+ description: "Get license status (admin)"
10860
+ }
10818
10861
  },
10819
10862
  {
10820
10863
  method: "POST",
10821
10864
  path: "/license/auto-create",
10822
10865
  handler: "license.autoCreate",
10823
- config: { policies: ["admin::isAuthenticatedAdmin"] }
10866
+ config: {
10867
+ policies: adminPolicy(),
10868
+ description: "Auto-create license for current admin (admin)"
10869
+ }
10824
10870
  },
10825
10871
  {
10826
10872
  method: "POST",
10827
10873
  path: "/license/create",
10828
10874
  handler: "license.createAndActivate",
10829
- config: { policies: ["admin::isAuthenticatedAdmin"] }
10875
+ config: {
10876
+ policies: adminPolicy(),
10877
+ description: "Create and activate a new license (admin)"
10878
+ }
10830
10879
  },
10831
10880
  {
10832
10881
  method: "POST",
10833
10882
  path: "/license/ping",
10834
10883
  handler: "license.ping",
10835
- config: { policies: ["admin::isAuthenticatedAdmin"] }
10884
+ config: {
10885
+ policies: adminPolicy(),
10886
+ description: "Ping the license server (admin)"
10887
+ }
10836
10888
  },
10837
10889
  {
10838
10890
  method: "POST",
10839
10891
  path: "/license/store-key",
10840
10892
  handler: "license.storeKey",
10841
- config: { policies: ["admin::isAuthenticatedAdmin"] }
10893
+ config: {
10894
+ policies: adminPolicy(),
10895
+ description: "Store a license key (admin)"
10896
+ }
10842
10897
  },
10898
+ // ============================ GEOLOCATION ============================
10843
10899
  {
10844
10900
  method: "GET",
10845
10901
  path: "/geolocation/:ipAddress",
10846
10902
  handler: "session.getIpGeolocation",
10847
10903
  config: {
10848
- policies: ["admin::isAuthenticatedAdmin"],
10849
- description: "Get IP geolocation data (Premium feature)"
10904
+ policies: adminPolicy(),
10905
+ description: "Get IP geolocation data (Premium feature, admin)"
10850
10906
  }
10851
10907
  },
10908
+ // ============================ SETTINGS ============================
10852
10909
  {
10853
10910
  method: "GET",
10854
10911
  path: "/settings",
10855
10912
  handler: "settings.getSettings",
10856
10913
  config: {
10857
- policies: ["admin::isAuthenticatedAdmin"],
10858
- description: "Get plugin settings"
10914
+ policies: adminPolicy(),
10915
+ description: "Get plugin settings (admin)"
10859
10916
  }
10860
10917
  },
10861
10918
  {
@@ -10863,8 +10920,8 @@ const baseRoutes = [
10863
10920
  path: "/settings",
10864
10921
  handler: "settings.updateSettings",
10865
10922
  config: {
10866
- policies: ["admin::isAuthenticatedAdmin"],
10867
- description: "Update plugin settings"
10923
+ policies: adminPolicy(),
10924
+ description: "Update plugin settings (admin)"
10868
10925
  }
10869
10926
  }
10870
10927
  ];
@@ -10874,8 +10931,8 @@ const devOnlyRoutes = [
10874
10931
  path: "/sessions/:sessionId/simulate-timeout",
10875
10932
  handler: "session.simulateTimeout",
10876
10933
  config: {
10877
- policies: ["admin::isAuthenticatedAdmin"],
10878
- description: "Simulate session timeout (dev-only)"
10934
+ policies: adminPolicy(),
10935
+ description: "Simulate session timeout (dev-only, admin)"
10879
10936
  }
10880
10937
  }
10881
10938
  ];
@@ -11238,7 +11295,14 @@ var session$3 = {
11238
11295
  }
11239
11296
  },
11240
11297
  /**
11241
- * Terminates all sessions of the authenticated user.
11298
+ * Terminates EVERY session of the authenticated user — including the
11299
+ * current one. After this call the caller will also be logged out on
11300
+ * the next request (their own JWT is rejected by the JWT-verify wrapper
11301
+ * with reason=manual).
11302
+ *
11303
+ * For the "log me out everywhere ELSE but keep me here" flow, use
11304
+ * `/logout-others` below.
11305
+ *
11242
11306
  * @route POST /api/magic-sessionmanager/logout-all
11243
11307
  */
11244
11308
  async logoutAll(ctx) {
@@ -11248,14 +11312,82 @@ var session$3 = {
11248
11312
  return ctx.unauthorized("Authentication required");
11249
11313
  }
11250
11314
  const sessionService = strapi.plugin("magic-sessionmanager").service("session");
11251
- await sessionService.terminateSession({ userId: userDocId, reason: "manual" });
11252
- strapi.log.info(`[magic-sessionmanager] User ${userDocId} logged out from all devices`);
11253
- ctx.body = { message: "Logged out from all devices successfully" };
11315
+ const { terminatedCount } = await sessionService.terminateSession({
11316
+ userId: userDocId,
11317
+ reason: "manual"
11318
+ });
11319
+ strapi.log.info(
11320
+ `[magic-sessionmanager] User ${userDocId} logged out from all devices (${terminatedCount})`
11321
+ );
11322
+ ctx.body = {
11323
+ message: "Logged out from all devices successfully",
11324
+ terminatedCount
11325
+ };
11254
11326
  } catch (err) {
11255
11327
  strapi.log.error("[magic-sessionmanager] Logout-all error:", err);
11256
11328
  ctx.throw(500, "Error during logout");
11257
11329
  }
11258
11330
  },
11331
+ /**
11332
+ * Terminates every session of the authenticated user EXCEPT the current
11333
+ * one. This is the "kick everyone else off my account" flow — the
11334
+ * caller stays logged in on the device they are using.
11335
+ *
11336
+ * If no "current session" record can be located for the caller's JWT
11337
+ * (edge case: user logged in before the session-manager was installed,
11338
+ * or right at the end of the grace window) we fall back to terminating
11339
+ * ALL sessions so the user still gets the safety effect they asked for,
11340
+ * and we report `fellBackToLogoutAll: true` so the client can adjust
11341
+ * its success message.
11342
+ *
11343
+ * @route POST /api/magic-sessionmanager/logout-others
11344
+ */
11345
+ async logoutOthers(ctx) {
11346
+ try {
11347
+ const userDocId = await resolveAuthUserDocId(ctx);
11348
+ const token = extractBearerToken$1(ctx);
11349
+ if (!userDocId || !token) {
11350
+ return ctx.unauthorized("Authentication required");
11351
+ }
11352
+ const currentTokenHash = hashToken$2(token);
11353
+ const currentSession = await strapi.documents(SESSION_UID$2).findFirst({
11354
+ filters: { user: { documentId: userDocId }, tokenHash: currentTokenHash },
11355
+ fields: ["documentId"]
11356
+ });
11357
+ const sessionService = strapi.plugin("magic-sessionmanager").service("session");
11358
+ if (!currentSession) {
11359
+ const { terminatedCount: terminatedCount2 } = await sessionService.terminateSession({
11360
+ userId: userDocId,
11361
+ reason: "manual"
11362
+ });
11363
+ strapi.log.warn(
11364
+ `[magic-sessionmanager] logoutOthers fell back to logoutAll for user ${userDocId} (no current session match)`
11365
+ );
11366
+ ctx.body = {
11367
+ message: "All sessions terminated (could not preserve current session)",
11368
+ terminatedCount: terminatedCount2,
11369
+ fellBackToLogoutAll: true
11370
+ };
11371
+ return;
11372
+ }
11373
+ const { terminatedCount } = await sessionService.terminateSession({
11374
+ userId: userDocId,
11375
+ exceptSessionId: currentSession.documentId,
11376
+ reason: "manual"
11377
+ });
11378
+ strapi.log.info(
11379
+ `[magic-sessionmanager] User ${userDocId} logged out ${terminatedCount} other device(s)`
11380
+ );
11381
+ ctx.body = {
11382
+ message: terminatedCount === 0 ? "No other active sessions to terminate" : `${terminatedCount} other session(s) terminated`,
11383
+ terminatedCount,
11384
+ currentSessionPreserved: true
11385
+ };
11386
+ } catch (err) {
11387
+ strapi.log.error("[magic-sessionmanager] Logout-others error:", err);
11388
+ ctx.throw(500, "Error terminating other sessions");
11389
+ }
11390
+ },
11259
11391
  /**
11260
11392
  * Returns the session associated with the current JWT.
11261
11393
  *
@@ -12110,9 +12242,9 @@ var session$1 = ({ strapi: strapi2 }) => {
12110
12242
  }
12111
12243
  },
12112
12244
  /**
12113
- * Terminates a single session or all sessions of a user with a typed
12114
- * reason so the JWT-verify wrapper and downstream middleware can
12115
- * communicate the cause to the client (and we can show sensible UI).
12245
+ * Terminates a single session, all sessions of a user, or all sessions
12246
+ * of a user EXCEPT one with a typed reason so the JWT-verify wrapper
12247
+ * can communicate the cause to the client.
12116
12248
  *
12117
12249
  * Supported reasons:
12118
12250
  * - 'manual': user clicked logout, or admin terminated a session
@@ -12126,12 +12258,18 @@ var session$1 = ({ strapi: strapi2 }) => {
12126
12258
  * to work, while new code relies on `terminationReason`.
12127
12259
  *
12128
12260
  * @param {Object} params
12129
- * @param {string} [params.sessionId]
12130
- * @param {string|number} [params.userId]
12261
+ * @param {string} [params.sessionId] Terminate exactly this session
12262
+ * @param {string|number} [params.userId] Terminate every active session
12263
+ * of this user …
12264
+ * @param {string} [params.exceptSessionId] … except for this one. Only
12265
+ * meaningful together with
12266
+ * `userId`. Used by
12267
+ * /logout-other-devices so
12268
+ * the caller stays logged in.
12131
12269
  * @param {'manual'|'idle'|'expired'|'blocked'} [params.reason='manual']
12132
- * @returns {Promise<void>}
12270
+ * @returns {Promise<{terminatedCount: number}>}
12133
12271
  */
12134
- async terminateSession({ sessionId, userId, reason = "manual" }) {
12272
+ async terminateSession({ sessionId, userId, exceptSessionId = null, reason = "manual" }) {
12135
12273
  try {
12136
12274
  const now = /* @__PURE__ */ new Date();
12137
12275
  const validReasons = ["manual", "idle", "expired", "blocked"];
@@ -12149,29 +12287,46 @@ var session$1 = ({ strapi: strapi2 }) => {
12149
12287
  });
12150
12288
  if (!existing) {
12151
12289
  log.warn(`Session ${sessionId} not found for termination`);
12152
- return;
12290
+ return { terminatedCount: 0 };
12153
12291
  }
12154
12292
  await strapi2.documents(SESSION_UID$1).update({
12155
12293
  documentId: sessionId,
12156
12294
  data: updateData
12157
12295
  });
12158
12296
  log.info(`Session ${sessionId} terminated (reason: ${finalReason})`);
12159
- } else if (userId) {
12297
+ return { terminatedCount: 1 };
12298
+ }
12299
+ if (userId) {
12160
12300
  const userDocumentId = await resolveUserDocumentId$1(strapi2, userId);
12161
- if (!userDocumentId) return;
12301
+ if (!userDocumentId) return { terminatedCount: 0 };
12302
+ const filters2 = { user: { documentId: userDocumentId }, isActive: true };
12303
+ if (exceptSessionId) {
12304
+ filters2.documentId = { $ne: exceptSessionId };
12305
+ }
12162
12306
  const activeSessions = await strapi2.documents(SESSION_UID$1).findMany({
12163
- filters: { user: { documentId: userDocumentId }, isActive: true },
12307
+ filters: filters2,
12164
12308
  fields: ["documentId"],
12165
12309
  limit: MAX_SESSIONS_QUERY
12166
12310
  });
12311
+ let terminatedCount = 0;
12167
12312
  for (const session2 of activeSessions) {
12168
- await strapi2.documents(SESSION_UID$1).update({
12169
- documentId: session2.documentId,
12170
- data: updateData
12171
- });
12313
+ try {
12314
+ await strapi2.documents(SESSION_UID$1).update({
12315
+ documentId: session2.documentId,
12316
+ data: updateData
12317
+ });
12318
+ terminatedCount++;
12319
+ } catch (err) {
12320
+ log.debug(`Failed to terminate session ${session2.documentId}:`, err.message);
12321
+ }
12172
12322
  }
12173
- log.info(`All sessions terminated for user ${userDocumentId} (reason: ${finalReason})`);
12323
+ const label = exceptSessionId ? "OTHER sessions" : "ALL sessions";
12324
+ log.info(
12325
+ `${label} terminated for user ${userDocumentId} (reason: ${finalReason}, count: ${terminatedCount})`
12326
+ );
12327
+ return { terminatedCount };
12174
12328
  }
12329
+ return { terminatedCount: 0 };
12175
12330
  } catch (err) {
12176
12331
  log.error("Error terminating session:", err);
12177
12332
  throw err;
@@ -12531,7 +12686,7 @@ var session$1 = ({ strapi: strapi2 }) => {
12531
12686
  }
12532
12687
  };
12533
12688
  };
12534
- const version$1 = "4.5.2";
12689
+ const version$1 = "4.5.4";
12535
12690
  const require$$2 = {
12536
12691
  version: version$1
12537
12692
  };
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "4.5.3",
2
+ "version": "4.5.5",
3
3
  "keywords": [
4
4
  "strapi",
5
5
  "strapi-plugin",