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" />
</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>

View File

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

View File

@@ -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 = {

View File

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

View File

@@ -85,3 +85,12 @@ object MainTabTasksRoute
@Serializable
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")
}
}
// 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(
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()

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

View File

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

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