feat(router,auth,invites): remove legacy routing flag, use typed invitation accept, and restrict cookies to refresh
This commit is contained in:
parent
d729c0311d
commit
a43308b866
5 changed files with 39 additions and 78 deletions
|
|
@ -25,14 +25,24 @@ export function setAuthTokenProvider(provider: (() => string | null) | null) {
|
|||
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>({
|
||||
baseUrl,
|
||||
fetch: (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const headers = new Headers(init?.headers || {})
|
||||
const token = authTokenProvider ? authTokenProvider() : null
|
||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||
const credentials = isRefreshRequest(input) ? 'include' : 'same-origin'
|
||||
return globalThis.fetch(input, {
|
||||
credentials: 'include',
|
||||
credentials,
|
||||
...init,
|
||||
headers,
|
||||
})
|
||||
|
|
@ -45,9 +55,6 @@ export async function fetchApi(path: string, init?: RequestInit): Promise<Respon
|
|||
const token = authTokenProvider ? authTokenProvider() : null
|
||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||
const url = baseUrl ? new URL(path, baseUrl).toString() : path
|
||||
return globalThis.fetch(url, {
|
||||
credentials: 'include',
|
||||
...init,
|
||||
headers,
|
||||
})
|
||||
const credentials = path.includes('/api/v1/auth/refresh') ? 'include' : 'same-origin'
|
||||
return globalThis.fetch(url, { credentials, ...init, headers })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { api, fetchApi } from '@/api/client'
|
||||
import { api } from '@/api/client'
|
||||
|
||||
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> {
|
||||
const resp = await fetchApi('/api/v1/invitations/accept', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token }),
|
||||
})
|
||||
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
|
||||
}
|
||||
const { data, error, response } = await api.POST('/api/v1/invitations/accept', { body: { token } as any })
|
||||
if (!response.ok) throw new Error(`${response.status} ${response.statusText || 'HTTP error'}${error ? `: ${String(error)}` : ''}`)
|
||||
const dataObj = data as any
|
||||
const dataWrapped: { household?: unknown } | null = (dataObj && typeof dataObj === 'object') ? dataObj : null
|
||||
let h: unknown = dataWrapped && 'household' in dataWrapped ? dataWrapped.household : undefined
|
||||
if (!isHousehold(h)) {
|
||||
throw new Error('Invalid invitation accept response')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<div
|
||||
v-if="enabled && households.length > 0"
|
||||
v-if="households.length > 0"
|
||||
class="household-switcher"
|
||||
>
|
||||
<label>Household:</label>
|
||||
|
|
@ -31,20 +31,18 @@ import { computed } from 'vue'
|
|||
import { useRoute } from 'vue-router'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
|
||||
const enabled = (typeof process !== 'undefined' && (process.env?.VUE_APP_MULTITENANT_ENABLED === 'true'))
|
||||
const route = useRoute()
|
||||
const { households } = useAuth()
|
||||
|
||||
const activeSlug = computed(() => (typeof route.params.householdSlug === 'string' ? route.params.householdSlug : null))
|
||||
|
||||
function toHousehold(slug: string) {
|
||||
if (enabled) return { name: 'mealplan', params: { householdSlug: slug } }
|
||||
return { name: 'mealplan' }
|
||||
return { name: 'mealplan', params: { householdSlug: slug } }
|
||||
}
|
||||
|
||||
function toSettings() {
|
||||
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' }
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ const InvitationAccept = () => import('@/views/InvitationAccept.vue')
|
|||
const HouseholdSettings = () => import('@/views/HouseholdSettings.vue')
|
||||
|
||||
export function createAppRouter(getCurrentUser: () => Promise<unknown> | unknown): Router {
|
||||
const multitenantEnabled = typeof process !== 'undefined' && process.env?.VUE_APP_MULTITENANT_ENABLED === 'true'
|
||||
const publicRoutes: RouteRecordRaw[] = [
|
||||
{ path: '/login', name: 'login', component: LoginPage },
|
||||
{ path: '/create-account', name: 'create-account', component: CreateAccount },
|
||||
|
|
@ -38,28 +37,9 @@ export function createAppRouter(getCurrentUser: () => Promise<unknown> | unknown
|
|||
]
|
||||
|
||||
const routes: RouteRecordRaw[] = []
|
||||
|
||||
if (multitenantEnabled) {
|
||||
routes.push({ path: '/', name: 'root', component: { template: '<div />' }, meta: { requiresAuth: true } })
|
||||
routes.push(...publicRoutes)
|
||||
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 } },
|
||||
)
|
||||
}
|
||||
routes.push({ path: '/', name: 'root', component: { template: '<div />' }, meta: { requiresAuth: true } })
|
||||
routes.push(...publicRoutes)
|
||||
routes.push({ path: '/:householdSlug', component: { template: '<router-view />' }, children: featureChildren })
|
||||
|
||||
// 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.
|
||||
|
|
@ -79,10 +59,9 @@ export function createAppRouter(getCurrentUser: () => Promise<unknown> | unknown
|
|||
if (!to.meta.requiresAuth) return true
|
||||
try {
|
||||
const user = await getCurrentUser()
|
||||
if (!user) throw new Error('not-authenticated')
|
||||
if (!multitenantEnabled) return true
|
||||
if (!user) throw new Error('not-authenticated')
|
||||
|
||||
// Multitenant redirects
|
||||
// Household-scoped redirects
|
||||
// Fetch households and redirect accordingly
|
||||
const { useAuth } = await import('@/composables/useAuth')
|
||||
const { fetchHouseholds, setActiveHousehold, households } = useAuth()
|
||||
|
|
@ -93,7 +72,7 @@ export function createAppRouter(getCurrentUser: () => Promise<unknown> | unknown
|
|||
return true
|
||||
}
|
||||
|
||||
// If no slug in route, redirect to first household's mealplan
|
||||
// If no slug in route, redirect to first household's mealplan
|
||||
const slug = typeof to.params.householdSlug === 'string' ? to.params.householdSlug : null
|
||||
if (!slug) {
|
||||
if (hs.length > 0) {
|
||||
|
|
|
|||
|
|
@ -1,13 +1,7 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
describe('router multitenant routing', () => {
|
||||
beforeEach(() => {
|
||||
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 () => {
|
||||
describe('router slugged routing', () => {
|
||||
it('includes public routes and only slugged feature routes', async () => {
|
||||
const { createAppRouter } = await import('@/router/index')
|
||||
const router = createAppRouter(() => null)
|
||||
const paths = router.getRoutes().map((r) => r.path)
|
||||
|
|
@ -15,23 +9,14 @@ describe('router multitenant routing', () => {
|
|||
expect(paths).toContain('/create-account')
|
||||
expect(paths).toContain('/welcome')
|
||||
expect(paths).toContain('/invitations/accept')
|
||||
// legacy flat routes
|
||||
expect(paths).toContain('/recipes')
|
||||
expect(paths).toContain('/shopping')
|
||||
})
|
||||
|
||||
it('nests routes under /:householdSlug when flag enabled', async () => {
|
||||
(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)
|
||||
// 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
|
||||
// Only slugged feature routes
|
||||
expect(paths).toContain('/:householdSlug/recipes')
|
||||
expect(paths).toContain('/:householdSlug/mealplan')
|
||||
expect(paths).toContain('/:householdSlug/shopping')
|
||||
expect(paths).toContain('/:householdSlug/shopping/current')
|
||||
expect(paths).toContain('/:householdSlug/settings/members')
|
||||
// No legacy flat routes
|
||||
expect(paths).not.toContain('/recipes')
|
||||
expect(paths).not.toContain('/shopping')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue