feat(invitations): implement invitation accept flow with TDD

This commit is contained in:
jableader 2025-11-01 14:17:59 +11:00
parent 062163483c
commit 9f7209f44d
5 changed files with 98 additions and 4 deletions

View file

@ -85,7 +85,7 @@ This plan is adapted to the existing codebase, focusing on refactoring rather th
- 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: Welcome page implements create-household flow using `POST /api/v1/households` and redirects to `/:slug/mealplan`. Create Account UI is implemented with email/displayName/password form calling `useAuth.createAccount`; Invitation Accept remains TODO.
- 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.
4. **[~] Integrate Household Context into the App**:
- **Create `src/composables/useHousehold.ts`**: Implemented. Extracts `householdSlug` from route and binds provider to API client.
@ -162,7 +162,8 @@ Progress Log (Nov 1, 2025)
- Implemented JWT login/register in `auth.ts` and wired token to client provider. `useAuth` updated with households fetching.
- 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.
- Next: Implement Invitation Accept flow and Household Settings (invite members form), then remove legacy Person UI.
- 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`.
- Next: Implement Household Settings (invite members form), then remove legacy Person UI.
---

View file

@ -35,3 +35,18 @@ export const api = createClient<paths>({
})
},
})
// For endpoints not yet in OpenAPI, provide a raw fetch that preserves header injection and base URL behavior
export async function fetchApi(path: string, init?: RequestInit): Promise<Response> {
const headers = new Headers(init?.headers || {})
const slug = householdSlugProvider ? householdSlugProvider() : null
if (slug) headers.set('X-Household-Slug', slug)
const token = authTokenProvider ? authTokenProvider() : null
if (token) headers.set('Authorization', `Bearer ${token}`)
const url = baseUrl ? new URL(path, baseUrl).toString() : path
return globalThis.fetch(url, {
credentials: 'include',
...init,
headers,
})
}

18
src/api/invitations.ts Normal file
View file

@ -0,0 +1,18 @@
import { fetchApi } from '@/api/client'
export type Household = { id: number; name: string; slug: string }
export async function acceptInvitation(token: string): Promise<Household> {
const resp = await fetchApi('/api/v1/invitations/accept', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
})
if (!resp.ok) throw new Error(`${resp.status} ${resp.statusText || 'HTTP error'}`)
const data = await resp.json().catch(() => null)
const h = data?.household
if (!h || typeof h.id !== 'number' || typeof h.slug !== 'string' || typeof h.name !== 'string') {
throw new Error('Invalid invitation accept response')
}
return { id: h.id, name: h.name, slug: h.slug }
}

View file

@ -1,10 +1,39 @@
<template>
<div>
<h1>Accept Invitation</h1>
<p>Processing your invitation token...</p>
<p v-if="loading">Processing your invitation token...</p>
<p v-else-if="error" class="error">{{ error }}</p>
</div>
</template>
<script setup lang="ts">
// TODO: Read token from route, call API, and redirect
import { onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { acceptInvitation } from '@/api/invitations'
const route = useRoute()
const router = useRouter()
const loading = ref(true)
const error = ref('')
onMounted(async () => {
const token = typeof route.query.token === 'string' ? route.query.token : null
if (!token) {
error.value = 'Missing invitation token'
loading.value = false
return
}
try {
const h = await acceptInvitation(token)
await router.push({ name: 'mealplan', params: { householdSlug: h.slug } })
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to accept invitation'
} finally {
loading.value = false
}
})
</script>
<style scoped>
.error { color: #a00; }
</style>

View file

@ -0,0 +1,31 @@
import { describe, it, expect } from 'vitest'
import { server, http, HttpResponse } from './test-setup'
import { loginWithPassword } from '@/api/auth'
import { acceptInvitation } from '@/api/invitations'
describe('invitations api', () => {
it('acceptInvitation posts token and returns household slug', async () => {
// simulate login so Authorization header is present
server.use(
http.post('*/api/v1/auth/login', () =>
HttpResponse.json({ accessToken: 'tok123', tokenType: 'bearer', user: { id: 9, email: 'u@e', displayName: 'U' } })
)
)
await loginWithPassword('u@e', 'pw')
server.use(
http.post('*/api/v1/invitations/accept', async ({ request }) => {
const body = await request.json()
expect(body).toEqual({ token: 'abc' })
const auth = request.headers.get('authorization')
expect(auth?.toLowerCase()).toBe('bearer tok123')
return HttpResponse.json({ household: { id: 1, name: 'Smiths', slug: 'the-smiths' } })
})
)
const result = await acceptInvitation('abc')
expect(result.slug).toBe('the-smiths')
expect(result.name).toBe('Smiths')
expect(result.id).toBe(1)
})
})