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
+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 }>(