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.
|
||||
- Verified by `tests/household.header.test.ts` and auth tests.
|
||||
|
||||
5. **[ ] Implement Invitation UI**:
|
||||
- Build the `HouseholdSettings.vue` view for inviting members and listing current members.
|
||||
5. **[~] Implement Invitation UI**:
|
||||
- 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**:
|
||||
- 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
|
||||
- Authentication & onboarding
|
||||
- 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
|
||||
- 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.
|
||||
|
|
|
|||
|
|
@ -11,9 +11,19 @@ export async function currentUser(): Promise<User | null> {
|
|||
try {
|
||||
const res = await api.POST('/api/v1/auth/refresh', { params: { cookie: { user_id: 0 } } })
|
||||
if (!res.response.ok) return null
|
||||
// refresh returns legacy Person; adapt minimally to User shape
|
||||
const p = res.data as unknown as { id: number; name: string } | null
|
||||
cachedUser = p ? ({ id: p.id, email: '', displayName: p.name } as User) : null
|
||||
// refresh may return legacy Person; adapt minimally to User shape without type assertions
|
||||
const p = res.data
|
||||
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 (_) {
|
||||
cachedUser = null
|
||||
}
|
||||
|
|
@ -22,7 +32,7 @@ export async function currentUser(): Promise<User | null> {
|
|||
|
||||
export async function login(username: string): Promise<User> {
|
||||
// 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
|
||||
}
|
||||
|
||||
|
|
@ -37,7 +47,7 @@ export async function loginWithPassword(email: string, password: string): Promis
|
|||
const user = res.data?.user ?? null
|
||||
if (!token || !user) throw new Error('Invalid token response')
|
||||
authToken = token
|
||||
cachedUser = user as User
|
||||
cachedUser = { id: user.id, email: user.email ?? '', displayName: user.displayName ?? '' }
|
||||
return cachedUser
|
||||
}
|
||||
|
||||
|
|
@ -51,11 +61,11 @@ export async function createAccount(email: string, displayName: string, password
|
|||
const user = res.data?.user ?? null
|
||||
if (!token || !user) throw new Error('Invalid token response')
|
||||
authToken = token
|
||||
cachedUser = user as User
|
||||
cachedUser = { id: user.id, email: user.email ?? '', displayName: user.displayName ?? '' }
|
||||
return cachedUser
|
||||
}
|
||||
|
||||
export async function handleGoogleLogin(_token: string): Promise<never> {
|
||||
export async function handleGoogleLogin(): Promise<never> {
|
||||
// Placeholder until Google OAuth flow is wired
|
||||
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 }
|
||||
|
||||
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> {
|
||||
const resp = await fetchApi('/api/v1/invitations/accept', {
|
||||
method: 'POST',
|
||||
|
|
@ -9,9 +27,14 @@ export async function acceptInvitation(token: string): Promise<Household> {
|
|||
body: JSON.stringify({ token }),
|
||||
})
|
||||
if (!resp.ok) throw new Error(`${resp.status} ${resp.statusText || 'HTTP error'}`)
|
||||
const data = await resp.json().catch(() => null)
|
||||
const h = data?.household
|
||||
if (!h || typeof h.id !== 'number' || typeof h.slug !== 'string' || typeof h.name !== 'string') {
|
||||
const data = await safeJson<{ household?: unknown }>(resp)
|
||||
let h: unknown
|
||||
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')
|
||||
}
|
||||
return { id: h.id, name: h.name, slug: h.slug }
|
||||
|
|
|
|||
|
|
@ -85,7 +85,8 @@ onMounted(async () => {
|
|||
})
|
||||
|
||||
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 || '/')
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -167,13 +167,14 @@ onBeforeMount(async () => {
|
|||
const loaded = await getMeal(id)
|
||||
Object.assign(meal, loaded)
|
||||
} else {
|
||||
const self = await currentUser()
|
||||
if (self) {
|
||||
meal.chefs = [self]
|
||||
meal.consumers = [self]
|
||||
meal.cleanup = [self]
|
||||
}
|
||||
const self = await currentUser()
|
||||
if (self) {
|
||||
const me: Person = { id: self.id, name: self.displayName }
|
||||
meal.chefs = [me]
|
||||
meal.consumers = [me]
|
||||
meal.cleanup = [me]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function selectDate(date: Date) {
|
||||
|
|
|
|||
|
|
@ -76,7 +76,8 @@ async function onSubmit() {
|
|||
try {
|
||||
await createAccount(email.value.trim(), displayName.value.trim(), password.value)
|
||||
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 || '/')
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,16 +5,39 @@
|
|||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<p class="muted">
|
||||
Listing members will be added once the backend endpoint is available.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|||
describe('router multitenant routing', () => {
|
||||
beforeEach(() => {
|
||||
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 () => {
|
||||
|
|
@ -20,7 +21,7 @@ describe('router multitenant routing', () => {
|
|||
})
|
||||
|
||||
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 router = createAppRouter(() => null)
|
||||
const paths = router.getRoutes().map((r) => r.path)
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ describe('shopping mappers boundary', () => {
|
|||
],
|
||||
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].meal).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.items[0].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 () => {
|
||||
const { useAuth } = await import('@/composables/useAuth')
|
||||
const { user, createHousehold, fetchHouseholds, ...rest } = useAuth() as any
|
||||
const auth = useAuth()
|
||||
const { user } = auth
|
||||
expect(user.value).toBeNull()
|
||||
// Implemented method should exist
|
||||
expect(typeof rest.createAccount).toBe('function')
|
||||
const newUser = await rest.createAccount('new@example.com', 'New User', 'pw')
|
||||
expect(newUser.id).toBe(77)
|
||||
expect(typeof (auth as Record<string, unknown>).createAccount).toBe('function')
|
||||
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(user.value?.displayName).toBe('New User')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue