diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index c5048fb..54c386d 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -52,10 +52,17 @@ jobs: - name: 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 - # que déposer un fichier dans /opt/deploy-staging et lancer le - # script sudo tripoli-deploy.sh, qui installe le binaire et - # redémarre EXACTEMENT le service nommé - rien de plus. Pas de clé + # que déposer des fichiers dans /opt/deploy-staging et lancer les + # scripts sudo tripoli-deploy*.sh, qui installent exactement ce + # qui est attendu (binaire + redémarrage du service nommé, ou + # synchronisation du frontend statique) - rien de plus. Pas de clé # root dans ce pipeline. - name: Déployer tripoli-edge et tripoli-api env: @@ -71,3 +78,6 @@ jobs: 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" 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 diff --git a/.gitignore b/.gitignore index 501b8ee..46c2fc3 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,6 @@ target/ *.pem *.key !**/wireguard/*.example + +# Claude Code local runtime state +.claude/ diff --git a/Cargo.lock b/Cargo.lock index 416d6d1..a3d2466 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2358,6 +2358,7 @@ dependencies = [ "chrono", "futures-util", "mime_guess", + "nix 0.31.3", "password-hash", "rand_core", "serde", diff --git a/README.md b/README.md index 4d2d58f..16d94a2 100644 --- a/README.md +++ b/README.md @@ -31,14 +31,15 @@ tenté, non simulé. | TLS Let's Encrypt | domaines (Forgejo, | routage par Host | lecheminvert, etc.) +-------------------------+ - | - 172.17.0.1:9000 (passerelle docker0) - v - +-------------------------+ - | tripoli-edge | Rust (hyper/tokio), systemd, - | /healthz /metrics | utilisateur système sans - | reverse-proxy | privilège - +-------------------------+ + /api/*, /healthz, /metrics tout le reste + | | + 172.17.0.1:9000 (docker0) /var/www/tripoli-cloud + v (fichiers statiques, + +-------------------------+ file_server de Caddy) + | tripoli-edge | + | /healthz /metrics | Rust (hyper/tokio), systemd, + | reverse-proxy | utilisateur système sans + +-------------------------+ privilège | 127.0.0.1:8081 v @@ -91,12 +92,18 @@ tenté, non simulé. d'Alertmanager, pas de policy Grafana dédiée branchée). - **SSH public toujours ouvert** : choix explicite fait avec 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 | 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 | | 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 | @@ -104,11 +111,11 @@ tenté, non simulé. | 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) | | `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** | | 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é | -| 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 diff --git a/app/README.md b/app/README.md index d6af7e3..96d628a 100644 --- a/app/README.md +++ b/app/README.md @@ -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) -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) +- Auth : inscription, connexion, invité (compte réel réutilisé), + 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 + (``), 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`: - -```json -{ - "$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 }] - } -} +```bash +npm install +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 ``` - -See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories. diff --git a/app/src/App.tsx b/app/src/App.tsx index c5faa72..b8635cd 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -25,8 +25,9 @@ function AppContent() { } function Root() { - const { view } = useSession(); + const { view, restoring } = useSession(); + if (restoring) return null; if (view === 'auth') return ; return ( diff --git a/app/src/api/auth.ts b/app/src/api/auth.ts index 6ad53e8..acaa945 100644 --- a/app/src/api/auth.ts +++ b/app/src/api/auth.ts @@ -1,5 +1,5 @@ import type { User } from '../types'; -import { mockFetch } from './client'; +import { ApiError, apiFetch } from './client'; export interface Credentials { email: string; @@ -10,6 +10,14 @@ export interface SignupPayload extends Credentials { name: string; } +interface UserView { + id: string; + name: string; + email: string; + is_guest: boolean; + created_at: string; +} + function initialsOf(name: string): string { const parts = name.trim().split(/\s+/).filter(Boolean); if (parts.length === 0) return '??'; @@ -19,35 +27,49 @@ function initialsOf(name: string): string { .join(''); } -export async function login({ email }: Credentials): Promise { - return mockFetch({ - id: 'usr_1', - name: 'Leïla Barghouti', - initials: 'LB', - email, - role: 'Administratrice', - isGuest: false, - }); +function toUser(view: UserView): User { + return { + id: view.id, + name: view.name, + initials: initialsOf(view.name || view.email), + email: view.email, + role: view.is_guest ? 'Accès invité' : 'Compte personnel', + isGuest: view.is_guest, + }; } -export async function signup({ name, email }: SignupPayload): Promise { - return mockFetch({ - id: 'usr_new', - name, - initials: initialsOf(name), - email, - role: 'Administratrice', - isGuest: false, +export async function login({ email, password }: Credentials): Promise { + const view = await apiFetch('/api/auth/login', { + method: 'POST', + body: JSON.stringify({ email, password }), }); + return toUser(view); +} + +export async function signup({ name, email, password }: SignupPayload): Promise { + const view = await apiFetch('/api/auth/register', { + method: 'POST', + body: JSON.stringify({ name, email, password }), + }); + return toUser(view); } export async function loginAsGuest(): Promise { - return mockFetch({ - id: 'usr_guest', - name: 'Invité', - initials: 'IN', - email: '', - role: 'Accès invité', - isGuest: true, - }); + const view = await apiFetch('/api/auth/guest', { method: 'POST' }); + return toUser(view); +} + +export async function logout(): Promise { + await apiFetch('/api/auth/logout', { method: 'POST' }); +} + +/** Restaure la session depuis le cookie au chargement de la page. `null` si non connecté. */ +export async function fetchCurrentUser(): Promise { + try { + const view = await apiFetch('/api/auth/me'); + return toUser(view); + } catch (err) { + if (err instanceof ApiError && err.status === 401) return null; + throw err; + } } diff --git a/app/src/api/backups.ts b/app/src/api/backups.ts index 194dc10..5834944 100644 --- a/app/src/api/backups.ts +++ b/app/src/api/backups.ts @@ -1,19 +1,25 @@ -import type { BackupDevice } from '../types'; -import { mockFetch } from './client'; +import { formatBytes, formatRelativeTime } from '../lib/format'; +import type { BackupItem } from '../types'; +import { apiFetch } from './client'; -const STATUS_LABEL: Record = { - 'up-to-date': 'À jour', - syncing: 'En cours', - late: 'Retard 2 j', -}; - -const RAW: Array> = [ - { 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' }, - { device: 'Tablette de consultation', detail: '38 Go · documents en lecture seule', status: 'syncing', icon: 'tablet' }, - { device: 'Coffre secondaire hors site', detail: '812 Go · miroir complet du volume', status: 'late', icon: 'server' }, -]; - -export async function getBackups(): Promise { - return mockFetch(RAW.map((b, i) => ({ ...b, id: `backup_${i}`, statusLabel: STATUS_LABEL[b.status] }))); +interface FileView { + id: string; + filename: string; + size_bytes: number; + created_at: string; +} + +/** + * Pas de suivi multi-appareils (aucun agent n'est déployé sur des + * postes clients) : une "sauvegarde" est ici un fichier déposé avec la + * catégorie "backup", comme n'importe quel autre dépôt. + */ +export async function getBackups(): Promise { + const views = await apiFetch('/api/files?category=backup'); + return views.map((v) => ({ + id: v.id, + name: v.filename, + detail: `${formatBytes(v.size_bytes)} · déposé ${formatRelativeTime(v.created_at)}`, + icon: 'server', + })); } diff --git a/app/src/api/client.ts b/app/src/api/client.ts index 3da00d9..e18eb1a 100644 --- a/app/src/api/client.ts +++ b/app/src/api/client.ts @@ -1,13 +1,42 @@ /** - * Every function in src/api/ is written as if it called `API_BASE`, but - * currently resolves a local mock after a short simulated delay. Swapping - * in a real backend later means rewriting the body of these functions only - * — components and hooks never change. + * Toutes les requêtes passent par la même origine (le frontend est servi + * par le même Caddy qui route /api/* vers tripoli-edge -> tripoli-api) : + * pas besoin de configurer une URL de base, ni de CORS. */ -export const API_BASE = import.meta.env.VITE_API_URL; +export class ApiError extends Error { + status: number; -export function mockFetch(data: T, delayMs = 220): Promise { - return new Promise((resolve) => { - setTimeout(() => resolve(data), delayMs); - }); + constructor(status: number, message: string) { + super(message); + this.status = status; + } +} + +export async function apiFetch(path: string, init?: RequestInit): Promise { + 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; } diff --git a/app/src/api/files.ts b/app/src/api/files.ts index d93f292..e4a9b49 100644 --- a/app/src/api/files.ts +++ b/app/src/api/files.ts @@ -1,33 +1,143 @@ import { THUMB_TONES } from '../lib/gradients'; -import type { FileItem, StatItem } from '../types'; -import { mockFetch } from './client'; +import { formatBytes, formatRelativeTime } from '../lib/format'; +import type { FileItem, FileKind, StatItem } from '../types'; +import { apiFetch } from './client'; -const RAW_FILES: Array> = [ - { name: 'Archives_fiscales_2026.zip', kind: 'archive', extLabel: 'Archive ZIP', size: '2,4 Go', modified: 'il y a 12 min' }, - { name: 'Contrats_signes_2026', kind: 'folder', extLabel: 'Dossier · 412 éléments', size: '18,7 Go', modified: 'il y a 2 h' }, - { name: 'Acte_de_propriete.pdf', kind: 'document', extLabel: 'Document PDF', size: '1,1 Mo', modified: 'hier' }, - { name: 'Backup_poste_principal', kind: 'backup', extLabel: 'Sauvegarde', size: '64 Go', modified: 'hier' }, - { name: 'Conseil_administration.mp4', kind: 'video', extLabel: 'Vidéo MP4', size: '820 Mo', modified: 'il y a 3 jours' }, - { name: 'Scans_administratifs', kind: 'folder', extLabel: 'Dossier · 38 éléments', size: '240 Mo', modified: 'il y a 5 jours' }, - { 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.' }, -]; +interface FileView { + id: string; + filename: string; + content_type: string; + category: string; + size_bytes: number; + created_at: string; +} -export async function getFiles(): Promise { - return mockFetch( - RAW_FILES.map((f, i) => ({ - ...f, - id: `file_${i}`, - thumbGradient: THUMB_TONES[i % THUMB_TONES.length]!, - })), - ); +interface StorageView { + used_bytes: number; + available_bytes: number; +} + +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 = { + 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 { + const qs = category ? `?category=${encodeURIComponent(category)}` : ''; + const views = await apiFetch(`/api/files${qs}`); + return views.map(toFileItem); +} + +export function downloadUrl(fileId: string): string { + return `/api/files/${fileId}`; +} + +export async function deleteFile(fileId: string): Promise { + await apiFetch(`/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 { + 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 { - return mockFetch([ - { id: 'stat_storage', label: 'Espace utilisé', value: '812 Go', note: '188 Go disponibles', icon: 'hard-drive', tone: 'brand' }, - { id: 'stat_docs', label: 'Documents', value: '12 480', note: '+142 cette semaine', icon: 'image', tone: 'brand' }, - { 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' }, + const [files, storage] = await Promise.all([ + apiFetch('/api/files'), + apiFetch('/api/storage'), ]); + + 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', + }, + ]; } diff --git a/app/src/api/notifications.ts b/app/src/api/notifications.ts index 2ea08da..f31c942 100644 --- a/app/src/api/notifications.ts +++ b/app/src/api/notifications.ts @@ -1,13 +1,33 @@ -import type { NotificationItem } from '../types'; -import { mockFetch } from './client'; +import { formatRelativeTime } from '../lib/format'; +import type { IconName, NotificationItem } from '../types'; +import { apiFetch } from './client'; -const RAW: Array> = [ - { icon: 'upload', title: 'Transfert terminé', body: 'Archives_fiscales_2026.zip a été chiffré et scellé.', time: 'il y a 12 min', unread: true }, - { 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 }, - { icon: 'check', title: 'Sauvegarde iPhone', body: '64 Go synchronisés et vérifiés, sans erreur.', time: 'hier, 23:40', unread: false }, - { icon: 'alert', title: 'Espace disque', body: "Il reste 18 % d'espace libre sur le volume principal.", time: 'il y a 2 jours', unread: false }, -]; +interface NotificationView { + id: string; + kind: string; + title: string; + body: string; + created_at: string; + unread: boolean; +} + +const ICON_BY_KIND: Record = { + upload: 'upload', + delete: 'alert', +}; export async function getNotifications(): Promise { - return mockFetch(RAW.map((n, i) => ({ ...n, id: `notif_${i}` }))); + const views = await apiFetch('/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 { + await apiFetch('/api/notifications/read-all', { method: 'POST' }); } diff --git a/app/src/api/photos.ts b/app/src/api/photos.ts index 827a0bd..5d5171d 100644 --- a/app/src/api/photos.ts +++ b/app/src/api/photos.ts @@ -1,28 +1,24 @@ -import { THUMB_TONES, tone } from '../lib/gradients'; -import type { Album, PhotoTile } from '../types'; -import { mockFetch } from './client'; +import { formatDateTime } from '../lib/format'; +import type { PhotoTile } from '../types'; +import { apiFetch } from './client'; -const RAW_ALBUMS: Array> = [ - { name: 'Contrats', count: '412 éléments' }, - { name: 'Titres de propriété', count: '318 éléments' }, - { name: 'Numérisations 1998', count: '219 éléments' }, - { name: "Pièces d'identité", count: '1 204 éléments' }, -]; - -export async function getAlbums(): Promise { - return mockFetch( - RAW_ALBUMS.map((a, i) => ({ ...a, id: `album_${i}`, thumbGradient: THUMB_TONES[i % THUMB_TONES.length]! })), - ); +interface FileView { + id: string; + filename: string; + created_at: string; } -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 { - return mockFetch( - Array.from({ length: 12 }, (_, i) => ({ - id: `tile_${i}`, - label: `16 sept. · ${9 + i}:0${i % 6}`, - thumbGradient: tone(TILE_TONES[i % TILE_TONES.length]!, '#141821'), - })), - ); + const views = await apiFetch('/api/files?category=photo'); + return views.map((v) => ({ + id: v.id, + label: formatDateTime(v.created_at), + url: `/api/files/${v.id}`, + })); } diff --git a/app/src/api/settings.ts b/app/src/api/settings.ts index 7fef49f..434109c 100644 --- a/app/src/api/settings.ts +++ b/app/src/api/settings.ts @@ -1,27 +1,20 @@ -import type { NotificationPrefKey, SettingsValueRow } from '../types'; -import { mockFetch } from './client'; +import { formatDateTime } from '../lib/format'; +import type { NotificationPrefKey, SettingsValueRow, User } from '../types'; -export interface StaticSettingsRows { - language: SettingsValueRow[]; - account: SettingsValueRow[]; -} +const PREFS_STORAGE_KEY = 'tripoli-cloud:notification-prefs'; -export async function getStaticSettingsRows(): Promise { - return mockFetch({ - language: [ - { kind: 'value', id: 'lang', label: "Langue de l'interface", help: 'S’applique à tous les postes connectés à ce coffre.', value: 'Français' }, - { kind: 'value', id: 'region', label: 'Région', help: 'Détermine le format des dates, nombres et unités.', value: 'Tunisie' }, - { 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' }, - ], - account: [ - { 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: 'leila@domaine.tn' }, - { 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' }, - ], - }); +/** + * Section "Compte" construite à partir du vrai utilisateur courant. + * Les champs fictifs de la maquette d'origine (2FA, clé de récupération, + * accès partagés) n'ont aucune fonctionnalité réelle derrière et ont + * été retirés plutôt que simulés. + */ +export function accountRows(user: User): SettingsValueRow[] { + return [ + { kind: 'value', id: 'display-name', label: 'Nom affiché', help: 'Visible sur ce compte.', value: user.name || '—' }, + { kind: 'value', id: 'email', label: 'Adresse e-mail', help: "Sert d'identifiant de connexion.", value: user.email || '—' }, + { kind: 'value', id: 'type', label: 'Type de compte', help: '', value: user.isGuest ? 'Invité (démonstration)' : 'Compte personnel' }, + ]; } export const NOTIFICATION_PREF_LABELS: Record = { @@ -31,6 +24,38 @@ export const NOTIFICATION_PREF_LABELS: Record> { - return mockFetch({ push: true, email: false, weekly: true, shared: true }); +const DEFAULT_PREFS: Record = { + 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 { + try { + const raw = localStorage.getItem(PREFS_STORAGE_KEY); + if (!raw) return DEFAULT_PREFS; + return { ...DEFAULT_PREFS, ...(JSON.parse(raw) as Partial>) }; + } catch { + return DEFAULT_PREFS; + } +} + +export function setNotificationPrefs(prefs: Record): 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)}`; } diff --git a/app/src/api/vault.ts b/app/src/api/vault.ts index 8778c03..f2e285f 100644 --- a/app/src/api/vault.ts +++ b/app/src/api/vault.ts @@ -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 { label: string; 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 { - return mockFetch({ label: '812 Go / 1 To', percent: 81, sealedNote: 'Coffre scellé il y a 2 h' }); + const view = await apiFetch('/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, + }; } diff --git a/app/src/components/auth/AuthLayout.tsx b/app/src/components/auth/AuthLayout.tsx index bed679d..40ac716 100644 --- a/app/src/components/auth/AuthLayout.tsx +++ b/app/src/components/auth/AuthLayout.tsx @@ -2,12 +2,6 @@ import { AuthBackground } from './AuthBackground'; import { AuthPanel } from './AuthPanel'; 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() { return (
@@ -31,17 +25,8 @@ export function AuthLayout() { sous clé.

- Documents sensibles, archives et sauvegardes dans un coffre-fort chiffré de bout en bout. Vous seul - détenez la clé. + Documents, photos et sauvegardes sur un serveur que vous contrôlez.

-
- {AUTH_STATS.map((s) => ( -
-

{s.value}

-

{s.label}

-
- ))} -
@@ -49,7 +34,7 @@ export function AuthLayout() {
Tripoli Cloud — 2026 - Hébergé sur votre propre matériel + Hébergé sur votre propre serveur Sans publicité, sans traçage
diff --git a/app/src/components/auth/AuthPanel.tsx b/app/src/components/auth/AuthPanel.tsx index 016d5d4..8366bb5 100644 --- a/app/src/components/auth/AuthPanel.tsx +++ b/app/src/components/auth/AuthPanel.tsx @@ -16,6 +16,7 @@ export function AuthPanel() { handleSubmit, continueAsGuest, loading, + error, } = useAuthForm(); const isSignup = mode === 'signup'; @@ -29,7 +30,7 @@ export function AuthPanel() { {isSignup ? 'Créer votre espace' : 'Bon retour'}

- {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.'}

@@ -54,6 +55,13 @@ export function AuthPanel() {
+ {error && ( +
+ + {error} +
+ )} + {isSignup && (
diff --git a/app/src/components/backups/BackupRow.tsx b/app/src/components/backups/BackupRow.tsx index 45505a9..f9588d4 100644 --- a/app/src/components/backups/BackupRow.tsx +++ b/app/src/components/backups/BackupRow.tsx @@ -1,31 +1,16 @@ -import type { BackupDevice } from '../../types'; +import type { BackupItem } from '../../types'; import { DataIcon } from '../icons/DataIcon'; -const STATUS_CLASSES: Record = { - 'up-to-date': 'bg-success/16 text-success', - syncing: 'bg-violet/16 text-violet', - late: 'bg-danger/16 text-danger', -}; - -const ICON_TONE: Record = { - '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 }) { +export function BackupRow({ backup }: { backup: BackupItem }) { return (
- +
-

{backup.device}

+

{backup.name}

{backup.detail}

- - {backup.statusLabel} -
); } diff --git a/app/src/components/backups/BackupsView.tsx b/app/src/components/backups/BackupsView.tsx index c3681c7..c890517 100644 --- a/app/src/components/backups/BackupsView.tsx +++ b/app/src/components/backups/BackupsView.tsx @@ -1,20 +1,44 @@ import { useBackups } from '../../hooks/useBackups'; +import { useFileUpload } from '../../hooks/useFileUpload'; +import { UploadZone } from '../dashboard/UploadZone'; import { BackupRow } from './BackupRow'; export function BackupsView() { - const backups = useBackups(); + const { backups, refresh } = useBackups(); + const upload = useFileUpload('backup', refresh); return (
-

4 appareils protégés

+

+ {backups.length} sauvegarde{backups.length === 1 ? '' : 's'} déposée{backups.length === 1 ? '' : 's'} +

Sauvegardes

-
- {backups.map((b) => ( - - ))} -
+ + + + {backups.length === 0 ? ( +

Aucune sauvegarde pour l'instant.

+ ) : ( +
+ {backups.map((b) => ( + + ))} +
+ )}
); } diff --git a/app/src/components/dashboard/CreateMenu.tsx b/app/src/components/dashboard/CreateMenu.tsx index de5169e..bb8ea55 100644 --- a/app/src/components/dashboard/CreateMenu.tsx +++ b/app/src/components/dashboard/CreateMenu.tsx @@ -1,46 +1,24 @@ -import { Plus } from 'lucide-react'; -import { useNavigation } from '../../contexts/NavigationContext'; -import { DataIcon } from '../icons/DataIcon'; -import type { IconName } from '../../types'; +import { Plus, Upload } from 'lucide-react'; -const OPTIONS: Array<{ icon: IconName; label: string }> = [ - { icon: 'folder-plus', label: 'Nouveau dossier' }, - { icon: 'file-pen', label: 'Nouveau document' }, - { icon: 'upload', label: 'Importer des fichiers' }, -]; - -export function CreateMenu() { - const { createOpen, toggleCreate, closeCreate } = useNavigation(); +interface CreateMenuProps { + onImport: () => void; +} +/** + * 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 ( -
- - - {createOpen && ( -
- {OPTIONS.map((opt) => ( - - ))} -
- )} -
+ ); } diff --git a/app/src/components/dashboard/DashboardView.tsx b/app/src/components/dashboard/DashboardView.tsx index 6924f67..9f0c02c 100644 --- a/app/src/components/dashboard/DashboardView.tsx +++ b/app/src/components/dashboard/DashboardView.tsx @@ -1,27 +1,35 @@ +import { useRef } from 'react'; import { useDashboardStats } from '../../hooks/useDashboardStats'; +import { useFileUpload } from '../../hooks/useFileUpload'; import { CreateMenu } from './CreateMenu'; -import { RecentFiles } from './RecentFiles'; +import { RecentFiles, type RecentFilesHandle } from './RecentFiles'; import { StatCard } from './StatCard'; import { UploadZone } from './UploadZone'; -interface DashboardViewProps { - /** Forwarded to UploadZone — undefined lets it run its own demo transfer. */ - uploadProgress?: number; -} +const TODAY = new Date().toLocaleDateString('fr-FR', { + weekday: 'long', + day: 'numeric', + month: 'long', +}); -export function DashboardView({ uploadProgress }: DashboardViewProps) { - const stats = useDashboardStats(); +export function DashboardView() { + const { stats, refresh: refreshStats } = useDashboardStats(); + const filesRef = useRef(null); + const upload = useFileUpload('file', () => { + filesRef.current?.refresh(); + refreshStats(); + }); return (
-

Mercredi 16 septembre

+

{TODAY}

Coffre déverrouillé.

- +
@@ -30,9 +38,21 @@ export function DashboardView({ uploadProgress }: DashboardViewProps) { ))}
- + - +
); } diff --git a/app/src/components/dashboard/FileGridItem.tsx b/app/src/components/dashboard/FileGridItem.tsx index 46716fb..0655cf7 100644 --- a/app/src/components/dashboard/FileGridItem.tsx +++ b/app/src/components/dashboard/FileGridItem.tsx @@ -1,12 +1,39 @@ +import { Trash2 } from 'lucide-react'; +import { downloadUrl } from '../../api/files'; 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 ( - + ); } diff --git a/app/src/components/dashboard/FileListRow.tsx b/app/src/components/dashboard/FileListRow.tsx index 8a9afad..e63b353 100644 --- a/app/src/components/dashboard/FileListRow.tsx +++ b/app/src/components/dashboard/FileListRow.tsx @@ -1,15 +1,43 @@ +import { Trash2 } from 'lucide-react'; +import { downloadUrl } from '../../api/files'; 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 ( - + + ); } diff --git a/app/src/components/dashboard/RecentFiles.tsx b/app/src/components/dashboard/RecentFiles.tsx index 776ed14..97d7125 100644 --- a/app/src/components/dashboard/RecentFiles.tsx +++ b/app/src/components/dashboard/RecentFiles.tsx @@ -1,10 +1,30 @@ import { ArrowUpDown, Grid2x2, List } from 'lucide-react'; +import { forwardRef, useImperativeHandle } from 'react'; import { useRecentFiles } from '../../hooks/useRecentFiles'; import { FileGridItem } from './FileGridItem'; import { FileListRow } from './FileListRow'; -export function RecentFiles() { - const { files, filters, filter, setFilter, sortDesc, toggleSort, gridView, setGridView } = useRecentFiles(); +export interface RecentFilesHandle { + 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(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 (
@@ -61,10 +81,12 @@ export function RecentFiles() {
- {gridView ? ( + {files.length === 0 ? ( +

Aucun fichier pour l'instant.

+ ) : gridView ? (
{files.map((file) => ( - + ))}
) : ( @@ -75,10 +97,10 @@ export function RecentFiles() { Modifié {files.map((file) => ( - + ))} )} ); -} +}); diff --git a/app/src/components/dashboard/UploadZone.tsx b/app/src/components/dashboard/UploadZone.tsx index 2d6d615..80ff67c 100644 --- a/app/src/components/dashboard/UploadZone.tsx +++ b/app/src/components/dashboard/UploadZone.tsx @@ -1,26 +1,47 @@ import { Upload } from 'lucide-react'; -import { useUploadSimulation } from '../../hooks/useUploadSimulation'; -import type { UploadTask } from '../../types'; +import type { ChangeEvent, DragEvent } from 'react'; interface UploadZoneProps { - /** Piloted externally once real transfers exist; undefined = simulate locally. */ - progress?: UploadTask['progress']; + uploading: boolean; + progress: number; + fileName: string; + dragging: boolean; + error: string | null; + inputRef: React.RefObject; + onPick: () => void; + onInputChange: (e: ChangeEvent) => void; + onDragOver: (e: DragEvent) => void; + onDragLeave: () => void; + onDrop: (e: DragEvent) => void; + accept?: string; } -export function UploadZone({ progress: externalProgress }: UploadZoneProps) { - const { uploading, progress, dragging, start, onDragOver, onDragLeave, onDrop } = - useUploadSimulation(externalProgress); - +export function UploadZone({ + uploading, + progress, + fileName, + dragging, + error, + inputRef, + onPick, + onInputChange, + onDragOver, + onDragLeave, + onDrop, + accept, +}: UploadZoneProps) { return (
+ +
@@ -29,9 +50,7 @@ export function UploadZone({ progress: externalProgress }: UploadZoneProps) {

{dragging ? 'Relâchez pour importer' : 'Déposez vos fichiers ici'}

-

- JPEG, RAW, MP4, PDF — jusqu'à 5 Go par fichier. Cliquez pour simuler un transfert. -

+

Cliquez ou déposez un fichier à importer.

Parcourir @@ -41,7 +60,7 @@ export function UploadZone({ progress: externalProgress }: UploadZoneProps) { {uploading && (
- Archives_fiscales_2026.zip + {fileName} {progress} %
@@ -51,10 +70,16 @@ export function UploadZone({ progress: externalProgress }: UploadZoneProps) { />

- {progress >= 100 ? 'Transfert terminé — fichier chiffré et vérifié.' : 'Chiffrement en cours côté client…'} + {progress >= 100 ? 'Transfert terminé.' : 'Envoi en cours…'}

)} + + {error && ( +

e.stopPropagation()}> + {error} +

+ )}
); } diff --git a/app/src/components/layout/TripoliSidebar.tsx b/app/src/components/layout/TripoliSidebar.tsx index 608d8ee..e2933fa 100644 --- a/app/src/components/layout/TripoliSidebar.tsx +++ b/app/src/components/layout/TripoliSidebar.tsx @@ -71,7 +71,6 @@ export function TripoliSidebar() {
-

{summary?.sealedNote ?? ''}

@@ -84,7 +83,7 @@ export function TripoliSidebar() {
- ); - })} - - - -
- {albums.map((album) => ( - - ))} -
-
-

Septembre 2026

+

+ {tiles.length} photo{tiles.length === 1 ? '' : 's'} +

+

Photos

+
+ + + + {tiles.length === 0 ? ( +

Aucune photo pour l'instant.

+ ) : (
{tiles.map((tile) => ( ))}
- + )} ); } diff --git a/app/src/components/settings/SettingsView.tsx b/app/src/components/settings/SettingsView.tsx index be936ca..cee745e 100644 --- a/app/src/components/settings/SettingsView.tsx +++ b/app/src/components/settings/SettingsView.tsx @@ -35,20 +35,6 @@ export function SettingsView() { {rows.map((row) => ( ))} -
- - -
); diff --git a/app/src/contexts/NavigationContext.tsx b/app/src/contexts/NavigationContext.tsx index 399ced3..016eb6e 100644 --- a/app/src/contexts/NavigationContext.tsx +++ b/app/src/contexts/NavigationContext.tsx @@ -26,7 +26,7 @@ const NavigationContext = createContext(null); */ export function NavigationProvider({ children }: { children: ReactNode }) { const [nav, setNav] = useState('dashboard'); - const [settingsTab, setSettingsTab] = useState('language'); + const [settingsTab, setSettingsTab] = useState('account'); const [drawerOpen, setDrawerOpen] = useState(false); const [notifOpen, setNotifOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false); diff --git a/app/src/contexts/SessionContext.tsx b/app/src/contexts/SessionContext.tsx index c1f34eb..ccc9295 100644 --- a/app/src/contexts/SessionContext.tsx +++ b/app/src/contexts/SessionContext.tsx @@ -1,55 +1,76 @@ -import { createContext, useContext, useState, type ReactNode } from 'react'; -import { login as apiLogin, loginAsGuest, signup as apiSignup, type Credentials, type SignupPayload } from '../api/auth'; +import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'; +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'; interface SessionContextValue { view: AppView; user: User | null; + /** true tant que la session n'a pas été restaurée depuis le cookie au premier chargement. */ + restoring: boolean; loading: boolean; + error: string | null; login: (credentials: Credentials) => Promise; signup: (payload: SignupPayload) => Promise; continueAsGuest: () => Promise; - logout: () => void; + logout: () => Promise; } const SessionContext = createContext(null); export function SessionProvider({ children }: { children: ReactNode }) { const [user, setUser] = useState(null); + const [restoring, setRestoring] = useState(true); const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); - const login = async (credentials: Credentials) => { + useEffect(() => { + void fetchCurrentUser() + .then(setUser) + .finally(() => setRestoring(false)); + }, []); + + const run = async (action: () => Promise) => { setLoading(true); + setError(null); try { - setUser(await apiLogin(credentials)); + setUser(await action()); + } catch (err) { + setError(err instanceof Error ? err.message : 'Une erreur est survenue'); } finally { setLoading(false); } }; - const signup = async (payload: SignupPayload) => { - setLoading(true); - try { - setUser(await apiSignup(payload)); - } finally { - setLoading(false); - } - }; + const login = (credentials: Credentials) => run(() => apiLogin(credentials)); + const signup = (payload: SignupPayload) => run(() => apiSignup(payload)); + const continueAsGuest = () => run(() => loginAsGuest()); - const continueAsGuest = async () => { - setLoading(true); - try { - setUser(await loginAsGuest()); - } finally { - setLoading(false); - } + const logout = async () => { + await apiLogout(); + setUser(null); }; - const logout = () => setUser(null); - return ( {children} diff --git a/app/src/hooks/useAuthForm.ts b/app/src/hooks/useAuthForm.ts index 3aeefee..b6bd74c 100644 --- a/app/src/hooks/useAuthForm.ts +++ b/app/src/hooks/useAuthForm.ts @@ -4,7 +4,7 @@ import { isValidEmail, isValidPassword } from '../lib/validators'; import type { AuthMode } from '../types'; export function useAuthForm() { - const { login, signup, continueAsGuest, loading } = useSession(); + const { login, signup, continueAsGuest, loading, error } = useSession(); const [mode, setModeState] = useState('login'); const [name, setName] = useState(''); const [email, setEmail] = useState(''); @@ -46,5 +46,6 @@ export function useAuthForm() { handleSubmit, continueAsGuest, loading, + error, }; } diff --git a/app/src/hooks/useBackups.ts b/app/src/hooks/useBackups.ts index f3c318d..884b07d 100644 --- a/app/src/hooks/useBackups.ts +++ b/app/src/hooks/useBackups.ts @@ -1,11 +1,17 @@ -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { getBackups } from '../api/backups'; -import type { BackupDevice } from '../types'; +import type { BackupItem } from '../types'; export function useBackups() { - const [backups, setBackups] = useState([]); - useEffect(() => { + const [backups, setBackups] = useState([]); + + const refresh = useCallback(() => { void getBackups().then(setBackups); }, []); - return backups; + + useEffect(() => { + refresh(); + }, [refresh]); + + return { backups, refresh }; } diff --git a/app/src/hooks/useDashboardStats.ts b/app/src/hooks/useDashboardStats.ts index 081ecd8..2c02db4 100644 --- a/app/src/hooks/useDashboardStats.ts +++ b/app/src/hooks/useDashboardStats.ts @@ -1,11 +1,17 @@ -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { getDashboardStats } from '../api/files'; import type { StatItem } from '../types'; export function useDashboardStats() { const [stats, setStats] = useState([]); - useEffect(() => { + + const refresh = useCallback(() => { void getDashboardStats().then(setStats); }, []); - return stats; + + useEffect(() => { + refresh(); + }, [refresh]); + + return { stats, refresh }; } diff --git a/app/src/hooks/useFileUpload.ts b/app/src/hooks/useFileUpload.ts new file mode 100644 index 0000000..69637d6 --- /dev/null +++ b/app/src/hooks/useFileUpload.ts @@ -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(null); + const inputRef = useRef(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) => { + 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, + }; +} diff --git a/app/src/hooks/useNotifications.ts b/app/src/hooks/useNotifications.ts index 887ac9d..c3490dc 100644 --- a/app/src/hooks/useNotifications.ts +++ b/app/src/hooks/useNotifications.ts @@ -1,20 +1,29 @@ -import { useEffect, useMemo, useState } from 'react'; -import { getNotifications } from '../api/notifications'; +import { useCallback, useEffect, useState } from 'react'; +import { getNotifications, markAllNotificationsRead } from '../api/notifications'; import type { NotificationItem } from '../types'; export function useNotifications() { const [notifications, setNotifications] = useState([]); - const [allRead, setAllRead] = useState(false); - useEffect(() => { + const refresh = useCallback(() => { void getNotifications().then(setNotifications); }, []); - const items = useMemo( - () => notifications.map((n) => ({ ...n, unread: n.unread && !allRead })), - [notifications, allRead], - ); - const unreadCount = items.filter((n) => n.unread).length; + useEffect(() => { + refresh(); + // Un dépôt ou une suppression ailleurs dans l'appli émet une vraie + // notification côté serveur ; on revérifie à intervalle simple + // 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 }; } diff --git a/app/src/hooks/usePhotos.ts b/app/src/hooks/usePhotos.ts index 4521eb4..4d9272b 100644 --- a/app/src/hooks/usePhotos.ts +++ b/app/src/hooks/usePhotos.ts @@ -1,18 +1,17 @@ -import { useEffect, useState } from 'react'; -import { getAlbums, getPhotoTiles } from '../api/photos'; -import type { Album, PhotoScope, PhotoTile } from '../types'; - -const SCOPES: PhotoScope[] = ['Tout', 'Personnes', 'Lieux']; +import { useCallback, useEffect, useState } from 'react'; +import { getPhotoTiles } from '../api/photos'; +import type { PhotoTile } from '../types'; export function usePhotos() { - const [albums, setAlbums] = useState([]); const [tiles, setTiles] = useState([]); - const [scope, setScope] = useState('Tout'); - useEffect(() => { - void getAlbums().then(setAlbums); + const refresh = useCallback(() => { void getPhotoTiles().then(setTiles); }, []); - return { albums, tiles, scopes: SCOPES, scope, setScope }; + useEffect(() => { + refresh(); + }, [refresh]); + + return { tiles, refresh }; } diff --git a/app/src/hooks/useRecentFiles.ts b/app/src/hooks/useRecentFiles.ts index 7667f67..aba91e3 100644 --- a/app/src/hooks/useRecentFiles.ts +++ b/app/src/hooks/useRecentFiles.ts @@ -1,17 +1,15 @@ -import { useEffect, useMemo, useState } from 'react'; -import { getFiles } from '../api/files'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { deleteFile, getFiles } from '../api/files'; 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 { switch (filter) { case 'Tous': return true; - case 'Dossiers': - return file.kind === 'folder'; case 'Documents': - return file.kind === 'document'; + return file.kind === 'document' || file.kind === 'archive'; case 'Médias': return file.kind === 'video' || file.kind === 'image'; } @@ -23,10 +21,19 @@ export function useRecentFiles() { const [sortDesc, setSortDesc] = useState(true); const [gridView, setGridView] = useState(true); - useEffect(() => { + const refresh = useCallback(() => { 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 filtered = files.filter((f) => matchesFilter(f, filter)); return sortDesc ? filtered : filtered.slice().reverse(); @@ -41,5 +48,7 @@ export function useRecentFiles() { toggleSort: () => setSortDesc((v) => !v), gridView, setGridView, + refresh, + removeFile, }; } diff --git a/app/src/hooks/useSettings.ts b/app/src/hooks/useSettings.ts index a4d17d9..446ddcc 100644 --- a/app/src/hooks/useSettings.ts +++ b/app/src/hooks/useSettings.ts @@ -1,43 +1,39 @@ -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import { - getDefaultNotificationPrefs, - getStaticSettingsRows, + accountRows, + getNotificationPrefs, NOTIFICATION_PREF_LABELS, - type StaticSettingsRows, + setNotificationPrefs, } from '../api/settings'; +import { useSession } from '../contexts/SessionContext'; import type { NotificationPrefKey, SettingsRow, SettingsTabKey } from '../types'; const TAB_TITLES: Record = { - language: 'Langue et région', notifications: 'Préférences de notification', account: 'Paramètres du compte', }; 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: 'notifications', label: 'Notifications' }, ]; export function useSettings(activeTab: SettingsTabKey) { - const [staticRows, setStaticRows] = useState(null); - const [prefs, setPrefs] = useState | null>(null); - - useEffect(() => { - void getStaticSettingsRows().then(setStaticRows); - void getDefaultNotificationPrefs().then(setPrefs); - }, []); + const { user } = useSession(); + const [prefs, setPrefs] = useState>(() => getNotificationPrefs()); 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[] = []; - if (activeTab === 'language') { - rows = staticRows?.language ?? []; - } else if (activeTab === 'account') { - rows = staticRows?.account ?? []; - } else if (prefs) { + if (activeTab === 'account') { + rows = user ? accountRows(user) : []; + } else { rows = (Object.keys(NOTIFICATION_PREF_LABELS) as NotificationPrefKey[]).map((key) => ({ kind: 'toggle', id: key, diff --git a/app/src/hooks/useStorageSummary.ts b/app/src/hooks/useStorageSummary.ts index 79d2fe1..aa75ca6 100644 --- a/app/src/hooks/useStorageSummary.ts +++ b/app/src/hooks/useStorageSummary.ts @@ -3,8 +3,16 @@ import { getStorageSummary, type StorageSummary } from '../api/vault'; export function useStorageSummary() { const [summary, setSummary] = useState(null); + 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; } diff --git a/app/src/hooks/useUploadSimulation.ts b/app/src/hooks/useUploadSimulation.ts deleted file mode 100644 index 3e8099d..0000000 --- a/app/src/hooks/useUploadSimulation.ts +++ /dev/null @@ -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(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 }; -} diff --git a/app/src/lib/format.ts b/app/src/lib/format.ts new file mode 100644 index 0000000..396a877 --- /dev/null +++ b/app/src/lib/format.ts @@ -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', + }); +} diff --git a/app/src/types.ts b/app/src/types.ts index 0f1ce05..deba3e6 100644 --- a/app/src/types.ts +++ b/app/src/types.ts @@ -50,27 +50,17 @@ export interface NotificationItem { unread: boolean; } -export interface Album { - id: string; - name: string; - count: string; - thumbGradient: string; -} - export interface PhotoTile { id: string; label: string; - thumbGradient: string; + /** URL réelle du fichier (`/api/files/:id`), affichée dans une . */ + url: string; } -export type BackupStatus = 'up-to-date' | 'syncing' | 'late'; - -export interface BackupDevice { +export interface BackupItem { id: string; - device: string; + name: string; detail: string; - status: BackupStatus; - statusLabel: string; icon: IconName; } @@ -83,7 +73,7 @@ export interface StatItem { tone: 'brand' | 'success' | 'violet'; } -export type SettingsTabKey = 'language' | 'notifications' | 'account'; +export type SettingsTabKey = 'notifications' | 'account'; export interface SettingsValueRow { kind: 'value'; @@ -108,6 +98,5 @@ export type NotificationPrefKey = 'push' | 'email' | 'weekly' | 'shared'; export type AppView = 'auth' | 'app'; export type NavKey = 'dashboard' | 'photos' | 'backups' | 'settings'; export type AuthMode = 'login' | 'signup'; -export type FileFilter = 'Tous' | 'Dossiers' | 'Documents' | 'Médias'; -export type PhotoScope = 'Tout' | 'Personnes' | 'Lieux'; +export type FileFilter = 'Tous' | 'Documents' | 'Médias'; export type Theme = 'light' | 'dark'; diff --git a/ci/README.md b/ci/README.md index 91478e6..3e54d13 100644 --- a/ci/README.md +++ b/ci/README.md @@ -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 `deploy` existe uniquement pour : -1. recevoir le binaire compilé par `scp` dans `/opt/deploy-staging/` ; -2. lancer `sudo /usr/local/sbin/tripoli-deploy.sh ` - un - script sudo dédié (voir `/etc/sudoers.d/deploy` sur le serveur) qui - n'accepte que `tripoli-edge` ou `tripoli-api` comme argument, installe - le binaire à l'emplacement attendu et redémarre exactement ce - service. Rien d'autre n'est accessible via ce compte. +1. recevoir le binaire compilé (`scp`) ou le build du frontend + (`rsync`) dans `/opt/deploy-staging/` ; +2. lancer un des scripts sudo dédiés (voir `/etc/sudoers.d/deploy` sur + le serveur), chacun figé sur une seule action : + - `tripoli-deploy.sh ` - n'accepte que `tripoli-edge` ou + `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 (`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`, puis `npm ci && npm run build` pour le frontend. - `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 - `tripoli-api` via le compte `deploy`. + build release + build frontend, puis dépôt/redémarrage de + `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 pipeline : ce sont des outils de labo qu'on lance à la main, pas des 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). diff --git a/tripoli-api/Cargo.toml b/tripoli-api/Cargo.toml index 5f4a523..ea74e0b 100644 --- a/tripoli-api/Cargo.toml +++ b/tripoli-api/Cargo.toml @@ -19,6 +19,7 @@ rand_core = { version = "0.6", features = ["std"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } futures-util = "0.3" +nix = { version = "0.31", features = ["fs"] } time = "0.3" tokio-util = { version = "0.7", features = ["io"] } mime_guess = "2" diff --git a/tripoli-api/README.md b/tripoli-api/README.md index b5908d7..69945ed 100644 --- a/tripoli-api/README.md +++ b/tripoli-api/README.md @@ -19,14 +19,19 @@ implémentations RustCrypto). | Route | Auth | Rôle | |---|---|---| | `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/guest` | non | connecte (crée si besoin) le compte invité réel `invite@tripoli.local` | | `POST /api/auth/logout` | oui | invalide la session | | `GET /api/auth/me` | oui | utilisateur courant | -| `GET /api/files` | oui | fichiers de l'utilisateur courant | -| `POST /api/files` | oui | upload multipart, champ `file` | +| `GET /api/files` | oui | fichiers de l'utilisateur courant, `?category=file\|photo\|backup` | +| `POST /api/files` | oui | upload multipart, champs `file` et `category` (optionnel) | | `GET /api/files/{id}` | oui | téléchargement (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 @@ -66,8 +71,18 @@ client - le corps de la requête part effectivement au fil de l'eau. jamais affiché dans un terminal). - tripoli-api tourne en systemd (`deploy/tripoli-api.service`) sous le même utilisateur système sans privilège `tripoli` que tripoli-edge. -- Les migrations (`migrations/0001_init.sql`) s'appliquent automatiquement - au démarrage (`sqlx::migrate!`). +- Les migrations (`migrations/*.sql`) s'appliquent automatiquement au + 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 diff --git a/tripoli-api/migrations/0002_full_features.sql b/tripoli-api/migrations/0002_full_features.sql new file mode 100644 index 0000000..e18b78f --- /dev/null +++ b/tripoli-api/migrations/0002_full_features.sql @@ -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); diff --git a/tripoli-api/src/auth.rs b/tripoli-api/src/auth.rs index 71ce795..06e5b1c 100644 --- a/tripoli-api/src/auth.rs +++ b/tripoli-api/src/auth.rs @@ -5,7 +5,7 @@ use axum::http::request::Parts; use axum::Json; use axum_extra::extract::cookie::{Cookie, SameSite}; use axum_extra::extract::CookieJar; -use chrono::{Duration, Utc}; +use chrono::{DateTime, Duration, Utc}; use rand_core::OsRng; use serde::{Deserialize, Serialize}; use sqlx::PgPool; @@ -16,16 +16,32 @@ use crate::state::SharedState; 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)] pub struct Credentials { pub email: String, pub password: String, } +#[derive(Deserialize)] +pub struct RegisterPayload { + pub name: String, + pub email: String, + pub password: String, +} + #[derive(Serialize)] pub struct UserView { pub id: Uuid, + pub name: String, pub email: String, + pub is_guest: bool, + pub created_at: DateTime, } fn hash_password(password: &str) -> Result { @@ -67,42 +83,53 @@ fn session_cookie(session_id: Uuid, ttl_hours: i64) -> Cookie<'static> { .build() } +fn to_view(id: Uuid, name: String, email: String, created_at: DateTime) -> UserView { + let is_guest = email == GUEST_EMAIL; + UserView { + id, + name, + email, + is_guest, + created_at, + } +} + pub async fn register( State(state): State, jar: CookieJar, - Json(body): Json, + Json(body): Json, ) -> Result<(CookieJar, Json), 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( - "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 user_id = Uuid::new_v4(); - sqlx::query("INSERT INTO users (id, email, password_hash) VALUES ($1, $2, $3)") - .bind(user_id) - .bind(&body.email) - .bind(&password_hash) - .execute(&state.pool) - .await - .map_err(|e| match e { - sqlx::Error::Database(ref db_err) if db_err.is_unique_violation() => { - AppError::EmailAlreadyTaken - } - other => AppError::from(other), - })?; + let created_at: DateTime = sqlx::query_scalar( + "INSERT INTO users (id, name, email, password_hash) VALUES ($1, $2, $3, $4) RETURNING created_at", + ) + .bind(user_id) + .bind(&body.name) + .bind(&body.email) + .bind(&password_hash) + .fetch_one(&state.pool) + .await + .map_err(|e| match e { + 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 jar = jar.add(session_cookie(session_id, state.session_ttl_hours)); Ok(( jar, - Json(UserView { - id: user_id, - email: body.email, - }), + Json(to_view(user_id, body.name, body.email, created_at)), )) } @@ -111,13 +138,14 @@ pub async fn login( jar: CookieJar, Json(body): Json, ) -> Result<(CookieJar, Json), AppError> { - let row: Option<(Uuid, String, String)> = - sqlx::query_as("SELECT id, email, password_hash FROM users WHERE email = $1") - .bind(&body.email) - .fetch_optional(&state.pool) - .await?; + let row: Option<(Uuid, String, String, String, DateTime)> = sqlx::query_as( + "SELECT id, name, email, password_hash, created_at FROM users WHERE email = $1", + ) + .bind(&body.email) + .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); }; @@ -128,7 +156,44 @@ pub async fn login( 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(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, + jar: CookieJar, +) -> Result<(CookieJar, Json), AppError> { + let existing: Option<(Uuid, String, String, DateTime)> = + 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 = 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( @@ -146,11 +211,12 @@ pub async fn logout( Ok(jar.remove(Cookie::from(SESSION_COOKIE))) } -pub async fn me(user: AuthUser) -> Json { - Json(UserView { - id: user.id, - email: user.email, - }) +pub async fn me(State(state): State, user: AuthUser) -> Result, AppError> { + let row: (DateTime,) = sqlx::query_as("SELECT created_at FROM users WHERE id = $1") + .bind(user.id) + .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 @@ -158,6 +224,7 @@ pub async fn me(user: AuthUser) -> Json { /// d'atteindre le handler - le handler n'a jamais à vérifier lui-même. pub struct AuthUser { pub id: Uuid, + pub name: String, pub email: String, } @@ -174,8 +241,8 @@ impl FromRequestParts for AuthUser { .and_then(|c| Uuid::parse_str(c.value()).ok()) .ok_or(AppError::Unauthorized)?; - let row: Option<(Uuid, String)> = sqlx::query_as( - "SELECT users.id, users.email FROM sessions \ + let row: Option<(Uuid, String, String)> = sqlx::query_as( + "SELECT users.id, users.name, users.email FROM sessions \ JOIN users ON users.id = sessions.user_id \ WHERE sessions.id = $1 AND sessions.expires_at > now()", ) @@ -184,7 +251,7 @@ impl FromRequestParts for AuthUser { .await .map_err(AppError::from)?; - let (id, email) = row.ok_or(AppError::Unauthorized)?; - Ok(AuthUser { id, email }) + let (id, name, email) = row.ok_or(AppError::Unauthorized)?; + Ok(AuthUser { id, name, email }) } } diff --git a/tripoli-api/src/files.rs b/tripoli-api/src/files.rs index 1dbedc0..dff7529 100644 --- a/tripoli-api/src/files.rs +++ b/tripoli-api/src/files.rs @@ -1,9 +1,9 @@ use axum::body::Body; -use axum::extract::{Multipart, Path, State}; +use axum::extract::{Multipart, Path, Query, State}; use axum::http::header; use axum::response::Response; use axum::Json; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use sqlx::Row; use tokio::fs::File; use tokio::io::AsyncWriteExt; @@ -12,36 +12,51 @@ use uuid::Uuid; use crate::auth::AuthUser; use crate::error::AppError; +use crate::notifications; use crate::state::SharedState; +const CATEGORIES: [&str; 3] = ["file", "photo", "backup"]; + #[derive(Serialize)] pub struct FileView { pub id: Uuid, pub filename: String, pub content_type: String, + pub category: String, pub size_bytes: i64, pub created_at: chrono::DateTime, } +#[derive(Deserialize)] +pub struct ListQuery { + category: Option, +} + pub async fn list( State(state): State, user: AuthUser, + Query(query): Query, ) -> Result>, AppError> { - let files = sqlx::query_as::<_, (Uuid, String, String, i64, chrono::DateTime)>( - "SELECT id, filename, content_type, size_bytes, created_at \ - FROM files WHERE owner_id = $1 ORDER BY created_at DESC", + let files = sqlx::query_as::<_, (Uuid, String, String, String, i64, chrono::DateTime)>( + "SELECT id, filename, content_type, category, size_bytes, created_at \ + FROM files WHERE owner_id = $1 AND ($2::text IS NULL OR category = $2) \ + ORDER BY created_at DESC", ) .bind(user.id) + .bind(query.category) .fetch_all(&state.pool) .await? .into_iter() - .map(|(id, filename, content_type, size_bytes, created_at)| FileView { - id, - filename, - content_type, - size_bytes, - created_at, - }) + .map( + |(id, filename, content_type, category, size_bytes, created_at)| FileView { + id, + filename, + content_type, + category, + size_bytes, + created_at, + }, + ) .collect(); 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é /// 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). +/// +/// `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( State(state): State, user: AuthUser, mut multipart: Multipart, ) -> Result, AppError> { - let Some(mut field) = multipart - .next_field() - .await - .map_err(|e| AppError::BadRequest(e.to_string()))? - else { - return Err(AppError::BadRequest("champ 'file' manquant".to_string())); + let mut category = "file".to_string(); + + // `Field` emprunte `multipart` : impossible de le faire sortir de la + // boucle sans conflit d'emprunt. On consomme donc les champs + // "category" un par un jusqu'à tomber sur le champ fichier, traité + // 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 @@ -93,22 +134,33 @@ pub async fn upload( tokio::fs::rename(&tmp_path, &final_path).await?; let created_at: chrono::DateTime = sqlx::query( - "INSERT INTO files (id, owner_id, filename, content_type, size_bytes) \ - VALUES ($1, $2, $3, $4, $5) RETURNING created_at", + "INSERT INTO files (id, owner_id, filename, content_type, category, size_bytes) \ + VALUES ($1, $2, $3, $4, $5, $6) RETURNING created_at", ) .bind(file_id) .bind(user.id) .bind(&filename) .bind(&content_type) + .bind(&category) .bind(size_bytes) .fetch_one(&state.pool) .await? .get("created_at"); + notifications::emit( + &state.pool, + user.id, + "upload", + "Transfert terminé", + &format!("{filename} a été déposé."), + ) + .await; + Ok(Json(FileView { id: file_id, filename, content_type, + category, size_bytes, created_at, })) @@ -134,11 +186,19 @@ pub async fn download( let file = File::open(&path).await.map_err(|_| AppError::NotFound)?; let body = Body::from_stream(ReaderStream::new(file)); + // "inline" pour les images : elles doivent s'afficher dans une + // balise , pas déclencher un téléchargement. + let disposition_kind = if content_type.starts_with("image/") { + "inline" + } else { + "attachment" + }; + Ok(Response::builder() .header(header::CONTENT_TYPE, content_type) .header( header::CONTENT_DISPOSITION, - format!("attachment; filename=\"{filename}\""), + format!("{disposition_kind}; filename=\"{filename}\""), ) .body(body) .map_err(|e| AppError::Internal(e.to_string()))?) @@ -149,15 +209,16 @@ pub async fn delete( user: AuthUser, Path(file_id): Path, ) -> Result<(), AppError> { - let result = sqlx::query("DELETE FROM files WHERE id = $1 AND owner_id = $2") - .bind(file_id) - .bind(user.id) - .execute(&state.pool) - .await?; + let filename: Option = + sqlx::query_scalar("DELETE FROM files WHERE id = $1 AND owner_id = $2 RETURNING filename") + .bind(file_id) + .bind(user.id) + .fetch_optional(&state.pool) + .await?; - if result.rows_affected() == 0 { + let Some(filename) = filename else { return Err(AppError::NotFound); - } + }; let path = state.blob_dir.join(file_id.to_string()); // 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. 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(()) } diff --git a/tripoli-api/src/main.rs b/tripoli-api/src/main.rs index 044c910..3923cf0 100644 --- a/tripoli-api/src/main.rs +++ b/tripoli-api/src/main.rs @@ -2,7 +2,9 @@ mod auth; mod config; mod error; mod files; +mod notifications; mod state; +mod storage; use std::sync::Arc; @@ -45,6 +47,7 @@ async fn main() { .route("/healthz", get(healthz)) .route("/api/auth/register", post(auth::register)) .route("/api/auth/login", post(auth::login)) + .route("/api/auth/guest", post(auth::guest)) .route("/api/auth/logout", post(auth::logout)) .route("/api/auth/me", get(auth::me)) .route("/api/files", get(files::list).post(files::upload)) @@ -52,6 +55,16 @@ async fn main() { "/api/files/{id}", 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); tracing::info!(listen_addr = %listen_addr, "tripoli-api démarré"); diff --git a/tripoli-api/src/notifications.rs b/tripoli-api/src/notifications.rs new file mode 100644 index 0000000..0a8a2f2 --- /dev/null +++ b/tripoli-api/src/notifications.rs @@ -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, + 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, + user: AuthUser, +) -> Result>, AppError> { + let rows = sqlx::query_as::<_, (Uuid, String, String, String, chrono::DateTime, Option>)>( + "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, + 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, + user: AuthUser, + Path(notification_id): Path, +) -> 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(()) +} diff --git a/tripoli-api/src/storage.rs b/tripoli-api/src/storage.rs new file mode 100644 index 0000000..22fcb2f --- /dev/null +++ b/tripoli-api/src/storage.rs @@ -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, + user: AuthUser, +) -> Result, 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 { + 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, + })) +}