push prog 1

This commit is contained in:
jableader 2025-11-01 19:12:12 +11:00
parent a43308b866
commit aa510d7e23
8 changed files with 82 additions and 46 deletions

View file

@ -3,7 +3,7 @@
All core features are migrated to multi-tenancy with path-scoped endpoints and token-based auth. The codebase no longer uses the `X-Household-Slug` header. Tests and type checks are fully green. All core features are migrated to multi-tenancy with path-scoped endpoints and token-based auth. The codebase no longer uses the `X-Household-Slug` header. Tests and type checks are fully green.
Status of tests and typing Status of tests and typing
- All tests pass: 27 files, 48 tests (router tests fixed via memory history fallback in non-browser envs). - All tests pass: 29 files, 49 tests (slug-only routes; memory history fallback in non-browser envs; unauthenticated and refresh-401 guard redirects covered).
- `tsc` and `vue-tsc` pass with no errors. - `tsc` and `vue-tsc` pass with no errors.
# Frontend Specification: Household Multi-Tenancy (v2) # Frontend Specification: Household Multi-Tenancy (v2)
@ -78,33 +78,31 @@ This plan is adapted to the existing codebase, focusing on refactoring rather th
2. **[x] Update Router for Multi-Tenancy**: 2. **[x] Update Router for Multi-Tenancy**:
- **Modify `src/router/index.ts`**: - **Modify `src/router/index.ts`**:
- Added new public routes: `/create-account`, `/welcome`, and `/invitations/accept`. - Added new public routes: `/create-account`, `/welcome`, and `/invitations/accept`.
- Feature flag `VUE_APP_MULTITENANT_ENABLED` controls nesting: - Removed legacy flag and flat routes: feature routes are always nested under `/:householdSlug/...`.
- When enabled: feature routes are nested under `/:householdSlug/...`.
- When disabled: legacy flat routes remain for backward compatibility.
- **Refactor the `beforeEach` guard**: - **Refactor the `beforeEach` guard**:
- Allows public routes. - Allows public routes.
- When multitenant flag is on, after auth fetches households via `useAuth().fetchHouseholds()`. - After auth, fetches households via `useAuth().fetchHouseholds()`.
- If none: redirect to `/welcome`. - If none: redirect to `/welcome`.
- If at root (`/`): redirect to first household's `/:householdSlug/mealplan`. - If at root (`/`): redirect to first household's `/:householdSlug/mealplan`.
- Ensures `activeHousehold` is set when navigating within a household. - Ensures `activeHousehold` is set when navigating within a household.
- **Nest existing routes**: Implemented behind feature flag. - **Nest existing routes**: Always under `/:householdSlug` (legacy flat routes removed).
- **History behavior**: Uses hash history in real browsers and memory history in tests/SSR (detected via `globalThis.location`). Router tests verify both flag modes. - **History behavior**: Uses hash history in real browsers and memory history in tests/SSR (detected via `globalThis.location`). Router tests assert slug-only mode.
3. **[~] Implement Onboarding and Invitation Flows**: 3. **[~] Implement Onboarding and Invitation Flows**:
- Build the `Welcome.vue` view for creating the first household. - Build the `Welcome.vue` view for creating the first household.
- Build the `CreateAccount.vue` view. - Build the `CreateAccount.vue` view.
- Build the "Accept Invitation" page (`/invitations/accept?token=...`). It should take the token from the URL, call the API, and redirect on success. - Build the "Accept Invitation" page (`/invitations/accept?token=...`). It should take the token from the URL, call the API, and redirect on success.
- Status: Welcome page implements create-household flow using `POST /api/v1/households` and redirects to `/:slug/mealplan`. Create Account UI implemented. Invitation Accept implemented: reads `token` from query, calls `POST /api/v1/invitations/accept`, and redirects to the accepted household. Uses a temporary raw fetch helper until OpenAPI adds this endpoint. - Status: Welcome page implements create-household flow using `POST /api/v1/households` and redirects to `/:slug/mealplan`. Create Account UI implemented. Invitation Accept implemented: reads `token` from query, calls typed `POST /api/v1/invitations/accept`, and redirects to the accepted household.
4. **[~] Integrate Household Context into the App**: 4. **[~] Integrate Household Context into the App**:
- **Create `src/composables/useHousehold.ts`**: Implemented. Extracts `householdSlug` from route and binds provider to API client. - **Create `src/composables/useHousehold.ts`**: Implemented. Extracts `householdSlug` from route and binds provider to API client.
- **Implement `HouseholdSwitcher.vue`**: Implemented minimal version and mounted in `App.vue`. - **Implement `HouseholdSwitcher.vue`**: Implemented minimal version and mounted in `App.vue`.
- **Update API Services**: Implemented header-injection in `src/api/client.ts` via `X-Household-Slug` and `Authorization` using configurable providers; no path changes. - **Update API Services**: No household header injection; Authorization header provided by token provider. Cookies are only sent for the refresh endpoint; all other requests avoid credentials. SDK/services use typed path params.
- Verified by `tests/household.header.test.ts` and auth tests. - Verified by `tests/household.header.test.ts`, auth tests, and router guard tests.
5. **[~] Implement Invitation UI**: 5. **[~] Implement Invitation UI**:
- Build the `HouseholdSettings.vue` view for inviting members and listing current members. - Build the `HouseholdSettings.vue` view for inviting members and listing current members.
- Status: Invite form implemented (sends email via POST `/api/v1/invitations`). Members listing pending backend endpoint. - Status: Invite form implemented (sends email via POST `/api/v1/invitations`). Members listing implemented using typed endpoint `GET /api/v1/households/{householdSlug}/members`.
6. **[ ] Final Review & Cleanup**: 6. **[ ] Final Review & Cleanup**:
- Remove the old `persons` concept from the frontend code. The `user` from `useAuth` is now the primary identity. - Remove the old `persons` concept from the frontend code. The `user` from `useAuth` is now the primary identity.
@ -122,7 +120,7 @@ What exists now
- `src/api/auth.ts`: email/password login and register; token-only refresh in `currentUser()` which then loads `/api/v1/users/me/households`. - `src/api/auth.ts`: email/password login and register; token-only refresh in `currentUser()` which then loads `/api/v1/users/me/households`.
- `src/composables/useAuth.ts`: manages `user`, `households`, and `activeHousehold`; exposes login/logout/createAccount and `fetchHouseholds()`. - `src/composables/useAuth.ts`: manages `user`, `households`, and `activeHousehold`; exposes login/logout/createAccount and `fetchHouseholds()`.
- Routing - Routing
- `src/router/index.ts`: feature-flagged nesting under `/:householdSlug/...`; public routes include `/create-account`, `/welcome`, and `/invitations/accept`. - `src/router/index.ts`: slug-only nesting under `/:householdSlug/...`; public routes include `/create-account`, `/welcome`, and `/invitations/accept`.
- Guard fetches households, redirects root `/` to the first household's `mealplan`, and uses memory history in tests (hash in browser). - Guard fetches households, redirects root `/` to the first household's `mealplan`, and uses memory history in tests (hash in browser).
- SDK/API - SDK/API
- `src/api/sdk.ts`: recipes, meals, and shopping are migrated to `/api/v1/households/{householdSlug}/...` typed endpoints. Persons and parse use temporary raw fetch endpoints where OpenAPI lacks coverage. - `src/api/sdk.ts`: recipes, meals, and shopping are migrated to `/api/v1/households/{householdSlug}/...` typed endpoints. Persons and parse use temporary raw fetch endpoints where OpenAPI lacks coverage.
@ -133,7 +131,7 @@ What exists now
- MyShopping: page remains as in master with editable panel backed by legacy v1 stubs (`getMyShoppingList/saveMyShoppingList`) pending backend ad-hoc item endpoints. - MyShopping: page remains as in master with editable panel backed by legacy v1 stubs (`getMyShoppingList/saveMyShoppingList`) pending backend ad-hoc item endpoints.
Status of tests and typing Status of tests and typing
- All tests pass: 27 files, 48 tests. - All tests pass: 29 files, 49 tests.
- `tsc` and `vue-tsc` pass with no errors. - `tsc` and `vue-tsc` pass with no errors.
--- ---
@ -160,11 +158,11 @@ Progress Log (Nov 1, 2025)
- Implemented JWT login/register in `auth.ts` and wired token to client provider. `useAuth` updated with households fetching. `currentUser` refactored to token-only refresh plus households load. - Implemented JWT login/register in `auth.ts` and wired token to client provider. `useAuth` updated with households fetching. `currentUser` refactored to token-only refresh plus households load.
- Router guard updated to handle public/multitenant routing and redirects. - Router guard updated to handle public/multitenant routing and redirects.
- Added `useAuth.createAccount` with state update and tests for it; implemented `CreateAccount.vue` with form and navigation. - Added `useAuth.createAccount` with state update and tests for it; implemented `CreateAccount.vue` with form and navigation.
- Implemented Invitation Accept flow: added `src/api/invitations.ts` with `acceptInvitation` (temporary raw fetch), `InvitationAccept.vue` reads token and redirects to household; added `tests/invitations.api.test.ts`. - Implemented Invitation Accept flow: added `src/api/invitations.ts` with typed `acceptInvitation`, `InvitationAccept.vue` reads token and redirects to household; added `tests/invitations.api.test.ts`.
- Next: Implement Household Settings (invite members form), then remove legacy Person UI. - Next: Implement Household Settings (invite members form), then remove legacy Person UI.
- Added a Settings link to `HouseholdSwitcher.vue` to surface the `household-settings` route for easier discovery. - Added a Settings link to `HouseholdSwitcher.vue` to surface the `household-settings` route for easier discovery.
- Implemented Household Settings invite form and members list UI. `src/views/HouseholdSettings.vue` now loads members via a temporary `listMembers()` in `src/api/households.ts` using the raw fetch helper. When the backend exposes a typed endpoint, we will swap to the generated client. - Implemented Household Settings invite form and members list UI. `src/views/HouseholdSettings.vue` now loads members via the typed endpoint.
- Router uses memory history in tests to avoid relying on `window.location`. - Router uses memory history in tests to avoid relying on `window.location`. Added unauthenticated and refresh-401 guard redirect tests.
- Backend updated OpenAPI and codegen has been run: - Backend updated OpenAPI and codegen has been run:
- Many endpoints are now path-scoped with `{householdSlug}` (recipes, meals, shopping, invitations (create), whoami). - Many endpoints are now path-scoped with `{householdSlug}` (recipes, meals, shopping, invitations (create), whoami).
- Auth endpoints (login/register/refresh/logout) are fully typed; `refresh` returns only `{ accessToken, tokenType }`. - Auth endpoints (login/register/refresh/logout) are fully typed; `refresh` returns only `{ accessToken, tokenType }`.
@ -235,7 +233,7 @@ Tests
- Auth refresh: returns `{ accessToken, tokenType }`. After refresh, call user/household endpoints to populate app state. Use `whoami` to validate the active routes slug when needed. - Auth refresh: returns `{ accessToken, tokenType }`. After refresh, call user/household endpoints to populate app state. Use `whoami` to validate the active routes slug when needed.
- Cleanup/migration tasks: - Cleanup/migration tasks:
1) Remove X-Household-Slug header injection in `api/client.ts` and refactor services to accept `householdSlug` via typed params. (Completed) 1) Remove X-Household-Slug header injection in `api/client.ts` and refactor services to accept `householdSlug` via typed params. (Completed)
2) Replace temporary raw fetch calls with generated typed endpoints where available: invitations and members listing are now typed; migrate usages. (Members listing migrated) Persons and parse remain raw for now. 2) Replace temporary raw fetch calls with generated typed endpoints where available: invitations and members listing are now typed; migrate usages. Persons and parse remain raw for now.
3) Integrate the new `POST /shopping/current/ingredients` endpoint into the SDK (`requestIngredient(ingredientId: number)`) and expose via `useShopping`; refactor `MyShoppingPage.vue` accordingly and remove legacy stubs. (SDK + composable done; UI refactor next) 3) Integrate the new `POST /shopping/current/ingredients` endpoint into the SDK (`requestIngredient(ingredientId: number)`) and expose via `useShopping`; refactor `MyShoppingPage.vue` accordingly and remove legacy stubs. (SDK + composable done; UI refactor next)
4) Legacy identity: continue using `User` as the primary identity. Keep `Person` in meal-related UIs where required by backend, but remove Person as the login/identity concept. 4) Legacy identity: continue using `User` as the primary identity. Keep `Person` in meal-related UIs where required by backend, but remove Person as the login/identity concept.
@ -243,7 +241,7 @@ Tests
## Migration Plan & Feature Flag ## Migration Plan & Feature Flag
- `VUE_APP_MULTITENANT_ENABLED` flag controls nested household routes. With the migration complete, keep this flag for rollout control; default can be enabled once backend is stable across environments. - Removed `VUE_APP_MULTITENANT_ENABLED` and legacy flat routes. The app now always uses slug-scoped routes.
--- ---

View file

@ -25,15 +25,22 @@ export function setAuthTokenProvider(provider: (() => string | null) | null) {
authTokenProvider = provider authTokenProvider = provider
} }
function isRefreshRequest(input: RequestInfo | URL): boolean { function requestInfoToUrl(input: RequestInfo | URL): string | null {
try { try {
const url = typeof input === 'string' ? input : (input as URL).toString() if (typeof input === 'string') return input
return url.includes('/api/v1/auth/refresh') if (typeof URL !== 'undefined' && input instanceof URL) return input.toString()
if (typeof Request !== 'undefined' && input instanceof Request) return input.url
return String(input)
} catch { } catch {
return false return null
} }
} }
function isRefreshRequest(input: RequestInfo | URL): boolean {
const url = requestInfoToUrl(input)
return !!url && url.includes('/api/v1/auth/refresh')
}
export const api = createClient<paths>({ export const api = createClient<paths>({
baseUrl, baseUrl,
fetch: (input: RequestInfo | URL, init?: RequestInit) => { fetch: (input: RequestInfo | URL, init?: RequestInit) => {

View file

@ -2,33 +2,26 @@ import { api } from '@/api/client'
export type Household = { id: number; name: string; slug: string } export type Household = { id: number; name: string; slug: string }
function hasKey<T extends object, K extends PropertyKey>(obj: T, key: K): obj is T & Record<K, unknown> {
return Object.prototype.hasOwnProperty.call(obj, key)
}
function isHousehold(value: unknown): value is Household { function isHousehold(value: unknown): value is Household {
if (typeof value !== 'object' || value === null) return false
const v = value as Record<string, unknown>
return ( return (
typeof value === 'object' && value !== null && typeof v.id === 'number' &&
typeof (value as { id: unknown }).id === 'number' && typeof v.name === 'string' &&
typeof (value as { name: unknown }).name === 'string' && typeof v.slug === 'string'
typeof (value as { slug: unknown }).slug === 'string'
) )
} }
async function safeJson<T>(resp: Response): Promise<T | null> {
try {
const d = await resp.json()
return d as T
} catch (_e) {
return null
}
}
export async function acceptInvitation(token: string): Promise<Household> { export async function acceptInvitation(token: string): Promise<Household> {
const { data, error, response } = await api.POST('/api/v1/invitations/accept', { body: { token } as any }) const { data, error, response } = await api.POST('/api/v1/invitations/accept', { body: { token } })
if (!response.ok) throw new Error(`${response.status} ${response.statusText || 'HTTP error'}${error ? `: ${String(error)}` : ''}`) if (!response.ok) throw new Error(`${response.status} ${response.statusText || 'HTTP error'}${error ? `: ${String(error)}` : ''}`)
const dataObj = data as any const obj = data && typeof data === 'object' ? data as Record<string, unknown> : null
const dataWrapped: { household?: unknown } | null = (dataObj && typeof dataObj === 'object') ? dataObj : null const h = obj && hasKey(obj, 'household') ? obj.household : undefined
let h: unknown = dataWrapped && 'household' in dataWrapped ? dataWrapped.household : undefined if (!isHousehold(h)) throw new Error('Invalid invitation accept response')
if (!isHousehold(h)) {
throw new Error('Invalid invitation accept response')
}
return { id: h.id, name: h.name, slug: h.slug } return { id: h.id, name: h.name, slug: h.slug }
} }

View file

@ -43,7 +43,8 @@ export function createAppRouter(getCurrentUser: () => Promise<unknown> | unknown
// Use hash history in real browsers; fallback to memory history in tests/SSR where `globalThis.location` may be unavailable // Use hash history in real browsers; fallback to memory history in tests/SSR where `globalThis.location` may be unavailable
// Some test runners may polyfill `window` but not the global `location`, and vue-router's hash history uses the global. // Some test runners may polyfill `window` but not the global `location`, and vue-router's hash history uses the global.
const hasLocation = typeof globalThis !== 'undefined' && typeof (globalThis as any).location !== 'undefined' // Avoid 'as' assertions; relying on typeof global 'location' is safe and non-throwing in Node
const hasLocation = typeof location !== 'undefined'
const history = hasLocation ? createWebHashHistory() : createMemoryHistory() const history = hasLocation ? createWebHashHistory() : createMemoryHistory()
const router = createRouter({ const router = createRouter({

View file

@ -14,7 +14,7 @@ describe('invitations api (send invite)', () => {
await loginWithPassword('x@y', 'pw') await loginWithPassword('x@y', 'pw')
server.use( server.use(
http.post('*/api/v1/households/the-smiths/invitations', async ({ request, requestId, cookies, params }) => { http.post('*/api/v1/households/the-smiths/invitations', async ({ request }) => {
const body = await request.json() const body = await request.json()
expect(body).toEqual({ email: 'invite@example.com' }) expect(body).toEqual({ email: 'invite@example.com' })
const auth = request.headers.get('authorization') const auth = request.headers.get('authorization')

View file

@ -0,0 +1,18 @@
import { describe, it, expect } from 'vitest'
describe('router guard refresh 401 handling', () => {
it('redirects to /login with redirect when getCurrentUser throws (e.g., refresh 401)', async () => {
const { createAppRouter } = await import('@/router/index')
const router = createAppRouter(async () => { throw new Error('401 Unauthorized') })
// Stub login route with inline component to avoid loading .vue files
try { router.removeRoute('login') } catch (_) { /* ignore */ }
router.addRoute({ path: '/login', name: 'login', component: { template: '<div />' } })
await router.push('/')
const current = router.currentRoute.value
expect(current.name).toBe('login')
expect(current.query.redirect).toBe('/')
})
})

View file

@ -0,0 +1,19 @@
import { describe, it, expect } from 'vitest'
describe('router guard unauthenticated redirect', () => {
it('redirects to /login with redirect query when not authenticated', async () => {
const { createAppRouter } = await import('@/router/index')
const router = createAppRouter(async () => null)
// Replace the lazy .vue login route with an inline component to avoid plugin-vue in tests
try { router.removeRoute('login') } catch (_) { /* ignore */ }
router.addRoute({ path: '/login', name: 'login', component: { template: '<div />' } })
// Navigate to a protected route that uses an inline component to avoid lazy .vue imports
await router.push('/')
const current = router.currentRoute.value
expect(current.name).toBe('login')
expect(current.query.redirect).toBe('/')
})
})