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
|
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,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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' }
|
||||||
|
|
|
||||||
|
|
@ -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[] = []
|
||||||
|
routes.push({ path: '/', name: 'root', component: { template: '<div />' }, meta: { requiresAuth: true } })
|
||||||
if (multitenantEnabled) {
|
routes.push(...publicRoutes)
|
||||||
routes.push({ path: '/', name: 'root', component: { template: '<div />' }, meta: { requiresAuth: true } })
|
routes.push({ path: '/:householdSlug', component: { template: '<router-view />' }, children: featureChildren })
|
||||||
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 } },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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.
|
||||||
|
|
@ -79,10 +59,9 @@ export function createAppRouter(getCurrentUser: () => Promise<unknown> | unknown
|
||||||
if (!to.meta.requiresAuth) return true
|
if (!to.meta.requiresAuth) return true
|
||||||
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()
|
||||||
|
|
@ -93,7 +72,7 @@ export function createAppRouter(getCurrentUser: () => Promise<unknown> | unknown
|
||||||
return true
|
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
|
const slug = typeof to.params.householdSlug === 'string' ? to.params.householdSlug : null
|
||||||
if (!slug) {
|
if (!slug) {
|
||||||
if (hs.length > 0) {
|
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', () => {
|
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')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue