Compare commits
6 Commits
a0e9244d6a
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| d33eee1281 | |||
| 7c9b733813 | |||
| bda1ef251a | |||
| a2f7f5a505 | |||
| ad0cf85b34 | |||
| 3dfaab4865 |
@@ -1,6 +1,9 @@
|
|||||||
*/target
|
*/target
|
||||||
.env
|
.env
|
||||||
.log*
|
.log*
|
||||||
|
|
||||||
Cargo.lock
|
Cargo.lock
|
||||||
.cargo/
|
.cargo/
|
||||||
|
.sqlx/
|
||||||
|
|
||||||
docker-compose*
|
docker-compose*
|
||||||
|
|||||||
Generated
+10
@@ -0,0 +1,10 @@
|
|||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# Ignored default folder with query files
|
||||||
|
/queries/
|
||||||
|
# Datasource local storage ignored files
|
||||||
|
/dataSources/
|
||||||
|
/dataSources.local.xml
|
||||||
|
# Editor-based HTTP Client requests
|
||||||
|
/httpRequests/
|
||||||
Generated
+12
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="JAVA_MODULE" version="4">
|
||||||
|
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
||||||
|
<exclude-output />
|
||||||
|
<content url="file://$MODULE_DIR$">
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/backend/src" isTestSource="false" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/backend/target" />
|
||||||
|
</content>
|
||||||
|
<orderEntry type="inheritedJdk" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
</component>
|
||||||
|
</module>
|
||||||
Generated
+17
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
|
||||||
|
<data-source source="LOCAL" name="chatapp dev" uuid="81992477-fd6f-427e-a27e-7378c26db6ef">
|
||||||
|
<driver-ref>postgresql</driver-ref>
|
||||||
|
<synchronize>true</synchronize>
|
||||||
|
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
|
||||||
|
<jdbc-url>jdbc:postgresql://100.118.108.58:5432/chatapp_dev</jdbc-url>
|
||||||
|
<jdbc-additional-properties>
|
||||||
|
<property name="com.intellij.clouds.kubernetes.db.host.port" />
|
||||||
|
<property name="com.intellij.clouds.kubernetes.db.enabled" value="false" />
|
||||||
|
<property name="com.intellij.clouds.kubernetes.db.container.port" />
|
||||||
|
</jdbc-additional-properties>
|
||||||
|
<working-dir>$ProjectFileDir$</working-dir>
|
||||||
|
</data-source>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
Generated
+11
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="GradleMigrationSettings" migrationVersion="1" />
|
||||||
|
<component name="GradleSettings">
|
||||||
|
<option name="linkedExternalProjectsSettings">
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/android" />
|
||||||
|
</GradleProjectSettings>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
+6
@@ -0,0 +1,6 @@
|
|||||||
|
<component name="InspectionProjectProfileManager">
|
||||||
|
<profile version="1.0">
|
||||||
|
<option name="myName" value="Project Default" />
|
||||||
|
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||||
|
</profile>
|
||||||
|
</component>
|
||||||
Generated
+9
@@ -0,0 +1,9 @@
|
|||||||
|
<component name="libraryTable">
|
||||||
|
<library name="highlight(1)">
|
||||||
|
<CLASSES>
|
||||||
|
<root url="jar://$PROJECT_DIR$/backend/static/highlight(1).zip!/" />
|
||||||
|
</CLASSES>
|
||||||
|
<JAVADOC />
|
||||||
|
<SOURCES />
|
||||||
|
</library>
|
||||||
|
</component>
|
||||||
Generated
+6
@@ -0,0 +1,6 @@
|
|||||||
|
<project version="4">
|
||||||
|
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||||
|
<component name="ProjectRootManager" version="2" project-jdk-name="25" project-jdk-type="JavaSDK">
|
||||||
|
<output url="file://$PROJECT_DIR$/out" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
Generated
+8
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectModuleManager">
|
||||||
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/.idea/chatapp.iml" filepath="$PROJECT_DIR$/.idea/chatapp.iml" />
|
||||||
|
</modules>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
Generated
+6
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -10,6 +10,12 @@
|
|||||||
"command": "clippy" // rust-analyzer.check.command (default: "check")
|
"command": "clippy" // rust-analyzer.check.command (default: "check")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"nu": {
|
||||||
|
"binary": {
|
||||||
|
"path": "/home/fantasypvp/.cargo/bin/nu",
|
||||||
|
"arguments": ["--lsp"]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
*.iml
|
*.iml
|
||||||
.gradle
|
.gradle
|
||||||
/local.properties
|
/local.properties
|
||||||
|
/keystore.properties
|
||||||
/.idea/caches
|
/.idea/caches
|
||||||
|
/.idea/.cache
|
||||||
/.idea/libraries
|
/.idea/libraries
|
||||||
/.idea/modules.xml
|
/.idea/modules.xml
|
||||||
/.idea/workspace.xml
|
/.idea/workspace.xml
|
||||||
@@ -13,3 +15,4 @@
|
|||||||
.externalNativeBuild
|
.externalNativeBuild
|
||||||
.cxx
|
.cxx
|
||||||
local.properties
|
local.properties
|
||||||
|
release/
|
||||||
+8
@@ -4,6 +4,14 @@
|
|||||||
<selectionStates>
|
<selectionStates>
|
||||||
<SelectionState runConfigName="app">
|
<SelectionState runConfigName="app">
|
||||||
<option name="selectionMode" value="DROPDOWN" />
|
<option name="selectionMode" value="DROPDOWN" />
|
||||||
|
<DropdownSelection timestamp="2026-04-02T14:33:39.814557661Z">
|
||||||
|
<Target type="DEFAULT_BOOT">
|
||||||
|
<handle>
|
||||||
|
<DeviceId pluginId="PhysicalDevice" identifier="serial=00319362N000094" />
|
||||||
|
</handle>
|
||||||
|
</Target>
|
||||||
|
</DropdownSelection>
|
||||||
|
<DialogSelection />
|
||||||
</SelectionState>
|
</SelectionState>
|
||||||
<SelectionState runConfigName="MainActivity">
|
<SelectionState runConfigName="MainActivity">
|
||||||
<option name="selectionMode" value="DROPDOWN" />
|
<option name="selectionMode" value="DROPDOWN" />
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import java.util.Properties
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.android.application)
|
||||||
alias(libs.plugins.kotlin.compose)
|
alias(libs.plugins.kotlin.compose)
|
||||||
@@ -8,6 +10,25 @@ android {
|
|||||||
namespace = "dev.zxq5.chatapp.android"
|
namespace = "dev.zxq5.chatapp.android"
|
||||||
compileSdk = 35
|
compileSdk = 35
|
||||||
|
|
||||||
|
val keystorePropertiesFile = rootProject.file("local.properties")
|
||||||
|
val keystoreProperties = Properties()
|
||||||
|
if (keystorePropertiesFile.exists()) {
|
||||||
|
keystoreProperties.load(keystorePropertiesFile.inputStream())
|
||||||
|
}
|
||||||
|
|
||||||
|
signingConfigs {
|
||||||
|
create("release") {
|
||||||
|
storeFile = file("${System.getProperty("user.home")}/keystores/chatapp.jks")
|
||||||
|
storePassword = keystoreProperties["KEYSTORE_PASSWORD"] as String?
|
||||||
|
?: System.getenv("KEYSTORE_PASSWORD")
|
||||||
|
?: ""
|
||||||
|
keyAlias = "chatapp"
|
||||||
|
keyPassword = keystoreProperties["KEY_PASSWORD"] as String?
|
||||||
|
?: System.getenv("KEY_PASSWORD")
|
||||||
|
?: ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "dev.zxq5.chatapp.android"
|
applicationId = "dev.zxq5.chatapp.android"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
@@ -20,19 +41,30 @@ android {
|
|||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
isMinifyEnabled = false
|
isMinifyEnabled = true // shrinks code
|
||||||
|
isShrinkResources = true // removes unused resources
|
||||||
|
signingConfig = signingConfigs.getByName("release")
|
||||||
proguardFiles(
|
proguardFiles(
|
||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
"proguard-rules.pro"
|
"proguard-rules.pro"
|
||||||
)
|
)
|
||||||
|
buildConfigField("String", "BASE_URL", "\"https://chat.zxq5.dev\"")
|
||||||
|
}
|
||||||
|
debug {
|
||||||
|
isMinifyEnabled = false
|
||||||
|
isDebuggable = true
|
||||||
|
applicationIdSuffix = ".debug" // lets you install both side by side
|
||||||
|
buildConfigField("String", "BASE_URL", "\"http://zxq5-x1:8000\"")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_11
|
sourceCompatibility = JavaVersion.VERSION_11
|
||||||
targetCompatibility = JavaVersion.VERSION_11
|
targetCompatibility = JavaVersion.VERSION_11
|
||||||
}
|
}
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
compose = true
|
compose = true
|
||||||
|
buildConfig = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,7 +76,6 @@ dependencies {
|
|||||||
implementation(libs.ktor.client.auth) // Auth plugin
|
implementation(libs.ktor.client.auth) // Auth plugin
|
||||||
// Kotlinx Serialization
|
// Kotlinx Serialization
|
||||||
implementation(libs.kotlinx.serialization.json)
|
implementation(libs.kotlinx.serialization.json)
|
||||||
|
|
||||||
// Coroutines
|
// Coroutines
|
||||||
implementation(libs.kotlinx.coroutines.android)
|
implementation(libs.kotlinx.coroutines.android)
|
||||||
|
|
||||||
@@ -73,4 +104,4 @@ dependencies {
|
|||||||
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
||||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||||
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+27
-1
@@ -18,4 +18,30 @@
|
|||||||
|
|
||||||
# If you keep the line number information, uncomment this to
|
# If you keep the line number information, uncomment this to
|
||||||
# hide the original source file name.
|
# hide the original source file name.
|
||||||
#-renamesourcefileattribute SourceFile
|
#-renamesourcefileattribute SourceFile
|
||||||
|
|
||||||
|
# Ktor
|
||||||
|
-keep class io.ktor.** { *; }
|
||||||
|
-keep class kotlinx.coroutines.** { *; }
|
||||||
|
|
||||||
|
# Kotlinx serialization
|
||||||
|
-keepattributes *Annotation*, InnerClasses
|
||||||
|
-dontnote kotlinx.serialization.AnnotationsKt
|
||||||
|
-keep,includedescriptorclasses class dev.zxq5.chatapp.android.**$$serializer { *; }
|
||||||
|
-keepclassmembers class dev.zxq5.chatapp.android.** {
|
||||||
|
*** Companion;
|
||||||
|
}
|
||||||
|
-keepclasseswithmembers class dev.zxq5.chatapp.android.** {
|
||||||
|
kotlinx.serialization.KSerializer serializer(...);
|
||||||
|
}
|
||||||
|
|
||||||
|
# Keep model classes (serialization needs these)
|
||||||
|
-keep class dev.zxq5.chatapp.android.api.model.** { *; }
|
||||||
|
-keep class dev.zxq5.chatapp.android.data.model.** { *; }
|
||||||
|
|
||||||
|
# Fix for missing errorprone and javax annotations used by Tink and other libraries
|
||||||
|
-dontwarn com.google.errorprone.annotations.**
|
||||||
|
-dontwarn javax.annotation.**
|
||||||
|
|
||||||
|
# Fix for missing java.lang.management referenced by Ktor (not available on Android)
|
||||||
|
-dontwarn java.lang.management.**
|
||||||
|
|||||||
@@ -3,6 +3,10 @@
|
|||||||
xmlns:tools="http://schemas.android.com/tools">
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
|
||||||
|
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".ChatApplication"
|
android:name=".ChatApplication"
|
||||||
@@ -15,6 +19,12 @@
|
|||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.Chatapp"
|
android:theme="@style/Theme.Chatapp"
|
||||||
android:usesCleartextTraffic="true">
|
android:usesCleartextTraffic="true">
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".core.service.MessageStreamService"
|
||||||
|
android:foregroundServiceType="dataSync"
|
||||||
|
android:exported="false"/>
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
|||||||
@@ -1,11 +1,51 @@
|
|||||||
package dev.zxq5.chatapp.android
|
package dev.zxq5.chatapp.android
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import dev.zxq5.chatapp.android.api.ApiClient
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.os.Build
|
||||||
|
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() {
|
class ChatApplication : Application() {
|
||||||
|
|
||||||
|
object AppState {
|
||||||
|
var isInForeground = false
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
ApiClient.init(this)
|
createNotificationChannels()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createNotificationChannels() {
|
||||||
|
val messageChannel = NotificationChannel(
|
||||||
|
"messages",
|
||||||
|
"Messages",
|
||||||
|
NotificationManager.IMPORTANCE_HIGH
|
||||||
|
).apply {
|
||||||
|
description = "New message notifications"
|
||||||
|
enableVibration(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// add this — required for the foreground service persistent notification
|
||||||
|
val serviceChannel = NotificationChannel(
|
||||||
|
"service",
|
||||||
|
"Background connection",
|
||||||
|
NotificationManager.IMPORTANCE_LOW
|
||||||
|
).apply {
|
||||||
|
description = "Keeps messages running in background"
|
||||||
|
}
|
||||||
|
|
||||||
|
val manager = getSystemService(NotificationManager::class.java)
|
||||||
|
manager.createNotificationChannel(messageChannel)
|
||||||
|
manager.createNotificationChannel(serviceChannel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,47 +4,182 @@ import android.os.Bundle
|
|||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.outlined.ChatBubbleOutline
|
||||||
|
import androidx.compose.material.icons.outlined.PeopleOutline
|
||||||
|
import androidx.compose.material.icons.outlined.Settings
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.NavigationBar
|
||||||
|
import androidx.compose.material3.NavigationBarItem
|
||||||
|
import androidx.compose.material3.NavigationBarItemDefaults
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import dev.zxq5.chatapp.android.model.ChatViewModel
|
import dev.zxq5.chatapp.android.ChatApplication.AppState
|
||||||
import dev.zxq5.chatapp.android.model.LoginState
|
import dev.zxq5.chatapp.android.core.service.MessageStreamService
|
||||||
import dev.zxq5.chatapp.android.model.MainScreen
|
import dev.zxq5.chatapp.android.data.repository.AuthState
|
||||||
import dev.zxq5.chatapp.android.ui.components.AuthScreen
|
import dev.zxq5.chatapp.android.feature.auth.AuthScreen
|
||||||
import dev.zxq5.chatapp.android.ui.components.ChatScreen
|
import dev.zxq5.chatapp.android.feature.auth.AuthViewModel
|
||||||
import dev.zxq5.chatapp.android.ui.components.SettingsScreen
|
import dev.zxq5.chatapp.android.feature.chat.ChatScreen
|
||||||
|
import dev.zxq5.chatapp.android.feature.chat.ChatViewModel
|
||||||
|
import dev.zxq5.chatapp.android.feature.chat.Screen
|
||||||
|
import dev.zxq5.chatapp.android.feature.contacts.ContactsScreen
|
||||||
|
import dev.zxq5.chatapp.android.feature.settings.SettingsScreen
|
||||||
|
import dev.zxq5.chatapp.android.feature.settings.SettingsViewModel
|
||||||
import dev.zxq5.chatapp.android.ui.theme.ChatappTheme
|
import dev.zxq5.chatapp.android.ui.theme.ChatappTheme
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
val app = application as ChatApplication
|
||||||
|
val authRepository = app.authRepository
|
||||||
|
val chatRepository = app.chatRepository
|
||||||
|
val settingsRepository = app.settingsRepository
|
||||||
|
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
setContent {
|
setContent {
|
||||||
ChatappTheme {
|
ChatappTheme {
|
||||||
val viewModel: ChatViewModel = viewModel()
|
val authViewModel: AuthViewModel = viewModel(factory = ViewModelFactory(authRepository))
|
||||||
val loginState by viewModel.loginState.collectAsState()
|
val chatViewModel: ChatViewModel = viewModel(factory = ViewModelFactory(chatRepository))
|
||||||
val currentScreen by viewModel.currentScreen.collectAsState()
|
val settingsViewModel: SettingsViewModel = viewModel(factory = ViewModelFactory(settingsRepository))
|
||||||
|
|
||||||
|
val authState by authViewModel.authState.collectAsState()
|
||||||
|
val currentScreen by chatViewModel.currentScreen.collectAsState()
|
||||||
|
val selectedChannelId by chatViewModel.channelId.collectAsState()
|
||||||
|
|
||||||
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
|
LaunchedEffect(authState) {
|
||||||
androidx.compose.foundation.layout.Box(modifier = Modifier.padding(innerPadding)) {
|
when (authState) {
|
||||||
if (loginState is LoginState.Success) {
|
AuthState.Authenticated -> MessageStreamService.start(this@MainActivity)
|
||||||
when (currentScreen) {
|
AuthState.Unauthenticated -> MessageStreamService.stop(this@MainActivity)
|
||||||
MainScreen.CHAT -> ChatScreen(viewModel = viewModel)
|
AuthState.AwaitingTotp -> {}
|
||||||
MainScreen.SETTINGS -> SettingsScreen(viewModel = viewModel)
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
intent.getIntExtra("channel_id", -1).takeIf { it != -1 }?.let {
|
||||||
|
chatViewModel.switchChannel(it.toLong())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authState == AuthState.Authenticated) {
|
||||||
|
Scaffold(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
bottomBar = {
|
||||||
|
// Only show bottom bar if we are NOT inside a specific chat channel
|
||||||
|
if (selectedChannelId == null) {
|
||||||
|
BottomDock(
|
||||||
|
currentScreen = currentScreen,
|
||||||
|
onNavigate = { chatViewModel.navigateTo(it) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) { innerPadding ->
|
||||||
|
Box(modifier = Modifier.padding(innerPadding)) {
|
||||||
|
when (currentScreen) {
|
||||||
|
Screen.CHAT -> ChatScreen(
|
||||||
|
viewModel = chatViewModel,
|
||||||
|
onNavigateToSettings = { chatViewModel.navigateTo(Screen.SETTINGS) },
|
||||||
|
onLogout = {
|
||||||
|
authViewModel.logout()
|
||||||
|
chatViewModel.clearChat()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Screen.CONTACTS -> ContactsScreen()
|
||||||
|
Screen.SETTINGS -> SettingsScreen(
|
||||||
|
viewModel = settingsViewModel,
|
||||||
|
onLogout = {
|
||||||
|
authViewModel.logout()
|
||||||
|
chatViewModel.clearChat()
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
AuthScreen(
|
|
||||||
viewModel = viewModel,
|
|
||||||
onSuccess = { }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
AuthScreen(viewModel = authViewModel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
AppState.isInForeground = true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPause() {
|
||||||
|
super.onPause()
|
||||||
|
AppState.isInForeground = false
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onNewIntent(intent: android.content.Intent) {
|
||||||
|
super.onNewIntent(intent)
|
||||||
|
intent.getIntExtra("channel_id", -1).takeIf { it != -1 }?.let { channelId ->
|
||||||
|
MessageStreamService.instance?.activeChannelId = channelId.toLong()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun BottomDock(currentScreen: Screen, onNavigate: (Screen) -> Unit) {
|
||||||
|
NavigationBar(
|
||||||
|
containerColor = MaterialTheme.colorScheme.background,
|
||||||
|
tonalElevation = 0.dp,
|
||||||
|
modifier = Modifier
|
||||||
|
.height(80.dp)
|
||||||
|
.border(
|
||||||
|
0.5.dp,
|
||||||
|
MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f),
|
||||||
|
RoundedCornerShape(topStart = 0.dp, topEnd = 0.dp)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
NavigationBarItem(
|
||||||
|
selected = currentScreen == Screen.CHAT,
|
||||||
|
onClick = { onNavigate(Screen.CHAT) },
|
||||||
|
icon = { Icon(Icons.Outlined.ChatBubbleOutline, contentDescription = "Chat") },
|
||||||
|
label = { Text("chat", style = MaterialTheme.typography.labelSmall) },
|
||||||
|
colors = NavigationBarItemDefaults.colors(
|
||||||
|
selectedIconColor = MaterialTheme.colorScheme.primary,
|
||||||
|
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
|
||||||
|
indicatorColor = Color.Transparent
|
||||||
|
)
|
||||||
|
)
|
||||||
|
NavigationBarItem(
|
||||||
|
selected = currentScreen == Screen.CONTACTS,
|
||||||
|
onClick = { onNavigate(Screen.CONTACTS) },
|
||||||
|
icon = { Icon(Icons.Outlined.PeopleOutline, contentDescription = "Contacts") },
|
||||||
|
label = { Text("contacts", style = MaterialTheme.typography.labelSmall) },
|
||||||
|
colors = NavigationBarItemDefaults.colors(
|
||||||
|
selectedIconColor = MaterialTheme.colorScheme.primary,
|
||||||
|
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
|
||||||
|
indicatorColor = Color.Transparent
|
||||||
|
)
|
||||||
|
)
|
||||||
|
NavigationBarItem(
|
||||||
|
selected = currentScreen == Screen.SETTINGS,
|
||||||
|
onClick = { onNavigate(Screen.SETTINGS) },
|
||||||
|
icon = { Icon(Icons.Outlined.Settings, contentDescription = "Settings") },
|
||||||
|
label = { Text("settings", style = MaterialTheme.typography.labelSmall) },
|
||||||
|
colors = NavigationBarItemDefaults.colors(
|
||||||
|
selectedIconColor = MaterialTheme.colorScheme.primary,
|
||||||
|
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
|
||||||
|
indicatorColor = Color.Transparent
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,117 @@
|
|||||||
|
package dev.zxq5.chatapp.android.api
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import dev.zxq5.chatapp.android.BuildConfig
|
||||||
|
import dev.zxq5.chatapp.android.BuildConfig.BASE_URL
|
||||||
|
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.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,65 @@
|
|||||||
|
package dev.zxq5.chatapp.android.api
|
||||||
|
|
||||||
|
import dev.zxq5.chatapp.android.BuildConfig.BASE_URL
|
||||||
|
import dev.zxq5.chatapp.android.api.model.Message
|
||||||
|
import dev.zxq5.chatapp.android.api.model.SendMessage
|
||||||
|
import dev.zxq5.chatapp.android.api.model.SpaceDto
|
||||||
|
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.get
|
||||||
|
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.readLine
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlin.time.Clock
|
||||||
|
import kotlin.time.ExperimentalTime
|
||||||
|
|
||||||
|
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 getAccessibleChannels(): List<SpaceDto> = http.get("${BASE_URL}/api/accessible_channels").body()
|
||||||
|
|
||||||
|
@OptIn(ExperimentalTime::class)
|
||||||
|
suspend fun sendMessage(channelId: Long, userId: Int, text: String) {
|
||||||
|
http.post("${BASE_URL}/api/chat/$channelId") {
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
setBody(SendMessage(user_id = userId, text = text, timestamp = Clock.System.now()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun messageStream(channelId: Long): Flow<Message> = flow {
|
||||||
|
http.prepareGet("${BASE_URL}/api/events/$channelId").execute { response ->
|
||||||
|
val channel = response.bodyAsChannel()
|
||||||
|
while (!channel.isClosedForRead) {
|
||||||
|
val line = channel.readLine() ?: 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.BuildConfig.BASE_URL
|
||||||
|
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.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,15 @@
|
|||||||
|
package dev.zxq5.chatapp.android.api.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlin.time.ExperimentalTime
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Channel @OptIn(ExperimentalTime::class) constructor(
|
||||||
|
val id: Long,
|
||||||
|
val name: String,
|
||||||
|
val description: String? = null,
|
||||||
|
val space_id: Long,
|
||||||
|
val created_at: Instant,
|
||||||
|
val updated_at: Instant
|
||||||
|
)
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package dev.zxq5.chatapp.android.api.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class DisplayNameRequest(val display_name: String?)
|
||||||
+2
-4
@@ -1,4 +1,4 @@
|
|||||||
package dev.zxq5.chatapp.android.model
|
package dev.zxq5.chatapp.android.api.model
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
@@ -6,6 +6,4 @@ import kotlinx.serialization.Serializable
|
|||||||
data class LoginRequest(
|
data class LoginRequest(
|
||||||
val username: String,
|
val username: String,
|
||||||
val password: String
|
val password: String
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package dev.zxq5.chatapp.android.model
|
package dev.zxq5.chatapp.android.api.model
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package dev.zxq5.chatapp.android.api.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlin.time.ExperimentalTime
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Message @OptIn(ExperimentalTime::class) constructor(
|
||||||
|
val user_id: Int,
|
||||||
|
val display_name: String,
|
||||||
|
val text: String,
|
||||||
|
val timestamp: Instant
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package dev.zxq5.chatapp.android.api.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlin.time.ExperimentalTime
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class SendMessage @OptIn(ExperimentalTime::class) constructor(
|
||||||
|
val user_id: Int,
|
||||||
|
val text: String,
|
||||||
|
val timestamp: Instant
|
||||||
|
)
|
||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
package dev.zxq5.chatapp.android.model
|
package dev.zxq5.chatapp.android.api.model
|
||||||
|
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
@@ -11,4 +11,4 @@ data class SignupRequest(
|
|||||||
|
|
||||||
@SerialName("access_token")
|
@SerialName("access_token")
|
||||||
val access_token: String
|
val access_token: String
|
||||||
)
|
)
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package dev.zxq5.chatapp.android.api.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlin.time.ExperimentalTime
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Space @OptIn(ExperimentalTime::class) constructor(
|
||||||
|
val id: Long,
|
||||||
|
val name: String,
|
||||||
|
val description: String? = null,
|
||||||
|
val owner_id: Long,
|
||||||
|
val created_at: Instant,
|
||||||
|
val updated_at: Instant
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class SpaceDto @OptIn(ExperimentalTime::class) constructor(
|
||||||
|
val channels: List<Channel>,
|
||||||
|
|
||||||
|
val id: Long,
|
||||||
|
val name: String,
|
||||||
|
val description: String? = null,
|
||||||
|
val owner_id: Long,
|
||||||
|
val created_at: Instant,
|
||||||
|
val updated_at: Instant
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
package dev.zxq5.chatapp.android.core
|
package dev.zxq5.chatapp.android.core
|
||||||
|
|
||||||
const val BASE_URL = "http://zxq5-x1:8000"
|
//const val BASE_URL = "http://zxq5-x1:8000"
|
||||||
@@ -8,12 +8,14 @@ import androidx.security.crypto.EncryptedSharedPreferences
|
|||||||
import androidx.security.crypto.MasterKey
|
import androidx.security.crypto.MasterKey
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
|
|
||||||
// In your ApiClient or a dedicated TokenStore
|
private const val KEY = "auth_token"
|
||||||
object TokenStore {
|
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(
|
return EncryptedSharedPreferences.create(
|
||||||
context,
|
context,
|
||||||
"secure_prefs",
|
"secure_prefs",
|
||||||
@@ -25,54 +27,54 @@ object TokenStore {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun save(context: Context, token: String) =
|
fun save(token: String) =
|
||||||
prefs(context).edit { putString(KEY, token) }
|
prefs().edit { putString(KEY, token) }
|
||||||
|
|
||||||
fun get(context: Context): String? =
|
fun get(): String? =
|
||||||
prefs(context).getString(KEY, null)
|
prefs().getString(KEY, null)
|
||||||
|
|
||||||
fun save2faEnabled(context: Context, enabled: Boolean) =
|
fun save2faEnabled( enabled: Boolean) =
|
||||||
prefs(context).edit { putBoolean(TWOFA_KEY, enabled) }
|
prefs().edit { putBoolean(TWOFA_KEY, enabled) }
|
||||||
|
|
||||||
fun is2faEnabled(context: Context): Boolean =
|
fun is2faEnabled(): Boolean =
|
||||||
prefs(context).getBoolean(TWOFA_KEY, false)
|
prefs().getBoolean(TWOFA_KEY, false)
|
||||||
|
|
||||||
fun clear(context: Context) =
|
fun clear() =
|
||||||
prefs(context).edit { remove(KEY).remove(TWOFA_KEY) }
|
prefs().edit { remove(KEY).remove(TWOFA_KEY) }
|
||||||
|
|
||||||
fun getUserId(context: Context): Int? {
|
fun getUserId(): Int? {
|
||||||
val token = get(context) ?: return null
|
val token = get() ?: return null
|
||||||
return getUserIdFromToken(token)
|
return getUserIdFromToken(token)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun getUserIdFromToken(token: String): Int? {
|
fun getUserIdFromToken(token: String): Int? {
|
||||||
return try {
|
return try {
|
||||||
val payload = token.split(".")[1]
|
val payload = token.split(".")[1]
|
||||||
// base64url needs padding restored
|
// base64url needs padding restored
|
||||||
val padded = payload + "==".take((4 - payload.length % 4) % 4)
|
val padded = payload + "==".take((4 - payload.length % 4) % 4)
|
||||||
val jsonString = String(Base64.decode(padded, Base64.URL_SAFE))
|
val jsonString = String(Base64.decode(padded, Base64.URL_SAFE))
|
||||||
val json = JSONObject(jsonString)
|
val json = JSONObject(jsonString)
|
||||||
|
|
||||||
// Handle both standard 'sub' and custom 'user_id'
|
// Handle both standard 'sub' and custom 'user_id'
|
||||||
when {
|
when {
|
||||||
json.has("sub") -> json.getInt("sub")
|
json.has("sub") -> json.getInt("sub")
|
||||||
json.has("user_id") -> json.getInt("user_id")
|
json.has("user_id") -> json.getInt("user_id")
|
||||||
else -> null
|
else -> null
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
null
|
|
||||||
}
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun getScopeFromToken(token: String): String? {
|
fun getScopeFromToken(token: String): String? {
|
||||||
return try {
|
return try {
|
||||||
val payload = token.split(".")[1]
|
val payload = token.split(".")[1]
|
||||||
val padded = payload + "==".take((4 - payload.length % 4) % 4)
|
val padded = payload + "==".take((4 - payload.length % 4) % 4)
|
||||||
val jsonString = String(Base64.decode(padded, Base64.URL_SAFE))
|
val jsonString = String(Base64.decode(padded, Base64.URL_SAFE))
|
||||||
val json = JSONObject(jsonString)
|
val json = JSONObject(jsonString)
|
||||||
if (json.has("scope")) json.getString("scope") else null
|
if (json.has("scope")) json.getString("scope") else null
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
null
|
null
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+104
@@ -0,0 +1,104 @@
|
|||||||
|
package dev.zxq5.chatapp.android.core.service
|
||||||
|
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.IBinder
|
||||||
|
import android.util.Log
|
||||||
|
import dev.zxq5.chatapp.android.ChatApplication
|
||||||
|
import dev.zxq5.chatapp.android.data.repository.ChatRepository
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.flow.catch
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
// core/service/MessageStreamService.kt
|
||||||
|
class MessageStreamService : Service() {
|
||||||
|
|
||||||
|
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
private lateinit var notificationService: NotificationService
|
||||||
|
private lateinit var chatRepository: ChatRepository
|
||||||
|
|
||||||
|
// which channel the user is currently looking at
|
||||||
|
// set by the ViewModel when the user opens/closes a channel
|
||||||
|
var activeChannelId: Long? = null
|
||||||
|
set(value) {
|
||||||
|
field = value
|
||||||
|
Log.d("Service", "activeChannelId set to $value")
|
||||||
|
if (value != null) {
|
||||||
|
// restart stream with new channel
|
||||||
|
currentStreamJob?.cancel()
|
||||||
|
observeMessages()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var currentStreamJob: kotlinx.coroutines.Job? = null
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
var instance: MessageStreamService? = null
|
||||||
|
|
||||||
|
fun start(context: Context) {
|
||||||
|
val intent = Intent(context, MessageStreamService::class.java)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
context.startForegroundService(intent)
|
||||||
|
} else {
|
||||||
|
context.startService(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop(context: Context) {
|
||||||
|
context.stopService(Intent(context, MessageStreamService::class.java))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
instance = this
|
||||||
|
notificationService = NotificationService(this)
|
||||||
|
chatRepository = (application as ChatApplication).chatRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
startForeground(
|
||||||
|
NotificationService.FOREGROUND_NOTIFICATION_ID,
|
||||||
|
notificationService.buildForegroundNotification()
|
||||||
|
)
|
||||||
|
observeMessages()
|
||||||
|
return START_STICKY // restart if killed
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun observeMessages() {
|
||||||
|
val channelId = activeChannelId ?: chatRepository.getLastActiveChannel()
|
||||||
|
Log.d("Service", "observeMessages called, channelId=$channelId")
|
||||||
|
if (channelId == null) {
|
||||||
|
Log.d("Service", "No channel to observe, waiting for switchChannel")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d("Service", "Starting stream for channel $channelId")
|
||||||
|
currentStreamJob = serviceScope.launch {
|
||||||
|
chatRepository.messageStream(channelId)
|
||||||
|
.catch { e -> Log.e("Service", "Stream error", e) }
|
||||||
|
.collect { message ->
|
||||||
|
if (!ChatApplication.AppState.isInForeground) { // no channel focused, always notify
|
||||||
|
notificationService.showMessageNotification(
|
||||||
|
conversationId = activeChannelId.toString(),
|
||||||
|
senderName = message.display_name,
|
||||||
|
messagePreview = message.text.take(80)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
super.onDestroy()
|
||||||
|
instance = null
|
||||||
|
serviceScope.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
+94
@@ -0,0 +1,94 @@
|
|||||||
|
package dev.zxq5.chatapp.android.core.service
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import dev.zxq5.chatapp.android.MainActivity
|
||||||
|
import dev.zxq5.chatapp.android.R
|
||||||
|
|
||||||
|
class NotificationService(private val context: Context) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val CHANNEL_ID = "messages"
|
||||||
|
const val FOREGROUND_NOTIFICATION_ID = 1 // ← this needs to exist
|
||||||
|
}
|
||||||
|
|
||||||
|
private val manager = context.getSystemService(NotificationManager::class.java)
|
||||||
|
|
||||||
|
fun createChannels() {
|
||||||
|
// channel for new message notifications
|
||||||
|
val messageChannel = NotificationChannel(
|
||||||
|
CHANNEL_ID,
|
||||||
|
"Messages",
|
||||||
|
NotificationManager.IMPORTANCE_HIGH
|
||||||
|
).apply {
|
||||||
|
enableVibration(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// channel for the persistent foreground service notification
|
||||||
|
// low importance so it doesn't make noise
|
||||||
|
val serviceChannel = NotificationChannel(
|
||||||
|
"service",
|
||||||
|
"Background connection",
|
||||||
|
NotificationManager.IMPORTANCE_LOW
|
||||||
|
)
|
||||||
|
|
||||||
|
val mgr = context.getSystemService(NotificationManager::class.java)
|
||||||
|
mgr.createNotificationChannel(messageChannel)
|
||||||
|
mgr.createNotificationChannel(serviceChannel)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun buildForegroundNotification(): Notification {
|
||||||
|
return NotificationCompat.Builder(context, "service")
|
||||||
|
.setSmallIcon(R.drawable.ic_notification)
|
||||||
|
.setContentTitle("chatapp")
|
||||||
|
.setContentText("Connected")
|
||||||
|
.setOngoing(true)
|
||||||
|
.setSilent(true)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showMessageNotification(
|
||||||
|
conversationId: String,
|
||||||
|
senderName: String,
|
||||||
|
messagePreview: String, // for E2E this would be "New message" — no plaintext
|
||||||
|
notificationId: Int = conversationId.hashCode()
|
||||||
|
) {
|
||||||
|
// intent that opens the app to the right conversation when tapped
|
||||||
|
val intent = Intent(context, MainActivity::class.java).apply {
|
||||||
|
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||||
|
putExtra("conversation_id", conversationId)
|
||||||
|
}
|
||||||
|
|
||||||
|
val pendingIntent = PendingIntent.getActivity(
|
||||||
|
context,
|
||||||
|
notificationId,
|
||||||
|
intent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
|
||||||
|
val notification = NotificationCompat.Builder(context, "messages")
|
||||||
|
.setSmallIcon(R.drawable.ic_notification)
|
||||||
|
.setContentTitle(senderName)
|
||||||
|
.setContentText(messagePreview)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
|
.setContentIntent(pendingIntent)
|
||||||
|
.setAutoCancel(true) // dismiss on tap
|
||||||
|
.build()
|
||||||
|
|
||||||
|
manager.notify(notificationId, notification)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissNotification(conversationId: String) {
|
||||||
|
manager.cancel(conversationId.hashCode())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissAll() {
|
||||||
|
manager.cancelAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
+67
-19
@@ -1,35 +1,83 @@
|
|||||||
package dev.zxq5.chatapp.android.data.repository
|
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.TokenStore
|
||||||
|
import dev.zxq5.chatapp.android.core.data.getScopeFromToken
|
||||||
import dev.zxq5.chatapp.android.core.error.ApiResult
|
import dev.zxq5.chatapp.android.core.error.ApiResult
|
||||||
//
|
import dev.zxq5.chatapp.android.feature.auth.TokenScope
|
||||||
//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);
|
|
||||||
//// }
|
|
||||||
//// }
|
|
||||||
// }
|
|
||||||
//}
|
|
||||||
|
|
||||||
|
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 {
|
sealed class LoginResult {
|
||||||
object Success : LoginResult()
|
object Success : LoginResult()
|
||||||
object TotpRequired : LoginResult() // step 1 outcome → go to totp screen
|
object TotpRequired : LoginResult()
|
||||||
data class TotpError(val message: String) : LoginResult() // step 2 failure → stay on totp screen, show error
|
data class TotpError(val message: String) : LoginResult()
|
||||||
data class Error(val message: String) : LoginResult() // general failure → show on login form
|
data class Error(val message: String) : LoginResult()
|
||||||
}
|
}
|
||||||
|
|
||||||
sealed class AuthState {
|
sealed class AuthState {
|
||||||
object Authenticated : AuthState()
|
object Authenticated : AuthState()
|
||||||
object AwaitingTotp : AuthState()
|
object AwaitingTotp : AuthState()
|
||||||
object Unauthenticated : AuthState()
|
object Unauthenticated : AuthState()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
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 dev.zxq5.chatapp.android.api.model.SpaceDto
|
||||||
|
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 var _lastActiveChannel: Long? = 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 getLastActiveChannel(): Long? {
|
||||||
|
return _lastActiveChannel
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getUserId() = tokenStore.getUserId()
|
||||||
|
|
||||||
|
suspend fun getAccessibleChannels(): List<SpaceDto> {
|
||||||
|
return getChatClient()?.getAccessibleChannels() ?: emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun sendMessage(channelId: Long, text: String) {
|
||||||
|
val userId = tokenStore.getUserId() ?: return
|
||||||
|
getChatClient()?.sendMessage(channelId, userId, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun messageStream(channelId: Long): Flow<Message> {
|
||||||
|
_lastActiveChannel = channelId
|
||||||
|
return getChatClient()?.messageStream(channelId) ?: emptyFlow()
|
||||||
|
}
|
||||||
|
}
|
||||||
+67
@@ -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,108 @@
|
|||||||
|
package dev.zxq5.chatapp.android.feature.auth
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dev.zxq5.chatapp.android.core.service.MessageStreamService
|
||||||
|
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"
|
||||||
|
}
|
||||||
+138
@@ -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,130 @@
|
|||||||
|
package dev.zxq5.chatapp.android.feature.chat
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dev.zxq5.chatapp.android.api.model.Channel
|
||||||
|
import dev.zxq5.chatapp.android.data.repository.ChatRepository
|
||||||
|
import dev.zxq5.chatapp.android.api.model.Message
|
||||||
|
import dev.zxq5.chatapp.android.api.model.Space
|
||||||
|
import dev.zxq5.chatapp.android.api.model.SpaceDto
|
||||||
|
import dev.zxq5.chatapp.android.core.service.MessageStreamService
|
||||||
|
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<Long?>(null)
|
||||||
|
val channelId: StateFlow<Long?> = _channelId
|
||||||
|
|
||||||
|
private val _currentScreen = MutableStateFlow(Screen.CHAT)
|
||||||
|
val currentScreen: StateFlow<Screen> = _currentScreen
|
||||||
|
|
||||||
|
private val _currentUserId = MutableStateFlow<Int?>(null)
|
||||||
|
val currentUserId: StateFlow<Int?> = _currentUserId
|
||||||
|
|
||||||
|
private val _spaces = MutableStateFlow<List<SpaceDto>>(emptyList())
|
||||||
|
val spaces: StateFlow<List<SpaceDto>> = _spaces
|
||||||
|
|
||||||
|
private val _error = MutableStateFlow<String?>(null)
|
||||||
|
val error: StateFlow<String?> = _error
|
||||||
|
|
||||||
|
private val _channelError = MutableStateFlow<String?>(null)
|
||||||
|
val channelError: StateFlow<String?> = _channelError
|
||||||
|
|
||||||
|
private var streamJob: Job? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
_currentUserId.value = chatRepository.getUserId()
|
||||||
|
observeChannel()
|
||||||
|
loadAccessibleChannels()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadAccessibleChannels() {
|
||||||
|
_error.value = null
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching {
|
||||||
|
chatRepository.getAccessibleChannels()
|
||||||
|
}.onSuccess { data ->
|
||||||
|
_spaces.value = data
|
||||||
|
}.onFailure { e ->
|
||||||
|
Log.e("Chat", "Failed to load spaces", e)
|
||||||
|
_error.value = "Failed to load channels: ${e.message}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun observeChannel() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_channelId.collect { id ->
|
||||||
|
streamJob?.cancel()
|
||||||
|
_messages.value = emptyList()
|
||||||
|
_channelError.value = null
|
||||||
|
if (id != null) {
|
||||||
|
streamJob = launch {
|
||||||
|
chatRepository.messageStream(id)
|
||||||
|
.catch { e ->
|
||||||
|
Log.e("Chat", "Stream error", e)
|
||||||
|
_channelError.value = "Connection lost: ${e.message}"
|
||||||
|
}
|
||||||
|
.collect { message ->
|
||||||
|
_messages.update { it + message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun navigateTo(screen: Screen) {
|
||||||
|
_currentScreen.value = screen
|
||||||
|
}
|
||||||
|
|
||||||
|
fun switchChannel(id: Long?) {
|
||||||
|
_channelId.value = id
|
||||||
|
|
||||||
|
MessageStreamService.instance?.activeChannelId = id
|
||||||
|
|
||||||
|
if (id != null) {
|
||||||
|
// Refresh user ID just in case it wasn't available at init
|
||||||
|
_currentUserId.value = chatRepository.getUserId()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
_channelError.value = "Failed to send message"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearChat() {
|
||||||
|
_messages.value = emptyList()
|
||||||
|
_channelId.value = null
|
||||||
|
_currentUserId.value = null
|
||||||
|
_error.value = null
|
||||||
|
_channelError.value = null
|
||||||
|
streamJob?.cancel()
|
||||||
|
chatRepository.resetClient()
|
||||||
|
MessageStreamService.instance?.activeChannelId = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearChannelError() {
|
||||||
|
_channelError.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package dev.zxq5.chatapp.android.feature.chat
|
||||||
|
|
||||||
|
enum class Screen {
|
||||||
|
CHAT, CONTACTS, SETTINGS
|
||||||
|
}
|
||||||
+100
-119
@@ -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.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
@@ -26,21 +27,18 @@ import androidx.compose.foundation.text.KeyboardActions
|
|||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
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.Add
|
||||||
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
import androidx.compose.material.icons.filled.Send
|
import androidx.compose.material.icons.filled.Send
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material.icons.outlined.ChatBubbleOutline
|
|
||||||
import androidx.compose.material.icons.outlined.Settings
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.NavigationBar
|
|
||||||
import androidx.compose.material3.NavigationBarItem
|
|
||||||
import androidx.compose.material3.NavigationBarItemDefaults
|
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
@@ -57,16 +55,23 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import dev.zxq5.chatapp.android.model.ChatViewModel
|
import dev.zxq5.chatapp.android.api.model.Channel
|
||||||
import dev.zxq5.chatapp.android.model.MainScreen
|
import dev.zxq5.chatapp.android.api.model.Message
|
||||||
import dev.zxq5.chatapp.android.model.Message
|
|
||||||
import java.text.DateFormat
|
import java.text.DateFormat
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
|
import kotlin.time.ExperimentalTime
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatScreen(viewModel: ChatViewModel) {
|
fun ChatScreen(
|
||||||
|
viewModel: ChatViewModel,
|
||||||
|
onNavigateToSettings: () -> Unit,
|
||||||
|
onLogout: () -> Unit
|
||||||
|
) {
|
||||||
val selectedChannelId by viewModel.channelId.collectAsState()
|
val selectedChannelId by viewModel.channelId.collectAsState()
|
||||||
|
|
||||||
if (selectedChannelId == null) {
|
if (selectedChannelId == null) {
|
||||||
@@ -85,18 +90,17 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
|||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ChannelListScreen(viewModel: ChatViewModel, onChannelSelect: (Int) -> Unit) {
|
fun ChannelListScreen(
|
||||||
|
viewModel: ChatViewModel,
|
||||||
|
onChannelSelect: (Long) -> Unit
|
||||||
|
) {
|
||||||
|
val spaces by viewModel.spaces.collectAsState()
|
||||||
|
val error by viewModel.error.collectAsState()
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
containerColor = MaterialTheme.colorScheme.background,
|
containerColor = MaterialTheme.colorScheme.background,
|
||||||
topBar = {
|
topBar = {
|
||||||
Column {
|
Column {
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
Text(
|
|
||||||
"contacts",
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
|
|
||||||
modifier = Modifier.padding(horizontal = 20.dp)
|
|
||||||
)
|
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = {
|
title = {
|
||||||
Text(
|
Text(
|
||||||
@@ -108,103 +112,69 @@ fun ChannelListScreen(viewModel: ChatViewModel, onChannelSelect: (Int) -> Unit)
|
|||||||
colors = TopAppBarDefaults.topAppBarColors(
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
containerColor = Color.Transparent,
|
containerColor = Color.Transparent,
|
||||||
titleContentColor = MaterialTheme.colorScheme.onSurface
|
titleContentColor = MaterialTheme.colorScheme.onSurface
|
||||||
)
|
),
|
||||||
|
windowInsets = androidx.compose.foundation.layout.WindowInsets(0, 0, 0, 0),
|
||||||
|
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
"5 channels · end-to-end encrypted",
|
"Public channels - dms coming soon.",
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f),
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f),
|
||||||
modifier = Modifier.padding(horizontal = 20.dp, vertical = 2.dp)
|
modifier = Modifier.padding(horizontal = 20.dp, vertical = 2.dp)
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
|
}
|
||||||
Row(
|
}
|
||||||
modifier = Modifier
|
) { padding ->
|
||||||
.fillMaxWidth()
|
if (error != null) {
|
||||||
.background(MaterialTheme.colorScheme.secondary.copy(alpha = 0.2f))
|
Column(
|
||||||
.padding(horizontal = 20.dp, vertical = 8.dp),
|
modifier = Modifier.fillMaxSize().padding(padding),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
verticalArrangement = Arrangement.Center
|
||||||
Box(
|
) {
|
||||||
modifier = Modifier
|
Text(
|
||||||
.size(6.dp)
|
text = error!!,
|
||||||
.clip(CircleShape)
|
color = MaterialTheme.colorScheme.error,
|
||||||
.background(MaterialTheme.colorScheme.primary)
|
textAlign = TextAlign.Center,
|
||||||
)
|
modifier = Modifier.padding(16.dp)
|
||||||
Spacer(Modifier.width(10.dp))
|
)
|
||||||
Text(
|
Button(onClick = { viewModel.loadAccessibleChannels() }) {
|
||||||
"global · walkie talkie",
|
Icon(Icons.Default.Refresh, contentDescription = null)
|
||||||
style = MaterialTheme.typography.labelSmall,
|
Spacer(Modifier.width(8.dp))
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
Text("Retry")
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
Surface(
|
|
||||||
color = Color.Transparent,
|
|
||||||
border = border(0.5.dp, MaterialTheme.colorScheme.outlineVariant),
|
|
||||||
shape = RoundedCornerShape(4.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
"hold to talk",
|
|
||||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp),
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
} else {
|
||||||
bottomBar = { BottomDock(viewModel) }
|
LazyColumn(modifier = Modifier.padding(padding).fillMaxSize()) {
|
||||||
) { padding ->
|
spaces.forEach { spaceDto ->
|
||||||
LazyColumn(modifier = Modifier.padding(padding).fillMaxSize()) {
|
item {
|
||||||
items(10) { i ->
|
Text(
|
||||||
val id = i + 1
|
text = spaceDto.name.lowercase(),
|
||||||
ChannelItem(id = id, onClick = { onChannelSelect(id) })
|
modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp),
|
||||||
HorizontalDivider(
|
style = MaterialTheme.typography.labelMedium,
|
||||||
modifier = Modifier.padding(horizontal = 20.dp),
|
color = MaterialTheme.colorScheme.primary,
|
||||||
thickness = 0.5.dp,
|
fontWeight = FontWeight.Bold
|
||||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f)
|
)
|
||||||
)
|
}
|
||||||
|
items(spaceDto.channels) { channel ->
|
||||||
|
ChannelItem(channel = channel, onClick = { onChannelSelect(channel.id) })
|
||||||
|
HorizontalDivider(
|
||||||
|
modifier = Modifier.padding(horizontal = 20.dp),
|
||||||
|
thickness = 0.5.dp,
|
||||||
|
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun BottomDock(viewModel: ChatViewModel) {
|
fun ChannelItem(channel: Channel, onClick: () -> Unit) {
|
||||||
val currentScreen by viewModel.currentScreen.collectAsState()
|
|
||||||
|
|
||||||
NavigationBar(
|
|
||||||
containerColor = MaterialTheme.colorScheme.background,
|
|
||||||
tonalElevation = 0.dp,
|
|
||||||
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) },
|
|
||||||
icon = { Icon(Icons.Outlined.ChatBubbleOutline, contentDescription = "Chat") },
|
|
||||||
label = { Text("chat", style = MaterialTheme.typography.labelSmall) },
|
|
||||||
colors = NavigationBarItemDefaults.colors(
|
|
||||||
selectedIconColor = MaterialTheme.colorScheme.primary,
|
|
||||||
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
|
|
||||||
indicatorColor = Color.Transparent
|
|
||||||
)
|
|
||||||
)
|
|
||||||
NavigationBarItem(
|
|
||||||
selected = currentScreen == MainScreen.SETTINGS,
|
|
||||||
onClick = { viewModel.navigateTo(MainScreen.SETTINGS) },
|
|
||||||
icon = { Icon(Icons.Outlined.Settings, contentDescription = "Settings") },
|
|
||||||
label = { Text("settings", style = MaterialTheme.typography.labelSmall) },
|
|
||||||
colors = NavigationBarItemDefaults.colors(
|
|
||||||
selectedIconColor = MaterialTheme.colorScheme.primary,
|
|
||||||
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
|
|
||||||
indicatorColor = Color.Transparent
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun ChannelItem(id: Int, onClick: () -> Unit) {
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -220,7 +190,7 @@ fun ChannelItem(id: Int, onClick: () -> Unit) {
|
|||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
"C$id",
|
channel.name.take(1).uppercase(),
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
@@ -228,31 +198,30 @@ fun ChannelItem(id: Int, onClick: () -> Unit) {
|
|||||||
Spacer(Modifier.width(12.dp))
|
Spacer(Modifier.width(12.dp))
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Text(
|
Text(
|
||||||
text = "channel $id",
|
text = channel.name,
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
)
|
)
|
||||||
Text(
|
if (channel.description != null) {
|
||||||
text = "tap to join",
|
Text(
|
||||||
style = MaterialTheme.typography.labelSmall,
|
text = channel.description,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
|
style = MaterialTheme.typography.labelSmall,
|
||||||
)
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Text(
|
|
||||||
"14:22",
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun MessageScreen(channelId: Int, viewModel: ChatViewModel, onBack: () -> Unit) {
|
fun MessageScreen(channelId: Long, viewModel: ChatViewModel, onBack: () -> Unit) {
|
||||||
val messages by viewModel.messages.collectAsState()
|
val messages by viewModel.messages.collectAsState()
|
||||||
val currentUserId by viewModel.currentUserId.collectAsState()
|
val currentUserId by viewModel.currentUserId.collectAsState()
|
||||||
|
val channelError by viewModel.channelError.collectAsState()
|
||||||
var input by remember { mutableStateOf("") }
|
var input by remember { mutableStateOf("") }
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
|
||||||
LaunchedEffect(messages.size) {
|
LaunchedEffect(messages.size) {
|
||||||
if (messages.isNotEmpty()) {
|
if (messages.isNotEmpty()) {
|
||||||
@@ -260,8 +229,16 @@ fun MessageScreen(channelId: Int, viewModel: ChatViewModel, onBack: () -> Unit)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(channelError) {
|
||||||
|
channelError?.let {
|
||||||
|
snackbarHostState.showSnackbar(it)
|
||||||
|
viewModel.clearChannelError()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
containerColor = MaterialTheme.colorScheme.background,
|
containerColor = MaterialTheme.colorScheme.background,
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = {
|
title = {
|
||||||
@@ -287,6 +264,7 @@ fun MessageScreen(channelId: Int, viewModel: ChatViewModel, onBack: () -> Unit)
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
windowInsets = androidx.compose.foundation.layout.WindowInsets(0, 0, 0, 0),
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
containerColor = Color.Transparent
|
containerColor = Color.Transparent
|
||||||
)
|
)
|
||||||
@@ -384,10 +362,13 @@ fun MessageScreen(channelId: Int, viewModel: ChatViewModel, onBack: () -> Unit)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalTime::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun MessageBubble(message: Message, currentUserId: Int?) {
|
fun MessageBubble(message: Message, currentUserId: Int?) {
|
||||||
val time = remember(message.timestamp) {
|
val time = remember(message.timestamp) {
|
||||||
DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(message.timestamp)).lowercase()
|
DateFormat.getTimeInstance(DateFormat.SHORT)
|
||||||
|
.format(Date(message.timestamp.toEpochMilliseconds()))
|
||||||
|
.lowercase()
|
||||||
}
|
}
|
||||||
|
|
||||||
val isMe = currentUserId != null && message.user_id == currentUserId
|
val isMe = currentUserId != null && message.user_id == currentUserId
|
||||||
@@ -409,7 +390,7 @@ fun MessageBubble(message: Message, currentUserId: Int?) {
|
|||||||
Column(modifier = Modifier.padding(horizontal = 11.dp, vertical = 8.dp)) {
|
Column(modifier = Modifier.padding(horizontal = 11.dp, vertical = 8.dp)) {
|
||||||
if (!isMe) {
|
if (!isMe) {
|
||||||
Text(
|
Text(
|
||||||
message.display_name.lowercase(),
|
message.display_name?.lowercase() ?: "unknown",
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.7f),
|
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.7f),
|
||||||
modifier = Modifier.padding(bottom = 2.dp)
|
modifier = Modifier.padding(bottom = 2.dp)
|
||||||
@@ -431,5 +412,5 @@ fun MessageBubble(message: Message, currentUserId: Int?) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun border(width: androidx.compose.ui.unit.Dp, color: Color) =
|
private fun border(width: Dp, color: Color) =
|
||||||
androidx.compose.foundation.BorderStroke(width, color)
|
BorderStroke(width, color)
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package dev.zxq5.chatapp.android.feature.contacts
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
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.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ContactsScreen() {
|
||||||
|
Scaffold(
|
||||||
|
containerColor = MaterialTheme.colorScheme.background,
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
"contacts",
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
},
|
||||||
|
windowInsets = androidx.compose.foundation.layout.WindowInsets(0, 0, 0, 0),
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = Color.Transparent,
|
||||||
|
titleContentColor = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { padding ->
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Contacts coming soon",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+157
@@ -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,525 @@
|
|||||||
|
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,
|
||||||
|
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
|
||||||
|
)
|
||||||
|
},
|
||||||
|
windowInsets = androidx.compose.foundation.layout.WindowInsets(0, 0, 0, 0),
|
||||||
|
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,11 +0,0 @@
|
|||||||
package dev.zxq5.chatapp.android.model
|
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class Message(
|
|
||||||
val user_id: Int,
|
|
||||||
val display_name: String,
|
|
||||||
val text: String,
|
|
||||||
val timestamp: Long
|
|
||||||
)
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
package dev.zxq5.chatapp.android.model
|
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class SendMessage(
|
|
||||||
val user_id: Int,
|
|
||||||
val text: String,
|
|
||||||
val timestamp: Long
|
|
||||||
)
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:autoMirrored="true" android:height="24dp" android:tint="#FFFFFF" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
|
||||||
|
|
||||||
|
<path android:fillColor="@android:color/white" android:pathData="M20,2L4,2c-1.1,0 -1.99,0.9 -1.99,2L2,22l4,-4h14c1.1,0 2,-0.9 2,-2L22,4c0,-1.1 -0.9,-2 -2,-2zM18,14L6,14v-2h12v2zM18,11L6,11L6,9h12v2zM18,8L6,8L6,6h12v2z"/>
|
||||||
|
|
||||||
|
</vector>
|
||||||
Generated
+10
@@ -0,0 +1,10 @@
|
|||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# Ignored default folder with query files
|
||||||
|
/queries/
|
||||||
|
# Datasource local storage ignored files
|
||||||
|
/dataSources/
|
||||||
|
/dataSources.local.xml
|
||||||
|
# Editor-based HTTP Client requests
|
||||||
|
/httpRequests/
|
||||||
Generated
+12
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="EMPTY_MODULE" version="4">
|
||||||
|
<component name="NewModuleRootManager">
|
||||||
|
<content url="file://$MODULE_DIR$">
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/tests" isTestSource="true" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/target" />
|
||||||
|
</content>
|
||||||
|
<orderEntry type="inheritedJdk" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
</component>
|
||||||
|
</module>
|
||||||
Generated
+17
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
|
||||||
|
<data-source source="LOCAL" name="chatapp_dev@100.118.108.58" uuid="b14acf5d-6750-469b-8aea-59c8343eb11c">
|
||||||
|
<driver-ref>postgresql</driver-ref>
|
||||||
|
<synchronize>true</synchronize>
|
||||||
|
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
|
||||||
|
<jdbc-url>jdbc:postgresql://100.118.108.58:5432/chatapp_dev</jdbc-url>
|
||||||
|
<jdbc-additional-properties>
|
||||||
|
<property name="com.intellij.clouds.kubernetes.db.host.port" />
|
||||||
|
<property name="com.intellij.clouds.kubernetes.db.enabled" value="false" />
|
||||||
|
<property name="com.intellij.clouds.kubernetes.db.container.port" />
|
||||||
|
</jdbc-additional-properties>
|
||||||
|
<working-dir>$ProjectFileDir$</working-dir>
|
||||||
|
</data-source>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
Generated
+7
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="DataSourcePerFileMappings">
|
||||||
|
<file url="file://$PROJECT_DIR$/sql/schema.sql" value="b14acf5d-6750-469b-8aea-59c8343eb11c" />
|
||||||
|
<file url="file://$PROJECT_DIR$/src/repo/user_repo.rs" value="b14acf5d-6750-469b-8aea-59c8343eb11c" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<component name="InspectionProjectProfileManager">
|
||||||
|
<profile version="1.0">
|
||||||
|
<option name="myName" value="Project Default" />
|
||||||
|
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||||
|
</profile>
|
||||||
|
</component>
|
||||||
Generated
+8
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectModuleManager">
|
||||||
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/.idea/backend.iml" filepath="$PROJECT_DIR$/.idea/backend.iml" />
|
||||||
|
</modules>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
Generated
+7
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="SqlDialectMappings">
|
||||||
|
<file url="file://$PROJECT_DIR$/sql/schema.sql" dialect="PostgreSQL" />
|
||||||
|
<file url="PROJECT" dialect="PostgreSQL" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
Generated
+6
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
+5
-2
@@ -10,7 +10,7 @@ dotenv = "0.15.0"
|
|||||||
futures-util = "0.3.31"
|
futures-util = "0.3.31"
|
||||||
image = "0.25.8"
|
image = "0.25.8"
|
||||||
jsonwebtoken = { version = "10.3.0", features = ["rust_crypto"] }
|
jsonwebtoken = { version = "10.3.0", features = ["rust_crypto"] }
|
||||||
rand = "0.9.2"
|
rand = "0.8"
|
||||||
redis = { version = "0.25.4", features = ["tokio-comp"] }
|
redis = { version = "0.25.4", features = ["tokio-comp"] }
|
||||||
reqwest = { version = "0.12.23", features = ["json"] }
|
reqwest = { version = "0.12.23", features = ["json"] }
|
||||||
rocket = { version = "0.5.1", features = ["json", "secrets"] }
|
rocket = { version = "0.5.1", features = ["json", "secrets"] }
|
||||||
@@ -20,8 +20,11 @@ rocket_dyn_templates = { version = "0.2.0", features = ["tera"] }
|
|||||||
serde = { version = "1.0.228", features = ["derive"] }
|
serde = { version = "1.0.228", features = ["derive"] }
|
||||||
serde_json = "1.0.145"
|
serde_json = "1.0.145"
|
||||||
sha2 = "0.10.9"
|
sha2 = "0.10.9"
|
||||||
sqlx = { version = "0.7.4", features = ["macros", "time"] }
|
sqlx = { version = "0.7.4", features = ["chrono", "macros", "postgres", "time"] }
|
||||||
tokio = { version = "1.47.1", features = ["full"] }
|
tokio = { version = "1.47.1", features = ["full"] }
|
||||||
totp-rs = { version = "5.7.0", features = ["gen_secret", "qr", "rand"] }
|
totp-rs = { version = "5.7.0", features = ["gen_secret", "qr", "rand"] }
|
||||||
tracing = "0.1.44"
|
tracing = "0.1.44"
|
||||||
uuid = { version = "1.18.1", features = ["v4"] }
|
uuid = { version = "1.18.1", features = ["v4"] }
|
||||||
|
thiserror = "1.0.69"
|
||||||
|
utoipa = { version = "5.4.0", features = ["rocket_extras", "chrono"] }
|
||||||
|
clap = { version = "4.5", features = ["derive"] }
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
-- Add migration script here
|
|
||||||
CREATE TABLE users (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
username VARCHAR(50) UNIQUE NOT NULL,
|
|
||||||
password VARCHAR(50) NOT NULL,
|
|
||||||
display_name VARCHAR(50),
|
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE channels (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
name VARCHAR(50) NOT NULL,
|
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE messages (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
|
||||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
content TEXT NOT NULL,
|
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
is_edited BOOLEAN DEFAULT FALSE
|
|
||||||
);
|
|
||||||
|
|
||||||
create table attachments (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
|
||||||
path TEXT NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_messages_channel_id ON messages (channel_id, id DESC);
|
|
||||||
CREATE INDEX idx_new_messages ON messages(created_at DESC);
|
|
||||||
|
|
||||||
-- Create a function to update the updated_at timestamp
|
|
||||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
|
||||||
RETURNS TRIGGER AS $$
|
|
||||||
BEGIN
|
|
||||||
NEW.updated_at = CURRENT_TIMESTAMP;
|
|
||||||
RETURN NEW;
|
|
||||||
END;
|
|
||||||
$$ language 'plpgsql';
|
|
||||||
|
|
||||||
-- Create trigger for messages table
|
|
||||||
CREATE TRIGGER update_messages_updated_at
|
|
||||||
BEFORE UPDATE ON messages
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION update_updated_at_column();
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
-- Add migration script here
|
|
||||||
CREATE TABLE sessions (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
|
||||||
token TEXT NOT NULL UNIQUE,
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
expires_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + INTERVAL '7 days'
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE OR REPLACE FUNCTION cleanup_expired_sessions()
|
|
||||||
RETURNS TRIGGER AS $$
|
|
||||||
BEGIN
|
|
||||||
DELETE FROM sessions WHERE expires_at < NOW();
|
|
||||||
RETURN NULL;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
CREATE TRIGGER trigger_cleanup_sessions
|
|
||||||
AFTER INSERT ON sessions
|
|
||||||
EXECUTE FUNCTION cleanup_expired_sessions();
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
-- Add migration script here
|
|
||||||
ALTER TABLE users ADD COLUMN email VARCHAR(100);
|
|
||||||
ALTER TABLE users ADD COLUMN twofa_enabled BOOLEAN DEFAULT FALSE;
|
|
||||||
ALTER TABLE users ADD COLUMN totp_secret VARCHAR(64);
|
|
||||||
ALTER TABLE users ADD COLUMN updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP;
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
-- Add migration script here
|
|
||||||
ALTER TABLE users ALTER COLUMN twofa_enabled SET NOT NULL;
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
-- Add migration script here
|
|
||||||
CREATE TABLE access_codes (
|
|
||||||
-- identifiers
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
creator_id INTEGER NOT NULL REFERENCES users(id),
|
|
||||||
|
|
||||||
-- code data
|
|
||||||
code VARCHAR(255) NOT NULL,
|
|
||||||
name VARCHAR(255) NOT NULL,
|
|
||||||
|
|
||||||
-- uses
|
|
||||||
uses INTEGER NOT NULL DEFAULT 0,
|
|
||||||
max_uses INTEGER NOT NULL DEFAULT 1,
|
|
||||||
|
|
||||||
-- time data
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
expires_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + INTERVAL '1 day'
|
|
||||||
);
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
-- Add migration script here
|
|
||||||
ALTER TABLE access_codes
|
|
||||||
ALTER COLUMN created_at
|
|
||||||
TYPE TIMESTAMP WITH TIME ZONE
|
|
||||||
USING created_at AT TIME ZONE 'UTC';
|
|
||||||
|
|
||||||
ALTER TABLE access_codes
|
|
||||||
ALTER COLUMN expires_at
|
|
||||||
TYPE TIMESTAMP WITH TIME ZONE
|
|
||||||
USING expires_at AT TIME ZONE 'UTC';
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
-- Add migration script here
|
|
||||||
TRUNCATE TABLE users CASCADE;
|
|
||||||
|
|
||||||
ALTER TABLE users DROP COLUMN password;
|
|
||||||
ALTER TABLE users ADD COLUMN pass_hash VARCHAR(255) NOT NULL;
|
|
||||||
ALTER TABLE users ADD CONSTRAINT users_username_unique UNIQUE (username);
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
-- Add migration script here
|
|
||||||
CREATE TYPE status AS ENUM ('pending', 'accepted', 'blocked');
|
|
||||||
|
|
||||||
CREATE TABLE relationships (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
from_user INTEGER NOT NULL REFERENCES users(id),
|
|
||||||
to_user INTEGER NOT NULL REFERENCES users(id),
|
|
||||||
status status NOT NULL DEFAULT 'pending',
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
CONSTRAINT no_self_relationship CHECK (from_user != to_user),
|
|
||||||
CONSTRAINT unique_relationship UNIQUE (from_user, to_user)
|
|
||||||
);
|
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
-- Add migration script here
|
||||||
|
-- Add migration script here
|
||||||
|
CREATE TYPE user_role AS ENUM ('user', 'admin');
|
||||||
|
CREATE TYPE totp_status AS ENUM ('disabled', 'pending', 'enabled');
|
||||||
|
|
||||||
|
CREATE TABLE users (
|
||||||
|
id BIGSERIAL PRIMARY KEY NOT NULL,
|
||||||
|
role user_role NOT NULL DEFAULT 'user',
|
||||||
|
|
||||||
|
-- profile
|
||||||
|
nickname VARCHAR(255),
|
||||||
|
|
||||||
|
-- basic auth
|
||||||
|
username VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
passhash VARCHAR(255) NOT NULL,
|
||||||
|
|
||||||
|
-- email
|
||||||
|
email VARCHAR(255),
|
||||||
|
email_verified BOOLEAN DEFAULT FALSE,
|
||||||
|
|
||||||
|
-- 2fa
|
||||||
|
totp_secret VARCHAR(255),
|
||||||
|
totp_status totp_status NOT NULL DEFAULT 'disabled',
|
||||||
|
|
||||||
|
-- update tracking
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
deleted_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE access_tokens (
|
||||||
|
id BIGSERIAL PRIMARY KEY NOT NULL,
|
||||||
|
creator_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
code VARCHAR(255) NOT NULL,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
|
||||||
|
uses INTEGER NOT NULL DEFAULT 0,
|
||||||
|
max_uses INTEGER NOT NULL DEFAULT 1,
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '24 hours',
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE refresh_tokens (
|
||||||
|
id BIGSERIAL PRIMARY KEY NOT NULL,
|
||||||
|
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
token_hash VARCHAR(255) NOT NULL,
|
||||||
|
|
||||||
|
revoked BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '7 days'
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
CREATE TABLE spaces (
|
||||||
|
id BIGSERIAL PRIMARY KEY NOT NULL,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
|
||||||
|
owner_id BIGINT NOT NULL REFERENCES users(id),
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE channels (
|
||||||
|
id BIGSERIAL PRIMARY KEY NOT NULL,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
|
||||||
|
space_id BIGINT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
CREATE TABLE space_members (
|
||||||
|
space_id BIGINT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
|
||||||
|
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
role user_role DEFAULT 'user',
|
||||||
|
|
||||||
|
PRIMARY KEY (space_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE messages (
|
||||||
|
id BIGSERIAL PRIMARY KEY NOT NULL,
|
||||||
|
channel_id BIGINT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||||
|
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
is_edited BOOLEAN DEFAULT FALSE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE attachments (
|
||||||
|
id BIGSERIAL PRIMARY KEY NOT NULL,
|
||||||
|
message_id BIGINT NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
||||||
|
filename VARCHAR(255) NOT NULL,
|
||||||
|
content_type VARCHAR(100) NOT NULL, -- mime type e.g. image/png, video/mp4
|
||||||
|
size_bytes BIGINT NOT NULL,
|
||||||
|
url TEXT NOT NULL, -- path to file on your CDN/storage
|
||||||
|
width INTEGER, -- null for non-image/video
|
||||||
|
height INTEGER, -- null for non-image/video
|
||||||
|
duration_ms INTEGER, -- null for non-audio/video
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TYPE relationship_status AS ENUM ('pending', 'accepted', 'blocked');
|
||||||
|
|
||||||
|
CREATE TABLE relationships (
|
||||||
|
id BIGSERIAL PRIMARY KEY NOT NULL,
|
||||||
|
|
||||||
|
from_user BIGINT NOT NULL REFERENCES users(id),
|
||||||
|
to_user BIGINT NOT NULL REFERENCES users(id),
|
||||||
|
status relationship_status NOT NULL DEFAULT 'pending',
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT no_self_relationship CHECK (from_user != to_user),
|
||||||
|
CONSTRAINT unique_relationship UNIQUE (from_user, to_user)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_messages_channel_id ON messages(channel_id, created_at DESC);
|
||||||
|
CREATE INDEX idx_messages_user_id ON messages(user_id);
|
||||||
|
CREATE INDEX idx_attachments_message ON attachments(message_id);
|
||||||
|
CREATE INDEX idx_channels_space_id ON channels(space_id);
|
||||||
|
CREATE INDEX idx_space_members_user ON space_members(user_id);
|
||||||
|
CREATE INDEX idx_refresh_tokens_hash ON refresh_tokens(token_hash);
|
||||||
|
CREATE INDEX idx_relationships_from ON relationships(from_user, to_user);
|
||||||
|
CREATE INDEX idx_relationships_to ON relationships(to_user);
|
||||||
|
CREATE INDEX idx_access_tokens_code ON access_tokens(code);
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION update_updated_at()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = NOW();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE TRIGGER users_updated_at
|
||||||
|
BEFORE UPDATE ON users
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||||
|
|
||||||
|
CREATE TRIGGER spaces_updated_at
|
||||||
|
BEFORE UPDATE ON spaces
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||||
|
|
||||||
|
CREATE TRIGGER channels_updated_at
|
||||||
|
BEFORE UPDATE ON channels
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||||
|
|
||||||
|
CREATE TRIGGER messages_updated_at
|
||||||
|
BEFORE UPDATE ON messages
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||||
|
|
||||||
|
CREATE TRIGGER relationships_updated_at
|
||||||
|
BEFORE UPDATE ON relationships
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||||
|
|
||||||
|
CREATE TRIGGER access_tokens_updated_at
|
||||||
|
BEFORE UPDATE ON access_tokens
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION add_owner_to_space()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO space_members (space_id, user_id, role, joined_at)
|
||||||
|
VALUES (NEW.id, NEW.owner_id, 'admin', NOW());
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE TRIGGER space_owner_becomes_member
|
||||||
|
AFTER INSERT ON spaces
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION add_owner_to_space();
|
||||||
@@ -1,21 +1,46 @@
|
|||||||
use std::{
|
use crate::error::ApiResult;
|
||||||
sync::LazyLock,
|
use crate::model::auth::{AccessTokenForm, AuthResponse, LoginCredentials, SignupCredentials};
|
||||||
time::{SystemTime, UNIX_EPOCH},
|
use crate::svc::auth_svc::AuthService;
|
||||||
};
|
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
|
||||||
|
use rocket::http::Status;
|
||||||
|
use rocket::request::{FromRequest, Outcome};
|
||||||
|
use rocket::serde::json::Json;
|
||||||
|
use rocket::serde::{Deserialize, Serialize};
|
||||||
|
use rocket::{Request, State};
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
use crate::svc::access_token_svc::AccessTokenService;
|
||||||
|
|
||||||
use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation, decode, encode};
|
#[post("/signup", data = "<cred>")]
|
||||||
use rand::Rng;
|
pub async fn signup(
|
||||||
use rocket::{
|
cred: Json<SignupCredentials>,
|
||||||
Request,
|
svc: &State<AuthService>
|
||||||
http::Status,
|
) -> ApiResult<Json<AuthResponse>> {
|
||||||
request::{self, FromRequest, Outcome},
|
let response = svc
|
||||||
};
|
.signup(
|
||||||
use rocket_db_pools::Connection;
|
&cred.email, &cred.username, &cred.password, &cred.access_token,
|
||||||
use serde::{Deserialize, Serialize};
|
).await?;
|
||||||
use sha2::{Digest, Sha256, digest::block_buffer::Lazy};
|
Ok(Json(response))
|
||||||
use sqlx::postgres::PgQueryResult;
|
}
|
||||||
|
|
||||||
use crate::db::Postgres;
|
#[post("/login", data = "<cred>")]
|
||||||
|
pub async fn login(
|
||||||
|
cred: Json<LoginCredentials>,
|
||||||
|
svc: &State<AuthService>
|
||||||
|
) -> ApiResult<Json<AuthResponse>> {
|
||||||
|
Ok(Json(svc.login(&cred.username, &cred.password).await?))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[post("/invite", data = "<form>")]
|
||||||
|
pub async fn generate_invite(
|
||||||
|
session: Session,
|
||||||
|
form: Json<AccessTokenForm>,
|
||||||
|
svc: &State<AccessTokenService>
|
||||||
|
) -> ApiResult<String> {
|
||||||
|
svc.create(
|
||||||
|
session.uid, &form.name, form.max_uses,
|
||||||
|
form.start_date, form.expiry_date).await
|
||||||
|
}
|
||||||
|
|
||||||
static JWT_SECRET: LazyLock<String> = LazyLock::new(|| std::env::var("JWT_SECRET").unwrap());
|
static JWT_SECRET: LazyLock<String> = LazyLock::new(|| std::env::var("JWT_SECRET").unwrap());
|
||||||
|
|
||||||
@@ -27,7 +52,7 @@ pub enum TokenScope {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub struct Session {
|
pub struct Session {
|
||||||
pub user_id: usize,
|
pub uid: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[rocket::async_trait]
|
#[rocket::async_trait]
|
||||||
@@ -37,7 +62,7 @@ impl<'r> FromRequest<'r> for Session {
|
|||||||
async fn from_request(req: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
async fn from_request(req: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
||||||
match Claims::from_request(req).await {
|
match Claims::from_request(req).await {
|
||||||
Outcome::Success(user) if user.scope == TokenScope::Full => Outcome::Success(Session {
|
Outcome::Success(user) if user.scope == TokenScope::Full => Outcome::Success(Session {
|
||||||
user_id: user.sub as usize,
|
uid: user.sub as i64,
|
||||||
}),
|
}),
|
||||||
Outcome::Success(_) => {
|
Outcome::Success(_) => {
|
||||||
eprintln!("warning: user with scope other than Full attempted to access session");
|
eprintln!("warning: user with scope other than Full attempted to access session");
|
||||||
@@ -106,4 +131,4 @@ impl<'r> FromRequest<'r> for Claims {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -26,7 +26,7 @@ pub async fn profile_pic(user_id: usize) -> Option<NamedFile> {
|
|||||||
Some(image)
|
Some(image)
|
||||||
} else {
|
} else {
|
||||||
Some(
|
Some(
|
||||||
NamedFile::open("./cdn/profiles/full/default.svg")
|
NamedFile::open("../../cdn/profiles/full/default.svg")
|
||||||
.await
|
.await
|
||||||
.ok()?,
|
.ok()?,
|
||||||
)
|
)
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
use crate::api::auth::Session;
|
||||||
|
use crate::error::ApiResult;
|
||||||
|
use crate::svc::chat_svc::ChatService;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use rocket::response::stream::Event;
|
||||||
|
use rocket::serde::json::Json;
|
||||||
|
use rocket::serde::{Deserialize, Serialize};
|
||||||
|
use rocket::{Shutdown, State, ___internal_EventStream as EventStream};
|
||||||
|
use sqlx::FromRow;
|
||||||
|
use tokio::select;
|
||||||
|
use tokio::sync::broadcast;
|
||||||
|
|
||||||
|
/// ---------- Rocket routes ----------
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone, FromRow)]
|
||||||
|
pub struct ChatMsg {
|
||||||
|
pub display_name: Option<String>,
|
||||||
|
pub user_id: i64,
|
||||||
|
pub text: String,
|
||||||
|
pub timestamp: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[post("/chat/<channel_id>", format = "json", data = "<msg>")]
|
||||||
|
pub async fn post_message(
|
||||||
|
msg: Json<ChatMsg>,
|
||||||
|
chat: &State<ChatService>,
|
||||||
|
session: Session,
|
||||||
|
channel_id: i64,
|
||||||
|
) -> ApiResult<()> {
|
||||||
|
chat.send(channel_id, session.uid, &msg.text, Utc::now()).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/events/<channel_id>")]
|
||||||
|
pub async fn event_stream(
|
||||||
|
chat: &State<ChatService>,
|
||||||
|
s: Session,
|
||||||
|
mut shutdown: Shutdown,
|
||||||
|
channel_id: i64,
|
||||||
|
) -> ApiResult<EventStream![]> {
|
||||||
|
let messages = chat.get_messages(channel_id, 100)
|
||||||
|
.await?; // if get message returned err, inform user.
|
||||||
|
|
||||||
|
let mut rx = chat.subscribe(channel_id).await;
|
||||||
|
let id = s.uid;
|
||||||
|
|
||||||
|
Ok(EventStream! {
|
||||||
|
for msg in messages {
|
||||||
|
yield Event::json(&msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
loop {
|
||||||
|
select!{
|
||||||
|
_ = &mut shutdown => break, // exit early on shutdown
|
||||||
|
msg = rx.recv() => match msg {
|
||||||
|
Ok(msg) => {
|
||||||
|
tracing::info!("yielding message!");
|
||||||
|
yield Event::json(&msg)
|
||||||
|
},
|
||||||
|
Err(broadcast::error::RecvError::Lagged(n)) => {
|
||||||
|
tracing::warn!("Receiver lagging on channel {channel_id} by {n} events",);
|
||||||
|
yield Event::comment("RecvError::Lagged");
|
||||||
|
}
|
||||||
|
Err(broadcast::error::RecvError::Closed) => {
|
||||||
|
tracing::info!("Broadcaster hung up on channel {channel_id}!");
|
||||||
|
break
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
pub mod auth;
|
||||||
|
pub mod chat;
|
||||||
|
pub mod totp;
|
||||||
|
pub mod settings;
|
||||||
|
pub mod cdn;
|
||||||
|
pub mod profile;
|
||||||
|
pub mod space;
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
use rocket::State;
|
||||||
|
use crate::api::auth::Session;
|
||||||
|
use crate::error::ApiResult;
|
||||||
|
use crate::svc::user_svc::UserService;
|
||||||
|
|
||||||
|
#[get("/users/<id>")]
|
||||||
|
pub async fn display_name(
|
||||||
|
id: i64,
|
||||||
|
_ag: Session,
|
||||||
|
svc: &State<UserService>,
|
||||||
|
) -> ApiResult<String> {
|
||||||
|
svc.get_username(id).await
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
use crate::api::auth::Session;
|
||||||
|
use crate::error::ApiResult;
|
||||||
|
use crate::svc::settings_svc::SettingsService;
|
||||||
|
use rocket::serde::json::Json;
|
||||||
|
use rocket::serde::{Deserialize, Serialize};
|
||||||
|
use rocket::State;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct PasswordForm {
|
||||||
|
pub old_password: String,
|
||||||
|
pub new_password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[post("/settings/password", data = "<form>")]
|
||||||
|
pub async fn change_password(
|
||||||
|
session: Session,
|
||||||
|
form: Json<PasswordForm>,
|
||||||
|
settings: &State<SettingsService>
|
||||||
|
) -> ApiResult<()> {
|
||||||
|
settings.change_password(
|
||||||
|
session.uid, &form.old_password, &form.new_password
|
||||||
|
).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug, Clone)]
|
||||||
|
pub struct DisplayNameForm {
|
||||||
|
pub display_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug, Clone)]
|
||||||
|
pub struct PasswordAnd2faForm {
|
||||||
|
pub password: String,
|
||||||
|
pub totp_code: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[delete("/settings", data = "<data>")]
|
||||||
|
pub async fn delete_account(
|
||||||
|
session: Session,
|
||||||
|
data: Json<PasswordAnd2faForm>,
|
||||||
|
settings: &State<SettingsService>
|
||||||
|
) -> ApiResult<()> {
|
||||||
|
settings.delete_account(
|
||||||
|
session.uid, &data.password, &data.totp_code
|
||||||
|
).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[patch("/settings/display_name", data = "<new>")]
|
||||||
|
pub async fn change_display_name(
|
||||||
|
session: Session,
|
||||||
|
new: Json<DisplayNameForm>,
|
||||||
|
settings: &State<SettingsService>
|
||||||
|
) -> ApiResult<()> {
|
||||||
|
settings.change_display_name(session.uid, new.display_name.clone()).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct UsernameForm {
|
||||||
|
pub username: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[patch("/settings/username", data = "<new>")]
|
||||||
|
pub async fn change_username(
|
||||||
|
session: Session,
|
||||||
|
new: Json<UsernameForm>,
|
||||||
|
settings: &State<SettingsService>
|
||||||
|
) -> ApiResult<()> {
|
||||||
|
settings.change_username(session.uid, &new.username).await
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
use crate::error::ApiResult;
|
||||||
|
use crate::model::space::{Space, SpaceDto};
|
||||||
|
use crate::model::space::Channel;
|
||||||
|
use crate::repo::{SpaceRepo, ChannelRepo};
|
||||||
|
use rocket::serde::json::Json;
|
||||||
|
use rocket::State;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use crate::api::auth::Session;
|
||||||
|
use crate::svc::chat_svc::ChatService;
|
||||||
|
|
||||||
|
#[get("/spaces")]
|
||||||
|
pub async fn list_spaces(
|
||||||
|
space_repo: &State<Arc<dyn SpaceRepo>>
|
||||||
|
) -> ApiResult<Json<Vec<Space>>> {
|
||||||
|
let spaces = space_repo.get_all().await?;
|
||||||
|
Ok(Json(spaces))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/spaces/<space_id>/channels")]
|
||||||
|
pub async fn list_channels(
|
||||||
|
space_id: i64,
|
||||||
|
channel_repo: &State<Arc<dyn ChannelRepo>>
|
||||||
|
) -> ApiResult<Json<Vec<Channel>>> {
|
||||||
|
let channels = channel_repo.get_by_space_id(space_id).await?;
|
||||||
|
Ok(Json(channels))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/accessible_channels")]
|
||||||
|
pub async fn get_accessible_channels(
|
||||||
|
session: Session,
|
||||||
|
svc: &State<ChatService>
|
||||||
|
) -> ApiResult<Json<Vec<SpaceDto>>> {
|
||||||
|
let space = svc.get_accessible_channels(session.uid).await?;
|
||||||
|
println!("{:?}", space);
|
||||||
|
Ok(Json(space))
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
use crate::api::auth::{Claims, Session, TokenScope};
|
||||||
|
use crate::error::{ApiResult, AppError};
|
||||||
|
use crate::model::auth::AuthResponse;
|
||||||
|
use crate::svc::auth_svc::AuthService;
|
||||||
|
use rocket::serde::json::Json;
|
||||||
|
use rocket::serde::{Deserialize, Serialize};
|
||||||
|
use rocket::State;
|
||||||
|
use totp_rs::{Algorithm, TOTP};
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct TOTPSixDigitCode {
|
||||||
|
code: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, sqlx::Type, Clone, Copy, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
#[sqlx(type_name = "totp_status", rename_all = "lowercase")]
|
||||||
|
pub enum TotpStatus {
|
||||||
|
Enabled,
|
||||||
|
Pending,
|
||||||
|
Disabled,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct QrResponse {
|
||||||
|
qr_code: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct TotpVerifyRequest {
|
||||||
|
pub code: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct PasswordConfirmation {
|
||||||
|
password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct PasswordAnd2fa {
|
||||||
|
pub password: String,
|
||||||
|
pub totp_code: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn totp_gen(user_id: i64, secret: &[u8]) -> ApiResult<TOTP> {
|
||||||
|
TOTP::new(
|
||||||
|
Algorithm::SHA1,
|
||||||
|
6,
|
||||||
|
1,
|
||||||
|
30,
|
||||||
|
secret.to_owned(),
|
||||||
|
Some("chat.zxq5.dev".to_string()),
|
||||||
|
format!("{}", user_id),
|
||||||
|
)
|
||||||
|
.map_err(|_| AppError::internal("failed to generate totp"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[post("/totp", data = "<form>")]
|
||||||
|
pub async fn confirm_totp(
|
||||||
|
user: Session,
|
||||||
|
form: Json<TOTPSixDigitCode>,
|
||||||
|
svc: &State<AuthService>,
|
||||||
|
) -> ApiResult<()> {
|
||||||
|
svc.confirm_totp(user.uid, &form.code).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[post("/totp.jpg", data = "<form>")]
|
||||||
|
pub async fn get_totp(
|
||||||
|
user: Session,
|
||||||
|
form: Json<PasswordConfirmation>,
|
||||||
|
svc: &State<AuthService>,
|
||||||
|
) -> ApiResult<Json<QrResponse>> {
|
||||||
|
let secret = svc.get_or_create_totp_secret(user.uid, &form.password).await?;
|
||||||
|
|
||||||
|
let qr_b64 = totp_gen(user.uid, secret.as_bytes())
|
||||||
|
.map_err(|_| AppError::internal("invalid totp secret"))?
|
||||||
|
.get_qr_base64()
|
||||||
|
.map_err(|_| AppError::internal("failed to generate qr code"))?;
|
||||||
|
|
||||||
|
Ok(Json(QrResponse {
|
||||||
|
qr_code: format!("data:image/png;base64,{}", qr_b64),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/totp/status")]
|
||||||
|
pub async fn get_totp_status(
|
||||||
|
user: Session,
|
||||||
|
svc: &State<AuthService>,
|
||||||
|
) -> ApiResult<Json<TotpStatus>> {
|
||||||
|
Ok(Json(
|
||||||
|
svc.get_totp_status(user.uid).await?
|
||||||
|
.then_some(TotpStatus::Enabled)
|
||||||
|
.unwrap_or(TotpStatus::Disabled),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[delete("/totp", data = "<form>")]
|
||||||
|
pub async fn disable_totp(
|
||||||
|
user: Session,
|
||||||
|
form: Json<PasswordAnd2fa>,
|
||||||
|
svc: &State<AuthService>,
|
||||||
|
) -> ApiResult<Json<AuthResponse>> {
|
||||||
|
let response = svc.disable_totp(user.uid, &form.password, &form.totp_code).await?;
|
||||||
|
Ok(Json(response))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[post("/totp/verify", data = "<body>")]
|
||||||
|
pub async fn verify_totp(
|
||||||
|
claims: Claims,
|
||||||
|
body: Json<TotpVerifyRequest>,
|
||||||
|
svc: &State<AuthService>,
|
||||||
|
) -> ApiResult<Json<AuthResponse>> {
|
||||||
|
// reject if they somehow got here with a full token
|
||||||
|
if claims.scope != TokenScope::TotpPending {
|
||||||
|
return Err(AppError::Forbidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = svc.login_totp(claims.sub as i64, &body.code).await?;
|
||||||
|
Ok(Json(response))
|
||||||
|
}
|
||||||
@@ -1,211 +0,0 @@
|
|||||||
use argon2::{
|
|
||||||
Argon2, PasswordHash, PasswordHasher, PasswordVerifier,
|
|
||||||
password_hash::{SaltString, rand_core::OsRng},
|
|
||||||
};
|
|
||||||
use jsonwebtoken::{EncodingKey, Header, encode};
|
|
||||||
use rocket::{
|
|
||||||
http::{CookieJar, Status},
|
|
||||||
response::{Redirect, status::BadRequest},
|
|
||||||
serde::json::Json,
|
|
||||||
time::OffsetDateTime,
|
|
||||||
};
|
|
||||||
use rocket_db_pools::Connection;
|
|
||||||
use rocket_dyn_templates::{Template, context};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
auth::session::{Claims, Session, TokenScope},
|
|
||||||
db::Postgres,
|
|
||||||
user::User,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
|
||||||
pub struct SignupCredentials {
|
|
||||||
pub email: String,
|
|
||||||
pub username: String,
|
|
||||||
pub password: String,
|
|
||||||
pub access_token: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
|
||||||
pub struct LoginCredentials {
|
|
||||||
pub username: String,
|
|
||||||
pub password: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
|
||||||
pub struct AuthResponse {
|
|
||||||
pub token: String,
|
|
||||||
pub totp_required: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[get("/signup")]
|
|
||||||
pub async fn signup_page() -> Template {
|
|
||||||
Template::render("signup", context!())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[post("/signup", data = "<cred>")]
|
|
||||||
pub async fn signup(
|
|
||||||
cred: Json<SignupCredentials>,
|
|
||||||
jar: &CookieJar<'_>,
|
|
||||||
mut db: Connection<Postgres>,
|
|
||||||
) -> Result<Json<AuthResponse>, Status> {
|
|
||||||
let token_id = AccessToken::validate(&cred.access_token, &mut db)
|
|
||||||
.await
|
|
||||||
.map_err(|_| Status::Unauthorized)?;
|
|
||||||
|
|
||||||
let salt = SaltString::generate(&mut OsRng);
|
|
||||||
let hashed = Argon2::default()
|
|
||||||
.hash_password(cred.password.as_bytes(), &salt)
|
|
||||||
.map_err(|_| Status::InternalServerError)?
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
let result = sqlx::query!(
|
|
||||||
"INSERT INTO users (email, username, pass_hash) VALUES ($1, $2, $3) RETURNING id",
|
|
||||||
cred.email,
|
|
||||||
cred.username,
|
|
||||||
hashed,
|
|
||||||
)
|
|
||||||
.fetch_one(&mut **db)
|
|
||||||
.await
|
|
||||||
.map_err(|_| Status::InternalServerError)?;
|
|
||||||
|
|
||||||
let jwt = Claims::new(result.id as usize, TokenScope::Full).encode();
|
|
||||||
|
|
||||||
token_id
|
|
||||||
.use_token(&mut db)
|
|
||||||
.await
|
|
||||||
.expect("unable to use access code");
|
|
||||||
|
|
||||||
Ok(Json(AuthResponse {
|
|
||||||
token: jwt,
|
|
||||||
totp_required: false,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[get("/login")]
|
|
||||||
pub async fn login_page() -> Template {
|
|
||||||
Template::render("login", context!())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[post("/login", data = "<cred>")]
|
|
||||||
pub async fn login(
|
|
||||||
mut db: Connection<Postgres>,
|
|
||||||
cred: Json<LoginCredentials>,
|
|
||||||
) -> Result<Json<AuthResponse>, Status> {
|
|
||||||
println!("e");
|
|
||||||
let row = sqlx::query!(
|
|
||||||
"SELECT id, pass_hash, twofa_enabled FROM users WHERE username = $1",
|
|
||||||
cred.username,
|
|
||||||
)
|
|
||||||
.fetch_one(&mut **db)
|
|
||||||
.await
|
|
||||||
.map_err(|_| Status::Unauthorized)?;
|
|
||||||
|
|
||||||
println!("ok");
|
|
||||||
|
|
||||||
// verify password as before
|
|
||||||
let parsed_hash = PasswordHash::new(&row.pass_hash).map_err(|_| Status::InternalServerError)?;
|
|
||||||
Argon2::default()
|
|
||||||
.verify_password(cred.password.as_bytes(), &parsed_hash)
|
|
||||||
.map_err(|_| Status::Unauthorized)?;
|
|
||||||
|
|
||||||
println!("ok2");
|
|
||||||
|
|
||||||
let user_id = row.id as usize;
|
|
||||||
|
|
||||||
// issue either a partial or full token depending on 2FA status
|
|
||||||
let (session, totp_required) = if row.twofa_enabled {
|
|
||||||
(Claims::new(user_id, TokenScope::TotpPending), true)
|
|
||||||
} else {
|
|
||||||
(Claims::new(user_id, TokenScope::Full), false)
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Json(AuthResponse {
|
|
||||||
token: session.encode(),
|
|
||||||
totp_required,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct AccessTokenForm {
|
|
||||||
pub name: String,
|
|
||||||
pub max_uses: usize,
|
|
||||||
pub expiry_date: usize,
|
|
||||||
pub start_date: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[get("/invite")]
|
|
||||||
pub async fn invite_page(_s: Session) -> Template {
|
|
||||||
Template::render("invite", context! {})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[post("/invite", data = "<form>")]
|
|
||||||
pub async fn generate_invite(
|
|
||||||
session: Session,
|
|
||||||
mut db: Connection<Postgres>,
|
|
||||||
form: Json<AccessTokenForm>,
|
|
||||||
) -> Result<String, Status> {
|
|
||||||
if form.start_date > form.expiry_date {
|
|
||||||
return Err(Status::BadRequest);
|
|
||||||
}
|
|
||||||
|
|
||||||
let code = Uuid::new_v4().to_string();
|
|
||||||
sqlx::query!(
|
|
||||||
"INSERT INTO access_codes (name, code, creator_id, max_uses, created_at, expires_at)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6) RETURNING id",
|
|
||||||
form.name,
|
|
||||||
code,
|
|
||||||
session.user_id as i32,
|
|
||||||
form.max_uses as i32,
|
|
||||||
OffsetDateTime::from_unix_timestamp_nanos(form.start_date as i128 * 1_000_000).unwrap(),
|
|
||||||
OffsetDateTime::from_unix_timestamp_nanos(form.expiry_date as i128 * 1_000_000).unwrap()
|
|
||||||
)
|
|
||||||
.fetch_one(&mut **db)
|
|
||||||
.await
|
|
||||||
.map_err(|_| Status::InternalServerError)?;
|
|
||||||
|
|
||||||
Ok(code)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct AccessToken {
|
|
||||||
id: i32,
|
|
||||||
_code: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AccessToken {
|
|
||||||
pub async fn validate(
|
|
||||||
token: &str,
|
|
||||||
db: &mut Connection<Postgres>,
|
|
||||||
) -> Result<AccessToken, String> {
|
|
||||||
match sqlx::query!(
|
|
||||||
"SELECT id FROM access_codes
|
|
||||||
WHERE code = $1
|
|
||||||
AND created_at < NOW()
|
|
||||||
AND expires_at > NOW()
|
|
||||||
AND uses < max_uses",
|
|
||||||
token
|
|
||||||
)
|
|
||||||
.fetch_one(&mut ***db)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(row) => Ok(AccessToken {
|
|
||||||
id: row.id,
|
|
||||||
_code: token.to_string(),
|
|
||||||
}),
|
|
||||||
Err(_) => Err(String::from("Invalid or Expired token!")),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn use_token(&self, db: &mut Connection<Postgres>) -> Result<(), String> {
|
|
||||||
sqlx::query!(
|
|
||||||
"UPDATE access_codes SET uses = uses + 1 WHERE id = $1",
|
|
||||||
self.id
|
|
||||||
)
|
|
||||||
.execute(&mut ***db)
|
|
||||||
.await
|
|
||||||
.map_err(|_| String::from("Invalid or Expired token!"))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
pub mod account;
|
|
||||||
pub mod profile;
|
|
||||||
pub mod session;
|
|
||||||
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 two_factor::{
|
|
||||||
confirm_totp, disable_totp, get_totp, get_totp_status, mfa_page, verify_totp,
|
|
||||||
};
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
use argon2::{
|
|
||||||
Argon2, PasswordHash, PasswordHasher, PasswordVerifier,
|
|
||||||
password_hash::{SaltString, rand_core::OsRng},
|
|
||||||
};
|
|
||||||
use rocket::{http::Status, serde::json::Json};
|
|
||||||
use rocket_db_pools::Connection;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
use crate::{auth::Session, db::Postgres, user::User};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct PasswordForm {
|
|
||||||
old_password: String,
|
|
||||||
new_password: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[post("/settings/password", data = "<form>")]
|
|
||||||
pub async fn change_password(
|
|
||||||
session: Session,
|
|
||||||
mut db: Connection<Postgres>,
|
|
||||||
form: Json<PasswordForm>,
|
|
||||||
) -> 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
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
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)?;
|
|
||||||
|
|
||||||
// old password is correct, so new one can be set.
|
|
||||||
let salt = SaltString::generate(&mut OsRng);
|
|
||||||
let hashed = Argon2::default()
|
|
||||||
.hash_password(form.new_password.as_bytes(), &salt)
|
|
||||||
.inspect_err(|e| tracing::error!("failed to hash password! {e}"))
|
|
||||||
.map_err(|_| Status::InternalServerError)?
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
user.set_pass_hash(hashed, &mut db)
|
|
||||||
.await
|
|
||||||
.inspect_err(|e| tracing::error!("{e}"))
|
|
||||||
.map_err(|_| Status::InternalServerError)?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize, Debug, Clone)]
|
|
||||||
pub struct DisplayNameForm {
|
|
||||||
pub display_name: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[post("/settings/display_name", data = "<new>")]
|
|
||||||
pub async fn change_display_name(
|
|
||||||
session: Session,
|
|
||||||
mut db: Connection<Postgres>,
|
|
||||||
new: Json<DisplayNameForm>,
|
|
||||||
) -> 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_display_name(new.display_name.clone(), &mut db)
|
|
||||||
.await
|
|
||||||
.inspect_err(|e| tracing::error!("{e}"))
|
|
||||||
.map_err(|_| Status::InternalServerError)?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@@ -1,286 +0,0 @@
|
|||||||
use rocket::{
|
|
||||||
Request,
|
|
||||||
http::Status,
|
|
||||||
outcome::{Outcome, try_outcome},
|
|
||||||
request::{self, FromRequest},
|
|
||||||
response::status::{self},
|
|
||||||
serde::json::Json,
|
|
||||||
};
|
|
||||||
use rocket_db_pools::Connection;
|
|
||||||
use rocket_dyn_templates::{Template, context};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use totp_rs::{Algorithm, Secret, TOTP};
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
auth::{
|
|
||||||
account::AuthResponse,
|
|
||||||
session::{Claims, Session, TokenScope},
|
|
||||||
},
|
|
||||||
db::Postgres,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Utility methods
|
|
||||||
|
|
||||||
pub fn totp_gen(user_id: usize, secret: &[u8]) -> Result<TOTP, String> {
|
|
||||||
TOTP::new(
|
|
||||||
Algorithm::SHA1,
|
|
||||||
6,
|
|
||||||
1,
|
|
||||||
30,
|
|
||||||
secret.to_owned(),
|
|
||||||
Some("chat.zxq5.dev".to_string()),
|
|
||||||
format!("{}", user_id),
|
|
||||||
)
|
|
||||||
.map_err(|_| String::from("Invalid Secret"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// pages
|
|
||||||
|
|
||||||
#[get("/totp")]
|
|
||||||
pub async fn mfa_page(_session: Session) -> Template {
|
|
||||||
Template::render("2fa", context!())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[post("/totp", data = "<form>")]
|
|
||||||
pub async fn confirm_totp(
|
|
||||||
mfa: TOTPSecret,
|
|
||||||
form: Json<TOTPSixDigitCode>,
|
|
||||||
mut db: Connection<Postgres>,
|
|
||||||
) -> Result<(), status::Custom<&'static str>> {
|
|
||||||
if form.code.len() != 6 || form.code.parse::<u32>().is_err() {
|
|
||||||
return Err(status::Custom(Status::BadRequest, "Invalid 6-digit code"));
|
|
||||||
}
|
|
||||||
|
|
||||||
println!("valid");
|
|
||||||
|
|
||||||
let totp = totp_gen(mfa.user_id, mfa.secret.as_bytes())
|
|
||||||
.map_err(|_| status::Custom(Status::InternalServerError, "TOTP Error"))?;
|
|
||||||
if !totp.check_current(&form.code).unwrap_or(false) {
|
|
||||||
return Err(status::Custom(Status::BadRequest, "Incorrect code"));
|
|
||||||
}
|
|
||||||
println!("correct");
|
|
||||||
|
|
||||||
if sqlx::query!(
|
|
||||||
"UPDATE users SET twofa_enabled = true WHERE id = $1",
|
|
||||||
mfa.user_id as i32
|
|
||||||
)
|
|
||||||
.execute(&mut **db)
|
|
||||||
.await
|
|
||||||
.is_err()
|
|
||||||
{
|
|
||||||
return Err(status::Custom(
|
|
||||||
Status::InternalServerError,
|
|
||||||
"unable to enable 2fa",
|
|
||||||
));
|
|
||||||
};
|
|
||||||
|
|
||||||
println!("enabled");
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[get("/totp.jpg")]
|
|
||||||
pub async fn get_totp(mfa: TOTPSecret) -> Option<Json<QrResponse>> {
|
|
||||||
let qr_b64 = totp_gen(mfa.user_id, mfa.secret.as_bytes())
|
|
||||||
.expect("Invalid TOTP")
|
|
||||||
.get_qr_base64()
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
Some(Json(QrResponse {
|
|
||||||
qr_code: format!("data:image/png;base64,{}", qr_b64),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub struct TOTPSixDigitCode {
|
|
||||||
code: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum TotpStatus {
|
|
||||||
Enabled,
|
|
||||||
Disabled,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct TOTPSecret {
|
|
||||||
user_id: usize,
|
|
||||||
secret: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
pub struct QrResponse {
|
|
||||||
qr_code: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[rocket::async_trait]
|
|
||||||
impl<'r> FromRequest<'r> for TOTPSecret {
|
|
||||||
type Error = ();
|
|
||||||
|
|
||||||
async fn from_request(request: &'r Request<'_>) -> request::Outcome<Self, Self::Error> {
|
|
||||||
let auth_header = request.headers().get_one("Authorization");
|
|
||||||
println!(
|
|
||||||
"TOTPSecret guard - Auth header present: {}",
|
|
||||||
auth_header.is_some()
|
|
||||||
);
|
|
||||||
|
|
||||||
let user = try_outcome!(request.guard::<Claims>().await);
|
|
||||||
println!(
|
|
||||||
"TOTPSecret guard - Claims ok, user: {}, scope: {:?}",
|
|
||||||
user.sub, user.scope
|
|
||||||
);
|
|
||||||
|
|
||||||
// only allow full tokens for TOTP setup
|
|
||||||
if user.scope != TokenScope::Full {
|
|
||||||
println!("TOTPSecret guard - rejected, scope is {:?}", user.scope);
|
|
||||||
return Outcome::Error((Status::Forbidden, ()));
|
|
||||||
}
|
|
||||||
|
|
||||||
let user = try_outcome!(request.guard::<Session>().await);
|
|
||||||
let mut pool = match request.guard::<Connection<Postgres>>().await {
|
|
||||||
Outcome::Success(pool) => pool,
|
|
||||||
_ => return Outcome::Error((Status::Unauthorized, ())),
|
|
||||||
};
|
|
||||||
|
|
||||||
let row = sqlx::query!(
|
|
||||||
"SELECT twofa_enabled, totp_secret FROM users WHERE id = $1",
|
|
||||||
user.user_id as i32
|
|
||||||
)
|
|
||||||
.fetch_one(&mut **pool)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
let (enabled, mut secret) = match row {
|
|
||||||
Ok(r) => (r.twofa_enabled, r.totp_secret),
|
|
||||||
Err(_) => return Outcome::Error((Status::Unauthorized, ())),
|
|
||||||
};
|
|
||||||
|
|
||||||
if secret.is_none() {
|
|
||||||
let new_secret = Secret::generate_secret().to_encoded().to_string();
|
|
||||||
sqlx::query!(
|
|
||||||
"UPDATE users SET totp_secret = $1 WHERE id = $2",
|
|
||||||
new_secret,
|
|
||||||
user.user_id as i32
|
|
||||||
)
|
|
||||||
.execute(&mut **pool)
|
|
||||||
.await
|
|
||||||
.ok();
|
|
||||||
secret = Some(new_secret);
|
|
||||||
}
|
|
||||||
|
|
||||||
Outcome::Success(TOTPSecret {
|
|
||||||
user_id: user.user_id,
|
|
||||||
secret: secret.unwrap(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TOTPSecret {
|
|
||||||
pub async fn enable(&self, db: &mut Connection<Postgres>) -> Result<(), ()> {
|
|
||||||
match sqlx::query!(
|
|
||||||
"UPDATE users SET twofa_enabled = true WHERE id = $1",
|
|
||||||
self.user_id as i32,
|
|
||||||
)
|
|
||||||
.execute(&mut ***db)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(_) => Ok(()),
|
|
||||||
Err(_) => Err(()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct TotpVerifyRequest {
|
|
||||||
pub code: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[get("/totp/status")]
|
|
||||||
pub async fn get_totp_status(
|
|
||||||
user: Session,
|
|
||||||
mut db: Connection<Postgres>,
|
|
||||||
) -> Result<Json<TotpStatus>, Status> {
|
|
||||||
Ok(Json(
|
|
||||||
if sqlx::query!(
|
|
||||||
"SELECT twofa_enabled FROM users WHERE id = $1",
|
|
||||||
user.user_id as i32,
|
|
||||||
)
|
|
||||||
.fetch_one(&mut **db)
|
|
||||||
.await
|
|
||||||
.map_err(|_| Status::NotFound)?
|
|
||||||
.twofa_enabled
|
|
||||||
{
|
|
||||||
TotpStatus::Enabled
|
|
||||||
} else {
|
|
||||||
TotpStatus::Disabled
|
|
||||||
},
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[delete("/totp")]
|
|
||||||
pub async fn disable_totp(
|
|
||||||
user: Session,
|
|
||||||
mut db: Connection<Postgres>,
|
|
||||||
) -> 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)
|
|
||||||
.await
|
|
||||||
.map_err(|_| Status::NotFound)?;
|
|
||||||
|
|
||||||
Ok(Json(AuthResponse {
|
|
||||||
token: Claims::new(user.user_id, TokenScope::Full).encode(),
|
|
||||||
totp_required: false,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[post("/totp/verify", data = "<body>")]
|
|
||||||
pub async fn verify_totp(
|
|
||||||
user: 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 {
|
|
||||||
return Err(Status::Forbidden);
|
|
||||||
}
|
|
||||||
|
|
||||||
println!("reached 2");
|
|
||||||
|
|
||||||
let row = sqlx::query!(
|
|
||||||
"SELECT totp_secret FROM users WHERE id = $1 AND twofa_enabled = TRUE",
|
|
||||||
user.sub
|
|
||||||
)
|
|
||||||
.fetch_one(&mut **db)
|
|
||||||
.await
|
|
||||||
.map_err(|_| Status::Unauthorized)?;
|
|
||||||
|
|
||||||
println!("reached 3");
|
|
||||||
|
|
||||||
let totp = totp_gen(
|
|
||||||
user.sub as usize,
|
|
||||||
row.totp_secret
|
|
||||||
.expect("user with 2fa enabled has no totp secret")
|
|
||||||
.as_bytes(),
|
|
||||||
)
|
|
||||||
.map_err(|_| Status::InternalServerError)?;
|
|
||||||
|
|
||||||
if !totp
|
|
||||||
.check_current(&body.code)
|
|
||||||
.map_err(|_| Status::InternalServerError)?
|
|
||||||
{
|
|
||||||
return Err(Status::Unauthorized);
|
|
||||||
}
|
|
||||||
|
|
||||||
println!("reached 5");
|
|
||||||
|
|
||||||
let claims = Claims::new(user.sub as usize, TokenScope::Full);
|
|
||||||
|
|
||||||
Ok(Json(AuthResponse {
|
|
||||||
token: claims.encode(),
|
|
||||||
totp_required: false,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
use clap::{Parser, Subcommand};
|
||||||
|
use sqlx::postgres::PgPoolOptions;
|
||||||
|
use std::time::Duration;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use crate::repo::user_repo::UserRepository;
|
||||||
|
use crate::repo::space_repo::SpaceRepository;
|
||||||
|
use crate::repo::channel_repo::ChannelRepository;
|
||||||
|
use crate::repo::{UserRepo, SpaceRepo, ChannelRepo};
|
||||||
|
use argon2::{
|
||||||
|
password_hash::{PasswordHasher, SaltString},
|
||||||
|
Argon2,
|
||||||
|
};
|
||||||
|
use rand::rngs::OsRng;
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(author, version, about, long_about = None)]
|
||||||
|
pub struct Cli {
|
||||||
|
#[command(subcommand)]
|
||||||
|
pub command: Option<Commands>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
pub enum Commands {
|
||||||
|
/// First-time setup for the server
|
||||||
|
Setup {
|
||||||
|
/// Admin username
|
||||||
|
#[arg(short, long)]
|
||||||
|
username: String,
|
||||||
|
|
||||||
|
/// Admin password
|
||||||
|
#[arg(short, long)]
|
||||||
|
password: String,
|
||||||
|
|
||||||
|
/// Default space name
|
||||||
|
#[arg(short, long, default_value = "Default Space")]
|
||||||
|
space: String,
|
||||||
|
|
||||||
|
/// Default channel name
|
||||||
|
#[arg(short, long, default_value = "general")]
|
||||||
|
channel: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn handle_cli() -> bool {
|
||||||
|
let cli = Cli::parse();
|
||||||
|
|
||||||
|
match cli.command {
|
||||||
|
Some(Commands::Setup { username, password, space, channel }) => {
|
||||||
|
if let Err(e) = run_setup(username, password, space, channel).await {
|
||||||
|
eprintln!("Setup failed: {}", e);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
println!("Setup completed successfully!");
|
||||||
|
true
|
||||||
|
}
|
||||||
|
None => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_setup(username: String, password: String, space_name: String, channel_name: String) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
dotenv::dotenv().ok();
|
||||||
|
let db_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
|
||||||
|
|
||||||
|
let pool = PgPoolOptions::new()
|
||||||
|
.max_connections(1)
|
||||||
|
.acquire_timeout(Duration::from_secs(5))
|
||||||
|
.connect(&db_url)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let user_repo = UserRepository::new(pool.clone());
|
||||||
|
let space_repo = SpaceRepository::new(pool.clone());
|
||||||
|
let channel_repo = ChannelRepository::new(pool.clone());
|
||||||
|
|
||||||
|
// 1. Create admin user
|
||||||
|
println!("Creating admin user: {}...", username);
|
||||||
|
|
||||||
|
let argon2 = Argon2::default();
|
||||||
|
let salt = SaltString::generate(&mut OsRng);
|
||||||
|
let passhash = argon2
|
||||||
|
.hash_password(password.as_bytes(), &salt)
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let user_id = user_repo.new_user("admin@localhost", &username, &passhash).await?;
|
||||||
|
user_repo.set_role(user_id, "admin").await?;
|
||||||
|
|
||||||
|
// 2. Create default space
|
||||||
|
println!("Creating default space: {}...", space_name);
|
||||||
|
let space_id = space_repo.create(&space_name, Some("Default space created during setup"), user_id).await?;
|
||||||
|
|
||||||
|
// 3. Create default channel
|
||||||
|
println!("Creating default channel: {}...", channel_name);
|
||||||
|
channel_repo.create(&channel_name, Some("Default channel"), space_id).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
use rocket_db_pools::{Database, deadpool_redis};
|
|
||||||
|
|
||||||
#[derive(Database)]
|
|
||||||
#[database("postgres_db")]
|
|
||||||
pub struct Postgres(sqlx::PgPool);
|
|
||||||
|
|
||||||
#[derive(Database)]
|
|
||||||
#[database("redis_cache")]
|
|
||||||
pub struct Redis(deadpool_redis::Pool);
|
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
// error.rs
|
||||||
|
use rocket::{http::Status, response::{self, Responder}, Request, Response};
|
||||||
|
use thiserror::Error;
|
||||||
|
use rocket_dyn_templates::Template;
|
||||||
|
use rocket::serde::Serialize;
|
||||||
|
|
||||||
|
#[derive(Error, Debug)]
|
||||||
|
pub enum AppError {
|
||||||
|
#[error("Not found")]
|
||||||
|
NotFound,
|
||||||
|
|
||||||
|
#[error("Unauthorized")]
|
||||||
|
Unauthorised(String),
|
||||||
|
|
||||||
|
#[error("Forbidden")]
|
||||||
|
Forbidden,
|
||||||
|
|
||||||
|
#[error("Bad request: {0}")]
|
||||||
|
BadRequest(String),
|
||||||
|
|
||||||
|
#[error("Database error: {0}")]
|
||||||
|
Database(#[from] sqlx::Error),
|
||||||
|
|
||||||
|
#[error("Internal error: {0}")]
|
||||||
|
Internal(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppError {
|
||||||
|
pub fn internal(msg: impl Into<String>) -> Self {
|
||||||
|
Self::Internal(msg.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn bad_request(msg: impl Into<String>) -> Self {
|
||||||
|
Self::BadRequest(msg.into())
|
||||||
|
}
|
||||||
|
pub fn unauthorised(msg: impl Into<String>) -> Self {
|
||||||
|
Self::Unauthorised(msg.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'r> Responder<'r, 'static> for AppError {
|
||||||
|
fn respond_to(self, _req: &'r Request<'_>) -> response::Result<'static> {
|
||||||
|
let status = match &self {
|
||||||
|
AppError::NotFound => Status::NotFound,
|
||||||
|
AppError::Unauthorised(_) => Status::Unauthorized,
|
||||||
|
AppError::Forbidden => Status::Forbidden,
|
||||||
|
AppError::BadRequest(_) => Status::BadRequest,
|
||||||
|
AppError::Database(_) => Status::InternalServerError,
|
||||||
|
AppError::Internal(_) => Status::InternalServerError,
|
||||||
|
};
|
||||||
|
|
||||||
|
// log internal errors
|
||||||
|
if status == Status::InternalServerError {
|
||||||
|
tracing::error!("Internal Server Error: {}", self);
|
||||||
|
}
|
||||||
|
|
||||||
|
Response::build()
|
||||||
|
.status(status)
|
||||||
|
.header(rocket::http::ContentType::Plain)
|
||||||
|
.sized_body(
|
||||||
|
self.to_string().len(),
|
||||||
|
std::io::Cursor::new(self.to_string())
|
||||||
|
)
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type ApiResult<T> = Result<T, AppError>;
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct ErrorContext {
|
||||||
|
error_code: u16,
|
||||||
|
error_message: &'static str,
|
||||||
|
additional_info: &'static str,
|
||||||
|
redirect: Option<RedirectContext>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct RedirectContext {
|
||||||
|
url: &'static str,
|
||||||
|
message: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[catch(404)]
|
||||||
|
pub async fn handle_404() -> Template {
|
||||||
|
Template::render(
|
||||||
|
"error",
|
||||||
|
ErrorContext {
|
||||||
|
error_code: 404,
|
||||||
|
error_message: "Not Found",
|
||||||
|
additional_info: "There's nothing here.",
|
||||||
|
redirect: Some(RedirectContext {
|
||||||
|
url: "/",
|
||||||
|
message: "Home",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[catch(401)]
|
||||||
|
pub async fn handle_401() -> Template {
|
||||||
|
Template::render(
|
||||||
|
"error",
|
||||||
|
ErrorContext {
|
||||||
|
error_code: 401,
|
||||||
|
error_message: "Unauthorised",
|
||||||
|
additional_info: "You are not authorised to access this resource.",
|
||||||
|
redirect: Some(RedirectContext {
|
||||||
|
url: "/login",
|
||||||
|
message: "Login",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[catch(default)]
|
||||||
|
pub async fn handle_default(status: Status, _request: &Request<'_>) -> Template {
|
||||||
|
Template::render(
|
||||||
|
"error",
|
||||||
|
ErrorContext {
|
||||||
|
error_code: status.code,
|
||||||
|
error_message: "Unknown Error",
|
||||||
|
additional_info: "I don't know what to do with this error.",
|
||||||
|
redirect: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
use rocket::{Request, http::Status};
|
|
||||||
use rocket_dyn_templates::Template;
|
|
||||||
use serde::Serialize;
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
struct ErrorContext {
|
|
||||||
error_code: u16,
|
|
||||||
error_message: &'static str,
|
|
||||||
additional_info: &'static str,
|
|
||||||
redirect: Option<RedirectContext>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
struct RedirectContext {
|
|
||||||
url: &'static str,
|
|
||||||
message: &'static str,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[catch(404)]
|
|
||||||
pub async fn handle_404() -> Template {
|
|
||||||
Template::render(
|
|
||||||
"error",
|
|
||||||
ErrorContext {
|
|
||||||
error_code: 404,
|
|
||||||
error_message: "Not Found",
|
|
||||||
additional_info: "There's nothing here.",
|
|
||||||
redirect: Some(RedirectContext {
|
|
||||||
url: "/",
|
|
||||||
message: "Home",
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[catch(401)]
|
|
||||||
pub async fn handle_401() -> Template {
|
|
||||||
Template::render(
|
|
||||||
"error",
|
|
||||||
ErrorContext {
|
|
||||||
error_code: 401,
|
|
||||||
error_message: "Unauthorised",
|
|
||||||
additional_info: "You are not authorised to access this resource.",
|
|
||||||
redirect: Some(RedirectContext {
|
|
||||||
url: "/login",
|
|
||||||
message: "Login",
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[catch(default)]
|
|
||||||
pub async fn handle_default(status: Status, _request: &Request<'_>) -> Template {
|
|
||||||
Template::render(
|
|
||||||
"error",
|
|
||||||
ErrorContext {
|
|
||||||
error_code: status.code,
|
|
||||||
error_message: "Unknown Error",
|
|
||||||
additional_info: "I don't know what to do with this error.",
|
|
||||||
redirect: None,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
#![deny(clippy::unwrap_used)]
|
||||||
|
#![warn(clippy::all, clippy::nursery, clippy::cargo, clippy::pedantic)]
|
||||||
|
|
||||||
|
#[macro_use]
|
||||||
|
extern crate rocket;
|
||||||
|
|
||||||
|
pub mod messenger;
|
||||||
|
pub mod api;
|
||||||
|
pub mod repo;
|
||||||
|
pub mod error;
|
||||||
|
pub mod svc;
|
||||||
|
pub mod model;
|
||||||
|
pub mod cli;
|
||||||
|
|
||||||
|
use crate::repo::{access_token_repo::AccessTokenRepo, Repo};
|
||||||
|
use crate::repo::message_repo::MessageRepository;
|
||||||
|
use crate::repo::user_repo::UserRepository;
|
||||||
|
use crate::repo::space_repo::SpaceRepository;
|
||||||
|
use crate::repo::channel_repo::ChannelRepository;
|
||||||
|
use crate::svc::auth_svc::AuthService;
|
||||||
|
use crate::svc::chat_svc::ChatService;
|
||||||
|
use crate::svc::settings_svc::SettingsService;
|
||||||
|
use crate::svc::user_svc::UserService;
|
||||||
|
use rocket::fs::{FileServer, NamedFile};
|
||||||
|
use rocket::http::Method;
|
||||||
|
use rocket_cors::{AllowedOrigins, CorsOptions};
|
||||||
|
use rocket_dyn_templates::Template;
|
||||||
|
use sqlx::postgres::PgPoolOptions;
|
||||||
|
use std::env;
|
||||||
|
use std::sync::{Arc, LazyLock};
|
||||||
|
use std::time::Duration;
|
||||||
|
use api::cdn;
|
||||||
|
use crate::svc::access_token_svc::AccessTokenService;
|
||||||
|
use crate::svc::llm_service::LlmService;
|
||||||
|
|
||||||
|
pub fn rocket() -> rocket::Rocket<rocket::Build> {
|
||||||
|
if std::env::var("RELEASE_MODE").unwrap_or_default() != "1" {
|
||||||
|
dotenv::dotenv().ok();
|
||||||
|
}
|
||||||
|
let db_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
|
||||||
|
|
||||||
|
|
||||||
|
let pool = PgPoolOptions::new()
|
||||||
|
.max_connections(25)
|
||||||
|
.min_connections(5)
|
||||||
|
.acquire_timeout(Duration::from_secs(5))
|
||||||
|
.connect_lazy(&db_url)
|
||||||
|
.expect("Failed to create database pool");
|
||||||
|
|
||||||
|
let user_repo = Arc::new(UserRepository::new(pool.clone()));
|
||||||
|
let message_repo = MessageRepository::new(pool.clone());
|
||||||
|
let token_repo = Arc::new(AccessTokenRepo::new(pool.clone()));
|
||||||
|
let space_repo: Arc<dyn repo::SpaceRepo> = Arc::new(SpaceRepository::new(pool.clone()));
|
||||||
|
let channel_repo: Arc<dyn repo::ChannelRepo> = Arc::new(ChannelRepository::new(pool.clone()));
|
||||||
|
let llm_service = LlmService::new();
|
||||||
|
let chat_service = ChatService::new(32, llm_service.clone(), message_repo.clone(), user_repo.clone(), channel_repo.clone(), space_repo.clone());
|
||||||
|
|
||||||
|
rocket_builder(user_repo, token_repo, space_repo, channel_repo, chat_service)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn rocket_builder(
|
||||||
|
user_repo: Arc<dyn repo::UserRepo>,
|
||||||
|
token_repo: Arc<dyn repo::AccessTokenRepoTrait>,
|
||||||
|
space_repo: Arc<dyn repo::SpaceRepo>,
|
||||||
|
channel_repo: Arc<dyn repo::ChannelRepo>,
|
||||||
|
chat_service: ChatService
|
||||||
|
) -> rocket::Rocket<rocket::Build> {
|
||||||
|
|
||||||
|
|
||||||
|
let cors = CorsOptions::default()
|
||||||
|
.allowed_origins(AllowedOrigins::all())
|
||||||
|
.allowed_methods(
|
||||||
|
vec![Method::Get, Method::Post, Method::Patch]
|
||||||
|
.into_iter()
|
||||||
|
.map(From::from)
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
.allow_credentials(true);
|
||||||
|
|
||||||
|
let access_token_svc = AccessTokenService::new(token_repo.clone());
|
||||||
|
let auth_service = AuthService::new(user_repo.clone(), access_token_svc.clone());
|
||||||
|
let settings_service = SettingsService::new(auth_service.clone(), user_repo.clone());
|
||||||
|
let user_service = UserService::new(user_repo.clone());
|
||||||
|
|
||||||
|
rocket::build()
|
||||||
|
.manage(chat_service)
|
||||||
|
.manage(auth_service)
|
||||||
|
.manage(settings_service)
|
||||||
|
.manage(user_service)
|
||||||
|
.manage(space_repo)
|
||||||
|
.manage(channel_repo)
|
||||||
|
.attach(cors.to_cors().unwrap())
|
||||||
|
.attach(Template::fairing())
|
||||||
|
.mount("/static", FileServer::from("static"))
|
||||||
|
.mount("/cdn", cdn::routes())
|
||||||
|
.mount(
|
||||||
|
"/",
|
||||||
|
routes![
|
||||||
|
favicon,
|
||||||
|
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.mount(
|
||||||
|
"/api",
|
||||||
|
routes![
|
||||||
|
cdn::upload_profile_pic,
|
||||||
|
api::profile::display_name,
|
||||||
|
|
||||||
|
// basic auth
|
||||||
|
api::auth::login,
|
||||||
|
api::auth::signup,
|
||||||
|
|
||||||
|
// 2fa
|
||||||
|
api::totp::confirm_totp,
|
||||||
|
api::totp::disable_totp,
|
||||||
|
api::totp::get_totp,
|
||||||
|
api::totp::get_totp_status,
|
||||||
|
api::totp::verify_totp,
|
||||||
|
|
||||||
|
// chat
|
||||||
|
api::chat::event_stream,
|
||||||
|
api::chat::post_message,
|
||||||
|
|
||||||
|
// user settings
|
||||||
|
api::settings::change_display_name,
|
||||||
|
api::settings::change_password,
|
||||||
|
api::settings::change_username,
|
||||||
|
api::settings::delete_account,
|
||||||
|
|
||||||
|
// spaces
|
||||||
|
api::space::list_spaces,
|
||||||
|
api::space::list_channels,
|
||||||
|
api::space::get_accessible_channels
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.register(
|
||||||
|
"/",
|
||||||
|
catchers![
|
||||||
|
error::handle_401,
|
||||||
|
error::handle_404,
|
||||||
|
error::handle_default,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/favicon.ico")]
|
||||||
|
pub async fn favicon() -> NamedFile {
|
||||||
|
NamedFile::open("static/favicon.ico").await.unwrap()
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user