diff --git a/app/controllers/UserController.scala b/app/controllers/UserController.scala
index 7074b9203ef..5a92da080f1 100755
--- a/app/controllers/UserController.scala
+++ b/app/controllers/UserController.scala
@@ -3,7 +3,6 @@ package controllers
import play.silhouette.api.Silhouette
import com.scalableminds.util.accesscontext.{DBAccessContext, GlobalAccessContext}
import com.scalableminds.util.tools.{Fox, FoxImplicits}
-
import models.annotation.{AnnotationDAO, AnnotationService, AnnotationType}
import models.organization.OrganizationService
import models.team._
@@ -16,6 +15,10 @@ import com.scalableminds.util.objectid.ObjectId
import javax.inject.Inject
import models.user.Theme.Theme
+import net.liftweb.common.{Failure, Full}
+import play.silhouette.api.exceptions.ProviderException
+import play.silhouette.api.util.Credentials
+import play.silhouette.impl.providers.CredentialsProvider
import security.WkEnv
import scala.concurrent.ExecutionContext
@@ -23,6 +26,7 @@ import scala.concurrent.ExecutionContext
class UserController @Inject()(userService: UserService,
userDAO: UserDAO,
multiUserDAO: MultiUserDAO,
+ credentialsProvider: CredentialsProvider,
organizationService: OrganizationService,
annotationDAO: AnnotationDAO,
teamMembershipService: TeamMembershipService,
@@ -175,6 +179,7 @@ class UserController @Inject()(userService: UserService,
((__ \ "firstName").readNullable[String] and
(__ \ "lastName").readNullable[String] and
(__ \ "email").readNullable[String] and
+ (__ \ "password").readNullable[String] and
(__ \ "isActive").readNullable[Boolean] and
(__ \ "isAdmin").readNullable[Boolean] and
(__ \ "isDatasetManager").readNullable[Boolean] and
@@ -195,13 +200,45 @@ class UserController @Inject()(userService: UserService,
Fox.successful(())
})
- private def checkAdminOnlyUpdates(user: User,
- isActive: Boolean,
- isAdmin: Boolean,
- isDatasetManager: Boolean,
- oldEmail: String,
- email: String)(issuingUser: User): Boolean =
- if (isActive && user.isAdmin == isAdmin && oldEmail == email && isDatasetManager == user.isDatasetManager)
+ private def checkTeamManagerOnlyUpdates(user: User,
+ experiences: Map[String, Int],
+ oldExperiences: Map[String, Int],
+ teams: List[TeamMembership],
+ oldTeams: List[TeamMembership])(issuingUser: User): Fox[Boolean] =
+ if (experiences == oldExperiences && teams == oldTeams)
+ Fox.successful(true)
+ else userService.isEditableBy(user, issuingUser)
+
+ private def checkPasswordIfEmailChanged(user: User, passwordOpt: Option[String], oldEmail: String, email: String)(
+ issuingUser: User): Fox[Unit] =
+ if (oldEmail == email) {
+ Fox.successful(())
+ } else if (user._id == issuingUser._id) {
+ passwordOpt match {
+ case Some(password) =>
+ val credentials = Credentials(user._id.id, password)
+ Fox.fromFutureBox(
+ credentialsProvider
+ .authenticate(credentials)
+ .flatMap { loginInfo =>
+ userService.retrieve(loginInfo).map {
+ case Some(_) => Full(())
+ case None => Failure("error.noUser")
+ }
+ }
+ .recover {
+ case _: ProviderException =>
+ Failure("user.email.change.passwordWrong")
+ })
+ case None => Fox.failure("user.email.change.passwordWrong")
+ }
+ } else {
+ Fox.failure("notAllowed")
+ }
+
+ private def checkAdminOnlyUpdates(user: User, isActive: Boolean, isAdmin: Boolean, isDatasetManager: Boolean)(
+ issuingUser: User): Boolean =
+ if (isActive && user.isAdmin == isAdmin && isDatasetManager == user.isDatasetManager)
true
else issuingUser.isAdminOf(user)
@@ -224,16 +261,6 @@ class UserController @Inject()(userService: UserService,
} yield ()
} else Fox.successful(())
- private def checkSuperUserOnlyUpdates(user: User, oldEmail: String, email: String)(issuingUser: User)(
- implicit ctx: DBAccessContext): Fox[Unit] =
- if (oldEmail == email) Fox.successful(())
- else
- for {
- count <- userDAO.countIdentitiesForMultiUser(user._multiUser)
- issuingMultiUser <- multiUserDAO.findOne(issuingUser._multiUser)
- _ <- Fox.fromBool(count <= 1 || issuingMultiUser.isSuperUser) ?~> "user.email.onlySuperUserCanChange"
- } yield ()
-
private def preventZeroAdmins(user: User, isAdmin: Boolean) =
if (user.isAdmin && !isAdmin) {
for {
@@ -256,6 +283,7 @@ class UserController @Inject()(userService: UserService,
case (firstNameOpt,
lastNameOpt,
emailOpt,
+ passwordOpt,
isActiveOpt,
isAdminOpt,
isDatasetManagerOpt,
@@ -264,6 +292,7 @@ class UserController @Inject()(userService: UserService,
lastTaskTypeIdOpt) =>
for {
user <- userDAO.findOne(userId) ?~> "user.notFound" ~> NOT_FOUND
+ // properties that can be changed by team managers and admins only: experiences, team memberships
oldExperience <- userService.experiencesFor(user._id)
oldAssignedMemberships <- userService.teamMembershipsFor(user._id)
firstName = firstNameOpt.getOrElse(user.firstName)
@@ -276,13 +305,19 @@ class UserController @Inject()(userService: UserService,
assignedMemberships = assignedMembershipsOpt.getOrElse(oldAssignedMemberships)
experiences = experiencesOpt.getOrElse(oldExperience)
lastTaskTypeId = if (lastTaskTypeIdOpt.isEmpty) user.lastTaskTypeId.map(_.id) else lastTaskTypeIdOpt
- _ <- Fox.assertTrue(userService.isEditableBy(user, request.identity)) ?~> "notAllowed" ~> FORBIDDEN
- _ <- Fox.fromBool(checkAdminOnlyUpdates(user, isActive, isAdmin, isDatasetManager, oldEmail, email)(
- issuingUser)) ?~> "notAllowed" ~> FORBIDDEN
+ _ <- Fox
+ .runIf(user._id != issuingUser._id)(Fox.assertTrue(userService.isEditableBy(user, request.identity))) ?~> "notAllowed" ~> FORBIDDEN
+ _ <- checkTeamManagerOnlyUpdates(user,
+ experiences,
+ oldExperience,
+ assignedMemberships,
+ oldAssignedMemberships)(issuingUser) ?~> "notAllowed" ~> FORBIDDEN
+ _ <- Fox
+ .fromBool(checkAdminOnlyUpdates(user, isActive, isAdmin, isDatasetManager)(issuingUser)) ?~> "notAllowed" ~> FORBIDDEN
+ _ <- checkPasswordIfEmailChanged(user, passwordOpt, oldEmail, email)(issuingUser)
_ <- Fox.fromBool(checkNoSelfDeactivate(user, isActive)(issuingUser)) ?~> "user.noSelfDeactivate" ~> FORBIDDEN
_ <- checkNoDeactivateWithRemainingTask(user, isActive)
_ <- checkNoActivateBeyondLimit(user, isActive)
- _ <- checkSuperUserOnlyUpdates(user, oldEmail, email)(issuingUser)
_ <- preventZeroAdmins(user, isAdmin)
_ <- preventZeroOwners(user, isActive)
teams <- Fox.combined(assignedMemberships.map(t =>
diff --git a/app/models/user/EmailVerificationService.scala b/app/models/user/EmailVerificationService.scala
index c15f965ba5f..5e5c839a43c 100644
--- a/app/models/user/EmailVerificationService.scala
+++ b/app/models/user/EmailVerificationService.scala
@@ -78,7 +78,7 @@ class EmailVerificationService @Inject()(conf: WkConf,
): Fox[Boolean] =
for {
multiUser: MultiUser <- multiUserDAO.findOne(user._multiUser) ?~> "user.notFound"
- endOfGracePeriod: Instant = multiUser.created + conf.WebKnossos.User.EmailVerification.gracePeriod
+ endOfGracePeriod: Instant = multiUser.emailChangeDate + conf.WebKnossos.User.EmailVerification.gracePeriod
overGracePeriod = endOfGracePeriod.isPast
} yield !conf.WebKnossos.User.EmailVerification.required || multiUser.isEmailVerified || !overGracePeriod
}
diff --git a/app/models/user/MultiUser.scala b/app/models/user/MultiUser.scala
index 0eb731d16d3..9c7fcdc9798 100644
--- a/app/models/user/MultiUser.scala
+++ b/app/models/user/MultiUser.scala
@@ -26,6 +26,7 @@ case class MultiUser(
selectedTheme: Theme = Theme.auto,
created: Instant = Instant.now,
isEmailVerified: Boolean = false,
+ emailChangeDate: Instant = Instant.now,
isDeleted: Boolean = false
)
@@ -57,6 +58,7 @@ class MultiUserDAO @Inject()(sqlClient: SqlClient)(implicit ec: ExecutionContext
theme,
Instant.fromSql(r.created),
r.isemailverified,
+ Instant.fromSql(r.emailchangedate),
r.isdeleted
)
}
@@ -91,7 +93,8 @@ class MultiUserDAO @Inject()(sqlClient: SqlClient)(implicit ec: ExecutionContext
_ <- run(q"""UPDATE webknossos.multiusers
SET
email = $email,
- isEmailVerified = false
+ isEmailVerified = false,
+ emailChangeDate = NOW()
WHERE _id = $multiUserId""".asUpdate)
} yield ()
diff --git a/app/models/user/UserService.scala b/app/models/user/UserService.scala
index 28589717945..d148dec0ec2 100755
--- a/app/models/user/UserService.scala
+++ b/app/models/user/UserService.scala
@@ -198,7 +198,10 @@ class UserService @Inject()(conf: WkConf,
}
for {
oldEmail <- emailFor(user)
- _ <- Fox.runIf(oldEmail != email)(multiUserDAO.updateEmail(user._multiUser, email))
+ _ <- Fox.runIf(oldEmail != email)(for {
+ _ <- multiUserDAO.updateEmail(user._multiUser, email)
+ _ = logger.info(s"Email of MultiUser ${user._multiUser} changed from $oldEmail to $email")
+ } yield ())
_ <- userDAO.updateValues(user._id,
firstName,
lastName,
diff --git a/conf/application.conf b/conf/application.conf
index 951c4611ad8..0657f8f17a4 100644
--- a/conf/application.conf
+++ b/conf/application.conf
@@ -83,8 +83,8 @@ webKnossos {
inviteExpiry = 14 days
ssoKey = ""
emailVerification {
- activated = false
- required = false
+ activated = true
+ required = true
gracePeriod = 7 days # time period in which users do not need to verify their email address
linkExpiry = 30 days
}
diff --git a/conf/evolutions/135-extra-column-for-email-changed.sql b/conf/evolutions/135-extra-column-for-email-changed.sql
new file mode 100644
index 00000000000..ded6a5fa594
--- /dev/null
+++ b/conf/evolutions/135-extra-column-for-email-changed.sql
@@ -0,0 +1,24 @@
+START TRANSACTION;
+
+do $$ begin ASSERT (select schemaVersion from webknossos.releaseInformation) = 134, 'Previous schema version mismatch'; end; $$ LANGUAGE plpgsql;
+
+DROP VIEW IF EXISTS webknossos.userInfos;
+DROP VIEW IF EXISTS webknossos.multiUsers_;
+
+ALTER TABLE webknossos.multiUsers ADD COLUMN emailChangeDate TIMESTAMPTZ NOT NULL DEFAULT NOW();
+UPDATE webknossos.multiUsers SET emailChangeDate = created;
+
+CREATE VIEW webknossos.multiUsers_ AS SELECT * FROM webknossos.multiUsers 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 = 135;
+
+COMMIT TRANSACTION;
diff --git a/conf/evolutions/reversions/135-extra-column-for-email-changed.sql b/conf/evolutions/reversions/135-extra-column-for-email-changed.sql
new file mode 100644
index 00000000000..7f9217d9e64
--- /dev/null
+++ b/conf/evolutions/reversions/135-extra-column-for-email-changed.sql
@@ -0,0 +1,23 @@
+START TRANSACTION;
+
+do $$ begin ASSERT (select schemaVersion from webknossos.releaseInformation) = 135, 'Previous schema version mismatch'; end; $$ LANGUAGE plpgsql;
+
+DROP VIEW IF EXISTS webknossos.userInfos;
+DROP VIEW IF EXISTS webknossos.multiUsers_;
+
+ALTER TABLE webknossos.multiUsers DROP COLUMN emailChangeDate;
+
+CREATE VIEW webknossos.multiUsers_ AS SELECT * FROM webknossos.multiUsers 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 = 134;
+
+COMMIT TRANSACTION;
diff --git a/conf/messages b/conf/messages
index 60bbba21dbc..24e36dbcc34 100644
--- a/conf/messages
+++ b/conf/messages
@@ -59,6 +59,7 @@ user.email.verification.keyInvalid=Verification key is invalid.
user.email.verification.keyUsed=Verification key has already been used.
user.email.verification.emailDoesNotMatch=This verification key is associated with a different email address.
user.email.verification.linkExpired=The email verification link is expired.
+user.email.change.passwordWrong=The password you entered is incorrect.
user.firstName.invalid=Please check your first name for any special characters
user.lastName.invalid=Please check your last name for any special characters
user.configuration.invalid=Could not parse configuration
diff --git a/frontend/javascripts/admin/auth/change_email_view.tsx b/frontend/javascripts/admin/auth/change_email_view.tsx
new file mode 100644
index 00000000000..0909d0c77f8
--- /dev/null
+++ b/frontend/javascripts/admin/auth/change_email_view.tsx
@@ -0,0 +1,176 @@
+import { LockOutlined, MailOutlined } from "@ant-design/icons";
+import { updateUser } from "admin/rest_api";
+import { Alert, Button, Col, Form, Input, Row } from "antd";
+import { useWkSelector } from "libs/react_hooks";
+import Request from "libs/request";
+import Toast from "libs/toast";
+import { type RouteComponentProps, withRouter } from "react-router-dom";
+import { logoutUserAction } from "viewer/model/actions/user_actions";
+import { Store } from "viewer/singletons";
+import { handleResendVerificationEmail } from "./verify_email_view";
+
+const FormItem = Form.Item;
+
+const NEW_EMAIL_FIELD_KEY = "newEmail";
+const CONFIRM_NEW_EMAIL_FIELD_KEY = "confirmNewEmail";
+const PASSWORD_FIELD_KEY = "password";
+
+function ChangeEmailView() {
+ const [form] = Form.useForm();
+ const activeUser = useWkSelector((state) => state.activeUser);
+
+ async function changeEmail(newEmail: string, password: string) {
+ const newUser = Object.assign({}, activeUser, {
+ email: newEmail,
+ password,
+ });
+ return updateUser(newUser);
+ }
+
+ function onFinish() {
+ const newEmail = form.getFieldValue(NEW_EMAIL_FIELD_KEY);
+ const password = form.getFieldValue(PASSWORD_FIELD_KEY);
+ changeEmail(newEmail, password)
+ .then(async () => {
+ handleResendVerificationEmail();
+ Toast.success("Email address changed successfully. You will be logged out.");
+ await Request.receiveJSON("/api/auth/logout");
+ window.location.href = "/auth/login";
+ Store.dispatch(logoutUserAction());
+ })
+ .catch((error) => {
+ const errorMsg = "An unexpected error occurred while changing the email address.";
+ Toast.error(errorMsg);
+ console.error(errorMsg, error);
+ });
+ }
+
+ function checkEmailsAreMatching(value: string, otherEmailFieldKey: string[]) {
+ const otherFieldValue = form.getFieldValue(otherEmailFieldKey);
+
+ if (value && otherFieldValue) {
+ if (value !== otherFieldValue) {
+ return Promise.reject(new Error("Email addresses do not match"));
+ } else if (form.getFieldError(otherEmailFieldKey).length > 0) {
+ form.validateFields([otherEmailFieldKey]);
+ }
+ }
+
+ return Promise.resolve();
+ }
+
+ return (
+
+
+ Change Email
+
+
+
+
+ );
+}
+
+export default withRouter(ChangeEmailView);
diff --git a/frontend/javascripts/admin/user/user_list_view.tsx b/frontend/javascripts/admin/user/user_list_view.tsx
index e617fab0d7f..a91a2d4fd46 100644
--- a/frontend/javascripts/admin/user/user_list_view.tsx
+++ b/frontend/javascripts/admin/user/user_list_view.tsx
@@ -17,7 +17,7 @@ import { getEditableUsers, updateUser } from "admin/rest_api";
import { renderTeamRolesAndPermissionsForUser } from "admin/team/team_list_view";
import ExperienceModalView from "admin/user/experience_modal_view";
import PermissionsAndTeamsModalView from "admin/user/permissions_and_teams_modal_view";
-import { Alert, App, Button, Col, Input, Modal, Row, Spin, Table, Tag, Tooltip } from "antd";
+import { Alert, App, Button, Col, Input, Row, Spin, Table, Tag, Tooltip } from "antd";
import LinkButton from "components/link_button";
import dayjs from "dayjs";
import Persistence from "libs/persistence";
@@ -25,7 +25,6 @@ import Toast from "libs/toast";
import * as Utils from "libs/utils";
import { location } from "libs/window";
import _ from "lodash";
-import messages from "messages";
import React, { type Key, useEffect, useState } from "react";
import { connect } from "react-redux";
import type { RouteComponentProps } from "react-router-dom";
@@ -34,9 +33,6 @@ import type { APIOrganization, APITeamMembership, APIUser, ExperienceMap } from
import { enforceActiveOrganization } from "viewer/model/accessors/organization_accessors";
import { enforceActiveUser } from "viewer/model/accessors/user_accessor";
import type { WebknossosState } from "viewer/store";
-import EditableTextLabel from "viewer/view/components/editable_text_label";
-import { logoutUserAction } from "../../viewer/model/actions/user_actions";
-import Store from "../../viewer/store";
const { Column } = Table;
const { Search } = Input;
@@ -122,28 +118,6 @@ function UserListView({ activeUser, activeOrganization }: Props) {
activateUser(user, false);
}
- async function changeEmail(selectedUser: APIUser, newEmail: string) {
- const newUserPromises = users.map((user) => {
- if (selectedUser.id === user.id) {
- const newUser = Object.assign({}, user, {
- email: newEmail,
- });
- return updateUser(newUser);
- }
-
- return Promise.resolve(user);
- });
- Promise.all(newUserPromises).then(
- (newUsers) => {
- setUsers(newUsers);
- setSelectedUserIds([selectedUser.id]);
- Toast.success(messages["users.change_email_confirmation"]);
- if (activeUser.email === selectedUser.email) Store.dispatch(logoutUserAction());
- },
- () => {}, // Do nothing, change did not succeed
- );
- }
-
function handleUsersChange(updatedUsers: Array): void {
setUsers(updatedUsers);
setIsExperienceModalOpen(false);
@@ -423,33 +397,6 @@ function UserListView({ activeUser, activeOrganization }: Props) {
key="email"
width={320}
sorter={Utils.localeCompareBy((user) => user.email)}
- render={(__, user: APIUser) =>
- activeUser.isAdmin ? (
- {
- if (newEmail !== user.email) {
- Modal.confirm({
- title: messages["users.change_email_title"],
- content: messages["users.change_email"]({
- newEmail,
- }),
- onOk: () => changeEmail(user, newEmail),
- });
- }
- }}
- />
- ) : (
- user.email
- )
- }
/>
Change Password,
},
+ { key: "changeEmail", label: Change Email },
{ key: "token", label: Auth Token },
{
key: "theme",
diff --git a/frontend/javascripts/router.tsx b/frontend/javascripts/router.tsx
index 6176b7aa097..0cd29bb55fd 100644
--- a/frontend/javascripts/router.tsx
+++ b/frontend/javascripts/router.tsx
@@ -1,5 +1,6 @@
import AcceptInviteView from "admin/auth/accept_invite_view";
import AuthTokenView from "admin/auth/auth_token_view";
+import ChangeEmailView from "admin/auth/change_email_view";
import ChangePasswordView from "admin/auth/change_password_view";
import FinishResetPasswordView from "admin/auth/finish_reset_password_view";
import LoginView from "admin/auth/login_view";
@@ -644,6 +645,11 @@ class ReactRouter extends React.Component {
path="/auth/token"
component={AuthTokenView}
/>
+