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:
parent
af767879e3
commit
26d0f39986
|
|
@ -28,12 +28,13 @@
|
||||||
"expo-dev-client": "~6.0.21",
|
"expo-dev-client": "~6.0.21",
|
||||||
"expo-haptics": "~15.0.8",
|
"expo-haptics": "~15.0.8",
|
||||||
"expo-image": "~3.0.11",
|
"expo-image": "~3.0.11",
|
||||||
"expo-image-manipulator": "^55.0.15",
|
"expo-image-manipulator": "^14.0.8",
|
||||||
"expo-linear-gradient": "~15.0.8",
|
"expo-linear-gradient": "~15.0.8",
|
||||||
"expo-localization": "~17.0.8",
|
"expo-localization": "~17.0.8",
|
||||||
"expo-location": "~19.0.8",
|
"expo-location": "~19.0.8",
|
||||||
"expo-navigation-bar": "~5.0.10",
|
"expo-navigation-bar": "~5.0.10",
|
||||||
"expo-network": "~8.0.8",
|
"expo-network": "~8.0.8",
|
||||||
|
"expo-secure-store": "^55.0.13",
|
||||||
"expo-status-bar": "~3.0.9",
|
"expo-status-bar": "~3.0.9",
|
||||||
"i18next": "^26.0.1",
|
"i18next": "^26.0.1",
|
||||||
"jpeg-js": "^0.4.4",
|
"jpeg-js": "^0.4.4",
|
||||||
|
|
|
||||||
|
|
@ -66,8 +66,8 @@ importers:
|
||||||
specifier: ~3.0.11
|
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)
|
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:
|
expo-image-manipulator:
|
||||||
specifier: ^55.0.15
|
specifier: ^14.0.8
|
||||||
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))
|
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:
|
expo-linear-gradient:
|
||||||
specifier: ~15.0.8
|
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)
|
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:
|
expo-network:
|
||||||
specifier: ~8.0.8
|
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)
|
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:
|
expo-status-bar:
|
||||||
specifier: ~3.0.9
|
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)
|
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:
|
peerDependencies:
|
||||||
expo: '*'
|
expo: '*'
|
||||||
|
|
||||||
expo-image-loader@55.0.0:
|
expo-image-loader@6.0.0:
|
||||||
resolution: {integrity: sha512-NOjp56wDrfuA5aiNAybBIjqIn1IxKeGJ8CECWZncQ/GzjZfyTYAHTCyeApYkdKkMBLHINzI4BbTGSlbCa0fXXQ==}
|
resolution: {integrity: sha512-nKs/xnOGw6ACb4g26xceBD57FKLFkSwEUTDXEDF3Gtcu3MqF3ZIYd3YM+sSb1/z9AKV1dYT7rMSGVNgsveXLIQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
expo: '*'
|
expo: '*'
|
||||||
|
|
||||||
expo-image-manipulator@55.0.15:
|
expo-image-manipulator@14.0.8:
|
||||||
resolution: {integrity: sha512-AbwC1PHhoJb7OeMlbb52RK/nsTZMAuJDz2RlbDHtrD6zFOKV1LcMomdJqVYCC5v7ILH5qLF01iygpZt54GlydQ==}
|
resolution: {integrity: sha512-sXsXjm7rIxLWZe0j2A41J/Ph53PpFJRdyzJ3EQ/qetxLUvS2m3K1sP5xy37px43qCf0l79N/i6XgFgenFV36/Q==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
expo: '*'
|
expo: '*'
|
||||||
|
|
||||||
|
|
@ -1860,6 +1863,11 @@ packages:
|
||||||
expo: '*'
|
expo: '*'
|
||||||
react: '*'
|
react: '*'
|
||||||
|
|
||||||
|
expo-secure-store@55.0.13:
|
||||||
|
resolution: {integrity: sha512-I6r0JNO1Fd4o0Gu7Ixiic7s89lqgdUHq17uBH9y1f/AntoyKn71TdtYJH82RgfsBbu5qNVzrwImmvlANyOlITQ==}
|
||||||
|
peerDependencies:
|
||||||
|
expo: '*'
|
||||||
|
|
||||||
expo-server@1.0.5:
|
expo-server@1.0.5:
|
||||||
resolution: {integrity: sha512-IGR++flYH70rhLyeXF0Phle56/k4cee87WeQ4mamS+MkVAVP+dDlOHf2nN06Z9Y2KhU0Gp1k+y61KkghF7HdhA==}
|
resolution: {integrity: sha512-IGR++flYH70rhLyeXF0Phle56/k4cee87WeQ4mamS+MkVAVP+dDlOHf2nN06Z9Y2KhU0Gp1k+y61KkghF7HdhA==}
|
||||||
engines: {node: '>=20.16.0'}
|
engines: {node: '>=20.16.0'}
|
||||||
|
|
@ -4863,9 +4871,7 @@ snapshots:
|
||||||
metro-runtime: 0.83.5
|
metro-runtime: 0.83.5
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@babel/core'
|
- '@babel/core'
|
||||||
- bufferutil
|
|
||||||
- supports-color
|
- supports-color
|
||||||
- utf-8-validate
|
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@react-native/normalize-colors@0.74.89': {}
|
'@react-native/normalize-colors@0.74.89': {}
|
||||||
|
|
@ -5681,14 +5687,14 @@ snapshots:
|
||||||
dependencies:
|
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: 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:
|
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: 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:
|
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: 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):
|
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:
|
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)
|
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
|
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-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):
|
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):
|
||||||
|
|
|
||||||
52
VinEye/src/components/auth/BannedModal.tsx
Normal file
52
VinEye/src/components/auth/BannedModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -3,12 +3,25 @@ import {
|
||||||
useCallback,
|
useCallback,
|
||||||
useContext,
|
useContext,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import * as Crypto from 'expo-crypto';
|
|
||||||
|
|
||||||
import * as authStorage from '@/services/auth/authStorage';
|
import * as authStorage from '@/services/auth/authStorage';
|
||||||
import { generateGuestUser } from '@/services/auth/randomUser';
|
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';
|
import type { User } from '@/types/auth';
|
||||||
|
|
||||||
interface AuthContextValue {
|
interface AuthContextValue {
|
||||||
|
|
@ -17,6 +30,8 @@ interface AuthContextValue {
|
||||||
isOnboardingComplete: boolean;
|
isOnboardingComplete: boolean;
|
||||||
hasAcceptedTerms: boolean;
|
hasAcceptedTerms: boolean;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
isBanned: boolean;
|
||||||
|
bannedReason: string | null;
|
||||||
login: (name: string, email: string) => Promise<void>;
|
login: (name: string, email: string) => Promise<void>;
|
||||||
loginAsGuest: () => Promise<void>;
|
loginAsGuest: () => Promise<void>;
|
||||||
logout: () => Promise<void>;
|
logout: () => Promise<void>;
|
||||||
|
|
@ -27,13 +42,32 @@ interface AuthContextValue {
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
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 }) {
|
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [user, setUser] = useState<User | null>(null);
|
const [user, setUser] = useState<User | null>(null);
|
||||||
const [isOnboardingComplete, setIsOnboardingComplete] = useState(false);
|
const [isOnboardingComplete, setIsOnboardingComplete] = useState(false);
|
||||||
const [hasAcceptedTerms, setHasAcceptedTerms] = useState(false);
|
const [hasAcceptedTerms, setHasAcceptedTerms] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
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(() => {
|
useEffect(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
(async () => {
|
(async () => {
|
||||||
|
|
@ -47,38 +81,118 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
setUser(storedUser);
|
setUser(storedUser);
|
||||||
setIsOnboardingComplete(onboarding);
|
setIsOnboardingComplete(onboarding);
|
||||||
setHasAcceptedTerms(terms.accepted);
|
setHasAcceptedTerms(terms.accepted);
|
||||||
|
if (storedUser?.banned) {
|
||||||
|
setIsBanned(true);
|
||||||
|
setBannedReason(storedUser.bannedReason ?? null);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (alive) setIsLoading(false);
|
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 () => {
|
return () => {
|
||||||
alive = false;
|
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 login = useCallback(async (name: string, email: string) => {
|
||||||
const newUser: User = {
|
const deviceId = await getDeviceId();
|
||||||
id: Crypto.randomUUID(),
|
const res = await syncUser({
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
email: email.trim(),
|
email: email.trim(),
|
||||||
isGuest: false,
|
deviceId,
|
||||||
createdAt: new Date().toISOString(),
|
});
|
||||||
};
|
if (!res.success) {
|
||||||
await authStorage.saveUser(newUser);
|
// Surface the failure so the AuthChoiceScreen can show a toast and
|
||||||
setUser(newUser);
|
// 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 loginAsGuest = useCallback(async () => {
|
||||||
const newUser = generateGuestUser();
|
const newUser = generateGuestUser();
|
||||||
await authStorage.saveUser(newUser);
|
await authStorage.saveUser(newUser);
|
||||||
setUser(newUser);
|
setUser(newUser);
|
||||||
|
setIsBanned(false);
|
||||||
|
setBannedReason(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const logout = useCallback(async () => {
|
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();
|
await authStorage.resetAuth();
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setIsOnboardingComplete(false);
|
setIsOnboardingComplete(false);
|
||||||
setHasAcceptedTerms(false);
|
setHasAcceptedTerms(false);
|
||||||
|
setIsBanned(false);
|
||||||
|
setBannedReason(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const resetAccount = useCallback(async () => {
|
const resetAccount = useCallback(async () => {
|
||||||
|
|
@ -101,6 +215,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
isOnboardingComplete,
|
isOnboardingComplete,
|
||||||
hasAcceptedTerms,
|
hasAcceptedTerms,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
isBanned,
|
||||||
|
bannedReason,
|
||||||
login,
|
login,
|
||||||
loginAsGuest,
|
loginAsGuest,
|
||||||
logout,
|
logout,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { storage } from '@/services/storage';
|
import { storage } from '@/services/storage';
|
||||||
import { buildMockScans } from '@/data/mockSeed';
|
import { buildMockScans } from '@/data/mockSeed';
|
||||||
|
import { pushScan } from '@/services/api/scans';
|
||||||
import type { ScanRecord } from '@/types/detection';
|
import type { ScanRecord } from '@/types/detection';
|
||||||
|
|
||||||
export function useHistory() {
|
export function useHistory() {
|
||||||
|
|
@ -24,6 +25,10 @@ export function useHistory() {
|
||||||
storage.set(storage.KEYS.SCAN_HISTORY, updated);
|
storage.set(storage.KEYS.SCAN_HISTORY, updated);
|
||||||
return 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) => {
|
const deleteScan = useCallback(async (id: string) => {
|
||||||
|
|
|
||||||
|
|
@ -439,7 +439,10 @@
|
||||||
"frontWarningTitle": "Front camera enabled",
|
"frontWarningTitle": "Front camera enabled",
|
||||||
"frontWarningDescription": "For best results, use the rear camera.",
|
"frontWarningDescription": "For best results, use the rear camera.",
|
||||||
"analyzingTitle": "Analyzing",
|
"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": {
|
"result": {
|
||||||
"vineDetected": "Vine detected!",
|
"vineDetected": "Vine detected!",
|
||||||
|
|
@ -547,7 +550,15 @@
|
||||||
"errors": {
|
"errors": {
|
||||||
"nameTooShort": "Name must be at least 2 characters",
|
"nameTooShort": "Name must be at least 2 characters",
|
||||||
"nameTooLong": "Name is too long (max 50 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": {
|
"onboarding": {
|
||||||
|
|
|
||||||
|
|
@ -439,7 +439,10 @@
|
||||||
"frontWarningTitle": "Caméra avant activée",
|
"frontWarningTitle": "Caméra avant activée",
|
||||||
"frontWarningDescription": "Pour de meilleurs résultats, utilisez la caméra arrière.",
|
"frontWarningDescription": "Pour de meilleurs résultats, utilisez la caméra arrière.",
|
||||||
"analyzingTitle": "Analyse en cours",
|
"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": {
|
"result": {
|
||||||
"vineDetected": "Vigne détectée !",
|
"vineDetected": "Vigne détectée !",
|
||||||
|
|
@ -547,7 +550,15 @@
|
||||||
"errors": {
|
"errors": {
|
||||||
"nameTooShort": "Le nom doit faire au moins 2 caractères",
|
"nameTooShort": "Le nom doit faire au moins 2 caractères",
|
||||||
"nameTooLong": "Le nom est trop long (50 caractères max)",
|
"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": {
|
"onboarding": {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { ActivityIndicator, View } from 'react-native';
|
||||||
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
||||||
import { NavigationContainer } from '@react-navigation/native';
|
import { NavigationContainer } from '@react-navigation/native';
|
||||||
|
|
||||||
|
import { BannedModal } from '@/components/auth/BannedModal';
|
||||||
import SplashScreen from '@/screens/SplashScreen';
|
import SplashScreen from '@/screens/SplashScreen';
|
||||||
import ResultScreen from '@/screens/ResultScreen';
|
import ResultScreen from '@/screens/ResultScreen';
|
||||||
import SearchScreen from '@/screens/SearchScreen';
|
import SearchScreen from '@/screens/SearchScreen';
|
||||||
|
|
@ -40,6 +41,7 @@ export default function RootNavigator() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NavigationContainer linking={linking}>
|
<NavigationContainer linking={linking}>
|
||||||
|
<BannedModal />
|
||||||
<Stack.Navigator
|
<Stack.Navigator
|
||||||
initialRouteName={isOnboardingComplete ? 'Splash' : 'Onboarding'}
|
initialRouteName={isOnboardingComplete ? 'Splash' : 'Onboarding'}
|
||||||
screenOptions={{
|
screenOptions={{
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { ChevronLeft } from 'lucide-react-native';
|
import { ChevronLeft } from 'lucide-react-native';
|
||||||
import Animated, { FadeIn } from 'react-native-reanimated';
|
import Animated, { FadeIn } from 'react-native-reanimated';
|
||||||
|
import { toast } from 'sonner-native';
|
||||||
|
|
||||||
import { Text } from '@/components/ui/text';
|
import { Text } from '@/components/ui/text';
|
||||||
import { EmailNameForm } from '@/components/onboarding/EmailNameForm';
|
import { EmailNameForm } from '@/components/onboarding/EmailNameForm';
|
||||||
|
|
@ -36,6 +37,10 @@ export default function AuthChoiceScreen() {
|
||||||
try {
|
try {
|
||||||
await login(name, email);
|
await login(name, email);
|
||||||
await completeOnboarding();
|
await completeOnboarding();
|
||||||
|
} catch (err) {
|
||||||
|
const message =
|
||||||
|
err instanceof Error ? err.message : t('auth.errors.network');
|
||||||
|
toast.error(t('auth.errors.signupFailed'), { description: message });
|
||||||
} finally {
|
} finally {
|
||||||
setCreating(false);
|
setCreating(false);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
41
VinEye/src/services/api/auth.ts
Normal file
41
VinEye/src/services/api/auth.ts
Normal 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 });
|
||||||
|
}
|
||||||
28
VinEye/src/services/api/authEvents.ts
Normal file
28
VinEye/src/services/api/authEvents.ts
Normal 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);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
import { API_CONFIG } from "@/config/api";
|
import { API_CONFIG } from "@/config/api";
|
||||||
|
import { getToken } from "@/services/auth/tokenStorage";
|
||||||
|
import { emitAuthEvent } from "@/services/api/authEvents";
|
||||||
|
|
||||||
if (__DEV__) {
|
if (__DEV__) {
|
||||||
console.log("[VinEye API] Base URL:", API_CONFIG.baseUrl);
|
console.log("[VinEye API] Base URL:", API_CONFIG.baseUrl);
|
||||||
|
|
@ -11,12 +13,108 @@ export type ApiError = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ApiResponse<T> =
|
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 };
|
| { 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>(
|
export async function apiGet<T>(
|
||||||
endpoint: string,
|
endpoint: string,
|
||||||
params?: Record<string, string>,
|
params?: Record<string, string>,
|
||||||
|
opts: FetchOpts = {},
|
||||||
): Promise<ApiResponse<T>> {
|
): Promise<ApiResponse<T>> {
|
||||||
const url = new URL(`${API_CONFIG.baseUrl}${endpoint}`);
|
const url = new URL(`${API_CONFIG.baseUrl}${endpoint}`);
|
||||||
if (params) {
|
if (params) {
|
||||||
|
|
@ -31,53 +129,40 @@ export async function apiGet<T>(
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url.toString(), {
|
const res = await fetch(url.toString(), {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: { Accept: "application/json" },
|
headers: await buildHeaders({ Accept: "application/json" }, !!opts.auth),
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
|
return handleResponse<T>(res, opts);
|
||||||
if (!res.ok) {
|
} catch (err) {
|
||||||
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) {
|
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
|
return { success: false, error: asApiError(err) };
|
||||||
if (err instanceof DOMException && err.name === "AbortError") {
|
}
|
||||||
return {
|
}
|
||||||
success: false,
|
|
||||||
error: { type: "TIMEOUT", message: "Request timed out" },
|
export async function apiPost<T>(
|
||||||
};
|
endpoint: string,
|
||||||
}
|
body: unknown,
|
||||||
|
opts: FetchOpts = {},
|
||||||
if (err instanceof TypeError) {
|
): Promise<ApiResponse<T>> {
|
||||||
return {
|
const url = `${API_CONFIG.baseUrl}${endpoint}`;
|
||||||
success: false,
|
const controller = new AbortController();
|
||||||
error: { type: "NETWORK", message: "No network connection" },
|
const timeoutId = setTimeout(() => controller.abort(), API_CONFIG.timeout);
|
||||||
};
|
|
||||||
}
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
return {
|
method: "POST",
|
||||||
success: false,
|
headers: await buildHeaders(
|
||||||
error: {
|
{ Accept: "application/json", "Content-Type": "application/json" },
|
||||||
type: "UNKNOWN",
|
!!opts.auth,
|
||||||
message: err instanceof Error ? err.message : "Unknown error",
|
),
|
||||||
},
|
body: JSON.stringify(body),
|
||||||
};
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
return handleResponse<T>(res, opts);
|
||||||
|
} catch (err) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
return { success: false, error: asApiError(err) };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
31
VinEye/src/services/api/scans.ts
Normal file
31
VinEye/src/services/api/scans.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
|
@ -29,6 +29,16 @@ export async function getUser(): Promise<User | null> {
|
||||||
email: typeof parsed.email === 'string' ? parsed.email : null,
|
email: typeof parsed.email === 'string' ? parsed.email : null,
|
||||||
isGuest: parsed.isGuest,
|
isGuest: parsed.isGuest,
|
||||||
createdAt: parsed.createdAt,
|
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 {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
|
|
|
||||||
72
VinEye/src/services/auth/tokenStorage.ts
Normal file
72
VinEye/src/services/auth/tokenStorage.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
export interface User {
|
export interface User {
|
||||||
/** UUID v4 généré via expo-crypto. */
|
/** UUID v4 (guest) ou id retourné par le backend (compte synchronisé). */
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
/** null pour les invités (compte généré sans email). */
|
/** null pour les invités (compte généré sans email). */
|
||||||
|
|
@ -7,6 +7,13 @@ export interface User {
|
||||||
isGuest: boolean;
|
isGuest: boolean;
|
||||||
/** ISO 8601. */
|
/** ISO 8601. */
|
||||||
createdAt: string;
|
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 {
|
export interface AuthState {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue