add backend auth

This commit is contained in:
m
2026-07-14 09:47:55 +02:00
parent 6761141fed
commit 76ceafa56c
28 changed files with 1345 additions and 64 deletions
+56 -23
View File
@@ -1,8 +1,12 @@
import React from 'react';
import { StatusBar } from 'expo-status-bar';
import { ActivityIndicator, View } from 'react-native';
import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { AuthProvider, useAuth } from './contexts/AuthContext';
import { LoginScreen } from './app/login';
import { RegisterScreen } from './app/register';
import { HomeScreen } from './app/index';
import { UploadScreen } from './app/upload';
import { ScanScreen } from './app/scan';
@@ -16,32 +20,61 @@ import { FolderScreen } from './app/folder';
const Stack = createNativeStackNavigator();
const queryClient = new QueryClient();
function AppNavigator() {
const { user, isLoading } = useAuth();
if (isLoading) {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<ActivityIndicator size="large" />
</View>
);
}
return (
<NavigationContainer>
<Stack.Navigator initialRouteName={user ? 'Home' : 'Login'}>
{user ? (
<>
<Stack.Screen
name="Home"
component={HomeScreen}
options={{
title: 'Dot.',
headerTitleStyle: { fontSize: 18 },
}}
/>
<Stack.Screen name="Upload" component={UploadScreen} options={{ title: 'Upload' }} />
<Stack.Screen name="Scan" component={ScanScreen} options={{ title: 'Scan' }} />
<Stack.Screen name="Search" component={SearchScreen} options={{ title: 'Recherche' }} />
<Stack.Screen name="BatchReview" component={BatchReviewScreen} options={{ title: 'Revue du lot' }} />
<Stack.Screen name="PendingReview" component={PendingReviewScreen} options={{ title: 'Réorganisation' }} />
<Stack.Screen
name="FileDetail"
component={FileDetailScreen}
options={{ title: 'Détails', headerTintColor: '#fff', headerStyle: { backgroundColor: '#000' } }}
/>
<Stack.Screen name="FileEdit" component={FileEditScreen} options={{ title: 'Édition' }} />
<Stack.Screen name="Folder" component={FolderScreen} options={{ title: 'Dossier' }} />
</>
) : (
<>
<Stack.Screen name="Login" component={LoginScreen} options={{ headerShown: false }} />
<Stack.Screen name="Register" component={RegisterScreen} options={{ headerShown: false }} />
</>
)}
</Stack.Navigator>
</NavigationContainer>
);
}
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<NavigationContainer>
<Stack.Navigator initialRouteName="Home">
<Stack.Screen name="Home" component={HomeScreen}
options={{
title: 'Dot.',
headerTitleStyle: { fontSize: 18 },
}}
/>
<Stack.Screen name="Upload" component={UploadScreen} options={{ title: 'Upload' }} />
<Stack.Screen name="Scan" component={ScanScreen} options={{ title: 'Scan' }} />
<Stack.Screen name="Search" component={SearchScreen} options={{ title: 'Recherche' }} />
<Stack.Screen name="BatchReview" component={BatchReviewScreen} options={{ title: 'Revue du lot' }} />
<Stack.Screen name="PendingReview" component={PendingReviewScreen} options={{ title: 'Réorganisation' }} />
<Stack.Screen
name="FileDetail"
component={FileDetailScreen}
options={{ title: 'Détails', headerTintColor: '#fff', headerStyle: { backgroundColor: '#000' } }}
/>
<Stack.Screen name="FileEdit" component={FileEditScreen} options={{ title: 'Édition' }} />
<Stack.Screen name="Folder" component={FolderScreen} options={{ title: 'Dossier' }} />
</Stack.Navigator>
</NavigationContainer>
<StatusBar style="auto" />
<AuthProvider>
<AppNavigator />
<StatusBar style="auto" />
</AuthProvider>
</QueryClientProvider>
);
}
+59 -5
View File
@@ -1,26 +1,47 @@
import { API_BASE_URL, ENDPOINTS } from '../constants/api';
import { ApiError, HttpError } from '../types';
import { tokenStorage } from './secureStorage';
class ApiClient {
private baseUrl: string;
private accessToken: string | null = null;
constructor(baseUrl: string) {
this.baseUrl = baseUrl;
}
setAccessToken(token: string | null) {
this.accessToken = token;
}
private async request<T>(
endpoint: string,
options: RequestInit = {}
options: RequestInit = {},
isRetry = false
): Promise<T> {
const url = `${this.baseUrl}${endpoint}`;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string>),
};
if (this.accessToken) {
headers['Authorization'] = `Bearer ${this.accessToken}`;
}
const response = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
...options.headers,
},
headers,
});
if (response.status === 401 && !isRetry) {
const refreshed = await this.tryRefreshToken();
if (refreshed) {
return this.request<T>(endpoint, options, true);
}
throw new HttpError(401, 'Session expirée', 'UNAUTHORIZED');
}
if (!response.ok) {
let message = 'Request failed';
let code: string | undefined;
@@ -35,6 +56,39 @@ class ApiClient {
return response.json();
}
private async tryRefreshToken(): Promise<boolean> {
try {
const refreshToken = await tokenStorage.getRefreshToken();
if (!refreshToken) return false;
const url = `${this.baseUrl}${ENDPOINTS.AUTH_REFRESH}`;
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh_token: refreshToken }),
});
if (!response.ok) {
await this.clearAuth();
return false;
}
const data = await response.json();
this.accessToken = data.access_token;
await tokenStorage.setRefreshToken(data.refresh_token);
return true;
} catch {
await this.clearAuth();
return false;
}
}
private async clearAuth() {
this.accessToken = null;
await tokenStorage.deleteRefreshToken();
await tokenStorage.deleteUser();
}
async get<T>(endpoint: string): Promise<T> {
return this.request<T>(endpoint);
}
+30
View File
@@ -0,0 +1,30 @@
import * as SecureStore from 'expo-secure-store';
const REFRESH_KEY = 'vaultdrop_refresh_token';
const USER_KEY = 'vaultdrop_user';
export const tokenStorage = {
async getRefreshToken(): Promise<string | null> {
return SecureStore.getItemAsync(REFRESH_KEY);
},
async setRefreshToken(token: string): Promise<void> {
await SecureStore.setItemAsync(REFRESH_KEY, token);
},
async deleteRefreshToken(): Promise<void> {
await SecureStore.deleteItemAsync(REFRESH_KEY);
},
async getUser(): Promise<string | null> {
return SecureStore.getItemAsync(USER_KEY);
},
async setUser(user: string): Promise<void> {
await SecureStore.setItemAsync(USER_KEY, user);
},
async deleteUser(): Promise<void> {
await SecureStore.deleteItemAsync(USER_KEY);
},
};
+4 -1
View File
@@ -31,6 +31,9 @@
},
"web": {
"favicon": "./assets/favicon.png"
}
},
"plugins": [
"expo-secure-store"
]
}
}
+93 -15
View File
@@ -30,9 +30,11 @@ type FileEditRouteParams = {
interface FileEditItemProps {
fileId: string;
selected: boolean;
onPress: () => void;
}
function FileEditItem({ fileId }: FileEditItemProps) {
function FileEditItem({ fileId, selected, onPress }: FileEditItemProps) {
const { data: fileData, isLoading: fileLoading } = useFile(fileId);
const { data: imageData, isLoading: imageLoading } = useFileImage(fileId);
const file = fileData as any;
@@ -41,14 +43,14 @@ function FileEditItem({ fileId }: FileEditItemProps) {
if (isLoading) {
return (
<View style={[styles.gridItem, styles.gridItemLoading]}>
<TouchableOpacity style={[styles.gridItem, styles.gridItemLoading]} onPress={onPress} activeOpacity={0.7}>
<ActivityIndicator size="small" color="#1976D2" />
</View>
</TouchableOpacity>
);
}
return (
<View style={styles.gridItem}>
<TouchableOpacity style={styles.gridItem} onPress={onPress} activeOpacity={0.7}>
{uri && file?.mimeType?.startsWith('image/') ? (
<Image source={{ uri }} style={styles.thumb} />
) : (
@@ -58,7 +60,14 @@ function FileEditItem({ fileId }: FileEditItemProps) {
size={ITEM_SIZE}
/>
)}
</View>
{selected && (
<View style={styles.selectedOverlay}>
<View style={styles.checkCircle}>
<MaterialIcons name="check" size={18} color="#fff" />
</View>
</View>
)}
</TouchableOpacity>
);
}
@@ -74,6 +83,26 @@ export function FileEditScreen() {
const [tagInput, setTagInput] = useState('');
const [pendingTags, setPendingTags] = useState<string[]>([]);
const [uploading, setUploading] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const hasSelection = selectedIds.size > 0;
const targetIds = hasSelection ? Array.from(selectedIds) : fileIds;
const toggleSelection = useCallback((id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
const toggleSelectAll = useCallback(() => {
setSelectedIds((prev) => {
if (prev.size === fileIds.length) return new Set();
return new Set(fileIds);
});
}, [fileIds]);
const handleAddTag = () => {
const tag = tagInput.trim().toLowerCase();
@@ -88,21 +117,21 @@ export function FileEditScreen() {
const handleApplyTags = useCallback(async () => {
if (pendingTags.length === 0) return;
for (const fileId of fileIds) {
for (const fileId of targetIds) {
await addTags.mutateAsync({ fileId, tags: pendingTags });
}
Alert.alert('Succès', `${pendingTags.length} tag${pendingTags.length > 1 ? 's' : ''} ajouté${pendingTags.length > 1 ? 's' : ''}`);
setPendingTags([]);
}, [pendingTags, fileIds, addTags]);
}, [pendingTags, targetIds, addTags]);
const handleGeneratePdf = useCallback(async () => {
if (fileIds.length === 0) return;
if (targetIds.length === 0) return;
setUploading(true);
try {
const imageUris: { uri: string }[] = [];
for (const fileId of fileIds) {
for (const fileId of targetIds) {
const response = await fetch(`${process.env.EXPO_PUBLIC_API_BASE_URL || 'http://192.168.1.17:8080/api/v1'}/files/${fileId}`);
const data = await response.json();
const url = data?.data?.url;
@@ -152,17 +181,26 @@ export function FileEditScreen() {
} finally {
setUploading(false);
}
}, [fileIds, generatePdf, upload, navigation]);
}, [targetIds, generatePdf, upload, navigation]);
const isLoading = generating || uploading;
return (
<View style={styles.container}>
<View style={styles.header}>
<Text style={styles.title}>Édition</Text>
<Text style={styles.subtitle}>
{fileIds.length} fichier{fileIds.length > 1 ? 's' : ''} sélectionné{fileIds.length > 1 ? 's' : ''}
</Text>
<View style={styles.headerRow}>
<View>
<Text style={styles.title}>Édition</Text>
<Text style={styles.subtitle}>
{hasSelection
? `${selectedIds.size} sélectionné${selectedIds.size > 1 ? 's' : ''} / ${fileIds.length}`
: `${fileIds.length} fichier${fileIds.length > 1 ? 's' : ''}`}
</Text>
</View>
<TouchableOpacity style={styles.selectAllBtn} onPress={toggleSelectAll}>
<Text style={styles.selectAllText}>{hasSelection && selectedIds.size === fileIds.length ? 'Tout' : 'Tout'}</Text>
</TouchableOpacity>
</View>
</View>
<FlatList
@@ -171,7 +209,13 @@ export function FileEditScreen() {
keyExtractor={(item) => item}
contentContainerStyle={styles.grid}
columnWrapperStyle={styles.gridRow}
renderItem={({ item }) => <FileEditItem fileId={item} />}
renderItem={({ item }) => (
<FileEditItem
fileId={item}
selected={hasSelection ? selectedIds.has(item) : true}
onPress={() => toggleSelection(item)}
/>
)}
/>
<View style={styles.tagSection}>
@@ -243,6 +287,11 @@ const styles = StyleSheet.create({
borderBottomWidth: 1,
borderBottomColor: '#e0e0e0',
},
headerRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
title: {
fontSize: 20,
fontWeight: '700',
@@ -252,6 +301,17 @@ const styles = StyleSheet.create({
fontSize: 14,
color: '#666',
},
selectAllBtn: {
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 16,
backgroundColor: '#E3F2FD',
},
selectAllText: {
fontSize: 13,
fontWeight: '600',
color: '#1976D2',
},
grid: {
padding: 16,
},
@@ -270,6 +330,24 @@ const styles = StyleSheet.create({
justifyContent: 'center',
alignItems: 'center',
},
selectedOverlay: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
justifyContent: 'flex-start',
alignItems: 'flex-end',
padding: 4,
},
checkCircle: {
width: 24,
height: 24,
borderRadius: 12,
backgroundColor: '#1976D2',
justifyContent: 'center',
alignItems: 'center',
},
thumb: {
width: '100%',
height: '100%',
+141
View File
@@ -0,0 +1,141 @@
import React, { useState } from 'react';
import {
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
Alert,
KeyboardAvoidingView,
Platform,
ActivityIndicator,
} from 'react-native';
import { useAuth } from '../contexts/AuthContext';
export function LoginScreen({ navigation }: any) {
const { login } = useAuth();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
async function handleLogin() {
if (!username.trim() || !password) {
Alert.alert('Erreur', 'Veuillez remplir tous les champs');
return;
}
setLoading(true);
try {
await login(username.trim(), password);
} catch (error: any) {
Alert.alert('Erreur', error.message || 'Connexion échouée');
} finally {
setLoading(false);
}
}
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<View style={styles.inner}>
<Text style={styles.title}>Dot.</Text>
<Text style={styles.subtitle}>Connectez-vous à votre compte</Text>
<TextInput
style={styles.input}
placeholder="Nom d'utilisateur"
value={username}
onChangeText={setUsername}
autoCapitalize="none"
autoCorrect={false}
editable={!loading}
/>
<TextInput
style={styles.input}
placeholder="Mot de passe"
value={password}
onChangeText={setPassword}
secureTextEntry
editable={!loading}
/>
<TouchableOpacity
style={[styles.button, loading && styles.buttonDisabled]}
onPress={handleLogin}
disabled={loading}
>
{loading ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.buttonText}>Se connecter</Text>
)}
</TouchableOpacity>
<TouchableOpacity
style={styles.linkButton}
onPress={() => navigation.navigate('Register')}
disabled={loading}
>
<Text style={styles.linkText}>Pas de compte ? S'inscrire</Text>
</TouchableOpacity>
</View>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
},
inner: {
flex: 1,
justifyContent: 'center',
paddingHorizontal: 32,
},
title: {
fontSize: 32,
fontWeight: 'bold',
textAlign: 'center',
marginBottom: 8,
},
subtitle: {
fontSize: 16,
color: '#666',
textAlign: 'center',
marginBottom: 32,
},
input: {
borderWidth: 1,
borderColor: '#ddd',
borderRadius: 8,
padding: 16,
fontSize: 16,
marginBottom: 16,
},
button: {
backgroundColor: '#000',
borderRadius: 8,
padding: 16,
alignItems: 'center',
marginBottom: 16,
},
buttonDisabled: {
opacity: 0.6,
},
buttonText: {
color: '#fff',
fontSize: 16,
fontWeight: '600',
},
linkButton: {
alignItems: 'center',
},
linkText: {
color: '#000',
fontSize: 14,
},
});
+161
View File
@@ -0,0 +1,161 @@
import React, { useState } from 'react';
import {
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
Alert,
KeyboardAvoidingView,
Platform,
ActivityIndicator,
} from 'react-native';
import { useAuth } from '../contexts/AuthContext';
export function RegisterScreen({ navigation }: any) {
const { register } = useAuth();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [loading, setLoading] = useState(false);
async function handleRegister() {
if (!username.trim() || !password || !confirmPassword) {
Alert.alert('Erreur', 'Veuillez remplir tous les champs');
return;
}
if (password !== confirmPassword) {
Alert.alert('Erreur', 'Les mots de passe ne correspondent pas');
return;
}
if (password.length < 8) {
Alert.alert('Erreur', 'Le mot de passe doit contenir au moins 8 caractères');
return;
}
setLoading(true);
try {
await register(username.trim(), password);
} catch (error: any) {
Alert.alert('Erreur', error.message || "Inscription échouée");
} finally {
setLoading(false);
}
}
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<View style={styles.inner}>
<Text style={styles.title}>Dot.</Text>
<Text style={styles.subtitle}>Créez votre compte</Text>
<TextInput
style={styles.input}
placeholder="Nom d'utilisateur"
value={username}
onChangeText={setUsername}
autoCapitalize="none"
autoCorrect={false}
editable={!loading}
/>
<TextInput
style={styles.input}
placeholder="Mot de passe"
value={password}
onChangeText={setPassword}
secureTextEntry
editable={!loading}
/>
<TextInput
style={styles.input}
placeholder="Confirmer le mot de passe"
value={confirmPassword}
onChangeText={setConfirmPassword}
secureTextEntry
editable={!loading}
/>
<TouchableOpacity
style={[styles.button, loading && styles.buttonDisabled]}
onPress={handleRegister}
disabled={loading}
>
{loading ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.buttonText}>S'inscrire</Text>
)}
</TouchableOpacity>
<TouchableOpacity
style={styles.linkButton}
onPress={() => navigation.navigate('Login')}
disabled={loading}
>
<Text style={styles.linkText}>Déjà un compte ? Se connecter</Text>
</TouchableOpacity>
</View>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
},
inner: {
flex: 1,
justifyContent: 'center',
paddingHorizontal: 32,
},
title: {
fontSize: 32,
fontWeight: 'bold',
textAlign: 'center',
marginBottom: 8,
},
subtitle: {
fontSize: 16,
color: '#666',
textAlign: 'center',
marginBottom: 32,
},
input: {
borderWidth: 1,
borderColor: '#ddd',
borderRadius: 8,
padding: 16,
fontSize: 16,
marginBottom: 16,
},
button: {
backgroundColor: '#000',
borderRadius: 8,
padding: 16,
alignItems: 'center',
marginBottom: 16,
},
buttonDisabled: {
opacity: 0.6,
},
buttonText: {
color: '#fff',
fontSize: 16,
fontWeight: '600',
},
linkButton: {
alignItems: 'center',
},
linkText: {
color: '#000',
fontSize: 14,
},
});
+4
View File
@@ -9,4 +9,8 @@ export const ENDPOINTS = {
FOLDERS: '/files/folders',
OCR_JOBS: '/ocr/jobs',
HEALTH: '/health',
AUTH_LOGIN: '/auth/login',
AUTH_REGISTER: '/auth/register',
AUTH_REFRESH: '/auth/refresh',
AUTH_LOGOUT: '/auth/logout',
} as const;
+90
View File
@@ -0,0 +1,90 @@
import React, { createContext, useContext, useEffect, useState } from 'react';
import { apiClient } from '../api/client';
import { tokenStorage } from '../api/secureStorage';
import { ENDPOINTS } from '../constants/api';
import { AuthResponse, User } from '../types';
interface AuthContextType {
user: User | null;
isLoading: boolean;
login: (username: string, password: string) => Promise<void>;
register: (username: string, password: string) => Promise<void>;
logout: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
loadUser();
}, []);
async function loadUser() {
try {
const stored = await tokenStorage.getUser();
if (stored) {
const userData = JSON.parse(stored) as User;
setUser(userData);
}
} catch {
await tokenStorage.deleteUser();
} finally {
setIsLoading(false);
}
}
async function login(username: string, password: string) {
const response = await apiClient.post<AuthResponse>(ENDPOINTS.AUTH_LOGIN, {
username,
password,
});
apiClient.setAccessToken(response.access_token);
await tokenStorage.setRefreshToken(response.refresh_token);
await tokenStorage.setUser(JSON.stringify(response.user));
setUser(response.user);
}
async function register(username: string, password: string) {
const response = await apiClient.post<AuthResponse>(ENDPOINTS.AUTH_REGISTER, {
username,
password,
});
apiClient.setAccessToken(response.access_token);
await tokenStorage.setRefreshToken(response.refresh_token);
await tokenStorage.setUser(JSON.stringify(response.user));
setUser(response.user);
}
async function logout() {
try {
const refreshToken = await tokenStorage.getRefreshToken();
if (refreshToken) {
await apiClient.post(ENDPOINTS.AUTH_LOGOUT, { refresh_token: refreshToken });
}
} catch {}
apiClient.setAccessToken(null);
await tokenStorage.deleteRefreshToken();
await tokenStorage.deleteUser();
setUser(null);
}
return (
<AuthContext.Provider value={{ user, isLoading, login, register, logout }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}
+10
View File
@@ -17,6 +17,7 @@
"expo-file-system": "~57.0.0",
"expo-image-picker": "~57.0.2",
"expo-print": "~57.0.0",
"expo-secure-store": "~57.0.0",
"expo-status-bar": "~57.0.0",
"react": "19.2.3",
"react-native": "0.86.0",
@@ -2924,6 +2925,15 @@
"react-native": "*"
}
},
"node_modules/expo-secure-store": {
"version": "57.0.0",
"resolved": "https://registry.npmjs.org/expo-secure-store/-/expo-secure-store-57.0.0.tgz",
"integrity": "sha512-vkP16rhW7b4bljW5BC4kKXBpNxQ0O1E9SpI5NIfh2biZnszLTpI/gUF4oBsvOY2nvkh7oXS2ERuUoA8cuS8FWQ==",
"license": "MIT",
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-server": {
"version": "57.0.0",
"resolved": "https://registry.npmjs.org/expo-server/-/expo-server-57.0.0.tgz",
+1
View File
@@ -12,6 +12,7 @@
"expo-file-system": "~57.0.0",
"expo-image-picker": "~57.0.2",
"expo-print": "~57.0.0",
"expo-secure-store": "~57.0.0",
"expo-status-bar": "~57.0.0",
"react": "19.2.3",
"react-native": "0.86.0",
+21
View File
@@ -83,3 +83,24 @@ export type Batch = {
photos: CapturedPhoto[];
tags: string[];
};
export interface User {
id: string;
username: string;
}
export interface AuthTokens {
access_token: string;
refresh_token: string;
}
export interface AuthResponse {
user: User;
access_token: string;
refresh_token: string;
}
export interface RefreshResponse {
access_token: string;
refresh_token: string;
}