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 {