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

- api: DeviceRegistration device-only, login/changePassword/resolveUser, interception 401 (purge) exonérée sur le login ; type User aligné sur le fil (is_admin)
- SecureStore (expo-secure-store v57) : token + profil, jamais en SQLite ; miroir active_user_id
- AuthContext : statut loading/signedOut/signedIn, bootstrap register→restore, signIn/signOut, garde-fou switch de compte
- app/login.tsx + gate de routes dans _layout (Redirect signedOut) ; i18n fr/en
- DB v5 : user_id sur pending_operations + resource_permissions (UNIQUE par user), repos scopés (user_id IS ? OR IS NULL)
- syncOutbox : delta permissions par compte (Map), outbox poussée du compte actif uniquement
- tests: register/login/401/resolve/changePassword, scoping outbox+permissions, migrations v5 ; e2e live revert register→login admin
This commit is contained in:
m
2026-09-10 21:34:34 +02:00
parent c2c7dddba6
commit 0536fca6c3
25 changed files with 663 additions and 49 deletions
+36 -1
View File
@@ -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<string, string | number | boolean | undefined | null>;
function toQuery(params?: QueryParams): string {
@@ -60,7 +70,8 @@ export class ApiError extends Error {
async function request<T>(
path: string,
init: RequestInit = {},
timeoutMs: number = DEFAULT_TIMEOUT_MS
timeoutMs: number = DEFAULT_TIMEOUT_MS,
opts: { skipUnauthorizedHandling?: boolean } = {},
): Promise<ApiData<T>> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
@@ -76,6 +87,10 @@ async function request<T>(
throw new ApiError('NETWORK_ERROR', 'Serveur injoignable');
}
if (response.status === 401 && !opts.skipUnauthorizedHandling) {
onUnauthorized?.();
}
const body = (await response.json().catch(() => null)) as
| ApiData<T>
| 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<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) =>
request<FileDto[]>(`/files${toQuery(params)}`),
+22
View File
@@ -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 = {
+2 -1
View File
@@ -26,7 +26,8 @@
"plugins": [
"expo-router",
"expo-sqlite",
"expo-localization"
"expo-localization",
"expo-secure-store"
]
}
}
+27 -4
View File
@@ -1,7 +1,8 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Stack } from 'expo-router';
import { AuthProvider } from '../context/AuthContext';
import '../i18n';
import { Redirect, Stack } from 'expo-router';
import { ActivityIndicator, View } from 'react-native';
import { AuthProvider, useAuth } from '../context/AuthContext';
import i18n from '../i18n';
const queryClient = new QueryClient({
defaultOptions: {
@@ -12,11 +13,33 @@ const queryClient = new QueryClient({
},
});
function AuthGate() {
const { status } = useAuth();
if (status === 'loading') {
return (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
<ActivityIndicator size="large" />
</View>
);
}
return (
<>
<Stack>
<Stack.Screen name="login" options={{ title: i18n.t('login_title') }} />
</Stack>
{/* Toute route est protégée tant qu'aucun compte n'est connecté. */}
{status === 'signedOut' ? <Redirect href="/login" /> : null}
</>
);
}
export default function RootLayout() {
return (
<QueryClientProvider client={queryClient}>
<AuthProvider>
<Stack />
<AuthGate />
</AuthProvider>
</QueryClientProvider>
);
+121
View File
@@ -0,0 +1,121 @@
import { useRouter } from 'expo-router';
import { useState } from 'react';
import { Alert, Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
import { useAuth } from '../context/AuthContext';
import { ApiError } from '../api/client';
import i18n from '../i18n';
export default function Login() {
const router = useRouter();
const { signIn, user } = useAuth();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async () => {
if (!username.trim() || !password) {
setError(i18n.t('login_error_required'));
return;
}
setSubmitting(true);
setError(null);
// Changer de compte en étant connecté remplace la session locale (garde-fou).
if (user) {
const confirmed = await new Promise<boolean>((resolve) => {
Alert.alert(i18n.t('login_switch_title'), i18n.t('login_switch_message'), [
{ text: i18n.t('login_switch_cancel'), style: 'cancel', onPress: () => resolve(false) },
{ text: i18n.t('login_switch_confirm'), style: 'destructive', onPress: () => resolve(true) },
]);
});
if (!confirmed) {
setSubmitting(false);
return;
}
}
try {
await signIn(username.trim(), password);
router.replace('/');
} catch (err) {
setError(err instanceof ApiError ? err.message : i18n.t('login_error_generic'));
} finally {
setSubmitting(false);
}
};
return (
<View style={styles.container}>
<Text style={styles.title}>{i18n.t('login_subtitle')}</Text>
<TextInput
style={styles.input}
placeholder={i18n.t('login_username')}
autoCapitalize="none"
autoCorrect={false}
value={username}
onChangeText={setUsername}
/>
<TextInput
style={styles.input}
placeholder={i18n.t('login_password')}
secureTextEntry
value={password}
onChangeText={setPassword}
/>
{error ? <Text style={styles.error}>{error}</Text> : null}
<Pressable
style={[styles.button, submitting && styles.buttonDisabled]}
onPress={handleSubmit}
disabled={submitting}
>
<Text style={styles.buttonText}>
{submitting ? i18n.t('login_submitting') : i18n.t('login_submit')}
</Text>
</Pressable>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
padding: 24,
justifyContent: 'center',
gap: 12,
},
title: {
fontSize: 18,
fontWeight: '600',
marginBottom: 8,
textAlign: 'center',
},
input: {
borderWidth: StyleSheet.hairlineWidth,
borderColor: '#ccc',
borderRadius: 8,
paddingHorizontal: 14,
paddingVertical: 12,
fontSize: 15,
},
error: {
color: '#c5221f',
fontSize: 14,
},
button: {
backgroundColor: '#1a73e8',
paddingVertical: 12,
borderRadius: 8,
alignItems: 'center',
marginTop: 4,
},
buttonDisabled: {
opacity: 0.6,
},
buttonText: {
color: '#fff',
fontSize: 16,
fontWeight: '600',
},
});
+59 -13
View File
@@ -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<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | 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(() => {
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 <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
+8 -1
View File
@@ -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<void>;
signOut: () => Promise<void>;
};
+10 -6
View File
@@ -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<PushResult> {
// ---- 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<string | null, number>();
export async function refreshPermissions(): Promise<number> {
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();
}
+13 -1
View File
@@ -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"
}
+13 -1
View File
@@ -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"
}
+10
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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';
+41
View File
@@ -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<number> {
+3
View File
@@ -13,6 +13,9 @@ export {
getDeviceUserId,
getDeviceAuthToken,
saveDeviceAuthToken,
getActiveUserId,
setActiveUserId,
clearActiveUserId,
} from './preferences';
export {
getResourcePermission,
+14 -3
View File
@@ -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<number> {
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<PendingOperationRow>(
`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<PendingOperationRow>(
`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<PendingOperation | null>
const row = await db.getFirstAsync<PendingOperationRow>(
`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<PendingOperat
const rows = await db.getAllAsync<PendingOperationRow>(
`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,
);
+11 -5
View File
@@ -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<ResourcePermission | null> {
const db = await getSession();
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,
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<AccessCheck> {
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);
+31 -1
View File
@@ -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<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> {
const db = await getSession();
const row = await db.getFirstAsync<{ value: string }>(
+7 -1
View File
@@ -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',
+3
View File
@@ -3,6 +3,9 @@ export {
getDeviceUserId,
getDeviceAuthToken,
saveDeviceAuthToken,
getActiveUserId,
setActiveUserId,
clearActiveUserId,
getFolders,
getFolderFolders,
getFolder,
+42
View File
@@ -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
}
}
+68 -5
View File
@@ -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 dun 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');
+17 -5
View File
@@ -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, 'ladmin 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({
+35 -1
View File
@@ -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);`);
}
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);
+68
View File
@@ -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 {
@@ -332,3 +334,69 @@ test('refreshPermissions garde la référence dans le temps pour after', async (
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], 'lop 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',
);
});