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:
+36
-1
@@ -5,7 +5,9 @@ import type {
|
|||||||
FileDto,
|
FileDto,
|
||||||
FolderDto,
|
FolderDto,
|
||||||
ListFilesParams,
|
ListFilesParams,
|
||||||
|
LoginResponse,
|
||||||
OcrJob,
|
OcrJob,
|
||||||
|
ResolvedUser,
|
||||||
ResourcePermission,
|
ResourcePermission,
|
||||||
SyncOperation,
|
SyncOperation,
|
||||||
SyncResult,
|
SyncResult,
|
||||||
@@ -25,6 +27,14 @@ export function hasAuthToken(): boolean {
|
|||||||
return authToken !== null;
|
return authToken !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Notification de 401 reçus sur un endpoint protégé (hors login lui-même) —
|
||||||
|
// AuthContext s'en sert pour purger la session (token révoqué/expiré).
|
||||||
|
let onUnauthorized: (() => void) | null = null;
|
||||||
|
|
||||||
|
export function setUnauthorizedHandler(handler: (() => void) | null): void {
|
||||||
|
onUnauthorized = handler;
|
||||||
|
}
|
||||||
|
|
||||||
type QueryParams = Record<string, string | number | boolean | undefined | null>;
|
type QueryParams = Record<string, string | number | boolean | undefined | null>;
|
||||||
|
|
||||||
function toQuery(params?: QueryParams): string {
|
function toQuery(params?: QueryParams): string {
|
||||||
@@ -60,7 +70,8 @@ export class ApiError extends Error {
|
|||||||
async function request<T>(
|
async function request<T>(
|
||||||
path: string,
|
path: string,
|
||||||
init: RequestInit = {},
|
init: RequestInit = {},
|
||||||
timeoutMs: number = DEFAULT_TIMEOUT_MS
|
timeoutMs: number = DEFAULT_TIMEOUT_MS,
|
||||||
|
opts: { skipUnauthorizedHandling?: boolean } = {},
|
||||||
): Promise<ApiData<T>> {
|
): Promise<ApiData<T>> {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
@@ -76,6 +87,10 @@ async function request<T>(
|
|||||||
throw new ApiError('NETWORK_ERROR', 'Serveur injoignable');
|
throw new ApiError('NETWORK_ERROR', 'Serveur injoignable');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (response.status === 401 && !opts.skipUnauthorizedHandling) {
|
||||||
|
onUnauthorized?.();
|
||||||
|
}
|
||||||
|
|
||||||
const body = (await response.json().catch(() => null)) as
|
const body = (await response.json().catch(() => null)) as
|
||||||
| ApiData<T>
|
| ApiData<T>
|
||||||
| ApiErrorBody
|
| ApiErrorBody
|
||||||
@@ -136,6 +151,26 @@ export const api = {
|
|||||||
body: JSON.stringify({ deviceId }),
|
body: JSON.stringify({ deviceId }),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// Seule porte d'émission de token (V1). Le 401 = mauvaises identifiants
|
||||||
|
// (normal sur l'écran de login) : on ne déclenche pas la purge de session.
|
||||||
|
login: (username: string, password: string, deviceId: string) =>
|
||||||
|
request<LoginResponse>('/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username, password, device_id: deviceId }),
|
||||||
|
}, DEFAULT_TIMEOUT_MS, { skipUnauthorizedHandling: true }),
|
||||||
|
|
||||||
|
changePassword: (currentPassword: string, newPassword: string) =>
|
||||||
|
request<{ id: string }>('/users/me/password', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }),
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Résolution exacte d'un destinataire par username — jamais de listing.
|
||||||
|
resolveUser: (username: string) =>
|
||||||
|
request<ResolvedUser>(`/users/resolve${toQuery({ username })}`),
|
||||||
|
|
||||||
listFiles: (params?: ListFilesParams) =>
|
listFiles: (params?: ListFilesParams) =>
|
||||||
request<FileDto[]>(`/files${toQuery(params)}`),
|
request<FileDto[]>(`/files${toQuery(params)}`),
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,29 @@ export type OcrJob = {
|
|||||||
|
|
||||||
export type DeviceRegistration = {
|
export type DeviceRegistration = {
|
||||||
deviceId: string;
|
deviceId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type User = {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
is_admin: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LoginRequest = {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
device_id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LoginResponse = {
|
||||||
token: string;
|
token: string;
|
||||||
|
expires_at: number;
|
||||||
|
user: User;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ResolvedUser = {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ListParams = {
|
export type ListParams = {
|
||||||
|
|||||||
+2
-1
@@ -26,7 +26,8 @@
|
|||||||
"plugins": [
|
"plugins": [
|
||||||
"expo-router",
|
"expo-router",
|
||||||
"expo-sqlite",
|
"expo-sqlite",
|
||||||
"expo-localization"
|
"expo-localization",
|
||||||
|
"expo-secure-store"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-4
@@ -1,7 +1,8 @@
|
|||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { Stack } from 'expo-router';
|
import { Redirect, Stack } from 'expo-router';
|
||||||
import { AuthProvider } from '../context/AuthContext';
|
import { ActivityIndicator, View } from 'react-native';
|
||||||
import '../i18n';
|
import { AuthProvider, useAuth } from '../context/AuthContext';
|
||||||
|
import i18n from '../i18n';
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
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() {
|
export default function RootLayout() {
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<Stack />
|
<AuthGate />
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,20 +1,33 @@
|
|||||||
import { createContext, useContext, useEffect, useMemo, useState } from 'react';
|
import { createContext, useContext, useEffect, useMemo, useState } from 'react';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { useSyncDevice } from '../features/syncDevice';
|
import { useSyncDevice } from '../features/syncDevice';
|
||||||
import { api, setAuthToken } from '../api/client';
|
import { api, setAuthToken, setUnauthorizedHandler } from '../api/client';
|
||||||
import {
|
import {
|
||||||
getDeviceAuthToken,
|
clearActiveUserId,
|
||||||
getDeviceUserId,
|
getDeviceUserId,
|
||||||
saveDeviceAuthToken,
|
setActiveUserId,
|
||||||
} from '../services/localStorage';
|
} from '../services/localStorage';
|
||||||
import type { AuthContextValue, User } from './AuthContext.types';
|
import {
|
||||||
|
clearStoredSession,
|
||||||
|
getStoredAccount,
|
||||||
|
getStoredToken,
|
||||||
|
setStoredSession,
|
||||||
|
} from '../services/secureStore';
|
||||||
|
import type { AuthContextValue, AuthStatus, User } from './AuthContext.types';
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
|
||||||
const [user, setUser] = useState<User | null>(null);
|
const [user, setUser] = useState<User | null>(null);
|
||||||
const [deviceUserId, setDeviceUserId] = useState<string | null>(null);
|
const [deviceUserId, setDeviceUserId] = useState<string | null>(null);
|
||||||
|
const [status, setStatus] = useState<AuthStatus>('loading');
|
||||||
|
|
||||||
|
const signOut = async () => {
|
||||||
|
await Promise.allSettled([clearStoredSession(), clearActiveUserId()]);
|
||||||
|
setAuthToken(null);
|
||||||
|
setUser(null);
|
||||||
|
setStatus('signedOut');
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
@@ -24,16 +37,26 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
if (active) setDeviceUserId(id);
|
if (active) setDeviceUserId(id);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let token = await getDeviceAuthToken();
|
// Enregistrement du device (idempotent côté serveur) — obligatoire
|
||||||
if ( !token ) {
|
// avant le login (INVALID_DEVICE_ID sinon). Aucun token émis ici.
|
||||||
const { data } = await api.registerDevice(id);
|
await api.registerDevice(id);
|
||||||
token = data.token;
|
|
||||||
await saveDeviceAuthToken(token);
|
|
||||||
}
|
|
||||||
setAuthToken(token);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('device registration failed (offline?)', error);
|
console.warn('device registration failed (offline?)', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [token, storedUser] = await Promise.all([getStoredToken(), getStoredAccount()]);
|
||||||
|
if (active && token && storedUser) {
|
||||||
|
setAuthToken(token);
|
||||||
|
setActiveUserId(storedUser.id);
|
||||||
|
setUser(storedUser);
|
||||||
|
setStatus('signedIn');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('could not restore session', error);
|
||||||
|
}
|
||||||
|
if (active) setStatus('signedOut');
|
||||||
};
|
};
|
||||||
|
|
||||||
bootstrap();
|
bootstrap();
|
||||||
@@ -44,7 +67,30 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const value = useMemo(() => ({ user, deviceUserId }), [user, deviceUserId]);
|
// 401 sur un endpoint protégé (token expiré/révoqué, compte supprimé) →
|
||||||
|
// purge de la session. Le 401 du login est exonéré côté client (option
|
||||||
|
// skipUnauthorizedHandling dans api/client.ts).
|
||||||
|
useEffect(() => {
|
||||||
|
setUnauthorizedHandler(() => {
|
||||||
|
void signOut();
|
||||||
|
});
|
||||||
|
return () => setUnauthorizedHandler(null);
|
||||||
|
}, [signOut]);
|
||||||
|
|
||||||
|
const signIn = async (username: string, password: string) => {
|
||||||
|
const id = deviceUserId ?? (await getDeviceUserId());
|
||||||
|
const result = await api.login(username, password, id);
|
||||||
|
await setStoredSession(result.data.token, result.data.user);
|
||||||
|
await setActiveUserId(result.data.user.id);
|
||||||
|
setAuthToken(result.data.token);
|
||||||
|
setUser(result.data.user);
|
||||||
|
setStatus('signedIn');
|
||||||
|
};
|
||||||
|
|
||||||
|
const value = useMemo(
|
||||||
|
() => ({ user, deviceUserId, status, signIn, signOut }),
|
||||||
|
[user, deviceUserId, status, signIn, signOut],
|
||||||
|
);
|
||||||
|
|
||||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
export type User = {};
|
import type { User } from '../api/types';
|
||||||
|
|
||||||
|
export type { User };
|
||||||
|
|
||||||
|
export type AuthStatus = 'loading' | 'signedOut' | 'signedIn';
|
||||||
|
|
||||||
export type AuthContextValue = {
|
export type AuthContextValue = {
|
||||||
user: User | null;
|
user: User | null;
|
||||||
deviceUserId: string | null;
|
deviceUserId: string | null;
|
||||||
|
status: AuthStatus;
|
||||||
|
signIn: (username: string, password: string) => Promise<void>;
|
||||||
|
signOut: () => Promise<void>;
|
||||||
};
|
};
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { api, hasAuthToken } from '../api/client';
|
import { api, hasAuthToken } from '../api/client';
|
||||||
import type { SyncOperation } from '../api/types';
|
import type { SyncOperation } from '../api/types';
|
||||||
import {
|
import {
|
||||||
|
getActiveUserId,
|
||||||
listQueuedOperations,
|
listQueuedOperations,
|
||||||
markPendingOperation,
|
markPendingOperation,
|
||||||
scheduleRetries,
|
scheduleRetries,
|
||||||
@@ -69,22 +70,25 @@ export async function pushPendingOps(): Promise<PushResult> {
|
|||||||
|
|
||||||
// ---- permissions snapshot --------------------------------------------------
|
// ---- permissions snapshot --------------------------------------------------
|
||||||
|
|
||||||
// Delta monotone en mémoire (réinitialisé au démarrage de l'app) : la valeur
|
// Delta monotone PAR COMPTE (clé = active_user_id, NULL hors compte) : la
|
||||||
// max de cached_at du dernier snapshot sert de borne `after` pour le prochain.
|
// valeur max de cached_at du dernier snapshot sert de borne `after` pour le
|
||||||
let lastPermissionCachedAt: number | null = null;
|
// prochain appel. Le snapshot n'est pas rejoué quand on change de compte.
|
||||||
|
const lastPermissionCachedAtByUser = new Map<string | null, number>();
|
||||||
|
|
||||||
export async function refreshPermissions(): Promise<number> {
|
export async function refreshPermissions(): Promise<number> {
|
||||||
if (!hasAuthToken()) return 0;
|
if (!hasAuthToken()) return 0;
|
||||||
const perms = await api.getSyncPermissions(lastPermissionCachedAt ?? undefined);
|
const activeUserId = await getActiveUserId();
|
||||||
|
const after = lastPermissionCachedAtByUser.get(activeUserId) ?? undefined;
|
||||||
|
const perms = await api.getSyncPermissions(after);
|
||||||
if (perms.data.length === 0) return 0;
|
if (perms.data.length === 0) return 0;
|
||||||
for (const p of perms.data) {
|
for (const p of perms.data) {
|
||||||
await saveResourcePermission(p);
|
await saveResourcePermission(p);
|
||||||
}
|
}
|
||||||
lastPermissionCachedAt = Math.max(...perms.data.map((p) => p.cachedAt));
|
lastPermissionCachedAtByUser.set(activeUserId, Math.max(...perms.data.map((p) => p.cachedAt)));
|
||||||
return perms.data.length;
|
return perms.data.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Visible uniquement pour les tests unitaires (reset de l'état en mémoire).
|
// Visible uniquement pour les tests unitaires (reset de l'état en mémoire).
|
||||||
export function resetPermissionCachedAtForTests(): void {
|
export function resetPermissionCachedAtForTests(): void {
|
||||||
lastPermissionCachedAt = null;
|
lastPermissionCachedAtByUser.clear();
|
||||||
}
|
}
|
||||||
+13
-1
@@ -8,5 +8,17 @@
|
|||||||
"empty_folder": "Empty folder — next syncDevice will refresh it.",
|
"empty_folder": "Empty folder — next syncDevice will refresh it.",
|
||||||
"bytes": "B",
|
"bytes": "B",
|
||||||
"kilobytes": "KB",
|
"kilobytes": "KB",
|
||||||
"megabytes": "MB"
|
"megabytes": "MB",
|
||||||
|
"login_title": "Sign in",
|
||||||
|
"login_subtitle": "Sign in to your account",
|
||||||
|
"login_username": "Username",
|
||||||
|
"login_password": "Password",
|
||||||
|
"login_submit": "Sign in",
|
||||||
|
"login_submitting": "Signing in…",
|
||||||
|
"login_error_required": "Username and password are required",
|
||||||
|
"login_error_generic": "Could not sign in right now",
|
||||||
|
"login_switch_title": "Account already signed in",
|
||||||
|
"login_switch_message": "This sign-in will replace the active account on this device. Continue?",
|
||||||
|
"login_switch_confirm": "Replace",
|
||||||
|
"login_switch_cancel": "Cancel"
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-1
@@ -8,5 +8,17 @@
|
|||||||
"empty_folder": "Dossier vide — le prochain syncDevice l'actualisera.",
|
"empty_folder": "Dossier vide — le prochain syncDevice l'actualisera.",
|
||||||
"bytes": "o",
|
"bytes": "o",
|
||||||
"kilobytes": "Ko",
|
"kilobytes": "Ko",
|
||||||
"megabytes": "Mo"
|
"megabytes": "Mo",
|
||||||
|
"login_title": "Connexion",
|
||||||
|
"login_subtitle": "Connecte-toi à ton compte",
|
||||||
|
"login_username": "Nom d'utilisateur",
|
||||||
|
"login_password": "Mot de passe",
|
||||||
|
"login_submit": "Se connecter",
|
||||||
|
"login_submitting": "Connexion…",
|
||||||
|
"login_error_required": "Nom d'utilisateur et mot de passe requis",
|
||||||
|
"login_error_generic": "Impossible de se connecter pour le moment",
|
||||||
|
"login_switch_title": "Compte déjà connecté",
|
||||||
|
"login_switch_message": "Cette connexion remplacera le compte actif sur cet appareil. Continuer ?",
|
||||||
|
"login_switch_confirm": "Remplacer",
|
||||||
|
"login_switch_cancel": "Annuler"
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+10
@@ -16,6 +16,7 @@
|
|||||||
"expo-linking": "~57.0.9",
|
"expo-linking": "~57.0.9",
|
||||||
"expo-localization": "~57.0.1",
|
"expo-localization": "~57.0.1",
|
||||||
"expo-router": "~57.0.20",
|
"expo-router": "~57.0.20",
|
||||||
|
"expo-secure-store": "~57.0.3",
|
||||||
"expo-sqlite": "~57.0.2",
|
"expo-sqlite": "~57.0.2",
|
||||||
"expo-status-bar": "~57.0.1",
|
"expo-status-bar": "~57.0.1",
|
||||||
"i18n-js": "^4.5.3",
|
"i18n-js": "^4.5.3",
|
||||||
@@ -4330,6 +4331,15 @@
|
|||||||
"integrity": "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==",
|
"integrity": "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/expo-secure-store": {
|
||||||
|
"version": "57.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/expo-secure-store/-/expo-secure-store-57.0.3.tgz",
|
||||||
|
"integrity": "sha512-w7XkSQeUiYXPoKXo1jSrQqql7pyCSyIzOp2k0apsZZBE+RkoLTQIMjcbqc181y6KgupfNnYxkaKe+4MEg94+SA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"expo": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/expo-server": {
|
"node_modules/expo-server": {
|
||||||
"version": "57.0.3",
|
"version": "57.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/expo-server/-/expo-server-57.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/expo-server/-/expo-server-57.0.3.tgz",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"expo-linking": "~57.0.9",
|
"expo-linking": "~57.0.9",
|
||||||
"expo-localization": "~57.0.1",
|
"expo-localization": "~57.0.1",
|
||||||
"expo-router": "~57.0.20",
|
"expo-router": "~57.0.20",
|
||||||
|
"expo-secure-store": "~57.0.3",
|
||||||
"expo-sqlite": "~57.0.2",
|
"expo-sqlite": "~57.0.2",
|
||||||
"expo-status-bar": "~57.0.1",
|
"expo-status-bar": "~57.0.1",
|
||||||
"i18n-js": "^4.5.3",
|
"i18n-js": "^4.5.3",
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export {
|
|||||||
DATABASE_VERSION,
|
DATABASE_VERSION,
|
||||||
DEVICE_USER_ID_KEY,
|
DEVICE_USER_ID_KEY,
|
||||||
AUTH_TOKEN_KEY,
|
AUTH_TOKEN_KEY,
|
||||||
|
ACTIVE_USER_ID_KEY,
|
||||||
PREFERENCES_KEY,
|
PREFERENCES_KEY,
|
||||||
PERMISSION_TTL_MS,
|
PERMISSION_TTL_MS,
|
||||||
} from './schema';
|
} from './schema';
|
||||||
@@ -218,6 +218,47 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_files_uri ON files(uri) WHERE uri IS NOT N
|
|||||||
await db.execAsync('PRAGMA foreign_keys = ON;');
|
await db.execAsync('PRAGMA foreign_keys = ON;');
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// User-first : pending_operations et resource_permissions deviennent
|
||||||
|
// scopés par compte (`user_id`), NULL = entrées legacy/device-local. Le
|
||||||
|
// UNIQUE de resource_permissions passe à (user_id, resource_id,
|
||||||
|
// resource_type) pour qu'un même fichier partagé à deux comptes ne
|
||||||
|
// collisionne pas côté cache. SQLite ne pouvant pas altérer un UNIQUE, la
|
||||||
|
// table est reconstruite (le schéma v5 se base sur v3/v4 — l'indice
|
||||||
|
// UNIQUE (resource_id, resource_type) a été posé en v3).
|
||||||
|
version: 5,
|
||||||
|
up: async (db) => {
|
||||||
|
await db.execAsync('PRAGMA foreign_keys = OFF;');
|
||||||
|
await db.withExclusiveTransactionAsync(async (txn) => {
|
||||||
|
await txn.execAsync(`
|
||||||
|
CREATE TABLE resource_permissions_new (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
user_id TEXT,
|
||||||
|
resource_id TEXT NOT NULL,
|
||||||
|
resource_type TEXT NOT NULL CHECK (resource_type IN ('folder', 'file')),
|
||||||
|
effective_access TEXT NOT NULL CHECK (effective_access IN ('owner', 'editor', 'commenter', 'viewer')),
|
||||||
|
inherit INTEGER NOT NULL DEFAULT 1,
|
||||||
|
owner_id TEXT,
|
||||||
|
shared_by_id TEXT,
|
||||||
|
expires_at INTEGER,
|
||||||
|
cached_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
UNIQUE (user_id, resource_id, resource_type)
|
||||||
|
);
|
||||||
|
INSERT INTO resource_permissions_new (id, user_id, resource_id, resource_type, effective_access, inherit, owner_id, shared_by_id, expires_at, cached_at, updated_at)
|
||||||
|
SELECT id, NULL, resource_id, resource_type, effective_access, inherit, owner_id, shared_by_id, expires_at, cached_at, updated_at FROM resource_permissions;
|
||||||
|
DROP TABLE resource_permissions;
|
||||||
|
ALTER TABLE resource_permissions_new RENAME TO resource_permissions;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_permissions_resource ON resource_permissions(resource_id, resource_type);
|
||||||
|
`);
|
||||||
|
await txn.execAsync(`
|
||||||
|
ALTER TABLE pending_operations ADD COLUMN user_id TEXT;
|
||||||
|
`);
|
||||||
|
await txn.execAsync(`PRAGMA user_version = 5;`);
|
||||||
|
});
|
||||||
|
await db.execAsync('PRAGMA foreign_keys = ON;');
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
async function readUserVersion(db: MigrationDb): Promise<number> {
|
async function readUserVersion(db: MigrationDb): Promise<number> {
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ export {
|
|||||||
getDeviceUserId,
|
getDeviceUserId,
|
||||||
getDeviceAuthToken,
|
getDeviceAuthToken,
|
||||||
saveDeviceAuthToken,
|
saveDeviceAuthToken,
|
||||||
|
getActiveUserId,
|
||||||
|
setActiveUserId,
|
||||||
|
clearActiveUserId,
|
||||||
} from './preferences';
|
} from './preferences';
|
||||||
export {
|
export {
|
||||||
getResourcePermission,
|
getResourcePermission,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { getSession } from '../session';
|
import { getSession } from '../session';
|
||||||
|
import { getActiveUserId } from './preferences';
|
||||||
import type {
|
import type {
|
||||||
NewPendingOperation,
|
NewPendingOperation,
|
||||||
PendingOperation,
|
PendingOperation,
|
||||||
@@ -19,10 +20,11 @@ export async function enqueuePendingOperation(
|
|||||||
operation: NewPendingOperation,
|
operation: NewPendingOperation,
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
const db = await getSession();
|
const db = await getSession();
|
||||||
|
const activeUserId = await getActiveUserId();
|
||||||
const row = await db.getFirstAsync<{ id: number }>(
|
const row = await db.getFirstAsync<{ id: number }>(
|
||||||
`INSERT INTO pending_operations
|
`INSERT INTO pending_operations
|
||||||
(resource_id, resource_type, ref_type, ref_id, operation, payload, status, attempts, created_at)
|
(resource_id, resource_type, ref_type, ref_id, operation, payload, status, attempts, created_at, user_id)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, 'pending', 0, ?)
|
VALUES (?, ?, ?, ?, ?, ?, 'pending', 0, ?, ?)
|
||||||
RETURNING id`,
|
RETURNING id`,
|
||||||
operation.resourceId ?? null,
|
operation.resourceId ?? null,
|
||||||
operation.resourceType ?? null,
|
operation.resourceType ?? null,
|
||||||
@@ -31,6 +33,7 @@ export async function enqueuePendingOperation(
|
|||||||
operation.operation,
|
operation.operation,
|
||||||
JSON.stringify(operation.payload ?? {}),
|
JSON.stringify(operation.payload ?? {}),
|
||||||
Date.now(),
|
Date.now(),
|
||||||
|
activeUserId,
|
||||||
);
|
);
|
||||||
return row!.id;
|
return row!.id;
|
||||||
}
|
}
|
||||||
@@ -42,12 +45,16 @@ export async function getPendingOperations(
|
|||||||
const rows = status
|
const rows = status
|
||||||
? await db.getAllAsync<PendingOperationRow>(
|
? await db.getAllAsync<PendingOperationRow>(
|
||||||
`SELECT ${PENDING_OPERATION_COLUMNS} FROM pending_operations
|
`SELECT ${PENDING_OPERATION_COLUMNS} FROM pending_operations
|
||||||
WHERE status = ? ORDER BY created_at ASC, id ASC`,
|
WHERE status = ? AND (user_id = ? OR user_id IS NULL)
|
||||||
|
ORDER BY created_at ASC, id ASC`,
|
||||||
status,
|
status,
|
||||||
|
await getActiveUserId(),
|
||||||
)
|
)
|
||||||
: await db.getAllAsync<PendingOperationRow>(
|
: await db.getAllAsync<PendingOperationRow>(
|
||||||
`SELECT ${PENDING_OPERATION_COLUMNS} FROM pending_operations
|
`SELECT ${PENDING_OPERATION_COLUMNS} FROM pending_operations
|
||||||
|
WHERE user_id = ? OR user_id IS NULL
|
||||||
ORDER BY created_at ASC, id ASC`,
|
ORDER BY created_at ASC, id ASC`,
|
||||||
|
await getActiveUserId(),
|
||||||
);
|
);
|
||||||
return rows.map(toPendingOperation);
|
return rows.map(toPendingOperation);
|
||||||
}
|
}
|
||||||
@@ -57,9 +64,11 @@ export async function getNextQueuedOperation(): Promise<PendingOperation | null>
|
|||||||
const row = await db.getFirstAsync<PendingOperationRow>(
|
const row = await db.getFirstAsync<PendingOperationRow>(
|
||||||
`SELECT ${PENDING_OPERATION_COLUMNS} FROM pending_operations
|
`SELECT ${PENDING_OPERATION_COLUMNS} FROM pending_operations
|
||||||
WHERE status = 'pending'
|
WHERE status = 'pending'
|
||||||
|
AND (user_id = ? OR user_id IS NULL)
|
||||||
AND (next_retry_at IS NULL OR next_retry_at <= ?)
|
AND (next_retry_at IS NULL OR next_retry_at <= ?)
|
||||||
ORDER BY created_at ASC, id ASC
|
ORDER BY created_at ASC, id ASC
|
||||||
LIMIT 1`,
|
LIMIT 1`,
|
||||||
|
await getActiveUserId(),
|
||||||
Date.now(),
|
Date.now(),
|
||||||
);
|
);
|
||||||
return row ? toPendingOperation(row) : null;
|
return row ? toPendingOperation(row) : null;
|
||||||
@@ -72,9 +81,11 @@ export async function listQueuedOperations(limit: number): Promise<PendingOperat
|
|||||||
const rows = await db.getAllAsync<PendingOperationRow>(
|
const rows = await db.getAllAsync<PendingOperationRow>(
|
||||||
`SELECT ${PENDING_OPERATION_COLUMNS} FROM pending_operations
|
`SELECT ${PENDING_OPERATION_COLUMNS} FROM pending_operations
|
||||||
WHERE status = 'pending'
|
WHERE status = 'pending'
|
||||||
|
AND (user_id = ? OR user_id IS NULL)
|
||||||
AND (next_retry_at IS NULL OR next_retry_at <= ?)
|
AND (next_retry_at IS NULL OR next_retry_at <= ?)
|
||||||
ORDER BY created_at ASC, id ASC
|
ORDER BY created_at ASC, id ASC
|
||||||
LIMIT ?`,
|
LIMIT ?`,
|
||||||
|
await getActiveUserId(),
|
||||||
Date.now(),
|
Date.now(),
|
||||||
limit,
|
limit,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { DbSession } from '../session';
|
import type { DbSession } from '../session';
|
||||||
import { getSession } from '../session';
|
import { getSession } from '../session';
|
||||||
import { PERMISSION_TTL_MS } from '../schema';
|
import { PERMISSION_TTL_MS } from '../schema';
|
||||||
import { getDeviceUserId } from './preferences';
|
import { getActiveUserId, getDeviceUserId } from './preferences';
|
||||||
import type {
|
import type {
|
||||||
AccessLevel,
|
AccessLevel,
|
||||||
NewResourcePermission,
|
NewResourcePermission,
|
||||||
@@ -44,9 +44,11 @@ export async function getResourcePermission(
|
|||||||
): Promise<ResourcePermission | null> {
|
): Promise<ResourcePermission | null> {
|
||||||
const db = await getSession();
|
const db = await getSession();
|
||||||
const row = await db.getFirstAsync<ResourcePermissionRow>(
|
const row = await db.getFirstAsync<ResourcePermissionRow>(
|
||||||
`SELECT * FROM resource_permissions WHERE resource_id = ? AND resource_type = ?`,
|
`SELECT * FROM resource_permissions
|
||||||
|
WHERE resource_id = ? AND resource_type = ? AND (user_id = ? OR user_id IS NULL)`,
|
||||||
resourceId,
|
resourceId,
|
||||||
resourceType,
|
resourceType,
|
||||||
|
await getActiveUserId(),
|
||||||
);
|
);
|
||||||
return row ? toResourcePermission(row) : null;
|
return row ? toResourcePermission(row) : null;
|
||||||
}
|
}
|
||||||
@@ -58,9 +60,9 @@ export async function saveResourcePermission(
|
|||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
await db.runAsync(
|
await db.runAsync(
|
||||||
`INSERT INTO resource_permissions
|
`INSERT INTO resource_permissions
|
||||||
(resource_id, resource_type, effective_access, inherit, owner_id, shared_by_id, expires_at, cached_at, updated_at)
|
(user_id, resource_id, resource_type, effective_access, inherit, owner_id, shared_by_id, expires_at, cached_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(resource_id, resource_type) DO UPDATE SET
|
ON CONFLICT(user_id, resource_id, resource_type) DO UPDATE SET
|
||||||
effective_access = excluded.effective_access,
|
effective_access = excluded.effective_access,
|
||||||
inherit = excluded.inherit,
|
inherit = excluded.inherit,
|
||||||
owner_id = excluded.owner_id,
|
owner_id = excluded.owner_id,
|
||||||
@@ -68,6 +70,7 @@ export async function saveResourcePermission(
|
|||||||
expires_at = excluded.expires_at,
|
expires_at = excluded.expires_at,
|
||||||
cached_at = excluded.cached_at,
|
cached_at = excluded.cached_at,
|
||||||
updated_at = excluded.updated_at`,
|
updated_at = excluded.updated_at`,
|
||||||
|
await getActiveUserId(),
|
||||||
permission.resource_id,
|
permission.resource_id,
|
||||||
permission.resourceType,
|
permission.resourceType,
|
||||||
permission.effectiveAccess,
|
permission.effectiveAccess,
|
||||||
@@ -140,6 +143,9 @@ export async function canAccess(
|
|||||||
required: AccessLevel,
|
required: AccessLevel,
|
||||||
): Promise<AccessCheck> {
|
): Promise<AccessCheck> {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
// Le device est le propriétaire local de ses propres ressources (device_user_id
|
||||||
|
// seedé en owner_id, cf. v4). Owner = device-local : le check reste volontairement
|
||||||
|
// indépendant du compte connecté (fallback hors-cloud, décision offline).
|
||||||
const deviceUserId = await getDeviceUserId();
|
const deviceUserId = await getDeviceUserId();
|
||||||
|
|
||||||
const exactCache = await getResourcePermission(resourceId, resourceType);
|
const exactCache = await getResourcePermission(resourceId, resourceType);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { getSession } from '../session';
|
import { getSession } from '../session';
|
||||||
import { AUTH_TOKEN_KEY, DEVICE_USER_ID_KEY, PREFERENCES_KEY } from '../schema';
|
import { ACTIVE_USER_ID_KEY, AUTH_TOKEN_KEY, DEVICE_USER_ID_KEY, PREFERENCES_KEY } from '../schema';
|
||||||
import type { UserPreferences } from '../types';
|
import type { UserPreferences } from '../types';
|
||||||
|
|
||||||
const DEFAULT_PREFERENCES: UserPreferences = {
|
const DEFAULT_PREFERENCES: UserPreferences = {
|
||||||
@@ -58,6 +58,36 @@ export async function saveDeviceAuthToken(token: string): Promise<void> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Miroir non-sensible du compte connecté (le token vit en SecureStore). NULL
|
||||||
|
// = aucun compte actif (mode device-local legacy / tests).
|
||||||
|
export async function getActiveUserId(): Promise<string | null> {
|
||||||
|
const db = await getSession();
|
||||||
|
const row = await db.getFirstAsync<{ value: string }>(
|
||||||
|
'SELECT "value" FROM user_preferences WHERE "key" = ?',
|
||||||
|
ACTIVE_USER_ID_KEY,
|
||||||
|
);
|
||||||
|
return row?.value || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setActiveUserId(userId: string): Promise<void> {
|
||||||
|
const db = await getSession();
|
||||||
|
await db.runAsync(
|
||||||
|
`INSERT INTO user_preferences ("key", "value", updated_at) VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT("key") DO UPDATE SET "value" = excluded."value", updated_at = excluded.updated_at`,
|
||||||
|
ACTIVE_USER_ID_KEY,
|
||||||
|
userId,
|
||||||
|
Date.now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearActiveUserId(): Promise<void> {
|
||||||
|
const db = await getSession();
|
||||||
|
await db.runAsync(
|
||||||
|
'DELETE FROM user_preferences WHERE "key" = ?',
|
||||||
|
ACTIVE_USER_ID_KEY,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function getUserPreferences(): Promise<UserPreferences> {
|
export async function getUserPreferences(): Promise<UserPreferences> {
|
||||||
const db = await getSession();
|
const db = await getSession();
|
||||||
const row = await db.getFirstAsync<{ value: string }>(
|
const row = await db.getFirstAsync<{ value: string }>(
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export const DATABASE_NAME = 'dot.db';
|
export const DATABASE_NAME = 'dot.db';
|
||||||
|
|
||||||
export const DATABASE_VERSION = 4;
|
export const DATABASE_VERSION = 5;
|
||||||
|
|
||||||
export const PREFERENCES_KEY = 'user_preferences';
|
export const PREFERENCES_KEY = 'user_preferences';
|
||||||
|
|
||||||
@@ -8,6 +8,12 @@ export const DEVICE_USER_ID_KEY = 'device_user_id';
|
|||||||
|
|
||||||
export const AUTH_TOKEN_KEY = 'auth_token';
|
export const AUTH_TOKEN_KEY = 'auth_token';
|
||||||
|
|
||||||
|
// Miroir non-sensible du compte connecté (voir services/secureStore.ts) : le
|
||||||
|
// token lui-même reste en SecureStore ; seul l'id du compte actif est répété en
|
||||||
|
// SQLite pour permettre aux repositories de scoper leurs lectures/écritures
|
||||||
|
// sans avoir à importer expo-secure-store (tests Node inclus).
|
||||||
|
export const ACTIVE_USER_ID_KEY = 'active_user_id';
|
||||||
|
|
||||||
export const FOLDER_COLUMNS = [
|
export const FOLDER_COLUMNS = [
|
||||||
'resource_id',
|
'resource_id',
|
||||||
'uri',
|
'uri',
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ export {
|
|||||||
getDeviceUserId,
|
getDeviceUserId,
|
||||||
getDeviceAuthToken,
|
getDeviceAuthToken,
|
||||||
saveDeviceAuthToken,
|
saveDeviceAuthToken,
|
||||||
|
getActiveUserId,
|
||||||
|
setActiveUserId,
|
||||||
|
clearActiveUserId,
|
||||||
getFolders,
|
getFolders,
|
||||||
getFolderFolders,
|
getFolderFolders,
|
||||||
getFolder,
|
getFolder,
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import * as SecureStore from 'expo-secure-store';
|
||||||
|
import type { User } from '../api/types';
|
||||||
|
|
||||||
|
// Clés SecureStore (iOS keychain / Android Keystore). Le token et le profil du
|
||||||
|
// compte connecté ne sont JAMAIS persistés en SQLite : un miroir non-sensible
|
||||||
|
// (active_user_id) est répété en base pour le scoping des repositories.
|
||||||
|
const TOKEN_KEY = 'vaultdrop.auth_token';
|
||||||
|
const ACCOUNT_KEY = 'vaultdrop.account';
|
||||||
|
|
||||||
|
export async function getStoredToken(): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
return await SecureStore.getItemAsync(TOKEN_KEY);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getStoredAccount(): Promise<User | null> {
|
||||||
|
try {
|
||||||
|
const raw = await SecureStore.getItemAsync(ACCOUNT_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
const parsed = JSON.parse(raw) as User;
|
||||||
|
if (typeof parsed?.id !== 'string' || typeof parsed?.username !== 'string') return null;
|
||||||
|
return parsed;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setStoredSession(token: string, user: User): Promise<void> {
|
||||||
|
await SecureStore.setItemAsync(TOKEN_KEY, token);
|
||||||
|
await SecureStore.setItemAsync(ACCOUNT_KEY, JSON.stringify(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearStoredSession(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await SecureStore.deleteItemAsync(TOKEN_KEY);
|
||||||
|
await SecureStore.deleteItemAsync(ACCOUNT_KEY);
|
||||||
|
} catch {
|
||||||
|
// clés absentes → rien à supprimer
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { test, beforeEach, afterEach } from 'node:test';
|
import { test, beforeEach, afterEach } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { api, setAuthToken, ApiError } from '../api/client';
|
import { api, setAuthToken, setUnauthorizedHandler, ApiError } from '../api/client';
|
||||||
import type { ApiData } from '../api/types';
|
import type { ApiData } from '../api/types';
|
||||||
|
|
||||||
function jsonResponse(status: number, body: unknown, headers?: HeadersInit): Response {
|
function jsonResponse(status: number, body: unknown, headers?: HeadersInit): Response {
|
||||||
@@ -33,10 +33,12 @@ function pathOf(url: string): string {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
calls = [];
|
calls = [];
|
||||||
setAuthToken(null);
|
setAuthToken(null);
|
||||||
|
setUnauthorizedHandler(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
globalThis.fetch = originalFetch;
|
globalThis.fetch = originalFetch;
|
||||||
|
setUnauthorizedHandler(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('nominal : GET /health', async () => {
|
test('nominal : GET /health', async () => {
|
||||||
@@ -47,13 +49,74 @@ test('nominal : GET /health', async () => {
|
|||||||
assert.deepEqual(res.data, { status: 'healthy' });
|
assert.deepEqual(res.data, { status: 'healthy' });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('nominal : POST /devices (register) JSON', async () => {
|
test('nominal : POST /devices (register) — plus aucun token, device-only', async () => {
|
||||||
stubFetch(() => ok({ deviceId: 'aaaa', token: 'v4.local.x' }));
|
stubFetch(() => ok({ deviceId: 'aaaa' }));
|
||||||
const res = await api.registerDevice('aaaa');
|
const res = await api.registerDevice('aaaa');
|
||||||
assert.equal(pathOf(calls[0].url), '/api/v1/devices');
|
assert.equal(pathOf(calls[0].url), '/api/v1/devices');
|
||||||
assert.equal(calls[0].init.method, 'POST');
|
assert.equal(calls[0].init.method, 'POST');
|
||||||
assert.deepEqual(JSON.parse(String(calls[0].init.body)), { deviceId: 'aaaa' });
|
assert.deepEqual(JSON.parse(String(calls[0].init.body)), { deviceId: 'aaaa' });
|
||||||
assert.equal(res.data.token, 'v4.local.x');
|
assert.deepEqual(res.data, { deviceId: 'aaaa' });
|
||||||
|
assert.ok(!('token' in res.data), 'POST /devices ne doit plus émettre de token');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nominal : POST /auth/login (seule porte de token)', async () => {
|
||||||
|
stubFetch(() =>
|
||||||
|
ok({
|
||||||
|
token: 'v4.local.xyz',
|
||||||
|
expires_at: 1700000000000,
|
||||||
|
user: { id: 'u-1', username: 'alice', is_admin: false },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const res = await api.login('alice', 'secret-pass', 'dev-1');
|
||||||
|
assert.equal(pathOf(calls[0].url), '/api/v1/auth/login');
|
||||||
|
assert.equal(calls[0].init.method, 'POST');
|
||||||
|
assert.deepEqual(JSON.parse(String(calls[0].init.body)), {
|
||||||
|
username: 'alice',
|
||||||
|
password: 'secret-pass',
|
||||||
|
device_id: 'dev-1',
|
||||||
|
});
|
||||||
|
assert.equal(res.data.user.id, 'u-1');
|
||||||
|
assert.equal(res.data.user.is_admin, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('auth : 401 de login ne déclenche PAS le handler de purge de session', async () => {
|
||||||
|
let purged = 0;
|
||||||
|
setUnauthorizedHandler(() => {
|
||||||
|
purged++;
|
||||||
|
});
|
||||||
|
stubFetch(() => jsonResponse(401, { error: { code: 'UNAUTHORIZED', message: 'invalid credentials' } }));
|
||||||
|
await assert.rejects(() => api.login('bob', 'wrong', 'dev-1'), (err: unknown) => (err as ApiError).code === 'UNAUTHORIZED');
|
||||||
|
assert.equal(purged, 0, 'mauvais identifiants ≠ session à purger');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('auth : 401 d’un endpoint protégé déclenche le handler de purge', async () => {
|
||||||
|
let purged = 0;
|
||||||
|
setUnauthorizedHandler(() => {
|
||||||
|
purged++;
|
||||||
|
});
|
||||||
|
setAuthToken('tok-expired');
|
||||||
|
stubFetch(() => jsonResponse(401, { error: { code: 'UNAUTHORIZED', message: 'token invalid' } }));
|
||||||
|
await assert.rejects(() => api.listFiles(), (err: unknown) => (err as ApiError).code === 'UNAUTHORIZED');
|
||||||
|
assert.equal(purged, 1, 'token révoqué → purge de session');
|
||||||
|
setUnauthorizedHandler(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nominal : PATCH /users/me/password', async () => {
|
||||||
|
stubFetch(() => ok({ id: 'u-1' }));
|
||||||
|
await api.changePassword('old-pass', 'new-pass-8chars');
|
||||||
|
assert.equal(pathOf(calls[0].url), '/api/v1/users/me/password');
|
||||||
|
assert.equal(calls[0].init.method, 'PATCH');
|
||||||
|
assert.deepEqual(JSON.parse(String(calls[0].init.body)), {
|
||||||
|
current_password: 'old-pass',
|
||||||
|
new_password: 'new-pass-8chars',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nominal : GET /users/resolve?username= (résolution exacte)', async () => {
|
||||||
|
stubFetch(() => ok({ id: 'u-9', username: 'bob' }));
|
||||||
|
const res = await api.resolveUser('BOB');
|
||||||
|
assert.equal(pathOf(calls[0].url), '/api/v1/users/resolve?username=BOB');
|
||||||
|
assert.equal(res.data.username, 'bob');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('nominal : GET /files — query filtrée (undefined/null ignorés)', async () => {
|
test('nominal : GET /files — query filtrée (undefined/null ignorés)', async () => {
|
||||||
@@ -184,7 +247,7 @@ test('auth : token posé → Authorization: Bearer', async () => {
|
|||||||
|
|
||||||
test('auth : fusion avec Content-Type existant (POST /devices + token)', async () => {
|
test('auth : fusion avec Content-Type existant (POST /devices + token)', async () => {
|
||||||
setAuthToken('tok-123');
|
setAuthToken('tok-123');
|
||||||
stubFetch(() => ok({ deviceId: 'a', token: 't' }));
|
stubFetch(() => ok({ deviceId: 'a' }));
|
||||||
await api.registerDevice('a');
|
await api.registerDevice('a');
|
||||||
const headers = new Headers(calls[0].init.headers);
|
const headers = new Headers(calls[0].init.headers);
|
||||||
assert.equal(headers.get('Authorization'), 'Bearer tok-123');
|
assert.equal(headers.get('Authorization'), 'Bearer tok-123');
|
||||||
|
|||||||
@@ -13,10 +13,17 @@ import { MIGRATIONS, type MigrationDb } from '../services/db/migrations';
|
|||||||
import type { DbSession } from '../services/db/session';
|
import type { DbSession } from '../services/db/session';
|
||||||
import { __setDbForTests } from '../services/db/session';
|
import { __setDbForTests } from '../services/db/session';
|
||||||
import { api, setAuthToken } from '../api/client';
|
import { api, setAuthToken } from '../api/client';
|
||||||
import { enqueuePendingOperation, getPendingOperations } from '../services/db';
|
import {
|
||||||
|
clearActiveUserId,
|
||||||
|
enqueuePendingOperation,
|
||||||
|
getPendingOperations,
|
||||||
|
setActiveUserId,
|
||||||
|
} from '../services/db';
|
||||||
import { pushPendingOps, refreshPermissions, resetPermissionCachedAtForTests } from '../features/syncOutbox';
|
import { pushPendingOps, refreshPermissions, resetPermissionCachedAtForTests } from '../features/syncOutbox';
|
||||||
|
|
||||||
const BASE_URL = process.env.EXPO_PUBLIC_API_BASE_URL ?? 'http://localhost:8080/api/v1';
|
const BASE_URL = process.env.EXPO_PUBLIC_API_BASE_URL ?? 'http://localhost:8080/api/v1';
|
||||||
|
const ADMIN_USERNAME = process.env.E2E_ADMIN_USERNAME ?? 'admin';
|
||||||
|
const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD ?? 'admin';
|
||||||
|
|
||||||
type Harness = MigrationDb & DbSession;
|
type Harness = MigrationDb & DbSession;
|
||||||
|
|
||||||
@@ -75,9 +82,10 @@ beforeEach(async () => {
|
|||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
__setDbForTests(null);
|
__setDbForTests(null);
|
||||||
setAuthToken(null);
|
setAuthToken(null);
|
||||||
|
clearActiveUserId();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('bout en bout : register → push outbox → snapshot → relecture serveur', async (t) => {
|
test('bout en bout : register + login admin → push outbox → snapshot → relecture serveur', async (t) => {
|
||||||
try {
|
try {
|
||||||
await api.health();
|
await api.health();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -88,11 +96,15 @@ test('bout en bout : register → push outbox → snapshot → relecture serveur
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enregistrement du device (idempotent) puis login = SEULE porte de token.
|
||||||
deviceId = newDeviceId();
|
deviceId = newDeviceId();
|
||||||
const reg = await api.registerDevice(deviceId);
|
await api.registerDevice(deviceId);
|
||||||
setAuthToken(reg.data.token);
|
const login = await api.login(ADMIN_USERNAME, ADMIN_PASSWORD, deviceId);
|
||||||
|
setAuthToken(login.data.token);
|
||||||
|
await setActiveUserId(login.data.user.id);
|
||||||
|
assert.equal(login.data.user.is_admin, true, 'l’admin E2E doit exister (ADMIN_*)');
|
||||||
|
|
||||||
// 1. Enqueue un CREATE_RESOURCE puis push
|
// 1. Enqueue un CREATE_RESOURCE puis push (scopé au compte connecté)
|
||||||
const name = `e2e-${deviceId.slice(0, 8)}`;
|
const name = `e2e-${deviceId.slice(0, 8)}`;
|
||||||
const resourceId = newDeviceId();
|
const resourceId = newDeviceId();
|
||||||
const opId = await enqueuePendingOperation({
|
const opId = await enqueuePendingOperation({
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ async function seedLegacyTree(h: Harness): Promise<void> {
|
|||||||
VALUES ('${SONG_URI}', 'song.mp3', '${MUSIC_URI}', 'mp3', 30, 'audio/mpeg', 0, 3000, 'cloud', 3000);`);
|
VALUES ('${SONG_URI}', 'song.mp3', '${MUSIC_URI}', 'mp3', 30, 'audio/mpeg', 0, 3000, 'cloud', 3000);`);
|
||||||
}
|
}
|
||||||
|
|
||||||
test('fresh migrate v0 → v4 creates full schema and seeds device id', async () => {
|
test('fresh migrate v0 → v5 creates full schema and seeds device id', async () => {
|
||||||
const h = createHarness();
|
const h = createHarness();
|
||||||
await migrateDatabase(h);
|
await migrateDatabase(h);
|
||||||
|
|
||||||
@@ -108,6 +108,11 @@ test('fresh migrate v0 → v4 creates full schema and seeds device id', async ()
|
|||||||
assert.ok(names.length > 0, `table ${table} must exist`);
|
assert.ok(names.length > 0, `table ${table} must exist`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const permCols = await columnNames(h, 'resource_permissions');
|
||||||
|
assert.ok(permCols.includes('user_id'), 'resource_permissions must have user_id (v5)');
|
||||||
|
const opCols = await columnNames(h, 'pending_operations');
|
||||||
|
assert.ok(opCols.includes('user_id'), 'pending_operations must have user_id (v5)');
|
||||||
|
|
||||||
const device = await h.getFirstAsync<{ value: string }>(
|
const device = await h.getFirstAsync<{ value: string }>(
|
||||||
'SELECT "value" FROM user_preferences WHERE "key" = ?',
|
'SELECT "value" FROM user_preferences WHERE "key" = ?',
|
||||||
DEVICE_USER_ID_KEY,
|
DEVICE_USER_ID_KEY,
|
||||||
@@ -117,6 +122,35 @@ test('fresh migrate v0 → v4 creates full schema and seeds device id', async ()
|
|||||||
assert.deepEqual(await foreignKeyViolations(h), [], 'no orphaned FKs after fresh migrate');
|
assert.deepEqual(await foreignKeyViolations(h), [], 'no orphaned FKs after fresh migrate');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('v5 : UNIQUE de resource_permissions scopé par (user_id, resource_id, resource_type)', async () => {
|
||||||
|
const h = createHarness();
|
||||||
|
await migrateDatabase(h);
|
||||||
|
|
||||||
|
const row = await h.getFirstAsync<{ sql: string }>(
|
||||||
|
`SELECT sql FROM sqlite_master WHERE type = 'table' AND tbl_name = 'resource_permissions'`,
|
||||||
|
);
|
||||||
|
assert.ok(row, 'table sql present');
|
||||||
|
assert.match(row.sql, /UNIQUE\s*\(user_id,\s*resource_id,\s*resource_type\)/);
|
||||||
|
|
||||||
|
// Deux comptes peuvent partager la même ressource sans collision.
|
||||||
|
await h.runAsync(
|
||||||
|
`INSERT INTO resource_permissions (user_id, resource_id, resource_type, effective_access, inherit, owner_id, cached_at, updated_at)
|
||||||
|
VALUES ('u1', 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 'folder', 'owner', 1, NULL, 1, 1)`,
|
||||||
|
);
|
||||||
|
await h.runAsync(
|
||||||
|
`INSERT INTO resource_permissions (user_id, resource_id, resource_type, effective_access, inherit, owner_id, cached_at, updated_at)
|
||||||
|
VALUES ('u2', 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 'folder', 'viewer', 0, NULL, 1, 1)`,
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
() =>
|
||||||
|
h.runAsync(
|
||||||
|
`INSERT INTO resource_permissions (user_id, resource_id, resource_type, effective_access, inherit, owner_id, cached_at, updated_at)
|
||||||
|
VALUES ('u1', 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 'folder', 'editor', 1, NULL, 1, 1)`,
|
||||||
|
),
|
||||||
|
/UNIQUE constraint failed/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('user_version is transactional (rollback restores previous version)', async () => {
|
test('user_version is transactional (rollback restores previous version)', async () => {
|
||||||
const h = createHarness();
|
const h = createHarness();
|
||||||
await migrateDatabase(h);
|
await migrateDatabase(h);
|
||||||
|
|||||||
@@ -7,11 +7,13 @@ import { __setDbForTests } from '../services/db/session';
|
|||||||
import { api, setAuthToken } from '../api/client';
|
import { api, setAuthToken } from '../api/client';
|
||||||
import {
|
import {
|
||||||
enqueuePendingOperation,
|
enqueuePendingOperation,
|
||||||
|
getActiveUserId,
|
||||||
getPendingOperations,
|
getPendingOperations,
|
||||||
getResourcePermission,
|
getResourcePermission,
|
||||||
listQueuedOperations,
|
listQueuedOperations,
|
||||||
markPendingOperation,
|
markPendingOperation,
|
||||||
scheduleRetries,
|
scheduleRetries,
|
||||||
|
setActiveUserId,
|
||||||
MAX_PENDING_ATTEMPTS,
|
MAX_PENDING_ATTEMPTS,
|
||||||
} from '../services/db';
|
} from '../services/db';
|
||||||
import {
|
import {
|
||||||
@@ -331,4 +333,70 @@ test('refreshPermissions garde la référence dans le temps pour after', async (
|
|||||||
await refreshPermissions();
|
await refreshPermissions();
|
||||||
assert.equal((await getResourcePermission('d'.repeat(32), 'folder'))?.effectiveAccess, 'viewer');
|
assert.equal((await getResourcePermission('d'.repeat(32), 'folder'))?.effectiveAccess, 'viewer');
|
||||||
assert.equal(pathOf(calls[1].url), '/api/v1/sync/permissions?after=42');
|
assert.equal(pathOf(calls[1].url), '/api/v1/sync/permissions?after=42');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('outbox scopée par compte : seul le compte actif voit/pousse ses ops', async () => {
|
||||||
|
await setActiveUserId('u1');
|
||||||
|
const idA = await enqueue({ operation: 'create_resource', payload: { name: 'A' } });
|
||||||
|
await setActiveUserId('u2');
|
||||||
|
const idB = await enqueue({ operation: 'create_resource', payload: { name: 'B' } });
|
||||||
|
|
||||||
|
assert.equal(await getActiveUserId(), 'u2');
|
||||||
|
const visibleAsU2 = (await getPendingOperations()).map((op) => op.id);
|
||||||
|
assert.deepEqual(visibleAsU2, [idB], 'u2 ne voit que ses opérations');
|
||||||
|
|
||||||
|
await setActiveUserId('u1');
|
||||||
|
assert.deepEqual(
|
||||||
|
(await getPendingOperations()).map((op) => op.id),
|
||||||
|
[idA],
|
||||||
|
'u1 ne voit que ses opérations',
|
||||||
|
);
|
||||||
|
|
||||||
|
stubFetch(() => ok({ applied: 1, failed: null }));
|
||||||
|
await setActiveUserId('u2');
|
||||||
|
const result = await pushPendingOps();
|
||||||
|
assert.deepEqual(result, { pushed: 1, retried: 0 });
|
||||||
|
const body = JSON.parse(String(calls[0].init.body));
|
||||||
|
assert.equal(body.operations.length, 1, 'u2 ne pousse pas les opérations de u1');
|
||||||
|
assert.equal(body.operations[0].operation_id, idB);
|
||||||
|
|
||||||
|
await setActiveUserId('u1');
|
||||||
|
const leftover = await getPendingOperations();
|
||||||
|
assert.deepEqual(leftover.map((op) => op.id), [idA], 'l’op u1 reste intacte et non-poussée');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('permissions scopées par compte : snapshot et miroir par compte', async () => {
|
||||||
|
const permA = {
|
||||||
|
resource_id: 'a'.repeat(32),
|
||||||
|
resourceType: 'folder',
|
||||||
|
effectiveAccess: 'owner',
|
||||||
|
inherit: true,
|
||||||
|
ownerId: 'u1',
|
||||||
|
sharedById: null,
|
||||||
|
expiresAt: null,
|
||||||
|
cachedAt: 1000,
|
||||||
|
updatedAt: 1000,
|
||||||
|
};
|
||||||
|
|
||||||
|
await setActiveUserId('u1');
|
||||||
|
stubFetch((url) => (new URL(url).searchParams.get('after') === null ? ok([permA]) : ok([])));
|
||||||
|
await refreshPermissions();
|
||||||
|
assert.equal(
|
||||||
|
(await getResourcePermission('a'.repeat(32), 'folder'))?.effectiveAccess,
|
||||||
|
'owner',
|
||||||
|
'u1 voit la permission de son snapshot',
|
||||||
|
);
|
||||||
|
|
||||||
|
// u2 n'a pas encore de snapshot : pas d'`after` (repart du début) et la
|
||||||
|
// permission de u1 n'est pas visible tant que son propre snapshot n'est pas
|
||||||
|
// arrivé.
|
||||||
|
await setActiveUserId('u2');
|
||||||
|
assert.equal(await getResourcePermission('a'.repeat(32), 'folder'), null);
|
||||||
|
await refreshPermissions();
|
||||||
|
assert.equal(pathOf(calls[1].url), '/api/v1/sync/permissions', 'u2 repart sans after');
|
||||||
|
assert.equal(
|
||||||
|
(await getResourcePermission('a'.repeat(32), 'folder'))?.effectiveAccess,
|
||||||
|
'owner',
|
||||||
|
'u2 voit sa propre copie après son snapshot',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
Reference in New Issue
Block a user