Compare commits

...
9 Commits
Author SHA1 Message Date
Selly SupriyatinandGitHub d1c014d9b3 Merge pull request #45 from ATLAS-PJK-GM007/selly/frontend
feat(auth): add placeholders and helper text to improve form UX
2026-06-16 22:55:34 +07:00
seriouselly 1db8eee8ea feat(auth): add placeholders and helper text to improve form UX
- Add descriptive placeholders to name, email, and password input fields.
- Display a helper text in register mode to guide users on password length requirements.
- Adjust password `minLength` validation in the frontend.
2026-06-16 22:46:06 +07:00
Selly SupriyatinandGitHub 74e17386ee Merge pull request #44 from ATLAS-PJK-GM007/selly/frontend
feat(ui): add green leaf favicon using SVG data URI
2026-06-16 22:00:36 +07:00
seriouselly a9ef795c90 feat(ui): add green leaf favicon using SVG data URI
- Update index.html to include a Lucide leaf icon as the tab favicon.
- Use URL-encoded SVG data URI to apply the ZeaVis Edu green brand color (#22C55E) directly without needing external image files.
2026-06-16 21:59:43 +07:00
Selly SupriyatinandGitHub 153abf4352 Merge pull request #43 from ATLAS-PJK-GM007/selly/frontend
feat(scan): implement drag and drop functionality for image upload
2026-06-16 19:20:20 +07:00
seriouselly da5c7c1cfa feat(scan): implement drag and drop functionality for image upload
- Add `onDragOver`, `onDragLeave`, and `onDrop` event handlers to capture dragged files.
- Introduce `isDragging` state to provide visual UI feedback when a file is hovered over the drop zone.
- Wire the dropped file data to the existing `handleFile` processing logic.
2026-06-16 19:12:44 +07:00
MythEclipseandClaude 58d4cc0164 chore: update telemetry submodule and fix Makefile comment
Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-16 16:40:23 +07:00
MythEclipseandClaude a3105200e3 docs(claude): document Android Google OAuth fixes and design rules
Record the three bugs found during Android OAuth debugging,
their root causes, and the fix patterns to follow for future
Tauri deep-link handlers.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-16 16:37:58 +07:00
MythEclipseandClaude c75cba214e fix(android): resolve Google OAuth login flow on Tauri Android
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 <DeepLinkRouterHandler /> 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 <noreply@anthropic.com>
2026-06-16 16:36:31 +07:00
10 changed files with 411 additions and 172 deletions
+45 -5
View File
@@ -142,19 +142,31 @@ make telemetry-down
## Telemetry architecture ## Telemetry architecture
The repository includes a full Prometheus → ClickHouse metric pipeline as a git submodule at `telemetry/`. Each ZeaVis Edu service exposes a `GET /metrics` endpoint: The telemetry stack lives as a git submodule at `telemetry/` (repo `MythEclipse/Telemetry`). Architecture:
| Layer | Service | Role |
|-------|---------|------|
| Collector & Storage | **Prometheus** | Metric scraping & TSDB storage |
| System metrics | **Node Exporter** | CPU, memory, disk per host |
| Query | **Query Proxy** | REST API over Prometheus HTTP API |
| Visualization | **Grafana** | OSS dashboard & PromQL |
| Entry point | **Telemetry UI** | nginx + Vue 3 SPA |
Data flow: Node Exporter → Prometheus scrape (every 15s) → Grafana (PromQL) / Query Proxy (/api/metrics).
Each ZeaVis Edu service exposes a `GET /metrics` endpoint:
- **Web app** (`apps/web`): In dev mode, a Vite plugin serves client-side session metrics (page views, Web Vitals). In production, nginx proxies `/metrics` to the API service. Source: `apps/web/src/lib/telemetry.ts`, `apps/web/vite-plugin-metrics.ts`. - **Web app** (`apps/web`): In dev mode, a Vite plugin serves client-side session metrics (page views, Web Vitals). In production, nginx proxies `/metrics` to the API service. Source: `apps/web/src/lib/telemetry.ts`, `apps/web/vite-plugin-metrics.ts`.
- **API** (`apps/api`): Uses `prom-client` for Node.js default metrics plus custom HTTP, auth, classification, and diagnosis counters/histograms. Source: `apps/api/src/lib/telemetry.ts`, exposed via `apps/api/src/routes/metrics.ts`. - **API** (`apps/api`): Uses `prom-client` for Node.js default metrics plus custom HTTP, auth, classification, and diagnosis counters/histograms. Source: `apps/api/src/lib/telemetry.ts`, exposed via `apps/api/src/routes/metrics.ts`.
- **ML service** (`apps/ml-service`): Uses the `prometheus` Rust crate for HTTP metrics, prediction counts, and model load status. Source: `apps/ml-service/src/telemetry.rs`. - **ML service** (`apps/ml-service`): Uses the `prometheus` Rust crate for HTTP metrics, prediction counts, and model load status. Source: `apps/ml-service/src/telemetry.rs`.
All three share the `zeavis_` metric prefix and are scraped by the Telemetry Prometheus instance via `file_sd_configs` (see `telemetry/prometheus/targets/zeavis-edu.json`). All three share the `zeavis_` metric prefix and are scraped by Prometheus via `file_sd_configs` (see `telemetry/prometheus/targets/zeavis-edu.json`).
**IMPORTANT — Production architecture:** ZeaVis Edu apps and the Telemetry stack run on **separate VPS instances** connected via **Tailscale** (mesh VPN). Prometheus scrapes the API and ML service through their **Tailscale IPs** (e.g. `100.x.x.a:3000`), not via Docker hostnames. The target file `telemetry/prometheus/targets/zeavis-edu.json` has `__CHANGE_ME__` placeholders — before deploying, replace with the actual Tailscale IPs of the app VPS. **IMPORTANT — Production architecture:** ZeaVis Edu apps and the Telemetry stack run on **separate VPS instances** connected via **Tailscale** (mesh VPN). Prometheus scrapes the API and ML service through their **Tailscale IPs** (e.g. `100.x.x.a:3000`), not via Docker hostnames. The target file has `__CHANGE_ME__` placeholders — replace with actual Tailscale IPs before deploying.
The telemetry stack is managed from the project root via `make telemetry-*` targets (see `Makefile`). The Docker Compose files in `telemetry/deploy/` define 6 services (Prometheus, Metric Ingester, Vector, ClickHouse, Query Proxy, Telemetry UI). The telemetry stack is managed from the project root via `make telemetry-*` targets (see `Makefile`). Docker Compose defines 5 services (Prometheus, Node Exporter, Query Proxy, Grafana, Telemetry UI).
For **local single-host dev**, Prometheus can reach app services via a shared Docker network (`app-shared-net`). Use `make telemetry-up-local` for this mode — it includes the `docker-compose.telemetry.yml` override. For **local single-host dev**, Prometheus can reach app services via a shared Docker network (`app-shared-net`). Use `make telemetry-up-local` for this mode.
## Fullstack application architecture ## Fullstack application architecture
@@ -191,6 +203,34 @@ The following files/directories are generated or externally supplied during the
- `Machine_Learning/best_model/best_model.keras` — trained model downloaded from Colab/Google Drive. - `Machine_Learning/best_model/best_model.keras` — trained model downloaded from Colab/Google Drive.
- `Machine_Learning/model/saved_model/`, `model/model.tflite`, `model/model.onnx`, and `model/tfjs_model/` — production exports. - `Machine_Learning/model/saved_model/`, `model/model.tflite`, `model/model.onnx`, and `model/tfjs_model/` — production exports.
## Android Google OAuth (Tauri) — known issues & fixes
The Tauri Android app uses Chrome's `intent://` protocol to bounce back from Google's OAuth browser page. Three bugs were found and fixed in commit `c75cba2`:
### 1. API base URL falls back to `http://tauri.localhost`
**Symptom:** Google login button navigates to `http://tauri.localhost/api/v1/auth/google` → 404.
**Root cause:** `auth-form.tsx` used `import.meta.env.VITE_API_BASE_URL || window.location.origin`. In Android WebView the origin is `http://tauri.localhost` (Vite dev server), not the API server.
**Fix:** Import shared `apiBaseUrl` from `api-client.ts` which already has the correct fallback: `import.meta.env.VITE_API_BASE_URL ?? 'https://zeavisedu.asepharyana.my.id'`.
### 2. `deep-link:get_current` IPC promise orphaned on SPA navigation
**Symptom:** `Cannot read properties of undefined (reading 'runCallback')` floods log; OAuth never completes.
**Root cause:** `plugin:deep-link|get_current` returns a JS promise that stays pending. When React Router's `navigate()` changes the URL (SPA, no page reload), the Tauri IPC bridge invalidates the pending callback reference — but the promise never resolves or rejects cleanly, so `.runCallback` is undefined.
**Fix (cold start):** `get_current` resolves via `window.location.href = target` (full reload). At boot there is no SPA state to lose, so a hard redirect is safe.
**Fix (warm start / `deep-link://new-url` event):** Store target in `sessionStorage` + dispatch a custom DOM event. A `<DeepLinkRouterHandler>` root layout route listens for the event and calls React Router's `navigate()`, keeping SPA state alive.
### 3. LoginPage `?token=` effect does not re-run on SPA navigation
**Symptom:** App navigates to `/login?token=xxx` but stays on the login form.
**Root cause:** The `useEffect` that reads `?token` and exchanges it for a session only listed `[setUser, queryClient, navigate]` as deps. React Router SPA navigation changes `location.search` but does not remount the component — so the effect never re-runs.
**Fix:** Added `location.search` to the effect's dependency array. Also added `visibilitychange` and `focus` event listeners as a backup — when the user returns from the Google OAuth browser tab, the app picks up the token from the URL even if the deep-link plugin's event was missed.
### Design rule for Tauri deep-link handlers
- **Cold start** (app was not running) → safe to use `window.location.href` (full reload). The React app has just booted, no state to lose.
- **Warm start** (app was running, user returns from system browser) → use React Router `navigate()` via custom events / sessionStorage. Do NOT use `window.location.href` — it triggers a full page unload which orphan Tauri IPC promises.
## Notes for future changes ## Notes for future changes
- Keep README command examples and this file in sync when changing the ML pipeline. - Keep README command examples and this file in sync when changing the ML pipeline.
+1 -1
View File
@@ -2,7 +2,7 @@
# ZeaVis Edu — Root Makefile # ZeaVis Edu — Root Makefile
# #
# Orchestrates the application stack (web, api, ml) and the telemetry # Orchestrates the application stack (web, api, ml) and the telemetry
# metric pipeline (Prometheus → Ingester → Vector → ClickHouse). # metric pipeline (Prometheus → Grafana).
# #
# Telemetry commands operate on the submodule at telemetry/. # Telemetry commands operate on the submodule at telemetry/.
# ============================================================================= # =============================================================================
+5
View File
@@ -4,6 +4,11 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ZeaVis Edu</title> <title>ZeaVis Edu</title>
<link
rel="icon"
type="image/svg+xml"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%2322C55E' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M11 20A7 7 0 0 1 9.8 6.1C15.5 5 17 4.48 19 2c1 2 2 4.18 2 8 0 5.5-4.78 10-10 10Z'/%3E%3Cpath d='M2 22l10-10'/%3E%3C/svg%3E"
/>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+133 -98
View File
@@ -3,6 +3,8 @@ import {
createBrowserRouter, createBrowserRouter,
RouterProvider, RouterProvider,
Navigate, Navigate,
Outlet,
useNavigate,
} from "react-router-dom"; } from "react-router-dom";
import { AuthInitializer } from "@/components/auth-initializer"; import { AuthInitializer } from "@/components/auth-initializer";
import { AuthGuard } from "@/components/auth-guard"; import { AuthGuard } from "@/components/auth-guard";
@@ -18,10 +20,10 @@ import { TelemetryPage } from "@/pages/telemetry-page";
import { LoginPage } from "@/pages/login-page"; import { LoginPage } from "@/pages/login-page";
import { RegisterPage } from "@/pages/register-page"; import { RegisterPage } from "@/pages/register-page";
import { MainLayout } from "@/components/layout/main-layout"; import { MainLayout } from "@/components/layout/main-layout";
import { useEffect } from "react"; import { useEffect, useRef } from "react";
import { useAuthStore } from "@/store/auth-store"; import { useAuthStore } from "@/store/auth-store";
import { apiClient } from "@/lib/api-client"; import { apiClient } from "@/lib/api-client";
import { setupDeepLinkHandler } from "@/lib/tauri"; import { setupDeepLinkHandler, consumeDeepLinkTarget } from "@/lib/tauri";
function LogoutProses() { function LogoutProses() {
const setUser = useAuthStore((state) => state.setUser); const setUser = useAuthStore((state) => state.setUser);
@@ -32,7 +34,7 @@ function LogoutProses() {
}).catch((error) => { }).catch((error) => {
console.error("Oops, gagal logout dari server:", error); console.error("Oops, gagal logout dari server:", error);
setUser(null); setUser(null);
}); });
}, [setUser]); }, [setUser]);
@@ -43,105 +45,138 @@ import { trackPageView, trackError } from "./lib/telemetry";
const queryClient = new QueryClient(); const queryClient = new QueryClient();
const router = createBrowserRouter([ const router = createBrowserRouter([
{ path: "/", element: <Navigate to="/login" replace /> },
{ path: "/login", element: <LoginPage /> },
{ path: "/register", element: <RegisterPage /> },
{ {
path: "/dashboard", element: <DeepLinkRouterHandler />,
element: ( children: [
<AuthGuard> { path: "/", element: <Navigate to="/login" replace /> },
<MainLayout> { path: "/login", element: <LoginPage /> },
<DashboardPage /> { path: "/register", element: <RegisterPage /> },
</MainLayout> {
</AuthGuard> path: "/dashboard",
), element: (
}, <AuthGuard>
{ <MainLayout>
path: "/scan", <DashboardPage />
element: ( </MainLayout>
<AuthGuard> </AuthGuard>
<MainLayout> ),
<ScanPage /> },
</MainLayout> {
</AuthGuard> path: "/scan",
), element: (
}, <AuthGuard>
{ <MainLayout>
path: "/library", <ScanPage />
element: ( </MainLayout>
<AuthGuard> </AuthGuard>
<MainLayout> ),
<LibraryPage /> },
</MainLayout> {
</AuthGuard> path: "/library",
), element: (
}, <AuthGuard>
{ <MainLayout>
path: "/diagnoses", <LibraryPage />
element: ( </MainLayout>
<AuthGuard> </AuthGuard>
<MainLayout> ),
<DiagnosesPage /> },
</MainLayout> {
</AuthGuard> path: "/diagnoses",
), element: (
}, <AuthGuard>
{ <MainLayout>
path: "/diagnoses/:id", <DiagnosesPage />
element: ( </MainLayout>
<AuthGuard> </AuthGuard>
<MainLayout> ),
<DiagnosisDetailPage /> },
</MainLayout> {
</AuthGuard> path: "/diagnoses/:id",
), element: (
}, <AuthGuard>
{ <MainLayout>
path: "/expert/reviews", <DiagnosisDetailPage />
element: ( </MainLayout>
<AuthGuard requireExpert={true}> </AuthGuard>
<MainLayout> ),
<ExpertReviewsPage /> },
</MainLayout> {
</AuthGuard> path: "/expert/reviews",
), element: (
}, <AuthGuard requireExpert={true}>
{ <MainLayout>
path: "/catalog", <ExpertReviewsPage />
element: ( </MainLayout>
<AuthGuard> </AuthGuard>
<MainLayout> ),
<CatalogPage /> },
</MainLayout> {
</AuthGuard> path: "/catalog",
), element: (
}, <AuthGuard>
{ <MainLayout>
path: "/catalog/:slug", <CatalogPage />
element: ( </MainLayout>
<AuthGuard> </AuthGuard>
<MainLayout> ),
<DiseaseDetailPage /> },
</MainLayout> {
</AuthGuard> path: "/catalog/:slug",
), element: (
}, <AuthGuard>
{ <MainLayout>
path: "/logout", <DiseaseDetailPage />
element: ( </MainLayout>
<LogoutProses /> </AuthGuard>
), ),
}, },
{ {
path: "/telemetry", path: "/logout",
element: ( element: <LogoutProses />,
<MainLayout> },
<TelemetryPage /> {
</MainLayout> path: "/telemetry",
), element: (
<MainLayout>
<TelemetryPage />
</MainLayout>
),
},
],
}, },
]); ]);
/**
* 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<string>());
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<string>) => {
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 <Outlet />;
}
function PageViewTracker() { function PageViewTracker() {
const location = window.location; const location = window.location;
useEffect(() => { useEffect(() => {
+110 -43
View File
@@ -1,24 +1,42 @@
import { FormEvent, useState, useCallback } from 'react'; import { FormEvent, useState, useCallback } from "react";
import { Eye, EyeOff } from 'lucide-react'; import { Eye, EyeOff } from "lucide-react";
import { Button } from '@/components/ui/button'; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import {
import { Input } from '@/components/ui/input'; Card,
import { Label } from '@/components/ui/label'; CardContent,
import { isTauri, openUrl } from '@/lib/tauri'; 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 = { type AuthFormProps = {
mode: 'login' | 'register'; mode: "login" | "register";
isSubmitting: boolean; isSubmitting: boolean;
error: string | null; error: string | null;
googleOAuthEnabled: boolean; googleOAuthEnabled: boolean;
onSubmit: (payload: { name?: string; email: string; password: string }) => Promise<unknown>; onSubmit: (payload: {
name?: string;
email: string;
password: string;
}) => Promise<unknown>;
onFieldChange?: () => void; onFieldChange?: () => void;
}; };
export function AuthForm({ mode, isSubmitting, error, googleOAuthEnabled, onSubmit, onFieldChange }: AuthFormProps) { export function AuthForm({
const [name, setName] = useState(''); mode,
const [email, setEmail] = useState(''); isSubmitting,
const [password, setPassword] = useState(''); error,
googleOAuthEnabled,
onSubmit,
onFieldChange,
}: AuthFormProps) {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
async function handleSubmit(event: FormEvent<HTMLFormElement>) { async function handleSubmit(event: FormEvent<HTMLFormElement>) {
@@ -28,70 +46,119 @@ export function AuthForm({ mode, isSubmitting, error, googleOAuthEnabled, onSubm
const handleGoogleLogin = useCallback(async (e: React.MouseEvent) => { const handleGoogleLogin = useCallback(async (e: React.MouseEvent) => {
e.preventDefault(); e.preventDefault();
const platform = isTauri() ? 'tauri' : 'web'; const platform = isTauri() ? "tauri" : "web";
// Use API base URL, not window.location.origin — on Tauri Android const googleUrl = `${apiBaseUrl}/api/v1/auth/google?platform=${platform}`;
// 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}`;
await openUrl(googleUrl); await openUrl(googleUrl);
}, []); }, []);
return ( return (
<Card className="mx-auto w-full max-w-md bg-transparent border-none shadow-none"> <Card className="mx-auto w-full max-w-md bg-transparent border-none shadow-none">
<CardHeader className="text-center space-y-2"> <CardHeader className="text-center space-y-2">
<CardTitle className="text-2xl font-bold text-emerald-900">{mode === 'login' ? 'Masuk Akun ZeaVis Edu' : 'Buat akun ZeaVis Edu'}</CardTitle> <CardTitle className="text-2xl font-bold text-emerald-900">
{mode === "login" ? "Masuk Akun ZeaVis Edu" : "Buat akun ZeaVis Edu"}
</CardTitle>
<CardDescription className="text-sm text-emerald-800/80"> <CardDescription className="text-sm text-emerald-800/80">
{mode === 'login' {mode === "login"
? 'Masuk untuk menyimpan diagnosis dan mengikuti review pakar.' ? "Masuk untuk menyimpan diagnosis dan mengikuti review pakar."
: 'Daftar untuk menyimpan diagnosis dan mengikuti review pakar.'} : "Daftar untuk menyimpan diagnosis dan mengikuti review pakar."}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<form className="space-y-4" onSubmit={handleSubmit}> <form className="space-y-4" onSubmit={handleSubmit}>
{mode === 'register' && ( {mode === "register" && (
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="name">Nama</Label> <Label htmlFor="name">Nama</Label>
<Input id="name" value={name} onChange={(event) => { <Input
setName(event.target.value); id="name"
onFieldChange?.(); placeholder="Masukkan nama Anda"
}} required /> value={name}
onChange={(event) => {
setName(event.target.value);
onFieldChange?.();
}}
required
/>
</div> </div>
)} )}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="email">Email</Label> <Label htmlFor="email">Email</Label>
<Input id="email" type="email" value={email} onChange={(event) => { <Input
setEmail(event.target.value); id="email"
onFieldChange?.(); type="email"
}} required /> placeholder="Masukkan email Anda"
value={email}
onChange={(event) => {
setEmail(event.target.value);
onFieldChange?.();
}}
required
/>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="password">Password</Label> <Label htmlFor="password">Password</Label>
<div className="relative"> <div className="relative">
<Input id="password" type={showPassword ? "text" : "password"} minLength={8} value={password} onChange={(event) => { <Input
setPassword(event.target.value); id="password"
onFieldChange?.(); type={showPassword ? "text" : "password"}
}} required /> placeholder="Password minimal 8 karakter"
minLength={8}
value={password}
onChange={(event) => {
setPassword(event.target.value);
onFieldChange?.();
}}
required
/>
<button <button
type="button" type="button"
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground focus:outline-none" className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground focus:outline-none"
onClick={() => setShowPassword(!showPassword)} onClick={() => setShowPassword(!showPassword)}
> >
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />} {showPassword ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button> </button>
</div> </div>
</div> </div>
{error && <p className="text-sm text-red-600" role="alert">{error}</p>} {error && (
<p className="text-sm text-red-600" role="alert">
{error}
</p>
)}
<Button className="w-full" type="submit" disabled={isSubmitting}> <Button className="w-full" type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Memproses...' : mode === 'login' ? 'Masuk' : 'Daftar'} {isSubmitting
? "Memproses..."
: mode === "login"
? "Masuk"
: "Daftar"}
</Button> </Button>
</form> </form>
{googleOAuthEnabled && ( {googleOAuthEnabled && (
<Button className="mt-3 w-full flex items-center justify-center gap-2.5" variant="outline" onClick={handleGoogleLogin} type="button"> <Button
className="mt-3 w-full flex items-center justify-center gap-2.5"
variant="outline"
onClick={handleGoogleLogin}
type="button"
>
<svg viewBox="0 0 24 24" className="h-5 w-5" aria-hidden="true"> <svg viewBox="0 0 24 24" className="h-5 w-5" aria-hidden="true">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" /> <path
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" /> fill="#4285F4"
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" /> d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" /> />
<path
fill="#34A853"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="#FBBC05"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
/>
<path
fill="#EA4335"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/>
<path fill="none" d="M1 1h22v22H1z" /> <path fill="none" d="M1 1h22v22H1z" />
</svg> </svg>
Masuk dengan Google Masuk dengan Google
+1 -1
View File
@@ -14,7 +14,7 @@ import type {
} from '@zeavis/shared'; } from '@zeavis/shared';
import { recordApiCall } from './telemetry'; 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'; const AUTH_TOKEN_KEY = 'zeavis_auth_token';
+55 -20
View File
@@ -1,6 +1,13 @@
/** /**
* Lightweight Tauri environment detection and utilities. * Lightweight Tauri environment detection and utilities.
* Uses raw __TAURI_INTERNALS__ IPC to avoid bundling/import issues on Android. * 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. <DeepLinkRouterHandler /> inside <RouterProvider> picks it up and
* calls navigate(), keeping the React app alive.
*/ */
let _isTauri: boolean | null = null; let _isTauri: boolean | null = null;
@@ -22,7 +29,6 @@ function tauriInvoke(): (cmd: string, args?: Record<string, unknown>) => Promise
export async function openUrl(url: string): Promise<void> { export async function openUrl(url: string): Promise<void> {
if (!isTauri()) { if (!isTauri()) {
// Not in Tauri — normal browser navigation
window.location.href = url; window.location.href = url;
return; return;
} }
@@ -31,47 +37,76 @@ export async function openUrl(url: string): Promise<void> {
await invoke('plugin:opener|open_url', { url }); await invoke('plugin:opener|open_url', { url });
} catch (err) { } catch (err) {
console.error('Tauri openUrl failed, trying fallback:', err); console.error('Tauri openUrl failed, trying fallback:', err);
// Fallback: navigate the WebView (Google will block, but best effort)
window.location.href = url; window.location.href = url;
} }
} }
function processDeepLinkUrl(url: string): void { // ── Deep link handling (no full reload) ─────────────────────────────────
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 */ }
// Fallback: handle both :// and :/ custom schemes const DEEP_LINK_KEY = 'zeavis_pending_deeplink';
let match = url.match(/^[^:]+:\/\/(?:[^/]+)?(\/.*)?$/); const DEEP_LINK_EVENT = 'zeavis:deeplink';
if (!match) match = url.match(/^[^:]+:\/(\/.*)?$/);
if (match?.[1]) window.location.href = match[1]; /** 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<void> { export async function setupDeepLinkHandler(): Promise<void> {
if (!isTauri()) return; if (!isTauri()) return;
try { try {
const invoke = tauriInvoke(); 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') invoke('plugin:deep-link|get_current')
.then((urls: any) => { .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'); const { listen } = await import('@tauri-apps/api/event');
listen('deep-link://new-url', (event: any) => { listen('deep-link://new-url', (event: any) => {
const urls = event.payload as string[]; 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) { } catch (err) {
console.error('Tauri deep-link setup failed:', 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] ?? '';
}
}
+41 -2
View File
@@ -1,9 +1,10 @@
import { useState, useEffect, useRef } from "react"; 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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AuthForm } from "@/components/auth-form"; import { AuthForm } from "@/components/auth-form";
import { apiClient, setAuthToken } from "@/lib/api-client"; import { apiClient, setAuthToken } from "@/lib/api-client";
import { useAuthStore } from "@/store/auth-store"; import { useAuthStore } from "@/store/auth-store";
import { isTauri } from "@/lib/tauri";
function getUrlParam(name: string): string | null { function getUrlParam(name: string): string | null {
return new URLSearchParams(window.location.search).get(name); return new URLSearchParams(window.location.search).get(name);
@@ -13,11 +14,13 @@ export function LoginPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const setUser = useAuthStore((state) => state.setUser); const setUser = useAuthStore((state) => state.setUser);
const location = useLocation();
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const oauthTokenConsumed = useRef(false); const oauthTokenConsumed = useRef(false);
const [oauthProcessing, setOauthProcessing] = useState(false); const [oauthProcessing, setOauthProcessing] = useState(false);
// Handle OAuth callback: the API redirects to /login?token=<session_token> // Handle OAuth callback: the API redirects to /login?token=<session_token>
// Must re-run on location.search change (SPA navigates to /login?token=xxx)
useEffect(() => { useEffect(() => {
const token = getUrlParam("token"); const token = getUrlParam("token");
if (!token || oauthTokenConsumed.current) return; if (!token || oauthTokenConsumed.current) return;
@@ -39,7 +42,36 @@ export function LoginPage() {
setOauthProcessing(false); setOauthProcessing(false);
setError(err instanceof Error ? err.message : "Google login gagal"); 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 // Show OAuth error from query param
const oauthError = getUrlParam("error"); const oauthError = getUrlParam("error");
@@ -48,6 +80,13 @@ export function LoginPage() {
queryFn: () => apiClient.getMe(), 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({ const mutation = useMutation({
mutationFn: apiClient.login, mutationFn: apiClient.login,
onSuccess: (response) => { onSuccess: (response) => {
+19 -1
View File
@@ -33,6 +33,7 @@ export function ScanPage() {
const imageRef = useRef<HTMLImageElement | null>(null); const imageRef = useRef<HTMLImageElement | null>(null);
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [isDragging, setIsDragging] = useState(false);
// Camera mode state // Camera mode state
const [useCamera, setUseCamera] = useState(false); const [useCamera, setUseCamera] = useState(false);
@@ -164,8 +165,25 @@ export function ScanPage() {
) : ( ) : (
<> <>
<div <div
className="w-full border-2 border-dashed border-green-300 rounded-md p-10 h-60 text-center cursor-pointer" className={`w-full border-2 border-dashed rounded-md p-10 h-60 text-center cursor-pointer transition-colors duration-200 ${
isDragging
? "border-blue-500 bg-blue-100"
: "border-green-300"
}`}
onClick={() => inputRef.current?.click()} onClick={() => inputRef.current?.click()}
onDragOver={(e) => {
e.preventDefault();
setIsDragging(true);
}}
onDragLeave={() => setIsDragging(false)}
onDrop={(e) => {
e.preventDefault();
setIsDragging(false);
const droppedFile = e.dataTransfer.files?.[0];
if (droppedFile) {
handleFile(droppedFile);
}
}}
> >
<Upload <Upload
className="mx-auto text-green-500 mb-3" className="mx-auto text-green-500 mb-3"