From 157fb2d2d4958a237318e9759998e2f50b483ede Mon Sep 17 00:00:00 2001
From: Moritz Hofmeister <62036655+hofi99@users.noreply.github.com>
Date: Tue, 17 Aug 2021 10:45:58 +0200
Subject: [PATCH] Migrate to ktor client and kotlinx.serialization (#100)

---
 .idea/codeStyles/Project.xml                  |   1 +
 common/build.gradle.kts                       |   7 +-
 .../studo/campusqr/common/ClientPayloads.kt   |  59 ++++---
 moderatorFrontend/build.gradle.kts            |   5 +
 .../AccessManagementDetails.kt                |  34 ++--
 .../AccessManagementRow.kt                    |  10 +-
 .../views/guestCheckIn/AddGuestCheckIn.kt     |   3 +-
 .../guestCheckInOverview/GuestCheckInRow.kt   |   6 +-
 .../kotlin/views/locations/AddLocation.kt     |   6 +-
 .../locationsOverview/LocationTableRow.kt     |   7 +-
 .../src/main/kotlin/views/login/MailLogin.kt  |   5 +-
 .../src/main/kotlin/views/report/Report.kt    |  18 +--
 .../src/main/kotlin/views/users/AddUser.kt    |  11 +-
 .../main/kotlin/views/users/UserTableRow.kt   |   7 +-
 .../src/main/kotlin/webcore/NetworkManager.kt | 145 ++++++++----------
 .../com/studo/campusqr/database/Payloads.kt   |   6 +-
 .../campusqr/endpoints/AccessManagement.kt    |   8 +-
 .../studo/campusqr/endpoints/ReportData.kt    |  11 +-
 18 files changed, 164 insertions(+), 185 deletions(-)

diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml
index 6a3b19e..b05025b 100644
--- a/.idea/codeStyles/Project.xml
+++ b/.idea/codeStyles/Project.xml
@@ -8,6 +8,7 @@
       </value>
     </option>
     <option name="RIGHT_MARGIN" value="140" />
+    <option name="FORMATTER_TAGS_ENABLED" value="true" />
     <JetCodeStyleSettings>
       <option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
     </JetCodeStyleSettings>
diff --git a/common/build.gradle.kts b/common/build.gradle.kts
index ce3b658..8b692f5 100644
--- a/common/build.gradle.kts
+++ b/common/build.gradle.kts
@@ -1,5 +1,6 @@
 plugins {
   kotlin("multiplatform")
+  kotlin("plugin.serialization") version "1.5.20"
 }
 
 kotlin {
@@ -10,7 +11,11 @@ kotlin {
   }
 
   sourceSets {
-    val commonMain by getting
+    val commonMain by getting {
+      dependencies {
+        api("org.jetbrains.kotlinx:kotlinx-serialization-json:1.2.1")
+      }
+    }
     val commonTest by getting
   }
 }
diff --git a/common/src/commonMain/kotlin/com/studo/campusqr/common/ClientPayloads.kt b/common/src/commonMain/kotlin/com/studo/campusqr/common/ClientPayloads.kt
index 1953440..2534ea8 100644
--- a/common/src/commonMain/kotlin/com/studo/campusqr/common/ClientPayloads.kt
+++ b/common/src/commonMain/kotlin/com/studo/campusqr/common/ClientPayloads.kt
@@ -1,18 +1,19 @@
 package com.studo.campusqr.common
 
+import kotlinx.serialization.Serializable
 
 interface ClientPayload
 
+@Serializable
 class ClientLocation(
   val id: String,
   val name: String,
   var checkInCount: Int?,
-  val accessType: String,
+  val accessType: LocationAccessType,
   val seatCount: Int?,
 ) : ClientPayload
 
-val ClientLocation.accessTypeEnum get() = LocationAccessType.valueOf(accessType)
-
+@Serializable
 class UserData(
   var appName: String,
   var clientUser: ClientUser? = null, // Null when unauthenticated
@@ -22,17 +23,19 @@ class UserData(
 
 val UserData.isAuthenticated get() = clientUser != null
 
+@Serializable
 class ReportData(
   val impactedUsersCount: Int,
-  val impactedUsersEmails: Array<String>,
+  val impactedUsersEmails: List<String>,
   val impactedUsersEmailsCsvData: String,
-  val reportedUserLocations: Array<UserLocation>,
+  val reportedUserLocations: List<UserLocation>,
   val reportedUserLocationsCsv: String,
   val reportedUserLocationsCsvFileName: String,
   val startDate: String,
   val endDate: String,
   val impactedUsersEmailsCsvFileName: String,
 ) : ClientPayload {
+  @Serializable
   class UserLocation(
     val locationId: String,
     val locationName: String,
@@ -41,25 +44,25 @@ class ReportData(
     val date: String,
     val seat: Int?,
     val potentialContacts: Int,
-    val filteredSeats: Array<Int>?,
+    val filteredSeats: List<Int>?,
   )
 }
 
+@Serializable
 class LocationVisitData(
   val csvData: String,
   val csvFileName: String,
 ) : ClientPayload
 
+@Serializable
 class ClientUser(
   val id: String,
   val email: String,
   val name: String,
-  val permissionsRaw: Array<String>,
+  val permissions: Set<UserPermission>,
   val firstLoginDate: String,
 ) : ClientPayload
 
-val ClientUser.permissions: Set<UserPermission> get() = permissionsRaw.map { UserPermission.valueOf(it) }.toSet()
-
 // Keep in sync with BackendUser
 val ClientUser.canEditUsers get() = UserPermission.EDIT_USERS in permissions
 val ClientUser.canEditLocations get() = UserPermission.EDIT_LOCATIONS in permissions
@@ -68,85 +71,98 @@ val ClientUser.canEditAnyLocationAccess get() = canEditOwnLocationAccess || canE
 val ClientUser.canEditOwnLocationAccess get() = UserPermission.EDIT_OWN_ACCESS in permissions
 val ClientUser.canEditAllLocationAccess get() = UserPermission.EDIT_ALL_ACCESS in permissions
 
+@Serializable
 class AccessManagementData(
-  val accessManagement: Array<ClientAccessManagement>,
+  val accessManagement: List<ClientAccessManagement>,
   val clientLocation: ClientLocation?,
 ) : ClientPayload
 
+@Serializable
 class AccessManagementExportData(
-  val permits: Array<Permit>,
+  val permits: List<Permit>,
   val clientLocation: ClientLocation?,
 ) : ClientPayload {
+  @Serializable
   class Permit(
     val dateRange: ClientDateRange,
     val email: String,
   )
 }
 
+@Serializable
 class ClientAccessManagement(
   val id: String,
   val locationName: String,
   val locationId: String,
-  val allowedEmails: Array<String>,
-  val dateRanges: Array<ClientDateRange>,
+  val allowedEmails: List<String>,
+  val dateRanges: List<ClientDateRange>,
   val note: String,
   val reason: String,
 ) : ClientPayload
 
+@Serializable
 class ClientDateRange(
   val from: Double,
   val to: Double,
 ) : ClientPayload
 
+@Serializable
 class NewAccess(
   val locationId: String,
-  val allowedEmails: Array<String>,
-  val dateRanges: Array<ClientDateRange>,
+  val allowedEmails: List<String>,
+  val dateRanges: List<ClientDateRange>,
   val note: String,
   val reason: String,
 ) : ClientPayload
 
+@Serializable
 class NewUserData(
   val email: String,
   val name: String,
   val password: String,
-  val permissions: Array<String>,
+  val permissions: List<String>,
 ) : ClientPayload
 
+@Serializable
 class EditUserData(
   val userId: String? = null,
   val name: String?,
   val password: String?,
-  val permissions: Array<String>?,
+  val permissions: List<String>?,
 ) : ClientPayload
 
+@Serializable
 class EditAccess(
   val locationId: String? = null,
-  val allowedEmails: Array<String>? = null,
-  val dateRanges: Array<ClientDateRange>? = null,
+  val allowedEmails: List<String>? = null,
+  val dateRanges: List<ClientDateRange>? = null,
   val note: String? = null,
   val reason: String? = null,
 ) : ClientPayload
 
+@Serializable
 class CreateLocation(
   val name: String,
   val accessType: LocationAccessType,
   val seatCount: Int?,
 ) : ClientPayload
 
+@Serializable
 class EditLocation(
   val name: String,
   val accessType: LocationAccessType,
   val seatCount: Int?,
 ) : ClientPayload
 
+@Serializable
 class ClientSeatFilter(
   val id: String,
   val locationId: String,
   val seat: Int,
-  val filteredSeats: Array<Int>,
+  val filteredSeats: List<Int>,
 ) : ClientPayload
 
+@Serializable
 class ActiveCheckIn(
   val id: String,
   val locationId: String,
@@ -156,15 +172,18 @@ class ActiveCheckIn(
   val email: String,
 ) : ClientPayload
 
+@Serializable
 class EditSeatFilter(
   val seat: Int,
   val filteredSeats: List<Int>,
 ) : ClientPayload
 
+@Serializable
 class DeleteSeatFilter(
   val seat: Int,
 ) : ClientPayload
 
+@Serializable
 class LiveCheckIn(
   val activeCheckIns: Int,
   val qrCodeContent: String?
diff --git a/moderatorFrontend/build.gradle.kts b/moderatorFrontend/build.gradle.kts
index a86e447..e728858 100644
--- a/moderatorFrontend/build.gradle.kts
+++ b/moderatorFrontend/build.gradle.kts
@@ -23,6 +23,11 @@ dependencies {
   api("org.jetbrains.kotlin-wrappers:kotlin-react-dom:17.0.2-pre.212-kotlin-1.5.10")
   api("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.0")
 
+  // kotlinx-serialization + Ktor client
+  implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.2.1")
+  implementation("io.ktor:ktor-client-core:1.6.1")
+  implementation("io.ktor:ktor-client-serialization:1.6.1")
+
   implementation(npm("normalize.css", "8.0.1"))
   implementation(devNpm("style-loader", "2.0.0"))
   implementation(devNpm("css-loader", "5.2.6"))
diff --git a/moderatorFrontend/src/main/kotlin/views/accessManagement/AccessManagementDetails.kt b/moderatorFrontend/src/main/kotlin/views/accessManagement/AccessManagementDetails.kt
index d8a47b9..2dff8df 100644
--- a/moderatorFrontend/src/main/kotlin/views/accessManagement/AccessManagementDetails.kt
+++ b/moderatorFrontend/src/main/kotlin/views/accessManagement/AccessManagementDetails.kt
@@ -120,17 +120,15 @@ class AddLocation(props: AccessManagementDetailsProps) : RComponent<AccessManage
     setState { showProgress = true }
     val response = NetworkManager.post<String>(
       url = "$apiBase/access/create",
-      json = JSON.stringify(
-        NewAccess(
-          locationId = state.selectedLocation!!.id,
-          // Add state.getPermittedEmailsFromTextField(), to make sure that any un-submitted emails get added
-          allowedEmails = state.permittedPeopleList.toTypedArray() + state.getPermittedEmailsFromTextField(),
-          dateRanges = state.timeSlots.toTypedArray(),
-          note = state.accessControlNoteTextFieldValue,
-          reason = state.accessControlReasonTextFieldValue
-        )
+      body = NewAccess(
+        locationId = state.selectedLocation!!.id,
+        // Add state.getPermittedEmailsFromTextField(), to make sure that any un-submitted emails get added
+        allowedEmails = state.permittedPeopleList + state.getPermittedEmailsFromTextField(),
+        dateRanges = state.timeSlots,
+        note = state.accessControlNoteTextFieldValue,
+        reason = state.accessControlReasonTextFieldValue
+      )
       )
-    )
     setState {
       showProgress = false
     }
@@ -142,15 +140,13 @@ class AddLocation(props: AccessManagementDetailsProps) : RComponent<AccessManage
     val accessManagementId = (props.config as Config.Edit).accessManagement.id
     val response = NetworkManager.post<String>(
       url = "$apiBase/access/$accessManagementId/edit",
-      json = JSON.stringify(
-        EditAccess(
-          locationId = state.selectedLocation?.id,
-          // Add state.getPermittedEmailsFromTextField(), to make sure that any un-submitted emails get added
-          allowedEmails = state.permittedPeopleList.toTypedArray() + state.getPermittedEmailsFromTextField(),
-          dateRanges = state.timeSlots.toTypedArray(),
-          note = state.accessControlNoteTextFieldValue,
-          reason = state.accessControlReasonTextFieldValue
-        )
+      body = EditAccess(
+        locationId = state.selectedLocation?.id,
+        // Add state.getPermittedEmailsFromTextField(), to make sure that any un-submitted emails get added
+        allowedEmails = state.permittedPeopleList + state.getPermittedEmailsFromTextField(),
+        dateRanges = state.timeSlots,
+        note = state.accessControlNoteTextFieldValue,
+        reason = state.accessControlReasonTextFieldValue
       )
     )
     setState {
diff --git a/moderatorFrontend/src/main/kotlin/views/accessManagement/accessManagementOverview/AccessManagementRow.kt b/moderatorFrontend/src/main/kotlin/views/accessManagement/accessManagementOverview/AccessManagementRow.kt
index 5f08c8b..76c72a1 100644
--- a/moderatorFrontend/src/main/kotlin/views/accessManagement/accessManagementOverview/AccessManagementRow.kt
+++ b/moderatorFrontend/src/main/kotlin/views/accessManagement/accessManagementOverview/AccessManagementRow.kt
@@ -150,20 +150,14 @@ class AccessManagementTableRow : RComponent<AccessManagementTableRowProps, Acces
               }),
               MenuItem(text = Strings.duplicate.get(), icon = fileCopyOutlinedIcon, onClick = {
                 launch {
-                  val response = NetworkManager.post<String>(
-                    "$apiBase/access/${props.config.accessManagement.id}/duplicate",
-                    params = null
-                  )
+                  val response = NetworkManager.post<String>("$apiBase/access/${props.config.accessManagement.id}/duplicate")
                   props.config.onOperationFinished(Operation.Duplicate, response == "ok")
                 }
               }),
               MenuItem(text = Strings.delete.get(), icon = deleteIcon, onClick = {
                 if (window.confirm(Strings.access_control_delete_are_your_sure.get())) {
                   launch {
-                    val response = NetworkManager.post<String>(
-                      "$apiBase/access/${props.config.accessManagement.id}/delete",
-                      params = null
-                    )
+                    val response = NetworkManager.post<String>("$apiBase/access/${props.config.accessManagement.id}/delete")
                     props.config.onOperationFinished(Operation.Delete, response == "ok")
                   }
                 }
diff --git a/moderatorFrontend/src/main/kotlin/views/guestCheckIn/AddGuestCheckIn.kt b/moderatorFrontend/src/main/kotlin/views/guestCheckIn/AddGuestCheckIn.kt
index 9f43148..94d2386 100644
--- a/moderatorFrontend/src/main/kotlin/views/guestCheckIn/AddGuestCheckIn.kt
+++ b/moderatorFrontend/src/main/kotlin/views/guestCheckIn/AddGuestCheckIn.kt
@@ -21,7 +21,6 @@ import webcore.materialUI.muiAutocomplete
 import webcore.materialUI.muiButton
 import webcore.materialUI.textField
 import webcore.materialUI.withStyles
-import kotlin.js.json
 
 interface AddGuestCheckInProps : RProps {
   class Config(
@@ -83,7 +82,7 @@ class AddGuestCheckIn : RComponent<AddGuestCheckInProps, AddGuestCheckInState>()
     val locationId = locationIdWithSeat(state.selectedLocation!!.id, state.seatInputValue)
     val response = NetworkManager.post<String>(
       url = "$baseUrl/location/$locationId/guestCheckIn",
-      params = json("email" to state.personEmailTextFieldValue)
+      body = mapOf("email" to state.personEmailTextFieldValue)
     )
     setState {
       showProgress = false
diff --git a/moderatorFrontend/src/main/kotlin/views/guestCheckIn/guestCheckInOverview/GuestCheckInRow.kt b/moderatorFrontend/src/main/kotlin/views/guestCheckIn/guestCheckInOverview/GuestCheckInRow.kt
index 1c90a54..d047703 100644
--- a/moderatorFrontend/src/main/kotlin/views/guestCheckIn/guestCheckInOverview/GuestCheckInRow.kt
+++ b/moderatorFrontend/src/main/kotlin/views/guestCheckIn/guestCheckInOverview/GuestCheckInRow.kt
@@ -17,7 +17,6 @@ import webcore.materialUI.mTableCell
 import webcore.materialUI.mTableRow
 import webcore.materialUI.muiButton
 import webcore.materialUI.withStyles
-import kotlin.js.json
 
 interface GuestCheckInRowProps : RProps {
   var classes: GuestCheckInRowClasses
@@ -55,9 +54,8 @@ class GuestCheckInRow : RComponent<GuestCheckInRowProps, GuestCheckInRowState>()
               val locationId = with(props.config.activeCheckIn) { locationIdWithSeat(locationId, seat) }
               launch {
                 val response = NetworkManager.post<String>(
-                  "$apiBase/location/$locationId/checkout", params = json(
-                    "email" to props.config.activeCheckIn.email
-                  )
+                  "$apiBase/location/$locationId/checkout",
+                  body = mapOf("email" to props.config.activeCheckIn.email)
                 )
                 if (response == "ok") {
                   props.config.onCheckedOut()
diff --git a/moderatorFrontend/src/main/kotlin/views/locations/AddLocation.kt b/moderatorFrontend/src/main/kotlin/views/locations/AddLocation.kt
index e150b23..3866feb 100644
--- a/moderatorFrontend/src/main/kotlin/views/locations/AddLocation.kt
+++ b/moderatorFrontend/src/main/kotlin/views/locations/AddLocation.kt
@@ -3,7 +3,6 @@ package views.locations
 import app.GlobalCss
 import com.studo.campusqr.common.ClientLocation
 import com.studo.campusqr.common.LocationAccessType
-import com.studo.campusqr.common.accessTypeEnum
 import com.studo.campusqr.common.extensions.format
 import kotlinext.js.js
 import org.w3c.dom.events.Event
@@ -19,7 +18,6 @@ import webcore.NetworkManager
 import webcore.extensions.inputValue
 import webcore.extensions.launch
 import webcore.materialUI.*
-import kotlin.js.json
 
 interface AddLocationProps : RProps {
   sealed class Config(val onFinished: (response: String?) -> Unit) {
@@ -45,7 +43,7 @@ class AddLocation(props: AddLocationProps) : RComponent<AddLocationProps, AddLoc
     locationCreationInProgress = false
     locationTextFieldError = ""
     locationTextFieldValue = (props.config as? Config.Edit)?.location?.name ?: ""
-    locationAccessType = (props.config as? Config.Edit)?.location?.accessTypeEnum ?: LocationAccessType.FREE
+    locationAccessType = (props.config as? Config.Edit)?.location?.accessType ?: LocationAccessType.FREE
     locationSeatCount = (props.config as? Config.Edit)?.location?.seatCount
   }
 
@@ -57,7 +55,7 @@ class AddLocation(props: AddLocationProps) : RComponent<AddLocationProps, AddLoc
     }
     val response = NetworkManager.post<String>(
       url = url,
-      params = json(
+      body = mapOf(
         "name" to state.locationTextFieldValue,
         "accessType" to state.locationAccessType.name,
         "seatCount" to state.locationSeatCount
diff --git a/moderatorFrontend/src/main/kotlin/views/locations/locationsOverview/LocationTableRow.kt b/moderatorFrontend/src/main/kotlin/views/locations/locationsOverview/LocationTableRow.kt
index 9d69469..19b7b7c 100644
--- a/moderatorFrontend/src/main/kotlin/views/locations/locationsOverview/LocationTableRow.kt
+++ b/moderatorFrontend/src/main/kotlin/views/locations/locationsOverview/LocationTableRow.kt
@@ -77,7 +77,7 @@ class LocationTableRow : RComponent<LocationTableRowProps, LocationTableRowState
         }
       }
       mTableCell {
-        +props.config.location.accessTypeEnum.localizedString.get()
+        +props.config.location.accessType.localizedString.get()
       }
       mTableCell {
         +(props.config.location.seatCount?.toString() ?: Strings.undefined.get())
@@ -135,10 +135,7 @@ class LocationTableRow : RComponent<LocationTableRowProps, LocationTableRowState
                   MenuItem(text = Strings.location_delete.get(), icon = deleteIcon, onClick = {
                     if (window.confirm(Strings.location_delete_are_you_sure.get())) {
                       launch {
-                        val response = NetworkManager.post<String>(
-                          "$apiBase/location/${props.config.location.id}/delete",
-                          params = null
-                        )
+                        val response = NetworkManager.post<String>("$apiBase/location/${props.config.location.id}/delete")
                         props.config.onDeleteFinished(response)
                       }
                     }
diff --git a/moderatorFrontend/src/main/kotlin/views/login/MailLogin.kt b/moderatorFrontend/src/main/kotlin/views/login/MailLogin.kt
index 08fa4fd..d1a9115 100644
--- a/moderatorFrontend/src/main/kotlin/views/login/MailLogin.kt
+++ b/moderatorFrontend/src/main/kotlin/views/login/MailLogin.kt
@@ -21,7 +21,6 @@ import webcore.extensions.launch
 import webcore.materialUI.textField
 import webcore.materialUI.typography
 import webcore.materialUI.withStyles
-import kotlin.js.json
 
 interface MailLoginProps : RProps {
   var classes: MailLoginClasses
@@ -47,11 +46,11 @@ class MailLogin : LoginDetailComponent<MailLoginProps, MailLoginState>() {
     launch {
       val response: LoginResult? = NetworkManager.post<String>(
         url = "$apiBase/user/login",
-        params = json(
+        body = mapOf(
           "email" to state.email,
           "password" to state.password
         ),
-        headers = json(
+        headers = mapOf(
           "csrfToken" to document.querySelector("meta[name='csrfToken']")!!.getAttribute("content")!!
         )
       )?.let { result -> LoginResult.values().find { it.name == result } ?: LoginResult.UNKNOWN_ERROR }
diff --git a/moderatorFrontend/src/main/kotlin/views/report/Report.kt b/moderatorFrontend/src/main/kotlin/views/report/Report.kt
index ad06db5..7fca229 100644
--- a/moderatorFrontend/src/main/kotlin/views/report/Report.kt
+++ b/moderatorFrontend/src/main/kotlin/views/report/Report.kt
@@ -24,7 +24,6 @@ import webcore.extensions.inputValue
 import webcore.extensions.launch
 import webcore.materialUI.*
 import kotlin.js.Date
-import kotlin.js.json
 
 interface ReportProps : RProps {
   var classes: ReportClasses
@@ -78,10 +77,8 @@ class Report : RComponent<ReportProps, ReportState>() {
       deleteFilter(userLocation)
     } else {
       val response = NetworkManager.post<String>(
-        "$apiBase/location/${userLocation.locationId}/editSeatFilter", params = json(
-          "seat" to userLocation.seat,
-          "filteredSeats" to filteredSeats
-        )
+        "$apiBase/location/${userLocation.locationId}/editSeatFilter",
+        body = mapOf("seat" to userLocation.seat, "filteredSeats" to filteredSeats)
       )
       setState {
         if (response == "ok") {
@@ -98,9 +95,8 @@ class Report : RComponent<ReportProps, ReportState>() {
   private fun deleteFilter(userLocation: ReportData.UserLocation) = launch {
     setState { showProgress = true }
     val response = NetworkManager.post<String>(
-      "$apiBase/location/${userLocation.locationId}/deleteSeatFilter", params = json(
-        "seat" to userLocation.seat
-      )
+      "$apiBase/location/${userLocation.locationId}/deleteSeatFilter",
+      body = mapOf("seat" to userLocation.seat)
     )
     setState {
       if (response == "ok") {
@@ -116,10 +112,8 @@ class Report : RComponent<ReportProps, ReportState>() {
   private fun traceContacts() = launch {
     setState { showProgress = true }
     val response = NetworkManager.post<ReportData>(
-      "$apiBase/report/list", params = json(
-        "email" to state.emailTextFieldValue,
-        "oldestDate" to state.infectionDate.getTime().toString()
-      )
+      "$apiBase/report/list",
+      body = mapOf("email" to state.emailTextFieldValue, "oldestDate" to state.infectionDate.getTime().toString())
     )
     setState {
       showProgress = false
diff --git a/moderatorFrontend/src/main/kotlin/views/users/AddUser.kt b/moderatorFrontend/src/main/kotlin/views/users/AddUser.kt
index 8970acc..31d8fb8 100644
--- a/moderatorFrontend/src/main/kotlin/views/users/AddUser.kt
+++ b/moderatorFrontend/src/main/kotlin/views/users/AddUser.kt
@@ -67,14 +67,12 @@ class AddUser(props: AddUserProps) : RComponent<AddUserProps, AddUserState>(prop
     setState { userCreationInProgress = true }
     val response = NetworkManager.post<String>(
       url = "$apiBase/user/create",
-      json = JSON.stringify(
-        NewUserData(
+      body = NewUserData(
           email = state.userEmailTextFieldValue,
           password = state.userPasswordTextFieldValue,
           name = state.userNameTextFieldValue,
-          permissions = state.userPermissions.map { it.name }.toTypedArray()
+          permissions = state.userPermissions.map { it.name }
         )
-      )
     )
     setState {
       userCreationInProgress = false
@@ -86,17 +84,14 @@ class AddUser(props: AddUserProps) : RComponent<AddUserProps, AddUserState>(prop
     setState { userCreationInProgress = true }
     val response = NetworkManager.post<String>(
       url = "$apiBase/user/edit",
-      json = JSON.stringify(
-        EditUserData(
+      body = EditUserData(
           userId = (props.config as Config.Edit).user.id,
           name = state.userNameTextFieldValue.emptyToNull(),
           password = state.userPasswordTextFieldValue.emptyToNull(),
           permissions = state.userPermissions
             .takeIf { (props.config as Config.Edit).user.permissions != it }
             ?.map { it.name }
-            ?.toTypedArray()
         )
-      )
     )
     setState {
       userCreationInProgress = false
diff --git a/moderatorFrontend/src/main/kotlin/views/users/UserTableRow.kt b/moderatorFrontend/src/main/kotlin/views/users/UserTableRow.kt
index 9aa08b2..23f728a 100644
--- a/moderatorFrontend/src/main/kotlin/views/users/UserTableRow.kt
+++ b/moderatorFrontend/src/main/kotlin/views/users/UserTableRow.kt
@@ -2,7 +2,6 @@ package views.users
 
 import com.studo.campusqr.common.ClientUser
 import com.studo.campusqr.common.UserData
-import com.studo.campusqr.common.permissions
 import kotlinext.js.js
 import kotlinx.browser.window
 import react.*
@@ -15,7 +14,6 @@ import util.localizedString
 import webcore.*
 import webcore.extensions.launch
 import webcore.materialUI.*
-import kotlin.js.json
 
 interface UserTableRowProps : RProps {
   class Config(
@@ -113,9 +111,8 @@ class UserTableRow : RComponent<UserTableRowProps, UserTableRowState>() {
               if (window.confirm(Strings.user_delete_are_you_sure.get())) {
                 launch {
                   val response = NetworkManager.post<String>(
-                    "$apiBase/user/delete", params = json(
-                      "userId" to props.config.user.id
-                    )
+                    "$apiBase/user/delete",
+                    body = mapOf("userId" to props.config.user.id)
                   )
                   props.config.onEditFinished(response)
                 }
diff --git a/moderatorFrontend/src/main/kotlin/webcore/NetworkManager.kt b/moderatorFrontend/src/main/kotlin/webcore/NetworkManager.kt
index 2e04b25..4355ee5 100644
--- a/moderatorFrontend/src/main/kotlin/webcore/NetworkManager.kt
+++ b/moderatorFrontend/src/main/kotlin/webcore/NetworkManager.kt
@@ -1,62 +1,84 @@
 package webcore
 
-import kotlinext.js.getOwnPropertyNames
+import io.ktor.client.*
+import io.ktor.client.call.*
+import io.ktor.client.features.json.*
+import io.ktor.client.features.json.serializer.*
+import io.ktor.client.request.*
+import io.ktor.client.statement.*
+import io.ktor.http.*
+import io.ktor.util.*
 import kotlinx.browser.window
-import org.w3c.dom.url.URLSearchParams
-import org.w3c.fetch.RequestInit
-import org.w3c.fetch.Response
-import webcore.extensions.await
-import webcore.extensions.awaitOrNull
-import kotlin.js.Json
-import kotlin.js.Promise
-import kotlin.reflect.KClass
 
+/**
+ * NetworkManager uses Ktor client with kotlinx.serialization to create network requests.
+ * All functions in this object check the "x-version-hash" header and reload the application if necessary.
+ */
 object NetworkManager {
 
-  suspend inline fun <reified T : Any> get(url: String, urlParams: Json? = null): T? = get(url, urlParams, T::class)
-
-  suspend fun <T : Any> get(url: String, urlParams: Json?, kClass: KClass<T>): T? {
-    val urlWithParams = url + (urlParams?.let { "?" + URLSearchParams(urlParams) } ?: "")
-    return window.fetch(urlWithParams).parseResponse(kClass)
+  val client = HttpClient {
+    install(JsonFeature) {
+      serializer = KotlinxSerializer()
+    }
   }
 
-  suspend inline fun <reified T : Any> post(url: String, params: Json? = null, headers: Json? = null): T? =
-    post(url, params, T::class, headers)
-
-  suspend inline fun <reified T : Any> post(url: String, json: String? = null, headers: Json? = null): T? =
-    post(url, json, T::class, headers)
-
-  suspend fun <T : Any> post(url: String, params: Json? = null, kClass: KClass<T>, headers: Json? = null): T? {
-    return post(url, JSON.stringify(params), kClass, headers)
+  suspend inline fun <reified T : Any> get(
+    url: String,
+    urlParams: Map<String, Any?> = emptyMap(),
+    headers: Map<String, Any?> = emptyMap(),
+  ): T? = try {
+    val response: HttpResponse = client.get(url) {
+      headers.forEach { header(it.key, it.value) }
+      urlParams.forEach { parameter(it.key, it.value) }
+    }
+    response.reloadIfLocalVersionIsOutdated()
+    response.receive<T>()
+  } catch (e: Exception) {
+    null
   }
 
-  suspend fun <T : Any> post(url: String, json: String? = null, kClass: KClass<T>, headers: Json? = null): T? {
-    val response = window.fetch(url, RequestInit().also { request ->
-      request.method = "POST"
-      json?.let { request.body = json }
-      headers?.let { request.headers = it }
-    })
-    return response.parseResponse(kClass)
+  suspend inline fun <reified T : Any> post(
+    url: String,
+    urlParams: Map<String, Any?> = emptyMap(),
+    body: Any? = null,
+    headers: Map<String, Any?> = emptyMap(),
+  ): T? = try {
+    val response: HttpResponse = client.post(url) {
+      body?.let {
+        contentType(ContentType.Application.Json)
+        this.body = body
+      }
+      headers.forEach { header(it.key, it.value) }
+      urlParams.forEach { parameter(it.key, it.value) }
+    }
+    response.reloadIfLocalVersionIsOutdated()
+    response.receive<T>()
+  } catch (e: Exception) {
+    null
   }
 
-  suspend inline fun <reified T : Any> put(url: String, params: Json? = null): T? = put(url, params, T::class)
-
-  suspend fun <T : Any> put(url: String, params: Json? = null, kClass: KClass<T>): T? {
-    val response = window.fetch(url, RequestInit().also { request ->
-      request.method = "PUT"
-      params?.let { request.body = JSON.stringify(it) }
-    })
-    return response.parseResponse(kClass)
+  suspend inline fun <reified T : Any> put(
+    url: String,
+    urlParams: Map<String, Any?> = emptyMap(),
+    headers: Map<String, Any?> = emptyMap(),
+  ): T? = try {
+    val response: HttpResponse = client.put(url) {
+      headers.forEach { header(it.key, it.value) }
+      urlParams.forEach { parameter(it.key, it.value) }
+    }
+    response.reloadIfLocalVersionIsOutdated()
+    response.receive<T>()
+  } catch (e: Exception) {
+    null
   }
 
   // Holds a unique hash for the local frontend version
   // The server send a hash with every call, when this hash mismatches with the server version, the page is reloaded.
   private var localFrontendVersionHash: String? = null
 
-  private fun Response.reloadIfLocalVersionIsOutdated() {
+  fun HttpResponse.reloadIfLocalVersionIsOutdated() {
     // If server doesn't send the hash, this feature is not supported on the backend
-    val remoteVersionHash = headers.get("x-version-hash") ?: return
-
+    val remoteVersionHash = headers.flattenEntries().toMap()["x-version-hash"] ?: return
     // Set the local version hash on the first call, the check for any changes on the subsequent calls
     when {
       localFrontendVersionHash == null -> localFrontendVersionHash = remoteVersionHash
@@ -66,47 +88,4 @@ object NetworkManager {
       }
     }
   }
-
-  private suspend inline fun <T : Any> Promise<Response>.parseResponse(kClass: KClass<T>): T? {
-    return try {
-      val response: Response = this.awaitOrNull() ?: return null
-      response.reloadIfLocalVersionIsOutdated()
-      @Suppress("UNCHECKED_CAST")
-      when (kClass) {
-        String::class -> response.toStringMessage() as T
-        Map::class -> response.toMapMessage() as T
-        else -> response.toJsonMessage<T>()
-      }
-    } catch (e: dynamic) {
-      console.log("Failed to parse response", this)
-      null
-    }
-  }
-
-  private suspend fun <T> Response.toJsonMessage(): T? {
-    return if (status == 200.toShort()) {
-      this.json().await().asDynamic() as T
-    } else {
-      null
-    }
-  }
-
-  private suspend fun Response.toMapMessage(): Map<String, Any?>? {
-    @Suppress("UNCHECKED_CAST_TO_EXTERNAL_INTERFACE")
-    val json = if (status == 200.toShort()) {
-      this.json().await().asDynamic() as Json
-    } else {
-      return null
-    }
-    return mutableMapOf<String, Any?>()
-      .apply { json.getOwnPropertyNames().forEach { key -> this[key] = json[key] } }
-  }
-
-  private suspend fun Response.toStringMessage(): String? {
-    return if (status == 200.toShort()) {
-      text().await()
-    } else {
-      null
-    }
-  }
 }
\ No newline at end of file
diff --git a/server/src/main/kotlin/com/studo/campusqr/database/Payloads.kt b/server/src/main/kotlin/com/studo/campusqr/database/Payloads.kt
index e731b23..2eca697 100644
--- a/server/src/main/kotlin/com/studo/campusqr/database/Payloads.kt
+++ b/server/src/main/kotlin/com/studo/campusqr/database/Payloads.kt
@@ -27,7 +27,7 @@ class BackendUser() : MongoMainEntry(), ClientPayloadable<ClientUser> {
     id = _id,
     email = email,
     name = name,
-    permissionsRaw = permissions.map { it.name }.toTypedArray(),
+    permissions = permissions,
     firstLoginDate = firstLoginDate?.toAustrianTime("dd.MM.yyyy")
       ?: LocalizedString(
         "Not logged in yet",
@@ -64,7 +64,7 @@ class BackendLocation : MongoMainEntry(), ClientPayloadable<ClientLocation> {
     id = _id,
     name = name,
     checkInCount = checkInCount,
-    accessType = accessType.name,
+    accessType = accessType,
     seatCount = seatCount
   )
 }
@@ -92,7 +92,7 @@ class BackendSeatFilter : MongoMainEntry(), ClientPayloadable<ClientSeatFilter>
     id = _id,
     locationId = locationId,
     seat = seat,
-    filteredSeats = filteredSeats.toTypedArray()
+    filteredSeats = filteredSeats
   )
 }
 
diff --git a/server/src/main/kotlin/com/studo/campusqr/endpoints/AccessManagement.kt b/server/src/main/kotlin/com/studo/campusqr/endpoints/AccessManagement.kt
index a84c3a1..ab92932 100644
--- a/server/src/main/kotlin/com/studo/campusqr/endpoints/AccessManagement.kt
+++ b/server/src/main/kotlin/com/studo/campusqr/endpoints/AccessManagement.kt
@@ -18,8 +18,8 @@ private fun BackendAccess.toClientClass(location: BackendLocation) = ClientAcces
   id = _id,
   locationName = location.name,
   locationId = location._id,
-  allowedEmails = allowedEmails.toTypedArray(),
-  dateRanges = dateRanges.map { it.toClientClass() }.toTypedArray(),
+  allowedEmails = allowedEmails,
+  dateRanges = dateRanges.map { it.toClientClass() },
   note = note,
   reason = reason
 )
@@ -55,7 +55,7 @@ suspend fun AuthenticatedApplicationCall.listAccess() {
 
   respondObject(
     AccessManagementData(
-      accessManagement = accessManagement.toTypedArray(),
+      accessManagement = accessManagement,
       clientLocation = if (locationId != null) locations.values.firstOrNull()?.toClientClass(language) else null
     )
   )
@@ -105,7 +105,7 @@ suspend fun AuthenticatedApplicationCall.listExportAccess() {
 
   respondObject(
     AccessManagementExportData(
-      permits = permits.toTypedArray(),
+      permits = permits,
       clientLocation = location?.toClientClass(language)
     )
   )
diff --git a/server/src/main/kotlin/com/studo/campusqr/endpoints/ReportData.kt b/server/src/main/kotlin/com/studo/campusqr/endpoints/ReportData.kt
index 5cfee94..9aed51c 100644
--- a/server/src/main/kotlin/com/studo/campusqr/endpoints/ReportData.kt
+++ b/server/src/main/kotlin/com/studo/campusqr/endpoints/ReportData.kt
@@ -191,7 +191,7 @@ internal suspend fun generateContactTracingReport(emails: List<String>, oldestDa
     ?.take(20)
 
   return ReportData(
-    impactedUsersEmails = impactedUsersEmails.toTypedArray(),
+    impactedUsersEmails = impactedUsersEmails,
     impactedUsersCount = impactedUsers.count(),
     reportedUserLocations = contacts.map { (reportedCheckIn, impactedCheckIns) ->
       val location = locationIdToLocationMap.getValue(reportedCheckIn.locationId)
@@ -203,13 +203,16 @@ internal suspend fun generateContactTracingReport(emails: List<String>, oldestDa
         date = reportedCheckIn.date.toAustrianTime(yearAtBeginning = false),
         seat = reportedCheckIn.seat,
         potentialContacts = impactedCheckIns.count(),
-        filteredSeats = seatFilterMap[reportedCheckIn.filterKey()]?.filteredSeats?.toTypedArray(),
+        filteredSeats = seatFilterMap[reportedCheckIn.filterKey()]?.filteredSeats,
       )
-    }.toTypedArray(),
+    },
     impactedUsersEmailsCsvData = impactedUsersEmails.joinToString("\n"),
     impactedUsersEmailsCsvFileName = "${csvFilePrefix?.plus("-emails") ?: "emails"}.csv",
     reportedUserLocationsCsv = "sep=;\n" + reportedUserCheckIns.joinToString("\n") {
-      "${it.email};${it.date.toAustrianTime(yearAtBeginning = false)};\"${locationIdToLocationMap.getValue(it.locationId).name.replace("\"", "\"\"")}\";${it.seat ?: "-"}"
+      "${it.email};" +
+          "${it.date.toAustrianTime(yearAtBeginning = false)};" +
+          "\"${locationIdToLocationMap.getValue(it.locationId).name.replace("\"", "\"\"")}\";" +
+          (it.seat ?: "-")
     },
     reportedUserLocationsCsvFileName = "${csvFilePrefix?.plus("-checkins") ?: "checkins"}.csv",
     startDate = oldestDate.toAustrianTime("dd.MM.yyyy"),
-- 
GitLab