import { Preferences } from '@capacitor/preferences' // Single source of truth for the bundled-app session: the auth token + the iDempiere // "context" (org / warehouse / role / client / user / language and the related objects) that the // existing /mobile pages and the auth middleware expect. Durable via Capacitor Preferences // (survives a WebView process kill, unlike localStorage which Android may evict); mirrored into a // useState ref for synchronous reads from the fetch interceptor. // // IMPORTANT: this composable is used ONLY by the app build (the 00.capacitor-api plugin). In the // web build that plugin is dropped (app:resolve hook in nuxt.config), so this file — and the // @capacitor/preferences import — tree-shakes out. The web build never touches Preferences. const STORE_KEY = 'logship_app_session' interface AppContext { organizationId?: string | number warehouseId?: string | number roleId?: string | number clientId?: string | number userId?: string | number language?: string user?: any client?: any role?: any organization?: any warehouse?: any organizations?: any clients?: any } interface AppSession { token: string | null refreshToken: string | null context: AppContext // Base64'd login credentials, kept ONLY in app-private Capacitor Preferences so the app can // silently re-login when the iDempiere refresh token has itself expired (the server cannot // refresh for the app — it never receives the refresh token). Same security posture as the web // build, which stores logship_xu / logship_py cookies for refreshTokenHelper's tokens fallback. credentials?: { u: string; p: string } | null } // The cookies the existing pages / auth middleware read client-side (the NON-httpOnly originals // that the SSR login used to set). We re-create them in the app's own WebView cookie jar so those // `useCookie('logship_*')` reads keep working with ZERO page edits. const COOKIE_MAP: Record = { logship_organization_id: 'organizationId', logship_warehouse_id: 'warehouseId', logship_role_id: 'roleId', logship_client_id: 'clientId', logship_user_id: 'userId', logship_language: 'language', logship_user: 'user', logship_client: 'client', logship_role: 'role', logship_organization: 'organization', logship_warehouse: 'warehouse', logship_organizations: 'organizations', logship_clients: 'clients', } // Scalar context sent to the server as X-Logship-* headers (the app-auth middleware maps them // back into the cookies the existing routes read). const HEADER_MAP: Array<[string, keyof AppContext]> = [ ['X-Logship-Organization-Id', 'organizationId'], ['X-Logship-Warehouse-Id', 'warehouseId'], ['X-Logship-Role-Id', 'roleId'], ['X-Logship-Client-Id', 'clientId'], ['X-Logship-User-Id', 'userId'], ['X-Logship-Language', 'language'], ] const b64 = (s: string) => { try { return btoa(unescape(encodeURIComponent(s))) } catch { return '' } } const unb64 = (s: string) => { try { return decodeURIComponent(escape(atob(s))) } catch { return '' } } const writeCookie = (name: string, value: any) => { if (typeof document === 'undefined') return if (value === undefined || value === null || value === '') return // Match how Nuxt's useCookie serializes: JSON for non-strings, URI-encoded. 30-day persistence. const v = typeof value === 'string' ? value : JSON.stringify(value) document.cookie = `${name}=${encodeURIComponent(v)}; path=/; max-age=${60 * 60 * 24 * 30}; samesite=lax` } const deleteCookie = (name: string) => { if (typeof document === 'undefined') return document.cookie = `${name}=; path=/; max-age=0` } export const useAuthSession = () => { const session = useState('appSession', () => ({ token: null, refreshToken: null, context: {}, credentials: null })) const token = computed(() => session.value.token) const refreshToken = computed(() => session.value.refreshToken) const context = computed(() => session.value.context) const persist = async () => { try { await Preferences.set({ key: STORE_KEY, value: JSON.stringify(session.value) }) } catch { /* non-fatal */ } } // Write the context into the WebView's own cookie jar so the existing pages' useCookie reads // (and the auth middleware) resolve unchanged. const syncCookies = () => { for (const [cookieName, ctxKey] of Object.entries(COOKIE_MAP)) { writeCookie(cookieName, session.value.context[ctxKey]) } } const load = async () => { try { const { value } = await Preferences.get({ key: STORE_KEY }) if (value) { const parsed = JSON.parse(value) as AppSession session.value = { token: parsed.token ?? null, refreshToken: parsed.refreshToken ?? null, context: parsed.context ?? {}, credentials: parsed.credentials ?? null, } syncCookies() } } catch { /* corrupt/empty store → start fresh */ } } // Map a login (app-auth/login) response body into the session, persist, and write cookies. const setFromLogin = (data: any) => { if (!data || !data.token) return session.value = { token: data.token ?? session.value.token ?? null, refreshToken: data.refresh_token ?? session.value.refreshToken ?? null, context: { organizationId: data.organization?.id ?? data.organizationId, warehouseId: data.warehouse?.id ?? data.warehouseId, roleId: data.role?.id ?? data.roleId, clientId: data.client?.id ?? data.clientId, userId: data.user?.id ?? data.userId, language: data.language, user: data.user, client: data.client, role: data.role, organization: data.organization, warehouse: data.warehouse, organizations: data.organizations, clients: data.clients, }, // Preserve stored credentials across a re-login so silent recovery keeps working. credentials: session.value.credentials ?? null, } syncCookies() persist() } const setTokens = (newToken: string, newRefresh?: string | null) => { session.value.token = newToken if (newRefresh) session.value.refreshToken = newRefresh persist() } // Persist the login credentials (base64) so the app can silently re-login when both the access // and refresh tokens have expired. Wiped on explicit logout via clear(). const setCredentials = (userName: string, password: string) => { if (!userName || !password) return session.value.credentials = { u: b64(userName), p: b64(password) } persist() } const getCredentials = (): { userName: string; password: string } | null => { const c = session.value.credentials if (!c?.u || !c?.p) return null const userName = unb64(c.u) const password = unb64(c.p) if (!userName || !password) return null return { userName, password } } const contextHeaders = (): Record => { const h: Record = { 'X-Logship-App': '1' } const ctx = session.value.context for (const [header, key] of HEADER_MAP) { const v = ctx[key] if (v !== undefined && v !== null && v !== '') h[header] = String(v) } // The auth middleware's /authenticated check returns the user object — carry it so the // server can repopulate the logship_user cookie for that request. if (ctx.user) h['X-Logship-User'] = b64(JSON.stringify(ctx.user)) return h } const isAuthenticated = computed(() => !!session.value.token) // Restore a session from a biometric-unlock blob ({ refreshToken, context }). No access token // yet — the caller mints a fresh one via /api/app-auth/token-refresh. Writes the context cookies // so the existing pages/auth middleware work, and persists. const restoreSession = (data: { refreshToken?: string | null, context?: AppContext }) => { session.value = { token: null, refreshToken: data?.refreshToken ?? null, context: data?.context ?? {}, credentials: session.value.credentials ?? null, } syncCookies() persist() } // A minimal, durable snapshot for biometric storage: the (revocable) refresh token + the context // needed to rebuild cookies/headers. The short-lived access token is intentionally excluded. const biometricSnapshot = () => ({ refreshToken: session.value.refreshToken, context: session.value.context, }) const clear = async () => { session.value = { token: null, refreshToken: null, context: {} } for (const name of Object.keys(COOKIE_MAP)) deleteCookie(name) try { await Preferences.remove({ key: STORE_KEY }) } catch { /* non-fatal */ } } return { token, refreshToken, context, isAuthenticated, load, setFromLogin, setTokens, setCredentials, getCredentials, contextHeaders, restoreSession, biometricSnapshot, clear } }