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:
@@ -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';
|
||||
@@ -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> {
|
||||
|
||||
@@ -13,6 +13,9 @@ export {
|
||||
getDeviceUserId,
|
||||
getDeviceAuthToken,
|
||||
saveDeviceAuthToken,
|
||||
getActiveUserId,
|
||||
setActiveUserId,
|
||||
clearActiveUserId,
|
||||
} from './preferences';
|
||||
export {
|
||||
getResourcePermission,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }>(
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user