From 26d0f39986745d55932ff2eebdb5d250732de6eb Mon Sep 17 00:00:00 2001 From: Yanis Date: Fri, 1 May 2026 12:11:11 +0200 Subject: [PATCH] feat(mobile): backend sync auth + ban handling + scan push MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the React Native app to the vineye-admin backend so user accounts and scans flow into the admin panel, and so a ban applied via the panel takes effect on the device on the next app boot (or sooner on any authenticated request). Core - Install expo-secure-store for storing the better-auth session token. Falls back to AsyncStorage on web/Expo Go where SecureStore is unavailable. - New tokenStorage service with saveToken/getToken/removeToken and a stable per-install getDeviceId() (used to derive the deterministic password the backend signs sign-up/sign-in with). - Extend the API client with apiPost(), automatic Bearer header attach, and a tiny pub/sub (authEvents) that emits 'unauthorized' on 401 and 'banned' on 403 with banned: true. Handlers are global so any request can trigger logout or open the BannedModal. Auth - New services/api/auth.ts: syncUser (POST /auth/sync), fetchMe (GET /auth/me), signOutServer (POST /auth/sign-out, best-effort). - types/auth.User now carries optional banned/bannedReason/role/xp/level hydrated from the backend. - AuthContext.login is now async vs the backend; the server-side id replaces any locally-generated UUID so mobile and admin agree on the same User row. Hydration is optimistic from AsyncStorage (never blocks the splash on the network) and a background fetchMe picks up server ban changes. logout/resetAccount best-effort revoke the server session. - AuthChoiceScreen surfaces sign-up failures through a toast instead of silently dropping the user into the app with no account. Ban UX - BannedModal: non-dismissible Tailwind modal with the bannedReason interpolated and a CTA that calls resetAccount. Mounted globally in RootNavigator and toggled by isBanned from AuthContext. - Banned state is persisted alongside the User in AsyncStorage so the modal stays visible across restarts even when /auth/me is unreachable. Scan sync - New services/api/scans.pushScan() that maps the mobile ScanRecord to the backend body: confidence /100 (0-100 → 0-1 backend), diseaseSlug passthrough (server resolves to diseaseId), latitude/longitude direct, imageUrl always null (V1 keeps photos local-only), customName dropped (no column server-side, stays in AsyncStorage). - useHistory.addScan now fires pushScan after the local save and ignores failures so the app stays usable offline. i18n - New keys auth.errors.network/signupFailed and auth.banned.{title, description,descriptionNoReason,cta}. The fr/en files also include scanner gallery placeholder keys from an adjacent feature WIP — not part of this commit's scope but bundled here to avoid splitting a small JSON. Co-Authored-By: Claude Opus 4.7 (1M context) --- VinEye/package.json | 3 +- VinEye/pnpm-lock.yaml | 32 ++-- VinEye/src/components/auth/BannedModal.tsx | 52 ++++++ VinEye/src/contexts/AuthContext.tsx | 134 +++++++++++++- VinEye/src/hooks/useHistory.ts | 5 + VinEye/src/i18n/en.json | 15 +- VinEye/src/i18n/fr.json | 15 +- VinEye/src/navigation/RootNavigator.tsx | 2 + .../screens/onboarding/AuthChoiceScreen.tsx | 5 + VinEye/src/services/api/auth.ts | 41 ++++ VinEye/src/services/api/authEvents.ts | 28 +++ VinEye/src/services/api/client.ts | 175 +++++++++++++----- VinEye/src/services/api/scans.ts | 31 ++++ VinEye/src/services/auth/authStorage.ts | 10 + VinEye/src/services/auth/tokenStorage.ts | 72 +++++++ VinEye/src/types/auth.ts | 9 +- 16 files changed, 558 insertions(+), 71 deletions(-) create mode 100644 VinEye/src/components/auth/BannedModal.tsx create mode 100644 VinEye/src/services/api/auth.ts create mode 100644 VinEye/src/services/api/authEvents.ts create mode 100644 VinEye/src/services/api/scans.ts create mode 100644 VinEye/src/services/auth/tokenStorage.ts diff --git a/VinEye/package.json b/VinEye/package.json index fcf5725..9dff5d6 100644 --- a/VinEye/package.json +++ b/VinEye/package.json @@ -28,12 +28,13 @@ "expo-dev-client": "~6.0.21", "expo-haptics": "~15.0.8", "expo-image": "~3.0.11", - "expo-image-manipulator": "^55.0.15", + "expo-image-manipulator": "^14.0.8", "expo-linear-gradient": "~15.0.8", "expo-localization": "~17.0.8", "expo-location": "~19.0.8", "expo-navigation-bar": "~5.0.10", "expo-network": "~8.0.8", + "expo-secure-store": "^55.0.13", "expo-status-bar": "~3.0.9", "i18next": "^26.0.1", "jpeg-js": "^0.4.4", diff --git a/VinEye/pnpm-lock.yaml b/VinEye/pnpm-lock.yaml index ce3fdc6..34cafd4 100644 --- a/VinEye/pnpm-lock.yaml +++ b/VinEye/pnpm-lock.yaml @@ -66,8 +66,8 @@ importers: specifier: ~3.0.11 version: 3.0.11(expo@54.0.33(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-web@0.21.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) expo-image-manipulator: - specifier: ^55.0.15 - version: 55.0.15(expo@54.0.33(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)) + specifier: ^14.0.8 + version: 14.0.8(expo@54.0.33(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)) expo-linear-gradient: specifier: ~15.0.8 version: 15.0.8(expo@54.0.33(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) @@ -83,6 +83,9 @@ importers: expo-network: specifier: ~8.0.8 version: 8.0.8(expo@54.0.33(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react@19.1.0) + expo-secure-store: + specifier: ^55.0.13 + version: 55.0.13(expo@54.0.33(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)) expo-status-bar: specifier: ~3.0.9 version: 3.0.9(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) @@ -1784,13 +1787,13 @@ packages: peerDependencies: expo: '*' - expo-image-loader@55.0.0: - resolution: {integrity: sha512-NOjp56wDrfuA5aiNAybBIjqIn1IxKeGJ8CECWZncQ/GzjZfyTYAHTCyeApYkdKkMBLHINzI4BbTGSlbCa0fXXQ==} + expo-image-loader@6.0.0: + resolution: {integrity: sha512-nKs/xnOGw6ACb4g26xceBD57FKLFkSwEUTDXEDF3Gtcu3MqF3ZIYd3YM+sSb1/z9AKV1dYT7rMSGVNgsveXLIQ==} peerDependencies: expo: '*' - expo-image-manipulator@55.0.15: - resolution: {integrity: sha512-AbwC1PHhoJb7OeMlbb52RK/nsTZMAuJDz2RlbDHtrD6zFOKV1LcMomdJqVYCC5v7ILH5qLF01iygpZt54GlydQ==} + expo-image-manipulator@14.0.8: + resolution: {integrity: sha512-sXsXjm7rIxLWZe0j2A41J/Ph53PpFJRdyzJ3EQ/qetxLUvS2m3K1sP5xy37px43qCf0l79N/i6XgFgenFV36/Q==} peerDependencies: expo: '*' @@ -1860,6 +1863,11 @@ packages: expo: '*' react: '*' + expo-secure-store@55.0.13: + resolution: {integrity: sha512-I6r0JNO1Fd4o0Gu7Ixiic7s89lqgdUHq17uBH9y1f/AntoyKn71TdtYJH82RgfsBbu5qNVzrwImmvlANyOlITQ==} + peerDependencies: + expo: '*' + expo-server@1.0.5: resolution: {integrity: sha512-IGR++flYH70rhLyeXF0Phle56/k4cee87WeQ4mamS+MkVAVP+dDlOHf2nN06Z9Y2KhU0Gp1k+y61KkghF7HdhA==} engines: {node: '>=20.16.0'} @@ -4863,9 +4871,7 @@ snapshots: metro-runtime: 0.83.5 transitivePeerDependencies: - '@babel/core' - - bufferutil - supports-color - - utf-8-validate optional: true '@react-native/normalize-colors@0.74.89': {} @@ -5681,14 +5687,14 @@ snapshots: dependencies: expo: 54.0.33(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) - expo-image-loader@55.0.0(expo@54.0.33(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)): + expo-image-loader@6.0.0(expo@54.0.33(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)): dependencies: expo: 54.0.33(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) - expo-image-manipulator@55.0.15(expo@54.0.33(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)): + expo-image-manipulator@14.0.8(expo@54.0.33(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)): dependencies: expo: 54.0.33(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) - expo-image-loader: 55.0.0(expo@54.0.33(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)) + expo-image-loader: 6.0.0(expo@54.0.33(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)) expo-image@3.0.11(expo@54.0.33(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-web@0.21.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): dependencies: @@ -5759,6 +5765,10 @@ snapshots: expo: 54.0.33(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) react: 19.1.0 + expo-secure-store@55.0.13(expo@54.0.33(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)): + dependencies: + expo: 54.0.33(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + expo-server@1.0.5: {} expo-status-bar@3.0.9(react-native@0.81.5(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): diff --git a/VinEye/src/components/auth/BannedModal.tsx b/VinEye/src/components/auth/BannedModal.tsx new file mode 100644 index 0000000..cf26950 --- /dev/null +++ b/VinEye/src/components/auth/BannedModal.tsx @@ -0,0 +1,52 @@ +import { Modal, View, Pressable } from 'react-native'; +import { useTranslation } from 'react-i18next'; +import { ShieldAlert } from 'lucide-react-native'; + +import { Text } from '@/components/ui/text'; +import { useAuth } from '@/contexts/AuthContext'; + +export function BannedModal() { + const { t } = useTranslation(); + const { isBanned, bannedReason, resetAccount } = useAuth(); + + return ( + { + // Non-dismissible: ignore back button on Android. + }} + > + + + + + + + + {t('auth.banned.title')} + + + + {bannedReason + ? t('auth.banned.description', { reason: bannedReason }) + : t('auth.banned.descriptionNoReason')} + + + { + void resetAccount(); + }} + className="mt-6 w-full rounded-2xl bg-red-600 py-3.5 items-center active:opacity-80" + > + + {t('auth.banned.cta')} + + + + + + ); +} diff --git a/VinEye/src/contexts/AuthContext.tsx b/VinEye/src/contexts/AuthContext.tsx index d68851e..307a497 100644 --- a/VinEye/src/contexts/AuthContext.tsx +++ b/VinEye/src/contexts/AuthContext.tsx @@ -3,12 +3,25 @@ import { useCallback, useContext, useEffect, + useRef, useState, } from 'react'; -import * as Crypto from 'expo-crypto'; import * as authStorage from '@/services/auth/authStorage'; import { generateGuestUser } from '@/services/auth/randomUser'; +import { + getDeviceId, + getToken, + removeToken, + saveToken, +} from '@/services/auth/tokenStorage'; +import { + fetchMe, + signOutServer, + syncUser, + type MobileServerUser, +} from '@/services/api/auth'; +import { subscribeAuthEvents } from '@/services/api/authEvents'; import type { User } from '@/types/auth'; interface AuthContextValue { @@ -17,6 +30,8 @@ interface AuthContextValue { isOnboardingComplete: boolean; hasAcceptedTerms: boolean; isLoading: boolean; + isBanned: boolean; + bannedReason: string | null; login: (name: string, email: string) => Promise; loginAsGuest: () => Promise; logout: () => Promise; @@ -27,13 +42,32 @@ interface AuthContextValue { const AuthContext = createContext(null); +function fromServerUser(server: MobileServerUser): User { + return { + id: server.id, + name: server.name, + email: server.email, + isGuest: false, + createdAt: server.createdAt, + role: server.role, + xp: server.xp, + level: server.level, + banned: server.banned, + bannedReason: server.bannedReason, + }; +} + export function AuthProvider({ children }: { children: React.ReactNode }) { const [user, setUser] = useState(null); const [isOnboardingComplete, setIsOnboardingComplete] = useState(false); const [hasAcceptedTerms, setHasAcceptedTerms] = useState(false); const [isLoading, setIsLoading] = useState(true); + const [isBanned, setIsBanned] = useState(false); + const [bannedReason, setBannedReason] = useState(null); + const userRef = useRef(null); + userRef.current = user; - // Hydrate from AsyncStorage on mount + // Optimistic hydration from AsyncStorage; never block the splash on the network. useEffect(() => { let alive = true; (async () => { @@ -47,38 +81,118 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { setUser(storedUser); setIsOnboardingComplete(onboarding); setHasAcceptedTerms(terms.accepted); + if (storedUser?.banned) { + setIsBanned(true); + setBannedReason(storedUser.bannedReason ?? null); + } } finally { if (alive) setIsLoading(false); } })(); + + // Background refresh: hit /auth/me to pick up server-side ban changes + // applied while the app was closed. Failures are silent (offline-friendly). + (async () => { + try { + const token = await getToken(); + if (!token) return; + const res = await fetchMe(); + if (!alive) return; + if (res.success) { + const fresh = fromServerUser(res.data.user); + setUser(fresh); + await authStorage.saveUser(fresh); + setIsBanned(fresh.banned ?? false); + setBannedReason(fresh.bannedReason ?? null); + } + // 401 → emitted by the client; subscribeAuthEvents handles logout + // 403 banned → also emitted by the client and handled below + } catch (err) { + if (__DEV__) console.warn('[Auth] fetchMe failed:', err); + } + })(); + return () => { alive = false; }; }, []); + // React to 401/banned events emitted from anywhere in the app. + useEffect(() => { + const unsub = subscribeAuthEvents((event) => { + if (event.type === 'banned') { + setIsBanned(true); + setBannedReason(event.reason); + // Persist so the modal stays visible across restarts even if /auth/me + // is unreachable. + const current = userRef.current; + if (current) { + void authStorage.saveUser({ + ...current, + banned: true, + bannedReason: event.reason, + }); + } + } else if (event.type === 'unauthorized') { + // Token revoked or expired — wipe credentials and send the user + // back to the onboarding flow. + void (async () => { + await removeToken(); + await authStorage.resetAuth(); + setUser(null); + setIsOnboardingComplete(false); + setHasAcceptedTerms(false); + setIsBanned(false); + setBannedReason(null); + })(); + } + }); + return unsub; + }, []); + const login = useCallback(async (name: string, email: string) => { - const newUser: User = { - id: Crypto.randomUUID(), + const deviceId = await getDeviceId(); + const res = await syncUser({ name: name.trim(), email: email.trim(), - isGuest: false, - createdAt: new Date().toISOString(), - }; - await authStorage.saveUser(newUser); - setUser(newUser); + deviceId, + }); + if (!res.success) { + // Surface the failure so the AuthChoiceScreen can show a toast and + // keep the user on the form instead of marking onboarding complete. + const msg = res.error.message || 'Network error'; + throw new Error(msg); + } + const fresh = fromServerUser(res.data.user); + await saveToken(res.data.token); + await authStorage.saveUser(fresh); + setUser(fresh); + setIsBanned(fresh.banned ?? false); + setBannedReason(fresh.bannedReason ?? null); }, []); const loginAsGuest = useCallback(async () => { const newUser = generateGuestUser(); await authStorage.saveUser(newUser); setUser(newUser); + setIsBanned(false); + setBannedReason(null); }, []); const logout = useCallback(async () => { + // Best-effort: revoke the server session, but never block the UI on it. + try { + await signOutServer(); + } catch { + // ignored + } + await removeToken(); await authStorage.resetAuth(); setUser(null); setIsOnboardingComplete(false); setHasAcceptedTerms(false); + setIsBanned(false); + setBannedReason(null); }, []); const resetAccount = useCallback(async () => { @@ -101,6 +215,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { isOnboardingComplete, hasAcceptedTerms, isLoading, + isBanned, + bannedReason, login, loginAsGuest, logout, diff --git a/VinEye/src/hooks/useHistory.ts b/VinEye/src/hooks/useHistory.ts index 3e23726..74cabcb 100644 --- a/VinEye/src/hooks/useHistory.ts +++ b/VinEye/src/hooks/useHistory.ts @@ -1,6 +1,7 @@ import { useState, useEffect, useCallback } from 'react'; import { storage } from '@/services/storage'; import { buildMockScans } from '@/data/mockSeed'; +import { pushScan } from '@/services/api/scans'; import type { ScanRecord } from '@/types/detection'; export function useHistory() { @@ -24,6 +25,10 @@ export function useHistory() { storage.set(storage.KEYS.SCAN_HISTORY, updated); return updated; }); + // Best-effort sync to the backend so the scan shows up in the admin + // panel. Network errors are intentionally swallowed; the local state + // is the source of truth for the mobile UX. + void pushScan(record).catch(() => {}); }, []); const deleteScan = useCallback(async (id: string) => { diff --git a/VinEye/src/i18n/en.json b/VinEye/src/i18n/en.json index a296b78..78abff2 100644 --- a/VinEye/src/i18n/en.json +++ b/VinEye/src/i18n/en.json @@ -439,7 +439,10 @@ "frontWarningTitle": "Front camera enabled", "frontWarningDescription": "For best results, use the rear camera.", "analyzingTitle": "Analyzing", - "analyzingSubtitle": "Identifying the plant…" + "analyzingSubtitle": "Identifying the plant…", + "pickFromGallery": "Pick from gallery", + "galleryComingSoonTitle": "Gallery coming soon", + "galleryComingSoonDescription": "This feature will be available in an upcoming update." }, "result": { "vineDetected": "Vine detected!", @@ -547,7 +550,15 @@ "errors": { "nameTooShort": "Name must be at least 2 characters", "nameTooLong": "Name is too long (max 50 characters)", - "emailInvalid": "Invalid email" + "emailInvalid": "Invalid email", + "network": "No network connection", + "signupFailed": "Could not create account" + }, + "banned": { + "title": "Account suspended", + "description": "Your account has been suspended: {{reason}}", + "descriptionNoReason": "Your account has been suspended. Please contact the administrator for more information.", + "cta": "Logout" } }, "onboarding": { diff --git a/VinEye/src/i18n/fr.json b/VinEye/src/i18n/fr.json index 0d87c37..4fb6ee2 100644 --- a/VinEye/src/i18n/fr.json +++ b/VinEye/src/i18n/fr.json @@ -439,7 +439,10 @@ "frontWarningTitle": "Caméra avant activée", "frontWarningDescription": "Pour de meilleurs résultats, utilisez la caméra arrière.", "analyzingTitle": "Analyse en cours", - "analyzingSubtitle": "Identification de la plante…" + "analyzingSubtitle": "Identification de la plante…", + "pickFromGallery": "Choisir depuis la galerie", + "galleryComingSoonTitle": "Galerie bientôt disponible", + "galleryComingSoonDescription": "Cette fonctionnalité arrive dans une prochaine mise à jour." }, "result": { "vineDetected": "Vigne détectée !", @@ -547,7 +550,15 @@ "errors": { "nameTooShort": "Le nom doit faire au moins 2 caractères", "nameTooLong": "Le nom est trop long (50 caractères max)", - "emailInvalid": "Email invalide" + "emailInvalid": "Email invalide", + "network": "Pas de connexion réseau", + "signupFailed": "Impossible de créer le compte" + }, + "banned": { + "title": "Compte suspendu", + "description": "Votre compte a été suspendu : {{reason}}", + "descriptionNoReason": "Votre compte a été suspendu. Contactez l'administrateur pour plus d'informations.", + "cta": "Se déconnecter" } }, "onboarding": { diff --git a/VinEye/src/navigation/RootNavigator.tsx b/VinEye/src/navigation/RootNavigator.tsx index 1ffde0d..faeec14 100644 --- a/VinEye/src/navigation/RootNavigator.tsx +++ b/VinEye/src/navigation/RootNavigator.tsx @@ -2,6 +2,7 @@ import { ActivityIndicator, View } from 'react-native'; import { createNativeStackNavigator } from '@react-navigation/native-stack'; import { NavigationContainer } from '@react-navigation/native'; +import { BannedModal } from '@/components/auth/BannedModal'; import SplashScreen from '@/screens/SplashScreen'; import ResultScreen from '@/screens/ResultScreen'; import SearchScreen from '@/screens/SearchScreen'; @@ -40,6 +41,7 @@ export default function RootNavigator() { return ( + ) so a +// future schema bump on a single field does not crash the mobile app. +export interface MobileServerUser { + id: string; + name: string; + email: string; + role: "USER" | "ADMIN"; + xp: number; + level: number; + banned: boolean; + bannedReason: string | null; + createdAt: string; +} + +export interface SyncResponse { + token: string; + user: MobileServerUser; +} + +export interface MeResponse { + user: MobileServerUser; +} + +export async function syncUser(args: { + name: string; + email: string; + deviceId: string; +}) { + return apiPost("/auth/sync", args, { raw: true }); +} + +export async function fetchMe() { + return apiGet("/auth/me", undefined, { auth: true, raw: true }); +} + +export async function signOutServer() { + // Best-effort. Backend always returns 204; we don't care about the body. + return apiPost("/auth/sign-out", {}, { auth: true, raw: true }); +} diff --git a/VinEye/src/services/api/authEvents.ts b/VinEye/src/services/api/authEvents.ts new file mode 100644 index 0000000..71d3bf8 --- /dev/null +++ b/VinEye/src/services/api/authEvents.ts @@ -0,0 +1,28 @@ +// Lightweight pub/sub for auth-impacting HTTP events. apiPost emits when it +// sees 401 (token expired/invalid) or 403 with banned: true. AuthContext +// subscribes to react globally without prop-drilling. + +type AuthEvent = + | { type: 'unauthorized' } + | { type: 'banned'; reason: string | null }; + +type Listener = (event: AuthEvent) => void; + +const listeners = new Set(); + +export function emitAuthEvent(event: AuthEvent): void { + listeners.forEach((l) => { + try { + l(event); + } catch (err) { + console.warn('[authEvents] listener threw:', err); + } + }); +} + +export function subscribeAuthEvents(listener: Listener): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} diff --git a/VinEye/src/services/api/client.ts b/VinEye/src/services/api/client.ts index c330098..107f824 100644 --- a/VinEye/src/services/api/client.ts +++ b/VinEye/src/services/api/client.ts @@ -1,4 +1,6 @@ import { API_CONFIG } from "@/config/api"; +import { getToken } from "@/services/auth/tokenStorage"; +import { emitAuthEvent } from "@/services/api/authEvents"; if (__DEV__) { console.log("[VinEye API] Base URL:", API_CONFIG.baseUrl); @@ -11,12 +13,108 @@ export type ApiError = { }; export type ApiResponse = - | { success: true; data: T; pagination?: { page: number; limit: number; total: number; pages: number } } + | { + success: true; + data: T; + pagination?: { page: number; limit: number; total: number; pages: number }; + } | { success: false; error: ApiError }; +type FetchOpts = { + /** When true, attach the Bearer session token if present. */ + auth?: boolean; + /** When true, do NOT unwrap json.data — return json directly as T. */ + raw?: boolean; +}; + +async function buildHeaders( + base: HeadersInit, + withAuth: boolean, +): Promise { + const headers = new Headers(base); + if (withAuth) { + const token = await getToken(); + if (token) { + headers.set("Authorization", `Bearer ${token}`); + } + } + return headers; +} + +async function handleResponse( + res: Response, + opts: FetchOpts, +): Promise> { + if (!res.ok) { + // Try to parse the body for richer error info (banned/bannedReason). + let body: unknown = null; + try { + body = await res.json(); + } catch { + // ignore + } + + if (res.status === 401) { + emitAuthEvent({ type: "unauthorized" }); + } else if ( + res.status === 403 && + typeof body === "object" && + body !== null && + (body as { banned?: boolean }).banned === true + ) { + emitAuthEvent({ + type: "banned", + reason: (body as { bannedReason?: string | null }).bannedReason ?? null, + }); + } + + return { + success: false, + error: { + type: "SERVER", + message: + (body as { error?: string } | null)?.error ?? + `Server responded with ${res.status}`, + status: res.status, + }, + }; + } + + try { + const json = await res.json(); + if (opts.raw) { + return { success: true, data: json as T }; + } + return { + success: true, + data: json.data as T, + pagination: json.pagination, + }; + } catch { + return { + success: false, + error: { type: "PARSE", message: "Failed to parse JSON response" }, + }; + } +} + +function asApiError(err: unknown): ApiError { + if (err instanceof DOMException && err.name === "AbortError") { + return { type: "TIMEOUT", message: "Request timed out" }; + } + if (err instanceof TypeError) { + return { type: "NETWORK", message: "No network connection" }; + } + return { + type: "UNKNOWN", + message: err instanceof Error ? err.message : "Unknown error", + }; +} + export async function apiGet( endpoint: string, params?: Record, + opts: FetchOpts = {}, ): Promise> { const url = new URL(`${API_CONFIG.baseUrl}${endpoint}`); if (params) { @@ -31,53 +129,40 @@ export async function apiGet( try { const res = await fetch(url.toString(), { method: "GET", - headers: { Accept: "application/json" }, + headers: await buildHeaders({ Accept: "application/json" }, !!opts.auth), signal: controller.signal, }); - clearTimeout(timeoutId); - - if (!res.ok) { - return { - success: false, - error: { - type: "SERVER", - message: `Server responded with ${res.status}`, - status: res.status, - }, - }; - } - - const json = await res.json(); - - return { - success: true, - data: json.data as T, - pagination: json.pagination, - }; - } catch (err: unknown) { + return handleResponse(res, opts); + } catch (err) { clearTimeout(timeoutId); - - if (err instanceof DOMException && err.name === "AbortError") { - return { - success: false, - error: { type: "TIMEOUT", message: "Request timed out" }, - }; - } - - if (err instanceof TypeError) { - return { - success: false, - error: { type: "NETWORK", message: "No network connection" }, - }; - } - - return { - success: false, - error: { - type: "UNKNOWN", - message: err instanceof Error ? err.message : "Unknown error", - }, - }; + return { success: false, error: asApiError(err) }; + } +} + +export async function apiPost( + endpoint: string, + body: unknown, + opts: FetchOpts = {}, +): Promise> { + const url = `${API_CONFIG.baseUrl}${endpoint}`; + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), API_CONFIG.timeout); + + try { + const res = await fetch(url, { + method: "POST", + headers: await buildHeaders( + { Accept: "application/json", "Content-Type": "application/json" }, + !!opts.auth, + ), + body: JSON.stringify(body), + signal: controller.signal, + }); + clearTimeout(timeoutId); + return handleResponse(res, opts); + } catch (err) { + clearTimeout(timeoutId); + return { success: false, error: asApiError(err) }; } } diff --git a/VinEye/src/services/api/scans.ts b/VinEye/src/services/api/scans.ts new file mode 100644 index 0000000..a9ab573 --- /dev/null +++ b/VinEye/src/services/api/scans.ts @@ -0,0 +1,31 @@ +import { apiPost } from '@/services/api/client'; +import { getDeviceId } from '@/services/auth/tokenStorage'; +import type { ScanRecord } from '@/types/detection'; + +interface PushScanResponse { + scan: { + id: string; + confidence: number; + latitude: number | null; + longitude: number | null; + createdAt: string; + disease: { slug: string; name: string } | null; + }; +} + +// Best-effort push of a freshly-saved scan to the backend. The mobile app +// never blocks on this — failures are silent (offline, no account, etc.). +// Confidence on the device is 0-100; the backend stores 0-1. +export async function pushScan(record: ScanRecord) { + const deviceId = await getDeviceId(); + + const body = { + confidence: Math.max(0, Math.min(1, record.detection.confidence / 100)), + diseaseSlug: record.detection.diseaseSlug ?? null, + latitude: typeof record.latitude === 'number' ? record.latitude : null, + longitude: typeof record.longitude === 'number' ? record.longitude : null, + deviceId, + }; + + return apiPost('/scans', body, { auth: true, raw: true }); +} diff --git a/VinEye/src/services/auth/authStorage.ts b/VinEye/src/services/auth/authStorage.ts index fd6fe27..23ada12 100644 --- a/VinEye/src/services/auth/authStorage.ts +++ b/VinEye/src/services/auth/authStorage.ts @@ -29,6 +29,16 @@ export async function getUser(): Promise { email: typeof parsed.email === 'string' ? parsed.email : null, isGuest: parsed.isGuest, createdAt: parsed.createdAt, + role: parsed.role === 'ADMIN' ? 'ADMIN' : parsed.role === 'USER' ? 'USER' : undefined, + xp: typeof parsed.xp === 'number' ? parsed.xp : undefined, + level: typeof parsed.level === 'number' ? parsed.level : undefined, + banned: typeof parsed.banned === 'boolean' ? parsed.banned : undefined, + bannedReason: + typeof parsed.bannedReason === 'string' + ? parsed.bannedReason + : parsed.bannedReason === null + ? null + : undefined, }; } catch { return null; diff --git a/VinEye/src/services/auth/tokenStorage.ts b/VinEye/src/services/auth/tokenStorage.ts new file mode 100644 index 0000000..c24ec11 --- /dev/null +++ b/VinEye/src/services/auth/tokenStorage.ts @@ -0,0 +1,72 @@ +import * as SecureStore from 'expo-secure-store'; +import * as Crypto from 'expo-crypto'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { Platform } from 'react-native'; + +const TOKEN_KEY = 'vineye_session_token'; +const DEVICE_ID_KEY = 'vineye_device_id'; + +// SecureStore is unavailable on web and on some legacy Android setups; we +// fall back to AsyncStorage so the auth flow keeps working in dev (Expo Go +// web preview) at the cost of weaker secrecy. +let secureAvailable: boolean | null = null; + +async function isSecureAvailable(): Promise { + if (secureAvailable !== null) return secureAvailable; + if (Platform.OS === 'web') { + secureAvailable = false; + return false; + } + try { + secureAvailable = await SecureStore.isAvailableAsync(); + } catch { + secureAvailable = false; + } + return secureAvailable; +} + +async function readSecure(key: string): Promise { + if (await isSecureAvailable()) { + return SecureStore.getItemAsync(key); + } + return AsyncStorage.getItem(key); +} + +async function writeSecure(key: string, value: string): Promise { + if (await isSecureAvailable()) { + await SecureStore.setItemAsync(key, value); + return; + } + await AsyncStorage.setItem(key, value); +} + +async function deleteSecure(key: string): Promise { + if (await isSecureAvailable()) { + await SecureStore.deleteItemAsync(key); + return; + } + await AsyncStorage.removeItem(key); +} + +export async function saveToken(token: string): Promise { + await writeSecure(TOKEN_KEY, token); +} + +export async function getToken(): Promise { + return readSecure(TOKEN_KEY); +} + +export async function removeToken(): Promise { + await deleteSecure(TOKEN_KEY); +} + +// A stable per-install device id. Used as part of the deterministic mobile +// auth password — must persist across logouts so the same email keeps the +// same backend account on this device. +export async function getDeviceId(): Promise { + const existing = await readSecure(DEVICE_ID_KEY); + if (existing) return existing; + const fresh = Crypto.randomUUID(); + await writeSecure(DEVICE_ID_KEY, fresh); + return fresh; +} diff --git a/VinEye/src/types/auth.ts b/VinEye/src/types/auth.ts index 1237506..ed08eca 100644 --- a/VinEye/src/types/auth.ts +++ b/VinEye/src/types/auth.ts @@ -1,5 +1,5 @@ export interface User { - /** UUID v4 généré via expo-crypto. */ + /** UUID v4 (guest) ou id retourné par le backend (compte synchronisé). */ id: string; name: string; /** null pour les invités (compte généré sans email). */ @@ -7,6 +7,13 @@ export interface User { isGuest: boolean; /** ISO 8601. */ createdAt: string; + /** Champs hydratés depuis /api/mobile/auth/me — absents tant que le compte + * n'a pas été synchronisé (offline ou guest). */ + role?: "USER" | "ADMIN"; + xp?: number; + level?: number; + banned?: boolean; + bannedReason?: string | null; } export interface AuthState {