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:
Trey t
2025-11-09 18:29:29 -06:00
parent e6dc54017b
commit fdcc2a2e16
19 changed files with 2196 additions and 4 deletions

View File

@@ -19,6 +19,18 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </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> </activity>
</application> </application>

View File

@@ -1,10 +1,15 @@
package com.example.mycrib package com.example.mycrib
import android.content.Intent
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.Composable 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 androidx.compose.ui.tooling.preview.Preview
import com.mycrib.storage.TokenManager import com.mycrib.storage.TokenManager
import com.mycrib.storage.TokenStorage import com.mycrib.storage.TokenStorage
@@ -12,6 +17,8 @@ import com.mycrib.storage.TaskCacheManager
import com.mycrib.storage.TaskCacheStorage import com.mycrib.storage.TaskCacheStorage
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private var deepLinkResetToken by mutableStateOf<String?>(null)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge() enableEdgeToEdge()
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@@ -22,8 +29,33 @@ class MainActivity : ComponentActivity() {
// Initialize TaskCacheStorage for offline task caching // Initialize TaskCacheStorage for offline task caching
TaskCacheStorage.initialize(TaskCacheManager.getInstance(applicationContext)) TaskCacheStorage.initialize(TaskCacheManager.getInstance(applicationContext))
// Handle deep link from intent
handleDeepLink(intent)
setContent { 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 @Preview
@Composable @Composable
fun AppAndroidPreview() { fun AppAndroidPreview() {
App() App(deepLinkResetToken = null)
} }

View File

@@ -19,13 +19,18 @@ import androidx.compose.ui.Modifier
import com.mycrib.android.ui.screens.AddResidenceScreen import com.mycrib.android.ui.screens.AddResidenceScreen
import com.mycrib.android.ui.screens.EditResidenceScreen import com.mycrib.android.ui.screens.EditResidenceScreen
import com.mycrib.android.ui.screens.EditTaskScreen 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.HomeScreen
import com.mycrib.android.ui.screens.LoginScreen import com.mycrib.android.ui.screens.LoginScreen
import com.mycrib.android.ui.screens.RegisterScreen 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.ResidenceDetailScreen
import com.mycrib.android.ui.screens.ResidencesScreen import com.mycrib.android.ui.screens.ResidencesScreen
import com.mycrib.android.ui.screens.TasksScreen import com.mycrib.android.ui.screens.TasksScreen
import com.mycrib.android.ui.screens.VerifyEmailScreen 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.resources.painterResource
import org.jetbrains.compose.ui.tooling.preview.Preview import org.jetbrains.compose.ui.tooling.preview.Preview
@@ -53,7 +58,10 @@ import mycrib.composeapp.generated.resources.compose_multiplatform
@Composable @Composable
@Preview @Preview
fun App() { fun App(
deepLinkResetToken: String? = null,
onClearDeepLinkToken: () -> Unit = {}
) {
var isLoggedIn by remember { mutableStateOf(TokenStorage.hasToken()) } var isLoggedIn by remember { mutableStateOf(TokenStorage.hasToken()) }
var isVerified by remember { mutableStateOf(false) } var isVerified by remember { mutableStateOf(false) }
var isCheckingAuth by remember { mutableStateOf(true) } var isCheckingAuth by remember { mutableStateOf(true) }
@@ -105,6 +113,7 @@ fun App() {
} }
val startDestination = when { val startDestination = when {
deepLinkResetToken != null -> ForgotPasswordRoute
!isLoggedIn -> LoginRoute !isLoggedIn -> LoginRoute
!isVerified -> VerifyEmailRoute !isVerified -> VerifyEmailRoute
else -> MainRoute else -> MainRoute
@@ -139,6 +148,9 @@ fun App() {
}, },
onNavigateToRegister = { onNavigateToRegister = {
navController.navigate(RegisterRoute) 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> { composable<VerifyEmailRoute> {
VerifyEmailScreen( VerifyEmailScreen(
onVerifySuccess = { onVerifySuccess = {

View File

@@ -69,3 +69,38 @@ data class UpdateProfileRequest(
@SerialName("last_name") val lastName: String? = null, @SerialName("last_name") val lastName: String? = null,
val email: 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
)

View File

@@ -85,3 +85,12 @@ object MainTabTasksRoute
@Serializable @Serializable
object MainTabProfileRoute object MainTabProfileRoute
@Serializable
object ForgotPasswordRoute
@Serializable
object VerifyResetCodeRoute
@Serializable
object ResetPasswordRoute

View File

@@ -120,4 +120,84 @@ class AuthApi(private val client: HttpClient = ApiClient.httpClient) {
ApiResult.Error(e.message ?: "Unknown error occurred") 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")
}
}
} }

View File

@@ -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
)
}
}
}
}
}
}

View File

@@ -24,6 +24,7 @@ import com.mycrib.shared.network.ApiResult
fun LoginScreen( fun LoginScreen(
onLoginSuccess: (com.mycrib.shared.models.User) -> Unit, onLoginSuccess: (com.mycrib.shared.models.User) -> Unit,
onNavigateToRegister: () -> Unit, onNavigateToRegister: () -> Unit,
onNavigateToForgotPassword: () -> Unit = {},
viewModel: AuthViewModel = viewModel { AuthViewModel() } viewModel: AuthViewModel = viewModel { AuthViewModel() }
) { ) {
var username by remember { mutableStateOf("") } 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( TextButton(
onClick = onNavigateToRegister, onClick = onNavigateToRegister,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()

View File

@@ -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
)
}
}

View File

@@ -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
)
}
}
}
}
}
}

View File

@@ -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
}
}

View File

@@ -4,5 +4,16 @@
<dict> <dict>
<key>CADisableMinimumFrameDurationOnPhone</key> <key>CADisableMinimumFrameDurationOnPhone</key>
<true/> <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> </dict>
</plist> </plist>

View File

@@ -6,7 +6,13 @@ struct LoginView: View {
@State private var showingRegister = false @State private var showingRegister = false
@State private var showMainTab = false @State private var showMainTab = false
@State private var showVerification = false @State private var showVerification = false
@State private var showPasswordReset = false
@State private var isPasswordVisible = false @State private var isPasswordVisible = false
@Binding var resetToken: String?
init(resetToken: Binding<String?> = .constant(nil)) {
_resetToken = resetToken
}
enum Field { enum Field {
case username, password case username, password
@@ -109,6 +115,19 @@ struct LoginView: View {
.disabled(viewModel.isLoading) .disabled(viewModel.isLoading)
} }
Section {
HStack {
Spacer()
Button("Forgot Password?") {
showPasswordReset = true
}
.font(.subheadline)
.fontWeight(.semibold)
Spacer()
}
}
.listRowBackground(Color.clear)
Section { Section {
HStack { HStack {
Spacer() Spacer()
@@ -168,6 +187,15 @@ struct LoginView: View {
.sheet(isPresented: $showingRegister) { .sheet(isPresented: $showingRegister) {
RegisterView() 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
}
}
} }
} }
} }

View 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())
}

View 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")
}

View 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
}
}

View 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: {})
}

View 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)
}

View File

@@ -3,6 +3,8 @@ import ComposeApp
@main @main
struct iOSApp: App { struct iOSApp: App {
@State private var deepLinkResetToken: String?
init() { init() {
// Initialize TokenStorage once at app startup // Initialize TokenStorage once at app startup
TokenStorage.shared.initialize(manager: TokenManager()) TokenStorage.shared.initialize(manager: TokenManager())
@@ -10,7 +12,32 @@ struct iOSApp: App {
var body: some Scene { var body: some Scene {
WindowGroup { 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")
} }
} }
} }