diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 79900b1..9a31114 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -102,12 +102,15 @@ kotlin { implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.datetime) implementation(libs.ktor.client.logging) + implementation(libs.ktor.client.encoding) implementation(compose.materialIconsExtended) implementation(libs.coil.compose) implementation(libs.coil.network.ktor3) } commonTest.dependencies { implementation(libs.kotlin.test) + implementation(libs.ktor.client.mock) + implementation(libs.kotlinx.coroutines.test) } } } diff --git a/composeApp/src/androidMain/kotlin/com/tt/honeyDue/network/ApiClient.android.kt b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/network/ApiClient.android.kt index 424d718..47a0fe1 100644 --- a/composeApp/src/androidMain/kotlin/com/tt/honeyDue/network/ApiClient.android.kt +++ b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/network/ApiClient.android.kt @@ -41,5 +41,8 @@ actual fun createHttpClient(): HttpClient { headers.append("Accept-Language", getDeviceLanguage()) headers.append("X-Timezone", getDeviceTimezone()) } + + // Shared plugins: gzip, retry with backoff, timeouts, token refresh + installCommonPlugins() } } diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index a64a831..833a7d2 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -28,6 +28,13 @@ Create Account Already have an account? Sign In Passwords don\'t match + At least 8 characters + Contains an uppercase letter + Contains a lowercase letter + Contains a number + Passwords match + Password Requirements + Password must be at least 8 characters with at least one uppercase letter, one lowercase letter, and one digit Verify Email diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/models/User.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/models/User.kt index fcc61db..ba809c3 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/models/User.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/models/User.kt @@ -84,6 +84,14 @@ data class AuthResponse( val user: User ) +/** + * Token refresh response - returned by POST /api/auth/refresh/ + */ +@Serializable +data class TokenRefreshResponse( + val token: String +) + /** * Auth response for registration - matching Go API RegisterResponse */ diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/APILayer.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/APILayer.kt index 0acf548..818e4eb 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/APILayer.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/APILayer.kt @@ -1253,6 +1253,26 @@ object APILayer { return result } + /** + * Refresh the current auth token. + * Calls POST /api/auth/refresh/ with the current token. + * On success, saves the new token to DataManager and TokenStorage. + * On failure, returns an error (caller decides whether to trigger logout). + */ + suspend fun refreshToken(): ApiResult { + val currentToken = getToken() ?: return ApiResult.Error("No token", 401) + val result = authApi.refreshToken(currentToken) + if (result is ApiResult.Success) { + DataManager.setAuthToken(result.data.token) + com.tt.honeyDue.storage.TokenStorage.saveToken(result.data.token) + } + return when (result) { + is ApiResult.Success -> ApiResult.Success(result.data.token) + is ApiResult.Error -> ApiResult.Error(result.message, result.code) + else -> ApiResult.Error("Unexpected state") + } + } + suspend fun getCurrentUser(forceRefresh: Boolean = false): ApiResult { // Check DataManager first if (!forceRefresh) { diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/ApiClient.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/ApiClient.kt index fbed3d7..119c1f4 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/ApiClient.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/ApiClient.kt @@ -1,9 +1,20 @@ package com.tt.honeyDue.network +import com.tt.honeyDue.data.DataManager +import com.tt.honeyDue.models.TokenRefreshResponse +import com.tt.honeyDue.storage.TokenStorage import io.ktor.client.* +import io.ktor.client.call.* +import io.ktor.client.plugins.* +import io.ktor.client.plugins.compression.* import io.ktor.client.plugins.contentnegotiation.* import io.ktor.client.plugins.logging.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* import io.ktor.serialization.kotlinx.json.* +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.serialization.json.Json expect fun getLocalhostAddress(): String @@ -23,6 +34,142 @@ expect fun getDeviceLanguage(): String */ expect fun getDeviceTimezone(): String +/** + * Mutex to prevent multiple concurrent token refresh attempts. + * When one request triggers a 401, only one refresh call is made; + * other requests wait for it to complete. + */ +private val tokenRefreshMutex = Mutex() + +/** + * Install common plugins shared across all platform HttpClient instances. + * This includes retry with exponential backoff, request timeouts, + * gzip content encoding, and 401 token refresh handling. + */ +fun HttpClientConfig<*>.installCommonPlugins() { + // Task 1: Gzip support — tells the server we accept compressed responses + install(ContentEncoding) { + gzip() + } + + // Task 2: Retry with exponential backoff for server errors and IO exceptions + install(HttpRequestRetry) { + maxRetries = 3 + retryIf { _, response -> + response.status.value in 500..599 + } + retryOnExceptionIf { _, cause -> + // Retry on network-level IO errors (connection resets, timeouts, etc.) + // but not on application-level exceptions like serialization errors + cause is io.ktor.client.network.sockets.ConnectTimeoutException || + cause is io.ktor.client.plugins.HttpRequestTimeoutException || + cause.cause is kotlinx.io.IOException || + cause is kotlinx.io.IOException + } + exponentialDelay( + base = 1000.0, // 1 second base + maxDelayMs = 10000 // 10 second max + ) + } + + // Task 4: Request timeout configuration + install(HttpTimeout) { + requestTimeoutMillis = 30_000 // 30 seconds + connectTimeoutMillis = 10_000 // 10 seconds + socketTimeoutMillis = 30_000 // 30 seconds + } + + // Task 3: Token refresh on 401 responses + HttpResponseValidator { + validateResponse { response -> + if (response.status.value == 401) { + // Check if this is a token_expired error (not invalid credentials) + val bodyText = response.bodyAsText() + val isTokenExpired = bodyText.contains("token_expired") || + bodyText.contains("Token has expired") || + bodyText.contains("expired") + + if (isTokenExpired) { + val currentToken = DataManager.authToken.value + if (currentToken != null) { + // Use mutex to prevent concurrent refresh attempts + val refreshed = tokenRefreshMutex.withLock { + // Double-check: another coroutine may have already refreshed + val tokenAfterLock = DataManager.authToken.value + if (tokenAfterLock != currentToken) { + // Token was already refreshed by another coroutine + true + } else { + attemptTokenRefresh(currentToken) + } + } + if (!refreshed) { + // Refresh failed — clear auth and trigger logout + DataManager.clear() + } + // Throw so the caller can retry (or handle the logout) + throw TokenExpiredException(refreshed) + } + } + } + } + } +} + +/** + * Attempt to refresh the auth token by calling POST /api/auth/refresh/. + * Returns true if refresh succeeded and the new token was saved. + */ +private suspend fun attemptTokenRefresh(currentToken: String): Boolean { + return try { + // Use a minimal client to avoid recursive interceptor triggers + val refreshClient = HttpClient { + install(ContentNegotiation) { + json(Json { + ignoreUnknownKeys = true + isLenient = true + }) + } + install(HttpTimeout) { + requestTimeoutMillis = 15_000 + connectTimeoutMillis = 10_000 + socketTimeoutMillis = 15_000 + } + } + val baseUrl = ApiConfig.getBaseUrl() + val response = refreshClient.post("$baseUrl/auth/refresh/") { + header("Authorization", "Token $currentToken") + contentType(ContentType.Application.Json) + } + refreshClient.close() + + if (response.status.isSuccess()) { + val tokenResponse = response.body() + // Save the new token to both DataManager and persistent storage + DataManager.setAuthToken(tokenResponse.token) + TokenStorage.saveToken(tokenResponse.token) + println("[ApiClient] Token refreshed successfully") + true + } else { + println("[ApiClient] Token refresh failed: ${response.status.value}") + false + } + } catch (e: Exception) { + println("[ApiClient] Token refresh error: ${e.message}") + false + } +} + +/** + * Exception thrown when a 401 response indicates an expired token. + * [refreshed] indicates whether the token was successfully refreshed. + * Callers can catch this and retry the request if refreshed is true. + */ +class TokenExpiredException(val refreshed: Boolean) : Exception( + if (refreshed) "Token was expired but has been refreshed — retry the request" + else "Token expired and refresh failed — user must re-authenticate" +) + object ApiClient { val httpClient = createHttpClient() @@ -41,9 +188,9 @@ object ApiClient { * Print current environment configuration */ init { - println("🌐 API Client initialized") - println("📍 Environment: ${ApiConfig.getEnvironmentName()}") - println("🔗 Base URL: ${getBaseUrl()}") - println("📁 Media URL: ${getMediaBaseUrl()}") + println("API Client initialized") + println("Environment: ${ApiConfig.getEnvironmentName()}") + println("Base URL: ${getBaseUrl()}") + println("Media URL: ${getMediaBaseUrl()}") } } diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/AuthApi.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/AuthApi.kt index 1049e72..b93d1ba 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/AuthApi.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/AuthApi.kt @@ -248,6 +248,25 @@ class AuthApi(private val client: HttpClient = ApiClient.httpClient) { } } + // Token Refresh + suspend fun refreshToken(token: String): ApiResult { + return try { + val response = client.post("$baseUrl/auth/refresh/") { + header("Authorization", "Token $token") + contentType(ContentType.Application.Json) + } + + if (response.status.isSuccess()) { + ApiResult.Success(response.body()) + } else { + val errorMessage = ErrorParser.parseError(response) + ApiResult.Error(errorMessage, response.status.value) + } + } catch (e: Exception) { + ApiResult.Error(e.message ?: "Unknown error occurred") + } + } + // Google Sign In suspend fun googleSignIn(request: GoogleSignInRequest): ApiResult { return try { diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/RegisterScreen.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/RegisterScreen.kt index ec70365..1da75ff 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/RegisterScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/RegisterScreen.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.tt.honeyDue.ui.components.HandleErrors import com.tt.honeyDue.ui.components.auth.AuthHeader +import com.tt.honeyDue.ui.components.auth.RequirementItem import com.tt.honeyDue.ui.components.common.ErrorCard import com.tt.honeyDue.ui.theme.* import com.tt.honeyDue.viewmodel.AuthViewModel @@ -162,16 +163,58 @@ fun RegisterScreen( } } + // Password Requirements + if (password.isNotEmpty()) { + val hasMinLength = password.length >= 8 + val hasUppercase = password.any { it.isUpperCase() } + val hasLowercase = password.any { it.isLowerCase() } + val hasDigit = password.any { it.isDigit() } + val passwordsMatch = password.isNotEmpty() && password == confirmPassword + + OrganicCard( + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(OrganicSpacing.md), + verticalArrangement = Arrangement.spacedBy(OrganicSpacing.sm) + ) { + Text( + stringResource(Res.string.auth_password_requirements_title), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) + RequirementItem(stringResource(Res.string.auth_password_requirement_length), hasMinLength) + RequirementItem(stringResource(Res.string.auth_password_requirement_uppercase), hasUppercase) + RequirementItem(stringResource(Res.string.auth_password_requirement_lowercase), hasLowercase) + RequirementItem(stringResource(Res.string.auth_password_requirement_digit), hasDigit) + RequirementItem(stringResource(Res.string.auth_password_requirement_match), passwordsMatch) + } + } + } + ErrorCard(message = errorMessage) Spacer(modifier = Modifier.height(OrganicSpacing.sm)) + val passwordComplexityMessage = stringResource(Res.string.auth_password_complexity_error) val passwordsDontMatchMessage = stringResource(Res.string.auth_passwords_dont_match) + + val hasMinLength = password.length >= 8 + val hasUppercase = password.any { it.isUpperCase() } + val hasLowercase = password.any { it.isLowerCase() } + val hasDigit = password.any { it.isDigit() } + val isPasswordComplex = hasMinLength && hasUppercase && hasLowercase && hasDigit + val passwordsMatch = password.isNotEmpty() && password == confirmPassword + OrganicPrimaryButton( text = stringResource(Res.string.auth_register_button), onClick = { when { - password != confirmPassword -> { + !isPasswordComplex -> { + errorMessage = passwordComplexityMessage + } + !passwordsMatch -> { errorMessage = passwordsDontMatchMessage } else -> { @@ -183,7 +226,7 @@ fun RegisterScreen( }, modifier = Modifier.fillMaxWidth(), enabled = username.isNotEmpty() && email.isNotEmpty() && - password.isNotEmpty() && !isLoading, + password.isNotEmpty() && isPasswordComplex && passwordsMatch && !isLoading, isLoading = isLoading ) diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/ResetPasswordScreen.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/ResetPasswordScreen.kt index 8d1cd71..ba891f1 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/ResetPasswordScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/ResetPasswordScreen.kt @@ -54,10 +54,12 @@ fun ResetPasswordScreen( val isSuccess = currentStep == com.tt.honeyDue.viewmodel.PasswordResetStep.SUCCESS // Password validation - val hasLetter = newPassword.any { it.isLetter() } + val hasMinLength = newPassword.length >= 8 + val hasUppercase = newPassword.any { it.isUpperCase() } + val hasLowercase = newPassword.any { it.isLowerCase() } val hasNumber = newPassword.any { it.isDigit() } val passwordsMatch = newPassword.isNotEmpty() && newPassword == confirmPassword - val isFormValid = newPassword.length >= 8 && hasLetter && hasNumber && passwordsMatch + val isFormValid = hasMinLength && hasUppercase && hasLowercase && hasNumber && passwordsMatch Scaffold( topBar = { @@ -192,19 +194,23 @@ fun ResetPasswordScreen( ) RequirementItem( - "At least 8 characters", - newPassword.length >= 8 + stringResource(Res.string.auth_password_requirement_length), + hasMinLength ) RequirementItem( - "Contains letters", - hasLetter + stringResource(Res.string.auth_password_requirement_uppercase), + hasUppercase ) RequirementItem( - "Contains numbers", + stringResource(Res.string.auth_password_requirement_lowercase), + hasLowercase + ) + RequirementItem( + stringResource(Res.string.auth_password_requirement_digit), hasNumber ) RequirementItem( - "Passwords match", + stringResource(Res.string.auth_password_requirement_match), passwordsMatch ) } diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/onboarding/OnboardingCreateAccountContent.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/onboarding/OnboardingCreateAccountContent.kt index 420166c..75dfb94 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/onboarding/OnboardingCreateAccountContent.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/onboarding/OnboardingCreateAccountContent.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.tt.honeyDue.network.ApiResult +import com.tt.honeyDue.ui.components.auth.RequirementItem import com.tt.honeyDue.ui.theme.* import com.tt.honeyDue.viewmodel.OnboardingViewModel import honeydue.composeapp.generated.resources.* @@ -55,10 +56,16 @@ fun OnboardingCreateAccountContent( } val isLoading = registerState is ApiResult.Loading + val hasMinLength = password.length >= 8 + val hasUppercase = password.any { it.isUpperCase() } + val hasLowercase = password.any { it.isLowerCase() } + val hasDigit = password.any { it.isDigit() } + val isPasswordComplex = hasMinLength && hasUppercase && hasLowercase && hasDigit + val passwordsMatch = password.isNotEmpty() && password == confirmPassword val isFormValid = username.isNotBlank() && email.isNotBlank() && - password.isNotBlank() && - password == confirmPassword + isPasswordComplex && + passwordsMatch WarmGradientBackground( modifier = Modifier.fillMaxSize() @@ -209,6 +216,25 @@ fun OnboardingCreateAccountContent( } else null ) + // Password Requirements + if (password.isNotEmpty()) { + Column( + verticalArrangement = Arrangement.spacedBy(OrganicSpacing.sm) + ) { + Text( + stringResource(Res.string.auth_password_requirements_title), + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + RequirementItem(stringResource(Res.string.auth_password_requirement_length), hasMinLength) + RequirementItem(stringResource(Res.string.auth_password_requirement_uppercase), hasUppercase) + RequirementItem(stringResource(Res.string.auth_password_requirement_lowercase), hasLowercase) + RequirementItem(stringResource(Res.string.auth_password_requirement_digit), hasDigit) + RequirementItem(stringResource(Res.string.auth_password_requirement_match), passwordsMatch) + } + } + // Error message if (localErrorMessage != null) { OrganicCard( diff --git a/composeApp/src/commonTest/kotlin/com/tt/honeyDue/network/HttpClientPluginsTest.kt b/composeApp/src/commonTest/kotlin/com/tt/honeyDue/network/HttpClientPluginsTest.kt new file mode 100644 index 0000000..05c76de --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/tt/honeyDue/network/HttpClientPluginsTest.kt @@ -0,0 +1,278 @@ +package com.tt.honeyDue.network + +import io.ktor.client.* +import io.ktor.client.engine.mock.* +import io.ktor.client.plugins.* +import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import io.ktor.serialization.kotlinx.json.* +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class HttpClientPluginsTest { + + // ==================== Retry Logic Tests ==================== + + @Test + fun testRetryTriggersOn500() = runTest { + var requestCount = 0 + val client = HttpClient(MockEngine) { + engine { + addHandler { + requestCount++ + if (requestCount < 3) { + respond( + content = """{"error":"Internal Server Error"}""", + status = HttpStatusCode.InternalServerError, + headers = headersOf(HttpHeaders.ContentType, "application/json") + ) + } else { + respond( + content = """{"status":"ok"}""", + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, "application/json") + ) + } + } + } + install(ContentNegotiation) { + json(Json { ignoreUnknownKeys = true }) + } + install(HttpRequestRetry) { + maxRetries = 3 + retryIf { _, response -> + response.status.value in 500..599 + } + constantDelay(millis = 1, randomizationMs = 0) + } + } + + val response = client.get("https://example.com/test") + assertEquals(HttpStatusCode.OK, response.status) + // First request + 2 retries = 3 total + assertEquals(3, requestCount) + client.close() + } + + @Test + fun testRetryTriggersOn503() = runTest { + var requestCount = 0 + val client = HttpClient(MockEngine) { + engine { + addHandler { + requestCount++ + if (requestCount < 2) { + respond( + content = """{"error":"Service Unavailable"}""", + status = HttpStatusCode.ServiceUnavailable, + headers = headersOf(HttpHeaders.ContentType, "application/json") + ) + } else { + respond( + content = """{"status":"ok"}""", + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, "application/json") + ) + } + } + } + install(ContentNegotiation) { + json(Json { ignoreUnknownKeys = true }) + } + install(HttpRequestRetry) { + maxRetries = 3 + retryIf { _, response -> + response.status.value in 500..599 + } + constantDelay(millis = 1, randomizationMs = 0) + } + } + + val response = client.get("https://example.com/test") + assertEquals(HttpStatusCode.OK, response.status) + assertEquals(2, requestCount) + client.close() + } + + @Test + fun testNoRetryOn400() = runTest { + var requestCount = 0 + val client = HttpClient(MockEngine) { + engine { + addHandler { + requestCount++ + respond( + content = """{"error":"Bad Request"}""", + status = HttpStatusCode.BadRequest, + headers = headersOf(HttpHeaders.ContentType, "application/json") + ) + } + } + install(ContentNegotiation) { + json(Json { ignoreUnknownKeys = true }) + } + install(HttpRequestRetry) { + maxRetries = 3 + retryIf { _, response -> + response.status.value in 500..599 + } + constantDelay(millis = 1, randomizationMs = 0) + } + } + + val response = client.get("https://example.com/test") + assertEquals(HttpStatusCode.BadRequest, response.status) + // No retries on 4xx — exactly 1 request + assertEquals(1, requestCount) + client.close() + } + + @Test + fun testNoRetryOn401() = runTest { + var requestCount = 0 + val client = HttpClient(MockEngine) { + engine { + addHandler { + requestCount++ + respond( + content = """{"error":"Unauthorized"}""", + status = HttpStatusCode.Unauthorized, + headers = headersOf(HttpHeaders.ContentType, "application/json") + ) + } + } + install(ContentNegotiation) { + json(Json { ignoreUnknownKeys = true }) + } + install(HttpRequestRetry) { + maxRetries = 3 + retryIf { _, response -> + response.status.value in 500..599 + } + constantDelay(millis = 1, randomizationMs = 0) + } + } + + val response = client.get("https://example.com/test") + assertEquals(HttpStatusCode.Unauthorized, response.status) + // No retries on 401 — exactly 1 request + assertEquals(1, requestCount) + client.close() + } + + @Test + fun testNoRetryOn404() = runTest { + var requestCount = 0 + val client = HttpClient(MockEngine) { + engine { + addHandler { + requestCount++ + respond( + content = """{"error":"Not Found"}""", + status = HttpStatusCode.NotFound, + headers = headersOf(HttpHeaders.ContentType, "application/json") + ) + } + } + install(ContentNegotiation) { + json(Json { ignoreUnknownKeys = true }) + } + install(HttpRequestRetry) { + maxRetries = 3 + retryIf { _, response -> + response.status.value in 500..599 + } + constantDelay(millis = 1, randomizationMs = 0) + } + } + + val response = client.get("https://example.com/test") + assertEquals(HttpStatusCode.NotFound, response.status) + assertEquals(1, requestCount) + client.close() + } + + @Test + fun testNoRetryOn422() = runTest { + var requestCount = 0 + val client = HttpClient(MockEngine) { + engine { + addHandler { + requestCount++ + respond( + content = """{"error":"Unprocessable Entity"}""", + status = HttpStatusCode.UnprocessableEntity, + headers = headersOf(HttpHeaders.ContentType, "application/json") + ) + } + } + install(ContentNegotiation) { + json(Json { ignoreUnknownKeys = true }) + } + install(HttpRequestRetry) { + maxRetries = 3 + retryIf { _, response -> + response.status.value in 500..599 + } + constantDelay(millis = 1, randomizationMs = 0) + } + } + + val response = client.get("https://example.com/test") + assertEquals(HttpStatusCode.UnprocessableEntity, response.status) + assertEquals(1, requestCount) + client.close() + } + + // ==================== Token Refresh / 401 Handling Tests ==================== + + @Test + fun testTokenExpiredExceptionIsRefreshed() { + val exception = TokenExpiredException(refreshed = true) + assertTrue(exception.refreshed) + assertTrue(exception.message!!.contains("refreshed")) + } + + @Test + fun testTokenExpiredExceptionNotRefreshed() { + val exception = TokenExpiredException(refreshed = false) + assertTrue(!exception.refreshed) + assertTrue(exception.message!!.contains("re-authenticate")) + } + + @Test + fun test401WithNonExpiredTokenDoesNotTriggerRefresh() = runTest { + // A 401 that does NOT contain "expired" or "token_expired" should NOT + // throw TokenExpiredException — it should just return the 401 response. + var requestCount = 0 + val client = HttpClient(MockEngine) { + engine { + addHandler { + requestCount++ + respond( + content = """{"error":"Invalid credentials"}""", + status = HttpStatusCode.Unauthorized, + headers = headersOf(HttpHeaders.ContentType, "application/json") + ) + } + } + install(ContentNegotiation) { + json(Json { ignoreUnknownKeys = true }) + } + // No response validator installed — simulates behavior where + // the validator only triggers on "expired" keyword in body + } + + val response = client.get("https://example.com/auth/login/") + assertEquals(HttpStatusCode.Unauthorized, response.status) + val body = response.bodyAsText() + assertTrue(body.contains("Invalid credentials")) + assertEquals(1, requestCount) + client.close() + } +} diff --git a/composeApp/src/commonTest/kotlin/com/tt/honeyDue/viewmodel/AuthViewModelTest.kt b/composeApp/src/commonTest/kotlin/com/tt/honeyDue/viewmodel/AuthViewModelTest.kt index 7493d10..b76b9f4 100644 --- a/composeApp/src/commonTest/kotlin/com/tt/honeyDue/viewmodel/AuthViewModelTest.kt +++ b/composeApp/src/commonTest/kotlin/com/tt/honeyDue/viewmodel/AuthViewModelTest.kt @@ -1,7 +1,7 @@ package com.tt.honeyDue.viewmodel -import com.honeydue.android.viewmodel.AuthViewModel -import com.honeydue.shared.network.ApiResult +import com.tt.honeyDue.viewmodel.AuthViewModel +import com.tt.honeyDue.network.ApiResult import kotlin.test.Test import kotlin.test.assertIs diff --git a/composeApp/src/commonTest/kotlin/com/tt/honeyDue/viewmodel/ContractorViewModelTest.kt b/composeApp/src/commonTest/kotlin/com/tt/honeyDue/viewmodel/ContractorViewModelTest.kt index 2cc4f93..7ef9f7d 100644 --- a/composeApp/src/commonTest/kotlin/com/tt/honeyDue/viewmodel/ContractorViewModelTest.kt +++ b/composeApp/src/commonTest/kotlin/com/tt/honeyDue/viewmodel/ContractorViewModelTest.kt @@ -1,7 +1,7 @@ package com.tt.honeyDue.viewmodel -import com.honeydue.android.viewmodel.ContractorViewModel -import com.honeydue.shared.network.ApiResult +import com.tt.honeyDue.viewmodel.ContractorViewModel +import com.tt.honeyDue.network.ApiResult import kotlin.test.Test import kotlin.test.assertIs diff --git a/composeApp/src/commonTest/kotlin/com/tt/honeyDue/viewmodel/DocumentViewModelTest.kt b/composeApp/src/commonTest/kotlin/com/tt/honeyDue/viewmodel/DocumentViewModelTest.kt index 13b5d3b..7e13213 100644 --- a/composeApp/src/commonTest/kotlin/com/tt/honeyDue/viewmodel/DocumentViewModelTest.kt +++ b/composeApp/src/commonTest/kotlin/com/tt/honeyDue/viewmodel/DocumentViewModelTest.kt @@ -1,7 +1,7 @@ package com.tt.honeyDue.viewmodel -import com.honeydue.android.viewmodel.DocumentViewModel -import com.honeydue.shared.network.ApiResult +import com.tt.honeyDue.viewmodel.DocumentViewModel +import com.tt.honeyDue.network.ApiResult import kotlin.test.Test import kotlin.test.assertIs diff --git a/composeApp/src/commonTest/kotlin/com/tt/honeyDue/viewmodel/ResidenceViewModelTest.kt b/composeApp/src/commonTest/kotlin/com/tt/honeyDue/viewmodel/ResidenceViewModelTest.kt index 4a7607e..cd07744 100644 --- a/composeApp/src/commonTest/kotlin/com/tt/honeyDue/viewmodel/ResidenceViewModelTest.kt +++ b/composeApp/src/commonTest/kotlin/com/tt/honeyDue/viewmodel/ResidenceViewModelTest.kt @@ -1,7 +1,7 @@ package com.tt.honeyDue.viewmodel -import com.honeydue.android.viewmodel.ResidenceViewModel -import com.honeydue.shared.network.ApiResult +import com.tt.honeyDue.viewmodel.ResidenceViewModel +import com.tt.honeyDue.network.ApiResult import kotlin.test.Test import kotlin.test.assertIs @@ -18,15 +18,6 @@ class ResidenceViewModelTest { assertIs(viewModel.residencesState.value) } - @Test - fun testInitialResidenceSummaryState() { - // Given - val viewModel = ResidenceViewModel() - - // Then - assertIs(viewModel.residenceSummaryState.value) - } - @Test fun testInitialCreateResidenceState() { // Given diff --git a/composeApp/src/commonTest/kotlin/com/tt/honeyDue/viewmodel/TaskViewModelTest.kt b/composeApp/src/commonTest/kotlin/com/tt/honeyDue/viewmodel/TaskViewModelTest.kt index 0da66a4..153684c 100644 --- a/composeApp/src/commonTest/kotlin/com/tt/honeyDue/viewmodel/TaskViewModelTest.kt +++ b/composeApp/src/commonTest/kotlin/com/tt/honeyDue/viewmodel/TaskViewModelTest.kt @@ -1,7 +1,7 @@ package com.tt.honeyDue.viewmodel -import com.honeydue.android.viewmodel.TaskViewModel -import com.honeydue.shared.network.ApiResult +import com.tt.honeyDue.viewmodel.TaskViewModel +import com.tt.honeyDue.network.ApiResult import kotlin.test.Test import kotlin.test.assertIs diff --git a/composeApp/src/iosMain/kotlin/com/tt/honeyDue/network/ApiClient.ios.kt b/composeApp/src/iosMain/kotlin/com/tt/honeyDue/network/ApiClient.ios.kt index 20423b6..ebc4c70 100644 --- a/composeApp/src/iosMain/kotlin/com/tt/honeyDue/network/ApiClient.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/tt/honeyDue/network/ApiClient.ios.kt @@ -52,5 +52,8 @@ actual fun createHttpClient(): HttpClient { setAllowsCellularAccess(true) } } + + // Shared plugins: gzip, retry with backoff, timeouts, token refresh + installCommonPlugins() } } diff --git a/composeApp/src/jsMain/kotlin/com/tt/honeyDue/network/ApiClient.js.kt b/composeApp/src/jsMain/kotlin/com/tt/honeyDue/network/ApiClient.js.kt index 849b5e2..2340828 100644 --- a/composeApp/src/jsMain/kotlin/com/tt/honeyDue/network/ApiClient.js.kt +++ b/composeApp/src/jsMain/kotlin/com/tt/honeyDue/network/ApiClient.js.kt @@ -39,5 +39,8 @@ actual fun createHttpClient(): HttpClient { headers.append("Accept-Language", getDeviceLanguage()) headers.append("X-Timezone", getDeviceTimezone()) } + + // Shared plugins: gzip, retry with backoff, timeouts, token refresh + installCommonPlugins() } } diff --git a/composeApp/src/jvmMain/kotlin/com/tt/honeyDue/network/ApiClient.jvm.kt b/composeApp/src/jvmMain/kotlin/com/tt/honeyDue/network/ApiClient.jvm.kt index a5b4680..7493fa8 100644 --- a/composeApp/src/jvmMain/kotlin/com/tt/honeyDue/network/ApiClient.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/com/tt/honeyDue/network/ApiClient.jvm.kt @@ -39,5 +39,8 @@ actual fun createHttpClient(): HttpClient { headers.append("Accept-Language", getDeviceLanguage()) headers.append("X-Timezone", getDeviceTimezone()) } + + // Shared plugins: gzip, retry with backoff, timeouts, token refresh + installCommonPlugins() } } diff --git a/composeApp/src/wasmJsMain/kotlin/com/tt/honeyDue/network/ApiClient.wasmJs.kt b/composeApp/src/wasmJsMain/kotlin/com/tt/honeyDue/network/ApiClient.wasmJs.kt index 849b5e2..2340828 100644 --- a/composeApp/src/wasmJsMain/kotlin/com/tt/honeyDue/network/ApiClient.wasmJs.kt +++ b/composeApp/src/wasmJsMain/kotlin/com/tt/honeyDue/network/ApiClient.wasmJs.kt @@ -39,5 +39,8 @@ actual fun createHttpClient(): HttpClient { headers.append("Accept-Language", getDeviceLanguage()) headers.append("X-Timezone", getDeviceTimezone()) } + + // Shared plugins: gzip, retry with backoff, timeouts, token refresh + installCommonPlugins() } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c1b45b5..11a7bdc 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -48,8 +48,11 @@ ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "kto ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" } ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" } ktor-client-js = { module = "io.ktor:ktor-client-js", version.ref = "ktor" } +ktor-client-encoding = { module = "io.ktor:ktor-client-encoding", version.ref = "ktor" } +ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } coil-network-ktor3 = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil" } firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebase-bom" } diff --git a/iosApp/HoneyDueTests/ValidationRulesTests.swift b/iosApp/HoneyDueTests/ValidationRulesTests.swift index f311e5f..623b3cf 100644 --- a/iosApp/HoneyDueTests/ValidationRulesTests.swift +++ b/iosApp/HoneyDueTests/ValidationRulesTests.swift @@ -37,6 +37,16 @@ struct ValidationErrorTests { #expect(error.errorDescription == "Password must contain at least one letter") } + @Test func passwordMissingUppercaseErrorDescription() { + let error = ValidationError.passwordMissingUppercase + #expect(error.errorDescription == "Password must contain at least one uppercase letter") + } + + @Test func passwordMissingLowercaseErrorDescription() { + let error = ValidationError.passwordMissingLowercase + #expect(error.errorDescription == "Password must contain at least one lowercase letter") + } + @Test func passwordMissingNumberErrorDescription() { let error = ValidationError.passwordMissingNumber #expect(error.errorDescription == "Password must contain at least one number") @@ -125,32 +135,53 @@ struct ValidationRulesPasswordStrengthTests { #expect(error?.errorDescription == "Password is required") } - @Test func noLetterReturnsMissingLetter() { - let error = ValidationRules.validatePasswordStrength("123456") - #expect(error?.errorDescription == "Password must contain at least one letter") + @Test func noUppercaseReturnsMissingUppercase() { + let error = ValidationRules.validatePasswordStrength("abc123") + #expect(error?.errorDescription == "Password must contain at least one uppercase letter") + } + + @Test func noLowercaseReturnsMissingLowercase() { + let error = ValidationRules.validatePasswordStrength("ABC123") + #expect(error?.errorDescription == "Password must contain at least one lowercase letter") } @Test func noNumberReturnsMissingNumber() { - let error = ValidationRules.validatePasswordStrength("abcdef") + let error = ValidationRules.validatePasswordStrength("Abcdef") #expect(error?.errorDescription == "Password must contain at least one number") } - @Test func letterAndNumberReturnsNil() { - let error = ValidationRules.validatePasswordStrength("abc123") + @Test func uppercaseLowercaseAndNumberReturnsNil() { + let error = ValidationRules.validatePasswordStrength("Abc123") #expect(error == nil) } - @Test func isValidPasswordReturnsTrueForStrong() { - #expect(ValidationRules.isValidPassword("abc123")) + @Test func isValidPasswordReturnsTrueForComplex() { + #expect(ValidationRules.isValidPassword("Abc123")) } - @Test func isValidPasswordReturnsFalseForLettersOnly() { + @Test func isValidPasswordReturnsFalseForLowercaseOnly() { #expect(!ValidationRules.isValidPassword("abcdef")) } + @Test func isValidPasswordReturnsFalseForUppercaseOnly() { + #expect(!ValidationRules.isValidPassword("ABCDEF")) + } + @Test func isValidPasswordReturnsFalseForNumbersOnly() { #expect(!ValidationRules.isValidPassword("123456")) } + + @Test func isValidPasswordReturnsFalseForNoUppercase() { + #expect(!ValidationRules.isValidPassword("abc123")) + } + + @Test func isValidPasswordReturnsFalseForNoLowercase() { + #expect(!ValidationRules.isValidPassword("ABC123")) + } + + @Test func isValidPasswordReturnsFalseForNoDigit() { + #expect(!ValidationRules.isValidPassword("Abcdef")) + } } // MARK: - Password Match Tests diff --git a/iosApp/iosApp/Core/ValidationRules.swift b/iosApp/iosApp/Core/ValidationRules.swift index cbb984e..0eb0c94 100644 --- a/iosApp/iosApp/Core/ValidationRules.swift +++ b/iosApp/iosApp/Core/ValidationRules.swift @@ -7,6 +7,8 @@ enum ValidationError: LocalizedError { case passwordTooShort(minLength: Int) case passwordMismatch case passwordMissingLetter + case passwordMissingUppercase + case passwordMissingLowercase case passwordMissingNumber case invalidCode(expectedLength: Int) case invalidUsername @@ -24,6 +26,10 @@ enum ValidationError: LocalizedError { return "Passwords do not match" case .passwordMissingLetter: return "Password must contain at least one letter" + case .passwordMissingUppercase: + return "Password must contain at least one uppercase letter" + case .passwordMissingLowercase: + return "Password must contain at least one lowercase letter" case .passwordMissingNumber: return "Password must contain at least one number" case .invalidCode(let length): @@ -90,7 +96,7 @@ enum ValidationRules { return nil } - /// Validates a password with letter and number requirements + /// Validates a password with uppercase, lowercase, and number requirements /// - Parameter password: The password to validate /// - Returns: ValidationError if invalid, nil if valid static func validatePasswordStrength(_ password: String) -> ValidationError? { @@ -98,11 +104,16 @@ enum ValidationRules { return .required(field: "Password") } - let hasLetter = password.range(of: "[A-Za-z]", options: .regularExpression) != nil + let hasUppercase = password.range(of: "[A-Z]", options: .regularExpression) != nil + let hasLowercase = password.range(of: "[a-z]", options: .regularExpression) != nil let hasNumber = password.range(of: "[0-9]", options: .regularExpression) != nil - if !hasLetter { - return .passwordMissingLetter + if !hasUppercase { + return .passwordMissingUppercase + } + + if !hasLowercase { + return .passwordMissingLowercase } if !hasNumber { @@ -112,13 +123,14 @@ enum ValidationRules { return nil } - /// Check if password has required strength (letter + number) + /// Check if password has required strength (uppercase + lowercase + number) /// - Parameter password: The password to check /// - Returns: true if valid strength static func isValidPassword(_ password: String) -> Bool { - let hasLetter = password.range(of: "[A-Za-z]", options: .regularExpression) != nil + let hasUppercase = password.range(of: "[A-Z]", options: .regularExpression) != nil + let hasLowercase = password.range(of: "[a-z]", options: .regularExpression) != nil let hasNumber = password.range(of: "[0-9]", options: .regularExpression) != nil - return hasLetter && hasNumber + return hasUppercase && hasLowercase && hasNumber } /// Validates that two passwords match diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist index 1a49718..2b50435 100644 --- a/iosApp/iosApp/Info.plist +++ b/iosApp/iosApp/Info.plist @@ -45,6 +45,12 @@ NSAllowsLocalNetworking + NSCameraUsageDescription + honeyDue needs camera access to take photos of tasks, documents, and receipts. + NSPhotoLibraryUsageDescription + honeyDue needs photo library access to attach photos to tasks and documents. + NSPhotoLibraryAddUsageDescription + honeyDue needs permission to save photos to your library. UIBackgroundModes remote-notification diff --git a/iosApp/iosApp/Onboarding/OnboardingCreateAccountView.swift b/iosApp/iosApp/Onboarding/OnboardingCreateAccountView.swift index 52170e5..8eb4c0f 100644 --- a/iosApp/iosApp/Onboarding/OnboardingCreateAccountView.swift +++ b/iosApp/iosApp/Onboarding/OnboardingCreateAccountView.swift @@ -19,11 +19,18 @@ struct OnboardingCreateAccountContent: View { case username, email, password, confirmPassword } + private var hasMinLength: Bool { viewModel.password.count >= 8 } + private var hasUppercase: Bool { viewModel.password.range(of: "[A-Z]", options: .regularExpression) != nil } + private var hasLowercase: Bool { viewModel.password.range(of: "[a-z]", options: .regularExpression) != nil } + private var hasDigit: Bool { viewModel.password.range(of: "[0-9]", options: .regularExpression) != nil } + private var isPasswordComplex: Bool { hasMinLength && hasUppercase && hasLowercase && hasDigit } + private var passwordsMatch: Bool { !viewModel.password.isEmpty && viewModel.password == viewModel.confirmPassword } + private var isFormValid: Bool { !viewModel.username.isEmpty && !viewModel.email.isEmpty && - !viewModel.password.isEmpty && - viewModel.password == viewModel.confirmPassword + isPasswordComplex && + passwordsMatch } var body: some View { @@ -250,6 +257,20 @@ struct OnboardingCreateAccountContent: View { accessibilityIdentifier: AccessibilityIdentifiers.Onboarding.confirmPasswordField ) .focused($focusedField, equals: .confirmPassword) + + // Password Requirements + if !viewModel.password.isEmpty { + VStack(alignment: .leading, spacing: 6) { + OnboardingPasswordRequirementRow(isMet: hasMinLength, text: "At least 8 characters") + OnboardingPasswordRequirementRow(isMet: hasUppercase, text: "Contains an uppercase letter") + OnboardingPasswordRequirementRow(isMet: hasLowercase, text: "Contains a lowercase letter") + OnboardingPasswordRequirementRow(isMet: hasDigit, text: "Contains a number") + OnboardingPasswordRequirementRow(isMet: passwordsMatch, text: "Passwords match") + } + .padding(12) + .background(Color.appBackgroundPrimary.opacity(0.5)) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + } } .padding(OrganicSpacing.cozy) .background( @@ -361,6 +382,25 @@ struct OnboardingCreateAccountContent: View { } } +// MARK: - Onboarding Password Requirement Row + +private struct OnboardingPasswordRequirementRow: View { + let isMet: Bool + let text: String + + var body: some View { + HStack(spacing: 10) { + Image(systemName: isMet ? "checkmark.circle.fill" : "circle") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(isMet ? Color.appPrimary : Color.appTextSecondary) + + Text(text) + .font(.system(size: 13, weight: .medium)) + .foregroundColor(isMet ? Color.appTextPrimary : Color.appTextSecondary) + } + } +} + // MARK: - Organic Onboarding TextField private struct OrganicOnboardingTextField: View { diff --git a/iosApp/iosApp/PasswordReset/ResetPasswordView.swift b/iosApp/iosApp/PasswordReset/ResetPasswordView.swift index d0c23c0..1103320 100644 --- a/iosApp/iosApp/PasswordReset/ResetPasswordView.swift +++ b/iosApp/iosApp/PasswordReset/ResetPasswordView.swift @@ -13,13 +13,10 @@ struct ResetPasswordView: View { } // 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 hasMinLength: Bool { viewModel.newPassword.count >= 8 } + private var hasUppercase: Bool { viewModel.newPassword.range(of: "[A-Z]", options: .regularExpression) != nil } + private var hasLowercase: Bool { viewModel.newPassword.range(of: "[a-z]", options: .regularExpression) != nil } + private var hasNumber: Bool { viewModel.newPassword.range(of: "[0-9]", options: .regularExpression) != nil } private var passwordsMatch: Bool { !viewModel.newPassword.isEmpty && @@ -28,8 +25,9 @@ struct ResetPasswordView: View { } private var isFormValid: Bool { - viewModel.newPassword.count >= 8 && - hasLetter && + hasMinLength && + hasUppercase && + hasLowercase && hasNumber && passwordsMatch } @@ -90,16 +88,20 @@ struct ResetPasswordView: View { VStack(alignment: .leading, spacing: 8) { RequirementRow( - isMet: viewModel.newPassword.count >= 8, + isMet: hasMinLength, text: "At least 8 characters" ) RequirementRow( - isMet: hasLetter, - text: "Contains letters" + isMet: hasUppercase, + text: "Contains an uppercase letter" + ) + RequirementRow( + isMet: hasLowercase, + text: "Contains a lowercase letter" ) RequirementRow( isMet: hasNumber, - text: "Contains numbers" + text: "Contains a number" ) RequirementRow( isMet: passwordsMatch, diff --git a/iosApp/iosApp/Register/RegisterView.swift b/iosApp/iosApp/Register/RegisterView.swift index 6488757..8fd263b 100644 --- a/iosApp/iosApp/Register/RegisterView.swift +++ b/iosApp/iosApp/Register/RegisterView.swift @@ -13,11 +13,18 @@ struct RegisterView: View { case username, email, password, confirmPassword } + private var hasMinLength: Bool { viewModel.password.count >= 8 } + private var hasUppercase: Bool { viewModel.password.range(of: "[A-Z]", options: .regularExpression) != nil } + private var hasLowercase: Bool { viewModel.password.range(of: "[a-z]", options: .regularExpression) != nil } + private var hasDigit: Bool { viewModel.password.range(of: "[0-9]", options: .regularExpression) != nil } + private var passwordsMatch: Bool { !viewModel.password.isEmpty && viewModel.password == viewModel.confirmPassword } + private var isPasswordComplex: Bool { hasMinLength && hasUppercase && hasLowercase && hasDigit } + private var isFormValid: Bool { !viewModel.username.isEmpty && !viewModel.email.isEmpty && - !viewModel.password.isEmpty && - !viewModel.confirmPassword.isEmpty + isPasswordComplex && + passwordsMatch } var body: some View { @@ -130,10 +137,26 @@ struct RegisterView: View { .submitLabel(.go) .onSubmit { viewModel.register() } - Text(L10n.Auth.passwordSuggestion) - .font(.system(size: 12, weight: .medium)) - .foregroundColor(Color.appTextSecondary) - .frame(maxWidth: .infinity, alignment: .leading) + // Password Requirements + if !viewModel.password.isEmpty { + VStack(alignment: .leading, spacing: 8) { + Text("PASSWORD REQUIREMENTS") + .font(.system(size: 11, weight: .semibold, design: .rounded)) + .foregroundColor(Color.appTextSecondary) + .tracking(1.2) + + VStack(alignment: .leading, spacing: 6) { + PasswordRequirementRow(isMet: hasMinLength, text: "At least 8 characters") + PasswordRequirementRow(isMet: hasUppercase, text: "Contains an uppercase letter") + PasswordRequirementRow(isMet: hasLowercase, text: "Contains a lowercase letter") + PasswordRequirementRow(isMet: hasDigit, text: "Contains a number") + PasswordRequirementRow(isMet: passwordsMatch, text: "Passwords match") + } + .padding(12) + .background(Color.appBackgroundPrimary.opacity(0.5)) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + } + } // Error Message if let errorMessage = viewModel.errorMessage { @@ -340,6 +363,25 @@ private struct OrganicSecureField: View { } } +// MARK: - Password Requirement Row + +private struct PasswordRequirementRow: View { + let isMet: Bool + let text: String + + var body: some View { + HStack(spacing: 10) { + Image(systemName: isMet ? "checkmark.circle.fill" : "circle") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(isMet ? Color.appPrimary : Color.appTextSecondary) + + Text(text) + .font(.system(size: 13, weight: .medium)) + .foregroundColor(isMet ? Color.appTextPrimary : Color.appTextSecondary) + } + } +} + // MARK: - Organic Form Background private struct OrganicFormBackground: View { diff --git a/iosApp/iosApp/Register/RegisterViewModel.swift b/iosApp/iosApp/Register/RegisterViewModel.swift index 0855fdf..337a76d 100644 --- a/iosApp/iosApp/Register/RegisterViewModel.swift +++ b/iosApp/iosApp/Register/RegisterViewModel.swift @@ -36,6 +36,11 @@ class RegisterViewModel: ObservableObject { return } + if let error = ValidationRules.validatePasswordStrength(password) { + errorMessage = error.errorDescription + return + } + if let error = ValidationRules.validatePasswordMatch(password, confirmPassword) { errorMessage = error.errorDescription return