From 0536fca6c35ceb4a24ef07b05f8a86d6e4829827 Mon Sep 17 00:00:00 2001 From: m Date: Thu, 10 Sep 2026 21:34:34 +0200 Subject: [PATCH] =?UTF-8?q?feat(mobile):=20identit=C3=A9=20user-first=20?= =?UTF-8?q?=E2=80=94=20login/password,=20SecureStore,=20scoping=20par=20co?= =?UTF-8?q?mpte?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- mobile/api/client.ts | 37 +++++- mobile/api/types.ts | 22 ++++ mobile/app.json | 3 +- mobile/app/_layout.tsx | 31 ++++- mobile/app/login.tsx | 121 ++++++++++++++++++ mobile/context/AuthContext.tsx | 72 +++++++++-- mobile/context/AuthContext.types.ts | 9 +- mobile/features/syncOutbox.ts | 16 ++- mobile/i18n/en.json | 14 +- mobile/i18n/fr.json | 14 +- mobile/package-lock.json | 10 ++ mobile/package.json | 1 + mobile/services/db/index.ts | 1 + mobile/services/db/migrations.ts | 41 ++++++ mobile/services/db/repositories/index.ts | 3 + mobile/services/db/repositories/pendingOps.ts | 17 ++- .../services/db/repositories/permissions.ts | 16 ++- .../services/db/repositories/preferences.ts | 32 ++++- mobile/services/db/schema.ts | 8 +- mobile/services/localStorage.ts | 3 + mobile/services/secureStore.ts | 42 ++++++ mobile/tests/apiClient.test.ts | 73 ++++++++++- mobile/tests/e2e.live.test.ts | 22 +++- mobile/tests/migrations.test.ts | 36 +++++- mobile/tests/syncOutbox.test.ts | 68 ++++++++++ 25 files changed, 663 insertions(+), 49 deletions(-) create mode 100644 mobile/app/login.tsx create mode 100644 mobile/services/secureStore.ts diff --git a/mobile/api/client.ts b/mobile/api/client.ts index d761aaa..60c13c6 100644 --- a/mobile/api/client.ts +++ b/mobile/api/client.ts @@ -5,7 +5,9 @@ import type { FileDto, FolderDto, ListFilesParams, + LoginResponse, OcrJob, + ResolvedUser, ResourcePermission, SyncOperation, SyncResult, @@ -25,6 +27,14 @@ export function hasAuthToken(): boolean { 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; function toQuery(params?: QueryParams): string { @@ -60,7 +70,8 @@ export class ApiError extends Error { async function request( path: string, init: RequestInit = {}, - timeoutMs: number = DEFAULT_TIMEOUT_MS + timeoutMs: number = DEFAULT_TIMEOUT_MS, + opts: { skipUnauthorizedHandling?: boolean } = {}, ): Promise> { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); @@ -76,6 +87,10 @@ async function request( throw new ApiError('NETWORK_ERROR', 'Serveur injoignable'); } + if (response.status === 401 && !opts.skipUnauthorizedHandling) { + onUnauthorized?.(); + } + const body = (await response.json().catch(() => null)) as | ApiData | ApiErrorBody @@ -136,6 +151,26 @@ export const api = { 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('/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(`/users/resolve${toQuery({ username })}`), + listFiles: (params?: ListFilesParams) => request(`/files${toQuery(params)}`), diff --git a/mobile/api/types.ts b/mobile/api/types.ts index 1578edd..e2060a6 100644 --- a/mobile/api/types.ts +++ b/mobile/api/types.ts @@ -44,7 +44,29 @@ export type OcrJob = { export type DeviceRegistration = { 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; + expires_at: number; + user: User; +}; + +export type ResolvedUser = { + id: string; + username: string; }; export type ListParams = { diff --git a/mobile/app.json b/mobile/app.json index a209442..1d93fae 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -26,7 +26,8 @@ "plugins": [ "expo-router", "expo-sqlite", - "expo-localization" + "expo-localization", + "expo-secure-store" ] } } diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx index 1054d8b..a28d392 100644 --- a/mobile/app/_layout.tsx +++ b/mobile/app/_layout.tsx @@ -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 ( + + + + ); + } + + return ( + <> + + + + {/* Toute route est protégée tant qu'aucun compte n'est connecté. */} + {status === 'signedOut' ? : null} + + ); +} + export default function RootLayout() { return ( - + ); diff --git a/mobile/app/login.tsx b/mobile/app/login.tsx new file mode 100644 index 0000000..679e99f --- /dev/null +++ b/mobile/app/login.tsx @@ -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(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((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 ( + + {i18n.t('login_subtitle')} + + + {error ? {error} : null} + + + {submitting ? i18n.t('login_submitting') : i18n.t('login_submit')} + + + + ); +} + +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', + }, +}); \ No newline at end of file diff --git a/mobile/context/AuthContext.tsx b/mobile/context/AuthContext.tsx index cf55ff5..ea209d7 100644 --- a/mobile/context/AuthContext.tsx +++ b/mobile/context/AuthContext.tsx @@ -1,20 +1,33 @@ import { createContext, useContext, useEffect, useMemo, useState } from 'react'; import type { ReactNode } from 'react'; import { useSyncDevice } from '../features/syncDevice'; -import { api, setAuthToken } from '../api/client'; +import { api, setAuthToken, setUnauthorizedHandler } from '../api/client'; import { - getDeviceAuthToken, + clearActiveUserId, getDeviceUserId, - saveDeviceAuthToken, + setActiveUserId, } 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(null); export function AuthProvider({ children }: { children: ReactNode }) { - const [user, setUser] = useState(null); const [deviceUserId, setDeviceUserId] = useState(null); + const [status, setStatus] = useState('loading'); + + const signOut = async () => { + await Promise.allSettled([clearStoredSession(), clearActiveUserId()]); + setAuthToken(null); + setUser(null); + setStatus('signedOut'); + }; useEffect(() => { let active = true; @@ -24,16 +37,26 @@ export function AuthProvider({ children }: { children: ReactNode }) { if (active) setDeviceUserId(id); try { - let token = await getDeviceAuthToken(); - if ( !token ) { - const { data } = await api.registerDevice(id); - token = data.token; - await saveDeviceAuthToken(token); - } - setAuthToken(token); + // Enregistrement du device (idempotent côté serveur) — obligatoire + // avant le login (INVALID_DEVICE_ID sinon). Aucun token émis ici. + await api.registerDevice(id); } catch (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(); @@ -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 {children}; } diff --git a/mobile/context/AuthContext.types.ts b/mobile/context/AuthContext.types.ts index ea71423..cf2b2f1 100644 --- a/mobile/context/AuthContext.types.ts +++ b/mobile/context/AuthContext.types.ts @@ -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 = { user: User | null; deviceUserId: string | null; + status: AuthStatus; + signIn: (username: string, password: string) => Promise; + signOut: () => Promise; }; \ No newline at end of file diff --git a/mobile/features/syncOutbox.ts b/mobile/features/syncOutbox.ts index a044631..2bf190a 100644 --- a/mobile/features/syncOutbox.ts +++ b/mobile/features/syncOutbox.ts @@ -1,6 +1,7 @@ import { api, hasAuthToken } from '../api/client'; import type { SyncOperation } from '../api/types'; import { + getActiveUserId, listQueuedOperations, markPendingOperation, scheduleRetries, @@ -69,22 +70,25 @@ export async function pushPendingOps(): Promise { // ---- permissions snapshot -------------------------------------------------- -// Delta monotone en mémoire (réinitialisé au démarrage de l'app) : la valeur -// max de cached_at du dernier snapshot sert de borne `after` pour le prochain. -let lastPermissionCachedAt: number | null = null; +// Delta monotone PAR COMPTE (clé = active_user_id, NULL hors compte) : la +// valeur max de cached_at du dernier snapshot sert de borne `after` pour le +// prochain appel. Le snapshot n'est pas rejoué quand on change de compte. +const lastPermissionCachedAtByUser = new Map(); export async function refreshPermissions(): Promise { 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; for (const p of perms.data) { 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; } // Visible uniquement pour les tests unitaires (reset de l'état en mémoire). export function resetPermissionCachedAtForTests(): void { - lastPermissionCachedAt = null; + lastPermissionCachedAtByUser.clear(); } \ No newline at end of file diff --git a/mobile/i18n/en.json b/mobile/i18n/en.json index 9e6eb1b..9daca6e 100644 --- a/mobile/i18n/en.json +++ b/mobile/i18n/en.json @@ -8,5 +8,17 @@ "empty_folder": "Empty folder — next syncDevice will refresh it.", "bytes": "B", "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" } diff --git a/mobile/i18n/fr.json b/mobile/i18n/fr.json index cbb80bc..0b25472 100644 --- a/mobile/i18n/fr.json +++ b/mobile/i18n/fr.json @@ -8,5 +8,17 @@ "empty_folder": "Dossier vide — le prochain syncDevice l'actualisera.", "bytes": "o", "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" } diff --git a/mobile/package-lock.json b/mobile/package-lock.json index 8960283..4a1fb13 100644 --- a/mobile/package-lock.json +++ b/mobile/package-lock.json @@ -16,6 +16,7 @@ "expo-linking": "~57.0.9", "expo-localization": "~57.0.1", "expo-router": "~57.0.20", + "expo-secure-store": "~57.0.3", "expo-sqlite": "~57.0.2", "expo-status-bar": "~57.0.1", "i18n-js": "^4.5.3", @@ -4330,6 +4331,15 @@ "integrity": "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==", "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": { "version": "57.0.3", "resolved": "https://registry.npmjs.org/expo-server/-/expo-server-57.0.3.tgz", diff --git a/mobile/package.json b/mobile/package.json index a1df3cb..21615e1 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -11,6 +11,7 @@ "expo-linking": "~57.0.9", "expo-localization": "~57.0.1", "expo-router": "~57.0.20", + "expo-secure-store": "~57.0.3", "expo-sqlite": "~57.0.2", "expo-status-bar": "~57.0.1", "i18n-js": "^4.5.3", diff --git a/mobile/services/db/index.ts b/mobile/services/db/index.ts index 4d77252..4925371 100644 --- a/mobile/services/db/index.ts +++ b/mobile/services/db/index.ts @@ -40,6 +40,7 @@ export { DATABASE_VERSION, DEVICE_USER_ID_KEY, AUTH_TOKEN_KEY, + ACTIVE_USER_ID_KEY, PREFERENCES_KEY, PERMISSION_TTL_MS, } from './schema'; \ No newline at end of file diff --git a/mobile/services/db/migrations.ts b/mobile/services/db/migrations.ts index 4f44cac..151f277 100644 --- a/mobile/services/db/migrations.ts +++ b/mobile/services/db/migrations.ts @@ -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;'); }, }, + { + // 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 { diff --git a/mobile/services/db/repositories/index.ts b/mobile/services/db/repositories/index.ts index 8dca46a..64bb5e3 100644 --- a/mobile/services/db/repositories/index.ts +++ b/mobile/services/db/repositories/index.ts @@ -13,6 +13,9 @@ export { getDeviceUserId, getDeviceAuthToken, saveDeviceAuthToken, + getActiveUserId, + setActiveUserId, + clearActiveUserId, } from './preferences'; export { getResourcePermission, diff --git a/mobile/services/db/repositories/pendingOps.ts b/mobile/services/db/repositories/pendingOps.ts index 221412f..2b05eae 100644 --- a/mobile/services/db/repositories/pendingOps.ts +++ b/mobile/services/db/repositories/pendingOps.ts @@ -1,4 +1,5 @@ import { getSession } from '../session'; +import { getActiveUserId } from './preferences'; import type { NewPendingOperation, PendingOperation, @@ -19,10 +20,11 @@ export async function enqueuePendingOperation( operation: NewPendingOperation, ): Promise { const db = await getSession(); + const activeUserId = await getActiveUserId(); const row = await db.getFirstAsync<{ id: number }>( `INSERT INTO pending_operations - (resource_id, resource_type, ref_type, ref_id, operation, payload, status, attempts, created_at) - VALUES (?, ?, ?, ?, ?, ?, 'pending', 0, ?) + (resource_id, resource_type, ref_type, ref_id, operation, payload, status, attempts, created_at, user_id) + VALUES (?, ?, ?, ?, ?, ?, 'pending', 0, ?, ?) RETURNING id`, operation.resourceId ?? null, operation.resourceType ?? null, @@ -31,6 +33,7 @@ export async function enqueuePendingOperation( operation.operation, JSON.stringify(operation.payload ?? {}), Date.now(), + activeUserId, ); return row!.id; } @@ -42,12 +45,16 @@ export async function getPendingOperations( const rows = status ? await db.getAllAsync( `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, + await getActiveUserId(), ) : await db.getAllAsync( `SELECT ${PENDING_OPERATION_COLUMNS} FROM pending_operations + WHERE user_id = ? OR user_id IS NULL ORDER BY created_at ASC, id ASC`, + await getActiveUserId(), ); return rows.map(toPendingOperation); } @@ -57,9 +64,11 @@ export async function getNextQueuedOperation(): Promise const row = await db.getFirstAsync( `SELECT ${PENDING_OPERATION_COLUMNS} FROM pending_operations WHERE status = 'pending' + AND (user_id = ? OR user_id IS NULL) AND (next_retry_at IS NULL OR next_retry_at <= ?) ORDER BY created_at ASC, id ASC LIMIT 1`, + await getActiveUserId(), Date.now(), ); return row ? toPendingOperation(row) : null; @@ -72,9 +81,11 @@ export async function listQueuedOperations(limit: number): Promise( `SELECT ${PENDING_OPERATION_COLUMNS} FROM pending_operations WHERE status = 'pending' + AND (user_id = ? OR user_id IS NULL) AND (next_retry_at IS NULL OR next_retry_at <= ?) ORDER BY created_at ASC, id ASC LIMIT ?`, + await getActiveUserId(), Date.now(), limit, ); diff --git a/mobile/services/db/repositories/permissions.ts b/mobile/services/db/repositories/permissions.ts index cdbf4d5..90b277a 100644 --- a/mobile/services/db/repositories/permissions.ts +++ b/mobile/services/db/repositories/permissions.ts @@ -1,7 +1,7 @@ import type { DbSession } from '../session'; import { getSession } from '../session'; import { PERMISSION_TTL_MS } from '../schema'; -import { getDeviceUserId } from './preferences'; +import { getActiveUserId, getDeviceUserId } from './preferences'; import type { AccessLevel, NewResourcePermission, @@ -44,9 +44,11 @@ export async function getResourcePermission( ): Promise { const db = await getSession(); const row = await db.getFirstAsync( - `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, resourceType, + await getActiveUserId(), ); return row ? toResourcePermission(row) : null; } @@ -58,9 +60,9 @@ export async function saveResourcePermission( const now = Date.now(); await db.runAsync( `INSERT INTO resource_permissions - (resource_id, resource_type, effective_access, inherit, owner_id, shared_by_id, expires_at, cached_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(resource_id, resource_type) DO UPDATE SET + (user_id, resource_id, resource_type, effective_access, inherit, owner_id, shared_by_id, expires_at, cached_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(user_id, resource_id, resource_type) DO UPDATE SET effective_access = excluded.effective_access, inherit = excluded.inherit, owner_id = excluded.owner_id, @@ -68,6 +70,7 @@ export async function saveResourcePermission( expires_at = excluded.expires_at, cached_at = excluded.cached_at, updated_at = excluded.updated_at`, + await getActiveUserId(), permission.resource_id, permission.resourceType, permission.effectiveAccess, @@ -140,6 +143,9 @@ export async function canAccess( required: AccessLevel, ): Promise { 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 exactCache = await getResourcePermission(resourceId, resourceType); diff --git a/mobile/services/db/repositories/preferences.ts b/mobile/services/db/repositories/preferences.ts index e2d5da9..a49b13c 100644 --- a/mobile/services/db/repositories/preferences.ts +++ b/mobile/services/db/repositories/preferences.ts @@ -1,5 +1,5 @@ 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'; const DEFAULT_PREFERENCES: UserPreferences = { @@ -58,6 +58,36 @@ export async function saveDeviceAuthToken(token: string): Promise { ); } +// 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 { + 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 { + 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 { + const db = await getSession(); + await db.runAsync( + 'DELETE FROM user_preferences WHERE "key" = ?', + ACTIVE_USER_ID_KEY, + ); +} + export async function getUserPreferences(): Promise { const db = await getSession(); const row = await db.getFirstAsync<{ value: string }>( diff --git a/mobile/services/db/schema.ts b/mobile/services/db/schema.ts index 2981a9e..880ceec 100644 --- a/mobile/services/db/schema.ts +++ b/mobile/services/db/schema.ts @@ -1,6 +1,6 @@ export const DATABASE_NAME = 'dot.db'; -export const DATABASE_VERSION = 4; +export const DATABASE_VERSION = 5; 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'; +// 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 = [ 'resource_id', 'uri', diff --git a/mobile/services/localStorage.ts b/mobile/services/localStorage.ts index 8646d71..4772a9d 100644 --- a/mobile/services/localStorage.ts +++ b/mobile/services/localStorage.ts @@ -3,6 +3,9 @@ export { getDeviceUserId, getDeviceAuthToken, saveDeviceAuthToken, + getActiveUserId, + setActiveUserId, + clearActiveUserId, getFolders, getFolderFolders, getFolder, diff --git a/mobile/services/secureStore.ts b/mobile/services/secureStore.ts new file mode 100644 index 0000000..b3ae029 --- /dev/null +++ b/mobile/services/secureStore.ts @@ -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 { + try { + return await SecureStore.getItemAsync(TOKEN_KEY); + } catch { + return null; + } +} + +export async function getStoredAccount(): Promise { + 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 { + await SecureStore.setItemAsync(TOKEN_KEY, token); + await SecureStore.setItemAsync(ACCOUNT_KEY, JSON.stringify(user)); +} + +export async function clearStoredSession(): Promise { + try { + await SecureStore.deleteItemAsync(TOKEN_KEY); + await SecureStore.deleteItemAsync(ACCOUNT_KEY); + } catch { + // clés absentes → rien à supprimer + } +} \ No newline at end of file diff --git a/mobile/tests/apiClient.test.ts b/mobile/tests/apiClient.test.ts index 0556c89..c670985 100644 --- a/mobile/tests/apiClient.test.ts +++ b/mobile/tests/apiClient.test.ts @@ -1,6 +1,6 @@ import { test, beforeEach, afterEach } from 'node:test'; 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'; function jsonResponse(status: number, body: unknown, headers?: HeadersInit): Response { @@ -33,10 +33,12 @@ function pathOf(url: string): string { beforeEach(() => { calls = []; setAuthToken(null); + setUnauthorizedHandler(null); }); afterEach(() => { globalThis.fetch = originalFetch; + setUnauthorizedHandler(null); }); test('nominal : GET /health', async () => { @@ -47,13 +49,74 @@ test('nominal : GET /health', async () => { assert.deepEqual(res.data, { status: 'healthy' }); }); -test('nominal : POST /devices (register) JSON', async () => { - stubFetch(() => ok({ deviceId: 'aaaa', token: 'v4.local.x' })); +test('nominal : POST /devices (register) — plus aucun token, device-only', async () => { + stubFetch(() => ok({ deviceId: 'aaaa' })); const res = await api.registerDevice('aaaa'); assert.equal(pathOf(calls[0].url), '/api/v1/devices'); assert.equal(calls[0].init.method, 'POST'); 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 () => { @@ -184,7 +247,7 @@ test('auth : token posé → Authorization: Bearer', async () => { test('auth : fusion avec Content-Type existant (POST /devices + token)', async () => { setAuthToken('tok-123'); - stubFetch(() => ok({ deviceId: 'a', token: 't' })); + stubFetch(() => ok({ deviceId: 'a' })); await api.registerDevice('a'); const headers = new Headers(calls[0].init.headers); assert.equal(headers.get('Authorization'), 'Bearer tok-123'); diff --git a/mobile/tests/e2e.live.test.ts b/mobile/tests/e2e.live.test.ts index ec1e279..27aa2b8 100644 --- a/mobile/tests/e2e.live.test.ts +++ b/mobile/tests/e2e.live.test.ts @@ -13,10 +13,17 @@ import { MIGRATIONS, type MigrationDb } from '../services/db/migrations'; import type { DbSession } from '../services/db/session'; import { __setDbForTests } from '../services/db/session'; 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'; 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; @@ -75,9 +82,10 @@ beforeEach(async () => { afterEach(() => { __setDbForTests(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 { await api.health(); } catch (error) { @@ -88,11 +96,15 @@ test('bout en bout : register → push outbox → snapshot → relecture serveur throw error; } + // Enregistrement du device (idempotent) puis login = SEULE porte de token. deviceId = newDeviceId(); - const reg = await api.registerDevice(deviceId); - setAuthToken(reg.data.token); + await api.registerDevice(deviceId); + 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 resourceId = newDeviceId(); const opId = await enqueuePendingOperation({ diff --git a/mobile/tests/migrations.test.ts b/mobile/tests/migrations.test.ts index bae9bcc..f0b4ae4 100644 --- a/mobile/tests/migrations.test.ts +++ b/mobile/tests/migrations.test.ts @@ -80,7 +80,7 @@ async function seedLegacyTree(h: Harness): Promise { 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(); 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`); } + 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 }>( 'SELECT "value" FROM user_preferences WHERE "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'); }); +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 () => { const h = createHarness(); await migrateDatabase(h); diff --git a/mobile/tests/syncOutbox.test.ts b/mobile/tests/syncOutbox.test.ts index e13b79b..123f48d 100644 --- a/mobile/tests/syncOutbox.test.ts +++ b/mobile/tests/syncOutbox.test.ts @@ -7,11 +7,13 @@ import { __setDbForTests } from '../services/db/session'; import { api, setAuthToken } from '../api/client'; import { enqueuePendingOperation, + getActiveUserId, getPendingOperations, getResourcePermission, listQueuedOperations, markPendingOperation, scheduleRetries, + setActiveUserId, MAX_PENDING_ATTEMPTS, } from '../services/db'; import { @@ -331,4 +333,70 @@ test('refreshPermissions garde la référence dans le temps pour after', async ( await refreshPermissions(); assert.equal((await getResourcePermission('d'.repeat(32), 'folder'))?.effectiveAccess, 'viewer'); 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', + ); }); \ No newline at end of file