Add multi-user MAX authentication and tenant isolation
This commit is contained in:
@@ -22,8 +22,8 @@ android {
|
||||
applicationId = "xyz.kusoft.qmax"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 54
|
||||
versionName = "0.1.53"
|
||||
versionCode = 55
|
||||
versionName = "0.1.54"
|
||||
|
||||
buildConfigField("String", "QMAX_DEFAULT_SERVER_URL", "\"https://qmax.kusoft.xyz\"")
|
||||
buildConfigField("String", "QMAX_DEFAULT_PAIRING_CODE", "\"qmax-MxRq4h2HQBEIFs6k\"")
|
||||
|
||||
@@ -275,11 +275,6 @@ private fun QMaxApp(vm: QMaxViewModel) {
|
||||
|
||||
Box(Modifier.fillMaxSize().background(QMaxBackground)) {
|
||||
when {
|
||||
state.session == null && state.pairingCode.isNotBlank() -> AutoLoginScreen(
|
||||
loading = state.loading,
|
||||
error = state.error,
|
||||
onRetry = vm::login
|
||||
)
|
||||
state.session == null -> LoginScreen(vm)
|
||||
state.selectedChat != null -> ChatScreen(vm)
|
||||
else -> ChatListScreen(vm)
|
||||
@@ -359,6 +354,17 @@ private fun LoginScreen(vm: QMaxViewModel) {
|
||||
value = state.serverUrl,
|
||||
onValueChange = vm::updateServerUrl,
|
||||
label = { Text("Сервер") },
|
||||
enabled = state.phoneAuthChallenge == null,
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
OutlinedTextField(
|
||||
value = state.phoneNumber,
|
||||
onValueChange = vm::updatePhoneNumber,
|
||||
label = { Text("Номер телефона MAX") },
|
||||
placeholder = { Text("+7 900 000-00-00") },
|
||||
enabled = state.phoneAuthChallenge == null,
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
@@ -366,15 +372,29 @@ private fun LoginScreen(vm: QMaxViewModel) {
|
||||
OutlinedTextField(
|
||||
value = state.pairingCode,
|
||||
onValueChange = vm::updatePairingCode,
|
||||
label = { Text("Код подключения") },
|
||||
placeholder = { Text("qmax-...") },
|
||||
supportingText = { Text("Код выдаёт сервер QMAX для привязки телефона") },
|
||||
label = { Text("Код регистрации сервера (если задан)") },
|
||||
enabled = state.phoneAuthChallenge == null,
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(Modifier.height(18.dp))
|
||||
Button(onClick = vm::login, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Войти")
|
||||
if (state.phoneAuthChallenge == null) {
|
||||
Spacer(Modifier.height(18.dp))
|
||||
Button(onClick = vm::login, enabled = state.phoneNumber.isNotBlank(), modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Получить код MAX")
|
||||
}
|
||||
} else {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
OutlinedTextField(
|
||||
value = state.maxCode,
|
||||
onValueChange = vm::updateMaxCode,
|
||||
label = { Text("Код из MAX") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(Modifier.height(18.dp))
|
||||
Button(onClick = vm::completePhoneLogin, enabled = state.maxCode.isNotBlank(), modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Войти в QMAX")
|
||||
}
|
||||
}
|
||||
ErrorLine(state.error)
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import xyz.kusoft.qmax.core.model.ChatDto
|
||||
import xyz.kusoft.qmax.core.model.ChatPresenceDto
|
||||
import xyz.kusoft.qmax.core.model.ContactDto
|
||||
import xyz.kusoft.qmax.core.model.PhoneContactDto
|
||||
import xyz.kusoft.qmax.core.model.PhoneAuthChallengeResponse
|
||||
import xyz.kusoft.qmax.core.model.MaxBridgeStatusDto
|
||||
import xyz.kusoft.qmax.core.model.MaxChannelSearchResultDto
|
||||
import xyz.kusoft.qmax.core.model.MessageDto
|
||||
@@ -68,6 +69,18 @@ class QMaxRepository(
|
||||
return response
|
||||
}
|
||||
|
||||
suspend fun beginPhoneAuth(serverUrl: String, phoneNumber: String, registrationCode: String): PhoneAuthChallengeResponse {
|
||||
val resolvedServer = serverUrl.ifBlank { BuildConfig.QMAX_DEFAULT_SERVER_URL }
|
||||
return api.beginPhoneAuth(resolvedServer, phoneNumber, registrationCode.ifBlank { null }, android.os.Build.MODEL)
|
||||
}
|
||||
|
||||
suspend fun completePhoneAuth(serverUrl: String, challengeId: String, challengeToken: String, code: String): AuthResponse {
|
||||
val resolvedServer = serverUrl.ifBlank { BuildConfig.QMAX_DEFAULT_SERVER_URL }
|
||||
val response = api.completePhoneAuth(resolvedServer, challengeId, challengeToken, code)
|
||||
tokenStore.save(QMaxSession(resolvedServer, response.accessToken, response.refreshToken, response.user.displayName))
|
||||
return response
|
||||
}
|
||||
|
||||
suspend fun logout() {
|
||||
tokenStore.clear()
|
||||
draftStore.clearAll()
|
||||
|
||||
@@ -24,6 +24,20 @@ data class DeviceLoginRequest(
|
||||
val deviceName: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class BeginPhoneAuthRequest(val phoneNumber: String, val deviceName: String, val registrationCode: String? = null)
|
||||
|
||||
@Serializable
|
||||
data class CompletePhoneAuthRequest(val challengeId: String, val challengeToken: String, val code: String)
|
||||
|
||||
@Serializable
|
||||
data class PhoneAuthChallengeResponse(
|
||||
val challengeId: String,
|
||||
val challengeToken: String,
|
||||
val maxStatus: MaxBridgeStatusDto,
|
||||
val expiresAt: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class RefreshTokenRequest(val refreshToken: String)
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import xyz.kusoft.qmax.BuildConfig
|
||||
import xyz.kusoft.qmax.core.model.AuthResponse
|
||||
import xyz.kusoft.qmax.core.model.ArgusManifestDto
|
||||
import xyz.kusoft.qmax.core.model.BeginMaxLoginRequest
|
||||
import xyz.kusoft.qmax.core.model.BeginPhoneAuthRequest
|
||||
import xyz.kusoft.qmax.core.model.ChatBulkActionRequest
|
||||
import xyz.kusoft.qmax.core.model.ChatDto
|
||||
import xyz.kusoft.qmax.core.model.ChatPresenceDto
|
||||
@@ -29,6 +30,8 @@ import xyz.kusoft.qmax.core.model.CreateDirectChatRequest
|
||||
import xyz.kusoft.qmax.core.model.ContactDto
|
||||
import xyz.kusoft.qmax.core.model.ContactUserRequest
|
||||
import xyz.kusoft.qmax.core.model.DeviceLoginRequest
|
||||
import xyz.kusoft.qmax.core.model.CompletePhoneAuthRequest
|
||||
import xyz.kusoft.qmax.core.model.PhoneAuthChallengeResponse
|
||||
import xyz.kusoft.qmax.core.model.EditMessageRequest
|
||||
import xyz.kusoft.qmax.core.model.ForwardMessageRequest
|
||||
import xyz.kusoft.qmax.core.model.MarkChatReadRequest
|
||||
@@ -73,6 +76,14 @@ class QMaxApi {
|
||||
return post(serverUrl, "/api/auth/device/login", null, DeviceLoginRequest(pairingCode, deviceName))
|
||||
}
|
||||
|
||||
suspend fun beginPhoneAuth(serverUrl: String, phoneNumber: String, registrationCode: String?, deviceName: String): PhoneAuthChallengeResponse {
|
||||
return post(serverUrl, "/api/auth/phone/start", null, BeginPhoneAuthRequest(phoneNumber, deviceName, registrationCode))
|
||||
}
|
||||
|
||||
suspend fun completePhoneAuth(serverUrl: String, challengeId: String, challengeToken: String, code: String): AuthResponse {
|
||||
return post(serverUrl, "/api/auth/phone/code", null, CompletePhoneAuthRequest(challengeId, challengeToken, code))
|
||||
}
|
||||
|
||||
suspend fun refresh(serverUrl: String, refreshToken: String): AuthResponse {
|
||||
return post(serverUrl, "/api/auth/refresh", null, xyz.kusoft.qmax.core.model.RefreshTokenRequest(refreshToken))
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import xyz.kusoft.qmax.core.model.MaxChannelSearchResultDto
|
||||
import xyz.kusoft.qmax.core.model.MessageDeletedDto
|
||||
import xyz.kusoft.qmax.core.model.MessageDto
|
||||
import xyz.kusoft.qmax.core.model.PhoneContactDto
|
||||
import xyz.kusoft.qmax.core.model.PhoneAuthChallengeResponse
|
||||
import xyz.kusoft.qmax.core.model.QMaxSession
|
||||
import xyz.kusoft.qmax.core.network.QMaxHttpException
|
||||
import xyz.kusoft.qmax.core.realtime.QMaxRealtimeClient
|
||||
@@ -32,6 +33,8 @@ import java.util.UUID
|
||||
data class QMaxUiState(
|
||||
val serverUrl: String = BuildConfig.QMAX_DEFAULT_SERVER_URL,
|
||||
val pairingCode: String = BuildConfig.QMAX_DEFAULT_PAIRING_CODE,
|
||||
val phoneNumber: String = "",
|
||||
val phoneAuthChallenge: PhoneAuthChallengeResponse? = null,
|
||||
val session: QMaxSession? = null,
|
||||
val chats: List<ChatDto> = emptyList(),
|
||||
val contacts: List<ContactDto> = emptyList(),
|
||||
@@ -133,7 +136,7 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
|
||||
pushRegistrationInFlightForToken = null
|
||||
pushRegisteredForToken = null
|
||||
realtime.disconnect()
|
||||
if (!autoLoginAttempted && state.value.pairingCode.isNotBlank()) {
|
||||
if (!autoLoginAttempted && state.value.phoneNumber.isNotBlank()) {
|
||||
autoLoginAttempted = true
|
||||
autoLoginJob?.cancel()
|
||||
autoLoginJob = viewModelScope.launch {
|
||||
@@ -162,6 +165,10 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
|
||||
state.value = state.value.copy(pairingCode = value)
|
||||
}
|
||||
|
||||
fun updatePhoneNumber(value: String) {
|
||||
state.value = state.value.copy(phoneNumber = value)
|
||||
}
|
||||
|
||||
fun updateComposer(value: String) {
|
||||
state.value = state.value.copy(composerText = value)
|
||||
if (state.value.editTarget != null) {
|
||||
@@ -263,7 +270,17 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
|
||||
|
||||
fun login() = launchLoading {
|
||||
val current = state.value
|
||||
repository.login(current.serverUrl, current.pairingCode)
|
||||
val challenge = repository.beginPhoneAuth(current.serverUrl, current.phoneNumber, current.pairingCode)
|
||||
state.value = state.value.copy(phoneAuthChallenge = challenge, maxStatus = challenge.maxStatus)
|
||||
}
|
||||
|
||||
fun completePhoneLogin() = launchLoading {
|
||||
val current = state.value
|
||||
val challenge = current.phoneAuthChallenge ?: return@launchLoading
|
||||
val code = current.maxCode.trim()
|
||||
if (code.isBlank()) return@launchLoading
|
||||
repository.completePhoneAuth(current.serverUrl, challenge.challengeId, challenge.challengeToken, code)
|
||||
state.value = state.value.copy(phoneAuthChallenge = null, maxCode = "")
|
||||
}
|
||||
|
||||
fun logout() = viewModelScope.launch {
|
||||
|
||||
Reference in New Issue
Block a user