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:
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user