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
1 change: 1 addition & 0 deletions changelog.d/2-features/pr-2407
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
CSV export in team management now includes the number of devices per user
10 changes: 7 additions & 3 deletions libs/wire-api/src/Wire/API/Team/Export.hs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down
12 changes: 9 additions & 3 deletions services/galley/src/Galley/API/Teams.hs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 $
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down
22 changes: 21 additions & 1 deletion services/galley/test/integration/API/Teams.hs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down