From e4a76b27ae0505dda5a5e2e9408c571624e36f66 Mon Sep 17 00:00:00 2001 From: Jan Tennert Date: Sat, 14 Sep 2024 18:59:10 +0200 Subject: [PATCH] Add password recovery to chat sample --- sample/chat-demo-mpp/README.md | 4 +- .../common/di/platformGoTrueConfig.kt | 2 + .../jan/supabase/common/ChatViewModel.kt | 104 +++++++++--------- .../jan/supabase/common/di/netModule.kt | 3 + .../jan/supabase/common/di/supabaseModule.kt | 9 +- .../github/jan/supabase/common/net/AuthApi.kt | 78 +++++++++++++ .../jan/supabase/common/net/MessageApi.kt | 8 +- .../common/ui/components/MessageCard.kt | 2 +- .../common/ui/components/OTPDialog.kt | 60 ++++++++++ .../ui/components/PasswordChangeDialog.kt | 61 ++++++++++ .../ui/components/PasswordRecoverDialog.kt | 48 ++++++++ .../supabase/common/ui/screen/ChatScreen.kt | 63 +++++++++-- .../supabase/common/ui/screen/LoginScreen.kt | 45 +++++++- 13 files changed, 413 insertions(+), 74 deletions(-) create mode 100644 sample/chat-demo-mpp/common/src/commonMain/kotlin/io/github/jan/supabase/common/net/AuthApi.kt create mode 100644 sample/chat-demo-mpp/common/src/commonMain/kotlin/io/github/jan/supabase/common/ui/components/OTPDialog.kt create mode 100644 sample/chat-demo-mpp/common/src/commonMain/kotlin/io/github/jan/supabase/common/ui/components/PasswordChangeDialog.kt create mode 100644 sample/chat-demo-mpp/common/src/commonMain/kotlin/io/github/jan/supabase/common/ui/components/PasswordRecoverDialog.kt diff --git a/sample/chat-demo-mpp/README.md b/sample/chat-demo-mpp/README.md index c870b4b56..1b3c82632 100644 --- a/sample/chat-demo-mpp/README.md +++ b/sample/chat-demo-mpp/README.md @@ -4,7 +4,9 @@ This is a demo of a chat app using Compose Multiplatform, Koin and supabase-kt. **Available platforms:** Android, iOS, Desktop, JS Canvas -**Modules used:** Realtime, GoTrue, Postgrest, Compose Auth UI +**Modules used:** Realtime, Auth*, Postgrest, Compose Auth UI + +* Integrated flows: Password, Google login & password recovery https://user-images.githubusercontent.com/26686035/216710629-d809ff58-cd3b-449f-877f-4c6c773daec4.mp4 diff --git a/sample/chat-demo-mpp/common/src/androidMain/kotlin/io/github/jan/supabase/common/di/platformGoTrueConfig.kt b/sample/chat-demo-mpp/common/src/androidMain/kotlin/io/github/jan/supabase/common/di/platformGoTrueConfig.kt index 119060501..bc6c93e53 100644 --- a/sample/chat-demo-mpp/common/src/androidMain/kotlin/io/github/jan/supabase/common/di/platformGoTrueConfig.kt +++ b/sample/chat-demo-mpp/common/src/androidMain/kotlin/io/github/jan/supabase/common/di/platformGoTrueConfig.kt @@ -1,8 +1,10 @@ package io.github.jan.supabase.common.di import io.github.jan.supabase.gotrue.AuthConfig +import io.github.jan.supabase.gotrue.ExternalAuthAction actual fun AuthConfig.platformGoTrueConfig() { scheme = "io.jan.supabase" host = "login" + defaultExternalAuthAction = ExternalAuthAction.CustomTabs() } \ No newline at end of file diff --git a/sample/chat-demo-mpp/common/src/commonMain/kotlin/io/github/jan/supabase/common/ChatViewModel.kt b/sample/chat-demo-mpp/common/src/commonMain/kotlin/io/github/jan/supabase/common/ChatViewModel.kt index 6e0100c8c..37b0e1d2c 100644 --- a/sample/chat-demo-mpp/common/src/commonMain/kotlin/io/github/jan/supabase/common/ChatViewModel.kt +++ b/sample/chat-demo-mpp/common/src/commonMain/kotlin/io/github/jan/supabase/common/ChatViewModel.kt @@ -3,23 +3,18 @@ package io.github.jan.supabase.common import co.touchlab.kermit.Logger import io.github.jan.supabase.SupabaseClient +import io.github.jan.supabase.common.net.AuthApi import io.github.jan.supabase.common.net.Message import io.github.jan.supabase.common.net.MessageApi -import io.github.jan.supabase.gotrue.auth -import io.github.jan.supabase.gotrue.providers.Google -import io.github.jan.supabase.gotrue.providers.builtin.Email -import io.github.jan.supabase.realtime.PostgresAction -import io.github.jan.supabase.realtime.RealtimeChannel -import io.github.jan.supabase.realtime.decodeRecord -import io.github.jan.supabase.realtime.postgresChangeFlow +import io.github.jan.supabase.gotrue.SessionStatus import io.github.jan.supabase.realtime.realtime import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import kotlinx.serialization.json.int -import kotlinx.serialization.json.jsonPrimitive expect open class MPViewModel() { @@ -28,27 +23,25 @@ expect open class MPViewModel() { } class ChatViewModel( val supabaseClient: SupabaseClient, - private val realtimeChannel: RealtimeChannel, - private val messageApi: MessageApi + private val messageApi: MessageApi, + private val authApi: AuthApi ) : MPViewModel() { - val sessionStatus = supabaseClient.auth.sessionStatus - val loginAlert = MutableStateFlow(null) + val sessionStatus = authApi.sessionStatus().stateIn(coroutineScope, SharingStarted.Eagerly, SessionStatus.NotAuthenticated(false)) + val alert = MutableStateFlow(null) val messages = MutableStateFlow>(emptyList()) + val passwordReset = MutableStateFlow(false) //Auth fun signUp(email: String, password: String) { coroutineScope.launch { kotlin.runCatching { - supabaseClient.auth.signUpWith(Email) { - this.email = email - this.password = password - } + authApi.signUp(email, password) }.onSuccess { - loginAlert.value = "Successfully registered! Check your E-Mail to verify your account." + alert.value = "Successfully registered! Check your E-Mail to verify your account." }.onFailure { - loginAlert.value = "There was an error while registering: ${it.message}" + alert.value = "There was an error while registering: ${it.message}" } } } @@ -56,13 +49,10 @@ class ChatViewModel( fun login(email: String, password: String) { coroutineScope.launch { kotlin.runCatching { - supabaseClient.auth.signInWith(Email) { - this.email = email - this.password = password - } + authApi.signIn(email, password) }.onFailure { it.printStackTrace() - loginAlert.value = "There was an error while logging in. Check your credentials and try again." + alert.value = "There was an error while logging in. Check your credentials and try again." } } } @@ -70,47 +60,63 @@ class ChatViewModel( fun loginWithGoogle() { coroutineScope.launch { kotlin.runCatching { - supabaseClient.auth.signInWith(Google) + authApi.signInWithGoogle() } } } - fun logout() { + fun loginWithOTP(email: String, code: String, reset: Boolean) { coroutineScope.launch { kotlin.runCatching { - supabaseClient.auth.signOut() - messages.value = emptyList() + authApi.verifyOtp(email, code) + }.onSuccess { + passwordReset.value = reset + }.onFailure { + alert.value = "There was an error while verifying the OTP: ${it.message}" } } } - //Realtime - fun connectToRealtime() { + fun resetPassword(email: String) { coroutineScope.launch { kotlin.runCatching { - realtimeChannel.postgresChangeFlow("public") { - table = "messages" - }.onEach { - when(it) { - is PostgresAction.Delete -> messages.value = messages.value.filter { message -> message.id != it.oldRecord["id"]!!.jsonPrimitive.int } - is PostgresAction.Insert -> messages.value = messages.value + it.decodeRecord() - is PostgresAction.Select -> error("Select should not be possible") - is PostgresAction.Update -> error("Update should not be possible") - } - }.launchIn(coroutineScope) - - realtimeChannel.subscribe() + authApi.resetPassword(email) + } + } + } + fun changePassword(password: String) { + coroutineScope.launch { + kotlin.runCatching { + authApi.changePassword(password) + }.onSuccess { + alert.value = "Password changed successfully!" }.onFailure { - it.printStackTrace() + alert.value = "There was an error while changing the password: ${it.message}" } } } - fun disconnectFromRealtime() { + fun logout() { coroutineScope.launch { kotlin.runCatching { - supabaseClient.realtime.disconnect() + authApi.signOut() + messages.value = emptyList() + } + } + } + + //Realtime + fun retrieveMessages() { + coroutineScope.launch { + kotlin.runCatching { + messageApi.retrieveMessages() + .onEach { + messages.value = it + } + .launchIn(coroutineScope) + }.onFailure { + Logger.e(it) { "Error while retrieving messages" } } } } @@ -136,14 +142,10 @@ class ChatViewModel( } } - fun retrieveMessages() { + fun disconnectFromRealtime() { coroutineScope.launch { kotlin.runCatching { - messageApi.retrieveMessages() - }.onSuccess { - messages.value = it - }.onFailure { - Logger.e(it) { "Error while retrieving messages" } + supabaseClient.realtime.removeAllChannels() } } } diff --git a/sample/chat-demo-mpp/common/src/commonMain/kotlin/io/github/jan/supabase/common/di/netModule.kt b/sample/chat-demo-mpp/common/src/commonMain/kotlin/io/github/jan/supabase/common/di/netModule.kt index 9e109bdc1..b698a6c60 100644 --- a/sample/chat-demo-mpp/common/src/commonMain/kotlin/io/github/jan/supabase/common/di/netModule.kt +++ b/sample/chat-demo-mpp/common/src/commonMain/kotlin/io/github/jan/supabase/common/di/netModule.kt @@ -1,9 +1,12 @@ package io.github.jan.supabase.common.di +import io.github.jan.supabase.common.net.AuthApi +import io.github.jan.supabase.common.net.AuthApiImpl import io.github.jan.supabase.common.net.MessageApi import io.github.jan.supabase.common.net.MessageApiImpl import org.koin.dsl.module val netModule = module { single { MessageApiImpl(get()) } + single { AuthApiImpl(get()) } } \ No newline at end of file diff --git a/sample/chat-demo-mpp/common/src/commonMain/kotlin/io/github/jan/supabase/common/di/supabaseModule.kt b/sample/chat-demo-mpp/common/src/commonMain/kotlin/io/github/jan/supabase/common/di/supabaseModule.kt index 75a1cc94d..05b90f918 100644 --- a/sample/chat-demo-mpp/common/src/commonMain/kotlin/io/github/jan/supabase/common/di/supabaseModule.kt +++ b/sample/chat-demo-mpp/common/src/commonMain/kotlin/io/github/jan/supabase/common/di/supabaseModule.kt @@ -1,12 +1,12 @@ package io.github.jan.supabase.common.di -import io.github.jan.supabase.SupabaseClient import io.github.jan.supabase.createSupabaseClient import io.github.jan.supabase.gotrue.Auth import io.github.jan.supabase.gotrue.AuthConfig +import io.github.jan.supabase.gotrue.FlowType +import io.github.jan.supabase.logging.LogLevel import io.github.jan.supabase.postgrest.Postgrest import io.github.jan.supabase.realtime.Realtime -import io.github.jan.supabase.realtime.channel import org.koin.dsl.module expect fun AuthConfig.platformGoTrueConfig() @@ -17,14 +17,13 @@ val supabaseModule = module { supabaseUrl = "YOUR_URL", supabaseKey = "YOUR_KEY" ) { + defaultLogLevel = LogLevel.DEBUG install(Postgrest) install(Auth) { platformGoTrueConfig() + flowType = FlowType.PKCE } install(Realtime) } } - single { - get().channel("messages") - } } \ No newline at end of file diff --git a/sample/chat-demo-mpp/common/src/commonMain/kotlin/io/github/jan/supabase/common/net/AuthApi.kt b/sample/chat-demo-mpp/common/src/commonMain/kotlin/io/github/jan/supabase/common/net/AuthApi.kt new file mode 100644 index 000000000..71653fb6e --- /dev/null +++ b/sample/chat-demo-mpp/common/src/commonMain/kotlin/io/github/jan/supabase/common/net/AuthApi.kt @@ -0,0 +1,78 @@ +package io.github.jan.supabase.common.net + +import io.github.jan.supabase.SupabaseClient +import io.github.jan.supabase.gotrue.OtpType +import io.github.jan.supabase.gotrue.SessionStatus +import io.github.jan.supabase.gotrue.auth +import io.github.jan.supabase.gotrue.providers.Google +import io.github.jan.supabase.gotrue.providers.builtin.Email +import kotlinx.coroutines.flow.Flow + +sealed interface AuthApi { + + suspend fun signIn(email: String, password: String) + + suspend fun signUp(email: String, password: String) + + suspend fun signInWithGoogle() + + suspend fun verifyOtp(email: String, otp: String) + + suspend fun signOut() + + suspend fun resetPassword(email: String) + + suspend fun changePassword(newPassword: String) + + fun sessionStatus(): Flow + +} + +internal class AuthApiImpl( + private val client: SupabaseClient +) : AuthApi { + + private val auth = client.auth + + override fun sessionStatus(): Flow { + return auth.sessionStatus + } + + override suspend fun verifyOtp(email: String, otp: String) { + auth.verifyEmailOtp(OtpType.Email.EMAIL, email, otp) + } + + override suspend fun signInWithGoogle() { + auth.signInWith(Google) + } + + override suspend fun signIn(email: String, password: String) { + auth.signInWith(Email) { + this.email = email + this.password = password + } + } + + override suspend fun signUp(email: String, password: String) { + auth.signUpWith(Email) { + this.email = email + this.password = password + } + } + + override suspend fun changePassword(newPassword: String) { + auth.updateUser { + this.password = newPassword + } + } + + override suspend fun signOut() { + auth.signOut() + } + + override suspend fun resetPassword(email: String) { + auth.resetPasswordForEmail(email) + } + + +} \ No newline at end of file diff --git a/sample/chat-demo-mpp/common/src/commonMain/kotlin/io/github/jan/supabase/common/net/MessageApi.kt b/sample/chat-demo-mpp/common/src/commonMain/kotlin/io/github/jan/supabase/common/net/MessageApi.kt index 6161333c2..061cdea8d 100644 --- a/sample/chat-demo-mpp/common/src/commonMain/kotlin/io/github/jan/supabase/common/net/MessageApi.kt +++ b/sample/chat-demo-mpp/common/src/commonMain/kotlin/io/github/jan/supabase/common/net/MessageApi.kt @@ -1,8 +1,11 @@ package io.github.jan.supabase.common.net import io.github.jan.supabase.SupabaseClient +import io.github.jan.supabase.annotations.SupabaseExperimental import io.github.jan.supabase.gotrue.auth import io.github.jan.supabase.postgrest.postgrest +import io.github.jan.supabase.realtime.selectAsFlow +import kotlinx.coroutines.flow.Flow import kotlinx.datetime.Instant import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -21,7 +24,7 @@ data class Message( sealed interface MessageApi { - suspend fun retrieveMessages(): List + suspend fun retrieveMessages(): Flow> suspend fun createMessage(content: String): Message @@ -35,7 +38,8 @@ internal class MessageApiImpl( private val table = client.postgrest["messages"] - override suspend fun retrieveMessages(): List = table.select().decodeList() + @OptIn(SupabaseExperimental::class) + override suspend fun retrieveMessages(): Flow> = table.selectAsFlow(Message::id) override suspend fun createMessage(content: String): Message { val user = (client.auth.currentSessionOrNull() ?: error("No session available")).user ?: error("No user available") diff --git a/sample/chat-demo-mpp/common/src/commonMain/kotlin/io/github/jan/supabase/common/ui/components/MessageCard.kt b/sample/chat-demo-mpp/common/src/commonMain/kotlin/io/github/jan/supabase/common/ui/components/MessageCard.kt index f5297f713..6a13cff02 100644 --- a/sample/chat-demo-mpp/common/src/commonMain/kotlin/io/github/jan/supabase/common/ui/components/MessageCard.kt +++ b/sample/chat-demo-mpp/common/src/commonMain/kotlin/io/github/jan/supabase/common/ui/components/MessageCard.kt @@ -28,7 +28,7 @@ fun MessageCard(message: Message, own: Boolean, modifier: Modifier, onDelete: () ElevatedCard(modifier = Modifier.widthIn(max = 200.dp), colors = CardDefaults.elevatedCardColors(containerColor = backgroundColor)) { Column(modifier = Modifier.padding(12.dp)) { Text(message.content) - Text(message.creatorId, fontSize = 8.sp, modifier = Modifier.padding(top = 4.dp)) + Text("UID: " + message.creatorId, fontSize = 8.sp, modifier = Modifier.padding(top = 4.dp)) } } if(own) { diff --git a/sample/chat-demo-mpp/common/src/commonMain/kotlin/io/github/jan/supabase/common/ui/components/OTPDialog.kt b/sample/chat-demo-mpp/common/src/commonMain/kotlin/io/github/jan/supabase/common/ui/components/OTPDialog.kt new file mode 100644 index 000000000..0ab4883be --- /dev/null +++ b/sample/chat-demo-mpp/common/src/commonMain/kotlin/io/github/jan/supabase/common/ui/components/OTPDialog.kt @@ -0,0 +1,60 @@ +package io.github.jan.supabase.common.ui.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.text.input.KeyboardType + +sealed interface OTPDialogState { + data object Invisible : OTPDialogState + data class Visible(val title: String = "Sign in using an OTP", val resetFlow: Boolean = false, val email: String? = null) : OTPDialogState +} + +@Composable +fun OTPDialog( + email: String? = null, + title: String, + onDismiss: () -> Unit, + onConfirm: (email: String, code: String) -> Unit +) { + var code by remember { mutableStateOf("") } + var otpEmail by remember { mutableStateOf("") } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { + Column { + if(email == null) { + OutlinedTextField(otpEmail, { otpEmail = it }, label = { Text("Email") }, singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)) + } else { + Text("Please enter the code sent to $email.") + } + OutlinedTextField(code, { code = it }, label = { Text("Code") }, singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)) + } + }, + confirmButton = { + TextButton( + onClick = { + onConfirm(email ?: otpEmail, code) + onDismiss() + }, + enabled = (email ?: otpEmail).isNotBlank() && code.isNotBlank() + ) { + Text("Confirm") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Dismiss") + } + } + ) +} diff --git a/sample/chat-demo-mpp/common/src/commonMain/kotlin/io/github/jan/supabase/common/ui/components/PasswordChangeDialog.kt b/sample/chat-demo-mpp/common/src/commonMain/kotlin/io/github/jan/supabase/common/ui/components/PasswordChangeDialog.kt new file mode 100644 index 000000000..638fb015c --- /dev/null +++ b/sample/chat-demo-mpp/common/src/commonMain/kotlin/io/github/jan/supabase/common/ui/components/PasswordChangeDialog.kt @@ -0,0 +1,61 @@ +package io.github.jan.supabase.common.ui.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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 io.github.jan.supabase.compose.auth.ui.AuthForm +import io.github.jan.supabase.compose.auth.ui.LocalAuthState +import io.github.jan.supabase.compose.auth.ui.annotations.AuthUiExperimental +import io.github.jan.supabase.compose.auth.ui.password.OutlinedPasswordField + +@OptIn(AuthUiExperimental::class, ExperimentalMaterial3Api::class) +@Composable +fun PasswordChangeDialog( + onDismiss: () -> Unit, + onConfirm: (newPassword: String) -> Unit +) { + var password by remember { mutableStateOf("") } + AuthForm { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Password change") }, + text = { + Column { + Text("Please enter your new password.") + Spacer(Modifier.height(8.dp)) + OutlinedPasswordField( + value = password, + onValueChange = { password = it }, + ) + } + }, + confirmButton = { + TextButton( + onClick = { + onConfirm(password) + onDismiss() + }, + enabled = LocalAuthState.current.validForm + ) { + Text("Confirm") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Dismiss") + } + } + ) + } +} \ No newline at end of file diff --git a/sample/chat-demo-mpp/common/src/commonMain/kotlin/io/github/jan/supabase/common/ui/components/PasswordRecoverDialog.kt b/sample/chat-demo-mpp/common/src/commonMain/kotlin/io/github/jan/supabase/common/ui/components/PasswordRecoverDialog.kt new file mode 100644 index 000000000..f2d561230 --- /dev/null +++ b/sample/chat-demo-mpp/common/src/commonMain/kotlin/io/github/jan/supabase/common/ui/components/PasswordRecoverDialog.kt @@ -0,0 +1,48 @@ +package io.github.jan.supabase.common.ui.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.text.input.KeyboardType + +@Composable +fun PasswordRecoveryDialog( + onDismiss: () -> Unit, + onConfirm: (email: String) -> Unit +) { + var email by remember { mutableStateOf("") } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Password recovery") }, + text = { + Column { + Text("Please enter your new password.") + OutlinedTextField(email, { email = it }, label = { Text("Email") }, singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)) + } + }, + confirmButton = { + TextButton( + onClick = { + onConfirm(email) + onDismiss() + }, + enabled = email.isNotBlank() + ) { + Text("Confirm") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Dismiss") + } + } + ) +} diff --git a/sample/chat-demo-mpp/common/src/commonMain/kotlin/io/github/jan/supabase/common/ui/screen/ChatScreen.kt b/sample/chat-demo-mpp/common/src/commonMain/kotlin/io/github/jan/supabase/common/ui/screen/ChatScreen.kt index 1ac492751..b813e384a 100644 --- a/sample/chat-demo-mpp/common/src/commonMain/kotlin/io/github/jan/supabase/common/ui/screen/ChatScreen.kt +++ b/sample/chat-demo-mpp/common/src/commonMain/kotlin/io/github/jan/supabase/common/ui/screen/ChatScreen.kt @@ -9,13 +9,15 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Send -import androidx.compose.material3.Button -import androidx.compose.material3.Divider +import androidx.compose.material.icons.automirrored.filled.Logout +import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material3.AlertDialog import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.TextField import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -31,6 +33,7 @@ import io.github.jan.supabase.CurrentPlatformTarget import io.github.jan.supabase.PlatformTarget import io.github.jan.supabase.common.ChatViewModel import io.github.jan.supabase.common.ui.components.MessageCard +import io.github.jan.supabase.common.ui.components.PasswordChangeDialog import io.github.jan.supabase.gotrue.user.UserInfo import kotlinx.coroutines.flow.map @@ -40,11 +43,12 @@ fun ChatScreen(viewModel: ChatViewModel, user: UserInfo) { val messages by viewModel.messages.map { it.reversed() }.collectAsState(emptyList()) var message by remember { mutableStateOf("") } val ownId = user.id + val reset by viewModel.passwordReset.collectAsState() + val alert by viewModel.alert.collectAsState() LaunchedEffect(Unit) { if(CurrentPlatformTarget in listOf(PlatformTarget.JVM, PlatformTarget.JS, PlatformTarget.ANDROID)) { viewModel.retrieveMessages() - viewModel.connectToRealtime() } } @@ -62,7 +66,7 @@ fun ChatScreen(viewModel: ChatViewModel, user: UserInfo) { } } } - Divider(thickness = 1.dp, modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)) + HorizontalDivider(thickness = 1.dp, modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)) Row(verticalAlignment = Alignment.CenterVertically) { TextField( value = message, @@ -73,18 +77,55 @@ fun ChatScreen(viewModel: ChatViewModel, user: UserInfo) { viewModel.createMessage(message) message = "" }, enabled = message.isNotBlank()) { - Icon(Icons.Filled.Send, "Send") + Icon(Icons.AutoMirrored.Filled.Send, "Send") } } } Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.TopStart) { - Button({ - viewModel.disconnectFromRealtime() - viewModel.logout() - }, enabled = true, modifier = Modifier.padding(5.dp)) { - Text("Logout") + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton({ + viewModel.disconnectFromRealtime() + viewModel.logout() + }, modifier = Modifier.padding(5.dp)) { + Icon(Icons.AutoMirrored.Filled.Logout, "Logout") + } + TextButton( + onClick = { + viewModel.passwordReset.value = true + } + ) { + Text("Reset password") + } } } + if(reset) { + PasswordChangeDialog( + onDismiss = { viewModel.passwordReset.value = false }, + onConfirm = { viewModel.changePassword(it) } + ) + } + + if(alert != null) { + AlertDialog( + onDismissRequest = { + viewModel.alert.value = null + }, + title = { Text("Info") }, + text = { Text(alert!!) }, + confirmButton = { + TextButton( + onClick = { + viewModel.alert.value = null + } + ) { + Text("Ok") + } + } + ) + } + } \ No newline at end of file diff --git a/sample/chat-demo-mpp/common/src/commonMain/kotlin/io/github/jan/supabase/common/ui/screen/LoginScreen.kt b/sample/chat-demo-mpp/common/src/commonMain/kotlin/io/github/jan/supabase/common/ui/screen/LoginScreen.kt index d4cc03004..223717797 100644 --- a/sample/chat-demo-mpp/common/src/commonMain/kotlin/io/github/jan/supabase/common/ui/screen/LoginScreen.kt +++ b/sample/chat-demo-mpp/common/src/commonMain/kotlin/io/github/jan/supabase/common/ui/screen/LoginScreen.kt @@ -32,7 +32,10 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import io.github.jan.supabase.annotations.SupabaseExperimental import io.github.jan.supabase.common.ChatViewModel +import io.github.jan.supabase.common.ui.components.OTPDialog +import io.github.jan.supabase.common.ui.components.OTPDialogState import io.github.jan.supabase.common.ui.components.PasswordField +import io.github.jan.supabase.common.ui.components.PasswordRecoveryDialog import io.github.jan.supabase.compose.auth.ui.ProviderButtonContent import io.github.jan.supabase.compose.auth.ui.annotations.AuthUiExperimental import io.github.jan.supabase.gotrue.providers.Google @@ -41,8 +44,10 @@ import io.github.jan.supabase.gotrue.providers.Google @Composable fun LoginScreen(viewModel: ChatViewModel) { var signUp by remember { mutableStateOf(false) } - val loginAlert by viewModel.loginAlert.collectAsState() + val loginAlert by viewModel.alert.collectAsState() var email by remember { mutableStateOf("") } + var otpDialogState by remember { mutableStateOf(OTPDialogState.Invisible) } + var showPasswordRecoveryDialog by remember { mutableStateOf(false) } Column( modifier = Modifier.fillMaxSize(), @@ -90,6 +95,12 @@ fun LoginScreen(viewModel: ChatViewModel) { ProviderButtonContent(Google, text = if (signUp) "Sign Up with Google" else "Login with Google") } + TextButton( + onClick = { otpDialogState = OTPDialogState.Visible(email) } + ) { + Text("Login with an OTP") + } + } Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.BottomCenter) { TextButton(onClick = { signUp = !signUp }) { @@ -97,17 +108,45 @@ fun LoginScreen(viewModel: ChatViewModel) { } } + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.BottomEnd) { + TextButton(onClick = { showPasswordRecoveryDialog = true }) { + Text("Forgot password?") + } + } + + if(otpDialogState is OTPDialogState.Visible) { + val state = (otpDialogState as OTPDialogState.Visible) + OTPDialog( + email = state.email, + title = state.title, + onDismiss = { otpDialogState = OTPDialogState.Invisible }, + onConfirm = { email, code -> + viewModel.loginWithOTP(email, code, state.resetFlow) + } + ) + } + + if(showPasswordRecoveryDialog) { + PasswordRecoveryDialog( + onDismiss = { showPasswordRecoveryDialog = false }, + onConfirm = { email -> + viewModel.resetPassword(email) + otpDialogState = OTPDialogState.Visible(title = "Password recovery", email = email, resetFlow = true) + } + ) + } + if(loginAlert != null) { AlertDialog( onDismissRequest = { - viewModel.loginAlert.value = null + viewModel.alert.value = null }, text = { Text(loginAlert!!) }, confirmButton = { TextButton(onClick = { - viewModel.loginAlert.value = null + viewModel.alert.value = null }) { Text("Ok") }