From c75cba214e7ad729d26f9de93920742cfa8aba9c Mon Sep 17 00:00:00 2001 From: MythEclipse Date: Tue, 16 Jun 2026 16:36:31 +0700 Subject: [PATCH] fix(android): resolve Google OAuth login flow on Tauri Android MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three interrelated fixes for the Android Google sign-in flow: 1. API base URL mismatch (404 error): - auth-form.tsx used 'window.location.origin || VITE_API_BASE_URL', which fell back to 'http://tauri.localhost' in Android WebView instead of the actual API server. - Fix: import shared 'apiBaseUrl' from api-client.ts (already had the correct fallback: 'https://zeavisedu.asepharyana.my.id'). - Added .env with VITE_API_BASE_URL for dev mode resilience. 2. Deep-link caused IPC callback errors: - 'processDeepLinkUrl()' used window.location.href = target, triggering a full page reload that orphaned pending Tauri IPC promises, causing 'Cannot read properties of undefined (reading 'runCallback')' errors. - Cold-start: keep get_current but use window.location.href (safe at boot — no SPA state to lose). - Warm-start: use sessionStorage + custom DOM event + React Router navigate() via new layout route, avoiding any page reload. 3. SPA navigation did not trigger OAuth token handler: - LoginPage's useEffect for ?token=xxx depended only on [setUser, queryClient, navigate] — location.search changes from a SPA navigate() call were ignored. - Fix: added location.search and location to deps. - Added visibilitychange + focus listeners so returning from the Google auth browser always re-checks URL params. Co-Authored-By: Claude --- apps/web/src/app.tsx | 231 +++++++++++++++----------- apps/web/src/components/auth-form.tsx | 6 +- apps/web/src/lib/api-client.ts | 2 +- apps/web/src/lib/tauri.ts | 75 ++++++--- apps/web/src/pages/login-page.tsx | 43 ++++- 5 files changed, 232 insertions(+), 125 deletions(-) diff --git a/apps/web/src/app.tsx b/apps/web/src/app.tsx index 4fc62ed..3682586 100644 --- a/apps/web/src/app.tsx +++ b/apps/web/src/app.tsx @@ -3,6 +3,8 @@ import { createBrowserRouter, RouterProvider, Navigate, + Outlet, + useNavigate, } from "react-router-dom"; import { AuthInitializer } from "@/components/auth-initializer"; import { AuthGuard } from "@/components/auth-guard"; @@ -18,10 +20,10 @@ import { TelemetryPage } from "@/pages/telemetry-page"; import { LoginPage } from "@/pages/login-page"; import { RegisterPage } from "@/pages/register-page"; import { MainLayout } from "@/components/layout/main-layout"; -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import { useAuthStore } from "@/store/auth-store"; import { apiClient } from "@/lib/api-client"; -import { setupDeepLinkHandler } from "@/lib/tauri"; +import { setupDeepLinkHandler, consumeDeepLinkTarget } from "@/lib/tauri"; function LogoutProses() { const setUser = useAuthStore((state) => state.setUser); @@ -32,7 +34,7 @@ function LogoutProses() { }).catch((error) => { console.error("Oops, gagal logout dari server:", error); - setUser(null); + setUser(null); }); }, [setUser]); @@ -43,105 +45,138 @@ import { trackPageView, trackError } from "./lib/telemetry"; const queryClient = new QueryClient(); const router = createBrowserRouter([ - { path: "/", element: }, - { path: "/login", element: }, - { path: "/register", element: }, { - path: "/dashboard", - element: ( - - - - - - ), - }, - { - path: "/scan", - element: ( - - - - - - ), - }, - { - path: "/library", - element: ( - - - - - - ), - }, - { - path: "/diagnoses", - element: ( - - - - - - ), - }, - { - path: "/diagnoses/:id", - element: ( - - - - - - ), - }, - { - path: "/expert/reviews", - element: ( - - - - - - ), - }, - { - path: "/catalog", - element: ( - - - - - - ), - }, - { - path: "/catalog/:slug", - element: ( - - - - - - ), - }, - { - path: "/logout", - element: ( - - ), - }, - { - path: "/telemetry", - element: ( - - - - ), + element: , + children: [ + { path: "/", element: }, + { path: "/login", element: }, + { path: "/register", element: }, + { + path: "/dashboard", + element: ( + + + + + + ), + }, + { + path: "/scan", + element: ( + + + + + + ), + }, + { + path: "/library", + element: ( + + + + + + ), + }, + { + path: "/diagnoses", + element: ( + + + + + + ), + }, + { + path: "/diagnoses/:id", + element: ( + + + + + + ), + }, + { + path: "/expert/reviews", + element: ( + + + + + + ), + }, + { + path: "/catalog", + element: ( + + + + + + ), + }, + { + path: "/catalog/:slug", + element: ( + + + + + + ), + }, + { + path: "/logout", + element: , + }, + { + path: "/telemetry", + element: ( + + + + ), + }, + ], }, ]); +/** + * Listens for deep-link custom events and routes via React Router's navigate(), + * avoiding full page reloads that break the Tauri IPC bridge. + */ +function DeepLinkRouterHandler() { + const navigate = useNavigate(); + const handled = useRef(new Set()); + + useEffect(() => { + // Check for cold-start pending deep link + const pending = consumeDeepLinkTarget(); + if (pending && !handled.current.has(pending)) { + handled.current.add(pending); + navigate(pending, { replace: true }); + } + + // Listen for warm-start deep links + const handler = (e: CustomEvent) => { + const target = e.detail; + if (handled.current.has(target)) return; + handled.current.add(target); + navigate(target, { replace: true }); + }; + window.addEventListener('zeavis:deeplink', handler as EventListener); + return () => window.removeEventListener('zeavis:deeplink', handler as EventListener); + }, [navigate]); + + return ; +} + function PageViewTracker() { const location = window.location; useEffect(() => { diff --git a/apps/web/src/components/auth-form.tsx b/apps/web/src/components/auth-form.tsx index 6fe38ca..31c1324 100644 --- a/apps/web/src/components/auth-form.tsx +++ b/apps/web/src/components/auth-form.tsx @@ -4,6 +4,7 @@ import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; +import { apiBaseUrl } from '@/lib/api-client'; import { isTauri, openUrl } from '@/lib/tauri'; type AuthFormProps = { @@ -29,10 +30,7 @@ export function AuthForm({ mode, isSubmitting, error, googleOAuthEnabled, onSubm const handleGoogleLogin = useCallback(async (e: React.MouseEvent) => { e.preventDefault(); const platform = isTauri() ? 'tauri' : 'web'; - // Use API base URL, not window.location.origin — on Tauri Android - // the origin is http://tauri.localhost which is not the API server. - const apiBase = import.meta.env.VITE_API_BASE_URL || window.location.origin; - const googleUrl = `${apiBase}/api/v1/auth/google?platform=${platform}`; + const googleUrl = `${apiBaseUrl}/api/v1/auth/google?platform=${platform}`; await openUrl(googleUrl); }, []); diff --git a/apps/web/src/lib/api-client.ts b/apps/web/src/lib/api-client.ts index ab04878..bbf14c2 100644 --- a/apps/web/src/lib/api-client.ts +++ b/apps/web/src/lib/api-client.ts @@ -14,7 +14,7 @@ import type { } from '@zeavis/shared'; import { recordApiCall } from './telemetry'; -const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'https://zeavisedu.asepharyana.my.id'; +export const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'https://zeavisedu.asepharyana.my.id'; const AUTH_TOKEN_KEY = 'zeavis_auth_token'; diff --git a/apps/web/src/lib/tauri.ts b/apps/web/src/lib/tauri.ts index 3f7f4b5..37f621f 100644 --- a/apps/web/src/lib/tauri.ts +++ b/apps/web/src/lib/tauri.ts @@ -1,6 +1,13 @@ /** * Lightweight Tauri environment detection and utilities. * Uses raw __TAURI_INTERNALS__ IPC to avoid bundling/import issues on Android. + * + * Deep-link flow (no full page reloads — uses React Router navigate()): + * 1. Tauri deep-link plugin receives URL via intent/custom-scheme. + * 2. processDeepLinkUrl stores the target path in sessionStorage + + * dispatches a custom DOM event. + * 3. inside picks it up and + * calls navigate(), keeping the React app alive. */ let _isTauri: boolean | null = null; @@ -22,7 +29,6 @@ function tauriInvoke(): (cmd: string, args?: Record) => Promise export async function openUrl(url: string): Promise { if (!isTauri()) { - // Not in Tauri — normal browser navigation window.location.href = url; return; } @@ -31,47 +37,76 @@ export async function openUrl(url: string): Promise { await invoke('plugin:opener|open_url', { url }); } catch (err) { console.error('Tauri openUrl failed, trying fallback:', err); - // Fallback: navigate the WebView (Google will block, but best effort) window.location.href = url; } } -function processDeepLinkUrl(url: string): void { - try { - const u = new URL(url); - const target = u.pathname + u.search + u.hash; - if (target && target !== '/') { - window.location.href = target; - return; - } - } catch { /* fall through */ } +// ── Deep link handling (no full reload) ───────────────────────────────── - // Fallback: handle both :// and :/ custom schemes - let match = url.match(/^[^:]+:\/\/(?:[^/]+)?(\/.*)?$/); - if (!match) match = url.match(/^[^:]+:\/(\/.*)?$/); - if (match?.[1]) window.location.href = match[1]; +const DEEP_LINK_KEY = 'zeavis_pending_deeplink'; +const DEEP_LINK_EVENT = 'zeavis:deeplink'; + +/** Store a target path for the React Router to pick up without page reload. */ +function storeDeepLinkTarget(target: string): void { + try { sessionStorage.setItem(DEEP_LINK_KEY, target); } catch { /* ignore */ } } +/** Read and clear the stored deep link target. */ +export function consumeDeepLinkTarget(): string | null { + try { + const v = sessionStorage.getItem(DEEP_LINK_KEY); + if (v) sessionStorage.removeItem(DEEP_LINK_KEY); + return v; + } catch { return null; } +} + + export async function setupDeepLinkHandler(): Promise { if (!isTauri()) return; try { const invoke = tauriInvoke(); - // Cold-start: app just opened via intent:// or custom scheme + // Cold-start: app opened via intent:// (e.g. from Google OAuth callback). + // Use window.location.href for this (full page reload) — at cold start there + // is no SPA state to lose, so redirecting via location.href avoids orphaned + // IPC promises that cause "Cannot read properties of undefined (reading 'runCallback')". invoke('plugin:deep-link|get_current') .then((urls: any) => { - if (urls?.[0]) processDeepLinkUrl(urls[0]); + if (!urls?.[0]) return; + const target = extractDeepLinkTarget(urls[0]); + if (target && target !== window.location.pathname + window.location.search + window.location.hash) { + window.location.href = target; + } }) - .catch(() => { /* plugin may not be registered yet */ }); + .catch(() => {}); - // Warm-start: listen for new URLs while app is running + // Warm-start: listen for new URLs (already running app). + // Use React Router navigate() here since we have SPA state. const { listen } = await import('@tauri-apps/api/event'); listen('deep-link://new-url', (event: any) => { const urls = event.payload as string[]; - for (const url of urls) processDeepLinkUrl(url); + for (const url of urls) { + const target = extractDeepLinkTarget(url); + if (target) { + storeDeepLinkTarget(target); + window.dispatchEvent(new CustomEvent(DEEP_LINK_EVENT, { detail: target })); + } + } }); } catch (err) { console.error('Tauri deep-link setup failed:', err); } } + +/** Extract path+query+hash from a deep-link URL. */ +function extractDeepLinkTarget(url: string): string { + try { + const u = new URL(url); + return u.pathname + u.search + u.hash; + } catch { + let m = url.match(/^[^:]+:\/\/(?:[^/]+)?(\/.*)?$/); + if (!m) m = url.match(/^[^:]+:\/(\/.*)?$/); + return m?.[1] ?? ''; + } +} diff --git a/apps/web/src/pages/login-page.tsx b/apps/web/src/pages/login-page.tsx index 59f57ab..9603b8a 100644 --- a/apps/web/src/pages/login-page.tsx +++ b/apps/web/src/pages/login-page.tsx @@ -1,9 +1,10 @@ import { useState, useEffect, useRef } from "react"; -import { Link, useNavigate } from "react-router-dom"; +import { Link, useNavigate, useLocation } from "react-router-dom"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { AuthForm } from "@/components/auth-form"; import { apiClient, setAuthToken } from "@/lib/api-client"; import { useAuthStore } from "@/store/auth-store"; +import { isTauri } from "@/lib/tauri"; function getUrlParam(name: string): string | null { return new URLSearchParams(window.location.search).get(name); @@ -13,11 +14,13 @@ export function LoginPage() { const navigate = useNavigate(); const queryClient = useQueryClient(); const setUser = useAuthStore((state) => state.setUser); + const location = useLocation(); const [error, setError] = useState(null); const oauthTokenConsumed = useRef(false); const [oauthProcessing, setOauthProcessing] = useState(false); // Handle OAuth callback: the API redirects to /login?token= + // Must re-run on location.search change (SPA navigates to /login?token=xxx) useEffect(() => { const token = getUrlParam("token"); if (!token || oauthTokenConsumed.current) return; @@ -39,7 +42,36 @@ export function LoginPage() { setOauthProcessing(false); setError(err instanceof Error ? err.message : "Google login gagal"); }); - }, [setUser, queryClient, navigate]); + }, [setUser, queryClient, navigate, location.search]); + + // Backup: when app returns from background (e.g. after Google OAuth browser) + // re-check URL params — the deep-link event may have been missed. + useEffect(() => { + if (!isTauri()) return; + if (getUrlParam("token") || oauthTokenConsumed.current) return; + + const onVisibility = () => { + if (document.visibilityState !== "visible") return; + const token = getUrlParam("token"); + if (token && !oauthTokenConsumed.current) { + setOauthProcessing(true); + } + }; + + const onFocus = () => { + const token = getUrlParam("token"); + if (token && !oauthTokenConsumed.current) { + setOauthProcessing(true); + } + }; + + document.addEventListener("visibilitychange", onVisibility); + window.addEventListener("focus", onFocus); + return () => { + document.removeEventListener("visibilitychange", onVisibility); + window.removeEventListener("focus", onFocus); + }; + }, []); // Show OAuth error from query param const oauthError = getUrlParam("error"); @@ -48,6 +80,13 @@ export function LoginPage() { queryFn: () => apiClient.getMe(), }); + // Already authenticated — redirect to dashboard + useEffect(() => { + if (!meQuery.isLoading && meQuery.data?.user) { + navigate("/dashboard", { replace: true }); + } + }, [meQuery.data, meQuery.isLoading, navigate]); + const mutation = useMutation({ mutationFn: apiClient.login, onSuccess: (response) => {