import { ofetch } from 'ofetch' // --------------------------------------------------------------------------- // Bundled-app API layer (Capacitor build ONLY). // // The existing /mobile pages call the API with relative `/api/...` URLs via both `$fetch`/ // `useFetch` AND raw `window.fetch` (e.g. tasks.vue transcribe, Strapi uploads). In the bundled // SPA there is no origin server, so this plugin transparently: // 1. rebases relative `/api/...` → `${apiBase}/api/...` // 2. injects `Authorization: Bearer` + `X-Logship-*` context headers (token auth) // 3. rewrites the login call to the dedicated `/api/app-auth/login` endpoint and captures the // response (and the credentials) into the session store // 4. keeps the session alive: two-tier recovery (refresh-token → silent credential re-login), // proactively on boot / resume / interval, and reactively on a 401. // // Why proactive matters here: the server CANNOT refresh for the app (it never receives the refresh // token or the credentials — only the Bearer token + context headers), and several routes return // their auth failure as a 200 body (errorHandlingHelper) rather than a clean 401. So reacting to // 401s alone is not enough; if we let the access token lapse, pages just receive error payloads and // "show nothing". Keeping the token fresh ahead of use is what makes the app feel "always logged in". // // It is gated on `config.public.apiBase`, which only exists in the app build → on the web build // this plugin is also dropped by the `app:resolve` hook, so it is a guaranteed no-op there. // ZERO edits to the mobile pages are required. // --------------------------------------------------------------------------- export default defineNuxtPlugin(async (nuxtApp) => { const config = useRuntimeConfig() const apiBase = config.public.apiBase as string | undefined if (!apiBase) return // web build / no app target → leave $fetch and window.fetch untouched const session = useAuthSession() await session.load() const origFetch: typeof fetch = globalThis.fetch.bind(globalThis) const isRelApi = (u: string) => u.startsWith('/api/') const isOurApi = (u: string) => isRelApi(u) || u.startsWith(`${apiBase}/api/`) const isAuthEndpoint = (u: string) => u.includes('/api/app-auth/') // signin.vue posts to /api/idempiere-auth/login; the app uses the dedicated token endpoint. const rewrite = (u: string) => u.replace('/api/idempiere-auth/login', '/api/app-auth/login') .replace('/api/idempiere-auth/logout', '/api/app-auth/logout') const toAbsolute = (u: string) => (isRelApi(u) ? apiBase + u : u) const appHeaders = (): Record => { const h: Record = { ...session.contextHeaders() } const tk = session.token.value if (tk) h['Authorization'] = `Bearer ${tk}` return h } // --- session-dead handling: send the user to /signin instead of leaving them on a page that // only throws errors. NEVER triggered on a network error (offline ≠ logged out). ---------- let deadHandled = false const handleSessionDead = async () => { if (deadHandled) return deadHandled = true await session.clear() try { const router: any = (nuxtApp as any).$router const cur: string | undefined = router?.currentRoute?.value?.path if (router && cur && !cur.startsWith('/signin')) await router.push('/signin') } catch { /* ignore */ } } // --- single-flight, two-tier token recovery (uses the ORIGINAL fetch so it never re-enters the // wrappers). `lastWasNetwork` distinguishes "server refused us" (→ sign out) from "couldn't // reach the server" (→ keep the session; just offline). ------------------------------------ let refreshing: Promise | null = null let lastWasNetwork = false const doRefresh = (): Promise => { if (!refreshing) { refreshing = (async () => { lastWasNetwork = false const ctx = session.context.value // tier 1 — exchange the refresh token for a fresh access token const rt = session.refreshToken.value if (rt) { try { const r = await origFetch(`${apiBase}/api/app-auth/token-refresh`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Logship-App': '1' }, body: JSON.stringify({ refresh_token: rt, clientId: ctx.clientId, userId: ctx.userId }), }) if (r.ok) { const d = await r.json() if (d?.token) { session.setTokens(d.token, d.refresh_token); deadHandled = false; return true } } // reachable but rejected → fall through to a credential re-login } catch { lastWasNetwork = true } } // tier 2 — silent re-login with the stored credentials (mirrors the web's auth/tokens // fallback in refreshTokenHelper). The X-Logship-* context headers make the server land // straight back on the same client/role/org/warehouse without a /select-role round-trip. const creds = session.getCredentials() if (creds) { try { const res = await origFetch(`${apiBase}/api/app-auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...session.contextHeaders() }, body: JSON.stringify({ userName: creds.userName, password: creds.password }), }) if (res.ok) { const d = await res.json() if (d?.token) { session.setFromLogin(d); deadHandled = false; lastWasNetwork = false; return true } lastWasNetwork = false // server answered, credentials rejected } else { lastWasNetwork = false } } catch { lastWasNetwork = true } } return false })().finally(() => { refreshing = null }) } return refreshing } // --- proactive keep-alive -------------------------------------------------------------------- const canRecover = () => !!(session.refreshToken.value || session.getCredentials()) // Decode the JWT `exp` (best-effort) so we only refresh when actually near expiry. If the token // is missing or its exp can't be read, we err on the side of refreshing (return true). const tokenExpiresSoon = (): boolean => { const tk = session.token.value if (!tk) return true try { const part = tk.split('.')[1] if (!part) return true const pad = part.length % 4 const b64 = part.replace(/-/g, '+').replace(/_/g, '/') + (pad ? '='.repeat(4 - pad) : '') const payload = JSON.parse(atob(b64)) if (!payload?.exp) return true return payload.exp * 1000 - Date.now() < 10 * 60 * 1000 // refresh when <10 min remain } catch { return true } } let lastRefreshAt = 0 const ensureFreshToken = async (force = false) => { if (!canRecover()) return // nothing to refresh with → never touch the session if (!force && !tokenExpiresSoon()) return const now = Date.now() if (!force && now - lastRefreshAt < 60_000) return // small burst guard lastRefreshAt = now const ok = await doRefresh() if (!ok && !lastWasNetwork) await handleSessionDead() } // Refresh once at boot (if a session exists and its token is stale) and make the FIRST API calls // wait for it, so pages that fetch on mount use a fresh token instead of an expired one. let bootRefresh: Promise = Promise.resolve() if (canRecover()) bootRefresh = ensureFreshToken().catch(() => {}) // ---------- 1) ofetch instance backing $fetch / useFetch ---------- const baseApi = ofetch.create({ baseURL: apiBase, onRequest(ctx: any) { const u = String(ctx.request) if (isOurApi(u)) { ctx.options.headers = { ...(ctx.options.headers || {}), ...appHeaders() } } }, }) const $api = (async (request: any, opts: any = {}) => { let req = request if (typeof req === 'string') req = rewrite(req) // Gate the first data calls behind the boot refresh (auth endpoints are never gated). if (typeof req === 'string' && isOurApi(req) && !isAuthEndpoint(req)) { try { await bootRefresh } catch { /* ignore */ } } try { const data = await baseApi(req, opts) if (typeof req === 'string' && req.includes('/api/app-auth/login')) { session.setFromLogin(data) const b = opts?.body // Only persist credentials on a genuinely successful login (the endpoint returns 200 with // { status: 401 } and no token on wrong credentials — don't store those). if (data?.token && b && b.userName && b.password) session.setCredentials(b.userName, b.password) if (data?.token) deadHandled = false } if (typeof req === 'string' && req.includes('/api/app-auth/logout')) await session.clear() return data } catch (e: any) { if (e?.response?.status === 401 && typeof req === 'string' && isOurApi(req) && !isAuthEndpoint(req) && !opts._retry) { if (await doRefresh()) return await ($api as any)(req, { ...opts, _retry: true }) if (!lastWasNetwork) await handleSessionDead() } throw e } }) as any // Preserve the ofetch surface used internally by Nuxt (useFetch may touch .raw/.create). $api.raw = (baseApi as any).raw $api.create = (baseApi as any).create globalThis.$fetch = $api ;(nuxtApp as any).$fetch = $api // ---------- 2) wrap window.fetch for raw fetch() calls ---------- // Only relative `/api/...` requests are rebased + authed + refreshed. Absolute URLs (Strapi // media uploads) and `data:`/blob URLs pass straight through to the native WebView fetch, which // handles multipart `FormData`/`Blob` uploads natively. globalThis.fetch = (async (input: any, init: any = {}) => { const urlStr = typeof input === 'string' ? input : '' if (urlStr && isRelApi(urlStr)) { const url = toAbsolute(rewrite(urlStr)) if (isOurApi(url) && !isAuthEndpoint(url)) { try { await bootRefresh } catch { /* ignore */ } } const headers = new Headers(init.headers || undefined) const ah = appHeaders() for (const k in ah) headers.set(k, ah[k]) let res = await origFetch(url, { ...init, headers }) if (res.status === 401 && !isAuthEndpoint(url) && !init._retry) { if (await doRefresh()) { const tk = session.token.value if (tk) headers.set('Authorization', `Bearer ${tk}`) res = await origFetch(url, { ...init, headers }) } else if (!lastWasNetwork) { await handleSessionDead() } } if (url.includes('/api/app-auth/login') && res.ok) { try { const d = await res.clone().json() session.setFromLogin(d) // Only persist credentials on a genuinely successful login (200 + token). if (d?.token && init.body) { try { const b = JSON.parse(init.body); if (b?.userName && b?.password) session.setCredentials(b.userName, b.password) } catch { /* ignore */ } } if (d?.token) deadHandled = false } catch { /* ignore */ } } return res } return origFetch(input, init) }) as any // ---------- 3) keep-alive triggers (web events — no @capacitor/app native dependency) ---------- // Fire when the WebView regains focus / becomes visible (typical "user reopens the app" moment) // and on a slow interval as a backstop. ensureFreshToken() is debounced + expiry-gated, so these // are cheap and only hit the network when the token is actually near expiry. if (typeof document !== 'undefined') { document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') ensureFreshToken() }) } if (typeof window !== 'undefined') { window.addEventListener('focus', () => ensureFreshToken()) setInterval(() => ensureFreshToken(), 10 * 60 * 1000) } })