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)}`),