Add password reset feature for iOS and Android with deep link support
Implemented complete password reset flow with email verification and deep linking: iOS (SwiftUI): - PasswordResetViewModel: Manages 3-step flow (request, verify, reset) with deep link support - ForgotPasswordView: Email entry screen with success/error states - VerifyResetCodeView: 6-digit code verification with resend option - ResetPasswordView: Password reset with live validation and strength indicators - PasswordResetFlow: Container managing navigation between screens - Deep link handling in iOSApp.swift for mycrib://reset-password?token=xxx - Info.plist: Added CFBundleURLTypes for deep link scheme Android (Compose): - PasswordResetViewModel: StateFlow-based state management with coroutines - ForgotPasswordScreen: Material3 email entry with auto-navigation - VerifyResetCodeScreen: Code verification with Material3 design - ResetPasswordScreen: Password reset with live validation checklist - Deep link handling in MainActivity.kt and AndroidManifest.xml - Token cleanup callback to prevent reusing expired deep link tokens - Shared ViewModel scoping across all password reset screens - Improved error handling for Django validation errors Shared: - Routes: Added ForgotPasswordRoute, VerifyResetCodeRoute, ResetPasswordRoute - AuthApi: Enhanced resetPassword with Django validation error parsing - User models: Added password reset request/response models Security features: - Deep link tokens expire after use - Proper token validation on backend - Session invalidation after password change - Password strength requirements enforced 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -19,6 +19,18 @@
|
|||||||
|
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<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>
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
|||||||
@@ -85,3 +85,12 @@ object MainTabTasksRoute
|
|||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
object MainTabProfileRoute
|
object MainTabProfileRoute
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
object ForgotPasswordRoute
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
object VerifyResetCodeRoute
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
object ResetPasswordRoute
|
||||||
|
|||||||
@@ -120,4 +120,84 @@ class AuthApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
ApiResult.Error(e.message ?: "Unknown error occurred")
|
ApiResult.Error(e.message ?: "Unknown error occurred")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Password Reset Methods
|
||||||
|
suspend fun forgotPassword(request: ForgotPasswordRequest): ApiResult<ForgotPasswordResponse> {
|
||||||
|
return try {
|
||||||
|
val response = client.post("$baseUrl/auth/forgot-password/") {
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
setBody(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status.isSuccess()) {
|
||||||
|
ApiResult.Success(response.body())
|
||||||
|
} else {
|
||||||
|
val errorBody = try {
|
||||||
|
response.body<Map<String, String>>()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
mapOf("error" to "Failed to send reset code")
|
||||||
|
}
|
||||||
|
ApiResult.Error(errorBody["error"] ?: "Failed to send reset code", response.status.value)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
ApiResult.Error(e.message ?: "Unknown error occurred")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun verifyResetCode(request: VerifyResetCodeRequest): ApiResult<VerifyResetCodeResponse> {
|
||||||
|
return try {
|
||||||
|
val response = client.post("$baseUrl/auth/verify-reset-code/") {
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
setBody(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status.isSuccess()) {
|
||||||
|
ApiResult.Success(response.body())
|
||||||
|
} else {
|
||||||
|
val errorBody = try {
|
||||||
|
response.body<Map<String, String>>()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
mapOf("error" to "Invalid code")
|
||||||
|
}
|
||||||
|
ApiResult.Error(errorBody["error"] ?: "Invalid code", response.status.value)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
ApiResult.Error(e.message ?: "Unknown error occurred")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun resetPassword(request: ResetPasswordRequest): ApiResult<ResetPasswordResponse> {
|
||||||
|
return try {
|
||||||
|
val response = client.post("$baseUrl/auth/reset-password/") {
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
setBody(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status.isSuccess()) {
|
||||||
|
ApiResult.Success(response.body())
|
||||||
|
} else {
|
||||||
|
// Try to parse Django validation errors (Map<String, List<String>>)
|
||||||
|
val errorMessage = try {
|
||||||
|
val validationErrors = response.body<Map<String, List<String>>>()
|
||||||
|
// Flatten all error messages into a single string
|
||||||
|
validationErrors.flatMap { (field, errors) ->
|
||||||
|
errors.map { error ->
|
||||||
|
if (field == "non_field_errors") error else "$field: $error"
|
||||||
|
}
|
||||||
|
}.joinToString(". ")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Try simple error format {error: "message"}
|
||||||
|
try {
|
||||||
|
val simpleError = response.body<Map<String, String>>()
|
||||||
|
simpleError["error"] ?: "Failed to reset password"
|
||||||
|
} catch (e2: Exception) {
|
||||||
|
"Failed to reset password"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ApiResult.Error(errorMessage, response.status.value)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
ApiResult.Error(e.message ?: "Unknown error occurred")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
package com.mycrib.android.ui.screens
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.*
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.mycrib.android.ui.components.auth.AuthHeader
|
||||||
|
import com.mycrib.android.ui.components.common.ErrorCard
|
||||||
|
import com.mycrib.android.viewmodel.PasswordResetViewModel
|
||||||
|
import com.mycrib.shared.network.ApiResult
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ForgotPasswordScreen(
|
||||||
|
onNavigateBack: () -> Unit,
|
||||||
|
onNavigateToVerify: () -> Unit,
|
||||||
|
onNavigateToReset: () -> Unit = {},
|
||||||
|
viewModel: PasswordResetViewModel
|
||||||
|
) {
|
||||||
|
var email by remember { mutableStateOf("") }
|
||||||
|
val forgotPasswordState by viewModel.forgotPasswordState.collectAsState()
|
||||||
|
val currentStep by viewModel.currentStep.collectAsState()
|
||||||
|
|
||||||
|
// Handle automatic navigation to next step
|
||||||
|
LaunchedEffect(currentStep) {
|
||||||
|
when (currentStep) {
|
||||||
|
com.mycrib.android.viewmodel.PasswordResetStep.VERIFY_CODE -> onNavigateToVerify()
|
||||||
|
com.mycrib.android.viewmodel.PasswordResetStep.RESET_PASSWORD -> onNavigateToReset()
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val errorMessage = when (forgotPasswordState) {
|
||||||
|
is ApiResult.Error -> (forgotPasswordState as ApiResult.Error).message
|
||||||
|
else -> ""
|
||||||
|
}
|
||||||
|
|
||||||
|
val isLoading = forgotPasswordState is ApiResult.Loading
|
||||||
|
val isSuccess = forgotPasswordState is ApiResult.Success
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("Forgot Password") },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onNavigateBack) {
|
||||||
|
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { paddingValues ->
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.background)
|
||||||
|
.padding(paddingValues),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth(0.9f)
|
||||||
|
.wrapContentHeight(),
|
||||||
|
shape = RoundedCornerShape(24.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface
|
||||||
|
),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(32.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(20.dp)
|
||||||
|
) {
|
||||||
|
AuthHeader(
|
||||||
|
icon = Icons.Default.Key,
|
||||||
|
title = "Forgot Password?",
|
||||||
|
subtitle = "Enter your email address and we'll send you a code to reset your password"
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = email,
|
||||||
|
onValueChange = {
|
||||||
|
email = it
|
||||||
|
viewModel.resetForgotPasswordState()
|
||||||
|
},
|
||||||
|
label = { Text("Email Address") },
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(Icons.Default.Email, contentDescription = null)
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true,
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
enabled = !isLoading
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
"We'll send a 6-digit verification code to this address",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
|
||||||
|
ErrorCard(message = errorMessage)
|
||||||
|
|
||||||
|
if (isSuccess) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.primaryContainer
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.CheckCircle,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
Text(
|
||||||
|
"Check your email for a 6-digit verification code",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
viewModel.setEmail(email)
|
||||||
|
viewModel.requestPasswordReset(email)
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(56.dp),
|
||||||
|
enabled = email.isNotEmpty() && !isLoading,
|
||||||
|
shape = RoundedCornerShape(12.dp)
|
||||||
|
) {
|
||||||
|
if (isLoading) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
strokeWidth = 2.dp
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Icon(Icons.Default.Send, contentDescription = null)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
"Send Reset Code",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TextButton(
|
||||||
|
onClick = onNavigateBack,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Remember your password? Back to Login",
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ import com.mycrib.shared.network.ApiResult
|
|||||||
fun LoginScreen(
|
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()
|
||||||
|
|||||||
@@ -0,0 +1,276 @@
|
|||||||
|
package com.mycrib.android.ui.screens
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.*
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.text.input.VisualTransformation
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.mycrib.android.ui.components.auth.AuthHeader
|
||||||
|
import com.mycrib.android.ui.components.common.ErrorCard
|
||||||
|
import com.mycrib.android.viewmodel.PasswordResetViewModel
|
||||||
|
import com.mycrib.shared.network.ApiResult
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ResetPasswordScreen(
|
||||||
|
onPasswordResetSuccess: () -> Unit,
|
||||||
|
onNavigateBack: (() -> Unit)? = null,
|
||||||
|
viewModel: PasswordResetViewModel
|
||||||
|
) {
|
||||||
|
var newPassword by remember { mutableStateOf("") }
|
||||||
|
var confirmPassword by remember { mutableStateOf("") }
|
||||||
|
var newPasswordVisible by remember { mutableStateOf(false) }
|
||||||
|
var confirmPasswordVisible by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val resetPasswordState by viewModel.resetPasswordState.collectAsState()
|
||||||
|
val currentStep by viewModel.currentStep.collectAsState()
|
||||||
|
|
||||||
|
val errorMessage = when (resetPasswordState) {
|
||||||
|
is ApiResult.Error -> (resetPasswordState as ApiResult.Error).message
|
||||||
|
else -> ""
|
||||||
|
}
|
||||||
|
|
||||||
|
val isLoading = resetPasswordState is ApiResult.Loading
|
||||||
|
val isSuccess = currentStep == com.mycrib.android.viewmodel.PasswordResetStep.SUCCESS
|
||||||
|
|
||||||
|
// Password validation
|
||||||
|
val hasLetter = newPassword.any { it.isLetter() }
|
||||||
|
val hasNumber = newPassword.any { it.isDigit() }
|
||||||
|
val passwordsMatch = newPassword.isNotEmpty() && newPassword == confirmPassword
|
||||||
|
val isFormValid = newPassword.length >= 8 && hasLetter && hasNumber && passwordsMatch
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("Reset Password") },
|
||||||
|
navigationIcon = {
|
||||||
|
onNavigateBack?.let { callback ->
|
||||||
|
IconButton(onClick = callback) {
|
||||||
|
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { paddingValues ->
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.background)
|
||||||
|
.padding(paddingValues),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth(0.9f)
|
||||||
|
.wrapContentHeight(),
|
||||||
|
shape = RoundedCornerShape(24.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface
|
||||||
|
),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(32.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(20.dp)
|
||||||
|
) {
|
||||||
|
if (isSuccess) {
|
||||||
|
// Success State
|
||||||
|
AuthHeader(
|
||||||
|
icon = Icons.Default.CheckCircle,
|
||||||
|
title = "Success!",
|
||||||
|
subtitle = "Your password has been reset successfully"
|
||||||
|
)
|
||||||
|
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.primaryContainer
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"You can now log in with your new password",
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = onPasswordResetSuccess,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(56.dp),
|
||||||
|
shape = RoundedCornerShape(12.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Return to Login",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Reset Password Form
|
||||||
|
AuthHeader(
|
||||||
|
icon = Icons.Default.LockReset,
|
||||||
|
title = "Set New Password",
|
||||||
|
subtitle = "Create a strong password to secure your account"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Password Requirements
|
||||||
|
Card(
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.secondaryContainer
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Password Requirements",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
|
||||||
|
RequirementItem(
|
||||||
|
"At least 8 characters",
|
||||||
|
newPassword.length >= 8
|
||||||
|
)
|
||||||
|
RequirementItem(
|
||||||
|
"Contains letters",
|
||||||
|
hasLetter
|
||||||
|
)
|
||||||
|
RequirementItem(
|
||||||
|
"Contains numbers",
|
||||||
|
hasNumber
|
||||||
|
)
|
||||||
|
RequirementItem(
|
||||||
|
"Passwords match",
|
||||||
|
passwordsMatch
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = newPassword,
|
||||||
|
onValueChange = {
|
||||||
|
newPassword = it
|
||||||
|
viewModel.resetResetPasswordState()
|
||||||
|
},
|
||||||
|
label = { Text("New Password") },
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(Icons.Default.Lock, contentDescription = null)
|
||||||
|
},
|
||||||
|
trailingIcon = {
|
||||||
|
IconButton(onClick = { newPasswordVisible = !newPasswordVisible }) {
|
||||||
|
Icon(
|
||||||
|
if (newPasswordVisible) Icons.Default.Visibility else Icons.Default.VisibilityOff,
|
||||||
|
contentDescription = if (newPasswordVisible) "Hide password" else "Show password"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true,
|
||||||
|
visualTransformation = if (newPasswordVisible) VisualTransformation.None else PasswordVisualTransformation(),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
enabled = !isLoading
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = confirmPassword,
|
||||||
|
onValueChange = {
|
||||||
|
confirmPassword = it
|
||||||
|
viewModel.resetResetPasswordState()
|
||||||
|
},
|
||||||
|
label = { Text("Confirm Password") },
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(Icons.Default.Lock, contentDescription = null)
|
||||||
|
},
|
||||||
|
trailingIcon = {
|
||||||
|
IconButton(onClick = { confirmPasswordVisible = !confirmPasswordVisible }) {
|
||||||
|
Icon(
|
||||||
|
if (confirmPasswordVisible) Icons.Default.Visibility else Icons.Default.VisibilityOff,
|
||||||
|
contentDescription = if (confirmPasswordVisible) "Hide password" else "Show password"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true,
|
||||||
|
visualTransformation = if (confirmPasswordVisible) VisualTransformation.None else PasswordVisualTransformation(),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
enabled = !isLoading
|
||||||
|
)
|
||||||
|
|
||||||
|
ErrorCard(message = errorMessage)
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
viewModel.resetPassword(newPassword, confirmPassword)
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(56.dp),
|
||||||
|
enabled = isFormValid && !isLoading,
|
||||||
|
shape = RoundedCornerShape(12.dp)
|
||||||
|
) {
|
||||||
|
if (isLoading) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
strokeWidth = 2.dp
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Icon(Icons.Default.LockReset, contentDescription = null)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
"Reset Password",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RequirementItem(text: String, satisfied: Boolean) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
if (satisfied) Icons.Default.CheckCircle else Icons.Default.Circle,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = if (satisfied) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = if (satisfied) MaterialTheme.colorScheme.onSecondaryContainer else MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
package com.mycrib.android.ui.screens
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.*
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.mycrib.android.ui.components.auth.AuthHeader
|
||||||
|
import com.mycrib.android.ui.components.common.ErrorCard
|
||||||
|
import com.mycrib.android.viewmodel.PasswordResetViewModel
|
||||||
|
import com.mycrib.shared.network.ApiResult
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun VerifyResetCodeScreen(
|
||||||
|
onNavigateBack: () -> Unit,
|
||||||
|
onNavigateToReset: () -> Unit,
|
||||||
|
viewModel: PasswordResetViewModel
|
||||||
|
) {
|
||||||
|
var code by remember { mutableStateOf("") }
|
||||||
|
val email by viewModel.email.collectAsState()
|
||||||
|
val verifyCodeState by viewModel.verifyCodeState.collectAsState()
|
||||||
|
val currentStep by viewModel.currentStep.collectAsState()
|
||||||
|
|
||||||
|
// Handle automatic navigation to next step
|
||||||
|
LaunchedEffect(currentStep) {
|
||||||
|
if (currentStep == com.mycrib.android.viewmodel.PasswordResetStep.RESET_PASSWORD) {
|
||||||
|
onNavigateToReset()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val errorMessage = when (verifyCodeState) {
|
||||||
|
is ApiResult.Error -> (verifyCodeState as ApiResult.Error).message
|
||||||
|
else -> ""
|
||||||
|
}
|
||||||
|
|
||||||
|
val isLoading = verifyCodeState is ApiResult.Loading
|
||||||
|
val isSuccess = verifyCodeState is ApiResult.Success
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("Verify Code") },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onNavigateBack) {
|
||||||
|
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { paddingValues ->
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.background)
|
||||||
|
.padding(paddingValues),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth(0.9f)
|
||||||
|
.wrapContentHeight(),
|
||||||
|
shape = RoundedCornerShape(24.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface
|
||||||
|
),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(32.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(20.dp)
|
||||||
|
) {
|
||||||
|
AuthHeader(
|
||||||
|
icon = Icons.Default.MarkEmailRead,
|
||||||
|
title = "Check Your Email",
|
||||||
|
subtitle = "We sent a 6-digit code to"
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
email,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
|
||||||
|
Card(
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.secondaryContainer
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Timer,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.secondary
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
Text(
|
||||||
|
"Code expires in 15 minutes",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = code,
|
||||||
|
onValueChange = {
|
||||||
|
if (it.length <= 6 && it.all { char -> char.isDigit() }) {
|
||||||
|
code = it
|
||||||
|
viewModel.resetVerifyCodeState()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
label = { Text("Verification Code") },
|
||||||
|
placeholder = { Text("000000") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
enabled = !isLoading,
|
||||||
|
textStyle = MaterialTheme.typography.headlineMedium.copy(
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
"Enter the 6-digit code from your email",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
|
||||||
|
ErrorCard(message = errorMessage)
|
||||||
|
|
||||||
|
if (isSuccess) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.primaryContainer
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.CheckCircle,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
Text(
|
||||||
|
"Code verified! Now set your new password",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
viewModel.verifyResetCode(email, code)
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(56.dp),
|
||||||
|
enabled = code.length == 6 && !isLoading,
|
||||||
|
shape = RoundedCornerShape(12.dp)
|
||||||
|
) {
|
||||||
|
if (isLoading) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
strokeWidth = 2.dp
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Icon(Icons.Default.CheckCircle, contentDescription = null)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
"Verify Code",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Didn't receive the code?",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
|
||||||
|
TextButton(onClick = {
|
||||||
|
code = ""
|
||||||
|
viewModel.resetVerifyCodeState()
|
||||||
|
viewModel.moveToPreviousStep()
|
||||||
|
onNavigateBack()
|
||||||
|
}) {
|
||||||
|
Text(
|
||||||
|
"Send New Code",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
"Check your spam folder if you don't see it",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
package com.mycrib.android.viewmodel
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.mycrib.shared.models.*
|
||||||
|
import com.mycrib.shared.network.ApiResult
|
||||||
|
import com.mycrib.shared.network.AuthApi
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
enum class PasswordResetStep {
|
||||||
|
REQUEST_CODE,
|
||||||
|
VERIFY_CODE,
|
||||||
|
RESET_PASSWORD,
|
||||||
|
SUCCESS
|
||||||
|
}
|
||||||
|
|
||||||
|
class PasswordResetViewModel(
|
||||||
|
private val deepLinkToken: String? = null
|
||||||
|
) : ViewModel() {
|
||||||
|
private val authApi = AuthApi()
|
||||||
|
|
||||||
|
private val _forgotPasswordState = MutableStateFlow<ApiResult<ForgotPasswordResponse>>(ApiResult.Idle)
|
||||||
|
val forgotPasswordState: StateFlow<ApiResult<ForgotPasswordResponse>> = _forgotPasswordState
|
||||||
|
|
||||||
|
private val _verifyCodeState = MutableStateFlow<ApiResult<VerifyResetCodeResponse>>(ApiResult.Idle)
|
||||||
|
val verifyCodeState: StateFlow<ApiResult<VerifyResetCodeResponse>> = _verifyCodeState
|
||||||
|
|
||||||
|
private val _resetPasswordState = MutableStateFlow<ApiResult<ResetPasswordResponse>>(ApiResult.Idle)
|
||||||
|
val resetPasswordState: StateFlow<ApiResult<ResetPasswordResponse>> = _resetPasswordState
|
||||||
|
|
||||||
|
private val _currentStep = MutableStateFlow(
|
||||||
|
if (deepLinkToken != null) PasswordResetStep.RESET_PASSWORD else PasswordResetStep.REQUEST_CODE
|
||||||
|
)
|
||||||
|
val currentStep: StateFlow<PasswordResetStep> = _currentStep
|
||||||
|
|
||||||
|
private val _resetToken = MutableStateFlow(deepLinkToken)
|
||||||
|
val resetToken: StateFlow<String?> = _resetToken
|
||||||
|
|
||||||
|
private val _email = MutableStateFlow("")
|
||||||
|
val email: StateFlow<String> = _email
|
||||||
|
|
||||||
|
fun setEmail(email: String) {
|
||||||
|
_email.value = email
|
||||||
|
}
|
||||||
|
|
||||||
|
fun requestPasswordReset(email: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_forgotPasswordState.value = ApiResult.Loading
|
||||||
|
val result = authApi.forgotPassword(ForgotPasswordRequest(email))
|
||||||
|
_forgotPasswordState.value = when (result) {
|
||||||
|
is ApiResult.Success -> {
|
||||||
|
_email.value = email
|
||||||
|
// Move to next step after short delay
|
||||||
|
kotlinx.coroutines.delay(1500)
|
||||||
|
_currentStep.value = PasswordResetStep.VERIFY_CODE
|
||||||
|
ApiResult.Success(result.data)
|
||||||
|
}
|
||||||
|
is ApiResult.Error -> result
|
||||||
|
else -> ApiResult.Error("Unknown error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun verifyResetCode(email: String, code: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_verifyCodeState.value = ApiResult.Loading
|
||||||
|
val result = authApi.verifyResetCode(VerifyResetCodeRequest(email, code))
|
||||||
|
_verifyCodeState.value = when (result) {
|
||||||
|
is ApiResult.Success -> {
|
||||||
|
_resetToken.value = result.data.resetToken
|
||||||
|
// Move to next step after short delay
|
||||||
|
kotlinx.coroutines.delay(1500)
|
||||||
|
_currentStep.value = PasswordResetStep.RESET_PASSWORD
|
||||||
|
ApiResult.Success(result.data)
|
||||||
|
}
|
||||||
|
is ApiResult.Error -> result
|
||||||
|
else -> ApiResult.Error("Unknown error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resetPassword(newPassword: String, confirmPassword: String) {
|
||||||
|
val token = _resetToken.value
|
||||||
|
if (token == null) {
|
||||||
|
_resetPasswordState.value = ApiResult.Error("Invalid reset token. Please start over.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
_resetPasswordState.value = ApiResult.Loading
|
||||||
|
val result = authApi.resetPassword(
|
||||||
|
ResetPasswordRequest(
|
||||||
|
resetToken = token,
|
||||||
|
newPassword = newPassword,
|
||||||
|
confirmPassword = confirmPassword
|
||||||
|
)
|
||||||
|
)
|
||||||
|
_resetPasswordState.value = when (result) {
|
||||||
|
is ApiResult.Success -> {
|
||||||
|
_currentStep.value = PasswordResetStep.SUCCESS
|
||||||
|
ApiResult.Success(result.data)
|
||||||
|
}
|
||||||
|
is ApiResult.Error -> result
|
||||||
|
else -> ApiResult.Error("Unknown error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun moveToNextStep() {
|
||||||
|
_currentStep.value = when (_currentStep.value) {
|
||||||
|
PasswordResetStep.REQUEST_CODE -> PasswordResetStep.VERIFY_CODE
|
||||||
|
PasswordResetStep.VERIFY_CODE -> PasswordResetStep.RESET_PASSWORD
|
||||||
|
PasswordResetStep.RESET_PASSWORD -> PasswordResetStep.SUCCESS
|
||||||
|
PasswordResetStep.SUCCESS -> PasswordResetStep.SUCCESS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun moveToPreviousStep() {
|
||||||
|
_currentStep.value = when (_currentStep.value) {
|
||||||
|
PasswordResetStep.REQUEST_CODE -> PasswordResetStep.REQUEST_CODE
|
||||||
|
PasswordResetStep.VERIFY_CODE -> PasswordResetStep.REQUEST_CODE
|
||||||
|
PasswordResetStep.RESET_PASSWORD -> PasswordResetStep.VERIFY_CODE
|
||||||
|
PasswordResetStep.SUCCESS -> PasswordResetStep.SUCCESS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resetForgotPasswordState() {
|
||||||
|
_forgotPasswordState.value = ApiResult.Idle
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resetVerifyCodeState() {
|
||||||
|
_verifyCodeState.value = ApiResult.Idle
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resetResetPasswordState() {
|
||||||
|
_resetPasswordState.value = ApiResult.Idle
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resetAll() {
|
||||||
|
_email.value = ""
|
||||||
|
_resetToken.value = null
|
||||||
|
_forgotPasswordState.value = ApiResult.Idle
|
||||||
|
_verifyCodeState.value = ApiResult.Idle
|
||||||
|
_resetPasswordState.value = ApiResult.Idle
|
||||||
|
_currentStep.value = PasswordResetStep.REQUEST_CODE
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,5 +4,16 @@
|
|||||||
<dict>
|
<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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
163
iosApp/iosApp/PasswordReset/ForgotPasswordView.swift
Normal file
163
iosApp/iosApp/PasswordReset/ForgotPasswordView.swift
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ForgotPasswordView: View {
|
||||||
|
@ObservedObject var viewModel: PasswordResetViewModel
|
||||||
|
@FocusState private var isEmailFocused: Bool
|
||||||
|
@Environment(\.dismiss) var dismiss
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationView {
|
||||||
|
ZStack {
|
||||||
|
Color(.systemGroupedBackground)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 24) {
|
||||||
|
Spacer().frame(height: 20)
|
||||||
|
|
||||||
|
// Header
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Image(systemName: "key.fill")
|
||||||
|
.font(.system(size: 60))
|
||||||
|
.foregroundStyle(.blue.gradient)
|
||||||
|
.padding(.bottom, 8)
|
||||||
|
|
||||||
|
Text("Forgot Password?")
|
||||||
|
.font(.title)
|
||||||
|
.fontWeight(.bold)
|
||||||
|
|
||||||
|
Text("Enter your email address and we'll send you a code to reset your password")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Email Input
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("Email Address")
|
||||||
|
.font(.headline)
|
||||||
|
.padding(.horizontal)
|
||||||
|
|
||||||
|
TextField("Enter your email", text: $viewModel.email)
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.keyboardType(.emailAddress)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(height: 44)
|
||||||
|
.padding(.horizontal)
|
||||||
|
.focused($isEmailFocused)
|
||||||
|
.submitLabel(.go)
|
||||||
|
.onSubmit {
|
||||||
|
viewModel.requestPasswordReset()
|
||||||
|
}
|
||||||
|
.onChange(of: viewModel.email) { _, _ in
|
||||||
|
viewModel.clearError()
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("We'll send a 6-digit verification code to this address")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error Message
|
||||||
|
if let errorMessage = viewModel.errorMessage {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.foregroundColor(.red)
|
||||||
|
Text(errorMessage)
|
||||||
|
.foregroundColor(.red)
|
||||||
|
.font(.subheadline)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color.red.opacity(0.1))
|
||||||
|
.cornerRadius(12)
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success Message
|
||||||
|
if let successMessage = viewModel.successMessage {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.foregroundColor(.green)
|
||||||
|
Text(successMessage)
|
||||||
|
.foregroundColor(.green)
|
||||||
|
.font(.subheadline)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color.green.opacity(0.1))
|
||||||
|
.cornerRadius(12)
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send Code Button
|
||||||
|
Button(action: {
|
||||||
|
viewModel.requestPasswordReset()
|
||||||
|
}) {
|
||||||
|
HStack {
|
||||||
|
if viewModel.isLoading {
|
||||||
|
ProgressView()
|
||||||
|
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||||
|
} else {
|
||||||
|
Image(systemName: "envelope.fill")
|
||||||
|
Text("Send Reset Code")
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 50)
|
||||||
|
.background(
|
||||||
|
!viewModel.email.isEmpty && !viewModel.isLoading
|
||||||
|
? Color.blue
|
||||||
|
: Color.gray.opacity(0.3)
|
||||||
|
)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.cornerRadius(12)
|
||||||
|
}
|
||||||
|
.disabled(viewModel.email.isEmpty || viewModel.isLoading)
|
||||||
|
.padding(.horizontal)
|
||||||
|
|
||||||
|
Spacer().frame(height: 20)
|
||||||
|
|
||||||
|
// Help Text
|
||||||
|
Text("Remember your password?")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
|
Button(action: {
|
||||||
|
dismiss()
|
||||||
|
}) {
|
||||||
|
Text("Back to Login")
|
||||||
|
.font(.subheadline)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.navigationBarBackButtonHidden(true)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .navigationBarLeading) {
|
||||||
|
Button(action: {
|
||||||
|
dismiss()
|
||||||
|
}) {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: "chevron.left")
|
||||||
|
.font(.system(size: 16))
|
||||||
|
Text("Back")
|
||||||
|
.font(.subheadline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
isEmailFocused = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
ForgotPasswordView(viewModel: PasswordResetViewModel())
|
||||||
|
}
|
||||||
34
iosApp/iosApp/PasswordReset/PasswordResetFlow.swift
Normal file
34
iosApp/iosApp/PasswordReset/PasswordResetFlow.swift
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct PasswordResetFlow: View {
|
||||||
|
@StateObject private var viewModel: PasswordResetViewModel
|
||||||
|
@Environment(\.dismiss) var dismiss
|
||||||
|
|
||||||
|
init(resetToken: String? = nil) {
|
||||||
|
_viewModel = StateObject(wrappedValue: PasswordResetViewModel(resetToken: resetToken))
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
switch viewModel.currentStep {
|
||||||
|
case .requestCode:
|
||||||
|
ForgotPasswordView(viewModel: viewModel)
|
||||||
|
case .verifyCode:
|
||||||
|
VerifyResetCodeView(viewModel: viewModel)
|
||||||
|
case .resetPassword, .success:
|
||||||
|
ResetPasswordView(viewModel: viewModel, onSuccess: {
|
||||||
|
dismiss()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animation(.easeInOut, value: viewModel.currentStep)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("Normal Flow") {
|
||||||
|
PasswordResetFlow()
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("Deep Link Flow") {
|
||||||
|
PasswordResetFlow(resetToken: "sample-token-from-deep-link")
|
||||||
|
}
|
||||||
319
iosApp/iosApp/PasswordReset/PasswordResetViewModel.swift
Normal file
319
iosApp/iosApp/PasswordReset/PasswordResetViewModel.swift
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
import Foundation
|
||||||
|
import ComposeApp
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
enum PasswordResetStep {
|
||||||
|
case requestCode // Step 1: Enter email
|
||||||
|
case verifyCode // Step 2: Enter 6-digit code
|
||||||
|
case resetPassword // Step 3: Set new password
|
||||||
|
case success // Final: Success confirmation
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
class PasswordResetViewModel: ObservableObject {
|
||||||
|
// MARK: - Published Properties
|
||||||
|
@Published var email: String = ""
|
||||||
|
@Published var code: String = ""
|
||||||
|
@Published var newPassword: String = ""
|
||||||
|
@Published var confirmPassword: String = ""
|
||||||
|
@Published var isLoading: Bool = false
|
||||||
|
@Published var errorMessage: String?
|
||||||
|
@Published var successMessage: String?
|
||||||
|
@Published var currentStep: PasswordResetStep = .requestCode
|
||||||
|
@Published var resetToken: String?
|
||||||
|
|
||||||
|
// MARK: - Private Properties
|
||||||
|
private let authApi: AuthApi
|
||||||
|
|
||||||
|
// MARK: - Initialization
|
||||||
|
init(resetToken: String? = nil) {
|
||||||
|
self.authApi = AuthApi(client: ApiClient_iosKt.createHttpClient())
|
||||||
|
|
||||||
|
// If we have a reset token from deep link, skip to password reset step
|
||||||
|
if let token = resetToken {
|
||||||
|
self.resetToken = token
|
||||||
|
self.currentStep = .resetPassword
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Public Methods
|
||||||
|
|
||||||
|
/// Step 1: Request password reset code
|
||||||
|
func requestPasswordReset() {
|
||||||
|
guard !email.isEmpty else {
|
||||||
|
errorMessage = "Email is required"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard isValidEmail(email) else {
|
||||||
|
errorMessage = "Please enter a valid email address"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading = true
|
||||||
|
errorMessage = nil
|
||||||
|
|
||||||
|
let request = ForgotPasswordRequest(email: email)
|
||||||
|
|
||||||
|
authApi.forgotPassword(request: request) { result, error in
|
||||||
|
if let successResult = result as? ApiResultSuccess<ForgotPasswordResponse> {
|
||||||
|
self.handleRequestSuccess(response: successResult)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let errorResult = result as? ApiResultError {
|
||||||
|
self.handleApiError(errorResult: errorResult)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let error = error {
|
||||||
|
self.handleError(error: error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
self.isLoading = false
|
||||||
|
self.errorMessage = "Failed to send reset code. Please try again."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Step 2: Verify reset code
|
||||||
|
func verifyResetCode() {
|
||||||
|
guard !code.isEmpty else {
|
||||||
|
errorMessage = "Verification code is required"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard code.count == 6 else {
|
||||||
|
errorMessage = "Please enter a 6-digit code"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading = true
|
||||||
|
errorMessage = nil
|
||||||
|
|
||||||
|
let request = VerifyResetCodeRequest(email: email, code: code)
|
||||||
|
|
||||||
|
authApi.verifyResetCode(request: request) { result, error in
|
||||||
|
if let successResult = result as? ApiResultSuccess<VerifyResetCodeResponse> {
|
||||||
|
self.handleVerifySuccess(response: successResult)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let errorResult = result as? ApiResultError {
|
||||||
|
self.handleApiError(errorResult: errorResult)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let error = error {
|
||||||
|
self.handleError(error: error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
self.isLoading = false
|
||||||
|
self.errorMessage = "Failed to verify code. Please try again."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Step 3: Reset password
|
||||||
|
func resetPassword() {
|
||||||
|
guard !newPassword.isEmpty else {
|
||||||
|
errorMessage = "New password is required"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard newPassword.count >= 8 else {
|
||||||
|
errorMessage = "Password must be at least 8 characters"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard !confirmPassword.isEmpty else {
|
||||||
|
errorMessage = "Please confirm your password"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard newPassword == confirmPassword else {
|
||||||
|
errorMessage = "Passwords do not match"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard isValidPassword(newPassword) else {
|
||||||
|
errorMessage = "Password must contain both letters and numbers"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let token = resetToken else {
|
||||||
|
errorMessage = "Invalid reset token. Please start over."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading = true
|
||||||
|
errorMessage = nil
|
||||||
|
|
||||||
|
let request = ResetPasswordRequest(
|
||||||
|
resetToken: token,
|
||||||
|
newPassword: newPassword,
|
||||||
|
confirmPassword: confirmPassword
|
||||||
|
)
|
||||||
|
|
||||||
|
authApi.resetPassword(request: request) { result, error in
|
||||||
|
if let successResult = result as? ApiResultSuccess<ResetPasswordResponse> {
|
||||||
|
self.handleResetSuccess(response: successResult)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let errorResult = result as? ApiResultError {
|
||||||
|
self.handleApiError(errorResult: errorResult)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let error = error {
|
||||||
|
self.handleError(error: error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
self.isLoading = false
|
||||||
|
self.errorMessage = "Failed to reset password. Please try again."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Navigate to next step
|
||||||
|
func moveToNextStep() {
|
||||||
|
switch currentStep {
|
||||||
|
case .requestCode:
|
||||||
|
currentStep = .verifyCode
|
||||||
|
case .verifyCode:
|
||||||
|
currentStep = .resetPassword
|
||||||
|
case .resetPassword:
|
||||||
|
currentStep = .success
|
||||||
|
case .success:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Navigate to previous step
|
||||||
|
func moveToPreviousStep() {
|
||||||
|
switch currentStep {
|
||||||
|
case .requestCode:
|
||||||
|
break
|
||||||
|
case .verifyCode:
|
||||||
|
currentStep = .requestCode
|
||||||
|
case .resetPassword:
|
||||||
|
currentStep = .verifyCode
|
||||||
|
case .success:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reset all state
|
||||||
|
func reset() {
|
||||||
|
email = ""
|
||||||
|
code = ""
|
||||||
|
newPassword = ""
|
||||||
|
confirmPassword = ""
|
||||||
|
resetToken = nil
|
||||||
|
errorMessage = nil
|
||||||
|
successMessage = nil
|
||||||
|
currentStep = .requestCode
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearError() {
|
||||||
|
errorMessage = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearSuccess() {
|
||||||
|
successMessage = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private Methods
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func handleRequestSuccess(response: ApiResultSuccess<ForgotPasswordResponse>) {
|
||||||
|
isLoading = false
|
||||||
|
successMessage = "Check your email for a 6-digit verification code"
|
||||||
|
|
||||||
|
// Automatically move to next step after short delay
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
|
||||||
|
self.successMessage = nil
|
||||||
|
self.currentStep = .verifyCode
|
||||||
|
}
|
||||||
|
|
||||||
|
print("Password reset requested for: \(email)")
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func handleVerifySuccess(response: ApiResultSuccess<VerifyResetCodeResponse>) {
|
||||||
|
if let token = response.data?.resetToken {
|
||||||
|
self.resetToken = token
|
||||||
|
self.isLoading = false
|
||||||
|
self.successMessage = "Code verified! Now set your new password"
|
||||||
|
|
||||||
|
// Automatically move to next step after short delay
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
|
||||||
|
self.successMessage = nil
|
||||||
|
self.currentStep = .resetPassword
|
||||||
|
}
|
||||||
|
|
||||||
|
print("Code verified, reset token received")
|
||||||
|
} else {
|
||||||
|
self.isLoading = false
|
||||||
|
self.errorMessage = "Failed to verify code"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func handleResetSuccess(response: ApiResultSuccess<ResetPasswordResponse>) {
|
||||||
|
isLoading = false
|
||||||
|
successMessage = "Password reset successfully! You can now log in with your new password."
|
||||||
|
currentStep = .success
|
||||||
|
|
||||||
|
print("Password reset successful")
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func handleError(error: any Error) {
|
||||||
|
self.isLoading = false
|
||||||
|
self.errorMessage = error.localizedDescription
|
||||||
|
print("Error: \(error)")
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func handleApiError(errorResult: ApiResultError) {
|
||||||
|
self.isLoading = false
|
||||||
|
|
||||||
|
// Handle specific error codes
|
||||||
|
if errorResult.code?.intValue == 429 {
|
||||||
|
self.errorMessage = "Too many requests. Please try again later."
|
||||||
|
} else if errorResult.code?.intValue == 400 {
|
||||||
|
// Parse error message from backend
|
||||||
|
let message = errorResult.message
|
||||||
|
if message.contains("expired") {
|
||||||
|
self.errorMessage = "Reset code has expired. Please request a new one."
|
||||||
|
} else if message.contains("attempts") {
|
||||||
|
self.errorMessage = "Too many failed attempts. Please request a new reset code."
|
||||||
|
} else if message.contains("Invalid") && message.contains("token") {
|
||||||
|
self.errorMessage = "Invalid or expired reset token. Please start over."
|
||||||
|
} else {
|
||||||
|
self.errorMessage = message
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.errorMessage = errorResult.message
|
||||||
|
}
|
||||||
|
|
||||||
|
print("API Error: \(errorResult.message)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Validation Helpers
|
||||||
|
|
||||||
|
private func isValidEmail(_ email: String) -> Bool {
|
||||||
|
let emailRegex = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}"
|
||||||
|
let emailPredicate = NSPredicate(format: "SELF MATCHES %@", emailRegex)
|
||||||
|
return emailPredicate.evaluate(with: email)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isValidPassword(_ password: String) -> Bool {
|
||||||
|
let hasLetter = password.range(of: "[A-Za-z]", options: .regularExpression) != nil
|
||||||
|
let hasNumber = password.range(of: "[0-9]", options: .regularExpression) != nil
|
||||||
|
return hasLetter && hasNumber
|
||||||
|
}
|
||||||
|
}
|
||||||
294
iosApp/iosApp/PasswordReset/ResetPasswordView.swift
Normal file
294
iosApp/iosApp/PasswordReset/ResetPasswordView.swift
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ResetPasswordView: View {
|
||||||
|
@ObservedObject var viewModel: PasswordResetViewModel
|
||||||
|
@FocusState private var focusedField: Field?
|
||||||
|
@State private var isNewPasswordVisible = false
|
||||||
|
@State private var isConfirmPasswordVisible = false
|
||||||
|
@Environment(\.dismiss) var dismiss
|
||||||
|
var onSuccess: () -> Void
|
||||||
|
|
||||||
|
enum Field {
|
||||||
|
case newPassword, confirmPassword
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
Color(.systemGroupedBackground)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 24) {
|
||||||
|
Spacer().frame(height: 20)
|
||||||
|
|
||||||
|
// Header
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Image(systemName: "lock.rotation")
|
||||||
|
.font(.system(size: 60))
|
||||||
|
.foregroundStyle(.blue.gradient)
|
||||||
|
.padding(.bottom, 8)
|
||||||
|
|
||||||
|
Text("Set New Password")
|
||||||
|
.font(.title)
|
||||||
|
.fontWeight(.bold)
|
||||||
|
|
||||||
|
Text("Create a strong password to secure your account")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Password Requirements
|
||||||
|
GroupBox {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Password Requirements")
|
||||||
|
.font(.subheadline)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: viewModel.newPassword.count >= 8 ? "checkmark.circle.fill" : "circle")
|
||||||
|
.foregroundColor(viewModel.newPassword.count >= 8 ? .green : .secondary)
|
||||||
|
Text("At least 8 characters")
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: hasLetter ? "checkmark.circle.fill" : "circle")
|
||||||
|
.foregroundColor(hasLetter ? .green : .secondary)
|
||||||
|
Text("Contains letters")
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: hasNumber ? "checkmark.circle.fill" : "circle")
|
||||||
|
.foregroundColor(hasNumber ? .green : .secondary)
|
||||||
|
Text("Contains numbers")
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: passwordsMatch ? "checkmark.circle.fill" : "circle")
|
||||||
|
.foregroundColor(passwordsMatch ? .green : .secondary)
|
||||||
|
Text("Passwords match")
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
|
||||||
|
// New Password Input
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("New Password")
|
||||||
|
.font(.headline)
|
||||||
|
.padding(.horizontal)
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
if isNewPasswordVisible {
|
||||||
|
TextField("Enter new password", text: $viewModel.newPassword)
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.focused($focusedField, equals: .newPassword)
|
||||||
|
.submitLabel(.next)
|
||||||
|
.onSubmit {
|
||||||
|
focusedField = .confirmPassword
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
SecureField("Enter new password", text: $viewModel.newPassword)
|
||||||
|
.focused($focusedField, equals: .newPassword)
|
||||||
|
.submitLabel(.next)
|
||||||
|
.onSubmit {
|
||||||
|
focusedField = .confirmPassword
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(action: {
|
||||||
|
isNewPasswordVisible.toggle()
|
||||||
|
}) {
|
||||||
|
Image(systemName: isNewPasswordVisible ? "eye.slash.fill" : "eye.fill")
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(height: 44)
|
||||||
|
.padding(.horizontal)
|
||||||
|
.onChange(of: viewModel.newPassword) { _, _ in
|
||||||
|
viewModel.clearError()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm Password Input
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("Confirm Password")
|
||||||
|
.font(.headline)
|
||||||
|
.padding(.horizontal)
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
if isConfirmPasswordVisible {
|
||||||
|
TextField("Re-enter new password", text: $viewModel.confirmPassword)
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.focused($focusedField, equals: .confirmPassword)
|
||||||
|
.submitLabel(.go)
|
||||||
|
.onSubmit {
|
||||||
|
viewModel.resetPassword()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
SecureField("Re-enter new password", text: $viewModel.confirmPassword)
|
||||||
|
.focused($focusedField, equals: .confirmPassword)
|
||||||
|
.submitLabel(.go)
|
||||||
|
.onSubmit {
|
||||||
|
viewModel.resetPassword()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(action: {
|
||||||
|
isConfirmPasswordVisible.toggle()
|
||||||
|
}) {
|
||||||
|
Image(systemName: isConfirmPasswordVisible ? "eye.slash.fill" : "eye.fill")
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(height: 44)
|
||||||
|
.padding(.horizontal)
|
||||||
|
.onChange(of: viewModel.confirmPassword) { _, _ in
|
||||||
|
viewModel.clearError()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error Message
|
||||||
|
if let errorMessage = viewModel.errorMessage {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.foregroundColor(.red)
|
||||||
|
Text(errorMessage)
|
||||||
|
.foregroundColor(.red)
|
||||||
|
.font(.subheadline)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color.red.opacity(0.1))
|
||||||
|
.cornerRadius(12)
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success Message
|
||||||
|
if let successMessage = viewModel.successMessage {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.foregroundColor(.green)
|
||||||
|
Text(successMessage)
|
||||||
|
.foregroundColor(.green)
|
||||||
|
.font(.subheadline)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color.green.opacity(0.1))
|
||||||
|
.cornerRadius(12)
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset Password Button
|
||||||
|
Button(action: {
|
||||||
|
viewModel.resetPassword()
|
||||||
|
}) {
|
||||||
|
HStack {
|
||||||
|
if viewModel.isLoading {
|
||||||
|
ProgressView()
|
||||||
|
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||||
|
} else {
|
||||||
|
Image(systemName: "lock.shield.fill")
|
||||||
|
Text("Reset Password")
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 50)
|
||||||
|
.background(
|
||||||
|
isFormValid && !viewModel.isLoading
|
||||||
|
? Color.blue
|
||||||
|
: Color.gray.opacity(0.3)
|
||||||
|
)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.cornerRadius(12)
|
||||||
|
}
|
||||||
|
.disabled(!isFormValid || viewModel.isLoading)
|
||||||
|
.padding(.horizontal)
|
||||||
|
|
||||||
|
// Return to Login Button (shown after success)
|
||||||
|
if viewModel.currentStep == .success {
|
||||||
|
Button(action: {
|
||||||
|
viewModel.reset()
|
||||||
|
onSuccess()
|
||||||
|
}) {
|
||||||
|
Text("Return to Login")
|
||||||
|
.font(.subheadline)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
}
|
||||||
|
.padding(.top, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer().frame(height: 20)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationBarBackButtonHidden(true)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .navigationBarLeading) {
|
||||||
|
// Only show back button if not from deep link
|
||||||
|
if viewModel.resetToken == nil || viewModel.currentStep != .resetPassword {
|
||||||
|
Button(action: {
|
||||||
|
if viewModel.currentStep == .success {
|
||||||
|
viewModel.reset()
|
||||||
|
onSuccess()
|
||||||
|
} else {
|
||||||
|
viewModel.moveToPreviousStep()
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: viewModel.currentStep == .success ? "xmark" : "chevron.left")
|
||||||
|
.font(.system(size: 16))
|
||||||
|
Text(viewModel.currentStep == .success ? "Close" : "Back")
|
||||||
|
.font(.subheadline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
focusedField = .newPassword
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Computed Properties
|
||||||
|
private var hasLetter: Bool {
|
||||||
|
viewModel.newPassword.range(of: "[A-Za-z]", options: .regularExpression) != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private var hasNumber: Bool {
|
||||||
|
viewModel.newPassword.range(of: "[0-9]", options: .regularExpression) != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private var passwordsMatch: Bool {
|
||||||
|
!viewModel.newPassword.isEmpty &&
|
||||||
|
!viewModel.confirmPassword.isEmpty &&
|
||||||
|
viewModel.newPassword == viewModel.confirmPassword
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isFormValid: Bool {
|
||||||
|
viewModel.newPassword.count >= 8 &&
|
||||||
|
hasLetter &&
|
||||||
|
hasNumber &&
|
||||||
|
passwordsMatch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
let vm = PasswordResetViewModel()
|
||||||
|
vm.currentStep = .resetPassword
|
||||||
|
vm.resetToken = "sample-token"
|
||||||
|
return ResetPasswordView(viewModel: vm, onSuccess: {})
|
||||||
|
}
|
||||||
196
iosApp/iosApp/PasswordReset/VerifyResetCodeView.swift
Normal file
196
iosApp/iosApp/PasswordReset/VerifyResetCodeView.swift
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct VerifyResetCodeView: View {
|
||||||
|
@ObservedObject var viewModel: PasswordResetViewModel
|
||||||
|
@FocusState private var isCodeFocused: Bool
|
||||||
|
@Environment(\.dismiss) var dismiss
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
Color(.systemGroupedBackground)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 24) {
|
||||||
|
Spacer().frame(height: 20)
|
||||||
|
|
||||||
|
// Header
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Image(systemName: "envelope.badge.fill")
|
||||||
|
.font(.system(size: 60))
|
||||||
|
.foregroundStyle(.blue.gradient)
|
||||||
|
.padding(.bottom, 8)
|
||||||
|
|
||||||
|
Text("Check Your Email")
|
||||||
|
.font(.title)
|
||||||
|
.fontWeight(.bold)
|
||||||
|
|
||||||
|
Text("We sent a 6-digit code to")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
|
Text(viewModel.email)
|
||||||
|
.font(.subheadline)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Info Card
|
||||||
|
GroupBox {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Image(systemName: "clock.fill")
|
||||||
|
.foregroundColor(.orange)
|
||||||
|
.font(.title2)
|
||||||
|
|
||||||
|
Text("Code expires in 15 minutes")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
|
||||||
|
// Code Input
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("Verification Code")
|
||||||
|
.font(.headline)
|
||||||
|
.padding(.horizontal)
|
||||||
|
|
||||||
|
TextField("000000", text: $viewModel.code)
|
||||||
|
.font(.system(size: 32, weight: .semibold, design: .rounded))
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.keyboardType(.numberPad)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(height: 60)
|
||||||
|
.padding(.horizontal)
|
||||||
|
.focused($isCodeFocused)
|
||||||
|
.onChange(of: viewModel.code) { _, newValue in
|
||||||
|
// Limit to 6 digits
|
||||||
|
if newValue.count > 6 {
|
||||||
|
viewModel.code = String(newValue.prefix(6))
|
||||||
|
}
|
||||||
|
// Only allow numbers
|
||||||
|
viewModel.code = newValue.filter { $0.isNumber }
|
||||||
|
viewModel.clearError()
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("Enter the 6-digit code from your email")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error Message
|
||||||
|
if let errorMessage = viewModel.errorMessage {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.foregroundColor(.red)
|
||||||
|
Text(errorMessage)
|
||||||
|
.foregroundColor(.red)
|
||||||
|
.font(.subheadline)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color.red.opacity(0.1))
|
||||||
|
.cornerRadius(12)
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success Message
|
||||||
|
if let successMessage = viewModel.successMessage {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.foregroundColor(.green)
|
||||||
|
Text(successMessage)
|
||||||
|
.foregroundColor(.green)
|
||||||
|
.font(.subheadline)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color.green.opacity(0.1))
|
||||||
|
.cornerRadius(12)
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify Button
|
||||||
|
Button(action: {
|
||||||
|
viewModel.verifyResetCode()
|
||||||
|
}) {
|
||||||
|
HStack {
|
||||||
|
if viewModel.isLoading {
|
||||||
|
ProgressView()
|
||||||
|
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||||
|
} else {
|
||||||
|
Image(systemName: "checkmark.shield.fill")
|
||||||
|
Text("Verify Code")
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 50)
|
||||||
|
.background(
|
||||||
|
viewModel.code.count == 6 && !viewModel.isLoading
|
||||||
|
? Color.blue
|
||||||
|
: Color.gray.opacity(0.3)
|
||||||
|
)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.cornerRadius(12)
|
||||||
|
}
|
||||||
|
.disabled(viewModel.code.count != 6 || viewModel.isLoading)
|
||||||
|
.padding(.horizontal)
|
||||||
|
|
||||||
|
Spacer().frame(height: 20)
|
||||||
|
|
||||||
|
// Help Section
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Text("Didn't receive the code?")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
|
Button(action: {
|
||||||
|
// Clear code and go back to request new one
|
||||||
|
viewModel.code = ""
|
||||||
|
viewModel.clearError()
|
||||||
|
viewModel.currentStep = .requestCode
|
||||||
|
}) {
|
||||||
|
Text("Send New Code")
|
||||||
|
.font(.subheadline)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("Check your spam folder if you don't see it")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.horizontal, 32)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationBarBackButtonHidden(true)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .navigationBarLeading) {
|
||||||
|
Button(action: {
|
||||||
|
viewModel.moveToPreviousStep()
|
||||||
|
}) {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: "chevron.left")
|
||||||
|
.font(.system(size: 16))
|
||||||
|
Text("Back")
|
||||||
|
.font(.subheadline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
isCodeFocused = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
let vm = PasswordResetViewModel()
|
||||||
|
vm.email = "test@example.com"
|
||||||
|
vm.currentStep = .verifyCode
|
||||||
|
return VerifyResetCodeView(viewModel: vm)
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ import ComposeApp
|
|||||||
|
|
||||||
@main
|
@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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user