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 }
|
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 CreateAccount = () => import('@/views/CreateAccount.vue')
|
||||||
const Welcome = () => import('@/views/Welcome.vue')
|
const Welcome = () => import('@/views/Welcome.vue')
|
||||||
const InvitationAccept = () => import('@/views/InvitationAccept.vue')
|
const InvitationAccept = () => import('@/views/InvitationAccept.vue')
|
||||||
|
const HouseholdSettings = () => import('@/views/HouseholdSettings.vue')
|
||||||
|
|
||||||
export function createAppRouter(getCurrentUser: () => Promise<unknown> | unknown): Router {
|
export function createAppRouter(getCurrentUser: () => Promise<unknown> | unknown): Router {
|
||||||
const multitenantEnabled = typeof process !== 'undefined' && process.env?.VUE_APP_MULTITENANT_ENABLED === 'true'
|
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', name: 'shopping', component: MyShoppingPage, meta: { requiresAuth: true } },
|
||||||
{ path: 'shopping/current', name: 'shopping-current', component: CurrentShoppingListPage, 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: '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[] = []
|
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', name: 'shopping', component: MyShoppingPage, meta: { requiresAuth: true } },
|
||||||
{ path: '/shopping/current', name: 'shopping-current', component: CurrentShoppingListPage, 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: '/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>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<h1>Accept Invitation</h1>
|
<h1>Accept Invitation</h1>
|
||||||
<p v-if="loading">Processing your invitation token...</p>
|
<p v-if="loading">
|
||||||
<p v-else-if="error" class="error">{{ error }}</p>
|
Processing your invitation token...
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-else-if="error"
|
||||||
|
class="error"
|
||||||
|
>
|
||||||
|
{{ error }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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