diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 31e4493..554c6d6 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -28,6 +28,14 @@
android:host="chat.daemonlord.ru"
android:pathPrefix="/join"
android:scheme="https" />
+
+
(null)
+ private var pendingVerifyEmailToken by mutableStateOf(null)
+ private var pendingResetPasswordToken by mutableStateOf(null)
private var pendingNotificationChatId by mutableStateOf(null)
private var pendingNotificationMessageId by mutableStateOf(null)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
pendingInviteToken = intent.extractInviteToken()
+ pendingVerifyEmailToken = intent.extractVerifyEmailToken()
+ pendingResetPasswordToken = intent.extractResetPasswordToken()
val notificationPayload = intent.extractNotificationOpenPayload()
pendingNotificationChatId = notificationPayload?.first
pendingNotificationMessageId = notificationPayload?.second
@@ -36,6 +40,10 @@ class MainActivity : ComponentActivity() {
AppRoot(
inviteToken = pendingInviteToken,
onInviteTokenConsumed = { pendingInviteToken = null },
+ verifyEmailToken = pendingVerifyEmailToken,
+ onVerifyEmailTokenConsumed = { pendingVerifyEmailToken = null },
+ resetPasswordToken = pendingResetPasswordToken,
+ onResetPasswordTokenConsumed = { pendingResetPasswordToken = null },
notificationChatId = pendingNotificationChatId,
notificationMessageId = pendingNotificationMessageId,
onNotificationConsumed = {
@@ -52,6 +60,8 @@ class MainActivity : ComponentActivity() {
super.onNewIntent(intent)
setIntent(intent)
pendingInviteToken = intent.extractInviteToken() ?: pendingInviteToken
+ pendingVerifyEmailToken = intent.extractVerifyEmailToken() ?: pendingVerifyEmailToken
+ pendingResetPasswordToken = intent.extractResetPasswordToken() ?: pendingResetPasswordToken
val notificationPayload = intent.extractNotificationOpenPayload()
if (notificationPayload != null) {
pendingNotificationChatId = notificationPayload.first
@@ -64,6 +74,10 @@ class MainActivity : ComponentActivity() {
private fun AppRoot(
inviteToken: String?,
onInviteTokenConsumed: () -> Unit,
+ verifyEmailToken: String?,
+ onVerifyEmailTokenConsumed: () -> Unit,
+ resetPasswordToken: String?,
+ onResetPasswordTokenConsumed: () -> Unit,
notificationChatId: Long?,
notificationMessageId: Long?,
onNotificationConsumed: () -> Unit,
@@ -71,12 +85,30 @@ private fun AppRoot(
MessengerNavHost(
inviteToken = inviteToken,
onInviteTokenConsumed = onInviteTokenConsumed,
+ verifyEmailToken = verifyEmailToken,
+ onVerifyEmailTokenConsumed = onVerifyEmailTokenConsumed,
+ resetPasswordToken = resetPasswordToken,
+ onResetPasswordTokenConsumed = onResetPasswordTokenConsumed,
notificationChatId = notificationChatId,
notificationMessageId = notificationMessageId,
onNotificationConsumed = onNotificationConsumed,
)
}
+private fun Intent?.extractVerifyEmailToken(): String? {
+ val uri = this?.data ?: return null
+ val isVerifyPath = uri.pathSegments.contains("verify-email") || uri.path.equals("/verify-email", ignoreCase = true)
+ if (!isVerifyPath) return null
+ return uri.getQueryParameter("token")?.trim()?.takeIf { it.isNotBlank() }
+}
+
+private fun Intent?.extractResetPasswordToken(): String? {
+ val uri = this?.data ?: return null
+ val isResetPath = uri.pathSegments.contains("reset-password") || uri.path.equals("/reset-password", ignoreCase = true)
+ if (!isResetPath) return null
+ return uri.getQueryParameter("token")?.trim()?.takeIf { it.isNotBlank() }
+}
+
private fun Intent?.extractInviteToken(): String? {
val uri = this?.data ?: return null
val queryToken = uri.getQueryParameter("token")?.trim().orEmpty()
diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/auth/LoginScreen.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/auth/LoginScreen.kt
index 83b2157..569d936 100644
--- a/android/app/src/main/java/ru/daemonlord/messenger/ui/auth/LoginScreen.kt
+++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/auth/LoginScreen.kt
@@ -26,6 +26,8 @@ fun LoginScreen(
onEmailChanged: (String) -> Unit,
onPasswordChanged: (String) -> Unit,
onLoginClick: () -> Unit,
+ onOpenVerifyEmail: () -> Unit,
+ onOpenResetPassword: () -> Unit,
) {
Column(
modifier = Modifier
@@ -86,14 +88,19 @@ fun LoginScreen(
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 12.dp),
)
- } else {
- TextButton(
- onClick = {},
- enabled = false,
- modifier = Modifier.padding(top = 8.dp),
- ) {
- Text(text = "Use your existing backend account")
- }
+ }
+ TextButton(
+ onClick = onOpenVerifyEmail,
+ enabled = !state.isLoading,
+ modifier = Modifier.padding(top = 8.dp),
+ ) {
+ Text(text = "Verify email by token")
+ }
+ TextButton(
+ onClick = onOpenResetPassword,
+ enabled = !state.isLoading,
+ ) {
+ Text(text = "Forgot password")
}
}
}
diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/auth/reset/ResetPasswordScreen.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/auth/reset/ResetPasswordScreen.kt
new file mode 100644
index 0000000..4aac86e
--- /dev/null
+++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/auth/reset/ResetPasswordScreen.kt
@@ -0,0 +1,87 @@
+package ru.daemonlord.messenger.ui.auth.reset
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.safeDrawing
+import androidx.compose.foundation.layout.windowInsetsPadding
+import androidx.compose.material3.Button
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+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.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import ru.daemonlord.messenger.ui.account.AccountViewModel
+
+@Composable
+fun ResetPasswordRoute(
+ token: String?,
+ onBackToLogin: () -> Unit,
+ viewModel: AccountViewModel = hiltViewModel(),
+) {
+ val state by viewModel.uiState.collectAsStateWithLifecycle()
+ var email by remember { mutableStateOf("") }
+ var password by remember { mutableStateOf("") }
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .windowInsetsPadding(WindowInsets.safeDrawing)
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ Text("Password reset", style = MaterialTheme.typography.headlineSmall)
+ OutlinedTextField(
+ value = email,
+ onValueChange = { email = it },
+ label = { Text("Email") },
+ modifier = Modifier.fillMaxWidth(),
+ )
+ Button(
+ onClick = { viewModel.requestPasswordReset(email) },
+ modifier = Modifier.fillMaxWidth(),
+ enabled = !state.isSaving && email.isNotBlank(),
+ ) {
+ Text("Send reset link")
+ }
+ OutlinedTextField(
+ value = password,
+ onValueChange = { password = it },
+ label = { Text("New password") },
+ modifier = Modifier.fillMaxWidth(),
+ )
+ Button(
+ onClick = {
+ if (!token.isNullOrBlank()) {
+ viewModel.resetPassword(token = token, password = password)
+ }
+ },
+ modifier = Modifier.fillMaxWidth(),
+ enabled = !state.isSaving && !token.isNullOrBlank() && password.length >= 8,
+ ) {
+ Text("Reset with token")
+ }
+ if (state.isSaving) {
+ CircularProgressIndicator()
+ }
+ if (!state.message.isNullOrBlank()) {
+ Text(state.message!!, color = MaterialTheme.colorScheme.primary)
+ }
+ if (!state.errorMessage.isNullOrBlank()) {
+ Text(state.errorMessage!!, color = MaterialTheme.colorScheme.error)
+ }
+ Button(onClick = onBackToLogin, modifier = Modifier.fillMaxWidth()) {
+ Text("Back to login")
+ }
+ }
+}
diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/auth/verify/VerifyEmailScreen.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/auth/verify/VerifyEmailScreen.kt
new file mode 100644
index 0000000..06fe5f5
--- /dev/null
+++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/auth/verify/VerifyEmailScreen.kt
@@ -0,0 +1,77 @@
+package ru.daemonlord.messenger.ui.auth.verify
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.safeDrawing
+import androidx.compose.foundation.layout.windowInsetsPadding
+import androidx.compose.material3.Button
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import ru.daemonlord.messenger.ui.account.AccountViewModel
+
+@Composable
+fun VerifyEmailRoute(
+ token: String?,
+ onBackToLogin: () -> Unit,
+ viewModel: AccountViewModel = hiltViewModel(),
+) {
+ val state by viewModel.uiState.collectAsStateWithLifecycle()
+ var editableToken by remember(token) { mutableStateOf(token.orEmpty()) }
+
+ LaunchedEffect(token) {
+ if (!token.isNullOrBlank()) {
+ viewModel.verifyEmail(token)
+ }
+ }
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .windowInsetsPadding(WindowInsets.safeDrawing)
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ Text("Verify email", style = MaterialTheme.typography.headlineSmall)
+ OutlinedTextField(
+ value = editableToken,
+ onValueChange = { editableToken = it },
+ label = { Text("Verification token") },
+ modifier = Modifier.fillMaxWidth(),
+ )
+ Button(
+ onClick = { viewModel.verifyEmail(editableToken) },
+ enabled = !state.isSaving && editableToken.isNotBlank(),
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text("Verify")
+ }
+ if (state.isSaving) {
+ CircularProgressIndicator()
+ }
+ if (!state.message.isNullOrBlank()) {
+ Text(state.message!!, color = MaterialTheme.colorScheme.primary)
+ }
+ if (!state.errorMessage.isNullOrBlank()) {
+ Text(state.errorMessage!!, color = MaterialTheme.colorScheme.error)
+ }
+ Button(onClick = onBackToLogin, modifier = Modifier.fillMaxWidth()) {
+ Text("Back to login")
+ }
+ }
+}
diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/navigation/AppNavGraph.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/navigation/AppNavGraph.kt
index adbe2d5..798f5e7 100644
--- a/android/app/src/main/java/ru/daemonlord/messenger/ui/navigation/AppNavGraph.kt
+++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/navigation/AppNavGraph.kt
@@ -31,6 +31,8 @@ import androidx.navigation.compose.navigation
import androidx.navigation.compose.rememberNavController
import ru.daemonlord.messenger.ui.auth.AuthViewModel
import ru.daemonlord.messenger.ui.auth.LoginScreen
+import ru.daemonlord.messenger.ui.auth.reset.ResetPasswordRoute
+import ru.daemonlord.messenger.ui.auth.verify.VerifyEmailRoute
import ru.daemonlord.messenger.ui.chat.ChatRoute
import ru.daemonlord.messenger.ui.chats.ChatListRoute
import ru.daemonlord.messenger.ui.profile.ProfileRoute
@@ -39,6 +41,8 @@ import ru.daemonlord.messenger.ui.settings.SettingsRoute
private object Routes {
const val AuthGraph = "auth_graph"
const val Login = "login"
+ const val VerifyEmail = "verify_email"
+ const val ResetPassword = "reset_password"
const val Chats = "chats"
const val Settings = "settings"
const val Profile = "profile"
@@ -51,6 +55,10 @@ fun MessengerNavHost(
viewModel: AuthViewModel = hiltViewModel(),
inviteToken: String? = null,
onInviteTokenConsumed: () -> Unit = {},
+ verifyEmailToken: String? = null,
+ onVerifyEmailTokenConsumed: () -> Unit = {},
+ resetPasswordToken: String? = null,
+ onResetPasswordTokenConsumed: () -> Unit = {},
notificationChatId: Long? = null,
notificationMessageId: Long? = null,
onNotificationConsumed: () -> Unit = {},
@@ -72,10 +80,26 @@ fun MessengerNavHost(
}
}
- LaunchedEffect(uiState.isCheckingSession, uiState.isAuthenticated, notificationChatId, notificationMessageId) {
+ LaunchedEffect(uiState.isCheckingSession, uiState.isAuthenticated, verifyEmailToken, resetPasswordToken, notificationChatId, notificationMessageId) {
if (uiState.isCheckingSession) {
return@LaunchedEffect
}
+ if (!uiState.isAuthenticated && !verifyEmailToken.isNullOrBlank()) {
+ navController.navigate("${Routes.VerifyEmail}?token=$verifyEmailToken") {
+ popUpTo(navController.graph.findStartDestination().id) { inclusive = true }
+ launchSingleTop = true
+ }
+ onVerifyEmailTokenConsumed()
+ return@LaunchedEffect
+ }
+ if (!uiState.isAuthenticated && !resetPasswordToken.isNullOrBlank()) {
+ navController.navigate("${Routes.ResetPassword}?token=$resetPasswordToken") {
+ popUpTo(navController.graph.findStartDestination().id) { inclusive = true }
+ launchSingleTop = true
+ }
+ onResetPasswordTokenConsumed()
+ return@LaunchedEffect
+ }
if (uiState.isAuthenticated) {
if (notificationChatId != null) {
navController.navigate("${Routes.Chat}/$notificationChatId") {
@@ -120,11 +144,45 @@ fun MessengerNavHost(
onEmailChanged = viewModel::onEmailChanged,
onPasswordChanged = viewModel::onPasswordChanged,
onLoginClick = viewModel::login,
+ onOpenVerifyEmail = { navController.navigate(Routes.VerifyEmail) },
+ onOpenResetPassword = { navController.navigate(Routes.ResetPassword) },
)
}
}
}
+ composable(
+ route = "${Routes.VerifyEmail}?token={token}",
+ arguments = listOf(
+ navArgument("token") {
+ type = NavType.StringType
+ nullable = true
+ defaultValue = null
+ }
+ ),
+ ) { entry ->
+ VerifyEmailRoute(
+ token = entry.arguments?.getString("token"),
+ onBackToLogin = { navController.navigate(Routes.Login) },
+ )
+ }
+
+ composable(
+ route = "${Routes.ResetPassword}?token={token}",
+ arguments = listOf(
+ navArgument("token") {
+ type = NavType.StringType
+ nullable = true
+ defaultValue = null
+ }
+ ),
+ ) { entry ->
+ ResetPasswordRoute(
+ token = entry.arguments?.getString("token"),
+ onBackToLogin = { navController.navigate(Routes.Login) },
+ )
+ }
+
composable(route = Routes.Chats) {
ChatListRoute(
inviteToken = inviteToken,