Add auto-login after password reset on iOS and Android

- Add LOGGING_IN step to PasswordResetStep enum on both platforms
- Auto-login with new credentials after successful password reset
- Navigate directly to main app (or verification screen if unverified)
- Show "Logging in..." state during auto-login process
- Hide back button during auto-login to prevent interruption
- Fall back to "Return to Login" if auto-login fails

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-12-06 00:19:47 -06:00
parent 859a6679ed
commit e13f2702a5
8 changed files with 181 additions and 21 deletions

View File

@@ -289,9 +289,28 @@ fun App(
} }
val passwordResetViewModel: PasswordResetViewModel = viewModel(parentEntry) { PasswordResetViewModel() } val passwordResetViewModel: PasswordResetViewModel = viewModel(parentEntry) { PasswordResetViewModel() }
// Set up auto-login callback
LaunchedEffect(Unit) {
passwordResetViewModel.onLoginSuccess = { verified ->
isLoggedIn = true
isVerified = verified
onClearDeepLinkToken()
// Navigate directly to main app or verification screen
if (verified) {
navController.navigate(MainRoute) {
popUpTo<ForgotPasswordRoute> { inclusive = true }
}
} else {
navController.navigate(VerifyEmailRoute) {
popUpTo<ForgotPasswordRoute> { inclusive = true }
}
}
}
}
ResetPasswordScreen( ResetPasswordScreen(
onPasswordResetSuccess = { onPasswordResetSuccess = {
// Clear deep link token and navigate back to login after successful password reset // Fallback: manual return to login (only shown if auto-login fails)
onClearDeepLinkToken() onClearDeepLinkToken()
navController.navigate(LoginRoute) { navController.navigate(LoginRoute) {
popUpTo<ForgotPasswordRoute> { inclusive = true } popUpTo<ForgotPasswordRoute> { inclusive = true }

View File

@@ -36,6 +36,7 @@ fun ResetPasswordScreen(
var confirmPasswordVisible by remember { mutableStateOf(false) } var confirmPasswordVisible by remember { mutableStateOf(false) }
val resetPasswordState by viewModel.resetPasswordState.collectAsState() val resetPasswordState by viewModel.resetPasswordState.collectAsState()
val loginState by viewModel.loginState.collectAsState()
val currentStep by viewModel.currentStep.collectAsState() val currentStep by viewModel.currentStep.collectAsState()
// Handle errors for password reset // Handle errors for password reset
@@ -50,6 +51,7 @@ fun ResetPasswordScreen(
} }
val isLoading = resetPasswordState is ApiResult.Loading val isLoading = resetPasswordState is ApiResult.Loading
val isLoggingIn = currentStep == com.example.casera.viewmodel.PasswordResetStep.LOGGING_IN
val isSuccess = currentStep == com.example.casera.viewmodel.PasswordResetStep.SUCCESS val isSuccess = currentStep == com.example.casera.viewmodel.PasswordResetStep.SUCCESS
// Password validation // Password validation
@@ -63,9 +65,12 @@ fun ResetPasswordScreen(
TopAppBar( TopAppBar(
title = { Text(stringResource(Res.string.auth_reset_title)) }, title = { Text(stringResource(Res.string.auth_reset_title)) },
navigationIcon = { navigationIcon = {
onNavigateBack?.let { callback -> // Hide back button while logging in
IconButton(onClick = callback) { if (!isLoggingIn) {
Icon(Icons.Default.ArrowBack, contentDescription = stringResource(Res.string.common_back)) onNavigateBack?.let { callback ->
IconButton(onClick = callback) {
Icon(Icons.Default.ArrowBack, contentDescription = stringResource(Res.string.common_back))
}
} }
} }
}, },
@@ -239,15 +244,21 @@ fun ResetPasswordScreen(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(56.dp), .height(56.dp),
enabled = isFormValid && !isLoading, enabled = isFormValid && !isLoading && !isLoggingIn,
shape = RoundedCornerShape(12.dp) shape = RoundedCornerShape(12.dp)
) { ) {
if (isLoading) { if (isLoading || isLoggingIn) {
CircularProgressIndicator( CircularProgressIndicator(
modifier = Modifier.size(24.dp), modifier = Modifier.size(24.dp),
color = MaterialTheme.colorScheme.onPrimary, color = MaterialTheme.colorScheme.onPrimary,
strokeWidth = 2.dp strokeWidth = 2.dp
) )
Spacer(modifier = Modifier.width(8.dp))
Text(
if (isLoggingIn) "Logging in..." else "Resetting...",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
} else { } else {
Icon(Icons.Default.LockReset, contentDescription = null) Icon(Icons.Default.LockReset, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))

View File

@@ -13,6 +13,7 @@ enum class PasswordResetStep {
REQUEST_CODE, REQUEST_CODE,
VERIFY_CODE, VERIFY_CODE,
RESET_PASSWORD, RESET_PASSWORD,
LOGGING_IN,
SUCCESS SUCCESS
} }
@@ -29,6 +30,12 @@ class PasswordResetViewModel(
private val _resetPasswordState = MutableStateFlow<ApiResult<ResetPasswordResponse>>(ApiResult.Idle) private val _resetPasswordState = MutableStateFlow<ApiResult<ResetPasswordResponse>>(ApiResult.Idle)
val resetPasswordState: StateFlow<ApiResult<ResetPasswordResponse>> = _resetPasswordState val resetPasswordState: StateFlow<ApiResult<ResetPasswordResponse>> = _resetPasswordState
private val _loginState = MutableStateFlow<ApiResult<AuthResponse>>(ApiResult.Idle)
val loginState: StateFlow<ApiResult<AuthResponse>> = _loginState
// Callback for successful login after password reset
var onLoginSuccess: ((Boolean) -> Unit)? = null
private val _currentStep = MutableStateFlow( private val _currentStep = MutableStateFlow(
if (deepLinkToken != null) PasswordResetStep.RESET_PASSWORD else PasswordResetStep.REQUEST_CODE if (deepLinkToken != null) PasswordResetStep.RESET_PASSWORD else PasswordResetStep.REQUEST_CODE
) )
@@ -80,6 +87,8 @@ class PasswordResetViewModel(
} }
} }
private var _newPassword: String = ""
fun resetPassword(newPassword: String, confirmPassword: String) { fun resetPassword(newPassword: String, confirmPassword: String) {
val token = _resetToken.value val token = _resetToken.value
if (token == null) { if (token == null) {
@@ -87,6 +96,8 @@ class PasswordResetViewModel(
return return
} }
_newPassword = newPassword
viewModelScope.launch { viewModelScope.launch {
_resetPasswordState.value = ApiResult.Loading _resetPasswordState.value = ApiResult.Loading
// Note: confirmPassword is for UI validation only, not sent to API // Note: confirmPassword is for UI validation only, not sent to API
@@ -98,7 +109,9 @@ class PasswordResetViewModel(
) )
_resetPasswordState.value = when (result) { _resetPasswordState.value = when (result) {
is ApiResult.Success -> { is ApiResult.Success -> {
_currentStep.value = PasswordResetStep.SUCCESS // Password reset successful - now auto-login
_currentStep.value = PasswordResetStep.LOGGING_IN
autoLogin()
ApiResult.Success(result.data) ApiResult.Success(result.data)
} }
is ApiResult.Error -> result is ApiResult.Error -> result
@@ -107,11 +120,46 @@ class PasswordResetViewModel(
} }
} }
private suspend fun autoLogin() {
val username = _email.value
if (username.isEmpty()) {
// If we don't have the email (e.g., deep link flow), fall back to manual login
_currentStep.value = PasswordResetStep.SUCCESS
return
}
_loginState.value = ApiResult.Loading
val loginResult = APILayer.login(LoginRequest(username = username, password = _newPassword))
when (loginResult) {
is ApiResult.Success -> {
val isVerified = loginResult.data.user.verified
// Initialize lookups
APILayer.initializeLookups()
_loginState.value = loginResult
// Call the login success callback
onLoginSuccess?.invoke(isVerified)
}
is ApiResult.Error -> {
// Auto-login failed, fall back to manual login
println("Auto-login failed: ${loginResult.message}")
_loginState.value = ApiResult.Idle
_currentStep.value = PasswordResetStep.SUCCESS
}
else -> {
_currentStep.value = PasswordResetStep.SUCCESS
}
}
}
fun moveToNextStep() { fun moveToNextStep() {
_currentStep.value = when (_currentStep.value) { _currentStep.value = when (_currentStep.value) {
PasswordResetStep.REQUEST_CODE -> PasswordResetStep.VERIFY_CODE PasswordResetStep.REQUEST_CODE -> PasswordResetStep.VERIFY_CODE
PasswordResetStep.VERIFY_CODE -> PasswordResetStep.RESET_PASSWORD PasswordResetStep.VERIFY_CODE -> PasswordResetStep.RESET_PASSWORD
PasswordResetStep.RESET_PASSWORD -> PasswordResetStep.SUCCESS PasswordResetStep.RESET_PASSWORD -> PasswordResetStep.LOGGING_IN
PasswordResetStep.LOGGING_IN -> PasswordResetStep.SUCCESS
PasswordResetStep.SUCCESS -> PasswordResetStep.SUCCESS PasswordResetStep.SUCCESS -> PasswordResetStep.SUCCESS
} }
} }
@@ -121,6 +169,7 @@ class PasswordResetViewModel(
PasswordResetStep.REQUEST_CODE -> PasswordResetStep.REQUEST_CODE PasswordResetStep.REQUEST_CODE -> PasswordResetStep.REQUEST_CODE
PasswordResetStep.VERIFY_CODE -> PasswordResetStep.REQUEST_CODE PasswordResetStep.VERIFY_CODE -> PasswordResetStep.REQUEST_CODE
PasswordResetStep.RESET_PASSWORD -> PasswordResetStep.VERIFY_CODE PasswordResetStep.RESET_PASSWORD -> PasswordResetStep.VERIFY_CODE
PasswordResetStep.LOGGING_IN -> PasswordResetStep.LOGGING_IN // Can't go back while logging in
PasswordResetStep.SUCCESS -> PasswordResetStep.SUCCESS PasswordResetStep.SUCCESS -> PasswordResetStep.SUCCESS
} }
} }

View File

@@ -17388,6 +17388,9 @@
}, },
"Log in" : { "Log in" : {
},
"Logging in..." : {
}, },
"Mark Task In Progress" : { "Mark Task In Progress" : {
"comment" : "A button label that says \"Mark Task In Progress\".", "comment" : "A button label that says \"Mark Task In Progress\".",
@@ -21300,6 +21303,9 @@
"Reset Password" : { "Reset Password" : {
"comment" : "The title of the screen where users can reset their passwords.", "comment" : "The title of the screen where users can reset their passwords.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
},
"Resetting..." : {
}, },
"Residences" : { "Residences" : {
"comment" : "A tab label for the \"Residences\" section in the main tab view.", "comment" : "A tab label for the \"Residences\" section in the main tab view.",

View File

@@ -336,7 +336,16 @@ struct LoginView: View {
RegisterView() RegisterView()
} }
.sheet(isPresented: $showPasswordReset) { .sheet(isPresented: $showPasswordReset) {
PasswordResetFlow(resetToken: resetToken) PasswordResetFlow(resetToken: resetToken, onLoginSuccess: { isVerified in
// Update the shared authentication manager
AuthenticationManager.shared.login(verified: isVerified)
if isVerified {
// User is verified, call the success callback
self.onLoginSuccess?()
}
// If not verified, RootView will handle showing VerifyEmailView
})
} }
.onChange(of: resetToken) { _, token in .onChange(of: resetToken) { _, token in
if token != nil { if token != nil {

View File

@@ -3,9 +3,11 @@ import SwiftUI
struct PasswordResetFlow: View { struct PasswordResetFlow: View {
@StateObject private var viewModel: PasswordResetViewModel @StateObject private var viewModel: PasswordResetViewModel
@Environment(\.dismiss) var dismiss @Environment(\.dismiss) var dismiss
var onLoginSuccess: ((Bool) -> Void)?
init(resetToken: String? = nil) { init(resetToken: String? = nil, onLoginSuccess: ((Bool) -> Void)? = nil) {
_viewModel = StateObject(wrappedValue: PasswordResetViewModel(resetToken: resetToken)) _viewModel = StateObject(wrappedValue: PasswordResetViewModel(resetToken: resetToken))
self.onLoginSuccess = onLoginSuccess
} }
var body: some View { var body: some View {
@@ -15,13 +17,22 @@ struct PasswordResetFlow: View {
ForgotPasswordView(viewModel: viewModel) ForgotPasswordView(viewModel: viewModel)
case .verifyCode: case .verifyCode:
VerifyResetCodeView(viewModel: viewModel) VerifyResetCodeView(viewModel: viewModel)
case .resetPassword, .success: case .resetPassword, .loggingIn, .success:
ResetPasswordView(viewModel: viewModel, onSuccess: { ResetPasswordView(viewModel: viewModel, onSuccess: {
dismiss() dismiss()
}) })
} }
} }
.animation(.easeInOut, value: viewModel.currentStep) .animation(.easeInOut, value: viewModel.currentStep)
.onAppear {
// Set up callback for auto-login success
viewModel.onLoginSuccess = { [self] isVerified in
// Dismiss the sheet first
dismiss()
// Then call the parent's login success handler
onLoginSuccess?(isVerified)
}
}
} }
} }

View File

@@ -6,7 +6,8 @@ enum PasswordResetStep: CaseIterable {
case requestCode // Step 1: Enter email case requestCode // Step 1: Enter email
case verifyCode // Step 2: Enter 6-digit code case verifyCode // Step 2: Enter 6-digit code
case resetPassword // Step 3: Set new password case resetPassword // Step 3: Set new password
case success // Final: Success confirmation case loggingIn // Auto-login in progress
case success // Final: Success confirmation (only used if auto-login fails)
} }
/// ViewModel for password reset flow. /// ViewModel for password reset flow.
@@ -24,6 +25,9 @@ class PasswordResetViewModel: ObservableObject {
@Published var currentStep: PasswordResetStep = .requestCode @Published var currentStep: PasswordResetStep = .requestCode
@Published var resetToken: String? @Published var resetToken: String?
// Callback for successful login after password reset
var onLoginSuccess: ((Bool) -> Void)?
// MARK: - Initialization // MARK: - Initialization
init(resetToken: String? = nil) { init(resetToken: String? = nil) {
// If we have a reset token from deep link, skip to password reset step // If we have a reset token from deep link, skip to password reset step
@@ -141,9 +145,10 @@ class PasswordResetViewModel: ObservableObject {
let result = try await APILayer.shared.resetPassword(request: request) let result = try await APILayer.shared.resetPassword(request: request)
if result is ApiResultSuccess<ResetPasswordResponse> { if result is ApiResultSuccess<ResetPasswordResponse> {
self.isLoading = false // Password reset successful - now auto-login
self.successMessage = "Password reset successfully! You can now log in with your new password." self.successMessage = "Password reset successfully! Logging you in..."
self.currentStep = .success self.currentStep = .loggingIn
await self.autoLogin()
} else if let error = result as? ApiResultError { } else if let error = result as? ApiResultError {
self.isLoading = false self.isLoading = false
self.errorMessage = ErrorMessageParser.parse(error.message) self.errorMessage = ErrorMessageParser.parse(error.message)
@@ -155,6 +160,51 @@ class PasswordResetViewModel: ObservableObject {
} }
} }
/// Auto-login after successful password reset
private func autoLogin() async {
// Use the email from the reset flow as the username
let username = email
guard !username.isEmpty else {
// If we don't have the email (e.g., deep link flow), fall back to manual login
self.isLoading = false
self.successMessage = "Password reset successfully! You can now log in with your new password."
self.currentStep = .success
return
}
do {
let loginResult = try await APILayer.shared.login(
request: LoginRequest(username: username, password: newPassword)
)
if let success = loginResult as? ApiResultSuccess<AuthResponse>,
let response = success.data {
let isVerified = response.user.verified
// Initialize lookups
_ = try? await APILayer.shared.initializeLookups()
self.isLoading = false
// Call the login success callback
self.onLoginSuccess?(isVerified)
} else if let error = loginResult as? ApiResultError {
// Auto-login failed, fall back to manual login
print("Auto-login failed: \(error.message)")
self.isLoading = false
self.successMessage = "Password reset successfully! You can now log in with your new password."
self.currentStep = .success
}
} catch {
// Auto-login failed, fall back to manual login
print("Auto-login error: \(error.localizedDescription)")
self.isLoading = false
self.successMessage = "Password reset successfully! You can now log in with your new password."
self.currentStep = .success
}
}
/// Navigate to next step /// Navigate to next step
func moveToNextStep() { func moveToNextStep() {
switch currentStep { switch currentStep {
@@ -163,6 +213,8 @@ class PasswordResetViewModel: ObservableObject {
case .verifyCode: case .verifyCode:
currentStep = .resetPassword currentStep = .resetPassword
case .resetPassword: case .resetPassword:
currentStep = .loggingIn
case .loggingIn:
currentStep = .success currentStep = .success
case .success: case .success:
break break
@@ -178,7 +230,7 @@ class PasswordResetViewModel: ObservableObject {
currentStep = .requestCode currentStep = .requestCode
case .resetPassword: case .resetPassword:
currentStep = .verifyCode currentStep = .verifyCode
case .success: case .loggingIn, .success:
break break
} }
} }

View File

@@ -182,8 +182,11 @@ struct ResetPasswordView: View {
}) { }) {
HStack { HStack {
Spacer() Spacer()
if viewModel.isLoading { if viewModel.isLoading || viewModel.currentStep == .loggingIn {
ProgressView() ProgressView()
.padding(.trailing, 8)
Text(viewModel.currentStep == .loggingIn ? "Logging in..." : "Resetting...")
.fontWeight(.semibold)
} else { } else {
Label("Reset Password", systemImage: "lock.shield.fill") Label("Reset Password", systemImage: "lock.shield.fill")
.fontWeight(.semibold) .fontWeight(.semibold)
@@ -191,9 +194,9 @@ struct ResetPasswordView: View {
Spacer() Spacer()
} }
} }
.disabled(!isFormValid || viewModel.isLoading) .disabled(!isFormValid || viewModel.isLoading || viewModel.currentStep == .loggingIn)
// Return to Login Button (shown after success) // Return to Login Button (shown only if auto-login fails)
if viewModel.currentStep == .success { if viewModel.currentStep == .success {
Button(action: { Button(action: {
viewModel.reset() viewModel.reset()
@@ -217,8 +220,8 @@ struct ResetPasswordView: View {
.navigationBarBackButtonHidden(true) .navigationBarBackButtonHidden(true)
.toolbar { .toolbar {
ToolbarItem(placement: .navigationBarLeading) { ToolbarItem(placement: .navigationBarLeading) {
// Only show back button if not from deep link // Only show back button if not from deep link and not logging in
if viewModel.resetToken == nil || viewModel.currentStep != .resetPassword { if (viewModel.resetToken == nil || viewModel.currentStep != .resetPassword) && viewModel.currentStep != .loggingIn {
Button(action: { Button(action: {
if viewModel.currentStep == .success { if viewModel.currentStep == .success {
viewModel.reset() viewModel.reset()