Add password reset feature for iOS and Android with deep link support
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 <noreply@anthropic.com>
This commit is contained in:
@@ -19,6 +19,18 @@
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Deep link for password reset -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data
|
||||
android:scheme="mycrib"
|
||||
android:host="reset-password" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
|
||||
|
||||
@@ -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<String?>(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)
|
||||
}
|
||||
@@ -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<ForgotPasswordRoute> { backStackEntry ->
|
||||
// Create shared ViewModel for all password reset screens
|
||||
val parentEntry = remember(backStackEntry) {
|
||||
navController.getBackStackEntry<ForgotPasswordRoute>()
|
||||
}
|
||||
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<VerifyResetCodeRoute> { backStackEntry ->
|
||||
// Use shared ViewModel from ForgotPasswordRoute
|
||||
val parentEntry = remember(backStackEntry) {
|
||||
navController.getBackStackEntry<ForgotPasswordRoute>()
|
||||
}
|
||||
val passwordResetViewModel: PasswordResetViewModel = viewModel(parentEntry) { PasswordResetViewModel() }
|
||||
|
||||
VerifyResetCodeScreen(
|
||||
onNavigateBack = {
|
||||
navController.popBackStack()
|
||||
},
|
||||
onNavigateToReset = {
|
||||
navController.navigate(ResetPasswordRoute)
|
||||
},
|
||||
viewModel = passwordResetViewModel
|
||||
)
|
||||
}
|
||||
|
||||
composable<ResetPasswordRoute> { backStackEntry ->
|
||||
// Use shared ViewModel from ForgotPasswordRoute
|
||||
val parentEntry = remember(backStackEntry) {
|
||||
navController.getBackStackEntry<ForgotPasswordRoute>()
|
||||
}
|
||||
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<ForgotPasswordRoute> { inclusive = true }
|
||||
}
|
||||
},
|
||||
onNavigateBack = {
|
||||
navController.popBackStack()
|
||||
},
|
||||
viewModel = passwordResetViewModel
|
||||
)
|
||||
}
|
||||
|
||||
composable<VerifyEmailRoute> {
|
||||
VerifyEmailScreen(
|
||||
onVerifySuccess = {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -85,3 +85,12 @@ object MainTabTasksRoute
|
||||
|
||||
@Serializable
|
||||
object MainTabProfileRoute
|
||||
|
||||
@Serializable
|
||||
object ForgotPasswordRoute
|
||||
|
||||
@Serializable
|
||||
object VerifyResetCodeRoute
|
||||
|
||||
@Serializable
|
||||
object ResetPasswordRoute
|
||||
|
||||
@@ -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<ForgotPasswordResponse> {
|
||||
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<Map<String, String>>()
|
||||
} 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<VerifyResetCodeResponse> {
|
||||
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<Map<String, String>>()
|
||||
} 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<ResetPasswordResponse> {
|
||||
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<String, List<String>>)
|
||||
val errorMessage = try {
|
||||
val validationErrors = response.body<Map<String, List<String>>>()
|
||||
// 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<Map<String, String>>()
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<ForgotPasswordResponse>>(ApiResult.Idle)
|
||||
val forgotPasswordState: StateFlow<ApiResult<ForgotPasswordResponse>> = _forgotPasswordState
|
||||
|
||||
private val _verifyCodeState = MutableStateFlow<ApiResult<VerifyResetCodeResponse>>(ApiResult.Idle)
|
||||
val verifyCodeState: StateFlow<ApiResult<VerifyResetCodeResponse>> = _verifyCodeState
|
||||
|
||||
private val _resetPasswordState = MutableStateFlow<ApiResult<ResetPasswordResponse>>(ApiResult.Idle)
|
||||
val resetPasswordState: StateFlow<ApiResult<ResetPasswordResponse>> = _resetPasswordState
|
||||
|
||||
private val _currentStep = MutableStateFlow(
|
||||
if (deepLinkToken != null) PasswordResetStep.RESET_PASSWORD else PasswordResetStep.REQUEST_CODE
|
||||
)
|
||||
val currentStep: StateFlow<PasswordResetStep> = _currentStep
|
||||
|
||||
private val _resetToken = MutableStateFlow(deepLinkToken)
|
||||
val resetToken: StateFlow<String?> = _resetToken
|
||||
|
||||
private val _email = MutableStateFlow("")
|
||||
val email: StateFlow<String> = _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
|
||||
}
|
||||
}
|
||||
@@ -4,5 +4,16 @@
|
||||
<dict>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>mycrib</string>
|
||||
</array>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>com.mycrib.app</string>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -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<String?> = .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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
163
iosApp/iosApp/PasswordReset/ForgotPasswordView.swift
Normal file
163
iosApp/iosApp/PasswordReset/ForgotPasswordView.swift
Normal file
@@ -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())
|
||||
}
|
||||
34
iosApp/iosApp/PasswordReset/PasswordResetFlow.swift
Normal file
34
iosApp/iosApp/PasswordReset/PasswordResetFlow.swift
Normal file
@@ -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")
|
||||
}
|
||||
319
iosApp/iosApp/PasswordReset/PasswordResetViewModel.swift
Normal file
319
iosApp/iosApp/PasswordReset/PasswordResetViewModel.swift
Normal file
@@ -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<ForgotPasswordResponse> {
|
||||
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<VerifyResetCodeResponse> {
|
||||
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<ResetPasswordResponse> {
|
||||
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<ForgotPasswordResponse>) {
|
||||
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<VerifyResetCodeResponse>) {
|
||||
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<ResetPasswordResponse>) {
|
||||
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
|
||||
}
|
||||
}
|
||||
294
iosApp/iosApp/PasswordReset/ResetPasswordView.swift
Normal file
294
iosApp/iosApp/PasswordReset/ResetPasswordView.swift
Normal file
@@ -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: {})
|
||||
}
|
||||
196
iosApp/iosApp/PasswordReset/VerifyResetCodeView.swift
Normal file
196
iosApp/iosApp/PasswordReset/VerifyResetCodeView.swift
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user