strapi-plugin-magic-sessionmanager 4.5.3 → 4.5.4

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 ==================
@@ -11251,7 +11281,14 @@ var session$3 = {
11251
11281
  }
11252
11282
  },
11253
11283
  /**
11254
- * Terminates all sessions of the authenticated user.
11284
+ * Terminates EVERY session of the authenticated user — including the
11285
+ * current one. After this call the caller will also be logged out on
11286
+ * the next request (their own JWT is rejected by the JWT-verify wrapper
11287
+ * with reason=manual).
11288
+ *
11289
+ * For the "log me out everywhere ELSE but keep me here" flow, use
11290
+ * `/logout-others` below.
11291
+ *
11255
11292
  * @route POST /api/magic-sessionmanager/logout-all
11256
11293
  */
11257
11294
  async logoutAll(ctx) {
@@ -11261,14 +11298,82 @@ var session$3 = {
11261
11298
  return ctx.unauthorized("Authentication required");
11262
11299
  }
11263
11300
  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" };
11301
+ const { terminatedCount } = await sessionService.terminateSession({
11302
+ userId: userDocId,
11303
+ reason: "manual"
11304
+ });
11305
+ strapi.log.info(
11306
+ `[magic-sessionmanager] User ${userDocId} logged out from all devices (${terminatedCount})`
11307
+ );
11308
+ ctx.body = {
11309
+ message: "Logged out from all devices successfully",
11310
+ terminatedCount
11311
+ };
11267
11312
  } catch (err) {
11268
11313
  strapi.log.error("[magic-sessionmanager] Logout-all error:", err);
11269
11314
  ctx.throw(500, "Error during logout");
11270
11315
  }
11271
11316
  },
11317
+ /**
11318
+ * Terminates every session of the authenticated user EXCEPT the current
11319
+ * one. This is the "kick everyone else off my account" flow — the
11320
+ * caller stays logged in on the device they are using.
11321
+ *
11322
+ * If no "current session" record can be located for the caller's JWT
11323
+ * (edge case: user logged in before the session-manager was installed,
11324
+ * or right at the end of the grace window) we fall back to terminating
11325
+ * ALL sessions so the user still gets the safety effect they asked for,
11326
+ * and we report `fellBackToLogoutAll: true` so the client can adjust
11327
+ * its success message.
11328
+ *
11329
+ * @route POST /api/magic-sessionmanager/logout-others
11330
+ */
11331
+ async logoutOthers(ctx) {
11332
+ try {
11333
+ const userDocId = await resolveAuthUserDocId(ctx);
11334
+ const token = extractBearerToken$1(ctx);
11335
+ if (!userDocId || !token) {
11336
+ return ctx.unauthorized("Authentication required");
11337
+ }
11338
+ const currentTokenHash = hashToken$2(token);
11339
+ const currentSession = await strapi.documents(SESSION_UID$2).findFirst({
11340
+ filters: { user: { documentId: userDocId }, tokenHash: currentTokenHash },
11341
+ fields: ["documentId"]
11342
+ });
11343
+ const sessionService = strapi.plugin("magic-sessionmanager").service("session");
11344
+ if (!currentSession) {
11345
+ const { terminatedCount: terminatedCount2 } = await sessionService.terminateSession({
11346
+ userId: userDocId,
11347
+ reason: "manual"
11348
+ });
11349
+ strapi.log.warn(
11350
+ `[magic-sessionmanager] logoutOthers fell back to logoutAll for user ${userDocId} (no current session match)`
11351
+ );
11352
+ ctx.body = {
11353
+ message: "All sessions terminated (could not preserve current session)",
11354
+ terminatedCount: terminatedCount2,
11355
+ fellBackToLogoutAll: true
11356
+ };
11357
+ return;
11358
+ }
11359
+ const { terminatedCount } = await sessionService.terminateSession({
11360
+ userId: userDocId,
11361
+ exceptSessionId: currentSession.documentId,
11362
+ reason: "manual"
11363
+ });
11364
+ strapi.log.info(
11365
+ `[magic-sessionmanager] User ${userDocId} logged out ${terminatedCount} other device(s)`
11366
+ );
11367
+ ctx.body = {
11368
+ message: terminatedCount === 0 ? "No other active sessions to terminate" : `${terminatedCount} other session(s) terminated`,
11369
+ terminatedCount,
11370
+ currentSessionPreserved: true
11371
+ };
11372
+ } catch (err) {
11373
+ strapi.log.error("[magic-sessionmanager] Logout-others error:", err);
11374
+ ctx.throw(500, "Error terminating other sessions");
11375
+ }
11376
+ },
11272
11377
  /**
11273
11378
  * Returns the session associated with the current JWT.
11274
11379
  *
@@ -12123,9 +12228,9 @@ var session$1 = ({ strapi: strapi2 }) => {
12123
12228
  }
12124
12229
  },
12125
12230
  /**
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).
12231
+ * Terminates a single session, all sessions of a user, or all sessions
12232
+ * of a user EXCEPT one with a typed reason so the JWT-verify wrapper
12233
+ * can communicate the cause to the client.
12129
12234
  *
12130
12235
  * Supported reasons:
12131
12236
  * - 'manual': user clicked logout, or admin terminated a session
@@ -12139,12 +12244,18 @@ var session$1 = ({ strapi: strapi2 }) => {
12139
12244
  * to work, while new code relies on `terminationReason`.
12140
12245
  *
12141
12246
  * @param {Object} params
12142
- * @param {string} [params.sessionId]
12143
- * @param {string|number} [params.userId]
12247
+ * @param {string} [params.sessionId] Terminate exactly this session
12248
+ * @param {string|number} [params.userId] Terminate every active session
12249
+ * of this user …
12250
+ * @param {string} [params.exceptSessionId] … except for this one. Only
12251
+ * meaningful together with
12252
+ * `userId`. Used by
12253
+ * /logout-other-devices so
12254
+ * the caller stays logged in.
12144
12255
  * @param {'manual'|'idle'|'expired'|'blocked'} [params.reason='manual']
12145
- * @returns {Promise<void>}
12256
+ * @returns {Promise<{terminatedCount: number}>}
12146
12257
  */
12147
- async terminateSession({ sessionId, userId, reason = "manual" }) {
12258
+ async terminateSession({ sessionId, userId, exceptSessionId = null, reason = "manual" }) {
12148
12259
  try {
12149
12260
  const now = /* @__PURE__ */ new Date();
12150
12261
  const validReasons = ["manual", "idle", "expired", "blocked"];
@@ -12162,29 +12273,46 @@ var session$1 = ({ strapi: strapi2 }) => {
12162
12273
  });
12163
12274
  if (!existing) {
12164
12275
  log.warn(`Session ${sessionId} not found for termination`);
12165
- return;
12276
+ return { terminatedCount: 0 };
12166
12277
  }
12167
12278
  await strapi2.documents(SESSION_UID$1).update({
12168
12279
  documentId: sessionId,
12169
12280
  data: updateData
12170
12281
  });
12171
12282
  log.info(`Session ${sessionId} terminated (reason: ${finalReason})`);
12172
- } else if (userId) {
12283
+ return { terminatedCount: 1 };
12284
+ }
12285
+ if (userId) {
12173
12286
  const userDocumentId = await resolveUserDocumentId$1(strapi2, userId);
12174
- if (!userDocumentId) return;
12287
+ if (!userDocumentId) return { terminatedCount: 0 };
12288
+ const filters2 = { user: { documentId: userDocumentId }, isActive: true };
12289
+ if (exceptSessionId) {
12290
+ filters2.documentId = { $ne: exceptSessionId };
12291
+ }
12175
12292
  const activeSessions = await strapi2.documents(SESSION_UID$1).findMany({
12176
- filters: { user: { documentId: userDocumentId }, isActive: true },
12293
+ filters: filters2,
12177
12294
  fields: ["documentId"],
12178
12295
  limit: MAX_SESSIONS_QUERY
12179
12296
  });
12297
+ let terminatedCount = 0;
12180
12298
  for (const session2 of activeSessions) {
12181
- await strapi2.documents(SESSION_UID$1).update({
12182
- documentId: session2.documentId,
12183
- data: updateData
12184
- });
12299
+ try {
12300
+ await strapi2.documents(SESSION_UID$1).update({
12301
+ documentId: session2.documentId,
12302
+ data: updateData
12303
+ });
12304
+ terminatedCount++;
12305
+ } catch (err) {
12306
+ log.debug(`Failed to terminate session ${session2.documentId}:`, err.message);
12307
+ }
12185
12308
  }
12186
- log.info(`All sessions terminated for user ${userDocumentId} (reason: ${finalReason})`);
12309
+ const label = exceptSessionId ? "OTHER sessions" : "ALL sessions";
12310
+ log.info(
12311
+ `${label} terminated for user ${userDocumentId} (reason: ${finalReason}, count: ${terminatedCount})`
12312
+ );
12313
+ return { terminatedCount };
12187
12314
  }
12315
+ return { terminatedCount: 0 };
12188
12316
  } catch (err) {
12189
12317
  log.error("Error terminating session:", err);
12190
12318
  throw err;
@@ -12544,7 +12672,7 @@ var session$1 = ({ strapi: strapi2 }) => {
12544
12672
  }
12545
12673
  };
12546
12674
  };
12547
- const version$1 = "4.5.2";
12675
+ const version$1 = "4.5.3";
12548
12676
  const require$$2 = {
12549
12677
  version: version$1
12550
12678
  };
@@ -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 ==================
@@ -11238,7 +11268,14 @@ var session$3 = {
11238
11268
  }
11239
11269
  },
11240
11270
  /**
11241
- * Terminates all sessions of the authenticated user.
11271
+ * Terminates EVERY session of the authenticated user — including the
11272
+ * current one. After this call the caller will also be logged out on
11273
+ * the next request (their own JWT is rejected by the JWT-verify wrapper
11274
+ * with reason=manual).
11275
+ *
11276
+ * For the "log me out everywhere ELSE but keep me here" flow, use
11277
+ * `/logout-others` below.
11278
+ *
11242
11279
  * @route POST /api/magic-sessionmanager/logout-all
11243
11280
  */
11244
11281
  async logoutAll(ctx) {
@@ -11248,14 +11285,82 @@ var session$3 = {
11248
11285
  return ctx.unauthorized("Authentication required");
11249
11286
  }
11250
11287
  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" };
11288
+ const { terminatedCount } = await sessionService.terminateSession({
11289
+ userId: userDocId,
11290
+ reason: "manual"
11291
+ });
11292
+ strapi.log.info(
11293
+ `[magic-sessionmanager] User ${userDocId} logged out from all devices (${terminatedCount})`
11294
+ );
11295
+ ctx.body = {
11296
+ message: "Logged out from all devices successfully",
11297
+ terminatedCount
11298
+ };
11254
11299
  } catch (err) {
11255
11300
  strapi.log.error("[magic-sessionmanager] Logout-all error:", err);
11256
11301
  ctx.throw(500, "Error during logout");
11257
11302
  }
11258
11303
  },
11304
+ /**
11305
+ * Terminates every session of the authenticated user EXCEPT the current
11306
+ * one. This is the "kick everyone else off my account" flow — the
11307
+ * caller stays logged in on the device they are using.
11308
+ *
11309
+ * If no "current session" record can be located for the caller's JWT
11310
+ * (edge case: user logged in before the session-manager was installed,
11311
+ * or right at the end of the grace window) we fall back to terminating
11312
+ * ALL sessions so the user still gets the safety effect they asked for,
11313
+ * and we report `fellBackToLogoutAll: true` so the client can adjust
11314
+ * its success message.
11315
+ *
11316
+ * @route POST /api/magic-sessionmanager/logout-others
11317
+ */
11318
+ async logoutOthers(ctx) {
11319
+ try {
11320
+ const userDocId = await resolveAuthUserDocId(ctx);
11321
+ const token = extractBearerToken$1(ctx);
11322
+ if (!userDocId || !token) {
11323
+ return ctx.unauthorized("Authentication required");
11324
+ }
11325
+ const currentTokenHash = hashToken$2(token);
11326
+ const currentSession = await strapi.documents(SESSION_UID$2).findFirst({
11327
+ filters: { user: { documentId: userDocId }, tokenHash: currentTokenHash },
11328
+ fields: ["documentId"]
11329
+ });
11330
+ const sessionService = strapi.plugin("magic-sessionmanager").service("session");
11331
+ if (!currentSession) {
11332
+ const { terminatedCount: terminatedCount2 } = await sessionService.terminateSession({
11333
+ userId: userDocId,
11334
+ reason: "manual"
11335
+ });
11336
+ strapi.log.warn(
11337
+ `[magic-sessionmanager] logoutOthers fell back to logoutAll for user ${userDocId} (no current session match)`
11338
+ );
11339
+ ctx.body = {
11340
+ message: "All sessions terminated (could not preserve current session)",
11341
+ terminatedCount: terminatedCount2,
11342
+ fellBackToLogoutAll: true
11343
+ };
11344
+ return;
11345
+ }
11346
+ const { terminatedCount } = await sessionService.terminateSession({
11347
+ userId: userDocId,
11348
+ exceptSessionId: currentSession.documentId,
11349
+ reason: "manual"
11350
+ });
11351
+ strapi.log.info(
11352
+ `[magic-sessionmanager] User ${userDocId} logged out ${terminatedCount} other device(s)`
11353
+ );
11354
+ ctx.body = {
11355
+ message: terminatedCount === 0 ? "No other active sessions to terminate" : `${terminatedCount} other session(s) terminated`,
11356
+ terminatedCount,
11357
+ currentSessionPreserved: true
11358
+ };
11359
+ } catch (err) {
11360
+ strapi.log.error("[magic-sessionmanager] Logout-others error:", err);
11361
+ ctx.throw(500, "Error terminating other sessions");
11362
+ }
11363
+ },
11259
11364
  /**
11260
11365
  * Returns the session associated with the current JWT.
11261
11366
  *
@@ -12110,9 +12215,9 @@ var session$1 = ({ strapi: strapi2 }) => {
12110
12215
  }
12111
12216
  },
12112
12217
  /**
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).
12218
+ * Terminates a single session, all sessions of a user, or all sessions
12219
+ * of a user EXCEPT one with a typed reason so the JWT-verify wrapper
12220
+ * can communicate the cause to the client.
12116
12221
  *
12117
12222
  * Supported reasons:
12118
12223
  * - 'manual': user clicked logout, or admin terminated a session
@@ -12126,12 +12231,18 @@ var session$1 = ({ strapi: strapi2 }) => {
12126
12231
  * to work, while new code relies on `terminationReason`.
12127
12232
  *
12128
12233
  * @param {Object} params
12129
- * @param {string} [params.sessionId]
12130
- * @param {string|number} [params.userId]
12234
+ * @param {string} [params.sessionId] Terminate exactly this session
12235
+ * @param {string|number} [params.userId] Terminate every active session
12236
+ * of this user …
12237
+ * @param {string} [params.exceptSessionId] … except for this one. Only
12238
+ * meaningful together with
12239
+ * `userId`. Used by
12240
+ * /logout-other-devices so
12241
+ * the caller stays logged in.
12131
12242
  * @param {'manual'|'idle'|'expired'|'blocked'} [params.reason='manual']
12132
- * @returns {Promise<void>}
12243
+ * @returns {Promise<{terminatedCount: number}>}
12133
12244
  */
12134
- async terminateSession({ sessionId, userId, reason = "manual" }) {
12245
+ async terminateSession({ sessionId, userId, exceptSessionId = null, reason = "manual" }) {
12135
12246
  try {
12136
12247
  const now = /* @__PURE__ */ new Date();
12137
12248
  const validReasons = ["manual", "idle", "expired", "blocked"];
@@ -12149,29 +12260,46 @@ var session$1 = ({ strapi: strapi2 }) => {
12149
12260
  });
12150
12261
  if (!existing) {
12151
12262
  log.warn(`Session ${sessionId} not found for termination`);
12152
- return;
12263
+ return { terminatedCount: 0 };
12153
12264
  }
12154
12265
  await strapi2.documents(SESSION_UID$1).update({
12155
12266
  documentId: sessionId,
12156
12267
  data: updateData
12157
12268
  });
12158
12269
  log.info(`Session ${sessionId} terminated (reason: ${finalReason})`);
12159
- } else if (userId) {
12270
+ return { terminatedCount: 1 };
12271
+ }
12272
+ if (userId) {
12160
12273
  const userDocumentId = await resolveUserDocumentId$1(strapi2, userId);
12161
- if (!userDocumentId) return;
12274
+ if (!userDocumentId) return { terminatedCount: 0 };
12275
+ const filters2 = { user: { documentId: userDocumentId }, isActive: true };
12276
+ if (exceptSessionId) {
12277
+ filters2.documentId = { $ne: exceptSessionId };
12278
+ }
12162
12279
  const activeSessions = await strapi2.documents(SESSION_UID$1).findMany({
12163
- filters: { user: { documentId: userDocumentId }, isActive: true },
12280
+ filters: filters2,
12164
12281
  fields: ["documentId"],
12165
12282
  limit: MAX_SESSIONS_QUERY
12166
12283
  });
12284
+ let terminatedCount = 0;
12167
12285
  for (const session2 of activeSessions) {
12168
- await strapi2.documents(SESSION_UID$1).update({
12169
- documentId: session2.documentId,
12170
- data: updateData
12171
- });
12286
+ try {
12287
+ await strapi2.documents(SESSION_UID$1).update({
12288
+ documentId: session2.documentId,
12289
+ data: updateData
12290
+ });
12291
+ terminatedCount++;
12292
+ } catch (err) {
12293
+ log.debug(`Failed to terminate session ${session2.documentId}:`, err.message);
12294
+ }
12172
12295
  }
12173
- log.info(`All sessions terminated for user ${userDocumentId} (reason: ${finalReason})`);
12296
+ const label = exceptSessionId ? "OTHER sessions" : "ALL sessions";
12297
+ log.info(
12298
+ `${label} terminated for user ${userDocumentId} (reason: ${finalReason}, count: ${terminatedCount})`
12299
+ );
12300
+ return { terminatedCount };
12174
12301
  }
12302
+ return { terminatedCount: 0 };
12175
12303
  } catch (err) {
12176
12304
  log.error("Error terminating session:", err);
12177
12305
  throw err;
@@ -12531,7 +12659,7 @@ var session$1 = ({ strapi: strapi2 }) => {
12531
12659
  }
12532
12660
  };
12533
12661
  };
12534
- const version$1 = "4.5.2";
12662
+ const version$1 = "4.5.3";
12535
12663
  const require$$2 = {
12536
12664
  version: version$1
12537
12665
  };
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "4.5.3",
2
+ "version": "4.5.4",
3
3
  "keywords": [
4
4
  "strapi",
5
5
  "strapi-plugin",