feat(invitations): accept and send invites; add settings view and routes
This commit is contained in:
parent
9f7209f44d
commit
b19051fc75
5 changed files with 106 additions and 2 deletions
|
|
@ -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'}`)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 } },
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
51
src/views/HouseholdSettings.vue
Normal file
51
src/views/HouseholdSettings.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
34
tests/invitations.send.api.test.ts
Normal file
34
tests/invitations.send.api.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue