Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions changelog.d/0-release-notes/WPB-10658
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
With this release it will be possible to invite personal users to teams. In `brig`'s config, `emailSMS.team.tExistingUserInvitationUrl` is required to be set to a value that points to the correct teams/account page.
If `emailSMS.team` is not defined at all in the current environment, the value of `externalUrls.teamSettings` (or, if not present, `externalUrls.nginz`) will be used to construct the correct url, and no configuration change is necessary.
1 change: 1 addition & 0 deletions changelog.d/1-api-changes/WPB-10658
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A new endpoint `POST /teams/invitations/accept` allows a non-team user to accept an invitation to join a team
1 change: 1 addition & 0 deletions changelog.d/2-features/WPB-10658
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Allow an existing non-team user to migrate to a team
3 changes: 3 additions & 0 deletions charts/brig/templates/configmap.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -179,14 +179,17 @@ data:
team:
{{- if .emailSMS.team }}
tInvitationUrl: {{ .emailSMS.team.tInvitationUrl }}
tExistingUserInvitationUrl: {{ .emailSMS.team.tExistingUserInvitationUrl }}
tActivationUrl: {{ .emailSMS.team.tActivationUrl }}
tCreatorWelcomeUrl: {{ .emailSMS.team.tCreatorWelcomeUrl }}
tMemberWelcomeUrl: {{ .emailSMS.team.tMemberWelcomeUrl }}
{{- else }}
{{- if .externalUrls.teamSettings }}
tInvitationUrl: {{ .externalUrls.teamSettings }}/join/?team-code=${code}
tExistingUserInvitationUrl: {{ .externalUrls.teamSettings }}/accept-invitation/?team-code=${code}
{{- else }}
tInvitationUrl: {{ .externalUrls.nginz }}/register?team=${team}&team_code=${code}
tExistingUserInvitationUrl: {{ .externalUrls.nginz }}/accept-invitation/?team-code=${code}
{{- end }}
tActivationUrl: {{ .externalUrls.nginz }}/register?team=${team}&team_code=${code}
tCreatorWelcomeUrl: {{ .externalUrls.teamCreatorWelcome }}
Expand Down
3 changes: 3 additions & 0 deletions charts/nginz/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,9 @@ nginx_conf:
envs:
- all
disable_zauth: true
- path: /teams/invitations/accept$
envs:
- all
- path: /i/teams/invitation-code
envs:
- staging
Expand Down
1 change: 1 addition & 0 deletions integration/integration.cabal
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ library
Test.Services
Test.Spar
Test.Swagger
Test.Teams
Test.TeamSettings
Test.User
Test.Version
Expand Down
18 changes: 15 additions & 3 deletions integration/test/API/Brig.hs
Original file line number Diff line number Diff line change
Expand Up @@ -434,11 +434,12 @@ putUserSupportedProtocols user ps = do
submit "PUT" (req & addJSONObject ["supported_protocols" .= ps])

data PostInvitation = PostInvitation
{ email :: Maybe String
{ email :: Maybe String,
role :: Maybe String
}

instance Default PostInvitation where
def = PostInvitation Nothing
def = PostInvitation Nothing Nothing

postInvitation ::
(HasCallStack, MakesValue user) =>
Expand All @@ -452,7 +453,7 @@ postInvitation user inv = do
joinHttpPath ["teams", tid, "invitations"]
email <- maybe randomEmail pure inv.email
submit "POST" $
req & addJSONObject ["email" .= email]
req & addJSONObject (["email" .= email] <> ["role" .= r | r <- toList inv.role])

getApiVersions :: (HasCallStack) => App Response
getApiVersions = do
Expand Down Expand Up @@ -783,3 +784,14 @@ activate domain key code = do
submit "GET" $
req
& addQueryParams [("key", key), ("code", code)]

acceptTeamInvitation :: (HasCallStack, MakesValue user) => user -> String -> Maybe String -> App Response
acceptTeamInvitation user code mPw = do
req <- baseRequest user Brig Versioned $ joinHttpPath ["teams", "invitations", "accept"]
submit "POST" $ req & addJSONObject (["code" .= code] <> maybeToList (((.=) "password") <$> mPw))

-- | https://staging-nginz-https.zinfra.io/v6/api/swagger-ui/#/default/get_teams__tid__invitations
listInvitations :: (HasCallStack, MakesValue user) => user -> String -> App Response
listInvitations user tid = do
req <- baseRequest user Brig Versioned $ joinHttpPath ["teams", tid, "invitations"]
submit "GET" req
3 changes: 3 additions & 0 deletions integration/test/Notifications.hs
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,9 @@ isUserActivateNotif = notifTypeIsEqual "user.activate"
isUserClientAddNotif :: (MakesValue a) => a -> App Bool
isUserClientAddNotif = notifTypeIsEqual "user.client-add"

isUserUpdatedNotif :: (MakesValue a) => a -> App Bool
isUserUpdatedNotif = notifTypeIsEqual "user.update"

isUserClientRemoveNotif :: (MakesValue a) => a -> App Bool
isUserClientRemoveNotif = notifTypeIsEqual "user.client-remove"

Expand Down
34 changes: 11 additions & 23 deletions integration/test/SetupHelpers.hs
Original file line number Diff line number Diff line change
Expand Up @@ -60,30 +60,18 @@ createTeamMemberWithRole ::
String ->
String ->
App Value
createTeamMemberWithRole inviter tid role = do
createTeamMemberWithRole inviter _ role = do
newUserEmail <- randomEmail
let invitationJSON = ["role" .= role, "email" .= newUserEmail]
invitationReq <-
baseRequest inviter Brig Versioned $
joinHttpPath ["teams", tid, "invitations"]
invitation <- getJSON 201 =<< submit "POST" (addJSONObject invitationJSON invitationReq)
invitationId <- objId invitation
invitationCodeReq <-
rawBaseRequest inviter Brig Unversioned "/i/teams/invitation-code"
<&> addQueryParams [("team", tid), ("invitation_id", invitationId)]
invitationCode <- bindResponse (submit "GET" invitationCodeReq) $ \res -> do
res.status `shouldMatchInt` 200
res.json %. "code" & asString
let registerJSON =
[ "name" .= newUserEmail,
"email" .= newUserEmail,
"password" .= defPassword,
"team_code" .= invitationCode
]
registerReq <-
rawBaseRequest inviter Brig Versioned "/register"
<&> addJSONObject registerJSON
getJSON 201 =<< submit "POST" registerReq
invitation <- postInvitation inviter (PostInvitation (Just newUserEmail) (Just role)) >>= getJSON 201
invitationCode <- getInvitationCode inviter invitation >>= getJSON 200 >>= (%. "code") & asString
let body =
AddUser
{ name = Just newUserEmail,
email = Just newUserEmail,
password = Just defPassword,
teamCode = Just invitationCode
}
addUser inviter body >>= getJSON 201

connectTwoUsers ::
( HasCallStack,
Expand Down
166 changes: 166 additions & 0 deletions integration/test/Test/Teams.hs
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
-- This file is part of the Wire Server implementation.
--
-- Copyright (C) 2024 Wire Swiss GmbH <opensource@wire.com>
--
-- This program is free software: you can redistribute it and/or modify it under
-- the terms of the GNU Affero General Public License as published by the Free
-- Software Foundation, either version 3 of the License, or (at your option) any
-- later version.
--
-- This program is distributed in the hope that it will be useful, but WITHOUT
-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
-- details.
--
-- You should have received a copy of the GNU Affero General Public License along
-- with this program. If not, see <https://www.gnu.org/licenses/>.

module Test.Teams where

import API.Brig
import API.BrigInternal (createUser, getInvitationCode, refreshIndex)
import API.Common
import API.Galley (getTeamMembers)
import API.GalleyInternal (setTeamFeatureStatus)
import Control.Monad.Codensity (Codensity (runCodensity))
import Control.Monad.Extra (findM)
import Control.Monad.Reader (asks)
import Notifications (isUserUpdatedNotif)
import SetupHelpers
import Testlib.JSON
import Testlib.Prelude
import Testlib.ResourcePool (acquireResources)

testInvitePersonalUserToTeam :: (HasCallStack) => App ()
testInvitePersonalUserToTeam = do
resourcePool <- asks (.resourcePool)
runCodensity (acquireResources 1 resourcePool) $ \[testBackend] -> do
let domain = testBackend.berDomain
(owner, tid, tm) <- runCodensity (startDynamicBackend testBackend def) $ \_ -> do
(owner, tid, tm : _) <- createTeam domain 2
pure (owner, tid, tm)

runCodensity
( startDynamicBackend
testBackend
(def {galleyCfg = setField "settings.exposeInvitationURLsTeamAllowlist" [tid]})
)
$ \_ -> do
bindResponse (listInvitations owner tid) $ \resp -> do
resp.status `shouldMatchInt` 200
resp.json %. "invitations" `shouldMatch` ([] :: [()])
ownerId <- owner %. "id" & asString
setTeamFeatureStatus domain tid "exposeInvitationURLsToTeamAdmin" "enabled" >>= assertSuccess
user <- createUser domain def >>= getJSON 201
uid <- user %. "id" >>= asString
email <- user %. "email" >>= asString
inv <- postInvitation owner (PostInvitation (Just email) Nothing) >>= getJSON 201
checkListInvitations owner tid email
code <- getInvitationCode owner inv >>= getJSON 200 >>= (%. "code") & asString
inv %. "url" & asString >>= assertUrlContainsCode code
acceptTeamInvitation user code Nothing >>= assertStatus 400
acceptTeamInvitation user code (Just "wrong-password") >>= assertStatus 403
void $ withWebSockets [user] $ \wss -> do
acceptTeamInvitation user code (Just defPassword) >>= assertSuccess
for wss $ \ws -> do
n <- awaitMatch isUserUpdatedNotif ws
n %. "payload.0.user.team" `shouldMatch` tid
bindResponse (getSelf user) $ \resp -> do
resp.status `shouldMatchInt` 200
resp.json %. "team" `shouldMatch` tid
-- a team member can now find the former personal user in the team
bindResponse (getTeamMembers tm tid) $ \resp -> do
resp.status `shouldMatchInt` 200
members <- resp.json %. "members" >>= asList
ids <- for members ((%. "user") >=> asString)
ids `shouldContain` [uid]
-- the former personal user can now see other team members
bindResponse (getTeamMembers user tid) $ \resp -> do
resp.status `shouldMatchInt` 200
members <- resp.json %. "members" >>= asList
ids <- for members ((%. "user") >=> asString)
tmId <- tm %. "id" & asString
ids `shouldContain` [ownerId]
ids `shouldContain` [tmId]
-- the former personal user can now search for the owner
bindResponse (searchContacts user (owner %. "name") domain) $ \resp -> do
resp.status `shouldMatchInt` 200
documents <- resp.json %. "documents" >>= asList
ids <- for documents ((%. "id") >=> asString)
ids `shouldContain` [ownerId]
refreshIndex domain
-- a team member can now search for the former personal user
bindResponse (searchContacts tm (user %. "name") domain) $ \resp -> do
resp.status `shouldMatchInt` 200
document <- resp.json %. "documents" >>= asList >>= assertOne
document %. "id" `shouldMatch` uid
document %. "team" `shouldMatch` tid
where
checkListInvitations :: Value -> String -> String -> App ()
checkListInvitations owner tid email = do
newUserEmail <- randomEmail
void $ postInvitation owner (PostInvitation (Just newUserEmail) Nothing) >>= assertSuccess
bindResponse (listInvitations owner tid) $ \resp -> do
resp.status `shouldMatchInt` 200
invitations <- resp.json %. "invitations" >>= asList

-- personal user invitations have a different invitation URL than non-existing user invitations
newUserInv <- invitations & findM (\i -> (i %. "email" >>= asString) <&> (== newUserEmail))
newUserInvUrl <- newUserInv %. "url" & asString
newUserInvUrl `shouldContainString` "/register"

personalUserInv <- invitations & findM (\i -> (i %. "email" >>= asString) <&> (== email))
personalUserInvUrl <- personalUserInv %. "url" & asString
personalUserInvUrl `shouldContainString` "/accept-invitation"

assertUrlContainsCode :: (HasCallStack) => String -> String -> App ()
assertUrlContainsCode code url = do
queryParam <- url & asString <&> getQueryParam "team-code"
queryParam `shouldMatch` Just (Just code)

testInvitePersonalUserToTeamMultipleInvitations :: (HasCallStack) => App ()
testInvitePersonalUserToTeamMultipleInvitations = do
(owner, tid, _) <- createTeam OwnDomain 0
(owner2, _, _) <- createTeam OwnDomain 0
user <- createUser OwnDomain def >>= getJSON 201
email <- user %. "email" >>= asString
inv <- postInvitation owner (PostInvitation (Just email) Nothing) >>= getJSON 201
inv2 <- postInvitation owner2 (PostInvitation (Just email) Nothing) >>= getJSON 201
code <- getInvitationCode owner inv >>= getJSON 200 >>= (%. "code") & asString
acceptTeamInvitation user code (Just defPassword) >>= assertSuccess
bindResponse (getSelf user) $ \resp -> do
resp.status `shouldMatchInt` 200
resp.json %. "team" `shouldMatch` tid
code2 <- getInvitationCode owner2 inv2 >>= getJSON 200 >>= (%. "code") & asString
bindResponse (acceptTeamInvitation user code2 (Just defPassword)) $ \resp -> do
resp.status `shouldMatchInt` 403
resp.json %. "label" `shouldMatch` "cannot-join-multiple-teams"
bindResponse (getSelf user) $ \resp -> do
resp.status `shouldMatchInt` 200
resp.json %. "team" `shouldMatch` tid
acceptTeamInvitation user code (Just defPassword) >>= assertStatus 400

testInvitationTypesAreDistinct :: (HasCallStack) => App ()
testInvitationTypesAreDistinct = do
-- We are only testing one direction because the other is not possible
-- because the non-existing user cannot have a valid session
(owner, _, _) <- createTeam OwnDomain 0
user <- createUser OwnDomain def >>= getJSON 201
email <- user %. "email" >>= asString
inv <- postInvitation owner (PostInvitation (Just email) Nothing) >>= getJSON 201
code <- getInvitationCode owner inv >>= getJSON 200 >>= (%. "code") & asString
let body =
AddUser
{ name = Just email,
email = Just email,
password = Just defPassword,
teamCode = Just code
}
addUser OwnDomain body >>= assertStatus 409

testTeamUserCannotBeInvited :: (HasCallStack) => App ()
testTeamUserCannotBeInvited = do
(_, _, tm : _) <- createTeam OwnDomain 2
(owner2, _, _) <- createTeam OwnDomain 0
email <- tm %. "email" >>= asString
postInvitation owner2 (PostInvitation (Just email) Nothing) >>= assertStatus 409
11 changes: 11 additions & 0 deletions integration/test/Testlib/HTTP.hs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import qualified Control.Exception as E
import Control.Monad.Reader
import qualified Data.Aeson as Aeson
import qualified Data.Aeson.Types as Aeson
import Data.Bifunctor (Bifunctor (bimap))
import Data.ByteString (ByteString)
import qualified Data.ByteString.Char8 as C8
import qualified Data.ByteString.Lazy as L
Expand All @@ -23,6 +24,7 @@ import GHC.Stack
import qualified Network.HTTP.Client as HTTP
import Network.HTTP.Types (hLocation)
import qualified Network.HTTP.Types as HTTP
import Network.HTTP.Types.URI (parseQuery)
import Network.URI (URI (..), URIAuth (..), parseURI)
import Testlib.Assertions
import Testlib.Env
Expand Down Expand Up @@ -221,3 +223,12 @@ locationHeader = findHeader hLocation

findHeader :: HTTP.HeaderName -> Response -> Maybe (HTTP.HeaderName, ByteString)
findHeader name resp = find (\(name', _) -> name == name') resp.headers

getQueryParam :: String -> String -> Maybe (Maybe String)
getQueryParam name url =
parseURI url
>>= lookup name
. fmap (bimap cs ((<$>) cs))
. parseQuery
. cs
. uriQuery
3 changes: 3 additions & 0 deletions libs/wire-api/src/Wire/API/Error/Brig.hs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ data BrigError
| ChangePasswordMustDiffer
| PasswordAuthenticationFailed
| TooManyTeamInvitations
| CannotJoinMultipleTeams
| InsufficientTeamPermissions
| KeyPackageDecodingError
| InvalidKeyPackageRef
Expand Down Expand Up @@ -251,6 +252,8 @@ type instance MapError 'PasswordAuthenticationFailed = 'StaticError 403 "passwor

type instance MapError 'TooManyTeamInvitations = 'StaticError 403 "too-many-team-invitations" "Too many team invitations for this team"

type instance MapError 'CannotJoinMultipleTeams = 'StaticError 403 "cannot-join-multiple-teams" "Cannot accept invitations from multiple teams"

type instance MapError 'InsufficientTeamPermissions = 'StaticError 403 "insufficient-permissions" "Insufficient team permissions"

type instance MapError 'KeyPackageDecodingError = 'StaticError 409 "decoding-error" "Key package could not be TLS-decoded"
Expand Down
18 changes: 18 additions & 0 deletions libs/wire-api/src/Wire/API/Routes/Public/Brig.hs
Original file line number Diff line number Diff line change
Expand Up @@ -1560,6 +1560,7 @@ type TeamsAPI =
:> CanThrow 'BlacklistedEmail
:> CanThrow 'TooManyTeamInvitations
:> CanThrow 'InsufficientTeamPermissions
:> CanThrow 'InvalidInvitationCode
:> ZUser
:> "teams"
:> Capture "tid" TeamId
Expand Down Expand Up @@ -1660,6 +1661,23 @@ type TeamsAPI =
'[JSON]
(Respond 200 "Number of team members" TeamSize)
)
:<|> Named
"accept-team-invitation"
( Summary "Accept a team invitation, changing a personal account into a team member account."
:> CanThrow 'PendingInvitationNotFound
:> CanThrow 'TooManyTeamMembers
:> CanThrow 'MissingIdentity
:> CanThrow 'InvalidActivationCodeWrongUser
:> CanThrow 'InvalidActivationCodeWrongCode
:> CanThrow 'BadCredentials
:> CanThrow 'MissingAuth
:> ZLocalUser
:> "teams"
:> "invitations"
:> "accept"
:> ReqBody '[JSON] AcceptTeamInvitation
:> MultiVerb 'POST '[JSON] '[RespondEmpty 200 "Team invitation accepted."] ()
)

type SystemSettingsAPI =
Named
Expand Down
Loading