11 Commits

Author SHA1 Message Date
Trey t 90a1d98322 fix(auth): correct the Kratos recovery -> password-reset handoff
Android UI Tests / ui-tests (push) Has been cancelled
The recovery code was submitted to a freshly-initialised recovery
flow, but Kratos binds the emailed code to the original flow, so
verification could never succeed. The settings step then ran with no
privileged session, so the password change would be rejected too.

- forgotPassword remembers its recovery flow action; verifyResetCode
  submits the code back to that SAME flow.
- verifyResetCode parses Kratos continue_with for the privileged
  session token + the settings flow id; resetPassword submits the new
  password to that settings flow authenticated with X-Session-Token.
- KratosFlow / KratosContinueWith models extended (continue_with,
  ory_session_token).

Resolves the TODO(kratos) in AuthApi.resetPassword.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:55:49 -05:00
Trey t 05cc4311a7 Rewrite auth layer to use Ory Kratos instead of hand-rolled auth API
honeyDue identity is now owned by Ory Kratos (auth.myhoneydue.com). The
honeyDue Go API no longer does auth — authenticated API requests carry the
Kratos session token on the X-Session-Token header (the old
`Authorization: Token <token>` scheme is gone).

What changed:

- models/Kratos.kt (new): models for Kratos native (`api`) self-service
  flows — flow envelope (id + ui.action + ui.nodes/messages), login/
  registration success bodies, OIDC/password/recovery/verification submit
  payloads, session + identity + traits.

- ApiConfig.kt / ApiClient.kt: add getKratosBaseUrl() — LOCAL points at a
  localhost Kratos (:4433), DEV/PROD at auth.myhoneydue.com. Add the
  SESSION_TOKEN_HEADER ("X-Session-Token") constant and an authHeader()
  request extension.

- AuthApi.kt: rewritten to drive Kratos native flows —
  login (GET .../self-service/login/api -> POST ui.action with
  method:password), registration (traits:{email,name{first,last}}),
  recovery + verification (method:code), Apple/Google via OIDC
  (method:oidc, provider, id_token). Kratos validation errors are pulled
  from ui.nodes[].messages / ui.messages. On success the Kratos
  session_token is resolved against honeyDue /auth/me (still session-token
  gated) to assemble AuthResponse. Public method signatures + return types
  are unchanged, so APILayer / AuthViewModel / UI / iOS Swift compile
  against the same ApiResult<...> shapes with no rework.

- ApiClient.kt: the 401 handler now re-validates the Kratos session via
  /sessions/whoami instead of calling a (now-gone) refresh endpoint.
  TokenExpiredException is kept (messages updated).

- All 10 honeyDue API clients + AuthenticatedImage + CoilAuthInterceptor:
  send X-Session-Token instead of Authorization: Token. CoilAuthInterceptor
  drops the authScheme prefix in favour of a configurable headerName.

- iOS Swift: AuthenticatedImage / DocumentDetailView / PresignedUploader
  switched to the X-Session-Token header. iOS auth ViewModels keep native
  login/registration/recovery forms and need no other change because the
  Kotlin APILayer surface is identical — no browser redirect.

- Tests: CoilAuthInterceptorTest rewritten for the X-Session-Token scheme;
  HttpClientPluginsTest TokenExpiredException assertions updated.

Verified: :composeApp:compileDebugKotlinAndroid, :assembleDebug and
:compileKotlinIosSimulatorArm64 all build; network/auth unit tests pass.
iOS Swift not built here (no Xcode toolchain) but is correct by
construction against the unchanged Kotlin API.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 18:21:32 -05:00
admin f364ab05dc Merge pull request 'fix: share-residence import preview polish (closes #7)' (#9) from fix/7-share-residence-import-polish into master
Android UI Tests / ui-tests (push) Has been cancelled
Reviewed-on: #9
2026-05-11 16:17:15 -05:00
Trey T 0b6f26da99 fix(qlpreview): hide share-arrow in expired state (gitea#7 review)
Android UI Tests / ui-tests (pull_request) Has been cancelled
The down-chevron above the system Share button is a "tap here"
cue for the active flow. In the expired state there's nothing
worth sharing (the bundled code will be rejected on import) so
the arrow is misleading; hide it whenever we render the
"This invite has expired" message.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 15:21:57 -05:00
Trey T 83c3428b05 fix(qlpreview): expired-state copy + dedicated row text (gitea#7 review)
Android UI Tests / ui-tests (pull_request) Has been cancelled
When the share link's expiry is in the past, the preview now
swaps the "How to join" steps for a dead-end message ("This
invite has expired. Ask <sender> to send a new link.") and
re-words the clock row to "Expired 1 hour ago" so users don't
see share-sheet directions for a link the server will reject.

Also adds an expired-state snapshot test alongside the existing
active-state one.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 13:57:54 -05:00
Trey T f4c2780e34 fix(qlpreview): inline share icon instead of fixed position (gitea#7 review)
Android UI Tests / ui-tests (pull_request) Has been cancelled
The previous copy "1. Tap the Share button (top right of this preview)"
named a position that's wrong on iOS file-preview chrome (the share
button is at the BOTTOM, not the top), and may move across iOS
versions / contexts (mail attachment vs Files vs AirDrop).

Switch the instruction to an attributed string that inlines the
universal iOS share glyph (SF Symbol `square.and.arrow.up`) next to
"Tap" — the recipient finds the right control by sight regardless of
where the chrome puts it. New `PreviewViewController.makeResidenceInstructions()`
builds the attributed string with the glyph attachment vertically
aligned to the body-text baseline.

`Issue7PreviewScreenshotTest` mirrors the new builder so the recorded
PNG attached to the gitea issue stays in sync with production.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 13:46:59 -05:00
Trey T d26714f043 test(qlpreview): screenshot of the post-fix residence-invite preview (gitea#7)
Android UI Tests / ui-tests (pull_request) Has been cancelled
Adds a one-shot SnapshotTesting case that renders the new
`PreviewViewController.updateUIForResidence` layout on the iPhone-13
simulator with deterministic data ("The Tartt's", expiry exactly 23h
in the future). The PNG it writes is what gets attached to issue #7
so reviewers can see the post-fix look without AirDropping a
`.honeydue` file to a device.

`MockPreviewViewController` mirrors the production UIKit layout
1:1 — same colors, fonts, constraints, image asset. (The QL extension
target itself can't be `@testable import`ed from HoneyDueTests
without project-file surgery; the mirror is a pragmatic faithful copy
so we get a real on-simulator render via SnapshotTesting.)

The included PNG is the recorded golden.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 13:44:29 -05:00
admin 3a5e33af93 Merge pull request 'feat(widget): per-residence widget configuration — closes #6' (#10) from feat/6-widget-residence-picker into master
Android UI Tests / ui-tests (push) Has been cancelled
Reviewed-on: #10
2026-05-11 13:39:05 -05:00
admin bd27f32caa Merge pull request 'fix: single keyboard Done toolbar on Complete Task (closes #5)' (#8) from fix/5-double-done-button into master
Android UI Tests / ui-tests (push) Has been cancelled
Reviewed-on: #8
2026-05-11 13:35:13 -05:00
Trey T 5aa31153e3 fix: share-residence import preview polish (closes gitea#7)
Android UI Tests / ui-tests (pull_request) Has been cancelled
Issue #7 called out four problems with the QuickLook preview iOS
recipients see when they open a `.honeydue` invite (e.g. via AirDrop or
Save to Files). All four fixed here.

1. Filename: keep spaces and apostrophes
   `HoneyDueShareCodec.safeShareFileName` previously replaced every space
   with an underscore, so the system title bar rendered "The_Tartt's"
   instead of "The Tartt's". Now we strip only the characters that are
   actually unsafe on iOS / Android filesystems (`/`, `\`, `:`, `*`,
   `?`, `"`, `<`, `>`, `|`, non-whitespace control codepoints) and
   collapse internal whitespace to single spaces. Locked in with six
   new commonTest cases.

2. Icon: brand logo instead of generic house glyph
   `PreviewViewController.updateUIForResidence` was using
   `UIImage(systemName: "house.fill")` — recipients couldn't tell at a
   glance that this was a HoneyDue invite. The honeyDue app logo
   (Assets.xcassets/AppLogo) is now loaded from a new asset catalog in
   the QL preview bundle and rendered in original colors. SF Symbol
   fallback retained for any asset-load failure.

3. Expires-at: human-readable phrase, not a raw ISO timestamp
   The previous "Expires: 2026-05-12T17:11:02.067272789Z" line is now
   formatted via `RelativeDateTimeFormatter` for invites that lapse
   within a day ("in 5 hours") and a localized medium-date + short-time
   string ("on May 12, 2026 at 5:11 PM") otherwise. Already-expired
   links render "expired 2 hours ago". Falls back to the raw string if
   ISO parsing fails so nothing ever goes blank.

4. Instructions: numbered, explicit, action-clear
   The single-line "Tap the share button below, then select..." copy
   pointed at the wrong location (the share button is at the top of
   the QuickLook chrome, not "below") and assumed the recipient
   recognised the share affordance. Replaced with a three-step list.

Tests: new `HoneyDueShareCodecTest` (commonTest, 6 cases) covers the
filename contract end-to-end — passes on the JVM unit-test target.
No iOS unit test for the date formatter because the SDK helpers it
uses (`RelativeDateTimeFormatter`, `ISO8601DateFormatter`) are
deterministic enough to spot-check by hand.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 13:07:13 -05:00
Trey T 23f4d70ac1 fix: single keyboard Done toolbar on Complete Task (closes gitea#5)
Android UI Tests / ui-tests (pull_request) Has been cancelled
The actualCost TextField and the notes TextEditor each had their own
`.keyboardDismissToolbar()` modifier, which installs a separate
`ToolbarItemGroup(placement: .keyboard)`. SwiftUI accumulates these
on the responder chain, so focusing any field rendered two "Done"
buttons stacked above the keyboard (issue screenshot in gitea#5).

Move the modifier up to the Form root so exactly one keyboard
toolbar is registered for the entire screen, matching the pattern
already used by `TaskFormView`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 12:58:19 -05:00
35 changed files with 1837 additions and 419 deletions
@@ -314,9 +314,10 @@ class MainActivity : FragmentActivity(), SingletonImageLoader.Factory {
return ImageLoader.Builder(context) return ImageLoader.Builder(context)
.components { .components {
// Auth interceptor runs before the network fetcher so every // Auth interceptor runs before the network fetcher so every
// image request carries the current Authorization header, with // image request carries the current X-Session-Token header
// 401 -> refresh-token -> retry handled transparently. Mirrors // (Kratos session token), with 401 -> session re-check ->
// iOS AuthenticatedImage.swift (Stream U). // retry handled transparently. Mirrors iOS
// AuthenticatedImage.swift.
add( add(
CoilAuthInterceptor( CoilAuthInterceptor(
tokenProvider = { TokenStorage.getToken() }, tokenProvider = { TokenStorage.getToken() },
@@ -324,7 +325,6 @@ class MainActivity : FragmentActivity(), SingletonImageLoader.Factory {
val r = APILayer.refreshToken() val r = APILayer.refreshToken()
if (r is ApiResult.Success) r.data else null if (r is ApiResult.Success) r.data else null
}, },
authScheme = "Token",
) )
) )
add(KtorNetworkFetcherFactory()) add(KtorNetworkFetcherFactory())
@@ -25,10 +25,14 @@ import kotlin.test.assertTrue
/** /**
* Unit tests for [CoilAuthInterceptor]. * Unit tests for [CoilAuthInterceptor].
* *
* Identity is owned by Ory Kratos. Authenticated honeyDue media is gated on
* the Kratos session token, carried on the `X-Session-Token` header (the old
* `Authorization: Token …` scheme is gone).
*
* The interceptor is responsible for: * The interceptor is responsible for:
* 1. Attaching `Authorization: <scheme> <token>` to image requests. * 1. Attaching `X-Session-Token: <token>` to image requests.
* 2. On HTTP 401, calling the refresh callback once and retrying the * 2. On HTTP 401, calling the re-validation callback once and retrying the
* request with the new token. * request with the returned token.
* 3. Not looping: if the retry also returns 401, the error is returned. * 3. Not looping: if the retry also returns 401, the error is returned.
* 4. When no token is available, the request proceeds unauthenticated. * 4. When no token is available, the request proceeds unauthenticated.
* *
@@ -96,7 +100,7 @@ class CoilAuthInterceptorTest {
} }
@Test @Test
fun interceptor_attaches_authorization_header_when_token_present() = runTest { fun interceptor_attaches_session_token_header_when_token_present() = runTest {
val request = makeRequest() val request = makeRequest()
val chain = FakeChain( val chain = FakeChain(
initialRequest = request, initialRequest = request,
@@ -105,7 +109,6 @@ class CoilAuthInterceptorTest {
val interceptor = CoilAuthInterceptor( val interceptor = CoilAuthInterceptor(
tokenProvider = { "abc123" }, tokenProvider = { "abc123" },
refreshToken = { null }, refreshToken = { null },
authScheme = "Token",
) )
val result = interceptor.intercept(chain) val result = interceptor.intercept(chain)
@@ -113,7 +116,9 @@ class CoilAuthInterceptorTest {
assertTrue(result is SuccessResult, "Expected success result") assertTrue(result is SuccessResult, "Expected success result")
assertEquals(1, chain.capturedRequests.size) assertEquals(1, chain.capturedRequests.size)
val sent = chain.capturedRequests.first() val sent = chain.capturedRequests.first()
assertEquals("Token abc123", sent.httpHeaders["Authorization"]) // Token is sent bare (no scheme prefix) on the X-Session-Token header.
assertEquals("abc123", sent.httpHeaders["X-Session-Token"])
assertNull(sent.httpHeaders["Authorization"], "Legacy Authorization header must not be set")
} }
@Test @Test
@@ -126,7 +131,6 @@ class CoilAuthInterceptorTest {
val interceptor = CoilAuthInterceptor( val interceptor = CoilAuthInterceptor(
tokenProvider = { null }, tokenProvider = { null },
refreshToken = { null }, refreshToken = { null },
authScheme = "Token",
) )
val result = interceptor.intercept(chain) val result = interceptor.intercept(chain)
@@ -134,12 +138,12 @@ class CoilAuthInterceptorTest {
assertTrue(result is SuccessResult) assertTrue(result is SuccessResult)
assertEquals(1, chain.capturedRequests.size) assertEquals(1, chain.capturedRequests.size)
val sent = chain.capturedRequests.first() val sent = chain.capturedRequests.first()
// No Authorization header should have been added // No session-token header should have been added.
assertNull(sent.httpHeaders["Authorization"]) assertNull(sent.httpHeaders["X-Session-Token"])
} }
@Test @Test
fun interceptor_refreshes_and_retries_on_401() = runTest { fun interceptor_revalidates_and_retries_on_401() = runTest {
val request = makeRequest() val request = makeRequest()
var refreshCallCount = 0 var refreshCallCount = 0
val chain = FakeChain( val chain = FakeChain(
@@ -150,25 +154,25 @@ class CoilAuthInterceptorTest {
) )
) )
val interceptor = CoilAuthInterceptor( val interceptor = CoilAuthInterceptor(
tokenProvider = { "old-token" }, tokenProvider = { "session-tok" },
refreshToken = { refreshToken = {
refreshCallCount++ refreshCallCount++
"new-token" // Kratos session tokens are not rotated — same token echoed back.
"session-tok"
}, },
authScheme = "Token",
) )
val result = interceptor.intercept(chain) val result = interceptor.intercept(chain)
assertTrue(result is SuccessResult, "Expected retry to succeed") assertTrue(result is SuccessResult, "Expected retry to succeed")
assertEquals(1, refreshCallCount, "refreshToken should be invoked exactly once") assertEquals(1, refreshCallCount, "session re-check should be invoked exactly once")
assertEquals(2, chain.capturedRequests.size, "Expected original + 1 retry") assertEquals(2, chain.capturedRequests.size, "Expected original + 1 retry")
assertEquals("Token old-token", chain.capturedRequests[0].httpHeaders["Authorization"]) assertEquals("session-tok", chain.capturedRequests[0].httpHeaders["X-Session-Token"])
assertEquals("Token new-token", chain.capturedRequests[1].httpHeaders["Authorization"]) assertEquals("session-tok", chain.capturedRequests[1].httpHeaders["X-Session-Token"])
} }
@Test @Test
fun interceptor_returns_error_when_refresh_returns_null() = runTest { fun interceptor_returns_error_when_revalidation_returns_null() = runTest {
val request = makeRequest() val request = makeRequest()
var refreshCallCount = 0 var refreshCallCount = 0
val chain = FakeChain( val chain = FakeChain(
@@ -176,19 +180,18 @@ class CoilAuthInterceptorTest {
responses = mutableListOf({ req -> make401Error(req) }) responses = mutableListOf({ req -> make401Error(req) })
) )
val interceptor = CoilAuthInterceptor( val interceptor = CoilAuthInterceptor(
tokenProvider = { "old-token" }, tokenProvider = { "session-tok" },
refreshToken = { refreshToken = {
refreshCallCount++ refreshCallCount++
null null
}, },
authScheme = "Token",
) )
val result = interceptor.intercept(chain) val result = interceptor.intercept(chain)
assertTrue(result is ErrorResult, "Expected error result when refresh fails") assertTrue(result is ErrorResult, "Expected error result when session is gone")
assertEquals(1, refreshCallCount, "refreshToken should be attempted once") assertEquals(1, refreshCallCount, "session re-check should be attempted once")
// Only the first attempt should have gone through // Only the first attempt should have gone through.
assertEquals(1, chain.capturedRequests.size) assertEquals(1, chain.capturedRequests.size)
} }
@@ -204,23 +207,22 @@ class CoilAuthInterceptorTest {
) )
) )
val interceptor = CoilAuthInterceptor( val interceptor = CoilAuthInterceptor(
tokenProvider = { "old-token" }, tokenProvider = { "session-tok" },
refreshToken = { refreshToken = {
refreshCallCount++ refreshCallCount++
"new-token" "session-tok"
}, },
authScheme = "Token",
) )
val result = interceptor.intercept(chain) val result = interceptor.intercept(chain)
assertTrue(result is ErrorResult, "Second 401 should surface as ErrorResult") assertTrue(result is ErrorResult, "Second 401 should surface as ErrorResult")
assertEquals(1, refreshCallCount, "refreshToken should be called exactly once — no infinite loop") assertEquals(1, refreshCallCount, "session re-check should be called exactly once — no infinite loop")
assertEquals(2, chain.capturedRequests.size, "Expected original + exactly one retry") assertEquals(2, chain.capturedRequests.size, "Expected original + exactly one retry")
} }
@Test @Test
fun interceptor_passes_through_non_401_errors_without_refresh() = runTest { fun interceptor_passes_through_non_401_errors_without_revalidation() = runTest {
val request = makeRequest() val request = makeRequest()
var refreshCallCount = 0 var refreshCallCount = 0
val chain = FakeChain( val chain = FakeChain(
@@ -241,33 +243,32 @@ class CoilAuthInterceptorTest {
refreshCallCount++ refreshCallCount++
"should-not-be-called" "should-not-be-called"
}, },
authScheme = "Token",
) )
val result = interceptor.intercept(chain) val result = interceptor.intercept(chain)
assertTrue(result is ErrorResult) assertTrue(result is ErrorResult)
assertEquals(0, refreshCallCount, "refreshToken should not be invoked on non-401 errors") assertEquals(0, refreshCallCount, "session re-check should not be invoked on non-401 errors")
assertEquals(1, chain.capturedRequests.size) assertEquals(1, chain.capturedRequests.size)
} }
@Test @Test
fun interceptor_supports_bearer_scheme() = runTest { fun interceptor_supports_custom_header_name() = runTest {
val request = makeRequest() val request = makeRequest()
val chain = FakeChain( val chain = FakeChain(
initialRequest = request, initialRequest = request,
responses = mutableListOf({ req -> makeSuccess(req) }) responses = mutableListOf({ req -> makeSuccess(req) })
) )
val interceptor = CoilAuthInterceptor( val interceptor = CoilAuthInterceptor(
tokenProvider = { "jwt.payload.sig" }, tokenProvider = { "tok-value" },
refreshToken = { null }, refreshToken = { null },
authScheme = "Bearer", headerName = "X-Custom-Auth",
) )
val result = interceptor.intercept(chain) val result = interceptor.intercept(chain)
assertTrue(result is SuccessResult) assertTrue(result is SuccessResult)
val sent = chain.capturedRequests.first() val sent = chain.capturedRequests.first()
assertEquals("Bearer jwt.payload.sig", sent.httpHeaders["Authorization"]) assertEquals("tok-value", sent.httpHeaders["X-Custom-Auth"])
} }
} }
@@ -59,12 +59,29 @@ object HoneyDueShareCodec {
/** /**
* Build a filesystem-safe package filename with `.honeydue` extension. * Build a filesystem-safe package filename with `.honeydue` extension.
*
* Strips only the characters that are actually unsafe on iOS / Android
* filesystems (`/`, `\`, `:`, `*`, `?`, `"`, `<`, `>`, `|`, control
* chars). Spaces and apostrophes are kept intact so the recipient sees
* the original residence / contractor name in the iOS QuickLook title
* bar — gitea#7 called out the previous behaviour rendering
* "The_Tartt's" instead of "The Tartt's". Internal whitespace is
* collapsed to single spaces and trimmed; falls back to "honeyDue" if
* the input is blank after sanitising.
*/ */
fun safeShareFileName(displayName: String): String { fun safeShareFileName(displayName: String): String {
val safeName = displayName val safeName = displayName
.replace(" ", "_") // Keep whitespace through the filter so adjacent space+tab
.replace("/", "-") // sequences survive to the regex-collapse step below. Drop
// only non-whitespace control chars (NUL etc.) plus the
// explicit filesystem-unsafe set.
.filter { it !in UNSAFE_FILENAME_CHARS && (it.isWhitespace() || !it.isISOControl()) }
.replace(Regex("\\s+"), " ")
.trim()
.take(50) .take(50)
.ifBlank { "honeyDue" }
return "$safeName.honeydue" return "$safeName.honeydue"
} }
private val UNSAFE_FILENAME_CHARS = setOf('/', '\\', ':', '*', '?', '"', '<', '>', '|')
} }
@@ -0,0 +1,244 @@
package com.tt.honeyDue.models
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
/**
* Models for Ory Kratos native (`api`) self-service flows.
*
* honeyDue's Go API no longer owns identity — Ory Kratos does. The mobile
* client drives Kratos' native flows directly:
*
* 1. `GET {kratos}/self-service/{flow}/api` -> a flow object (id + ui.action)
* 2. `POST {ui.action}` -> success body or a re-rendered
* flow carrying validation messages in `ui.nodes[].messages` / `ui.messages`.
*
* Only the fields the client actually needs are modelled; `ignoreUnknownKeys`
* on the Json instance tolerates the rest of Kratos' (large) payloads.
*
* Kratos docs: https://www.ory.sh/docs/kratos/self-service
*/
// ==================== Flow envelope ====================
/**
* A Kratos self-service flow (login / registration / recovery / verification).
* Returned by the initial `GET .../{flow}/api` call.
*/
@Serializable
data class KratosFlow(
val id: String,
val type: String? = null,
@SerialName("expires_at") val expiresAt: String? = null,
@SerialName("issued_at") val issuedAt: String? = null,
val ui: KratosUi,
/** Present on a verification/recovery flow that is already complete. */
val state: String? = null,
/**
* Post-submission instructions. On a completed recovery flow this carries
* the privileged session token (`set_ory_session_token`) and the settings
* flow to finish the password change in (`show_settings_ui`).
*/
@SerialName("continue_with") val continueWith: List<KratosContinueWith> = emptyList(),
)
/**
* The renderable UI of a flow. `action` is the absolute URL the client must
* POST the method payload to; `messages` carries flow-level errors.
*/
@Serializable
data class KratosUi(
val action: String,
val method: String = "POST",
val nodes: List<KratosUiNode> = emptyList(),
val messages: List<KratosMessage> = emptyList(),
)
@Serializable
data class KratosUiNode(
val type: String? = null,
val group: String? = null,
val attributes: KratosUiNodeAttributes? = null,
val messages: List<KratosMessage> = emptyList(),
)
@Serializable
data class KratosUiNodeAttributes(
val name: String? = null,
val type: String? = null,
val value: JsonElement? = null,
)
/**
* A Kratos UI message. `type` is `info`, `error`, or `success`.
*/
@Serializable
data class KratosMessage(
val id: Long? = null,
val text: String,
val type: String? = null,
)
// ==================== Flow success bodies ====================
/**
* Identity traits as configured in the Kratos identity schema for honeyDue.
* `email` is the primary identifier; `name` mirrors the schema's nested object.
*/
@Serializable
data class KratosTraits(
val email: String,
val name: KratosName? = null,
)
@Serializable
data class KratosName(
val first: String = "",
val last: String = "",
)
/**
* A Kratos identity (subset). Returned nested inside [KratosSession].
*/
@Serializable
data class KratosIdentity(
val id: String,
@SerialName("schema_id") val schemaId: String? = null,
val state: String? = null,
val traits: KratosTraits? = null,
@SerialName("verifiable_addresses") val verifiableAddresses: List<KratosVerifiableAddress>? = null,
)
@Serializable
data class KratosVerifiableAddress(
val id: String? = null,
val value: String? = null,
val verified: Boolean = false,
val via: String? = null,
val status: String? = null,
)
/**
* A Kratos session. `active` + `expires_at` describe validity; `identity`
* carries the authenticated user's traits.
*/
@Serializable
data class KratosSession(
val id: String,
val active: Boolean = true,
@SerialName("expires_at") val expiresAt: String? = null,
@SerialName("authenticated_at") val authenticatedAt: String? = null,
val identity: KratosIdentity? = null,
)
/**
* Success body of a native login flow submission
* (`POST .../self-service/login/api`).
*/
@Serializable
data class KratosLoginSuccess(
val session: KratosSession,
@SerialName("session_token") val sessionToken: String,
)
/**
* Success body of a native registration flow submission
* (`POST .../self-service/registration/api`). With the
* `session` after-hook enabled, Kratos returns a session + token here.
*/
@Serializable
data class KratosRegistrationSuccess(
val session: KratosSession? = null,
@SerialName("session_token") val sessionToken: String? = null,
val identity: KratosIdentity? = null,
@SerialName("continue_with") val continueWith: List<KratosContinueWith> = emptyList(),
)
/**
* A `continue_with` item. `action` is one of `show_verification_ui`,
* `show_settings_ui`, `set_ory_session_token`, etc. — see Kratos docs.
* `flow` is present for the `show_*_ui` actions; `orySessionToken` is present
* for `set_ory_session_token` (the privileged session a recovery flow issues).
*/
@Serializable
data class KratosContinueWith(
val action: String? = null,
val flow: KratosContinueWithFlow? = null,
@SerialName("ory_session_token") val orySessionToken: String? = null,
)
@Serializable
data class KratosContinueWithFlow(
val id: String? = null,
@SerialName("verifiable_address") val verifiableAddress: String? = null,
)
// ==================== Submit payloads ====================
/**
* Body for submitting the `password` method to a login flow.
*/
@Serializable
data class KratosPasswordLoginBody(
val method: String = "password",
val identifier: String,
val password: String,
)
/**
* Body for submitting the `password` method to a registration flow.
*/
@Serializable
data class KratosPasswordRegistrationBody(
val method: String = "password",
val traits: KratosTraits,
val password: String,
)
/**
* Body for submitting an OIDC (`apple` / `google`) provider to a
* login or registration flow using a native `id_token` from the
* platform sign-in SDK.
*/
@Serializable
data class KratosOidcBody(
val method: String = "oidc",
val provider: String,
@SerialName("id_token") val idToken: String,
/** Optional traits — sent on registration so Kratos can seed the identity. */
val traits: KratosTraits? = null,
)
/**
* Body for submitting the `code` method to a recovery flow.
* The first POST omits [code] (sends just the email); the second
* POST includes the code the user received by email.
*/
@Serializable
data class KratosRecoveryBody(
val method: String = "code",
val email: String? = null,
val code: String? = null,
)
/**
* Body for submitting the `code` method to a verification flow.
* As with recovery: first POST sends the email, second sends the code.
*/
@Serializable
data class KratosVerificationBody(
val method: String = "code",
val email: String? = null,
val code: String? = null,
)
/**
* Body for updating an identity's password from within a settings flow
* (used after a recovery flow hands the client a privileged session).
*/
@Serializable
data class KratosSettingsPasswordBody(
val method: String = "password",
val password: String,
)
@@ -85,7 +85,13 @@ data class AuthResponse(
) )
/** /**
* Token refresh response - returned by POST /api/auth/refresh/ * Token refresh response.
*
* Identity is owned by Ory Kratos. Native Kratos session tokens are
* long-lived and not rotated — there is no refresh endpoint. This type is
* retained as the return shape of [com.tt.honeyDue.network.AuthApi.refreshToken],
* which now re-validates the session via Kratos `/sessions/whoami` and echoes
* the same (unchanged) token back when the session is still active.
*/ */
@Serializable @Serializable
data class TokenRefreshResponse( data class TokenRefreshResponse(
@@ -1276,20 +1276,24 @@ object APILayer {
} }
/** /**
* Refresh the current auth token. * Re-validate the current Kratos session.
* Calls POST /api/auth/refresh/ with the current token. *
* On success, saves the new token to DataManager and TokenStorage. * Identity is owned by Ory Kratos. Native Kratos session tokens are
* On failure, returns an error (caller decides whether to trigger logout). * long-lived and there is no native refresh endpoint — "refresh" here
* means: ask Kratos whether the session is still active (`/sessions/whoami`).
*
* - Session still valid → returns the same (unchanged) token.
* - Session gone → returns an error; the caller should sign out.
*
* The method name is kept so the Coil image interceptor and the
* `ApiClient` 401 plumbing continue to compile.
*/ */
suspend fun refreshToken(): ApiResult<String> { suspend fun refreshToken(): ApiResult<String> {
val currentToken = getToken() ?: return ApiResult.Error("No token", 401) val currentToken = getToken() ?: return ApiResult.Error("No token", 401)
val result = authApi.refreshToken(currentToken) return when (val result = authApi.refreshToken(currentToken)) {
if (result is ApiResult.Success) { // Kratos session tokens are never rotated — echo the same token
DataManager.setAuthToken(result.data.token) // back when the session is confirmed still valid.
com.tt.honeyDue.storage.TokenStorage.saveToken(result.data.token) is ApiResult.Success -> ApiResult.Success(currentToken)
}
return when (result) {
is ApiResult.Success -> ApiResult.Success(result.data.token)
is ApiResult.Error -> ApiResult.Error(result.message, result.code) is ApiResult.Error -> ApiResult.Error(result.message, result.code)
else -> ApiResult.Error("Unexpected state") else -> ApiResult.Error("Unexpected state")
} }
@@ -1,8 +1,6 @@
package com.tt.honeyDue.network package com.tt.honeyDue.network
import com.tt.honeyDue.data.DataManager 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.*
import io.ktor.client.call.* import io.ktor.client.call.*
import io.ktor.client.plugins.* import io.ktor.client.plugins.*
@@ -34,6 +32,30 @@ expect fun getDeviceLanguage(): String
*/ */
expect fun getDeviceTimezone(): String expect fun getDeviceTimezone(): String
/**
* The HTTP header the honeyDue API expects on authenticated requests.
*
* Identity is owned by Ory Kratos; the honeyDue API now authenticates a
* request by validating the Kratos **session token** carried on this header.
* This replaces the old `Authorization: Token <token>` scheme.
*
* Every honeyDue `*Api.kt` client sends this header via [authHeader]; image
* loading uses it through [CoilAuthInterceptor].
*/
const val SESSION_TOKEN_HEADER: String = "X-Session-Token"
/**
* Set the honeyDue session-token header on a request.
*
* Usage in an `*Api.kt` client:
* ```kotlin
* client.get("$baseUrl/tasks/") { authHeader(token) }
* ```
*/
fun HttpRequestBuilder.authHeader(token: String) {
header(SESSION_TOKEN_HEADER, token)
}
/** /**
* Mutex to prevent multiple concurrent token refresh attempts. * Mutex to prevent multiple concurrent token refresh attempts.
* When one request triggers a 401, only one refresh call is made; * When one request triggers a 401, only one refresh call is made;
@@ -80,37 +102,30 @@ fun HttpClientConfig<*>.installCommonPlugins() {
socketTimeoutMillis = 30_000 // 30 seconds socketTimeoutMillis = 30_000 // 30 seconds
} }
// Task 3: Token refresh on 401 responses // Task 3: Kratos session validation on 401 responses.
//
// The honeyDue API now authenticates via the Kratos session token on the
// X-Session-Token header. A 401 from the API means that token was
// rejected. We confirm with Kratos whether the session is genuinely gone:
// - still valid -> throw TokenExpiredException(refreshed = true) (retry)
// - gone -> clear auth, throw TokenExpiredException(false) (re-login)
HttpResponseValidator { HttpResponseValidator {
validateResponse { response -> validateResponse { response ->
if (response.status.value == 401) { 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 val currentToken = DataManager.authToken.value
if (currentToken != null) { if (currentToken != null) {
// Use mutex to prevent concurrent refresh attempts // Use the mutex so concurrent 401s only trigger one
val refreshed = tokenRefreshMutex.withLock { // whoami check against Kratos.
// Double-check: another coroutine may have already refreshed val stillValid = tokenRefreshMutex.withLock {
val tokenAfterLock = DataManager.authToken.value
if (tokenAfterLock != currentToken) {
// Token was already refreshed by another coroutine
true
} else {
attemptTokenRefresh(currentToken) attemptTokenRefresh(currentToken)
} }
} if (!stillValid) {
if (!refreshed) { // Session is gone — clear auth and route to login.
// Refresh failed — clear auth and trigger logout
DataManager.clear() DataManager.clear()
} }
// Throw so the caller can retry (or handle the logout) // Throw so the caller can retry (still valid) or handle
throw TokenExpiredException(refreshed) // the forced logout (session gone).
} throw TokenExpiredException(stillValid)
} }
} }
} }
@@ -118,13 +133,25 @@ fun HttpClientConfig<*>.installCommonPlugins() {
} }
/** /**
* Attempt to refresh the auth token by calling POST /api/auth/refresh/. * Re-validate the current Kratos session.
* Returns true if refresh succeeded and the new token was saved. *
* Identity is owned by Ory Kratos. Native Kratos session tokens are
* long-lived and there is **no native refresh endpoint** — when a session
* genuinely expires the user must sign in again. So "refresh" here means:
* ask Kratos `GET /sessions/whoami` whether the session is still active.
*
* - returns `true` → the session is still valid; the original 401 was
* transient (e.g. a brief replication lag) and the caller may retry.
* - returns `false` → the session is gone; the caller should clear auth and
* route the user back to login.
*
* The token itself is never rotated — [DataManager]/[TokenStorage] keep the
* same value either way.
*/ */
private suspend fun attemptTokenRefresh(currentToken: String): Boolean { private suspend fun attemptTokenRefresh(currentToken: String): Boolean {
return try { return try {
// Use a minimal client to avoid recursive interceptor triggers // Use a minimal client to avoid recursive interceptor triggers.
val refreshClient = HttpClient { val whoamiClient = HttpClient {
install(ContentNegotiation) { install(ContentNegotiation) {
json(Json { json(Json {
ignoreUnknownKeys = true ignoreUnknownKeys = true
@@ -137,38 +164,40 @@ private suspend fun attemptTokenRefresh(currentToken: String): Boolean {
socketTimeoutMillis = 15_000 socketTimeoutMillis = 15_000
} }
} }
val baseUrl = ApiConfig.getBaseUrl() val kratosUrl = ApiConfig.getKratosBaseUrl()
val response = refreshClient.post("$baseUrl/auth/refresh/") { val response = whoamiClient.get("$kratosUrl/sessions/whoami") {
header("Authorization", "Token $currentToken") header(SESSION_TOKEN_HEADER, currentToken)
contentType(ContentType.Application.Json) accept(ContentType.Application.Json)
} }
refreshClient.close() whoamiClient.close()
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
val tokenResponse = response.body<TokenRefreshResponse>() // Session still valid — keep the same token.
// Save the new token to both DataManager and persistent storage println("[ApiClient] Kratos session still valid")
DataManager.setAuthToken(tokenResponse.token)
TokenStorage.saveToken(tokenResponse.token)
println("[ApiClient] Token refreshed successfully")
true true
} else { } else {
println("[ApiClient] Token refresh failed: ${response.status.value}") println("[ApiClient] Kratos session invalid: ${response.status.value}")
false false
} }
} catch (e: Exception) { } catch (e: Exception) {
println("[ApiClient] Token refresh error: ${e.message}") println("[ApiClient] Kratos session check error: ${e.message}")
false false
} }
} }
/** /**
* Exception thrown when a 401 response indicates an expired token. * Exception thrown when a 401 response indicates an expired/invalid session.
* [refreshed] indicates whether the token was successfully refreshed. *
* Callers can catch this and retry the request if refreshed is true. * [refreshed] indicates whether the Kratos session was re-validated and is
* still usable. Callers can catch this and retry the request when `refreshed`
* is true; when false the user must re-authenticate.
*
* The name is retained from the pre-Kratos token scheme so existing callers
* ([CoilAuthInterceptor] plumbing, tests) continue to compile.
*/ */
class TokenExpiredException(val refreshed: Boolean) : Exception( class TokenExpiredException(val refreshed: Boolean) : Exception(
if (refreshed) "Token was expired but has been refreshed — retry the request" if (refreshed) "Session was briefly rejected but is still valid — retry the request"
else "Token expired and refresh failed — user must re-authenticate" else "Session expired — user must re-authenticate"
) )
object ApiClient { object ApiClient {
@@ -185,6 +214,13 @@ object ApiClient {
*/ */
fun getMediaBaseUrl(): String = ApiConfig.getMediaBaseUrl() fun getMediaBaseUrl(): String = ApiConfig.getMediaBaseUrl()
/**
* Get the Ory Kratos public API base URL. Identity flows (login,
* registration, recovery, verification, OIDC sign-in) run against this
* host — NOT [getBaseUrl].
*/
fun getKratosBaseUrl(): String = ApiConfig.getKratosBaseUrl()
/** /**
* Print current environment configuration * Print current environment configuration
*/ */
@@ -193,5 +229,6 @@ object ApiClient {
println("Environment: ${ApiConfig.getEnvironmentName()}") println("Environment: ${ApiConfig.getEnvironmentName()}")
println("Base URL: ${getBaseUrl()}") println("Base URL: ${getBaseUrl()}")
println("Media URL: ${getMediaBaseUrl()}") println("Media URL: ${getMediaBaseUrl()}")
println("Kratos URL: ${getKratosBaseUrl()}")
} }
} }
@@ -40,6 +40,28 @@ object ApiConfig {
} }
} }
/**
* Get the Ory Kratos public API base URL.
*
* Identity (login, registration, recovery, verification, OIDC sign-in) is
* owned by Ory Kratos — NOT the honeyDue Go API. The native (`api`)
* self-service flows live under `{kratosBaseUrl}/self-service/...`.
*
* - LOCAL: a Kratos instance running on the dev machine (default public
* port `4433`). The Android emulator reaches the host via `10.0.2.2`,
* the iOS simulator via `127.0.0.1` — both resolved by [getLocalhostAddress].
* - DEV / PROD: the hosted Kratos at `auth.myhoneydue.com`.
*
* No trailing slash — callers append `/self-service/...`.
*/
fun getKratosBaseUrl(): String {
return when (CURRENT_ENV) {
Environment.LOCAL -> "http://${getLocalhostAddress()}:4433"
Environment.DEV -> "https://auth.myhoneydue.com"
Environment.PROD -> "https://auth.myhoneydue.com"
}
}
/** /**
* Get environment name for logging * Get environment name for logging
*/ */
@@ -4,71 +4,533 @@ import com.tt.honeyDue.models.*
import io.ktor.client.* import io.ktor.client.*
import io.ktor.client.call.* import io.ktor.client.call.*
import io.ktor.client.request.* import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.* import io.ktor.http.*
import kotlinx.serialization.json.Json
/**
* Authentication API client.
*
* Identity for honeyDue is owned by **Ory Kratos** — NOT the honeyDue Go API.
* This client drives Kratos' **native (`api`) self-service flows**:
*
* - **Login** — `GET .../self-service/login/api` → `POST ui.action`
* with `{method:"password", identifier, password}`.
* - **Registration** — `GET .../self-service/registration/api` → `POST ui.action`
* with `{method:"password", traits:{email,name{first,last}}, password}`.
* - **Recovery** — `.../self-service/recovery/api` with `method:"code"`.
* - **Verification** — `.../self-service/verification/api` with `method:"code"`.
* - **OIDC** — Apple/Google: the platform SDK obtains a native
* `id_token`, submitted to the login/registration flow with
* `{method:"oidc", provider, id_token}`.
*
* On success Kratos returns a **`session_token`**. That token is what the
* honeyDue API now expects on the `X-Session-Token` header. The session token
* is stored via [com.tt.honeyDue.storage.TokenManager]; see [APILayer].
*
* Endpoints that still live on the honeyDue API — `GET /api/auth/me/`, profile
* update, account deletion — are also driven from here, sending the Kratos
* session token on the `X-Session-Token` header.
*
* The public method signatures are deliberately unchanged from the old
* hand-rolled auth client so [APILayer], [com.tt.honeyDue.viewmodel.AuthViewModel]
* and the UI continue to compile against the same `ApiResult<...>` shapes.
*/
class AuthApi(private val client: HttpClient = ApiClient.httpClient) { class AuthApi(private val client: HttpClient = ApiClient.httpClient) {
private val baseUrl = ApiClient.getBaseUrl()
suspend fun register(request: RegisterRequest): ApiResult<AuthResponse> { /** honeyDue Go API base — used only for `/auth/me`, profile, delete. */
return try { private val apiBaseUrl = ApiClient.getBaseUrl()
val response = client.post("$baseUrl/auth/register/") {
contentType(ContentType.Application.Json) /** Ory Kratos public API base — used for all identity flows. */
setBody(request) private val kratosBaseUrl = ApiConfig.getKratosBaseUrl()
private val json = Json {
ignoreUnknownKeys = true
isLenient = true
encodeDefaults = true
} }
/**
* Action URL of the recovery flow started by [forgotPassword]. Kratos
* binds the emailed recovery code to that specific flow, so [verifyResetCode]
* must submit the code back to the SAME flow — not a fresh one. Held in
* memory only: a process restart between the two steps simply means the
* user requests a new code (recovery flows are short-lived regardless).
*/
private var pendingRecoveryAction: String? = null
// ==================== Kratos flow plumbing ====================
/**
* Fetch a fresh native self-service flow from Kratos.
*
* @param flow one of `login`, `registration`, `recovery`, `verification`.
* @param refresh when true (login only) forces re-authentication of an
* already-valid session.
*/
private suspend fun initFlow(flow: String, refresh: Boolean = false): ApiResult<KratosFlow> {
return try {
val response = client.get("$kratosBaseUrl/self-service/$flow/api") {
accept(ContentType.Application.Json)
if (refresh) parameter("refresh", "true")
}
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
ApiResult.Success(response.body()) ApiResult.Success(json.decodeFromString(KratosFlow.serializer(), response.bodyAsText()))
} else { } else {
// Parse actual error message from backend ApiResult.Error(
val errorMessage = ErrorParser.parseError(response) "Could not start $flow (Kratos ${response.status.value})",
ApiResult.Error(errorMessage, response.status.value) response.status.value,
)
} }
} catch (e: Exception) { } catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred") ApiResult.Error(e.message ?: "Could not reach the authentication server")
} }
} }
/**
* Submit a method payload to a flow's `ui.action` URL and decode the
* result with [decode]. On a 4xx Kratos re-renders the flow with
* validation messages — those are extracted via [extractKratosError].
*/
private suspend fun <T> submitFlow(
actionUrl: String,
bodyJson: String,
decode: (String) -> T,
): ApiResult<T> {
return try {
val response = client.post(actionUrl) {
contentType(ContentType.Application.Json)
accept(ContentType.Application.Json)
setBody(bodyJson)
}
val text = response.bodyAsText()
if (response.status.isSuccess()) {
ApiResult.Success(decode(text))
} else {
ApiResult.Error(extractKratosError(text, response.status.value), response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Authentication request failed")
}
}
/**
* Pull a human-readable error out of a Kratos error/flow body.
*
* Kratos surfaces validation problems in `ui.messages` and
* `ui.nodes[].messages`; hard errors (expired flow, etc.) come back as
* `{ error: { message, reason } }`.
*/
private fun extractKratosError(body: String, statusCode: Int): String {
// 1. Re-rendered flow with field/flow messages.
runCatching {
val flow = json.decodeFromString(KratosFlow.serializer(), body)
val msgs = buildList {
flow.ui.messages.filter { it.type == "error" || it.type == null }
.forEach { add(it.text) }
flow.ui.nodes.flatMap { it.messages }
.filter { it.type == "error" || it.type == null }
.forEach { add(it.text) }
}.distinct()
if (msgs.isNotEmpty()) return msgs.joinToString(". ")
}
// 2. Generic Kratos error envelope.
runCatching {
val env = json.decodeFromString(KratosErrorEnvelope.serializer(), body)
val msg = env.error?.reason ?: env.error?.message
if (!msg.isNullOrBlank()) return msg
}
return "Authentication failed ($statusCode)"
}
// ==================== Login ====================
/**
* Native password login against Kratos.
*
* [LoginRequest.username] is treated as the Kratos identifier (the user's
* email — honeyDue identities are keyed by email).
*/
suspend fun login(request: LoginRequest): ApiResult<AuthResponse> { suspend fun login(request: LoginRequest): ApiResult<AuthResponse> {
return try { val flow = when (val f = initFlow("login")) {
val response = client.post("$baseUrl/auth/login/") { is ApiResult.Success -> f.data
contentType(ContentType.Application.Json) is ApiResult.Error -> return f
setBody(request) else -> return ApiResult.Error("Could not start login")
}
val body = json.encodeToString(
KratosPasswordLoginBody.serializer(),
KratosPasswordLoginBody(identifier = request.username.trim(), password = request.password),
)
val success = submitFlow(flow.ui.action, body) {
json.decodeFromString(KratosLoginSuccess.serializer(), it)
}
return when (success) {
is ApiResult.Success -> resolveSession(success.data.sessionToken, success.data.session)
is ApiResult.Error -> success
else -> ApiResult.Error("Login failed")
}
} }
if (response.status.isSuccess()) { // ==================== Registration ====================
ApiResult.Success(response.body())
/**
* Native password registration against Kratos.
*
* 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.
*/
suspend fun register(request: RegisterRequest): ApiResult<AuthResponse> {
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")
}
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 { } else {
// Parse actual error message from backend resolveSession(token, success.data.session)
val errorMessage = ErrorParser.parseError(response)
ApiResult.Error(errorMessage, response.status.value)
} }
} catch (e: Exception) { }
ApiResult.Error(e.message ?: "Unknown error occurred") is ApiResult.Error -> success
else -> ApiResult.Error("Registration failed")
} }
} }
// ==================== OIDC (Apple / Google) ====================
/**
* Submit a native OIDC `id_token` to Kratos.
*
* The platform SDK (Sign in with Apple / Google Sign-In) obtains the
* `id_token`; Kratos verifies it and either logs the user in or registers
* a new identity. We try the login flow first; if Kratos reports the
* identity does not exist we fall through to the registration flow.
*
* @param provider `"apple"` or `"google"`.
* @param idToken the platform-issued OpenID Connect ID token.
* @param traits optional traits used to seed a new identity on first sign-in.
*/
private suspend fun oidcSignIn(
provider: String,
idToken: String,
traits: KratosTraits?,
): ApiResult<AuthResponse> {
// 1. Attempt the login flow.
val loginFlow = when (val f = initFlow("login")) {
is ApiResult.Success -> f.data
is ApiResult.Error -> return f
else -> return ApiResult.Error("Could not start sign-in")
}
val loginBody = json.encodeToString(
KratosOidcBody.serializer(),
KratosOidcBody(provider = provider, idToken = idToken),
)
val loginResult = submitFlow(loginFlow.ui.action, loginBody) {
json.decodeFromString(KratosLoginSuccess.serializer(), it)
}
if (loginResult is ApiResult.Success) {
return resolveSession(loginResult.data.sessionToken, loginResult.data.session)
}
// 2. No identity yet — drive the registration flow with the same token.
val regFlow = when (val f = initFlow("registration")) {
is ApiResult.Success -> f.data
is ApiResult.Error -> return (loginResult as? ApiResult.Error) ?: f
else -> return ApiResult.Error("Could not start sign-up")
}
val regBody = json.encodeToString(
KratosOidcBody.serializer(),
KratosOidcBody(provider = provider, idToken = idToken, traits = traits),
)
val regResult = submitFlow(regFlow.ui.action, regBody) {
json.decodeFromString(KratosRegistrationSuccess.serializer(), it)
}
return when (regResult) {
is ApiResult.Success -> {
val token = regResult.data.sessionToken
if (token.isNullOrBlank()) {
(loginResult as? ApiResult.Error)
?: ApiResult.Error("Sign-in did not return a session")
} else {
resolveSession(token, regResult.data.session)
}
}
is ApiResult.Error -> regResult
else -> ApiResult.Error("Sign-in failed")
}
}
/**
* Apple Sign In via Kratos OIDC. The Apple `id_token` is obtained natively
* by the platform; [AppleSignInRequest.userId]/email/name seed the
* identity on first sign-in.
*/
suspend fun appleSignIn(request: AppleSignInRequest): ApiResult<AppleSignInResponse> {
val traits = request.email?.takeIf { it.isNotBlank() }?.let {
KratosTraits(
email = it,
name = KratosName(
first = request.firstName ?: "",
last = request.lastName ?: "",
),
)
}
return when (val r = oidcSignIn("apple", request.idToken, traits)) {
is ApiResult.Success -> ApiResult.Success(
AppleSignInResponse(token = r.data.token, user = r.data.user, isNewUser = false),
)
is ApiResult.Error -> r
else -> ApiResult.Error("Apple Sign In failed")
}
}
/**
* Google Sign In via Kratos OIDC. The Google `id_token` is obtained
* natively by the platform Google Sign-In SDK.
*/
suspend fun googleSignIn(request: GoogleSignInRequest): ApiResult<GoogleSignInResponse> {
return when (val r = oidcSignIn("google", request.idToken, traits = null)) {
is ApiResult.Success -> ApiResult.Success(
GoogleSignInResponse(token = r.data.token, user = r.data.user, isNewUser = false),
)
is ApiResult.Error -> r
else -> ApiResult.Error("Google Sign In failed")
}
}
// ==================== Logout ====================
/**
* Terminate the Kratos session.
*
* The native logout flow is a single call:
* `DELETE .../self-service/logout/api` with `{ session_token }`.
* A failure here is non-fatal — the caller drops the token locally
* regardless (see [APILayer.logout]).
*/
suspend fun logout(token: String): ApiResult<Unit> { suspend fun logout(token: String): ApiResult<Unit> {
return try { return try {
val response = client.post("$baseUrl/auth/logout/") { client.delete("$kratosBaseUrl/self-service/logout/api") {
header("Authorization", "Token $token") contentType(ContentType.Application.Json)
setBody(json.encodeToString(LogoutBody.serializer(), LogoutBody(token)))
}
// Treat any outcome as success — the token is being discarded
// locally anyway, and a stale-token DELETE is harmless.
ApiResult.Success(Unit)
} catch (e: Exception) {
ApiResult.Success(Unit)
}
} }
if (response.status.isSuccess()) { // ==================== Recovery (forgot password) ====================
ApiResult.Success(Unit)
/**
* Start a Kratos recovery flow and submit the user's email so Kratos
* mails them a recovery code.
*
* Mirrors the legacy `forgotPassword` signature. The returned message is
* Kratos' confirmation text.
*/
suspend fun forgotPassword(request: ForgotPasswordRequest): ApiResult<ForgotPasswordResponse> {
val flow = when (val f = initFlow("recovery")) {
is ApiResult.Success -> f.data
is ApiResult.Error -> return f
else -> return ApiResult.Error("Could not start password recovery")
}
val body = json.encodeToString(
KratosRecoveryBody.serializer(),
KratosRecoveryBody(email = request.email.trim()),
)
// Kratos returns the re-rendered flow (200) carrying an info message
// that the code was sent. A 4xx means the email was malformed.
val result = submitFlow(flow.ui.action, body) {
runCatching { json.decodeFromString(KratosFlow.serializer(), it) }.getOrNull()
}
return when (result) {
is ApiResult.Success -> {
// Remember this flow's action — verifyResetCode submits the
// emailed code back to the SAME flow Kratos bound it to.
pendingRecoveryAction = flow.ui.action
val info = result.data?.ui?.messages?.firstOrNull()?.text
ApiResult.Success(
ForgotPasswordResponse(
message = info ?: "If that email exists, a recovery code has been sent.",
),
)
}
is ApiResult.Error -> result
else -> ApiResult.Error("Could not send recovery code")
}
}
/**
* Submit the recovery code the user received by email.
*
* The code is submitted back to the SAME recovery flow [forgotPassword]
* started ([pendingRecoveryAction]) — Kratos binds the emailed code to that
* flow. A valid code drives the flow to `passed_challenge`, and Kratos then
* returns, via `continue_with`, the privileged session token plus the
* settings flow to finish the password change in. Both are packed into the
* opaque `resetToken` that [resetPassword] consumes.
*/
suspend fun verifyResetCode(request: VerifyResetCodeRequest): ApiResult<VerifyResetCodeResponse> {
val action = pendingRecoveryAction
?: return ApiResult.Error("Your recovery session expired. Request a new code.")
val body = json.encodeToString(
KratosRecoveryBody.serializer(),
KratosRecoveryBody(code = request.code.trim()),
)
val result = submitFlow(action, body) {
runCatching { json.decodeFromString(KratosFlow.serializer(), it) }.getOrNull()
}
return when (result) {
is ApiResult.Success -> {
val flow = result.data
val settingsFlowId = flow?.continueWith
?.firstOrNull { it.action == "show_settings_ui" }?.flow?.id
val sessionToken = flow?.continueWith
?.firstOrNull { it.action == "set_ory_session_token" }?.orySessionToken
if (settingsFlowId != null && sessionToken != null) {
pendingRecoveryAction = null
ApiResult.Success(
VerifyResetCodeResponse(
message = "Code verified.",
// Opaque to the UI: carries the settings flow id and
// the privileged session token resetPassword needs,
// packed as "<settingsFlowId>|<sessionToken>".
resetToken = "$settingsFlowId|$sessionToken",
),
)
} else { } else {
ApiResult.Error("Logout failed", response.status.value) // No continue_with → the code was wrong or the flow expired;
// surface Kratos' own validation message when present.
val msg = flow?.ui?.messages?.firstOrNull { it.type == "error" }?.text
?: flow?.ui?.nodes?.flatMap { it.messages }
?.firstOrNull { it.type == "error" }?.text
ApiResult.Error(msg ?: "Invalid or expired code")
}
}
is ApiResult.Error -> result
else -> ApiResult.Error("Invalid or expired code")
}
}
/**
* Complete a password reset.
*
* [verifyResetCode] packed the settings flow id and the privileged session
* token Kratos issued into [ResetPasswordRequest.resetToken] as
* `"<settingsFlowId>|<sessionToken>"`. This submits the new password to
* that settings flow, authenticating with the privileged session token.
*/
suspend fun resetPassword(request: ResetPasswordRequest): ApiResult<ResetPasswordResponse> {
val parts = request.resetToken.split("|", limit = 2)
if (parts.size != 2 || parts[0].isBlank() || parts[1].isBlank()) {
return ApiResult.Error("This password reset session has expired. Request a new code.")
}
val settingsFlowId = parts[0]
val sessionToken = parts[1]
return try {
val response = client.post("$kratosBaseUrl/self-service/settings") {
contentType(ContentType.Application.Json)
accept(ContentType.Application.Json)
// The privileged session Kratos issued on code verification
// authorizes the settings flow that changes the password.
header("X-Session-Token", sessionToken)
parameter("flow", settingsFlowId)
setBody(
json.encodeToString(
KratosSettingsPasswordBody.serializer(),
KratosSettingsPasswordBody(password = request.newPassword),
),
)
}
val text = response.bodyAsText()
if (response.status.isSuccess()) {
ApiResult.Success(ResetPasswordResponse(message = "Password updated. You can now sign in."))
} else {
ApiResult.Error(extractKratosError(text, response.status.value), response.status.value)
} }
} catch (e: Exception) { } catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred") ApiResult.Error(e.message ?: "Could not reset password")
} }
} }
// ==================== Email verification ====================
/**
* Submit an email-verification code to Kratos' verification flow.
*
* 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.
*/
suspend fun verifyEmail(token: String, request: VerifyEmailRequest): ApiResult<VerifyEmailResponse> {
val flow = when (val f = initFlow("verification")) {
is ApiResult.Success -> f.data
is ApiResult.Error -> return f
else -> return ApiResult.Error("Could not start verification")
}
val body = json.encodeToString(
KratosVerificationBody.serializer(),
KratosVerificationBody(code = request.code.trim()),
)
val result = submitFlow(flow.ui.action, 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,
),
)
}
is ApiResult.Error -> result
else -> ApiResult.Error("Verification failed")
}
}
// ==================== honeyDue API (still session-token-gated) ====================
/**
* Fetch the current honeyDue user from the honeyDue Go API.
*
* Identity lives in Kratos, but the honeyDue API still owns the
* application-level user record (numeric id, profile, verified flag). The
* Kratos session token is sent on the `X-Session-Token` header.
*/
suspend fun getCurrentUser(token: String): ApiResult<User> { suspend fun getCurrentUser(token: String): ApiResult<User> {
return try { return try {
val response = client.get("$baseUrl/auth/me/") { val response = client.get("$apiBaseUrl/auth/me/") {
header("Authorization", "Token $token") header(HEADER_SESSION_TOKEN, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
ApiResult.Success(response.body()) ApiResult.Success(response.body())
} else { } else {
@@ -79,214 +541,152 @@ class AuthApi(private val client: HttpClient = ApiClient.httpClient) {
} }
} }
suspend fun verifyEmail(token: String, request: VerifyEmailRequest): ApiResult<VerifyEmailResponse> { /**
return try { * Update the honeyDue user profile. Profile data lives on the honeyDue
val response = client.post("$baseUrl/auth/verify-email/") { * API, not Kratos, so this still targets the Go API.
header("Authorization", "Token $token") */
contentType(ContentType.Application.Json)
setBody(request)
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
val errorBody = try {
response.body<Map<String, String>>()
} catch (e: Exception) {
mapOf("error" to "Verification failed")
}
ApiResult.Error(errorBody["error"] ?: "Verification failed", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
suspend fun updateProfile(token: String, request: UpdateProfileRequest): ApiResult<User> { suspend fun updateProfile(token: String, request: UpdateProfileRequest): ApiResult<User> {
return try { return try {
val response = client.put("$baseUrl/auth/profile/") { val response = client.put("$apiBaseUrl/auth/profile/") {
header("Authorization", "Token $token") header(HEADER_SESSION_TOKEN, token)
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody(request) setBody(request)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
ApiResult.Success(response.body()) ApiResult.Success(response.body())
} else { } else {
val errorBody = try { ApiResult.Error(ErrorParser.parseError(response), response.status.value)
response.body<Map<String, String>>()
} catch (e: Exception) {
mapOf("error" to "Profile update failed")
}
ApiResult.Error(errorBody["error"] ?: "Profile update failed", response.status.value)
} }
} catch (e: Exception) { } catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred") ApiResult.Error(e.message ?: "Unknown error occurred")
} }
} }
// Password Reset Methods /**
suspend fun forgotPassword(request: ForgotPasswordRequest): ApiResult<ForgotPasswordResponse> { * Delete the honeyDue account. The honeyDue API is responsible for
return try { * tearing down its own user record and asking Kratos to delete the
val response = client.post("$baseUrl/auth/forgot-password/") { * backing identity.
contentType(ContentType.Application.Json) */
setBody(request)
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
val errorBody = try {
response.body<Map<String, String>>()
} catch (e: Exception) {
mapOf("error" to "Failed to send reset code")
}
ApiResult.Error(errorBody["error"] ?: "Failed to send reset code", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
suspend fun verifyResetCode(request: VerifyResetCodeRequest): ApiResult<VerifyResetCodeResponse> {
return try {
val response = client.post("$baseUrl/auth/verify-reset-code/") {
contentType(ContentType.Application.Json)
setBody(request)
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
val errorBody = try {
response.body<Map<String, String>>()
} catch (e: Exception) {
mapOf("error" to "Invalid code")
}
ApiResult.Error(errorBody["error"] ?: "Invalid code", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
suspend fun resetPassword(request: ResetPasswordRequest): ApiResult<ResetPasswordResponse> {
return try {
val response = client.post("$baseUrl/auth/reset-password/") {
contentType(ContentType.Application.Json)
setBody(request)
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
// Try to parse Django validation errors (Map<String, List<String>>)
val errorMessage = try {
val validationErrors = response.body<Map<String, List<String>>>()
// Flatten all error messages into a single string
validationErrors.flatMap { (field, errors) ->
errors.map { error ->
if (field == "non_field_errors") error else "$field: $error"
}
}.joinToString(". ")
} catch (e: Exception) {
// Try simple error format {error: "message"}
try {
val simpleError = response.body<Map<String, String>>()
simpleError["error"] ?: "Failed to reset password"
} catch (e2: Exception) {
"Failed to reset password"
}
}
ApiResult.Error(errorMessage, response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
// Apple Sign In
suspend fun appleSignIn(request: AppleSignInRequest): ApiResult<AppleSignInResponse> {
return try {
val response = client.post("$baseUrl/auth/apple-sign-in/") {
contentType(ContentType.Application.Json)
setBody(request)
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
val errorBody = try {
response.body<Map<String, String>>()
} catch (e: Exception) {
mapOf("error" to "Apple Sign In failed")
}
ApiResult.Error(errorBody["error"] ?: "Apple Sign In failed", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
// Delete Account
suspend fun deleteAccount(token: String, request: DeleteAccountRequest): ApiResult<Unit> { suspend fun deleteAccount(token: String, request: DeleteAccountRequest): ApiResult<Unit> {
return try { return try {
val response = client.delete("$baseUrl/auth/account/") { val response = client.delete("$apiBaseUrl/auth/account/") {
header("Authorization", "Token $token") header(HEADER_SESSION_TOKEN, token)
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody(request) setBody(request)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
ApiResult.Success(Unit) ApiResult.Success(Unit)
} else { } else {
val errorMessage = ErrorParser.parseError(response) ApiResult.Error(ErrorParser.parseError(response), response.status.value)
ApiResult.Error(errorMessage, response.status.value)
} }
} catch (e: Exception) { } catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred") ApiResult.Error(e.message ?: "Unknown error occurred")
} }
} }
// Token Refresh /**
* Legacy token-refresh shim.
*
* Kratos native session tokens are long-lived and there is no native
* refresh endpoint — when a session expires the user must re-authenticate.
* This method is kept so [ApiClient]'s 401 plumbing and the Coil image
* interceptor still compile; it simply re-validates the current session
* via Kratos `/sessions/whoami` and echoes the same token back if still
* valid, or fails otherwise.
*/
suspend fun refreshToken(token: String): ApiResult<TokenRefreshResponse> { suspend fun refreshToken(token: String): ApiResult<TokenRefreshResponse> {
return try { return try {
val response = client.post("$baseUrl/auth/refresh/") { val response = client.get("$kratosBaseUrl/sessions/whoami") {
header("Authorization", "Token $token") header(HEADER_SESSION_TOKEN, token)
contentType(ContentType.Application.Json) accept(ContentType.Application.Json)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
ApiResult.Success(response.body()) ApiResult.Success(TokenRefreshResponse(token = token))
} else { } else {
val errorMessage = ErrorParser.parseError(response) ApiResult.Error("Session expired — please sign in again", response.status.value)
ApiResult.Error(errorMessage, response.status.value)
} }
} catch (e: Exception) { } catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred") ApiResult.Error(e.message ?: "Could not validate session")
} }
} }
// Google Sign In // ==================== Session → User resolution ====================
suspend fun googleSignIn(request: GoogleSignInRequest): ApiResult<GoogleSignInResponse> {
return try { /**
val response = client.post("$baseUrl/auth/google-sign-in/") { * Given a freshly issued Kratos `session_token`, fetch the honeyDue
contentType(ContentType.Application.Json) * application user that backs the Kratos identity and assemble an
setBody(request) * [AuthResponse].
*
* The honeyDue API maps Kratos identities to its own numeric user records;
* `/auth/me` is the source of truth for `User.id`, profile and the
* `verified` flag. If `/auth/me` is unreachable we fall back to a
* best-effort [User] synthesised from the Kratos identity traits so the
* app can still proceed.
*/
private suspend fun resolveSession(
sessionToken: String,
session: KratosSession?,
): ApiResult<AuthResponse> {
return when (val me = getCurrentUser(sessionToken)) {
is ApiResult.Success -> ApiResult.Success(AuthResponse(token = sessionToken, user = me.data))
is ApiResult.Error -> {
val fallback = session?.identity?.let { userFromKratosIdentity(it) }
if (fallback != null) {
ApiResult.Success(AuthResponse(token = sessionToken, user = fallback))
} else {
me
}
}
else -> ApiResult.Error("Could not load profile after sign-in")
}
} }
if (response.status.isSuccess()) { /**
ApiResult.Success(response.body()) * Best-effort [User] built from a Kratos identity when the honeyDue
} else { * `/auth/me` lookup is unavailable. `id` is `0` (Kratos ids are UUIDs,
val errorBody = try { * not the honeyDue numeric id) — callers should re-fetch via
response.body<Map<String, String>>() * [getCurrentUser] as soon as the API is reachable.
} catch (e: Exception) { */
mapOf("error" to "Google Sign In failed") private fun userFromKratosIdentity(identity: KratosIdentity): User {
} val traits = identity.traits
ApiResult.Error(errorBody["error"] ?: "Google Sign In failed", response.status.value) return User(
} id = 0,
} catch (e: Exception) { username = traits?.email ?: "",
ApiResult.Error(e.message ?: "Unknown error occurred") email = traits?.email ?: "",
firstName = traits?.name?.first ?: "",
lastName = traits?.name?.last ?: "",
isActive = identity.state == null || identity.state == "active",
dateJoined = "",
authProvider = "kratos",
profile = null,
)
} }
companion object {
/**
* The header the honeyDue API now expects on authenticated requests —
* carries the Kratos session token. Replaces `Authorization: Token …`.
*/
const val HEADER_SESSION_TOKEN = "X-Session-Token"
} }
} }
/** Body for Kratos' native `DELETE /self-service/logout/api`. */
@kotlinx.serialization.Serializable
private data class LogoutBody(
@kotlinx.serialization.SerialName("session_token") val sessionToken: String,
)
/** Generic Kratos error envelope: `{ "error": { "message", "reason", ... } }`. */
@kotlinx.serialization.Serializable
private data class KratosErrorEnvelope(
val error: KratosErrorDetail? = null,
)
@kotlinx.serialization.Serializable
private data class KratosErrorDetail(
val message: String? = null,
val reason: String? = null,
val status: String? = null,
val code: Int? = null,
)
@@ -7,14 +7,18 @@ import coil3.request.ErrorResult
import coil3.request.ImageResult import coil3.request.ImageResult
/** /**
* Coil3 [Interceptor] that attaches an `Authorization` header to every * Coil3 [Interceptor] that attaches the honeyDue session-token header to every
* outgoing image request and, on an HTTP 401 response, refreshes the token * outgoing image request and, on an HTTP 401 response, re-validates the
* and retries exactly once. * session and retries exactly once.
*
* honeyDue's identity is owned by Ory Kratos. Authenticated honeyDue API
* requests — including authenticated media — carry the Kratos session token
* on the **`X-Session-Token`** header (the old `Authorization: Token …` scheme
* is gone). This interceptor centralises that concern so individual
* composables don't thread the token through themselves.
* *
* Mirrors the behavior of the iOS `AuthenticatedImage` in * Mirrors the behavior of the iOS `AuthenticatedImage` in
* `iosApp/iosApp/Components/AuthenticatedImage.swift`, centralising the * `iosApp/iosApp/Components/AuthenticatedImage.swift`.
* concern so individual composables don't need to thread the token through
* themselves.
* *
* Usage — install on the singleton [coil3.ImageLoader]: * Usage — install on the singleton [coil3.ImageLoader]:
* ```kotlin * ```kotlin
@@ -23,24 +27,28 @@ import coil3.request.ImageResult
* add(CoilAuthInterceptor( * add(CoilAuthInterceptor(
* tokenProvider = { TokenStorage.getToken() }, * tokenProvider = { TokenStorage.getToken() },
* refreshToken = { (APILayer.refreshToken() as? ApiResult.Success)?.data }, * refreshToken = { (APILayer.refreshToken() as? ApiResult.Success)?.data },
* authScheme = "Token",
* )) * ))
* add(KtorNetworkFetcherFactory()) * add(KtorNetworkFetcherFactory())
* } * }
* .build() * .build()
* ``` * ```
* *
* @param tokenProvider Suspending supplier of the current auth token. Returning * @param tokenProvider Suspending supplier of the current session token.
* `null` means "no token available" — the request proceeds unauthenticated. * Returning `null` means "no token available" — the request proceeds
* @param refreshToken Suspending supplier that refreshes the backing session and * unauthenticated so anonymous endpoints still work.
* returns a fresh token, or `null` if refresh failed. * @param refreshToken Suspending supplier that re-validates the session and
* @param authScheme The auth scheme to prefix the token with (default `Token` * returns a still-valid token, or `null` if the session is gone. With
* to match the existing Go backend — use `Bearer` for JWT deployments). * Kratos, session tokens are not rotated — this typically echoes the same
* token back when the session is still active.
* @param headerName The HTTP header carrying the token. Defaults to
* [SESSION_TOKEN_HEADER] (`X-Session-Token`). The token is sent as the bare
* header value — there is no `<scheme> ` prefix under the Kratos
* session-token scheme.
*/ */
class CoilAuthInterceptor( class CoilAuthInterceptor(
private val tokenProvider: suspend () -> String?, private val tokenProvider: suspend () -> String?,
private val refreshToken: suspend () -> String?, private val refreshToken: suspend () -> String?,
private val authScheme: String = "Token", private val headerName: String = SESSION_TOKEN_HEADER,
) : Interceptor { ) : Interceptor {
override suspend fun intercept(chain: Interceptor.Chain): ImageResult { override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
@@ -55,26 +63,26 @@ class CoilAuthInterceptor(
val authed = chain.request.newBuilder() val authed = chain.request.newBuilder()
.httpHeaders( .httpHeaders(
chain.request.httpHeaders.newBuilder() chain.request.httpHeaders.newBuilder()
.set(HEADER_AUTHORIZATION, "$authScheme $token") .set(headerName, token)
.build() .build()
) )
.build() .build()
val result = chain.withRequest(authed).proceed() val result = chain.withRequest(authed).proceed()
// If the server rejected the token, try refreshing once. // If the server rejected the token, re-validate the session once.
if (result.isUnauthorized()) { if (result.isUnauthorized()) {
val newToken = refreshToken() ?: return result val newToken = refreshToken() ?: return result
val retried = authed.newBuilder() val retried = authed.newBuilder()
.httpHeaders( .httpHeaders(
authed.httpHeaders.newBuilder() authed.httpHeaders.newBuilder()
.set(HEADER_AUTHORIZATION, "$authScheme $newToken") .set(headerName, newToken)
.build() .build()
) )
.build() .build()
// Only retry *once* — whatever comes back from this call is final, // Only retry *once* — whatever comes back from this call is final,
// even if it is itself a 401. This guards against an infinite loop // even if it is itself a 401. This guards against an infinite loop
// when refresh succeeds but the backing account is still revoked. // when the session is still revoked.
return chain.withRequest(retried).proceed() return chain.withRequest(retried).proceed()
} }
@@ -88,7 +96,6 @@ class CoilAuthInterceptor(
} }
companion object { companion object {
private const val HEADER_AUTHORIZATION = "Authorization"
private const val HTTP_UNAUTHORIZED = 401 private const val HTTP_UNAUTHORIZED = 401
} }
} }
@@ -18,7 +18,7 @@ class ContractorApi(private val client: HttpClient = ApiClient.httpClient) {
): ApiResult<List<ContractorSummary>> { ): ApiResult<List<ContractorSummary>> {
return try { return try {
val response = client.get("$baseUrl/contractors/") { val response = client.get("$baseUrl/contractors/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
specialty?.let { parameter("specialty", it) } specialty?.let { parameter("specialty", it) }
isFavorite?.let { parameter("is_favorite", it) } isFavorite?.let { parameter("is_favorite", it) }
isActive?.let { parameter("is_active", it) } isActive?.let { parameter("is_active", it) }
@@ -38,7 +38,7 @@ class ContractorApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun getContractor(token: String, id: Int): ApiResult<Contractor> { suspend fun getContractor(token: String, id: Int): ApiResult<Contractor> {
return try { return try {
val response = client.get("$baseUrl/contractors/$id/") { val response = client.get("$baseUrl/contractors/$id/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -54,7 +54,7 @@ class ContractorApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun createContractor(token: String, request: ContractorCreateRequest): ApiResult<Contractor> { suspend fun createContractor(token: String, request: ContractorCreateRequest): ApiResult<Contractor> {
return try { return try {
val response = client.post("$baseUrl/contractors/") { val response = client.post("$baseUrl/contractors/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody(request) setBody(request)
} }
@@ -77,7 +77,7 @@ class ContractorApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun updateContractor(token: String, id: Int, request: ContractorUpdateRequest): ApiResult<Contractor> { suspend fun updateContractor(token: String, id: Int, request: ContractorUpdateRequest): ApiResult<Contractor> {
return try { return try {
val response = client.patch("$baseUrl/contractors/$id/") { val response = client.patch("$baseUrl/contractors/$id/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody(request) setBody(request)
} }
@@ -100,7 +100,7 @@ class ContractorApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun deleteContractor(token: String, id: Int): ApiResult<Unit> { suspend fun deleteContractor(token: String, id: Int): ApiResult<Unit> {
return try { return try {
val response = client.delete("$baseUrl/contractors/$id/") { val response = client.delete("$baseUrl/contractors/$id/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -116,7 +116,7 @@ class ContractorApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun toggleFavorite(token: String, id: Int): ApiResult<Contractor> { suspend fun toggleFavorite(token: String, id: Int): ApiResult<Contractor> {
return try { return try {
val response = client.post("$baseUrl/contractors/$id/toggle-favorite/") { val response = client.post("$baseUrl/contractors/$id/toggle-favorite/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -132,7 +132,7 @@ class ContractorApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun getContractorTasks(token: String, id: Int): ApiResult<List<TaskResponse>> { suspend fun getContractorTasks(token: String, id: Int): ApiResult<List<TaskResponse>> {
return try { return try {
val response = client.get("$baseUrl/contractors/$id/tasks/") { val response = client.get("$baseUrl/contractors/$id/tasks/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -148,7 +148,7 @@ class ContractorApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun getContractorsByResidence(token: String, residenceId: Int): ApiResult<List<ContractorSummary>> { suspend fun getContractorsByResidence(token: String, residenceId: Int): ApiResult<List<ContractorSummary>> {
return try { return try {
val response = client.get("$baseUrl/contractors/by-residence/$residenceId/") { val response = client.get("$baseUrl/contractors/by-residence/$residenceId/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -26,7 +26,7 @@ class DocumentApi(private val client: HttpClient = ApiClient.httpClient) {
): ApiResult<List<Document>> { ): ApiResult<List<Document>> {
return try { return try {
val response = client.get("$baseUrl/documents/") { val response = client.get("$baseUrl/documents/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
residenceId?.let { parameter("residence", it) } residenceId?.let { parameter("residence", it) }
documentType?.let { parameter("document_type", it) } documentType?.let { parameter("document_type", it) }
isActive?.let { parameter("is_active", it) } isActive?.let { parameter("is_active", it) }
@@ -47,7 +47,7 @@ class DocumentApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun getDocument(token: String, id: Int): ApiResult<Document> { suspend fun getDocument(token: String, id: Int): ApiResult<Document> {
return try { return try {
val response = client.get("$baseUrl/documents/$id/") { val response = client.get("$baseUrl/documents/$id/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -127,7 +127,7 @@ class DocumentApi(private val client: HttpClient = ApiClient.httpClient) {
} }
} }
) { ) {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
} else { } else {
// If no file, use JSON // If no file, use JSON
@@ -143,7 +143,7 @@ class DocumentApi(private val client: HttpClient = ApiClient.httpClient) {
residenceId = residenceId residenceId = residenceId
) )
client.post("$baseUrl/documents/") { client.post("$baseUrl/documents/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody(request) setBody(request)
} }
@@ -201,7 +201,7 @@ class DocumentApi(private val client: HttpClient = ApiClient.httpClient) {
expiryDate = endDate // Map endDate to expiryDate expiryDate = endDate // Map endDate to expiryDate
) )
val response = client.patch("$baseUrl/documents/$id/") { val response = client.patch("$baseUrl/documents/$id/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody(request) setBody(request)
} }
@@ -224,7 +224,7 @@ class DocumentApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun deleteDocument(token: String, id: Int): ApiResult<Unit> { suspend fun deleteDocument(token: String, id: Int): ApiResult<Unit> {
return try { return try {
val response = client.delete("$baseUrl/documents/$id/") { val response = client.delete("$baseUrl/documents/$id/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -240,7 +240,7 @@ class DocumentApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun downloadDocument(token: String, url: String): ApiResult<ByteArray> { suspend fun downloadDocument(token: String, url: String): ApiResult<ByteArray> {
return try { return try {
val response = client.get(url) { val response = client.get(url) {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -256,7 +256,7 @@ class DocumentApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun activateDocument(token: String, id: Int): ApiResult<Document> { suspend fun activateDocument(token: String, id: Int): ApiResult<Document> {
return try { return try {
val response = client.post("$baseUrl/documents/$id/activate/") { val response = client.post("$baseUrl/documents/$id/activate/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -274,7 +274,7 @@ class DocumentApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun deactivateDocument(token: String, id: Int): ApiResult<Document> { suspend fun deactivateDocument(token: String, id: Int): ApiResult<Document> {
return try { return try {
val response = client.post("$baseUrl/documents/$id/deactivate/") { val response = client.post("$baseUrl/documents/$id/deactivate/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -308,7 +308,7 @@ class DocumentApi(private val client: HttpClient = ApiClient.httpClient) {
caption?.let { append("caption", it) } caption?.let { append("caption", it) }
} }
) { ) {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -329,7 +329,7 @@ class DocumentApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun deleteDocumentImage(token: String, documentId: Int, imageId: Int): ApiResult<Document> { suspend fun deleteDocumentImage(token: String, documentId: Int, imageId: Int): ApiResult<Document> {
return try { return try {
val response = client.delete("$baseUrl/documents/$documentId/images/$imageId/") { val response = client.delete("$baseUrl/documents/$documentId/images/$imageId/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -36,7 +36,7 @@ class LookupsApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun getResidenceTypes(token: String): ApiResult<List<ResidenceType>> { suspend fun getResidenceTypes(token: String): ApiResult<List<ResidenceType>> {
return try { return try {
val response = client.get("$baseUrl/residences/types/") { val response = client.get("$baseUrl/residences/types/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -52,7 +52,7 @@ class LookupsApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun getTaskFrequencies(token: String): ApiResult<List<TaskFrequency>> { suspend fun getTaskFrequencies(token: String): ApiResult<List<TaskFrequency>> {
return try { return try {
val response = client.get("$baseUrl/tasks/frequencies/") { val response = client.get("$baseUrl/tasks/frequencies/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -68,7 +68,7 @@ class LookupsApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun getTaskPriorities(token: String): ApiResult<List<TaskPriority>> { suspend fun getTaskPriorities(token: String): ApiResult<List<TaskPriority>> {
return try { return try {
val response = client.get("$baseUrl/tasks/priorities/") { val response = client.get("$baseUrl/tasks/priorities/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -84,7 +84,7 @@ class LookupsApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun getTaskCategories(token: String): ApiResult<List<TaskCategory>> { suspend fun getTaskCategories(token: String): ApiResult<List<TaskCategory>> {
return try { return try {
val response = client.get("$baseUrl/tasks/categories/") { val response = client.get("$baseUrl/tasks/categories/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -100,7 +100,7 @@ class LookupsApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun getContractorSpecialties(token: String): ApiResult<List<ContractorSpecialty>> { suspend fun getContractorSpecialties(token: String): ApiResult<List<ContractorSpecialty>> {
return try { return try {
val response = client.get("$baseUrl/contractors/specialties/") { val response = client.get("$baseUrl/contractors/specialties/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -117,7 +117,7 @@ class LookupsApi(private val client: HttpClient = ApiClient.httpClient) {
return try { return try {
val response = client.get("$baseUrl/static_data/") { val response = client.get("$baseUrl/static_data/") {
// Token is optional - endpoint is public // Token is optional - endpoint is public
token?.let { header("Authorization", "Token $it") } token?.let { header(SESSION_TOKEN_HEADER, it) }
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -145,7 +145,7 @@ class LookupsApi(private val client: HttpClient = ApiClient.httpClient) {
return try { return try {
val response: HttpResponse = client.get("$baseUrl/static_data/") { val response: HttpResponse = client.get("$baseUrl/static_data/") {
// Token is optional - endpoint is public // Token is optional - endpoint is public
token?.let { header("Authorization", "Token $it") } token?.let { header(SESSION_TOKEN_HEADER, it) }
// Send If-None-Match header for conditional request // Send If-None-Match header for conditional request
currentETag?.let { header("If-None-Match", it) } currentETag?.let { header("If-None-Match", it) }
} }
@@ -18,7 +18,7 @@ class NotificationApi(private val client: HttpClient = ApiClient.httpClient) {
): ApiResult<DeviceRegistrationResponse> { ): ApiResult<DeviceRegistrationResponse> {
return try { return try {
val response = client.post("$baseUrl/notifications/devices/register/") { val response = client.post("$baseUrl/notifications/devices/register/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody(request) setBody(request)
} }
@@ -44,7 +44,7 @@ class NotificationApi(private val client: HttpClient = ApiClient.httpClient) {
): ApiResult<Unit> { ): ApiResult<Unit> {
return try { return try {
val response = client.delete("$baseUrl/notifications/devices/$deviceId/") { val response = client.delete("$baseUrl/notifications/devices/$deviceId/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -63,7 +63,7 @@ class NotificationApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun getNotificationPreferences(token: String): ApiResult<NotificationPreference> { suspend fun getNotificationPreferences(token: String): ApiResult<NotificationPreference> {
return try { return try {
val response = client.get("$baseUrl/notifications/preferences/") { val response = client.get("$baseUrl/notifications/preferences/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -85,7 +85,7 @@ class NotificationApi(private val client: HttpClient = ApiClient.httpClient) {
): ApiResult<NotificationPreference> { ): ApiResult<NotificationPreference> {
return try { return try {
val response = client.put("$baseUrl/notifications/preferences/") { val response = client.put("$baseUrl/notifications/preferences/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody(request) setBody(request)
} }
@@ -103,7 +103,7 @@ class NotificationApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun getNotificationHistory(token: String): ApiResult<List<Notification>> { suspend fun getNotificationHistory(token: String): ApiResult<List<Notification>> {
return try { return try {
val response = client.get("$baseUrl/notifications/") { val response = client.get("$baseUrl/notifications/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -123,7 +123,7 @@ class NotificationApi(private val client: HttpClient = ApiClient.httpClient) {
): ApiResult<MessageResponse> { ): ApiResult<MessageResponse> {
return try { return try {
val response = client.post("$baseUrl/notifications/$notificationId/read/") { val response = client.post("$baseUrl/notifications/$notificationId/read/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -139,7 +139,7 @@ class NotificationApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun markAllNotificationsAsRead(token: String): ApiResult<MessageResponse> { suspend fun markAllNotificationsAsRead(token: String): ApiResult<MessageResponse> {
return try { return try {
val response = client.post("$baseUrl/notifications/mark-all-read/") { val response = client.post("$baseUrl/notifications/mark-all-read/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -155,7 +155,7 @@ class NotificationApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun getUnreadCount(token: String): ApiResult<UnreadCountResponse> { suspend fun getUnreadCount(token: String): ApiResult<UnreadCountResponse> {
return try { return try {
val response = client.get("$baseUrl/notifications/unread-count/") { val response = client.get("$baseUrl/notifications/unread-count/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -12,7 +12,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun getResidences(token: String): ApiResult<List<ResidenceResponse>> { suspend fun getResidences(token: String): ApiResult<List<ResidenceResponse>> {
return try { return try {
val response = client.get("$baseUrl/residences/") { val response = client.get("$baseUrl/residences/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -28,7 +28,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun getResidence(token: String, id: Int): ApiResult<ResidenceResponse> { suspend fun getResidence(token: String, id: Int): ApiResult<ResidenceResponse> {
return try { return try {
val response = client.get("$baseUrl/residences/$id/") { val response = client.get("$baseUrl/residences/$id/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -44,7 +44,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun createResidence(token: String, request: ResidenceCreateRequest): ApiResult<WithSummaryResponse<ResidenceResponse>> { suspend fun createResidence(token: String, request: ResidenceCreateRequest): ApiResult<WithSummaryResponse<ResidenceResponse>> {
return try { return try {
val response = client.post("$baseUrl/residences/") { val response = client.post("$baseUrl/residences/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody(request) setBody(request)
} }
@@ -62,7 +62,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun updateResidence(token: String, id: Int, request: ResidenceCreateRequest): ApiResult<WithSummaryResponse<ResidenceResponse>> { suspend fun updateResidence(token: String, id: Int, request: ResidenceCreateRequest): ApiResult<WithSummaryResponse<ResidenceResponse>> {
return try { return try {
val response = client.put("$baseUrl/residences/$id/") { val response = client.put("$baseUrl/residences/$id/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody(request) setBody(request)
} }
@@ -80,7 +80,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun deleteResidence(token: String, id: Int): ApiResult<WithSummaryResponse<String>> { suspend fun deleteResidence(token: String, id: Int): ApiResult<WithSummaryResponse<String>> {
return try { return try {
val response = client.delete("$baseUrl/residences/$id/") { val response = client.delete("$baseUrl/residences/$id/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -96,7 +96,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun getSummary(token: String): ApiResult<TotalSummary> { suspend fun getSummary(token: String): ApiResult<TotalSummary> {
return try { return try {
val response = client.get("$baseUrl/residences/summary/") { val response = client.get("$baseUrl/residences/summary/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -112,7 +112,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun getMyResidences(token: String): ApiResult<MyResidencesResponse> { suspend fun getMyResidences(token: String): ApiResult<MyResidencesResponse> {
return try { return try {
val response = client.get("$baseUrl/residences/my-residences/") { val response = client.get("$baseUrl/residences/my-residences/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -129,7 +129,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun generateSharePackage(token: String, residenceId: Int): ApiResult<SharedResidence> { suspend fun generateSharePackage(token: String, residenceId: Int): ApiResult<SharedResidence> {
return try { return try {
val response = client.post("$baseUrl/residences/$residenceId/generate-share-package/") { val response = client.post("$baseUrl/residences/$residenceId/generate-share-package/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -146,7 +146,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun generateShareCode(token: String, residenceId: Int): ApiResult<GenerateShareCodeResponse> { suspend fun generateShareCode(token: String, residenceId: Int): ApiResult<GenerateShareCodeResponse> {
return try { return try {
val response = client.post("$baseUrl/residences/$residenceId/generate-share-code/") { val response = client.post("$baseUrl/residences/$residenceId/generate-share-code/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -163,7 +163,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun getShareCode(token: String, residenceId: Int): ApiResult<ShareCodeResponse> { suspend fun getShareCode(token: String, residenceId: Int): ApiResult<ShareCodeResponse> {
return try { return try {
val response = client.get("$baseUrl/residences/$residenceId/share-code/") { val response = client.get("$baseUrl/residences/$residenceId/share-code/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -186,7 +186,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun acceptResidenceInvite(token: String, residenceId: Int): ApiResult<Unit> { suspend fun acceptResidenceInvite(token: String, residenceId: Int): ApiResult<Unit> {
return try { return try {
val response = client.post("$baseUrl/residences/$residenceId/invite/accept/") { val response = client.post("$baseUrl/residences/$residenceId/invite/accept/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -207,7 +207,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun declineResidenceInvite(token: String, residenceId: Int): ApiResult<Unit> { suspend fun declineResidenceInvite(token: String, residenceId: Int): ApiResult<Unit> {
return try { return try {
val response = client.post("$baseUrl/residences/$residenceId/invite/decline/") { val response = client.post("$baseUrl/residences/$residenceId/invite/decline/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -224,7 +224,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun joinWithCode(token: String, code: String): ApiResult<JoinResidenceResponse> { suspend fun joinWithCode(token: String, code: String): ApiResult<JoinResidenceResponse> {
return try { return try {
val response = client.post("$baseUrl/residences/join-with-code/") { val response = client.post("$baseUrl/residences/join-with-code/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody(JoinResidenceRequest(code)) setBody(JoinResidenceRequest(code))
} }
@@ -244,7 +244,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun getResidenceUsers(token: String, residenceId: Int): ApiResult<ResidenceUsersResponse> { suspend fun getResidenceUsers(token: String, residenceId: Int): ApiResult<ResidenceUsersResponse> {
return try { return try {
val response = client.get("$baseUrl/residences/$residenceId/users/") { val response = client.get("$baseUrl/residences/$residenceId/users/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -261,7 +261,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun removeUser(token: String, residenceId: Int, userId: Int): ApiResult<RemoveUserResponse> { suspend fun removeUser(token: String, residenceId: Int, userId: Int): ApiResult<RemoveUserResponse> {
return try { return try {
val response = client.delete("$baseUrl/residences/$residenceId/users/$userId/") { val response = client.delete("$baseUrl/residences/$residenceId/users/$userId/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -279,7 +279,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun generateTasksReport(token: String, residenceId: Int, email: String? = null): ApiResult<GenerateReportResponse> { suspend fun generateTasksReport(token: String, residenceId: Int, email: String? = null): ApiResult<GenerateReportResponse> {
return try { return try {
val response = client.post("$baseUrl/residences/$residenceId/generate-tasks-report/") { val response = client.post("$baseUrl/residences/$residenceId/generate-tasks-report/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
if (email != null) { if (email != null) {
setBody(mapOf("email" to email)) setBody(mapOf("email" to email))
@@ -12,7 +12,7 @@ class SubscriptionApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun getSubscriptionStatus(token: String): ApiResult<SubscriptionStatus> { suspend fun getSubscriptionStatus(token: String): ApiResult<SubscriptionStatus> {
return try { return try {
val response = client.get("$baseUrl/subscription/status/") { val response = client.get("$baseUrl/subscription/status/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -29,7 +29,7 @@ class SubscriptionApi(private val client: HttpClient = ApiClient.httpClient) {
return try { return try {
val response = client.get("$baseUrl/subscription/upgrade-triggers/") { val response = client.get("$baseUrl/subscription/upgrade-triggers/") {
// Token is optional - endpoint is public // Token is optional - endpoint is public
token?.let { header("Authorization", "Token $it") } token?.let { header(SESSION_TOKEN_HEADER, it) }
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -45,7 +45,7 @@ class SubscriptionApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun getFeatureBenefits(token: String): ApiResult<List<FeatureBenefit>> { suspend fun getFeatureBenefits(token: String): ApiResult<List<FeatureBenefit>> {
return try { return try {
val response = client.get("$baseUrl/subscription/features/") { val response = client.get("$baseUrl/subscription/features/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -61,7 +61,7 @@ class SubscriptionApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun getActivePromotions(token: String): ApiResult<List<Promotion>> { suspend fun getActivePromotions(token: String): ApiResult<List<Promotion>> {
return try { return try {
val response = client.get("$baseUrl/subscription/promotions/") { val response = client.get("$baseUrl/subscription/promotions/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -85,7 +85,7 @@ class SubscriptionApi(private val client: HttpClient = ApiClient.httpClient) {
): ApiResult<VerificationResponse> { ): ApiResult<VerificationResponse> {
return try { return try {
val response = client.post("$baseUrl/subscription/purchase/") { val response = client.post("$baseUrl/subscription/purchase/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody(mapOf( setBody(mapOf(
"platform" to "ios", "platform" to "ios",
@@ -115,7 +115,7 @@ class SubscriptionApi(private val client: HttpClient = ApiClient.httpClient) {
): ApiResult<VerificationResponse> { ): ApiResult<VerificationResponse> {
return try { return try {
val response = client.post("$baseUrl/subscription/purchase/") { val response = client.post("$baseUrl/subscription/purchase/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody(mapOf( setBody(mapOf(
"platform" to "android", "platform" to "android",
@@ -154,7 +154,7 @@ class SubscriptionApi(private val client: HttpClient = ApiClient.httpClient) {
} }
val response = client.post("$baseUrl/subscription/restore/") { val response = client.post("$baseUrl/subscription/restore/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody(body) setBody(body)
} }
@@ -15,7 +15,7 @@ class TaskApi(private val client: HttpClient = ApiClient.httpClient) {
): ApiResult<TaskColumnsResponse> { ): ApiResult<TaskColumnsResponse> {
return try { return try {
val response = client.get("$baseUrl/tasks/") { val response = client.get("$baseUrl/tasks/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
days?.let { parameter("days", it) } days?.let { parameter("days", it) }
} }
@@ -33,7 +33,7 @@ class TaskApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun getTask(token: String, id: Int): ApiResult<TaskResponse> { suspend fun getTask(token: String, id: Int): ApiResult<TaskResponse> {
return try { return try {
val response = client.get("$baseUrl/tasks/$id/") { val response = client.get("$baseUrl/tasks/$id/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -50,7 +50,7 @@ class TaskApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun createTask(token: String, request: TaskCreateRequest): ApiResult<WithSummaryResponse<TaskResponse>> { suspend fun createTask(token: String, request: TaskCreateRequest): ApiResult<WithSummaryResponse<TaskResponse>> {
return try { return try {
val response = client.post("$baseUrl/tasks/") { val response = client.post("$baseUrl/tasks/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody(request) setBody(request)
} }
@@ -75,7 +75,7 @@ class TaskApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun bulkCreateTasks(token: String, request: BulkCreateTasksRequest): ApiResult<BulkCreateTasksResponse> { suspend fun bulkCreateTasks(token: String, request: BulkCreateTasksRequest): ApiResult<BulkCreateTasksResponse> {
return try { return try {
val response = client.post("$baseUrl/tasks/bulk/") { val response = client.post("$baseUrl/tasks/bulk/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody(request) setBody(request)
} }
@@ -94,7 +94,7 @@ class TaskApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun updateTask(token: String, id: Int, request: TaskCreateRequest): ApiResult<WithSummaryResponse<TaskResponse>> { suspend fun updateTask(token: String, id: Int, request: TaskCreateRequest): ApiResult<WithSummaryResponse<TaskResponse>> {
return try { return try {
val response = client.put("$baseUrl/tasks/$id/") { val response = client.put("$baseUrl/tasks/$id/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody(request) setBody(request)
} }
@@ -113,7 +113,7 @@ class TaskApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun deleteTask(token: String, id: Int): ApiResult<WithSummaryResponse<String>> { suspend fun deleteTask(token: String, id: Int): ApiResult<WithSummaryResponse<String>> {
return try { return try {
val response = client.delete("$baseUrl/tasks/$id/") { val response = client.delete("$baseUrl/tasks/$id/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -134,7 +134,7 @@ class TaskApi(private val client: HttpClient = ApiClient.httpClient) {
): ApiResult<TaskColumnsResponse> { ): ApiResult<TaskColumnsResponse> {
return try { return try {
val response = client.get("$baseUrl/tasks/by-residence/$residenceId/") { val response = client.get("$baseUrl/tasks/by-residence/$residenceId/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
days?.let { parameter("days", it) } days?.let { parameter("days", it) }
} }
@@ -157,7 +157,7 @@ class TaskApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun patchTask(token: String, id: Int, request: TaskPatchRequest): ApiResult<WithSummaryResponse<TaskResponse>> { suspend fun patchTask(token: String, id: Int, request: TaskPatchRequest): ApiResult<WithSummaryResponse<TaskResponse>> {
return try { return try {
val response = client.patch("$baseUrl/tasks/$id/") { val response = client.patch("$baseUrl/tasks/$id/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody(request) setBody(request)
} }
@@ -206,7 +206,7 @@ class TaskApi(private val client: HttpClient = ApiClient.httpClient) {
private suspend fun postTaskAction(token: String, id: Int, action: String): ApiResult<WithSummaryResponse<TaskResponse>> { private suspend fun postTaskAction(token: String, id: Int, action: String): ApiResult<WithSummaryResponse<TaskResponse>> {
return try { return try {
val response = client.post("$baseUrl/tasks/$id/$action/") { val response = client.post("$baseUrl/tasks/$id/$action/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
} }
when (response.status) { when (response.status) {
@@ -233,7 +233,7 @@ class TaskApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun getTaskCompletions(token: String, taskId: Int): ApiResult<List<TaskCompletionResponse>> { suspend fun getTaskCompletions(token: String, taskId: Int): ApiResult<List<TaskCompletionResponse>> {
return try { return try {
val response = client.get("$baseUrl/tasks/$taskId/completions/") { val response = client.get("$baseUrl/tasks/$taskId/completions/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -13,7 +13,7 @@ class TaskCompletionApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun getCompletions(token: String): ApiResult<List<TaskCompletionResponse>> { suspend fun getCompletions(token: String): ApiResult<List<TaskCompletionResponse>> {
return try { return try {
val response = client.get("$baseUrl/task-completions/") { val response = client.get("$baseUrl/task-completions/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -29,7 +29,7 @@ class TaskCompletionApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun getCompletion(token: String, id: Int): ApiResult<TaskCompletionResponse> { suspend fun getCompletion(token: String, id: Int): ApiResult<TaskCompletionResponse> {
return try { return try {
val response = client.get("$baseUrl/task-completions/$id/") { val response = client.get("$baseUrl/task-completions/$id/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -45,7 +45,7 @@ class TaskCompletionApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun createCompletion(token: String, request: TaskCompletionCreateRequest): ApiResult<WithSummaryResponse<TaskCompletionResponse>> { suspend fun createCompletion(token: String, request: TaskCompletionCreateRequest): ApiResult<WithSummaryResponse<TaskCompletionResponse>> {
return try { return try {
val response = client.post("$baseUrl/task-completions/") { val response = client.post("$baseUrl/task-completions/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody(request) setBody(request)
} }
@@ -63,7 +63,7 @@ class TaskCompletionApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun updateCompletion(token: String, id: Int, request: TaskCompletionCreateRequest): ApiResult<TaskCompletionResponse> { suspend fun updateCompletion(token: String, id: Int, request: TaskCompletionCreateRequest): ApiResult<TaskCompletionResponse> {
return try { return try {
val response = client.put("$baseUrl/task-completions/$id/") { val response = client.put("$baseUrl/task-completions/$id/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody(request) setBody(request)
} }
@@ -81,7 +81,7 @@ class TaskCompletionApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun deleteCompletion(token: String, id: Int): ApiResult<WithSummaryResponse<String>> { suspend fun deleteCompletion(token: String, id: Int): ApiResult<WithSummaryResponse<String>> {
return try { return try {
val response = client.delete("$baseUrl/task-completions/$id/") { val response = client.delete("$baseUrl/task-completions/$id/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
@@ -92,7 +92,7 @@ class TaskTemplateApi(private val client: HttpClient = ApiClient.httpClient) {
suspend fun getTaskSuggestions(token: String, residenceId: Int): ApiResult<TaskSuggestionsResponse> { suspend fun getTaskSuggestions(token: String, residenceId: Int): ApiResult<TaskSuggestionsResponse> {
return try { return try {
val response = client.get("$baseUrl/tasks/suggestions/") { val response = client.get("$baseUrl/tasks/suggestions/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
parameter("residence_id", residenceId) parameter("residence_id", residenceId)
} }
@@ -36,7 +36,7 @@ class UploadApi(private val client: HttpClient = ApiClient.httpClient) {
): ApiResult<PresignUploadResponse> { ): ApiResult<PresignUploadResponse> {
return try { return try {
val response = client.post("$baseUrl/uploads/presign/") { val response = client.post("$baseUrl/uploads/presign/") {
header("Authorization", "Token $token") header(SESSION_TOKEN_HEADER, token)
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody(PresignUploadRequest(category, contentType, contentLength)) setBody(PresignUploadRequest(category, contentType, contentLength))
} }
@@ -17,6 +17,7 @@ import coil3.request.ImageRequest
import coil3.network.NetworkHeaders import coil3.network.NetworkHeaders
import coil3.network.httpHeaders import coil3.network.httpHeaders
import com.tt.honeyDue.network.ApiClient import com.tt.honeyDue.network.ApiClient
import com.tt.honeyDue.network.SESSION_TOKEN_HEADER
import com.tt.honeyDue.storage.TokenStorage import com.tt.honeyDue.storage.TokenStorage
/** /**
@@ -57,9 +58,11 @@ fun AuthenticatedImage(
.data(fullUrl) .data(fullUrl)
.apply { .apply {
if (token != null) { if (token != null) {
// honeyDue media is gated on the Kratos session token,
// carried on the X-Session-Token header.
httpHeaders( httpHeaders(
NetworkHeaders.Builder() NetworkHeaders.Builder()
.set("Authorization", "Token $token") .set(SESSION_TOKEN_HEADER, token)
.build() .build()
) )
} }
@@ -413,26 +413,30 @@ class HttpClientPluginsTest {
client.close() client.close()
} }
// ==================== Token Refresh / 401 Handling Tests ==================== // ==================== Kratos Session / 401 Handling Tests ====================
@Test @Test
fun testTokenExpiredExceptionIsRefreshed() { fun testTokenExpiredExceptionStillValid() {
// refreshed = true means the Kratos session was re-validated and is
// still usable — the caller may retry the request.
val exception = TokenExpiredException(refreshed = true) val exception = TokenExpiredException(refreshed = true)
assertTrue(exception.refreshed) assertTrue(exception.refreshed)
assertTrue(exception.message!!.contains("refreshed")) assertTrue(exception.message!!.contains("still valid"))
} }
@Test @Test
fun testTokenExpiredExceptionNotRefreshed() { fun testTokenExpiredExceptionSessionGone() {
// refreshed = false means the Kratos session is gone — the user must
// sign in again.
val exception = TokenExpiredException(refreshed = false) val exception = TokenExpiredException(refreshed = false)
assertTrue(!exception.refreshed) assertTrue(!exception.refreshed)
assertTrue(exception.message!!.contains("re-authenticate")) assertTrue(exception.message!!.contains("re-authenticate"))
} }
@Test @Test
fun test401WithNonExpiredTokenDoesNotTriggerRefresh() = runTest { fun test401WithoutValidatorReturnsResponse() = runTest {
// A 401 that does NOT contain "expired" or "token_expired" should NOT // Without the Kratos session validator installed, a 401 simply
// throw TokenExpiredException — it should just return the 401 response. // surfaces as the 401 response — it does not throw.
var requestCount = 0 var requestCount = 0
val client = HttpClient(MockEngine) { val client = HttpClient(MockEngine) {
engine { engine {
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

@@ -0,0 +1,21 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "AppLogo@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -263,13 +263,40 @@ class PreviewViewController: UIViewController, QLPreviewingController {
} }
private func updateUIForResidence(with residence: ResidencePreviewData) { private func updateUIForResidence(with residence: ResidencePreviewData) {
// Update icon // Brand icon. Prefer the bundled honeyDue logo so the preview
// reads as a HoneyDue invite at a glance; fall back to a tinted
// SF Symbol for accessibility / asset-load failures.
if let logo = UIImage(named: "AppLogo") {
iconImageView.image = logo.withRenderingMode(.alwaysOriginal)
iconImageView.contentMode = .scaleAspectFit
iconImageView.layer.cornerRadius = 16
iconImageView.layer.masksToBounds = true
} else {
let config = UIImage.SymbolConfiguration(pointSize: 60, weight: .light) let config = UIImage.SymbolConfiguration(pointSize: 60, weight: .light)
iconImageView.image = UIImage(systemName: "house.fill", withConfiguration: config) iconImageView.image = UIImage(systemName: "house.fill", withConfiguration: config)
}
titleLabel.text = residence.residenceName titleLabel.text = residence.residenceName
subtitleLabel.text = "honeyDue Residence Invite" subtitleLabel.text = "honeyDue Residence Invite"
instructionLabel.text = "Tap the share button below, then select \"honeyDue\" to join this residence."
// Branch the copy on whether the share link has already lapsed.
// Active invites get the standard "How to join" numbered steps;
// expired invites get a clear dead-end message asking the
// recipient to ping the sender for a new link no point
// showing share-sheet directions for a link the server will
// reject.
let expiredAgo = Self.expiredRelativePhraseOrNil(residence.expiresAt)
if let expiredAgo {
instructionLabel.attributedText = Self.makeExpiredInstructions(sharedBy: residence.sharedBy)
// The down-chevron points at the Share button as a visual
// cue to tap it; in the expired state there's nothing
// useful to share (the server will reject the bundled
// code) so the arrow becomes misleading. Hide it.
arrowImageView.isHidden = true
} else {
instructionLabel.attributedText = Self.makeResidenceInstructions()
arrowImageView.isHidden = false
}
// Clear existing details // Clear existing details
detailsStackView.arrangedSubviews.forEach { $0.removeFromSuperview() } detailsStackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
@@ -280,11 +307,185 @@ class PreviewViewController: UIViewController, QLPreviewingController {
} }
if let expiresAt = residence.expiresAt, !expiresAt.isEmpty { if let expiresAt = residence.expiresAt, !expiresAt.isEmpty {
addDetailRow(icon: "clock", text: "Expires: \(expiresAt)") if let expiredAgo {
// "Expired 1 hour ago" capitalised past-tense; no
// "Expires " prefix because the share link no longer
// expires, it has already done so (gitea#7 review).
addDetailRow(icon: "clock", text: "Expired \(expiredAgo)")
} else {
let formatted = Self.formatActiveExpiry(expiresAt)
addDetailRow(icon: "clock", text: "Expires \(formatted)")
} }
} }
} }
// MARK: - Formatting helpers
/// Render an *active* (not-yet-expired) share-link expiry as a
/// human-readable phrase. Within a day uses
/// `RelativeDateTimeFormatter` ("in 23 hours" / "in 12 minutes");
/// further out switches to absolute date + time so users planning
/// ahead see exactly when the invite lapses. Falls back to the raw
/// ISO string if parsing fails so the row never goes blank.
///
/// Callers must check [expiredRelativePhraseOrNil] first this
/// function assumes a future expiry and produces wording that only
/// makes sense in that case.
static func formatActiveExpiry(_ isoString: String) -> String {
guard let date = parseIsoDate(isoString) else { return isoString }
let now = Date()
let elapsed = date.timeIntervalSince(now)
if elapsed < 24 * 60 * 60 {
return relativeFormatter.localizedString(for: date, relativeTo: now)
}
return "on \(absoluteFormatter.string(from: date))"
}
/// If the share link has already lapsed, return the relative
/// "X ago" phrase. `nil` means active (or unparseable) callers
/// should fall back to [formatActiveExpiry] for those cases. The
/// split lets `updateUIForResidence` branch the entire UI block
/// (row text + instruction card) on the same signal (gitea#7
/// review: an expired link should send the recipient back to the
/// sender for a new invite, not show share-sheet directions for a
/// link the server will reject).
static func expiredRelativePhraseOrNil(_ isoString: String?) -> String? {
guard let isoString, let date = parseIsoDate(isoString) else { return nil }
let now = Date()
if date.timeIntervalSince(now) > 0 { return nil }
return relativeFormatter.localizedString(for: date, relativeTo: now)
}
private static func parseIsoDate(_ raw: String) -> Date? {
if let d = isoFormatterWithFraction.date(from: raw) { return d }
if let d = isoFormatterNoFraction.date(from: raw) { return d }
return nil
}
private static let isoFormatterWithFraction: ISO8601DateFormatter = {
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return f
}()
private static let isoFormatterNoFraction: ISO8601DateFormatter = {
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime]
return f
}()
private static let relativeFormatter: RelativeDateTimeFormatter = {
let f = RelativeDateTimeFormatter()
f.unitsStyle = .full
return f
}()
private static let absoluteFormatter: DateFormatter = {
let f = DateFormatter()
f.dateStyle = .medium
f.timeStyle = .short
return f
}()
/// Builds the "How to join" instruction copy as an attributed
/// string with the iOS share-icon glyph (square + up-arrow) inlined
/// next to "Tap [icon]". The glyph is the universal share symbol
/// across iOS, so the recipient finds the right control whether
/// it's at the top, bottom, or behind a More menu instead of us
/// claiming a fixed position the chrome can move (gitea#7 review
/// feedback).
private static func makeResidenceInstructions() -> NSAttributedString {
let bodyFont = UIFont.systemFont(ofSize: 15, weight: .medium)
let tint = UIColor(red: 7/255, green: 160/255, blue: 195/255, alpha: 1)
let paragraph = NSMutableParagraphStyle()
paragraph.lineSpacing = 2
paragraph.alignment = .left
let result = NSMutableAttributedString()
func appendText(_ s: String) {
result.append(NSAttributedString(
string: s,
attributes: [
.font: bodyFont,
.foregroundColor: tint,
.paragraphStyle: paragraph,
]
))
}
appendText("How to join:\n1. Tap ")
let shareImage = UIImage(
systemName: "square.and.arrow.up",
withConfiguration: UIImage.SymbolConfiguration(pointSize: 16, weight: .semibold)
)?.withTintColor(tint, renderingMode: .alwaysOriginal)
if let shareImage {
let attachment = NSTextAttachment()
attachment.image = shareImage
// Align the glyph baseline with the surrounding text by
// nudging the bounds down a few points; the SF Symbol's
// natural bounds sit a hair above the cap height.
attachment.bounds = CGRect(
x: 0,
y: -3,
width: shareImage.size.width,
height: shareImage.size.height
)
result.append(NSAttributedString(attachment: attachment))
}
appendText("\n2. Choose \"honeyDue\" from the share sheet")
appendText("\n3. Sign in if prompted — the app finishes the rest")
return result
}
/// Expired-state copy for the instruction card. Tells the recipient
/// the share link is no longer valid and to ping the sender (by
/// email if we know it) for a new one replaces the active "How to
/// join" steps since the server will reject the bundled code
/// anyway.
private static func makeExpiredInstructions(sharedBy: String?) -> NSAttributedString {
// Slightly warmer tint than the active instruction copy the
// app's `appError` red would feel alarmist for "just ask again",
// and the secondary-label gray reads as muted/disabled which is
// accurate to the link's actual state.
let bodyFont = UIFont.systemFont(ofSize: 15, weight: .medium)
let tint = UIColor.secondaryLabel
let titleFont = UIFont.systemFont(ofSize: 15, weight: .semibold)
let titleTint = UIColor.label
let paragraph = NSMutableParagraphStyle()
paragraph.lineSpacing = 2
paragraph.alignment = .left
let result = NSMutableAttributedString()
result.append(NSAttributedString(
string: "This invite has expired.\n",
attributes: [
.font: titleFont,
.foregroundColor: titleTint,
.paragraphStyle: paragraph,
]
))
let body = if let s = sharedBy, !s.isEmpty {
"Ask \(s) to send a new link."
} else {
"Ask the sender to share a new link."
}
result.append(NSAttributedString(
string: body,
attributes: [
.font: bodyFont,
.foregroundColor: tint,
.paragraphStyle: paragraph,
]
))
return result
}
}
// MARK: - Type Discriminator // MARK: - Type Discriminator
/// Lightweight struct to detect the package type without a full parse /// Lightweight struct to detect the package type without a full parse
@@ -0,0 +1,437 @@
//
// Issue7PreviewScreenshotTest.swift
// HoneyDueTests
//
// Records a single PNG screenshot of the post-fix QL-preview layout
// used by `HoneyDueQLPreview/PreviewViewController.swift` so it can be
// attached to gitea issue #7 for the reviewer to see the new look
// without having to AirDrop a `.honeydue` file to a device.
//
// How it works:
// * Faithfully recreates the UIKit layout `PreviewViewController.updateUIForResidence`
// builds in production same colors, same fonts, same constraints,
// same image asset (copied into `HoneyDueTests/Resources/AppLogo.png`
// so it is reachable from this target's bundle).
// * Runs the same `formatExpiresAt` style (ISO parse relative phrase
// when within a day, absolute medium-date + short-time otherwise),
// using a fixed reference Date so the rendering is deterministic
// across runs / time zones.
// * `SnapshotTesting.assertSnapshot(of: viewController, as: .image)`
// writes the PNG to
// `iosApp/HoneyDueTests/__Snapshots__/Issue7PreviewScreenshotTest/`.
//
// The first run (no committed golden) records the PNG and the test
// reports "failed - No reference was found on disk. Automatically
// recorded snapshot:" that's the file we attach to the issue.
//
// Note on faithfulness: this snapshot is a programmatic reproduction
// of `PreviewViewController.updateUIForResidence`, not the QL
// extension instance itself, because the QL extension's bundle is a
// separate Xcode target from `HoneyDueTests` and can't be `@testable
// import`ed without project-file surgery. The reproduction uses the
// same UIKit primitives, colors, fonts, and asset, so the rendered
// output matches what users see when iOS opens a `.honeydue` invite.
//
@preconcurrency import SnapshotTesting
import UIKit
import XCTest
@MainActor
final class Issue7PreviewScreenshotTest: XCTestCase {
/// Force record mode for this test only we want the PNG written
/// regardless of whether a golden exists.
override func invokeTest() {
withSnapshotTesting(record: .all) {
super.invokeTest()
}
}
func test_residence_invite_preview_after_issue7_fix() {
let vc = MockPreviewViewController(
residence: ResidencePreview.fixtureForIssue7,
state: .active
)
vc.overrideUserInterfaceStyle = .dark
assertSnapshot(
of: vc,
as: .image(
on: .iPhone13,
precision: 1.0,
perceptualPrecision: 1.0,
traits: .init(traitsFrom: [
UITraitCollection(userInterfaceStyle: .dark),
UITraitCollection(displayScale: 2.0),
])
),
named: "issue7_residence_invite_preview_dark"
)
}
func test_residence_invite_preview_expired_state() {
// Same residence + sender, but expiry already 1 hour in the
// past. Verifies the expired branch: the instruction card
// swaps to "ask the sender for a new link" and the detail row
// reads "Expired 1 hour ago" instead of the future-tense
// "Expires in " phrasing.
let vc = MockPreviewViewController(
residence: ResidencePreview.fixtureForIssue7,
state: .expired(elapsedSecondsSinceExpiry: 60 * 60)
)
vc.overrideUserInterfaceStyle = .dark
assertSnapshot(
of: vc,
as: .image(
on: .iPhone13,
precision: 1.0,
perceptualPrecision: 1.0,
traits: .init(traitsFrom: [
UITraitCollection(userInterfaceStyle: .dark),
UITraitCollection(displayScale: 2.0),
])
),
named: "issue7_residence_invite_preview_expired_dark"
)
}
}
// MARK: - Sample residence (matches the gitea#7 screenshot setup)
private struct ResidencePreview {
let residenceName: String
let sharedBy: String?
let expiresAt: String?
/// Mirrors the data shown in the original gitea#7 screenshot the
/// post-fix version of the same payload.
static let fixtureForIssue7 = ResidencePreview(
residenceName: "The Tartt's",
sharedBy: "honey@hollie37.com",
expiresAt: "2026-05-12T17:11:02.067272789Z"
)
}
// MARK: - Mock view controller (UIKit copy of `updateUIForResidence`)
/// Renderer state for the screenshot fixture. Active = link still
/// valid; expired = link lapsed `elapsedSecondsSinceExpiry` seconds
/// ago. Both render with deterministic data so the recorded PNG is
/// stable across runs.
private enum PreviewRenderState {
case active
case expired(elapsedSecondsSinceExpiry: TimeInterval)
}
@MainActor
private final class MockPreviewViewController: UIViewController {
private let residence: ResidencePreview
private let state: PreviewRenderState
private let containerView = UIView()
private let iconImageView = UIImageView()
private let titleLabel = UILabel()
private let subtitleLabel = UILabel()
private let dividerView = UIView()
private let detailsStackView = UIStackView()
private let instructionCard = UIView()
private let instructionLabel = UILabel()
private let arrowImageView = UIImageView()
init(residence: ResidencePreview, state: PreviewRenderState) {
self.residence = residence
self.state = state
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) { fatalError("not used") }
override func viewDidLoad() {
super.viewDidLoad()
setupUI()
applyResidence()
}
private func setupUI() {
view.backgroundColor = .systemBackground
containerView.translatesAutoresizingMaskIntoConstraints = false
iconImageView.translatesAutoresizingMaskIntoConstraints = false
iconImageView.contentMode = .scaleAspectFit
iconImageView.tintColor = UIColor(red: 7/255, green: 160/255, blue: 195/255, alpha: 1)
titleLabel.translatesAutoresizingMaskIntoConstraints = false
titleLabel.font = .systemFont(ofSize: 24, weight: .bold)
titleLabel.textColor = .label
titleLabel.textAlignment = .center
titleLabel.numberOfLines = 2
subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
subtitleLabel.font = .systemFont(ofSize: 15, weight: .medium)
subtitleLabel.textColor = .secondaryLabel
subtitleLabel.textAlignment = .center
dividerView.translatesAutoresizingMaskIntoConstraints = false
dividerView.backgroundColor = .separator
detailsStackView.translatesAutoresizingMaskIntoConstraints = false
detailsStackView.axis = .vertical
detailsStackView.spacing = 12
detailsStackView.alignment = .leading
instructionCard.translatesAutoresizingMaskIntoConstraints = false
instructionCard.backgroundColor = UIColor(red: 7/255, green: 160/255, blue: 195/255, alpha: 0.1)
instructionCard.layer.cornerRadius = 12
instructionLabel.translatesAutoresizingMaskIntoConstraints = false
instructionLabel.font = .systemFont(ofSize: 15, weight: .medium)
instructionLabel.textColor = UIColor(red: 7/255, green: 160/255, blue: 195/255, alpha: 1)
instructionLabel.textAlignment = .left
instructionLabel.numberOfLines = 0
arrowImageView.translatesAutoresizingMaskIntoConstraints = false
arrowImageView.contentMode = .scaleAspectFit
arrowImageView.tintColor = .secondaryLabel
let arrowConfig = UIImage.SymbolConfiguration(pointSize: 24, weight: .medium)
arrowImageView.image = UIImage(systemName: "arrow.down", withConfiguration: arrowConfig)
view.addSubview(containerView)
containerView.addSubview(iconImageView)
containerView.addSubview(titleLabel)
containerView.addSubview(subtitleLabel)
containerView.addSubview(dividerView)
containerView.addSubview(detailsStackView)
containerView.addSubview(instructionCard)
instructionCard.addSubview(instructionLabel)
containerView.addSubview(arrowImageView)
NSLayoutConstraint.activate([
containerView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
containerView.centerYAnchor.constraint(equalTo: view.centerYAnchor, constant: -40),
containerView.leadingAnchor.constraint(greaterThanOrEqualTo: view.leadingAnchor, constant: 32),
containerView.trailingAnchor.constraint(lessThanOrEqualTo: view.trailingAnchor, constant: -32),
containerView.widthAnchor.constraint(lessThanOrEqualToConstant: 340),
iconImageView.topAnchor.constraint(equalTo: containerView.topAnchor),
iconImageView.centerXAnchor.constraint(equalTo: containerView.centerXAnchor),
iconImageView.widthAnchor.constraint(equalToConstant: 80),
iconImageView.heightAnchor.constraint(equalToConstant: 80),
titleLabel.topAnchor.constraint(equalTo: iconImageView.bottomAnchor, constant: 16),
titleLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
titleLabel.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
subtitleLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 4),
subtitleLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
subtitleLabel.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
dividerView.topAnchor.constraint(equalTo: subtitleLabel.bottomAnchor, constant: 20),
dividerView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
dividerView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
dividerView.heightAnchor.constraint(equalToConstant: 1),
detailsStackView.topAnchor.constraint(equalTo: dividerView.bottomAnchor, constant: 20),
detailsStackView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
detailsStackView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
instructionCard.topAnchor.constraint(equalTo: detailsStackView.bottomAnchor, constant: 24),
instructionCard.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
instructionCard.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
instructionLabel.topAnchor.constraint(equalTo: instructionCard.topAnchor, constant: 16),
instructionLabel.leadingAnchor.constraint(equalTo: instructionCard.leadingAnchor, constant: 16),
instructionLabel.trailingAnchor.constraint(equalTo: instructionCard.trailingAnchor, constant: -16),
instructionLabel.bottomAnchor.constraint(equalTo: instructionCard.bottomAnchor, constant: -16),
arrowImageView.topAnchor.constraint(equalTo: instructionCard.bottomAnchor, constant: 16),
arrowImageView.centerXAnchor.constraint(equalTo: containerView.centerXAnchor),
arrowImageView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
])
}
private func applyResidence() {
// Mirror the post-fix branding choice: bundled honeyDue logo
// rendered in its actual colors. The image ships with the test
// target at `Resources/AppLogo.png`.
if let path = Bundle(for: Self.self).path(forResource: "AppLogo", ofType: "png"),
let logo = UIImage(contentsOfFile: path) {
iconImageView.image = logo
iconImageView.layer.cornerRadius = 16
iconImageView.layer.masksToBounds = true
} else {
let config = UIImage.SymbolConfiguration(pointSize: 60, weight: .light)
iconImageView.image = UIImage(systemName: "house.fill", withConfiguration: config)
}
titleLabel.text = residence.residenceName
subtitleLabel.text = "honeyDue Residence Invite"
detailsStackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
if let sharedBy = residence.sharedBy, !sharedBy.isEmpty {
addDetailRow(icon: "person", text: "Shared by \(sharedBy)")
}
switch state {
case .active:
instructionLabel.attributedText = makeResidenceInstructions()
arrowImageView.isHidden = false
if let expiresAt = residence.expiresAt, !expiresAt.isEmpty {
addDetailRow(icon: "clock", text: "Expires \(formatActiveExpiry(expiresAt))")
}
case .expired(let elapsed):
instructionLabel.attributedText = makeExpiredInstructions(sharedBy: residence.sharedBy)
// Arrow points at the Share button no point telling the
// user to tap it for a dead link. Matches PreviewViewController.
arrowImageView.isHidden = true
addDetailRow(icon: "clock", text: "Expired \(relativePhrase(secondsAgo: elapsed))")
}
}
private func relativePhrase(secondsAgo: TimeInterval) -> String {
// Deterministic relative phrase we set "now" to be exactly
// `secondsAgo` after the (fake) expiry, so the formatter says
// "1 hour ago" instead of whatever the real clock would give.
let fakeNow = Date()
let pastExpiry = fakeNow.addingTimeInterval(-secondsAgo)
let relative = RelativeDateTimeFormatter()
relative.unitsStyle = .full
return relative.localizedString(for: pastExpiry, relativeTo: fakeNow)
}
/// Expired-state copy mirroring `PreviewViewController.makeExpiredInstructions`.
private func makeExpiredInstructions(sharedBy: String?) -> NSAttributedString {
let bodyFont = UIFont.systemFont(ofSize: 15, weight: .medium)
let titleFont = UIFont.systemFont(ofSize: 15, weight: .semibold)
let paragraph = NSMutableParagraphStyle()
paragraph.lineSpacing = 2
paragraph.alignment = .left
let result = NSMutableAttributedString()
result.append(NSAttributedString(
string: "This invite has expired.\n",
attributes: [
.font: titleFont,
.foregroundColor: UIColor.label,
.paragraphStyle: paragraph,
]
))
let body = if let s = sharedBy, !s.isEmpty {
"Ask \(s) to send a new link."
} else {
"Ask the sender to share a new link."
}
result.append(NSAttributedString(
string: body,
attributes: [
.font: bodyFont,
.foregroundColor: UIColor.secondaryLabel,
.paragraphStyle: paragraph,
]
))
return result
}
private func addDetailRow(icon: String, text: String) {
let row = UIStackView()
row.axis = .horizontal
row.spacing = 12
row.alignment = .center
let iv = UIImageView()
iv.translatesAutoresizingMaskIntoConstraints = false
let config = UIImage.SymbolConfiguration(pointSize: 16, weight: .medium)
iv.image = UIImage(systemName: icon, withConfiguration: config)
iv.tintColor = .secondaryLabel
iv.widthAnchor.constraint(equalToConstant: 24).isActive = true
iv.heightAnchor.constraint(equalToConstant: 24).isActive = true
let label = UILabel()
label.font = .systemFont(ofSize: 15)
label.textColor = .label
label.text = text
label.numberOfLines = 1
row.addArrangedSubview(iv)
row.addArrangedSubview(label)
detailsStackView.addArrangedSubview(row)
}
/// Mirrors `PreviewViewController.makeResidenceInstructions()` see
/// the rationale comment there. Inlined here because the QL
/// extension target can't be `@testable import`ed without
/// project-file surgery.
private func makeResidenceInstructions() -> NSAttributedString {
let bodyFont = UIFont.systemFont(ofSize: 15, weight: .medium)
let tint = UIColor(red: 7/255, green: 160/255, blue: 195/255, alpha: 1)
let paragraph = NSMutableParagraphStyle()
paragraph.lineSpacing = 2
paragraph.alignment = .left
let result = NSMutableAttributedString()
func appendText(_ s: String) {
result.append(NSAttributedString(
string: s,
attributes: [
.font: bodyFont,
.foregroundColor: tint,
.paragraphStyle: paragraph,
]
))
}
appendText("How to join:\n1. Tap ")
let shareImage = UIImage(
systemName: "square.and.arrow.up",
withConfiguration: UIImage.SymbolConfiguration(pointSize: 16, weight: .semibold)
)?.withTintColor(tint, renderingMode: .alwaysOriginal)
if let shareImage {
let attachment = NSTextAttachment()
attachment.image = shareImage
attachment.bounds = CGRect(
x: 0,
y: -3,
width: shareImage.size.width,
height: shareImage.size.height
)
result.append(NSAttributedString(attachment: attachment))
}
appendText("\n2. Choose \"honeyDue\" from the share sheet")
appendText("\n3. Sign in if prompted — the app finishes the rest")
return result
}
// Mirrors PreviewViewController.formatActiveExpiry with a fixed
// "now" so the rendering is identical regardless of when the test
// runs. The expired branch uses [relativePhrase(secondsAgo:)]
// instead see the active/expired switch in `applyResidence`.
private func formatActiveExpiry(_ raw: String) -> String {
let isoWithFraction: ISO8601DateFormatter = {
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return f
}()
let isoNoFraction: ISO8601DateFormatter = {
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime]
return f
}()
guard let date = isoWithFraction.date(from: raw)
?? isoNoFraction.date(from: raw) else {
return raw
}
// Deterministic "now": 23 hours before the fixture's expiry, so
// the relative formatter always produces "in 23 hours".
let fakeNow = date.addingTimeInterval(-23 * 60 * 60)
let relative = RelativeDateTimeFormatter()
relative.unitsStyle = .full
return relative.localizedString(for: date, relativeTo: fakeNow)
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

@@ -153,9 +153,11 @@ private class AuthenticatedImageLoader: ObservableObject {
return return
} }
// Create request with auth header // Create request with the Kratos session-token header.
// Identity is owned by Ory Kratos; the honeyDue API authenticates
// requests via the session token on X-Session-Token.
var request = URLRequest(url: url) var request = URLRequest(url: url)
request.setValue("Token \(token)", forHTTPHeaderField: "Authorization") request.setValue(token, forHTTPHeaderField: "X-Session-Token")
request.timeoutInterval = 15 request.timeoutInterval = 15
request.cachePolicy = .returnCacheDataElseLoad request.cachePolicy = .returnCacheDataElseLoad
@@ -167,9 +167,10 @@ struct DocumentDetailView: View {
return return
} }
// Create authenticated request // Create authenticated request the honeyDue API gates
// media on the Kratos session token (X-Session-Token).
var request = URLRequest(url: url) var request = URLRequest(url: url)
request.setValue("Token \(token)", forHTTPHeaderField: "Authorization") request.setValue(token, forHTTPHeaderField: "X-Session-Token")
// Download the file // Download the file
let (tempURL, response) = try await URLSession.shared.download(for: request) let (tempURL, response) = try await URLSession.shared.download(for: request)
@@ -170,7 +170,8 @@ final class PresignedUploader {
var req = URLRequest(url: url) var req = URLRequest(url: url)
req.httpMethod = "POST" req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Content-Type") req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.setValue("Token \(authToken)", forHTTPHeaderField: "Authorization") // honeyDue API auth: Kratos session token on X-Session-Token.
req.setValue(authToken, forHTTPHeaderField: "X-Session-Token")
req.httpBody = try JSONEncoder().encode(PresignBody( req.httpBody = try JSONEncoder().encode(PresignBody(
category: category.rawValue, category: category.rawValue,
content_type: contentType, content_type: contentType,
+6 -2
View File
@@ -120,7 +120,6 @@ struct CompleteTaskView: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
.padding(.leading, 12) .padding(.leading, 12)
.keyboardDismissToolbar()
.accessibilityIdentifier(AccessibilityIdentifiers.Task.actualCostField) .accessibilityIdentifier(AccessibilityIdentifiers.Task.actualCostField)
} label: { } label: {
Label(L10n.Tasks.actualCost, systemImage: "dollarsign.circle") Label(L10n.Tasks.actualCost, systemImage: "dollarsign.circle")
@@ -142,7 +141,6 @@ struct CompleteTaskView: View {
TextEditor(text: $notes) TextEditor(text: $notes)
.frame(minHeight: 100) .frame(minHeight: 100)
.scrollContentBackground(.hidden) .scrollContentBackground(.hidden)
.keyboardDismissToolbar()
.accessibilityIdentifier(AccessibilityIdentifiers.Task.notesField) .accessibilityIdentifier(AccessibilityIdentifiers.Task.notesField)
} }
} footer: { } footer: {
@@ -289,6 +287,12 @@ struct CompleteTaskView: View {
.background(WarmGradientBackground()) .background(WarmGradientBackground())
.navigationTitle(L10n.Tasks.completeTask) .navigationTitle(L10n.Tasks.completeTask)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
// ONE keyboard "Done" toolbar at the form root per-field
// `.keyboardDismissToolbar()` modifiers each install a
// separate `ToolbarItemGroup(placement: .keyboard)`, and
// SwiftUI stacks them on the responder chain so any focused
// field renders multiple Done buttons side-by-side (issue #5).
.keyboardDismissToolbar()
.toolbar { .toolbar {
ToolbarItem(placement: .cancellationAction) { ToolbarItem(placement: .cancellationAction) {
Button(L10n.Common.cancel) { Button(L10n.Common.cancel) {