diff --git a/changelog.d/0-release-notes/WPB-10658 b/changelog.d/0-release-notes/WPB-10658 new file mode 100644 index 00000000000..df9e6dc5e17 --- /dev/null +++ b/changelog.d/0-release-notes/WPB-10658 @@ -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. diff --git a/changelog.d/1-api-changes/WPB-10658 b/changelog.d/1-api-changes/WPB-10658 new file mode 100644 index 00000000000..a40aff74ef1 --- /dev/null +++ b/changelog.d/1-api-changes/WPB-10658 @@ -0,0 +1 @@ +A new endpoint `POST /teams/invitations/accept` allows a non-team user to accept an invitation to join a team diff --git a/changelog.d/2-features/WPB-10658 b/changelog.d/2-features/WPB-10658 new file mode 100644 index 00000000000..e0d4302a688 --- /dev/null +++ b/changelog.d/2-features/WPB-10658 @@ -0,0 +1 @@ +Allow an existing non-team user to migrate to a team diff --git a/charts/brig/templates/configmap.yaml b/charts/brig/templates/configmap.yaml index 32ccd3acc04..4e1e5393a2e 100644 --- a/charts/brig/templates/configmap.yaml +++ b/charts/brig/templates/configmap.yaml @@ -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 }} diff --git a/charts/nginz/values.yaml b/charts/nginz/values.yaml index c3db69f37fc..12d6708f8d9 100644 --- a/charts/nginz/values.yaml +++ b/charts/nginz/values.yaml @@ -410,6 +410,9 @@ nginx_conf: envs: - all disable_zauth: true + - path: /teams/invitations/accept$ + envs: + - all - path: /i/teams/invitation-code envs: - staging diff --git a/integration/integration.cabal b/integration/integration.cabal index d6e3384b98c..faf1a6a4867 100644 --- a/integration/integration.cabal +++ b/integration/integration.cabal @@ -149,6 +149,7 @@ library Test.Services Test.Spar Test.Swagger + Test.Teams Test.TeamSettings Test.User Test.Version diff --git a/integration/test/API/Brig.hs b/integration/test/API/Brig.hs index 65e8d1d1961..e5a64dde9d7 100644 --- a/integration/test/API/Brig.hs +++ b/integration/test/API/Brig.hs @@ -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)) diff --git a/integration/test/Notifications.hs b/integration/test/Notifications.hs index 13dd5a0fb35..548f930fd24 100644 --- a/integration/test/Notifications.hs +++ b/integration/test/Notifications.hs @@ -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" diff --git a/integration/test/Test/Teams.hs b/integration/test/Test/Teams.hs new file mode 100644 index 00000000000..b6ee60de36b --- /dev/null +++ b/integration/test/Test/Teams.hs @@ -0,0 +1,115 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2024 Wire Swiss GmbH +-- +-- 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 . + +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 diff --git a/integration/test/Testlib/HTTP.hs b/integration/test/Testlib/HTTP.hs index f9f495abc96..ae15b01adb1 100644 --- a/integration/test/Testlib/HTTP.hs +++ b/integration/test/Testlib/HTTP.hs @@ -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 @@ -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 @@ -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 diff --git a/libs/wire-api/src/Wire/API/Error/Brig.hs b/libs/wire-api/src/Wire/API/Error/Brig.hs index 8399afaf1a5..16efe68b803 100644 --- a/libs/wire-api/src/Wire/API/Error/Brig.hs +++ b/libs/wire-api/src/Wire/API/Error/Brig.hs @@ -70,6 +70,7 @@ data BrigError | ChangePasswordMustDiffer | PasswordAuthenticationFailed | TooManyTeamInvitations + | CannotJoinMultipleTeams | InsufficientTeamPermissions | KeyPackageDecodingError | InvalidKeyPackageRef @@ -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" diff --git a/libs/wire-api/src/Wire/API/Routes/Public/Brig.hs b/libs/wire-api/src/Wire/API/Routes/Public/Brig.hs index 72afa66ff3b..90576d20c82 100644 --- a/libs/wire-api/src/Wire/API/Routes/Public/Brig.hs +++ b/libs/wire-api/src/Wire/API/Routes/Public/Brig.hs @@ -1560,6 +1560,7 @@ type TeamsAPI = :> CanThrow 'BlacklistedEmail :> CanThrow 'TooManyTeamInvitations :> CanThrow 'InsufficientTeamPermissions + :> CanThrow 'InvalidInvitationCode :> ZUser :> "teams" :> Capture "tid" TeamId @@ -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 diff --git a/libs/wire-api/src/Wire/API/Team/Invitation.hs b/libs/wire-api/src/Wire/API/Team/Invitation.hs index 967adad2832..f195b4072ce 100644 --- a/libs/wire-api/src/Wire/API/Team/Invitation.hs +++ b/libs/wire-api/src/Wire/API/Team/Invitation.hs @@ -23,6 +23,7 @@ module Wire.API.Team.Invitation Invitation (..), InvitationList (..), InvitationLocation (..), + AcceptTeamInvitation (..), HeadInvitationByEmailResult (..), HeadInvitationsResponses, ) @@ -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 @@ -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 (..)) -------------------------------------------------------------------------------- @@ -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 diff --git a/libs/wire-api/src/Wire/API/UserEvent.hs b/libs/wire-api/src/Wire/API/UserEvent.hs index db9794604c6..db4c69d2c24 100644 --- a/libs/wire-api/src/Wire/API/UserEvent.hs +++ b/libs/wire-api/src/Wire/API/UserEvent.hs @@ -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) @@ -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 @@ -206,7 +210,8 @@ emptyUserUpdatedData u = eupManagedBy = Nothing, eupSSOId = Nothing, eupSSOIdRemoved = False, - eupSupportedProtocols = Nothing + eupSupportedProtocols = Nothing, + eupTeam = Nothing } -- Event schema @@ -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) ) ) ) diff --git a/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual.hs b/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual.hs index 0ff493d9e85..d85fdd1bbd8 100644 --- a/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual.hs +++ b/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual.hs @@ -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 diff --git a/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/UserEvent.hs b/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/UserEvent.hs index 0f0443cc710..5d99e783a5c 100644 --- a/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/UserEvent.hs +++ b/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/UserEvent.hs @@ -33,6 +33,7 @@ module Test.Wire.API.Golden.Manual.UserEvent testObject_UserEvent_15, testObject_UserEvent_16, testObject_UserEvent_17, + testObject_UserEvent_18, ) where @@ -99,6 +100,7 @@ testObject_UserEvent_6 = Nothing False (Just mempty) + Nothing ) ) @@ -191,6 +193,27 @@ testObject_UserEvent_16 = testObject_UserEvent_17 :: Event testObject_UserEvent_17 = ClientEvent (ClientRemoved (ClientId 2839)) +testObject_UserEvent_18 :: Event +testObject_UserEvent_18 = + UserEvent + ( UserUpdated + ( UserUpdatedData + (userId alice) + (Just alice.userDisplayName) + alice.userTextStatus + (Just alice.userPict) + (Just alice.userAccentId) + (Just alice.userAssets) + alice.userHandle + (Just alice.userLocale) + (Just alice.userManagedBy) + Nothing + False + (Just mempty) + alice.userTeam + ) + ) + -------------------------------------------------------------------------------- alice :: User @@ -216,7 +239,7 @@ alice = userService = Nothing, userHandle = Nothing, userExpire = Nothing, - userTeam = Nothing, + userTeam = Just $ Id (fromJust (UUID.fromString "bb843450-b2f5-4ec8-90bd-52c7d5f1d22e")), userManagedBy = ManagedByWire, userSupportedProtocols = defSupportedProtocols } diff --git a/libs/wire-api/test/golden/testObject_UserEvent_1.json b/libs/wire-api/test/golden/testObject_UserEvent_1.json index 6938bd328fe..bfe90d9970a 100644 --- a/libs/wire-api/test/golden/testObject_UserEvent_1.json +++ b/libs/wire-api/test/golden/testObject_UserEvent_1.json @@ -16,6 +16,7 @@ "supported_protocols": [ "proteus" ], + "team": "bb843450-b2f5-4ec8-90bd-52c7d5f1d22e", "text_status": "text status" } } diff --git a/libs/wire-api/test/golden/testObject_UserEvent_18.json b/libs/wire-api/test/golden/testObject_UserEvent_18.json new file mode 100644 index 00000000000..96f97d80149 --- /dev/null +++ b/libs/wire-api/test/golden/testObject_UserEvent_18.json @@ -0,0 +1,16 @@ +{ + "type": "user.update", + "user": { + "accent_id": 1, + "assets": [], + "id": "539d9183-32a5-4fc4-ba5c-4634454e7585", + "locale": "tn-SB", + "managed_by": "wire", + "name": "alice", + "picture": [], + "sso_id_deleted": false, + "supported_protocols": [], + "team": "bb843450-b2f5-4ec8-90bd-52c7d5f1d22e", + "text_status": "text status" + } +} diff --git a/libs/wire-api/test/golden/testObject_UserEvent_2.json b/libs/wire-api/test/golden/testObject_UserEvent_2.json index 2b051ddd45a..e630fcc9701 100644 --- a/libs/wire-api/test/golden/testObject_UserEvent_2.json +++ b/libs/wire-api/test/golden/testObject_UserEvent_2.json @@ -16,6 +16,7 @@ "supported_protocols": [ "proteus" ], + "team": "bb843450-b2f5-4ec8-90bd-52c7d5f1d22e", "text_status": "text status" } } diff --git a/libs/wire-subsystems/src/Wire/InvitationCodeStore.hs b/libs/wire-subsystems/src/Wire/InvitationCodeStore.hs index a9183ae2da9..78eee5283dc 100644 --- a/libs/wire-subsystems/src/Wire/InvitationCodeStore.hs +++ b/libs/wire-subsystems/src/Wire/InvitationCodeStore.hs @@ -70,7 +70,8 @@ data InsertInvitation = MkInsertInvitation createdAt :: UTCTime, createdBy :: Maybe UserId, inviteeEmail :: EmailAddress, - inviteeName :: Maybe Name + inviteeName :: Maybe Name, + code :: InvitationCode } deriving (Show, Eq, Generic) diff --git a/libs/wire-subsystems/src/Wire/InvitationCodeStore/Cassandra.hs b/libs/wire-subsystems/src/Wire/InvitationCodeStore/Cassandra.hs index f8a3bc4a688..37463cfb966 100644 --- a/libs/wire-subsystems/src/Wire/InvitationCodeStore/Cassandra.hs +++ b/libs/wire-subsystems/src/Wire/InvitationCodeStore/Cassandra.hs @@ -36,8 +36,7 @@ insertInvitationImpl :: -- | The timeout for the invitation code. Timeout -> Client StoredInvitation -insertInvitationImpl (MkInsertInvitation invId teamId role (toUTCTimeMillis -> now) uid email name) timeout = do - code <- liftIO mkInvitationCode +insertInvitationImpl (MkInsertInvitation invId teamId role (toUTCTimeMillis -> now) uid email name code) timeout = do let inv = MkStoredInvitation { teamId = teamId, diff --git a/services/brig/brig.integration.yaml b/services/brig/brig.integration.yaml index 265eca6cfc9..daec729b8c2 100644 --- a/services/brig/brig.integration.yaml +++ b/services/brig/brig.integration.yaml @@ -124,6 +124,7 @@ emailSMS: team: tInvitationUrl: http://127.0.0.1:8080/register?team=${team}&team_code=${code} + tExistingUserInvitationUrl: http://127.0.0.1:8080/accept-invitation?team-code=${code} tActivationUrl: http://127.0.0.1:8080/register?team=${team}&team_code=${code} tCreatorWelcomeUrl: http://127.0.0.1:8080/creator-welcome-website tMemberWelcomeUrl: http://127.0.0.1:8080/member-welcome-website diff --git a/services/brig/deb/opt/brig/templates/en/team/email/existing-invitation-subject.txt b/services/brig/deb/opt/brig/templates/en/team/email/existing-invitation-subject.txt new file mode 100644 index 00000000000..9fef363e407 --- /dev/null +++ b/services/brig/deb/opt/brig/templates/en/team/email/existing-invitation-subject.txt @@ -0,0 +1 @@ +You have been invited to join a team on ${brand} \ No newline at end of file diff --git a/services/brig/deb/opt/brig/templates/en/team/email/existing-invitation.html b/services/brig/deb/opt/brig/templates/en/team/email/existing-invitation.html new file mode 100644 index 00000000000..2985716ea58 --- /dev/null +++ b/services/brig/deb/opt/brig/templates/en/team/email/existing-invitation.html @@ -0,0 +1,183 @@ + + + + + + + You have been invited to join a team on ${brand} + + + + + + + + +
+ + + + + + +
+ + + + + + +
+ + + + + + + +
+ + + + + + +
+

+
+
+ + + + + + +
+

${brand_label_url}

+
+
+
+
+ + + + + + +
+ + + + + + +
+ + + + + + + +
+

Team invitation

+

${inviter} has invited you to join a team on ${brand}. Click the button below to accept the invitation.

+ + + + + + +
 
+
+ + + + + + +
+ + + + + + +
Join team
+
+
+ + + + + + +
 
+

If you can’t click the button, copy and paste this link to your browser:

+

${url}

+

If you have any questions, please contact us.

+

What is Wire?
Wire is the most secure collaboration platform. Work with your team and external partners wherever you are through messages, video conferencing and file sharing – always secured with end-to-end-encryption. Learn more.

+
+
+
+ + + + + + + +
+
                                                           
+ + + diff --git a/services/brig/deb/opt/brig/templates/en/team/email/existing-invitation.txt b/services/brig/deb/opt/brig/templates/en/team/email/existing-invitation.txt new file mode 100644 index 00000000000..918c8fde767 --- /dev/null +++ b/services/brig/deb/opt/brig/templates/en/team/email/existing-invitation.txt @@ -0,0 +1,25 @@ +[${brand_logo}] + +${brand_label_url} [${brand_url}] + +TEAM INVITATION +${inviter} has invited you to join a team on ${brand}. Click the button below to +accept the invitation. + +Join team [${url}]If you can’t click the button, copy and paste this link to +your browser: + +${url} + +If you have any questions, please contact us [${support}]. + +What is Wire? +Wire is the most secure collaboration platform. Work with your team and external +partners wherever you are through messages, video conferencing and file sharing +– always secured with end-to-end-encryption. Learn more [https://wire.com/]. + + +-------------------------------------------------------------------------------- + +Privacy policy and terms of use [${legal}] · Report Misuse [${misuse}] +${copyright}. ALL RIGHTS RESERVED. \ No newline at end of file diff --git a/services/brig/src/Brig/API/User.hs b/services/brig/src/Brig/API/User.hs index 4f76936fae7..f18ae4b8d30 100644 --- a/services/brig/src/Brig/API/User.hs +++ b/services/brig/src/Brig/API/User.hs @@ -65,6 +65,7 @@ module Brig.API.User -- * Utilities fetchUserIdentity, + findTeamInvitation, ) where @@ -282,12 +283,8 @@ createUser new = do (mNewTeamUser, teamInvitation, tid) <- case newUserTeam new of Just (NewTeamMember i) -> do - mbTeamInv <- findTeamInvitation (mkEmailKey <$> email) i - case mbTeamInv of - Just (inv, info, tid) -> - pure (Nothing, Just (inv, info), Just tid) - Nothing -> - pure (Nothing, Nothing, Nothing) + (inv, info) <- findTeamInvitation (mkEmailKey <$> email) i + pure (Nothing, Just (inv, info), Just info.teamId) Just (NewTeamCreator t) -> do (Just t,Nothing,) <$> (Just . Id <$> liftIO nextRandom) Just (NewTeamMemberSSO tid) -> @@ -386,44 +383,8 @@ createUser new = do let email = newUserEmail newUser for_ (mkEmailKey <$> email) $ \k -> verifyUniquenessAndCheckBlacklist k !>> identityErrorToRegisterError - pure email - findTeamInvitation :: - Maybe EmailKey -> - InvitationCode -> - ExceptT - RegisterError - (AppT r) - ( Maybe - (StoredInvitation, StoredInvitationInfo, TeamId) - ) - findTeamInvitation Nothing _ = throwE RegisterErrorMissingIdentity - findTeamInvitation (Just e) c = - lift (liftSem $ InvitationCodeStore.lookupInvitationInfo c) >>= \case - Just invitationInfo -> do - inv <- lift . liftSem $ InvitationCodeStore.lookupInvitation invitationInfo.teamId invitationInfo.invitationId - case (inv, (.email) <$> inv) of - (Just invite, Just em) - | e == mkEmailKey em -> do - ensureMemberCanJoin invitationInfo.teamId - pure $ Just (invite, invitationInfo, invitationInfo.teamId) - _ -> throwE RegisterErrorInvalidInvitationCode - Nothing -> throwE RegisterErrorInvalidInvitationCode - - ensureMemberCanJoin :: TeamId -> ExceptT RegisterError (AppT r) () - ensureMemberCanJoin tid = do - maxSize <- fromIntegral . setMaxTeamSize <$> view settings - (TeamSize teamSize) <- TeamSize.teamSize tid - when (teamSize >= maxSize) $ - throwE RegisterErrorTooManyTeamMembers - -- FUTUREWORK: The above can easily be done/tested in the intra call. - -- Remove after the next release. - canAdd <- lift $ liftSem $ GalleyAPIAccess.checkUserCanJoinTeam tid - case canAdd of - Just e -> throwM $ API.UserNotAllowedToJoinTeam e - Nothing -> pure () - acceptTeamInvitation :: UserAccount -> StoredInvitation -> @@ -490,6 +451,37 @@ createUser new = do !>> activationErrorToRegisterError pure Nothing +findTeamInvitation :: + ( Member GalleyAPIAccess r, + Member InvitationCodeStore r + ) => + Maybe EmailKey -> + InvitationCode -> + ExceptT RegisterError (AppT r) (StoredInvitation, StoredInvitationInfo) +findTeamInvitation Nothing _ = throwE RegisterErrorMissingIdentity +findTeamInvitation (Just e) c = + lift (liftSem $ InvitationCodeStore.lookupInvitationInfo c) >>= \case + Just invitationInfo -> do + inv <- lift . liftSem $ InvitationCodeStore.lookupInvitation invitationInfo.teamId invitationInfo.invitationId + case (inv, (.email) <$> inv) of + (Just invite, Just em) + | e == mkEmailKey em -> do + ensureMemberCanJoin invitationInfo.teamId + pure (invite, invitationInfo) + _ -> throwE RegisterErrorInvalidInvitationCode + Nothing -> throwE RegisterErrorInvalidInvitationCode + where + ensureMemberCanJoin :: (Member GalleyAPIAccess r) => TeamId -> ExceptT RegisterError (AppT r) () + ensureMemberCanJoin tid = do + maxSize <- fromIntegral . setMaxTeamSize <$> view settings + (TeamSize teamSize) <- TeamSize.teamSize tid + when (teamSize >= maxSize) $ + throwE RegisterErrorTooManyTeamMembers + -- FUTUREWORK: The above can easily be done/tested in the intra call. + -- Remove after the next release. + mAddUserError <- lift $ liftSem $ GalleyAPIAccess.checkUserCanJoinTeam tid + maybe (pure ()) (throwM . API.UserNotAllowedToJoinTeam) mAddUserError + initAccountFeatureConfig :: UserId -> (AppT r) () initAccountFeatureConfig uid = do mStatus <- preview (settings . featureFlags . _Just . to conferenceCalling . to forNew . _Just) diff --git a/services/brig/src/Brig/Data/User.hs b/services/brig/src/Brig/Data/User.hs index 55412d7e069..b71bdd02cfe 100644 --- a/services/brig/src/Brig/Data/User.hs +++ b/services/brig/src/Brig/Data/User.hs @@ -53,6 +53,7 @@ module Brig.Data.User updateStatus, updateRichInfo, updateFeatureConferenceCalling, + updateUserTeam, -- * Deletions deleteEmail, @@ -382,6 +383,12 @@ lookupUserTeam u = (runIdentity =<<) <$> retry x1 (query1 teamSelect (params LocalQuorum (Identity u))) +updateUserTeam :: (MonadClient m) => UserId -> TeamId -> m () +updateUserTeam u t = retry x5 $ write userTeamUpdate (params LocalQuorum (t, u)) + where + userTeamUpdate :: PrepQuery W (TeamId, UserId) () + userTeamUpdate = "UPDATE user SET team = ? WHERE id = ?" + lookupAuth :: (MonadClient m) => UserId -> m (Maybe (Maybe Password, AccountStatus)) lookupAuth u = fmap f <$> retry x1 (query1 authSelect (params LocalQuorum (Identity u))) where diff --git a/services/brig/src/Brig/IO/Intra.hs b/services/brig/src/Brig/IO/Intra.hs index 716272ccf62..d833def04fe 100644 --- a/services/brig/src/Brig/IO/Intra.hs +++ b/services/brig/src/Brig/IO/Intra.hs @@ -217,7 +217,8 @@ updateSearchIndex orig e = embed $ case e of isJust eupAccentId, isJust eupHandle, isJust eupManagedBy, - isJust eupSSOId || eupSSOIdRemoved + isJust eupSSOId || eupSSOIdRemoved, + isJust eupTeam ] when interesting $ Search.reindex orig diff --git a/services/brig/src/Brig/Options.hs b/services/brig/src/Brig/Options.hs index 31d586cf165..a5a1f761fb6 100644 --- a/services/brig/src/Brig/Options.hs +++ b/services/brig/src/Brig/Options.hs @@ -224,6 +224,8 @@ instance FromJSON ProviderOpts data TeamOpts = TeamOpts { -- | Team Invitation URL template tInvitationUrl :: !Text, + -- | Existing User Invitation URL template + tExistingUserInvitationUrl :: !Text, -- | Team Activation URL template tActivationUrl :: !Text, -- | Team Creator Welcome URL diff --git a/services/brig/src/Brig/Team/API.hs b/services/brig/src/Brig/Team/API.hs index a7e285ad822..0595ff771c6 100644 --- a/services/brig/src/Brig/Team/API.hs +++ b/services/brig/src/Brig/Team/API.hs @@ -31,11 +31,12 @@ import Brig.API.Handler import Brig.API.User (createUserInviteViaScim, fetchUserIdentity) import Brig.API.User qualified as API import Brig.API.Util (logEmail, logInvitationCode) -import Brig.App -import Brig.App qualified as App +import Brig.App as App +import Brig.Data.User as User import Brig.Effects.ConnectionStore (ConnectionStore) import Brig.Effects.UserPendingActivationStore (UserPendingActivationStore) -import Brig.Options (setMaxTeamSize, setTeamInvitationTimeout) +import Brig.IO.Intra qualified as Intra +import Brig.Options import Brig.Team.Email import Brig.Team.Template import Brig.Team.Util (ensurePermissionToAddUser, ensurePermissions) @@ -46,7 +47,7 @@ import Control.Monad.Trans.Except (mapExceptT) import Data.ByteString.Conversion (toByteString, toByteString') import Data.Id import Data.List1 qualified as List1 -import Data.Qualified (Local) +import Data.Qualified (Local, tUnqualified) import Data.Range import Data.Text.Ascii import Data.Text.Encoding (encodeUtf8) @@ -66,6 +67,7 @@ import URI.ByteString (Absolute, URIRef, laxURIParserOptions, parseURI) import Util.Logging (logFunction, logTeam) import Wire.API.Error import Wire.API.Error.Brig qualified as E +import Wire.API.Password import Wire.API.Routes.Internal.Brig (FoundInvitationCode (FoundInvitationCode)) import Wire.API.Routes.Internal.Galley.TeamsIntra qualified as Team import Wire.API.Routes.Named @@ -80,15 +82,18 @@ import Wire.API.Team.Role import Wire.API.Team.Role qualified as Public import Wire.API.User hiding (fromEmail) import Wire.API.User qualified as Public +import Wire.API.UserEvent import Wire.BlockListStore import Wire.EmailSending (EmailSending) import Wire.EmailSubsystem.Template import Wire.Error import Wire.GalleyAPIAccess (GalleyAPIAccess, ShowOrHideInvitationUrl (..)) import Wire.GalleyAPIAccess qualified as GalleyAPIAccess -import Wire.InvitationCodeStore (InsertInvitation (..), InvitationCodeStore (..), PaginatedResult (..), StoredInvitation (..)) +import Wire.InvitationCodeStore (InvitationCodeStore (..), PaginatedResult (..), StoredInvitation (..)) import Wire.InvitationCodeStore qualified as Store +import Wire.InvitationCodeStore.Cassandra qualified as Store (mkInvitationCode) import Wire.NotificationSubsystem +import Wire.PasswordStore import Wire.Sem.Concurrency import Wire.Sem.Paging.Cassandra (InternalPaging) import Wire.UserKeyStore @@ -98,9 +103,15 @@ servantAPI :: ( Member GalleyAPIAccess r, Member UserKeyStore r, Member UserSubsystem r, + Member Store.InvitationCodeStore r, Member EmailSending r, + Member (Input (Local ())) r, + Member (Input UTCTime) r, + Member (ConnectionStore InternalPaging) r, Member TinyLog r, - Member Store.InvitationCodeStore r + Member (Embed HttpClientIO) r, + Member NotificationSubsystem r, + Member PasswordStore r ) => ServerT TeamsAPI (Handler r) servantAPI = @@ -111,6 +122,7 @@ servantAPI = :<|> Named @"get-team-invitation-info" getInvitationByCode :<|> Named @"head-team-invitations" headInvitationByEmail :<|> Named @"get-team-size" teamSizePublic + :<|> Named @"accept-team-invitation" acceptTeamInvitationByPersonalUser teamSizePublic :: (Member GalleyAPIAccess r) => UserId -> TeamId -> (Handler r) TeamSize teamSizePublic uid tid = do @@ -138,10 +150,11 @@ data CreateInvitationInviter = CreateInvitationInviter createInvitation :: ( Member GalleyAPIAccess r, Member UserKeyStore r, + Member InvitationCodeStore r, Member UserSubsystem r, Member EmailSending r, Member TinyLog r, - Member InvitationCodeStore r + Member (Input (Local ())) r ) => UserId -> TeamId -> @@ -171,14 +184,15 @@ createInvitation uid tid body = do InvitationLocation $ "/teams/" <> toByteString' tid <> "/invitations/" <> toByteString' inv.invitationId createInvitationViaScim :: - ( Member BlockListStore r, - Member GalleyAPIAccess r, + ( Member GalleyAPIAccess r, + Member BlockListStore r, Member UserKeyStore r, + Member InvitationCodeStore r, Member (UserPendingActivationStore p) r, Member TinyLog r, - Member EmailSending r, Member UserSubsystem r, - Member InvitationCodeStore r + Member EmailSending r, + Member (Input (Local ())) r ) => TeamId -> NewUserScimInvitation -> @@ -186,7 +200,7 @@ createInvitationViaScim :: createInvitationViaScim tid newUser@(NewUserScimInvitation _tid uid _eid loc name email role) = do env <- ask let inviteeRole = role - fromEmail = env ^. emailSender + fromEmail = env ^. App.emailSender invreq = InvitationRequest { locale = loc, @@ -225,12 +239,13 @@ logInvitationRequest context action = pure (Right result) createInvitation' :: - ( Member UserSubsystem r, - Member GalleyAPIAccess r, + ( Member GalleyAPIAccess r, + Member UserSubsystem r, Member UserKeyStore r, + Member InvitationCodeStore r, Member EmailSending r, Member TinyLog r, - Member InvitationCodeStore r + Member (Input (Local ())) r ) => TeamId -> Maybe UserId -> @@ -239,15 +254,28 @@ createInvitation' :: EmailAddress -> Public.InvitationRequest -> Handler r (Public.Invitation, Public.InvitationCode) -createInvitation' tid mUid inviteeRole mbInviterUid fromEmail body = do - let email = body.inviteeEmail +createInvitation' tid mUid inviteeRole mbInviterUid fromEmail invRequest = do + let email = invRequest.inviteeEmail let uke = mkEmailKey email blacklistedEm <- lift $ liftSem $ isBlocked email when blacklistedEm $ throwStd blacklistedEmail emailTaken <- lift $ liftSem $ isJust <$> lookupKey uke + isPersonalUserMigration <- + if emailTaken + then do + mAccount <- lift $ liftSem $ getLocalUserAccountByUserKey =<< qualifyLocal' uke + pure $ case mAccount of + -- this can e.g. happen if the key is claimed but the account is not yet created + Nothing -> False + Just account -> + account.accountStatus == Active + && isNothing account.accountUser.userTeam + else pure False + when emailTaken $ - throwStd emailExists + unless isPersonalUserMigration $ + throwStd emailExists maxSize <- setMaxTeamSize <$> view settings pending <- lift $ liftSem $ Store.countInvitations tid @@ -256,27 +284,39 @@ createInvitation' tid mUid inviteeRole mbInviterUid fromEmail body = do showInvitationUrl <- lift $ liftSem $ GalleyAPIAccess.getExposeInvitationURLsToTeamAdmin tid - iid <- maybe (liftIO randomId) (pure . Id . toUUID) mUid - now <- liftIO =<< view currentTime - timeout <- setTeamInvitationTimeout <$> view settings - let insertInv = - MkInsertInvitation - { invitationId = iid, - teamId = tid, - role = inviteeRole, - createdAt = now, - createdBy = mbInviterUid, - inviteeEmail = email, - inviteeName = body.inviteeName - } - newInv <- - lift . liftSem $ - Store.insertInvitation - insertInv - timeout - lift $ sendInvitationMail email tid fromEmail newInv.code body.locale - inv <- toInvitation showInvitationUrl newInv - pure (inv, newInv.code) + lift $ do + iid <- maybe randomId (pure . Id . toUUID) mUid + now <- liftIO =<< view currentTime + timeout <- setTeamInvitationTimeout <$> view settings + code <- liftIO $ Store.mkInvitationCode + mUrl <- + liftSem $ + if isPersonalUserMigration + then mkInviteUrlPersonalUser showInvitationUrl tid code + else mkInviteUrl showInvitationUrl tid code + newInv <- + let insertInv = + Store.MkInsertInvitation + { invitationId = iid, + teamId = tid, + role = inviteeRole, + createdAt = now, + createdBy = mbInviterUid, + inviteeEmail = email, + inviteeName = invRequest.inviteeName, + code = code + -- mUrl = mUrl + } + in liftSem $ Store.insertInvitation insertInv timeout + + let sendOp = + if isPersonalUserMigration + then sendInvitationMailPersonalUser + else sendInvitationMail + + sendOp email tid fromEmail code invRequest.locale + inv <- liftSem $ toInvitation showInvitationUrl newInv + pure (inv, code) deleteInvitation :: (Member GalleyAPIAccess r, Member InvitationCodeStore r) => @@ -304,20 +344,19 @@ listInvitations uid tid startingId mSize = do let toInvitations is = mapM (toInvitation showInvitationUrl) is lift (liftSem $ Store.lookupInvitationsPaginated mSize tid startingId) >>= \case PaginatedResultHasMore storedInvs -> do - invs <- toInvitations storedInvs + invs <- lift . liftSem $ toInvitations storedInvs pure $ InvitationList invs True PaginatedResult storedInvs -> do - invs <- toInvitations storedInvs + invs <- lift . liftSem $ toInvitations storedInvs pure $ InvitationList invs False -- | brig used to not store the role, so for migration we allow this to be empty and fill in the -- default here. toInvitation :: - ( Member TinyLog r - ) => + (Member TinyLog r) => ShowOrHideInvitationUrl -> StoredInvitation -> - (Handler r) Invitation + Sem r Invitation toInvitation showUrl storedInv = do url <- mkInviteUrl showUrl storedInv.teamId storedInv.code pure $ @@ -332,32 +371,55 @@ toInvitation showUrl storedInv = do inviteeUrl = url } -mkInviteUrl :: +getInviteUrl :: + forall r. (Member TinyLog r) => - ShowOrHideInvitationUrl -> + InvitationEmailTemplate -> TeamId -> - InvitationCode -> - (Handler r) (Maybe (URIRef Absolute)) -mkInviteUrl HideInvitationUrl _ _ = pure Nothing -mkInviteUrl ShowInvitationUrl team (InvitationCode c) = do - template <- invitationEmailUrl . invitationEmail . snd <$> teamTemplates Nothing + AsciiText Base64Url -> + Sem r (Maybe (URIRef Absolute)) +getInviteUrl (invitationEmailUrl -> template) team code = do branding <- view App.templateBranding let url = Text.toStrict $ renderTextWithBranding template replace branding parseHttpsUrl url where replace "team" = idToText team - replace "code" = toText c + replace "code" = toText code replace x = x - parseHttpsUrl :: (Member TinyLog r) => Text -> (Handler r) (Maybe (URIRef Absolute)) + + parseHttpsUrl :: Text -> Sem r (Maybe (URIRef Absolute)) parseHttpsUrl url = - either (\e -> lift . liftSem $ logError url e >> pure Nothing) (pure . Just) $ + either (\e -> Nothing <$ logError url e) (pure . Just) $ parseURI laxURIParserOptions (encodeUtf8 url) + logError url e = Log.err $ Log.msg @Text "Unable to create invitation url. Please check configuration." . Log.field "url" url . Log.field "error" (show e) +mkInviteUrl :: + (Member TinyLog r) => + ShowOrHideInvitationUrl -> + TeamId -> + InvitationCode -> + Sem r (Maybe (URIRef Absolute)) +mkInviteUrl HideInvitationUrl _ _ = pure Nothing +mkInviteUrl ShowInvitationUrl team (InvitationCode c) = do + template <- invitationEmail . snd <$> teamTemplates Nothing + getInviteUrl template team c + +mkInviteUrlPersonalUser :: + (Member TinyLog r) => + ShowOrHideInvitationUrl -> + TeamId -> + InvitationCode -> + Sem r (Maybe (URIRef Absolute)) +mkInviteUrlPersonalUser HideInvitationUrl _ _ = pure Nothing +mkInviteUrlPersonalUser ShowInvitationUrl team (InvitationCode c) = do + template <- existingUserInvitationEmail . snd <$> teamTemplates Nothing + getInviteUrl template team c + getInvitation :: ( Member GalleyAPIAccess r, Member InvitationCodeStore r, @@ -375,7 +437,7 @@ getInvitation uid tid iid = do Nothing -> pure Nothing Just invitation -> do showInvitationUrl <- lift . liftSem $ GalleyAPIAccess.getExposeInvitationURLsToTeamAdmin tid - maybeUrl <- mkInviteUrl showInvitationUrl tid invitation.code + maybeUrl <- lift . liftSem $ mkInviteUrl showInvitationUrl tid invitation.code pure $ Just (Store.invitationFromStored maybeUrl invitation) getInvitationByCode :: @@ -475,3 +537,53 @@ changeTeamAccountStatuses tid s = do where toList1 (x : xs) = pure $ List1.list1 x xs toList1 [] = throwStd (notFound "Team not found or no members") + +acceptTeamInvitationByPersonalUser :: + forall r. + ( Member UserSubsystem r, + Member GalleyAPIAccess r, + Member (Input (Local ())) r, + Member (Input UTCTime) r, + Member (ConnectionStore InternalPaging) r, + Member TinyLog r, + Member (Embed HttpClientIO) r, + Member NotificationSubsystem r, + Member InvitationCodeStore r, + Member PasswordStore r + ) => + Local UserId -> + AcceptTeamInvitation -> + (Handler r) () +acceptTeamInvitationByPersonalUser luid req = do + (mek, mTid) <- do + mSelfProfile <- lift $ liftSem $ getSelfProfile luid + let mek = mkEmailKey <$> (userEmail . selfUser =<< mSelfProfile) + mTid = mSelfProfile >>= userTeam . selfUser + pure (mek, mTid) + checkPassword + (inv, (.teamId) -> tid) <- API.findTeamInvitation mek req.code !>> toInvitationError + let minvmeta = (,inv.createdAt) <$> inv.createdBy + uid = tUnqualified luid + for_ mTid $ \userTid -> + unless (tid == userTid) $ + throwStd (errorToWai @'E.CannotJoinMultipleTeams) + added <- lift $ liftSem $ GalleyAPIAccess.addTeamMember uid tid minvmeta (fromMaybe defaultRole inv.role) + unless added $ throwStd (errorToWai @'E.TooManyTeamMembers) + lift $ do + wrapClient $ User.updateUserTeam uid tid + liftSem $ Store.deleteInvitation inv.teamId inv.invitationId + liftSem $ Intra.onUserEvent uid Nothing (teamUpdated uid tid) + where + checkPassword = do + p <- + lift (liftSem . lookupHashedPassword . tUnqualified $ luid) + >>= maybe (throwStd (errorToWai @'E.MissingAuth)) pure + unless (verifyPassword req.password p) $ + throwStd (errorToWai @'E.BadCredentials) + toInvitationError :: RegisterError -> HttpError + toInvitationError = \case + RegisterErrorMissingIdentity -> StdError (errorToWai @'E.MissingIdentity) + RegisterErrorInvalidActivationCodeWrongUser -> StdError (errorToWai @'E.InvalidActivationCodeWrongUser) + RegisterErrorInvalidActivationCodeWrongCode -> StdError (errorToWai @'E.InvalidActivationCodeWrongCode) + RegisterErrorInvalidInvitationCode -> StdError (errorToWai @'E.InvalidInvitationCode) + _ -> StdError (notFound "Something went wrong, while looking up the invitation") diff --git a/services/brig/src/Brig/Team/Email.hs b/services/brig/src/Brig/Team/Email.hs index d76a6671f68..9bfd2d653f3 100644 --- a/services/brig/src/Brig/Team/Email.hs +++ b/services/brig/src/Brig/Team/Email.hs @@ -22,6 +22,7 @@ module Brig.Team.Email CreatorWelcomeEmail (..), MemberWelcomeEmail (..), sendInvitationMail, + sendInvitationMailPersonalUser, sendMemberWelcomeMail, ) where @@ -39,9 +40,6 @@ import Wire.API.User import Wire.EmailSending import Wire.EmailSubsystem.Template (TemplateBranding, renderHtmlWithBranding, renderTextWithBranding) -------------------------------------------------------------------------------- --- Invitation Email - sendInvitationMail :: (Member EmailSending r) => EmailAddress -> TeamId -> EmailAddress -> InvitationCode -> Maybe Locale -> (AppT r) () sendInvitationMail to tid from code loc = do tpl <- invitationEmail . snd <$> teamTemplates loc @@ -49,6 +47,13 @@ sendInvitationMail to tid from code loc = do let mail = InvitationEmail to tid code from liftSem $ sendMail $ renderInvitationEmail mail tpl branding +sendInvitationMailPersonalUser :: (Member EmailSending r) => EmailAddress -> TeamId -> EmailAddress -> InvitationCode -> Maybe Locale -> (AppT r) () +sendInvitationMailPersonalUser to tid from code loc = do + tpl <- existingUserInvitationEmail . snd <$> teamTemplates loc + branding <- view templateBranding + let mail = InvitationEmail to tid code from + liftSem $ sendMail $ renderInvitationEmail mail tpl branding + sendMemberWelcomeMail :: (Member EmailSending r) => EmailAddress -> TeamId -> Text -> Maybe Locale -> (AppT r) () sendMemberWelcomeMail to tid teamName loc = do tpl <- memberWelcomeEmail . snd <$> teamTemplates loc diff --git a/services/brig/src/Brig/Team/Template.hs b/services/brig/src/Brig/Team/Template.hs index d725ec556f4..129ca30ef37 100644 --- a/services/brig/src/Brig/Team/Template.hs +++ b/services/brig/src/Brig/Team/Template.hs @@ -61,6 +61,7 @@ data MemberWelcomeEmailTemplate = MemberWelcomeEmailTemplate data TeamTemplates = TeamTemplates { invitationEmail :: !InvitationEmailTemplate, + existingUserInvitationEmail :: !InvitationEmailTemplate, creatorWelcomeEmail :: !CreatorWelcomeEmailTemplate, memberWelcomeEmail :: !MemberWelcomeEmailTemplate } @@ -75,6 +76,13 @@ loadTeamTemplates o = readLocalesDir defLocale (templateDir gOptions) "team" $ \ <*> pure (emailSender gOptions) <*> readText fp "email/sender.txt" ) + <*> ( InvitationEmailTemplate tExistingUrl + <$> readTemplate fp "email/existing-invitation-subject.txt" + <*> readTemplate fp "email/existing-invitation.txt" + <*> readTemplate fp "email/existing-invitation.html" + <*> pure (emailSender gOptions) + <*> readText fp "email/sender.txt" + ) <*> ( CreatorWelcomeEmailTemplate (tCreatorWelcomeUrl tOptions) <$> readTemplate fp "email/new-creator-welcome-subject.txt" <*> readTemplate fp "email/new-creator-welcome.txt" @@ -93,6 +101,7 @@ loadTeamTemplates o = readLocalesDir defLocale (templateDir gOptions) "team" $ \ gOptions = general (emailSMS o) tOptions = team (emailSMS o) tUrl = template $ tInvitationUrl tOptions + tExistingUrl = template $ tExistingUserInvitationUrl tOptions defLocale = setDefaultTemplateLocale (optSettings o) readTemplate = readTemplateWithDefault (templateDir gOptions) defLocale "team" readText = readTextWithDefault (templateDir gOptions) defLocale "team" diff --git a/services/brig/src/Brig/User/Search/Index.hs b/services/brig/src/Brig/User/Search/Index.hs index 24d8ec75016..afaca2554fd 100644 --- a/services/brig/src/Brig/User/Search/Index.hs +++ b/services/brig/src/Brig/User/Search/Index.hs @@ -706,6 +706,7 @@ lookupForIndex u = do "SELECT \ \id, \ \team, \ + \writetime(team), \ \name, \ \writetime(name), \ \status, \ @@ -757,6 +758,7 @@ scanForIndex num = do "SELECT \ \id, \ \team, \ + \writetime(team), \ \name, \ \writetime(name), \ \status, \ @@ -784,6 +786,7 @@ type Activated = Bool type ReindexRow = ( UserId, Maybe TeamId, + Maybe (Writetime TeamId), Name, Writetime Name, Maybe AccountStatus, @@ -808,12 +811,13 @@ type ReindexRow = -- the _2 lens does not work for a tuple this big teamInReindexRow :: ReindexRow -> Maybe TeamId -teamInReindexRow (_f1, f2, _f3, _f4, _f5, _f6, _f7, _f8, _f9, _f10, _f11, _f12, _f13, _f14, _f15, _f16, _f17, _f18, _f19, _f20, _f21, _f22) = f2 +teamInReindexRow (_f1, f2, _f3, _f4, _f5, _f6, _f7, _f8, _f9, _f10, _f11, _f12, _f13, _f14, _f15, _f16, _f17, _f18, _f19, _f20, _f21, _f22, _f23) = f2 reindexRowToIndexUser :: forall m. (MonadThrow m) => ReindexRow -> SearchVisibilityInbound -> m IndexUser reindexRowToIndexUser ( u, mteam, + tTeam, name, tName, status, @@ -849,7 +853,8 @@ reindexRowToIndexUser v <$> tService, v <$> tManagedBy, v <$> tSsoId, - v <$> tEmailUnvalidated + v <$> tEmailUnvalidated, + v <$> tTeam ] pure $ if shouldIndex diff --git a/services/nginz/integration-test/conf/nginz/nginx.conf b/services/nginz/integration-test/conf/nginz/nginx.conf index 41be5df60bf..95b560f7b1d 100644 --- a/services/nginz/integration-test/conf/nginz/nginx.conf +++ b/services/nginz/integration-test/conf/nginz/nginx.conf @@ -211,6 +211,11 @@ http { proxy_pass http://brig; } + location ~* ^(/v[0-9]+)?/teams/invitations/accept$ { + include common_response_with_zauth.conf; + proxy_pass http://brig; + } + location ~* ^(/v[0-9]+)?/teams/invitations/([^/]*)$ { include common_response_no_zauth.conf; proxy_pass http://brig;