diff --git a/app/composables/adminFetch.ts b/app/composables/adminFetch.ts index 02a1ec75..85f32554 100644 --- a/app/composables/adminFetch.ts +++ b/app/composables/adminFetch.ts @@ -5,9 +5,12 @@ type KeysOf = Array() /** - * `useFetch` wrapper for the /admin surface. Navigation is never blocked - * (`lazy: true`), and successful responses are cached client-side for the - * lifetime of the app so returning to a page renders instantly from cache. + * `useFetch` wrapper for the /admin surface. Requests are client-only + * (`server: false`): the admin session lives in an httpOnly cookie the + * browser holds, which a server-side render does not forward to + * `/api/admin/*`, so an SSR'd request 401s and hydrates as empty state. + * Navigation is never blocked (`lazy: true`), and successful responses are + * cached client-side for the lifetime of the app so returning to a page renders instantly from cache. * A cache hit still triggers a background refresh once the app is idle, so * stale data heals itself without blocking the UI. */ @@ -29,6 +32,7 @@ export function useAdminFetch ( const result = useFetch(url, { key, lazy: true, + server: false, getCachedData: () => (import.meta.client ? cache.get(key) as T | undefined : undefined), ...options, } as UseFetchOptions) as AsyncData | undefined> @@ -38,6 +42,10 @@ export function useAdminFetch ( if (status === 'success') cache.set(key, value) }, { immediate: true }) + watch(result.error, error => { + if (isUnauthorised(error)) redirectToLogin() + }, { immediate: true }) + if (hasCached) { onNuxtReady(() => result.refresh()) } diff --git a/app/plugins/adminAuth.client.ts b/app/plugins/adminAuth.client.ts deleted file mode 100644 index 64309308..00000000 --- a/app/plugins/adminAuth.client.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Sends the browser back through `/login` when an `/api/admin` call reports - * that the OAuth session is gone, instead of leaving the page rendering - * empty state. A full page load is used so the server middleware can - * re-issue the authorize redirect. - */ -export default defineNuxtPlugin(() => { - globalThis.$fetch = $fetch.create({ - onResponseError ({ request, response }) { - if (response.status !== 401) return - - const url = typeof request === 'string' ? request : request.url - if (!url.includes('/api/admin/')) return - - window.location.href = '/login' - }, - }) -}) diff --git a/app/plugins/unauthorised.client.ts b/app/plugins/unauthorised.client.ts new file mode 100644 index 00000000..44e0137f --- /dev/null +++ b/app/plugins/unauthorised.client.ts @@ -0,0 +1,16 @@ +export default defineNuxtPlugin({ + enforce: 'pre', + setup () { + globalThis.$fetch = globalThis.$fetch.create({ + onResponseError ({ request, response }) { + if (!isUnauthorised(response)) return + + const url = typeof request === 'string' ? request : request instanceof Request ? request.url : String(request) + const path = new URL(url, window.location.origin).pathname + + if (path.startsWith('/api/admin/')) redirectToLogin() + else if (path.startsWith('/api/')) useNuxtApp().$auth?.logout() + }, + }) + }, +}) diff --git a/app/utils/unauthorised.ts b/app/utils/unauthorised.ts new file mode 100644 index 00000000..2215f0f6 --- /dev/null +++ b/app/utils/unauthorised.ts @@ -0,0 +1,16 @@ +/** + * Sends the browser back through `/login` when the admin OAuth session is + * gone, instead of leaving the page rendering empty state. A full page load + * is used so the server middleware can re-issue the authorize redirect. + */ +export function redirectToLogin () { + if (import.meta.server) return + if (window.location.pathname === '/login') return + window.location.href = '/login' +} + +export function isUnauthorised (error: unknown) { + const { statusCode, status } = (error ?? {}) as { statusCode?: number, status?: number } + const code = statusCode ?? status + return code === 401 || code === 403 +}