From fdcc2a2e16750e771510f10a4062dfd359b0acc0 Mon Sep 17 00:00:00 2001 From: Trey t Date: Sun, 9 Nov 2025 18:29:29 -0600 Subject: [PATCH] Add password reset feature for iOS and Android with deep link support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implemented complete password reset flow with email verification and deep linking: iOS (SwiftUI): - PasswordResetViewModel: Manages 3-step flow (request, verify, reset) with deep link support - ForgotPasswordView: Email entry screen with success/error states - VerifyResetCodeView: 6-digit code verification with resend option - ResetPasswordView: Password reset with live validation and strength indicators - PasswordResetFlow: Container managing navigation between screens - Deep link handling in iOSApp.swift for mycrib://reset-password?token=xxx - Info.plist: Added CFBundleURLTypes for deep link scheme Android (Compose): - PasswordResetViewModel: StateFlow-based state management with coroutines - ForgotPasswordScreen: Material3 email entry with auto-navigation - VerifyResetCodeScreen: Code verification with Material3 design - ResetPasswordScreen: Password reset with live validation checklist - Deep link handling in MainActivity.kt and AndroidManifest.xml - Token cleanup callback to prevent reusing expired deep link tokens - Shared ViewModel scoping across all password reset screens - Improved error handling for Django validation errors Shared: - Routes: Added ForgotPasswordRoute, VerifyResetCodeRoute, ResetPasswordRoute - AuthApi: Enhanced resetPassword with Django validation error parsing - User models: Added password reset request/response models Security features: - Deep link tokens expire after use - Proper token validation on backend - Session invalidation after password change - Password strength requirements enforced 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../src/androidMain/AndroidManifest.xml | 12 + .../kotlin/com/example/mycrib/MainActivity.kt | 36 +- .../kotlin/com/example/mycrib/App.kt | 79 ++++- .../kotlin/com/example/mycrib/models/User.kt | 35 ++ .../com/example/mycrib/navigation/Routes.kt | 9 + .../com/example/mycrib/network/AuthApi.kt | 80 +++++ .../mycrib/ui/screens/ForgotPasswordScreen.kt | 191 +++++++++++ .../example/mycrib/ui/screens/LoginScreen.kt | 12 + .../mycrib/ui/screens/ResetPasswordScreen.kt | 276 +++++++++++++++ .../ui/screens/VerifyResetCodeScreen.kt | 247 ++++++++++++++ .../viewmodel/PasswordResetViewModel.kt | 149 ++++++++ iosApp/iosApp/Info.plist | 11 + iosApp/iosApp/Login/LoginView.swift | 28 ++ .../PasswordReset/ForgotPasswordView.swift | 163 +++++++++ .../PasswordReset/PasswordResetFlow.swift | 34 ++ .../PasswordResetViewModel.swift | 319 ++++++++++++++++++ .../PasswordReset/ResetPasswordView.swift | 294 ++++++++++++++++ .../PasswordReset/VerifyResetCodeView.swift | 196 +++++++++++ iosApp/iosApp/iOSApp.swift | 29 +- 19 files changed, 2196 insertions(+), 4 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ForgotPasswordScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ResetPasswordScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/VerifyResetCodeScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/PasswordResetViewModel.kt create mode 100644 iosApp/iosApp/PasswordReset/ForgotPasswordView.swift create mode 100644 iosApp/iosApp/PasswordReset/PasswordResetFlow.swift create mode 100644 iosApp/iosApp/PasswordReset/PasswordResetViewModel.swift create mode 100644 iosApp/iosApp/PasswordReset/ResetPasswordView.swift create mode 100644 iosApp/iosApp/PasswordReset/VerifyResetCodeView.swift diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml index ab643ef..038ff16 100644 --- a/composeApp/src/androidMain/AndroidManifest.xml +++ b/composeApp/src/androidMain/AndroidManifest.xml @@ -19,6 +19,18 @@ + + + + + + + + + + diff --git a/composeApp/src/androidMain/kotlin/com/example/mycrib/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/example/mycrib/MainActivity.kt index 80b2733..628159c 100644 --- a/composeApp/src/androidMain/kotlin/com/example/mycrib/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/com/example/mycrib/MainActivity.kt @@ -1,10 +1,15 @@ package com.example.mycrib +import android.content.Intent +import android.net.Uri import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue import androidx.compose.ui.tooling.preview.Preview import com.mycrib.storage.TokenManager import com.mycrib.storage.TokenStorage @@ -12,6 +17,8 @@ import com.mycrib.storage.TaskCacheManager import com.mycrib.storage.TaskCacheStorage class MainActivity : ComponentActivity() { + private var deepLinkResetToken by mutableStateOf(null) + override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() super.onCreate(savedInstanceState) @@ -22,8 +29,33 @@ class MainActivity : ComponentActivity() { // Initialize TaskCacheStorage for offline task caching TaskCacheStorage.initialize(TaskCacheManager.getInstance(applicationContext)) + // Handle deep link from intent + handleDeepLink(intent) + setContent { - App() + App( + deepLinkResetToken = deepLinkResetToken, + onClearDeepLinkToken = { + deepLinkResetToken = null + } + ) + } + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + handleDeepLink(intent) + } + + private fun handleDeepLink(intent: Intent?) { + val data: Uri? = intent?.data + if (data != null && data.scheme == "mycrib" && data.host == "reset-password") { + // Extract token from query parameter + val token = data.getQueryParameter("token") + if (token != null) { + deepLinkResetToken = token + println("Deep link received with token: $token") + } } } } @@ -31,5 +63,5 @@ class MainActivity : ComponentActivity() { @Preview @Composable fun AppAndroidPreview() { - App() + App(deepLinkResetToken = null) } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/App.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/App.kt index b058305..95ed65e 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/App.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/App.kt @@ -19,13 +19,18 @@ import androidx.compose.ui.Modifier import com.mycrib.android.ui.screens.AddResidenceScreen import com.mycrib.android.ui.screens.EditResidenceScreen import com.mycrib.android.ui.screens.EditTaskScreen +import com.mycrib.android.ui.screens.ForgotPasswordScreen import com.mycrib.android.ui.screens.HomeScreen import com.mycrib.android.ui.screens.LoginScreen import com.mycrib.android.ui.screens.RegisterScreen +import com.mycrib.android.ui.screens.ResetPasswordScreen import com.mycrib.android.ui.screens.ResidenceDetailScreen import com.mycrib.android.ui.screens.ResidencesScreen import com.mycrib.android.ui.screens.TasksScreen import com.mycrib.android.ui.screens.VerifyEmailScreen +import com.mycrib.android.ui.screens.VerifyResetCodeScreen +import com.mycrib.android.viewmodel.PasswordResetViewModel +import androidx.lifecycle.viewmodel.compose.viewModel import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.ui.tooling.preview.Preview @@ -53,7 +58,10 @@ import mycrib.composeapp.generated.resources.compose_multiplatform @Composable @Preview -fun App() { +fun App( + deepLinkResetToken: String? = null, + onClearDeepLinkToken: () -> Unit = {} +) { var isLoggedIn by remember { mutableStateOf(TokenStorage.hasToken()) } var isVerified by remember { mutableStateOf(false) } var isCheckingAuth by remember { mutableStateOf(true) } @@ -105,6 +113,7 @@ fun App() { } val startDestination = when { + deepLinkResetToken != null -> ForgotPasswordRoute !isLoggedIn -> LoginRoute !isVerified -> VerifyEmailRoute else -> MainRoute @@ -139,6 +148,9 @@ fun App() { }, onNavigateToRegister = { navController.navigate(RegisterRoute) + }, + onNavigateToForgotPassword = { + navController.navigate(ForgotPasswordRoute) } ) } @@ -160,6 +172,71 @@ fun App() { ) } + composable { backStackEntry -> + // Create shared ViewModel for all password reset screens + val parentEntry = remember(backStackEntry) { + navController.getBackStackEntry() + } + val passwordResetViewModel: PasswordResetViewModel = viewModel(parentEntry) { + PasswordResetViewModel(deepLinkToken = deepLinkResetToken) + } + + ForgotPasswordScreen( + onNavigateBack = { + // Clear deep link token when navigating back to login + onClearDeepLinkToken() + navController.popBackStack() + }, + onNavigateToVerify = { + navController.navigate(VerifyResetCodeRoute) + }, + onNavigateToReset = { + navController.navigate(ResetPasswordRoute) + }, + viewModel = passwordResetViewModel + ) + } + + composable { backStackEntry -> + // Use shared ViewModel from ForgotPasswordRoute + val parentEntry = remember(backStackEntry) { + navController.getBackStackEntry() + } + val passwordResetViewModel: PasswordResetViewModel = viewModel(parentEntry) { PasswordResetViewModel() } + + VerifyResetCodeScreen( + onNavigateBack = { + navController.popBackStack() + }, + onNavigateToReset = { + navController.navigate(ResetPasswordRoute) + }, + viewModel = passwordResetViewModel + ) + } + + composable { backStackEntry -> + // Use shared ViewModel from ForgotPasswordRoute + val parentEntry = remember(backStackEntry) { + navController.getBackStackEntry() + } + val passwordResetViewModel: PasswordResetViewModel = viewModel(parentEntry) { PasswordResetViewModel() } + + ResetPasswordScreen( + onPasswordResetSuccess = { + // Clear deep link token and navigate back to login after successful password reset + onClearDeepLinkToken() + navController.navigate(LoginRoute) { + popUpTo { inclusive = true } + } + }, + onNavigateBack = { + navController.popBackStack() + }, + viewModel = passwordResetViewModel + ) + } + composable { VerifyEmailScreen( onVerifySuccess = { diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/models/User.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/models/User.kt index 8287847..5e17529 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/models/User.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/models/User.kt @@ -69,3 +69,38 @@ data class UpdateProfileRequest( @SerialName("last_name") val lastName: String? = null, val email: String? = null ) + +// Password Reset Models +@Serializable +data class ForgotPasswordRequest( + val email: String +) + +@Serializable +data class ForgotPasswordResponse( + val message: String +) + +@Serializable +data class VerifyResetCodeRequest( + val email: String, + val code: String +) + +@Serializable +data class VerifyResetCodeResponse( + val message: String, + @SerialName("reset_token") val resetToken: String +) + +@Serializable +data class ResetPasswordRequest( + @SerialName("reset_token") val resetToken: String, + @SerialName("new_password") val newPassword: String, + @SerialName("confirm_password") val confirmPassword: String +) + +@Serializable +data class ResetPasswordResponse( + val message: String +) diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/navigation/Routes.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/navigation/Routes.kt index 91b49fb..b149c79 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/navigation/Routes.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/navigation/Routes.kt @@ -85,3 +85,12 @@ object MainTabTasksRoute @Serializable object MainTabProfileRoute + +@Serializable +object ForgotPasswordRoute + +@Serializable +object VerifyResetCodeRoute + +@Serializable +object ResetPasswordRoute diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/network/AuthApi.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/network/AuthApi.kt index 9ad4f9c..828f69a 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/network/AuthApi.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/network/AuthApi.kt @@ -120,4 +120,84 @@ class AuthApi(private val client: HttpClient = ApiClient.httpClient) { ApiResult.Error(e.message ?: "Unknown error occurred") } } + + // Password Reset Methods + suspend fun forgotPassword(request: ForgotPasswordRequest): ApiResult { + return try { + val response = client.post("$baseUrl/auth/forgot-password/") { + contentType(ContentType.Application.Json) + setBody(request) + } + + if (response.status.isSuccess()) { + ApiResult.Success(response.body()) + } else { + val errorBody = try { + response.body>() + } catch (e: Exception) { + mapOf("error" to "Failed to send reset code") + } + ApiResult.Error(errorBody["error"] ?: "Failed to send reset code", response.status.value) + } + } catch (e: Exception) { + ApiResult.Error(e.message ?: "Unknown error occurred") + } + } + + suspend fun verifyResetCode(request: VerifyResetCodeRequest): ApiResult { + return try { + val response = client.post("$baseUrl/auth/verify-reset-code/") { + contentType(ContentType.Application.Json) + setBody(request) + } + + if (response.status.isSuccess()) { + ApiResult.Success(response.body()) + } else { + val errorBody = try { + response.body>() + } catch (e: Exception) { + mapOf("error" to "Invalid code") + } + ApiResult.Error(errorBody["error"] ?: "Invalid code", response.status.value) + } + } catch (e: Exception) { + ApiResult.Error(e.message ?: "Unknown error occurred") + } + } + + suspend fun resetPassword(request: ResetPasswordRequest): ApiResult { + return try { + val response = client.post("$baseUrl/auth/reset-password/") { + contentType(ContentType.Application.Json) + setBody(request) + } + + if (response.status.isSuccess()) { + ApiResult.Success(response.body()) + } else { + // Try to parse Django validation errors (Map>) + val errorMessage = try { + val validationErrors = response.body>>() + // Flatten all error messages into a single string + validationErrors.flatMap { (field, errors) -> + errors.map { error -> + if (field == "non_field_errors") error else "$field: $error" + } + }.joinToString(". ") + } catch (e: Exception) { + // Try simple error format {error: "message"} + try { + val simpleError = response.body>() + simpleError["error"] ?: "Failed to reset password" + } catch (e2: Exception) { + "Failed to reset password" + } + } + ApiResult.Error(errorMessage, response.status.value) + } + } catch (e: Exception) { + ApiResult.Error(e.message ?: "Unknown error occurred") + } + } } diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ForgotPasswordScreen.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ForgotPasswordScreen.kt new file mode 100644 index 0000000..dd0fe8f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ForgotPasswordScreen.kt @@ -0,0 +1,191 @@ +package com.mycrib.android.ui.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.mycrib.android.ui.components.auth.AuthHeader +import com.mycrib.android.ui.components.common.ErrorCard +import com.mycrib.android.viewmodel.PasswordResetViewModel +import com.mycrib.shared.network.ApiResult + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ForgotPasswordScreen( + onNavigateBack: () -> Unit, + onNavigateToVerify: () -> Unit, + onNavigateToReset: () -> Unit = {}, + viewModel: PasswordResetViewModel +) { + var email by remember { mutableStateOf("") } + val forgotPasswordState by viewModel.forgotPasswordState.collectAsState() + val currentStep by viewModel.currentStep.collectAsState() + + // Handle automatic navigation to next step + LaunchedEffect(currentStep) { + when (currentStep) { + com.mycrib.android.viewmodel.PasswordResetStep.VERIFY_CODE -> onNavigateToVerify() + com.mycrib.android.viewmodel.PasswordResetStep.RESET_PASSWORD -> onNavigateToReset() + else -> {} + } + } + + val errorMessage = when (forgotPasswordState) { + is ApiResult.Error -> (forgotPasswordState as ApiResult.Error).message + else -> "" + } + + val isLoading = forgotPasswordState is ApiResult.Loading + val isSuccess = forgotPasswordState is ApiResult.Success + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Forgot Password") }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(Icons.Default.ArrowBack, contentDescription = "Back") + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) + } + ) { paddingValues -> + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .padding(paddingValues), + contentAlignment = Alignment.Center + ) { + Card( + modifier = Modifier + .fillMaxWidth(0.9f) + .wrapContentHeight(), + shape = RoundedCornerShape(24.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ), + elevation = CardDefaults.cardElevation(defaultElevation = 8.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(20.dp) + ) { + AuthHeader( + icon = Icons.Default.Key, + title = "Forgot Password?", + subtitle = "Enter your email address and we'll send you a code to reset your password" + ) + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedTextField( + value = email, + onValueChange = { + email = it + viewModel.resetForgotPasswordState() + }, + label = { Text("Email Address") }, + leadingIcon = { + Icon(Icons.Default.Email, contentDescription = null) + }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + shape = RoundedCornerShape(12.dp), + enabled = !isLoading + ) + + Text( + "We'll send a 6-digit verification code to this address", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + + ErrorCard(message = errorMessage) + + if (isSuccess) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.CheckCircle, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + "Check your email for a 6-digit verification code", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + } + } + + Button( + onClick = { + viewModel.setEmail(email) + viewModel.requestPasswordReset(email) + }, + modifier = Modifier + .fillMaxWidth() + .height(56.dp), + enabled = email.isNotEmpty() && !isLoading, + shape = RoundedCornerShape(12.dp) + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = MaterialTheme.colorScheme.onPrimary, + strokeWidth = 2.dp + ) + } else { + Icon(Icons.Default.Send, contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Text( + "Send Reset Code", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + } + } + + TextButton( + onClick = onNavigateBack, + modifier = Modifier.fillMaxWidth() + ) { + Text( + "Remember your password? Back to Login", + style = MaterialTheme.typography.bodyMedium + ) + } + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/LoginScreen.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/LoginScreen.kt index af2e0be..c0f6d95 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/LoginScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/LoginScreen.kt @@ -24,6 +24,7 @@ import com.mycrib.shared.network.ApiResult fun LoginScreen( onLoginSuccess: (com.mycrib.shared.models.User) -> Unit, onNavigateToRegister: () -> Unit, + onNavigateToForgotPassword: () -> Unit = {}, viewModel: AuthViewModel = viewModel { AuthViewModel() } ) { var username by remember { mutableStateOf("") } @@ -140,6 +141,17 @@ fun LoginScreen( } } + TextButton( + onClick = onNavigateToForgotPassword, + modifier = Modifier.fillMaxWidth() + ) { + Text( + "Forgot Password?", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold + ) + } + TextButton( onClick = onNavigateToRegister, modifier = Modifier.fillMaxWidth() diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ResetPasswordScreen.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ResetPasswordScreen.kt new file mode 100644 index 0000000..13cbd3d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ResetPasswordScreen.kt @@ -0,0 +1,276 @@ +package com.mycrib.android.ui.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.mycrib.android.ui.components.auth.AuthHeader +import com.mycrib.android.ui.components.common.ErrorCard +import com.mycrib.android.viewmodel.PasswordResetViewModel +import com.mycrib.shared.network.ApiResult + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ResetPasswordScreen( + onPasswordResetSuccess: () -> Unit, + onNavigateBack: (() -> Unit)? = null, + viewModel: PasswordResetViewModel +) { + var newPassword by remember { mutableStateOf("") } + var confirmPassword by remember { mutableStateOf("") } + var newPasswordVisible by remember { mutableStateOf(false) } + var confirmPasswordVisible by remember { mutableStateOf(false) } + + val resetPasswordState by viewModel.resetPasswordState.collectAsState() + val currentStep by viewModel.currentStep.collectAsState() + + val errorMessage = when (resetPasswordState) { + is ApiResult.Error -> (resetPasswordState as ApiResult.Error).message + else -> "" + } + + val isLoading = resetPasswordState is ApiResult.Loading + val isSuccess = currentStep == com.mycrib.android.viewmodel.PasswordResetStep.SUCCESS + + // Password validation + val hasLetter = newPassword.any { it.isLetter() } + val hasNumber = newPassword.any { it.isDigit() } + val passwordsMatch = newPassword.isNotEmpty() && newPassword == confirmPassword + val isFormValid = newPassword.length >= 8 && hasLetter && hasNumber && passwordsMatch + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Reset Password") }, + navigationIcon = { + onNavigateBack?.let { callback -> + IconButton(onClick = callback) { + Icon(Icons.Default.ArrowBack, contentDescription = "Back") + } + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) + } + ) { paddingValues -> + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .padding(paddingValues), + contentAlignment = Alignment.Center + ) { + Card( + modifier = Modifier + .fillMaxWidth(0.9f) + .wrapContentHeight(), + shape = RoundedCornerShape(24.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ), + elevation = CardDefaults.cardElevation(defaultElevation = 8.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(20.dp) + ) { + if (isSuccess) { + // Success State + AuthHeader( + icon = Icons.Default.CheckCircle, + title = "Success!", + subtitle = "Your password has been reset successfully" + ) + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer + ) + ) { + Text( + "You can now log in with your new password", + modifier = Modifier.padding(16.dp), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onPrimaryContainer, + textAlign = TextAlign.Center + ) + } + + Button( + onClick = onPasswordResetSuccess, + modifier = Modifier + .fillMaxWidth() + .height(56.dp), + shape = RoundedCornerShape(12.dp) + ) { + Text( + "Return to Login", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + } + } else { + // Reset Password Form + AuthHeader( + icon = Icons.Default.LockReset, + title = "Set New Password", + subtitle = "Create a strong password to secure your account" + ) + + // Password Requirements + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + "Password Requirements", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold + ) + + RequirementItem( + "At least 8 characters", + newPassword.length >= 8 + ) + RequirementItem( + "Contains letters", + hasLetter + ) + RequirementItem( + "Contains numbers", + hasNumber + ) + RequirementItem( + "Passwords match", + passwordsMatch + ) + } + } + + OutlinedTextField( + value = newPassword, + onValueChange = { + newPassword = it + viewModel.resetResetPasswordState() + }, + label = { Text("New Password") }, + leadingIcon = { + Icon(Icons.Default.Lock, contentDescription = null) + }, + trailingIcon = { + IconButton(onClick = { newPasswordVisible = !newPasswordVisible }) { + Icon( + if (newPasswordVisible) Icons.Default.Visibility else Icons.Default.VisibilityOff, + contentDescription = if (newPasswordVisible) "Hide password" else "Show password" + ) + } + }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + visualTransformation = if (newPasswordVisible) VisualTransformation.None else PasswordVisualTransformation(), + shape = RoundedCornerShape(12.dp), + enabled = !isLoading + ) + + OutlinedTextField( + value = confirmPassword, + onValueChange = { + confirmPassword = it + viewModel.resetResetPasswordState() + }, + label = { Text("Confirm Password") }, + leadingIcon = { + Icon(Icons.Default.Lock, contentDescription = null) + }, + trailingIcon = { + IconButton(onClick = { confirmPasswordVisible = !confirmPasswordVisible }) { + Icon( + if (confirmPasswordVisible) Icons.Default.Visibility else Icons.Default.VisibilityOff, + contentDescription = if (confirmPasswordVisible) "Hide password" else "Show password" + ) + } + }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + visualTransformation = if (confirmPasswordVisible) VisualTransformation.None else PasswordVisualTransformation(), + shape = RoundedCornerShape(12.dp), + enabled = !isLoading + ) + + ErrorCard(message = errorMessage) + + Button( + onClick = { + viewModel.resetPassword(newPassword, confirmPassword) + }, + modifier = Modifier + .fillMaxWidth() + .height(56.dp), + enabled = isFormValid && !isLoading, + shape = RoundedCornerShape(12.dp) + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = MaterialTheme.colorScheme.onPrimary, + strokeWidth = 2.dp + ) + } else { + Icon(Icons.Default.LockReset, contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Text( + "Reset Password", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + } + } + } + } + } + } + } +} + +@Composable +private fun RequirementItem(text: String, satisfied: Boolean) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + if (satisfied) Icons.Default.CheckCircle else Icons.Default.Circle, + contentDescription = null, + tint = if (satisfied) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text, + style = MaterialTheme.typography.bodySmall, + color = if (satisfied) MaterialTheme.colorScheme.onSecondaryContainer else MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/VerifyResetCodeScreen.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/VerifyResetCodeScreen.kt new file mode 100644 index 0000000..69eef24 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/VerifyResetCodeScreen.kt @@ -0,0 +1,247 @@ +package com.mycrib.android.ui.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.mycrib.android.ui.components.auth.AuthHeader +import com.mycrib.android.ui.components.common.ErrorCard +import com.mycrib.android.viewmodel.PasswordResetViewModel +import com.mycrib.shared.network.ApiResult + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun VerifyResetCodeScreen( + onNavigateBack: () -> Unit, + onNavigateToReset: () -> Unit, + viewModel: PasswordResetViewModel +) { + var code by remember { mutableStateOf("") } + val email by viewModel.email.collectAsState() + val verifyCodeState by viewModel.verifyCodeState.collectAsState() + val currentStep by viewModel.currentStep.collectAsState() + + // Handle automatic navigation to next step + LaunchedEffect(currentStep) { + if (currentStep == com.mycrib.android.viewmodel.PasswordResetStep.RESET_PASSWORD) { + onNavigateToReset() + } + } + + val errorMessage = when (verifyCodeState) { + is ApiResult.Error -> (verifyCodeState as ApiResult.Error).message + else -> "" + } + + val isLoading = verifyCodeState is ApiResult.Loading + val isSuccess = verifyCodeState is ApiResult.Success + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Verify Code") }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(Icons.Default.ArrowBack, contentDescription = "Back") + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) + } + ) { paddingValues -> + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .padding(paddingValues), + contentAlignment = Alignment.Center + ) { + Card( + modifier = Modifier + .fillMaxWidth(0.9f) + .wrapContentHeight(), + shape = RoundedCornerShape(24.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ), + elevation = CardDefaults.cardElevation(defaultElevation = 8.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(20.dp) + ) { + AuthHeader( + icon = Icons.Default.MarkEmailRead, + title = "Check Your Email", + subtitle = "We sent a 6-digit code to" + ) + + Text( + email, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center + ) + + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.Timer, + contentDescription = null, + tint = MaterialTheme.colorScheme.secondary + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + "Code expires in 15 minutes", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedTextField( + value = code, + onValueChange = { + if (it.length <= 6 && it.all { char -> char.isDigit() }) { + code = it + viewModel.resetVerifyCodeState() + } + }, + label = { Text("Verification Code") }, + placeholder = { Text("000000") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + shape = RoundedCornerShape(12.dp), + enabled = !isLoading, + textStyle = MaterialTheme.typography.headlineMedium.copy( + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center + ) + ) + + Text( + "Enter the 6-digit code from your email", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + + ErrorCard(message = errorMessage) + + if (isSuccess) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.CheckCircle, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + "Code verified! Now set your new password", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + } + } + + Button( + onClick = { + viewModel.verifyResetCode(email, code) + }, + modifier = Modifier + .fillMaxWidth() + .height(56.dp), + enabled = code.length == 6 && !isLoading, + shape = RoundedCornerShape(12.dp) + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = MaterialTheme.colorScheme.onPrimary, + strokeWidth = 2.dp + ) + } else { + Icon(Icons.Default.CheckCircle, contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Text( + "Verify Code", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + } + } + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + "Didn't receive the code?", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + TextButton(onClick = { + code = "" + viewModel.resetVerifyCodeState() + viewModel.moveToPreviousStep() + onNavigateBack() + }) { + Text( + "Send New Code", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold + ) + } + + Text( + "Check your spam folder if you don't see it", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + } + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/PasswordResetViewModel.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/PasswordResetViewModel.kt new file mode 100644 index 0000000..128e7cd --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/PasswordResetViewModel.kt @@ -0,0 +1,149 @@ +package com.mycrib.android.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.mycrib.shared.models.* +import com.mycrib.shared.network.ApiResult +import com.mycrib.shared.network.AuthApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +enum class PasswordResetStep { + REQUEST_CODE, + VERIFY_CODE, + RESET_PASSWORD, + SUCCESS +} + +class PasswordResetViewModel( + private val deepLinkToken: String? = null +) : ViewModel() { + private val authApi = AuthApi() + + private val _forgotPasswordState = MutableStateFlow>(ApiResult.Idle) + val forgotPasswordState: StateFlow> = _forgotPasswordState + + private val _verifyCodeState = MutableStateFlow>(ApiResult.Idle) + val verifyCodeState: StateFlow> = _verifyCodeState + + private val _resetPasswordState = MutableStateFlow>(ApiResult.Idle) + val resetPasswordState: StateFlow> = _resetPasswordState + + private val _currentStep = MutableStateFlow( + if (deepLinkToken != null) PasswordResetStep.RESET_PASSWORD else PasswordResetStep.REQUEST_CODE + ) + val currentStep: StateFlow = _currentStep + + private val _resetToken = MutableStateFlow(deepLinkToken) + val resetToken: StateFlow = _resetToken + + private val _email = MutableStateFlow("") + val email: StateFlow = _email + + fun setEmail(email: String) { + _email.value = email + } + + fun requestPasswordReset(email: String) { + viewModelScope.launch { + _forgotPasswordState.value = ApiResult.Loading + val result = authApi.forgotPassword(ForgotPasswordRequest(email)) + _forgotPasswordState.value = when (result) { + is ApiResult.Success -> { + _email.value = email + // Move to next step after short delay + kotlinx.coroutines.delay(1500) + _currentStep.value = PasswordResetStep.VERIFY_CODE + ApiResult.Success(result.data) + } + is ApiResult.Error -> result + else -> ApiResult.Error("Unknown error") + } + } + } + + fun verifyResetCode(email: String, code: String) { + viewModelScope.launch { + _verifyCodeState.value = ApiResult.Loading + val result = authApi.verifyResetCode(VerifyResetCodeRequest(email, code)) + _verifyCodeState.value = when (result) { + is ApiResult.Success -> { + _resetToken.value = result.data.resetToken + // Move to next step after short delay + kotlinx.coroutines.delay(1500) + _currentStep.value = PasswordResetStep.RESET_PASSWORD + ApiResult.Success(result.data) + } + is ApiResult.Error -> result + else -> ApiResult.Error("Unknown error") + } + } + } + + fun resetPassword(newPassword: String, confirmPassword: String) { + val token = _resetToken.value + if (token == null) { + _resetPasswordState.value = ApiResult.Error("Invalid reset token. Please start over.") + return + } + + viewModelScope.launch { + _resetPasswordState.value = ApiResult.Loading + val result = authApi.resetPassword( + ResetPasswordRequest( + resetToken = token, + newPassword = newPassword, + confirmPassword = confirmPassword + ) + ) + _resetPasswordState.value = when (result) { + is ApiResult.Success -> { + _currentStep.value = PasswordResetStep.SUCCESS + ApiResult.Success(result.data) + } + is ApiResult.Error -> result + else -> ApiResult.Error("Unknown error") + } + } + } + + fun moveToNextStep() { + _currentStep.value = when (_currentStep.value) { + PasswordResetStep.REQUEST_CODE -> PasswordResetStep.VERIFY_CODE + PasswordResetStep.VERIFY_CODE -> PasswordResetStep.RESET_PASSWORD + PasswordResetStep.RESET_PASSWORD -> PasswordResetStep.SUCCESS + PasswordResetStep.SUCCESS -> PasswordResetStep.SUCCESS + } + } + + fun moveToPreviousStep() { + _currentStep.value = when (_currentStep.value) { + PasswordResetStep.REQUEST_CODE -> PasswordResetStep.REQUEST_CODE + PasswordResetStep.VERIFY_CODE -> PasswordResetStep.REQUEST_CODE + PasswordResetStep.RESET_PASSWORD -> PasswordResetStep.VERIFY_CODE + PasswordResetStep.SUCCESS -> PasswordResetStep.SUCCESS + } + } + + fun resetForgotPasswordState() { + _forgotPasswordState.value = ApiResult.Idle + } + + fun resetVerifyCodeState() { + _verifyCodeState.value = ApiResult.Idle + } + + fun resetResetPasswordState() { + _resetPasswordState.value = ApiResult.Idle + } + + fun resetAll() { + _email.value = "" + _resetToken.value = null + _forgotPasswordState.value = ApiResult.Idle + _verifyCodeState.value = ApiResult.Idle + _resetPasswordState.value = ApiResult.Idle + _currentStep.value = PasswordResetStep.REQUEST_CODE + } +} diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist index 11845e1..dab9d2c 100644 --- a/iosApp/iosApp/Info.plist +++ b/iosApp/iosApp/Info.plist @@ -4,5 +4,16 @@ CADisableMinimumFrameDurationOnPhone + CFBundleURLTypes + + + CFBundleURLSchemes + + mycrib + + CFBundleURLName + com.mycrib.app + + diff --git a/iosApp/iosApp/Login/LoginView.swift b/iosApp/iosApp/Login/LoginView.swift index 08bc76f..9d9cddf 100644 --- a/iosApp/iosApp/Login/LoginView.swift +++ b/iosApp/iosApp/Login/LoginView.swift @@ -6,7 +6,13 @@ struct LoginView: View { @State private var showingRegister = false @State private var showMainTab = false @State private var showVerification = false + @State private var showPasswordReset = false @State private var isPasswordVisible = false + @Binding var resetToken: String? + + init(resetToken: Binding = .constant(nil)) { + _resetToken = resetToken + } enum Field { case username, password @@ -109,6 +115,19 @@ struct LoginView: View { .disabled(viewModel.isLoading) } + Section { + HStack { + Spacer() + Button("Forgot Password?") { + showPasswordReset = true + } + .font(.subheadline) + .fontWeight(.semibold) + Spacer() + } + } + .listRowBackground(Color.clear) + Section { HStack { Spacer() @@ -168,6 +187,15 @@ struct LoginView: View { .sheet(isPresented: $showingRegister) { RegisterView() } + .sheet(isPresented: $showPasswordReset) { + PasswordResetFlow(resetToken: resetToken) + } + .onChange(of: resetToken) { _, token in + // When deep link token arrives, show password reset + if token != nil { + showPasswordReset = true + } + } } } } diff --git a/iosApp/iosApp/PasswordReset/ForgotPasswordView.swift b/iosApp/iosApp/PasswordReset/ForgotPasswordView.swift new file mode 100644 index 0000000..7cc772b --- /dev/null +++ b/iosApp/iosApp/PasswordReset/ForgotPasswordView.swift @@ -0,0 +1,163 @@ +import SwiftUI + +struct ForgotPasswordView: View { + @ObservedObject var viewModel: PasswordResetViewModel + @FocusState private var isEmailFocused: Bool + @Environment(\.dismiss) var dismiss + + var body: some View { + NavigationView { + ZStack { + Color(.systemGroupedBackground) + .ignoresSafeArea() + + ScrollView { + VStack(spacing: 24) { + Spacer().frame(height: 20) + + // Header + VStack(spacing: 12) { + Image(systemName: "key.fill") + .font(.system(size: 60)) + .foregroundStyle(.blue.gradient) + .padding(.bottom, 8) + + Text("Forgot Password?") + .font(.title) + .fontWeight(.bold) + + Text("Enter your email address and we'll send you a code to reset your password") + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + } + + // Email Input + VStack(alignment: .leading, spacing: 12) { + Text("Email Address") + .font(.headline) + .padding(.horizontal) + + TextField("Enter your email", text: $viewModel.email) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.emailAddress) + .textFieldStyle(.roundedBorder) + .frame(height: 44) + .padding(.horizontal) + .focused($isEmailFocused) + .submitLabel(.go) + .onSubmit { + viewModel.requestPasswordReset() + } + .onChange(of: viewModel.email) { _, _ in + viewModel.clearError() + } + + Text("We'll send a 6-digit verification code to this address") + .font(.caption) + .foregroundColor(.secondary) + .padding(.horizontal) + } + + // Error Message + if let errorMessage = viewModel.errorMessage { + HStack(spacing: 12) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.red) + Text(errorMessage) + .foregroundColor(.red) + .font(.subheadline) + } + .padding() + .background(Color.red.opacity(0.1)) + .cornerRadius(12) + .padding(.horizontal) + } + + // Success Message + if let successMessage = viewModel.successMessage { + HStack(spacing: 12) { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + Text(successMessage) + .foregroundColor(.green) + .font(.subheadline) + } + .padding() + .background(Color.green.opacity(0.1)) + .cornerRadius(12) + .padding(.horizontal) + } + + // Send Code Button + Button(action: { + viewModel.requestPasswordReset() + }) { + HStack { + if viewModel.isLoading { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + } else { + Image(systemName: "envelope.fill") + Text("Send Reset Code") + .fontWeight(.semibold) + } + } + .frame(maxWidth: .infinity) + .frame(height: 50) + .background( + !viewModel.email.isEmpty && !viewModel.isLoading + ? Color.blue + : Color.gray.opacity(0.3) + ) + .foregroundColor(.white) + .cornerRadius(12) + } + .disabled(viewModel.email.isEmpty || viewModel.isLoading) + .padding(.horizontal) + + Spacer().frame(height: 20) + + // Help Text + Text("Remember your password?") + .font(.subheadline) + .foregroundColor(.secondary) + + Button(action: { + dismiss() + }) { + Text("Back to Login") + .font(.subheadline) + .fontWeight(.semibold) + } + } + } + } + .navigationBarTitleDisplayMode(.inline) + .navigationBarBackButtonHidden(true) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button(action: { + dismiss() + }) { + HStack(spacing: 4) { + Image(systemName: "chevron.left") + .font(.system(size: 16)) + Text("Back") + .font(.subheadline) + } + } + } + } + .onAppear { + isEmailFocused = true + } + } + } +} + +#Preview { + ForgotPasswordView(viewModel: PasswordResetViewModel()) +} diff --git a/iosApp/iosApp/PasswordReset/PasswordResetFlow.swift b/iosApp/iosApp/PasswordReset/PasswordResetFlow.swift new file mode 100644 index 0000000..c16b1a5 --- /dev/null +++ b/iosApp/iosApp/PasswordReset/PasswordResetFlow.swift @@ -0,0 +1,34 @@ +import SwiftUI + +struct PasswordResetFlow: View { + @StateObject private var viewModel: PasswordResetViewModel + @Environment(\.dismiss) var dismiss + + init(resetToken: String? = nil) { + _viewModel = StateObject(wrappedValue: PasswordResetViewModel(resetToken: resetToken)) + } + + var body: some View { + Group { + switch viewModel.currentStep { + case .requestCode: + ForgotPasswordView(viewModel: viewModel) + case .verifyCode: + VerifyResetCodeView(viewModel: viewModel) + case .resetPassword, .success: + ResetPasswordView(viewModel: viewModel, onSuccess: { + dismiss() + }) + } + } + .animation(.easeInOut, value: viewModel.currentStep) + } +} + +#Preview("Normal Flow") { + PasswordResetFlow() +} + +#Preview("Deep Link Flow") { + PasswordResetFlow(resetToken: "sample-token-from-deep-link") +} diff --git a/iosApp/iosApp/PasswordReset/PasswordResetViewModel.swift b/iosApp/iosApp/PasswordReset/PasswordResetViewModel.swift new file mode 100644 index 0000000..65548a8 --- /dev/null +++ b/iosApp/iosApp/PasswordReset/PasswordResetViewModel.swift @@ -0,0 +1,319 @@ +import Foundation +import ComposeApp +import Combine + +enum PasswordResetStep { + case requestCode // Step 1: Enter email + case verifyCode // Step 2: Enter 6-digit code + case resetPassword // Step 3: Set new password + case success // Final: Success confirmation +} + +@MainActor +class PasswordResetViewModel: ObservableObject { + // MARK: - Published Properties + @Published var email: String = "" + @Published var code: String = "" + @Published var newPassword: String = "" + @Published var confirmPassword: String = "" + @Published var isLoading: Bool = false + @Published var errorMessage: String? + @Published var successMessage: String? + @Published var currentStep: PasswordResetStep = .requestCode + @Published var resetToken: String? + + // MARK: - Private Properties + private let authApi: AuthApi + + // MARK: - Initialization + init(resetToken: String? = nil) { + self.authApi = AuthApi(client: ApiClient_iosKt.createHttpClient()) + + // If we have a reset token from deep link, skip to password reset step + if let token = resetToken { + self.resetToken = token + self.currentStep = .resetPassword + } + } + + // MARK: - Public Methods + + /// Step 1: Request password reset code + func requestPasswordReset() { + guard !email.isEmpty else { + errorMessage = "Email is required" + return + } + + guard isValidEmail(email) else { + errorMessage = "Please enter a valid email address" + return + } + + isLoading = true + errorMessage = nil + + let request = ForgotPasswordRequest(email: email) + + authApi.forgotPassword(request: request) { result, error in + if let successResult = result as? ApiResultSuccess { + self.handleRequestSuccess(response: successResult) + return + } + + if let errorResult = result as? ApiResultError { + self.handleApiError(errorResult: errorResult) + return + } + + if let error = error { + self.handleError(error: error) + return + } + + self.isLoading = false + self.errorMessage = "Failed to send reset code. Please try again." + } + } + + /// Step 2: Verify reset code + func verifyResetCode() { + guard !code.isEmpty else { + errorMessage = "Verification code is required" + return + } + + guard code.count == 6 else { + errorMessage = "Please enter a 6-digit code" + return + } + + isLoading = true + errorMessage = nil + + let request = VerifyResetCodeRequest(email: email, code: code) + + authApi.verifyResetCode(request: request) { result, error in + if let successResult = result as? ApiResultSuccess { + self.handleVerifySuccess(response: successResult) + return + } + + if let errorResult = result as? ApiResultError { + self.handleApiError(errorResult: errorResult) + return + } + + if let error = error { + self.handleError(error: error) + return + } + + self.isLoading = false + self.errorMessage = "Failed to verify code. Please try again." + } + } + + /// Step 3: Reset password + func resetPassword() { + guard !newPassword.isEmpty else { + errorMessage = "New password is required" + return + } + + guard newPassword.count >= 8 else { + errorMessage = "Password must be at least 8 characters" + return + } + + guard !confirmPassword.isEmpty else { + errorMessage = "Please confirm your password" + return + } + + guard newPassword == confirmPassword else { + errorMessage = "Passwords do not match" + return + } + + guard isValidPassword(newPassword) else { + errorMessage = "Password must contain both letters and numbers" + return + } + + guard let token = resetToken else { + errorMessage = "Invalid reset token. Please start over." + return + } + + isLoading = true + errorMessage = nil + + let request = ResetPasswordRequest( + resetToken: token, + newPassword: newPassword, + confirmPassword: confirmPassword + ) + + authApi.resetPassword(request: request) { result, error in + if let successResult = result as? ApiResultSuccess { + self.handleResetSuccess(response: successResult) + return + } + + if let errorResult = result as? ApiResultError { + self.handleApiError(errorResult: errorResult) + return + } + + if let error = error { + self.handleError(error: error) + return + } + + self.isLoading = false + self.errorMessage = "Failed to reset password. Please try again." + } + } + + /// Navigate to next step + func moveToNextStep() { + switch currentStep { + case .requestCode: + currentStep = .verifyCode + case .verifyCode: + currentStep = .resetPassword + case .resetPassword: + currentStep = .success + case .success: + break + } + } + + /// Navigate to previous step + func moveToPreviousStep() { + switch currentStep { + case .requestCode: + break + case .verifyCode: + currentStep = .requestCode + case .resetPassword: + currentStep = .verifyCode + case .success: + break + } + } + + /// Reset all state + func reset() { + email = "" + code = "" + newPassword = "" + confirmPassword = "" + resetToken = nil + errorMessage = nil + successMessage = nil + currentStep = .requestCode + isLoading = false + } + + func clearError() { + errorMessage = nil + } + + func clearSuccess() { + successMessage = nil + } + + // MARK: - Private Methods + + @MainActor + private func handleRequestSuccess(response: ApiResultSuccess) { + isLoading = false + successMessage = "Check your email for a 6-digit verification code" + + // Automatically move to next step after short delay + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + self.successMessage = nil + self.currentStep = .verifyCode + } + + print("Password reset requested for: \(email)") + } + + @MainActor + private func handleVerifySuccess(response: ApiResultSuccess) { + if let token = response.data?.resetToken { + self.resetToken = token + self.isLoading = false + self.successMessage = "Code verified! Now set your new password" + + // Automatically move to next step after short delay + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + self.successMessage = nil + self.currentStep = .resetPassword + } + + print("Code verified, reset token received") + } else { + self.isLoading = false + self.errorMessage = "Failed to verify code" + } + } + + @MainActor + private func handleResetSuccess(response: ApiResultSuccess) { + isLoading = false + successMessage = "Password reset successfully! You can now log in with your new password." + currentStep = .success + + print("Password reset successful") + } + + @MainActor + private func handleError(error: any Error) { + self.isLoading = false + self.errorMessage = error.localizedDescription + print("Error: \(error)") + } + + @MainActor + private func handleApiError(errorResult: ApiResultError) { + self.isLoading = false + + // Handle specific error codes + if errorResult.code?.intValue == 429 { + self.errorMessage = "Too many requests. Please try again later." + } else if errorResult.code?.intValue == 400 { + // Parse error message from backend + let message = errorResult.message + if message.contains("expired") { + self.errorMessage = "Reset code has expired. Please request a new one." + } else if message.contains("attempts") { + self.errorMessage = "Too many failed attempts. Please request a new reset code." + } else if message.contains("Invalid") && message.contains("token") { + self.errorMessage = "Invalid or expired reset token. Please start over." + } else { + self.errorMessage = message + } + } else { + self.errorMessage = errorResult.message + } + + print("API Error: \(errorResult.message)") + } + + // MARK: - Validation Helpers + + private func isValidEmail(_ email: String) -> Bool { + let emailRegex = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}" + let emailPredicate = NSPredicate(format: "SELF MATCHES %@", emailRegex) + return emailPredicate.evaluate(with: email) + } + + private func isValidPassword(_ password: String) -> Bool { + let hasLetter = password.range(of: "[A-Za-z]", options: .regularExpression) != nil + let hasNumber = password.range(of: "[0-9]", options: .regularExpression) != nil + return hasLetter && hasNumber + } +} diff --git a/iosApp/iosApp/PasswordReset/ResetPasswordView.swift b/iosApp/iosApp/PasswordReset/ResetPasswordView.swift new file mode 100644 index 0000000..956a5da --- /dev/null +++ b/iosApp/iosApp/PasswordReset/ResetPasswordView.swift @@ -0,0 +1,294 @@ +import SwiftUI + +struct ResetPasswordView: View { + @ObservedObject var viewModel: PasswordResetViewModel + @FocusState private var focusedField: Field? + @State private var isNewPasswordVisible = false + @State private var isConfirmPasswordVisible = false + @Environment(\.dismiss) var dismiss + var onSuccess: () -> Void + + enum Field { + case newPassword, confirmPassword + } + + var body: some View { + ZStack { + Color(.systemGroupedBackground) + .ignoresSafeArea() + + ScrollView { + VStack(spacing: 24) { + Spacer().frame(height: 20) + + // Header + VStack(spacing: 12) { + Image(systemName: "lock.rotation") + .font(.system(size: 60)) + .foregroundStyle(.blue.gradient) + .padding(.bottom, 8) + + Text("Set New Password") + .font(.title) + .fontWeight(.bold) + + Text("Create a strong password to secure your account") + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + } + + // Password Requirements + GroupBox { + VStack(alignment: .leading, spacing: 8) { + Text("Password Requirements") + .font(.subheadline) + .fontWeight(.semibold) + + HStack(spacing: 8) { + Image(systemName: viewModel.newPassword.count >= 8 ? "checkmark.circle.fill" : "circle") + .foregroundColor(viewModel.newPassword.count >= 8 ? .green : .secondary) + Text("At least 8 characters") + .font(.caption) + } + + HStack(spacing: 8) { + Image(systemName: hasLetter ? "checkmark.circle.fill" : "circle") + .foregroundColor(hasLetter ? .green : .secondary) + Text("Contains letters") + .font(.caption) + } + + HStack(spacing: 8) { + Image(systemName: hasNumber ? "checkmark.circle.fill" : "circle") + .foregroundColor(hasNumber ? .green : .secondary) + Text("Contains numbers") + .font(.caption) + } + + HStack(spacing: 8) { + Image(systemName: passwordsMatch ? "checkmark.circle.fill" : "circle") + .foregroundColor(passwordsMatch ? .green : .secondary) + Text("Passwords match") + .font(.caption) + } + } + .padding(.vertical, 4) + } + .padding(.horizontal) + + // New Password Input + VStack(alignment: .leading, spacing: 12) { + Text("New Password") + .font(.headline) + .padding(.horizontal) + + HStack { + if isNewPasswordVisible { + TextField("Enter new password", text: $viewModel.newPassword) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .focused($focusedField, equals: .newPassword) + .submitLabel(.next) + .onSubmit { + focusedField = .confirmPassword + } + } else { + SecureField("Enter new password", text: $viewModel.newPassword) + .focused($focusedField, equals: .newPassword) + .submitLabel(.next) + .onSubmit { + focusedField = .confirmPassword + } + } + + Button(action: { + isNewPasswordVisible.toggle() + }) { + Image(systemName: isNewPasswordVisible ? "eye.slash.fill" : "eye.fill") + .foregroundColor(.secondary) + } + .buttonStyle(.plain) + } + .textFieldStyle(.roundedBorder) + .frame(height: 44) + .padding(.horizontal) + .onChange(of: viewModel.newPassword) { _, _ in + viewModel.clearError() + } + } + + // Confirm Password Input + VStack(alignment: .leading, spacing: 12) { + Text("Confirm Password") + .font(.headline) + .padding(.horizontal) + + HStack { + if isConfirmPasswordVisible { + TextField("Re-enter new password", text: $viewModel.confirmPassword) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .focused($focusedField, equals: .confirmPassword) + .submitLabel(.go) + .onSubmit { + viewModel.resetPassword() + } + } else { + SecureField("Re-enter new password", text: $viewModel.confirmPassword) + .focused($focusedField, equals: .confirmPassword) + .submitLabel(.go) + .onSubmit { + viewModel.resetPassword() + } + } + + Button(action: { + isConfirmPasswordVisible.toggle() + }) { + Image(systemName: isConfirmPasswordVisible ? "eye.slash.fill" : "eye.fill") + .foregroundColor(.secondary) + } + .buttonStyle(.plain) + } + .textFieldStyle(.roundedBorder) + .frame(height: 44) + .padding(.horizontal) + .onChange(of: viewModel.confirmPassword) { _, _ in + viewModel.clearError() + } + } + + // Error Message + if let errorMessage = viewModel.errorMessage { + HStack(spacing: 12) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.red) + Text(errorMessage) + .foregroundColor(.red) + .font(.subheadline) + } + .padding() + .background(Color.red.opacity(0.1)) + .cornerRadius(12) + .padding(.horizontal) + } + + // Success Message + if let successMessage = viewModel.successMessage { + HStack(spacing: 12) { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + Text(successMessage) + .foregroundColor(.green) + .font(.subheadline) + .multilineTextAlignment(.center) + } + .padding() + .background(Color.green.opacity(0.1)) + .cornerRadius(12) + .padding(.horizontal) + } + + // Reset Password Button + Button(action: { + viewModel.resetPassword() + }) { + HStack { + if viewModel.isLoading { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + } else { + Image(systemName: "lock.shield.fill") + Text("Reset Password") + .fontWeight(.semibold) + } + } + .frame(maxWidth: .infinity) + .frame(height: 50) + .background( + isFormValid && !viewModel.isLoading + ? Color.blue + : Color.gray.opacity(0.3) + ) + .foregroundColor(.white) + .cornerRadius(12) + } + .disabled(!isFormValid || viewModel.isLoading) + .padding(.horizontal) + + // Return to Login Button (shown after success) + if viewModel.currentStep == .success { + Button(action: { + viewModel.reset() + onSuccess() + }) { + Text("Return to Login") + .font(.subheadline) + .fontWeight(.semibold) + } + .padding(.top, 8) + } + + Spacer().frame(height: 20) + } + } + } + .navigationBarBackButtonHidden(true) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + // Only show back button if not from deep link + if viewModel.resetToken == nil || viewModel.currentStep != .resetPassword { + Button(action: { + if viewModel.currentStep == .success { + viewModel.reset() + onSuccess() + } else { + viewModel.moveToPreviousStep() + } + }) { + HStack(spacing: 4) { + Image(systemName: viewModel.currentStep == .success ? "xmark" : "chevron.left") + .font(.system(size: 16)) + Text(viewModel.currentStep == .success ? "Close" : "Back") + .font(.subheadline) + } + } + } + } + } + .onAppear { + focusedField = .newPassword + } + } + + // Computed Properties + private var hasLetter: Bool { + viewModel.newPassword.range(of: "[A-Za-z]", options: .regularExpression) != nil + } + + private var hasNumber: Bool { + viewModel.newPassword.range(of: "[0-9]", options: .regularExpression) != nil + } + + private var passwordsMatch: Bool { + !viewModel.newPassword.isEmpty && + !viewModel.confirmPassword.isEmpty && + viewModel.newPassword == viewModel.confirmPassword + } + + private var isFormValid: Bool { + viewModel.newPassword.count >= 8 && + hasLetter && + hasNumber && + passwordsMatch + } +} + +#Preview { + let vm = PasswordResetViewModel() + vm.currentStep = .resetPassword + vm.resetToken = "sample-token" + return ResetPasswordView(viewModel: vm, onSuccess: {}) +} diff --git a/iosApp/iosApp/PasswordReset/VerifyResetCodeView.swift b/iosApp/iosApp/PasswordReset/VerifyResetCodeView.swift new file mode 100644 index 0000000..131d8e3 --- /dev/null +++ b/iosApp/iosApp/PasswordReset/VerifyResetCodeView.swift @@ -0,0 +1,196 @@ +import SwiftUI + +struct VerifyResetCodeView: View { + @ObservedObject var viewModel: PasswordResetViewModel + @FocusState private var isCodeFocused: Bool + @Environment(\.dismiss) var dismiss + + var body: some View { + ZStack { + Color(.systemGroupedBackground) + .ignoresSafeArea() + + ScrollView { + VStack(spacing: 24) { + Spacer().frame(height: 20) + + // Header + VStack(spacing: 12) { + Image(systemName: "envelope.badge.fill") + .font(.system(size: 60)) + .foregroundStyle(.blue.gradient) + .padding(.bottom, 8) + + Text("Check Your Email") + .font(.title) + .fontWeight(.bold) + + Text("We sent a 6-digit code to") + .font(.subheadline) + .foregroundColor(.secondary) + + Text(viewModel.email) + .font(.subheadline) + .fontWeight(.semibold) + .foregroundColor(.primary) + .padding(.horizontal) + } + + // Info Card + GroupBox { + HStack(spacing: 12) { + Image(systemName: "clock.fill") + .foregroundColor(.orange) + .font(.title2) + + Text("Code expires in 15 minutes") + .font(.subheadline) + .foregroundColor(.primary) + .fontWeight(.semibold) + } + .padding(.vertical, 4) + } + .padding(.horizontal) + + // Code Input + VStack(alignment: .leading, spacing: 12) { + Text("Verification Code") + .font(.headline) + .padding(.horizontal) + + TextField("000000", text: $viewModel.code) + .font(.system(size: 32, weight: .semibold, design: .rounded)) + .multilineTextAlignment(.center) + .keyboardType(.numberPad) + .textFieldStyle(.roundedBorder) + .frame(height: 60) + .padding(.horizontal) + .focused($isCodeFocused) + .onChange(of: viewModel.code) { _, newValue in + // Limit to 6 digits + if newValue.count > 6 { + viewModel.code = String(newValue.prefix(6)) + } + // Only allow numbers + viewModel.code = newValue.filter { $0.isNumber } + viewModel.clearError() + } + + Text("Enter the 6-digit code from your email") + .font(.caption) + .foregroundColor(.secondary) + .padding(.horizontal) + } + + // Error Message + if let errorMessage = viewModel.errorMessage { + HStack(spacing: 12) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.red) + Text(errorMessage) + .foregroundColor(.red) + .font(.subheadline) + } + .padding() + .background(Color.red.opacity(0.1)) + .cornerRadius(12) + .padding(.horizontal) + } + + // Success Message + if let successMessage = viewModel.successMessage { + HStack(spacing: 12) { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + Text(successMessage) + .foregroundColor(.green) + .font(.subheadline) + } + .padding() + .background(Color.green.opacity(0.1)) + .cornerRadius(12) + .padding(.horizontal) + } + + // Verify Button + Button(action: { + viewModel.verifyResetCode() + }) { + HStack { + if viewModel.isLoading { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + } else { + Image(systemName: "checkmark.shield.fill") + Text("Verify Code") + .fontWeight(.semibold) + } + } + .frame(maxWidth: .infinity) + .frame(height: 50) + .background( + viewModel.code.count == 6 && !viewModel.isLoading + ? Color.blue + : Color.gray.opacity(0.3) + ) + .foregroundColor(.white) + .cornerRadius(12) + } + .disabled(viewModel.code.count != 6 || viewModel.isLoading) + .padding(.horizontal) + + Spacer().frame(height: 20) + + // Help Section + VStack(spacing: 12) { + Text("Didn't receive the code?") + .font(.subheadline) + .foregroundColor(.secondary) + + Button(action: { + // Clear code and go back to request new one + viewModel.code = "" + viewModel.clearError() + viewModel.currentStep = .requestCode + }) { + Text("Send New Code") + .font(.subheadline) + .fontWeight(.semibold) + } + + Text("Check your spam folder if you don't see it") + .font(.caption) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + } + } + } + } + .navigationBarBackButtonHidden(true) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button(action: { + viewModel.moveToPreviousStep() + }) { + HStack(spacing: 4) { + Image(systemName: "chevron.left") + .font(.system(size: 16)) + Text("Back") + .font(.subheadline) + } + } + } + } + .onAppear { + isCodeFocused = true + } + } +} + +#Preview { + let vm = PasswordResetViewModel() + vm.email = "test@example.com" + vm.currentStep = .verifyCode + return VerifyResetCodeView(viewModel: vm) +} diff --git a/iosApp/iosApp/iOSApp.swift b/iosApp/iosApp/iOSApp.swift index f53af62..4664838 100644 --- a/iosApp/iosApp/iOSApp.swift +++ b/iosApp/iosApp/iOSApp.swift @@ -3,6 +3,8 @@ import ComposeApp @main struct iOSApp: App { + @State private var deepLinkResetToken: String? + init() { // Initialize TokenStorage once at app startup TokenStorage.shared.initialize(manager: TokenManager()) @@ -10,7 +12,32 @@ struct iOSApp: App { var body: some Scene { WindowGroup { - LoginView() + LoginView(resetToken: $deepLinkResetToken) + .onOpenURL { url in + handleDeepLink(url: url) + } + } + } + + // MARK: - Deep Link Handling + private func handleDeepLink(url: URL) { + print("Deep link received: \(url)") + + // Handle mycrib://reset-password?token=xxx + guard url.scheme == "mycrib", + url.host == "reset-password" else { + print("Unrecognized deep link scheme or host") + return + } + + // Parse token from query parameters + if let components = URLComponents(url: url, resolvingAgainstBaseURL: true), + let queryItems = components.queryItems, + let token = queryItems.first(where: { $0.name == "token" })?.value { + print("Reset token extracted: \(token)") + deepLinkResetToken = token + } else { + print("No token found in deep link") } } } \ No newline at end of file