diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/data/DataManager.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/data/DataManager.kt index fe42461..3f78d0a 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/data/DataManager.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/data/DataManager.kt @@ -109,6 +109,24 @@ object DataManager : IDataManager { private val _currentUser = MutableStateFlow(null) override val currentUser: StateFlow = _currentUser.asStateFlow() + /** + * Flow ID of a pending Kratos verification flow. + * + * Set in [AuthApi] right after a registration response carries a + * `continue_with` item of action `show_verification_ui`. Cleared on + * successful verification. While set, [AuthApi.verifyEmail] posts the + * 6-digit code to this exact flow rather than initialising a fresh one + * (the auto-emailed code is bound to a specific flow ID — posting it + * to a new flow always fails). + * + * In-memory only. Kratos's default verification flow lifespan is 1h, + * which is plenty for the user to receive the email and enter the + * code. If they kill the app between sign-up and entering the code + * they'll need a fresh code (handled by the resend path). + */ + private val _pendingVerificationFlowId = MutableStateFlow(null) + val pendingVerificationFlowId: StateFlow = _pendingVerificationFlowId.asStateFlow() + // ==================== APP PREFERENCES ==================== private val _themeId = MutableStateFlow("default") @@ -302,6 +320,16 @@ object DataManager : IDataManager { persistToDisk() } + /** + * Set the in-flight Kratos verification flow ID. AuthApi calls this with + * the value pulled out of the registration response's `continue_with` + * `show_verification_ui` item. Pass `null` to clear (e.g. on successful + * verification or sign-out). + */ + fun setPendingVerificationFlowId(flowId: String?) { + _pendingVerificationFlowId.value = flowId + } + // ==================== THEME UPDATE METHODS ==================== fun setThemeId(id: String) { diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/models/Kratos.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/models/Kratos.kt index da2daee..3e53115 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/models/Kratos.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/models/Kratos.kt @@ -135,11 +135,21 @@ data class KratosSession( /** * Success body of a native login flow submission * (`POST .../self-service/login/api`). + * + * `continue_with` is normally empty on a login response. The one case it + * isn't: Kratos v26 transparently registers a previously unseen OIDC + * identity when an `oidc` method is submitted to the login flow and no + * matching identity exists. In that scenario Kratos runs the registration + * flow internally and returns the result through the **login** endpoint — + * with `continue_with` carrying the `show_verification_ui` item that + * normal registration would have surfaced. We capture it here so the + * email-verification screen can submit the code to the correct flow. */ @Serializable data class KratosLoginSuccess( val session: KratosSession, @SerialName("session_token") val sessionToken: String, + @SerialName("continue_with") val continueWith: List = emptyList(), ) /** 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 63f319a..bffa03d 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/APILayer.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/APILayer.kt @@ -1320,6 +1320,15 @@ object APILayer { return result } + /** + * Start a client-owned email-verification flow and send the code. + * Call from the verification screen on appear and on "resend". See + * [AuthApi.startEmailVerification] for why the screen must own the flow. + */ + suspend fun startEmailVerification(email: String): ApiResult { + return authApi.startEmailVerification(email) + } + suspend fun verifyEmail(token: String, request: VerifyEmailRequest): ApiResult { return authApi.verifyEmail(token, request) } 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 e50ce0e..efb63e3 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/AuthApi.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/AuthApi.kt @@ -1,5 +1,6 @@ package com.tt.honeyDue.network +import com.tt.honeyDue.data.DataManager import com.tt.honeyDue.models.* import io.ktor.client.* import io.ktor.client.call.* @@ -59,6 +60,24 @@ class AuthApi(private val client: HttpClient = ApiClient.httpClient) { */ private var pendingRecoveryAction: String? = null + /** + * Scan a registration response's `continue_with` for a + * `show_verification_ui` item and persist the embedded flow id in + * [DataManager.pendingVerificationFlowId]. Kratos auto-emails the + * verification code as soon as registration completes, and the code is + * bound to **that** flow — submitting it to a fresh `initFlow("verification")` + * is rejected. [verifyEmail] reads the persisted id and posts to it + * directly. + */ + private fun capturePendingVerificationFlow(continueWith: List) { + val flowId = continueWith + .firstOrNull { it.action == "show_verification_ui" } + ?.flow?.id + if (!flowId.isNullOrBlank()) { + DataManager.setPendingVerificationFlowId(flowId) + } + } + // ==================== Kratos flow plumbing ==================== /** @@ -165,7 +184,13 @@ class AuthApi(private val client: HttpClient = ApiClient.httpClient) { json.decodeFromString(KratosLoginSuccess.serializer(), it) } return when (success) { - is ApiResult.Success -> resolveSession(success.data.sessionToken, success.data.session) + is ApiResult.Success -> { + // Defensive: password login normally doesn't return a + // verification continue_with, but Kratos surfacing one here + // means the verification screen should target that flow. + capturePendingVerificationFlow(success.data.continueWith) + resolveSession(success.data.sessionToken, success.data.session) + } is ApiResult.Error -> success else -> ApiResult.Error("Login failed") } @@ -174,49 +199,55 @@ class AuthApi(private val client: HttpClient = ApiClient.httpClient) { // ==================== Registration ==================== /** - * Native password registration against Kratos. + * Password registration. * - * Kratos identity traits are `{ email, name: { first, last } }`. The - * legacy [RegisterRequest.username] is preserved for the UI but is not a - * Kratos trait — the email is the identifier. + * Registration does NOT go through Kratos' self-service registration flow. + * That flow auto-emails a verification code to a flow whose id Kratos never + * returns to the client, so the client could never submit the user's code + * to the right flow (verified 2026-06-03). Instead this calls the honeyDue + * API `POST /auth/register/`, which admin-creates the Kratos identity with + * an unverified email and sends NO email. + * + * The new identity is immediately usable, so we log in right away to obtain + * a session token — Kratos permits login for unverified identities; app + * access is gated on the `verified` flag, not on Kratos login. The user + * lands authenticated-but-unverified, the verification screen sends the one + * code (via [startEmailVerification]) and completes the email check. + * + * [RegisterRequest.username] is kept for the UI but the email is the Kratos + * identifier, so the follow-up login authenticates by email. */ suspend fun register(request: RegisterRequest): ApiResult { - val flow = when (val f = initFlow("registration")) { - is ApiResult.Success -> f.data - is ApiResult.Error -> return f - else -> return ApiResult.Error("Could not start registration") + // 1. Create the identity via the honeyDue API (admin-create in Kratos). + when (val created = createAccount(request)) { + is ApiResult.Success -> Unit + is ApiResult.Error -> return created + else -> return ApiResult.Error("Registration failed") } - val traits = KratosTraits( - email = request.email.trim(), - name = KratosName( - first = request.firstName ?: "", - last = request.lastName ?: "", - ), - ) - val body = json.encodeToString( - KratosPasswordRegistrationBody.serializer(), - KratosPasswordRegistrationBody(traits = traits, password = request.password), - ) - val success = submitFlow(flow.ui.action, body) { - json.decodeFromString(KratosRegistrationSuccess.serializer(), it) - } - return when (success) { - is ApiResult.Success -> { - val token = success.data.sessionToken - if (token.isNullOrBlank()) { - // Kratos was configured without the `session` after-hook — - // the identity exists but no session was issued. The caller - // must complete a verification flow then log in. - ApiResult.Error( - "Account created. Please verify your email, then sign in.", - 200, - ) - } else { - resolveSession(token, success.data.session) - } + // 2. Log in immediately to get a session token. The identifier is the + // email (the Kratos credential identifier), not the display username. + return login(LoginRequest(username = request.email.trim(), password = request.password)) + } + + /** + * Create a password account via `POST {api}/auth/register/`. The honeyDue + * API admin-creates the Kratos identity (unverified, no email sent) and + * returns 201 with no token. Maps 409 → email taken, 400 → weak password. + */ + private suspend fun createAccount(request: RegisterRequest): ApiResult { + return try { + val response = client.post("$apiBaseUrl/auth/register/") { + contentType(ContentType.Application.Json) + accept(ContentType.Application.Json) + setBody(request) } - is ApiResult.Error -> success - else -> ApiResult.Error("Registration failed") + if (response.status.isSuccess()) { + ApiResult.Success(Unit) + } else { + ApiResult.Error(ErrorParser.parseError(response), response.status.value) + } + } catch (e: Exception) { + ApiResult.Error(e.message ?: "Could not create account") } } @@ -253,6 +284,12 @@ class AuthApi(private val client: HttpClient = ApiClient.httpClient) { json.decodeFromString(KratosLoginSuccess.serializer(), it) } if (loginResult is ApiResult.Success) { + // Kratos v26 transparently registers a previously-unseen OIDC + // identity through the login endpoint and returns the + // verification continue_with on the login response. Capture it + // here so verifyEmail() targets the correct (already-emailed) + // flow rather than spinning up a fresh, codeless one. + capturePendingVerificationFlow(loginResult.data.continueWith) return resolveSession(loginResult.data.sessionToken, loginResult.data.session) } @@ -271,6 +308,10 @@ class AuthApi(private val client: HttpClient = ApiClient.httpClient) { } return when (regResult) { is ApiResult.Success -> { + // Same pattern as password registration: Kratos has already + // emailed the verification code bound to the flow it surfaces + // here. Stash it for verifyEmail. + capturePendingVerificationFlow(regResult.data.continueWith) val token = regResult.data.sessionToken if (token.isNullOrBlank()) { (loginResult as? ApiResult.Error) @@ -483,13 +524,28 @@ class AuthApi(private val client: HttpClient = ApiClient.httpClient) { // ==================== Email verification ==================== /** - * Submit an email-verification code to Kratos' verification flow. + * Start (or restart) an email-verification flow that THIS client owns. * - * Note: the [token] parameter (a session token) is unused for the Kratos - * verification flow — verification is anonymous and keyed by the code — - * but the parameter is kept so [APILayer]/`AuthViewModel` need no change. + * Why this exists — the hard-won root cause (2026-06-03): Kratos's native + * registration response does NOT return the auto-created verification + * flow's id. With the `session` after-hook enabled, `continue_with` carries + * only `set_ory_session_token` — never `show_verification_ui`. Kratos still + * emails a code server-side, but the client has no way to learn which flow + * that code is bound to. Submitting it to any other flow is rejected. So + * the client can never reuse the registration-time flow. + * + * The fix: the verification SCREEN owns the flow. This call + * 1. inits a fresh verification flow, + * 2. submits the user's [email] to it, which makes Kratos send a code + * bound to THIS flow, + * 3. stores the flow id in [DataManager.pendingVerificationFlowId]. + * [verifyEmail] then posts the code the user receives back to this exact + * flow. Robust across app restarts, re-sign-ins, and both password and + * OIDC sign-ups — the screen is the single source of truth for the flow. + * + * Call this when the verification screen appears and on every "resend". */ - suspend fun verifyEmail(token: String, request: VerifyEmailRequest): ApiResult { + suspend fun startEmailVerification(email: String): ApiResult { val flow = when (val f = initFlow("verification")) { is ApiResult.Success -> f.data is ApiResult.Error -> return f @@ -497,20 +553,66 @@ class AuthApi(private val client: HttpClient = ApiClient.httpClient) { } val body = json.encodeToString( KratosVerificationBody.serializer(), - KratosVerificationBody(code = request.code.trim()), + KratosVerificationBody(email = email.trim()), ) val result = submitFlow(flow.ui.action, body) { runCatching { json.decodeFromString(KratosFlow.serializer(), it) }.getOrNull() } + return when (result) { + is ApiResult.Success -> { + // Persist the flow id BEFORE the user enters the code — this is + // the flow Kratos just bound the emailed code to. + DataManager.setPendingVerificationFlowId(flow.id) + ApiResult.Success(Unit) + } + is ApiResult.Error -> result + else -> ApiResult.Error("Could not send verification code") + } + } + + /** + * Submit an email-verification code to the flow the client started via + * [startEmailVerification]. + * + * The code is bound to a specific Kratos flow id (held in + * [DataManager.pendingVerificationFlowId]). We POST it to exactly that + * flow — never a fresh one, because a fresh flow has no code attached and + * the submission would always fail. If there is no pending flow id the + * screen hasn't sent a code yet; surface that so the UI can call + * [startEmailVerification] first. + * + * Note: the [token] parameter (a session token) is unused for the Kratos + * verification flow — verification is keyed by the code/flow — but the + * parameter is kept so [APILayer]/`AuthViewModel` need no change. + */ + suspend fun verifyEmail(token: String, request: VerifyEmailRequest): ApiResult { + val pendingFlowId = DataManager.pendingVerificationFlowId.value + if (pendingFlowId.isNullOrBlank()) { + return ApiResult.Error("Please request a verification code first.") + } + val body = json.encodeToString( + KratosVerificationBody.serializer(), + KratosVerificationBody(code = request.code.trim()), + ) + val result = submitFlow("$kratosBaseUrl/self-service/verification?flow=$pendingFlowId", body) { + runCatching { json.decodeFromString(KratosFlow.serializer(), it) }.getOrNull() + } return when (result) { is ApiResult.Success -> { val verified = result.data?.state == "passed_challenge" - ApiResult.Success( - VerifyEmailResponse( - message = if (verified) "Email verified." else "Verification submitted.", - verified = verified, - ), - ) + if (verified) { + // One-shot — drop the cached flow so a future verification + // (different account, address change, etc.) starts clean. + DataManager.setPendingVerificationFlowId(null) + ApiResult.Success(VerifyEmailResponse(message = "Email verified.", verified = true)) + } else { + // 200 with a re-rendered flow == wrong/expired code. Surface + // Kratos' own message so the user knows to retry/resend. + val msg = result.data?.ui?.messages?.firstOrNull { it.type == "error" }?.text + ?: result.data?.ui?.nodes?.flatMap { it.messages } + ?.firstOrNull { it.type == "error" }?.text + ApiResult.Error(msg ?: "Invalid or expired code. Tap resend for a new one.") + } } is ApiResult.Error -> result else -> ApiResult.Error("Verification failed") diff --git a/iosApp/iosApp/Onboarding/OnboardingVerifyEmailView.swift b/iosApp/iosApp/Onboarding/OnboardingVerifyEmailView.swift index 2349998..4be18ff 100644 --- a/iosApp/iosApp/Onboarding/OnboardingVerifyEmailView.swift +++ b/iosApp/iosApp/Onboarding/OnboardingVerifyEmailView.swift @@ -243,6 +243,10 @@ struct OnboardingVerifyEmailContent: View { .onAppear { print("🏠 ONBOARDING: OnboardingVerifyEmailContent appeared") isAnimating = true + // Establish the client-owned verification flow + send the code. + // Kratos binds the code to this flow; verifyEmail submits it back + // here. Without this the screen has no flow to verify against. + viewModel.sendCode(silent: true) DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { isCodeFieldFocused = true } diff --git a/iosApp/iosApp/VerifyEmail/VerifyEmailView.swift b/iosApp/iosApp/VerifyEmail/VerifyEmailView.swift index c400fb1..9e89d27 100644 --- a/iosApp/iosApp/VerifyEmail/VerifyEmailView.swift +++ b/iosApp/iosApp/VerifyEmail/VerifyEmailView.swift @@ -165,6 +165,17 @@ struct VerifyEmailView: View { .disabled(viewModel.code.count != 6 || viewModel.isLoading) .accessibilityIdentifier(AccessibilityIdentifiers.Authentication.verifyButton) + // Resend code + Button(action: { + viewModel.code = "" + viewModel.sendCode() + }) { + Text("Resend code") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(Color.appPrimary) + } + .disabled(viewModel.isLoading) + // Help Text Text(L10n.Auth.verifyHelpText) .font(.system(size: 12, weight: .medium)) @@ -202,6 +213,11 @@ struct VerifyEmailView: View { } .onAppear { isFocused = true + // Establish the client-owned verification flow and send the + // code. Kratos binds the code to this flow; verifyEmail submits + // it back here. Without this the screen has no flow to verify + // against. Re-running on every appear also covers expired codes. + viewModel.sendCode(silent: true) } .onChange(of: viewModel.isVerified) { _, isVerified in if isVerified { diff --git a/iosApp/iosApp/VerifyEmail/VerifyEmailViewModel.swift b/iosApp/iosApp/VerifyEmail/VerifyEmailViewModel.swift index 78ad1ab..c5d1a67 100644 --- a/iosApp/iosApp/VerifyEmail/VerifyEmailViewModel.swift +++ b/iosApp/iosApp/VerifyEmail/VerifyEmailViewModel.swift @@ -26,6 +26,30 @@ class VerifyEmailViewModel: ObservableObject { } // MARK: - Public Methods + + /// Start a client-owned verification flow and have Kratos send a code. + /// + /// MUST run before the user can verify: Kratos binds each emailed code to a + /// specific flow id, and the registration response never exposes the + /// auto-created flow's id (see AuthApi.startEmailVerification). The screen + /// establishes its own flow here; verifyEmail() submits the code back to it. + /// Called on screen appear and on "Resend code". + func sendCode(silent: Bool = false) { + guard let email = dataManager.currentUser?.email, !email.isEmpty else { + errorMessage = "We couldn't determine your email. Please sign in again." + return + } + if !silent { isLoading = true } + errorMessage = nil + Task { + let result = try? await APILayer.shared.startEmailVerification(email: email) + if let result = result, let error = ApiResultBridge.error(from: result) { + self.errorMessage = ErrorMessageParser.parse(error.message) + } + self.isLoading = false + } + } + func verifyEmail() { // Validation using ValidationRules if let error = ValidationRules.validateCode(code, expectedLength: 6) {