feat(mobile): backend sync auth + ban handling + scan push

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) <noreply@anthropic.com>
This commit is contained in:
Yanis 2026-05-01 12:11:11 +02:00
parent af767879e3
commit 26d0f39986
16 changed files with 558 additions and 71 deletions

View file

@ -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",

View file

@ -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):

View file

@ -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 (
<Modal
visible={isBanned}
transparent
animationType="fade"
statusBarTranslucent
onRequestClose={() => {
// Non-dismissible: ignore back button on Android.
}}
>
<View className="flex-1 items-center justify-center bg-black/70 px-6">
<View className="w-full max-w-[360px] rounded-3xl bg-white p-6 items-center">
<View className="w-16 h-16 rounded-full items-center justify-center bg-red-50 mb-4">
<ShieldAlert size={32} color="#DC2626" />
</View>
<Text className="text-xl font-bold text-[#1A1A1A] text-center">
{t('auth.banned.title')}
</Text>
<Text className="mt-3 text-sm text-[#475569] leading-5 text-center">
{bannedReason
? t('auth.banned.description', { reason: bannedReason })
: t('auth.banned.descriptionNoReason')}
</Text>
<Pressable
onPress={() => {
void resetAccount();
}}
className="mt-6 w-full rounded-2xl bg-red-600 py-3.5 items-center active:opacity-80"
>
<Text className="text-base font-semibold text-white">
{t('auth.banned.cta')}
</Text>
</Pressable>
</View>
</View>
</Modal>
);
}

View file

@ -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<void>;
loginAsGuest: () => Promise<void>;
logout: () => Promise<void>;
@ -27,13 +42,32 @@ interface AuthContextValue {
const AuthContext = createContext<AuthContextValue | null>(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<User | null>(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<string | null>(null);
const userRef = useRef<User | null>(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,

View file

@ -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) => {

View file

@ -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": {

View file

@ -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": {

View file

@ -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 (
<NavigationContainer linking={linking}>
<BannedModal />
<Stack.Navigator
initialRouteName={isOnboardingComplete ? 'Splash' : 'Onboarding'}
screenOptions={{

View file

@ -6,6 +6,7 @@ import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { useTranslation } from 'react-i18next';
import { ChevronLeft } from 'lucide-react-native';
import Animated, { FadeIn } from 'react-native-reanimated';
import { toast } from 'sonner-native';
import { Text } from '@/components/ui/text';
import { EmailNameForm } from '@/components/onboarding/EmailNameForm';
@ -36,6 +37,10 @@ export default function AuthChoiceScreen() {
try {
await login(name, email);
await completeOnboarding();
} catch (err) {
const message =
err instanceof Error ? err.message : t('auth.errors.network');
toast.error(t('auth.errors.signupFailed'), { description: message });
} finally {
setCreating(false);
}

View file

@ -0,0 +1,41 @@
import { apiPost, apiGet } from "@/services/api/client";
// Mirrors what /api/mobile/auth/* returns. Stays loose (Partial<>) 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<SyncResponse>("/auth/sync", args, { raw: true });
}
export async function fetchMe() {
return apiGet<MeResponse>("/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<unknown>("/auth/sign-out", {}, { auth: true, raw: true });
}

View file

@ -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<Listener>();
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);
};
}

View file

@ -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<T> =
| { 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<Headers> {
const headers = new Headers(base);
if (withAuth) {
const token = await getToken();
if (token) {
headers.set("Authorization", `Bearer ${token}`);
}
}
return headers;
}
async function handleResponse<T>(
res: Response,
opts: FetchOpts,
): Promise<ApiResponse<T>> {
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<T>(
endpoint: string,
params?: Record<string, string>,
opts: FetchOpts = {},
): Promise<ApiResponse<T>> {
const url = new URL(`${API_CONFIG.baseUrl}${endpoint}`);
if (params) {
@ -31,53 +129,40 @@ export async function apiGet<T>(
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<T>(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<T>(
endpoint: string,
body: unknown,
opts: FetchOpts = {},
): Promise<ApiResponse<T>> {
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<T>(res, opts);
} catch (err) {
clearTimeout(timeoutId);
return { success: false, error: asApiError(err) };
}
}

View file

@ -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<PushScanResponse>('/scans', body, { auth: true, raw: true });
}

View file

@ -29,6 +29,16 @@ export async function getUser(): Promise<User | null> {
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;

View file

@ -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<boolean> {
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<string | null> {
if (await isSecureAvailable()) {
return SecureStore.getItemAsync(key);
}
return AsyncStorage.getItem(key);
}
async function writeSecure(key: string, value: string): Promise<void> {
if (await isSecureAvailable()) {
await SecureStore.setItemAsync(key, value);
return;
}
await AsyncStorage.setItem(key, value);
}
async function deleteSecure(key: string): Promise<void> {
if (await isSecureAvailable()) {
await SecureStore.deleteItemAsync(key);
return;
}
await AsyncStorage.removeItem(key);
}
export async function saveToken(token: string): Promise<void> {
await writeSecure(TOKEN_KEY, token);
}
export async function getToken(): Promise<string | null> {
return readSecure(TOKEN_KEY);
}
export async function removeToken(): Promise<void> {
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<string> {
const existing = await readSecure(DEVICE_ID_KEY);
if (existing) return existing;
const fresh = Crypto.randomUUID();
await writeSecure(DEVICE_ID_KEY, fresh);
return fresh;
}

View file

@ -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 é synchronisé (offline ou guest). */
role?: "USER" | "ADMIN";
xp?: number;
level?: number;
banned?: boolean;
bannedReason?: string | null;
}
export interface AuthState {