Add password reset feature for iOS and Android with deep link support

Implemented complete password reset flow with email verification and deep linking:

iOS (SwiftUI):
- PasswordResetViewModel: Manages 3-step flow (request, verify, reset) with deep link support
- ForgotPasswordView: Email entry screen with success/error states
- VerifyResetCodeView: 6-digit code verification with resend option
- ResetPasswordView: Password reset with live validation and strength indicators
- PasswordResetFlow: Container managing navigation between screens
- Deep link handling in iOSApp.swift for mycrib://reset-password?token=xxx
- Info.plist: Added CFBundleURLTypes for deep link scheme

Android (Compose):
- PasswordResetViewModel: StateFlow-based state management with coroutines
- ForgotPasswordScreen: Material3 email entry with auto-navigation
- VerifyResetCodeScreen: Code verification with Material3 design
- ResetPasswordScreen: Password reset with live validation checklist
- Deep link handling in MainActivity.kt and AndroidManifest.xml
- Token cleanup callback to prevent reusing expired deep link tokens
- Shared ViewModel scoping across all password reset screens
- Improved error handling for Django validation errors

Shared:
- Routes: Added ForgotPasswordRoute, VerifyResetCodeRoute, ResetPasswordRoute
- AuthApi: Enhanced resetPassword with Django validation error parsing
- User models: Added password reset request/response models

Security features:
- Deep link tokens expire after use
- Proper token validation on backend
- Session invalidation after password change
- Password strength requirements enforced

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-11-09 18:29:29 -06:00
parent e6dc54017b
commit fdcc2a2e16
19 changed files with 2196 additions and 4 deletions

View File

@@ -4,5 +4,16 @@
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>mycrib</string>
</array>
<key>CFBundleURLName</key>
<string>com.mycrib.app</string>
</dict>
</array>
</dict>
</plist>

View File

@@ -6,7 +6,13 @@ struct LoginView: View {
@State private var showingRegister = false
@State private var showMainTab = false
@State private var showVerification = false
@State private var showPasswordReset = false
@State private var isPasswordVisible = false
@Binding var resetToken: String?
init(resetToken: Binding<String?> = .constant(nil)) {
_resetToken = resetToken
}
enum Field {
case username, password
@@ -109,6 +115,19 @@ struct LoginView: View {
.disabled(viewModel.isLoading)
}
Section {
HStack {
Spacer()
Button("Forgot Password?") {
showPasswordReset = true
}
.font(.subheadline)
.fontWeight(.semibold)
Spacer()
}
}
.listRowBackground(Color.clear)
Section {
HStack {
Spacer()
@@ -168,6 +187,15 @@ struct LoginView: View {
.sheet(isPresented: $showingRegister) {
RegisterView()
}
.sheet(isPresented: $showPasswordReset) {
PasswordResetFlow(resetToken: resetToken)
}
.onChange(of: resetToken) { _, token in
// When deep link token arrives, show password reset
if token != nil {
showPasswordReset = true
}
}
}
}
}

View File

@@ -0,0 +1,163 @@
import SwiftUI
struct ForgotPasswordView: View {
@ObservedObject var viewModel: PasswordResetViewModel
@FocusState private var isEmailFocused: Bool
@Environment(\.dismiss) var dismiss
var body: some View {
NavigationView {
ZStack {
Color(.systemGroupedBackground)
.ignoresSafeArea()
ScrollView {
VStack(spacing: 24) {
Spacer().frame(height: 20)
// Header
VStack(spacing: 12) {
Image(systemName: "key.fill")
.font(.system(size: 60))
.foregroundStyle(.blue.gradient)
.padding(.bottom, 8)
Text("Forgot Password?")
.font(.title)
.fontWeight(.bold)
Text("Enter your email address and we'll send you a code to reset your password")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal)
}
// Email Input
VStack(alignment: .leading, spacing: 12) {
Text("Email Address")
.font(.headline)
.padding(.horizontal)
TextField("Enter your email", text: $viewModel.email)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.keyboardType(.emailAddress)
.textFieldStyle(.roundedBorder)
.frame(height: 44)
.padding(.horizontal)
.focused($isEmailFocused)
.submitLabel(.go)
.onSubmit {
viewModel.requestPasswordReset()
}
.onChange(of: viewModel.email) { _, _ in
viewModel.clearError()
}
Text("We'll send a 6-digit verification code to this address")
.font(.caption)
.foregroundColor(.secondary)
.padding(.horizontal)
}
// Error Message
if let errorMessage = viewModel.errorMessage {
HStack(spacing: 12) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.red)
Text(errorMessage)
.foregroundColor(.red)
.font(.subheadline)
}
.padding()
.background(Color.red.opacity(0.1))
.cornerRadius(12)
.padding(.horizontal)
}
// Success Message
if let successMessage = viewModel.successMessage {
HStack(spacing: 12) {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
Text(successMessage)
.foregroundColor(.green)
.font(.subheadline)
}
.padding()
.background(Color.green.opacity(0.1))
.cornerRadius(12)
.padding(.horizontal)
}
// Send Code Button
Button(action: {
viewModel.requestPasswordReset()
}) {
HStack {
if viewModel.isLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
} else {
Image(systemName: "envelope.fill")
Text("Send Reset Code")
.fontWeight(.semibold)
}
}
.frame(maxWidth: .infinity)
.frame(height: 50)
.background(
!viewModel.email.isEmpty && !viewModel.isLoading
? Color.blue
: Color.gray.opacity(0.3)
)
.foregroundColor(.white)
.cornerRadius(12)
}
.disabled(viewModel.email.isEmpty || viewModel.isLoading)
.padding(.horizontal)
Spacer().frame(height: 20)
// Help Text
Text("Remember your password?")
.font(.subheadline)
.foregroundColor(.secondary)
Button(action: {
dismiss()
}) {
Text("Back to Login")
.font(.subheadline)
.fontWeight(.semibold)
}
}
}
}
.navigationBarTitleDisplayMode(.inline)
.navigationBarBackButtonHidden(true)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button(action: {
dismiss()
}) {
HStack(spacing: 4) {
Image(systemName: "chevron.left")
.font(.system(size: 16))
Text("Back")
.font(.subheadline)
}
}
}
}
.onAppear {
isEmailFocused = true
}
}
}
}
#Preview {
ForgotPasswordView(viewModel: PasswordResetViewModel())
}

View File

@@ -0,0 +1,34 @@
import SwiftUI
struct PasswordResetFlow: View {
@StateObject private var viewModel: PasswordResetViewModel
@Environment(\.dismiss) var dismiss
init(resetToken: String? = nil) {
_viewModel = StateObject(wrappedValue: PasswordResetViewModel(resetToken: resetToken))
}
var body: some View {
Group {
switch viewModel.currentStep {
case .requestCode:
ForgotPasswordView(viewModel: viewModel)
case .verifyCode:
VerifyResetCodeView(viewModel: viewModel)
case .resetPassword, .success:
ResetPasswordView(viewModel: viewModel, onSuccess: {
dismiss()
})
}
}
.animation(.easeInOut, value: viewModel.currentStep)
}
}
#Preview("Normal Flow") {
PasswordResetFlow()
}
#Preview("Deep Link Flow") {
PasswordResetFlow(resetToken: "sample-token-from-deep-link")
}

View File

@@ -0,0 +1,319 @@
import Foundation
import ComposeApp
import Combine
enum PasswordResetStep {
case requestCode // Step 1: Enter email
case verifyCode // Step 2: Enter 6-digit code
case resetPassword // Step 3: Set new password
case success // Final: Success confirmation
}
@MainActor
class PasswordResetViewModel: ObservableObject {
// MARK: - Published Properties
@Published var email: String = ""
@Published var code: String = ""
@Published var newPassword: String = ""
@Published var confirmPassword: String = ""
@Published var isLoading: Bool = false
@Published var errorMessage: String?
@Published var successMessage: String?
@Published var currentStep: PasswordResetStep = .requestCode
@Published var resetToken: String?
// MARK: - Private Properties
private let authApi: AuthApi
// MARK: - Initialization
init(resetToken: String? = nil) {
self.authApi = AuthApi(client: ApiClient_iosKt.createHttpClient())
// If we have a reset token from deep link, skip to password reset step
if let token = resetToken {
self.resetToken = token
self.currentStep = .resetPassword
}
}
// MARK: - Public Methods
/// Step 1: Request password reset code
func requestPasswordReset() {
guard !email.isEmpty else {
errorMessage = "Email is required"
return
}
guard isValidEmail(email) else {
errorMessage = "Please enter a valid email address"
return
}
isLoading = true
errorMessage = nil
let request = ForgotPasswordRequest(email: email)
authApi.forgotPassword(request: request) { result, error in
if let successResult = result as? ApiResultSuccess<ForgotPasswordResponse> {
self.handleRequestSuccess(response: successResult)
return
}
if let errorResult = result as? ApiResultError {
self.handleApiError(errorResult: errorResult)
return
}
if let error = error {
self.handleError(error: error)
return
}
self.isLoading = false
self.errorMessage = "Failed to send reset code. Please try again."
}
}
/// Step 2: Verify reset code
func verifyResetCode() {
guard !code.isEmpty else {
errorMessage = "Verification code is required"
return
}
guard code.count == 6 else {
errorMessage = "Please enter a 6-digit code"
return
}
isLoading = true
errorMessage = nil
let request = VerifyResetCodeRequest(email: email, code: code)
authApi.verifyResetCode(request: request) { result, error in
if let successResult = result as? ApiResultSuccess<VerifyResetCodeResponse> {
self.handleVerifySuccess(response: successResult)
return
}
if let errorResult = result as? ApiResultError {
self.handleApiError(errorResult: errorResult)
return
}
if let error = error {
self.handleError(error: error)
return
}
self.isLoading = false
self.errorMessage = "Failed to verify code. Please try again."
}
}
/// Step 3: Reset password
func resetPassword() {
guard !newPassword.isEmpty else {
errorMessage = "New password is required"
return
}
guard newPassword.count >= 8 else {
errorMessage = "Password must be at least 8 characters"
return
}
guard !confirmPassword.isEmpty else {
errorMessage = "Please confirm your password"
return
}
guard newPassword == confirmPassword else {
errorMessage = "Passwords do not match"
return
}
guard isValidPassword(newPassword) else {
errorMessage = "Password must contain both letters and numbers"
return
}
guard let token = resetToken else {
errorMessage = "Invalid reset token. Please start over."
return
}
isLoading = true
errorMessage = nil
let request = ResetPasswordRequest(
resetToken: token,
newPassword: newPassword,
confirmPassword: confirmPassword
)
authApi.resetPassword(request: request) { result, error in
if let successResult = result as? ApiResultSuccess<ResetPasswordResponse> {
self.handleResetSuccess(response: successResult)
return
}
if let errorResult = result as? ApiResultError {
self.handleApiError(errorResult: errorResult)
return
}
if let error = error {
self.handleError(error: error)
return
}
self.isLoading = false
self.errorMessage = "Failed to reset password. Please try again."
}
}
/// Navigate to next step
func moveToNextStep() {
switch currentStep {
case .requestCode:
currentStep = .verifyCode
case .verifyCode:
currentStep = .resetPassword
case .resetPassword:
currentStep = .success
case .success:
break
}
}
/// Navigate to previous step
func moveToPreviousStep() {
switch currentStep {
case .requestCode:
break
case .verifyCode:
currentStep = .requestCode
case .resetPassword:
currentStep = .verifyCode
case .success:
break
}
}
/// Reset all state
func reset() {
email = ""
code = ""
newPassword = ""
confirmPassword = ""
resetToken = nil
errorMessage = nil
successMessage = nil
currentStep = .requestCode
isLoading = false
}
func clearError() {
errorMessage = nil
}
func clearSuccess() {
successMessage = nil
}
// MARK: - Private Methods
@MainActor
private func handleRequestSuccess(response: ApiResultSuccess<ForgotPasswordResponse>) {
isLoading = false
successMessage = "Check your email for a 6-digit verification code"
// Automatically move to next step after short delay
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
self.successMessage = nil
self.currentStep = .verifyCode
}
print("Password reset requested for: \(email)")
}
@MainActor
private func handleVerifySuccess(response: ApiResultSuccess<VerifyResetCodeResponse>) {
if let token = response.data?.resetToken {
self.resetToken = token
self.isLoading = false
self.successMessage = "Code verified! Now set your new password"
// Automatically move to next step after short delay
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
self.successMessage = nil
self.currentStep = .resetPassword
}
print("Code verified, reset token received")
} else {
self.isLoading = false
self.errorMessage = "Failed to verify code"
}
}
@MainActor
private func handleResetSuccess(response: ApiResultSuccess<ResetPasswordResponse>) {
isLoading = false
successMessage = "Password reset successfully! You can now log in with your new password."
currentStep = .success
print("Password reset successful")
}
@MainActor
private func handleError(error: any Error) {
self.isLoading = false
self.errorMessage = error.localizedDescription
print("Error: \(error)")
}
@MainActor
private func handleApiError(errorResult: ApiResultError) {
self.isLoading = false
// Handle specific error codes
if errorResult.code?.intValue == 429 {
self.errorMessage = "Too many requests. Please try again later."
} else if errorResult.code?.intValue == 400 {
// Parse error message from backend
let message = errorResult.message
if message.contains("expired") {
self.errorMessage = "Reset code has expired. Please request a new one."
} else if message.contains("attempts") {
self.errorMessage = "Too many failed attempts. Please request a new reset code."
} else if message.contains("Invalid") && message.contains("token") {
self.errorMessage = "Invalid or expired reset token. Please start over."
} else {
self.errorMessage = message
}
} else {
self.errorMessage = errorResult.message
}
print("API Error: \(errorResult.message)")
}
// MARK: - Validation Helpers
private func isValidEmail(_ email: String) -> Bool {
let emailRegex = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}"
let emailPredicate = NSPredicate(format: "SELF MATCHES %@", emailRegex)
return emailPredicate.evaluate(with: email)
}
private func isValidPassword(_ password: String) -> Bool {
let hasLetter = password.range(of: "[A-Za-z]", options: .regularExpression) != nil
let hasNumber = password.range(of: "[0-9]", options: .regularExpression) != nil
return hasLetter && hasNumber
}
}

View File

@@ -0,0 +1,294 @@
import SwiftUI
struct ResetPasswordView: View {
@ObservedObject var viewModel: PasswordResetViewModel
@FocusState private var focusedField: Field?
@State private var isNewPasswordVisible = false
@State private var isConfirmPasswordVisible = false
@Environment(\.dismiss) var dismiss
var onSuccess: () -> Void
enum Field {
case newPassword, confirmPassword
}
var body: some View {
ZStack {
Color(.systemGroupedBackground)
.ignoresSafeArea()
ScrollView {
VStack(spacing: 24) {
Spacer().frame(height: 20)
// Header
VStack(spacing: 12) {
Image(systemName: "lock.rotation")
.font(.system(size: 60))
.foregroundStyle(.blue.gradient)
.padding(.bottom, 8)
Text("Set New Password")
.font(.title)
.fontWeight(.bold)
Text("Create a strong password to secure your account")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal)
}
// Password Requirements
GroupBox {
VStack(alignment: .leading, spacing: 8) {
Text("Password Requirements")
.font(.subheadline)
.fontWeight(.semibold)
HStack(spacing: 8) {
Image(systemName: viewModel.newPassword.count >= 8 ? "checkmark.circle.fill" : "circle")
.foregroundColor(viewModel.newPassword.count >= 8 ? .green : .secondary)
Text("At least 8 characters")
.font(.caption)
}
HStack(spacing: 8) {
Image(systemName: hasLetter ? "checkmark.circle.fill" : "circle")
.foregroundColor(hasLetter ? .green : .secondary)
Text("Contains letters")
.font(.caption)
}
HStack(spacing: 8) {
Image(systemName: hasNumber ? "checkmark.circle.fill" : "circle")
.foregroundColor(hasNumber ? .green : .secondary)
Text("Contains numbers")
.font(.caption)
}
HStack(spacing: 8) {
Image(systemName: passwordsMatch ? "checkmark.circle.fill" : "circle")
.foregroundColor(passwordsMatch ? .green : .secondary)
Text("Passwords match")
.font(.caption)
}
}
.padding(.vertical, 4)
}
.padding(.horizontal)
// New Password Input
VStack(alignment: .leading, spacing: 12) {
Text("New Password")
.font(.headline)
.padding(.horizontal)
HStack {
if isNewPasswordVisible {
TextField("Enter new password", text: $viewModel.newPassword)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.focused($focusedField, equals: .newPassword)
.submitLabel(.next)
.onSubmit {
focusedField = .confirmPassword
}
} else {
SecureField("Enter new password", text: $viewModel.newPassword)
.focused($focusedField, equals: .newPassword)
.submitLabel(.next)
.onSubmit {
focusedField = .confirmPassword
}
}
Button(action: {
isNewPasswordVisible.toggle()
}) {
Image(systemName: isNewPasswordVisible ? "eye.slash.fill" : "eye.fill")
.foregroundColor(.secondary)
}
.buttonStyle(.plain)
}
.textFieldStyle(.roundedBorder)
.frame(height: 44)
.padding(.horizontal)
.onChange(of: viewModel.newPassword) { _, _ in
viewModel.clearError()
}
}
// Confirm Password Input
VStack(alignment: .leading, spacing: 12) {
Text("Confirm Password")
.font(.headline)
.padding(.horizontal)
HStack {
if isConfirmPasswordVisible {
TextField("Re-enter new password", text: $viewModel.confirmPassword)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.focused($focusedField, equals: .confirmPassword)
.submitLabel(.go)
.onSubmit {
viewModel.resetPassword()
}
} else {
SecureField("Re-enter new password", text: $viewModel.confirmPassword)
.focused($focusedField, equals: .confirmPassword)
.submitLabel(.go)
.onSubmit {
viewModel.resetPassword()
}
}
Button(action: {
isConfirmPasswordVisible.toggle()
}) {
Image(systemName: isConfirmPasswordVisible ? "eye.slash.fill" : "eye.fill")
.foregroundColor(.secondary)
}
.buttonStyle(.plain)
}
.textFieldStyle(.roundedBorder)
.frame(height: 44)
.padding(.horizontal)
.onChange(of: viewModel.confirmPassword) { _, _ in
viewModel.clearError()
}
}
// Error Message
if let errorMessage = viewModel.errorMessage {
HStack(spacing: 12) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.red)
Text(errorMessage)
.foregroundColor(.red)
.font(.subheadline)
}
.padding()
.background(Color.red.opacity(0.1))
.cornerRadius(12)
.padding(.horizontal)
}
// Success Message
if let successMessage = viewModel.successMessage {
HStack(spacing: 12) {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
Text(successMessage)
.foregroundColor(.green)
.font(.subheadline)
.multilineTextAlignment(.center)
}
.padding()
.background(Color.green.opacity(0.1))
.cornerRadius(12)
.padding(.horizontal)
}
// Reset Password Button
Button(action: {
viewModel.resetPassword()
}) {
HStack {
if viewModel.isLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
} else {
Image(systemName: "lock.shield.fill")
Text("Reset Password")
.fontWeight(.semibold)
}
}
.frame(maxWidth: .infinity)
.frame(height: 50)
.background(
isFormValid && !viewModel.isLoading
? Color.blue
: Color.gray.opacity(0.3)
)
.foregroundColor(.white)
.cornerRadius(12)
}
.disabled(!isFormValid || viewModel.isLoading)
.padding(.horizontal)
// Return to Login Button (shown after success)
if viewModel.currentStep == .success {
Button(action: {
viewModel.reset()
onSuccess()
}) {
Text("Return to Login")
.font(.subheadline)
.fontWeight(.semibold)
}
.padding(.top, 8)
}
Spacer().frame(height: 20)
}
}
}
.navigationBarBackButtonHidden(true)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
// Only show back button if not from deep link
if viewModel.resetToken == nil || viewModel.currentStep != .resetPassword {
Button(action: {
if viewModel.currentStep == .success {
viewModel.reset()
onSuccess()
} else {
viewModel.moveToPreviousStep()
}
}) {
HStack(spacing: 4) {
Image(systemName: viewModel.currentStep == .success ? "xmark" : "chevron.left")
.font(.system(size: 16))
Text(viewModel.currentStep == .success ? "Close" : "Back")
.font(.subheadline)
}
}
}
}
}
.onAppear {
focusedField = .newPassword
}
}
// Computed Properties
private var hasLetter: Bool {
viewModel.newPassword.range(of: "[A-Za-z]", options: .regularExpression) != nil
}
private var hasNumber: Bool {
viewModel.newPassword.range(of: "[0-9]", options: .regularExpression) != nil
}
private var passwordsMatch: Bool {
!viewModel.newPassword.isEmpty &&
!viewModel.confirmPassword.isEmpty &&
viewModel.newPassword == viewModel.confirmPassword
}
private var isFormValid: Bool {
viewModel.newPassword.count >= 8 &&
hasLetter &&
hasNumber &&
passwordsMatch
}
}
#Preview {
let vm = PasswordResetViewModel()
vm.currentStep = .resetPassword
vm.resetToken = "sample-token"
return ResetPasswordView(viewModel: vm, onSuccess: {})
}

View File

@@ -0,0 +1,196 @@
import SwiftUI
struct VerifyResetCodeView: View {
@ObservedObject var viewModel: PasswordResetViewModel
@FocusState private var isCodeFocused: Bool
@Environment(\.dismiss) var dismiss
var body: some View {
ZStack {
Color(.systemGroupedBackground)
.ignoresSafeArea()
ScrollView {
VStack(spacing: 24) {
Spacer().frame(height: 20)
// Header
VStack(spacing: 12) {
Image(systemName: "envelope.badge.fill")
.font(.system(size: 60))
.foregroundStyle(.blue.gradient)
.padding(.bottom, 8)
Text("Check Your Email")
.font(.title)
.fontWeight(.bold)
Text("We sent a 6-digit code to")
.font(.subheadline)
.foregroundColor(.secondary)
Text(viewModel.email)
.font(.subheadline)
.fontWeight(.semibold)
.foregroundColor(.primary)
.padding(.horizontal)
}
// Info Card
GroupBox {
HStack(spacing: 12) {
Image(systemName: "clock.fill")
.foregroundColor(.orange)
.font(.title2)
Text("Code expires in 15 minutes")
.font(.subheadline)
.foregroundColor(.primary)
.fontWeight(.semibold)
}
.padding(.vertical, 4)
}
.padding(.horizontal)
// Code Input
VStack(alignment: .leading, spacing: 12) {
Text("Verification Code")
.font(.headline)
.padding(.horizontal)
TextField("000000", text: $viewModel.code)
.font(.system(size: 32, weight: .semibold, design: .rounded))
.multilineTextAlignment(.center)
.keyboardType(.numberPad)
.textFieldStyle(.roundedBorder)
.frame(height: 60)
.padding(.horizontal)
.focused($isCodeFocused)
.onChange(of: viewModel.code) { _, newValue in
// Limit to 6 digits
if newValue.count > 6 {
viewModel.code = String(newValue.prefix(6))
}
// Only allow numbers
viewModel.code = newValue.filter { $0.isNumber }
viewModel.clearError()
}
Text("Enter the 6-digit code from your email")
.font(.caption)
.foregroundColor(.secondary)
.padding(.horizontal)
}
// Error Message
if let errorMessage = viewModel.errorMessage {
HStack(spacing: 12) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.red)
Text(errorMessage)
.foregroundColor(.red)
.font(.subheadline)
}
.padding()
.background(Color.red.opacity(0.1))
.cornerRadius(12)
.padding(.horizontal)
}
// Success Message
if let successMessage = viewModel.successMessage {
HStack(spacing: 12) {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
Text(successMessage)
.foregroundColor(.green)
.font(.subheadline)
}
.padding()
.background(Color.green.opacity(0.1))
.cornerRadius(12)
.padding(.horizontal)
}
// Verify Button
Button(action: {
viewModel.verifyResetCode()
}) {
HStack {
if viewModel.isLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
} else {
Image(systemName: "checkmark.shield.fill")
Text("Verify Code")
.fontWeight(.semibold)
}
}
.frame(maxWidth: .infinity)
.frame(height: 50)
.background(
viewModel.code.count == 6 && !viewModel.isLoading
? Color.blue
: Color.gray.opacity(0.3)
)
.foregroundColor(.white)
.cornerRadius(12)
}
.disabled(viewModel.code.count != 6 || viewModel.isLoading)
.padding(.horizontal)
Spacer().frame(height: 20)
// Help Section
VStack(spacing: 12) {
Text("Didn't receive the code?")
.font(.subheadline)
.foregroundColor(.secondary)
Button(action: {
// Clear code and go back to request new one
viewModel.code = ""
viewModel.clearError()
viewModel.currentStep = .requestCode
}) {
Text("Send New Code")
.font(.subheadline)
.fontWeight(.semibold)
}
Text("Check your spam folder if you don't see it")
.font(.caption)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
}
}
}
}
.navigationBarBackButtonHidden(true)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button(action: {
viewModel.moveToPreviousStep()
}) {
HStack(spacing: 4) {
Image(systemName: "chevron.left")
.font(.system(size: 16))
Text("Back")
.font(.subheadline)
}
}
}
}
.onAppear {
isCodeFocused = true
}
}
}
#Preview {
let vm = PasswordResetViewModel()
vm.email = "test@example.com"
vm.currentStep = .verifyCode
return VerifyResetCodeView(viewModel: vm)
}

View File

@@ -3,6 +3,8 @@ import ComposeApp
@main
struct iOSApp: App {
@State private var deepLinkResetToken: String?
init() {
// Initialize TokenStorage once at app startup
TokenStorage.shared.initialize(manager: TokenManager())
@@ -10,7 +12,32 @@ struct iOSApp: App {
var body: some Scene {
WindowGroup {
LoginView()
LoginView(resetToken: $deepLinkResetToken)
.onOpenURL { url in
handleDeepLink(url: url)
}
}
}
// MARK: - Deep Link Handling
private func handleDeepLink(url: URL) {
print("Deep link received: \(url)")
// Handle mycrib://reset-password?token=xxx
guard url.scheme == "mycrib",
url.host == "reset-password" else {
print("Unrecognized deep link scheme or host")
return
}
// Parse token from query parameters
if let components = URLComponents(url: url, resolvingAgainstBaseURL: true),
let queryItems = components.queryItems,
let token = queryItems.first(where: { $0.name == "token" })?.value {
print("Reset token extracted: \(token)")
deepLinkResetToken = token
} else {
print("No token found in deep link")
}
}
}