diff --git a/changelog.d/2-features/pr-2407 b/changelog.d/2-features/pr-2407 new file mode 100644 index 0000000000..3af661fc26 --- /dev/null +++ b/changelog.d/2-features/pr-2407 @@ -0,0 +1 @@ +CSV export in team management now includes the number of devices per user diff --git a/libs/wire-api/src/Wire/API/Team/Export.hs b/libs/wire-api/src/Wire/API/Team/Export.hs index 4cffed4cf2..ac9cd8a824 100644 --- a/libs/wire-api/src/Wire/API/Team/Export.hs +++ b/libs/wire-api/src/Wire/API/Team/Export.hs @@ -49,7 +49,8 @@ data TeamExportUser = TeamExportUser tExportSAMLNamedId :: Text, -- If SAML IdP and SCIM peer are set up correctly, 'tExportSAMLNamedId' and 'tExportSCIMExternalId' always align. tExportSCIMExternalId :: Text, tExportSCIMRichInfo :: Maybe RichInfo, - tExportUserId :: UserId + tExportUserId :: UserId, + tExportNumDevices :: Int } deriving (Show, Eq, Generic) deriving (Arbitrary) via (GenericUniform TeamExportUser) @@ -68,7 +69,8 @@ instance ToNamedRecord TeamExportUser where ("saml_name_id", secureCsvFieldToByteString (tExportSAMLNamedId row)), ("scim_external_id", secureCsvFieldToByteString (tExportSCIMExternalId row)), ("scim_rich_info", maybe "" (cs . Aeson.encode) (tExportSCIMRichInfo row)), - ("user_id", secureCsvFieldToByteString (tExportUserId row)) + ("user_id", secureCsvFieldToByteString (tExportUserId row)), + ("num_devices", secureCsvFieldToByteString (tExportNumDevices row)) ] secureCsvFieldToByteString :: forall a. ToByteString a => a -> ByteString @@ -89,7 +91,8 @@ instance DefaultOrdered TeamExportUser where "saml_name_id", "scim_external_id", "scim_rich_info", - "user_id" + "user_id", + "num_devices" ] allowEmpty :: (ByteString -> Parser a) -> ByteString -> Parser (Maybe a) @@ -117,6 +120,7 @@ instance FromNamedRecord TeamExportUser where <*> (nrec .: "scim_external_id" >>= parseByteString) <*> (nrec .: "scim_rich_info" >>= allowEmpty (maybe (fail "failed to decode RichInfo") pure . Aeson.decode . cs)) <*> (nrec .: "user_id" >>= parseByteString) + <*> (nrec .: "num_devices" >>= parseByteString) quoted :: ByteString -> ByteString quoted bs = case C.uncons bs of diff --git a/services/galley/src/Galley/API/Teams.hs b/services/galley/src/Galley/API/Teams.hs index 283279c6d3..1c9b0ce032 100644 --- a/services/galley/src/Galley/API/Teams.hs +++ b/services/galley/src/Galley/API/Teams.hs @@ -519,10 +519,11 @@ getTeamMembersCSVH (zusr ::: tid ::: _) = do lookupUser <$> E.lookupActivatedUsers (fmap (view userId) members) richInfos <- lookupRichInfo <$> E.getRichInfoMultiUser (fmap (view userId) members) + numUserClients <- lookupClients <$> E.lookupClients (fmap (view userId) members) output @LByteString ( encodeDefaultOrderedByNameWith defaultEncodeOptions - (mapMaybe (teamExportUser users inviters richInfos) members) + (mapMaybe (teamExportUser users inviters richInfos numUserClients) members) ) pure $ responseStream @@ -548,9 +549,10 @@ getTeamMembersCSVH (zusr ::: tid ::: _) = do (UserId -> Maybe User) -> (UserId -> Maybe Handle.Handle) -> (UserId -> Maybe RichInfo) -> + (UserId -> Int) -> TeamMember -> Maybe TeamExportUser - teamExportUser users inviters richInfos member = do + teamExportUser users inviters richInfos numClients member = do let uid = member ^. userId user <- users uid pure $ @@ -566,7 +568,8 @@ getTeamMembersCSVH (zusr ::: tid ::: _) = do tExportSAMLNamedId = fromMaybe "" (samlNamedId user), tExportSCIMExternalId = fromMaybe "" (userSCIMExternalId user), tExportSCIMRichInfo = richInfos uid, - tExportUserId = U.userId user + tExportUserId = U.userId user, + tExportNumDevices = numClients uid } lookupInviterHandle :: Member BrigAccess r => [TeamMember] -> Sem r (UserId -> Maybe Handle.Handle) @@ -595,6 +598,9 @@ getTeamMembersCSVH (zusr ::: tid ::: _) = do lookupRichInfo :: [(UserId, RichInfo)] -> (UserId -> Maybe RichInfo) lookupRichInfo pairs = (`M.lookup` M.fromList pairs) + lookupClients :: Conv.UserClients -> UserId -> Int + lookupClients userClients uid = maybe 0 length (M.lookup uid (Conv.userClients userClients)) + samlNamedId :: User -> Maybe Text samlNamedId = userSSOId >=> \case diff --git a/services/galley/test/integration/API/Teams.hs b/services/galley/test/integration/API/Teams.hs index 3803e17628..64510e7a08 100644 --- a/services/galley/test/integration/API/Teams.hs +++ b/services/galley/test/integration/API/Teams.hs @@ -46,6 +46,7 @@ import Data.Json.Util hiding ((#)) import qualified Data.LegalHold as LH import Data.List1 import qualified Data.List1 as List1 +import qualified Data.Map as Map import Data.Misc (HttpsUrl, PlainTextPassword (..), mkHttpsUrl) import Data.Qualified import Data.Range @@ -86,6 +87,8 @@ import qualified Wire.API.Team.Member as Member import qualified Wire.API.Team.Member as TM import qualified Wire.API.User as Public import qualified Wire.API.User as U +import qualified Wire.API.User.Client as C +import qualified Wire.API.User.Client.Prekey as PC tests :: IO TestSetup -> TestTree tests s = @@ -286,7 +289,9 @@ testListTeamMembersCsv :: HasCallStack => Int -> TestM () testListTeamMembersCsv numMembers = do let teamSize = numMembers + 1 - (owner, tid, _mbs) <- Util.createBindingTeamWithNMembersWithHandles True numMembers + (owner, tid, mbs) <- Util.createBindingTeamWithNMembersWithHandles True numMembers + let numClientMappings = Map.fromList $ (owner : mbs) `zip` (cycle [1, 2, 3] :: [Int]) + addClients numClientMappings resp <- Util.getTeamMembersCsv owner tid let rbody = fromMaybe (error "no body") . responseBody $ resp usersInCsv <- either (error "could not decode csv") pure (decodeCSV @TeamExportUser rbody) @@ -322,6 +327,7 @@ testListTeamMembersCsv numMembers = do assertEqual ("tExportIdpIssuer: " <> show (U.userId user)) (userToIdPIssuer user) (tExportIdpIssuer export) assertEqual ("tExportManagedBy: " <> show (U.userId user)) (U.userManagedBy user) (tExportManagedBy export) assertEqual ("tExportUserId: " <> show (U.userId user)) (U.userId user) (tExportUserId export) + assertEqual ("tExportNumDevices: ") (Map.findWithDefault (-1) (U.userId user) numClientMappings) (tExportNumDevices export) where userToIdPIssuer :: HasCallStack => U.User -> Maybe HttpsUrl userToIdPIssuer usr = case (U.userIdentity >=> U.ssoIdentity) usr of @@ -335,6 +341,20 @@ testListTeamMembersCsv numMembers = do countOn :: Eq b => (a -> b) -> b -> [a] -> Int countOn prop val xs = sum $ fmap (bool 0 1 . (== val) . prop) xs + addClients :: Map.Map UserId Int -> TestM () + addClients xs = forM_ (Map.toList xs) addClientForUser + + addClientForUser :: (UserId, Int) -> TestM () + addClientForUser (uid, n) = forM_ [0 .. (n -1)] (addClient uid) + + addClient :: UserId -> Int -> TestM () + addClient uid i = do + brig <- view tsBrig + post (brig . paths ["i", "clients", toByteString' uid] . contentJson . json (newClient (someLastPrekeys !! i)) . queryItem "skip_reauth" "true") !!! const 201 === statusCode + + newClient :: PC.LastPrekey -> C.NewClient + newClient lpk = C.newClient C.PermanentClientType lpk + testListTeamMembersTruncated :: TestM () testListTeamMembersTruncated = do (owner, tid, _) <- Util.createBindingTeamWithNMembers 4