feat(invitations): accept + send invitations with settings view; keep repo green

This commit is contained in:
jableader 2025-11-01 14:41:55 +11:00
parent b19051fc75
commit 218b56ccce
10 changed files with 96 additions and 34 deletions

View file

@ -93,8 +93,9 @@ This plan is adapted to the existing codebase, focusing on refactoring rather th
- **Update API Services**: Implemented header-injection in `src/api/client.ts` via `X-Household-Slug` and `Authorization` using configurable providers; no path changes. - **Update API Services**: Implemented header-injection in `src/api/client.ts` via `X-Household-Slug` and `Authorization` using configurable providers; no path changes.
- Verified by `tests/household.header.test.ts` and auth tests. - Verified by `tests/household.header.test.ts` and auth tests.
5. **[ ] Implement Invitation UI**: 5. **[~] Implement Invitation UI**:
- Build the `HouseholdSettings.vue` view for inviting members and listing current members. - Build the `HouseholdSettings.vue` view for inviting members and listing current members.
- Status: Invite form implemented (sends email via POST `/api/v1/invitations`). Members listing pending backend endpoint.
6. **[ ] Final Review & Cleanup**: 6. **[ ] Final Review & Cleanup**:
- Remove the old `persons` concept from the frontend code. The `user` from `useAuth` is now the primary identity. - Remove the old `persons` concept from the frontend code. The `user` from `useAuth` is now the primary identity.
@ -124,7 +125,7 @@ What exists today
Gaps vs requirements Gaps vs requirements
- Authentication & onboarding - Authentication & onboarding
- Implemented: JWT `loginWithPassword`, `logout`, account creation via `createAccount` with token provider wiring. - Implemented: JWT `loginWithPassword`, `logout`, account creation via `createAccount` with token provider wiring.
- UI: `LoginPage.vue` shows email/password form under feature flag; `CreateAccount.vue` view implemented; `Welcome.vue` creates household; Invitation Accept pending; Google OAuth pending. - UI: `LoginPage.vue` shows email/password form under feature flag; `CreateAccount.vue` view implemented; `Welcome.vue` creates household; Invitation Accept implemented; Google OAuth pending.
- Routing & URL-based tenancy - Routing & URL-based tenancy
- Partial: Feature-flagged nesting implemented; still need guard logic for fetching households and redirects. - Partial: Feature-flagged nesting implemented; still need guard logic for fetching households and redirects.
- `useHousehold.ts` and `HouseholdSwitcher.vue` added; further wiring to fetch households pending. - `useHousehold.ts` and `HouseholdSwitcher.vue` added; further wiring to fetch households pending.

View file

@ -11,9 +11,19 @@ export async function currentUser(): Promise<User | null> {
try { try {
const res = await api.POST('/api/v1/auth/refresh', { params: { cookie: { user_id: 0 } } }) const res = await api.POST('/api/v1/auth/refresh', { params: { cookie: { user_id: 0 } } })
if (!res.response.ok) return null if (!res.response.ok) return null
// refresh returns legacy Person; adapt minimally to User shape // refresh may return legacy Person; adapt minimally to User shape without type assertions
const p = res.data as unknown as { id: number; name: string } | null const p = res.data
cachedUser = p ? ({ id: p.id, email: '', displayName: p.name } as User) : null if (typeof p === 'object' && p !== null) {
const idDesc = Object.getOwnPropertyDescriptor(p, 'id')
const nameDesc = Object.getOwnPropertyDescriptor(p, 'name')
const id = idDesc?.value
const name = nameDesc?.value
if (typeof id === 'number' && typeof name === 'string') {
cachedUser = { id, email: '', displayName: name }
} else {
cachedUser = null
}
}
} catch (_) { } catch (_) {
cachedUser = null cachedUser = null
} }
@ -22,7 +32,7 @@ export async function currentUser(): Promise<User | null> {
export async function login(username: string): Promise<User> { export async function login(username: string): Promise<User> {
// Legacy compatibility shim: avoid network call; will be removed with new LoginPage // Legacy compatibility shim: avoid network call; will be removed with new LoginPage
cachedUser = { id: -1, email: '', displayName: username } as User cachedUser = { id: -1, email: '', displayName: username }
return cachedUser return cachedUser
} }
@ -37,7 +47,7 @@ export async function loginWithPassword(email: string, password: string): Promis
const user = res.data?.user ?? null const user = res.data?.user ?? null
if (!token || !user) throw new Error('Invalid token response') if (!token || !user) throw new Error('Invalid token response')
authToken = token authToken = token
cachedUser = user as User cachedUser = { id: user.id, email: user.email ?? '', displayName: user.displayName ?? '' }
return cachedUser return cachedUser
} }
@ -51,11 +61,11 @@ export async function createAccount(email: string, displayName: string, password
const user = res.data?.user ?? null const user = res.data?.user ?? null
if (!token || !user) throw new Error('Invalid token response') if (!token || !user) throw new Error('Invalid token response')
authToken = token authToken = token
cachedUser = user as User cachedUser = { id: user.id, email: user.email ?? '', displayName: user.displayName ?? '' }
return cachedUser return cachedUser
} }
export async function handleGoogleLogin(_token: string): Promise<never> { export async function handleGoogleLogin(): Promise<never> {
// Placeholder until Google OAuth flow is wired // Placeholder until Google OAuth flow is wired
throw new Error('handleGoogleLogin not implemented yet') throw new Error('handleGoogleLogin not implemented yet')
} }

View file

@ -2,6 +2,24 @@ import { fetchApi } from '@/api/client'
export type Household = { id: number; name: string; slug: string } export type Household = { id: number; name: string; slug: string }
function isHousehold(value: unknown): value is Household {
return (
typeof value === 'object' && value !== null &&
typeof (value as { id: unknown }).id === 'number' &&
typeof (value as { name: unknown }).name === 'string' &&
typeof (value as { slug: unknown }).slug === 'string'
)
}
async function safeJson<T>(resp: Response): Promise<T | null> {
try {
const d = await resp.json()
return d as T
} catch (_e) {
return null
}
}
export async function acceptInvitation(token: string): Promise<Household> { export async function acceptInvitation(token: string): Promise<Household> {
const resp = await fetchApi('/api/v1/invitations/accept', { const resp = await fetchApi('/api/v1/invitations/accept', {
method: 'POST', method: 'POST',
@ -9,9 +27,14 @@ export async function acceptInvitation(token: string): Promise<Household> {
body: JSON.stringify({ token }), body: JSON.stringify({ token }),
}) })
if (!resp.ok) throw new Error(`${resp.status} ${resp.statusText || 'HTTP error'}`) if (!resp.ok) throw new Error(`${resp.status} ${resp.statusText || 'HTTP error'}`)
const data = await resp.json().catch(() => null) const data = await safeJson<{ household?: unknown }>(resp)
const h = data?.household let h: unknown
if (!h || typeof h.id !== 'number' || typeof h.slug !== 'string' || typeof h.name !== 'string') { if (data && typeof data === 'object' && 'household' in data) {
h = (data as { household?: unknown }).household
} else {
h = undefined
}
if (!isHousehold(h)) {
throw new Error('Invalid invitation accept response') throw new Error('Invalid invitation accept response')
} }
return { id: h.id, name: h.name, slug: h.slug } return { id: h.id, name: h.name, slug: h.slug }

View file

@ -85,7 +85,8 @@ onMounted(async () => {
}) })
function afterLoginNavigate() { function afterLoginNavigate() {
const redirectPath = (route.query?.redirect as string | undefined) || props.redirect const q = route.query?.redirect
const redirectPath = (typeof q === 'string' ? q : undefined) || props.redirect
router.push(redirectPath || '/') router.push(redirectPath || '/')
} }

View file

@ -169,9 +169,10 @@ onBeforeMount(async () => {
} else { } else {
const self = await currentUser() const self = await currentUser()
if (self) { if (self) {
meal.chefs = [self] const me: Person = { id: self.id, name: self.displayName }
meal.consumers = [self] meal.chefs = [me]
meal.cleanup = [self] meal.consumers = [me]
meal.cleanup = [me]
} }
} }
}) })

View file

@ -76,7 +76,8 @@ async function onSubmit() {
try { try {
await createAccount(email.value.trim(), displayName.value.trim(), password.value) await createAccount(email.value.trim(), displayName.value.trim(), password.value)
if (user.value?.id !== undefined) { if (user.value?.id !== undefined) {
const redirectPath = (route.query?.redirect as string | undefined) || '/' const q = route.query?.redirect
const redirectPath = (typeof q === 'string' ? q : undefined) || '/'
router.push(redirectPath || '/') router.push(redirectPath || '/')
return return
} }

View file

@ -5,16 +5,39 @@
<h2>Invite a member</h2> <h2>Invite a member</h2>
<form @submit.prevent="onInvite"> <form @submit.prevent="onInvite">
<label for="email">Email</label> <label for="email">Email</label>
<input id="email" v-model="email" type="email" required autocomplete="email" /> <input
<button type="submit" :disabled="submitting">Send Invitation</button> id="email"
v-model="email"
type="email"
required
autocomplete="email"
>
<button
type="submit"
:disabled="submitting"
>
Send Invitation
</button>
</form> </form>
<p v-if="message" class="message">{{ message }}</p> <p
<p v-if="error" class="error">{{ error }}</p> v-if="message"
class="message"
>
{{ message }}
</p>
<p
v-if="error"
class="error"
>
{{ error }}
</p>
</section> </section>
<section> <section>
<h2>Current members</h2> <h2>Current members</h2>
<p class="muted">Listing members will be added once the backend endpoint is available.</p> <p class="muted">
Listing members will be added once the backend endpoint is available.
</p>
</section> </section>
</div> </div>
</template> </template>

View file

@ -3,7 +3,8 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
describe('router multitenant routing', () => { describe('router multitenant routing', () => {
beforeEach(() => { beforeEach(() => {
vi.resetModules() vi.resetModules()
delete (process.env as any).VUE_APP_MULTITENANT_ENABLED const env: Record<string, unknown> = process.env as unknown as Record<string, unknown>
delete env.VUE_APP_MULTITENANT_ENABLED
}) })
it('includes public routes and legacy flat routes when flag disabled', async () => { it('includes public routes and legacy flat routes when flag disabled', async () => {
@ -20,7 +21,7 @@ describe('router multitenant routing', () => {
}) })
it('nests routes under /:householdSlug when flag enabled', async () => { it('nests routes under /:householdSlug when flag enabled', async () => {
(process.env as any).VUE_APP_MULTITENANT_ENABLED = 'true' (process.env as unknown as Record<string, unknown>).VUE_APP_MULTITENANT_ENABLED = 'true'
const { createAppRouter } = await import('@/router/index') const { createAppRouter } = await import('@/router/index')
const router = createAppRouter(() => null) const router = createAppRouter(() => null)
const paths = router.getRoutes().map((r) => r.path) const paths = router.getRoutes().map((r) => r.path)

View file

@ -14,7 +14,7 @@ describe('shopping mappers boundary', () => {
], ],
purchasedItems: [], purchasedItems: [],
} }
const mapped = mapCurrentShoppingList(dto as any) const mapped = mapCurrentShoppingList(dto as unknown as Parameters<typeof mapCurrentShoppingList>[0])
expect(mapped.outstandingItems[0].ingredient).toBeUndefined() expect(mapped.outstandingItems[0].ingredient).toBeUndefined()
expect(mapped.outstandingItems[0].meal).toBeUndefined() expect(mapped.outstandingItems[0].meal).toBeUndefined()
expect(mapped.outstandingItems[0].recipe).toBeUndefined() expect(mapped.outstandingItems[0].recipe).toBeUndefined()
@ -37,7 +37,7 @@ describe('shopping mappers boundary', () => {
], ],
}, },
} }
const mapped = mapPurchasedShoppingList(dto as any) const mapped = mapPurchasedShoppingList(dto as unknown as Parameters<typeof mapPurchasedShoppingList>[0])
expect(mapped.list.createdDate).toBeInstanceOf(Date) expect(mapped.list.createdDate).toBeInstanceOf(Date)
expect(mapped.list.items[0].createdDate).toBeInstanceOf(Date) expect(mapped.list.items[0].createdDate).toBeInstanceOf(Date)
expect(mapped.list.items[1].createdDate).toBeInstanceOf(Date) expect(mapped.list.items[1].createdDate).toBeInstanceOf(Date)

View file

@ -20,11 +20,12 @@ describe('useAuth.createAccount', () => {
it('updates user after successful createAccount', async () => { it('updates user after successful createAccount', async () => {
const { useAuth } = await import('@/composables/useAuth') const { useAuth } = await import('@/composables/useAuth')
const { user, createHousehold, fetchHouseholds, ...rest } = useAuth() as any const auth = useAuth()
const { user } = auth
expect(user.value).toBeNull() expect(user.value).toBeNull()
// Implemented method should exist // Implemented method should exist
expect(typeof rest.createAccount).toBe('function') expect(typeof (auth as Record<string, unknown>).createAccount).toBe('function')
const newUser = await rest.createAccount('new@example.com', 'New User', 'pw') const newUser = await (auth as unknown as { createAccount: (e: string, d: string, p: string) => Promise<{ id: number; displayName: string }> }).createAccount('new@example.com', 'New User', 'pw')
expect(newUser.id).toBe(77) expect(newUser.id).toBe(77)
expect(user.value?.displayName).toBe('New User') expect(user.value?.displayName).toBe('New User')
}) })