Compare commits
2 Commits
e0af5f4053
...
f7e866a08d
| Author | SHA1 | Date | |
|---|---|---|---|
| f7e866a08d | |||
| 95bbeb57d2 |
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## What This Is
|
## What This Is
|
||||||
|
|
||||||
A mobile-first meal planning app for a small household — pick recipes for the week, fill a calendar across five meal slots per day, and watch pantry gaps + shopping lists emerge from the plan. Kotlin Multiplatform targeting iOS primarily, with Android, Desktop, and Wasm as secondary targets. Built for me + my partner (shared household plan) with a handful of family/friends as authorized users on the same self-hosted backend.
|
A mobile-first meal planning app for a small household — pick recipes for the week, fill a calendar across five meal slots per day, and watch pantry gaps + shopping lists emerge from the plan. Kotlin Multiplatform targeting iOS primarily, with Android as the secondary app target and a JVM Ktor server. Built for me + my partner (shared household plan) with a handful of family/friends as authorized users on the same self-hosted backend.
|
||||||
|
|
||||||
## Core Value
|
## Core Value
|
||||||
|
|
||||||
@@ -60,6 +60,7 @@ A mobile-first meal planning app for a small household — pick recipes for the
|
|||||||
**Polish UI foundation**
|
**Polish UI foundation**
|
||||||
- [ ] All user-facing strings are externalized into resource files (i18n-ready), even though v1 ships Polish only
|
- [ ] All user-facing strings are externalized into resource files (i18n-ready), even though v1 ships Polish only
|
||||||
- [ ] UI uses a Liquid-Glass-inspired visual language (translucent surfaces, blur, soft depth) implemented in Compose Multiplatform
|
- [ ] UI uses a Liquid-Glass-inspired visual language (translucent surfaces, blur, soft depth) implemented in Compose Multiplatform
|
||||||
|
- [ ] Signed-in users have a real app shell early: main menu/tab chrome, empty Planner / Recipes / Pantry / Shopping views, and a working search affordance before domain data arrives
|
||||||
- [ ] Visual hierarchy is less cramped than the mockup (more breathing room, calmer typography)
|
- [ ] Visual hierarchy is less cramped than the mockup (more breathing room, calmer typography)
|
||||||
- [ ] iOS app feels iOS-idiomatic within Compose's constraints (tab bar placement, navigation patterns, safe areas, dark mode)
|
- [ ] iOS app feels iOS-idiomatic within Compose's constraints (tab bar placement, navigation patterns, safe areas, dark mode)
|
||||||
|
|
||||||
@@ -71,7 +72,7 @@ A mobile-first meal planning app for a small household — pick recipes for the
|
|||||||
- Nutrition goal tracking (targets, streaks, deficits) — *v1 shows numbers informationally only*
|
- Nutrition goal tracking (targets, streaks, deficits) — *v1 shows numbers informationally only*
|
||||||
- English and other language copy — *code is i18n-ready but v1 ships Polish only*
|
- English and other language copy — *code is i18n-ready but v1 ships Polish only*
|
||||||
- True native iOS 26 Liquid Glass via SwiftUI interop — *Compose approximation for v1; revisit only if real-device chrome feels clearly inadequate*
|
- True native iOS 26 Liquid Glass via SwiftUI interop — *Compose approximation for v1; revisit only if real-device chrome feels clearly inadequate*
|
||||||
- Desktop and Wasm as shipped products — *Desktop useful for hot-reload dev; Wasm is a possible future target, neither is a v1 deliverable*
|
- Desktop and Wasm app targets — *removed from the v1 target matrix to keep the build focused on iOS, Android, and the JVM server*
|
||||||
- Sign in with Apple as a first-class button — *user's Authentik handles auth; Apple can be federated upstream in Authentik if needed later*
|
- Sign in with Apple as a first-class button — *user's Authentik handles auth; Apple can be federated upstream in Authentik if needed later*
|
||||||
- Barcode scanning / receipt OCR for pantry updates — *manual entry is fine for a 2-person household*
|
- Barcode scanning / receipt OCR for pantry updates — *manual entry is fine for a 2-person household*
|
||||||
- AI-generated recipes — *curated catalog is the value*
|
- AI-generated recipes — *curated catalog is the value*
|
||||||
@@ -88,7 +89,7 @@ A mobile-first meal planning app for a small household — pick recipes for the
|
|||||||
|
|
||||||
## Context
|
## Context
|
||||||
|
|
||||||
**Codebase state.** The `~/dev/repo/recipe` directory is a freshly-generated Kotlin Multiplatform Compose template from IntelliJ with four modules: `composeApp` (Android + Desktop + iOS shared UI), `iosApp` (iOS bootstrap), `server` (Ktor, not yet written), and `shared` (common code). No app logic exists yet — this is effectively greenfield with the build infra in place.
|
**Codebase state.** The `~/dev/repo/recipe` directory has four modules: `composeApp` (Android + iOS shared UI), `iosApp` (iOS bootstrap), `server` (Ktor), and `shared` (common domain/DTO code). `shared` still has a JVM target because the server consumes it; `composeApp` does not ship Desktop or Wasm targets in v1.
|
||||||
|
|
||||||
**Reference implementation.** The user built a working PWA at `~/dev/repo/recipe-mockup/` (vanilla JS + Tailwind CDN + nginx/Docker). It implements the same four views (Recipe List, Meal Planner, Pantry, Shopping List) and has mature logic worth mining as a *functional* spec — particularly planner entry customization (substitutions, amount overrides, product selection), shortfall computation over a horizon, and shopping-list aggregation with "bought" session tracking. The mockup's UI design is **not** being carried forward; the user is redesigning visuals around a Liquid-Glass-inspired language.
|
**Reference implementation.** The user built a working PWA at `~/dev/repo/recipe-mockup/` (vanilla JS + Tailwind CDN + nginx/Docker). It implements the same four views (Recipe List, Meal Planner, Pantry, Shopping List) and has mature logic worth mining as a *functional* spec — particularly planner entry customization (substitutions, amount overrides, product selection), shortfall computation over a horizon, and shopping-list aggregation with "bought" session tracking. The mockup's UI design is **not** being carried forward; the user is redesigning visuals around a Liquid-Glass-inspired language.
|
||||||
|
|
||||||
@@ -96,9 +97,9 @@ A mobile-first meal planning app for a small household — pick recipes for the
|
|||||||
|
|
||||||
**Infra.** User runs a homelab. Authentik is already installed. The Ktor backend will run on the same server (containerized). No managed cloud dependencies planned.
|
**Infra.** User runs a homelab. Authentik is already installed. The Ktor backend will run on the same server (containerized). No managed cloud dependencies planned.
|
||||||
|
|
||||||
**Language & platform.** Polish-only UI for v1 (strings externalized for future i18n). iOS is the primary daily driver; Android deployed later for friends; Desktop useful for development (hot reload); Wasm is aspirational.
|
**Language & platform.** Polish-only UI for v1 (strings externalized for future i18n). iOS is the primary daily driver; Android deployed later for friends. Desktop and Wasm app targets are deferred out of v1.
|
||||||
|
|
||||||
**Liquid Glass decision.** True iOS 26 Liquid Glass (refractive material, specular highlights, morphing chrome) is a SwiftUI-native feature that Compose on iOS cannot reproduce exactly (Compose uses Skia, not Metal-native glass material). The v1 plan is: Compose-only approximation (blur + translucency + gradients) everywhere, measure real-device performance and visual quality, and **only** selectively add SwiftUI interop for the chrome (tab bar, nav bar) if the approximation feels insufficient. This avoids upfront interop complexity for 90%+ of the UI.
|
**Liquid Glass decision.** True iOS 26 Liquid Glass (refractive material, specular highlights, morphing chrome) is a SwiftUI-native feature that Compose on iOS cannot reproduce exactly (Compose uses Skia, not Metal-native glass material). The v1 plan is: Compose-only approximation using the Liquid library for menu/search/button chrome first, with blur/translucency fallbacks where needed; measure real-device performance and visual quality; and **only** selectively add SwiftUI interop for chrome if the approximation feels insufficient. This avoids upfront interop complexity for 90%+ of the UI.
|
||||||
|
|
||||||
## Constraints
|
## Constraints
|
||||||
|
|
||||||
@@ -117,12 +118,13 @@ A mobile-first meal planning app for a small household — pick recipes for the
|
|||||||
|
|
||||||
| Decision | Rationale | Outcome |
|
| Decision | Rationale | Outcome |
|
||||||
|----------|-----------|---------|
|
|----------|-----------|---------|
|
||||||
| KMP with Compose Multiplatform for UI | iOS-primary + Android secondary + Desktop/Wasm optional; single codebase for 90%+ of UI | — Pending |
|
| KMP with Compose Multiplatform for UI | iOS-primary + Android secondary; Desktop/Wasm app targets removed from v1 to keep the build focused | — Pending |
|
||||||
| Household-sharing from day 1 (me + partner share one plan) | Core use case is cooking together; per-user + later-sharing would force data-model rewrite | — Pending |
|
| Household-sharing from day 1 (me + partner share one plan) | Core use case is cooking together; per-user + later-sharing would force data-model rewrite | — Pending |
|
||||||
| Authentik OIDC as sole auth provider for MVP | User already runs Authentik; self-hosted == aligned; Apple Sign-in likely not required for App Store since Authentik is user's own IdP, not a third-party social login | — Pending |
|
| Authentik OIDC as sole auth provider for MVP | User already runs Authentik; self-hosted == aligned; Apple Sign-in likely not required for App Store since Authentik is user's own IdP, not a third-party social login | — Pending |
|
||||||
| Server lives on user's homelab alongside Authentik | Existing infra, zero managed-cloud cost, same ops surface | — Pending |
|
| Server lives on user's homelab alongside Authentik | Existing infra, zero managed-cloud cost, same ops surface | — Pending |
|
||||||
| Offline-first with last-write-wins sync | Grocery-store usage demands offline; conflict resolution overkill for a 2-person household | — Pending |
|
| Offline-first with last-write-wins sync | Grocery-store usage demands offline; conflict resolution overkill for a 2-person household | — Pending |
|
||||||
| Compose-only Liquid Glass approximation for v1 | Real iOS 26 Liquid Glass requires SwiftUI interop; approximation keeps single codebase; revisit only if chrome feels inadequate on real device | — Pending |
|
| Compose-only Liquid Glass approximation for v1 | Real iOS 26 Liquid Glass requires SwiftUI interop; Liquid gives Compose chrome/buttons a closer approximation while keeping a single codebase; revisit only if chrome feels inadequate on real device | — Pending |
|
||||||
|
| Real app shell before household/domain work | The authenticated app should stop feeling like a placeholder before Phase 3; menu navigation, empty states, and search can be built without household data and will reduce UI churn in later phases | — Pending |
|
||||||
| Polish-only strings, i18n-ready infrastructure | Single-language content for v1 speed; externalized strings prevent future rewrite | — Pending |
|
| Polish-only strings, i18n-ready infrastructure | Single-language content for v1 speed; externalized strings prevent future rewrite | — Pending |
|
||||||
| Start catalog fresh (don't port mockup seed data) | Mockup data is a reference, not production content; user wants to re-curate | — Pending |
|
| Start catalog fresh (don't port mockup seed data) | Mockup data is a reference, not production content; user wants to re-curate | — Pending |
|
||||||
| Nutrition is informational only in v1 | Keep scope tight; tracking/goals are a natural v2 if usage patterns justify | — Pending |
|
| Nutrition is informational only in v1 | Keep scope tight; tracking/goals are a natural v2 if usage patterns justify | — Pending |
|
||||||
@@ -132,7 +134,8 @@ A mobile-first meal planning app for a small household — pick recipes for the
|
|||||||
|
|
||||||
| Decision | Rationale | Outcome |
|
| Decision | Rationale | Outcome |
|
||||||
|----------|-----------|---------|
|
|----------|-----------|---------|
|
||||||
| Navigation: Jetpack Navigation Compose (CMP port) — `org.jetbrains.androidx.navigation:navigation-compose:2.9.x` | JetBrains-official recommendation on kotlinlang.org; type-safe routes via `@Serializable`; works across Android/iOS/Desktop/Wasm; skill transferable to Android | — Pending |
|
| Navigation: Jetpack Navigation Compose (CMP port) — `org.jetbrains.androidx.navigation:navigation-compose:2.9.x` | JetBrains-official recommendation on kotlinlang.org; type-safe routes via `@Serializable`; works across Android/iOS; skill transferable to Android | — Pending |
|
||||||
|
| Component foundation: Composables / Compose Unstyled | Use renderless, accessible primitives from `composables.com` for new shared controls so Recipe owns the visual language instead of inheriting Material 3's Android look. Composables One is optional only if the project has/chooses the paid kit. | — Pending |
|
||||||
| Architecture: ViewModel + StateFlow + method-per-action | Standard modern pattern; matches JetBrains/Google samples; lowest ceremony; upgrade individual screens to sealed-event onEvent only when they grow complex | — Pending |
|
| Architecture: ViewModel + StateFlow + method-per-action | Standard modern pattern; matches JetBrains/Google samples; lowest ceremony; upgrade individual screens to sealed-event onEvent only when they grow complex | — Pending |
|
||||||
| DI: Koin — `koin-core`, `koin-compose`, `koin-compose-viewmodel` | De facto KMP standard; smoothest `koinViewModel()` integration with Jetpack Nav back-stack scoping; no codegen; small surface to learn | — Pending |
|
| DI: Koin — `koin-core`, `koin-compose`, `koin-compose-viewmodel` | De facto KMP standard; smoothest `koinViewModel()` integration with Jetpack Nav back-stack scoping; no codegen; small surface to learn | — Pending |
|
||||||
| Local DB: SQLDelight 2.x | Most mature KMP DB; Wasm-ready (hedge for future Compose-for-Web target); raw SQL is a transferable skill; clear migration story via .sq files | — Pending |
|
| Local DB: SQLDelight 2.x | Most mature KMP DB; Wasm-ready (hedge for future Compose-for-Web target); raw SQL is a transferable skill; clear migration story via .sq files | — Pending |
|
||||||
@@ -142,7 +145,7 @@ A mobile-first meal planning app for a small household — pick recipes for the
|
|||||||
| Logging: Kermit (Touchlab) | KMP-native logger; simple API; optional Crashlytics/Sentry bridges | — Pending |
|
| Logging: Kermit (Touchlab) | KMP-native logger; simple API; optional Crashlytics/Sentry bridges | — Pending |
|
||||||
| Image loading: Coil 3 (`io.coil-kt.coil3:coil-compose`) | First-class Compose Multiplatform support; modern API | — Pending |
|
| Image loading: Coil 3 (`io.coil-kt.coil3:coil-compose`) | First-class Compose Multiplatform support; modern API | — Pending |
|
||||||
| Key-value settings: `com.russhwolf:multiplatform-settings` | Small prefs (last tab, theme toggles) that don't belong in SQLDelight | — Pending |
|
| Key-value settings: `com.russhwolf:multiplatform-settings` | Small prefs (last tab, theme toggles) that don't belong in SQLDelight | — Pending |
|
||||||
| Glass/blur effects: Haze (`dev.chrisbanes.haze:haze`) | Purpose-built for glass UI in CMP; handles content capture + efficient re-blur; multiplatform | — Pending |
|
| Glass effects: Liquid (`io.github.fletchmckee.liquid:liquid`) first for chrome/buttons; Haze only as a fallback/simple blur tool if needed | Liquid lets modifier nodes sample/manipulate pixels behind controls for a closer Liquid-Glass-style effect in Compose Multiplatform; keep effects constrained to chrome/buttons and verify on real iOS hardware | — Pending |
|
||||||
| Mobile OIDC: Lokksmith on Android/iOS, exposed via the KMP `OidcClient` interface | Keeps OIDC browser flow, PKCE, state/nonce, refresh, and end-session in Kotlin Multiplatform; removes the Swift AppAuth bridge and SwiftPM package from the iOS shell while still using ASWebAuthenticationSession underneath on iOS | — Pending |
|
| Mobile OIDC: Lokksmith on Android/iOS, exposed via the KMP `OidcClient` interface | Keeps OIDC browser flow, PKCE, state/nonce, refresh, and end-session in Kotlin Multiplatform; removes the Swift AppAuth bridge and SwiftPM package from the iOS shell while still using ASWebAuthenticationSession underneath on iOS | — Pending |
|
||||||
|
|
||||||
### Server tech stack
|
### Server tech stack
|
||||||
|
|||||||
@@ -87,12 +87,13 @@
|
|||||||
- [ ] **UI-01**: All user-facing strings are externalized as Compose resources (i18n-ready), even though v1 ships Polish only
|
- [ ] **UI-01**: All user-facing strings are externalized as Compose resources (i18n-ready), even though v1 ships Polish only
|
||||||
- [ ] **UI-02**: App ships with Polish-language copy throughout
|
- [ ] **UI-02**: App ships with Polish-language copy throughout
|
||||||
- [ ] **UI-03**: Bottom tab navigation with 4 tabs: Przepisy / Planer / Spiżarnia / Zakupy, each preserving its own back stack independently
|
- [ ] **UI-03**: Bottom tab navigation with 4 tabs: Przepisy / Planer / Spiżarnia / Zakupy, each preserving its own back stack independently
|
||||||
- [ ] **UI-04**: Tab bar and nav bar use Haze-based glass/blur effects (Liquid Glass approximation)
|
- [ ] **UI-04**: App chrome and primary icon buttons use the chosen Compose Liquid-Glass approximation, starting with the Liquid library for menu/search controls
|
||||||
- [ ] **UI-05**: App supports light and dark color schemes with translucent surfaces working in both
|
- [ ] **UI-05**: App supports light and dark color schemes with translucent surfaces working in both
|
||||||
- [ ] **UI-06**: UI is iOS-idiomatic within Compose constraints (safe areas, swipe-back gesture where applicable, keyboard avoidance)
|
- [ ] **UI-06**: UI is iOS-idiomatic within Compose constraints (safe areas, swipe-back gesture where applicable, keyboard avoidance)
|
||||||
- [ ] **UI-07**: Visual hierarchy is less cramped than the mockup — deliberate spacing, calmer typography, readable at arm's length
|
- [ ] **UI-07**: Visual hierarchy is less cramped than the mockup — deliberate spacing, calmer typography, readable at arm's length
|
||||||
- [ ] **UI-08**: Locale-aware date formatting for display (days, months, weekday names in Polish); sync wire-format stays UTC ISO-8601
|
- [ ] **UI-08**: Locale-aware date formatting for display (days, months, weekday names in Polish); sync wire-format stays UTC ISO-8601
|
||||||
- [ ] **UI-09**: App starts cleanly on first launch (no blank flash) and shows appropriate empty states when catalog / plan / pantry / shopping are empty
|
- [ ] **UI-09**: App starts cleanly on first launch (no blank flash) and shows appropriate empty states when catalog / plan / pantry / shopping are empty
|
||||||
|
- [ ] **UI-10**: Main app search affordance is functional before catalog data exists: search opens, query state updates, clear/close actions work, and the no-results/empty-data state is deliberate
|
||||||
|
|
||||||
### Infrastructure & build
|
### Infrastructure & build
|
||||||
|
|
||||||
@@ -217,13 +218,14 @@ Populated during roadmap creation. Each v1 requirement maps to exactly one phase
|
|||||||
| SYNC-10 | Phase 4: Sync Engine Skeleton | Pending |
|
| SYNC-10 | Phase 4: Sync Engine Skeleton | Pending |
|
||||||
| UI-01 | Phase 11: Localization & iOS Deployment | Pending |
|
| UI-01 | Phase 11: Localization & iOS Deployment | Pending |
|
||||||
| UI-02 | Phase 11: Localization & iOS Deployment | Pending |
|
| UI-02 | Phase 11: Localization & iOS Deployment | Pending |
|
||||||
| UI-03 | Phase 10: UI Chrome & Haze Liquid-Glass Polish | Pending |
|
| UI-03 | Phase 2.1: App Shell, Navigation & Search Foundation | Pending |
|
||||||
| UI-04 | Phase 10: UI Chrome & Haze Liquid-Glass Polish | Pending |
|
| UI-04 | Phase 2.1: App Shell, Navigation & Search Foundation | Pending |
|
||||||
| UI-05 | Phase 5: Recipe Catalog (Read Path) | Pending |
|
| UI-05 | Phase 5: Recipe Catalog (Read Path) | Pending |
|
||||||
| UI-06 | Phase 10: UI Chrome & Haze Liquid-Glass Polish | Pending |
|
| UI-06 | Phase 10: UI Chrome & Liquid-Glass Polish | Pending |
|
||||||
| UI-07 | Phase 10: UI Chrome & Haze Liquid-Glass Polish | Pending |
|
| UI-07 | Phase 10: UI Chrome & Liquid-Glass Polish | Pending |
|
||||||
| UI-08 | Phase 5: Recipe Catalog (Read Path) | Pending |
|
| UI-08 | Phase 5: Recipe Catalog (Read Path) | Pending |
|
||||||
| UI-09 | Phase 10: UI Chrome & Haze Liquid-Glass Polish | Pending |
|
| UI-09 | Phase 2.1: App Shell, Navigation & Search Foundation | Pending |
|
||||||
|
| UI-10 | Phase 2.1: App Shell, Navigation & Search Foundation | Pending |
|
||||||
| INFRA-01 | Phase 1: Project Infrastructure & Module Wiring | Complete |
|
| INFRA-01 | Phase 1: Project Infrastructure & Module Wiring | Complete |
|
||||||
| INFRA-02 | Phase 1: Project Infrastructure & Module Wiring | Complete |
|
| INFRA-02 | Phase 1: Project Infrastructure & Module Wiring | Complete |
|
||||||
| INFRA-03 | Phase 1: Project Infrastructure & Module Wiring | Complete |
|
| INFRA-03 | Phase 1: Project Infrastructure & Module Wiring | Complete |
|
||||||
@@ -233,8 +235,8 @@ Populated during roadmap creation. Each v1 requirement maps to exactly one phase
|
|||||||
| INFRA-07 | Phase 11: Localization & iOS Deployment | Pending |
|
| INFRA-07 | Phase 11: Localization & iOS Deployment | Pending |
|
||||||
|
|
||||||
**Coverage:**
|
**Coverage:**
|
||||||
- v1 requirements: **72 total** (AUTH=6, HSHD=7, RCPE=8, PLAN=14, PNTR=5, SHOP=6, SYNC=10, UI=9, INFRA=7)
|
- v1 requirements: **73 total** (AUTH=6, HSHD=7, RCPE=8, PLAN=14, PNTR=5, SHOP=6, SYNC=10, UI=10, INFRA=7)
|
||||||
- Mapped to phases: **72**
|
- Mapped to phases: **73**
|
||||||
- Unmapped: **0**
|
- Unmapped: **0**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -2,14 +2,15 @@
|
|||||||
|
|
||||||
**Core Value:** "My week is planned." I pick recipes, the calendar fills up, and I know what we're eating.
|
**Core Value:** "My week is planned." I pick recipes, the calendar fills up, and I know what we're eating.
|
||||||
|
|
||||||
**Granularity:** Fine (11 phases)
|
**Granularity:** Fine (11 phases + 1 inserted shell phase)
|
||||||
**Mode:** YOLO
|
**Mode:** YOLO
|
||||||
**Source of truth:** Derived from `.planning/REQUIREMENTS.md` (72 v1 requirements) guided by `.planning/research/SUMMARY.md` (suggested skeleton) and `.planning/research/ARCHITECTURE.md` (build-order reasoning).
|
**Source of truth:** Derived from `.planning/REQUIREMENTS.md` (73 v1 requirements) guided by `.planning/research/SUMMARY.md` (suggested skeleton) and `.planning/research/ARCHITECTURE.md` (build-order reasoning).
|
||||||
|
|
||||||
## Phases
|
## Phases
|
||||||
|
|
||||||
- [x] **Phase 1: Project Infrastructure & Module Wiring** — Running-but-empty KMP client + Ktor server with all build infra baked in
|
- [x] **Phase 1: Project Infrastructure & Module Wiring** — Running-but-empty KMP client + Ktor server with all build infra baked in
|
||||||
- [ ] **Phase 2: Authentication Foundation** — User signs in through Authentik (OIDC+PKCE) and the server validates tokens
|
- [x] **Phase 2: Authentication Foundation** — User signs in through Authentik (OIDC+PKCE) and the server validates tokens
|
||||||
|
- [ ] **Phase 2.1: App Shell, Navigation & Search Foundation** — Signed-in users can move between the four empty app areas through a Liquid-styled menu and open the search surface
|
||||||
- [ ] **Phase 3: Households, Membership & Server Data Foundation** — Users create/join households; server enforces household scope
|
- [ ] **Phase 3: Households, Membership & Server Data Foundation** — Users create/join households; server enforces household scope
|
||||||
- [ ] **Phase 4: Sync Engine Skeleton** — Offline-first read/write with outbox-backed LWW sync on a sentinel table
|
- [ ] **Phase 4: Sync Engine Skeleton** — Offline-first read/write with outbox-backed LWW sync on a sentinel table
|
||||||
- [ ] **Phase 5: Recipe Catalog (Read Path)** — User browses, filters, and opens recipe details from a seeded catalog
|
- [ ] **Phase 5: Recipe Catalog (Read Path)** — User browses, filters, and opens recipe details from a seeded catalog
|
||||||
@@ -17,7 +18,7 @@
|
|||||||
- [ ] **Phase 7: Meal Planner — Customization & Nutrition** — User tweaks servings/ingredients/products per meal entry and sees daily nutrition
|
- [ ] **Phase 7: Meal Planner — Customization & Nutrition** — User tweaks servings/ingredients/products per meal entry and sees daily nutrition
|
||||||
- [ ] **Phase 8: Pantry** — User tracks what's on hand and sees shortfalls against the plan
|
- [ ] **Phase 8: Pantry** — User tracks what's on hand and sees shortfalls against the plan
|
||||||
- [ ] **Phase 9: Shopping List & Session Log** — User generates a grouped shopping list from the plan and shops with "bought" tracking
|
- [ ] **Phase 9: Shopping List & Session Log** — User generates a grouped shopping list from the plan and shops with "bought" tracking
|
||||||
- [ ] **Phase 10: UI Chrome & Haze Liquid-Glass Polish** — Tab/nav glass effects, iOS-idiomatic chrome, calmer visual hierarchy
|
- [ ] **Phase 10: UI Chrome & Liquid-Glass Polish** — Real-device Liquid glass tuning, iOS-idiomatic chrome, calmer visual hierarchy
|
||||||
- [ ] **Phase 11: Localization & iOS Deployment** — Full Polish copy pass, i18n-ready resources, TestFlight to partner
|
- [ ] **Phase 11: Localization & iOS Deployment** — Full Polish copy pass, i18n-ready resources, TestFlight to partner
|
||||||
|
|
||||||
## Phase Summary Table
|
## Phase Summary Table
|
||||||
@@ -26,6 +27,7 @@
|
|||||||
|---|------|-----------------|--------------|-----|
|
|---|------|-----------------|--------------|-----|
|
||||||
| 1 | Project Infrastructure & Module Wiring | KMP client + Ktor server build cleanly with convention plugins, version catalog, iOS binary flags, and a shared DTO module | INFRA-01, INFRA-02, INFRA-03, INFRA-06 | 4 |
|
| 1 | Project Infrastructure & Module Wiring | KMP client + Ktor server build cleanly with convention plugins, version catalog, iOS binary flags, and a shared DTO module | INFRA-01, INFRA-02, INFRA-03, INFRA-06 | 4 |
|
||||||
| 2 | Authentication Foundation | End-to-end OIDC+PKCE login to Authentik with JIT user provisioning and server-side JWT validation | AUTH-01, AUTH-02, AUTH-03, AUTH-04, AUTH-05, AUTH-06 | 5 |
|
| 2 | Authentication Foundation | End-to-end OIDC+PKCE login to Authentik with JIT user provisioning and server-side JWT validation | AUTH-01, AUTH-02, AUTH-03, AUTH-04, AUTH-05, AUTH-06 | 5 |
|
||||||
|
| 2.1 | App Shell, Navigation & Search Foundation | Signed-in users land in the real 4-tab app shell with empty Planner / Recipes / Pantry / Shopping screens, Liquid-styled chrome, and an operational search affordance | UI-03, UI-04, UI-09, UI-10 | 5 |
|
||||||
| 3 | Households, Membership & Server Data Foundation | Create/join households via invites; every request carries a household-scoped principal derived from JWT | HSHD-01, HSHD-02, HSHD-03, HSHD-04, HSHD-05, HSHD-06, HSHD-07, INFRA-05 | 5 |
|
| 3 | Households, Membership & Server Data Foundation | Create/join households via invites; every request carries a household-scoped principal derived from JWT | HSHD-01, HSHD-02, HSHD-03, HSHD-04, HSHD-05, HSHD-06, HSHD-07, INFRA-05 | 5 |
|
||||||
| 4 | Sync Engine Skeleton | Outbox-backed LWW sync works round-trip on a sentinel table with server-assigned timestamps and cursor pull | SYNC-01, SYNC-02, SYNC-03, SYNC-04, SYNC-05, SYNC-06, SYNC-07, SYNC-08, SYNC-09, SYNC-10 | 5 |
|
| 4 | Sync Engine Skeleton | Outbox-backed LWW sync works round-trip on a sentinel table with server-assigned timestamps and cursor pull | SYNC-01, SYNC-02, SYNC-03, SYNC-04, SYNC-05, SYNC-06, SYNC-07, SYNC-08, SYNC-09, SYNC-10 | 5 |
|
||||||
| 5 | Recipe Catalog (Read Path) | User browses a seeded recipe catalog, filters/searches, and opens a detail view — offline-capable | RCPE-01, RCPE-02, RCPE-03, RCPE-04, RCPE-05, RCPE-06, RCPE-07, RCPE-08, UI-05, UI-08 | 5 |
|
| 5 | Recipe Catalog (Read Path) | User browses a seeded recipe catalog, filters/searches, and opens a detail view — offline-capable | RCPE-01, RCPE-02, RCPE-03, RCPE-04, RCPE-05, RCPE-06, RCPE-07, RCPE-08, UI-05, UI-08 | 5 |
|
||||||
@@ -33,7 +35,7 @@
|
|||||||
| 7 | Meal Planner — Customization & Nutrition | User customizes ingredients per meal entry and sees daily macro totals that respect customizations | PLAN-07, PLAN-08, PLAN-09, PLAN-10, PLAN-11, PLAN-13 | 4 |
|
| 7 | Meal Planner — Customization & Nutrition | User customizes ingredients per meal entry and sees daily macro totals that respect customizations | PLAN-07, PLAN-08, PLAN-09, PLAN-10, PLAN-11, PLAN-13 | 4 |
|
||||||
| 8 | Pantry | User manages pantry inventory by category and sees shortfalls for a chosen horizon | PNTR-01, PNTR-02, PNTR-03, PNTR-04, PNTR-05 | 4 |
|
| 8 | Pantry | User manages pantry inventory by category and sees shortfalls for a chosen horizon | PNTR-01, PNTR-02, PNTR-03, PNTR-04, PNTR-05 | 4 |
|
||||||
| 9 | Shopping List & Session Log | User generates a category-grouped shopping list and marks items bought during a session | SHOP-01, SHOP-02, SHOP-03, SHOP-04, SHOP-05, SHOP-06 | 4 |
|
| 9 | Shopping List & Session Log | User generates a category-grouped shopping list and marks items bought during a session | SHOP-01, SHOP-02, SHOP-03, SHOP-04, SHOP-05, SHOP-06 | 4 |
|
||||||
| 10 | UI Chrome & Haze Liquid-Glass Polish | 4-tab nav with independent back stacks, Haze glass chrome, iOS idioms, breathing-room visual hierarchy | UI-03, UI-04, UI-06, UI-07, UI-09 | 5 |
|
| 10 | UI Chrome & Liquid-Glass Polish | Real-device tuning for Liquid glass chrome, iOS idioms, breathing-room visual hierarchy, and cross-screen polish after real data exists | UI-06, UI-07 | 5 |
|
||||||
| 11 | Localization & iOS Deployment | All strings externalized, Polish copy throughout, partner installs via TestFlight | UI-01, UI-02, INFRA-04, INFRA-07 | 4 |
|
| 11 | Localization & iOS Deployment | All strings externalized, Polish copy throughout, partner installs via TestFlight | UI-01, UI-02, INFRA-04, INFRA-07 | 4 |
|
||||||
|
|
||||||
## Phase Details
|
## Phase Details
|
||||||
@@ -78,18 +80,33 @@ Plans:
|
|||||||
Plans:
|
Plans:
|
||||||
- [x] 02-01-PLAN.md — Shared auth contracts, dependency aliases, Authentik setup docs, and source audit
|
- [x] 02-01-PLAN.md — Shared auth contracts, dependency aliases, Authentik setup docs, and source audit
|
||||||
- [x] 02-02-PLAN.md — Server JWT validation, JWKS hardening, JIT users, and `/api/v1/me`
|
- [x] 02-02-PLAN.md — Server JWT validation, JWKS hardening, JIT users, and `/api/v1/me`
|
||||||
- [ ] 02-03-PLAN.md — Common OIDC/store contracts, JVM/Wasm actuals, and store contract test
|
- [x] 02-03-PLAN.md — Common OIDC/store contracts, JVM/Wasm actuals, and store contract test
|
||||||
- [ ] 02-04-PLAN.md — Android OIDC actual, Android secure AuthState store, and manifest callback
|
- [x] 02-04-PLAN.md — Android OIDC actual, Android secure AuthState store, and manifest callback
|
||||||
- [ ] 02-05-PLAN.md — iOS OIDC actual, iOS Keychain store, and URL scheme callback
|
- [x] 02-05-PLAN.md — iOS OIDC actual, iOS Keychain store, and URL scheme callback
|
||||||
- [ ] 02-06-PLAN.md — AuthSession state machine, bearer HTTP client, refresh/logout behavior, and Koin wiring
|
- [x] 02-06-PLAN.md — AuthSession state machine, bearer HTTP client, refresh/logout behavior, and Koin wiring
|
||||||
- [ ] 02-07-PLAN.md — Compose auth gate UI, Polish resource strings, and iOS Authentik UAT
|
- [x] 02-07-PLAN.md — Compose auth gate UI, Polish resource strings, and iOS Authentik UAT
|
||||||
|
**UI hint:** yes
|
||||||
|
**Research flag:** yes
|
||||||
|
|
||||||
|
### Phase 2.1: App Shell, Navigation & Search Foundation
|
||||||
|
|
||||||
|
**Goal:** Replace the post-login placeholder with the real app shell before household/domain data lands: four persistent top-level destinations (Przepisy, Planer, Spiżarnia, Zakupy), deliberate empty states for each, a working search affordance, and the first shared component layer based on Composables + Liquid instead of growing further around Material 3.
|
||||||
|
**Depends on:** Phase 2
|
||||||
|
**Requirements:** UI-03, UI-04, UI-09, UI-10
|
||||||
|
**Success Criteria** (what must be TRUE):
|
||||||
|
1. After sign-in I land in the main app shell, not the Phase 2 welcome placeholder; I can switch between Przepisy, Planer, Spiżarnia, and Zakupy from the main menu without signing out.
|
||||||
|
2. Each tab has its own navigation state boundary from day 1, so future detail screens can preserve back stacks independently; the initial screens are intentionally empty states, not throwaway placeholders.
|
||||||
|
3. The shared UI foundation uses Composables' Compose Unstyled/renderless primitives for new controls where applicable, with local Recipe components providing the visual styling; Material 3 remains only as temporary legacy auth scaffold until migrated.
|
||||||
|
4. Menu chrome and primary icon buttons use the Liquid library (`io.github.fletchmckee.liquid:liquid`) for the first Liquid-Glass-inspired treatment, constrained to chrome/buttons and backed by a simple fallback path if performance or platform support is not acceptable.
|
||||||
|
5. The search button is functional: tapping it opens a search surface, query input updates state, close/clear actions work, and empty/no-data content is intentional until the recipe catalog read path wires real results in Phase 5.
|
||||||
|
**Plans:** TBD
|
||||||
**UI hint:** yes
|
**UI hint:** yes
|
||||||
**Research flag:** yes
|
**Research flag:** yes
|
||||||
|
|
||||||
### Phase 3: Households, Membership & Server Data Foundation
|
### Phase 3: Households, Membership & Server Data Foundation
|
||||||
|
|
||||||
**Goal:** Introduce the tenancy model before any feature tables land — `households`, `memberships`, `invites` with Flyway migrations; server's `PrincipalResolver` maps JWT `sub` to an active `householdId`; client finishes onboarding by creating or joining a household.
|
**Goal:** Introduce the tenancy model before any feature tables land — `households`, `memberships`, `invites` with Flyway migrations; server's `PrincipalResolver` maps JWT `sub` to an active `householdId`; client finishes onboarding by creating or joining a household.
|
||||||
**Depends on:** Phase 2
|
**Depends on:** Phase 2.1
|
||||||
**Requirements:** HSHD-01, HSHD-02, HSHD-03, HSHD-04, HSHD-05, HSHD-06, HSHD-07, INFRA-05
|
**Requirements:** HSHD-01, HSHD-02, HSHD-03, HSHD-04, HSHD-05, HSHD-06, HSHD-07, INFRA-05
|
||||||
**Success Criteria** (what must be TRUE):
|
**Success Criteria** (what must be TRUE):
|
||||||
1. On my first login, I see an onboarding screen asking me to create a new household or enter an invite code.
|
1. On my first login, I see an onboarding screen asking me to create a new household or enter an invite code.
|
||||||
@@ -188,17 +205,17 @@ Plans:
|
|||||||
**UI hint:** yes
|
**UI hint:** yes
|
||||||
**Research flag:** no
|
**Research flag:** no
|
||||||
|
|
||||||
### Phase 10: UI Chrome & Haze Liquid-Glass Polish
|
### Phase 10: UI Chrome & Liquid-Glass Polish
|
||||||
|
|
||||||
**Goal:** Swap the boring default chrome used in Phases 5–9 for the intended Liquid-Glass-inspired feel — 4-tab bottom nav with independent back stacks, Haze-based blur on tab/nav chrome, iOS-idiomatic safe-area/keyboard/swipe-back behaviors, and a calmer spacing/typography pass across every screen. Measurable against realistic data already present.
|
**Goal:** Polish and harden the app-wide visual system after real catalog/planner/pantry/shopping data exists — tune Liquid glass chrome on device, verify iOS idioms, remove remaining Material 3-looking surfaces, and run the calmer spacing/typography pass across every screen.
|
||||||
**Depends on:** Phase 9
|
**Depends on:** Phase 9
|
||||||
**Requirements:** UI-03, UI-04, UI-06, UI-07, UI-09
|
**Requirements:** UI-06, UI-07
|
||||||
**Success Criteria** (what must be TRUE):
|
**Success Criteria** (what must be TRUE):
|
||||||
1. The app has a 4-tab bottom nav (Przepisy / Planer / Spiżarnia / Zakupy); tapping into a recipe detail, switching tabs, and coming back preserves the detail — each tab keeps its own back stack.
|
1. The Phase 2.1 app shell still preserves each tab's back stack after real recipe detail, planner, pantry, and shopping flows exist.
|
||||||
2. The tab bar and top nav bar use Haze-based translucent blur over content beneath them, consistent in light and dark schemes, and scrolling a full recipe grid on iPhone 11 stays above ~55 fps.
|
2. The tab bar, nav bar, and search/button chrome use the chosen Liquid-Glass approximation consistently in light and dark schemes, and scrolling a full recipe grid on iPhone 11 stays above ~55 fps.
|
||||||
3. The app respects iOS safe areas, supports the swipe-back gesture where applicable, and keyboards never cover focused inputs.
|
3. The app respects iOS safe areas, supports the swipe-back gesture where applicable, and keyboards never cover focused inputs.
|
||||||
4. Typography and spacing feel noticeably calmer than the legacy PWA mockup — more whitespace between cards, larger hit targets, readable at arm's length.
|
4. Typography and spacing feel noticeably calmer than the legacy PWA mockup — more whitespace between cards, larger hit targets, readable at arm's length.
|
||||||
5. On a fresh install I never see a blank flash on launch, and every main screen (catalog / planner / pantry / shopping) renders a deliberate empty state when there's nothing to show yet.
|
5. Any remaining Material 3-looking default components from earlier phases are replaced by Recipe-styled components built on the agreed component foundation.
|
||||||
**Plans:** TBD
|
**Plans:** TBD
|
||||||
**UI hint:** yes
|
**UI hint:** yes
|
||||||
**Research flag:** yes
|
**Research flag:** yes
|
||||||
@@ -222,7 +239,8 @@ Plans:
|
|||||||
| Phase | Plans Complete | Status | Completed |
|
| Phase | Plans Complete | Status | Completed |
|
||||||
|-------|----------------|--------|-----------|
|
|-------|----------------|--------|-----------|
|
||||||
| 1. Project Infrastructure & Module Wiring | 7/7 | Complete | 2026-04-24 |
|
| 1. Project Infrastructure & Module Wiring | 7/7 | Complete | 2026-04-24 |
|
||||||
| 2. Authentication Foundation | 2/7 | Executing | - |
|
| 2. Authentication Foundation | 7/7 | Complete | 2026-04-28 |
|
||||||
|
| 2.1 App Shell, Navigation & Search Foundation | 0/0 | Not started | - |
|
||||||
| 3. Households, Membership & Server Data Foundation | 0/0 | Not started | - |
|
| 3. Households, Membership & Server Data Foundation | 0/0 | Not started | - |
|
||||||
| 4. Sync Engine Skeleton | 0/0 | Not started | - |
|
| 4. Sync Engine Skeleton | 0/0 | Not started | - |
|
||||||
| 5. Recipe Catalog (Read Path) | 0/0 | Not started | - |
|
| 5. Recipe Catalog (Read Path) | 0/0 | Not started | - |
|
||||||
@@ -230,16 +248,16 @@ Plans:
|
|||||||
| 7. Meal Planner — Customization & Nutrition | 0/0 | Not started | - |
|
| 7. Meal Planner — Customization & Nutrition | 0/0 | Not started | - |
|
||||||
| 8. Pantry | 0/0 | Not started | - |
|
| 8. Pantry | 0/0 | Not started | - |
|
||||||
| 9. Shopping List & Session Log | 0/0 | Not started | - |
|
| 9. Shopping List & Session Log | 0/0 | Not started | - |
|
||||||
| 10. UI Chrome & Haze Liquid-Glass Polish | 0/0 | Not started | - |
|
| 10. UI Chrome & Liquid-Glass Polish | 0/0 | Not started | - |
|
||||||
| 11. Localization & iOS Deployment | 0/0 | Not started | - |
|
| 11. Localization & iOS Deployment | 0/0 | Not started | - |
|
||||||
|
|
||||||
## Coverage Summary
|
## Coverage Summary
|
||||||
|
|
||||||
- **v1 requirements total:** 72 (AUTH=6, HSHD=7, RCPE=8, PLAN=14, PNTR=5, SHOP=6, SYNC=10, UI=9, INFRA=7)
|
- **v1 requirements total:** 73 (AUTH=6, HSHD=7, RCPE=8, PLAN=14, PNTR=5, SHOP=6, SYNC=10, UI=10, INFRA=7)
|
||||||
- **Mapped to phases:** 72
|
- **Mapped to phases:** 73
|
||||||
- **Unmapped:** 0
|
- **Unmapped:** 0
|
||||||
- **Coverage:** 100%
|
- **Coverage:** 100%
|
||||||
|
|
||||||
---
|
---
|
||||||
*Roadmap created: 2026-04-23*
|
*Roadmap created: 2026-04-23*
|
||||||
*Granularity: fine (11 phases) | Mode: yolo*
|
*Granularity: fine (11 phases + inserted Phase 2.1) | Mode: yolo*
|
||||||
|
|||||||
@@ -2,15 +2,15 @@
|
|||||||
gsd_state_version: 1.0
|
gsd_state_version: 1.0
|
||||||
milestone: v1.0
|
milestone: v1.0
|
||||||
milestone_name: milestone
|
milestone_name: milestone
|
||||||
current_plan: 7
|
current_plan: 0
|
||||||
status: executing
|
status: ready_to_plan
|
||||||
last_updated: "2026-04-28T14:57:40.504Z"
|
last_updated: "2026-05-07T00:00:00.000Z"
|
||||||
progress:
|
progress:
|
||||||
total_phases: 11
|
total_phases: 12
|
||||||
completed_phases: 1
|
completed_phases: 2
|
||||||
total_plans: 14
|
total_plans: 14
|
||||||
completed_plans: 13
|
completed_plans: 14
|
||||||
percent: 93
|
percent: 17
|
||||||
---
|
---
|
||||||
|
|
||||||
# Project State: Recipe
|
# Project State: Recipe
|
||||||
@@ -25,23 +25,23 @@ progress:
|
|||||||
|
|
||||||
## Current Position
|
## Current Position
|
||||||
|
|
||||||
Phase: 02 (authentication-foundation) — EXECUTING
|
Phase: 2.1 (app-shell-navigation-search-foundation) — READY TO PLAN
|
||||||
Plan: 7 of 7
|
Plan: not planned yet
|
||||||
**Current focus:** Phase 02 — authentication-foundation
|
**Current focus:** Phase 2.1 — App Shell, Navigation & Search Foundation
|
||||||
**Current plan:** 7
|
**Current plan:** none
|
||||||
**Status:** Ready to execute
|
**Status:** Ready for detailed planning
|
||||||
**Phase progress:** 6 / 7 plans complete
|
**Phase progress:** 0 / 0 plans created
|
||||||
**Progress bar:** `[█████████░] 93%`
|
**Progress bar:** `[██░░░░░░░░] 17%`
|
||||||
|
|
||||||
## Performance Metrics
|
## Performance Metrics
|
||||||
|
|
||||||
| Metric | Value |
|
| Metric | Value |
|
||||||
|--------|-------|
|
|--------|-------|
|
||||||
| Phases planned | 11 |
|
| Phases planned | 12 |
|
||||||
| v1 requirements | 72 |
|
| v1 requirements | 73 |
|
||||||
| Coverage | 100% |
|
| Coverage | 100% |
|
||||||
| Phases complete | 1 |
|
| Phases complete | 2 |
|
||||||
| Plans complete | 13 |
|
| Plans complete | 14 |
|
||||||
| Phase 02 P02 | 13min | 3 tasks | 14 files |
|
| Phase 02 P02 | 13min | 3 tasks | 14 files |
|
||||||
| Phase 02-authentication-foundation P03 | 31m | 2 tasks | 8 files |
|
| Phase 02-authentication-foundation P03 | 31m | 2 tasks | 8 files |
|
||||||
| Phase 02-authentication-foundation P06 | 34m | 3 tasks | 7 files |
|
| Phase 02-authentication-foundation P06 | 34m | 3 tasks | 7 files |
|
||||||
@@ -62,17 +62,19 @@ All locked tech-stack decisions are captured in `.planning/PROJECT.md § Key Dec
|
|||||||
|
|
||||||
## Session Continuity
|
## Session Continuity
|
||||||
|
|
||||||
**Last session:** 2026-04-28T14:57:40.504Z
|
**Last session:** 2026-05-07T00:00:00.000Z
|
||||||
|
|
||||||
**Next action:** `/gsd-execute-phase 2` — Authentication Foundation plan 07.
|
**Next action:** `/gsd-discuss-phase 2.1` — App Shell, Navigation & Search Foundation, followed by `/gsd-plan-phase 2.1`.
|
||||||
|
|
||||||
**Research flags to revisit during future phase planning:**
|
**Research flags to revisit during future phase planning:**
|
||||||
|
|
||||||
- Phase 4 (SyncEngine): concrete cursor format, outbox schema ordering guarantees, retry/backoff policy.
|
- Phase 4 (SyncEngine): concrete cursor format, outbox schema ordering guarantees, retry/backoff policy.
|
||||||
- Phase 10 (UI chrome): current Haze CMP-iOS perf on iPhone 11/12-era hardware; liquid-glass approximation patterns.
|
- Phase 2.1 (App shell): validate current Composables / Compose Unstyled setup and Liquid `1.1.x` integration details before planning.
|
||||||
|
- Phase 10 (UI chrome): real-device Liquid glass performance on iPhone 11/12-era hardware after real data exists.
|
||||||
|
|
||||||
---
|
---
|
||||||
*Last updated: 2026-04-28*
|
*Last updated: 2026-05-07*
|
||||||
|
|
||||||
**Planned Phase:** 1 (Project Infrastructure & Module Wiring) — 7 plans — 2026-04-24T16:07:36.289Z
|
**Planned Phase:** 1 (Project Infrastructure & Module Wiring) — 7 plans — 2026-04-24T16:07:36.289Z
|
||||||
**Planned Phase:** 2 (Authentication Foundation) — 7 plans — 2026-04-28T08:30:48.000Z
|
**Planned Phase:** 2 (Authentication Foundation) — 7 plans — 2026-04-28T08:30:48.000Z
|
||||||
|
**Inserted Phase:** 2.1 (App Shell, Navigation & Search Foundation) — planning pending — 2026-05-07
|
||||||
|
|||||||
19
AGENTS.md
19
AGENTS.md
@@ -15,7 +15,7 @@ This project uses GSD (Get Shit Done). All product scope, tech decisions, requir
|
|||||||
| File | What it is | When to read |
|
| File | What it is | When to read |
|
||||||
|------|-----------|--------------|
|
|------|-----------|--------------|
|
||||||
| `.planning/PROJECT.md` | Product scope, locked tech decisions, constraints, out-of-scope | Every session — source of truth |
|
| `.planning/PROJECT.md` | Product scope, locked tech decisions, constraints, out-of-scope | Every session — source of truth |
|
||||||
| `.planning/REQUIREMENTS.md` | 72 v1 requirements with REQ-IDs grouped by category, plus v2 / out-of-scope | When touching any feature area |
|
| `.planning/REQUIREMENTS.md` | 73 v1 requirements with REQ-IDs grouped by category, plus v2 / out-of-scope | When touching any feature area |
|
||||||
| `.planning/ROADMAP.md` | 11 phases with goals, mapped requirements, success criteria | To know which phase we're in |
|
| `.planning/ROADMAP.md` | 11 phases with goals, mapped requirements, success criteria | To know which phase we're in |
|
||||||
| `.planning/STATE.md` | Current phase + high-level pointer | Fast orientation |
|
| `.planning/STATE.md` | Current phase + high-level pointer | Fast orientation |
|
||||||
| `.planning/config.json` | Workflow settings (YOLO mode, fine granularity, quality models) | Rarely — set once |
|
| `.planning/config.json` | Workflow settings (YOLO mode, fine granularity, quality models) | Rarely — set once |
|
||||||
@@ -26,7 +26,7 @@ This project uses GSD (Get Shit Done). All product scope, tech decisions, requir
|
|||||||
## Tech stack (locked — see PROJECT.md § Key Decisions for full rationale)
|
## Tech stack (locked — see PROJECT.md § Key Decisions for full rationale)
|
||||||
|
|
||||||
**Client (`composeApp/`):**
|
**Client (`composeApp/`):**
|
||||||
- Kotlin Multiplatform + Compose Multiplatform (iOS-primary; Android, Desktop, Wasm secondary)
|
- Kotlin Multiplatform + Compose Multiplatform (iOS-primary; Android secondary; Desktop/Wasm app targets removed from v1)
|
||||||
- Navigation: `org.jetbrains.androidx.navigation:navigation-compose` (JetBrains-official CMP port of Jetpack Navigation)
|
- Navigation: `org.jetbrains.androidx.navigation:navigation-compose` (JetBrains-official CMP port of Jetpack Navigation)
|
||||||
- State: ViewModel + StateFlow, method-per-action; `org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose`
|
- State: ViewModel + StateFlow, method-per-action; `org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose`
|
||||||
- DI: Koin (`koin-core`, `koin-compose`, `koin-compose-viewmodel`)
|
- DI: Koin (`koin-core`, `koin-compose`, `koin-compose-viewmodel`)
|
||||||
@@ -37,7 +37,8 @@ This project uses GSD (Get Shit Done). All product scope, tech decisions, requir
|
|||||||
- Logging: Kermit (Touchlab)
|
- Logging: Kermit (Touchlab)
|
||||||
- Images: Coil 3 (`io.coil-kt.coil3:coil-compose`)
|
- Images: Coil 3 (`io.coil-kt.coil3:coil-compose`)
|
||||||
- Settings/KV: `com.russhwolf:multiplatform-settings`
|
- Settings/KV: `com.russhwolf:multiplatform-settings`
|
||||||
- Glass/blur: Haze (`dev.chrisbanes.haze:haze`)
|
- Components: Composables / Compose Unstyled from `composables.com` for new shared controls; avoid expanding the app around Material 3
|
||||||
|
- Glass effects: Liquid (`io.github.fletchmckee.liquid:liquid`) first for menu/search/button chrome; Haze only as a fallback/simple blur tool if needed
|
||||||
- Mobile OIDC: Lokksmith on Android/iOS (KMP interface; ASWebAuthenticationSession underneath on iOS)
|
- Mobile OIDC: Lokksmith on Android/iOS (KMP interface; ASWebAuthenticationSession underneath on iOS)
|
||||||
|
|
||||||
**Server (`server/`):**
|
**Server (`server/`):**
|
||||||
@@ -57,10 +58,10 @@ This project uses GSD (Get Shit Done). All product scope, tech decisions, requir
|
|||||||
|
|
||||||
```
|
```
|
||||||
recipe/
|
recipe/
|
||||||
├── composeApp/ # KMP: commonMain + androidMain + iosMain + jvmMain (desktop)
|
├── composeApp/ # KMP mobile app: commonMain + androidMain + iosMain
|
||||||
├── iosApp/ # iOS bootstrap (Swift/SwiftUI thin shell)
|
├── iosApp/ # iOS bootstrap (Swift/SwiftUI thin shell)
|
||||||
├── server/ # Ktor + Exposed + Postgres + Flyway
|
├── server/ # Ktor + Exposed + Postgres + Flyway
|
||||||
├── shared/ # commonMain: domain + DTOs, no UI/HTTP/DB
|
├── shared/ # commonMain domain + DTOs; jvm target exists for server dependency
|
||||||
├── build-logic/ # Convention plugins (Kotlin/Compose/test config)
|
├── build-logic/ # Convention plugins (Kotlin/Compose/test config)
|
||||||
├── gradle/libs.versions.toml # Single source of truth for versions
|
├── gradle/libs.versions.toml # Single source of truth for versions
|
||||||
└── .planning/ # GSD planning artifacts (see above)
|
└── .planning/ # GSD planning artifacts (see above)
|
||||||
@@ -72,8 +73,8 @@ dev.ulfrx.recipe/
|
|||||||
├── app/ # App entry, Koin init, theme
|
├── app/ # App entry, Koin init, theme
|
||||||
├── navigation/ # NavHost, routes, nav graph (nested NavHosts per tab)
|
├── navigation/ # NavHost, routes, nav graph (nested NavHosts per tab)
|
||||||
├── ui/
|
├── ui/
|
||||||
│ ├── theme/ # Colors, typography, Haze glass styles
|
│ ├── theme/ # Colors, typography, Liquid glass style tokens
|
||||||
│ ├── components/ # Shared composables
|
│ ├── components/ # Shared Recipe-styled composables built on Compose Unstyled where useful
|
||||||
│ └── screens/{recipes,planner,pantry,shopping}/ # Each with screen + ViewModel
|
│ └── screens/{recipes,planner,pantry,shopping}/ # Each with screen + ViewModel
|
||||||
├── data/{local,remote,repository}/
|
├── data/{local,remote,repository}/
|
||||||
└── domain/ # Client-only logic; shared/ handles cross-cutting
|
└── domain/ # Client-only logic; shared/ handles cross-cutting
|
||||||
@@ -92,14 +93,14 @@ dev.ulfrx.recipe/
|
|||||||
7. **iOS binary flags on day 1:** `kotlin.native.binary.objcDisposeOnMain=false`, `kotlin.native.binary.gc=cms`.
|
7. **iOS binary flags on day 1:** `kotlin.native.binary.objcDisposeOnMain=false`, `kotlin.native.binary.gc=cms`.
|
||||||
8. **`shared/commonMain` stays light.** No Ktor, Compose, or SQLDelight imports.
|
8. **`shared/commonMain` stays light.** No Ktor, Compose, or SQLDelight imports.
|
||||||
9. **Strings externalized from day 1** — Polish-only content, but resources are multi-locale-ready.
|
9. **Strings externalized from day 1** — Polish-only content, but resources are multi-locale-ready.
|
||||||
10. **Haze blur on chrome only** (tab bar, nav bar), never over fast-scrolling content.
|
10. **Liquid/glass effects on chrome only** (menu, tab/nav/search/button chrome), never over fast-scrolling content; Haze is fallback only.
|
||||||
|
|
||||||
## Current phase
|
## Current phase
|
||||||
|
|
||||||
See `.planning/STATE.md`. The roadmap has 11 phases; you must work within the currently active one. Don't jump ahead.
|
See `.planning/STATE.md`. The roadmap has 11 phases; you must work within the currently active one. Don't jump ahead.
|
||||||
|
|
||||||
**Build order (load-bearing — do not reorder):**
|
**Build order (load-bearing — do not reorder):**
|
||||||
Phase 1 Infra → Phase 2 Auth → Phase 3 Households → Phase 4 SyncEngine skeleton → Phase 5 Recipe catalog → Phase 6 Planner core → Phase 7 Planner customization/nutrition → Phase 8 Pantry → Phase 9 Shopping → Phase 10 UI chrome (Haze) → Phase 11 Localization + deployment.
|
Phase 1 Infra → Phase 2 Auth → Phase 2.1 App shell/navigation/search → Phase 3 Households → Phase 4 SyncEngine skeleton → Phase 5 Recipe catalog → Phase 6 Planner core → Phase 7 Planner customization/nutrition → Phase 8 Pantry → Phase 9 Shopping → Phase 10 UI chrome polish → Phase 11 Localization + deployment.
|
||||||
|
|
||||||
## GSD commands you'll use
|
## GSD commands you'll use
|
||||||
|
|
||||||
|
|||||||
19
CLAUDE.md
19
CLAUDE.md
@@ -15,7 +15,7 @@ This project uses GSD (Get Shit Done). All product scope, tech decisions, requir
|
|||||||
| File | What it is | When to read |
|
| File | What it is | When to read |
|
||||||
|------|-----------|--------------|
|
|------|-----------|--------------|
|
||||||
| `.planning/PROJECT.md` | Product scope, locked tech decisions, constraints, out-of-scope | Every session — source of truth |
|
| `.planning/PROJECT.md` | Product scope, locked tech decisions, constraints, out-of-scope | Every session — source of truth |
|
||||||
| `.planning/REQUIREMENTS.md` | 72 v1 requirements with REQ-IDs grouped by category, plus v2 / out-of-scope | When touching any feature area |
|
| `.planning/REQUIREMENTS.md` | 73 v1 requirements with REQ-IDs grouped by category, plus v2 / out-of-scope | When touching any feature area |
|
||||||
| `.planning/ROADMAP.md` | 11 phases with goals, mapped requirements, success criteria | To know which phase we're in |
|
| `.planning/ROADMAP.md` | 11 phases with goals, mapped requirements, success criteria | To know which phase we're in |
|
||||||
| `.planning/STATE.md` | Current phase + high-level pointer | Fast orientation |
|
| `.planning/STATE.md` | Current phase + high-level pointer | Fast orientation |
|
||||||
| `.planning/config.json` | Workflow settings (YOLO mode, fine granularity, quality models) | Rarely — set once |
|
| `.planning/config.json` | Workflow settings (YOLO mode, fine granularity, quality models) | Rarely — set once |
|
||||||
@@ -26,7 +26,7 @@ This project uses GSD (Get Shit Done). All product scope, tech decisions, requir
|
|||||||
## Tech stack (locked — see PROJECT.md § Key Decisions for full rationale)
|
## Tech stack (locked — see PROJECT.md § Key Decisions for full rationale)
|
||||||
|
|
||||||
**Client (`composeApp/`):**
|
**Client (`composeApp/`):**
|
||||||
- Kotlin Multiplatform + Compose Multiplatform (iOS-primary; Android, Desktop, Wasm secondary)
|
- Kotlin Multiplatform + Compose Multiplatform (iOS-primary; Android secondary; Desktop/Wasm app targets removed from v1)
|
||||||
- Navigation: `org.jetbrains.androidx.navigation:navigation-compose` (JetBrains-official CMP port of Jetpack Navigation)
|
- Navigation: `org.jetbrains.androidx.navigation:navigation-compose` (JetBrains-official CMP port of Jetpack Navigation)
|
||||||
- State: ViewModel + StateFlow, method-per-action; `org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose`
|
- State: ViewModel + StateFlow, method-per-action; `org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose`
|
||||||
- DI: Koin (`koin-core`, `koin-compose`, `koin-compose-viewmodel`)
|
- DI: Koin (`koin-core`, `koin-compose`, `koin-compose-viewmodel`)
|
||||||
@@ -37,7 +37,8 @@ This project uses GSD (Get Shit Done). All product scope, tech decisions, requir
|
|||||||
- Logging: Kermit (Touchlab)
|
- Logging: Kermit (Touchlab)
|
||||||
- Images: Coil 3 (`io.coil-kt.coil3:coil-compose`)
|
- Images: Coil 3 (`io.coil-kt.coil3:coil-compose`)
|
||||||
- Settings/KV: `com.russhwolf:multiplatform-settings`
|
- Settings/KV: `com.russhwolf:multiplatform-settings`
|
||||||
- Glass/blur: Haze (`dev.chrisbanes.haze:haze`)
|
- Components: Composables / Compose Unstyled from `composables.com` for new shared controls; avoid expanding the app around Material 3
|
||||||
|
- Glass effects: Liquid (`io.github.fletchmckee.liquid:liquid`) first for menu/search/button chrome; Haze only as a fallback/simple blur tool if needed
|
||||||
- Mobile OIDC: Lokksmith on Android/iOS through the KMP `OidcClient` interface. iOS uses ASWebAuthenticationSession underneath without a Swift auth bridge.
|
- Mobile OIDC: Lokksmith on Android/iOS through the KMP `OidcClient` interface. iOS uses ASWebAuthenticationSession underneath without a Swift auth bridge.
|
||||||
|
|
||||||
**Server (`server/`):**
|
**Server (`server/`):**
|
||||||
@@ -57,10 +58,10 @@ This project uses GSD (Get Shit Done). All product scope, tech decisions, requir
|
|||||||
|
|
||||||
```
|
```
|
||||||
recipe/
|
recipe/
|
||||||
├── composeApp/ # KMP: commonMain + androidMain + iosMain + jvmMain (desktop)
|
├── composeApp/ # KMP mobile app: commonMain + androidMain + iosMain
|
||||||
├── iosApp/ # iOS bootstrap (Swift/SwiftUI thin shell)
|
├── iosApp/ # iOS bootstrap (Swift/SwiftUI thin shell)
|
||||||
├── server/ # Ktor + Exposed + Postgres + Flyway
|
├── server/ # Ktor + Exposed + Postgres + Flyway
|
||||||
├── shared/ # commonMain: domain + DTOs, no UI/HTTP/DB
|
├── shared/ # commonMain domain + DTOs; jvm target exists for server dependency
|
||||||
├── build-logic/ # Convention plugins (Kotlin/Compose/test config)
|
├── build-logic/ # Convention plugins (Kotlin/Compose/test config)
|
||||||
├── gradle/libs.versions.toml # Single source of truth for versions
|
├── gradle/libs.versions.toml # Single source of truth for versions
|
||||||
└── .planning/ # GSD planning artifacts (see above)
|
└── .planning/ # GSD planning artifacts (see above)
|
||||||
@@ -72,8 +73,8 @@ dev.ulfrx.recipe/
|
|||||||
├── app/ # App entry, Koin init, theme
|
├── app/ # App entry, Koin init, theme
|
||||||
├── navigation/ # NavHost, routes, nav graph (nested NavHosts per tab)
|
├── navigation/ # NavHost, routes, nav graph (nested NavHosts per tab)
|
||||||
├── ui/
|
├── ui/
|
||||||
│ ├── theme/ # Colors, typography, Haze glass styles
|
│ ├── theme/ # Colors, typography, Liquid glass style tokens
|
||||||
│ ├── components/ # Shared composables
|
│ ├── components/ # Shared Recipe-styled composables built on Compose Unstyled where useful
|
||||||
│ └── screens/{recipes,planner,pantry,shopping}/ # Each with screen + ViewModel
|
│ └── screens/{recipes,planner,pantry,shopping}/ # Each with screen + ViewModel
|
||||||
├── data/{local,remote,repository}/
|
├── data/{local,remote,repository}/
|
||||||
└── domain/ # Client-only logic; shared/ handles cross-cutting
|
└── domain/ # Client-only logic; shared/ handles cross-cutting
|
||||||
@@ -92,14 +93,14 @@ dev.ulfrx.recipe/
|
|||||||
7. **iOS binary flags on day 1:** `kotlin.native.binary.objcDisposeOnMain=false`, `kotlin.native.binary.gc=cms`.
|
7. **iOS binary flags on day 1:** `kotlin.native.binary.objcDisposeOnMain=false`, `kotlin.native.binary.gc=cms`.
|
||||||
8. **`shared/commonMain` stays light.** No Ktor, Compose, or SQLDelight imports.
|
8. **`shared/commonMain` stays light.** No Ktor, Compose, or SQLDelight imports.
|
||||||
9. **Strings externalized from day 1** — Polish-only content, but resources are multi-locale-ready.
|
9. **Strings externalized from day 1** — Polish-only content, but resources are multi-locale-ready.
|
||||||
10. **Haze blur on chrome only** (tab bar, nav bar), never over fast-scrolling content.
|
10. **Liquid/glass effects on chrome only** (menu, tab/nav/search/button chrome), never over fast-scrolling content; Haze is fallback only.
|
||||||
|
|
||||||
## Current phase
|
## Current phase
|
||||||
|
|
||||||
See `.planning/STATE.md`. The roadmap has 11 phases; you must work within the currently active one. Don't jump ahead.
|
See `.planning/STATE.md`. The roadmap has 11 phases; you must work within the currently active one. Don't jump ahead.
|
||||||
|
|
||||||
**Build order (load-bearing — do not reorder):**
|
**Build order (load-bearing — do not reorder):**
|
||||||
Phase 1 Infra → Phase 2 Auth → Phase 3 Households → Phase 4 SyncEngine skeleton → Phase 5 Recipe catalog → Phase 6 Planner core → Phase 7 Planner customization/nutrition → Phase 8 Pantry → Phase 9 Shopping → Phase 10 UI chrome (Haze) → Phase 11 Localization + deployment.
|
Phase 1 Infra → Phase 2 Auth → Phase 2.1 App shell/navigation/search → Phase 3 Households → Phase 4 SyncEngine skeleton → Phase 5 Recipe catalog → Phase 6 Planner core → Phase 7 Planner customization/nutrition → Phase 8 Pantry → Phase 9 Shopping → Phase 10 UI chrome polish → Phase 11 Localization + deployment.
|
||||||
|
|
||||||
## GSD commands you'll use
|
## GSD commands you'll use
|
||||||
|
|
||||||
|
|||||||
53
README.md
53
README.md
@@ -1,13 +1,11 @@
|
|||||||
This is a Kotlin Multiplatform project targeting Android, iOS, Web, Desktop (JVM), Server.
|
This is a Kotlin Multiplatform project targeting Android, iOS, and a JVM Ktor server.
|
||||||
|
|
||||||
* [/composeApp](./composeApp/src) is for code that will be shared across your Compose Multiplatform applications.
|
* [/composeApp](./composeApp/src) is for code that will be shared across your Compose Multiplatform applications.
|
||||||
It contains several subfolders:
|
It contains several subfolders:
|
||||||
- [commonMain](./composeApp/src/commonMain/kotlin) is for code that’s common for all targets.
|
- [commonMain](./composeApp/src/commonMain/kotlin) is for code that is common to the mobile app targets.
|
||||||
|
- [androidMain](./composeApp/src/androidMain/kotlin) contains Android-specific app code.
|
||||||
|
- [iosMain](./composeApp/src/iosMain/kotlin) contains iOS-specific app code.
|
||||||
- Other folders are for Kotlin code that will be compiled for only the platform indicated in the folder name.
|
- Other folders are for Kotlin code that will be compiled for only the platform indicated in the folder name.
|
||||||
For example, if you want to use Apple’s CoreCrypto for the iOS part of your Kotlin app,
|
|
||||||
the [iosMain](./composeApp/src/iosMain/kotlin) folder would be the right place for such calls.
|
|
||||||
Similarly, if you want to edit the Desktop (JVM) specific part, the [jvmMain](./composeApp/src/jvmMain/kotlin)
|
|
||||||
folder is the appropriate location.
|
|
||||||
|
|
||||||
* [/iosApp](./iosApp/iosApp) contains iOS applications. Even if you’re sharing your UI with Compose Multiplatform,
|
* [/iosApp](./iosApp/iosApp) contains iOS applications. Even if you’re sharing your UI with Compose Multiplatform,
|
||||||
you need this entry point for your iOS app. This is also where you should add SwiftUI code for your project.
|
you need this entry point for your iOS app. This is also where you should add SwiftUI code for your project.
|
||||||
@@ -15,8 +13,9 @@ This is a Kotlin Multiplatform project targeting Android, iOS, Web, Desktop (JVM
|
|||||||
* [/server](./server/src/main/kotlin) is for the Ktor server application.
|
* [/server](./server/src/main/kotlin) is for the Ktor server application.
|
||||||
|
|
||||||
* [/shared](./shared/src) is for the code that will be shared between all targets in the project.
|
* [/shared](./shared/src) is for the code that will be shared between all targets in the project.
|
||||||
The most important subfolder is [commonMain](./shared/src/commonMain/kotlin). If preferred, you
|
The most important subfolder is [commonMain](./shared/src/commonMain/kotlin). `shared` still declares
|
||||||
can add code to the platform-specific folders here too.
|
a JVM target because the Ktor server depends on `projects.shared`; this does not mean the Compose
|
||||||
|
desktop application target is enabled.
|
||||||
|
|
||||||
### Build and Run Android Application
|
### Build and Run Android Application
|
||||||
|
|
||||||
@@ -32,20 +31,6 @@ in your IDE’s toolbar or build it directly from the terminal:
|
|||||||
.\gradlew.bat :composeApp:assembleDebug
|
.\gradlew.bat :composeApp:assembleDebug
|
||||||
```
|
```
|
||||||
|
|
||||||
### Build and Run Desktop (JVM) Application
|
|
||||||
|
|
||||||
To build and run the development version of the desktop app, use the run configuration from the run widget
|
|
||||||
in your IDE’s toolbar or run it directly from the terminal:
|
|
||||||
|
|
||||||
- on macOS/Linux
|
|
||||||
```shell
|
|
||||||
./gradlew :composeApp:run
|
|
||||||
```
|
|
||||||
- on Windows
|
|
||||||
```shell
|
|
||||||
.\gradlew.bat :composeApp:run
|
|
||||||
```
|
|
||||||
|
|
||||||
### Build and Run Server
|
### Build and Run Server
|
||||||
|
|
||||||
To build and run the development version of the server, use the run configuration from the run widget
|
To build and run the development version of the server, use the run configuration from the run widget
|
||||||
@@ -60,21 +45,6 @@ in your IDE’s toolbar or run it directly from the terminal:
|
|||||||
.\gradlew.bat :server:run
|
.\gradlew.bat :server:run
|
||||||
```
|
```
|
||||||
|
|
||||||
### Build and Run Web Application
|
|
||||||
|
|
||||||
To build and run the development version of the web app, use the run configuration from the run widget
|
|
||||||
in your IDE's toolbar or run it directly from the terminal:
|
|
||||||
|
|
||||||
- for the Wasm target (faster, modern browsers):
|
|
||||||
- on macOS/Linux
|
|
||||||
```shell
|
|
||||||
./gradlew :composeApp:wasmJsBrowserDevelopmentRun
|
|
||||||
```
|
|
||||||
- on Windows
|
|
||||||
```shell
|
|
||||||
.\gradlew.bat :composeApp:wasmJsBrowserDevelopmentRun
|
|
||||||
```
|
|
||||||
|
|
||||||
### Build and Run iOS Application
|
### Build and Run iOS Application
|
||||||
|
|
||||||
To build and run the development version of the iOS app, use the run configuration from the run widget
|
To build and run the development version of the iOS app, use the run configuration from the run widget
|
||||||
@@ -126,10 +96,5 @@ docker compose down -v
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
Learn more about [Kotlin Multiplatform](https://www.jetbrains.com/help/kotlin-multiplatform-dev/get-started.html),
|
Learn more about [Kotlin Multiplatform](https://www.jetbrains.com/help/kotlin-multiplatform-dev/get-started.html)
|
||||||
[Compose Multiplatform](https://github.com/JetBrains/compose-multiplatform/#compose-multiplatform),
|
and [Compose Multiplatform](https://github.com/JetBrains/compose-multiplatform/#compose-multiplatform).
|
||||||
[Kotlin/Wasm](https://kotl.in/wasm/)…
|
|
||||||
|
|
||||||
We would appreciate your feedback on Compose/Web and Kotlin/Wasm in the public Slack
|
|
||||||
channel [#compose-web](https://slack-chats.kotlinlang.org/c/compose-web).
|
|
||||||
If you face any issues, please report them on [YouTrack](https://youtrack.jetbrains.com/newIssue?project=CMP).
|
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
dependencyResolutionManagement {
|
dependencyResolutionManagement {
|
||||||
repositories {
|
repositories {
|
||||||
google()
|
google {
|
||||||
|
mavenContent {
|
||||||
|
includeGroupAndSubgroups("androidx")
|
||||||
|
includeGroupAndSubgroups("com.android")
|
||||||
|
includeGroupAndSubgroups("com.google")
|
||||||
|
}
|
||||||
|
}
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
gradlePluginPortal()
|
gradlePluginPortal()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,87 +1,22 @@
|
|||||||
// Establishes the D-05 target matrix + JVM toolchain + warning policy.
|
import org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask
|
||||||
// Android bytecode is JVM 11 (D-08); server + desktop + shared/jvm are JVM 21.
|
|
||||||
//
|
|
||||||
// This plugin is intentionally dependency-free: shared/ must stay light
|
|
||||||
// (no Koin, no Kermit), and composeApp adds those in its own build file.
|
|
||||||
|
|
||||||
import org.gradle.api.artifacts.VersionCatalogsExtension
|
|
||||||
import org.gradle.kotlin.dsl.getByType
|
|
||||||
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
|
|
||||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("org.jetbrains.kotlin.multiplatform")
|
id("org.jetbrains.kotlin.multiplatform")
|
||||||
}
|
}
|
||||||
|
|
||||||
val libs = extensions.getByType<VersionCatalogsExtension>().named("libs")
|
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
jvmToolchain(21)
|
jvmToolchain(21)
|
||||||
|
|
||||||
androidTarget {
|
|
||||||
compilerOptions {
|
|
||||||
jvmTarget.set(JvmTarget.JVM_11)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Framework declaration moved here from composeApp/build.gradle.kts when the
|
|
||||||
// CocoaPods plugin was dropped (2026-04-28). The Xcode run script invokes
|
|
||||||
// :composeApp:embedAndSignAppleFrameworkForXcode, which needs `baseName` to
|
|
||||||
// resolve `import ComposeApp` from Swift. `isStatic = true` keeps the link
|
|
||||||
// shape unchanged from the previous CocoaPods setup. The `:shared` module is
|
|
||||||
// still re-exported so Swift can read shared constants when needed.
|
|
||||||
listOf(iosArm64(), iosSimulatorArm64()).forEach { target ->
|
|
||||||
target.binaries.framework {
|
|
||||||
baseName = "ComposeApp"
|
|
||||||
isStatic = true
|
|
||||||
// `composeApp` only applies the multiplatform plugin; project deps
|
|
||||||
// live in its own build file. Skip the export when this convention
|
|
||||||
// plugin is applied to a module that doesn't depend on `:shared`
|
|
||||||
// (e.g., shared itself).
|
|
||||||
project.findProject(":shared")?.let { sharedProject ->
|
|
||||||
if (project != sharedProject) export(sharedProject)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
jvm {
|
|
||||||
compilerOptions {
|
|
||||||
jvmTarget.set(JvmTarget.JVM_21)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalWasmDsl::class)
|
|
||||||
wasmJs { browser() }
|
|
||||||
|
|
||||||
compilerOptions {
|
compilerOptions {
|
||||||
allWarningsAsErrors.set(true)
|
allWarningsAsErrors.set(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
sourceSets {
|
|
||||||
commonTest.dependencies {
|
|
||||||
implementation(libs.findLibrary("kotlin-test").get())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Relax allWarningsAsErrors for KLIB-merging metadata tasks. KotlinCompileCommon
|
// KMP metadata tasks can surface duplicate KLIB unique_name warnings from upstream
|
||||||
// aggregates dependency KLIBs and surfaces upstream "duplicated unique_name"
|
// Compose/AndroidX artifacts. Keep warnings-as-errors for source compilation, but
|
||||||
// resolver warnings caused by androidx.lifecycle 2.10.0 (Android-only) and
|
// do not fail metadata aggregation on dependency metadata warnings.
|
||||||
// org.jetbrains.androidx.lifecycle 2.10.0 (CMP) co-publishing artifacts with
|
tasks.withType<KotlinCompilationTask<*>>().configureEach {
|
||||||
// matching KLIB unique_names. This is an upstream Compose-Multiplatform 1.10 +
|
|
||||||
// lifecycle 2.10 ecosystem condition (KT-62515-style), not actionable in our
|
|
||||||
// source — so we keep -Werror on real source compilation tasks but disable it
|
|
||||||
// for the metadata-aggregation step where no user code is being compiled.
|
|
||||||
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompileCommon>().configureEach {
|
|
||||||
compilerOptions {
|
compilerOptions {
|
||||||
allWarningsAsErrors.set(false)
|
allWarningsAsErrors.set(!name.endsWith("KotlinMetadata"))
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinNativeCompile>().configureEach {
|
|
||||||
if (name.endsWith("KotlinMetadata")) {
|
|
||||||
compilerOptions {
|
|
||||||
allWarningsAsErrors.set(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,23 +18,3 @@ spotless {
|
|||||||
trimTrailingWhitespace()
|
trimTrailingWhitespace()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// D-11 redundancy guard: if a module applies recipe.quality alongside a Kotlin plugin
|
|
||||||
// (multiplatform or jvm), ensure allWarningsAsErrors still applies even if the module
|
|
||||||
// build didn't already configure it. Guarded with plugins.withId so this plugin is
|
|
||||||
// safely composable even when applied alone (no KotlinCompilationTask type available
|
|
||||||
// on the classpath until a Kotlin plugin is present).
|
|
||||||
plugins.withId("org.jetbrains.kotlin.multiplatform") {
|
|
||||||
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask<*>>().configureEach {
|
|
||||||
compilerOptions {
|
|
||||||
allWarningsAsErrors.set(!name.endsWith("KotlinMetadata"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
plugins.withId("org.jetbrains.kotlin.jvm") {
|
|
||||||
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask<*>>().configureEach {
|
|
||||||
compilerOptions {
|
|
||||||
allWarningsAsErrors.set(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
plugins {
|
plugins {
|
||||||
// this is necessary to avoid the plugins to be loaded multiple times
|
// this is necessary to avoid the plugins to be loaded multiple times in each subproject's classloader
|
||||||
// in each subproject's classloader
|
|
||||||
alias(libs.plugins.androidApplication) apply false
|
alias(libs.plugins.androidApplication) apply false
|
||||||
alias(libs.plugins.androidLibrary) apply false
|
alias(libs.plugins.androidLibrary) apply false
|
||||||
alias(libs.plugins.composeHotReload) apply false
|
|
||||||
alias(libs.plugins.composeMultiplatform) apply false
|
alias(libs.plugins.composeMultiplatform) apply false
|
||||||
alias(libs.plugins.composeCompiler) apply false
|
alias(libs.plugins.composeCompiler) apply false
|
||||||
alias(libs.plugins.kotlinJvm) apply false
|
alias(libs.plugins.kotlinJvm) apply false
|
||||||
|
|||||||
@@ -1,18 +1,15 @@
|
|||||||
|
import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
// AGP must apply before recipe.kotlin.multiplatform — the latter calls androidTarget(),
|
|
||||||
// which requires the Android Gradle Plugin to already be on the project.
|
|
||||||
alias(libs.plugins.androidApplication)
|
alias(libs.plugins.androidApplication)
|
||||||
id("recipe.kotlin.multiplatform")
|
id("recipe.kotlin.multiplatform")
|
||||||
alias(libs.plugins.composeMultiplatform)
|
alias(libs.plugins.composeMultiplatform)
|
||||||
alias(libs.plugins.composeCompiler)
|
alias(libs.plugins.composeCompiler)
|
||||||
alias(libs.plugins.composeHotReload)
|
|
||||||
alias(libs.plugins.kotlinSerialization)
|
alias(libs.plugins.kotlinSerialization)
|
||||||
|
alias(libs.plugins.koin.compiler)
|
||||||
id("recipe.quality")
|
id("recipe.quality")
|
||||||
}
|
}
|
||||||
|
|
||||||
// `group` is referenced by Compose Resources package naming — the
|
|
||||||
// `compose.resources { packageOfResClass }` block below pins the historical package
|
|
||||||
// regardless, but keep `group` set explicitly. Gradle artifact metadata only.
|
|
||||||
group = "dev.ulfrx.recipe"
|
group = "dev.ulfrx.recipe"
|
||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
|
|
||||||
@@ -57,8 +54,21 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
|
androidTarget()
|
||||||
|
iosArm64()
|
||||||
|
iosSimulatorArm64()
|
||||||
|
|
||||||
|
listOf(targets.getByName("iosArm64"), targets.getByName("iosSimulatorArm64")).forEach { target ->
|
||||||
|
(target as KotlinNativeTarget).binaries.framework {
|
||||||
|
baseName = "ComposeApp"
|
||||||
|
isStatic = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
commonMain.dependencies {
|
commonMain.dependencies {
|
||||||
|
implementation(projects.shared)
|
||||||
|
|
||||||
implementation(project.dependencies.platform(libs.koin.bom))
|
implementation(project.dependencies.platform(libs.koin.bom))
|
||||||
implementation(libs.koin.core)
|
implementation(libs.koin.core)
|
||||||
implementation(libs.koin.compose)
|
implementation(libs.koin.compose)
|
||||||
@@ -72,13 +82,7 @@ kotlin {
|
|||||||
implementation(libs.compose.uiToolingPreview)
|
implementation(libs.compose.uiToolingPreview)
|
||||||
implementation(libs.androidx.lifecycle.viewmodelCompose)
|
implementation(libs.androidx.lifecycle.viewmodelCompose)
|
||||||
implementation(libs.androidx.lifecycle.runtimeCompose)
|
implementation(libs.androidx.lifecycle.runtimeCompose)
|
||||||
// `api` so `:shared` types flow through to the exported ObjC
|
|
||||||
// framework headers when the iOS shell needs them.
|
|
||||||
api(projects.shared)
|
|
||||||
|
|
||||||
// Phase 2: Ktor client + serialization + secure settings (D-13, D-16, D-17).
|
|
||||||
// The MPP variant of `ktor-serialization-kotlinx-json` is required here; the
|
|
||||||
// server module keeps the `-jvm` variant via `libs.ktor.serializationKotlinxJson`.
|
|
||||||
implementation(libs.ktor.clientCore)
|
implementation(libs.ktor.clientCore)
|
||||||
implementation(libs.ktor.clientAuth)
|
implementation(libs.ktor.clientAuth)
|
||||||
implementation(libs.ktor.clientContentNegotiation)
|
implementation(libs.ktor.clientContentNegotiation)
|
||||||
@@ -86,42 +90,23 @@ kotlin {
|
|||||||
implementation(libs.ktor.serializationKotlinxJsonMpp)
|
implementation(libs.ktor.serializationKotlinxJsonMpp)
|
||||||
implementation(libs.kotlinx.serializationJson)
|
implementation(libs.kotlinx.serializationJson)
|
||||||
implementation(libs.multiplatform.settings)
|
implementation(libs.multiplatform.settings)
|
||||||
implementation(libs.multiplatform.settings.coroutines)
|
implementation(libs.lokksmith.compose)
|
||||||
}
|
}
|
||||||
commonTest.dependencies {
|
commonTest.dependencies {
|
||||||
// 02-07: kotlinx.coroutines.test.runTest is the multiplatform-safe
|
implementation(libs.kotlin.test)
|
||||||
// alternative to runBlocking (which is JVM/Native-only and breaks the
|
|
||||||
// wasmJs test target). All commonTest coroutine tests use it.
|
|
||||||
implementation(libs.kotlinx.coroutinesTest)
|
implementation(libs.kotlinx.coroutinesTest)
|
||||||
}
|
}
|
||||||
androidMain.dependencies {
|
androidMain.dependencies {
|
||||||
implementation(libs.compose.uiToolingPreview)
|
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
implementation(libs.koin.android)
|
implementation(libs.koin.android)
|
||||||
|
|
||||||
// Phase 2 Android: AndroidX Security Crypto for the SecureAuthStateStore
|
|
||||||
// actual (D-13). EncryptedSharedPreferences is accepted technical debt per
|
|
||||||
// Open Question #1; the Keystore-backed implementation can replace it
|
|
||||||
// without touching AuthSession.
|
|
||||||
implementation(libs.androidx.security.crypto)
|
|
||||||
implementation(libs.lokksmith.core)
|
|
||||||
implementation(libs.ktor.clientOkhttp)
|
implementation(libs.ktor.clientOkhttp)
|
||||||
}
|
}
|
||||||
iosMain.dependencies {
|
iosMain.dependencies {
|
||||||
// Phase 2 iOS: Darwin engine for Ktor. Lokksmith handles the native
|
// Darwin engine for Ktor. Lokksmith handles the native
|
||||||
// ASWebAuthenticationSession integration directly from Kotlin.
|
// ASWebAuthenticationSession integration directly from Kotlin.
|
||||||
implementation(libs.lokksmith.core)
|
|
||||||
implementation(libs.ktor.clientDarwin)
|
implementation(libs.ktor.clientDarwin)
|
||||||
}
|
}
|
||||||
jvmMain.dependencies {
|
|
||||||
implementation(compose.desktop.currentOs)
|
|
||||||
implementation(libs.kotlinx.coroutinesSwing)
|
|
||||||
|
|
||||||
// Phase 2 Desktop: CIO is the JVM Ktor engine for the dev-mode auth stub
|
|
||||||
// (D-02). The full stub lives in Plan 02-04; this just makes the engine
|
|
||||||
// available so `composeApp:run` still compiles in Phase 2.
|
|
||||||
implementation(libs.ktor.clientCio)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,10 +114,16 @@ dependencies {
|
|||||||
debugImplementation(libs.compose.uiTooling)
|
debugImplementation(libs.compose.uiTooling)
|
||||||
}
|
}
|
||||||
|
|
||||||
// `group = "dev.ulfrx.recipe"` shifts the Compose Resources `Res` class package from
|
|
||||||
// `recipe.composeapp.generated.resources` to `dev.ulfrx.recipe.composeapp.generated.resources`,
|
|
||||||
// breaking the Phase 1 `App.kt` import. Lock the historical package so module-naming
|
|
||||||
// changes don't cascade into UI code.
|
|
||||||
compose.resources {
|
compose.resources {
|
||||||
packageOfResClass = "recipe.composeapp.generated.resources"
|
packageOfResClass = "recipe.composeapp.generated.resources"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The Koin compiler plugin's strict graph check (default `compileSafety = true`) only
|
||||||
|
// validates types registered via the no-lambda `single<T>()` plugin DSL. Our DI graph
|
||||||
|
// includes factory-built types (Settings, Lokksmith, HttpClient) that must use the
|
||||||
|
// traditional `single<T> { ... }` form because they need custom construction. Disable
|
||||||
|
// the strict check so those lambda-registered types stop tripping false-positive
|
||||||
|
// "Missing dependency" errors. Runtime resolution is unchanged.
|
||||||
|
koinCompiler {
|
||||||
|
compileSafety = false
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,23 @@
|
|||||||
package dev.ulfrx.recipe.auth
|
package dev.ulfrx.recipe.auth
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import com.russhwolf.settings.Settings
|
||||||
|
import com.russhwolf.settings.SharedPreferencesSettings
|
||||||
|
import dev.lokksmith.Lokksmith
|
||||||
|
import dev.lokksmith.SingletonLokksmithProvider
|
||||||
|
import dev.lokksmith.createLokksmith
|
||||||
import org.koin.android.ext.koin.androidContext
|
import org.koin.android.ext.koin.androidContext
|
||||||
import org.koin.dsl.module
|
import org.koin.dsl.module
|
||||||
|
|
||||||
val androidAuthModule =
|
val androidAuthModule =
|
||||||
module {
|
module {
|
||||||
single { createAndroidLokksmith(androidContext().applicationContext) }
|
single<Lokksmith> {
|
||||||
|
createLokksmith(androidContext().applicationContext).also { lokksmith ->
|
||||||
|
SingletonLokksmithProvider.set(lokksmith)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
single<Settings> {
|
||||||
|
val prefs = androidContext().applicationContext.getSharedPreferences("recipe_auth_state", Context.MODE_PRIVATE)
|
||||||
|
SharedPreferencesSettings(prefs)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
@file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
|
|
||||||
|
|
||||||
package dev.ulfrx.recipe.auth
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import dev.lokksmith.Lokksmith
|
|
||||||
import dev.lokksmith.SingletonLokksmithProvider
|
|
||||||
import dev.lokksmith.android.LokksmithAuthFlowActivity
|
|
||||||
import dev.lokksmith.createLokksmith
|
|
||||||
import org.koin.core.context.GlobalContext
|
|
||||||
|
|
||||||
actual class OidcClient {
|
|
||||||
private val context: Context
|
|
||||||
get() = GlobalContext.get().get<Context>().applicationContext
|
|
||||||
|
|
||||||
private val lokksmith: Lokksmith
|
|
||||||
get() = GlobalContext.get().get()
|
|
||||||
|
|
||||||
actual suspend fun login(): OidcResult {
|
|
||||||
val client = lokksmith.recipeClient()
|
|
||||||
val flow = client.recipeAuthorizationCodeFlow()
|
|
||||||
val initiation = flow.prepare()
|
|
||||||
|
|
||||||
context.startActivity(
|
|
||||||
LokksmithAuthFlowActivity
|
|
||||||
.createCustomTabsIntent(context = context, initiation = initiation)
|
|
||||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
|
|
||||||
)
|
|
||||||
|
|
||||||
return when (val failure = lokksmith.completeAuthFlow(client).toOidcFailureOrNull()) {
|
|
||||||
null -> {
|
|
||||||
runCatching { client.toOidcSuccess() }.getOrElse { error ->
|
|
||||||
OidcResult.AuthError(error.message ?: "OIDC login failed", error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {
|
|
||||||
failure
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
actual suspend fun refresh(authStateJson: String): OidcResult {
|
|
||||||
if (authStateJson != LOKKSMITH_AUTH_STATE_JSON) {
|
|
||||||
return OidcResult.AuthError("Stored Android auth state is not a Lokksmith session")
|
|
||||||
}
|
|
||||||
val client = lokksmith.recipeClient()
|
|
||||||
return runCatching { client.toOidcSuccess() }
|
|
||||||
.getOrElse { OidcResult.AuthError(it.message ?: "OIDC refresh failed", it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
actual suspend fun logout(authStateJson: String) {
|
|
||||||
val client = lokksmith.recipeClient()
|
|
||||||
val flow = client.recipeEndSessionFlow()
|
|
||||||
|
|
||||||
if (flow != null) {
|
|
||||||
runCatching {
|
|
||||||
val initiation = flow.prepare()
|
|
||||||
context.startActivity(
|
|
||||||
LokksmithAuthFlowActivity
|
|
||||||
.createCustomTabsIntent(context = context, initiation = initiation)
|
|
||||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
|
|
||||||
)
|
|
||||||
lokksmith.completeAuthFlow(client)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
client.resetTokens()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun createAndroidLokksmith(context: Context): Lokksmith =
|
|
||||||
createLokksmith(context).also { lokksmith ->
|
|
||||||
SingletonLokksmithProvider.set(lokksmith)
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
@file:Suppress("DEPRECATION", "EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
|
|
||||||
|
|
||||||
package dev.ulfrx.recipe.auth
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.security.crypto.EncryptedSharedPreferences
|
|
||||||
import androidx.security.crypto.MasterKey
|
|
||||||
import org.koin.core.context.GlobalContext
|
|
||||||
|
|
||||||
actual class SecureAuthStateStore {
|
|
||||||
private val preferences by lazy {
|
|
||||||
val appContext = GlobalContext.get().get<Context>().applicationContext
|
|
||||||
val masterKey =
|
|
||||||
MasterKey
|
|
||||||
.Builder(appContext)
|
|
||||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
// AndroidX Security Crypto is deprecated, but AUTH-02 explicitly requires
|
|
||||||
// EncryptedSharedPreferences in v1; this abstraction contains that debt.
|
|
||||||
EncryptedSharedPreferences.create(
|
|
||||||
appContext,
|
|
||||||
FILE_NAME,
|
|
||||||
masterKey,
|
|
||||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
|
||||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
actual fun read(): String? = preferences.getString(KEY_AUTH_STATE_JSON, null)
|
|
||||||
|
|
||||||
actual fun write(authStateJson: String) {
|
|
||||||
preferences
|
|
||||||
.edit()
|
|
||||||
.putString(KEY_AUTH_STATE_JSON, authStateJson)
|
|
||||||
.apply()
|
|
||||||
}
|
|
||||||
|
|
||||||
actual fun clear() {
|
|
||||||
preferences
|
|
||||||
.edit()
|
|
||||||
.remove(KEY_AUTH_STATE_JSON)
|
|
||||||
.apply()
|
|
||||||
}
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
const val FILE_NAME = "recipe_auth_state"
|
|
||||||
const val KEY_AUTH_STATE_JSON = "auth_state_json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -13,20 +13,25 @@ import dev.ulfrx.recipe.ui.screens.auth.PostLoginPlaceholderScreen
|
|||||||
import dev.ulfrx.recipe.ui.screens.auth.PostLoginViewModel
|
import dev.ulfrx.recipe.ui.screens.auth.PostLoginViewModel
|
||||||
import dev.ulfrx.recipe.ui.screens.auth.SplashScreen
|
import dev.ulfrx.recipe.ui.screens.auth.SplashScreen
|
||||||
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
import dev.ulfrx.recipe.ui.theme.RecipeTheme
|
||||||
|
import dev.ulfrx.recipe.user.UserRepository
|
||||||
import org.koin.compose.koinInject
|
import org.koin.compose.koinInject
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Auth gate. Observes [AuthSession.state] and renders Splash / Login / PostLogin per
|
* Two-layer gate: [AuthSession] tells us whether tokens exist; [UserRepository]
|
||||||
* `02-UI-SPEC.md` § Auth Gate Routing Contract. State changes drive recomposition;
|
* tells us who the authenticated principal is in the app's data model. While
|
||||||
* no manual navigation. Phase 3 replaces the `Authenticated` branch with `HouseholdGate`.
|
* tokens are present but the `/me` fetch hasn't returned yet, we hold the
|
||||||
|
* splash so the user never sees an empty post-login screen. Phase 3 replaces
|
||||||
|
* the `Authenticated + user` branch with `HouseholdGate`.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
@Preview
|
@Preview
|
||||||
fun App() {
|
fun App() {
|
||||||
RecipeTheme {
|
RecipeTheme {
|
||||||
val authSession = koinInject<AuthSession>()
|
val authSession = koinInject<AuthSession>()
|
||||||
|
val userRepository = koinInject<UserRepository>()
|
||||||
val authState by authSession.state.collectAsStateWithLifecycle()
|
val authState by authSession.state.collectAsStateWithLifecycle()
|
||||||
|
val currentUser by userRepository.currentUser.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
// Kick off the persisted-session restore once. AuthSession.initialize()
|
// Kick off the persisted-session restore once. AuthSession.initialize()
|
||||||
// refreshes the stored AuthState (or transitions to Unauthenticated on
|
// refreshes the stored AuthState (or transitions to Unauthenticated on
|
||||||
@@ -35,21 +40,22 @@ fun App() {
|
|||||||
authSession.initialize()
|
authSession.initialize()
|
||||||
}
|
}
|
||||||
|
|
||||||
when (val current = authState) {
|
when (authState) {
|
||||||
AuthState.Loading -> {
|
AuthState.Loading -> SplashScreen()
|
||||||
|
|
||||||
|
AuthState.Unauthenticated -> LoginScreen(viewModel = koinViewModel<LoginViewModel>())
|
||||||
|
|
||||||
|
AuthState.Authenticated -> {
|
||||||
|
val user = currentUser
|
||||||
|
if (user == null) {
|
||||||
SplashScreen()
|
SplashScreen()
|
||||||
}
|
} else {
|
||||||
|
|
||||||
AuthState.Unauthenticated -> {
|
|
||||||
LoginScreen(viewModel = koinViewModel<LoginViewModel>())
|
|
||||||
}
|
|
||||||
|
|
||||||
is AuthState.Authenticated -> {
|
|
||||||
PostLoginPlaceholderScreen(
|
PostLoginPlaceholderScreen(
|
||||||
user = current.user,
|
user = user,
|
||||||
viewModel = koinViewModel<PostLoginViewModel>(),
|
viewModel = koinViewModel<PostLoginViewModel>(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package dev.ulfrx.recipe.auth
|
||||||
|
|
||||||
|
import dev.lokksmith.client.request.flow.AuthFlow
|
||||||
|
import dev.lokksmith.client.request.flow.AuthFlowResultProvider
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bridges suspending OIDC orchestration ([OidcClient]) to Lokksmith's
|
||||||
|
* Compose-native launcher.
|
||||||
|
*
|
||||||
|
* Lokksmith owns the platform user-agent step (Custom Tabs / `ASWebAuthenticationSession`)
|
||||||
|
* via `rememberAuthFlowLauncher()`, which exposes its state as Compose `State`. To keep
|
||||||
|
* [AuthSession] / [LoginViewModel] callable as plain `suspend` functions, the screen
|
||||||
|
* wraps the Compose launcher in an [AuthBrowser] (see [ComposeAuthBrowser]) and hands
|
||||||
|
* it to the ViewModel. Result polling happens via `snapshotFlow`.
|
||||||
|
*
|
||||||
|
* Tests can fake this seam without touching Compose or Lokksmith.
|
||||||
|
*/
|
||||||
|
interface AuthBrowser {
|
||||||
|
suspend fun launchAndAwait(initiation: AuthFlow.Initiation): AuthFlowResultProvider.Result
|
||||||
|
}
|
||||||
@@ -3,25 +3,23 @@ package dev.ulfrx.recipe.auth
|
|||||||
import dev.ulfrx.recipe.ui.screens.auth.LoginViewModel
|
import dev.ulfrx.recipe.ui.screens.auth.LoginViewModel
|
||||||
import dev.ulfrx.recipe.ui.screens.auth.PostLoginViewModel
|
import dev.ulfrx.recipe.ui.screens.auth.PostLoginViewModel
|
||||||
import io.ktor.client.HttpClient
|
import io.ktor.client.HttpClient
|
||||||
import org.koin.core.module.dsl.viewModel
|
|
||||||
import org.koin.dsl.module
|
import org.koin.dsl.module
|
||||||
|
import org.koin.plugin.module.dsl.viewModel
|
||||||
|
|
||||||
val authModule =
|
val authModule =
|
||||||
module {
|
module {
|
||||||
single<SecureAuthStateStore> { SecureAuthStateStore() }
|
single<SecureAuthStateStore> { SecureAuthStateStore(get()) }
|
||||||
single<OidcClient> { OidcClient() }
|
single<OidcClient> { OidcClient(get()) }
|
||||||
single<MeClient> { MeClient() }
|
|
||||||
single<AuthSession> {
|
single<AuthSession> {
|
||||||
AuthSession(
|
AuthSession(
|
||||||
oidcClient = get<OidcClient>(),
|
oidcClient = get<OidcClient>(),
|
||||||
store = get<SecureAuthStateStore>(),
|
store = get<SecureAuthStateStore>(),
|
||||||
meClient = get<MeClient>(),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
single<HttpClient> { AuthHttpClient.create(get()) }
|
single<HttpClient> { AuthHttpClient.create(get()) }
|
||||||
|
|
||||||
// Phase 2 auth-gate ViewModels (02-07). Registered here so the same module that
|
// Phase 2 auth-gate ViewModels (02-07). Registered here so the same module that
|
||||||
// owns AuthSession also owns its UI consumers; Koin scopes them per Composable.
|
// owns AuthSession also owns its UI consumers; Koin scopes them per Composable.
|
||||||
viewModel { LoginViewModel(authSession = get()) }
|
viewModel<LoginViewModel>()
|
||||||
viewModel { PostLoginViewModel(authSession = get()) }
|
viewModel<PostLoginViewModel>()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,11 +6,11 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
|
||||||
interface OidcClientGateway {
|
interface OidcClientGateway {
|
||||||
suspend fun login(): OidcResult
|
suspend fun login(browser: AuthBrowser): OidcResult
|
||||||
|
|
||||||
suspend fun refresh(authStateJson: String): OidcResult
|
suspend fun refresh(authStateJson: String): OidcResult
|
||||||
|
|
||||||
suspend fun logout(authStateJson: String)
|
suspend fun logout(authStateJson: String, browser: AuthBrowser)
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AuthStateStore {
|
interface AuthStateStore {
|
||||||
@@ -33,24 +33,27 @@ sealed interface AuthLoginResult {
|
|||||||
) : AuthLoginResult
|
) : AuthLoginResult
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns *just* the authentication state machine: tokens, refresh, logout.
|
||||||
|
* User profile fetch lives in [dev.ulfrx.recipe.user.UserRepository], which
|
||||||
|
* observes [state] and reacts to transitions.
|
||||||
|
*/
|
||||||
class AuthSession(
|
class AuthSession(
|
||||||
private val oidcClient: OidcClientGateway,
|
private val oidcClient: OidcClientGateway,
|
||||||
private val store: AuthStateStore,
|
private val store: AuthStateStore,
|
||||||
private val meClient: MeGateway,
|
|
||||||
) {
|
) {
|
||||||
constructor(
|
constructor(
|
||||||
oidcClient: OidcClient,
|
oidcClient: OidcClient,
|
||||||
store: SecureAuthStateStore,
|
store: SecureAuthStateStore,
|
||||||
meClient: MeClient,
|
|
||||||
) : this(
|
) : this(
|
||||||
oidcClient =
|
oidcClient =
|
||||||
object : OidcClientGateway {
|
object : OidcClientGateway {
|
||||||
override suspend fun login(): OidcResult = oidcClient.login()
|
override suspend fun login(browser: AuthBrowser): OidcResult = oidcClient.login(browser)
|
||||||
|
|
||||||
override suspend fun refresh(authStateJson: String): OidcResult = oidcClient.refresh(authStateJson)
|
override suspend fun refresh(authStateJson: String): OidcResult = oidcClient.refresh(authStateJson)
|
||||||
|
|
||||||
override suspend fun logout(authStateJson: String) {
|
override suspend fun logout(authStateJson: String, browser: AuthBrowser) {
|
||||||
oidcClient.logout(authStateJson)
|
oidcClient.logout(authStateJson, browser)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
store =
|
store =
|
||||||
@@ -65,7 +68,6 @@ class AuthSession(
|
|||||||
store.clear()
|
store.clear()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
meClient = meClient,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
private val _state = MutableStateFlow<AuthState>(AuthState.Loading)
|
private val _state = MutableStateFlow<AuthState>(AuthState.Loading)
|
||||||
@@ -83,7 +85,7 @@ class AuthSession(
|
|||||||
}
|
}
|
||||||
|
|
||||||
when (val refreshResult = oidcClient.refresh(storedJson)) {
|
when (val refreshResult = oidcClient.refresh(storedJson)) {
|
||||||
is OidcResult.Success -> authenticate(refreshResult)
|
is OidcResult.Success -> persistAndAuthenticate(refreshResult)
|
||||||
|
|
||||||
OidcResult.Cancelled,
|
OidcResult.Cancelled,
|
||||||
OidcResult.NetworkError,
|
OidcResult.NetworkError,
|
||||||
@@ -92,10 +94,10 @@ class AuthSession(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun login(): AuthLoginResult =
|
suspend fun login(browser: AuthBrowser): AuthLoginResult =
|
||||||
when (val loginResult = oidcClient.login()) {
|
when (val loginResult = oidcClient.login(browser)) {
|
||||||
is OidcResult.Success -> {
|
is OidcResult.Success -> {
|
||||||
authenticate(loginResult)
|
persistAndAuthenticate(loginResult)
|
||||||
AuthLoginResult.Success
|
AuthLoginResult.Success
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,11 +117,11 @@ class AuthSession(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun logout() {
|
suspend fun logout(browser: AuthBrowser) {
|
||||||
val storedJson = store.read()
|
val storedJson = store.read()
|
||||||
if (!storedJson.isNullOrBlank()) {
|
if (!storedJson.isNullOrBlank()) {
|
||||||
runCatching {
|
runCatching {
|
||||||
oidcClient.logout(storedJson)
|
oidcClient.logout(storedJson, browser)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,10 +155,9 @@ class AuthSession(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun authenticate(result: OidcResult.Success) {
|
private fun persistAndAuthenticate(result: OidcResult.Success) {
|
||||||
persistTokens(result)
|
persistTokens(result)
|
||||||
val user = meClient.getMe(result.accessToken)
|
_state.value = AuthState.Authenticated
|
||||||
_state.value = AuthState.Authenticated(user = user, householdId = null)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun persistTokens(result: OidcResult.Success) {
|
private fun persistTokens(result: OidcResult.Success) {
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
package dev.ulfrx.recipe.auth
|
package dev.ulfrx.recipe.auth
|
||||||
|
|
||||||
import dev.ulfrx.recipe.shared.dto.User
|
/**
|
||||||
|
* Pure authentication state — token-bearing or not. User profile (display name,
|
||||||
typealias HouseholdId = String
|
* email, server-issued id, household membership) lives behind
|
||||||
|
* [dev.ulfrx.recipe.user.UserRepository] and is loaded after auth flips to
|
||||||
|
* [Authenticated]. Screens that need a user observe both flows.
|
||||||
|
*/
|
||||||
sealed class AuthState {
|
sealed class AuthState {
|
||||||
data object Loading : AuthState()
|
data object Loading : AuthState()
|
||||||
|
|
||||||
data object Unauthenticated : AuthState()
|
data object Unauthenticated : AuthState()
|
||||||
|
|
||||||
data class Authenticated(
|
data object Authenticated : AuthState()
|
||||||
val user: User,
|
|
||||||
val householdId: HouseholdId? = null,
|
|
||||||
) : AuthState()
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package dev.ulfrx.recipe.auth
|
||||||
|
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
|
import dev.lokksmith.client.request.flow.AuthFlow
|
||||||
|
import dev.lokksmith.client.request.flow.AuthFlowResultProvider
|
||||||
|
import dev.lokksmith.compose.AuthFlowLauncher
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adapter that converts Lokksmith's Compose-native [AuthFlowLauncher] (state-based)
|
||||||
|
* into a suspending [AuthBrowser] (one-shot await). The screen creates this once via
|
||||||
|
* `remember(launcher)` and passes it to the ViewModel, so call sites stay plain
|
||||||
|
* `suspend`-friendly.
|
||||||
|
*/
|
||||||
|
class ComposeAuthBrowser(
|
||||||
|
private val launcher: AuthFlowLauncher,
|
||||||
|
) : AuthBrowser {
|
||||||
|
override suspend fun launchAndAwait(initiation: AuthFlow.Initiation): AuthFlowResultProvider.Result {
|
||||||
|
launcher.launch(initiation)
|
||||||
|
return snapshotFlow { launcher.result }
|
||||||
|
.first { result ->
|
||||||
|
result is AuthFlowResultProvider.Result.Success ||
|
||||||
|
result is AuthFlowResultProvider.Result.Cancelled ||
|
||||||
|
result is AuthFlowResultProvider.Result.Error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,24 +2,17 @@ package dev.ulfrx.recipe.auth
|
|||||||
|
|
||||||
import dev.lokksmith.Lokksmith
|
import dev.lokksmith.Lokksmith
|
||||||
import dev.lokksmith.client.Client
|
import dev.lokksmith.client.Client
|
||||||
import dev.lokksmith.client.InternalClient
|
import dev.lokksmith.client.request.flow.AuthFlow
|
||||||
import dev.lokksmith.client.request.flow.AuthFlowResultProvider
|
import dev.lokksmith.client.request.flow.AuthFlowResultProvider
|
||||||
import dev.lokksmith.client.request.flow.AuthFlowStateResponseHandler
|
|
||||||
import dev.lokksmith.client.request.flow.authorizationCode.AuthorizationCodeFlow
|
import dev.lokksmith.client.request.flow.authorizationCode.AuthorizationCodeFlow
|
||||||
import dev.lokksmith.client.request.flow.endSession.EndSessionFlow
|
import dev.lokksmith.client.request.flow.endSession.EndSessionFlow
|
||||||
import dev.lokksmith.client.request.parameter.Scope
|
import dev.lokksmith.client.request.parameter.Scope
|
||||||
import dev.lokksmith.discoveryUrl
|
import dev.lokksmith.discoveryUrl
|
||||||
import dev.lokksmith.id
|
import dev.lokksmith.id
|
||||||
import dev.ulfrx.recipe.shared.Constants
|
import dev.ulfrx.recipe.shared.Constants
|
||||||
import kotlinx.coroutines.async
|
|
||||||
import kotlinx.coroutines.coroutineScope
|
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.selects.select
|
|
||||||
|
|
||||||
internal const val LOKKSMITH_CLIENT_KEY = "recipe-app"
|
internal const val LOKKSMITH_CLIENT_KEY = "recipe-app"
|
||||||
internal const val LOKKSMITH_AUTH_STATE_JSON = "lokksmith:$LOKKSMITH_CLIENT_KEY"
|
internal const val LOKKSMITH_AUTH_STATE_MARKER = "lokksmith:$LOKKSMITH_CLIENT_KEY"
|
||||||
|
|
||||||
internal suspend fun Lokksmith.recipeClient(): Client =
|
internal suspend fun Lokksmith.recipeClient(): Client =
|
||||||
getOrCreate(LOKKSMITH_CLIENT_KEY) {
|
getOrCreate(LOKKSMITH_CLIENT_KEY) {
|
||||||
@@ -27,7 +20,7 @@ internal suspend fun Lokksmith.recipeClient(): Client =
|
|||||||
discoveryUrl = Constants.OIDC_ISSUER + ".well-known/openid-configuration"
|
discoveryUrl = Constants.OIDC_ISSUER + ".well-known/openid-configuration"
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun Client.recipeAuthorizationCodeFlow(): dev.lokksmith.client.request.flow.AuthFlow =
|
internal fun Client.recipeAuthorizationCodeFlow(): AuthFlow =
|
||||||
authorizationCodeFlow(
|
authorizationCodeFlow(
|
||||||
AuthorizationCodeFlow.Request(
|
AuthorizationCodeFlow.Request(
|
||||||
redirectUri = Constants.OIDC_REDIRECT_URI,
|
redirectUri = Constants.OIDC_REDIRECT_URI,
|
||||||
@@ -35,49 +28,15 @@ internal fun Client.recipeAuthorizationCodeFlow(): dev.lokksmith.client.request.
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
internal fun Client.recipeEndSessionFlow(): dev.lokksmith.client.request.flow.AuthFlow? =
|
internal fun Client.recipeEndSessionFlow(): AuthFlow? =
|
||||||
endSessionFlow(EndSessionFlow.Request(redirectUri = Constants.OIDC_REDIRECT_URI))
|
endSessionFlow(EndSessionFlow.Request(redirectUri = Constants.OIDC_REDIRECT_URI))
|
||||||
|
|
||||||
internal suspend fun Client.awaitTerminalAuthFlowResult(): AuthFlowResultProvider.Result =
|
|
||||||
AuthFlowResultProvider
|
|
||||||
.forClient(this)
|
|
||||||
.first { result ->
|
|
||||||
result is AuthFlowResultProvider.Result.Success ||
|
|
||||||
result is AuthFlowResultProvider.Result.Cancelled ||
|
|
||||||
result is AuthFlowResultProvider.Result.Error
|
|
||||||
}
|
|
||||||
|
|
||||||
internal suspend fun Lokksmith.completeAuthFlow(client: Client): AuthFlowResultProvider.Result =
|
|
||||||
coroutineScope {
|
|
||||||
val terminal = async { client.awaitTerminalAuthFlowResult() }
|
|
||||||
val responseUri =
|
|
||||||
async {
|
|
||||||
(client as InternalClient)
|
|
||||||
.snapshots
|
|
||||||
.map { snapshot -> snapshot.ephemeralFlowState?.responseUri }
|
|
||||||
.distinctUntilChanged()
|
|
||||||
.first { responseUri -> responseUri != null }
|
|
||||||
}
|
|
||||||
|
|
||||||
select<AuthFlowResultProvider.Result> {
|
|
||||||
terminal.onAwait { result ->
|
|
||||||
responseUri.cancel()
|
|
||||||
result
|
|
||||||
}
|
|
||||||
responseUri.onAwait { uri ->
|
|
||||||
terminal.cancel()
|
|
||||||
AuthFlowStateResponseHandler(this@completeAuthFlow).onResponse(checkNotNull(uri))
|
|
||||||
client.awaitTerminalAuthFlowResult()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal suspend fun Client.toOidcSuccess(): OidcResult.Success {
|
internal suspend fun Client.toOidcSuccess(): OidcResult.Success {
|
||||||
var freshTokens: Client.Tokens? = null
|
var freshTokens: Client.Tokens? = null
|
||||||
runWithTokens { tokens -> freshTokens = tokens }
|
runWithTokens { tokens -> freshTokens = tokens }
|
||||||
val tokens = checkNotNull(freshTokens) { "Lokksmith returned no tokens" }
|
val tokens = checkNotNull(freshTokens) { "Lokksmith returned no tokens" }
|
||||||
return OidcResult.Success(
|
return OidcResult.Success(
|
||||||
authStateJson = LOKKSMITH_AUTH_STATE_JSON,
|
authStateJson = LOKKSMITH_AUTH_STATE_MARKER,
|
||||||
accessToken = tokens.accessToken.token,
|
accessToken = tokens.accessToken.token,
|
||||||
idToken = tokens.idToken.raw,
|
idToken = tokens.idToken.raw,
|
||||||
expiresAtEpochMillis = tokens.accessToken.expiresAt?.let { it * 1_000L } ?: 0L,
|
expiresAtEpochMillis = tokens.accessToken.expiresAt?.let { it * 1_000L } ?: 0L,
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
package dev.ulfrx.recipe.auth
|
|
||||||
|
|
||||||
import dev.ulfrx.recipe.shared.Constants
|
|
||||||
import dev.ulfrx.recipe.shared.dto.User
|
|
||||||
import io.ktor.client.HttpClient
|
|
||||||
import io.ktor.client.call.body
|
|
||||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
|
||||||
import io.ktor.client.request.get
|
|
||||||
import io.ktor.client.request.header
|
|
||||||
import io.ktor.http.HttpHeaders
|
|
||||||
import io.ktor.serialization.kotlinx.json.json
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
|
|
||||||
interface MeGateway {
|
|
||||||
suspend fun getMe(accessToken: String? = null): User
|
|
||||||
}
|
|
||||||
|
|
||||||
class MeClient(
|
|
||||||
private val httpClient: HttpClient =
|
|
||||||
HttpClient {
|
|
||||||
install(ContentNegotiation) {
|
|
||||||
json(authJson)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
) : MeGateway {
|
|
||||||
override suspend fun getMe(accessToken: String?): User =
|
|
||||||
httpClient
|
|
||||||
.get("${Constants.API_BASE_URL}api/v1/me") {
|
|
||||||
if (!accessToken.isNullOrBlank()) {
|
|
||||||
header(HttpHeaders.Authorization, "Bearer ".plus(accessToken))
|
|
||||||
}
|
|
||||||
}.body<dev.ulfrx.recipe.shared.dto.MeResponse>()
|
|
||||||
.toUser()
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
val authJson =
|
|
||||||
Json {
|
|
||||||
ignoreUnknownKeys = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +1,52 @@
|
|||||||
@file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
|
|
||||||
|
|
||||||
package dev.ulfrx.recipe.auth
|
package dev.ulfrx.recipe.auth
|
||||||
|
|
||||||
|
import dev.lokksmith.Lokksmith
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Common seam for Authentik OIDC.
|
* Common Authentik OIDC client built on Lokksmith.
|
||||||
*
|
*
|
||||||
* Native Android/iOS actuals use Lokksmith for browser-based Authorization Code
|
* Lokksmith owns PKCE, state, nonce, token storage, refresh, and end-session
|
||||||
* + PKCE. Login requests must be public PKCE-compatible OIDC requests with
|
* (D-06, D-16, D-19, D-20). This class only orchestrates: build the flow request
|
||||||
* exactly these scopes: `openid profile email offline_access` (D-06).
|
* and hand its [dev.lokksmith.client.request.flow.AuthFlow.Initiation] to the
|
||||||
* Lokksmith owns state and nonce verification.
|
* caller-supplied [AuthBrowser] (Lokksmith's `rememberAuthFlowLauncher` on
|
||||||
|
* mobile; a fake in tests), then map the terminal result.
|
||||||
*
|
*
|
||||||
* Refresh must go through Lokksmith fresh-token handling, then return an opaque
|
* Logout still clears local state if remote end-session fails so users are never
|
||||||
* auth-state marker for persistence (D-16). Logout must use RP-initiated
|
* trapped in a stale session.
|
||||||
* end-session before local state is cleared; callers still clear local state if
|
|
||||||
* remote logout fails so users are never trapped in a stale session (D-19, D-20).
|
|
||||||
*/
|
*/
|
||||||
expect class OidcClient() {
|
class OidcClient(
|
||||||
suspend fun login(): OidcResult
|
private val lokksmith: Lokksmith,
|
||||||
|
) {
|
||||||
|
suspend fun login(browser: AuthBrowser): OidcResult {
|
||||||
|
val client = lokksmith.recipeClient()
|
||||||
|
val flow = client.recipeAuthorizationCodeFlow()
|
||||||
|
|
||||||
suspend fun refresh(authStateJson: String): OidcResult
|
return when (val failure = browser.launchAndAwait(flow.prepare()).toOidcFailureOrNull()) {
|
||||||
|
null ->
|
||||||
|
runCatching { client.toOidcSuccess() }
|
||||||
|
.getOrElse { OidcResult.AuthError(it.message ?: "OIDC login failed", it) }
|
||||||
|
|
||||||
suspend fun logout(authStateJson: String)
|
else -> failure
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun refresh(authStateJson: String): OidcResult {
|
||||||
|
if (authStateJson != LOKKSMITH_AUTH_STATE_MARKER) {
|
||||||
|
return OidcResult.AuthError("Stored auth state is not a Lokksmith session")
|
||||||
|
}
|
||||||
|
val client = lokksmith.recipeClient()
|
||||||
|
return runCatching { client.toOidcSuccess() }
|
||||||
|
.getOrElse { OidcResult.AuthError(it.message ?: "OIDC refresh failed", it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun logout(authStateJson: String, browser: AuthBrowser) {
|
||||||
|
val client = lokksmith.recipeClient()
|
||||||
|
val flow = client.recipeEndSessionFlow()
|
||||||
|
|
||||||
|
if (flow != null) {
|
||||||
|
runCatching { browser.launchAndAwait(flow.prepare()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
client.resetTokens()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,34 @@
|
|||||||
@file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
|
|
||||||
|
|
||||||
package dev.ulfrx.recipe.auth
|
package dev.ulfrx.recipe.auth
|
||||||
|
|
||||||
|
import com.russhwolf.settings.Settings
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Persists the opaque platform auth state marker for the current app install.
|
* Persists the opaque auth-state marker that signals "this install has logged in".
|
||||||
*
|
*
|
||||||
* Mobile actuals must use explicit secure platform storage for token material
|
* The actual OIDC tokens (access, refresh, id) live in Lokksmith's own platform
|
||||||
* (D-13): iOS Keychain and Android encrypted/Keystore-backed storage. Do not use
|
* storage (Keychain on iOS, encrypted store on Android). This class only persists
|
||||||
* no-arg or default insecure settings implementations for auth state. The stored
|
* the literal marker constant ([LOKKSMITH_AUTH_STATE_MARKER]) so [AuthSession]
|
||||||
* value is global to the install and must be deleted on logout (D-15).
|
* can decide whether to attempt a silent refresh on cold start. Because the value
|
||||||
|
* is non-secret, plain key/value storage is sufficient.
|
||||||
|
*
|
||||||
|
* Platform [Settings] are wired in the platform Koin module:
|
||||||
|
* - Android: [com.russhwolf.settings.SharedPreferencesSettings]
|
||||||
|
* - iOS: [com.russhwolf.settings.KeychainSettings]
|
||||||
*/
|
*/
|
||||||
expect class SecureAuthStateStore() {
|
class SecureAuthStateStore(
|
||||||
fun read(): String?
|
private val settings: Settings,
|
||||||
|
) {
|
||||||
|
fun read(): String? = settings.getStringOrNull(KEY)
|
||||||
|
|
||||||
fun write(authStateJson: String)
|
fun write(authStateJson: String) {
|
||||||
|
settings.putString(KEY, authStateJson)
|
||||||
|
}
|
||||||
|
|
||||||
fun clear()
|
fun clear() {
|
||||||
|
settings.remove(KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val KEY = "auth_state_marker"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
package dev.ulfrx.recipe.di
|
package dev.ulfrx.recipe.di
|
||||||
|
|
||||||
import dev.ulfrx.recipe.auth.authModule
|
import dev.ulfrx.recipe.auth.authModule
|
||||||
|
import dev.ulfrx.recipe.user.userModule
|
||||||
import org.koin.dsl.module
|
import org.koin.dsl.module
|
||||||
|
|
||||||
// Phase 2 adds authModule; Phase 4 adds syncModule; Phase 5 adds catalogModule; etc.
|
// Phase 2 adds authModule + userModule; Phase 4 adds syncModule; Phase 5 adds catalogModule; etc.
|
||||||
val appModule =
|
val appModule =
|
||||||
module {
|
module {
|
||||||
includes(authModule)
|
includes(authModule, userModule)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,10 @@ import androidx.compose.material3.Surface
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
|
import dev.lokksmith.compose.rememberAuthFlowLauncher
|
||||||
|
import dev.ulfrx.recipe.auth.ComposeAuthBrowser
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -35,6 +38,8 @@ import recipe.composeapp.generated.resources.auth_sign_in_button
|
|||||||
@Composable
|
@Composable
|
||||||
fun LoginScreen(viewModel: LoginViewModel) {
|
fun LoginScreen(viewModel: LoginViewModel) {
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
val launcher = rememberAuthFlowLauncher()
|
||||||
|
val browser = remember(launcher) { ComposeAuthBrowser(launcher) }
|
||||||
|
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
@@ -55,7 +60,7 @@ fun LoginScreen(viewModel: LoginViewModel) {
|
|||||||
)
|
)
|
||||||
Spacer(Modifier.height(24.dp))
|
Spacer(Modifier.height(24.dp))
|
||||||
Button(
|
Button(
|
||||||
onClick = { viewModel.onSignInClick() },
|
onClick = { viewModel.onSignInClick(browser) },
|
||||||
enabled = !state.isLoading,
|
enabled = !state.isLoading,
|
||||||
) {
|
) {
|
||||||
if (state.isLoading) {
|
if (state.isLoading) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package dev.ulfrx.recipe.ui.screens.auth
|
|||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dev.ulfrx.recipe.auth.AuthBrowser
|
||||||
import dev.ulfrx.recipe.auth.AuthLoginResult
|
import dev.ulfrx.recipe.auth.AuthLoginResult
|
||||||
import dev.ulfrx.recipe.auth.AuthSession
|
import dev.ulfrx.recipe.auth.AuthSession
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@@ -26,9 +27,9 @@ data class LoginScreenState(
|
|||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wraps [AuthSession] to drive the LoginScreen. Method-per-action: [onSignInClick] is the
|
* Wraps [AuthSession] to drive the LoginScreen. The screen owns the
|
||||||
* single entry point. Cancellation/network/unknown failures map to user-facing string
|
* Lokksmith [AuthBrowser] (via `rememberAuthFlowLauncher` + [dev.ulfrx.recipe.auth.ComposeAuthBrowser])
|
||||||
* resources per `02-UI-SPEC.md` § Copywriting Contract.
|
* and hands it in on click — the ViewModel never touches Compose or Lokksmith directly.
|
||||||
*
|
*
|
||||||
* Returns the launched [Job] from [onSignInClick] so tests can deterministically await
|
* Returns the launched [Job] from [onSignInClick] so tests can deterministically await
|
||||||
* completion without dragging a TestDispatcher into commonTest.
|
* completion without dragging a TestDispatcher into commonTest.
|
||||||
@@ -39,12 +40,12 @@ class LoginViewModel(
|
|||||||
private val _state = MutableStateFlow(LoginScreenState())
|
private val _state = MutableStateFlow(LoginScreenState())
|
||||||
val state: StateFlow<LoginScreenState> = _state.asStateFlow()
|
val state: StateFlow<LoginScreenState> = _state.asStateFlow()
|
||||||
|
|
||||||
fun onSignInClick(): Job {
|
fun onSignInClick(browser: AuthBrowser): Job {
|
||||||
// Clear any previous inline error and enter the loading state before suspending —
|
// Clear any previous inline error and enter the loading state before suspending —
|
||||||
// contract from UI-SPEC: tapping the button again clears stale error text.
|
// contract from UI-SPEC: tapping the button again clears stale error text.
|
||||||
_state.value = LoginScreenState(isLoading = true, errorKey = null)
|
_state.value = LoginScreenState(isLoading = true, errorKey = null)
|
||||||
return viewModelScope.launch {
|
return viewModelScope.launch {
|
||||||
val result = authSession.login()
|
val result = authSession.login(browser)
|
||||||
_state.value =
|
_state.value =
|
||||||
LoginScreenState(
|
LoginScreenState(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
|
|||||||
@@ -12,7 +12,10 @@ import androidx.compose.material3.OutlinedButton
|
|||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
|
import dev.lokksmith.compose.rememberAuthFlowLauncher
|
||||||
|
import dev.ulfrx.recipe.auth.ComposeAuthBrowser
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -30,6 +33,8 @@ fun PostLoginPlaceholderScreen(
|
|||||||
user: User,
|
user: User,
|
||||||
viewModel: PostLoginViewModel,
|
viewModel: PostLoginViewModel,
|
||||||
) {
|
) {
|
||||||
|
val launcher = rememberAuthFlowLauncher()
|
||||||
|
val browser = remember(launcher) { ComposeAuthBrowser(launcher) }
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
color = MaterialTheme.colorScheme.surface,
|
color = MaterialTheme.colorScheme.surface,
|
||||||
@@ -49,7 +54,7 @@ fun PostLoginPlaceholderScreen(
|
|||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(24.dp))
|
Spacer(Modifier.height(24.dp))
|
||||||
OutlinedButton(onClick = { viewModel.onSignOutClick() }) {
|
OutlinedButton(onClick = { viewModel.onSignOutClick(browser) }) {
|
||||||
Text(text = stringResource(Res.string.auth_sign_out_button))
|
Text(text = stringResource(Res.string.auth_sign_out_button))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,20 +2,22 @@ package dev.ulfrx.recipe.ui.screens.auth
|
|||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dev.ulfrx.recipe.auth.AuthBrowser
|
||||||
import dev.ulfrx.recipe.auth.AuthSession
|
import dev.ulfrx.recipe.auth.AuthSession
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Trivial in Phase 2 — exists so Phase 3's `HouseholdGate` follows the same VM pattern.
|
* Trivial in Phase 2 — exists so Phase 3's `HouseholdGate` follows the same VM pattern.
|
||||||
* Logout is silent (CONTEXT D-19): no confirmation modal; tap immediately initiates
|
* Logout is silent (CONTEXT D-19): no confirmation modal; tap immediately initiates
|
||||||
* RP-initiated end-session via [AuthSession.logout].
|
* RP-initiated end-session via [AuthSession.logout]. The screen supplies the
|
||||||
|
* Lokksmith-backed [AuthBrowser].
|
||||||
*/
|
*/
|
||||||
class PostLoginViewModel(
|
class PostLoginViewModel(
|
||||||
private val authSession: AuthSession,
|
private val authSession: AuthSession,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
fun onSignOutClick() {
|
fun onSignOutClick(browser: AuthBrowser) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
authSession.logout()
|
authSession.logout(browser)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import androidx.compose.material3.Text
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
import recipe.composeapp.generated.resources.Res
|
import recipe.composeapp.generated.resources.Res
|
||||||
@@ -25,6 +26,7 @@ import recipe.composeapp.generated.resources.auth_app_name
|
|||||||
* color flash.
|
* color flash.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
|
@Preview
|
||||||
fun SplashScreen() {
|
fun SplashScreen() {
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package dev.ulfrx.recipe.user
|
||||||
|
|
||||||
|
import dev.ulfrx.recipe.shared.Constants
|
||||||
|
import dev.ulfrx.recipe.shared.dto.MeResponse
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
|
import io.ktor.client.call.body
|
||||||
|
import io.ktor.client.request.get
|
||||||
|
import org.koin.dsl.module
|
||||||
|
|
||||||
|
val userModule =
|
||||||
|
module {
|
||||||
|
single<UserRepository> {
|
||||||
|
UserRepository(
|
||||||
|
authSession = get(),
|
||||||
|
fetchUser = {
|
||||||
|
get<HttpClient>()
|
||||||
|
.get("${Constants.API_BASE_URL}api/v1/me")
|
||||||
|
.body<MeResponse>()
|
||||||
|
.toUser()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package dev.ulfrx.recipe.user
|
||||||
|
|
||||||
|
import dev.ulfrx.recipe.auth.AuthSession
|
||||||
|
import dev.ulfrx.recipe.auth.AuthState
|
||||||
|
import dev.ulfrx.recipe.shared.dto.User
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns the current authenticated user as observable state.
|
||||||
|
*
|
||||||
|
* Subscribes to [AuthSession.state] for life: when auth flips to
|
||||||
|
* [AuthState.Authenticated] it fetches `/me` via [fetchUser] once and emits
|
||||||
|
* the result through [currentUser]. On [AuthState.Unauthenticated] it clears
|
||||||
|
* [currentUser] so screens drop back to the login gate cleanly.
|
||||||
|
*
|
||||||
|
* The fetch is a `suspend () -> User` lambda rather than a wrapped gateway:
|
||||||
|
* one consumer, one impl, no interface needed. When Phase 4 introduces a
|
||||||
|
* SyncEngine with local + remote sources, extract the seam then.
|
||||||
|
*/
|
||||||
|
class UserRepository(
|
||||||
|
private val authSession: AuthSession,
|
||||||
|
private val fetchUser: suspend () -> User,
|
||||||
|
scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
|
||||||
|
) {
|
||||||
|
private val _currentUser = MutableStateFlow<User?>(null)
|
||||||
|
val currentUser: StateFlow<User?> = _currentUser.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
scope.launch {
|
||||||
|
authSession.state
|
||||||
|
.collect { state ->
|
||||||
|
when (state) {
|
||||||
|
is AuthState.Authenticated -> {
|
||||||
|
if (_currentUser.value == null) {
|
||||||
|
runCatching { fetchUser() }
|
||||||
|
.onSuccess { user -> _currentUser.value = user }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AuthState.Unauthenticated -> _currentUser.value = null
|
||||||
|
AuthState.Loading -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun refresh() {
|
||||||
|
runCatching { fetchUser() }
|
||||||
|
.onSuccess { user -> _currentUser.value = user }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package dev.ulfrx.recipe.auth
|
package dev.ulfrx.recipe.auth
|
||||||
|
|
||||||
import dev.ulfrx.recipe.shared.dto.User
|
import dev.lokksmith.client.request.flow.AuthFlow
|
||||||
|
import dev.lokksmith.client.request.flow.AuthFlowResultProvider
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
@@ -22,7 +23,7 @@ class AuthSessionTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun successfulLoginWritesAuthStateJsonFetchesMeAndEmitsAuthenticatedWithNullHouseholdId() {
|
fun successfulLoginPersistsAuthStateAndEmitsAuthenticated() {
|
||||||
runTest {
|
runTest {
|
||||||
val store = FakeAuthStateStore()
|
val store = FakeAuthStateStore()
|
||||||
val oidcClient =
|
val oidcClient =
|
||||||
@@ -35,22 +36,18 @@ class AuthSessionTest {
|
|||||||
expiresAtEpochMillis = 123_456L,
|
expiresAtEpochMillis = 123_456L,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
val meClient = FakeMeClient(user = USER)
|
val session = newSession(store = store, oidcClient = oidcClient)
|
||||||
val session = newSession(store = store, oidcClient = oidcClient, meClient = meClient)
|
|
||||||
|
|
||||||
val result = session.login()
|
val result = session.login(NoopBrowser)
|
||||||
|
|
||||||
assertEquals(AuthLoginResult.Success, result)
|
assertEquals(AuthLoginResult.Success, result)
|
||||||
assertEquals(AUTH_STATE_JSON, store.value)
|
assertEquals(AUTH_STATE_JSON, store.value)
|
||||||
assertEquals(listOf<String?>(ACCESS_TOKEN), meClient.accessTokens)
|
assertIs<AuthState.Authenticated>(session.state.value)
|
||||||
val authenticated = assertIs<AuthState.Authenticated>(session.state.value)
|
|
||||||
assertEquals(USER, authenticated.user)
|
|
||||||
assertNull(authenticated.householdId)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun existingStoreRefreshesBeforeMeAndEmitsAuthenticatedWithoutLogin() {
|
fun existingStoreRefreshesAndEmitsAuthenticatedWithoutLogin() {
|
||||||
runTest {
|
runTest {
|
||||||
val store = FakeAuthStateStore(value = "stored-auth-state-json")
|
val store = FakeAuthStateStore(value = "stored-auth-state-json")
|
||||||
val oidcClient =
|
val oidcClient =
|
||||||
@@ -63,18 +60,14 @@ class AuthSessionTest {
|
|||||||
expiresAtEpochMillis = 789_000L,
|
expiresAtEpochMillis = 789_000L,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
val meClient = FakeMeClient(user = USER)
|
val session = newSession(store = store, oidcClient = oidcClient)
|
||||||
val session = newSession(store = store, oidcClient = oidcClient, meClient = meClient)
|
|
||||||
|
|
||||||
session.initialize()
|
session.initialize()
|
||||||
|
|
||||||
assertEquals(emptyList(), oidcClient.loginCalls)
|
assertEquals(emptyList(), oidcClient.loginCalls)
|
||||||
assertEquals(listOf("stored-auth-state-json"), oidcClient.refreshCalls)
|
assertEquals(listOf("stored-auth-state-json"), oidcClient.refreshCalls)
|
||||||
assertEquals(REFRESHED_AUTH_STATE_JSON, store.value)
|
assertEquals(REFRESHED_AUTH_STATE_JSON, store.value)
|
||||||
assertEquals(listOf<String?>(REFRESHED_ACCESS_TOKEN), meClient.accessTokens)
|
assertIs<AuthState.Authenticated>(session.state.value)
|
||||||
val authenticated = assertIs<AuthState.Authenticated>(session.state.value)
|
|
||||||
assertEquals(USER, authenticated.user)
|
|
||||||
assertNull(authenticated.householdId)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,7 +112,7 @@ class AuthSessionTest {
|
|||||||
val oidcClient = FakeOidcClient()
|
val oidcClient = FakeOidcClient()
|
||||||
val session = newSession(store = store, oidcClient = oidcClient)
|
val session = newSession(store = store, oidcClient = oidcClient)
|
||||||
|
|
||||||
session.logout()
|
session.logout(NoopBrowser)
|
||||||
|
|
||||||
assertEquals(listOf(AUTH_STATE_JSON), oidcClient.logoutCalls)
|
assertEquals(listOf(AUTH_STATE_JSON), oidcClient.logoutCalls)
|
||||||
assertNull(store.value)
|
assertNull(store.value)
|
||||||
@@ -134,7 +127,7 @@ class AuthSessionTest {
|
|||||||
val oidcClient = FakeOidcClient(logoutThrows = true)
|
val oidcClient = FakeOidcClient(logoutThrows = true)
|
||||||
val session = newSession(store = store, oidcClient = oidcClient)
|
val session = newSession(store = store, oidcClient = oidcClient)
|
||||||
|
|
||||||
session.logout()
|
session.logout(NoopBrowser)
|
||||||
|
|
||||||
assertEquals(listOf(AUTH_STATE_JSON), oidcClient.logoutCalls)
|
assertEquals(listOf(AUTH_STATE_JSON), oidcClient.logoutCalls)
|
||||||
assertNull(store.value)
|
assertNull(store.value)
|
||||||
@@ -152,7 +145,7 @@ class AuthSessionTest {
|
|||||||
oidcClient = FakeOidcClient(loginResult = OidcResult.Cancelled),
|
oidcClient = FakeOidcClient(loginResult = OidcResult.Cancelled),
|
||||||
)
|
)
|
||||||
|
|
||||||
val result = session.login()
|
val result = session.login(NoopBrowser)
|
||||||
|
|
||||||
assertEquals(AuthLoginResult.Cancelled, result)
|
assertEquals(AuthLoginResult.Cancelled, result)
|
||||||
assertNull(store.value)
|
assertNull(store.value)
|
||||||
@@ -163,14 +156,17 @@ class AuthSessionTest {
|
|||||||
private fun newSession(
|
private fun newSession(
|
||||||
store: AuthStateStore = FakeAuthStateStore(),
|
store: AuthStateStore = FakeAuthStateStore(),
|
||||||
oidcClient: OidcClientGateway = FakeOidcClient(),
|
oidcClient: OidcClientGateway = FakeOidcClient(),
|
||||||
meClient: MeGateway = FakeMeClient(user = USER),
|
|
||||||
): AuthSession =
|
): AuthSession =
|
||||||
AuthSession(
|
AuthSession(
|
||||||
oidcClient = oidcClient,
|
oidcClient = oidcClient,
|
||||||
store = store,
|
store = store,
|
||||||
meClient = meClient,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private object NoopBrowser : AuthBrowser {
|
||||||
|
override suspend fun launchAndAwait(initiation: AuthFlow.Initiation): AuthFlowResultProvider.Result =
|
||||||
|
AuthFlowResultProvider.Result.Undefined
|
||||||
|
}
|
||||||
|
|
||||||
private class FakeAuthStateStore(
|
private class FakeAuthStateStore(
|
||||||
var value: String? = null,
|
var value: String? = null,
|
||||||
) : AuthStateStore {
|
) : AuthStateStore {
|
||||||
@@ -194,7 +190,7 @@ class AuthSessionTest {
|
|||||||
val refreshCalls = mutableListOf<String>()
|
val refreshCalls = mutableListOf<String>()
|
||||||
val logoutCalls = mutableListOf<String>()
|
val logoutCalls = mutableListOf<String>()
|
||||||
|
|
||||||
override suspend fun login(): OidcResult {
|
override suspend fun login(browser: AuthBrowser): OidcResult {
|
||||||
loginCalls += Unit
|
loginCalls += Unit
|
||||||
return loginResult
|
return loginResult
|
||||||
}
|
}
|
||||||
@@ -204,7 +200,7 @@ class AuthSessionTest {
|
|||||||
return refreshResult
|
return refreshResult
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun logout(authStateJson: String) {
|
override suspend fun logout(authStateJson: String, browser: AuthBrowser) {
|
||||||
logoutCalls += authStateJson
|
logoutCalls += authStateJson
|
||||||
if (logoutThrows) {
|
if (logoutThrows) {
|
||||||
error("end-session failed")
|
error("end-session failed")
|
||||||
@@ -212,29 +208,10 @@ class AuthSessionTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private class FakeMeClient(
|
|
||||||
private val user: User,
|
|
||||||
) : MeGateway {
|
|
||||||
val accessTokens = mutableListOf<String?>()
|
|
||||||
|
|
||||||
override suspend fun getMe(accessToken: String?): User {
|
|
||||||
accessTokens += accessToken
|
|
||||||
return user
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val AUTH_STATE_JSON = """{"refresh_token":"initial"}"""
|
const val AUTH_STATE_JSON = """{"refresh_token":"initial"}"""
|
||||||
const val REFRESHED_AUTH_STATE_JSON = """{"refresh_token":"refreshed"}"""
|
const val REFRESHED_AUTH_STATE_JSON = """{"refresh_token":"refreshed"}"""
|
||||||
const val ACCESS_TOKEN = "access-token"
|
const val ACCESS_TOKEN = "access-token"
|
||||||
const val REFRESHED_ACCESS_TOKEN = "refreshed-access-token"
|
const val REFRESHED_ACCESS_TOKEN = "refreshed-access-token"
|
||||||
|
|
||||||
val USER =
|
|
||||||
User(
|
|
||||||
id = "00000000-0000-0000-0000-000000000001",
|
|
||||||
sub = "authentik-sub",
|
|
||||||
email = "user@example.invalid",
|
|
||||||
displayName = "Recipe User",
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,49 @@
|
|||||||
package dev.ulfrx.recipe.auth
|
package dev.ulfrx.recipe.auth
|
||||||
|
|
||||||
|
import com.russhwolf.settings.Settings
|
||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
import kotlin.test.assertNull
|
import kotlin.test.assertNull
|
||||||
|
|
||||||
|
private class InMemorySettings : Settings {
|
||||||
|
private val map = mutableMapOf<String, Any>()
|
||||||
|
|
||||||
|
override val keys: Set<String> get() = map.keys
|
||||||
|
override val size: Int get() = map.size
|
||||||
|
|
||||||
|
override fun clear() = map.clear()
|
||||||
|
override fun remove(key: String) { map.remove(key) }
|
||||||
|
override fun hasKey(key: String): Boolean = map.containsKey(key)
|
||||||
|
|
||||||
|
override fun putInt(key: String, value: Int) { map[key] = value }
|
||||||
|
override fun getInt(key: String, defaultValue: Int): Int = (map[key] as? Int) ?: defaultValue
|
||||||
|
override fun getIntOrNull(key: String): Int? = map[key] as? Int
|
||||||
|
|
||||||
|
override fun putLong(key: String, value: Long) { map[key] = value }
|
||||||
|
override fun getLong(key: String, defaultValue: Long): Long = (map[key] as? Long) ?: defaultValue
|
||||||
|
override fun getLongOrNull(key: String): Long? = map[key] as? Long
|
||||||
|
|
||||||
|
override fun putString(key: String, value: String) { map[key] = value }
|
||||||
|
override fun getString(key: String, defaultValue: String): String = (map[key] as? String) ?: defaultValue
|
||||||
|
override fun getStringOrNull(key: String): String? = map[key] as? String
|
||||||
|
|
||||||
|
override fun putFloat(key: String, value: Float) { map[key] = value }
|
||||||
|
override fun getFloat(key: String, defaultValue: Float): Float = (map[key] as? Float) ?: defaultValue
|
||||||
|
override fun getFloatOrNull(key: String): Float? = map[key] as? Float
|
||||||
|
|
||||||
|
override fun putDouble(key: String, value: Double) { map[key] = value }
|
||||||
|
override fun getDouble(key: String, defaultValue: Double): Double = (map[key] as? Double) ?: defaultValue
|
||||||
|
override fun getDoubleOrNull(key: String): Double? = map[key] as? Double
|
||||||
|
|
||||||
|
override fun putBoolean(key: String, value: Boolean) { map[key] = value }
|
||||||
|
override fun getBoolean(key: String, defaultValue: Boolean): Boolean = (map[key] as? Boolean) ?: defaultValue
|
||||||
|
override fun getBooleanOrNull(key: String): Boolean? = map[key] as? Boolean
|
||||||
|
}
|
||||||
|
|
||||||
class SecureAuthStateStoreContractTest {
|
class SecureAuthStateStoreContractTest {
|
||||||
@Test
|
@Test
|
||||||
fun writeOverwritesPreviousValueAndReadReturnsLatest() {
|
fun writeOverwritesPreviousValueAndReadReturnsLatest() {
|
||||||
val store = SecureAuthStateStore()
|
val store = SecureAuthStateStore(InMemorySettings())
|
||||||
|
|
||||||
store.write("""{"refresh_token":"first"}""")
|
store.write("""{"refresh_token":"first"}""")
|
||||||
store.write("""{"refresh_token":"second"}""")
|
store.write("""{"refresh_token":"second"}""")
|
||||||
@@ -17,7 +53,7 @@ class SecureAuthStateStoreContractTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun clearRemovesStoredValue() {
|
fun clearRemovesStoredValue() {
|
||||||
val store = SecureAuthStateStore()
|
val store = SecureAuthStateStore(InMemorySettings())
|
||||||
|
|
||||||
store.write("""{"refresh_token":"stored"}""")
|
store.write("""{"refresh_token":"stored"}""")
|
||||||
store.clear()
|
store.clear()
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
package dev.ulfrx.recipe.ui.screens.auth
|
package dev.ulfrx.recipe.ui.screens.auth
|
||||||
|
|
||||||
|
import dev.lokksmith.client.request.flow.AuthFlow
|
||||||
|
import dev.lokksmith.client.request.flow.AuthFlowResultProvider
|
||||||
|
import dev.ulfrx.recipe.auth.AuthBrowser
|
||||||
import dev.ulfrx.recipe.auth.AuthSession
|
import dev.ulfrx.recipe.auth.AuthSession
|
||||||
import dev.ulfrx.recipe.auth.AuthStateStore
|
import dev.ulfrx.recipe.auth.AuthStateStore
|
||||||
import dev.ulfrx.recipe.auth.MeGateway
|
|
||||||
import dev.ulfrx.recipe.auth.OidcClientGateway
|
import dev.ulfrx.recipe.auth.OidcClientGateway
|
||||||
import dev.ulfrx.recipe.auth.OidcResult
|
import dev.ulfrx.recipe.auth.OidcResult
|
||||||
import dev.ulfrx.recipe.shared.dto.User
|
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import recipe.composeapp.generated.resources.Res
|
import recipe.composeapp.generated.resources.Res
|
||||||
@@ -24,7 +25,7 @@ class LoginViewModelTest {
|
|||||||
val session = newSession(loginResult = OidcResult.Cancelled)
|
val session = newSession(loginResult = OidcResult.Cancelled)
|
||||||
val viewModel = LoginViewModel(session)
|
val viewModel = LoginViewModel(session)
|
||||||
|
|
||||||
viewModel.onSignInClick().join()
|
viewModel.onSignInClick(NoopBrowser).join()
|
||||||
|
|
||||||
assertEquals(Res.string.auth_error_cancelled, viewModel.state.value.errorKey)
|
assertEquals(Res.string.auth_error_cancelled, viewModel.state.value.errorKey)
|
||||||
assertEquals(false, viewModel.state.value.isLoading)
|
assertEquals(false, viewModel.state.value.isLoading)
|
||||||
@@ -36,7 +37,7 @@ class LoginViewModelTest {
|
|||||||
val session = newSession(loginResult = OidcResult.NetworkError)
|
val session = newSession(loginResult = OidcResult.NetworkError)
|
||||||
val viewModel = LoginViewModel(session)
|
val viewModel = LoginViewModel(session)
|
||||||
|
|
||||||
viewModel.onSignInClick().join()
|
viewModel.onSignInClick(NoopBrowser).join()
|
||||||
|
|
||||||
assertEquals(Res.string.auth_error_network, viewModel.state.value.errorKey)
|
assertEquals(Res.string.auth_error_network, viewModel.state.value.errorKey)
|
||||||
assertEquals(false, viewModel.state.value.isLoading)
|
assertEquals(false, viewModel.state.value.isLoading)
|
||||||
@@ -48,7 +49,7 @@ class LoginViewModelTest {
|
|||||||
val session = newSession(loginResult = OidcResult.AuthError("token endpoint failed"))
|
val session = newSession(loginResult = OidcResult.AuthError("token endpoint failed"))
|
||||||
val viewModel = LoginViewModel(session)
|
val viewModel = LoginViewModel(session)
|
||||||
|
|
||||||
viewModel.onSignInClick().join()
|
viewModel.onSignInClick(NoopBrowser).join()
|
||||||
|
|
||||||
assertEquals(Res.string.auth_error_unknown, viewModel.state.value.errorKey)
|
assertEquals(Res.string.auth_error_unknown, viewModel.state.value.errorKey)
|
||||||
assertEquals(false, viewModel.state.value.isLoading)
|
assertEquals(false, viewModel.state.value.isLoading)
|
||||||
@@ -69,7 +70,7 @@ class LoginViewModelTest {
|
|||||||
)
|
)
|
||||||
val viewModel = LoginViewModel(session)
|
val viewModel = LoginViewModel(session)
|
||||||
|
|
||||||
viewModel.onSignInClick().join()
|
viewModel.onSignInClick(NoopBrowser).join()
|
||||||
|
|
||||||
assertNull(viewModel.state.value.errorKey)
|
assertNull(viewModel.state.value.errorKey)
|
||||||
assertEquals(false, viewModel.state.value.isLoading)
|
assertEquals(false, viewModel.state.value.isLoading)
|
||||||
@@ -85,23 +86,24 @@ class LoginViewModelTest {
|
|||||||
val queue = mutableListOf<OidcResult>(OidcResult.Cancelled)
|
val queue = mutableListOf<OidcResult>(OidcResult.Cancelled)
|
||||||
val oidc =
|
val oidc =
|
||||||
object : OidcClientGateway {
|
object : OidcClientGateway {
|
||||||
override suspend fun login(): OidcResult = if (queue.isNotEmpty()) queue.removeAt(0) else gate.await()
|
override suspend fun login(browser: AuthBrowser): OidcResult =
|
||||||
|
if (queue.isNotEmpty()) queue.removeAt(0) else gate.await()
|
||||||
|
|
||||||
override suspend fun refresh(authStateJson: String): OidcResult = OidcResult.AuthError("not used")
|
override suspend fun refresh(authStateJson: String): OidcResult = OidcResult.AuthError("not used")
|
||||||
|
|
||||||
override suspend fun logout(authStateJson: String) {}
|
override suspend fun logout(authStateJson: String, browser: AuthBrowser) {}
|
||||||
}
|
}
|
||||||
val session = AuthSession(oidc, FakeAuthStateStore(), FakeMeClient(USER))
|
val session = AuthSession(oidc, FakeAuthStateStore())
|
||||||
val viewModel = LoginViewModel(session)
|
val viewModel = LoginViewModel(session)
|
||||||
|
|
||||||
// First attempt: error seeded.
|
// First attempt: error seeded.
|
||||||
viewModel.onSignInClick().join()
|
viewModel.onSignInClick(NoopBrowser).join()
|
||||||
assertEquals(Res.string.auth_error_cancelled, viewModel.state.value.errorKey)
|
assertEquals(Res.string.auth_error_cancelled, viewModel.state.value.errorKey)
|
||||||
|
|
||||||
// Second attempt: launching the job sets loading=true + clears error
|
// Second attempt: launching the job sets loading=true + clears error
|
||||||
// BEFORE suspending. onSignInClick() does that synchronously before
|
// BEFORE suspending. onSignInClick() does that synchronously before
|
||||||
// returning the launched Job, so we can assert immediately.
|
// returning the launched Job, so we can assert immediately.
|
||||||
val job = viewModel.onSignInClick()
|
val job = viewModel.onSignInClick(NoopBrowser)
|
||||||
assertTrue(viewModel.state.value.isLoading)
|
assertTrue(viewModel.state.value.isLoading)
|
||||||
assertNull(viewModel.state.value.errorKey)
|
assertNull(viewModel.state.value.errorKey)
|
||||||
|
|
||||||
@@ -116,14 +118,17 @@ class LoginViewModelTest {
|
|||||||
private fun newSession(
|
private fun newSession(
|
||||||
loginResult: OidcResult,
|
loginResult: OidcResult,
|
||||||
store: AuthStateStore = FakeAuthStateStore(),
|
store: AuthStateStore = FakeAuthStateStore(),
|
||||||
meClient: MeGateway = FakeMeClient(USER),
|
|
||||||
): AuthSession =
|
): AuthSession =
|
||||||
AuthSession(
|
AuthSession(
|
||||||
oidcClient = FakeOidcClient(loginResult = loginResult),
|
oidcClient = FakeOidcClient(loginResult = loginResult),
|
||||||
store = store,
|
store = store,
|
||||||
meClient = meClient,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private object NoopBrowser : AuthBrowser {
|
||||||
|
override suspend fun launchAndAwait(initiation: AuthFlow.Initiation): AuthFlowResultProvider.Result =
|
||||||
|
AuthFlowResultProvider.Result.Undefined
|
||||||
|
}
|
||||||
|
|
||||||
private class FakeAuthStateStore(
|
private class FakeAuthStateStore(
|
||||||
var value: String? = null,
|
var value: String? = null,
|
||||||
) : AuthStateStore {
|
) : AuthStateStore {
|
||||||
@@ -142,26 +147,10 @@ class LoginViewModelTest {
|
|||||||
private val loginResult: OidcResult = OidcResult.AuthError("login not configured"),
|
private val loginResult: OidcResult = OidcResult.AuthError("login not configured"),
|
||||||
private val refreshResult: OidcResult = OidcResult.AuthError("refresh not configured"),
|
private val refreshResult: OidcResult = OidcResult.AuthError("refresh not configured"),
|
||||||
) : OidcClientGateway {
|
) : OidcClientGateway {
|
||||||
override suspend fun login(): OidcResult = loginResult
|
override suspend fun login(browser: AuthBrowser): OidcResult = loginResult
|
||||||
|
|
||||||
override suspend fun refresh(authStateJson: String): OidcResult = refreshResult
|
override suspend fun refresh(authStateJson: String): OidcResult = refreshResult
|
||||||
|
|
||||||
override suspend fun logout(authStateJson: String) {}
|
override suspend fun logout(authStateJson: String, browser: AuthBrowser) {}
|
||||||
}
|
|
||||||
|
|
||||||
private class FakeMeClient(
|
|
||||||
private val user: User,
|
|
||||||
) : MeGateway {
|
|
||||||
override suspend fun getMe(accessToken: String?): User = user
|
|
||||||
}
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
val USER =
|
|
||||||
User(
|
|
||||||
id = "00000000-0000-0000-0000-000000000001",
|
|
||||||
sub = "authentik-sub",
|
|
||||||
email = "user@example.invalid",
|
|
||||||
displayName = "Recipe User",
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
package dev.ulfrx.recipe.user
|
||||||
|
|
||||||
|
import dev.lokksmith.client.request.flow.AuthFlow
|
||||||
|
import dev.lokksmith.client.request.flow.AuthFlowResultProvider
|
||||||
|
import dev.ulfrx.recipe.auth.AuthBrowser
|
||||||
|
import dev.ulfrx.recipe.auth.AuthSession
|
||||||
|
import dev.ulfrx.recipe.auth.AuthStateStore
|
||||||
|
import dev.ulfrx.recipe.auth.OidcClientGateway
|
||||||
|
import dev.ulfrx.recipe.auth.OidcResult
|
||||||
|
import dev.ulfrx.recipe.shared.dto.User
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.firstOrNull
|
||||||
|
import kotlinx.coroutines.test.TestScope
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
|
||||||
|
class UserRepositoryTest {
|
||||||
|
@Test
|
||||||
|
fun fetchesUserWhenAuthFlipsToAuthenticated() =
|
||||||
|
runTest {
|
||||||
|
val session = newSession()
|
||||||
|
var fetchCount = 0
|
||||||
|
val repository =
|
||||||
|
UserRepository(
|
||||||
|
authSession = session,
|
||||||
|
fetchUser = { fetchCount++; USER },
|
||||||
|
scope = TestScope(testScheduler),
|
||||||
|
)
|
||||||
|
|
||||||
|
session.login(NoopBrowser)
|
||||||
|
|
||||||
|
val user = repository.currentUser.first { it != null }
|
||||||
|
assertEquals(USER, user)
|
||||||
|
assertEquals(1, fetchCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun clearsUserOnLogout() =
|
||||||
|
runTest {
|
||||||
|
val session = newSession()
|
||||||
|
val repository =
|
||||||
|
UserRepository(
|
||||||
|
authSession = session,
|
||||||
|
fetchUser = { USER },
|
||||||
|
scope = TestScope(testScheduler),
|
||||||
|
)
|
||||||
|
|
||||||
|
session.login(NoopBrowser)
|
||||||
|
repository.currentUser.first { it != null }
|
||||||
|
|
||||||
|
session.logout(NoopBrowser)
|
||||||
|
|
||||||
|
val cleared = repository.currentUser.firstOrNull { it == null }
|
||||||
|
assertNull(cleared)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun networkFailureLeavesCurrentUserNullWithoutCrashing() =
|
||||||
|
runTest {
|
||||||
|
val session = newSession()
|
||||||
|
val repository =
|
||||||
|
UserRepository(
|
||||||
|
authSession = session,
|
||||||
|
fetchUser = { error("network down") },
|
||||||
|
scope = TestScope(testScheduler),
|
||||||
|
)
|
||||||
|
|
||||||
|
session.login(NoopBrowser)
|
||||||
|
testScheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
assertNull(repository.currentUser.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun newSession(): AuthSession =
|
||||||
|
AuthSession(
|
||||||
|
oidcClient = FakeOidcClient(loginResult = SUCCESS),
|
||||||
|
store = FakeAuthStateStore(),
|
||||||
|
)
|
||||||
|
|
||||||
|
private object NoopBrowser : AuthBrowser {
|
||||||
|
override suspend fun launchAndAwait(initiation: AuthFlow.Initiation): AuthFlowResultProvider.Result =
|
||||||
|
AuthFlowResultProvider.Result.Undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FakeAuthStateStore(
|
||||||
|
var value: String? = null,
|
||||||
|
) : AuthStateStore {
|
||||||
|
override fun read(): String? = value
|
||||||
|
|
||||||
|
override fun write(authStateJson: String) {
|
||||||
|
value = authStateJson
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun clear() {
|
||||||
|
value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FakeOidcClient(
|
||||||
|
private val loginResult: OidcResult,
|
||||||
|
) : OidcClientGateway {
|
||||||
|
override suspend fun login(browser: AuthBrowser): OidcResult = loginResult
|
||||||
|
|
||||||
|
override suspend fun refresh(authStateJson: String): OidcResult = OidcResult.AuthError("not used")
|
||||||
|
|
||||||
|
override suspend fun logout(authStateJson: String, browser: AuthBrowser) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
val SUCCESS =
|
||||||
|
OidcResult.Success(
|
||||||
|
authStateJson = "{}",
|
||||||
|
accessToken = "access",
|
||||||
|
idToken = null,
|
||||||
|
expiresAtEpochMillis = 0L,
|
||||||
|
)
|
||||||
|
|
||||||
|
val USER =
|
||||||
|
User(
|
||||||
|
id = "00000000-0000-0000-0000-000000000001",
|
||||||
|
sub = "authentik-sub",
|
||||||
|
email = "user@example.invalid",
|
||||||
|
displayName = "Recipe User",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,30 @@
|
|||||||
|
@file:OptIn(
|
||||||
|
com.russhwolf.settings.ExperimentalSettingsApi::class,
|
||||||
|
com.russhwolf.settings.ExperimentalSettingsImplementation::class,
|
||||||
|
kotlinx.cinterop.ExperimentalForeignApi::class,
|
||||||
|
)
|
||||||
|
|
||||||
package dev.ulfrx.recipe.auth
|
package dev.ulfrx.recipe.auth
|
||||||
|
|
||||||
|
import com.russhwolf.settings.KeychainSettings
|
||||||
|
import com.russhwolf.settings.Settings
|
||||||
|
import dev.lokksmith.Lokksmith
|
||||||
|
import dev.lokksmith.SingletonLokksmithProvider
|
||||||
|
import dev.lokksmith.createLokksmith
|
||||||
import org.koin.dsl.module
|
import org.koin.dsl.module
|
||||||
|
import platform.Security.kSecAttrAccessible
|
||||||
|
import platform.Security.kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
||||||
|
|
||||||
val iosAuthModule =
|
val iosAuthModule =
|
||||||
module {
|
module {
|
||||||
single { createIosLokksmith() }
|
single<Lokksmith> {
|
||||||
|
createLokksmith().also { lokksmith ->
|
||||||
|
SingletonLokksmithProvider.set(lokksmith)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
single<Settings> {
|
||||||
|
KeychainSettings(
|
||||||
|
kSecAttrAccessible to kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,98 +0,0 @@
|
|||||||
package dev.ulfrx.recipe.auth
|
|
||||||
|
|
||||||
import dev.lokksmith.Lokksmith
|
|
||||||
import dev.lokksmith.client.Client
|
|
||||||
import dev.lokksmith.client.InternalClient
|
|
||||||
import dev.lokksmith.client.request.flow.AuthFlowResultProvider
|
|
||||||
import dev.lokksmith.client.request.flow.AuthFlowStateResponseHandler
|
|
||||||
import dev.lokksmith.client.request.flow.authorizationCode.AuthorizationCodeFlow
|
|
||||||
import dev.lokksmith.client.request.flow.endSession.EndSessionFlow
|
|
||||||
import dev.lokksmith.client.request.parameter.Scope
|
|
||||||
import dev.lokksmith.discoveryUrl
|
|
||||||
import dev.lokksmith.id
|
|
||||||
import dev.ulfrx.recipe.shared.Constants
|
|
||||||
import kotlinx.coroutines.async
|
|
||||||
import kotlinx.coroutines.coroutineScope
|
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.selects.select
|
|
||||||
|
|
||||||
internal const val LOKKSMITH_CLIENT_KEY = "recipe-app"
|
|
||||||
internal const val LOKKSMITH_AUTH_STATE_JSON = "lokksmith:$LOKKSMITH_CLIENT_KEY"
|
|
||||||
|
|
||||||
internal suspend fun Lokksmith.recipeClient(): Client =
|
|
||||||
getOrCreate(LOKKSMITH_CLIENT_KEY) {
|
|
||||||
id = Constants.OIDC_CLIENT_ID
|
|
||||||
discoveryUrl = Constants.OIDC_ISSUER + ".well-known/openid-configuration"
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun Client.recipeAuthorizationCodeFlow(): dev.lokksmith.client.request.flow.AuthFlow =
|
|
||||||
authorizationCodeFlow(
|
|
||||||
AuthorizationCodeFlow.Request(
|
|
||||||
redirectUri = Constants.OIDC_REDIRECT_URI,
|
|
||||||
scope = setOf(Scope.Profile, Scope.Email, Scope.Custom("offline_access")),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
internal fun Client.recipeEndSessionFlow(): dev.lokksmith.client.request.flow.AuthFlow? =
|
|
||||||
endSessionFlow(EndSessionFlow.Request(redirectUri = Constants.OIDC_REDIRECT_URI))
|
|
||||||
|
|
||||||
internal suspend fun Client.awaitTerminalAuthFlowResult(): AuthFlowResultProvider.Result =
|
|
||||||
AuthFlowResultProvider
|
|
||||||
.forClient(this)
|
|
||||||
.first { result ->
|
|
||||||
result is AuthFlowResultProvider.Result.Success ||
|
|
||||||
result is AuthFlowResultProvider.Result.Cancelled ||
|
|
||||||
result is AuthFlowResultProvider.Result.Error
|
|
||||||
}
|
|
||||||
|
|
||||||
internal suspend fun Lokksmith.completeAuthFlow(client: Client): AuthFlowResultProvider.Result =
|
|
||||||
coroutineScope {
|
|
||||||
val terminal = async { client.awaitTerminalAuthFlowResult() }
|
|
||||||
val responseUri =
|
|
||||||
async {
|
|
||||||
(client as InternalClient)
|
|
||||||
.snapshots
|
|
||||||
.map { snapshot -> snapshot.ephemeralFlowState?.responseUri }
|
|
||||||
.distinctUntilChanged()
|
|
||||||
.first { responseUri -> responseUri != null }
|
|
||||||
}
|
|
||||||
|
|
||||||
select<AuthFlowResultProvider.Result> {
|
|
||||||
terminal.onAwait { result ->
|
|
||||||
responseUri.cancel()
|
|
||||||
result
|
|
||||||
}
|
|
||||||
responseUri.onAwait { uri ->
|
|
||||||
terminal.cancel()
|
|
||||||
AuthFlowStateResponseHandler(this@completeAuthFlow).onResponse(checkNotNull(uri))
|
|
||||||
client.awaitTerminalAuthFlowResult()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal suspend fun Client.toOidcSuccess(): OidcResult.Success {
|
|
||||||
var freshTokens: Client.Tokens? = null
|
|
||||||
runWithTokens { tokens -> freshTokens = tokens }
|
|
||||||
val tokens = checkNotNull(freshTokens) { "Lokksmith returned no tokens" }
|
|
||||||
return OidcResult.Success(
|
|
||||||
authStateJson = LOKKSMITH_AUTH_STATE_JSON,
|
|
||||||
accessToken = tokens.accessToken.token,
|
|
||||||
idToken = tokens.idToken.raw,
|
|
||||||
expiresAtEpochMillis = tokens.accessToken.expiresAt?.let { it * 1_000L } ?: 0L,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun AuthFlowResultProvider.Result.toOidcFailureOrNull(): OidcResult? =
|
|
||||||
when (this) {
|
|
||||||
is AuthFlowResultProvider.Result.Success -> null
|
|
||||||
|
|
||||||
is AuthFlowResultProvider.Result.Cancelled -> OidcResult.Cancelled
|
|
||||||
|
|
||||||
is AuthFlowResultProvider.Result.Error -> OidcResult.AuthError(message ?: "OIDC flow failed")
|
|
||||||
|
|
||||||
AuthFlowResultProvider.Result.Undefined,
|
|
||||||
is AuthFlowResultProvider.Result.Processing,
|
|
||||||
-> OidcResult.AuthError("OIDC flow did not complete")
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
@file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
|
|
||||||
|
|
||||||
package dev.ulfrx.recipe.auth
|
|
||||||
|
|
||||||
import dev.lokksmith.Lokksmith
|
|
||||||
import dev.lokksmith.SingletonLokksmithProvider
|
|
||||||
import dev.lokksmith.createLokksmith
|
|
||||||
import dev.lokksmith.ios.launchAuthFlow
|
|
||||||
import org.koin.mp.KoinPlatform
|
|
||||||
|
|
||||||
actual class OidcClient {
|
|
||||||
private val lokksmith: Lokksmith
|
|
||||||
get() = KoinPlatform.getKoin().get()
|
|
||||||
|
|
||||||
actual suspend fun login(): OidcResult {
|
|
||||||
val client = lokksmith.recipeClient()
|
|
||||||
val flow = client.recipeAuthorizationCodeFlow()
|
|
||||||
val initiation = flow.prepare()
|
|
||||||
|
|
||||||
lokksmith.launchAuthFlow(initiation)
|
|
||||||
|
|
||||||
return when (val failure = lokksmith.completeAuthFlow(client).toOidcFailureOrNull()) {
|
|
||||||
null -> runCatching { client.toOidcSuccess() }.getOrElse { OidcResult.AuthError(it.message ?: "OIDC login failed", it) }
|
|
||||||
else -> failure
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
actual suspend fun refresh(authStateJson: String): OidcResult {
|
|
||||||
if (authStateJson != LOKKSMITH_AUTH_STATE_JSON) {
|
|
||||||
return OidcResult.AuthError("Stored iOS auth state is not a Lokksmith session")
|
|
||||||
}
|
|
||||||
val client = lokksmith.recipeClient()
|
|
||||||
return runCatching { client.toOidcSuccess() }
|
|
||||||
.getOrElse { OidcResult.AuthError(it.message ?: "OIDC refresh failed", it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
actual suspend fun logout(authStateJson: String) {
|
|
||||||
val client = lokksmith.recipeClient()
|
|
||||||
val flow = client.recipeEndSessionFlow()
|
|
||||||
|
|
||||||
if (flow != null) {
|
|
||||||
runCatching {
|
|
||||||
lokksmith.launchAuthFlow(flow.prepare())
|
|
||||||
lokksmith.completeAuthFlow(client)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
client.resetTokens()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun createIosLokksmith(): Lokksmith =
|
|
||||||
createLokksmith().also { lokksmith ->
|
|
||||||
SingletonLokksmithProvider.set(lokksmith)
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
@file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
|
|
||||||
|
|
||||||
package dev.ulfrx.recipe.auth
|
|
||||||
|
|
||||||
import com.russhwolf.settings.ExperimentalSettingsApi
|
|
||||||
import com.russhwolf.settings.ExperimentalSettingsImplementation
|
|
||||||
import com.russhwolf.settings.KeychainSettings
|
|
||||||
import kotlinx.cinterop.ExperimentalForeignApi
|
|
||||||
import platform.Security.kSecAttrAccessible
|
|
||||||
import platform.Security.kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
|
||||||
|
|
||||||
@OptIn(ExperimentalSettingsApi::class, ExperimentalSettingsImplementation::class, ExperimentalForeignApi::class)
|
|
||||||
actual class SecureAuthStateStore {
|
|
||||||
private val settings =
|
|
||||||
KeychainSettings(
|
|
||||||
kSecAttrAccessible to kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
|
|
||||||
)
|
|
||||||
|
|
||||||
actual fun read(): String? = settings.getStringOrNull(AUTH_STATE_KEY)
|
|
||||||
|
|
||||||
actual fun write(authStateJson: String) {
|
|
||||||
settings.putString(AUTH_STATE_KEY, authStateJson)
|
|
||||||
}
|
|
||||||
|
|
||||||
actual fun clear() {
|
|
||||||
settings.remove(AUTH_STATE_KEY)
|
|
||||||
}
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
const val AUTH_STATE_KEY = "dev.ulfrx.recipe.auth.lokksmith-state"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
@file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
|
|
||||||
|
|
||||||
package dev.ulfrx.recipe.auth
|
|
||||||
|
|
||||||
actual class OidcClient {
|
|
||||||
actual suspend fun login(): OidcResult {
|
|
||||||
val token =
|
|
||||||
System.getenv(DEV_AUTH_TOKEN)
|
|
||||||
?: return OidcResult.AuthError("DEV_AUTH_TOKEN is not set")
|
|
||||||
|
|
||||||
return OidcResult.Success(
|
|
||||||
authStateJson = "dev:$token",
|
|
||||||
accessToken = token,
|
|
||||||
idToken = null,
|
|
||||||
expiresAtEpochMillis = Long.MAX_VALUE,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
actual suspend fun refresh(authStateJson: String): OidcResult {
|
|
||||||
val token =
|
|
||||||
authStateJson.removePrefix("dev:").takeIf { it.isNotBlank() }
|
|
||||||
?: System.getenv(DEV_AUTH_TOKEN)
|
|
||||||
?: return OidcResult.AuthError("DEV_AUTH_TOKEN is not set")
|
|
||||||
|
|
||||||
return OidcResult.Success(
|
|
||||||
authStateJson = "dev:$token",
|
|
||||||
accessToken = token,
|
|
||||||
idToken = null,
|
|
||||||
expiresAtEpochMillis = Long.MAX_VALUE,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
actual suspend fun logout(authStateJson: String) = Unit
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
const val DEV_AUTH_TOKEN = "DEV_AUTH_TOKEN"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
@file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
|
|
||||||
|
|
||||||
package dev.ulfrx.recipe.auth
|
|
||||||
|
|
||||||
actual class SecureAuthStateStore {
|
|
||||||
private var authStateJson: String? = null
|
|
||||||
|
|
||||||
actual fun read(): String? = authStateJson
|
|
||||||
|
|
||||||
actual fun write(authStateJson: String) {
|
|
||||||
this.authStateJson = authStateJson
|
|
||||||
}
|
|
||||||
|
|
||||||
actual fun clear() {
|
|
||||||
authStateJson = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
package dev.ulfrx.recipe
|
|
||||||
|
|
||||||
import androidx.compose.ui.window.Window
|
|
||||||
import androidx.compose.ui.window.application
|
|
||||||
import dev.ulfrx.recipe.di.initKoin
|
|
||||||
import dev.ulfrx.recipe.logging.configureLogging
|
|
||||||
|
|
||||||
fun main() {
|
|
||||||
configureLogging()
|
|
||||||
initKoin()
|
|
||||||
application {
|
|
||||||
Window(
|
|
||||||
onCloseRequest = ::exitApplication,
|
|
||||||
title = "recipe",
|
|
||||||
) {
|
|
||||||
App()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
@file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
|
|
||||||
|
|
||||||
package dev.ulfrx.recipe.auth
|
|
||||||
|
|
||||||
actual class OidcClient {
|
|
||||||
actual suspend fun login(): OidcResult = throw NotImplementedError("Wasm OIDC: v2")
|
|
||||||
|
|
||||||
actual suspend fun refresh(authStateJson: String): OidcResult = throw NotImplementedError("Wasm OIDC: v2")
|
|
||||||
|
|
||||||
actual suspend fun logout(authStateJson: String): Unit = throw NotImplementedError("Wasm OIDC: v2")
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
@file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
|
|
||||||
|
|
||||||
package dev.ulfrx.recipe.auth
|
|
||||||
|
|
||||||
actual class SecureAuthStateStore {
|
|
||||||
private var authStateJson: String? = null
|
|
||||||
|
|
||||||
actual fun read(): String? = authStateJson
|
|
||||||
|
|
||||||
actual fun write(authStateJson: String) {
|
|
||||||
this.authStateJson = authStateJson
|
|
||||||
}
|
|
||||||
|
|
||||||
actual fun clear() {
|
|
||||||
authStateJson = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
package dev.ulfrx.recipe
|
|
||||||
|
|
||||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
|
||||||
import androidx.compose.ui.window.ComposeViewport
|
|
||||||
import dev.ulfrx.recipe.di.initKoin
|
|
||||||
import dev.ulfrx.recipe.logging.configureLogging
|
|
||||||
|
|
||||||
@OptIn(ExperimentalComposeUiApi::class)
|
|
||||||
fun main() {
|
|
||||||
configureLogging()
|
|
||||||
initKoin()
|
|
||||||
ComposeViewport {
|
|
||||||
App()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>recipe</title>
|
|
||||||
<link type="text/css" rel="stylesheet" href="styles.css">
|
|
||||||
</head>
|
|
||||||
<body style="text-align: center; align-content: center">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 50 50" role="presentation">
|
|
||||||
<circle cx="25" cy="25" r="20" stroke="#ccc" stroke-width="4" fill="none"/>
|
|
||||||
<circle cx="25" cy="25" r="20" stroke="#333" stroke-width="4" fill="none" stroke-linecap="round"
|
|
||||||
stroke-dasharray="90 125">
|
|
||||||
<animateTransform attributeName="transform" type="rotate" from="0 25 25" to="360 25 25" dur="1s"
|
|
||||||
repeatCount="indefinite"/>
|
|
||||||
</circle>
|
|
||||||
</svg>
|
|
||||||
<script type="application/javascript" src="composeApp.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
html, body {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
@@ -183,8 +183,8 @@ plan number), ✂ explicitly deferred (see end of section).
|
|||||||
| RESEARCH | Open Question resolved: Exposed `newSuspendedTransaction` import verified at impl time | ⤳ 02-02 |
|
| RESEARCH | Open Question resolved: Exposed `newSuspendedTransaction` import verified at impl time | ⤳ 02-02 |
|
||||||
| RESEARCH | Open Question resolved: Ktor patch version follows the selected auth client | ✅ Lokksmith requires Ktor 3.4.2 |
|
| RESEARCH | Open Question resolved: Ktor patch version follows the selected auth client | ✅ Lokksmith requires Ktor 3.4.2 |
|
||||||
| CONTEXT | **D-01** Lokksmith on both mobile platforms via expect/actual `OidcClient` | ⤳ 02-03 (expect) + 02-04 (Android actual) + 02-05 (iOS actual) |
|
| CONTEXT | **D-01** Lokksmith on both mobile platforms via expect/actual `OidcClient` | ⤳ 02-03 (expect) + 02-04 (Android actual) + 02-05 (iOS actual) |
|
||||||
| CONTEXT | **D-02** JVM `actual` is `DEV_AUTH_TOKEN` env-var stub | ⤳ 02-03 |
|
| CONTEXT | **D-02** Desktop/JVM app auth stub | Superseded: `composeApp` no longer has a JVM/Desktop target; `shared.jvm()` remains only for the server dependency |
|
||||||
| CONTEXT | **D-03** Wasm `actual` is `NotImplementedError("Wasm OIDC: v2")` | ⤳ 02-03 |
|
| CONTEXT | **D-03** Wasm app auth stub | Superseded: `composeApp` no longer has a Wasm target in v1 |
|
||||||
| CONTEXT | **D-04** `OidcClient.login()` / `.refresh()` are `suspend` | ⤳ 02-03 |
|
| CONTEXT | **D-04** `OidcClient.login()` / `.refresh()` are `suspend` | ⤳ 02-03 |
|
||||||
| CONTEXT | **D-05** Public + PKCE S256 | ✅ Provider |
|
| CONTEXT | **D-05** Public + PKCE S256 | ✅ Provider |
|
||||||
| CONTEXT | **D-06** scopes `openid profile email offline_access` | ✅ Scopes |
|
| CONTEXT | **D-06** scopes `openid profile email offline_access` | ✅ Scopes |
|
||||||
@@ -226,8 +226,8 @@ plan number), ✂ explicitly deferred (see end of section).
|
|||||||
These are explicitly out of scope for v1 per `.planning/phases/02-authentication-foundation/02-CONTEXT.md` § Deferred Ideas. Listed here so the audit makes the exclusions traceable.
|
These are explicitly out of scope for v1 per `.planning/phases/02-authentication-foundation/02-CONTEXT.md` § Deferred Ideas. Listed here so the audit makes the exclusions traceable.
|
||||||
|
|
||||||
- **Universal Links / App Links** — excluded; rely on `recipe://callback` custom scheme. Revisit only if app gains broader distribution beyond the household or if Apple/Google deprecate custom-scheme OIDC redirects.
|
- **Universal Links / App Links** — excluded; rely on `recipe://callback` custom scheme. Revisit only if app gains broader distribution beyond the household or if Apple/Google deprecate custom-scheme OIDC redirects.
|
||||||
- **Real Desktop OIDC** — JVM target ships a `DEV_AUTH_TOKEN` env-var stub (D-02). Loopback-redirect implementation deferred until Desktop becomes a release surface.
|
- **Real Desktop OIDC** — no longer applicable in v1; the `composeApp` JVM/Desktop target was removed.
|
||||||
- **Wasm OIDC implementation** — `wasmJs` actual throws `NotImplementedError`. Browser-redirect flow deferred until Wasm becomes a release surface.
|
- **Wasm OIDC implementation** — no longer applicable in v1; the `composeApp` Wasm target was removed.
|
||||||
- **Apple Sign-in as a first-class button** — Authentik can federate Apple upstream if ever desired.
|
- **Apple Sign-in as a first-class button** — Authentik can federate Apple upstream if ever desired.
|
||||||
- **Authentik provisioning automation (Terraform/Ansible)** — this document is the manual reproduction playbook; automation deferred post-v1.
|
- **Authentik provisioning automation (Terraform/Ansible)** — this document is the manual reproduction playbook; automation deferred post-v1.
|
||||||
- **JWT validation tests against a real Authentik instance** — Phase 2 ships unit/integration tests with hand-crafted JWTs. Real-Authentik integration tests deferred to Phase 11 (deployment).
|
- **JWT validation tests against a real Authentik instance** — Phase 2 ships unit/integration tests with hand-crafted JWTs. Real-Authentik integration tests deferred to Phase 11 (deployment).
|
||||||
|
|||||||
@@ -1,23 +1,17 @@
|
|||||||
[versions]
|
[versions]
|
||||||
agp = "8.11.2"
|
agp = "8.11.2"
|
||||||
android-compileSdk = "36"
|
android-compileSdk = "36"
|
||||||
android-minSdk = "24"
|
android-minSdk = "33"
|
||||||
android-targetSdk = "36"
|
android-targetSdk = "36"
|
||||||
androidx-activity = "1.13.0"
|
androidx-activity = "1.13.0"
|
||||||
androidx-appcompat = "1.7.1"
|
|
||||||
androidx-core = "1.18.0"
|
|
||||||
androidx-espresso = "3.7.0"
|
|
||||||
androidx-lifecycle = "2.10.0"
|
androidx-lifecycle = "2.10.0"
|
||||||
androidx-security-crypto = "1.1.0"
|
|
||||||
androidx-testExt = "1.3.0"
|
|
||||||
composeHotReload = "1.0.0"
|
|
||||||
composeMultiplatform = "1.10.3"
|
composeMultiplatform = "1.10.3"
|
||||||
exposed = "0.55.0"
|
exposed = "0.55.0"
|
||||||
flyway = "12.4.0"
|
flyway = "12.4.0"
|
||||||
hikari = "6.2.1"
|
hikari = "6.2.1"
|
||||||
junit = "4.13.2"
|
|
||||||
kermit = "2.1.0"
|
kermit = "2.1.0"
|
||||||
koin = "4.2.1"
|
koin = "4.2.1"
|
||||||
|
koin-plugin = "1.0.0-RC2"
|
||||||
kotlin = "2.3.20"
|
kotlin = "2.3.20"
|
||||||
kotlinx-coroutines = "1.10.2"
|
kotlinx-coroutines = "1.10.2"
|
||||||
kotlinx-serialization = "1.7.3"
|
kotlinx-serialization = "1.7.3"
|
||||||
@@ -32,15 +26,10 @@ testcontainers = "1.21.4"
|
|||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
|
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
|
||||||
kotlin-testJunit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" }
|
kotlin-testJunit5 = { module = "org.jetbrains.kotlin:kotlin-test-junit5", version.ref = "kotlin" }
|
||||||
junit = { module = "junit:junit", version.ref = "junit" }
|
|
||||||
|
|
||||||
# kotlinx.serialization (shared DTOs — D-27)
|
# kotlinx.serialization (shared DTOs — D-27)
|
||||||
kotlinx-serializationJson = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
|
kotlinx-serializationJson = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
|
||||||
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-core" }
|
|
||||||
androidx-testExt-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-testExt" }
|
|
||||||
androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidx-espresso" }
|
|
||||||
androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" }
|
|
||||||
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" }
|
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" }
|
||||||
compose-uiTooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "composeMultiplatform" }
|
compose-uiTooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "composeMultiplatform" }
|
||||||
androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" }
|
androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" }
|
||||||
@@ -93,11 +82,9 @@ ktor-clientDarwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor
|
|||||||
ktor-clientCio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }
|
ktor-clientCio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }
|
||||||
ktor-serializationKotlinxJsonMpp = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
|
ktor-serializationKotlinxJsonMpp = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
|
||||||
|
|
||||||
# Phase 2 — Client: Lokksmith OIDC + Android secure storage + multiplatform-settings (D-01, D-13, AUTH-02)
|
# Phase 2 — Client: Lokksmith OIDC (Compose integration pulls core transitively) + multiplatform-settings (D-01, D-13, AUTH-02)
|
||||||
lokksmith-core = { module = "dev.lokksmith:lokksmith-core", version.ref = "lokksmith" }
|
lokksmith-compose = { module = "dev.lokksmith:lokksmith-compose", version.ref = "lokksmith" }
|
||||||
androidx-security-crypto = { module = "androidx.security:security-crypto", version.ref = "androidx-security-crypto" }
|
|
||||||
multiplatform-settings = { module = "com.russhwolf:multiplatform-settings", version.ref = "multiplatformSettings" }
|
multiplatform-settings = { module = "com.russhwolf:multiplatform-settings", version.ref = "multiplatformSettings" }
|
||||||
multiplatform-settings-coroutines = { module = "com.russhwolf:multiplatform-settings-coroutines", version.ref = "multiplatformSettings" }
|
|
||||||
|
|
||||||
# Phase 2 — Server: Exposed DSL + Hikari (D-26)
|
# Phase 2 — Server: Exposed DSL + Hikari (D-26)
|
||||||
exposed-core = { module = "org.jetbrains.exposed:exposed-core", version.ref = "exposed" }
|
exposed-core = { module = "org.jetbrains.exposed:exposed-core", version.ref = "exposed" }
|
||||||
@@ -112,7 +99,6 @@ testcontainers-junit-jupiter = { module = "org.testcontainers:junit-jupiter", ve
|
|||||||
[plugins]
|
[plugins]
|
||||||
androidApplication = { id = "com.android.application", version.ref = "agp" }
|
androidApplication = { id = "com.android.application", version.ref = "agp" }
|
||||||
androidLibrary = { id = "com.android.library", version.ref = "agp" }
|
androidLibrary = { id = "com.android.library", version.ref = "agp" }
|
||||||
composeHotReload = { id = "org.jetbrains.compose.hot-reload", version.ref = "composeHotReload" }
|
|
||||||
composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" }
|
composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" }
|
||||||
composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||||
kotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
|
kotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
|
||||||
@@ -121,3 +107,4 @@ ktor = { id = "io.ktor.plugin", version.ref = "ktor" }
|
|||||||
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
|
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
|
||||||
spotless = { id = "com.diffplug.spotless", version.ref = "spotless" }
|
spotless = { id = "com.diffplug.spotless", version.ref = "spotless" }
|
||||||
flywayPlugin = { id = "org.flywaydb.flyway", version.ref = "flyway" }
|
flywayPlugin = { id = "org.flywaydb.flyway", version.ref = "flyway" }
|
||||||
|
koin-compiler = { id = "io.insert-koin.compiler.plugin", version.ref = "koin-plugin" }
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
|
|
||||||
# yarn lockfile v1
|
|
||||||
|
|
||||||
|
|
||||||
"@js-joda/core@3.2.0":
|
|
||||||
version "3.2.0"
|
|
||||||
resolved "https://registry.yarnpkg.com/@js-joda/core/-/core-3.2.0.tgz#3e61e21b7b2b8a6be746df1335cf91d70db2a273"
|
|
||||||
integrity sha512-PMqgJ0sw5B7FKb2d5bWYIoxjri+QlW/Pys7+Rw82jSH0QN3rB05jZ/VrrsUdh1w4+i2kw9JOejXGq/KhDOX7Kg==
|
|
||||||
|
|
||||||
ws@8.18.3:
|
|
||||||
version "8.18.3"
|
|
||||||
resolved "https://registry.yarnpkg.com/ws/-/ws-8.18.3.tgz#b56b88abffde62791c639170400c93dcb0c95472"
|
|
||||||
integrity sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@ plugins {
|
|||||||
alias(libs.plugins.kotlinSerialization)
|
alias(libs.plugins.kotlinSerialization)
|
||||||
alias(libs.plugins.ktor)
|
alias(libs.plugins.ktor)
|
||||||
alias(libs.plugins.flywayPlugin)
|
alias(libs.plugins.flywayPlugin)
|
||||||
|
alias(libs.plugins.koin.compiler)
|
||||||
application
|
application
|
||||||
id("recipe.quality")
|
id("recipe.quality")
|
||||||
}
|
}
|
||||||
@@ -17,6 +18,10 @@ kotlin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tasks.test {
|
||||||
|
useJUnitPlatform()
|
||||||
|
}
|
||||||
|
|
||||||
application {
|
application {
|
||||||
mainClass.set("dev.ulfrx.recipe.ApplicationKt")
|
mainClass.set("dev.ulfrx.recipe.ApplicationKt")
|
||||||
|
|
||||||
@@ -36,7 +41,6 @@ dependencies {
|
|||||||
implementation(libs.postgresql)
|
implementation(libs.postgresql)
|
||||||
implementation(projects.shared)
|
implementation(projects.shared)
|
||||||
|
|
||||||
// Phase 2: Ktor auth + JWT validation + observability (D-21..D-23).
|
|
||||||
implementation(libs.ktor.serverAuth)
|
implementation(libs.ktor.serverAuth)
|
||||||
implementation(libs.ktor.serverAuthJwt)
|
implementation(libs.ktor.serverAuthJwt)
|
||||||
implementation(libs.ktor.serverCallLogging)
|
implementation(libs.ktor.serverCallLogging)
|
||||||
@@ -49,10 +53,8 @@ dependencies {
|
|||||||
implementation(libs.hikari)
|
implementation(libs.hikari)
|
||||||
|
|
||||||
testImplementation(libs.ktor.serverTestHost)
|
testImplementation(libs.ktor.serverTestHost)
|
||||||
testImplementation(libs.kotlin.testJunit)
|
testImplementation(libs.kotlin.testJunit5)
|
||||||
|
|
||||||
// Phase 2: Testcontainers for JIT user provisioning + JWT auth integration tests
|
|
||||||
// (AUTH-03, AUTH-06). Wired here so Plan 02-02 only needs to write tests.
|
|
||||||
testImplementation(libs.testcontainers.postgresql)
|
testImplementation(libs.testcontainers.postgresql)
|
||||||
testImplementation(libs.testcontainers.junit.jupiter)
|
testImplementation(libs.testcontainers.junit.jupiter)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,9 +15,11 @@ import io.ktor.server.routing.routing
|
|||||||
import io.ktor.server.testing.testApplication
|
import io.ktor.server.testing.testApplication
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import org.flywaydb.core.Flyway
|
import org.flywaydb.core.Flyway
|
||||||
import org.junit.AfterClass
|
import org.junit.jupiter.api.AfterAll
|
||||||
import org.junit.BeforeClass
|
import org.junit.jupiter.api.BeforeAll
|
||||||
import org.testcontainers.containers.PostgreSQLContainer
|
import org.testcontainers.containers.PostgreSQLContainer
|
||||||
|
import org.testcontainers.junit.jupiter.Container
|
||||||
|
import org.testcontainers.junit.jupiter.Testcontainers
|
||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
import kotlin.test.assertNotEquals
|
import kotlin.test.assertNotEquals
|
||||||
@@ -34,16 +36,18 @@ import org.jetbrains.exposed.sql.Database as ExposedDatabase
|
|||||||
* process before any test executes; Exposed is connected through Hikari to
|
* process before any test executes; Exposed is connected through Hikari to
|
||||||
* the container.
|
* the container.
|
||||||
*/
|
*/
|
||||||
|
@Testcontainers
|
||||||
class MeRouteTest {
|
class MeRouteTest {
|
||||||
companion object {
|
companion object {
|
||||||
|
@Container
|
||||||
|
@JvmStatic
|
||||||
private val postgres = PostgreSQLContainer("postgres:16")
|
private val postgres = PostgreSQLContainer("postgres:16")
|
||||||
private lateinit var dataSource: HikariDataSource
|
private lateinit var dataSource: HikariDataSource
|
||||||
private val json = Json { ignoreUnknownKeys = true }
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
@BeforeClass
|
@BeforeAll
|
||||||
fun setUpClass() {
|
fun setUpClass() {
|
||||||
postgres.start()
|
|
||||||
Flyway
|
Flyway
|
||||||
.configure()
|
.configure()
|
||||||
.dataSource(postgres.jdbcUrl, postgres.username, postgres.password)
|
.dataSource(postgres.jdbcUrl, postgres.username, postgres.password)
|
||||||
@@ -65,10 +69,9 @@ class MeRouteTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
@AfterClass
|
@AfterAll
|
||||||
fun tearDownClass() {
|
fun tearDownClass() {
|
||||||
dataSource.close()
|
dataSource.close()
|
||||||
postgres.stop()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ plugins {
|
|||||||
// AGP must apply BEFORE recipe.kotlin.multiplatform — the latter calls androidTarget(),
|
// AGP must apply BEFORE recipe.kotlin.multiplatform — the latter calls androidTarget(),
|
||||||
// which requires the Android Gradle Plugin to already be on the project.
|
// which requires the Android Gradle Plugin to already be on the project.
|
||||||
alias(libs.plugins.androidLibrary)
|
alias(libs.plugins.androidLibrary)
|
||||||
|
alias(libs.plugins.koin.compiler)
|
||||||
id("recipe.kotlin.multiplatform")
|
id("recipe.kotlin.multiplatform")
|
||||||
alias(libs.plugins.kotlinSerialization)
|
alias(libs.plugins.kotlinSerialization)
|
||||||
id("recipe.quality")
|
id("recipe.quality")
|
||||||
@@ -10,6 +11,11 @@ plugins {
|
|||||||
kotlin {
|
kotlin {
|
||||||
explicitApi()
|
explicitApi()
|
||||||
|
|
||||||
|
androidTarget()
|
||||||
|
iosArm64()
|
||||||
|
iosSimulatorArm64()
|
||||||
|
jvm()
|
||||||
|
|
||||||
// No iOS framework here — composeApp's umbrella `ComposeApp.framework`
|
// No iOS framework here — composeApp's umbrella `ComposeApp.framework`
|
||||||
// transitively exports shared. Producing a second framework would double-bundle
|
// transitively exports shared. Producing a second framework would double-bundle
|
||||||
// the Kotlin stdlib at link time (PITFALL: duplicate-framework collision).
|
// the Kotlin stdlib at link time (PITFALL: duplicate-framework collision).
|
||||||
@@ -23,6 +29,10 @@ kotlin {
|
|||||||
// re-declaring it.
|
// re-declaring it.
|
||||||
api(libs.kotlinx.serializationJson)
|
api(libs.kotlinx.serializationJson)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
commonTest.dependencies {
|
||||||
|
implementation(kotlin("test"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
package dev.ulfrx.recipe
|
|
||||||
|
|
||||||
public class JVMPlatform : Platform {
|
|
||||||
override val name: String = "Java ${System.getProperty("java.version")}"
|
|
||||||
}
|
|
||||||
|
|
||||||
public actual fun getPlatform(): Platform = JVMPlatform()
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
package dev.ulfrx.recipe
|
|
||||||
|
|
||||||
public class WasmPlatform : Platform {
|
|
||||||
override val name: String = "Web with Kotlin/Wasm"
|
|
||||||
}
|
|
||||||
|
|
||||||
public actual fun getPlatform(): Platform = WasmPlatform()
|
|
||||||
Reference in New Issue
Block a user