feat(router,auth,invites): remove legacy routing flag, use typed invitation accept, and restrict cookies to refresh

This commit is contained in:
jableader 2025-11-01 19:03:10 +11:00
parent d729c0311d
commit a43308b866
5 changed files with 39 additions and 78 deletions

View file

@ -25,14 +25,24 @@ export function setAuthTokenProvider(provider: (() => string | null) | null) {
authTokenProvider = provider authTokenProvider = provider
} }
function isRefreshRequest(input: RequestInfo | URL): boolean {
try {
const url = typeof input === 'string' ? input : (input as URL).toString()
return url.includes('/api/v1/auth/refresh')
} catch {
return false
}
}
export const api = createClient<paths>({ export const api = createClient<paths>({
baseUrl, baseUrl,
fetch: (input: RequestInfo | URL, init?: RequestInit) => { fetch: (input: RequestInfo | URL, init?: RequestInit) => {
const headers = new Headers(init?.headers || {}) const headers = new Headers(init?.headers || {})
const token = authTokenProvider ? authTokenProvider() : null const token = authTokenProvider ? authTokenProvider() : null
if (token) headers.set('Authorization', `Bearer ${token}`) if (token) headers.set('Authorization', `Bearer ${token}`)
const credentials = isRefreshRequest(input) ? 'include' : 'same-origin'
return globalThis.fetch(input, { return globalThis.fetch(input, {
credentials: 'include', credentials,
...init, ...init,
headers, headers,
}) })
@ -45,9 +55,6 @@ export async function fetchApi(path: string, init?: RequestInit): Promise<Respon
const token = authTokenProvider ? authTokenProvider() : null const token = authTokenProvider ? authTokenProvider() : null
if (token) headers.set('Authorization', `Bearer ${token}`) if (token) headers.set('Authorization', `Bearer ${token}`)
const url = baseUrl ? new URL(path, baseUrl).toString() : path const url = baseUrl ? new URL(path, baseUrl).toString() : path
return globalThis.fetch(url, { const credentials = path.includes('/api/v1/auth/refresh') ? 'include' : 'same-origin'
credentials: 'include', return globalThis.fetch(url, { credentials, ...init, headers })
...init,
headers,
})
} }

View file

@ -1,4 +1,4 @@
import { api, fetchApi } from '@/api/client' import { api } from '@/api/client'
export type Household = { id: number; name: string; slug: string } export type Household = { id: number; name: string; slug: string }
@ -21,19 +21,11 @@ async function safeJson<T>(resp: Response): Promise<T | 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 { data, error, response } = await api.POST('/api/v1/invitations/accept', { body: { token } as any })
method: 'POST', if (!response.ok) throw new Error(`${response.status} ${response.statusText || 'HTTP error'}${error ? `: ${String(error)}` : ''}`)
headers: { 'Content-Type': 'application/json' }, const dataObj = data as any
body: JSON.stringify({ token }), const dataWrapped: { household?: unknown } | null = (dataObj && typeof dataObj === 'object') ? dataObj : null
}) let h: unknown = dataWrapped && 'household' in dataWrapped ? dataWrapped.household : undefined
if (!resp.ok) throw new Error(`${resp.status} ${resp.statusText || 'HTTP error'}`)
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)) { if (!isHousehold(h)) {
throw new Error('Invalid invitation accept response') throw new Error('Invalid invitation accept response')
} }

View file

@ -1,6 +1,6 @@
<template> <template>
<div <div
v-if="enabled && households.length > 0" v-if="households.length > 0"
class="household-switcher" class="household-switcher"
> >
<label>Household:</label> <label>Household:</label>
@ -31,20 +31,18 @@ import { computed } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { useAuth } from '@/composables/useAuth' import { useAuth } from '@/composables/useAuth'
const enabled = (typeof process !== 'undefined' && (process.env?.VUE_APP_MULTITENANT_ENABLED === 'true'))
const route = useRoute() const route = useRoute()
const { households } = useAuth() const { households } = useAuth()
const activeSlug = computed(() => (typeof route.params.householdSlug === 'string' ? route.params.householdSlug : null)) const activeSlug = computed(() => (typeof route.params.householdSlug === 'string' ? route.params.householdSlug : null))
function toHousehold(slug: string) { function toHousehold(slug: string) {
if (enabled) return { name: 'mealplan', params: { householdSlug: slug } } return { name: 'mealplan', params: { householdSlug: slug } }
return { name: 'mealplan' }
} }
function toSettings() { function toSettings() {
const slug = activeSlug.value const slug = activeSlug.value
if (enabled && typeof slug === 'string' && slug.length > 0) { if (typeof slug === 'string' && slug.length > 0) {
return { name: 'household-settings', params: { householdSlug: slug } } return { name: 'household-settings', params: { householdSlug: slug } }
} }
return { name: 'household-settings' } return { name: 'household-settings' }

View file

@ -16,7 +16,6 @@ const InvitationAccept = () => import('@/views/InvitationAccept.vue')
const HouseholdSettings = () => import('@/views/HouseholdSettings.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 publicRoutes: RouteRecordRaw[] = [ const publicRoutes: RouteRecordRaw[] = [
{ path: '/login', name: 'login', component: LoginPage }, { path: '/login', name: 'login', component: LoginPage },
{ path: '/create-account', name: 'create-account', component: CreateAccount }, { path: '/create-account', name: 'create-account', component: CreateAccount },
@ -38,28 +37,9 @@ export function createAppRouter(getCurrentUser: () => Promise<unknown> | unknown
] ]
const routes: RouteRecordRaw[] = [] const routes: RouteRecordRaw[] = []
if (multitenantEnabled) {
routes.push({ path: '/', name: 'root', component: { template: '<div />' }, meta: { requiresAuth: true } }) routes.push({ path: '/', name: 'root', component: { template: '<div />' }, meta: { requiresAuth: true } })
routes.push(...publicRoutes) routes.push(...publicRoutes)
routes.push({ path: '/:householdSlug', component: { template: '<router-view />' }, children: featureChildren }) routes.push({ path: '/:householdSlug', component: { template: '<router-view />' }, children: featureChildren })
} else {
routes.push({ path: '/', redirect: { name: 'mealplan' } })
routes.push(...publicRoutes)
// legacy flat routes
routes.push(
{ path: '/recipes', name: 'recipes', component: RecipesPage, meta: { requiresAuth: true } },
{ path: '/recipes/add', name: 'recipe-add', component: EditRecipePage, meta: { requiresAuth: true } },
{ path: '/recipes/:id', name: 'recipe-edit', component: EditRecipePage, props: true, meta: { requiresAuth: true } },
{ path: '/mealplan', name: 'mealplan', component: MealPlanPage, meta: { requiresAuth: true } },
{ path: '/meals/add', name: 'meal-add', component: EditMealPage, meta: { requiresAuth: true } },
{ path: '/meals/:id', name: 'meal-edit', component: EditMealPage, props: true, 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/:id', name: 'shopping-list', component: PurchasedShoppingListPage, props: true, meta: { requiresAuth: true } },
{ path: '/settings/members', name: 'household-settings', component: HouseholdSettings, meta: { requiresAuth: true } },
)
}
// Use hash history in real browsers; fallback to memory history in tests/SSR where `globalThis.location` may be unavailable // Use hash history in real browsers; fallback to memory history in tests/SSR where `globalThis.location` may be unavailable
// Some test runners may polyfill `window` but not the global `location`, and vue-router's hash history uses the global. // Some test runners may polyfill `window` but not the global `location`, and vue-router's hash history uses the global.
@ -80,9 +60,8 @@ export function createAppRouter(getCurrentUser: () => Promise<unknown> | unknown
try { try {
const user = await getCurrentUser() const user = await getCurrentUser()
if (!user) throw new Error('not-authenticated') if (!user) throw new Error('not-authenticated')
if (!multitenantEnabled) return true
// Multitenant redirects // Household-scoped redirects
// Fetch households and redirect accordingly // Fetch households and redirect accordingly
const { useAuth } = await import('@/composables/useAuth') const { useAuth } = await import('@/composables/useAuth')
const { fetchHouseholds, setActiveHousehold, households } = useAuth() const { fetchHouseholds, setActiveHousehold, households } = useAuth()

View file

@ -1,13 +1,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest' import { describe, it, expect } from 'vitest'
describe('router multitenant routing', () => { describe('router slugged routing', () => {
beforeEach(() => { it('includes public routes and only slugged feature routes', async () => {
vi.resetModules()
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 () => {
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)
@ -15,23 +9,14 @@ describe('router multitenant routing', () => {
expect(paths).toContain('/create-account') expect(paths).toContain('/create-account')
expect(paths).toContain('/welcome') expect(paths).toContain('/welcome')
expect(paths).toContain('/invitations/accept') expect(paths).toContain('/invitations/accept')
// legacy flat routes // Only slugged feature routes
expect(paths).toContain('/recipes') expect(paths).toContain('/:householdSlug/recipes')
expect(paths).toContain('/shopping') expect(paths).toContain('/:householdSlug/mealplan')
}) expect(paths).toContain('/:householdSlug/shopping')
expect(paths).toContain('/:householdSlug/shopping/current')
it('nests routes under /:householdSlug when flag enabled', async () => { expect(paths).toContain('/:householdSlug/settings/members')
(process.env as unknown as Record<string, unknown>).VUE_APP_MULTITENANT_ENABLED = 'true' // No legacy flat routes
const { createAppRouter } = await import('@/router/index')
const router = createAppRouter(() => null)
const paths = router.getRoutes().map((r) => r.path)
// Public routes still exist
expect(paths).toContain('/login')
expect(paths).toContain('/create-account')
// Nested route example
const hasNestedRecipes = paths.some((p) => p === '/:householdSlug/recipes')
expect(hasNestedRecipes).toBe(true)
// Legacy route should not be present when nested is enabled
expect(paths).not.toContain('/recipes') expect(paths).not.toContain('/recipes')
expect(paths).not.toContain('/shopping')
}) })
}) })