housekeeping
This commit is contained in:
parent
faf0a91d2d
commit
a446bc1c11
15 changed files with 402 additions and 66 deletions
|
|
@ -57,37 +57,40 @@ This plan is adapted to the existing codebase, focusing on refactoring rather th
|
||||||
|
|
||||||
## 4. Actionable Implementation Steps
|
## 4. Actionable Implementation Steps
|
||||||
|
|
||||||
1. **[ ] Refactor Authentication State & API**:
|
1. **[~] Refactor Authentication State & API**:
|
||||||
- **Modify `src/api/auth.ts`**:
|
- **Modify `src/api/auth.ts`**:
|
||||||
- Replace `login(username: string)` with `login(email, password)`.
|
- Added minimal multitenant-ready surface while preserving legacy login:
|
||||||
- Add `createAccount(email, displayName, password)`, `handleGoogleLogin(token)`, and `logout()`.
|
- `loginWithPassword(email, password)` implemented by delegating to legacy `login(username)`; tests added.
|
||||||
- The API client should handle storing and clearing the auth token (e.g., from `localStorage`).
|
- `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`**:
|
- **Modify `src/composables/useAuth.ts`**:
|
||||||
- Update the `login` function to accept email/password.
|
- TODO: Update to accept email/password and expose `register`, `logout`, `households`, and `activeHousehold`. Not started.
|
||||||
- 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.
|
|
||||||
|
|
||||||
2. **[ ] Update Router for Multi-Tenancy**:
|
2. **[~] Update Router for Multi-Tenancy**:
|
||||||
- **Modify `src/router/index.ts`**:
|
- **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**:
|
- **Refactor the `beforeEach` guard**:
|
||||||
- It should allow access to public routes.
|
- It should allow access to public routes.
|
||||||
- After login, it must fetch the user's households.
|
- After login, it must fetch the user's households.
|
||||||
- If the user has no households, redirect to `/welcome`.
|
- 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`).
|
- 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**:
|
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: Placeholder views created for all three; logic pending.
|
||||||
|
|
||||||
4. **[ ] Integrate Household Context into the App**:
|
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.
|
- **Create `src/composables/useHousehold.ts`**: Implemented. Extracts `householdSlug` from route and binds provider to API client.
|
||||||
- **Implement `HouseholdSwitcher.vue`**: This component will use `useAuth` to get the list of the user's households and render navigation links.
|
- **Implement `HouseholdSwitcher.vue`**: Implemented minimal version and mounted in `App.vue`.
|
||||||
- **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.
|
- **Update API Services**: Implemented header-injection in `src/api/client.ts` via `X-Household-Slug` using a configurable provider; no path changes.
|
||||||
- Example: `api.get('/recipes')` becomes `api.get(\`/${activeHouseholdSlug.value}/recipes\`)`.
|
- Verified by `tests/household.header.test.ts`.
|
||||||
|
|
||||||
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.
|
||||||
|
|
@ -119,11 +122,11 @@ What exists today
|
||||||
|
|
||||||
Gaps vs requirements
|
Gaps vs requirements
|
||||||
- Authentication & onboarding
|
- 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.
|
- Missing `CreateAccount.vue`, `Welcome.vue`, invitation acceptance flow.
|
||||||
- Routing & URL-based tenancy
|
- Routing & URL-based tenancy
|
||||||
- Missing `/:householdSlug/...` route nesting and post-login redirect logic.
|
- Partial: Feature-flagged nesting implemented; still need guard logic for fetching households and redirects.
|
||||||
- No `useHousehold.ts`; no `HouseholdSwitcher.vue`.
|
- `useHousehold.ts` and `HouseholdSwitcher.vue` added; further wiring to fetch households pending.
|
||||||
- API boundary
|
- API boundary
|
||||||
- No household scoping passed to backend. Need a typed strategy (prefer header parameter) without breaking OpenAPI typing.
|
- No household scoping passed to backend. Need a typed strategy (prefer header parameter) without breaking OpenAPI typing.
|
||||||
- Cleanup
|
- Cleanup
|
||||||
|
|
@ -144,6 +147,18 @@ To align with OpenAPI typing and README axioms, do not rewrite request paths to
|
||||||
|
|
||||||
Action
|
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.
|
- 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`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@
|
||||||
</router-link>
|
</router-link>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
<HouseholdSwitcher />
|
||||||
</div>
|
</div>
|
||||||
<div class="viewport">
|
<div class="viewport">
|
||||||
<router-view />
|
<router-view />
|
||||||
|
|
@ -39,6 +40,11 @@
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import AlertToast from './components/AlertToast.vue'
|
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
|
// components in <script setup> are auto-registered by import + usage
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -28,3 +28,24 @@ export async function login(username: string): Promise<Person> {
|
||||||
if (!cachedUser) throw new Error('Login failed: empty response')
|
if (!cachedUser) throw new Error('Login failed: empty response')
|
||||||
return cachedUser
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,22 @@ const isTest = typeof process !== 'undefined' && (process.env?.VITEST === 'true'
|
||||||
const defaultBase = ''
|
const defaultBase = ''
|
||||||
const baseUrl: string = isTest ? 'http://localhost' : (vueCliBase || 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>({
|
export const api = createClient<paths>({
|
||||||
baseUrl,
|
baseUrl,
|
||||||
fetch: (input: RequestInfo | URL, init?: RequestInit) => {
|
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, {
|
return globalThis.fetch(input, {
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
...init,
|
...init,
|
||||||
|
headers,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
|
||||||
42
src/components/HouseholdSwitcher.vue
Normal file
42
src/components/HouseholdSwitcher.vue
Normal 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>
|
||||||
|
|
@ -1,8 +1,12 @@
|
||||||
import { ref } from 'vue'
|
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'
|
import type { Person } from '@/domain/types'
|
||||||
|
|
||||||
|
type Household = { id: number; name: string; slug: string }
|
||||||
|
|
||||||
const user = ref<Person | null>(null)
|
const user = ref<Person | null>(null)
|
||||||
|
const households = ref<Household[]>([])
|
||||||
|
const activeHousehold = ref<Household | null>(null)
|
||||||
let initialized = false
|
let initialized = false
|
||||||
|
|
||||||
export async function loadUser() {
|
export async function loadUser() {
|
||||||
|
|
@ -18,6 +22,34 @@ export async function login(username: string) {
|
||||||
return user.value
|
return user.value
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAuth() {
|
export async function loginWithPassword(email: string, password: string) {
|
||||||
return { user, loadUser, login }
|
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 }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
18
src/composables/useHousehold.ts
Normal file
18
src/composables/useHousehold.ts
Normal 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 }
|
||||||
|
}
|
||||||
|
|
@ -10,55 +10,54 @@ const PurchasedShoppingListPage = () => import('@/components/shopping/PurchasedS
|
||||||
const CurrentShoppingListPage = () => import('@/components/shopping/CurrentShoppingListPage.vue')
|
const CurrentShoppingListPage = () => import('@/components/shopping/CurrentShoppingListPage.vue')
|
||||||
const EditMealPage = () => import('@/components/meals/EditMealPage.vue')
|
const EditMealPage = () => import('@/components/meals/EditMealPage.vue')
|
||||||
const EditRecipePage = () => import('@/components/recipes/EditRecipePage.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 {
|
export function createAppRouter(getCurrentUser: () => Promise<unknown> | unknown): Router {
|
||||||
const routes: RouteRecordRaw[] = [
|
const multitenantEnabled = typeof process !== 'undefined' && process.env?.VUE_APP_MULTITENANT_ENABLED === 'true'
|
||||||
{ path: '/', redirect: { name: 'mealplan' } },
|
const publicRoutes: RouteRecordRaw[] = [
|
||||||
{ path: '/login', name: 'login', component: LoginPage },
|
{ path: '/login', name: 'login', component: LoginPage },
|
||||||
{ path: '/mealplan', name: 'mealplan', component: MealPlanPage, meta: { requiresAuth: true } },
|
{ path: '/create-account', name: 'create-account', component: CreateAccount },
|
||||||
{
|
{ path: '/welcome', name: 'welcome', component: Welcome },
|
||||||
path: '/shopping',
|
{ path: '/invitations/accept', name: 'invitation-accept', component: InvitationAccept },
|
||||||
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 },
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
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({
|
const router = createRouter({
|
||||||
history: createWebHashHistory(),
|
history: createWebHashHistory(),
|
||||||
routes,
|
routes,
|
||||||
|
|
|
||||||
10
src/views/CreateAccount.vue
Normal file
10
src/views/CreateAccount.vue
Normal 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>
|
||||||
10
src/views/InvitationAccept.vue
Normal file
10
src/views/InvitationAccept.vue
Normal 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
10
src/views/Welcome.vue
Normal 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
42
tests/auth.api.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
23
tests/household.header.test.ts
Normal file
23
tests/household.header.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
36
tests/router.multitenant.test.ts
Normal file
36
tests/router.multitenant.test.ts
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
62
tests/useAuth.multitenant.test.ts
Normal file
62
tests/useAuth.multitenant.test.ts
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in a new issue