feat(invitations): accept + send invitations with settings view; keep repo green
This commit is contained in:
parent
b19051fc75
commit
218b56ccce
10 changed files with 96 additions and 34 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
|
|
||||||
|
|
@ -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 || '/')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -167,13 +167,14 @@ onBeforeMount(async () => {
|
||||||
const loaded = await getMeal(id)
|
const loaded = await getMeal(id)
|
||||||
Object.assign(meal, loaded)
|
Object.assign(meal, loaded)
|
||||||
} 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]
|
||||||
}
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
function selectDate(date: Date) {
|
function selectDate(date: Date) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -20,12 +20,13 @@ 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')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue