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-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",
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
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,
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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={{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
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 { 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) };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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,
|
||||
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;
|
||||
|
|
|
|||
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 {
|
||||
/** 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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue