feat(invitations): implement invitation accept flow with TDD
This commit is contained in:
parent
062163483c
commit
9f7209f44d
5 changed files with 98 additions and 4 deletions
|
|
@ -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 `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 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**:
|
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.
|
||||||
|
|
@ -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.
|
- 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.
|
- 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.
|
||||||
- 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
18
src/api/invitations.ts
Normal 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 }
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,39 @@
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<h1>Accept Invitation</h1>
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.error { color: #a00; }
|
||||||
|
</style>
|
||||||
|
|
|
||||||
31
tests/invitations.api.test.ts
Normal file
31
tests/invitations.api.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in a new issue