Frontend et backend entièrement réels : plus aucune donnée simulée
All checks were successful
CI / build-and-test (push) Successful in 2m14s
CI / deploy (push) Successful in 3m17s

À la demande explicite de l'utilisateur après avoir constaté que
tripoli.cloud affichait un 404 vide (le frontend n'avait jamais été
déployé) : déploiement du frontend, puis remplacement complet des
mocks par de vraies données, des deux côtés.

Backend (tripoli-api) :
- users.name, files.category (file/photo/backup), table notifications
  (migration 0002).
- POST /api/auth/guest : compte invité réel et réutilisé
  (invite@tripoli.local), pas une identité simulée côté frontend.
- GET /api/files?category=... , GET /api/notifications (+ read-all/
  {id}/read), GET /api/storage (used_bytes réel, available_bytes via
  statvfs - jamais un quota inventé).
- Notifications émises réellement sur upload/suppression.

Frontend (app/) :
- src/api/*.ts appellent réellement tripoli-api (plus de mockFetch).
- Upload réel (XHR, vraie progression), suppression avec bouton dédié,
  vraies miniatures photo (<img src="/api/files/:id">).
- Session restaurée au chargement (GET /api/auth/me), logout réel.
- Retiré plutôt que simulé : 2FA, clé de récupération, accès partagés,
  albums, dossiers, "4 appareils sauvegardés", chiffres marketing et
  claim AES-256 côté client sur la page de connexion (jamais vrai -
  remplacé par le vrai : HTTPS + Argon2).

Déploiement :
- Frontend construit et déployé sur le serveur (le build local échouait
  spécifiquement sur cette machine - rolldown/esbuild natif ne répondait
  plus, cause non résolue ; contourné en buildant sur le serveur, comme
  déjà fait pour les crates Rust).
- Caddy : /var/www/tripoli-cloud servi en statique pour tripoli.cloud,
  /api/*, /healthz, /metrics toujours proxyés vers tripoli-edge.
- Pipeline CI étendu : le job deploy construit et déploie maintenant
  aussi le frontend, via un script sudo dédié supplémentaire
  (tripoli-deploy-frontend.sh) sur le compte deploy sans privilège.
- Vérifié en navigateur réel (Chrome) : inscription, connexion, invité,
  upload avec progression, photos, sauvegardes, notifications,
  suppression, déconnexion - tous testés en conditions réelles.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Adam Belkacemi 2026-09-17 18:19:51 +02:00
parent 5816c7eda6
commit a639b705a6
53 changed files with 1352 additions and 587 deletions

View file

@ -52,10 +52,17 @@ jobs:
- name: Build release (workspace) - name: Build release (workspace)
run: cargo build --release --workspace run: cargo build --release --workspace
- name: npm build (app/)
working-directory: app
run: |
npm ci
npm run build
# Identité de déploiement dédiée, sans privilège : `deploy` ne peut # Identité de déploiement dédiée, sans privilège : `deploy` ne peut
# que déposer un fichier dans /opt/deploy-staging et lancer le # que déposer des fichiers dans /opt/deploy-staging et lancer les
# script sudo tripoli-deploy.sh, qui installe le binaire et # scripts sudo tripoli-deploy*.sh, qui installent exactement ce
# redémarre EXACTEMENT le service nommé - rien de plus. Pas de clé # qui est attendu (binaire + redémarrage du service nommé, ou
# synchronisation du frontend statique) - rien de plus. Pas de clé
# root dans ce pipeline. # root dans ce pipeline.
- name: Déployer tripoli-edge et tripoli-api - name: Déployer tripoli-edge et tripoli-api
env: env:
@ -71,3 +78,6 @@ jobs:
scp -i ~/.ssh/deploy_key "target/release/$service" "deploy@$DEPLOY_HOST:/opt/deploy-staging/$service" scp -i ~/.ssh/deploy_key "target/release/$service" "deploy@$DEPLOY_HOST:/opt/deploy-staging/$service"
ssh -i ~/.ssh/deploy_key "deploy@$DEPLOY_HOST" sudo /usr/local/sbin/tripoli-deploy.sh "$service" ssh -i ~/.ssh/deploy_key "deploy@$DEPLOY_HOST" sudo /usr/local/sbin/tripoli-deploy.sh "$service"
done done
rsync -az -e "ssh -i ~/.ssh/deploy_key" --delete app/dist/ "deploy@$DEPLOY_HOST:/opt/deploy-staging/frontend/"
ssh -i ~/.ssh/deploy_key "deploy@$DEPLOY_HOST" sudo /usr/local/sbin/tripoli-deploy-frontend.sh

3
.gitignore vendored
View file

@ -21,3 +21,6 @@ target/
*.pem *.pem
*.key *.key
!**/wireguard/*.example !**/wireguard/*.example
# Claude Code local runtime state
.claude/

1
Cargo.lock generated
View file

@ -2358,6 +2358,7 @@ dependencies = [
"chrono", "chrono",
"futures-util", "futures-util",
"mime_guess", "mime_guess",
"nix 0.31.3",
"password-hash", "password-hash",
"rand_core", "rand_core",
"serde", "serde",

View file

@ -31,14 +31,15 @@ tenté, non simulé.
| TLS Let's Encrypt | domaines (Forgejo, | TLS Let's Encrypt | domaines (Forgejo,
| routage par Host | lecheminvert, etc.) | routage par Host | lecheminvert, etc.)
+-------------------------+ +-------------------------+
| /api/*, /healthz, /metrics tout le reste
172.17.0.1:9000 (passerelle docker0) | |
v 172.17.0.1:9000 (docker0) /var/www/tripoli-cloud
+-------------------------+ v (fichiers statiques,
| tripoli-edge | Rust (hyper/tokio), systemd, +-------------------------+ file_server de Caddy)
| /healthz /metrics | utilisateur système sans | tripoli-edge |
| reverse-proxy | privilège | /healthz /metrics | Rust (hyper/tokio), systemd,
+-------------------------+ | reverse-proxy | utilisateur système sans
+-------------------------+ privilège
| |
127.0.0.1:8081 127.0.0.1:8081
v v
@ -91,12 +92,18 @@ tenté, non simulé.
d'Alertmanager, pas de policy Grafana dédiée branchée). d'Alertmanager, pas de policy Grafana dédiée branchée).
- **SSH public toujours ouvert** : choix explicite fait avec - **SSH public toujours ouvert** : choix explicite fait avec
l'utilisateur, pas encore restreint au tunnel WireGuard. l'utilisateur, pas encore restreint au tunnel WireGuard.
- **Frontend, fonctionnalités volontairement absentes** : pas de
dossiers (stockage à plat), pas de suivi multi-appareils pour les
sauvegardes (aucun agent client), pas de partage/accès multi-
utilisateurs, préférences de notification stockées dans le navigateur
(pas synchronisées entre appareils). Rien de tout ça n'est simulé -
simplement pas construit dans cette passe.
## État réel ## État réel
| Composant | État | | Composant | État |
|---|---| |---|---|
| Prototype frontend (`app/`) | Fait — React/TS/Vite/Tailwind, maquette | | Frontend (`app/`) | Fait et déployé en prod — React/TS/Vite/Tailwind, servi en statique par Caddy (`/var/www/tripoli-cloud`), **branché sur les vraies données de tripoli-api** (plus aucun mock) : auth, upload réel avec progression, photos/sauvegardes/notifications/espace disque réels. Éléments sans fonctionnalité réelle possible (2FA, albums, "4 appareils sauvegardés"...) retirés plutôt que simulés — détail dans `app/README.md` |
| Reconnaissance serveur (Phase 0) | Fait | | Reconnaissance serveur (Phase 0) | Fait |
| WireGuard (canal admin Mac ↔ serveur) | Fait — `10.100.0.0/24`, port 58212/udp (51820 filtré par le pare-feu Hetzner Cloud, voir `ansible/group_vars/all.yml`) | | WireGuard (canal admin Mac ↔ serveur) | Fait — `10.100.0.0/24`, port 58212/udp (51820 filtré par le pare-feu Hetzner Cloud, voir `ansible/group_vars/all.yml`) |
| Pare-feu / fail2ban / unattended-upgrades | Déjà en place avant ce projet ; confirmés/complétés par Ansible | | Pare-feu / fail2ban / unattended-upgrades | Déjà en place avant ce projet ; confirmés/complétés par Ansible |
@ -104,11 +111,11 @@ tenté, non simulé.
| Ansible (rôle de base) | Fait — `ansible/` | | Ansible (rôle de base) | Fait — `ansible/` |
| OpenTofu (serveur + DNS) | Code écrit — `tofu/` ; serveur en donnée en lecture seule (jamais recréé/détruit), DNS géré via le provider OVH. `plan`/`apply` pas encore exécutés (nécessitent des identifiants OVH/Hetzner non fournis) | | OpenTofu (serveur + DNS) | Code écrit — `tofu/` ; serveur en donnée en lecture seule (jamais recréé/détruit), DNS géré via le provider OVH. `plan`/`apply` pas encore exécutés (nécessitent des identifiants OVH/Hetzner non fournis) |
| `tripoli-edge` (Rust, Phase 2) | Fait et déployé en prod — `tripoli-edge/`, service systemd, derrière Caddy, vérifié via `https://tripoli.cloud/healthz` et `/metrics` | | `tripoli-edge` (Rust, Phase 2) | Fait et déployé en prod — `tripoli-edge/`, service systemd, derrière Caddy, vérifié via `https://tripoli.cloud/healthz` et `/metrics` |
| `tripoli-api` (Rust, Phase 3) | Fait et déployé en prod — `tripoli-api/`, auth par session (Argon2), upload/download, PostgreSQL dédié (Docker), vérifié via `https://tripoli.cloud/api/...` | | `tripoli-api` (Rust, Phase 3) | Fait et déployé en prod — `tripoli-api/`, auth par session (Argon2, compte invité réel), fichiers par catégorie (file/photo/backup), notifications réelles, espace disque réel (`statvfs`), PostgreSQL dédié (Docker), vérifié via `https://tripoli.cloud/api/...` et en navigateur réel |
| `tripoli-netctl` (Rust, Phase 4) | Fait — `tripoli-netctl/`, labo pédagogique (netns + veth via `rtnetlink`), testé sur le serveur (création, ping traversant la veth, destruction propre), **volontairement pas branché sur le trafic de prod** | | `tripoli-netctl` (Rust, Phase 4) | Fait — `tripoli-netctl/`, labo pédagogique (netns + veth via `rtnetlink`), testé sur le serveur (création, ping traversant la veth, destruction propre), **volontairement pas branché sur le trafic de prod** |
| Observabilité (Phase 5) | Fait — pas de stack dédiée (réutilise le Prometheus/Grafana partagé existant), scrape tripoli-edge + alertes (down, disque) + dashboard versionné (`monitoring/`), vérifié via l'API Prometheus/Grafana | | Observabilité (Phase 5) | Fait — pas de stack dédiée (réutilise le Prometheus/Grafana partagé existant), scrape tripoli-edge + alertes (down, disque) + dashboard versionné (`monitoring/`), vérifié via l'API Prometheus/Grafana |
| Bird/BGP (Phase 6, bonus) | Fait — `bird-lab/`, BGP+BFD entre 2 namespaces via la racine en transit (AS 65000/65010/65011), vérifié (sessions Established, BFD Up, propagation de route, détection de panne sur coupure de lien), puis proprement démonté | | Bird/BGP (Phase 6, bonus) | Fait — `bird-lab/`, BGP+BFD entre 2 namespaces via la racine en transit (AS 65000/65010/65011), vérifié (sessions Established, BFD Up, propagation de route, détection de panne sur coupure de lien), puis proprement démonté |
| CI/CD | Fait — `.forgejo/workflows/ci.yml` (pas `.gitlab-ci.yml` : le dépôt vit sur Forgejo, pas GitLab). Runner Forgejo Actions déployé en systemd, déploiement via un compte `deploy` sans privilège (`ci/README.md`) | | CI/CD | Fait — `.forgejo/workflows/ci.yml` (pas `.gitlab-ci.yml` : le dépôt vit sur Forgejo, pas GitLab). Runner Forgejo Actions déployé en systemd, déploie binaires Rust **et** frontend, via un compte `deploy` sans privilège (`ci/README.md`) |
## Dépôt ## Dépôt

View file

@ -1,32 +1,39 @@
# React + TypeScript + Vite # Tripoli Cloud — frontend
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules. React + TypeScript + Vite + Tailwind. Appelle réellement `tripoli-api`
via `/api/...` (même origine, servi par le Caddy partagé - voir le
README racine) : plus aucune donnée simulée.
Currently, two official plugins are available: ## Ce qui est réel
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) - Auth : inscription, connexion, invité (compte réel réutilisé),
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) session persistée par cookie, restaurée au chargement.
- Fichiers : upload réel (XHR, vraie progression), téléchargement,
suppression, filtré par catégorie (`file`/`photo`/`backup`).
- Photos : grille des fichiers catégorie `photo`, vraies miniatures
(`<img src="/api/files/:id">`), pas de génération de miniature à part.
- Sauvegardes : fichiers catégorie `backup` - pas de suivi
multi-appareils (aucun agent client n'existe).
- Espace disque : `used_bytes` réel (somme des fichiers), `available_bytes`
réel (`statvfs` côté serveur) - jamais un quota inventé.
- Notifications : émises par le serveur à chaque upload/suppression.
- Réglages : section Compte depuis le vrai utilisateur ; préférences de
notification dans `localStorage` (device-local, pas synchronisé -
annoncé comme tel, pas simulé comme synchronisé).
## React Compiler ## Ce qui a été retiré (pas simulé)
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). La maquette d'origine (`Tripoli Cloud.dc.html`) incluait des éléments
sans fonctionnalité réelle possible dans cette version : 2FA, clé de
récupération, accès partagés, albums photo, dossiers, "4 appareils
sauvegardés", chiffres marketing sur la page de connexion, chiffrement
AES-256 côté client. Retirés plutôt que simulés - voir les commentaires
dans `src/api/*.ts` pour le détail de chaque décision.
## Expanding the Oxlint configuration ## Développement
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`: ```bash
npm install
```json npm run dev # nécessite tripoli-api accessible sur /api (proxy Vite à ajouter si besoin en local)
{ npm run build # build de production, servi en statique par Caddy
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"options": {
"typeAware": true
},
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
``` ```
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.

View file

@ -25,8 +25,9 @@ function AppContent() {
} }
function Root() { function Root() {
const { view } = useSession(); const { view, restoring } = useSession();
if (restoring) return null;
if (view === 'auth') return <AuthLayout />; if (view === 'auth') return <AuthLayout />;
return ( return (

View file

@ -1,5 +1,5 @@
import type { User } from '../types'; import type { User } from '../types';
import { mockFetch } from './client'; import { ApiError, apiFetch } from './client';
export interface Credentials { export interface Credentials {
email: string; email: string;
@ -10,6 +10,14 @@ export interface SignupPayload extends Credentials {
name: string; name: string;
} }
interface UserView {
id: string;
name: string;
email: string;
is_guest: boolean;
created_at: string;
}
function initialsOf(name: string): string { function initialsOf(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean); const parts = name.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return '??'; if (parts.length === 0) return '??';
@ -19,35 +27,49 @@ function initialsOf(name: string): string {
.join(''); .join('');
} }
export async function login({ email }: Credentials): Promise<User> { function toUser(view: UserView): User {
return mockFetch({ return {
id: 'usr_1', id: view.id,
name: 'Leïla Barghouti', name: view.name,
initials: 'LB', initials: initialsOf(view.name || view.email),
email, email: view.email,
role: 'Administratrice', role: view.is_guest ? 'Accès invité' : 'Compte personnel',
isGuest: false, isGuest: view.is_guest,
}); };
} }
export async function signup({ name, email }: SignupPayload): Promise<User> { export async function login({ email, password }: Credentials): Promise<User> {
return mockFetch({ const view = await apiFetch<UserView>('/api/auth/login', {
id: 'usr_new', method: 'POST',
name, body: JSON.stringify({ email, password }),
initials: initialsOf(name),
email,
role: 'Administratrice',
isGuest: false,
}); });
return toUser(view);
}
export async function signup({ name, email, password }: SignupPayload): Promise<User> {
const view = await apiFetch<UserView>('/api/auth/register', {
method: 'POST',
body: JSON.stringify({ name, email, password }),
});
return toUser(view);
} }
export async function loginAsGuest(): Promise<User> { export async function loginAsGuest(): Promise<User> {
return mockFetch({ const view = await apiFetch<UserView>('/api/auth/guest', { method: 'POST' });
id: 'usr_guest', return toUser(view);
name: 'Invité', }
initials: 'IN',
email: '', export async function logout(): Promise<void> {
role: 'Accès invité', await apiFetch<void>('/api/auth/logout', { method: 'POST' });
isGuest: true, }
});
/** Restaure la session depuis le cookie au chargement de la page. `null` si non connecté. */
export async function fetchCurrentUser(): Promise<User | null> {
try {
const view = await apiFetch<UserView>('/api/auth/me');
return toUser(view);
} catch (err) {
if (err instanceof ApiError && err.status === 401) return null;
throw err;
}
} }

View file

@ -1,19 +1,25 @@
import type { BackupDevice } from '../types'; import { formatBytes, formatRelativeTime } from '../lib/format';
import { mockFetch } from './client'; import type { BackupItem } from '../types';
import { apiFetch } from './client';
const STATUS_LABEL: Record<BackupDevice['status'], string> = { interface FileView {
'up-to-date': 'À jour', id: string;
syncing: 'En cours', filename: string;
late: 'Retard 2 j', size_bytes: number;
}; created_at: string;
}
const RAW: Array<Omit<BackupDevice, 'id' | 'statusLabel'>> = [
{ device: 'Poste principal', detail: '64 Go · documents, messages, réglages', status: 'up-to-date', icon: 'smartphone' }, /**
{ device: 'Portable de travail', detail: '212 Go · dossier de travail et bureau', status: 'up-to-date', icon: 'laptop' }, * Pas de suivi multi-appareils (aucun agent n'est déployé sur des
{ device: 'Tablette de consultation', detail: '38 Go · documents en lecture seule', status: 'syncing', icon: 'tablet' }, * postes clients) : une "sauvegarde" est ici un fichier déposé avec la
{ device: 'Coffre secondaire hors site', detail: '812 Go · miroir complet du volume', status: 'late', icon: 'server' }, * catégorie "backup", comme n'importe quel autre dépôt.
]; */
export async function getBackups(): Promise<BackupItem[]> {
export async function getBackups(): Promise<BackupDevice[]> { const views = await apiFetch<FileView[]>('/api/files?category=backup');
return mockFetch(RAW.map((b, i) => ({ ...b, id: `backup_${i}`, statusLabel: STATUS_LABEL[b.status] }))); return views.map((v) => ({
id: v.id,
name: v.filename,
detail: `${formatBytes(v.size_bytes)} · déposé ${formatRelativeTime(v.created_at)}`,
icon: 'server',
}));
} }

View file

@ -1,13 +1,42 @@
/** /**
* Every function in src/api/ is written as if it called `API_BASE`, but * Toutes les requêtes passent par la même origine (le frontend est servi
* currently resolves a local mock after a short simulated delay. Swapping * par le même Caddy qui route /api/* vers tripoli-edge -> tripoli-api) :
* in a real backend later means rewriting the body of these functions only * pas besoin de configurer une URL de base, ni de CORS.
* — components and hooks never change.
*/ */
export const API_BASE = import.meta.env.VITE_API_URL; export class ApiError extends Error {
status: number;
export function mockFetch<T>(data: T, delayMs = 220): Promise<T> { constructor(status: number, message: string) {
return new Promise((resolve) => { super(message);
setTimeout(() => resolve(data), delayMs); this.status = status;
}); }
}
export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(path, {
...init,
credentials: 'include',
headers: {
...(init?.body && !(init.body instanceof FormData)
? { 'Content-Type': 'application/json' }
: {}),
...init?.headers,
},
});
if (!res.ok) {
let message = res.statusText;
try {
const body = (await res.json()) as { error?: string };
if (body.error) message = body.error;
} catch {
// pas de corps JSON exploitable - on garde le statusText
}
throw new ApiError(res.status, message);
}
if (res.status === 204 || res.headers.get('content-length') === '0') {
return undefined as T;
}
return (await res.json()) as T;
} }

View file

@ -1,33 +1,143 @@
import { THUMB_TONES } from '../lib/gradients'; import { THUMB_TONES } from '../lib/gradients';
import type { FileItem, StatItem } from '../types'; import { formatBytes, formatRelativeTime } from '../lib/format';
import { mockFetch } from './client'; import type { FileItem, FileKind, StatItem } from '../types';
import { apiFetch } from './client';
const RAW_FILES: Array<Omit<FileItem, 'id' | 'thumbGradient'>> = [ interface FileView {
{ name: 'Archives_fiscales_2026.zip', kind: 'archive', extLabel: 'Archive ZIP', size: '2,4 Go', modified: 'il y a 12 min' }, id: string;
{ name: 'Contrats_signes_2026', kind: 'folder', extLabel: 'Dossier · 412 éléments', size: '18,7 Go', modified: 'il y a 2 h' }, filename: string;
{ name: 'Acte_de_propriete.pdf', kind: 'document', extLabel: 'Document PDF', size: '1,1 Mo', modified: 'hier' }, content_type: string;
{ name: 'Backup_poste_principal', kind: 'backup', extLabel: 'Sauvegarde', size: '64 Go', modified: 'hier' }, category: string;
{ name: 'Conseil_administration.mp4', kind: 'video', extLabel: 'Vidéo MP4', size: '820 Mo', modified: 'il y a 3 jours' }, size_bytes: number;
{ name: 'Scans_administratifs', kind: 'folder', extLabel: 'Dossier · 38 éléments', size: '240 Mo', modified: 'il y a 5 jours' }, created_at: string;
{ name: 'Statuts_et_annexes.docx', kind: 'document', extLabel: 'Document Word', size: '96 Ko', modified: 'il y a 1 sem.' }, }
{ name: 'Numerisations_1998', kind: 'folder', extLabel: 'Dossier · 219 éléments', size: '3,2 Go', modified: 'il y a 2 sem.' },
];
export async function getFiles(): Promise<FileItem[]> { interface StorageView {
return mockFetch( used_bytes: number;
RAW_FILES.map((f, i) => ({ available_bytes: number;
...f, }
id: `file_${i}`,
thumbGradient: THUMB_TONES[i % THUMB_TONES.length]!, function kindOf(view: FileView): FileKind {
})), if (view.category === 'backup') return 'backup';
); if (view.content_type.startsWith('image/')) return 'image';
if (view.content_type.startsWith('video/')) return 'video';
if (
view.content_type.includes('zip') ||
view.content_type.includes('gzip') ||
view.content_type.includes('tar') ||
view.content_type.includes('compressed')
) {
return 'archive';
}
return 'document';
}
function extLabelOf(view: FileView, kind: FileKind): string {
const ext = view.filename.includes('.') ? view.filename.split('.').pop()!.toUpperCase() : '';
const byKind: Record<FileKind, string> = {
image: 'Image',
video: 'Vidéo',
archive: 'Archive',
backup: 'Sauvegarde',
document: 'Document',
folder: 'Dossier',
};
return ext ? `${byKind[kind]} ${ext}` : byKind[kind];
}
function toFileItem(view: FileView, index: number): FileItem {
const kind = kindOf(view);
return {
id: view.id,
name: view.filename,
kind,
extLabel: extLabelOf(view, kind),
size: formatBytes(view.size_bytes),
modified: formatRelativeTime(view.created_at),
thumbGradient: THUMB_TONES[index % THUMB_TONES.length]!,
};
}
export async function getFiles(category?: string): Promise<FileItem[]> {
const qs = category ? `?category=${encodeURIComponent(category)}` : '';
const views = await apiFetch<FileView[]>(`/api/files${qs}`);
return views.map(toFileItem);
}
export function downloadUrl(fileId: string): string {
return `/api/files/${fileId}`;
}
export async function deleteFile(fileId: string): Promise<void> {
await apiFetch<void>(`/api/files/${fileId}`, { method: 'DELETE' });
}
/**
* Upload réel via XMLHttpRequest (pas `fetch`) : c'est la seule API du
* navigateur qui expose un vrai événement de progression sur le corps
* envoyé (`upload.onprogress`) - `fetch` n'a pas d'équivalent côté
* requête sortante.
*/
export function uploadFile(
file: File,
category: 'file' | 'photo' | 'backup',
onProgress: (percent: number) => void,
): Promise<FileItem> {
return new Promise((resolve, reject) => {
const form = new FormData();
form.append('category', category);
form.append('file', file);
const xhr = new XMLHttpRequest();
xhr.open('POST', '/api/files');
xhr.withCredentials = true;
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) onProgress(Math.round((e.loaded / e.total) * 100));
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
const view = JSON.parse(xhr.responseText) as FileView;
resolve(toFileItem(view, 0));
} else {
let message = xhr.statusText;
try {
message = (JSON.parse(xhr.responseText) as { error?: string }).error ?? message;
} catch {
// pas de corps JSON exploitable
}
reject(new Error(message));
}
};
xhr.onerror = () => reject(new Error('Échec réseau pendant le transfert'));
xhr.send(form);
});
} }
export async function getDashboardStats(): Promise<StatItem[]> { export async function getDashboardStats(): Promise<StatItem[]> {
return mockFetch([ const [files, storage] = await Promise.all([
{ id: 'stat_storage', label: 'Espace utilisé', value: '812 Go', note: '188 Go disponibles', icon: 'hard-drive', tone: 'brand' }, apiFetch<FileView[]>('/api/files'),
{ id: 'stat_docs', label: 'Documents', value: '12 480', note: '+142 cette semaine', icon: 'image', tone: 'brand' }, apiFetch<StorageView>('/api/storage'),
{ id: 'stat_devices', label: 'Postes', value: '4', note: 'tous scellés', icon: 'smartphone', tone: 'success' },
{ id: 'stat_access', label: 'Accès actifs', value: '7', note: '3 liens expirent bientôt', icon: 'share', tone: 'violet' },
]); ]);
return [
{
id: 'stat_storage',
label: 'Espace utilisé',
value: formatBytes(storage.used_bytes),
note: `${formatBytes(storage.available_bytes)} disponibles sur le serveur`,
icon: 'hard-drive',
tone: 'brand',
},
{
id: 'stat_docs',
label: 'Fichiers',
value: String(files.length),
note: files.length > 0 ? `dernier dépôt ${formatRelativeTime(files[0]!.created_at)}` : 'aucun dépôt',
icon: 'image',
tone: 'success',
},
];
} }

View file

@ -1,13 +1,33 @@
import type { NotificationItem } from '../types'; import { formatRelativeTime } from '../lib/format';
import { mockFetch } from './client'; import type { IconName, NotificationItem } from '../types';
import { apiFetch } from './client';
const RAW: Array<Omit<NotificationItem, 'id'>> = [ interface NotificationView {
{ icon: 'upload', title: 'Transfert terminé', body: 'Archives_fiscales_2026.zip a été chiffré et scellé.', time: 'il y a 12 min', unread: true }, id: string;
{ icon: 'share', title: 'Nouveau dépôt', body: '42 documents ont été ajoutés au coffre Archives.', time: 'il y a 3 h', unread: true }, kind: string;
{ icon: 'check', title: 'Sauvegarde iPhone', body: '64 Go synchronisés et vérifiés, sans erreur.', time: 'hier, 23:40', unread: false }, title: string;
{ icon: 'alert', title: 'Espace disque', body: "Il reste 18 % d'espace libre sur le volume principal.", time: 'il y a 2 jours', unread: false }, body: string;
]; created_at: string;
unread: boolean;
}
const ICON_BY_KIND: Record<string, IconName> = {
upload: 'upload',
delete: 'alert',
};
export async function getNotifications(): Promise<NotificationItem[]> { export async function getNotifications(): Promise<NotificationItem[]> {
return mockFetch(RAW.map((n, i) => ({ ...n, id: `notif_${i}` }))); const views = await apiFetch<NotificationView[]>('/api/notifications');
return views.map((v) => ({
id: v.id,
icon: ICON_BY_KIND[v.kind] ?? 'check',
title: v.title,
body: v.body,
time: formatRelativeTime(v.created_at),
unread: v.unread,
}));
}
export async function markAllNotificationsRead(): Promise<void> {
await apiFetch<void>('/api/notifications/read-all', { method: 'POST' });
} }

View file

@ -1,28 +1,24 @@
import { THUMB_TONES, tone } from '../lib/gradients'; import { formatDateTime } from '../lib/format';
import type { Album, PhotoTile } from '../types'; import type { PhotoTile } from '../types';
import { mockFetch } from './client'; import { apiFetch } from './client';
const RAW_ALBUMS: Array<Omit<Album, 'id' | 'thumbGradient'>> = [ interface FileView {
{ name: 'Contrats', count: '412 éléments' }, id: string;
{ name: 'Titres de propriété', count: '318 éléments' }, filename: string;
{ name: 'Numérisations 1998', count: '219 éléments' }, created_at: string;
{ name: "Pièces d'identité", count: '1 204 éléments' },
];
export async function getAlbums(): Promise<Album[]> {
return mockFetch(
RAW_ALBUMS.map((a, i) => ({ ...a, id: `album_${i}`, thumbGradient: THUMB_TONES[i % THUMB_TONES.length]! })),
);
} }
const TILE_TONES = ['#3b4a63', '#4a3f5f', '#51413a', '#33505a', '#3d4a41', '#46384f']; /**
* Pas de concept d'album : ce serait simuler une organisation
* (personnes, lieux) qu'aucune fonctionnalité réelle ne produit
* aujourd'hui. La vue Photos est une vraie grille des fichiers
* catégorie "photo", triés par date de dépôt.
*/
export async function getPhotoTiles(): Promise<PhotoTile[]> { export async function getPhotoTiles(): Promise<PhotoTile[]> {
return mockFetch( const views = await apiFetch<FileView[]>('/api/files?category=photo');
Array.from({ length: 12 }, (_, i) => ({ return views.map((v) => ({
id: `tile_${i}`, id: v.id,
label: `16 sept. · ${9 + i}:0${i % 6}`, label: formatDateTime(v.created_at),
thumbGradient: tone(TILE_TONES[i % TILE_TONES.length]!, '#141821'), url: `/api/files/${v.id}`,
})), }));
);
} }

View file

@ -1,27 +1,20 @@
import type { NotificationPrefKey, SettingsValueRow } from '../types'; import { formatDateTime } from '../lib/format';
import { mockFetch } from './client'; import type { NotificationPrefKey, SettingsValueRow, User } from '../types';
export interface StaticSettingsRows { const PREFS_STORAGE_KEY = 'tripoli-cloud:notification-prefs';
language: SettingsValueRow[];
account: SettingsValueRow[];
}
export async function getStaticSettingsRows(): Promise<StaticSettingsRows> { /**
return mockFetch({ * Section "Compte" construite à partir du vrai utilisateur courant.
language: [ * Les champs fictifs de la maquette d'origine (2FA, clé de récupération,
{ kind: 'value', id: 'lang', label: "Langue de l'interface", help: 'S’applique à tous les postes connectés à ce coffre.', value: 'Français' }, * accès partagés) n'ont aucune fonctionnalité réelle derrière et ont
{ kind: 'value', id: 'region', label: 'Région', help: 'Détermine le format des dates, nombres et unités.', value: 'Tunisie' }, * été retirés plutôt que simulés.
{ kind: 'value', id: 'tz', label: 'Fuseau horaire', help: 'Utilisé pour l’horodatage des sauvegardes.', value: 'GMT+1 · Tunis' }, */
{ kind: 'value', id: 'week-start', label: 'Premier jour de la semaine', help: 'Affichage des calendriers et des vues par date.', value: 'Lundi' }, export function accountRows(user: User): SettingsValueRow[] {
], return [
account: [ { kind: 'value', id: 'display-name', label: 'Nom affiché', help: 'Visible sur ce compte.', value: user.name || '—' },
{ kind: 'value', id: 'display-name', label: 'Nom affiché', help: 'Visible par les personnes autorisées sur ce coffre.', value: 'Leïla Barghouti' }, { kind: 'value', id: 'email', label: 'Adresse e-mail', help: "Sert d'identifiant de connexion.", value: user.email || '—' },
{ kind: 'value', id: 'email', label: 'Adresse e-mail', help: 'Sert d’identifiant de connexion.', value: 'leila@domaine.tn' }, { kind: 'value', id: 'type', label: 'Type de compte', help: '', value: user.isGuest ? 'Invité (démonstration)' : 'Compte personnel' },
{ kind: 'value', id: '2fa', label: 'Double authentification', help: 'Application d’authentification requise à chaque connexion.', value: 'Activée' }, ];
{ kind: 'value', id: 'access', label: 'Accès autorisés', help: 'Quatre personnes disposent d’une clé de déverrouillage.', value: '4 accès' },
{ kind: 'value', id: 'recovery-key', label: 'Clé de récupération', help: 'À conserver hors ligne — elle ne peut pas être régénérée.', value: 'Téléchargée' },
],
});
} }
export const NOTIFICATION_PREF_LABELS: Record<NotificationPrefKey, { label: string; help: string }> = { export const NOTIFICATION_PREF_LABELS: Record<NotificationPrefKey, { label: string; help: string }> = {
@ -31,6 +24,38 @@ export const NOTIFICATION_PREF_LABELS: Record<NotificationPrefKey, { label: stri
shared: { label: 'Activité des partages', help: 'Quand un accès autorisé dépose ou retire un document.' }, shared: { label: 'Activité des partages', help: 'Quand un accès autorisé dépose ou retire un document.' },
}; };
export async function getDefaultNotificationPrefs(): Promise<Record<NotificationPrefKey, boolean>> { const DEFAULT_PREFS: Record<NotificationPrefKey, boolean> = {
return mockFetch({ push: true, email: false, weekly: true, shared: true }); push: true,
email: false,
weekly: true,
shared: true,
};
/**
* Préférences persistées dans le navigateur (localStorage), pas sur le
* serveur : réellement sauvegardées (elles survivent à un rechargement),
* mais propres à cet appareil - pas de synchronisation multi-appareils,
* ce serait mentir sur une fonctionnalité qui n'existe pas.
*/
export function getNotificationPrefs(): Record<NotificationPrefKey, boolean> {
try {
const raw = localStorage.getItem(PREFS_STORAGE_KEY);
if (!raw) return DEFAULT_PREFS;
return { ...DEFAULT_PREFS, ...(JSON.parse(raw) as Partial<Record<NotificationPrefKey, boolean>>) };
} catch {
return DEFAULT_PREFS;
}
}
export function setNotificationPrefs(prefs: Record<NotificationPrefKey, boolean>): void {
try {
localStorage.setItem(PREFS_STORAGE_KEY, JSON.stringify(prefs));
} catch {
// stockage indisponible (navigation privée, quota) - la préférence
// reste simplement en mémoire pour cette session
}
}
export function formatMemberSince(isoDate: string): string {
return `Membre depuis le ${formatDateTime(isoDate)}`;
} }

View file

@ -1,11 +1,25 @@
import { mockFetch } from './client'; import { formatBytes } from '../lib/format';
import { apiFetch } from './client';
interface StorageView {
used_bytes: number;
available_bytes: number;
}
export interface StorageSummary { export interface StorageSummary {
label: string; label: string;
percent: number; percent: number;
sealedNote: string;
} }
/** Pas de quota fictif du type "1 To" : le total affiché est l'espace
* réellement disponible sur le disque du serveur (`statvfs`), pas un
* chiffre inventé. */
export async function getStorageSummary(): Promise<StorageSummary> { export async function getStorageSummary(): Promise<StorageSummary> {
return mockFetch({ label: '812 Go / 1 To', percent: 81, sealedNote: 'Coffre scellé il y a 2 h' }); const view = await apiFetch<StorageView>('/api/storage');
const total = view.used_bytes + view.available_bytes;
const percent = total > 0 ? Math.round((view.used_bytes / total) * 100) : 0;
return {
label: `${formatBytes(view.used_bytes)} / ${formatBytes(total)}`,
percent,
};
} }

View file

@ -2,12 +2,6 @@ import { AuthBackground } from './AuthBackground';
import { AuthPanel } from './AuthPanel'; import { AuthPanel } from './AuthPanel';
import { TripoliLogo, TripoliWordmark } from '../layout/TripoliLogo'; import { TripoliLogo, TripoliWordmark } from '../layout/TripoliLogo';
const AUTH_STATS = [
{ value: '12 480', label: 'documents indexés' },
{ value: '4', label: 'postes sauvegardés' },
{ value: '100 %', label: 'chiffré localement' },
];
export function AuthLayout() { export function AuthLayout() {
return ( return (
<div className="relative min-h-screen overflow-hidden bg-[#07080c]"> <div className="relative min-h-screen overflow-hidden bg-[#07080c]">
@ -31,17 +25,8 @@ export function AuthLayout() {
sous clé. sous clé.
</h1> </h1>
<p className="m-0 max-w-[44ch] text-[15px] leading-relaxed text-white/62 sm:text-[17.5px]"> <p className="m-0 max-w-[44ch] text-[15px] leading-relaxed text-white/62 sm:text-[17.5px]">
Documents sensibles, archives et sauvegardes dans un coffre-fort chiffré de bout en bout. Vous seul Documents, photos et sauvegardes sur un serveur que vous contrôlez.
détenez la clé.
</p> </p>
<div className="mt-[38px] flex flex-wrap gap-7">
{AUTH_STATS.map((s) => (
<div key={s.label}>
<p className="m-0 mb-[3px] text-[22px] font-bold tracking-tight text-white">{s.value}</p>
<p className="m-0 text-[12.5px] text-white/50">{s.label}</p>
</div>
))}
</div>
</div> </div>
<AuthPanel /> <AuthPanel />
@ -49,7 +34,7 @@ export function AuthLayout() {
<div className="flex flex-wrap gap-5 text-xs text-white/34"> <div className="flex flex-wrap gap-5 text-xs text-white/34">
<span>Tripoli Cloud — 2026</span> <span>Tripoli Cloud — 2026</span>
<span>Hébergé sur votre propre matériel</span> <span>Hébergé sur votre propre serveur</span>
<span>Sans publicité, sans traçage</span> <span>Sans publicité, sans traçage</span>
</div> </div>
</div> </div>

View file

@ -16,6 +16,7 @@ export function AuthPanel() {
handleSubmit, handleSubmit,
continueAsGuest, continueAsGuest,
loading, loading,
error,
} = useAuthForm(); } = useAuthForm();
const isSignup = mode === 'signup'; const isSignup = mode === 'signup';
@ -29,7 +30,7 @@ export function AuthPanel() {
{isSignup ? 'Créer votre espace' : 'Bon retour'} {isSignup ? 'Créer votre espace' : 'Bon retour'}
</h2> </h2>
<p className="m-0 mb-6 text-[13.5px] text-white/55"> <p className="m-0 mb-6 text-[13.5px] text-white/55">
{isSignup ? 'Un coffre-fort chiffré, une seule clé : la vôtre.' : 'Déverrouillez votre coffre-fort.'} {isSignup ? 'Créez votre compte en quelques secondes.' : 'Déverrouillez votre coffre-fort.'}
</p> </p>
<div className="mb-6 flex gap-1 rounded-xl border border-white/8 bg-black/28 p-1"> <div className="mb-6 flex gap-1 rounded-xl border border-white/8 bg-black/28 p-1">
@ -54,6 +55,13 @@ export function AuthPanel() {
</div> </div>
<form onSubmit={handleSubmit} className="flex flex-col gap-4"> <form onSubmit={handleSubmit} className="flex flex-col gap-4">
{error && (
<div className="flex items-center gap-2 rounded-xl border border-danger/40 bg-danger/10 px-3.5 py-3 text-[13px] font-semibold text-danger">
<AlertCircle size={15} />
{error}
</div>
)}
{isSignup && ( {isSignup && (
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<label htmlFor="tc-name" className="text-[12.5px] font-semibold text-white/72"> <label htmlFor="tc-name" className="text-[12.5px] font-semibold text-white/72">
@ -155,7 +163,7 @@ export function AuthPanel() {
<div className="mt-1.5 flex items-center gap-2.5 border-t border-white/10 pt-4 text-xs text-white/48"> <div className="mt-1.5 flex items-center gap-2.5 border-t border-white/10 pt-4 text-xs text-white/48">
<ShieldCheck size={14} className="text-success" strokeWidth={2} /> <ShieldCheck size={14} className="text-success" strokeWidth={2} />
Chiffrement AES-256 côté client — aucune donnée en clair sur le serveur. Connexion chiffrée (HTTPS), mots de passe hachés (Argon2) — jamais stockés en clair.
</div> </div>
</form> </form>
</div> </div>

View file

@ -1,31 +1,16 @@
import type { BackupDevice } from '../../types'; import type { BackupItem } from '../../types';
import { DataIcon } from '../icons/DataIcon'; import { DataIcon } from '../icons/DataIcon';
const STATUS_CLASSES: Record<BackupDevice['status'], string> = { export function BackupRow({ backup }: { backup: BackupItem }) {
'up-to-date': 'bg-success/16 text-success',
syncing: 'bg-violet/16 text-violet',
late: 'bg-danger/16 text-danger',
};
const ICON_TONE: Record<BackupDevice['status'], string> = {
'up-to-date': 'bg-selected text-brand-soft',
syncing: 'bg-violet/16 text-violet',
late: 'bg-danger/16 text-danger',
};
export function BackupRow({ backup }: { backup: BackupDevice }) {
return ( return (
<div className="flex flex-wrap items-center gap-4 rounded-2xl border border-line bg-panel p-[18px]"> <div className="flex flex-wrap items-center gap-4 rounded-2xl border border-line bg-panel p-[18px]">
<span className={`flex h-10 w-10 flex-none items-center justify-center rounded-xl ${ICON_TONE[backup.status]}`}> <span className="flex h-10 w-10 flex-none items-center justify-center rounded-xl bg-selected text-brand-soft">
<DataIcon name={backup.icon} size={19} /> <DataIcon name={backup.icon} size={19} />
</span> </span>
<div className="min-w-0 flex-1 basis-[200px]"> <div className="min-w-0 flex-1 basis-[200px]">
<p className="m-0 mb-[3px] text-[14.5px] font-bold text-ink">{backup.device}</p> <p className="m-0 mb-[3px] text-[14.5px] font-bold text-ink">{backup.name}</p>
<p className="m-0 text-[12.5px] text-ink-soft">{backup.detail}</p> <p className="m-0 text-[12.5px] text-ink-soft">{backup.detail}</p>
</div> </div>
<span className={`flex-none rounded-full px-3 py-1.5 text-[11.5px] font-bold ${STATUS_CLASSES[backup.status]}`}>
{backup.statusLabel}
</span>
</div> </div>
); );
} }

View file

@ -1,20 +1,44 @@
import { useBackups } from '../../hooks/useBackups'; import { useBackups } from '../../hooks/useBackups';
import { useFileUpload } from '../../hooks/useFileUpload';
import { UploadZone } from '../dashboard/UploadZone';
import { BackupRow } from './BackupRow'; import { BackupRow } from './BackupRow';
export function BackupsView() { export function BackupsView() {
const backups = useBackups(); const { backups, refresh } = useBackups();
const upload = useFileUpload('backup', refresh);
return ( return (
<div className="flex max-w-[960px] flex-col gap-6"> <div className="flex max-w-[960px] flex-col gap-6">
<div> <div>
<p className="m-0 mb-[7px] text-[12.5px] font-semibold text-ink-soft">4 appareils protégés</p> <p className="m-0 mb-[7px] text-[12.5px] font-semibold text-ink-soft">
{backups.length} sauvegarde{backups.length === 1 ? '' : 's'} déposée{backups.length === 1 ? '' : 's'}
</p>
<h1 className="m-0 text-[26px] font-extrabold leading-tight tracking-tight sm:text-[36px]">Sauvegardes</h1> <h1 className="m-0 text-[26px] font-extrabold leading-tight tracking-tight sm:text-[36px]">Sauvegardes</h1>
</div> </div>
<div className="flex flex-col gap-3">
{backups.map((b) => ( <UploadZone
<BackupRow key={b.id} backup={b} /> uploading={upload.uploading}
))} progress={upload.progress}
</div> fileName={upload.fileName}
dragging={upload.dragging}
error={upload.error}
inputRef={upload.inputRef}
onPick={upload.pickFiles}
onInputChange={upload.onInputChange}
onDragOver={upload.onDragOver}
onDragLeave={upload.onDragLeave}
onDrop={upload.onDrop}
/>
{backups.length === 0 ? (
<p className="m-0 text-[13px] text-ink-soft">Aucune sauvegarde pour l'instant.</p>
) : (
<div className="flex flex-col gap-3">
{backups.map((b) => (
<BackupRow key={b.id} backup={b} />
))}
</div>
)}
</div> </div>
); );
} }

View file

@ -1,46 +1,24 @@
import { Plus } from 'lucide-react'; import { Plus, Upload } from 'lucide-react';
import { useNavigation } from '../../contexts/NavigationContext';
import { DataIcon } from '../icons/DataIcon';
import type { IconName } from '../../types';
const OPTIONS: Array<{ icon: IconName; label: string }> = [ interface CreateMenuProps {
{ icon: 'folder-plus', label: 'Nouveau dossier' }, onImport: () => void;
{ icon: 'file-pen', label: 'Nouveau document' }, }
{ icon: 'upload', label: 'Importer des fichiers' },
];
export function CreateMenu() {
const { createOpen, toggleCreate, closeCreate } = useNavigation();
/**
* Un seul vrai bouton : "Nouveau dossier"/"Nouveau document" de la
* maquette d'origine n'ont aucune fonctionnalité derrière (pas de
* dossiers, pas d'éditeur de document) - retirés plutôt que simulés.
*/
export function CreateMenu({ onImport }: CreateMenuProps) {
return ( return (
<div className="relative"> <button
<button type="button"
type="button" onClick={onImport}
onClick={toggleCreate} className="flex items-center gap-2 rounded-xl bg-brand-gradient px-[17px] py-3 text-[13.5px] font-bold text-white shadow-[0_10px_26px_rgba(91,140,255,0.3)] transition-[filter] hover:brightness-110"
aria-expanded={createOpen} >
className="flex items-center gap-2 rounded-xl bg-brand-gradient px-[17px] py-3 text-[13.5px] font-bold text-white shadow-[0_10px_26px_rgba(91,140,255,0.3)] transition-[filter] hover:brightness-110" <Plus size={16} strokeWidth={2.2} />
> <Upload size={16} strokeWidth={2.2} />
<Plus size={16} strokeWidth={2.2} /> Importer
Créer </button>
</button>
{createOpen && (
<div className="animate-tc-in absolute right-0 top-[50px] z-50 w-[232px] rounded-2xl border border-line bg-panel2 p-1.5 shadow-tc backdrop-blur-xl">
{OPTIONS.map((opt) => (
<button
key={opt.label}
type="button"
onClick={closeCreate}
className="flex w-full items-center gap-2.5 rounded-[10px] px-3 py-2.5 text-left text-[13.5px] font-semibold text-ink hover:bg-field"
>
<span className="flex h-7 w-7 items-center justify-center rounded-[9px] bg-selected text-brand-soft">
<DataIcon name={opt.icon} size={15} />
</span>
{opt.label}
</button>
))}
</div>
)}
</div>
); );
} }

View file

@ -1,27 +1,35 @@
import { useRef } from 'react';
import { useDashboardStats } from '../../hooks/useDashboardStats'; import { useDashboardStats } from '../../hooks/useDashboardStats';
import { useFileUpload } from '../../hooks/useFileUpload';
import { CreateMenu } from './CreateMenu'; import { CreateMenu } from './CreateMenu';
import { RecentFiles } from './RecentFiles'; import { RecentFiles, type RecentFilesHandle } from './RecentFiles';
import { StatCard } from './StatCard'; import { StatCard } from './StatCard';
import { UploadZone } from './UploadZone'; import { UploadZone } from './UploadZone';
interface DashboardViewProps { const TODAY = new Date().toLocaleDateString('fr-FR', {
/** Forwarded to UploadZone — undefined lets it run its own demo transfer. */ weekday: 'long',
uploadProgress?: number; day: 'numeric',
} month: 'long',
});
export function DashboardView({ uploadProgress }: DashboardViewProps) { export function DashboardView() {
const stats = useDashboardStats(); const { stats, refresh: refreshStats } = useDashboardStats();
const filesRef = useRef<RecentFilesHandle>(null);
const upload = useFileUpload('file', () => {
filesRef.current?.refresh();
refreshStats();
});
return ( return (
<div className="flex max-w-[1240px] flex-col gap-6"> <div className="flex max-w-[1240px] flex-col gap-6">
<div className="flex flex-wrap items-end justify-between gap-4"> <div className="flex flex-wrap items-end justify-between gap-4">
<div> <div>
<p className="m-0 mb-[7px] text-[12.5px] font-semibold text-ink-soft">Mercredi 16 septembre</p> <p className="m-0 mb-[7px] text-[12.5px] font-semibold capitalize text-ink-soft">{TODAY}</p>
<h1 className="m-0 text-[26px] font-extrabold leading-tight tracking-tight sm:text-[36px]"> <h1 className="m-0 text-[26px] font-extrabold leading-tight tracking-tight sm:text-[36px]">
Coffre déverrouillé. Coffre déverrouillé.
</h1> </h1>
</div> </div>
<CreateMenu /> <CreateMenu onImport={upload.pickFiles} />
</div> </div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(196px,1fr))] gap-3.5"> <div className="grid grid-cols-[repeat(auto-fit,minmax(196px,1fr))] gap-3.5">
@ -30,9 +38,21 @@ export function DashboardView({ uploadProgress }: DashboardViewProps) {
))} ))}
</div> </div>
<UploadZone progress={uploadProgress} /> <UploadZone
uploading={upload.uploading}
progress={upload.progress}
fileName={upload.fileName}
dragging={upload.dragging}
error={upload.error}
inputRef={upload.inputRef}
onPick={upload.pickFiles}
onInputChange={upload.onInputChange}
onDragOver={upload.onDragOver}
onDragLeave={upload.onDragLeave}
onDrop={upload.onDrop}
/>
<RecentFiles /> <RecentFiles ref={filesRef} onChange={refreshStats} />
</div> </div>
); );
} }

View file

@ -1,12 +1,39 @@
import { Trash2 } from 'lucide-react';
import { downloadUrl } from '../../api/files';
import type { FileItem } from '../../types'; import type { FileItem } from '../../types';
export function FileGridItem({ file }: { file: FileItem }) { interface FileGridItemProps {
file: FileItem;
onDelete: (id: string) => void;
}
export function FileGridItem({ file, onDelete }: FileGridItemProps) {
return ( return (
<div className="flex flex-col gap-3 rounded-2xl border border-line bg-field p-3.5 transition-colors hover:bg-panel2"> <a
href={downloadUrl(file.id)}
target="_blank"
rel="noreferrer"
className="group flex flex-col gap-3 rounded-2xl border border-line bg-field p-3.5 transition-colors hover:bg-panel2"
>
<div className="relative h-[88px] overflow-hidden rounded-xl" style={{ background: file.thumbGradient }}> <div className="relative h-[88px] overflow-hidden rounded-xl" style={{ background: file.thumbGradient }}>
{file.kind === 'image' && (
<img src={downloadUrl(file.id)} alt="" className="h-full w-full object-cover" />
)}
<span className="absolute left-2 top-2 rounded-[7px] bg-black/55 px-2 py-1 text-[10px] font-bold uppercase tracking-wide text-white backdrop-blur-sm"> <span className="absolute left-2 top-2 rounded-[7px] bg-black/55 px-2 py-1 text-[10px] font-bold uppercase tracking-wide text-white backdrop-blur-sm">
{file.extLabel} {file.extLabel}
</span> </span>
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onDelete(file.id);
}}
aria-label={`Supprimer ${file.name}`}
className="absolute right-2 top-2 flex h-7 w-7 items-center justify-center rounded-lg bg-black/55 text-white opacity-0 backdrop-blur-sm transition-opacity group-hover:opacity-100"
>
<Trash2 size={14} strokeWidth={2} />
</button>
</div> </div>
<div> <div>
<p className="m-0 mb-1 break-words text-[13.5px] font-semibold leading-snug text-ink">{file.name}</p> <p className="m-0 mb-1 break-words text-[13.5px] font-semibold leading-snug text-ink">{file.name}</p>
@ -14,6 +41,6 @@ export function FileGridItem({ file }: { file: FileItem }) {
{file.size} · {file.modified} {file.size} · {file.modified}
</p> </p>
</div> </div>
</div> </a>
); );
} }

View file

@ -1,15 +1,43 @@
import { Trash2 } from 'lucide-react';
import { downloadUrl } from '../../api/files';
import type { FileItem } from '../../types'; import type { FileItem } from '../../types';
export function FileListRow({ file }: { file: FileItem }) { interface FileListRowProps {
file: FileItem;
onDelete: (id: string) => void;
}
export function FileListRow({ file, onDelete }: FileListRowProps) {
return ( return (
<div className="flex items-center gap-4 border-b border-line px-4 py-3 transition-colors hover:bg-field sm:px-[18px]"> <a
<span className="h-[34px] w-[34px] flex-none rounded-[10px]" style={{ background: file.thumbGradient }} /> href={downloadUrl(file.id)}
target="_blank"
rel="noreferrer"
className="group flex items-center gap-4 border-b border-line px-4 py-3 transition-colors hover:bg-field sm:px-[18px]"
>
<span className="h-[34px] w-[34px] flex-none overflow-hidden rounded-[10px]" style={{ background: file.thumbGradient }}>
{file.kind === 'image' && (
<img src={downloadUrl(file.id)} alt="" className="h-full w-full object-cover" />
)}
</span>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="m-0 truncate text-[13.5px] font-semibold text-ink">{file.name}</p> <p className="m-0 truncate text-[13.5px] font-semibold text-ink">{file.name}</p>
<p className="m-0 mt-0.5 text-[11.5px] text-ink-soft">{file.extLabel}</p> <p className="m-0 mt-0.5 text-[11.5px] text-ink-soft">{file.extLabel}</p>
</div> </div>
<span className="w-[90px] flex-none text-[12.5px] text-ink-soft">{file.size}</span> <span className="w-[90px] flex-none text-[12.5px] text-ink-soft">{file.size}</span>
<span className="w-[120px] flex-none text-[12.5px] text-ink-soft">{file.modified}</span> <span className="w-[120px] flex-none text-[12.5px] text-ink-soft">{file.modified}</span>
</div> <button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onDelete(file.id);
}}
aria-label={`Supprimer ${file.name}`}
className="flex h-7 w-7 flex-none items-center justify-center rounded-lg text-ink-soft opacity-0 transition-opacity hover:text-danger group-hover:opacity-100"
>
<Trash2 size={14} strokeWidth={2} />
</button>
</a>
); );
} }

View file

@ -1,10 +1,30 @@
import { ArrowUpDown, Grid2x2, List } from 'lucide-react'; import { ArrowUpDown, Grid2x2, List } from 'lucide-react';
import { forwardRef, useImperativeHandle } from 'react';
import { useRecentFiles } from '../../hooks/useRecentFiles'; import { useRecentFiles } from '../../hooks/useRecentFiles';
import { FileGridItem } from './FileGridItem'; import { FileGridItem } from './FileGridItem';
import { FileListRow } from './FileListRow'; import { FileListRow } from './FileListRow';
export function RecentFiles() { export interface RecentFilesHandle {
const { files, filters, filter, setFilter, sortDesc, toggleSort, gridView, setGridView } = useRecentFiles(); refresh: () => void;
}
interface RecentFilesProps {
/** Notifié après une suppression réussie, pour que le tableau de bord puisse rafraîchir ses stats. */
onChange?: () => void;
}
export const RecentFiles = forwardRef<RecentFilesHandle, RecentFilesProps>(function RecentFiles(
{ onChange },
ref,
) {
const { files, filters, filter, setFilter, sortDesc, toggleSort, gridView, setGridView, refresh, removeFile } =
useRecentFiles();
useImperativeHandle(ref, () => ({ refresh }), [refresh]);
const handleDelete = (id: string) => {
void removeFile(id).then(() => onChange?.());
};
return ( return (
<div className="overflow-hidden rounded-[18px] border border-line bg-panel backdrop-blur-md"> <div className="overflow-hidden rounded-[18px] border border-line bg-panel backdrop-blur-md">
@ -61,10 +81,12 @@ export function RecentFiles() {
</div> </div>
</div> </div>
{gridView ? ( {files.length === 0 ? (
<p className="m-0 p-[18px] text-[13px] text-ink-soft">Aucun fichier pour l'instant.</p>
) : gridView ? (
<div className="grid grid-cols-[repeat(auto-fill,minmax(188px,1fr))] gap-3.5 p-[18px]"> <div className="grid grid-cols-[repeat(auto-fill,minmax(188px,1fr))] gap-3.5 p-[18px]">
{files.map((file) => ( {files.map((file) => (
<FileGridItem key={file.id} file={file} /> <FileGridItem key={file.id} file={file} onDelete={handleDelete} />
))} ))}
</div> </div>
) : ( ) : (
@ -75,10 +97,10 @@ export function RecentFiles() {
<span className="w-[120px] flex-none">Modifié</span> <span className="w-[120px] flex-none">Modifié</span>
</div> </div>
{files.map((file) => ( {files.map((file) => (
<FileListRow key={file.id} file={file} /> <FileListRow key={file.id} file={file} onDelete={handleDelete} />
))} ))}
</div> </div>
)} )}
</div> </div>
); );
} });

View file

@ -1,26 +1,47 @@
import { Upload } from 'lucide-react'; import { Upload } from 'lucide-react';
import { useUploadSimulation } from '../../hooks/useUploadSimulation'; import type { ChangeEvent, DragEvent } from 'react';
import type { UploadTask } from '../../types';
interface UploadZoneProps { interface UploadZoneProps {
/** Piloted externally once real transfers exist; undefined = simulate locally. */ uploading: boolean;
progress?: UploadTask['progress']; progress: number;
fileName: string;
dragging: boolean;
error: string | null;
inputRef: React.RefObject<HTMLInputElement | null>;
onPick: () => void;
onInputChange: (e: ChangeEvent<HTMLInputElement>) => void;
onDragOver: (e: DragEvent) => void;
onDragLeave: () => void;
onDrop: (e: DragEvent) => void;
accept?: string;
} }
export function UploadZone({ progress: externalProgress }: UploadZoneProps) { export function UploadZone({
const { uploading, progress, dragging, start, onDragOver, onDragLeave, onDrop } = uploading,
useUploadSimulation(externalProgress); progress,
fileName,
dragging,
error,
inputRef,
onPick,
onInputChange,
onDragOver,
onDragLeave,
onDrop,
accept,
}: UploadZoneProps) {
return ( return (
<div <div
onDragOver={onDragOver} onDragOver={onDragOver}
onDragLeave={onDragLeave} onDragLeave={onDragLeave}
onDrop={onDrop} onDrop={onDrop}
onClick={start} onClick={onPick}
className={`cursor-pointer rounded-[18px] border-[1.5px] border-dashed p-6 transition-colors sm:p-[34px] ${ className={`cursor-pointer rounded-[18px] border-[1.5px] border-dashed p-6 transition-colors sm:p-[34px] ${
dragging ? 'border-brand bg-selected' : 'border-line bg-panel' dragging ? 'border-brand bg-selected' : 'border-line bg-panel'
}`} }`}
> >
<input ref={inputRef} type="file" accept={accept} className="hidden" onChange={onInputChange} />
<div className="flex flex-wrap items-center gap-[18px]"> <div className="flex flex-wrap items-center gap-[18px]">
<span className="flex h-[52px] w-[52px] flex-none items-center justify-center rounded-2xl border border-brand/30 bg-selected text-brand-soft"> <span className="flex h-[52px] w-[52px] flex-none items-center justify-center rounded-2xl border border-brand/30 bg-selected text-brand-soft">
<Upload size={22} strokeWidth={2} /> <Upload size={22} strokeWidth={2} />
@ -29,9 +50,7 @@ export function UploadZone({ progress: externalProgress }: UploadZoneProps) {
<p className="m-0 mb-[5px] text-[16.5px] font-bold tracking-tight text-ink"> <p className="m-0 mb-[5px] text-[16.5px] font-bold tracking-tight text-ink">
{dragging ? 'Relâchez pour importer' : 'Déposez vos fichiers ici'} {dragging ? 'Relâchez pour importer' : 'Déposez vos fichiers ici'}
</p> </p>
<p className="m-0 text-[13px] leading-relaxed text-ink-soft"> <p className="m-0 text-[13px] leading-relaxed text-ink-soft">Cliquez ou déposez un fichier à importer.</p>
JPEG, RAW, MP4, PDF — jusqu'à 5 Go par fichier. Cliquez pour simuler un transfert.
</p>
</div> </div>
<span className="flex-none rounded-[11px] border border-line bg-field px-4 py-2.5 text-[13px] font-semibold text-ink"> <span className="flex-none rounded-[11px] border border-line bg-field px-4 py-2.5 text-[13px] font-semibold text-ink">
Parcourir Parcourir
@ -41,7 +60,7 @@ export function UploadZone({ progress: externalProgress }: UploadZoneProps) {
{uploading && ( {uploading && (
<div className="mt-6 border-t border-line pt-5"> <div className="mt-6 border-t border-line pt-5">
<div className="mb-2.5 flex justify-between gap-3 text-[12.5px] font-semibold text-ink"> <div className="mb-2.5 flex justify-between gap-3 text-[12.5px] font-semibold text-ink">
<span>Archives_fiscales_2026.zip</span> <span>{fileName}</span>
<span className="text-brand-soft">{progress} %</span> <span className="text-brand-soft">{progress} %</span>
</div> </div>
<div className="h-2 overflow-hidden rounded-full bg-field"> <div className="h-2 overflow-hidden rounded-full bg-field">
@ -51,10 +70,16 @@ export function UploadZone({ progress: externalProgress }: UploadZoneProps) {
/> />
</div> </div>
<p className="m-0 mt-2.5 text-xs text-ink-soft"> <p className="m-0 mt-2.5 text-xs text-ink-soft">
{progress >= 100 ? 'Transfert terminé — fichier chiffré et vérifié.' : 'Chiffrement en cours côté client…'} {progress >= 100 ? 'Transfert terminé.' : 'Envoi en cours…'}
</p> </p>
</div> </div>
)} )}
{error && (
<p className="m-0 mt-4 text-[12.5px] font-semibold text-danger" onClick={(e) => e.stopPropagation()}>
{error}
</p>
)}
</div> </div>
); );
} }

View file

@ -71,7 +71,6 @@ export function TripoliSidebar() {
<div className="my-2.5 h-[7px] overflow-hidden rounded-full bg-field"> <div className="my-2.5 h-[7px] overflow-hidden rounded-full bg-field">
<div className="h-[7px] rounded-full bg-brand-gradient" style={{ width: `${summary?.percent ?? 0}%` }} /> <div className="h-[7px] rounded-full bg-brand-gradient" style={{ width: `${summary?.percent ?? 0}%` }} />
</div> </div>
<p className="m-0 text-[11.5px] leading-relaxed text-ink-soft">{summary?.sealedNote ?? ''}</p>
</div> </div>
<div className="mt-auto flex items-center gap-2.5 rounded-2xl border border-line bg-panel p-2.5"> <div className="mt-auto flex items-center gap-2.5 rounded-2xl border border-line bg-panel p-2.5">
@ -84,7 +83,7 @@ export function TripoliSidebar() {
</div> </div>
<button <button
type="button" type="button"
onClick={logout} onClick={() => void logout()}
aria-label="Se déconnecter" aria-label="Se déconnecter"
className="flex h-7 w-7 flex-none items-center justify-center rounded-lg text-ink-soft" className="flex h-7 w-7 flex-none items-center justify-center rounded-lg text-ink-soft"
> >

View file

@ -5,13 +5,16 @@ import { DataIcon } from '../icons/DataIcon';
export function NotificationCenter() { export function NotificationCenter() {
const { notifOpen, toggleNotif, closeNotif } = useNavigation(); const { notifOpen, toggleNotif, closeNotif } = useNavigation();
const { notifications, unreadCount, markAllRead } = useNotifications(); const { notifications, unreadCount, markAllRead, refresh } = useNotifications();
return ( return (
<div className="relative"> <div className="relative">
<button <button
type="button" type="button"
onClick={toggleNotif} onClick={() => {
refresh();
toggleNotif();
}}
aria-label="Notifications" aria-label="Notifications"
aria-expanded={notifOpen} aria-expanded={notifOpen}
className="relative flex h-[38px] w-[38px] items-center justify-center rounded-[11px] border border-line bg-field text-ink" className="relative flex h-[38px] w-[38px] items-center justify-center rounded-[11px] border border-line bg-field text-ink"

View file

@ -1,13 +0,0 @@
import type { Album } from '../../types';
export function AlbumCard({ album }: { album: Album }) {
return (
<div className="w-[200px] flex-none overflow-hidden rounded-2xl border border-line bg-panel">
<div className="h-[116px]" style={{ background: album.thumbGradient }} />
<div className="p-3.5">
<p className="m-0 mb-[3px] text-[13.5px] font-bold text-ink">{album.name}</p>
<p className="m-0 text-[11.5px] text-ink-soft">{album.count}</p>
</div>
</div>
);
}

View file

@ -2,13 +2,16 @@ import type { PhotoTile as PhotoTileData } from '../../types';
export function PhotoTile({ tile }: { tile: PhotoTileData }) { export function PhotoTile({ tile }: { tile: PhotoTileData }) {
return ( return (
<div <a
className="relative aspect-square cursor-pointer overflow-hidden rounded-[13px] border border-line transition-transform hover:scale-[1.025]" href={tile.url}
style={{ background: tile.thumbGradient }} target="_blank"
rel="noreferrer"
className="relative block aspect-square cursor-pointer overflow-hidden rounded-[13px] border border-line bg-field transition-transform hover:scale-[1.025]"
> >
<img src={tile.url} alt="" className="h-full w-full object-cover" loading="lazy" />
<span className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-[rgba(5,6,10,0.72)] to-transparent px-2.5 pb-2 pt-[18px] text-[11px] font-semibold text-white"> <span className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-[rgba(5,6,10,0.72)] to-transparent px-2.5 pb-2 pt-[18px] text-[11px] font-semibold text-white">
{tile.label} {tile.label}
</span> </span>
</div> </a>
); );
} }

View file

@ -1,50 +1,45 @@
import { usePhotos } from '../../hooks/usePhotos'; import { usePhotos } from '../../hooks/usePhotos';
import { AlbumCard } from './AlbumCard'; import { useFileUpload } from '../../hooks/useFileUpload';
import { UploadZone } from '../dashboard/UploadZone';
import { PhotoTile } from './PhotoTile'; import { PhotoTile } from './PhotoTile';
export function PhotosView() { export function PhotosView() {
const { albums, tiles, scopes, scope, setScope } = usePhotos(); const { tiles, refresh } = usePhotos();
const upload = useFileUpload('photo', refresh);
return ( return (
<div className="flex max-w-[1240px] flex-col gap-6"> <div className="flex max-w-[1240px] flex-col gap-6">
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
<p className="m-0 mb-[7px] text-[12.5px] font-semibold text-ink-soft">12 480 documents · 318 numérisations</p>
<h1 className="m-0 text-[26px] font-extrabold leading-tight tracking-tight sm:text-[36px]">Photos</h1>
</div>
<div className="flex gap-2">
{scopes.map((s) => {
const active = scope === s;
return (
<button
key={s}
type="button"
onClick={() => setScope(s)}
className={`rounded-full border px-[15px] py-2 text-[12.5px] font-semibold transition-colors ${
active ? 'border-brand/40 bg-selected text-brand-soft' : 'border-line bg-field text-ink-soft'
}`}
>
{s}
</button>
);
})}
</div>
</div>
<div className="flex gap-3.5 overflow-x-auto pb-1">
{albums.map((album) => (
<AlbumCard key={album.id} album={album} />
))}
</div>
<div> <div>
<h2 className="m-0 mb-3.5 text-sm font-bold tracking-tight text-ink">Septembre 2026</h2> <p className="m-0 mb-[7px] text-[12.5px] font-semibold text-ink-soft">
{tiles.length} photo{tiles.length === 1 ? '' : 's'}
</p>
<h1 className="m-0 text-[26px] font-extrabold leading-tight tracking-tight sm:text-[36px]">Photos</h1>
</div>
<UploadZone
uploading={upload.uploading}
progress={upload.progress}
fileName={upload.fileName}
dragging={upload.dragging}
error={upload.error}
inputRef={upload.inputRef}
onPick={upload.pickFiles}
onInputChange={upload.onInputChange}
onDragOver={upload.onDragOver}
onDragLeave={upload.onDragLeave}
onDrop={upload.onDrop}
accept="image/*"
/>
{tiles.length === 0 ? (
<p className="m-0 text-[13px] text-ink-soft">Aucune photo pour l'instant.</p>
) : (
<div className="grid grid-cols-[repeat(auto-fill,minmax(132px,1fr))] gap-2.5"> <div className="grid grid-cols-[repeat(auto-fill,minmax(132px,1fr))] gap-2.5">
{tiles.map((tile) => ( {tiles.map((tile) => (
<PhotoTile key={tile.id} tile={tile} /> <PhotoTile key={tile.id} tile={tile} />
))} ))}
</div> </div>
</div> )}
</div> </div>
); );
} }

View file

@ -35,20 +35,6 @@ export function SettingsView() {
{rows.map((row) => ( {rows.map((row) => (
<SettingsRowView key={row.id} row={row} onToggle={togglePref} /> <SettingsRowView key={row.id} row={row} onToggle={togglePref} />
))} ))}
<div className="flex flex-wrap gap-2.5 p-[18px]">
<button
type="button"
className="rounded-xl bg-brand-gradient px-[18px] py-3 text-[13.5px] font-bold text-white transition-[filter] hover:brightness-110"
>
Enregistrer
</button>
<button
type="button"
className="rounded-xl border border-line bg-field px-[18px] py-3 text-[13.5px] font-semibold text-ink"
>
Annuler
</button>
</div>
</div> </div>
</div> </div>
); );

View file

@ -26,7 +26,7 @@ const NavigationContext = createContext<NavigationContextValue | null>(null);
*/ */
export function NavigationProvider({ children }: { children: ReactNode }) { export function NavigationProvider({ children }: { children: ReactNode }) {
const [nav, setNav] = useState<NavKey>('dashboard'); const [nav, setNav] = useState<NavKey>('dashboard');
const [settingsTab, setSettingsTab] = useState<SettingsTabKey>('language'); const [settingsTab, setSettingsTab] = useState<SettingsTabKey>('account');
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const [notifOpen, setNotifOpen] = useState(false); const [notifOpen, setNotifOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);

View file

@ -1,55 +1,76 @@
import { createContext, useContext, useState, type ReactNode } from 'react'; import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
import { login as apiLogin, loginAsGuest, signup as apiSignup, type Credentials, type SignupPayload } from '../api/auth'; import {
fetchCurrentUser,
login as apiLogin,
loginAsGuest,
logout as apiLogout,
signup as apiSignup,
type Credentials,
type SignupPayload,
} from '../api/auth';
import type { AppView, User } from '../types'; import type { AppView, User } from '../types';
interface SessionContextValue { interface SessionContextValue {
view: AppView; view: AppView;
user: User | null; user: User | null;
/** true tant que la session n'a pas été restaurée depuis le cookie au premier chargement. */
restoring: boolean;
loading: boolean; loading: boolean;
error: string | null;
login: (credentials: Credentials) => Promise<void>; login: (credentials: Credentials) => Promise<void>;
signup: (payload: SignupPayload) => Promise<void>; signup: (payload: SignupPayload) => Promise<void>;
continueAsGuest: () => Promise<void>; continueAsGuest: () => Promise<void>;
logout: () => void; logout: () => Promise<void>;
} }
const SessionContext = createContext<SessionContextValue | null>(null); const SessionContext = createContext<SessionContextValue | null>(null);
export function SessionProvider({ children }: { children: ReactNode }) { export function SessionProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null); const [user, setUser] = useState<User | null>(null);
const [restoring, setRestoring] = useState(true);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const login = async (credentials: Credentials) => { useEffect(() => {
void fetchCurrentUser()
.then(setUser)
.finally(() => setRestoring(false));
}, []);
const run = async (action: () => Promise<User>) => {
setLoading(true); setLoading(true);
setError(null);
try { try {
setUser(await apiLogin(credentials)); setUser(await action());
} catch (err) {
setError(err instanceof Error ? err.message : 'Une erreur est survenue');
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
const signup = async (payload: SignupPayload) => { const login = (credentials: Credentials) => run(() => apiLogin(credentials));
setLoading(true); const signup = (payload: SignupPayload) => run(() => apiSignup(payload));
try { const continueAsGuest = () => run(() => loginAsGuest());
setUser(await apiSignup(payload));
} finally {
setLoading(false);
}
};
const continueAsGuest = async () => { const logout = async () => {
setLoading(true); await apiLogout();
try { setUser(null);
setUser(await loginAsGuest());
} finally {
setLoading(false);
}
}; };
const logout = () => setUser(null);
return ( return (
<SessionContext.Provider <SessionContext.Provider
value={{ view: user ? 'app' : 'auth', user, loading, login, signup, continueAsGuest, logout }} value={{
view: user ? 'app' : 'auth',
user,
restoring,
loading,
error,
login,
signup,
continueAsGuest,
logout,
}}
> >
{children} {children}
</SessionContext.Provider> </SessionContext.Provider>

View file

@ -4,7 +4,7 @@ import { isValidEmail, isValidPassword } from '../lib/validators';
import type { AuthMode } from '../types'; import type { AuthMode } from '../types';
export function useAuthForm() { export function useAuthForm() {
const { login, signup, continueAsGuest, loading } = useSession(); const { login, signup, continueAsGuest, loading, error } = useSession();
const [mode, setModeState] = useState<AuthMode>('login'); const [mode, setModeState] = useState<AuthMode>('login');
const [name, setName] = useState(''); const [name, setName] = useState('');
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
@ -46,5 +46,6 @@ export function useAuthForm() {
handleSubmit, handleSubmit,
continueAsGuest, continueAsGuest,
loading, loading,
error,
}; };
} }

View file

@ -1,11 +1,17 @@
import { useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { getBackups } from '../api/backups'; import { getBackups } from '../api/backups';
import type { BackupDevice } from '../types'; import type { BackupItem } from '../types';
export function useBackups() { export function useBackups() {
const [backups, setBackups] = useState<BackupDevice[]>([]); const [backups, setBackups] = useState<BackupItem[]>([]);
useEffect(() => {
const refresh = useCallback(() => {
void getBackups().then(setBackups); void getBackups().then(setBackups);
}, []); }, []);
return backups;
useEffect(() => {
refresh();
}, [refresh]);
return { backups, refresh };
} }

View file

@ -1,11 +1,17 @@
import { useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { getDashboardStats } from '../api/files'; import { getDashboardStats } from '../api/files';
import type { StatItem } from '../types'; import type { StatItem } from '../types';
export function useDashboardStats() { export function useDashboardStats() {
const [stats, setStats] = useState<StatItem[]>([]); const [stats, setStats] = useState<StatItem[]>([]);
useEffect(() => {
const refresh = useCallback(() => {
void getDashboardStats().then(setStats); void getDashboardStats().then(setStats);
}, []); }, []);
return stats;
useEffect(() => {
refresh();
}, [refresh]);
return { stats, refresh };
} }

View file

@ -0,0 +1,61 @@
import { useRef, useState, type DragEvent } from 'react';
import { uploadFile } from '../api/files';
/** Pilote un vrai transfert (XHR + progression) vers /api/files. */
export function useFileUpload(category: 'file' | 'photo' | 'backup', onDone: () => void) {
const [uploading, setUploading] = useState(false);
const [progress, setProgress] = useState(0);
const [fileName, setFileName] = useState('');
const [dragging, setDragging] = useState(false);
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const send = async (file: File) => {
setUploading(true);
setProgress(0);
setFileName(file.name);
setError(null);
try {
await uploadFile(file, category, setProgress);
onDone();
} catch (err) {
setError(err instanceof Error ? err.message : 'Échec du transfert');
} finally {
setUploading(false);
}
};
const pickFiles = () => inputRef.current?.click();
const onInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = '';
if (file) void send(file);
};
const onDragOver = (e: DragEvent) => {
e.preventDefault();
setDragging(true);
};
const onDragLeave = () => setDragging(false);
const onDrop = (e: DragEvent) => {
e.preventDefault();
setDragging(false);
const file = e.dataTransfer.files?.[0];
if (file) void send(file);
};
return {
uploading,
progress,
fileName,
dragging,
error,
inputRef,
pickFiles,
onInputChange,
onDragOver,
onDragLeave,
onDrop,
};
}

View file

@ -1,20 +1,29 @@
import { useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { getNotifications } from '../api/notifications'; import { getNotifications, markAllNotificationsRead } from '../api/notifications';
import type { NotificationItem } from '../types'; import type { NotificationItem } from '../types';
export function useNotifications() { export function useNotifications() {
const [notifications, setNotifications] = useState<NotificationItem[]>([]); const [notifications, setNotifications] = useState<NotificationItem[]>([]);
const [allRead, setAllRead] = useState(false);
useEffect(() => { const refresh = useCallback(() => {
void getNotifications().then(setNotifications); void getNotifications().then(setNotifications);
}, []); }, []);
const items = useMemo( useEffect(() => {
() => notifications.map((n) => ({ ...n, unread: n.unread && !allRead })), refresh();
[notifications, allRead], // Un dépôt ou une suppression ailleurs dans l'appli émet une vraie
); // notification côté serveur ; on revérifie à intervalle simple
const unreadCount = items.filter((n) => n.unread).length; // plutôt que de câbler un flux temps réel pour ce volume d'usage.
const interval = window.setInterval(refresh, 15000);
return () => window.clearInterval(interval);
}, [refresh]);
return { notifications: items, unreadCount, markAllRead: () => setAllRead(true) }; const unreadCount = notifications.filter((n) => n.unread).length;
const markAllRead = () => {
setNotifications((prev) => prev.map((n) => ({ ...n, unread: false })));
void markAllNotificationsRead();
};
return { notifications, unreadCount, markAllRead, refresh };
} }

View file

@ -1,18 +1,17 @@
import { useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { getAlbums, getPhotoTiles } from '../api/photos'; import { getPhotoTiles } from '../api/photos';
import type { Album, PhotoScope, PhotoTile } from '../types'; import type { PhotoTile } from '../types';
const SCOPES: PhotoScope[] = ['Tout', 'Personnes', 'Lieux'];
export function usePhotos() { export function usePhotos() {
const [albums, setAlbums] = useState<Album[]>([]);
const [tiles, setTiles] = useState<PhotoTile[]>([]); const [tiles, setTiles] = useState<PhotoTile[]>([]);
const [scope, setScope] = useState<PhotoScope>('Tout');
useEffect(() => { const refresh = useCallback(() => {
void getAlbums().then(setAlbums);
void getPhotoTiles().then(setTiles); void getPhotoTiles().then(setTiles);
}, []); }, []);
return { albums, tiles, scopes: SCOPES, scope, setScope }; useEffect(() => {
refresh();
}, [refresh]);
return { tiles, refresh };
} }

View file

@ -1,17 +1,15 @@
import { useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { getFiles } from '../api/files'; import { deleteFile, getFiles } from '../api/files';
import type { FileFilter, FileItem } from '../types'; import type { FileFilter, FileItem } from '../types';
const FILTERS: FileFilter[] = ['Tous', 'Dossiers', 'Documents', 'Médias']; const FILTERS: FileFilter[] = ['Tous', 'Documents', 'Médias'];
function matchesFilter(file: FileItem, filter: FileFilter): boolean { function matchesFilter(file: FileItem, filter: FileFilter): boolean {
switch (filter) { switch (filter) {
case 'Tous': case 'Tous':
return true; return true;
case 'Dossiers':
return file.kind === 'folder';
case 'Documents': case 'Documents':
return file.kind === 'document'; return file.kind === 'document' || file.kind === 'archive';
case 'Médias': case 'Médias':
return file.kind === 'video' || file.kind === 'image'; return file.kind === 'video' || file.kind === 'image';
} }
@ -23,10 +21,19 @@ export function useRecentFiles() {
const [sortDesc, setSortDesc] = useState(true); const [sortDesc, setSortDesc] = useState(true);
const [gridView, setGridView] = useState(true); const [gridView, setGridView] = useState(true);
useEffect(() => { const refresh = useCallback(() => {
void getFiles().then(setFiles); void getFiles().then(setFiles);
}, []); }, []);
useEffect(() => {
refresh();
}, [refresh]);
const removeFile = useCallback(async (id: string) => {
await deleteFile(id);
setFiles((prev) => prev.filter((f) => f.id !== id));
}, []);
const visibleFiles = useMemo(() => { const visibleFiles = useMemo(() => {
const filtered = files.filter((f) => matchesFilter(f, filter)); const filtered = files.filter((f) => matchesFilter(f, filter));
return sortDesc ? filtered : filtered.slice().reverse(); return sortDesc ? filtered : filtered.slice().reverse();
@ -41,5 +48,7 @@ export function useRecentFiles() {
toggleSort: () => setSortDesc((v) => !v), toggleSort: () => setSortDesc((v) => !v),
gridView, gridView,
setGridView, setGridView,
refresh,
removeFile,
}; };
} }

View file

@ -1,43 +1,39 @@
import { useEffect, useState } from 'react'; import { useState } from 'react';
import { import {
getDefaultNotificationPrefs, accountRows,
getStaticSettingsRows, getNotificationPrefs,
NOTIFICATION_PREF_LABELS, NOTIFICATION_PREF_LABELS,
type StaticSettingsRows, setNotificationPrefs,
} from '../api/settings'; } from '../api/settings';
import { useSession } from '../contexts/SessionContext';
import type { NotificationPrefKey, SettingsRow, SettingsTabKey } from '../types'; import type { NotificationPrefKey, SettingsRow, SettingsTabKey } from '../types';
const TAB_TITLES: Record<SettingsTabKey, string> = { const TAB_TITLES: Record<SettingsTabKey, string> = {
language: 'Langue et région',
notifications: 'Préférences de notification', notifications: 'Préférences de notification',
account: 'Paramètres du compte', account: 'Paramètres du compte',
}; };
export const SETTINGS_TABS: Array<{ key: SettingsTabKey; label: string }> = [ export const SETTINGS_TABS: Array<{ key: SettingsTabKey; label: string }> = [
{ key: 'language', label: 'Langue et région' },
{ key: 'notifications', label: 'Notifications' },
{ key: 'account', label: 'Compte' }, { key: 'account', label: 'Compte' },
{ key: 'notifications', label: 'Notifications' },
]; ];
export function useSettings(activeTab: SettingsTabKey) { export function useSettings(activeTab: SettingsTabKey) {
const [staticRows, setStaticRows] = useState<StaticSettingsRows | null>(null); const { user } = useSession();
const [prefs, setPrefs] = useState<Record<NotificationPrefKey, boolean> | null>(null); const [prefs, setPrefs] = useState<Record<NotificationPrefKey, boolean>>(() => getNotificationPrefs());
useEffect(() => {
void getStaticSettingsRows().then(setStaticRows);
void getDefaultNotificationPrefs().then(setPrefs);
}, []);
const togglePref = (key: NotificationPrefKey) => { const togglePref = (key: NotificationPrefKey) => {
setPrefs((prev) => (prev ? { ...prev, [key]: !prev[key] } : prev)); setPrefs((prev) => {
const next = { ...prev, [key]: !prev[key] };
setNotificationPrefs(next);
return next;
});
}; };
let rows: SettingsRow[] = []; let rows: SettingsRow[] = [];
if (activeTab === 'language') { if (activeTab === 'account') {
rows = staticRows?.language ?? []; rows = user ? accountRows(user) : [];
} else if (activeTab === 'account') { } else {
rows = staticRows?.account ?? [];
} else if (prefs) {
rows = (Object.keys(NOTIFICATION_PREF_LABELS) as NotificationPrefKey[]).map((key) => ({ rows = (Object.keys(NOTIFICATION_PREF_LABELS) as NotificationPrefKey[]).map((key) => ({
kind: 'toggle', kind: 'toggle',
id: key, id: key,

View file

@ -3,8 +3,16 @@ import { getStorageSummary, type StorageSummary } from '../api/vault';
export function useStorageSummary() { export function useStorageSummary() {
const [summary, setSummary] = useState<StorageSummary | null>(null); const [summary, setSummary] = useState<StorageSummary | null>(null);
useEffect(() => { useEffect(() => {
void getStorageSummary().then(setSummary); const refresh = () => void getStorageSummary().then(setSummary);
refresh();
// La barre de stockage vit dans la sidebar, hors de portée des
// callbacks d'upload des vues - un intervalle simple évite de
// faire remonter cet état jusqu'à la racine de l'appli.
const interval = window.setInterval(refresh, 15000);
return () => window.clearInterval(interval);
}, []); }, []);
return summary; return summary;
} }

View file

@ -1,44 +0,0 @@
import { useEffect, useRef, useState, type DragEvent } from 'react';
/**
* Drives the UploadZone's demo transfer. `externalProgress` lets a caller
* override the simulated value with real numbers later (the component
* itself never knows the difference) — this is the `progress` prop the
* brief asked to expose.
*/
export function useUploadSimulation(externalProgress?: number) {
const [uploading, setUploading] = useState(false);
const [internalProgress, setInternalProgress] = useState(0);
const [dragging, setDragging] = useState(false);
const timerRef = useRef<number | undefined>(undefined);
useEffect(() => () => window.clearInterval(timerRef.current), []);
const start = () => {
window.clearInterval(timerRef.current);
setUploading(true);
setInternalProgress(0);
setDragging(false);
timerRef.current = window.setInterval(() => {
setInternalProgress((p) => {
const next = Math.min(100, p + 4 + Math.random() * 7);
if (next >= 100) window.clearInterval(timerRef.current);
return next;
});
}, 280);
};
const onDragOver = (e: DragEvent) => {
e.preventDefault();
setDragging(true);
};
const onDragLeave = () => setDragging(false);
const onDrop = (e: DragEvent) => {
e.preventDefault();
start();
};
const progress = Math.round(externalProgress ?? internalProgress);
return { uploading, progress, dragging, start, onDragOver, onDragLeave, onDrop };
}

37
app/src/lib/format.ts Normal file
View file

@ -0,0 +1,37 @@
export function formatBytes(bytes: number): string {
if (bytes <= 0) return '0 Ko';
const units = ['Ko', 'Mo', 'Go', 'To'];
let value = bytes / 1024;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex += 1;
}
return `${value.toFixed(value < 10 ? 1 : 0).replace('.', ',')} ${units[unitIndex]}`;
}
export function formatRelativeTime(iso: string): string {
const date = new Date(iso);
const diffMs = Date.now() - date.getTime();
const diffMin = Math.round(diffMs / 60000);
if (diffMin < 1) return "à l'instant";
if (diffMin < 60) return `il y a ${diffMin} min`;
const diffH = Math.round(diffMin / 60);
if (diffH < 24) return `il y a ${diffH} h`;
const diffD = Math.round(diffH / 24);
if (diffD === 1) return 'hier';
if (diffD < 7) return `il y a ${diffD} jours`;
const diffW = Math.round(diffD / 7);
if (diffW < 5) return `il y a ${diffW} sem.`;
return date.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short', year: 'numeric' });
}
export function formatDateTime(iso: string): string {
return new Date(iso).toLocaleString('fr-FR', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
});
}

View file

@ -50,27 +50,17 @@ export interface NotificationItem {
unread: boolean; unread: boolean;
} }
export interface Album {
id: string;
name: string;
count: string;
thumbGradient: string;
}
export interface PhotoTile { export interface PhotoTile {
id: string; id: string;
label: string; label: string;
thumbGradient: string; /** URL réelle du fichier (`/api/files/:id`), affichée dans une <img>. */
url: string;
} }
export type BackupStatus = 'up-to-date' | 'syncing' | 'late'; export interface BackupItem {
export interface BackupDevice {
id: string; id: string;
device: string; name: string;
detail: string; detail: string;
status: BackupStatus;
statusLabel: string;
icon: IconName; icon: IconName;
} }
@ -83,7 +73,7 @@ export interface StatItem {
tone: 'brand' | 'success' | 'violet'; tone: 'brand' | 'success' | 'violet';
} }
export type SettingsTabKey = 'language' | 'notifications' | 'account'; export type SettingsTabKey = 'notifications' | 'account';
export interface SettingsValueRow { export interface SettingsValueRow {
kind: 'value'; kind: 'value';
@ -108,6 +98,5 @@ export type NotificationPrefKey = 'push' | 'email' | 'weekly' | 'shared';
export type AppView = 'auth' | 'app'; export type AppView = 'auth' | 'app';
export type NavKey = 'dashboard' | 'photos' | 'backups' | 'settings'; export type NavKey = 'dashboard' | 'photos' | 'backups' | 'settings';
export type AuthMode = 'login' | 'signup'; export type AuthMode = 'login' | 'signup';
export type FileFilter = 'Tous' | 'Dossiers' | 'Documents' | 'Médias'; export type FileFilter = 'Tous' | 'Documents' | 'Médias';
export type PhotoScope = 'Tout' | 'Personnes' | 'Lieux';
export type Theme = 'light' | 'dark'; export type Theme = 'light' | 'dark';

View file

@ -20,12 +20,18 @@ Forgejo mono-utilisateur, pas un service ouvert à des tiers.
Le pipeline ne se connecte jamais en root. Un utilisateur système Le pipeline ne se connecte jamais en root. Un utilisateur système
`deploy` existe uniquement pour : `deploy` existe uniquement pour :
1. recevoir le binaire compilé par `scp` dans `/opt/deploy-staging/` ; 1. recevoir le binaire compilé (`scp`) ou le build du frontend
2. lancer `sudo /usr/local/sbin/tripoli-deploy.sh <service>` - un (`rsync`) dans `/opt/deploy-staging/` ;
script sudo dédié (voir `/etc/sudoers.d/deploy` sur le serveur) qui 2. lancer un des scripts sudo dédiés (voir `/etc/sudoers.d/deploy` sur
n'accepte que `tripoli-edge` ou `tripoli-api` comme argument, installe le serveur), chacun figé sur une seule action :
le binaire à l'emplacement attendu et redémarre exactement ce - `tripoli-deploy.sh <service>` - n'accepte que `tripoli-edge` ou
service. Rien d'autre n'est accessible via ce compte. `tripoli-api`, installe le binaire et redémarre exactement ce
service ;
- `tripoli-deploy-frontend.sh` - synchronise le build statique vers
`/var/www/tripoli-cloud` (servi directement par Caddy, pas de
redémarrage de service nécessaire).
Rien d'autre n'est accessible via ce compte.
La clé privée correspondante est stockée comme secret Forgejo Actions La clé privée correspondante est stockée comme secret Forgejo Actions
(`DEPLOY_SSH_KEY`), jamais dans ce dépôt. (`DEPLOY_SSH_KEY`), jamais dans ce dépôt.
@ -37,9 +43,21 @@ La clé privée correspondante est stockée comme secret Forgejo Actions
pour ce code), `cargo build --workspace`, `cargo test --workspace`, pour ce code), `cargo build --workspace`, `cargo test --workspace`,
puis `npm ci && npm run build` pour le frontend. puis `npm ci && npm run build` pour le frontend.
- `deploy` (uniquement sur `main`, après succès du job précédent) : - `deploy` (uniquement sur `main`, après succès du job précédent) :
build release, puis dépôt + redémarrage de `tripoli-edge` et build release + build frontend, puis dépôt/redémarrage de
`tripoli-api` via le compte `deploy`. `tripoli-edge` et `tripoli-api`, et synchronisation du frontend
statique - tout via le compte `deploy`.
`tripoli-netctl` et le labo `bird-lab` ne sont pas déployés par ce `tripoli-netctl` et le labo `bird-lab` ne sont pas déployés par ce
pipeline : ce sont des outils de labo qu'on lance à la main, pas des pipeline : ce sont des outils de labo qu'on lance à la main, pas des
services qui tournent en continu. services qui tournent en continu.
## Frontend : plus de données simulées
Le frontend appelle désormais réellement `tripoli-api` (auth, upload
avec vraie progression XHR, photos/sauvegardes/notifications/espace
disque réels). Ce qui n'avait honnêtement rien de réel derrière dans la
maquette d'origine a été retiré plutôt que simulé : 2FA, clé de
récupération, accès partagés, albums, "appareils sauvegardés", chiffres
marketing inventés sur la page de connexion. Détails dans
`app/src/api/*.ts` (chaque module documente ce qu'il retire et
pourquoi).

View file

@ -19,6 +19,7 @@ rand_core = { version = "0.6", features = ["std"] }
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
futures-util = "0.3" futures-util = "0.3"
nix = { version = "0.31", features = ["fs"] }
time = "0.3" time = "0.3"
tokio-util = { version = "0.7", features = ["io"] } tokio-util = { version = "0.7", features = ["io"] }
mime_guess = "2" mime_guess = "2"

View file

@ -19,14 +19,19 @@ implémentations RustCrypto).
| Route | Auth | Rôle | | Route | Auth | Rôle |
|---|---|---| |---|---|---|
| `GET /healthz` | non | ping PostgreSQL | | `GET /healthz` | non | ping PostgreSQL |
| `POST /api/auth/register` | non | `{email, password}` -> crée le compte + session | | `POST /api/auth/register` | non | `{name, email, password}` -> crée le compte + session |
| `POST /api/auth/login` | non | `{email, password}` -> session | | `POST /api/auth/login` | non | `{email, password}` -> session |
| `POST /api/auth/guest` | non | connecte (crée si besoin) le compte invité réel `invite@tripoli.local` |
| `POST /api/auth/logout` | oui | invalide la session | | `POST /api/auth/logout` | oui | invalide la session |
| `GET /api/auth/me` | oui | utilisateur courant | | `GET /api/auth/me` | oui | utilisateur courant |
| `GET /api/files` | oui | fichiers de l'utilisateur courant | | `GET /api/files` | oui | fichiers de l'utilisateur courant, `?category=file\|photo\|backup` |
| `POST /api/files` | oui | upload multipart, champ `file` | | `POST /api/files` | oui | upload multipart, champs `file` et `category` (optionnel) |
| `GET /api/files/{id}` | oui | téléchargement (vérifie la propriété) | | `GET /api/files/{id}` | oui | téléchargement (vérifie la propriété) |
| `DELETE /api/files/{id}` | oui | suppression (vérifie la propriété) | | `DELETE /api/files/{id}` | oui | suppression (vérifie la propriété) |
| `GET /api/notifications` | oui | notifications de l'utilisateur (émises par upload/suppression) |
| `POST /api/notifications/read-all` | oui | marque tout comme lu |
| `POST /api/notifications/{id}/read` | oui | marque une notification comme lue |
| `GET /api/storage` | oui | `used_bytes` (somme réelle des fichiers), `available_bytes` (`statvfs`) |
## Stockage ## Stockage
@ -66,8 +71,18 @@ client - le corps de la requête part effectivement au fil de l'eau.
jamais affiché dans un terminal). jamais affiché dans un terminal).
- tripoli-api tourne en systemd (`deploy/tripoli-api.service`) sous le même - tripoli-api tourne en systemd (`deploy/tripoli-api.service`) sous le même
utilisateur système sans privilège `tripoli` que tripoli-edge. utilisateur système sans privilège `tripoli` que tripoli-edge.
- Les migrations (`migrations/0001_init.sql`) s'appliquent automatiquement - Les migrations (`migrations/*.sql`) s'appliquent automatiquement au
au démarrage (`sqlx::migrate!`). démarrage (`sqlx::migrate!`) - `0002_full_features.sql` ajoute `name`
aux utilisateurs, `category` aux fichiers, et la table
`notifications`.
## Compte invité
"Visiter en tant qu'invité" se connecte à un compte réel
(`invite@tripoli.local`), créé une seule fois puis réutilisé - jamais
une identité simulée côté frontend. Son mot de passe est un UUID
aléatoire jamais communiqué : personne ne peut s'y connecter autrement
qu'en passant par `POST /api/auth/guest`.
## Vérifié en production ## Vérifié en production

View file

@ -0,0 +1,15 @@
ALTER TABLE users ADD COLUMN name TEXT NOT NULL DEFAULT '';
ALTER TABLE files ADD COLUMN category TEXT NOT NULL DEFAULT 'file';
CREATE TABLE notifications (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
kind TEXT NOT NULL,
title TEXT NOT NULL,
body TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
read_at TIMESTAMPTZ
);
CREATE INDEX notifications_user_id_idx ON notifications(user_id);

View file

@ -5,7 +5,7 @@ use axum::http::request::Parts;
use axum::Json; use axum::Json;
use axum_extra::extract::cookie::{Cookie, SameSite}; use axum_extra::extract::cookie::{Cookie, SameSite};
use axum_extra::extract::CookieJar; use axum_extra::extract::CookieJar;
use chrono::{Duration, Utc}; use chrono::{DateTime, Duration, Utc};
use rand_core::OsRng; use rand_core::OsRng;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::PgPool; use sqlx::PgPool;
@ -16,16 +16,32 @@ use crate::state::SharedState;
pub const SESSION_COOKIE: &str = "tripoli_session"; pub const SESSION_COOKIE: &str = "tripoli_session";
/// Adresse réservée au compte invité : "Visiter en tant qu'invité" se
/// connecte réellement à ce compte plutôt que de simuler une identité -
/// mêmes routes, mêmes sessions, même stockage. Pas de mot de passe
/// dessus : `guest()` crée ou réutilise ce compte directement.
const GUEST_EMAIL: &str = "invite@tripoli.local";
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct Credentials { pub struct Credentials {
pub email: String, pub email: String,
pub password: String, pub password: String,
} }
#[derive(Deserialize)]
pub struct RegisterPayload {
pub name: String,
pub email: String,
pub password: String,
}
#[derive(Serialize)] #[derive(Serialize)]
pub struct UserView { pub struct UserView {
pub id: Uuid, pub id: Uuid,
pub name: String,
pub email: String, pub email: String,
pub is_guest: bool,
pub created_at: DateTime<Utc>,
} }
fn hash_password(password: &str) -> Result<String, AppError> { fn hash_password(password: &str) -> Result<String, AppError> {
@ -67,42 +83,53 @@ fn session_cookie(session_id: Uuid, ttl_hours: i64) -> Cookie<'static> {
.build() .build()
} }
fn to_view(id: Uuid, name: String, email: String, created_at: DateTime<Utc>) -> UserView {
let is_guest = email == GUEST_EMAIL;
UserView {
id,
name,
email,
is_guest,
created_at,
}
}
pub async fn register( pub async fn register(
State(state): State<SharedState>, State(state): State<SharedState>,
jar: CookieJar, jar: CookieJar,
Json(body): Json<Credentials>, Json(body): Json<RegisterPayload>,
) -> Result<(CookieJar, Json<UserView>), AppError> { ) -> Result<(CookieJar, Json<UserView>), AppError> {
if body.email.trim().is_empty() || body.password.len() < 8 { if body.name.trim().is_empty() || body.email.trim().is_empty() || body.password.len() < 8 {
return Err(AppError::BadRequest( return Err(AppError::BadRequest(
"email requis, mot de passe de 8 caractères minimum".to_string(), "nom et email requis, mot de passe de 8 caractères minimum".to_string(),
)); ));
} }
let password_hash = hash_password(&body.password)?; let password_hash = hash_password(&body.password)?;
let user_id = Uuid::new_v4(); let user_id = Uuid::new_v4();
sqlx::query("INSERT INTO users (id, email, password_hash) VALUES ($1, $2, $3)") let created_at: DateTime<Utc> = sqlx::query_scalar(
.bind(user_id) "INSERT INTO users (id, name, email, password_hash) VALUES ($1, $2, $3, $4) RETURNING created_at",
.bind(&body.email) )
.bind(&password_hash) .bind(user_id)
.execute(&state.pool) .bind(&body.name)
.await .bind(&body.email)
.map_err(|e| match e { .bind(&password_hash)
sqlx::Error::Database(ref db_err) if db_err.is_unique_violation() => { .fetch_one(&state.pool)
AppError::EmailAlreadyTaken .await
} .map_err(|e| match e {
other => AppError::from(other), sqlx::Error::Database(ref db_err) if db_err.is_unique_violation() => {
})?; AppError::EmailAlreadyTaken
}
other => AppError::from(other),
})?;
let session_id = create_session(&state.pool, user_id, state.session_ttl_hours).await?; let session_id = create_session(&state.pool, user_id, state.session_ttl_hours).await?;
let jar = jar.add(session_cookie(session_id, state.session_ttl_hours)); let jar = jar.add(session_cookie(session_id, state.session_ttl_hours));
Ok(( Ok((
jar, jar,
Json(UserView { Json(to_view(user_id, body.name, body.email, created_at)),
id: user_id,
email: body.email,
}),
)) ))
} }
@ -111,13 +138,14 @@ pub async fn login(
jar: CookieJar, jar: CookieJar,
Json(body): Json<Credentials>, Json(body): Json<Credentials>,
) -> Result<(CookieJar, Json<UserView>), AppError> { ) -> Result<(CookieJar, Json<UserView>), AppError> {
let row: Option<(Uuid, String, String)> = let row: Option<(Uuid, String, String, String, DateTime<Utc>)> = sqlx::query_as(
sqlx::query_as("SELECT id, email, password_hash FROM users WHERE email = $1") "SELECT id, name, email, password_hash, created_at FROM users WHERE email = $1",
.bind(&body.email) )
.fetch_optional(&state.pool) .bind(&body.email)
.await?; .fetch_optional(&state.pool)
.await?;
let Some((user_id, email, password_hash)) = row else { let Some((user_id, name, email, password_hash, created_at)) = row else {
return Err(AppError::InvalidCredentials); return Err(AppError::InvalidCredentials);
}; };
@ -128,7 +156,44 @@ pub async fn login(
let session_id = create_session(&state.pool, user_id, state.session_ttl_hours).await?; let session_id = create_session(&state.pool, user_id, state.session_ttl_hours).await?;
let jar = jar.add(session_cookie(session_id, state.session_ttl_hours)); let jar = jar.add(session_cookie(session_id, state.session_ttl_hours));
Ok((jar, Json(UserView { id: user_id, email }))) Ok((jar, Json(to_view(user_id, name, email, created_at))))
}
/// Compte réel, réutilisé à chaque appel (créé une seule fois, mot de
/// passe aléatoire jamais communiqué - personne ne peut se connecter
/// autrement qu'en repassant par cette route). "Invité" veut dire
/// "compte de démonstration sans inscription", pas "identité simulée".
pub async fn guest(
State(state): State<SharedState>,
jar: CookieJar,
) -> Result<(CookieJar, Json<UserView>), AppError> {
let existing: Option<(Uuid, String, String, DateTime<Utc>)> =
sqlx::query_as("SELECT id, name, email, created_at FROM users WHERE email = $1")
.bind(GUEST_EMAIL)
.fetch_optional(&state.pool)
.await?;
let (user_id, name, email, created_at) = if let Some(row) = existing {
row
} else {
let user_id = Uuid::new_v4();
let password_hash = hash_password(&Uuid::new_v4().to_string())?;
let created_at: DateTime<Utc> = sqlx::query_scalar(
"INSERT INTO users (id, name, email, password_hash) VALUES ($1, $2, $3, $4) RETURNING created_at",
)
.bind(user_id)
.bind("Invité")
.bind(GUEST_EMAIL)
.bind(&password_hash)
.fetch_one(&state.pool)
.await?;
(user_id, "Invité".to_string(), GUEST_EMAIL.to_string(), created_at)
};
let session_id = create_session(&state.pool, user_id, state.session_ttl_hours).await?;
let jar = jar.add(session_cookie(session_id, state.session_ttl_hours));
Ok((jar, Json(to_view(user_id, name, email, created_at))))
} }
pub async fn logout( pub async fn logout(
@ -146,11 +211,12 @@ pub async fn logout(
Ok(jar.remove(Cookie::from(SESSION_COOKIE))) Ok(jar.remove(Cookie::from(SESSION_COOKIE)))
} }
pub async fn me(user: AuthUser) -> Json<UserView> { pub async fn me(State(state): State<SharedState>, user: AuthUser) -> Result<Json<UserView>, AppError> {
Json(UserView { let row: (DateTime<Utc>,) = sqlx::query_as("SELECT created_at FROM users WHERE id = $1")
id: user.id, .bind(user.id)
email: user.email, .fetch_one(&state.pool)
}) .await?;
Ok(Json(to_view(user.id, user.name, user.email, row.0)))
} }
/// Extracteur de requête : présent sur toute route qui exige une session /// Extracteur de requête : présent sur toute route qui exige une session
@ -158,6 +224,7 @@ pub async fn me(user: AuthUser) -> Json<UserView> {
/// d'atteindre le handler - le handler n'a jamais à vérifier lui-même. /// d'atteindre le handler - le handler n'a jamais à vérifier lui-même.
pub struct AuthUser { pub struct AuthUser {
pub id: Uuid, pub id: Uuid,
pub name: String,
pub email: String, pub email: String,
} }
@ -174,8 +241,8 @@ impl FromRequestParts<SharedState> for AuthUser {
.and_then(|c| Uuid::parse_str(c.value()).ok()) .and_then(|c| Uuid::parse_str(c.value()).ok())
.ok_or(AppError::Unauthorized)?; .ok_or(AppError::Unauthorized)?;
let row: Option<(Uuid, String)> = sqlx::query_as( let row: Option<(Uuid, String, String)> = sqlx::query_as(
"SELECT users.id, users.email FROM sessions \ "SELECT users.id, users.name, users.email FROM sessions \
JOIN users ON users.id = sessions.user_id \ JOIN users ON users.id = sessions.user_id \
WHERE sessions.id = $1 AND sessions.expires_at > now()", WHERE sessions.id = $1 AND sessions.expires_at > now()",
) )
@ -184,7 +251,7 @@ impl FromRequestParts<SharedState> for AuthUser {
.await .await
.map_err(AppError::from)?; .map_err(AppError::from)?;
let (id, email) = row.ok_or(AppError::Unauthorized)?; let (id, name, email) = row.ok_or(AppError::Unauthorized)?;
Ok(AuthUser { id, email }) Ok(AuthUser { id, name, email })
} }
} }

View file

@ -1,9 +1,9 @@
use axum::body::Body; use axum::body::Body;
use axum::extract::{Multipart, Path, State}; use axum::extract::{Multipart, Path, Query, State};
use axum::http::header; use axum::http::header;
use axum::response::Response; use axum::response::Response;
use axum::Json; use axum::Json;
use serde::Serialize; use serde::{Deserialize, Serialize};
use sqlx::Row; use sqlx::Row;
use tokio::fs::File; use tokio::fs::File;
use tokio::io::AsyncWriteExt; use tokio::io::AsyncWriteExt;
@ -12,36 +12,51 @@ use uuid::Uuid;
use crate::auth::AuthUser; use crate::auth::AuthUser;
use crate::error::AppError; use crate::error::AppError;
use crate::notifications;
use crate::state::SharedState; use crate::state::SharedState;
const CATEGORIES: [&str; 3] = ["file", "photo", "backup"];
#[derive(Serialize)] #[derive(Serialize)]
pub struct FileView { pub struct FileView {
pub id: Uuid, pub id: Uuid,
pub filename: String, pub filename: String,
pub content_type: String, pub content_type: String,
pub category: String,
pub size_bytes: i64, pub size_bytes: i64,
pub created_at: chrono::DateTime<chrono::Utc>, pub created_at: chrono::DateTime<chrono::Utc>,
} }
#[derive(Deserialize)]
pub struct ListQuery {
category: Option<String>,
}
pub async fn list( pub async fn list(
State(state): State<SharedState>, State(state): State<SharedState>,
user: AuthUser, user: AuthUser,
Query(query): Query<ListQuery>,
) -> Result<Json<Vec<FileView>>, AppError> { ) -> Result<Json<Vec<FileView>>, AppError> {
let files = sqlx::query_as::<_, (Uuid, String, String, i64, chrono::DateTime<chrono::Utc>)>( let files = sqlx::query_as::<_, (Uuid, String, String, String, i64, chrono::DateTime<chrono::Utc>)>(
"SELECT id, filename, content_type, size_bytes, created_at \ "SELECT id, filename, content_type, category, size_bytes, created_at \
FROM files WHERE owner_id = $1 ORDER BY created_at DESC", FROM files WHERE owner_id = $1 AND ($2::text IS NULL OR category = $2) \
ORDER BY created_at DESC",
) )
.bind(user.id) .bind(user.id)
.bind(query.category)
.fetch_all(&state.pool) .fetch_all(&state.pool)
.await? .await?
.into_iter() .into_iter()
.map(|(id, filename, content_type, size_bytes, created_at)| FileView { .map(
id, |(id, filename, content_type, category, size_bytes, created_at)| FileView {
filename, id,
content_type, filename,
size_bytes, content_type,
created_at, category,
}) size_bytes,
created_at,
},
)
.collect(); .collect();
Ok(Json(files)) Ok(Json(files))
@ -52,17 +67,43 @@ pub async fn list(
/// mémoire : c'est ce qui rend la progression d'upload réelle côté /// mémoire : c'est ce qui rend la progression d'upload réelle côté
/// client (le corps de la requête part réellement au fil de l'eau, pas /// client (le corps de la requête part réellement au fil de l'eau, pas
/// d'un coup une fois tout reçu par le serveur). /// d'un coup une fois tout reçu par le serveur).
///
/// `category` est un champ multipart optionnel envoyé avant `file` par
/// le client (photo/backup/file) ; tout le reste du multipart est
/// traité comme avant.
pub async fn upload( pub async fn upload(
State(state): State<SharedState>, State(state): State<SharedState>,
user: AuthUser, user: AuthUser,
mut multipart: Multipart, mut multipart: Multipart,
) -> Result<Json<FileView>, AppError> { ) -> Result<Json<FileView>, AppError> {
let Some(mut field) = multipart let mut category = "file".to_string();
.next_field()
.await // `Field` emprunte `multipart` : impossible de le faire sortir de la
.map_err(|e| AppError::BadRequest(e.to_string()))? // boucle sans conflit d'emprunt. On consomme donc les champs
else { // "category" un par un jusqu'à tomber sur le champ fichier, traité
return Err(AppError::BadRequest("champ 'file' manquant".to_string())); // immédiatement dans cette même itération.
let mut field = loop {
let Some(field) = multipart
.next_field()
.await
.map_err(|e| AppError::BadRequest(e.to_string()))?
else {
return Err(AppError::BadRequest("champ 'file' manquant".to_string()));
};
if field.name() == Some("category") {
let value = field
.text()
.await
.map_err(|e| AppError::BadRequest(e.to_string()))?;
if !CATEGORIES.contains(&value.as_str()) {
return Err(AppError::BadRequest(format!("catégorie inconnue : {value}")));
}
category = value;
continue;
}
break field;
}; };
let filename = field let filename = field
@ -93,22 +134,33 @@ pub async fn upload(
tokio::fs::rename(&tmp_path, &final_path).await?; tokio::fs::rename(&tmp_path, &final_path).await?;
let created_at: chrono::DateTime<chrono::Utc> = sqlx::query( let created_at: chrono::DateTime<chrono::Utc> = sqlx::query(
"INSERT INTO files (id, owner_id, filename, content_type, size_bytes) \ "INSERT INTO files (id, owner_id, filename, content_type, category, size_bytes) \
VALUES ($1, $2, $3, $4, $5) RETURNING created_at", VALUES ($1, $2, $3, $4, $5, $6) RETURNING created_at",
) )
.bind(file_id) .bind(file_id)
.bind(user.id) .bind(user.id)
.bind(&filename) .bind(&filename)
.bind(&content_type) .bind(&content_type)
.bind(&category)
.bind(size_bytes) .bind(size_bytes)
.fetch_one(&state.pool) .fetch_one(&state.pool)
.await? .await?
.get("created_at"); .get("created_at");
notifications::emit(
&state.pool,
user.id,
"upload",
"Transfert terminé",
&format!("{filename} a été déposé."),
)
.await;
Ok(Json(FileView { Ok(Json(FileView {
id: file_id, id: file_id,
filename, filename,
content_type, content_type,
category,
size_bytes, size_bytes,
created_at, created_at,
})) }))
@ -134,11 +186,19 @@ pub async fn download(
let file = File::open(&path).await.map_err(|_| AppError::NotFound)?; let file = File::open(&path).await.map_err(|_| AppError::NotFound)?;
let body = Body::from_stream(ReaderStream::new(file)); let body = Body::from_stream(ReaderStream::new(file));
// "inline" pour les images : elles doivent s'afficher dans une
// balise <img>, pas déclencher un téléchargement.
let disposition_kind = if content_type.starts_with("image/") {
"inline"
} else {
"attachment"
};
Ok(Response::builder() Ok(Response::builder()
.header(header::CONTENT_TYPE, content_type) .header(header::CONTENT_TYPE, content_type)
.header( .header(
header::CONTENT_DISPOSITION, header::CONTENT_DISPOSITION,
format!("attachment; filename=\"{filename}\""), format!("{disposition_kind}; filename=\"{filename}\""),
) )
.body(body) .body(body)
.map_err(|e| AppError::Internal(e.to_string()))?) .map_err(|e| AppError::Internal(e.to_string()))?)
@ -149,15 +209,16 @@ pub async fn delete(
user: AuthUser, user: AuthUser,
Path(file_id): Path<Uuid>, Path(file_id): Path<Uuid>,
) -> Result<(), AppError> { ) -> Result<(), AppError> {
let result = sqlx::query("DELETE FROM files WHERE id = $1 AND owner_id = $2") let filename: Option<String> =
.bind(file_id) sqlx::query_scalar("DELETE FROM files WHERE id = $1 AND owner_id = $2 RETURNING filename")
.bind(user.id) .bind(file_id)
.execute(&state.pool) .bind(user.id)
.await?; .fetch_optional(&state.pool)
.await?;
if result.rows_affected() == 0 { let Some(filename) = filename else {
return Err(AppError::NotFound); return Err(AppError::NotFound);
} };
let path = state.blob_dir.join(file_id.to_string()); let path = state.blob_dir.join(file_id.to_string());
// Le blob peut déjà être absent (incohérence disque/DB préexistante) : // Le blob peut déjà être absent (incohérence disque/DB préexistante) :
@ -165,5 +226,14 @@ pub async fn delete(
// qui, du point de vue de l'utilisateur, a réussi. // qui, du point de vue de l'utilisateur, a réussi.
let _ = tokio::fs::remove_file(&path).await; let _ = tokio::fs::remove_file(&path).await;
notifications::emit(
&state.pool,
user.id,
"delete",
"Fichier supprimé",
&format!("{filename} a été retiré du coffre."),
)
.await;
Ok(()) Ok(())
} }

View file

@ -2,7 +2,9 @@ mod auth;
mod config; mod config;
mod error; mod error;
mod files; mod files;
mod notifications;
mod state; mod state;
mod storage;
use std::sync::Arc; use std::sync::Arc;
@ -45,6 +47,7 @@ async fn main() {
.route("/healthz", get(healthz)) .route("/healthz", get(healthz))
.route("/api/auth/register", post(auth::register)) .route("/api/auth/register", post(auth::register))
.route("/api/auth/login", post(auth::login)) .route("/api/auth/login", post(auth::login))
.route("/api/auth/guest", post(auth::guest))
.route("/api/auth/logout", post(auth::logout)) .route("/api/auth/logout", post(auth::logout))
.route("/api/auth/me", get(auth::me)) .route("/api/auth/me", get(auth::me))
.route("/api/files", get(files::list).post(files::upload)) .route("/api/files", get(files::list).post(files::upload))
@ -52,6 +55,16 @@ async fn main() {
"/api/files/{id}", "/api/files/{id}",
get(files::download).delete(files::delete), get(files::download).delete(files::delete),
) )
.route("/api/notifications", get(notifications::list))
.route(
"/api/notifications/read-all",
post(notifications::mark_all_read),
)
.route(
"/api/notifications/{id}/read",
post(notifications::mark_read),
)
.route("/api/storage", get(storage::get))
.with_state(state); .with_state(state);
tracing::info!(listen_addr = %listen_addr, "tripoli-api démarré"); tracing::info!(listen_addr = %listen_addr, "tripoli-api démarré");

View file

@ -0,0 +1,95 @@
use axum::extract::{Path, State};
use axum::Json;
use serde::Serialize;
use sqlx::PgPool;
use uuid::Uuid;
use crate::auth::AuthUser;
use crate::error::AppError;
use crate::state::SharedState;
#[derive(Serialize)]
pub struct NotificationView {
pub id: Uuid,
pub kind: String,
pub title: String,
pub body: String,
pub created_at: chrono::DateTime<chrono::Utc>,
pub unread: bool,
}
/// Émet une notification réelle liée à un événement qui vient de se
/// produire (upload, suppression, ...). Volontairement best-effort :
/// une notification qui échoue à s'insérer ne doit jamais faire échouer
/// l'action qui l'a déclenchée.
pub async fn emit(pool: &PgPool, user_id: Uuid, kind: &str, title: &str, body: &str) {
let result = sqlx::query(
"INSERT INTO notifications (id, user_id, kind, title, body) VALUES ($1, $2, $3, $4, $5)",
)
.bind(Uuid::new_v4())
.bind(user_id)
.bind(kind)
.bind(title)
.bind(body)
.execute(pool)
.await;
if let Err(err) = result {
tracing::warn!(error = %err, "échec de l'émission d'une notification");
}
}
pub async fn list(
State(state): State<SharedState>,
user: AuthUser,
) -> Result<Json<Vec<NotificationView>>, AppError> {
let rows = sqlx::query_as::<_, (Uuid, String, String, String, chrono::DateTime<chrono::Utc>, Option<chrono::DateTime<chrono::Utc>>)>(
"SELECT id, kind, title, body, created_at, read_at FROM notifications \
WHERE user_id = $1 ORDER BY created_at DESC LIMIT 50",
)
.bind(user.id)
.fetch_all(&state.pool)
.await?
.into_iter()
.map(|(id, kind, title, body, created_at, read_at)| NotificationView {
id,
kind,
title,
body,
created_at,
unread: read_at.is_none(),
})
.collect();
Ok(Json(rows))
}
pub async fn mark_all_read(
State(state): State<SharedState>,
user: AuthUser,
) -> Result<(), AppError> {
sqlx::query("UPDATE notifications SET read_at = now() WHERE user_id = $1 AND read_at IS NULL")
.bind(user.id)
.execute(&state.pool)
.await?;
Ok(())
}
pub async fn mark_read(
State(state): State<SharedState>,
user: AuthUser,
Path(notification_id): Path<Uuid>,
) -> Result<(), AppError> {
let result = sqlx::query(
"UPDATE notifications SET read_at = now() WHERE id = $1 AND user_id = $2 AND read_at IS NULL",
)
.bind(notification_id)
.bind(user.id)
.execute(&state.pool)
.await?;
if result.rows_affected() == 0 {
return Err(AppError::NotFound);
}
Ok(())
}

View file

@ -0,0 +1,47 @@
use axum::extract::State;
use axum::Json;
use serde::Serialize;
use crate::auth::AuthUser;
use crate::error::AppError;
use crate::state::SharedState;
#[derive(Serialize)]
pub struct StorageView {
/// Somme réelle des tailles de fichiers de cet utilisateur.
pub used_bytes: i64,
/// Espace réellement disponible sur le disque du serveur qui
/// stocke les blobs (`statvfs`, pas un quota inventé).
pub available_bytes: u64,
}
/// Pas de quota fictif type "1 To" : l'espace "disponible" est celui
/// que `statvfs` rapporte vraiment sur le volume qui stocke
/// `blob_dir`, partagé avec le reste du serveur.
pub async fn get(
State(state): State<SharedState>,
user: AuthUser,
) -> Result<Json<StorageView>, AppError> {
// `SUM(bigint)` renvoie un NUMERIC côté Postgres (pour ne jamais
// silencieusement déborder) - cast explicite pour revenir à un i64.
let used_bytes: i64 = sqlx::query_scalar(
"SELECT COALESCE(SUM(size_bytes), 0)::bigint FROM files WHERE owner_id = $1",
)
.bind(user.id)
.fetch_one(&state.pool)
.await?;
let blob_dir = state.blob_dir.clone();
let available_bytes = tokio::task::spawn_blocking(move || -> Result<u64, AppError> {
let stats = nix::sys::statvfs::statvfs(&blob_dir)
.map_err(|e| AppError::Internal(format!("statvfs : {e}")))?;
Ok(stats.blocks_available() as u64 * stats.fragment_size())
})
.await
.map_err(|e| AppError::Internal(e.to_string()))??;
Ok(Json(StorageView {
used_bytes,
available_bytes,
}))
}