From 48005dd591be8e852784336c4b5713a116e4b292 Mon Sep 17 00:00:00 2001 From: Florian M Date: Tue, 12 Aug 2025 14:16:18 +0200 Subject: [PATCH 01/22] Logout Everywhere --- .../AuthenticationController.scala | 7 ++++++ app/models/user/User.scala | 18 +++++++++----- app/models/user/UserService.scala | 2 +- .../CombinedAuthenticatorService.scala | 20 ++++++++++++++-- conf/evolutions/139-logout-everywhere.sql | 24 +++++++++++++++++++ .../reversions/139-logout-everywhere.sql | 24 +++++++++++++++++++ conf/webknossos.latest.routes | 3 ++- frontend/javascripts/admin/rest_api.ts | 6 ++++- tools/postgres/schema.sql | 3 ++- 9 files changed, 95 insertions(+), 12 deletions(-) create mode 100644 conf/evolutions/139-logout-everywhere.sql create mode 100644 conf/evolutions/reversions/139-logout-everywhere.sql diff --git a/app/controllers/AuthenticationController.scala b/app/controllers/AuthenticationController.scala index af9b744b754..e0ffdb626fa 100755 --- a/app/controllers/AuthenticationController.scala +++ b/app/controllers/AuthenticationController.scala @@ -957,6 +957,13 @@ class AuthenticationController @Inject()( (errors, fN, lN) } + def logoutEverywhere: Action[AnyContent] = sil.SecuredAction.async { implicit request => + for { + _ <- userDAO.logOutEverywhereByMultiUserId(request.identity._multiUser) + userIds <- userDAO.findIdsByMultiUserId(request.identity._multiUser) + _ = userIds.map(userService.removeUserFromCache) + } yield Ok + } } case class InviteParameters( diff --git a/app/models/user/User.scala b/app/models/user/User.scala index bcbbc2782e6..8e8bd205cdd 100644 --- a/app/models/user/User.scala +++ b/app/models/user/User.scala @@ -40,6 +40,7 @@ case class User( isUnlisted: Boolean, created: Instant = Instant.now, lastTaskTypeId: Option[ObjectId] = None, + loggedOutEverywhereTime: Option[Instant] = None, isDeleted: Boolean = false ) extends DBAccessContextPayload with Identity @@ -118,6 +119,7 @@ class UserDAO @Inject()(sqlClient: SqlClient)(implicit ec: ExecutionContext) r.isunlisted, Instant.fromSql(r.created), r.lasttasktypeid.map(ObjectId(_)), + r.loggedouteverywheretime.map(Instant.fromSql), r.isdeleted ) } @@ -163,6 +165,9 @@ class UserDAO @Inject()(sqlClient: SqlClient)(implicit ec: ExecutionContext) parsed <- parseAll(r) } yield parsed + def findIdsByMultiUserId(multiUserId: ObjectId): Fox[Seq[ObjectId]] = + run(q"SELECT _id FROM $existingCollectionName WHERE _multiUser = $multiUserId".as[ObjectId]) + def buildSelectionPredicates(isEditableOpt: Option[Boolean], isTeamManagerOrAdminOpt: Option[Boolean], isAdminOpt: Option[Boolean], @@ -465,12 +470,6 @@ class UserDAO @Inject()(sqlClient: SqlClient)(implicit ec: ExecutionContext) result <- resultList.headOption.toFox } yield result - def countIdentitiesForMultiUser(multiUserId: ObjectId): Fox[Int] = - for { - resultList <- run(q"SELECT COUNT(*) FROM $existingCollectionName WHERE _multiUser = $multiUserId".as[Int]) - result <- resultList.headOption.toFox - } yield result - def insertOne(u: User): Fox[Unit] = for { _ <- run(q"""INSERT INTO webknossos.users( @@ -478,6 +477,7 @@ class UserDAO @Inject()(sqlClient: SqlClient)(implicit ec: ExecutionContext) lastActivity, userConfiguration, isDeactivated, isAdmin, isOrganizationOwner, isDatasetManager, isUnlisted, + loggedOutEverywhereTime, created, isDeleted ) VALUES( @@ -485,6 +485,7 @@ class UserDAO @Inject()(sqlClient: SqlClient)(implicit ec: ExecutionContext) ${u.lastActivity}, ${u.userConfiguration}, ${u.isDeactivated}, ${u.isAdmin}, ${u.isOrganizationOwner}, ${u.isDatasetManager}, ${u.isUnlisted}, + ${u.loggedOutEverywhereTime}, ${u.created}, ${u.isDeleted} )""".asUpdate) } yield () @@ -586,6 +587,11 @@ class UserDAO @Inject()(sqlClient: SqlClient)(implicit ec: ExecutionContext) parsed <- Fox.combined(r.toList.map(parse)) } yield parsed + def logOutEverywhereByMultiUserId(multiUserId: ObjectId): Fox[Unit] = + for { + _ <- run( + q"""UPDATE webknossos.users SET loggedOutEverywhereTime = ${Instant.now} WHERE _multiUser = $multiUserId""".asUpdate) + } yield () } class UserExperiencesDAO @Inject()(sqlClient: SqlClient, userDAO: UserDAO)(implicit ec: ExecutionContext) diff --git a/app/models/user/UserService.scala b/app/models/user/UserService.scala index d8c5d5472b2..141449889a5 100755 --- a/app/models/user/UserService.scala +++ b/app/models/user/UserService.scala @@ -217,7 +217,7 @@ class UserService @Inject()(conf: WkConf, } yield updated } - private def removeUserFromCache(userId: ObjectId): Unit = + def removeUserFromCache(userId: ObjectId): Unit = userCache.clear(idAndAccessContextString => idAndAccessContextString._1 == userId) def getPasswordInfo(passwordOpt: Option[String]): PasswordInfo = diff --git a/app/security/CombinedAuthenticatorService.scala b/app/security/CombinedAuthenticatorService.scala index 6cd0a5f3030..eb9f0f4fb47 100644 --- a/app/security/CombinedAuthenticatorService.scala +++ b/app/security/CombinedAuthenticatorService.scala @@ -1,11 +1,12 @@ package security +import com.scalableminds.util.time.Instant import play.silhouette.api._ import play.silhouette.api.crypto.Base64AuthenticatorEncoder import play.silhouette.api.services.{AuthenticatorResult, AuthenticatorService} import play.silhouette.api.util.{Clock, ExtractableRequest, FingerprintGenerator, IDGenerator} import play.silhouette.crypto.{JcaSigner, JcaSignerSettings} -import play.silhouette.impl.authenticators._ +import play.silhouette.impl.authenticators.{CookieAuthenticator, _} import models.user.UserService import play.api.mvc._ import utils.WkConf @@ -71,9 +72,24 @@ case class CombinedAuthenticatorService(cookieSettings: CookieAuthenticatorSetti override def retrieve[B](implicit request: ExtractableRequest[B]): Future[Option[CombinedAuthenticator]] = for { optionCookie <- cookieAuthenticatorService.retrieve(request) + optionCookieUnlessSignedOutEverywhere <- cookieUnlessSignedOutEverywhere(optionCookie) optionToken <- tokenAuthenticatorService.retrieve(request) } yield { - optionCookie.map(CombinedAuthenticator(_)).orElse { optionToken.map(CombinedAuthenticator(_)) } + optionCookieUnlessSignedOutEverywhere.map(CombinedAuthenticator(_)).orElse { + optionToken.map(CombinedAuthenticator(_)) + } + } + + private def cookieUnlessSignedOutEverywhere( + optionCookie: Option[CookieAuthenticator]): Future[Option[CookieAuthenticator]] = + optionCookie match { + case None => Future.successful(None) + case Some(cookie) => + for { + userOpt <- userService.retrieve(cookie.loginInfo) + loggedOutEverywhereTime = userOpt.flatMap(_.loggedOutEverywhereTime).getOrElse(Instant.zero) + cookieLastUsedTime = Instant(cookie.lastUsedDateTime.toInstant.toEpochMilli) + } yield if (cookieLastUsedTime > loggedOutEverywhereTime) Some(cookie) else None } // only called in token case diff --git a/conf/evolutions/139-logout-everywhere.sql b/conf/evolutions/139-logout-everywhere.sql new file mode 100644 index 00000000000..e4f4df051c1 --- /dev/null +++ b/conf/evolutions/139-logout-everywhere.sql @@ -0,0 +1,24 @@ +START TRANSACTION; + +do $$ begin ASSERT (select schemaVersion from webknossos.releaseInformation) = 138, 'Previous schema version mismatch'; end; $$ LANGUAGE plpgsql; + +DROP VIEW webknossos.userInfos; +DROP VIEW webknossos.users_; + +ALTER TABLE webknossos.users ADD COLUMN loggedOutEverywhereTime TIMESTAMPTZ; + +CREATE VIEW webknossos.users_ AS SELECT * FROM webknossos.users WHERE NOT isDeleted; + +CREATE VIEW webknossos.userInfos AS +SELECT +u._id AS _user, m.email, u.firstName, u.lastname, o.name AS organization_name, +u.isDeactivated, u.isDatasetManager, u.isAdmin, m.isSuperUser, +u._organization, o._id AS organization_id, u.created AS user_created, +m.created AS multiuser_created, u._multiUser, m._lastLoggedInIdentity, u.lastActivity, m.isEmailVerified +FROM webknossos.users_ u +JOIN webknossos.organizations_ o ON u._organization = o._id +JOIN webknossos.multiUsers_ m on u._multiUser = m._id; + +UPDATE webknossos.releaseInformation SET schemaVersion = 139; + +COMMIT TRANSACTION; diff --git a/conf/evolutions/reversions/139-logout-everywhere.sql b/conf/evolutions/reversions/139-logout-everywhere.sql new file mode 100644 index 00000000000..f64f23571a4 --- /dev/null +++ b/conf/evolutions/reversions/139-logout-everywhere.sql @@ -0,0 +1,24 @@ +START TRANSACTION; + +do $$ begin ASSERT (select schemaVersion from webknossos.releaseInformation) = 139, 'Previous schema version mismatch'; end; $$ LANGUAGE plpgsql; + +DROP VIEW webknossos.userInfos; +DROP VIEW webknossos.users_; + +ALTER TABLE webknossos.users DROP COLUMN loggedOutEverywhereTime; + +CREATE VIEW webknossos.users_ AS SELECT * FROM webknossos.users WHERE NOT isDeleted; + +CREATE VIEW webknossos.userInfos AS +SELECT +u._id AS _user, m.email, u.firstName, u.lastname, o.name AS organization_name, +u.isDeactivated, u.isDatasetManager, u.isAdmin, m.isSuperUser, +u._organization, o._id AS organization_id, u.created AS user_created, +m.created AS multiuser_created, u._multiUser, m._lastLoggedInIdentity, u.lastActivity, m.isEmailVerified +FROM webknossos.users_ u +JOIN webknossos.organizations_ o ON u._organization = o._id +JOIN webknossos.multiUsers_ m on u._multiUser = m._id; + +UPDATE webknossos.releaseInformation SET schemaVersion = 138; + +COMMIT TRANSACTION; diff --git a/conf/webknossos.latest.routes b/conf/webknossos.latest.routes index 8e993e906b2..bbca5d4031d 100644 --- a/conf/webknossos.latest.routes +++ b/conf/webknossos.latest.routes @@ -32,7 +32,8 @@ POST /auth/sendInvites POST /auth/startResetPassword controllers.AuthenticationController.handleStartResetPassword() POST /auth/changePassword controllers.AuthenticationController.changePassword() POST /auth/resetPassword controllers.AuthenticationController.handleResetPassword() -GET /auth/logout controllers.AuthenticationController.logout() +POST /auth/logout controllers.AuthenticationController.logout() +POST /auth/logoutEverywhere controllers.AuthenticationController.logoutEverywhere() GET /auth/sso controllers.AuthenticationController.singleSignOn(sso: String, sig: String) GET /auth/oidc/login controllers.AuthenticationController.loginViaOpenIdConnect() diff --git a/frontend/javascripts/admin/rest_api.ts b/frontend/javascripts/admin/rest_api.ts index 4eac3914521..cdceed0e87e 100644 --- a/frontend/javascripts/admin/rest_api.ts +++ b/frontend/javascripts/admin/rest_api.ts @@ -151,7 +151,11 @@ export async function loginUser(formValues: { } export async function logoutUser(): Promise { - await Request.receiveJSON("/api/auth/logout"); + await Request.receiveJSON("/api/auth/logout", { method: "POST" }); +} + +export async function logoutUserEverywhere(): Promise { + await Request.receiveJSON("/api/auth/logoutEverywhere", { method: "POST" }); } export async function getUsers(): Promise> { diff --git a/tools/postgres/schema.sql b/tools/postgres/schema.sql index b5dce80eea3..7a27d7c6ad0 100644 --- a/tools/postgres/schema.sql +++ b/tools/postgres/schema.sql @@ -21,7 +21,7 @@ CREATE TABLE webknossos.releaseInformation ( schemaVersion BIGINT NOT NULL ); -INSERT INTO webknossos.releaseInformation(schemaVersion) values(138); +INSERT INTO webknossos.releaseInformation(schemaVersion) values(139); COMMIT TRANSACTION; @@ -410,6 +410,7 @@ CREATE TABLE webknossos.users( created TIMESTAMPTZ NOT NULL DEFAULT NOW(), lastTaskTypeId TEXT CONSTRAINT lastTaskTypeId_objectId CHECK (lastTaskTypeId ~ '^[0-9a-f]{24}$') DEFAULT NULL, isUnlisted BOOLEAN NOT NULL DEFAULT FALSE, + loggedOutEverywhereTime TIMESTAMPTZ, isDeleted BOOLEAN NOT NULL DEFAULT FALSE, UNIQUE (_multiUser, _organization), CONSTRAINT userConfigurationIsJsonObject CHECK(jsonb_typeof(userConfiguration) = 'object') From 84d3847fe2abfdfb835865ba520b50a6a5f1a46f Mon Sep 17 00:00:00 2001 From: Florian M Date: Tue, 12 Aug 2025 14:17:09 +0200 Subject: [PATCH 02/22] changelog --- unreleased_changes/8850.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 unreleased_changes/8850.md diff --git a/unreleased_changes/8850.md b/unreleased_changes/8850.md new file mode 100644 index 00000000000..b94f7e03068 --- /dev/null +++ b/unreleased_changes/8850.md @@ -0,0 +1,5 @@ +### Added +- Added the option to log out from all devices. + +### Postgres Evolutions +- [139-logout-everywhere.sql](conf/evolutions/139-logout-everywhere.sql) From cd8603c99752b37e46750d705aded41c96b036d7 Mon Sep 17 00:00:00 2001 From: Florian M Date: Wed, 13 Aug 2025 14:08:20 +0200 Subject: [PATCH 03/22] adapt test db --- test/db/users.csv | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/test/db/users.csv b/test/db/users.csv index b65c51e23c4..ad99a41650d 100644 --- a/test/db/users.csv +++ b/test/db/users.csv @@ -1,10 +1,10 @@ -_id,_multiUser,_organization,firstName,lastName,lastActivity,userConfiguration,isDeactivated,isAdmin,isOrganizationOwner,isDatasetManager,created,lasttasktypeid,isUnlisted,isDeleted -'570b9f4d2a7c0e4d008da6ef','8fb0c6a674d0af7b003b23ea','Organization_X','user_A','last_A','2016-04-11T12:57:49.053Z','{"configuration":{"sortCommentsAsc":true,"messages":[{"success":"Your configuration got updated"}],"isosurfaceDisplay":false,"moveValue":300,"rotateValue":0.01,"particleSize":5,"scale":1,"clippingDistance":50,"mouseRotateValue":0.004,"displayCrosshair":true,"crosshairSize":0.1,"sortTreesByName":false,"keyboardDelay":200,"motionsensorActive":false,"moveValue3d":300,"keyboardActive":true,"isosurfaceResolution":80,"isosurfaceBBsize":1,"mouseActive":true,"newNodeNewTree":false,"segmentationOpacity":20,"inverseY":false,"sphericalCapRadius":140,"clippingDistanceArbitrary":64,"scaleValue":0.05,"tdViewDisplayPlanes":true,"overrideNodeRadius":true,"renderComments":false,"zoom":2,"inverseX":false,"gamepadActive":false,"dynamicSpaceDirection":true,"firstVisToggle":true}}',f,t,t,t,'2016-04-11T12:57:49.000Z',,f,f -'670b9f4d2a7c0e4d008da6ef','8fb0c6a674d0af7b003b23eb','Organization_X','user_B','last_B','2016-04-12T12:57:49.053Z','{"configuration":{"sortCommentsAsc":true,"messages":[{"success":"Your configuration got updated"}],"isosurfaceDisplay":false,"moveValue":300,"rotateValue":0.01,"particleSize":5,"scale":1,"clippingDistance":50,"mouseRotateValue":0.004,"displayCrosshair":true,"crosshairSize":0.1,"sortTreesByName":false,"keyboardDelay":200,"motionsensorActive":false,"moveValue3d":300,"keyboardActive":true,"isosurfaceResolution":80,"isosurfaceBBsize":1,"mouseActive":true,"newNodeNewTree":false,"segmentationOpacity":20,"inverseY":false,"sphericalCapRadius":140,"clippingDistanceArbitrary":64,"scaleValue":0.05,"tdViewDisplayPlanes":true,"overrideNodeRadius":true,"renderComments":false,"zoom":2,"inverseX":false,"gamepadActive":false,"dynamicSpaceDirection":true,"firstVisToggle":true}}',f,f,f,t,'2016-04-11T12:57:49.000Z',,f,f -'770b9f4d2a7c0e4d008da6ef','8fb0c6a674d0af7b003b23ec','Organization_X','user_C','last_C','2016-04-13T12:57:49.053Z','{"configuration":{"sortCommentsAsc":true,"messages":[{"success":"Your configuration got updated"}],"isosurfaceDisplay":false,"moveValue":300,"rotateValue":0.01,"particleSize":5,"scale":1,"clippingDistance":50,"mouseRotateValue":0.004,"displayCrosshair":true,"crosshairSize":0.1,"sortTreesByName":false,"keyboardDelay":200,"motionsensorActive":false,"moveValue3d":300,"keyboardActive":true,"isosurfaceResolution":80,"isosurfaceBBsize":1,"mouseActive":true,"newNodeNewTree":false,"segmentationOpacity":20,"inverseY":false,"sphericalCapRadius":140,"clippingDistanceArbitrary":64,"scaleValue":0.05,"tdViewDisplayPlanes":true,"overrideNodeRadius":true,"renderComments":false,"zoom":2,"inverseX":false,"gamepadActive":false,"dynamicSpaceDirection":true,"firstVisToggle":true}}',f,f,f,f,'2016-04-11T12:57:49.000Z',,f,f -'870b9f4d2a7c0e4d008da6ef','8fb0c6a674d0af7b003b23ed','Organization_X','user_D','last_D','2016-04-14T12:57:49.053Z','{"configuration":{"sortCommentsAsc":true,"messages":[{"success":"Your configuration got updated"}],"isosurfaceDisplay":false,"moveValue":300,"rotateValue":0.01,"particleSize":5,"scale":1,"clippingDistance":50,"mouseRotateValue":0.004,"displayCrosshair":true,"crosshairSize":0.1,"sortTreesByName":false,"keyboardDelay":200,"motionsensorActive":false,"moveValue3d":300,"keyboardActive":true,"isosurfaceResolution":80,"isosurfaceBBsize":1,"mouseActive":true,"newNodeNewTree":false,"segmentationOpacity":20,"inverseY":false,"sphericalCapRadius":140,"clippingDistanceArbitrary":64,"scaleValue":0.05,"tdViewDisplayPlanes":true,"overrideNodeRadius":true,"renderComments":false,"zoom":2,"inverseX":false,"gamepadActive":false,"dynamicSpaceDirection":true,"firstVisToggle":true}}',f,f,f,f,'2016-04-11T12:57:49.000Z',,f,f -'970b9f4d2a7c0e4d008da6ea','8fb0c6a674d0af7b003b23ee','Organization_Y','user_E','last_E','2016-04-06T12:57:49.053Z','{"configuration":{"sortCommentsAsc":true,"messages":[{"success":"Your configuration got updated"}],"isosurfaceDisplay":false,"moveValue":300,"rotateValue":0.01,"particleSize":5,"scale":1,"clippingDistance":50,"mouseRotateValue":0.004,"displayCrosshair":true,"crosshairSize":0.1,"sortTreesByName":false,"keyboardDelay":200,"motionsensorActive":false,"moveValue3d":300,"keyboardActive":true,"isosurfaceResolution":80,"isosurfaceBBsize":1,"mouseActive":true,"newNodeNewTree":false,"segmentationOpacity":20,"inverseY":false,"sphericalCapRadius":140,"clippingDistanceArbitrary":64,"scaleValue":0.05,"tdViewDisplayPlanes":true,"overrideNodeRadius":true,"renderComments":false,"zoom":2,"inverseX":false,"gamepadActive":false,"dynamicSpaceDirection":true,"firstVisToggle":true}}',f,t,t,f,'2016-04-11T12:57:49.000Z',,f,f -'970b9f4d2a7c0e4d008da6eb','8fb0c6a674d0af7b003b23ee','Organization_X','user_E_in_X','last_E','2016-04-06T12:57:49.053Z','{"configuration":{"sortCommentsAsc":true,"messages":[{"success":"Your configuration got updated"}],"isosurfaceDisplay":false,"moveValue":300,"rotateValue":0.01,"particleSize":5,"scale":1,"clippingDistance":50,"mouseRotateValue":0.004,"displayCrosshair":true,"crosshairSize":0.1,"sortTreesByName":false,"keyboardDelay":200,"motionsensorActive":false,"moveValue3d":300,"keyboardActive":true,"isosurfaceResolution":80,"isosurfaceBBsize":1,"mouseActive":true,"newNodeNewTree":false,"segmentationOpacity":20,"inverseY":false,"sphericalCapRadius":140,"clippingDistanceArbitrary":64,"scaleValue":0.05,"tdViewDisplayPlanes":true,"overrideNodeRadius":true,"renderComments":false,"zoom":2,"inverseX":false,"gamepadActive":false,"dynamicSpaceDirection":true,"firstVisToggle":true}}',f,f,f,f,'2016-04-12T12:57:49.000Z',,f,f -'970b9f4d2a7c0e4d008da6ec','8fb0c6a674d0af7b003b23ef','Organization_Z','user_F','last_F','2016-04-06T12:57:49.053Z','{"configuration":{"sortCommentsAsc":true,"messages":[{"success":"Your configuration got updated"}],"isosurfaceDisplay":false,"moveValue":300,"rotateValue":0.01,"particleSize":5,"scale":1,"clippingDistance":50,"mouseRotateValue":0.004,"displayCrosshair":true,"crosshairSize":0.1,"sortTreesByName":false,"keyboardDelay":200,"motionsensorActive":false,"moveValue3d":300,"keyboardActive":true,"isosurfaceResolution":80,"isosurfaceBBsize":1,"mouseActive":true,"newNodeNewTree":false,"segmentationOpacity":20,"inverseY":false,"sphericalCapRadius":140,"clippingDistanceArbitrary":64,"scaleValue":0.05,"tdViewDisplayPlanes":true,"overrideNodeRadius":true,"renderComments":false,"zoom":2,"inverseX":false,"gamepadActive":false,"dynamicSpaceDirection":true,"firstVisToggle":true}}',f,t,t,f,'2016-04-11T12:57:49.000Z',,f,f -'970b9f4d2a7c0e4d008da6ed','8fb0c6a674d0af7b003b23ef','Organization_Y','user_F_in_Y','last_F','2016-04-06T12:57:49.053Z','{"configuration":{"sortCommentsAsc":true,"messages":[{"success":"Your configuration got updated"}],"isosurfaceDisplay":false,"moveValue":300,"rotateValue":0.01,"particleSize":5,"scale":1,"clippingDistance":50,"mouseRotateValue":0.004,"displayCrosshair":true,"crosshairSize":0.1,"sortTreesByName":false,"keyboardDelay":200,"motionsensorActive":false,"moveValue3d":300,"keyboardActive":true,"isosurfaceResolution":80,"isosurfaceBBsize":1,"mouseActive":true,"newNodeNewTree":false,"segmentationOpacity":20,"inverseY":false,"sphericalCapRadius":140,"clippingDistanceArbitrary":64,"scaleValue":0.05,"tdViewDisplayPlanes":true,"overrideNodeRadius":true,"renderComments":false,"zoom":2,"inverseX":false,"gamepadActive":false,"dynamicSpaceDirection":true,"firstVisToggle":true}}',f,f,f,f,'2016-04-12T12:57:49.000Z',,f,f -'970b9f4d2a7c0e4d008da6ee','8fb0c6a674d0af7b003b23ef','Organization_X','user_F_in_X','last_F','2016-04-06T12:57:49.053Z','{"configuration":{"sortCommentsAsc":true,"messages":[{"success":"Your configuration got updated"}],"isosurfaceDisplay":false,"moveValue":300,"rotateValue":0.01,"particleSize":5,"scale":1,"clippingDistance":50,"mouseRotateValue":0.004,"displayCrosshair":true,"crosshairSize":0.1,"sortTreesByName":false,"keyboardDelay":200,"motionsensorActive":false,"moveValue3d":300,"keyboardActive":true,"isosurfaceResolution":80,"isosurfaceBBsize":1,"mouseActive":true,"newNodeNewTree":false,"segmentationOpacity":20,"inverseY":false,"sphericalCapRadius":140,"clippingDistanceArbitrary":64,"scaleValue":0.05,"tdViewDisplayPlanes":true,"overrideNodeRadius":true,"renderComments":false,"zoom":2,"inverseX":false,"gamepadActive":false,"dynamicSpaceDirection":true,"firstVisToggle":true}}',f,f,f,f,'2016-04-13T12:57:49.000Z',,f,f +_id,_multiUser,_organization,firstName,lastName,lastActivity,userConfiguration,isDeactivated,isAdmin,isOrganizationOwner,isDatasetManager,created,lasttasktypeid,isUnlisted,loggedOutEverywhereTime,isDeleted +'570b9f4d2a7c0e4d008da6ef','8fb0c6a674d0af7b003b23ea','Organization_X','user_A','last_A','2016-04-11T12:57:49.053Z','{"configuration":{"sortCommentsAsc":true,"messages":[{"success":"Your configuration got updated"}],"isosurfaceDisplay":false,"moveValue":300,"rotateValue":0.01,"particleSize":5,"scale":1,"clippingDistance":50,"mouseRotateValue":0.004,"displayCrosshair":true,"crosshairSize":0.1,"sortTreesByName":false,"keyboardDelay":200,"motionsensorActive":false,"moveValue3d":300,"keyboardActive":true,"isosurfaceResolution":80,"isosurfaceBBsize":1,"mouseActive":true,"newNodeNewTree":false,"segmentationOpacity":20,"inverseY":false,"sphericalCapRadius":140,"clippingDistanceArbitrary":64,"scaleValue":0.05,"tdViewDisplayPlanes":true,"overrideNodeRadius":true,"renderComments":false,"zoom":2,"inverseX":false,"gamepadActive":false,"dynamicSpaceDirection":true,"firstVisToggle":true}}',f,t,t,t,'2016-04-11T12:57:49.000Z',,f,,f +'670b9f4d2a7c0e4d008da6ef','8fb0c6a674d0af7b003b23eb','Organization_X','user_B','last_B','2016-04-12T12:57:49.053Z','{"configuration":{"sortCommentsAsc":true,"messages":[{"success":"Your configuration got updated"}],"isosurfaceDisplay":false,"moveValue":300,"rotateValue":0.01,"particleSize":5,"scale":1,"clippingDistance":50,"mouseRotateValue":0.004,"displayCrosshair":true,"crosshairSize":0.1,"sortTreesByName":false,"keyboardDelay":200,"motionsensorActive":false,"moveValue3d":300,"keyboardActive":true,"isosurfaceResolution":80,"isosurfaceBBsize":1,"mouseActive":true,"newNodeNewTree":false,"segmentationOpacity":20,"inverseY":false,"sphericalCapRadius":140,"clippingDistanceArbitrary":64,"scaleValue":0.05,"tdViewDisplayPlanes":true,"overrideNodeRadius":true,"renderComments":false,"zoom":2,"inverseX":false,"gamepadActive":false,"dynamicSpaceDirection":true,"firstVisToggle":true}}',f,f,f,t,'2016-04-11T12:57:49.000Z',,f,,f +'770b9f4d2a7c0e4d008da6ef','8fb0c6a674d0af7b003b23ec','Organization_X','user_C','last_C','2016-04-13T12:57:49.053Z','{"configuration":{"sortCommentsAsc":true,"messages":[{"success":"Your configuration got updated"}],"isosurfaceDisplay":false,"moveValue":300,"rotateValue":0.01,"particleSize":5,"scale":1,"clippingDistance":50,"mouseRotateValue":0.004,"displayCrosshair":true,"crosshairSize":0.1,"sortTreesByName":false,"keyboardDelay":200,"motionsensorActive":false,"moveValue3d":300,"keyboardActive":true,"isosurfaceResolution":80,"isosurfaceBBsize":1,"mouseActive":true,"newNodeNewTree":false,"segmentationOpacity":20,"inverseY":false,"sphericalCapRadius":140,"clippingDistanceArbitrary":64,"scaleValue":0.05,"tdViewDisplayPlanes":true,"overrideNodeRadius":true,"renderComments":false,"zoom":2,"inverseX":false,"gamepadActive":false,"dynamicSpaceDirection":true,"firstVisToggle":true}}',f,f,f,f,'2016-04-11T12:57:49.000Z',,f,,f +'870b9f4d2a7c0e4d008da6ef','8fb0c6a674d0af7b003b23ed','Organization_X','user_D','last_D','2016-04-14T12:57:49.053Z','{"configuration":{"sortCommentsAsc":true,"messages":[{"success":"Your configuration got updated"}],"isosurfaceDisplay":false,"moveValue":300,"rotateValue":0.01,"particleSize":5,"scale":1,"clippingDistance":50,"mouseRotateValue":0.004,"displayCrosshair":true,"crosshairSize":0.1,"sortTreesByName":false,"keyboardDelay":200,"motionsensorActive":false,"moveValue3d":300,"keyboardActive":true,"isosurfaceResolution":80,"isosurfaceBBsize":1,"mouseActive":true,"newNodeNewTree":false,"segmentationOpacity":20,"inverseY":false,"sphericalCapRadius":140,"clippingDistanceArbitrary":64,"scaleValue":0.05,"tdViewDisplayPlanes":true,"overrideNodeRadius":true,"renderComments":false,"zoom":2,"inverseX":false,"gamepadActive":false,"dynamicSpaceDirection":true,"firstVisToggle":true}}',f,f,f,f,'2016-04-11T12:57:49.000Z',,f,,f +'970b9f4d2a7c0e4d008da6ea','8fb0c6a674d0af7b003b23ee','Organization_Y','user_E','last_E','2016-04-06T12:57:49.053Z','{"configuration":{"sortCommentsAsc":true,"messages":[{"success":"Your configuration got updated"}],"isosurfaceDisplay":false,"moveValue":300,"rotateValue":0.01,"particleSize":5,"scale":1,"clippingDistance":50,"mouseRotateValue":0.004,"displayCrosshair":true,"crosshairSize":0.1,"sortTreesByName":false,"keyboardDelay":200,"motionsensorActive":false,"moveValue3d":300,"keyboardActive":true,"isosurfaceResolution":80,"isosurfaceBBsize":1,"mouseActive":true,"newNodeNewTree":false,"segmentationOpacity":20,"inverseY":false,"sphericalCapRadius":140,"clippingDistanceArbitrary":64,"scaleValue":0.05,"tdViewDisplayPlanes":true,"overrideNodeRadius":true,"renderComments":false,"zoom":2,"inverseX":false,"gamepadActive":false,"dynamicSpaceDirection":true,"firstVisToggle":true}}',f,t,t,f,'2016-04-11T12:57:49.000Z',,f,,f +'970b9f4d2a7c0e4d008da6eb','8fb0c6a674d0af7b003b23ee','Organization_X','user_E_in_X','last_E','2016-04-06T12:57:49.053Z','{"configuration":{"sortCommentsAsc":true,"messages":[{"success":"Your configuration got updated"}],"isosurfaceDisplay":false,"moveValue":300,"rotateValue":0.01,"particleSize":5,"scale":1,"clippingDistance":50,"mouseRotateValue":0.004,"displayCrosshair":true,"crosshairSize":0.1,"sortTreesByName":false,"keyboardDelay":200,"motionsensorActive":false,"moveValue3d":300,"keyboardActive":true,"isosurfaceResolution":80,"isosurfaceBBsize":1,"mouseActive":true,"newNodeNewTree":false,"segmentationOpacity":20,"inverseY":false,"sphericalCapRadius":140,"clippingDistanceArbitrary":64,"scaleValue":0.05,"tdViewDisplayPlanes":true,"overrideNodeRadius":true,"renderComments":false,"zoom":2,"inverseX":false,"gamepadActive":false,"dynamicSpaceDirection":true,"firstVisToggle":true}}',f,f,f,f,'2016-04-12T12:57:49.000Z',,f,,f +'970b9f4d2a7c0e4d008da6ec','8fb0c6a674d0af7b003b23ef','Organization_Z','user_F','last_F','2016-04-06T12:57:49.053Z','{"configuration":{"sortCommentsAsc":true,"messages":[{"success":"Your configuration got updated"}],"isosurfaceDisplay":false,"moveValue":300,"rotateValue":0.01,"particleSize":5,"scale":1,"clippingDistance":50,"mouseRotateValue":0.004,"displayCrosshair":true,"crosshairSize":0.1,"sortTreesByName":false,"keyboardDelay":200,"motionsensorActive":false,"moveValue3d":300,"keyboardActive":true,"isosurfaceResolution":80,"isosurfaceBBsize":1,"mouseActive":true,"newNodeNewTree":false,"segmentationOpacity":20,"inverseY":false,"sphericalCapRadius":140,"clippingDistanceArbitrary":64,"scaleValue":0.05,"tdViewDisplayPlanes":true,"overrideNodeRadius":true,"renderComments":false,"zoom":2,"inverseX":false,"gamepadActive":false,"dynamicSpaceDirection":true,"firstVisToggle":true}}',f,t,t,f,'2016-04-11T12:57:49.000Z',,f,,f +'970b9f4d2a7c0e4d008da6ed','8fb0c6a674d0af7b003b23ef','Organization_Y','user_F_in_Y','last_F','2016-04-06T12:57:49.053Z','{"configuration":{"sortCommentsAsc":true,"messages":[{"success":"Your configuration got updated"}],"isosurfaceDisplay":false,"moveValue":300,"rotateValue":0.01,"particleSize":5,"scale":1,"clippingDistance":50,"mouseRotateValue":0.004,"displayCrosshair":true,"crosshairSize":0.1,"sortTreesByName":false,"keyboardDelay":200,"motionsensorActive":false,"moveValue3d":300,"keyboardActive":true,"isosurfaceResolution":80,"isosurfaceBBsize":1,"mouseActive":true,"newNodeNewTree":false,"segmentationOpacity":20,"inverseY":false,"sphericalCapRadius":140,"clippingDistanceArbitrary":64,"scaleValue":0.05,"tdViewDisplayPlanes":true,"overrideNodeRadius":true,"renderComments":false,"zoom":2,"inverseX":false,"gamepadActive":false,"dynamicSpaceDirection":true,"firstVisToggle":true}}',f,f,f,f,'2016-04-12T12:57:49.000Z',,f,,f +'970b9f4d2a7c0e4d008da6ee','8fb0c6a674d0af7b003b23ef','Organization_X','user_F_in_X','last_F','2016-04-06T12:57:49.053Z','{"configuration":{"sortCommentsAsc":true,"messages":[{"success":"Your configuration got updated"}],"isosurfaceDisplay":false,"moveValue":300,"rotateValue":0.01,"particleSize":5,"scale":1,"clippingDistance":50,"mouseRotateValue":0.004,"displayCrosshair":true,"crosshairSize":0.1,"sortTreesByName":false,"keyboardDelay":200,"motionsensorActive":false,"moveValue3d":300,"keyboardActive":true,"isosurfaceResolution":80,"isosurfaceBBsize":1,"mouseActive":true,"newNodeNewTree":false,"segmentationOpacity":20,"inverseY":false,"sphericalCapRadius":140,"clippingDistanceArbitrary":64,"scaleValue":0.05,"tdViewDisplayPlanes":true,"overrideNodeRadius":true,"renderComments":false,"zoom":2,"inverseX":false,"gamepadActive":false,"dynamicSpaceDirection":true,"firstVisToggle":true}}',f,f,f,f,'2016-04-13T12:57:49.000Z',,f,,f From aa2ddcf4bc1f7c8b7eca8cfe8f7bb2ae35e4f04f Mon Sep 17 00:00:00 2001 From: Charlie Meister Date: Wed, 20 Aug 2025 16:11:35 +0200 Subject: [PATCH 04/22] add account setting card to logout everywhere --- .../admin/account/account_password_view.tsx | 65 ++++++++++++++----- 1 file changed, 47 insertions(+), 18 deletions(-) diff --git a/frontend/javascripts/admin/account/account_password_view.tsx b/frontend/javascripts/admin/account/account_password_view.tsx index 805e8d07d73..fcee318d83f 100644 --- a/frontend/javascripts/admin/account/account_password_view.tsx +++ b/frontend/javascripts/admin/account/account_password_view.tsx @@ -1,6 +1,6 @@ import { EditOutlined, LockOutlined } from "@ant-design/icons"; -import { changePassword, logoutUser } from "admin/rest_api"; -import { Alert, Button, Col, Form, Input, Row, Space } from "antd"; +import { changePassword, logoutUser, logoutUserEverywhere } from "admin/rest_api"; +import { Alert, Button, Col, Form, Input, Modal, Row, Space } from "antd"; import features from "features"; import Toast from "libs/toast"; import messages from "messages"; @@ -8,11 +8,11 @@ import { useState } from "react"; import { useNavigate } from "react-router-dom"; import { logoutUserAction } from "viewer/model/actions/user_actions"; import Store from "viewer/store"; -import { SettingsCard } from "./helpers/settings_card"; import { SettingsTitle } from "./helpers/settings_title"; const FormItem = Form.Item; const { Password } = Input; import PasskeysView from "../auth/passkeys_view"; +import { SettingsCard, type SettingsCardProps } from "./helpers/settings_card"; const MIN_PASSWORD_LENGTH = 8; @@ -20,6 +20,7 @@ function AccountPasswordView() { const navigate = useNavigate(); const [form] = Form.useForm(); const [isResetPasswordVisible, setResetPasswordVisible] = useState(false); + const [showConfirmLogoutModal, setShowConfirmLogoutModal] = useState(false); function onFinish(formValues: Record) { changePassword(formValues) @@ -153,32 +154,60 @@ function AccountPasswordView() { ); } + const securityItems: SettingsCardProps[] = [ + { + title: "Password", + content: getPasswordComponent(), + action: ( + + ), + }, + ]; + function handleResetPassword() { setResetPasswordVisible(!isResetPasswordVisible); } + async function handleLogout() { + await logoutUserEverywhere().then(() => { + navigate("/login"); + }); + } + const { passkeysEnabled } = features(); return (
- - } - size="small" - onClick={handleResetPassword} - /> - } - /> - + {securityItems.map((item) => ( + + + + ))} + setShowConfirmLogoutModal(false)} + > +

Are you sure you want to log out on all devices?

+
{passkeysEnabled && ( <> From 56011884cfd093d459f6947dd3f3be803d0f374c Mon Sep 17 00:00:00 2001 From: Charlie Meister Date: Wed, 20 Aug 2025 16:23:20 +0200 Subject: [PATCH 05/22] after resetting pw and email, logout user everywhere --- frontend/javascripts/admin/account/account_password_view.tsx | 4 ++-- frontend/javascripts/admin/auth/change_email_view.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/javascripts/admin/account/account_password_view.tsx b/frontend/javascripts/admin/account/account_password_view.tsx index fcee318d83f..edab360ff5a 100644 --- a/frontend/javascripts/admin/account/account_password_view.tsx +++ b/frontend/javascripts/admin/account/account_password_view.tsx @@ -1,5 +1,5 @@ import { EditOutlined, LockOutlined } from "@ant-design/icons"; -import { changePassword, logoutUser, logoutUserEverywhere } from "admin/rest_api"; +import { changePassword, logoutUserEverywhere } from "admin/rest_api"; import { Alert, Button, Col, Form, Input, Modal, Row, Space } from "antd"; import features from "features"; import Toast from "libs/toast"; @@ -26,7 +26,7 @@ function AccountPasswordView() { changePassword(formValues) .then(async () => { Toast.success(messages["auth.reset_pw_confirmation"]); - await logoutUser(); + await logoutUserEverywhere(); Store.dispatch(logoutUserAction()); navigate("/auth/login"); }) diff --git a/frontend/javascripts/admin/auth/change_email_view.tsx b/frontend/javascripts/admin/auth/change_email_view.tsx index 59c2d62d6f8..0bf62b32196 100644 --- a/frontend/javascripts/admin/auth/change_email_view.tsx +++ b/frontend/javascripts/admin/auth/change_email_view.tsx @@ -1,5 +1,5 @@ import { LockOutlined, MailOutlined } from "@ant-design/icons"; -import { logoutUser, updateUser } from "admin/rest_api"; +import { logoutUserEverywhere, updateUser } from "admin/rest_api"; import { Alert, Button, Form, Input, Space } from "antd"; import { useWkSelector } from "libs/react_hooks"; import Toast from "libs/toast"; @@ -36,7 +36,7 @@ function ChangeEmailView({ onCancel }: { onCancel: () => void }) { .then(async () => { handleResendVerificationEmail(); Toast.success("Email address changed successfully. You will be logged out."); - await logoutUser(); + await logoutUserEverywhere(); Store.dispatch(logoutUserAction()); window.location.href = "/auth/login"; }) From 26c870434f653c18985cef43901b338402ebc9d5 Mon Sep 17 00:00:00 2001 From: Charlie Meister Date: Wed, 20 Aug 2025 16:30:50 +0200 Subject: [PATCH 06/22] rename account password to account security page --- ...count_password_view.tsx => account_security_view.tsx} | 9 ++++++--- .../javascripts/admin/account/account_settings_view.tsx | 6 +++--- frontend/javascripts/router/router.tsx | 4 ++-- 3 files changed, 11 insertions(+), 8 deletions(-) rename frontend/javascripts/admin/account/{account_password_view.tsx => account_security_view.tsx} (97%) diff --git a/frontend/javascripts/admin/account/account_password_view.tsx b/frontend/javascripts/admin/account/account_security_view.tsx similarity index 97% rename from frontend/javascripts/admin/account/account_password_view.tsx rename to frontend/javascripts/admin/account/account_security_view.tsx index edab360ff5a..e3fc1f29f9e 100644 --- a/frontend/javascripts/admin/account/account_password_view.tsx +++ b/frontend/javascripts/admin/account/account_security_view.tsx @@ -16,7 +16,7 @@ import { SettingsCard, type SettingsCardProps } from "./helpers/settings_card"; const MIN_PASSWORD_LENGTH = 8; -function AccountPasswordView() { +function AccountSecurityView() { const navigate = useNavigate(); const [form] = Form.useForm(); const [isResetPasswordVisible, setResetPasswordVisible] = useState(false); @@ -192,7 +192,10 @@ function AccountPasswordView() { return (
- + {securityItems.map((item) => ( @@ -223,4 +226,4 @@ function AccountPasswordView() { ); } -export default AccountPasswordView; +export default AccountSecurityView; diff --git a/frontend/javascripts/admin/account/account_settings_view.tsx b/frontend/javascripts/admin/account/account_settings_view.tsx index e526e9c97ce..564e858ebfc 100644 --- a/frontend/javascripts/admin/account/account_settings_view.tsx +++ b/frontend/javascripts/admin/account/account_settings_view.tsx @@ -7,7 +7,7 @@ const { Sider, Content } = Layout; const BREADCRUMB_LABELS = { token: "Auth Token", - password: "Password", + security: "Security", profile: "Profile", }; @@ -22,9 +22,9 @@ const MENU_ITEMS: MenuItemGroupType[] = [ label: "Profile", }, { - key: "password", + key: "security", icon: , - label: "Password", + label: "Security", }, ], }, diff --git a/frontend/javascripts/router/router.tsx b/frontend/javascripts/router/router.tsx index 1e421486f5a..cf1583d2c3c 100644 --- a/frontend/javascripts/router/router.tsx +++ b/frontend/javascripts/router/router.tsx @@ -46,8 +46,8 @@ import { CommandPalette } from "viewer/view/components/command_palette"; const { Content } = Layout; import AccountAuthTokenView from "admin/account/account_auth_token_view"; -import AccountPasswordView from "admin/account/account_password_view"; import AccountProfileView from "admin/account/account_profile_view"; +import AccountSecurityView from "admin/account/account_security_view"; import { OrganizationDangerZoneView } from "admin/organization/organization_danger_zone_view"; import { OrganizationNotificationsView } from "admin/organization/organization_notifications_view"; import { OrganizationOverviewView } from "admin/organization/organization_overview_view"; @@ -465,7 +465,7 @@ const routes = createRoutesFromElements( > } /> } /> - } /> + } /> } /> } /> From 9c1ed364d394b7a43cf2b4d09b871b191d47833e Mon Sep 17 00:00:00 2001 From: Charlie Meister Date: Thu, 21 Aug 2025 13:32:34 +0200 Subject: [PATCH 07/22] catch error --- .../admin/account/account_security_view.tsx | 12 +++++++++--- .../javascripts/admin/auth/change_email_view.tsx | 4 ++-- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/frontend/javascripts/admin/account/account_security_view.tsx b/frontend/javascripts/admin/account/account_security_view.tsx index e3fc1f29f9e..be109ec1797 100644 --- a/frontend/javascripts/admin/account/account_security_view.tsx +++ b/frontend/javascripts/admin/account/account_security_view.tsx @@ -183,9 +183,15 @@ function AccountSecurityView() { } async function handleLogout() { - await logoutUserEverywhere().then(() => { - navigate("/login"); - }); + logoutUserEverywhere() + .then(() => { + Store.dispatch(logoutUserAction()); + navigate("/login"); + }) + .catch((error) => { + Toast.error("Failed to log out. See console for more details"); + console.error("Logout failed:", error); + }); } const { passkeysEnabled } = features(); diff --git a/frontend/javascripts/admin/auth/change_email_view.tsx b/frontend/javascripts/admin/auth/change_email_view.tsx index 0bf62b32196..7c6b56610a2 100644 --- a/frontend/javascripts/admin/auth/change_email_view.tsx +++ b/frontend/javascripts/admin/auth/change_email_view.tsx @@ -18,7 +18,7 @@ const PASSWORD_FIELD_KEY = "password"; function ChangeEmailView({ onCancel }: { onCancel: () => void }) { const [form] = Form.useForm(); const activeUser = useWkSelector((state) => state.activeUser); - useNavigate(); + const navigate = useNavigate(); async function changeEmail(newEmail: string, password: string) { const newUser = Object.assign({}, activeUser, { @@ -38,7 +38,7 @@ function ChangeEmailView({ onCancel }: { onCancel: () => void }) { Toast.success("Email address changed successfully. You will be logged out."); await logoutUserEverywhere(); Store.dispatch(logoutUserAction()); - window.location.href = "/auth/login"; + navigate("/auth/login"); }) .catch((error) => { const errorMsg = "An unexpected error occurred while changing the email address."; From 98c4eac361f81ed8c16ab578d9445ddb164e7fcb Mon Sep 17 00:00:00 2001 From: Charlie Meister Date: Thu, 21 Aug 2025 14:54:03 +0200 Subject: [PATCH 08/22] add error toast for 401 hppt codes --- .../javascripts/libs/handle_http_status.ts | 9 -------- .../javascripts/libs/handle_http_status.tsx | 21 +++++++++++++++++++ frontend/javascripts/libs/toast.tsx | 14 +++++++++++++ 3 files changed, 35 insertions(+), 9 deletions(-) delete mode 100644 frontend/javascripts/libs/handle_http_status.ts create mode 100644 frontend/javascripts/libs/handle_http_status.tsx diff --git a/frontend/javascripts/libs/handle_http_status.ts b/frontend/javascripts/libs/handle_http_status.ts deleted file mode 100644 index fdc4544f6ba..00000000000 --- a/frontend/javascripts/libs/handle_http_status.ts +++ /dev/null @@ -1,9 +0,0 @@ -const handleStatus = (response: Response): Promise => { - if (response.status >= 200 && response.status < 400) { - return Promise.resolve(response); - } - - return Promise.reject(response); -}; - -export default handleStatus; diff --git a/frontend/javascripts/libs/handle_http_status.tsx b/frontend/javascripts/libs/handle_http_status.tsx new file mode 100644 index 00000000000..13cf8df85d4 --- /dev/null +++ b/frontend/javascripts/libs/handle_http_status.tsx @@ -0,0 +1,21 @@ +import { Button } from "antd"; +import { showToastOnce } from "./toast"; + +const handleStatus = (response: Response): Promise => { + if (response.status >= 200 && response.status < 400) { + return Promise.resolve(response); + } + if (response.status === 401) { + showToastOnce( + "error", + <> + Your session has expired. Please refresh the page + + , + { timeout: 10000, sticky: true }, + ); + } + return Promise.reject(response); +}; + +export default handleStatus; diff --git a/frontend/javascripts/libs/toast.tsx b/frontend/javascripts/libs/toast.tsx index 7a9d26f1ba5..dc6d793a77a 100644 --- a/frontend/javascripts/libs/toast.tsx +++ b/frontend/javascripts/libs/toast.tsx @@ -1,5 +1,6 @@ import { CloseCircleOutlined } from "@ant-design/icons"; import { Collapse, notification } from "antd"; +import _ from "lodash"; import type React from "react"; import { useEffect } from "react"; import { animationFrame, sleep } from "./utils"; @@ -243,3 +244,16 @@ const Toast = { }, }; export default Toast; + +export const showToastOnce = _.debounce( + ( + type: ToastStyle, + message: React.ReactNode, + config: ToastConfig = {}, + details?: string | undefined, + ) => { + Toast[type](message, config, details); + }, + 60000, + { leading: true }, +); From 569259cab93a86e567cd81bb6dddcd751c6705f9 Mon Sep 17 00:00:00 2001 From: Charlie Meister Date: Thu, 21 Aug 2025 16:48:15 +0200 Subject: [PATCH 09/22] move error toast to tsx module --- .../javascripts/libs/handle_http_status.tsx | 97 ++++++++++++++++--- frontend/javascripts/libs/request.ts | 76 +-------------- 2 files changed, 87 insertions(+), 86 deletions(-) diff --git a/frontend/javascripts/libs/handle_http_status.tsx b/frontend/javascripts/libs/handle_http_status.tsx index 13cf8df85d4..5e74786dc9a 100644 --- a/frontend/javascripts/libs/handle_http_status.tsx +++ b/frontend/javascripts/libs/handle_http_status.tsx @@ -1,21 +1,92 @@ import { Button } from "antd"; -import { showToastOnce } from "./toast"; +import type { ServerErrorMessage } from "./request"; +import Toast, { showToastOnce } from "./toast"; -const handleStatus = (response: Response): Promise => { +export const handleStatus = (response: Response): Promise => { if (response.status >= 200 && response.status < 400) { return Promise.resolve(response); } - if (response.status === 401) { - showToastOnce( - "error", - <> - Your session has expired. Please refresh the page - - , - { timeout: 10000, sticky: true }, - ); - } return Promise.reject(response); }; -export default handleStatus; +export const handleError = async ( + requestedUrl: string, + showErrorToast: boolean, + doInvestigate: boolean, + error: Response | Error, +): Promise => { + if (doInvestigate) { + // Avoid circular imports via dynamic import + const { pingMentionedDataStores } = await import("admin/datastore_health_check"); + // Check whether this request failed due to a problematic datastore + pingMentionedDataStores(requestedUrl); + if (error instanceof Response) { + if (error.status === 401 && showErrorToast) { + showToastOnce( + "error", + <> + Your session has expired. Please refresh the page +
+ + , + { timeout: 10000, sticky: true }, + ); + } + return error.text().then( + (text) => { + try { + const json = JSON.parse(text); + + // Propagate HTTP status code for further processing down the road + if (error.status != null) { + json.status = error.status; + } + + const messages = json.messages.map((message: ServerErrorMessage[]) => ({ + ...message, + key: json.status.toString(), + })); + if (showErrorToast) { + Toast.messages(messages); // Note: Toast.error internally logs to console + } else { + console.error(messages); + } + // Check whether the error chain mentions an url which belongs + // to a datastore. Then, ping the datastore + pingMentionedDataStores(text); + + /* eslint-disable-next-line prefer-promise-reject-errors */ + return Promise.reject({ ...json, url: requestedUrl }); + } catch (_jsonError) { + if (showErrorToast) { + Toast.error(text); // Note: Toast.error internally logs to console + } else { + console.error(`Request failed for ${requestedUrl}:`, text); + } + + /* eslint-disable-next-line prefer-promise-reject-errors */ + return Promise.reject({ + errors: [text], + status: error.status != null ? error.status : -1, + url: requestedUrl, + }); + } + }, + (textError) => { + Toast.error(textError.toString()); + return Promise.reject(textError); + }, + ); + } + } + + // If doInvestigate is false or the error is not instanceof Response, + // still add additional information to the error + if (!(error instanceof Response)) { + error.message += ` - Url: ${requestedUrl}`; + } + + return Promise.reject(error); +}; diff --git a/frontend/javascripts/libs/request.ts b/frontend/javascripts/libs/request.ts index 801eadc00e0..428f4dce9ab 100644 --- a/frontend/javascripts/libs/request.ts +++ b/frontend/javascripts/libs/request.ts @@ -1,5 +1,4 @@ -import handleStatus from "libs/handle_http_status"; -import Toast from "libs/toast"; +import { handleError, handleStatus } from "libs/handle_http_status"; import _ from "lodash"; import type { ArbitraryObject } from "types/globals"; import urljoin from "url-join"; @@ -256,7 +255,7 @@ class Request { ? fetchBufferWithHeaders(url, options) : fetchBufferViaWorker(url, options); } else { - fetchPromise = fetch(url, options).then(handleStatus); + fetchPromise = fetch(url, options).then((response) => handleStatus(response)); if (responseDataHandler != null) { fetchPromise = fetchPromise.then(responseDataHandler); @@ -264,7 +263,7 @@ class Request { } fetchPromise = fetchPromise.catch((error) => - this.handleError(url, options.showErrorToast || false, !options.doNotInvestigate, error), + handleError(url, options.showErrorToast || false, !options.doNotInvestigate, error), ); if (options.timeout != null) { @@ -285,75 +284,6 @@ class Request { setTimeout(() => resolve("timeout"), timeout); }); - handleError = async ( - requestedUrl: string, - showErrorToast: boolean, - doInvestigate: boolean, - error: Response | Error, - ): Promise => { - if (doInvestigate) { - // Avoid circular imports via dynamic import - const { pingMentionedDataStores } = await import("admin/datastore_health_check"); - // Check whether this request failed due to a problematic datastore - pingMentionedDataStores(requestedUrl); - if (error instanceof Response) { - return error.text().then( - (text) => { - try { - const json = JSON.parse(text); - - // Propagate HTTP status code for further processing down the road - if (error.status != null) { - json.status = error.status; - } - - const messages = json.messages.map((message: ServerErrorMessage[]) => ({ - ...message, - key: json.status.toString(), - })); - if (showErrorToast) { - Toast.messages(messages); // Note: Toast.error internally logs to console - } else { - console.error(messages); - } - // Check whether the error chain mentions an url which belongs - // to a datastore. Then, ping the datastore - pingMentionedDataStores(text); - - /* eslint-disable-next-line prefer-promise-reject-errors */ - return Promise.reject({ ...json, url: requestedUrl }); - } catch (_jsonError) { - if (showErrorToast) { - Toast.error(text); // Note: Toast.error internally logs to console - } else { - console.error(`Request failed for ${requestedUrl}:`, text); - } - - /* eslint-disable-next-line prefer-promise-reject-errors */ - return Promise.reject({ - errors: [text], - status: error.status != null ? error.status : -1, - url: requestedUrl, - }); - } - }, - (textError) => { - Toast.error(textError.toString()); - return Promise.reject(textError); - }, - ); - } - } - - // If doInvestigate is false or the error is not instanceof Response, - // still add additional information to the error - if (!(error instanceof Response)) { - error.message += ` - Url: ${requestedUrl}`; - } - - return Promise.reject(error); - }; - handleEmptyJsonResponse = (response: Response): Promise => response.text().then((responseText) => { if (responseText.length === 0) { From a1b1d8cbef20cb62d4b886fc116c9be813e90405 Mon Sep 17 00:00:00 2001 From: Charlie Meister Date: Thu, 21 Aug 2025 17:10:43 +0200 Subject: [PATCH 10/22] fix imports --- frontend/javascripts/admin/auth/verify_email_view.tsx | 2 +- frontend/javascripts/libs/handle_http_status.tsx | 5 ++++- frontend/javascripts/libs/request.ts | 4 ---- frontend/javascripts/viewer/workers/fetch_buffer.worker.ts | 2 +- .../viewer/workers/fetch_buffer_with_headers.worker.ts | 2 +- 5 files changed, 7 insertions(+), 8 deletions(-) diff --git a/frontend/javascripts/admin/auth/verify_email_view.tsx b/frontend/javascripts/admin/auth/verify_email_view.tsx index d95e5e3dc0a..fa64014c9d1 100644 --- a/frontend/javascripts/admin/auth/verify_email_view.tsx +++ b/frontend/javascripts/admin/auth/verify_email_view.tsx @@ -1,7 +1,7 @@ import { requestVerificationMail, verifyEmail } from "admin/rest_api"; import { Spin } from "antd"; +import type { ServerErrorMessage } from "libs/handle_http_status"; import { useFetch } from "libs/react_helpers"; -import type { ServerErrorMessage } from "libs/request"; import Toast from "libs/toast"; import { useEffect } from "react"; import { useNavigate, useParams } from "react-router-dom"; diff --git a/frontend/javascripts/libs/handle_http_status.tsx b/frontend/javascripts/libs/handle_http_status.tsx index 5e74786dc9a..1f58e3af02f 100644 --- a/frontend/javascripts/libs/handle_http_status.tsx +++ b/frontend/javascripts/libs/handle_http_status.tsx @@ -1,7 +1,10 @@ import { Button } from "antd"; -import type { ServerErrorMessage } from "./request"; import Toast, { showToastOnce } from "./toast"; +export type ServerErrorMessage = { + error: string; +}; + export const handleStatus = (response: Response): Promise => { if (response.status >= 200 && response.status < 400) { return Promise.resolve(response); diff --git a/frontend/javascripts/libs/request.ts b/frontend/javascripts/libs/request.ts index 428f4dce9ab..47695871553 100644 --- a/frontend/javascripts/libs/request.ts +++ b/frontend/javascripts/libs/request.ts @@ -32,10 +32,6 @@ export type RequestOptionsWithData = RequestOptions & { data: T; }; -export type ServerErrorMessage = { - error: string; -}; - class Request { // IN: nothing // OUT: json diff --git a/frontend/javascripts/viewer/workers/fetch_buffer.worker.ts b/frontend/javascripts/viewer/workers/fetch_buffer.worker.ts index 93272ae510b..91ab268f9f1 100644 --- a/frontend/javascripts/viewer/workers/fetch_buffer.worker.ts +++ b/frontend/javascripts/viewer/workers/fetch_buffer.worker.ts @@ -1,4 +1,4 @@ -import handleStatus from "libs/handle_http_status"; +import { handleStatus } from "libs/handle_http_status"; import { expose } from "./comlink_wrapper"; // @ts-expect-error ts-migrate(2304) FIXME: Cannot find name 'RequestOptions'. diff --git a/frontend/javascripts/viewer/workers/fetch_buffer_with_headers.worker.ts b/frontend/javascripts/viewer/workers/fetch_buffer_with_headers.worker.ts index 029c335f57f..000e35559f5 100644 --- a/frontend/javascripts/viewer/workers/fetch_buffer_with_headers.worker.ts +++ b/frontend/javascripts/viewer/workers/fetch_buffer_with_headers.worker.ts @@ -1,4 +1,4 @@ -import handleStatus from "libs/handle_http_status"; +import { handleStatus } from "libs/handle_http_status"; import { expose, transfer } from "./comlink_wrapper"; function fetchBufferWithHeaders( From 2974ccefec71a15d5f8d4cc3efdd9937cbe2966b Mon Sep 17 00:00:00 2001 From: Charlie Meister Date: Thu, 21 Aug 2025 17:37:02 +0200 Subject: [PATCH 11/22] move react using function to new module --- .../javascripts/libs/handle_http_status.ts | 12 +++ .../javascripts/libs/handle_http_status.tsx | 95 ------------------- .../libs/handle_request_error_helper.tsx | 85 +++++++++++++++++ frontend/javascripts/libs/request.ts | 3 +- .../viewer/workers/fetch_buffer.worker.ts | 2 +- .../fetch_buffer_with_headers.worker.ts | 2 +- 6 files changed, 101 insertions(+), 98 deletions(-) create mode 100644 frontend/javascripts/libs/handle_http_status.ts delete mode 100644 frontend/javascripts/libs/handle_http_status.tsx create mode 100644 frontend/javascripts/libs/handle_request_error_helper.tsx diff --git a/frontend/javascripts/libs/handle_http_status.ts b/frontend/javascripts/libs/handle_http_status.ts new file mode 100644 index 00000000000..92ed15a00f8 --- /dev/null +++ b/frontend/javascripts/libs/handle_http_status.ts @@ -0,0 +1,12 @@ +export type ServerErrorMessage = { + error: string; +}; + +const handleStatus = (response: Response): Promise => { + if (response.status >= 200 && response.status < 400) { + return Promise.resolve(response); + } + return Promise.reject(response); +}; + +export default handleStatus; \ No newline at end of file diff --git a/frontend/javascripts/libs/handle_http_status.tsx b/frontend/javascripts/libs/handle_http_status.tsx deleted file mode 100644 index 1f58e3af02f..00000000000 --- a/frontend/javascripts/libs/handle_http_status.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { Button } from "antd"; -import Toast, { showToastOnce } from "./toast"; - -export type ServerErrorMessage = { - error: string; -}; - -export const handleStatus = (response: Response): Promise => { - if (response.status >= 200 && response.status < 400) { - return Promise.resolve(response); - } - return Promise.reject(response); -}; - -export const handleError = async ( - requestedUrl: string, - showErrorToast: boolean, - doInvestigate: boolean, - error: Response | Error, -): Promise => { - if (doInvestigate) { - // Avoid circular imports via dynamic import - const { pingMentionedDataStores } = await import("admin/datastore_health_check"); - // Check whether this request failed due to a problematic datastore - pingMentionedDataStores(requestedUrl); - if (error instanceof Response) { - if (error.status === 401 && showErrorToast) { - showToastOnce( - "error", - <> - Your session has expired. Please refresh the page -
- - , - { timeout: 10000, sticky: true }, - ); - } - return error.text().then( - (text) => { - try { - const json = JSON.parse(text); - - // Propagate HTTP status code for further processing down the road - if (error.status != null) { - json.status = error.status; - } - - const messages = json.messages.map((message: ServerErrorMessage[]) => ({ - ...message, - key: json.status.toString(), - })); - if (showErrorToast) { - Toast.messages(messages); // Note: Toast.error internally logs to console - } else { - console.error(messages); - } - // Check whether the error chain mentions an url which belongs - // to a datastore. Then, ping the datastore - pingMentionedDataStores(text); - - /* eslint-disable-next-line prefer-promise-reject-errors */ - return Promise.reject({ ...json, url: requestedUrl }); - } catch (_jsonError) { - if (showErrorToast) { - Toast.error(text); // Note: Toast.error internally logs to console - } else { - console.error(`Request failed for ${requestedUrl}:`, text); - } - - /* eslint-disable-next-line prefer-promise-reject-errors */ - return Promise.reject({ - errors: [text], - status: error.status != null ? error.status : -1, - url: requestedUrl, - }); - } - }, - (textError) => { - Toast.error(textError.toString()); - return Promise.reject(textError); - }, - ); - } - } - - // If doInvestigate is false or the error is not instanceof Response, - // still add additional information to the error - if (!(error instanceof Response)) { - error.message += ` - Url: ${requestedUrl}`; - } - - return Promise.reject(error); -}; diff --git a/frontend/javascripts/libs/handle_request_error_helper.tsx b/frontend/javascripts/libs/handle_request_error_helper.tsx new file mode 100644 index 00000000000..9c03d6e07ff --- /dev/null +++ b/frontend/javascripts/libs/handle_request_error_helper.tsx @@ -0,0 +1,85 @@ +import { Button } from "antd"; +import Toast, { showToastOnce } from "./toast"; +import type { ServerErrorMessage } from "./handle_http_status"; + +export const handleError = async ( + requestedUrl: string, + showErrorToast: boolean, + doInvestigate: boolean, + error: Response | Error, +): Promise => { + if (doInvestigate) { + // Avoid circular imports via dynamic import + const { pingMentionedDataStores } = await import("admin/datastore_health_check"); + // Check whether this request failed due to a problematic datastore + pingMentionedDataStores(requestedUrl); + if (error instanceof Response) { + if (error.status === 401 && showErrorToast) { + showToastOnce( + "error", + <> + Your session has expired. Please refresh the page +
+ + , + { timeout: 10000, sticky: true }, + ); + } + return error.text().then( + (text) => { + try { + const json = JSON.parse(text); + + // Propagate HTTP status code for further processing down the road + if (error.status != null) { + json.status = error.status; + } + + const messages = json.messages.map((message: ServerErrorMessage[]) => ({ + ...message, + key: json.status.toString(), + })); + if (showErrorToast) { + Toast.messages(messages); // Note: Toast.error internally logs to console + } else { + console.error(messages); + } + // Check whether the error chain mentions an url which belongs + // to a datastore. Then, ping the datastore + pingMentionedDataStores(text); + + /* eslint-disable-next-line prefer-promise-reject-errors */ + return Promise.reject({ ...json, url: requestedUrl }); + } catch (_jsonError) { + if (showErrorToast) { + Toast.error(text); // Note: Toast.error internally logs to console + } else { + console.error(`Request failed for ${requestedUrl}:`, text); + } + + /* eslint-disable-next-line prefer-promise-reject-errors */ + return Promise.reject({ + errors: [text], + status: error.status != null ? error.status : -1, + url: requestedUrl, + }); + } + }, + (textError) => { + Toast.error(textError.toString()); + return Promise.reject(textError); + }, + ); + } + } + + // If doInvestigate is false or the error is not instanceof Response, + // still add additional information to the error + if (!(error instanceof Response)) { + error.message += ` - Url: ${requestedUrl}`; + } + + return Promise.reject(error); +}; diff --git a/frontend/javascripts/libs/request.ts b/frontend/javascripts/libs/request.ts index 47695871553..663dd717504 100644 --- a/frontend/javascripts/libs/request.ts +++ b/frontend/javascripts/libs/request.ts @@ -1,4 +1,4 @@ -import { handleError, handleStatus } from "libs/handle_http_status"; +import handleStatus from "libs/handle_http_status"; import _ from "lodash"; import type { ArbitraryObject } from "types/globals"; import urljoin from "url-join"; @@ -6,6 +6,7 @@ import { createWorker } from "viewer/workers/comlink_wrapper"; import CompressWorker from "viewer/workers/compress.worker"; import FetchBufferWorker from "viewer/workers/fetch_buffer.worker"; import FetchBufferWithHeadersWorker from "viewer/workers/fetch_buffer_with_headers.worker"; +import { handleError } from "./handle_request_error_helper"; const fetchBufferViaWorker = createWorker(FetchBufferWorker); const fetchBufferWithHeaders = createWorker(FetchBufferWithHeadersWorker); diff --git a/frontend/javascripts/viewer/workers/fetch_buffer.worker.ts b/frontend/javascripts/viewer/workers/fetch_buffer.worker.ts index 91ab268f9f1..93272ae510b 100644 --- a/frontend/javascripts/viewer/workers/fetch_buffer.worker.ts +++ b/frontend/javascripts/viewer/workers/fetch_buffer.worker.ts @@ -1,4 +1,4 @@ -import { handleStatus } from "libs/handle_http_status"; +import handleStatus from "libs/handle_http_status"; import { expose } from "./comlink_wrapper"; // @ts-expect-error ts-migrate(2304) FIXME: Cannot find name 'RequestOptions'. diff --git a/frontend/javascripts/viewer/workers/fetch_buffer_with_headers.worker.ts b/frontend/javascripts/viewer/workers/fetch_buffer_with_headers.worker.ts index 000e35559f5..029c335f57f 100644 --- a/frontend/javascripts/viewer/workers/fetch_buffer_with_headers.worker.ts +++ b/frontend/javascripts/viewer/workers/fetch_buffer_with_headers.worker.ts @@ -1,4 +1,4 @@ -import { handleStatus } from "libs/handle_http_status"; +import handleStatus from "libs/handle_http_status"; import { expose, transfer } from "./comlink_wrapper"; function fetchBufferWithHeaders( From 0607eeeb80cc63535ad01df93f48c7484236db31 Mon Sep 17 00:00:00 2001 From: Charlie Meister Date: Thu, 21 Aug 2025 17:57:03 +0200 Subject: [PATCH 12/22] dont show error toast for sharing link requests --- frontend/javascripts/admin/rest_api.ts | 1 + .../javascripts/libs/handle_http_status.ts | 2 +- .../libs/handle_request_error_helper.tsx | 144 +++++++++--------- 3 files changed, 74 insertions(+), 73 deletions(-) diff --git a/frontend/javascripts/admin/rest_api.ts b/frontend/javascripts/admin/rest_api.ts index 0abbe1e4881..b8798c443c3 100644 --- a/frontend/javascripts/admin/rest_api.ts +++ b/frontend/javascripts/admin/rest_api.ts @@ -2327,6 +2327,7 @@ export const createShortLink = _.memoize( (longLink: string): Promise => Request.sendJSONReceiveJSON("/api/shortLinks", { method: "POST", + showErrorToast: false, // stringify is necessary because the back-end expects a JSON string // (i.e., a string which contains quotes at the beginning and end). // The Request module does not add additional string quotes diff --git a/frontend/javascripts/libs/handle_http_status.ts b/frontend/javascripts/libs/handle_http_status.ts index 92ed15a00f8..36de5a2164a 100644 --- a/frontend/javascripts/libs/handle_http_status.ts +++ b/frontend/javascripts/libs/handle_http_status.ts @@ -9,4 +9,4 @@ const handleStatus = (response: Response): Promise => { return Promise.reject(response); }; -export default handleStatus; \ No newline at end of file +export default handleStatus; diff --git a/frontend/javascripts/libs/handle_request_error_helper.tsx b/frontend/javascripts/libs/handle_request_error_helper.tsx index 9c03d6e07ff..156061114ad 100644 --- a/frontend/javascripts/libs/handle_request_error_helper.tsx +++ b/frontend/javascripts/libs/handle_request_error_helper.tsx @@ -1,85 +1,85 @@ import { Button } from "antd"; -import Toast, { showToastOnce } from "./toast"; import type { ServerErrorMessage } from "./handle_http_status"; +import Toast, { showToastOnce } from "./toast"; export const handleError = async ( - requestedUrl: string, - showErrorToast: boolean, - doInvestigate: boolean, - error: Response | Error, + requestedUrl: string, + showErrorToast: boolean, + doInvestigate: boolean, + error: Response | Error, ): Promise => { - if (doInvestigate) { - // Avoid circular imports via dynamic import - const { pingMentionedDataStores } = await import("admin/datastore_health_check"); - // Check whether this request failed due to a problematic datastore - pingMentionedDataStores(requestedUrl); - if (error instanceof Response) { - if (error.status === 401 && showErrorToast) { - showToastOnce( - "error", - <> - Your session has expired. Please refresh the page -
- - , - { timeout: 10000, sticky: true }, - ); - } - return error.text().then( - (text) => { - try { - const json = JSON.parse(text); + if (doInvestigate) { + // Avoid circular imports via dynamic import + const { pingMentionedDataStores } = await import("admin/datastore_health_check"); + // Check whether this request failed due to a problematic datastore + pingMentionedDataStores(requestedUrl); + if (error instanceof Response) { + if (error.status === 401 && showErrorToast) { + showToastOnce( + "error", + <> + Your session has expired. Please refresh the page +
+ + , + { timeout: 10000, sticky: true }, + ); + } + return error.text().then( + (text) => { + try { + const json = JSON.parse(text); - // Propagate HTTP status code for further processing down the road - if (error.status != null) { - json.status = error.status; - } + // Propagate HTTP status code for further processing down the road + if (error.status != null) { + json.status = error.status; + } - const messages = json.messages.map((message: ServerErrorMessage[]) => ({ - ...message, - key: json.status.toString(), - })); - if (showErrorToast) { - Toast.messages(messages); // Note: Toast.error internally logs to console - } else { - console.error(messages); - } - // Check whether the error chain mentions an url which belongs - // to a datastore. Then, ping the datastore - pingMentionedDataStores(text); + const messages = json.messages.map((message: ServerErrorMessage[]) => ({ + ...message, + key: json.status.toString(), + })); + if (showErrorToast) { + Toast.messages(messages); // Note: Toast.error internally logs to console + } else { + console.error(messages); + } + // Check whether the error chain mentions an url which belongs + // to a datastore. Then, ping the datastore + pingMentionedDataStores(text); - /* eslint-disable-next-line prefer-promise-reject-errors */ - return Promise.reject({ ...json, url: requestedUrl }); - } catch (_jsonError) { - if (showErrorToast) { - Toast.error(text); // Note: Toast.error internally logs to console - } else { - console.error(`Request failed for ${requestedUrl}:`, text); - } + /* eslint-disable-next-line prefer-promise-reject-errors */ + return Promise.reject({ ...json, url: requestedUrl }); + } catch (_jsonError) { + if (showErrorToast) { + Toast.error(text); // Note: Toast.error internally logs to console + } else { + console.error(`Request failed for ${requestedUrl}:`, text); + } - /* eslint-disable-next-line prefer-promise-reject-errors */ - return Promise.reject({ - errors: [text], - status: error.status != null ? error.status : -1, - url: requestedUrl, - }); - } - }, - (textError) => { - Toast.error(textError.toString()); - return Promise.reject(textError); - }, - ); - } + /* eslint-disable-next-line prefer-promise-reject-errors */ + return Promise.reject({ + errors: [text], + status: error.status != null ? error.status : -1, + url: requestedUrl, + }); + } + }, + (textError) => { + Toast.error(textError.toString()); + return Promise.reject(textError); + }, + ); } + } - // If doInvestigate is false or the error is not instanceof Response, - // still add additional information to the error - if (!(error instanceof Response)) { - error.message += ` - Url: ${requestedUrl}`; - } + // If doInvestigate is false or the error is not instanceof Response, + // still add additional information to the error + if (!(error instanceof Response)) { + error.message += ` - Url: ${requestedUrl}`; + } - return Promise.reject(error); + return Promise.reject(error); }; From 2ba5c9a7e68782ed008fe9a3c657ff3bef909b16 Mon Sep 17 00:00:00 2001 From: Charlie Meister Date: Thu, 21 Aug 2025 18:18:55 +0200 Subject: [PATCH 13/22] clean up code --- frontend/javascripts/admin/auth/verify_email_view.tsx | 2 +- frontend/javascripts/libs/handle_http_status.ts | 4 ---- frontend/javascripts/libs/handle_request_error_helper.tsx | 4 ++-- frontend/javascripts/types/api_types.ts | 4 ++++ 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/frontend/javascripts/admin/auth/verify_email_view.tsx b/frontend/javascripts/admin/auth/verify_email_view.tsx index fa64014c9d1..8eaaf5421ad 100644 --- a/frontend/javascripts/admin/auth/verify_email_view.tsx +++ b/frontend/javascripts/admin/auth/verify_email_view.tsx @@ -1,10 +1,10 @@ import { requestVerificationMail, verifyEmail } from "admin/rest_api"; import { Spin } from "antd"; -import type { ServerErrorMessage } from "libs/handle_http_status"; import { useFetch } from "libs/react_helpers"; import Toast from "libs/toast"; import { useEffect } from "react"; import { useNavigate, useParams } from "react-router-dom"; +import type { ServerErrorMessage } from "types/api_types"; import { Store } from "viewer/singletons"; export const VERIFICATION_ERROR_TOAST_KEY = "verificationError"; diff --git a/frontend/javascripts/libs/handle_http_status.ts b/frontend/javascripts/libs/handle_http_status.ts index 36de5a2164a..1fb0bbec014 100644 --- a/frontend/javascripts/libs/handle_http_status.ts +++ b/frontend/javascripts/libs/handle_http_status.ts @@ -1,7 +1,3 @@ -export type ServerErrorMessage = { - error: string; -}; - const handleStatus = (response: Response): Promise => { if (response.status >= 200 && response.status < 400) { return Promise.resolve(response); diff --git a/frontend/javascripts/libs/handle_request_error_helper.tsx b/frontend/javascripts/libs/handle_request_error_helper.tsx index 156061114ad..cb1910e1c4c 100644 --- a/frontend/javascripts/libs/handle_request_error_helper.tsx +++ b/frontend/javascripts/libs/handle_request_error_helper.tsx @@ -1,5 +1,5 @@ import { Button } from "antd"; -import type { ServerErrorMessage } from "./handle_http_status"; +import type { ServerErrorMessage } from "types/api_types"; import Toast, { showToastOnce } from "./toast"; export const handleError = async ( @@ -20,7 +20,7 @@ export const handleError = async ( <> Your session has expired. Please refresh the page
- , diff --git a/frontend/javascripts/types/api_types.ts b/frontend/javascripts/types/api_types.ts index fe494902d0d..3c95a709299 100644 --- a/frontend/javascripts/types/api_types.ts +++ b/frontend/javascripts/types/api_types.ts @@ -1300,3 +1300,7 @@ export type RenderAnimationOptions = { movieResolution: MOVIE_RESOLUTIONS; cameraPosition: CAMERA_POSITIONS; }; + +export type ServerErrorMessage = { + error: string; +}; From 85dc08ba9f18784b872a426792a7d419495c9098 Mon Sep 17 00:00:00 2001 From: Charlie Meister Date: Thu, 21 Aug 2025 18:39:39 +0200 Subject: [PATCH 14/22] address coderabbit review --- .../admin/account/account_security_view.tsx | 25 +++++++++---------- frontend/javascripts/router/router.tsx | 3 ++- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/frontend/javascripts/admin/account/account_security_view.tsx b/frontend/javascripts/admin/account/account_security_view.tsx index be109ec1797..f62ebfc084c 100644 --- a/frontend/javascripts/admin/account/account_security_view.tsx +++ b/frontend/javascripts/admin/account/account_security_view.tsx @@ -22,18 +22,17 @@ function AccountSecurityView() { const [isResetPasswordVisible, setResetPasswordVisible] = useState(false); const [showConfirmLogoutModal, setShowConfirmLogoutModal] = useState(false); - function onFinish(formValues: Record) { - changePassword(formValues) - .then(async () => { - Toast.success(messages["auth.reset_pw_confirmation"]); - await logoutUserEverywhere(); - Store.dispatch(logoutUserAction()); - navigate("/auth/login"); - }) - .catch((error) => { - console.error("Password change failed:", error); - Toast.error("Failed to change password. Please try again."); - }); + async function onFinish(formValues: Record) { + try { + await changePassword(formValues); + Toast.success(messages["auth.reset_pw_confirmation"]); + await logoutUserEverywhere(); + Store.dispatch(logoutUserAction()); + navigate("/auth/login"); + } catch (error) { + console.error("Password change failed:", error); + Toast.error("Failed to change password. Please try again."); + } } function checkPasswordsAreMatching(value: string, otherPasswordFieldKey: string[]) { @@ -186,7 +185,7 @@ function AccountSecurityView() { logoutUserEverywhere() .then(() => { Store.dispatch(logoutUserAction()); - navigate("/login"); + navigate("/auth/login"); }) .catch((error) => { Toast.error("Failed to log out. See console for more details"); diff --git a/frontend/javascripts/router/router.tsx b/frontend/javascripts/router/router.tsx index cf1583d2c3c..b5851b5f78b 100644 --- a/frontend/javascripts/router/router.tsx +++ b/frontend/javascripts/router/router.tsx @@ -375,7 +375,8 @@ const routes = createRoutesFromElements( {/* Backwards compatibility for old auth token URLs */} } /> {/* Backwards compatibility for old password change URLs */} - } /> + } /> + } /> } /> } /> From f4164ed06f9efdbc51c1da90dc970865e1cb5736 Mon Sep 17 00:00:00 2001 From: Charlie Meister Date: Thu, 21 Aug 2025 18:50:33 +0200 Subject: [PATCH 15/22] use replace for legacy routes --- frontend/javascripts/router/router.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/frontend/javascripts/router/router.tsx b/frontend/javascripts/router/router.tsx index b5851b5f78b..1a17eba31f4 100644 --- a/frontend/javascripts/router/router.tsx +++ b/frontend/javascripts/router/router.tsx @@ -373,21 +373,21 @@ const routes = createRoutesFromElements( element={} /> {/* Backwards compatibility for old auth token URLs */} - } /> + } /> {/* Backwards compatibility for old password change URLs */} - } /> - } /> + } /> + } /> } /> } /> } /> {/* Backwards compatibility for signup URLs */} - } /> + } /> {/* Backwards compatibility for register URLs */} - } /> + } /> {/* Backwards compatibility for register URLs */} - } /> + } /> } /> } /> From d1fa0f8ac170641d3389efa8369e29aa1a510259 Mon Sep 17 00:00:00 2001 From: Florian M Date: Mon, 25 Aug 2025 14:07:34 +0200 Subject: [PATCH 16/22] When logging out everywhere, also invalidate DataStore tokens --- app/controllers/AuthenticationController.scala | 2 ++ app/security/Token.scala | 12 ++++++++++++ 2 files changed, 14 insertions(+) diff --git a/app/controllers/AuthenticationController.scala b/app/controllers/AuthenticationController.scala index e0ffdb626fa..7545e446f1c 100755 --- a/app/controllers/AuthenticationController.scala +++ b/app/controllers/AuthenticationController.scala @@ -201,6 +201,7 @@ class AuthenticationController @Inject()( organizationDAO: OrganizationDAO, analyticsService: AnalyticsService, userDAO: UserDAO, + tokenDAO: TokenDAO, multiUserDAO: MultiUserDAO, defaultMails: DefaultMails, conf: WkConf, @@ -962,6 +963,7 @@ class AuthenticationController @Inject()( _ <- userDAO.logOutEverywhereByMultiUserId(request.identity._multiUser) userIds <- userDAO.findIdsByMultiUserId(request.identity._multiUser) _ = userIds.map(userService.removeUserFromCache) + _ <- tokenDAO.deleteDataStoreTokensForMultiUser(request.identity._multiUser) } yield Ok } } diff --git a/app/security/Token.scala b/app/security/Token.scala index 30d5d90e901..5600273eed3 100644 --- a/app/security/Token.scala +++ b/app/security/Token.scala @@ -134,6 +134,18 @@ class TokenDAO @Inject()(sqlClient: SqlClient)(implicit ec: ExecutionContext) for { _ <- run(query.update(true)) } yield () } + def deleteDataStoreTokensForMultiUser(multiUserId: ObjectId): Fox[Unit] = + for { + _ <- run(q"""UPDATE webknossos.tokens + SET isDeleted = ${true} + WHERE tokenType = ${TokenType.DataStore} + AND loginInfo_providerKey IN ( + SELECT _id + FROM webknossos.users_ + WHERE _multiUser = $multiUserId + )""".asUpdate) + } yield () + def updateEmail(oldEmail: String, newEmail: String): Fox[Unit] = for { _ <- run(q"""UPDATE webknossos.tokens From ad16619c8037e87f5409b5659204d8041db4b581 Mon Sep 17 00:00:00 2001 From: Charlie Meister Date: Wed, 27 Aug 2025 16:05:57 +0200 Subject: [PATCH 17/22] address review: add comment and use confirm rather than modal --- .../admin/account/account_security_view.tsx | 22 +++++++++---------- frontend/javascripts/admin/rest_api.ts | 1 - .../libs/handle_request_error_helper.tsx | 2 ++ 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/frontend/javascripts/admin/account/account_security_view.tsx b/frontend/javascripts/admin/account/account_security_view.tsx index f62ebfc084c..633d078b2a0 100644 --- a/frontend/javascripts/admin/account/account_security_view.tsx +++ b/frontend/javascripts/admin/account/account_security_view.tsx @@ -1,6 +1,6 @@ import { EditOutlined, LockOutlined } from "@ant-design/icons"; import { changePassword, logoutUserEverywhere } from "admin/rest_api"; -import { Alert, Button, Col, Form, Input, Modal, Row, Space } from "antd"; +import { Alert, App, Button, Col, Form, Input, Row, Space } from "antd"; import features from "features"; import Toast from "libs/toast"; import messages from "messages"; @@ -19,8 +19,9 @@ const MIN_PASSWORD_LENGTH = 8; function AccountSecurityView() { const navigate = useNavigate(); const [form] = Form.useForm(); + const modal = App.useApp(); + const confirm = modal.modal.confirm; const [isResetPasswordVisible, setResetPasswordVisible] = useState(false); - const [showConfirmLogoutModal, setShowConfirmLogoutModal] = useState(false); async function onFinish(formValues: Record) { try { @@ -153,6 +154,13 @@ function AccountSecurityView() { ); } + const handleLogoutEverywhere = () => { + confirm({ + title: "Confirm Logout", + content:

Are you sure you want to log out on all devices?

, + onOk: handleLogout, + }); + }; const securityItems: SettingsCardProps[] = [ { title: "Password", @@ -170,7 +178,7 @@ function AccountSecurityView() { { title: "Log out everywhere", content: ( - ), @@ -208,14 +216,6 @@ function AccountSecurityView() { ))}
- setShowConfirmLogoutModal(false)} - > -

Are you sure you want to log out on all devices?

-
{passkeysEnabled && ( <> diff --git a/frontend/javascripts/admin/rest_api.ts b/frontend/javascripts/admin/rest_api.ts index b8798c443c3..0abbe1e4881 100644 --- a/frontend/javascripts/admin/rest_api.ts +++ b/frontend/javascripts/admin/rest_api.ts @@ -2327,7 +2327,6 @@ export const createShortLink = _.memoize( (longLink: string): Promise => Request.sendJSONReceiveJSON("/api/shortLinks", { method: "POST", - showErrorToast: false, // stringify is necessary because the back-end expects a JSON string // (i.e., a string which contains quotes at the beginning and end). // The Request module does not add additional string quotes diff --git a/frontend/javascripts/libs/handle_request_error_helper.tsx b/frontend/javascripts/libs/handle_request_error_helper.tsx index cb1910e1c4c..93a0675cfe7 100644 --- a/frontend/javascripts/libs/handle_request_error_helper.tsx +++ b/frontend/javascripts/libs/handle_request_error_helper.tsx @@ -14,6 +14,8 @@ export const handleError = async ( // Check whether this request failed due to a problematic datastore pingMentionedDataStores(requestedUrl); if (error instanceof Response) { + // Handle 401 Unauthorized errors and ensure an understandable error toast is shown. + // This might happen e.g. after a user logged out everywhere. if (error.status === 401 && showErrorToast) { showToastOnce( "error", From fe4743518399b04d9f5c8950e73716cd62669961 Mon Sep 17 00:00:00 2001 From: Charlie Meister Date: Wed, 27 Aug 2025 19:25:28 +0200 Subject: [PATCH 18/22] add replace for every --- frontend/javascripts/router/router.tsx | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/frontend/javascripts/router/router.tsx b/frontend/javascripts/router/router.tsx index 1a17eba31f4..e8806ec90fc 100644 --- a/frontend/javascripts/router/router.tsx +++ b/frontend/javascripts/router/router.tsx @@ -169,7 +169,7 @@ const routes = createRoutesFromElements( } /> - } /> + } /> } > - } /> + } /> } /> } /> } /> @@ -354,7 +354,10 @@ const routes = createRoutesFromElements( } /> - } /> + } + /> } > - } /> + } /> } /> } /> } /> } + element={ + + } /> {/* Backwards compatibility for old auth token URLs */} } /> @@ -427,7 +432,7 @@ const routes = createRoutesFromElements( /> } /> } /> - } /> + } /> } > - } /> + } /> } /> } /> } /> From f622925982d1a6260e1879b0c62727d3b8ed97b9 Mon Sep 17 00:00:00 2001 From: Charlie Meister Date: Thu, 28 Aug 2025 15:17:14 +0200 Subject: [PATCH 19/22] fix publications redirect --- conf/application.conf | 2 +- frontend/javascripts/router/router.tsx | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/conf/application.conf b/conf/application.conf index 9debdad22f1..a0ea72fbfc3 100644 --- a/conf/application.conf +++ b/conf/application.conf @@ -146,7 +146,7 @@ features { discussionBoard = "https://forum.image.sc/tag/webknossos" discussionBoardRequiresAdmin = false hideNavbarLogin = false - isWkorgInstance = false + isWkorgInstance = true recommendWkorgInstance = true taskReopenAllowedInSeconds = 30 allowDeleteDatasets = true diff --git a/frontend/javascripts/router/router.tsx b/frontend/javascripts/router/router.tsx index e8806ec90fc..582cf534714 100644 --- a/frontend/javascripts/router/router.tsx +++ b/frontend/javascripts/router/router.tsx @@ -32,6 +32,7 @@ import { Route, createBrowserRouter, createRoutesFromElements, + redirect, } from "react-router-dom"; import AccountSettingsView from "admin/account/account_settings_view"; @@ -432,7 +433,10 @@ const routes = createRoutesFromElements( /> } /> } /> - } /> + redirect(`/publications/${params.id}`)} + /> Date: Thu, 28 Aug 2025 15:51:22 +0200 Subject: [PATCH 20/22] exchange SecuredAction for UserAwareAction when creating shortLink --- app/controllers/ShortLinkController.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/ShortLinkController.scala b/app/controllers/ShortLinkController.scala index 94b2e264ac2..8630a8db017 100644 --- a/app/controllers/ShortLinkController.scala +++ b/app/controllers/ShortLinkController.scala @@ -18,7 +18,7 @@ class ShortLinkController @Inject()(shortLinkDAO: ShortLinkDAO, sil: Silhouette[ extends Controller with FoxImplicits { - def create: Action[String] = sil.SecuredAction.async(validateJson[String]) { implicit request => + def create: Action[String] = sil.UserAwareAction.async(validateJson[String]) { implicit request => val longLink = request.body val _id = ObjectId.generate val key = RandomIDGenerator.generateBlocking(12) From ef2e01e1acf84809ac476ddffa6b459e1ee71b7d Mon Sep 17 00:00:00 2001 From: Charlie Meister Date: Thu, 28 Aug 2025 18:43:27 +0200 Subject: [PATCH 21/22] remove application.conf edit and fix replace for keyboard shortcuts --- conf/application.conf | 2 +- frontend/javascripts/router/router.tsx | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/conf/application.conf b/conf/application.conf index a0ea72fbfc3..9debdad22f1 100644 --- a/conf/application.conf +++ b/conf/application.conf @@ -146,7 +146,7 @@ features { discussionBoard = "https://forum.image.sc/tag/webknossos" discussionBoardRequiresAdmin = false hideNavbarLogin = false - isWkorgInstance = true + isWkorgInstance = false recommendWkorgInstance = true taskReopenAllowedInSeconds = 30 allowDeleteDatasets = true diff --git a/frontend/javascripts/router/router.tsx b/frontend/javascripts/router/router.tsx index 582cf534714..74ddb545e31 100644 --- a/frontend/javascripts/router/router.tsx +++ b/frontend/javascripts/router/router.tsx @@ -374,9 +374,7 @@ const routes = createRoutesFromElements( - } + loader={() => redirect("https://docs.webknossos.org/webknossos/ui/keyboard_shortcuts.html")} /> {/* Backwards compatibility for old auth token URLs */} } /> From 4cd0f7a0180c82b1361676cbf2d7eb844f0dfca3 Mon Sep 17 00:00:00 2001 From: Charlie Meister Date: Thu, 28 Aug 2025 18:52:02 +0200 Subject: [PATCH 22/22] fix login redirect --- frontend/javascripts/router/router.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/javascripts/router/router.tsx b/frontend/javascripts/router/router.tsx index 74ddb545e31..9f09bbe3a9f 100644 --- a/frontend/javascripts/router/router.tsx +++ b/frontend/javascripts/router/router.tsx @@ -381,7 +381,7 @@ const routes = createRoutesFromElements( {/* Backwards compatibility for old password change URLs */} } /> } /> - } /> + } /> } />