feat(mobile): identité user-first — login/password, SecureStore, scoping par compte

- api: DeviceRegistration device-only, login/changePassword/resolveUser, interception 401 (purge) exonérée sur le login ; type User aligné sur le fil (is_admin)
- SecureStore (expo-secure-store v57) : token + profil, jamais en SQLite ; miroir active_user_id
- AuthContext : statut loading/signedOut/signedIn, bootstrap register→restore, signIn/signOut, garde-fou switch de compte
- app/login.tsx + gate de routes dans _layout (Redirect signedOut) ; i18n fr/en
- DB v5 : user_id sur pending_operations + resource_permissions (UNIQUE par user), repos scopés (user_id IS ? OR IS NULL)
- syncOutbox : delta permissions par compte (Map), outbox poussée du compte actif uniquement
- tests: register/login/401/resolve/changePassword, scoping outbox+permissions, migrations v5 ; e2e live revert register→login admin
This commit is contained in:
m
2026-09-10 21:34:34 +02:00
parent c2c7dddba6
commit 0536fca6c3
25 changed files with 663 additions and 49 deletions
+27 -4
View File
@@ -1,7 +1,8 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Stack } from 'expo-router';
import { AuthProvider } from '../context/AuthContext';
import '../i18n';
import { Redirect, Stack } from 'expo-router';
import { ActivityIndicator, View } from 'react-native';
import { AuthProvider, useAuth } from '../context/AuthContext';
import i18n from '../i18n';
const queryClient = new QueryClient({
defaultOptions: {
@@ -12,11 +13,33 @@ const queryClient = new QueryClient({
},
});
function AuthGate() {
const { status } = useAuth();
if (status === 'loading') {
return (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
<ActivityIndicator size="large" />
</View>
);
}
return (
<>
<Stack>
<Stack.Screen name="login" options={{ title: i18n.t('login_title') }} />
</Stack>
{/* Toute route est protégée tant qu'aucun compte n'est connecté. */}
{status === 'signedOut' ? <Redirect href="/login" /> : null}
</>
);
}
export default function RootLayout() {
return (
<QueryClientProvider client={queryClient}>
<AuthProvider>
<Stack />
<AuthGate />
</AuthProvider>
</QueryClientProvider>
);
+121
View File
@@ -0,0 +1,121 @@
import { useRouter } from 'expo-router';
import { useState } from 'react';
import { Alert, Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
import { useAuth } from '../context/AuthContext';
import { ApiError } from '../api/client';
import i18n from '../i18n';
export default function Login() {
const router = useRouter();
const { signIn, user } = useAuth();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async () => {
if (!username.trim() || !password) {
setError(i18n.t('login_error_required'));
return;
}
setSubmitting(true);
setError(null);
// Changer de compte en étant connecté remplace la session locale (garde-fou).
if (user) {
const confirmed = await new Promise<boolean>((resolve) => {
Alert.alert(i18n.t('login_switch_title'), i18n.t('login_switch_message'), [
{ text: i18n.t('login_switch_cancel'), style: 'cancel', onPress: () => resolve(false) },
{ text: i18n.t('login_switch_confirm'), style: 'destructive', onPress: () => resolve(true) },
]);
});
if (!confirmed) {
setSubmitting(false);
return;
}
}
try {
await signIn(username.trim(), password);
router.replace('/');
} catch (err) {
setError(err instanceof ApiError ? err.message : i18n.t('login_error_generic'));
} finally {
setSubmitting(false);
}
};
return (
<View style={styles.container}>
<Text style={styles.title}>{i18n.t('login_subtitle')}</Text>
<TextInput
style={styles.input}
placeholder={i18n.t('login_username')}
autoCapitalize="none"
autoCorrect={false}
value={username}
onChangeText={setUsername}
/>
<TextInput
style={styles.input}
placeholder={i18n.t('login_password')}
secureTextEntry
value={password}
onChangeText={setPassword}
/>
{error ? <Text style={styles.error}>{error}</Text> : null}
<Pressable
style={[styles.button, submitting && styles.buttonDisabled]}
onPress={handleSubmit}
disabled={submitting}
>
<Text style={styles.buttonText}>
{submitting ? i18n.t('login_submitting') : i18n.t('login_submit')}
</Text>
</Pressable>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
padding: 24,
justifyContent: 'center',
gap: 12,
},
title: {
fontSize: 18,
fontWeight: '600',
marginBottom: 8,
textAlign: 'center',
},
input: {
borderWidth: StyleSheet.hairlineWidth,
borderColor: '#ccc',
borderRadius: 8,
paddingHorizontal: 14,
paddingVertical: 12,
fontSize: 15,
},
error: {
color: '#c5221f',
fontSize: 14,
},
button: {
backgroundColor: '#1a73e8',
paddingVertical: 12,
borderRadius: 8,
alignItems: 'center',
marginTop: 4,
},
buttonDisabled: {
opacity: 0.6,
},
buttonText: {
color: '#fff',
fontSize: 16,
fontWeight: '600',
},
});