Skip to content
Open
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
0ce4e2a
WIP: Set Teams and Roles for Invites
fm3 Nov 10, 2025
2ce8f3d
changelog
fm3 Nov 10, 2025
4a62317
add isTeamManager to invite, use it
fm3 Nov 10, 2025
f37e3d2
Merge branch 'master' into invite-roles
fm3 Nov 11, 2025
d51d06b
bump schema version after merge
fm3 Nov 11, 2025
fb28e66
Merge branch 'master' into invite-roles
fm3 Nov 11, 2025
e5b4e99
read teamMemberships from param
fm3 Nov 11, 2025
a41c463
save invite team roles
fm3 Nov 11, 2025
1a4406f
Merge branch 'master' into invite-roles
fm3 Nov 12, 2025
2d66253
use team memberships from invite when user joins
fm3 Nov 12, 2025
72e5980
wip FormattedId
fm3 Nov 12, 2025
eed6c8e
Revert "wip FormattedId"
fm3 Nov 12, 2025
535a981
refactor permission and teams modal view
knollengewaechs Nov 14, 2025
e91eb6f
include user roles and team permissions in invite modal; style is WIP
knollengewaechs Nov 14, 2025
04cdb08
use divider to structure modal
knollengewaechs Nov 19, 2025
41b857f
use bold h5 for subtitles
knollengewaechs Nov 19, 2025
10c6e01
use divider and h5
knollengewaechs Nov 19, 2025
3eafe50
improve invite and permissions modal, set default team as default for…
knollengewaechs Nov 20, 2025
55c6a01
set default team
knollengewaechs Nov 20, 2025
476a0ca
prevent unwanted changes to user permissions
knollengewaechs Nov 20, 2025
77c4433
Merge branch 'master' into invite-roles
knollengewaechs Nov 20, 2025
585af07
implement coderabbit feedback (use result of isTeamManagerOrAdminOf, …
fm3 Nov 20, 2025
66935db
remove double value and defaultValue
knollengewaechs Nov 20, 2025
179e243
Merge branch 'master' into invite-roles
fm3 Nov 25, 2025
1a6db3c
add foreign key constraints
fm3 Nov 25, 2025
19836d8
rename foreign key constraint
fm3 Nov 25, 2025
ea2c5de
do not add organizationTeamMembership if the invite already contains it
fm3 Dec 1, 2025
78825d0
Merge branch 'master' into invite-roles
fm3 Dec 1, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 52 additions & 19 deletions app/controllers/AuthenticationController.scala
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import models.organization.{Organization, OrganizationDAO, OrganizationService}
import models.user._
import com.scalableminds.util.tools.{Box, Empty, Failure, Full}
import com.scalableminds.util.tools.Box.tryo
import models.team.TeamMembership
import org.apache.commons.codec.binary.Base64
import org.apache.commons.codec.digest.{HmacAlgorithms, HmacUtils}
import org.apache.pekko.actor.ActorSystem
Expand Down Expand Up @@ -290,15 +291,21 @@ class AuthenticationController @Inject()(
isEmailVerified: Boolean = false): Fox[User] = {
val passwordInfo: PasswordInfo = userService.getPasswordInfo(password)
for {
user <- userService.insert(organization._id,
email,
firstName,
lastName,
autoActivate,
passwordInfo,
isAdmin = false,
isOrganizationOwner = false,
isEmailVerified = isEmailVerified) ?~> "user.creation.failed"
teamMemberships <- userService.initialTeamMemberships(organization._id,
inviteIdOpt = inviteBox.map(_._id).toOption)
user <- userService.insert(
organization._id,
email,
firstName,
lastName,
autoActivate,
passwordInfo,
isAdmin = inviteBox.map(_.isAdmin).getOrElse(false),
isDatasetManager = inviteBox.map(_.isDatasetManager).getOrElse(false),
isOrganizationOwner = false,
isEmailVerified = isEmailVerified,
teamMemberships = teamMemberships
) ?~> "user.creation.failed"
multiUser <- multiUserDAO.findOne(user._multiUser)(GlobalAccessContext)
_ = analyticsService.track(SignupEvent(user, inviteBox.isDefined))
_ <- Fox.runIf(inviteBox.isDefined)(Fox.runOptional(inviteBox.toOption)(i =>
Expand Down Expand Up @@ -414,10 +421,15 @@ class AuthenticationController @Inject()(
alreadyPayingOrgaForMultiUser <- userDAO.findPayingOrgaIdForMultiUser(requestingMultiUser._id)
_ <- Fox.runIf(!requestingMultiUser.isSuperUser && alreadyPayingOrgaForMultiUser.isEmpty)(organizationService
.assertUsersCanBeAdded(organization._id)(GlobalAccessContext, ec)) ?~> "organization.users.userLimitReached"
_ <- userService.joinOrganization(request.identity,
organization._id,
autoActivate = invite.autoActivate,
isAdmin = false)
teamMemberships <- userService.initialTeamMemberships(organization._id, Some(invite._id))
_ <- userService.joinOrganization(
request.identity,
organization._id,
autoActivate = invite.autoActivate,
isAdmin = invite.isAdmin,
isDatasetManager = invite.isDatasetManager,
teamMemberships = teamMemberships
)
_ = analyticsService.track(JoinOrganizationEvent(request.identity, organization))
userEmail <- userService.emailFor(request.identity)
newUserEmailRecipient <- organizationService.newUserMailRecipient(organization)
Expand All @@ -434,13 +446,28 @@ class AuthenticationController @Inject()(
def sendInvites: Action[InviteParameters] = sil.SecuredAction.async(validateJson[InviteParameters]) {
implicit request =>
for {
_ <- Fox.serialCombined(request.body.recipients)(recipient =>
inviteService.inviteOneRecipient(recipient, request.identity, request.body.autoActivate))
_ <- validateInvitePermissions(request.identity, request.body)
_ <- Fox.serialCombined(request.body.recipients)(
recipient =>
inviteService.inviteOneRecipient(recipient,
request.identity,
request.body.autoActivate,
request.body.isAdmin,
request.body.isDatasetManager,
request.body.teamMemberships))
_ = analyticsService.track(InviteEvent(request.identity, request.body.recipients.length))
_ = mailchimpClient.tagUser(request.identity, MailchimpTag.HasInvitedTeam)
} yield Ok
}

private def validateInvitePermissions(requestingUser: User, inviteParameters: InviteParameters): Fox[Unit] =
for {
_ <- Fox.serialCombined(inviteParameters.teamMemberships)(teamMembership =>
userService.isTeamManagerOrAdminOf(requestingUser, teamMembership.teamId)) ?~> "Can only send invites with team roles for teams you manage."
_ <- Fox.runIf(inviteParameters.isDatasetManager || inviteParameters.isAdmin)(Fox.fromBool(
requestingUser.isAdmin)) ?~> "Only admins can send invites that promote new users to admin or dataset manager."
} yield ()

// If a user has forgotten their password
def handleStartResetPassword: Action[AnyContent] = Action.async { implicit request =>
emailForm
Expand Down Expand Up @@ -852,6 +879,7 @@ class AuthenticationController @Inject()(
organization <- organizationService.createOrganization(
Option(signUpData.organization).filter(_.trim.nonEmpty),
signUpData.organizationName) ?~> "organization.create.failed"
teamMemberships <- userService.initialTeamMemberships(organization._id, inviteIdOpt = None)
user <- userService.insert(
organization._id,
email,
Expand All @@ -860,8 +888,10 @@ class AuthenticationController @Inject()(
isActive = true,
passwordHasher.hash(signUpData.password),
isAdmin = true,
isDatasetManager = false,
isOrganizationOwner = true,
isEmailVerified = false
isEmailVerified = false,
teamMemberships = teamMemberships
) ?~> "user.creation.failed"
_ = analyticsService.track(SignupEvent(user, hadInvite = false))
multiUser <- multiUserDAO.findOne(user._multiUser)
Expand Down Expand Up @@ -972,12 +1002,15 @@ class AuthenticationController @Inject()(
}

case class InviteParameters(
recipients: List[String],
autoActivate: Boolean
recipients: Seq[String],
autoActivate: Boolean,
isAdmin: Boolean,
isDatasetManager: Boolean,
teamMemberships: Seq[TeamMembership]
)

object InviteParameters {
implicit val jsonFormat: Format[InviteParameters] = Json.format[InviteParameters]
implicit val jsonReads: Reads[InviteParameters] = Json.reads[InviteParameters]
}

trait AuthForms {
Expand Down
13 changes: 11 additions & 2 deletions app/controllers/OrganizationController.scala
Original file line number Diff line number Diff line change
Expand Up @@ -77,11 +77,14 @@ class OrganizationController @Inject()(
owner <- multiUserDAO.findOneByEmail(request.body.ownerEmail) ?~> "user.notFound"
org <- organizationService.createOrganization(request.body.organization, request.body.organizationName)
user <- userDAO.findFirstByMultiUser(owner._id)
teamMemberships <- userService.initialTeamMemberships(org._id, inviteIdOpt = None)
_ <- userService.joinOrganization(user,
org._id,
autoActivate = true,
isAdmin = true,
isOrganizationOwner = true)
isDatasetManager = false,
isOrganizationOwner = true,
teamMemberships = teamMemberships)
_ <- freeCreditTransactionService.handOutMonthlyFreeCredits()
} yield Ok(org._id)
}
Expand Down Expand Up @@ -179,7 +182,13 @@ class OrganizationController @Inject()(
multiUser <- multiUserDAO.findOneByEmail(request.body)
organization <- organizationDAO.findOne(organizationId) ?~> Messages("organization.notFound", organizationId) ~> NOT_FOUND
user <- userDAO.findFirstByMultiUser(multiUser._id)
user <- userService.joinOrganization(user, organization._id, autoActivate = true, isAdmin = false)
teamMemberships <- userService.initialTeamMemberships(organization._id, inviteIdOpt = None)
user <- userService.joinOrganization(user,
organization._id,
autoActivate = true,
isAdmin = false,
isDatasetManager = false,
teamMemberships = teamMemberships)
} yield Ok(user._id.toString)
}

Expand Down
9 changes: 4 additions & 5 deletions app/controllers/UserController.scala
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ class UserController @Inject()(userService: UserService,
credentialsProvider: CredentialsProvider,
organizationService: OrganizationService,
annotationDAO: AnnotationDAO,
teamMembershipService: TeamMembershipService,
annotationService: AnnotationService,
teamDAO: TeamDAO,
sil: Silhouette[WkEnv])(implicit ec: ExecutionContext, bodyParsers: PlayBodyParsers)
Expand Down Expand Up @@ -183,7 +182,7 @@ class UserController @Inject()(userService: UserService,
(__ \ "isActive").readNullable[Boolean] and
(__ \ "isAdmin").readNullable[Boolean] and
(__ \ "isDatasetManager").readNullable[Boolean] and
(__ \ "teams").readNullable[List[TeamMembership]](Reads.list(teamMembershipService.publicReads())) and
(__ \ "teams").readNullable[List[TeamMembership]] and
(__ \ "experiences").readNullable[Map[String, Int]] and
(__ \ "lastTaskTypeId").readNullable[String]).tupled

Expand All @@ -192,9 +191,9 @@ class UserController @Inject()(userService: UserService,
Fox.combined(teams.map {
case (TeamMembership(_, true), team) =>
for {
_ <- Fox.fromBool(team.couldBeAdministratedBy(user)) ?~> Messages("team.admin.notPossibleBy",
team.name,
user.name) ~> FORBIDDEN
_ <- Fox.fromBool(user._organization == team._organization) ?~> Messages("team.admin.notPossibleBy",
team.name,
user.name) ~> FORBIDDEN
} yield ()
case (_, _) =>
Fox.successful(())
Expand Down
7 changes: 1 addition & 6 deletions app/models/team/Team.scala
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,7 @@ case class Team(
isOrganizationTeam: Boolean = false,
created: Instant = Instant.now,
isDeleted: Boolean = false
) extends FoxImplicits {

def couldBeAdministratedBy(user: User): Boolean =
user._organization == this._organization

}
)

class TeamService @Inject()(organizationDAO: OrganizationDAO,
annotationDAO: AnnotationDAO,
Expand Down
13 changes: 7 additions & 6 deletions app/models/team/TeamMembership.scala
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,21 @@ import play.api.libs.functional.syntax._
import play.api.libs.json._

case class TeamMembership(teamId: ObjectId, isTeamManager: Boolean)
object TeamMembership {
implicit val jsonReads: Reads[TeamMembership] = {
((__ \ "id").read[ObjectId] and
(__ \ "isTeamManager").read[Boolean])((id, isTeamManager) => TeamMembership(id, isTeamManager))
}
}

class TeamMembershipService @Inject()(teamDAO: TeamDAO) {
def publicWrites(teamMembership: TeamMembership)(implicit ctx: DBAccessContext): Fox[JsObject] =
for {
team <- teamDAO.findOne(teamMembership.teamId)
} yield {
} yield
Json.obj(
"id" -> teamMembership.teamId,
"name" -> team.name,
"isTeamManager" -> teamMembership.isTeamManager
)
}

def publicReads(): Reads[TeamMembership] =
((__ \ "id").read[ObjectId] and
(__ \ "isTeamManager").read[Boolean])((id, isTeamManager) => TeamMembership(id, isTeamManager))
}
48 changes: 42 additions & 6 deletions app/models/user/Invite.scala
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import mail.{DefaultMails, Send}

import javax.inject.Inject
import models.organization.OrganizationDAO
import models.team.TeamMembership
import security.RandomIDGenerator
import slick.jdbc.PostgresProfile.api._
import slick.lifted.Rep
Expand All @@ -24,6 +25,8 @@ case class Invite(
tokenValue: String,
_organization: String,
autoActivate: Boolean,
isAdmin: Boolean,
isDatasetManager: Boolean,
expirationDateTime: Instant,
created: Instant = Instant.now,
isDeleted: Boolean = false
Expand All @@ -40,15 +43,23 @@ class InviteService @Inject()(conf: WkConf,
private lazy val Mailer =
actorSystem.actorSelection("/user/mailActor")

def inviteOneRecipient(recipient: String, sender: User, autoActivate: Boolean)(
implicit ctx: DBAccessContext): Fox[Unit] =
def inviteOneRecipient(recipient: String,
sender: User,
autoActivate: Boolean,
isAdmin: Boolean,
isDatasetManager: Boolean,
teamMemberships: Seq[TeamMembership])(implicit ctx: DBAccessContext): Fox[Unit] =
for {
invite <- Fox.fromFuture(generateInvite(sender._organization, autoActivate))
invite <- Fox.fromFuture(generateInvite(sender._organization, autoActivate, isAdmin, isDatasetManager))
_ <- inviteDAO.insertOne(invite)
_ <- inviteDAO.insertTeamMemberships(invite._id, teamMemberships)
_ <- sendInviteMail(recipient, sender, invite)
} yield ()

private def generateInvite(organizationId: String, autoActivate: Boolean): Future[Invite] =
private def generateInvite(organizationId: String,
autoActivate: Boolean,
isAdmin: Boolean,
isDatasetManager: Boolean): Future[Invite] =
for {
tokenValue <- tokenValueGenerator.generate
} yield
Expand All @@ -57,6 +68,8 @@ class InviteService @Inject()(conf: WkConf,
tokenValue,
organizationId,
autoActivate,
isAdmin,
isDatasetManager,
Instant.in(conf.WebKnossos.User.inviteExpiry)
)

Expand Down Expand Up @@ -98,6 +111,8 @@ class InviteDAO @Inject()(sqlClient: SqlClient)(implicit ec: ExecutionContext)
r.tokenvalue,
r._Organization,
r.autoactivate,
r.isadmin,
r.isdatasetmanager,
Instant.fromSql(r.expirationdatetime),
Instant.fromSql(r.created),
r.isdeleted
Expand All @@ -112,12 +127,33 @@ class InviteDAO @Inject()(sqlClient: SqlClient)(implicit ec: ExecutionContext)

def insertOne(i: Invite): Fox[Unit] =
for {
_ <- run(
q"""INSERT INTO webknossos.invites(_id, tokenValue, _organization, autoActivate, expirationDateTime, created, isDeleted)
_ <- run(q"""INSERT INTO webknossos.invites(
_id, tokenValue, _organization, autoActivate,
isAdmin, isDatasetManager,
expirationDateTime, created, isDeleted)
VALUES(${i._id}, ${i.tokenValue}, ${i._organization}, ${i.autoActivate},
${i.isAdmin}, ${i.isDatasetManager},
${i.expirationDateTime}, ${i.created}, ${i.isDeleted})""".asUpdate)
} yield ()

private def insertTeamMembershipQuery(inviteId: ObjectId, teamMembership: TeamMembership) =
q"INSERT INTO webknossos.invite_team_roles(_invite, _team, isTeamManager) VALUES($inviteId, ${teamMembership.teamId}, ${teamMembership.isTeamManager})".asUpdate

def insertTeamMemberships(inviteId: ObjectId, teamMemberships: Seq[TeamMembership]): Fox[Unit] = {
val insertQueries = teamMemberships.map(insertTeamMembershipQuery(inviteId, _))
for {
_ <- run(DBIO.sequence(insertQueries).transactionally)
} yield ()
}

def findTeamMembershipsFor(inviteId: ObjectId): Fox[Seq[TeamMembership]] =
for {
rows <- run(
q"SELECT _team, isTeamManager FROM WEBKNOSSOS.invite_team_roles WHERE _invite = $inviteId"
.as[(ObjectId, Boolean)])
parsed = rows.map(row => TeamMembership(row._1, row._2))
} yield parsed

def deleteAllExpired(): Fox[Unit] = {
val query = for {
row <- collection if notdel(row) && row.expirationdatetime <= Instant.now.toSql
Expand Down
2 changes: 1 addition & 1 deletion app/models/user/User.scala
Original file line number Diff line number Diff line change
Expand Up @@ -558,7 +558,7 @@ class UserDAO @Inject()(sqlClient: SqlClient)(implicit ec: ExecutionContext)
private def insertTeamMembershipQuery(userId: ObjectId, teamMembership: TeamMembership) =
q"INSERT INTO webknossos.user_team_roles(_user, _team, isTeamManager) VALUES($userId, ${teamMembership.teamId}, ${teamMembership.isTeamManager})".asUpdate

def updateTeamMembershipsForUser(userId: ObjectId, teamMemberships: List[TeamMembership])(
def updateTeamMembershipsForUser(userId: ObjectId, teamMemberships: Seq[TeamMembership])(
implicit ctx: DBAccessContext): Fox[Unit] = {
val clearQuery = q"DELETE FROM webknossos.user_team_roles WHERE _user = $userId".asUpdate
val insertQueries = teamMemberships.map(insertTeamMembershipQuery(userId, _))
Expand Down
Loading