feat(invitations): accept and send invites; add settings view and routes

This commit is contained in:
jableader 2025-11-01 14:21:48 +11:00
parent 9f7209f44d
commit b19051fc75
5 changed files with 106 additions and 2 deletions

View file

@ -16,3 +16,12 @@ export async function acceptInvitation(token: string): Promise<Household> {
}
return { id: h.id, name: h.name, slug: h.slug }
}
export async function sendInvitation(email: string): Promise<void> {
const resp = await fetchApi('/api/v1/invitations', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
})
if (!resp.ok && resp.status !== 204) throw new Error(`${resp.status} ${resp.statusText || 'HTTP error'}`)
}

View file

@ -13,6 +13,7 @@ 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')
const HouseholdSettings = () => import('@/views/HouseholdSettings.vue')
export function createAppRouter(getCurrentUser: () => Promise<unknown> | unknown): Router {
const multitenantEnabled = typeof process !== 'undefined' && process.env?.VUE_APP_MULTITENANT_ENABLED === 'true'
@ -33,6 +34,7 @@ export function createAppRouter(getCurrentUser: () => Promise<unknown> | unknown
{ 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: 'settings/members', name: 'household-settings', component: HouseholdSettings, meta: { requiresAuth: true } },
]
const routes: RouteRecordRaw[] = []
@ -55,6 +57,7 @@ export function createAppRouter(getCurrentUser: () => Promise<unknown> | unknown
{ 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: '/settings/members', name: 'household-settings', component: HouseholdSettings, meta: { requiresAuth: true } },
)
}

View file

@ -0,0 +1,51 @@
<template>
<div>
<h1>Household Members</h1>
<section>
<h2>Invite a member</h2>
<form @submit.prevent="onInvite">
<label for="email">Email</label>
<input id="email" v-model="email" type="email" required autocomplete="email" />
<button type="submit" :disabled="submitting">Send Invitation</button>
</form>
<p v-if="message" class="message">{{ message }}</p>
<p v-if="error" class="error">{{ error }}</p>
</section>
<section>
<h2>Current members</h2>
<p class="muted">Listing members will be added once the backend endpoint is available.</p>
</section>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { sendInvitation } from '@/api/invitations'
const email = ref('')
const submitting = ref(false)
const message = ref('')
const error = ref('')
async function onInvite() {
message.value = ''
error.value = ''
submitting.value = true
try {
await sendInvitation(email.value.trim())
message.value = 'Invitation sent.'
email.value = ''
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to send invitation'
} finally {
submitting.value = false
}
}
</script>
<style scoped>
.muted { color: #666; }
.message { color: #0a0; }
.error { color: #a00; }
</style>

View file

@ -1,8 +1,15 @@
<template>
<div>
<h1>Accept Invitation</h1>
<p v-if="loading">Processing your invitation token...</p>
<p v-else-if="error" class="error">{{ error }}</p>
<p v-if="loading">
Processing your invitation token...
</p>
<p
v-else-if="error"
class="error"
>
{{ error }}
</p>
</div>
</template>

View file

@ -0,0 +1,34 @@
import { describe, it, expect } from 'vitest'
import { server, http, HttpResponse } from './test-setup'
import { loginWithPassword } from '@/api/auth'
import { sendInvitation } from '@/api/invitations'
import { setHouseholdSlugProvider } from '@/api/client'
describe('invitations api (send invite)', () => {
it('posts email with Authorization and X-Household-Slug', async () => {
// Simulate auth token
server.use(
http.post('*/api/v1/auth/login', () =>
HttpResponse.json({ accessToken: 'tokABC', tokenType: 'bearer', user: { id: 4, email: 'x@y', displayName: 'X' } })
)
)
await loginWithPassword('x@y', 'pw')
// Provide a household slug for header injection
setHouseholdSlugProvider(() => 'the-smiths')
server.use(
http.post('*/api/v1/invitations', async ({ request }) => {
const body = await request.json()
expect(body).toEqual({ email: 'invite@example.com' })
const auth = request.headers.get('authorization')
expect(auth?.toLowerCase()).toBe('bearer tokabc')
const slug = request.headers.get('x-household-slug')
expect(slug).toBe('the-smiths')
return HttpResponse.json({}, { status: 204 })
})
)
await expect(sendInvitation('invite@example.com')).resolves.toBeUndefined()
})
})