diff --git a/frontend-spec.md b/frontend-spec.md index fa98433..46d719d 100644 --- a/frontend-spec.md +++ b/frontend-spec.md @@ -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. diff --git a/src/api/auth.ts b/src/api/auth.ts index b6d2c14..8db42e8 100644 --- a/src/api/auth.ts +++ b/src/api/auth.ts @@ -11,9 +11,19 @@ export async function currentUser(): Promise { 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 { export async function login(username: string): Promise { // 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 { +export async function handleGoogleLogin(): Promise { // Placeholder until Google OAuth flow is wired throw new Error('handleGoogleLogin not implemented yet') } diff --git a/src/api/invitations.ts b/src/api/invitations.ts index df60230..a40a0f5 100644 --- a/src/api/invitations.ts +++ b/src/api/invitations.ts @@ -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(resp: Response): Promise { + try { + const d = await resp.json() + return d as T + } catch (_e) { + return null + } +} + export async function acceptInvitation(token: string): Promise { const resp = await fetchApi('/api/v1/invitations/accept', { method: 'POST', @@ -9,9 +27,14 @@ export async function acceptInvitation(token: string): Promise { 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 } diff --git a/src/components/LoginPage.vue b/src/components/LoginPage.vue index 65046cc..8e6a7e9 100644 --- a/src/components/LoginPage.vue +++ b/src/components/LoginPage.vue @@ -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 || '/') } diff --git a/src/components/meals/EditMealPage.vue b/src/components/meals/EditMealPage.vue index 1aa98ab..4f98809 100644 --- a/src/components/meals/EditMealPage.vue +++ b/src/components/meals/EditMealPage.vue @@ -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) { diff --git a/src/views/CreateAccount.vue b/src/views/CreateAccount.vue index 7e96b32..7ba22c7 100644 --- a/src/views/CreateAccount.vue +++ b/src/views/CreateAccount.vue @@ -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 } diff --git a/src/views/HouseholdSettings.vue b/src/views/HouseholdSettings.vue index c961965..5448d39 100644 --- a/src/views/HouseholdSettings.vue +++ b/src/views/HouseholdSettings.vue @@ -5,16 +5,39 @@

Invite a member

- - + +
-

{{ message }}

-

{{ error }}

+

+ {{ message }} +

+

+ {{ error }} +

Current members

-

Listing members will be added once the backend endpoint is available.

+

+ Listing members will be added once the backend endpoint is available. +

diff --git a/tests/router.multitenant.test.ts b/tests/router.multitenant.test.ts index 487a43e..49b3e20 100644 --- a/tests/router.multitenant.test.ts +++ b/tests/router.multitenant.test.ts @@ -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 = process.env as unknown as Record + 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).VUE_APP_MULTITENANT_ENABLED = 'true' const { createAppRouter } = await import('@/router/index') const router = createAppRouter(() => null) const paths = router.getRoutes().map((r) => r.path) diff --git a/tests/shopping.mappers.boundary.test.ts b/tests/shopping.mappers.boundary.test.ts index 1edb65b..69b17b0 100644 --- a/tests/shopping.mappers.boundary.test.ts +++ b/tests/shopping.mappers.boundary.test.ts @@ -14,7 +14,7 @@ describe('shopping mappers boundary', () => { ], purchasedItems: [], } - const mapped = mapCurrentShoppingList(dto as any) + const mapped = mapCurrentShoppingList(dto as unknown as Parameters[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[0]) expect(mapped.list.createdDate).toBeInstanceOf(Date) expect(mapped.list.items[0].createdDate).toBeInstanceOf(Date) expect(mapped.list.items[1].createdDate).toBeInstanceOf(Date) diff --git a/tests/useAuth.createAccount.test.ts b/tests/useAuth.createAccount.test.ts index 230e06a..d51bad7 100644 --- a/tests/useAuth.createAccount.test.ts +++ b/tests/useAuth.createAccount.test.ts @@ -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).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') }) })