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)
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

3
.gitignore vendored
View file

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

1
Cargo.lock generated
View file

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

View file

@ -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

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)
- [@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
(`<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`:
```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.

View file

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

View file

@ -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<User> {
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<User> {
return mockFetch({
id: 'usr_new',
name,
initials: initialsOf(name),
email,
role: 'Administratrice',
isGuest: false,
export async function login({ email, password }: Credentials): Promise<User> {
const view = await apiFetch<UserView>('/api/auth/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
});
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> {
return mockFetch({
id: 'usr_guest',
name: 'Invité',
initials: 'IN',
email: '',
role: 'Accès invité',
isGuest: true,
});
const view = await apiFetch<UserView>('/api/auth/guest', { method: 'POST' });
return toUser(view);
}
export async function logout(): Promise<void> {
await apiFetch<void>('/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<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 { mockFetch } from './client';
import { formatBytes, formatRelativeTime } from '../lib/format';
import type { BackupItem } from '../types';
import { apiFetch } from './client';
const STATUS_LABEL: Record<BackupDevice['status'], string> = {
'up-to-date': 'À jour',
syncing: 'En cours',
late: 'Retard 2 j',
};
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' },
{ 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<BackupDevice[]> {
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<BackupItem[]> {
const views = await apiFetch<FileView[]>('/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',
}));
}

View file

@ -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<T>(data: T, delayMs = 220): Promise<T> {
return new Promise((resolve) => {
setTimeout(() => resolve(data), delayMs);
});
constructor(status: number, message: string) {
super(message);
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 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<Omit<FileItem, 'id' | 'thumbGradient'>> = [
{ 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<FileItem[]> {
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<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[]> {
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<FileView[]>('/api/files'),
apiFetch<StorageView>('/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',
},
];
}

View file

@ -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<Omit<NotificationItem, 'id'>> = [
{ 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<string, IconName> = {
upload: 'upload',
delete: 'alert',
};
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 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<Omit<Album, 'id' | 'thumbGradient'>> = [
{ 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<Album[]> {
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<PhotoTile[]> {
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<FileView[]>('/api/files?category=photo');
return views.map((v) => ({
id: v.id,
label: formatDateTime(v.created_at),
url: `/api/files/${v.id}`,
}));
}

View file

@ -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<StaticSettingsRows> {
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<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.' },
};
export async function getDefaultNotificationPrefs(): Promise<Record<NotificationPrefKey, boolean>> {
return mockFetch({ push: true, email: false, weekly: true, shared: true });
const DEFAULT_PREFS: Record<NotificationPrefKey, boolean> = {
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 {
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<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 { 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 (
<div className="relative min-h-screen overflow-hidden bg-[#07080c]">
@ -31,17 +25,8 @@ export function AuthLayout() {
sous clé.
</h1>
<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
détenez la clé.
Documents, photos et sauvegardes sur un serveur que vous contrôlez.
</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>
<AuthPanel />
@ -49,7 +34,7 @@ export function AuthLayout() {
<div className="flex flex-wrap gap-5 text-xs text-white/34">
<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>
</div>
</div>

View file

@ -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'}
</h2>
<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>
<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>
<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 && (
<div className="flex flex-col gap-1.5">
<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">
<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>
</form>
</div>

View file

@ -1,31 +1,16 @@
import type { BackupDevice } from '../../types';
import type { BackupItem } from '../../types';
import { DataIcon } from '../icons/DataIcon';
const STATUS_CLASSES: Record<BackupDevice['status'], string> = {
'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 }) {
export function BackupRow({ backup }: { backup: BackupItem }) {
return (
<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} />
</span>
<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>
</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>
);
}

View file

@ -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 (
<div className="flex max-w-[960px] flex-col gap-6">
<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>
</div>
<div className="flex flex-col gap-3">
{backups.map((b) => (
<BackupRow key={b.id} backup={b} />
))}
</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}
/>
{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>
);
}

View file

@ -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 (
<div className="relative">
<button
type="button"
onClick={toggleCreate}
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} />
Créer
</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>
<button
type="button"
onClick={onImport}
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} />
Importer
</button>
);
}

View file

@ -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<RecentFilesHandle>(null);
const upload = useFileUpload('file', () => {
filesRef.current?.refresh();
refreshStats();
});
return (
<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">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]">
Coffre déverrouillé.
</h1>
</div>
<CreateMenu />
<CreateMenu onImport={upload.pickFiles} />
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(196px,1fr))] gap-3.5">
@ -30,9 +38,21 @@ export function DashboardView({ uploadProgress }: DashboardViewProps) {
))}
</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>
);
}

View file

@ -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 (
<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 }}>
{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">
{file.extLabel}
</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>
<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}
</p>
</div>
</div>
</a>
);
}

View file

@ -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 (
<div className="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 rounded-[10px]" style={{ background: file.thumbGradient }} />
<a
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">
<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>
</div>
<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>
</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 { 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<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 (
<div className="overflow-hidden rounded-[18px] border border-line bg-panel backdrop-blur-md">
@ -61,10 +81,12 @@ export function RecentFiles() {
</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]">
{files.map((file) => (
<FileGridItem key={file.id} file={file} />
<FileGridItem key={file.id} file={file} onDelete={handleDelete} />
))}
</div>
) : (
@ -75,10 +97,10 @@ export function RecentFiles() {
<span className="w-[120px] flex-none">Modifié</span>
</div>
{files.map((file) => (
<FileListRow key={file.id} file={file} />
<FileListRow key={file.id} file={file} onDelete={handleDelete} />
))}
</div>
)}
</div>
);
}
});

View file

@ -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<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) {
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 (
<div
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
onClick={start}
onClick={onPick}
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'
}`}
>
<input ref={inputRef} type="file" accept={accept} className="hidden" onChange={onInputChange} />
<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">
<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">
{dragging ? 'Relâchez pour importer' : 'Déposez vos fichiers ici'}
</p>
<p className="m-0 text-[13px] leading-relaxed text-ink-soft">
JPEG, RAW, MP4, PDF — jusqu'à 5 Go par fichier. Cliquez pour simuler un transfert.
</p>
<p className="m-0 text-[13px] leading-relaxed text-ink-soft">Cliquez ou déposez un fichier à importer.</p>
</div>
<span className="flex-none rounded-[11px] border border-line bg-field px-4 py-2.5 text-[13px] font-semibold text-ink">
Parcourir
@ -41,7 +60,7 @@ export function UploadZone({ progress: externalProgress }: UploadZoneProps) {
{uploading && (
<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">
<span>Archives_fiscales_2026.zip</span>
<span>{fileName}</span>
<span className="text-brand-soft">{progress} %</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-field">
@ -51,10 +70,16 @@ export function UploadZone({ progress: externalProgress }: UploadZoneProps) {
/>
</div>
<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>
</div>
)}
{error && (
<p className="m-0 mt-4 text-[12.5px] font-semibold text-danger" onClick={(e) => e.stopPropagation()}>
{error}
</p>
)}
</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="h-[7px] rounded-full bg-brand-gradient" style={{ width: `${summary?.percent ?? 0}%` }} />
</div>
<p className="m-0 text-[11.5px] leading-relaxed text-ink-soft">{summary?.sealedNote ?? ''}</p>
</div>
<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>
<button
type="button"
onClick={logout}
onClick={() => void logout()}
aria-label="Se déconnecter"
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() {
const { notifOpen, toggleNotif, closeNotif } = useNavigation();
const { notifications, unreadCount, markAllRead } = useNotifications();
const { notifications, unreadCount, markAllRead, refresh } = useNotifications();
return (
<div className="relative">
<button
type="button"
onClick={toggleNotif}
onClick={() => {
refresh();
toggleNotif();
}}
aria-label="Notifications"
aria-expanded={notifOpen}
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 }) {
return (
<div
className="relative aspect-square cursor-pointer overflow-hidden rounded-[13px] border border-line transition-transform hover:scale-[1.025]"
style={{ background: tile.thumbGradient }}
<a
href={tile.url}
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">
{tile.label}
</span>
</div>
</a>
);
}

View file

@ -1,50 +1,45 @@
import { usePhotos } from '../../hooks/usePhotos';
import { AlbumCard } from './AlbumCard';
import { useFileUpload } from '../../hooks/useFileUpload';
import { UploadZone } from '../dashboard/UploadZone';
import { PhotoTile } from './PhotoTile';
export function PhotosView() {
const { albums, tiles, scopes, scope, setScope } = usePhotos();
const { tiles, refresh } = usePhotos();
const upload = useFileUpload('photo', refresh);
return (
<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>
<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">
{tiles.map((tile) => (
<PhotoTile key={tile.id} tile={tile} />
))}
</div>
</div>
)}
</div>
);
}

View file

@ -35,20 +35,6 @@ export function SettingsView() {
{rows.map((row) => (
<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>
);

View file

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

View file

@ -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<void>;
signup: (payload: SignupPayload) => Promise<void>;
continueAsGuest: () => Promise<void>;
logout: () => void;
logout: () => Promise<void>;
}
const SessionContext = createContext<SessionContextValue | null>(null);
export function SessionProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [restoring, setRestoring] = useState(true);
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);
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 (
<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}
</SessionContext.Provider>

View file

@ -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<AuthMode>('login');
const [name, setName] = useState('');
const [email, setEmail] = useState('');
@ -46,5 +46,6 @@ export function useAuthForm() {
handleSubmit,
continueAsGuest,
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 type { BackupDevice } from '../types';
import type { BackupItem } from '../types';
export function useBackups() {
const [backups, setBackups] = useState<BackupDevice[]>([]);
useEffect(() => {
const [backups, setBackups] = useState<BackupItem[]>([]);
const refresh = useCallback(() => {
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 type { StatItem } from '../types';
export function useDashboardStats() {
const [stats, setStats] = useState<StatItem[]>([]);
useEffect(() => {
const refresh = useCallback(() => {
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 { 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<NotificationItem[]>([]);
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 };
}

View file

@ -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<Album[]>([]);
const [tiles, setTiles] = useState<PhotoTile[]>([]);
const [scope, setScope] = useState<PhotoScope>('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 };
}

View file

@ -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,
};
}

View file

@ -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<SettingsTabKey, string> = {
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<StaticSettingsRows | null>(null);
const [prefs, setPrefs] = useState<Record<NotificationPrefKey, boolean> | null>(null);
useEffect(() => {
void getStaticSettingsRows().then(setStaticRows);
void getDefaultNotificationPrefs().then(setPrefs);
}, []);
const { user } = useSession();
const [prefs, setPrefs] = useState<Record<NotificationPrefKey, boolean>>(() => 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,

View file

@ -3,8 +3,16 @@ import { getStorageSummary, type StorageSummary } from '../api/vault';
export function useStorageSummary() {
const [summary, setSummary] = useState<StorageSummary | null>(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;
}

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;
}
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 <img>. */
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';

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
`deploy` existe uniquement pour :
1. recevoir le binaire compilé par `scp` dans `/opt/deploy-staging/` ;
2. lancer `sudo /usr/local/sbin/tripoli-deploy.sh <service>` - 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 <service>` - 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).

View file

@ -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"

View file

@ -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

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_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<Utc>,
}
fn hash_password(password: &str) -> Result<String, AppError> {
@ -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<Utc>) -> UserView {
let is_guest = email == GUEST_EMAIL;
UserView {
id,
name,
email,
is_guest,
created_at,
}
}
pub async fn register(
State(state): State<SharedState>,
jar: CookieJar,
Json(body): Json<Credentials>,
Json(body): Json<RegisterPayload>,
) -> 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(
"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<Utc> = 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<Credentials>,
) -> Result<(CookieJar, Json<UserView>), 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<Utc>)> = 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<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(
@ -146,11 +211,12 @@ pub async fn logout(
Ok(jar.remove(Cookie::from(SESSION_COOKIE)))
}
pub async fn me(user: AuthUser) -> Json<UserView> {
Json(UserView {
id: user.id,
email: user.email,
})
pub async fn me(State(state): State<SharedState>, user: AuthUser) -> Result<Json<UserView>, AppError> {
let row: (DateTime<Utc>,) = 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<UserView> {
/// 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<SharedState> 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<SharedState> 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 })
}
}

View file

@ -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<chrono::Utc>,
}
#[derive(Deserialize)]
pub struct ListQuery {
category: Option<String>,
}
pub async fn list(
State(state): State<SharedState>,
user: AuthUser,
Query(query): Query<ListQuery>,
) -> Result<Json<Vec<FileView>>, AppError> {
let files = sqlx::query_as::<_, (Uuid, String, String, i64, chrono::DateTime<chrono::Utc>)>(
"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<chrono::Utc>)>(
"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<SharedState>,
user: AuthUser,
mut multipart: Multipart,
) -> Result<Json<FileView>, 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<chrono::Utc> = 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 <img>, 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<Uuid>,
) -> 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<String> =
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(())
}

View file

@ -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é");

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