Skip to content

Commit

Permalink
WPB-10658: invite personal users into teams.
Browse files Browse the repository at this point in the history
failing test

basic implementation with holes

email sending function no impl

dummy endpoint for accepting invitation

failing test that user is added to team

todo honor timeout

refactoring to make findInvitation reuseable

accept invitation

extend test, publich events

test for team id, include team in es doc version

assert notification to user sent

when fanoutsize is low

Add email invitation placeholder

Revert "when fanoutsize is low"

This reverts commit 0834e48.

update nginz confs

added errors

clean up/renaming

email sending

Add change logs

setting invite url in configmap

verify invitation url query parameter

release notes

clean up

move code to http helpers

extend golden tests

renaming

use same timeout settings for all invitations

fix

format email html

add password test (failing)

Check if the user password is correct

fix test in CI

test user cannot join multiple teams

Simplify a helper function type

Guard against accepting multiple team invitations

Throw a suitable invitation not found error
  • Loading branch information
fisx committed Sep 13, 2024
1 parent f1bc7b9 commit 440606c
Show file tree
Hide file tree
Showing 34 changed files with 693 additions and 119 deletions.
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
5 changes: 5 additions & 0 deletions integration/test/API/Brig.hs
Original file line number Diff line number Diff line change
Expand Up @@ -783,3 +783,8 @@ 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))
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
115 changes: 115 additions & 0 deletions integration/test/Test/Teams.hs
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
-- This file is part of the Wire Server implementation.
--
-- Copyright (C) 2024 Wire Swiss GmbH <[email protected]>
--
-- 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.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
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) >>= getJSON 201
code <- getInvitationCode owner inv >>= getJSON 200 >>= (%. "code") & asString
queryParam <- inv %. "url" & asString <&> getQueryParam "team-code"
queryParam `shouldMatch` Just (Just 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

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) >>= getJSON 201
inv2 <- postInvitation owner2 (PostInvitation $ Just email) >>= 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
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."
:> 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
23 changes: 20 additions & 3 deletions libs/wire-api/src/Wire/API/Team/Invitation.hs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ module Wire.API.Team.Invitation
Invitation (..),
InvitationList (..),
InvitationLocation (..),
AcceptTeamInvitation (..),
HeadInvitationByEmailResult (..),
HeadInvitationsResponses,
)
Expand All @@ -33,6 +34,7 @@ import Data.Aeson qualified as A
import Data.ByteString.Conversion
import Data.Id
import Data.Json.Util
import Data.Misc
import Data.OpenApi qualified as S
import Data.SOP
import Data.Schema
Expand All @@ -42,11 +44,9 @@ import Servant (FromHttpApiData (..), ToHttpApiData (..))
import URI.ByteString
import Wire.API.Error
import Wire.API.Error.Brig
import Wire.API.Locale (Locale)
import Wire.API.Routes.MultiVerb
import Wire.API.Team.Role (Role, defaultRole)
import Wire.API.User.Identity (EmailAddress)
import Wire.API.User.Profile (Name)
import Wire.API.User
import Wire.Arbitrary (Arbitrary, GenericUniform (..))

--------------------------------------------------------------------------------
Expand Down Expand Up @@ -179,3 +179,20 @@ instance ToSchema InvitationList where
.= field "invitations" (array schema)
<*> ilHasMore
.= fieldWithDocModifier "has_more" (description ?~ "Indicator that the server has more invitations than returned.") schema

--------------------------------------------------------------------------------
-- AcceptTeamInvitation

data AcceptTeamInvitation = AcceptTeamInvitation
{ code :: InvitationCode,
password :: PlainTextPassword6
}
deriving stock (Eq, Show, Generic)
deriving (A.FromJSON, A.ToJSON, S.ToSchema) via (Schema AcceptTeamInvitation)

instance ToSchema AcceptTeamInvitation where
schema =
objectWithDocModifier "AcceptTeamInvitation" (description ?~ "Accept an invitation to join a team on Wire.") $
AcceptTeamInvitation
<$> code .= fieldWithDocModifier "code" (description ?~ "Invitation code to accept.") schema
<*> password .= fieldWithDocModifier "password" (description ?~ "The user account password.") schema
17 changes: 9 additions & 8 deletions libs/wire-api/src/Wire/API/UserEvent.hs
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,8 @@ data UserUpdatedData = UserUpdatedData
eupManagedBy :: !(Maybe ManagedBy),
eupSSOId :: !(Maybe UserSSOId),
eupSSOIdRemoved :: Bool,
eupSupportedProtocols :: !(Maybe (Set BaseProtocolTag))
eupSupportedProtocols :: !(Maybe (Set BaseProtocolTag)),
eupTeam :: !(Maybe TeamId)
}
deriving stock (Eq, Show)

Expand Down Expand Up @@ -192,6 +193,9 @@ emailUpdated :: UserId -> EmailAddress -> UserEvent
emailUpdated u e =
UserIdentityUpdated $ UserIdentityUpdatedData u (Just e) Nothing

teamUpdated :: UserId -> TeamId -> UserEvent
teamUpdated u t = UserUpdated (emptyUserUpdatedData u) {eupTeam = Just t}

emptyUserUpdatedData :: UserId -> UserUpdatedData
emptyUserUpdatedData u =
UserUpdatedData
Expand All @@ -206,7 +210,8 @@ emptyUserUpdatedData u =
eupManagedBy = Nothing,
eupSSOId = Nothing,
eupSSOIdRemoved = False,
eupSupportedProtocols = Nothing
eupSupportedProtocols = Nothing,
eupTeam = Nothing
}

-- Event schema
Expand Down Expand Up @@ -247,12 +252,8 @@ eventObjectSchema =
<*> eupManagedBy .= maybe_ (optField "managed_by" schema)
<*> eupSSOId .= maybe_ (optField "sso_id" genericToSchema)
<*> eupSSOIdRemoved .= field "sso_id_deleted" schema
<*> eupSupportedProtocols
.= maybe_
( optField
"supported_protocols"
(set schema)
)
<*> eupSupportedProtocols .= maybe_ (optField "supported_protocols" (set schema))
<*> eupTeam .= maybe_ (optField "team" schema)
)
)
)
Expand Down
3 changes: 2 additions & 1 deletion libs/wire-api/test/golden/Test/Wire/API/Golden/Manual.hs
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,8 @@ tests =
(testObject_UserEvent_14, "testObject_UserEvent_14.json"),
(testObject_UserEvent_15, "testObject_UserEvent_15.json"),
(testObject_UserEvent_16, "testObject_UserEvent_16.json"),
(testObject_UserEvent_17, "testObject_UserEvent_17.json")
(testObject_UserEvent_17, "testObject_UserEvent_17.json"),
(testObject_UserEvent_18, "testObject_UserEvent_18.json")
],
testGroup "MLSPublicKeys" $
testObjects
Expand Down
Loading

0 comments on commit 440606c

Please sign in to comment.