Compare commits

2 Commits

45 changed files with 2305 additions and 1513 deletions
@@ -1,11 +1,19 @@
package dev.zxq5.chatapp.android
import android.app.Application
import dev.zxq5.chatapp.android.api.ApiClient
import dev.zxq5.chatapp.android.core.data.TokenStore
import dev.zxq5.chatapp.android.data.repository.AuthRepository
import dev.zxq5.chatapp.android.data.repository.ChatRepository
import dev.zxq5.chatapp.android.data.repository.SettingsRepository
class ChatApplication : Application() {
val tokenStore by lazy { TokenStore(this) }
val authRepository by lazy { AuthRepository(tokenStore) }
val chatRepository by lazy { ChatRepository(tokenStore) }
val settingsRepository by lazy { SettingsRepository(tokenStore) }
override fun onCreate() {
super.onCreate()
ApiClient.init(this)
}
}
@@ -11,36 +11,66 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.viewmodel.compose.viewModel
import dev.zxq5.chatapp.android.model.ChatViewModel
import dev.zxq5.chatapp.android.model.LoginState
import dev.zxq5.chatapp.android.model.MainScreen
import dev.zxq5.chatapp.android.ui.components.AuthScreen
import dev.zxq5.chatapp.android.ui.components.ChatScreen
import dev.zxq5.chatapp.android.ui.components.SettingsScreen
import dev.zxq5.chatapp.android.core.data.TokenStore
import dev.zxq5.chatapp.android.data.repository.AuthRepository
import dev.zxq5.chatapp.android.data.repository.AuthState
import dev.zxq5.chatapp.android.data.repository.ChatRepository
import dev.zxq5.chatapp.android.data.repository.SettingsRepository
import dev.zxq5.chatapp.android.feature.auth.AuthViewModel
import dev.zxq5.chatapp.android.feature.chat.ChatViewModel
import dev.zxq5.chatapp.android.feature.chat.Screen
import dev.zxq5.chatapp.android.feature.settings.SettingsViewModel
import dev.zxq5.chatapp.android.feature.auth.AuthScreen
import dev.zxq5.chatapp.android.feature.chat.ChatScreen
import dev.zxq5.chatapp.android.feature.settings.SettingsScreen
import dev.zxq5.chatapp.android.ui.theme.ChatappTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val app = application as ChatApplication
val tokenStore = app.tokenStore
val authRepository = app.authRepository
val chatRepository = app.chatRepository
val settingsRepository = app.settingsRepository
enableEdgeToEdge()
setContent {
ChatappTheme {
val viewModel: ChatViewModel = viewModel()
val loginState by viewModel.loginState.collectAsState()
val currentScreen by viewModel.currentScreen.collectAsState()
val authViewModel: AuthViewModel = viewModel(factory = ViewModelFactory(authRepository))
val chatViewModel: ChatViewModel = viewModel(factory = ViewModelFactory(chatRepository))
val settingsViewModel: SettingsViewModel = viewModel(factory = ViewModelFactory(settingsRepository))
val authState by authViewModel.authState.collectAsState()
val currentScreen by chatViewModel.currentScreen.collectAsState()
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
androidx.compose.foundation.layout.Box(modifier = Modifier.padding(innerPadding)) {
if (loginState is LoginState.Success) {
when (authState) {
AuthState.Authenticated -> {
when (currentScreen) {
MainScreen.CHAT -> ChatScreen(viewModel = viewModel)
MainScreen.SETTINGS -> SettingsScreen(viewModel = viewModel)
Screen.CHAT -> ChatScreen(
viewModel = chatViewModel,
onNavigateToSettings = { chatViewModel.navigateTo(Screen.SETTINGS) },
onLogout = {
authViewModel.logout()
chatViewModel.clearChat()
}
} else {
AuthScreen(
viewModel = viewModel,
onSuccess = { }
)
Screen.SETTINGS -> SettingsScreen(
viewModel = settingsViewModel,
onBack = { chatViewModel.navigateTo(Screen.CHAT) },
onLogout = {
authViewModel.logout()
chatViewModel.clearChat()
}
)
}
}
AuthState.AwaitingTotp, AuthState.Unauthenticated -> {
AuthScreen(viewModel = authViewModel)
}
}
}
}
@@ -0,0 +1,27 @@
package dev.zxq5.chatapp.android
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import dev.zxq5.chatapp.android.data.repository.AuthRepository
import dev.zxq5.chatapp.android.data.repository.ChatRepository
import dev.zxq5.chatapp.android.data.repository.SettingsRepository
import dev.zxq5.chatapp.android.feature.auth.AuthViewModel
import dev.zxq5.chatapp.android.feature.chat.ChatViewModel
import dev.zxq5.chatapp.android.feature.settings.SettingsViewModel
class ViewModelFactory(private val repository: Any) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return when {
modelClass.isAssignableFrom(AuthViewModel::class.java) -> {
AuthViewModel(repository as AuthRepository) as T
}
modelClass.isAssignableFrom(ChatViewModel::class.java) -> {
ChatViewModel(repository as ChatRepository) as T
}
modelClass.isAssignableFrom(SettingsViewModel::class.java) -> {
SettingsViewModel(repository as SettingsRepository) as T
}
else -> throw IllegalArgumentException("Unknown ViewModel class")
}
}
}
@@ -1,361 +0,0 @@
package dev.zxq5.chatapp.android.api
import android.content.Context
import android.util.Log
import dev.zxq5.chatapp.android.core.data.TokenStore.getScopeFromToken
import dev.zxq5.chatapp.android.model.LoginRequest
import dev.zxq5.chatapp.android.model.LoginResponse
import dev.zxq5.chatapp.android.model.Message
import dev.zxq5.chatapp.android.model.SendMessage
import dev.zxq5.chatapp.android.model.SignupRequest
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.engine.android.Android
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.get
import io.ktor.client.request.post
import io.ktor.client.request.delete
import io.ktor.client.request.prepareGet
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsChannel
import io.ktor.http.ContentType
import io.ktor.http.contentType
import io.ktor.http.isSuccess
import io.ktor.serialization.kotlinx.json.json
import io.ktor.utils.io.readUTF8Line
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import io.ktor.client.plugins.auth.Auth
import io.ktor.client.plugins.auth.providers.BearerTokens
import io.ktor.client.plugins.auth.providers.bearer
import io.ktor.http.encodedPath
import dev.zxq5.chatapp.android.core.BASE_URL
import dev.zxq5.chatapp.android.core.data.TokenStore
import dev.zxq5.chatapp.android.core.data.TokenStore.getUserIdFromToken
import dev.zxq5.chatapp.android.core.error.ApiResult
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
@Serializable
data class QrResponse(val qr_code: String)
@Serializable
data class TOTPSixDigitCode(val code: String)
@Serializable(with = TotpStatusSerializer::class)
enum class TotpStatus {
ENABLED, DISABLED;
val isEnabled: Boolean get() = this == ENABLED
}
object TotpStatusSerializer : KSerializer<TotpStatus> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("TotpStatus", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: TotpStatus) = encoder.encodeString(value.name.lowercase())
override fun deserialize(decoder: Decoder): TotpStatus =
TotpStatus.valueOf(decoder.decodeString().uppercase())
}
@Serializable
data class TotpStatusResponse(val status: TotpStatus)
@Serializable
data class PasswordChangeRequest(val old_password: String, val new_password: String)
@Serializable
data class DisplayNameRequest(val display_name: String?)
object ApiClient {
private lateinit var appContext: Context
fun init(context: Context) {
appContext = context.applicationContext
}
fun hasToken(): Boolean {
return TokenStore.get(appContext) != null
}
fun getTokenScope(): String? {
val token = TokenStore.get(appContext) ?: return null
val scope = getScopeFromToken(token)
Log.d("Chat", "Current token scope: $scope")
return scope
}
fun getStoredUserId(): Int? {
return TokenStore.getUserId(appContext)
}
fun is2faEnabledLocal(): Boolean {
return TokenStore.is2faEnabled(appContext)
}
fun set2faEnabledLocal(enabled: Boolean) {
TokenStore.save2faEnabled(appContext, enabled)
}
private var _http: HttpClient? = null
val http: HttpClient
get() = synchronized(this) {
_http ?: createClient().also { _http = it }
}
private fun createClient(): HttpClient {
return HttpClient(Android) {
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
}
install(Auth) {
bearer {
loadTokens {
val token = TokenStore.get(appContext) ?: return@loadTokens null
Log.d("Chat", "Auth plugin loading token: ${getScopeFromToken(token)}")
BearerTokens(token, "")
}
sendWithoutRequest { request ->
val path = request.url.encodedPath
!path.endsWith("/login") && !path.endsWith("/signup")
}
}
}
}
}
private fun resetClient() {
Log.d("Chat", "Resetting HttpClient to refresh tokens")
_http?.close()
_http = null
}
suspend fun login(username: String, password: String): ApiResult<LoginResponse> {
return try {
val response = http.post("${BASE_URL}/api/login") {
contentType(ContentType.Application.Json)
setBody(LoginRequest(username, password))
}
if (response.status.isSuccess()) {
val body = response.body<LoginResponse>()
Log.d("Chat", "Login token scope: ${getScopeFromToken(body.token)}")
TokenStore.save(appContext, body.token)
resetClient()
ApiResult.Success(body)
} else {
ApiResult.HttpError(
status = response.status.value,
message = when (response.status.value) {
401 -> "Invalid username or password"
403 -> "Account suspended"
429 -> "Too many attempts, please wait"
else -> "Login failed (${response.status.value})"
}
)
}
} catch (e: Exception) {
Log.e("Chat", "Login network error", e)
ApiResult.NetworkError(e.localizedMessage ?: "Network error")
}
}
suspend fun signup(username: String, email: String, password: String, token: String): ApiResult<LoginResponse> {
return try {
val response = http.post("${BASE_URL}/api/signup") {
contentType(ContentType.Application.Json)
setBody(SignupRequest(username = username, email = email, password = password, access_token = token))
}
if (response.status.isSuccess()) {
val body = response.body<LoginResponse>()
TokenStore.save(appContext, body.token)
resetClient()
ApiResult.Success(body)
} else {
ApiResult.HttpError(
status = response.status.value,
message = when (response.status.value) {
401 -> "Invalid access token"
else -> "Signup failed (${response.status.value})"
}
)
}
} catch (e: Exception) {
Log.e("Chat", "Signup error", e)
ApiResult.NetworkError(e.localizedMessage ?: "Network error")
}
}
fun logout() {
TokenStore.clear(appContext)
resetClient()
}
suspend fun sendMessage(channelId: Int, text: String) {
val userId = TokenStore.getUserId(appContext) ?: return
http.post("${BASE_URL}/api/chat/$channelId") {
contentType(ContentType.Application.Json)
setBody(SendMessage(
user_id = userId,
text = text,
timestamp = System.currentTimeMillis()
))
}
}
fun messageStream(channelId: Int): Flow<Message> = flow {
http.prepareGet("${BASE_URL}/api/events/$channelId").execute { response ->
val channel = response.bodyAsChannel()
while (!channel.isClosedForRead) {
val line = channel.readUTF8Line(256) ?: break
if (line.startsWith("data:")) {
val json = line.removePrefix("data:").trim()
runCatching { Json.decodeFromString<Message>(json) }
.onSuccess { emit(it) }
}
}
}
}
suspend fun getTotpQr(): QrResponse? {
return try {
http.get("${BASE_URL}/api/totp.jpg").body<QrResponse>()
} catch (e: Exception) {
Log.e("Chat", "Error fetching TOTP QR", e)
null
}
}
suspend fun confirmTotp(code: String): Boolean {
return try {
val response = http.post("${BASE_URL}/api/totp") {
contentType(ContentType.Application.Json)
setBody(TOTPSixDigitCode(code))
}
if (response.status.isSuccess()) {
// If confirming TOTP returns a new token (e.g. from partial to full), we should save it
// Assuming confirm might return a LoginResponse if it upgrades the session
runCatching {
val body = response.body<LoginResponse>()
TokenStore.save(appContext, body.token)
resetClient()
}
set2faEnabledLocal(true)
true
} else {
false
}
} catch (e: Exception) {
Log.e("Chat", "Error confirming TOTP", e)
false
}
}
suspend fun verifyTotpLogin(code: String): ApiResult<LoginResponse> {
return try {
val response = http.post("${BASE_URL}/api/totp/verify") {
contentType(ContentType.Application.Json)
setBody(TOTPSixDigitCode(code))
}
if (response.status.isSuccess()) {
val body = response.body<LoginResponse>()
val tok = body.token;
Log.d("Chat", "UID ${getUserIdFromToken(tok)}");
Log.d("Chat", "Token ${getScopeFromToken(tok)}");
TokenStore.save(appContext, body.token)
resetClient()
ApiResult.Success(body)
} else {
val errorText = try { response.body<String>() } catch (e: Exception) { "Unknown error" }
Log.e("Chat", "TOTP verify failed: ${response.status.value} - $errorText")
ApiResult.HttpError(
status = response.status.value,
message = when (response.status.value) {
401 -> "Incorrect code, please try again"
403 -> "Session expired, please log in again"
429 -> "Too many attempts, please wait"
else -> "Verification failed (${response.status.value})"
}
)
}
} catch (e: Exception) {
Log.e("Chat", "TOTP verify network error", e)
ApiResult.NetworkError(e.localizedMessage ?: "Network error")
}
}
suspend fun getTotpStatus(): Boolean {
return try {
val response = http.get("${BASE_URL}/api/totp/status")
if (response.status.isSuccess()) {
val status = response.body<TotpStatus>()
val enabled = status.isEnabled
set2faEnabledLocal(enabled)
enabled
} else {
is2faEnabledLocal()
}
} catch (e: Exception) {
Log.e("Chat", "Error getting TOTP status", e)
is2faEnabledLocal()
}
}
suspend fun disableTotp(): ApiResult<LoginResponse> {
return try {
val response = http.delete("${BASE_URL}/api/totp")
if (response.status.isSuccess()) {
val body = response.body<LoginResponse>()
TokenStore.save(appContext, body.token)
set2faEnabledLocal(false)
resetClient()
ApiResult.Success(body)
} else {
ApiResult.HttpError(response.status.value, "Failed to disable TOTP")
}
} catch (e: Exception) {
Log.e("Chat", "Error disabling TOTP", e)
ApiResult.NetworkError(e.localizedMessage ?: "Network error")
}
}
suspend fun changePassword(old: String, new: String): ApiResult<Unit> {
return try {
val response = http.post("${BASE_URL}/api/settings/password") {
contentType(ContentType.Application.Json)
setBody(PasswordChangeRequest(old, new))
}
if (response.status.isSuccess()) {
ApiResult.Success(Unit)
} else {
ApiResult.HttpError(
response.status.value,
if (response.status.value == 401) "Old password is wrong" else "Password change failed"
)
}
} catch (e: Exception) {
Log.e("Chat", "Error changing password", e)
ApiResult.NetworkError(e.localizedMessage ?: "Network error")
}
}
suspend fun updateDisplayName(name: String?): Boolean {
return try {
val response = http.post("${BASE_URL}/api/settings/display_name") {
contentType(ContentType.Application.Json)
setBody(DisplayNameRequest(name))
}
response.status.isSuccess()
} catch (e: Exception) {
Log.e("Chat", "Error updating display name", e)
false
}
}
}
@@ -0,0 +1,116 @@
package dev.zxq5.chatapp.android.api
import android.util.Log
import dev.zxq5.chatapp.android.api.model.LoginRequest
import dev.zxq5.chatapp.android.api.model.LoginResponse
import dev.zxq5.chatapp.android.api.model.TOTPSixDigitCode
import dev.zxq5.chatapp.android.core.BASE_URL
import dev.zxq5.chatapp.android.core.error.ApiResult
import dev.zxq5.chatapp.android.api.model.SignupRequest
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.engine.android.Android
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.header
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.contentType
import io.ktor.http.isSuccess
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
/**
* Client for unauthenticated and pre-authenticated (2FA) requests.
*/
object AuthClient {
private val http = HttpClient(Android) {
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
}
}
suspend fun login(username: String, password: String): ApiResult<LoginResponse> {
return try {
val response = http.post("${BASE_URL}/api/login") {
contentType(ContentType.Application.Json)
setBody(LoginRequest(username, password))
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body<LoginResponse>())
} else {
ApiResult.HttpError(
status = response.status.value,
message = when (response.status.value) {
401 -> "Invalid username or password"
403 -> "Account suspended"
429 -> "Too many attempts, please wait"
else -> "Login failed (${response.status.value})"
}
)
}
} catch (e: Exception) {
Log.e("Chat", "Login network error", e)
ApiResult.NetworkError(e.localizedMessage ?: "Network error")
}
}
suspend fun signup(username: String, email: String, password: String, token: String): ApiResult<LoginResponse> {
return try {
val response = http.post("${BASE_URL}/api/signup") {
contentType(ContentType.Application.Json)
setBody(
SignupRequest(
username = username,
email = email,
password = password,
access_token = token
)
)
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body<LoginResponse>())
} else {
ApiResult.HttpError(
status = response.status.value,
message = when (response.status.value) {
401 -> "Invalid access token"
else -> "Signup failed (${response.status.value})"
}
)
}
} catch (e: Exception) {
Log.e("Chat", "Signup error", e)
ApiResult.NetworkError(e.localizedMessage ?: "Network error")
}
}
suspend fun verifyTotpLogin(partialToken: String, code: String): ApiResult<LoginResponse> {
return try {
val response = http.post("${BASE_URL}/api/totp/verify") {
header(HttpHeaders.Authorization, "Bearer $partialToken")
contentType(ContentType.Application.Json)
setBody(TOTPSixDigitCode(code))
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body<LoginResponse>())
} else {
val errorText = try { response.body<String>() } catch (e: Exception) { "Unknown error" }
Log.e("Chat", "TOTP verify failed: ${response.status.value} - $errorText")
ApiResult.HttpError(
status = response.status.value,
message = when (response.status.value) {
401 -> "Incorrect code, please try again"
403 -> "Session expired, please log in again"
429 -> "Too many attempts, please wait"
else -> "Verification failed (${response.status.value})"
}
)
}
} catch (e: Exception) {
Log.e("Chat", "TOTP verify network error", e)
ApiResult.NetworkError(e.localizedMessage ?: "Network error")
}
}
}
@@ -0,0 +1,57 @@
package dev.zxq5.chatapp.android.api
import dev.zxq5.chatapp.android.core.BASE_URL
import dev.zxq5.chatapp.android.api.model.Message
import dev.zxq5.chatapp.android.api.model.SendMessage
import io.ktor.client.HttpClient
import io.ktor.client.engine.android.Android
import io.ktor.client.plugins.auth.Auth
import io.ktor.client.plugins.auth.providers.BearerTokens
import io.ktor.client.plugins.auth.providers.bearer
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.post
import io.ktor.client.request.prepareGet
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsChannel
import io.ktor.http.ContentType
import io.ktor.http.contentType
import io.ktor.serialization.kotlinx.json.json
import io.ktor.utils.io.readUTF8Line
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.serialization.json.Json
class ChatClient(private val token: String) {
private val http = HttpClient(Android) {
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
}
install(Auth) {
bearer {
loadTokens { BearerTokens(token, "") }
}
}
}
suspend fun sendMessage(channelId: Int, userId: Int, text: String) {
http.post("${BASE_URL}/api/chat/$channelId") {
contentType(ContentType.Application.Json)
setBody(SendMessage(user_id = userId, text = text, timestamp = System.currentTimeMillis()))
}
}
fun messageStream(channelId: Int): Flow<Message> = flow {
http.prepareGet("${BASE_URL}/api/events/$channelId").execute { response ->
val channel = response.bodyAsChannel()
while (!channel.isClosedForRead) {
val line = channel.readUTF8Line(256) ?: break
if (line.startsWith("data:")) {
val json = line.removePrefix("data:").trim()
runCatching { Json.decodeFromString<Message>(json) }
.onSuccess { emit(it) }
}
}
}
}
}
@@ -0,0 +1,171 @@
package dev.zxq5.chatapp.android.api
import android.util.Log
import dev.zxq5.chatapp.android.api.model.AccountDeleteRequest
import dev.zxq5.chatapp.android.api.model.DisplayNameRequest
import dev.zxq5.chatapp.android.api.model.PasswordChangeRequest
import dev.zxq5.chatapp.android.api.model.QrResponse
import dev.zxq5.chatapp.android.api.model.TOTPSixDigitCode
import dev.zxq5.chatapp.android.api.model.TotpStatus
import dev.zxq5.chatapp.android.api.model.UsernameRequest
import dev.zxq5.chatapp.android.api.model.TotpDeleteRequest
import dev.zxq5.chatapp.android.api.model.PasswordRequest
import dev.zxq5.chatapp.android.core.BASE_URL
import dev.zxq5.chatapp.android.core.error.ApiResult
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.engine.android.Android
import io.ktor.client.plugins.auth.Auth
import io.ktor.client.plugins.auth.providers.BearerTokens
import io.ktor.client.plugins.auth.providers.bearer
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.delete
import io.ktor.client.request.get
import io.ktor.client.request.patch
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.contentType
import io.ktor.http.isSuccess
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
/**
* Client for account settings and TOTP management.
*/
class SettingsClient(private val token: String) {
private val http = HttpClient(Android) {
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
}
install(Auth) {
bearer {
loadTokens { BearerTokens(token, "") }
}
}
}
suspend fun getTotpQr(password: String): ApiResult<QrResponse> {
return try {
val response = http.post("${BASE_URL}/api/totp.jpg") {
contentType(ContentType.Application.Json)
setBody(PasswordRequest(password))
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body<QrResponse>())
} else {
ApiResult.HttpError(response.status.value, "Failed to get QR code")
}
} catch (e: Exception) {
Log.e("Chat", "Error fetching TOTP QR", e)
ApiResult.NetworkError(e.localizedMessage ?: "Network error")
}
}
suspend fun confirmTotp(code: String): ApiResult<Unit> {
return try {
val response = http.post("${BASE_URL}/api/totp") {
contentType(ContentType.Application.Json)
setBody(TOTPSixDigitCode(code))
}
if (response.status.isSuccess()) {
ApiResult.Success(Unit)
} else {
ApiResult.HttpError(response.status.value, "Failed to confirm TOTP")
}
} catch (e: Exception) {
Log.e("Chat", "Error confirming TOTP", e)
ApiResult.NetworkError(e.localizedMessage ?: "Network error")
}
}
suspend fun getTotpStatus(): ApiResult<TotpStatus> {
return try {
val response = http.get("${BASE_URL}/api/totp/status")
if (response.status.isSuccess()) {
ApiResult.Success(response.body<TotpStatus>())
} else {
ApiResult.HttpError(response.status.value, "Failed to get TOTP status")
}
} catch (e: Exception) {
Log.e("Chat", "Error getting TOTP status", e)
ApiResult.NetworkError(e.localizedMessage ?: "Network error")
}
}
suspend fun disableTotp(password: String, totpCode: String): ApiResult<Unit> {
return try {
val response = http.delete("${BASE_URL}/api/totp") {
contentType(ContentType.Application.Json)
setBody(TotpDeleteRequest(password, totpCode))
}
if (response.status.isSuccess()) {
ApiResult.Success(Unit)
} else {
ApiResult.HttpError(response.status.value, "Failed to disable TOTP")
}
} catch (e: Exception) {
Log.e("Chat", "Error disabling TOTP", e)
ApiResult.NetworkError(e.localizedMessage ?: "Network error")
}
}
suspend fun changePassword(old: String, new: String): ApiResult<Unit> {
return try {
val response = http.post("${BASE_URL}/api/settings/password") {
contentType(ContentType.Application.Json)
setBody(PasswordChangeRequest(old, new))
}
if (response.status.isSuccess()) {
ApiResult.Success(Unit)
} else {
ApiResult.HttpError(
response.status.value,
if (response.status.value == 401) "Old password is wrong" else "Password change failed"
)
}
} catch (e: Exception) {
Log.e("Chat", "Error changing password", e)
ApiResult.NetworkError(e.localizedMessage ?: "Network error")
}
}
suspend fun updateDisplayName(name: String?): Boolean {
return try {
val response = http.patch("${BASE_URL}/api/settings/display_name") {
contentType(ContentType.Application.Json)
setBody(DisplayNameRequest(name))
}
response.status.isSuccess()
} catch (e: Exception) {
Log.e("Chat", "Error updating display name", e)
false
}
}
suspend fun updateUsername(username: String): ApiResult<Unit> {
return try {
val response = http.patch("${BASE_URL}/api/settings/username") {
contentType(ContentType.Application.Json)
setBody(UsernameRequest(username))
}
if (response.status.isSuccess()) ApiResult.Success(Unit)
else ApiResult.HttpError(response.status.value, "Failed to update username")
} catch (e: Exception) {
ApiResult.NetworkError(e.localizedMessage ?: "Network error")
}
}
suspend fun deleteAccount(password: String, totpCode: String?): ApiResult<Unit> {
return try {
val response = http.delete("${BASE_URL}/api/settings") {
contentType(ContentType.Application.Json)
setBody(AccountDeleteRequest(password, totpCode))
}
if (response.status.isSuccess()) ApiResult.Success(Unit)
else ApiResult.HttpError(response.status.value, "Failed to delete account")
} catch (e: Exception) {
ApiResult.NetworkError(e.localizedMessage ?: "Network error")
}
}
}
@@ -0,0 +1,6 @@
package dev.zxq5.chatapp.android.api.model
import kotlinx.serialization.Serializable
@Serializable
data class AccountDeleteRequest(val password: String, val totp_code: String? = null)
@@ -0,0 +1,6 @@
package dev.zxq5.chatapp.android.api.model
import kotlinx.serialization.Serializable
@Serializable
data class DisplayNameRequest(val display_name: String?)
@@ -1,4 +1,4 @@
package dev.zxq5.chatapp.android.model
package dev.zxq5.chatapp.android.api.model
import kotlinx.serialization.Serializable
@@ -7,5 +7,3 @@ data class LoginRequest(
val username: String,
val password: String
)
@@ -1,4 +1,4 @@
package dev.zxq5.chatapp.android.model
package dev.zxq5.chatapp.android.api.model
import kotlinx.serialization.Serializable
@@ -1,4 +1,4 @@
package dev.zxq5.chatapp.android.model
package dev.zxq5.chatapp.android.api.model
import kotlinx.serialization.Serializable
@@ -0,0 +1,6 @@
package dev.zxq5.chatapp.android.api.model
import kotlinx.serialization.Serializable
@Serializable
data class PasswordChangeRequest(val old_password: String, val new_password: String)
@@ -0,0 +1,6 @@
package dev.zxq5.chatapp.android.api.model
import kotlinx.serialization.Serializable
@Serializable
data class PasswordRequest(val password: String)
@@ -0,0 +1,6 @@
package dev.zxq5.chatapp.android.api.model
import kotlinx.serialization.Serializable
@Serializable
data class QrResponse(val qr_code: String)
@@ -1,4 +1,4 @@
package dev.zxq5.chatapp.android.model
package dev.zxq5.chatapp.android.api.model
import kotlinx.serialization.Serializable
@@ -1,4 +1,4 @@
package dev.zxq5.chatapp.android.model
package dev.zxq5.chatapp.android.api.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@@ -0,0 +1,6 @@
package dev.zxq5.chatapp.android.api.model
import kotlinx.serialization.Serializable
@Serializable
data class TOTPSixDigitCode(val code: String)
@@ -0,0 +1,6 @@
package dev.zxq5.chatapp.android.api.model
import kotlinx.serialization.Serializable
@Serializable
data class TotpDeleteRequest(val password: String, val totp_code: String)
@@ -0,0 +1,24 @@
package dev.zxq5.chatapp.android.api.model
import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
@Serializable(with = TotpStatus.TotpStatusSerializer::class)
enum class TotpStatus {
ENABLED, DISABLED;
val isEnabled: Boolean get() = this == ENABLED
companion object TotpStatusSerializer : KSerializer<TotpStatus> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("TotpStatus", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: TotpStatus) = encoder.encodeString(value.name.lowercase())
override fun deserialize(decoder: Decoder): TotpStatus =
TotpStatus.valueOf(decoder.decodeString().uppercase())
}
}
@@ -0,0 +1,6 @@
package dev.zxq5.chatapp.android.api.model
import kotlinx.serialization.Serializable
@Serializable
data class UsernameRequest(val username: String)
@@ -8,12 +8,14 @@ import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import org.json.JSONObject
// In your ApiClient or a dedicated TokenStore
object TokenStore {
private const val KEY = "auth_token"
private const val TWOFA_KEY = "twofa_enabled"
private const val KEY = "auth_token"
private const val TWOFA_KEY = "twofa_enabled"
private fun prefs(context: Context): SharedPreferences {
// In your ChatClient.kt or a dedicated TokenStore
class TokenStore(appContext: Context) {
private val context = appContext.applicationContext;
private fun prefs(): SharedPreferences {
return EncryptedSharedPreferences.create(
context,
"secure_prefs",
@@ -25,27 +27,28 @@ object TokenStore {
)
}
fun save(context: Context, token: String) =
prefs(context).edit { putString(KEY, token) }
fun save(token: String) =
prefs().edit { putString(KEY, token) }
fun get(context: Context): String? =
prefs(context).getString(KEY, null)
fun get(): String? =
prefs().getString(KEY, null)
fun save2faEnabled(context: Context, enabled: Boolean) =
prefs(context).edit { putBoolean(TWOFA_KEY, enabled) }
fun save2faEnabled( enabled: Boolean) =
prefs().edit { putBoolean(TWOFA_KEY, enabled) }
fun is2faEnabled(context: Context): Boolean =
prefs(context).getBoolean(TWOFA_KEY, false)
fun is2faEnabled(): Boolean =
prefs().getBoolean(TWOFA_KEY, false)
fun clear(context: Context) =
prefs(context).edit { remove(KEY).remove(TWOFA_KEY) }
fun clear() =
prefs().edit { remove(KEY).remove(TWOFA_KEY) }
fun getUserId(context: Context): Int? {
val token = get(context) ?: return null
fun getUserId(): Int? {
val token = get() ?: return null
return getUserIdFromToken(token)
}
}
fun getUserIdFromToken(token: String): Int? {
fun getUserIdFromToken(token: String): Int? {
return try {
val payload = token.split(".")[1]
// base64url needs padding restored
@@ -62,9 +65,9 @@ object TokenStore {
} catch (e: Exception) {
null
}
}
}
fun getScopeFromToken(token: String): String? {
fun getScopeFromToken(token: String): String? {
return try {
val payload = token.split(".")[1]
val padded = payload + "==".take((4 - payload.length % 4) % 4)
@@ -74,5 +77,4 @@ object TokenStore {
} catch (e: Exception) {
null
}
}
}
@@ -1,31 +1,79 @@
package dev.zxq5.chatapp.android.data.repository
import dev.zxq5.chatapp.android.api.ApiClient
import dev.zxq5.chatapp.android.api.AuthClient
import dev.zxq5.chatapp.android.core.data.TokenStore
import dev.zxq5.chatapp.android.core.data.getScopeFromToken
import dev.zxq5.chatapp.android.core.error.ApiResult
//
//class AuthRepository(
// private val apiClient: ApiClient,
// private val tokenStore: TokenStore,
//) {
//
// suspend fun login(username: String, password: String): LoginResult {
//// return when(val result = apiClient.login(username, password)) {
//// is ApiResult.Success -> {
//// tokenStore.save(context = context, result.data.token);
//// }
//// }
// }
//}
import dev.zxq5.chatapp.android.feature.auth.TokenScope
class AuthRepository(
private val tokenStore: TokenStore,
) {
suspend fun signup(username: String, email: String, password: String, accessToken: String): SignupResult {
return when(val result = AuthClient.signup(username, email, password, accessToken)) {
is ApiResult.HttpError -> SignupResult.Error(result.message)
is ApiResult.NetworkError -> SignupResult.Error("Network error: ${result.message}")
is ApiResult.Success -> {
tokenStore.save(result.data.token)
SignupResult.Success
}
}
}
suspend fun verifyTotpLogin(code: String): LoginResult {
val partialToken = tokenStore.get() ?: return LoginResult.Error("Session expired")
return when(val result = AuthClient.verifyTotpLogin(partialToken, code)) {
is ApiResult.HttpError -> LoginResult.TotpError(result.message)
is ApiResult.NetworkError -> LoginResult.TotpError("Network error: ${result.message}")
is ApiResult.Success -> {
tokenStore.save(result.data.token)
LoginResult.Success
}
}
}
suspend fun login(username: String, password: String): LoginResult {
return when(val result = AuthClient.login(username, password)) {
is ApiResult.HttpError -> LoginResult.Error(result.message)
is ApiResult.NetworkError -> LoginResult.Error("Network error: ${result.message}")
is ApiResult.Success -> {
tokenStore.save(result.data.token)
when (val scope = getScopeFromToken(result.data.token)) {
TokenScope.TOTP_PENDING -> LoginResult.TotpRequired
TokenScope.FULL -> LoginResult.Success
else -> LoginResult.Error("Unexpected token scope: $scope")
}
}
}
}
fun logout() {
tokenStore.clear()
}
fun getUserId() = tokenStore.getUserId()
fun getAuthState(): AuthState {
val token = tokenStore.get() ?: return AuthState.Unauthenticated
return when (getScopeFromToken(token)) {
TokenScope.FULL -> AuthState.Authenticated
TokenScope.TOTP_PENDING -> AuthState.AwaitingTotp
else -> AuthState.Unauthenticated
}
}
}
sealed class SignupResult {
object Success : SignupResult()
data class Error(val message: String) : SignupResult()
}
sealed class LoginResult {
object Success : LoginResult()
object TotpRequired : LoginResult() // step 1 outcome → go to totp screen
data class TotpError(val message: String) : LoginResult() // step 2 failure → stay on totp screen, show error
data class Error(val message: String) : LoginResult() // general failure → show on login form
object TotpRequired : LoginResult()
data class TotpError(val message: String) : LoginResult()
data class Error(val message: String) : LoginResult()
}
sealed class AuthState {
@@ -0,0 +1,38 @@
package dev.zxq5.chatapp.android.data.repository
import dev.zxq5.chatapp.android.api.ChatClient
import dev.zxq5.chatapp.android.core.data.TokenStore
import dev.zxq5.chatapp.android.api.model.Message
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
class ChatRepository(private val tokenStore: TokenStore) {
private var _chatClient: ChatClient? = null
private var _lastToken: String? = null
private fun getChatClient(): ChatClient? {
val token = tokenStore.get() ?: return null
if (_chatClient == null || token != _lastToken) {
_chatClient = ChatClient(token)
_lastToken = token
}
return _chatClient
}
fun resetClient() {
_chatClient = null
_lastToken = null
}
fun getUserId() = tokenStore.getUserId()
suspend fun sendMessage(channelId: Int, text: String) {
val userId = tokenStore.getUserId() ?: return
getChatClient()?.sendMessage(channelId, userId, text)
}
fun messageStream(channelId: Int): Flow<Message> {
return getChatClient()?.messageStream(channelId) ?: emptyFlow()
}
}
@@ -0,0 +1,67 @@
package dev.zxq5.chatapp.android.data.repository
import dev.zxq5.chatapp.android.api.model.QrResponse
import dev.zxq5.chatapp.android.api.SettingsClient
import dev.zxq5.chatapp.android.api.model.TotpStatus
import dev.zxq5.chatapp.android.core.data.TokenStore
import dev.zxq5.chatapp.android.core.error.ApiResult
class SettingsRepository(private val tokenStore: TokenStore) {
private var _settingsClient: SettingsClient? = null
private var _lastToken: String? = null
private fun getSettingsClient(): SettingsClient? {
val token = tokenStore.get() ?: return null
if (_settingsClient == null || token != _lastToken) {
_settingsClient = SettingsClient(token)
_lastToken = token
}
return _settingsClient
}
fun resetClient() {
_settingsClient = null
_lastToken = null
}
suspend fun getTotpQr(password: String): ApiResult<QrResponse?> {
val settingsClient = getSettingsClient() ?: return ApiResult.NetworkError("Not authenticated")
return settingsClient.getTotpQr(password)
}
suspend fun confirmTotp(code: String): ApiResult<Unit> {
val client = getSettingsClient() ?: return ApiResult.NetworkError("Not authenticated")
return client.confirmTotp(code)
}
suspend fun getTotpStatus(): ApiResult<TotpStatus> {
return getSettingsClient()?.getTotpStatus() ?: ApiResult.NetworkError("Not authenticated")
}
suspend fun disableTotp(password: String, totpCode: String): ApiResult<Unit> {
val client = getSettingsClient() ?: return ApiResult.NetworkError("Not authenticated")
return client.disableTotp(password, totpCode)
}
suspend fun changePassword(old: String, new: String): ApiResult<Unit> {
return getSettingsClient()?.changePassword(old, new) ?: ApiResult.NetworkError("Not authenticated")
}
suspend fun updateDisplayName(name: String?): Boolean {
return getSettingsClient()?.updateDisplayName(name) ?: false
}
suspend fun updateUsername(username: String): ApiResult<Unit> {
return getSettingsClient()?.updateUsername(username) ?: ApiResult.NetworkError("Not authenticated")
}
suspend fun deleteAccount(password: String, totpCode: String?): ApiResult<Unit> {
return getSettingsClient()?.deleteAccount(password, totpCode) ?: ApiResult.NetworkError("Not authenticated")
}
fun logout() {
tokenStore.clear()
resetClient()
}
}
@@ -0,0 +1,5 @@
package dev.zxq5.chatapp.android.feature.auth
enum class AuthMode {
LOGIN, SIGNUP
}
@@ -0,0 +1,39 @@
package dev.zxq5.chatapp.android.feature.auth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import dev.zxq5.chatapp.android.model.LoginState
@Composable
fun AuthScreen(viewModel: AuthViewModel) {
val loginState by viewModel.loginState.collectAsState()
val authMode by viewModel.authMode.collectAsState()
val totpError by viewModel.totpError.collectAsState()
if (loginState is LoginState.TwoFactorRequired ||
(loginState is LoginState.Loading && totpError != null)) {
TwoFactorLoginScreen(
onVerify = { viewModel.verifyTotpLogin(it) },
onBack = {
viewModel.clearTotpError()
viewModel.setAuthMode(AuthMode.LOGIN)
},
isLoading = loginState is LoginState.Loading,
error = totpError
)
return
}
if (authMode == AuthMode.SIGNUP) {
SignupScreen(
viewModel = viewModel,
onSwitchToLogin = { viewModel.setAuthMode(AuthMode.LOGIN) }
)
} else {
LoginScreen(
viewModel = viewModel,
onSwitchToSignup = { viewModel.setAuthMode(AuthMode.SIGNUP) }
)
}
}
@@ -0,0 +1,107 @@
package dev.zxq5.chatapp.android.feature.auth
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dev.zxq5.chatapp.android.data.repository.AuthRepository
import dev.zxq5.chatapp.android.data.repository.LoginResult
import dev.zxq5.chatapp.android.data.repository.SignupResult
import dev.zxq5.chatapp.android.data.repository.AuthState
import dev.zxq5.chatapp.android.model.LoginState
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
class AuthViewModel(private val authRepository: AuthRepository) : ViewModel() {
private val _loginState = MutableStateFlow<LoginState>(LoginState.Idle)
val loginState: StateFlow<LoginState> = _loginState
private val _authMode = MutableStateFlow(AuthMode.LOGIN)
val authMode: StateFlow<AuthMode> = _authMode
private val _authState = MutableStateFlow(authRepository.getAuthState())
val authState: StateFlow<AuthState> = _authState
private val _totpError = MutableStateFlow<String?>(null)
val totpError: StateFlow<String?> = _totpError
fun setAuthMode(mode: AuthMode) {
_authMode.value = mode
if (_loginState.value is LoginState.Error) {
_loginState.value = LoginState.Idle
}
}
fun signup(username: String, email: String, password: String, accessToken: String) {
viewModelScope.launch {
_loginState.value = LoginState.Loading
when (val result = authRepository.signup(username, email, password, accessToken)) {
is SignupResult.Success -> {
updateAuthState()
_loginState.value = LoginState.Success
}
is SignupResult.Error -> {
_loginState.value = LoginState.Error(result.message)
}
}
}
}
fun login(username: String, password: String) {
viewModelScope.launch {
_loginState.value = LoginState.Loading
when (val result = authRepository.login(username, password)) {
is LoginResult.Success -> {
updateAuthState()
_loginState.value = LoginState.Success
}
is LoginResult.TotpRequired -> {
updateAuthState()
_loginState.value = LoginState.TwoFactorRequired
}
is LoginResult.Error -> {
_loginState.value = LoginState.Error(result.message)
}
is LoginResult.TotpError -> {
_loginState.value = LoginState.Error(result.message)
}
}
}
}
fun verifyTotpLogin(code: String) {
viewModelScope.launch {
_loginState.value = LoginState.Loading
when (val result = authRepository.verifyTotpLogin(code)) {
is LoginResult.Success -> {
updateAuthState()
_loginState.value = LoginState.Success
}
is LoginResult.TotpError -> {
_totpError.value = result.message
_loginState.value = LoginState.TwoFactorRequired
}
is LoginResult.Error -> {
_loginState.value = LoginState.Error(result.message)
}
is LoginResult.TotpRequired -> {
_loginState.value = LoginState.TwoFactorRequired
}
}
}
}
fun logout() {
authRepository.logout()
updateAuthState()
_loginState.value = LoginState.Idle
}
private fun updateAuthState() {
_authState.value = authRepository.getAuthState()
}
fun clearTotpError() {
_totpError.value = null
}
}
@@ -0,0 +1,133 @@
package dev.zxq5.chatapp.android.feature.auth
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import dev.zxq5.chatapp.android.model.LoginState
import dev.zxq5.chatapp.android.ui.components.TextField
@Composable
fun LoginScreen(
viewModel: AuthViewModel,
onSwitchToSignup: () -> Unit
) {
val loginState by viewModel.loginState.collectAsState()
var username by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var localError by remember { mutableStateOf<String?>(null) }
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.padding(24.dp)
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(Modifier.height(40.dp))
Text(
text = "messenger",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 8.dp)
)
Text(
text = "welcome back",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(bottom = 48.dp)
)
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
TextField(
value = username,
onValueChange = { username = it },
label = "username"
)
TextField(
value = password,
onValueChange = { password = it },
label = "password",
isPassword = true
)
}
Spacer(Modifier.height(32.dp))
Button(
onClick = {
localError = null
if (username.isBlank() || password.isBlank()) {
localError = "fill all fields"
return@Button
}
viewModel.login(username, password)
},
enabled = loginState !is LoginState.Loading,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary,
disabledContainerColor = MaterialTheme.colorScheme.secondary
)
) {
if (loginState is LoginState.Loading) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), color = MaterialTheme.colorScheme.onPrimary, strokeWidth = 2.dp)
} else {
Text("login", style = MaterialTheme.typography.bodyLarge)
}
}
val displayError = localError ?: (loginState as? LoginState.Error)?.message
if (displayError != null) {
Text(
text = displayError.lowercase(),
style = MaterialTheme.typography.labelSmall,
color = Color.Red,
modifier = Modifier.padding(top = 16.dp)
)
}
Spacer(Modifier.height(16.dp))
TextButton(onClick = onSwitchToSignup) {
Text(
"no account? sign up",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@@ -0,0 +1,160 @@
package dev.zxq5.chatapp.android.feature.auth
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import dev.zxq5.chatapp.android.model.LoginState
import dev.zxq5.chatapp.android.ui.components.TextField
@Composable
fun SignupScreen(
viewModel: AuthViewModel,
onSwitchToLogin: () -> Unit
) {
val loginState by viewModel.loginState.collectAsState()
var username by remember { mutableStateOf("") }
var email by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var confirmPassword by remember { mutableStateOf("") }
var accessToken by remember { mutableStateOf("") }
var localError by remember { mutableStateOf<String?>(null) }
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.padding(24.dp)
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(Modifier.height(40.dp))
Text(
text = "messenger",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 8.dp)
)
Text(
text = "create account",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(bottom = 48.dp)
)
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
TextField(
value = username,
onValueChange = { username = it },
label = "username"
)
TextField(
value = email,
onValueChange = { email = it },
label = "email"
)
TextField(
value = password,
onValueChange = { password = it },
label = "password",
isPassword = true
)
TextField(
value = confirmPassword,
onValueChange = { confirmPassword = it },
label = "confirm password",
isPassword = true
)
TextField(
value = accessToken,
onValueChange = { accessToken = it },
label = "access token"
)
}
Spacer(Modifier.height(32.dp))
Button(
onClick = {
localError = null
if (username.isBlank() || email.isBlank() || password.isBlank() || accessToken.isBlank()) {
localError = "fill all fields"
return@Button
}
if (password != confirmPassword) {
localError = "passwords mismatch"
return@Button
}
viewModel.signup(username, email, password, accessToken)
},
enabled = loginState !is LoginState.Loading,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary,
disabledContainerColor = MaterialTheme.colorScheme.secondary
)
) {
if (loginState is LoginState.Loading) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), color = MaterialTheme.colorScheme.onPrimary, strokeWidth = 2.dp)
} else {
Text("sign up", style = MaterialTheme.typography.bodyLarge)
}
}
val displayError = localError ?: (loginState as? LoginState.Error)?.message
if (displayError != null) {
Text(
text = displayError.lowercase(),
style = MaterialTheme.typography.labelSmall,
color = Color.Red,
modifier = Modifier.padding(top = 16.dp)
)
}
Spacer(Modifier.height(16.dp))
TextButton(onClick = onSwitchToLogin) {
Text(
"have account? login",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@@ -0,0 +1,6 @@
package dev.zxq5.chatapp.android.feature.auth
object TokenScope {
const val FULL = "full"
const val TOTP_PENDING = "totp_pending"
}
@@ -0,0 +1,138 @@
package dev.zxq5.chatapp.android.feature.auth
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@Composable
fun TwoFactorLoginScreen(
onVerify: (String) -> Unit,
onBack: () -> Unit,
isLoading: Boolean,
error: String?
) {
var code by remember { mutableStateOf("") }
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = onBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Text(
"security verification",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(Modifier.height(80.dp))
Text(
"two-factor auth",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onSurface
)
Text(
"enter the 6-digit code from your app",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
modifier = Modifier.padding(top = 8.dp, bottom = 48.dp)
)
OutlinedTextField(
value = code,
onValueChange = { if (it.length <= 6) code = it },
placeholder = { Text("000000", color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f)) },
modifier = Modifier.width(200.dp),
textStyle = MaterialTheme.typography.headlineMedium.copy(
textAlign = TextAlign.Center,
letterSpacing = 8.sp
),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
shape = RoundedCornerShape(8.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.primary,
unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant,
focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.1f),
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.1f)
),
singleLine = true
)
if (error != null) {
Text(
text = error.lowercase(),
style = MaterialTheme.typography.labelSmall,
color = Color.Red,
modifier = Modifier.padding(top = 12.dp)
)
} else {
Spacer(Modifier.height(12.dp))
}
Spacer(Modifier.height(36.dp))
Button(
onClick = { if (code.length == 6) onVerify(code) },
enabled = code.length == 6 && !isLoading,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary
)
) {
if (isLoading) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), color = MaterialTheme.colorScheme.onPrimary, strokeWidth = 2.dp)
} else {
Text("verify", style = MaterialTheme.typography.bodyLarge)
}
}
}
}
@@ -0,0 +1,93 @@
package dev.zxq5.chatapp.android.feature.chat
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dev.zxq5.chatapp.android.data.repository.ChatRepository
import dev.zxq5.chatapp.android.api.model.Message
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class ChatViewModel(private val chatRepository: ChatRepository) : ViewModel() {
private val _messages = MutableStateFlow<List<Message>>(emptyList())
val messages: StateFlow<List<Message>> = _messages
private val _channelId = MutableStateFlow<Int?>(null)
val channelId: StateFlow<Int?> = _channelId
private val _currentScreen = MutableStateFlow(Screen.CHAT)
val currentScreen: StateFlow<Screen> = _currentScreen
private val _currentUserId = MutableStateFlow<Int?>(null)
val currentUserId: StateFlow<Int?> = _currentUserId
private var streamJob: Job? = null
init {
_currentUserId.value = chatRepository.getUserId()
observeChannel()
}
private fun observeChannel() {
viewModelScope.launch {
_channelId.collect { id ->
streamJob?.cancel()
_messages.value = emptyList()
if (id != null) {
streamJob = launch {
chatRepository.messageStream(id)
.catch { e ->
Log.e("Chat", "Stream error", e)
}
.collect { message ->
_messages.update { it + message }
}
}
}
}
}
}
fun navigateTo(screen: Screen) {
_currentScreen.value = screen
}
fun switchChannel(id: Int?) {
_channelId.value = id
if (id != null) {
// Refresh user ID just in case it wasn't available at init
_currentUserId.value = chatRepository.getUserId()
chatRepository.resetClient()
}
}
fun sendMessage(text: String) {
val currentId = _channelId.value ?: return
viewModelScope.launch {
runCatching {
chatRepository.sendMessage(
channelId = currentId,
text = text
)
}.onFailure { e ->
Log.e("Chat", "Send message error", e)
}
}
}
fun clearChat() {
_messages.value = emptyList()
_channelId.value = null
_currentUserId.value = null
streamJob?.cancel()
chatRepository.resetClient()
}
}
@@ -0,0 +1,5 @@
package dev.zxq5.chatapp.android.feature.chat
enum class Screen {
CHAT, SETTINGS
}
@@ -1,5 +1,6 @@
package dev.zxq5.chatapp.android.ui.components
package dev.zxq5.chatapp.android.feature.chat
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
@@ -26,12 +27,7 @@ import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ExitToApp
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Send
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.outlined.ChatBubbleOutline
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
@@ -45,6 +41,9 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material.icons.filled.Send
import androidx.compose.material.icons.outlined.ChatBubbleOutline
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@@ -58,21 +57,25 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import dev.zxq5.chatapp.android.model.ChatViewModel
import dev.zxq5.chatapp.android.model.MainScreen
import dev.zxq5.chatapp.android.model.Message
import dev.zxq5.chatapp.android.api.model.Message
import java.text.DateFormat
import java.util.Date
@Composable
fun ChatScreen(viewModel: ChatViewModel) {
fun ChatScreen(
viewModel: ChatViewModel,
onNavigateToSettings: () -> Unit,
onLogout: () -> Unit // Note: logout is now part of SettingsScreen in this UI, but we'll keep the param for now
) {
val selectedChannelId by viewModel.channelId.collectAsState()
if (selectedChannelId == null) {
ChannelListScreen(
viewModel = viewModel,
onChannelSelect = { viewModel.switchChannel(it) }
onChannelSelect = { viewModel.switchChannel(it) },
onNavigateToSettings = onNavigateToSettings
)
} else {
MessageScreen(
@@ -85,7 +88,11 @@ fun ChatScreen(viewModel: ChatViewModel) {
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChannelListScreen(viewModel: ChatViewModel, onChannelSelect: (Int) -> Unit) {
fun ChannelListScreen(
viewModel: ChatViewModel,
onChannelSelect: (Int) -> Unit,
onNavigateToSettings: () -> Unit
) {
Scaffold(
containerColor = MaterialTheme.colorScheme.background,
topBar = {
@@ -153,7 +160,7 @@ fun ChannelListScreen(viewModel: ChatViewModel, onChannelSelect: (Int) -> Unit)
}
}
},
bottomBar = { BottomDock(viewModel) }
bottomBar = { BottomDock(viewModel, onNavigateToSettings) }
) { padding ->
LazyColumn(modifier = Modifier.padding(padding).fillMaxSize()) {
items(10) { i ->
@@ -170,7 +177,7 @@ fun ChannelListScreen(viewModel: ChatViewModel, onChannelSelect: (Int) -> Unit)
}
@Composable
fun BottomDock(viewModel: ChatViewModel) {
fun BottomDock(viewModel: ChatViewModel, onNavigateToSettings: () -> Unit) {
val currentScreen by viewModel.currentScreen.collectAsState()
NavigationBar(
@@ -179,8 +186,8 @@ fun BottomDock(viewModel: ChatViewModel) {
modifier = Modifier.border(0.5.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f), RoundedCornerShape(topStart = 0.dp, topEnd = 0.dp))
) {
NavigationBarItem(
selected = currentScreen == MainScreen.CHAT,
onClick = { viewModel.navigateTo(MainScreen.CHAT) },
selected = currentScreen == Screen.CHAT,
onClick = { viewModel.navigateTo(Screen.CHAT) },
icon = { Icon(Icons.Outlined.ChatBubbleOutline, contentDescription = "Chat") },
label = { Text("chat", style = MaterialTheme.typography.labelSmall) },
colors = NavigationBarItemDefaults.colors(
@@ -190,8 +197,8 @@ fun BottomDock(viewModel: ChatViewModel) {
)
)
NavigationBarItem(
selected = currentScreen == MainScreen.SETTINGS,
onClick = { viewModel.navigateTo(MainScreen.SETTINGS) },
selected = currentScreen == Screen.SETTINGS,
onClick = onNavigateToSettings,
icon = { Icon(Icons.Outlined.Settings, contentDescription = "Settings") },
label = { Text("settings", style = MaterialTheme.typography.labelSmall) },
colors = NavigationBarItemDefaults.colors(
@@ -409,7 +416,7 @@ fun MessageBubble(message: Message, currentUserId: Int?) {
Column(modifier = Modifier.padding(horizontal = 11.dp, vertical = 8.dp)) {
if (!isMe) {
Text(
message.display_name.lowercase(),
message.display_name?.lowercase() ?: "unknown",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.7f),
modifier = Modifier.padding(bottom = 2.dp)
@@ -431,5 +438,5 @@ fun MessageBubble(message: Message, currentUserId: Int?) {
}
}
private fun border(width: androidx.compose.ui.unit.Dp, color: Color) =
androidx.compose.foundation.BorderStroke(width, color)
private fun border(width: Dp, color: Color) =
BorderStroke(width, color)
@@ -0,0 +1,157 @@
package dev.zxq5.chatapp.android.feature.settings
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dev.zxq5.chatapp.android.api.model.QrResponse
import dev.zxq5.chatapp.android.core.error.ApiResult
import dev.zxq5.chatapp.android.data.repository.SettingsRepository
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
class SettingsViewModel(private val settingsRepository: SettingsRepository) : ViewModel() {
private val _is2faEnabled = MutableStateFlow(false)
val is2faEnabled: StateFlow<Boolean> = _is2faEnabled
private val _totpQr = MutableStateFlow<QrResponse?>(null)
val totpQr: StateFlow<QrResponse?> = _totpQr
private val _totpError = MutableStateFlow<String?>(null)
val totpError: StateFlow<String?> = _totpError
private val _settingsError = MutableStateFlow<String?>(null)
val settingsError: StateFlow<String?> = _settingsError
private val _isSuccessState = MutableStateFlow<Map<String, Boolean>>(emptyMap())
val isSuccessState: StateFlow<Map<String, Boolean>> = _isSuccessState
fun clearMessages() {
_settingsError.value = null
_totpError.value = null
}
private fun triggerSuccess(key: String) {
viewModelScope.launch {
_isSuccessState.value = _isSuccessState.value + (key to true)
delay(5000)
_isSuccessState.value = _isSuccessState.value + (key to false)
}
}
fun fetchTotpStatus() {
viewModelScope.launch {
when (val result = settingsRepository.getTotpStatus()) {
is ApiResult.Success -> _is2faEnabled.value = result.data.isEnabled
else -> {}
}
}
}
fun fetchTotpQr(password: String) {
viewModelScope.launch {
_totpError.value = null
when (val result = settingsRepository.getTotpQr(password)) {
is ApiResult.Success -> {
_totpQr.value = result.data
}
is ApiResult.HttpError -> {
_totpError.value = result.message
}
is ApiResult.NetworkError -> {
_totpError.value = "Network error: ${result.message}"
}
}
}
}
fun confirmTotp(code: String) {
viewModelScope.launch {
_totpError.value = null
when (val result = settingsRepository.confirmTotp(code)) {
is ApiResult.Success -> {
_is2faEnabled.value = true
_totpQr.value = null
triggerSuccess("2fa")
}
is ApiResult.HttpError -> {
_totpError.value = result.message
}
is ApiResult.NetworkError -> {
_totpError.value = "Network error: ${result.message}"
}
}
}
}
fun disableTotp(password: String, totpCode: String) {
viewModelScope.launch {
_totpError.value = null
when (val result = settingsRepository.disableTotp(password, totpCode)) {
is ApiResult.Success -> {
_is2faEnabled.value = false
triggerSuccess("2fa")
}
is ApiResult.HttpError -> _totpError.value = result.message
is ApiResult.NetworkError -> _totpError.value = "Network error: ${result.message}"
}
}
}
fun changePassword(old: String, new: String) {
viewModelScope.launch {
clearMessages()
when (val result = settingsRepository.changePassword(old, new)) {
is ApiResult.Success -> {
triggerSuccess("password")
}
is ApiResult.HttpError -> _settingsError.value = result.message
is ApiResult.NetworkError -> _settingsError.value = "Network error: ${result.message}"
}
}
}
fun updateDisplayName(name: String?) {
viewModelScope.launch {
clearMessages()
if (settingsRepository.updateDisplayName(name)) {
triggerSuccess("display_name")
} else {
_settingsError.value = "Failed to update display name"
}
}
}
fun updateUsername(username: String) {
viewModelScope.launch {
clearMessages()
when (val result = settingsRepository.updateUsername(username)) {
is ApiResult.Success -> {
triggerSuccess("username")
}
is ApiResult.HttpError -> _settingsError.value = result.message
is ApiResult.NetworkError -> _settingsError.value = "Network error: ${result.message}"
}
}
}
fun deleteAccount(password: String, totpCode: String?, onLogout: () -> Unit) {
viewModelScope.launch {
clearMessages()
when (val result = settingsRepository.deleteAccount(password, totpCode)) {
is ApiResult.Success -> {
_isSuccessState.value = _isSuccessState.value + ("delete" to true)
delay(3000)
onLogout()
}
is ApiResult.HttpError -> _settingsError.value = result.message
is ApiResult.NetworkError -> _settingsError.value = "Network error: ${result.message}"
}
}
}
fun logout() {
settingsRepository.logout()
}
}
@@ -0,0 +1,534 @@
package dev.zxq5.chatapp.android.feature.settings
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import android.util.Base64
import android.graphics.BitmapFactory
import androidx.compose.ui.text.style.TextAlign
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
viewModel: SettingsViewModel,
onBack: () -> Unit,
onLogout: () -> Unit
) {
val is2faEnabled by viewModel.is2faEnabled.collectAsState()
val totpQr by viewModel.totpQr.collectAsState()
val settingsError by viewModel.settingsError.collectAsState()
val isSuccessState by viewModel.isSuccessState.collectAsState()
val totpError by viewModel.totpError.collectAsState()
LaunchedEffect(Unit) {
viewModel.clearMessages()
viewModel.fetchTotpStatus()
}
Scaffold(
containerColor = MaterialTheme.colorScheme.background,
topBar = {
TopAppBar(
title = {
Text(
"settings",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface
)
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent)
)
}
) { padding ->
Column(
modifier = Modifier
.padding(padding)
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
SettingsSection(title = "profile") {
var displayName by remember { mutableStateOf("") }
var username by remember { mutableStateOf("") }
SettingsField(
label = "display name",
value = displayName,
onValueChange = { displayName = it },
buttonLabel = "update",
isSuccess = isSuccessState["display_name"] == true,
onClick = { viewModel.updateDisplayName(displayName.ifBlank { null }) }
)
Spacer(Modifier.height(16.dp))
SettingsField(
label = "username",
value = username,
onValueChange = { username = it },
buttonLabel = "update",
isSuccess = isSuccessState["username"] == true,
onClick = { if (username.isNotBlank()) viewModel.updateUsername(username) }
)
}
SettingsSection(title = "security") {
var oldPassword by remember { mutableStateOf("") }
var newPassword by remember { mutableStateOf("") }
Text("change password", style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(bottom = 8.dp))
OutlinedTextField(
value = oldPassword,
onValueChange = { oldPassword = it },
label = { Text("old password") },
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp)
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = newPassword,
onValueChange = { newPassword = it },
label = { Text("new password") },
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp)
)
Spacer(Modifier.height(12.dp))
SuccessButton(
onClick = {
viewModel.changePassword(oldPassword, newPassword)
oldPassword = ""
newPassword = ""
},
label = "update password",
isSuccess = isSuccessState["password"] == true,
enabled = oldPassword.isNotEmpty() && newPassword.isNotEmpty(),
modifier = Modifier.fillMaxWidth()
)
HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp), color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f))
var show2faSetup by remember { mutableStateOf(false) }
var setupPassword by remember { mutableStateOf("") }
var show2faDisable by remember { mutableStateOf(false) }
var disablePassword by remember { mutableStateOf("") }
var disableCode by remember { mutableStateOf("") }
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text("two-factor authentication", style = MaterialTheme.typography.bodyLarge)
Text(
if (is2faEnabled) "enabled" else "disabled",
style = MaterialTheme.typography.labelSmall,
color = if (is2faEnabled) Color.Green.copy(alpha = 0.7f) else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
)
}
if (!is2faEnabled) {
Button(
onClick = {
show2faSetup = !show2faSetup
if (!show2faSetup) setupPassword = ""
},
shape = RoundedCornerShape(8.dp),
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), contentColor = MaterialTheme.colorScheme.onSurface)
) {
Text(if (show2faSetup) "cancel" else "setup", style = MaterialTheme.typography.labelSmall)
}
} else {
SuccessButton(
onClick = {
show2faDisable = !show2faDisable
if (!show2faDisable) {
disablePassword = ""
disableCode = ""
}
},
label = if (show2faDisable) "cancel" else "disable",
isSuccess = isSuccessState["2fa"] == true,
baseColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.1f),
contentColor = Color.Red,
successColor = Color.Red
)
}
}
if (show2faSetup && !is2faEnabled) {
Spacer(Modifier.height(16.dp))
if (totpQr == null) {
OutlinedTextField(
value = setupPassword,
onValueChange = { setupPassword = it },
label = { Text("confirm password to setup") },
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp)
)
Spacer(Modifier.height(8.dp))
Button(
onClick = { viewModel.fetchTotpQr(setupPassword) },
enabled = setupPassword.isNotBlank(),
modifier = Modifier.fillMaxWidth()
) {
Text("get qr code")
}
} else {
TwoFactorSetup(
qrCodeBase64 = totpQr?.qr_code,
error = totpError,
onConfirm = { viewModel.confirmTotp(it) }
)
}
}
if (show2faDisable && is2faEnabled) {
Spacer(Modifier.height(16.dp))
OutlinedTextField(
value = disablePassword,
onValueChange = { disablePassword = it },
label = { Text("password") },
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp)
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = disableCode,
onValueChange = { if (it.length <= 6) disableCode = it },
label = { Text("2fa code") },
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
)
Spacer(Modifier.height(8.dp))
SuccessButton(
onClick = { viewModel.disableTotp(disablePassword, disableCode) },
label = "confirm disable",
isSuccess = isSuccessState["2fa"] == true,
baseColor = Color.Red,
enabled = disablePassword.isNotBlank() && disableCode.length == 6,
modifier = Modifier.fillMaxWidth()
)
}
if (totpError != null && !show2faSetup && !show2faDisable) {
Text(totpError!!.lowercase(), color = Color.Red, style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(top = 8.dp))
}
}
SettingsSection(title = "danger zone", color = Color.Red.copy(alpha = 0.7f)) {
var deletePassword by remember { mutableStateOf("") }
var deleteTotp by remember { mutableStateOf("") }
var showDeleteConfirm by remember { mutableStateOf(false) }
if (!showDeleteConfirm) {
Button(
onClick = { showDeleteConfirm = true },
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color.Red.copy(alpha = 0.1f), contentColor = Color.Red)
) {
Text("delete account")
}
} else {
Text("confirm account deletion", color = Color.Red, style = MaterialTheme.typography.bodyMedium)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = deletePassword,
onValueChange = { deletePassword = it },
label = { Text("password") },
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp)
)
if (is2faEnabled) {
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = deleteTotp,
onValueChange = { if (it.length <= 6) deleteTotp = it },
label = { Text("2fa code") },
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
)
}
Spacer(Modifier.height(12.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(
onClick = { showDeleteConfirm = false },
modifier = Modifier.weight(1f),
shape = RoundedCornerShape(8.dp),
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
) {
Text("cancel")
}
SuccessButton(
onClick = { viewModel.deleteAccount(
deletePassword, deleteTotp.ifBlank { null },
onLogout
) },
label = "delete forever",
isSuccess = isSuccessState["delete"] == true,
baseColor = Color.Red.copy(alpha = 0.1f),
contentColor = Color.Red,
successColor = Color.Red,
modifier = Modifier.weight(1f),
enabled = deletePassword.isNotEmpty() && (!is2faEnabled || deleteTotp.length == 6)
)
}
}
}
SettingsSection(title = "session") {
Spacer(Modifier.height(16.dp))
Button(
onClick = onLogout,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color.White, contentColor = Color.Black)
) {
Text("logout")
}
}
if (settingsError != null) {
Text(settingsError!!, color = Color.Red, style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(top = 8.dp))
}
}
}
}
@Composable
fun SettingsSection(
title: String,
color: Color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
content: @Composable () -> Unit
) {
var expanded by remember { mutableStateOf(true) }
Column(
modifier = Modifier
.fillMaxWidth()
.border(0.5.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f), RoundedCornerShape(12.dp))
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.5f))
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { expanded = !expanded }
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(title.lowercase(), style = MaterialTheme.typography.labelSmall, color = color)
Icon(
if (expanded) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown,
contentDescription = null,
tint = color,
modifier = Modifier.size(16.dp)
)
}
AnimatedVisibility(visible = expanded) {
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp).padding(bottom = 8.dp)) {
content()
}
}
}
}
@Composable
fun SettingsField(
label: String,
value: String,
onValueChange: (String) -> Unit,
buttonLabel: String,
isSuccess: Boolean,
onClick: () -> Unit
) {
Column {
OutlinedTextField(
value = value,
onValueChange = onValueChange,
label = { Text(label) },
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = if (isSuccess) Color.Green else MaterialTheme.colorScheme.primary
)
)
Spacer(Modifier.height(8.dp))
SuccessButton(
onClick = onClick,
label = buttonLabel,
isSuccess = isSuccess,
enabled = value.isNotBlank(),
modifier = Modifier.fillMaxWidth()
)
}
}
@Composable
fun SuccessButton(
onClick: () -> Unit,
label: String,
isSuccess: Boolean,
modifier: Modifier = Modifier,
enabled: Boolean = true,
baseColor: Color = MaterialTheme.colorScheme.primary,
contentColor: Color = MaterialTheme.colorScheme.onPrimary,
successColor: Color = Color.Green.copy(alpha = 0.8f)
) {
val backgroundColor by animateColorAsState(
targetValue = if (isSuccess) successColor else baseColor,
animationSpec = tween(500)
)
Button(
onClick = onClick,
modifier = modifier,
enabled = enabled,
shape = RoundedCornerShape(8.dp),
colors = ButtonDefaults.buttonColors(
containerColor = backgroundColor,
contentColor = if (isSuccess) Color.White else contentColor
)
) {
Text(if (isSuccess) "success" else label)
}
}
@Composable
fun TwoFactorSetup(
qrCodeBase64: String?,
error: String?,
onConfirm: (String) -> Unit
) {
var code by remember { mutableStateOf("") }
Column(
modifier = Modifier
.fillMaxWidth()
.border(0.5.dp, MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.1f), RoundedCornerShape(12.dp))
.padding(20.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
if (qrCodeBase64 != null) {
val bitmap = remember(qrCodeBase64) {
val base64Data = qrCodeBase64.substringAfter("base64,")
val decodedString = Base64.decode(base64Data, Base64.DEFAULT)
BitmapFactory.decodeByteArray(decodedString, 0, decodedString.size)
}
bitmap?.let {
Image(
bitmap = it.asImageBitmap(),
contentDescription = "QR Code",
modifier = Modifier
.size(180.dp)
.clip(RoundedCornerShape(8.dp))
.background(Color.White)
.padding(8.dp)
)
}
} else {
CircularProgressIndicator(modifier = Modifier.size(40.dp))
}
Spacer(Modifier.height(24.dp))
OutlinedTextField(
value = code,
onValueChange = { if (it.length <= 6) code = it },
placeholder = { Text("000000") },
modifier = Modifier.width(150.dp),
textStyle = MaterialTheme.typography.headlineMedium.copy(textAlign = TextAlign.Center),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
shape = RoundedCornerShape(8.dp)
)
if (error != null) {
Text(error.lowercase(), color = Color.Red, style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(top = 8.dp))
}
Spacer(Modifier.height(24.dp))
Button(
onClick = { if (code.length == 6) onConfirm(code) },
enabled = code.length == 6,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp)
) {
Text("confirm code")
}
}
}
@@ -1,325 +0,0 @@
package dev.zxq5.chatapp.android.model
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dev.zxq5.chatapp.android.api.ApiClient
import dev.zxq5.chatapp.android.core.error.ApiResult
import dev.zxq5.chatapp.android.api.QrResponse
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
enum class AuthMode {
LOGIN, SIGNUP
}
enum class MainScreen {
CHAT, SETTINGS
}
class ChatViewModel : ViewModel() {
private val _messages = MutableStateFlow<List<Message>>(emptyList())
val messages: StateFlow<List<Message>> = _messages
private val _channelId = MutableStateFlow<Int?>(null)
val channelId: StateFlow<Int?> = _channelId
private val _currentUserId = MutableStateFlow<Int?>(null)
val currentUserId: StateFlow<Int?> = _currentUserId
private val _currentScreen = MutableStateFlow(MainScreen.CHAT)
val currentScreen: StateFlow<MainScreen> = _currentScreen
val loginState = MutableStateFlow<LoginState>(LoginState.Idle)
// Tracks whether the user is viewing the Login or Signup screen
val authMode = MutableStateFlow(AuthMode.LOGIN)
// 2FA state
private val _totpQr = MutableStateFlow<QrResponse?>(null)
val totpQr: StateFlow<QrResponse?> = _totpQr
private val _is2faEnabled = MutableStateFlow(false)
val is2faEnabled: StateFlow<Boolean> = _is2faEnabled
private val _totpError = MutableStateFlow<String?>(null)
val totpError: StateFlow<String?> = _totpError
// Settings state
private val _settingsError = MutableStateFlow<String?>(null)
val settingsError: StateFlow<String?> = _settingsError
private val _settingsSuccess = MutableStateFlow<String?>(null)
val settingsSuccess: StateFlow<String?> = _settingsSuccess
fun clearSettingsMessages() {
_settingsError.value = null
_settingsSuccess.value = null
}
fun clearTotpError() {
_totpError.value = null
}
private var streamJob: Job? = null
init {
initAuth(ApiClient.hasToken())
}
fun initAuth(hasToken: Boolean) {
if (hasToken) {
val scope = ApiClient.getTokenScope()
if (scope == TokenScope.TOTP_PENDING) {
loginState.value = LoginState.TwoFactorRequired
} else if (scope == TokenScope.FULL) {
loginState.value = LoginState.Success
_currentUserId.value = ApiClient.getStoredUserId()
_is2faEnabled.value = ApiClient.is2faEnabledLocal()
fetchTotpStatus()
observeChannel()
} else {
loginState.value = LoginState.Error("Unknown token scope: $scope")
}
} else {
loginState.value = LoginState.Idle
}
}
private fun observeChannel() {
// restart stream whenever channel changes
viewModelScope.launch {
_channelId.filterNotNull().collect { id ->
streamJob?.cancel()
_messages.value = emptyList()
streamJob = launch {
ApiClient.messageStream(id)
.catch { e ->
Log.e("Chat", "Stream error", e)
}
.collect { message ->
_messages.update { it + message }
}
}
}
}
}
fun navigateTo(screen: MainScreen) {
_currentScreen.value = screen
if (screen == MainScreen.SETTINGS) {
fetchTotpStatus()
}
}
fun switchChannel(id: Int?) {
_channelId.value = id
}
fun setAuthMode(mode: AuthMode) {
authMode.value = mode
// Clear errors when switching modes
if (loginState.value is LoginState.Error || loginState.value is LoginState.TwoFactorRequired) {
loginState.value = LoginState.Idle
}
}
fun sendMessage(text: String) {
val currentId = _channelId.value ?: return
viewModelScope.launch {
runCatching {
ApiClient.sendMessage(
channelId = currentId,
text = text
)
}.onFailure { e ->
Log.e("Chat", "Send message error", e)
}
}
}
fun login(username: String, password: String) {
viewModelScope.launch {
loginState.value = LoginState.Loading
when (val result = ApiClient.login(username, password)) {
is ApiResult.Success -> {
when (val scope = ApiClient.getTokenScope()) {
TokenScope.FULL -> {
_currentUserId.value = ApiClient.getStoredUserId()
_is2faEnabled.value = ApiClient.is2faEnabledLocal()
loginState.value = LoginState.Success
fetchTotpStatus()
observeChannel()
}
TokenScope.TOTP_PENDING -> {
loginState.value = LoginState.TwoFactorRequired
}
else -> {
loginState.value = LoginState.Error("Unknown token scope: $scope")
}
}
}
is ApiResult.HttpError -> {
loginState.value = LoginState.Error(result.message)
}
is ApiResult.NetworkError -> {
loginState.value = LoginState.Error("Could not reach server: ${result.message}")
}
}
}
}
fun verifyLogin2fa(code: String) {
viewModelScope.launch {
loginState.value = LoginState.Loading
when (val result = ApiClient.verifyTotpLogin(code)) {
is ApiResult.Success -> {
val scope = ApiClient.getTokenScope()
if (scope == TokenScope.FULL) {
_currentUserId.value = ApiClient.getStoredUserId()
_is2faEnabled.value = ApiClient.is2faEnabledLocal()
loginState.value = LoginState.Success
fetchTotpStatus()
observeChannel()
} else {
// token came back but scope is wrong — shouldn't happen
loginState.value = LoginState.Error("Unexpected token scope after verification")
}
}
is ApiResult.HttpError -> {
// stay on TOTP screen but show the error
loginState.value = LoginState.TwoFactorRequired
// use a separate error signal so we don't lose the TOTP state
_totpError.value = result.message
}
is ApiResult.NetworkError -> {
loginState.value = LoginState.TwoFactorRequired
_totpError.value = "Could not reach server: ${result.message}"
}
}
}
}
fun signup(username: String, email: String, password: String, accessToken: String) {
viewModelScope.launch {
loginState.value = LoginState.Loading
try {
when (val result = ApiClient.signup(username, email, password, accessToken)) {
is ApiResult.Success -> {
_currentUserId.value = ApiClient.getStoredUserId()
_is2faEnabled.value = ApiClient.is2faEnabledLocal()
loginState.value = LoginState.Success
observeChannel()
}
is ApiResult.HttpError -> {
loginState.value = LoginState.Error(result.message)
}
is ApiResult.NetworkError -> {
loginState.value = LoginState.Error("Could not reach server: ${result.message}")
}
}
} catch (e: Exception) {
Log.e("Chat", "Signup error", e)
loginState.value = LoginState.Error("Signup failed: ${e.localizedMessage}")
}
}
}
fun logout() {
viewModelScope.launch {
ApiClient.logout()
_currentUserId.value = null
_is2faEnabled.value = false
loginState.value = LoginState.Idle
_messages.value = emptyList()
_channelId.value = null
_currentScreen.value = MainScreen.CHAT
streamJob?.cancel()
}
}
fun fetchTotpQr() {
viewModelScope.launch {
_totpQr.value = ApiClient.getTotpQr()
}
}
fun confirmTotp(code: String) {
viewModelScope.launch {
val success = ApiClient.confirmTotp(code)
if (success) {
_is2faEnabled.value = true
ApiClient.set2faEnabledLocal(true)
_totpQr.value = null
} else {
_totpError.value = "Invalid verification code"
}
}
}
fun fetchTotpStatus() {
viewModelScope.launch {
_is2faEnabled.value = ApiClient.getTotpStatus()
}
}
fun disableTotp() {
viewModelScope.launch {
when (val result = ApiClient.disableTotp()) {
is ApiResult.Success -> {
_is2faEnabled.value = false
_settingsSuccess.value = "2FA disabled successfully"
}
is ApiResult.HttpError -> {
_settingsError.value = result.message
}
is ApiResult.NetworkError -> {
_settingsError.value = "Network error: ${result.message}"
}
}
}
}
fun changePassword(old: String, new: String) {
viewModelScope.launch {
_settingsError.value = null
_settingsSuccess.value = null
when (val result = ApiClient.changePassword(old, new)) {
is ApiResult.Success -> {
_settingsSuccess.value = "Password updated"
}
is ApiResult.HttpError -> {
_settingsError.value = result.message
}
is ApiResult.NetworkError -> {
_settingsError.value = "Network error: ${result.message}"
}
}
}
}
fun updateDisplayName(name: String?) {
viewModelScope.launch {
_settingsError.value = null
_settingsSuccess.value = null
val success = ApiClient.updateDisplayName(name)
if (success) {
_settingsSuccess.value = "Display name updated"
} else {
_settingsError.value = "Failed to update display name"
}
}
}
}
object TokenScope {
const val FULL = "full";
const val TOTP_PENDING = "totp_pending";
}
@@ -1,318 +0,0 @@
package dev.zxq5.chatapp.android.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import dev.zxq5.chatapp.android.model.AuthMode
import dev.zxq5.chatapp.android.model.ChatViewModel
import dev.zxq5.chatapp.android.model.LoginState
@Composable
fun AuthScreen(viewModel: ChatViewModel, onSuccess: () -> Unit) {
val loginState by viewModel.loginState.collectAsState()
val authMode by viewModel.authMode.collectAsState()
val totpError by viewModel.totpError.collectAsState()
var username by remember { mutableStateOf("") }
var email by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var confirmPassword by remember { mutableStateOf("") }
var accessToken by remember { mutableStateOf("") }
var localError by remember { mutableStateOf<String?>(null) }
LaunchedEffect(loginState) {
if (loginState is LoginState.Success) onSuccess()
}
LaunchedEffect(authMode) {
localError = null
}
if (loginState is LoginState.TwoFactorRequired ||
(loginState is LoginState.Loading && totpError != null)) {
TwoFactorLoginScreen(
onVerify = { code -> viewModel.verifyLogin2fa(code) },
onBack = {
viewModel.clearTotpError()
viewModel.setAuthMode(AuthMode.LOGIN)
},
isLoading = loginState is LoginState.Loading,
error = totpError
)
return
}
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.padding(24.dp)
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(Modifier.height(40.dp))
Text(
text = "messenger",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 8.dp)
)
Text(
text = if (authMode == AuthMode.LOGIN) "welcome back" else "create account",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(bottom = 48.dp)
)
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
TextField(
value = username,
onValueChange = { username = it },
label = "username"
)
if (authMode == AuthMode.SIGNUP) {
TextField(
value = email,
onValueChange = { email = it },
label = "email"
)
}
TextField(
value = password,
onValueChange = { password = it },
label = "password",
isPassword = true
)
if (authMode == AuthMode.SIGNUP) {
TextField(
value = confirmPassword,
onValueChange = { confirmPassword = it },
label = "confirm password",
isPassword = true
)
TextField(
value = accessToken,
onValueChange = { accessToken = it },
label = "access token"
)
}
}
Spacer(Modifier.height(32.dp))
Button(
onClick = {
localError = null
if (authMode == AuthMode.LOGIN) {
if (username.isBlank() || password.isBlank()) {
localError = "fill all fields"
return@Button
}
viewModel.login(username, password)
} else {
if (username.isBlank() || email.isBlank() || password.isBlank() || accessToken.isBlank()) {
localError = "fill all fields"
return@Button
}
if (password != confirmPassword) {
localError = "passwords mismatch"
return@Button
}
viewModel.signup(username, email, password, accessToken)
}
},
enabled = loginState !is LoginState.Loading,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary,
disabledContainerColor = MaterialTheme.colorScheme.secondary
)
) {
if (loginState is LoginState.Loading) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), color = MaterialTheme.colorScheme.onPrimary, strokeWidth = 2.dp)
} else {
Text(
if (authMode == AuthMode.LOGIN) "login" else "sign up",
style = MaterialTheme.typography.bodyLarge
)
}
}
val displayError = localError ?: (loginState as? LoginState.Error)?.message
if (displayError != null) {
Text(
text = displayError.lowercase(),
style = MaterialTheme.typography.labelSmall,
color = Color.Red,
modifier = Modifier.padding(top = 16.dp)
)
}
Spacer(Modifier.height(16.dp))
TextButton(
onClick = {
viewModel.setAuthMode(if (authMode == AuthMode.LOGIN) AuthMode.SIGNUP else AuthMode.LOGIN)
}
) {
Text(
if (authMode == AuthMode.LOGIN) "no account? sign up"
else "have account? login",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@Composable
fun TwoFactorLoginScreen(
onVerify: (String) -> Unit,
onBack: () -> Unit,
isLoading: Boolean,
error: String?
) {
var code by remember { mutableStateOf("") }
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = onBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Text(
"security verification",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(Modifier.height(80.dp))
Text(
"two-factor auth",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onSurface
)
Text(
"enter the 6-digit code from your app",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
modifier = Modifier.padding(top = 8.dp, bottom = 48.dp)
)
OutlinedTextField(
value = code,
onValueChange = { if (it.length <= 6) code = it },
placeholder = { Text("000000", color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f)) },
modifier = Modifier.width(200.dp),
textStyle = MaterialTheme.typography.headlineMedium.copy(
textAlign = TextAlign.Center,
letterSpacing = 8.sp
),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
shape = RoundedCornerShape(8.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.primary,
unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant,
focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.1f),
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.1f)
),
singleLine = true
)
if (error != null) {
Text(
text = error.lowercase(),
style = MaterialTheme.typography.labelSmall,
color = Color.Red,
modifier = Modifier.padding(top = 12.dp)
)
} else {
Spacer(Modifier.height(12.dp)) // keep layout stable when no error
}
Spacer(Modifier.height(36.dp))
Button(
onClick = { if (code.length == 6) onVerify(code) },
enabled = code.length == 6 && !isLoading,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary
)
) {
if (isLoading) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), color = MaterialTheme.colorScheme.onPrimary, strokeWidth = 2.dp)
} else {
Text("verify", style = MaterialTheme.typography.bodyLarge)
}
}
}
}
@@ -1,368 +0,0 @@
package dev.zxq5.chatapp.android.ui.components
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import dev.zxq5.chatapp.android.model.ChatViewModel
import dev.zxq5.chatapp.android.model.MainScreen
import android.util.Base64
import android.graphics.BitmapFactory
import androidx.compose.runtime.LaunchedEffect
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(viewModel: ChatViewModel) {
val is2faEnabled by viewModel.is2faEnabled.collectAsState()
val totpQr by viewModel.totpQr.collectAsState()
val settingsError by viewModel.settingsError.collectAsState()
val settingsSuccess by viewModel.settingsSuccess.collectAsState()
var show2faSetup by remember { mutableStateOf(false) }
var displayName by remember { mutableStateOf("") }
var oldPassword by remember { mutableStateOf("") }
var newPassword by remember { mutableStateOf("") }
LaunchedEffect(Unit) {
viewModel.clearSettingsMessages()
viewModel.fetchTotpStatus()
}
Scaffold(
containerColor = MaterialTheme.colorScheme.background,
topBar = {
TopAppBar(
title = {
Text(
"settings",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface
)
},
navigationIcon = {
IconButton(onClick = { viewModel.navigateTo(MainScreen.CHAT) }) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent
)
)
}
) { padding ->
Column(
modifier = Modifier
.padding(padding)
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(24.dp)
) {
if (settingsError != null) {
Text(settingsError!!, color = Color.Red, style = MaterialTheme.typography.bodySmall)
}
if (settingsSuccess != null) {
Text(settingsSuccess!!, color = Color.Green, style = MaterialTheme.typography.bodySmall)
}
// Profile Section
Column {
Text(
"profile",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
modifier = Modifier.padding(bottom = 12.dp)
)
OutlinedTextField(
value = displayName,
onValueChange = { displayName = it },
label = { Text("display name") },
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
trailingIcon = {
IconButton(onClick = { viewModel.updateDisplayName(displayName.ifBlank { null }) }) {
Icon(Icons.Default.Check, "Save")
}
}
)
}
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f))
// Security Section
Column {
Text(
"account security",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
modifier = Modifier.padding(bottom = 12.dp)
)
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
"two-factor authentication",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface
)
Text(
if (is2faEnabled) "enabled" else "disabled",
style = MaterialTheme.typography.labelSmall,
color = if (is2faEnabled) Color.Green.copy(alpha = 0.7f) else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
)
}
if (!is2faEnabled) {
Button(
onClick = {
show2faSetup = true
viewModel.fetchTotpQr()
},
shape = RoundedCornerShape(8.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f),
contentColor = MaterialTheme.colorScheme.onSurface
),
modifier = Modifier.height(32.dp)
) {
Text("setup", style = MaterialTheme.typography.labelSmall)
}
} else {
Button(
onClick = { viewModel.disableTotp() },
shape = RoundedCornerShape(8.dp),
colors = ButtonDefaults.buttonColors(
containerColor = Color.Red.copy(alpha = 0.1f),
contentColor = Color.Red
),
modifier = Modifier.height(32.dp)
) {
Text("disable", style = MaterialTheme.typography.labelSmall)
}
}
}
if (show2faSetup && !is2faEnabled) {
Spacer(Modifier.height(16.dp))
TwoFactorSetup(
qrCodeBase64 = totpQr?.qr_code,
onConfirm = { code ->
viewModel.confirmTotp(code)
},
onCancel = { show2faSetup = false }
)
}
Spacer(Modifier.height(16.dp))
Text("change password", style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(bottom = 8.dp))
OutlinedTextField(
value = oldPassword,
onValueChange = { oldPassword = it },
label = { Text("old password") },
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp)
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = newPassword,
onValueChange = { newPassword = it },
label = { Text("new password") },
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp)
)
Spacer(Modifier.height(12.dp))
Button(
onClick = {
viewModel.changePassword(oldPassword, newPassword)
oldPassword = ""
newPassword = ""
},
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
enabled = oldPassword.isNotEmpty() && newPassword.isNotEmpty()
) {
Text("update password")
}
}
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f))
// Application Section
Column {
Text(
"application",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
modifier = Modifier.padding(bottom = 12.dp)
)
Button(
onClick = { viewModel.logout() },
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
colors = ButtonDefaults.buttonColors(
containerColor = Color.Red.copy(alpha = 0.1f),
contentColor = Color.Red
)
) {
Text("logout", style = MaterialTheme.typography.bodyLarge)
}
}
}
}
}
@Composable
fun TwoFactorSetup(
qrCodeBase64: String?,
onConfirm: (String) -> Unit,
onCancel: () -> Unit
) {
var code by remember { mutableStateOf("") }
Column(
modifier = Modifier
.fillMaxWidth()
.border(0.5.dp, MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.1f), RoundedCornerShape(12.dp))
.padding(20.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
"scan qr code",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(16.dp))
if (qrCodeBase64 != null) {
val bitmap = remember(qrCodeBase64) {
val base64Data = qrCodeBase64.substringAfter("base64,")
val decodedString = Base64.decode(base64Data, Base64.DEFAULT)
BitmapFactory.decodeByteArray(decodedString, 0, decodedString.size)
}
bitmap?.let {
Image(
bitmap = it.asImageBitmap(),
contentDescription = "QR Code",
modifier = Modifier
.size(180.dp)
.clip(RoundedCornerShape(8.dp))
.background(Color.White) // QR codes usually need white background
.padding(8.dp)
)
}
} else {
Box(
modifier = Modifier.size(180.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
}
}
Spacer(Modifier.height(24.dp))
OutlinedTextField(
value = code,
onValueChange = { if (it.length <= 6) code = it },
placeholder = { Text("000000", color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f)) },
modifier = Modifier.width(150.dp),
textStyle = MaterialTheme.typography.headlineMedium.copy(
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
letterSpacing = 4.sp
),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
shape = RoundedCornerShape(8.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.primary,
unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant
)
)
Spacer(Modifier.height(24.dp))
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(
onClick = onCancel,
modifier = Modifier.weight(1f),
shape = RoundedCornerShape(8.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent, contentColor = MaterialTheme.colorScheme.onSurfaceVariant)
) {
Text("cancel", style = MaterialTheme.typography.labelSmall)
}
Button(
onClick = { if (code.length == 6) onConfirm(code) },
enabled = code.length == 6,
modifier = Modifier.weight(1f),
shape = RoundedCornerShape(8.dp)
) {
Text("confirm", style = MaterialTheme.typography.labelSmall)
}
}
}
}
+1 -1
View File
@@ -6,7 +6,7 @@ pub mod two_factor;
pub use session::Session;
pub use account::{generate_invite, invite_page, login, login_page, signup, signup_page};
pub use profile::{change_display_name, change_password};
pub use profile::{change_display_name, change_password, change_username, delete_account};
pub use two_factor::{
confirm_totp, disable_totp, get_totp, get_totp_status, mfa_page, verify_totp,
};
+67 -8
View File
@@ -30,13 +30,7 @@ pub async fn change_password(
)
})?;
let parsed_hash = PasswordHash::new(&user.pass_hash)
.inspect_err(|e| tracing::error!("Failed to parse hash for password! uid:{} {e}", user.id))
.map_err(|_| Status::InternalServerError)?;
Argon2::default()
.verify_password(form.old_password.as_bytes(), &parsed_hash)
.map_err(|_| Status::Unauthorized)?;
user.verify_password(&form.old_password)?;
// old password is correct, so new one can be set.
let salt = SaltString::generate(&mut OsRng);
@@ -59,7 +53,43 @@ pub struct DisplayNameForm {
pub display_name: Option<String>,
}
#[post("/settings/display_name", data = "<new>")]
#[derive(Deserialize, Debug, Clone)]
pub struct PasswordAnd2fa {
pub password: String,
pub totp_code: Option<String>,
}
#[delete("/settings", data = "<data>")]
pub async fn delete_account(
session: Session,
mut db: Connection<Postgres>,
data: Json<PasswordAnd2fa>,
) -> Result<(), Status> {
let mut user = User::get_by_id(session.user_id, &mut db)
.await
.ok_or(Status::NotFound)
.inspect_err(|_| {
tracing::error!(
"Valid session does not have a valid user. ID: {}",
session.user_id
)
})?;
user.verify_password(&data.password)?;
if user.twofa_enabled {
user.verify_2fa(data.totp_code.as_deref().unwrap_or(""))?;
}
user.delete(&mut db)
.await
.inspect_err(|e| tracing::error!("{e}"))
.map_err(|_| Status::InternalServerError)?;
Ok(())
}
#[patch("/settings/display_name", data = "<new>")]
pub async fn change_display_name(
session: Session,
mut db: Connection<Postgres>,
@@ -82,3 +112,32 @@ pub async fn change_display_name(
Ok(())
}
#[derive(Deserialize)]
pub struct UsernameForm {
username: String,
}
#[patch("/settings/username", data = "<new>")]
pub async fn change_username(
session: Session,
mut db: Connection<Postgres>,
new: Json<UsernameForm>,
) -> Result<(), Status> {
let mut user = User::get_by_id(session.user_id, &mut db)
.await
.ok_or(Status::NotFound)
.inspect_err(|_| {
tracing::error!(
"Valid session does not have a valid user. ID: {}",
session.user_id
)
})?;
user.set_username(new.username.clone(), &mut db)
.await
.inspect_err(|e| tracing::error!("{e}"))
.map_err(|_| Status::InternalServerError)?;
Ok(())
}
+30 -15
View File
@@ -1,3 +1,4 @@
use futures_util::TryFutureExt;
use rocket::{
Request,
http::Status,
@@ -14,9 +15,11 @@ use totp_rs::{Algorithm, Secret, TOTP};
use crate::{
auth::{
account::AuthResponse,
profile::PasswordAnd2fa,
session::{Claims, Session, TokenScope},
},
db::Postgres,
user::User,
};
// Utility methods
@@ -79,8 +82,16 @@ pub async fn confirm_totp(
Ok(())
}
#[get("/totp.jpg")]
pub async fn get_totp(mfa: TOTPSecret) -> Option<Json<QrResponse>> {
#[derive(Deserialize)]
pub struct PasswordConfirmation {
password: String,
}
#[post("/totp.jpg", data = "<form>")]
pub async fn get_totp(
mfa: TOTPSecret,
form: Json<PasswordConfirmation>,
) -> Option<Json<QrResponse>> {
let qr_b64 = totp_gen(mfa.user_id, mfa.secret.as_bytes())
.expect("Invalid TOTP")
.get_qr_base64()
@@ -216,35 +227,39 @@ pub async fn get_totp_status(
))
}
#[delete("/totp")]
#[delete("/totp", data = "<form>")]
pub async fn disable_totp(
user: Session,
mut db: Connection<Postgres>,
form: Json<PasswordAnd2fa>,
) -> Result<Json<AuthResponse>, Status> {
sqlx::query!(
"UPDATE users SET twofa_enabled = false, totp_secret = NULL WHERE id = $1",
user.user_id as i32,
)
.execute(&mut **db)
let totp_code = form.totp_code.clone().ok_or(Status::BadRequest)?;
let mut user = User::get_by_id(user.user_id, &mut db)
.await
.map_err(|_| Status::NotFound)?;
.ok_or(Status::NotFound)?;
user.verify_password(&form.password)?;
user.verify_2fa(&totp_code)?;
user.set_twofa_enabled(false, &mut db)
.await
.map_err(|_| Status::InternalServerError)?;
Ok(Json(AuthResponse {
token: Claims::new(user.user_id, TokenScope::Full).encode(),
token: Claims::new(user.id as usize, TokenScope::Full).encode(),
totp_required: false,
}))
}
#[post("/totp/verify", data = "<body>")]
pub async fn verify_totp(
user: Claims, // request guard checks token validity
claims: Claims, // request guard checks token validity
mut db: Connection<Postgres>,
body: Json<TotpVerifyRequest>,
) -> Result<Json<AuthResponse>, Status> {
println!("reached 1");
// reject if they somehow got here with a full token
if user.scope != TokenScope::TotpPending {
if claims.scope != TokenScope::TotpPending {
return Err(Status::Forbidden);
}
@@ -252,7 +267,7 @@ pub async fn verify_totp(
let row = sqlx::query!(
"SELECT totp_secret FROM users WHERE id = $1 AND twofa_enabled = TRUE",
user.sub
claims.sub
)
.fetch_one(&mut **db)
.await
@@ -261,7 +276,7 @@ pub async fn verify_totp(
println!("reached 3");
let totp = totp_gen(
user.sub as usize,
claims.sub as usize,
row.totp_secret
.expect("user with 2fa enabled has no totp secret")
.as_bytes(),
@@ -277,7 +292,7 @@ pub async fn verify_totp(
println!("reached 5");
let claims = Claims::new(user.sub as usize, TokenScope::Full);
let claims = Claims::new(claims.sub as usize, TokenScope::Full);
Ok(Json(AuthResponse {
token: claims.encode(),
+3 -1
View File
@@ -77,7 +77,9 @@ fn rocket() -> Rocket<Build> {
auth::disable_totp,
auth::get_totp_status,
auth::change_password,
auth::change_display_name
auth::change_display_name,
auth::change_username,
auth::delete_account,
],
)
.register(
+72 -2
View File
@@ -1,9 +1,10 @@
use argon2::{Argon2, PasswordHash, PasswordVerifier};
use redis::AsyncCommands;
use rocket::{serde::json::Json, time::OffsetDateTime};
use rocket::{http::Status, serde::json::Json, time::OffsetDateTime};
use rocket_db_pools::Connection;
use crate::{
auth::Session,
auth::{Session, two_factor::totp_gen},
db::{Postgres, Redis},
};
@@ -31,6 +32,43 @@ impl User {
.unwrap_or(None)
}
pub async fn delete(&mut self, db: &mut Connection<Postgres>) -> Result<(), sqlx::Error> {
sqlx::query!("DELETE FROM users WHERE id = $1", self.id)
.execute(&mut ***db)
.await?;
Ok(())
}
pub fn verify_2fa(&self, code: &str) -> Result<(), Status> {
if totp_gen(
self.id as usize,
self.totp_secret
.clone()
.expect("user with 2fa enabled has no totp secret")
.as_bytes(),
)
.map_err(|_| Status::InternalServerError)?
.check_current(code)
.map_err(|_| Status::InternalServerError)?
{
Ok(())
} else {
Err(Status::Unauthorized)
}
}
pub fn verify_password(&self, password: &str) -> Result<(), Status> {
let parsed_hash = PasswordHash::new(&self.pass_hash)
.inspect_err(|e| {
tracing::error!("Failed to parse hash for password! uid:{} {e}", self.id)
})
.map_err(|_| Status::InternalServerError)?;
Argon2::default()
.verify_password(password.as_bytes(), &parsed_hash)
.map_err(|_| Status::Unauthorized)
}
pub async fn set_display_name(
&mut self,
display_name: Option<String>,
@@ -47,6 +85,38 @@ impl User {
Ok(())
}
pub async fn set_username(
&mut self,
username: String,
db: &mut Connection<Postgres>,
) -> Result<(), sqlx::Error> {
self.username = username;
sqlx::query!(
"UPDATE users SET username = $1 WHERE id = $2",
self.username,
self.id
)
.execute(&mut ***db)
.await?;
Ok(())
}
pub async fn set_twofa_enabled(
&mut self,
enabled: bool,
db: &mut Connection<Postgres>,
) -> Result<(), sqlx::Error> {
self.twofa_enabled = enabled;
sqlx::query!(
"UPDATE users SET twofa_enabled = $1 WHERE id = $2",
self.twofa_enabled,
self.id
)
.execute(&mut ***db)
.await?;
Ok(())
}
pub async fn set_pass_hash(
&mut self,
pass_hash: String,