housekeeping

This commit is contained in:
jableader 2025-11-01 13:44:58 +11:00
parent faf0a91d2d
commit a446bc1c11
15 changed files with 402 additions and 66 deletions

View file

@ -57,37 +57,40 @@ This plan is adapted to the existing codebase, focusing on refactoring rather th
## 4. Actionable Implementation Steps
1. **[ ] Refactor Authentication State & API**:
1. **[~] Refactor Authentication State & API**:
- **Modify `src/api/auth.ts`**:
- Replace `login(username: string)` with `login(email, password)`.
- Add `createAccount(email, displayName, password)`, `handleGoogleLogin(token)`, and `logout()`.
- The API client should handle storing and clearing the auth token (e.g., from `localStorage`).
- Added minimal multitenant-ready surface while preserving legacy login:
- `loginWithPassword(email, password)` implemented by delegating to legacy `login(username)`; tests added.
- `logout()` clears cached user; covered by tests.
- Stubs for `createAccount(email, displayName, password)` and `handleGoogleLogin(token)` throw pending-implementation errors; tests assert presence.
- Token/JWT storage is still cookie-based (per current backend); no localStorage changes yet.
- **Modify `src/composables/useAuth.ts`**:
- Update the `login` function to accept email/password.
- Add a `register` function.
- The `user` ref should now hold the global User profile, and you should add a new state for `households` and `activeHousehold`.
- The `logout` function should clear the JWT and all user state.
- TODO: Update to accept email/password and expose `register`, `logout`, `households`, and `activeHousehold`. Not started.
2. **[ ] Update Router for Multi-Tenancy**:
2. **[~] Update Router for Multi-Tenancy**:
- **Modify `src/router/index.ts`**:
- Add 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:
- When enabled: feature routes are nested under `/:householdSlug/...`.
- When disabled: legacy flat routes remain for backward compatibility.
- **Refactor the `beforeEach` guard**:
- It should allow access to public routes.
- After login, it must fetch the user's households.
- If the user has no households, redirect to `/welcome`.
- If the user has households but is at the root (`/`), redirect to the first household's dashboard (e.g., `/${householdSlug}/dashboard`).
- **Nest existing routes**: All current data-related routes (`/meals`, `/shopping`, etc.) must be moved as children of a new dynamic `/:householdSlug` route.
- **Nest existing routes**: Implemented behind feature flag.
3. **[ ] Implement Onboarding and Invitation Flows**:
- Build the `Welcome.vue` view for creating the first household.
- 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.
- Status: Placeholder views created for all three; logic pending.
4. **[ ] Integrate Household Context into the App**:
- **Create `src/composables/useHousehold.ts`**: This composable should extract the `householdSlug` from the current route's params. It will provide the `activeHouseholdSlug` to any component or service that needs it.
- **Implement `HouseholdSwitcher.vue`**: This component will use `useAuth` to get the list of the user's households and render navigation links.
- **Update API Services**: All data-fetching calls (e.g., for recipes, meals) must be updated to use the `activeHouseholdSlug` from `useHousehold`. The API client wrapper should be modified to prepend this slug to the request URL.
- Example: `api.get('/recipes')` becomes `api.get(\`/${activeHouseholdSlug.value}/recipes\`)`.
4. **[~] Integrate Household Context into the App**:
- **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`.
- **Update API Services**: Implemented header-injection in `src/api/client.ts` via `X-Household-Slug` using a configurable provider; no path changes.
- Verified by `tests/household.header.test.ts`.
5. **[ ] Implement Invitation UI**:
- Build the `HouseholdSettings.vue` view for inviting members and listing current members.
@ -119,11 +122,11 @@ What exists today
Gaps vs requirements
- Authentication & onboarding
- Missing email/password login, Google OAuth, account creation, logout, JWT storage/refresh.
- Partial: Added `loginWithPassword` shim and `logout`. Still missing Google OAuth, account creation, JWT storage/refresh beyond cookie.
- Missing `CreateAccount.vue`, `Welcome.vue`, invitation acceptance flow.
- Routing & URL-based tenancy
- Missing `/:householdSlug/...` route nesting and post-login redirect logic.
- No `useHousehold.ts`; no `HouseholdSwitcher.vue`.
- Partial: Feature-flagged nesting implemented; still need guard logic for fetching households and redirects.
- `useHousehold.ts` and `HouseholdSwitcher.vue` added; further wiring to fetch households pending.
- API boundary
- No household scoping passed to backend. Need a typed strategy (prefer header parameter) without breaking OpenAPI typing.
- Cleanup
@ -144,6 +147,18 @@ To align with OpenAPI typing and README axioms, do not rewrite request paths to
Action
- Implement header injection in `api/client.ts` with a pluggable getter for the active slug (decoupled from Vue imports). Update this spec once backend finalizes the parameter shape.
- DONE: Implemented with `setHouseholdSlugProvider`. Will align to OpenAPI when backend finalizes.
---
Progress Log (Nov 1, 2025)
- Established green baseline (typecheck + tests pass).
- Added auth API tests driving a minimal multitenant-ready surface.
- Implemented `loginWithPassword`, `logout`, and stubs in `src/api/auth.ts` to satisfy tests.
- Refactored `useAuth` to add households and activeHousehold state, plus `loginWithPassword` and `logout`. Tests added and passing.
- Added router tests and implemented feature-flagged nested routes and new public routes. Placeholders for onboarding/invitations added.
- Implemented `useHousehold.ts`, header injection provider in API client, minimal `HouseholdSwitcher.vue`, and mounted it. Added a header injection test.
- Next: Update `LoginPage.vue` to email/password UI using `useAuth.loginWithPassword`; implement router guard post-login household redirects; scaffold `HouseholdSettings.vue` and households fetching in `useAuth`.
---

View file

@ -29,6 +29,7 @@
</router-link>
</li>
</ul>
<HouseholdSwitcher />
</div>
<div class="viewport">
<router-view />
@ -39,6 +40,11 @@
<script setup>
import AlertToast from './components/AlertToast.vue'
import HouseholdSwitcher from '@/components/HouseholdSwitcher.vue'
import { useHousehold } from '@/composables/useHousehold'
// Initialize household slug provider binding to current route
useHousehold()
// components in <script setup> are auto-registered by import + usage
</script>

View file

@ -28,3 +28,24 @@ export async function login(username: string): Promise<Person> {
if (!cachedUser) throw new Error('Login failed: empty response')
return cachedUser
}
// New multitenant-ready API surface (backward compatible)
export async function loginWithPassword(email: string, _password: string): Promise<Person> {
// Backend currently expects { username }; map email to username until OpenAPI updates
return login(email)
}
export async function createAccount(_email: string, _displayName: string, _password: string): Promise<never> {
// Placeholder until backend endpoints and OpenAPI are finalized
throw new Error('createAccount not implemented yet')
}
export async function handleGoogleLogin(_token: string): Promise<never> {
// Placeholder until Google OAuth flow is wired
throw new Error('handleGoogleLogin not implemented yet')
}
export async function logout(): Promise<void> {
// Clear local cache; server session is cookie-based and will be refreshed on next call
cachedUser = null
}

View file

@ -9,12 +9,22 @@ const isTest = typeof process !== 'undefined' && (process.env?.VITEST === 'true'
const defaultBase = ''
const baseUrl: string = isTest ? 'http://localhost' : (vueCliBase || defaultBase)
let householdSlugProvider: (() => string | null) | null = null
export function setHouseholdSlugProvider(provider: (() => string | null) | null) {
householdSlugProvider = provider
}
export const api = createClient<paths>({
baseUrl,
fetch: (input: RequestInfo | URL, init?: RequestInit) => {
const headers = new Headers(init?.headers || {})
const slug = householdSlugProvider ? householdSlugProvider() : null
if (slug) headers.set('X-Household-Slug', slug)
return globalThis.fetch(input, {
credentials: 'include',
...init,
headers,
})
},
})

View file

@ -0,0 +1,42 @@
<template>
<div v-if="enabled && households.length > 0" class="household-switcher">
<label>Household:</label>
<ul>
<li v-for="h in households" :key="h.slug">
<router-link :to="toHousehold(h.slug)" :class="{ active: h.slug === activeSlug }">
{{ h.name }}
</router-link>
</li>
</ul>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { useAuth } from '@/composables/useAuth'
const enabled = (typeof process !== 'undefined' && (process.env?.VUE_APP_MULTITENANT_ENABLED === 'true'))
const route = useRoute()
const { households } = useAuth()
const activeSlug = computed(() => (typeof route.params.householdSlug === 'string' ? route.params.householdSlug : null))
function toHousehold(slug: string) {
if (enabled) return { name: 'mealplan', params: { householdSlug: slug } }
return { name: 'mealplan' }
}
</script>
<style scoped>
.household-switcher {
display: inline-block;
}
.household-switcher ul {
list-style: none;
display: inline-flex;
gap: 8px;
margin: 0 0 0 8px;
padding: 0;
}
</style>

View file

@ -1,8 +1,12 @@
import { ref } from 'vue'
import { currentUser as apiCurrentUser, login as apiLogin } from '@/api/auth'
import { currentUser as apiCurrentUser, login as apiLogin, loginWithPassword as apiLoginWithPassword, logout as apiLogout } from '@/api/auth'
import type { Person } from '@/domain/types'
type Household = { id: number; name: string; slug: string }
const user = ref<Person | null>(null)
const households = ref<Household[]>([])
const activeHousehold = ref<Household | null>(null)
let initialized = false
export async function loadUser() {
@ -18,6 +22,34 @@ export async function login(username: string) {
return user.value
}
export function useAuth() {
return { user, loadUser, login }
export async function loginWithPassword(email: string, password: string) {
user.value = await apiLoginWithPassword(email, password)
return user.value
}
export async function logout() {
await apiLogout()
user.value = null
households.value = []
activeHousehold.value = null
}
export function setHouseholds(hs: Household[]) {
households.value = Array.isArray(hs) ? hs.slice() : []
}
export function setActiveHousehold(h: Household | string | null) {
if (h == null) {
activeHousehold.value = null
return
}
if (typeof h === 'string') {
activeHousehold.value = households.value.find((x) => x.slug === h) ?? null
} else {
activeHousehold.value = h
}
}
export function useAuth() {
return { user, households, activeHousehold, loadUser, login, loginWithPassword, logout, setHouseholds, setActiveHousehold }
}

View file

@ -0,0 +1,18 @@
import { computed, watchEffect } from 'vue'
import { useRoute } from 'vue-router'
import { setHouseholdSlugProvider } from '@/api/client'
export function useHousehold() {
const route = useRoute()
const activeHouseholdSlug = computed<string | null>(() => {
const p = route.params?.householdSlug
return typeof p === 'string' ? p : null
})
// Keep API client in sync with current route's household slug
watchEffect(() => {
setHouseholdSlugProvider(() => activeHouseholdSlug.value)
})
return { activeHouseholdSlug }
}

View file

@ -10,55 +10,54 @@ const PurchasedShoppingListPage = () => import('@/components/shopping/PurchasedS
const CurrentShoppingListPage = () => import('@/components/shopping/CurrentShoppingListPage.vue')
const EditMealPage = () => import('@/components/meals/EditMealPage.vue')
const EditRecipePage = () => import('@/components/recipes/EditRecipePage.vue')
const CreateAccount = () => import('@/views/CreateAccount.vue')
const Welcome = () => import('@/views/Welcome.vue')
const InvitationAccept = () => import('@/views/InvitationAccept.vue')
export function createAppRouter(getCurrentUser: () => Promise<unknown> | unknown): Router {
const routes: RouteRecordRaw[] = [
{ path: '/', redirect: { name: 'mealplan' } },
const multitenantEnabled = typeof process !== 'undefined' && process.env?.VUE_APP_MULTITENANT_ENABLED === 'true'
const publicRoutes: RouteRecordRaw[] = [
{ path: '/login', name: 'login', component: LoginPage },
{ path: '/mealplan', name: 'mealplan', component: MealPlanPage, meta: { requiresAuth: true } },
{
path: '/shopping',
name: 'shopping',
component: MyShoppingPage,
meta: { requiresAuth: true },
},
{
path: '/shopping/current',
name: 'shopping-current',
component: CurrentShoppingListPage,
meta: { requiresAuth: true },
},
{
path: '/shopping/:id',
name: 'shopping-list',
component: PurchasedShoppingListPage,
props: true,
meta: { requiresAuth: true },
},
{ path: '/recipes', name: 'recipes', component: RecipesPage, meta: { requiresAuth: true } },
{
path: '/recipes/add',
name: 'recipe-add',
component: EditRecipePage,
meta: { requiresAuth: true },
},
{
path: '/recipes/:id',
name: 'recipe-edit',
component: EditRecipePage,
props: true,
meta: { requiresAuth: true },
},
{ path: '/meals/add', name: 'meal-add', component: EditMealPage, meta: { requiresAuth: true } },
{
path: '/meals/:id',
name: 'meal-edit',
component: EditMealPage,
props: true,
meta: { requiresAuth: true },
},
{ path: '/create-account', name: 'create-account', component: CreateAccount },
{ path: '/welcome', name: 'welcome', component: Welcome },
{ path: '/invitations/accept', name: 'invitation-accept', component: InvitationAccept },
]
const featureChildren: RouteRecordRaw[] = [
{ path: 'recipes', name: 'recipes', component: RecipesPage, meta: { requiresAuth: true } },
{ path: 'recipes/add', name: 'recipe-add', component: EditRecipePage, meta: { requiresAuth: true } },
{ path: 'recipes/:id', name: 'recipe-edit', component: EditRecipePage, props: true, meta: { requiresAuth: true } },
{ path: 'mealplan', name: 'mealplan', component: MealPlanPage, meta: { requiresAuth: true } },
{ path: 'meals/add', name: 'meal-add', component: EditMealPage, meta: { requiresAuth: true } },
{ path: 'meals/:id', name: 'meal-edit', component: EditMealPage, props: true, meta: { requiresAuth: true } },
{ path: 'shopping', name: 'shopping', component: MyShoppingPage, meta: { requiresAuth: true } },
{ path: 'shopping/current', name: 'shopping-current', component: CurrentShoppingListPage, meta: { requiresAuth: true } },
{ path: 'shopping/:id', name: 'shopping-list', component: PurchasedShoppingListPage, props: true, meta: { requiresAuth: true } },
]
const routes: RouteRecordRaw[] = []
if (multitenantEnabled) {
routes.push({ path: '/', redirect: { name: 'login' } })
routes.push(...publicRoutes)
routes.push({ path: '/:householdSlug', component: { template: '<router-view />' }, children: featureChildren })
} else {
routes.push({ path: '/', redirect: { name: 'mealplan' } })
routes.push(...publicRoutes)
// legacy flat routes
routes.push(
{ path: '/recipes', name: 'recipes', component: RecipesPage, meta: { requiresAuth: true } },
{ path: '/recipes/add', name: 'recipe-add', component: EditRecipePage, meta: { requiresAuth: true } },
{ path: '/recipes/:id', name: 'recipe-edit', component: EditRecipePage, props: true, meta: { requiresAuth: true } },
{ path: '/mealplan', name: 'mealplan', component: MealPlanPage, meta: { requiresAuth: true } },
{ path: '/meals/add', name: 'meal-add', component: EditMealPage, meta: { requiresAuth: true } },
{ path: '/meals/:id', name: 'meal-edit', component: EditMealPage, props: true, meta: { requiresAuth: true } },
{ path: '/shopping', name: 'shopping', component: MyShoppingPage, meta: { requiresAuth: true } },
{ path: '/shopping/current', name: 'shopping-current', component: CurrentShoppingListPage, meta: { requiresAuth: true } },
{ path: '/shopping/:id', name: 'shopping-list', component: PurchasedShoppingListPage, props: true, meta: { requiresAuth: true } },
)
}
const router = createRouter({
history: createWebHashHistory(),
routes,

View file

@ -0,0 +1,10 @@
<template>
<div>
<h1>Create Account</h1>
<p>Placeholder for email, display name, password, and Google signup.</p>
</div>
</template>
<script setup lang="ts">
// TODO: Implement form per spec
</script>

View file

@ -0,0 +1,10 @@
<template>
<div>
<h1>Accept Invitation</h1>
<p>Processing your invitation token...</p>
</div>
</template>
<script setup lang="ts">
// TODO: Read token from route, call API, and redirect
</script>

10
src/views/Welcome.vue Normal file
View file

@ -0,0 +1,10 @@
<template>
<div>
<h1>Welcome</h1>
<p>Choose to create a new household or join via invitation link.</p>
</div>
</template>
<script setup lang="ts">
// TODO: Implement onboarding flow per spec
</script>

42
tests/auth.api.test.ts Normal file
View file

@ -0,0 +1,42 @@
import { describe, it, expect } from 'vitest'
import { server, http, HttpResponse } from './test-setup'
import { currentUser, logout, loginWithPassword, createAccount, handleGoogleLogin } from '@/api/auth'
describe('auth api (multitenant prep)', () => {
it('loginWithPassword proxies to /auth/login with username=email', async () => {
server.use(
http.post('*/api/v1/auth/login', async ({ request }) => {
const body = await request.json()
expect(body).toEqual({ username: 'test@example.com' })
return HttpResponse.json({ id: 123, name: 'Test User' })
})
)
const person = await loginWithPassword('test@example.com', 'secret')
expect(person.id).toBe(123)
expect(person.name).toBe('Test User')
})
it('logout clears cached user; subsequent currentUser returns null', async () => {
// First refresh returns a user
server.use(
http.post('*/api/v1/auth/refresh', () => {
return HttpResponse.json({ id: 1, name: 'Ada' })
})
)
const first = await currentUser()
expect(first?.name).toBe('Ada')
// After logout, next refresh returns 401 and currentUser should resolve to null
await logout()
server.use(
http.post('*/api/v1/auth/refresh', () => new HttpResponse(null, { status: 401 }))
)
const second = await currentUser()
expect(second).toBeNull()
})
it('createAccount and handleGoogleLogin exist but are not implemented yet', async () => {
await expect(createAccount('new@example.com', 'New User', 'pw')).rejects.toBeInstanceOf(Error)
await expect(handleGoogleLogin('token-123')).rejects.toBeInstanceOf(Error)
})
})

View file

@ -0,0 +1,23 @@
import { describe, it, expect } from 'vitest'
import { server, http, HttpResponse } from './test-setup'
import { api } from '@/api/client'
describe('API household header injection', () => {
it('sends X-Household-Slug when provider returns slug', async () => {
server.use(
http.get('*/api/v1/recipes', ({ request }) => {
// Should include our header set by client provider
const slug = request.headers.get('x-household-slug') || request.headers.get('X-Household-Slug')
if (!slug) return new HttpResponse(null, { status: 400 })
return HttpResponse.json([])
})
)
// Directly set provider without Vue router by calling internal setter via dynamic import
const { setHouseholdSlugProvider } = await import('@/api/client')
setHouseholdSlugProvider(() => 'the-smiths')
const res = await api.GET('/api/v1/recipes', { params: {} })
expect(res.response.ok).toBe(true)
})
})

View file

@ -0,0 +1,36 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
describe('router multitenant routing', () => {
beforeEach(() => {
vi.resetModules()
delete (process.env as any).VUE_APP_MULTITENANT_ENABLED
})
it('includes public routes and legacy flat routes when flag disabled', async () => {
const { createAppRouter } = await import('@/router/index')
const router = createAppRouter(() => null)
const paths = router.getRoutes().map((r) => r.path)
expect(paths).toContain('/login')
expect(paths).toContain('/create-account')
expect(paths).toContain('/welcome')
expect(paths).toContain('/invitations/accept')
// legacy flat routes
expect(paths).toContain('/recipes')
expect(paths).toContain('/shopping')
})
it('nests routes under /:householdSlug when flag enabled', async () => {
;(process.env as any).VUE_APP_MULTITENANT_ENABLED = 'true'
const { createAppRouter } = await import('@/router/index')
const router = createAppRouter(() => null)
const paths = router.getRoutes().map((r) => r.path)
// Public routes still exist
expect(paths).toContain('/login')
expect(paths).toContain('/create-account')
// Nested route example
const hasNestedRecipes = paths.some((p) => p === '/:householdSlug/recipes')
expect(hasNestedRecipes).toBe(true)
// Legacy route should not be present when nested is enabled
expect(paths).not.toContain('/recipes')
})
})

View file

@ -0,0 +1,62 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('@/api/auth', () => {
return {
currentUser: vi.fn(async () => ({ id: 42, name: 'Grace Hopper' })),
login: vi.fn(async (username: string) => ({ id: 7, name: username })),
loginWithPassword: vi.fn(async (email: string) => ({ id: 8, name: email })),
logout: vi.fn(async () => {}),
createAccount: vi.fn(async () => {
throw new Error('createAccount not implemented yet')
}),
handleGoogleLogin: vi.fn(async () => {
throw new Error('handleGoogleLogin not implemented yet')
}),
}
})
import { useAuth, loadUser } from '@/composables/useAuth'
describe('useAuth (multitenant state)', () => {
beforeEach(() => {
// Reset modules and state between tests
vi.resetModules()
})
it('exposes households and activeHousehold state with setters', async () => {
const { user, households, activeHousehold, setHouseholds, setActiveHousehold } = useAuth()
expect(user.value).toBeNull()
expect(households.value).toEqual([])
expect(activeHousehold.value).toBeNull()
const hs = [
{ id: 1, name: 'Smiths', slug: 'the-smiths' },
{ id: 2, name: 'Johnsons', slug: 'the-johnsons' },
]
setHouseholds(hs)
expect(households.value.length).toBe(2)
setActiveHousehold(hs[1])
expect(activeHousehold.value?.slug).toBe('the-johnsons')
setActiveHousehold('the-smiths')
expect(activeHousehold.value?.slug).toBe('the-smiths')
})
it('supports loading current user, loginWithPassword, and logout clearing user', async () => {
const { user, loginWithPassword, logout } = useAuth()
// load current user
await loadUser()
expect(user.value?.name).toBe('Grace Hopper')
// login with email/password
await loginWithPassword('user@example.com', 'pw')
expect(user.value?.name).toBe('user@example.com')
// logout clears user
await logout()
expect(user.value).toBeNull()
})
it('keeps legacy login(username) available for now', async () => {
const { user, login } = useAuth()
await login('legacy-user')
expect(user.value?.name).toBe('legacy-user')
})
})