+ {/* Header */}
+
+
+
+
Nuage
+
Gestionnaire de fichiers
+
+
+
+
+ {canCreateSubfolder && (
+
+ )}
+
+
+
+
+
+
+ {/* Breadcrumb + Explorer */}
+
+
navigateTo(null),
+ active: breadcrumb.length === 0,
+ },
+ ...(breadcrumb.length > 3
+ ? [
+ {
+ key: 'ellipsis',
+ label: '···',
+ onClick: () => navigateTo(breadcrumb[0].id),
+ active: false,
+ },
+ ...breadcrumb.slice(-2).map((folder, i) => ({
+ key: folder.id,
+ label: folder.name,
+ onClick: () => navigateTo(folder.id),
+ active: i === 1,
+ })),
+ ]
+ : breadcrumb.map((folder, i) => ({
+ key: folder.id,
+ label: folder.name,
+ onClick: () => navigateTo(folder.id),
+ active: i === breadcrumb.length - 1,
+ }))
+ ),
+ ]}
+ />
+
+ {/* Upload queue indicator */}
+ {uploading && uploadQueue.length > 0 && (
+
+
Téléversement en cours…
+
+ {uploadQueue.map((f, i) => (
+
+ {f.done ? : }
+ {f.name}
+
+ ))}
+
+
+ )}
+
+ {/* Explorer area */}
+
+ { e.preventDefault(); if (!dragOverFolder) setDragOver(true); }}
+ onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget)) { setDragOver(false); setDragOverFolder(null); } }}
+ onDrop={handleDrop}
+ >
+ {viewMode === 'list' ? (
+ /* List view */
+
{
+ if (item._type === 'folder') navigateTo(item.id);
+ else if (isViewable(item.mime_type)) handleOpenViewer({ ...item, type: 'file', name: item.display_name });
+ }}
+ getRowProps={getRowProps}
+ renderActions={(item) => (
+
+ )}
+ showShareBadge
+ emptyMessage="Ce dossier est vide"
+ emptyDescription='Glissez des fichiers ici ou cliquez sur « Téléverser »'
+ size="sm"
+ />
+ ) : loading ? (
+
+
+
+ ) : allFoldersSorted.length === 0 && allFilesSorted.length === 0 ? (
+
+
+
Ce dossier est vide
+
Glissez des fichiers ici ou cliquez sur « Téléverser »
+
+ ) : (
+ /* Grid view */
+
+ {allFoldersSorted.map(folder => (
+
navigateTo(folder.id)}
+ onDragOver={e => { e.preventDefault(); e.stopPropagation(); setDragOver(false); setDragOverFolder(folder.id); }}
+ onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget)) setDragOverFolder(null); }}
+ onDrop={e => { e.preventDefault(); e.stopPropagation(); setDragOverFolder(null); if (e.dataTransfer.files?.length) uploadFiles(e.dataTransfer.files, folder.id); }}
+ >
+
+
{folder.name}
+ {dragOverFolder === folder.id && (
+
Déposer ici
+ )}
+ {folder.has_active_share && dragOverFolder !== folder.id && (
+
+ )}
+
+
+ ))}
+ {allFilesSorted.map(file => {
+ const FileIcon = getFileIcon(file.mime_type);
+ const fileColor = getFileColor(file.mime_type);
+ const canPreview = isViewable(file.mime_type);
+ return (
+
canPreview && handleOpenViewer({ ...file, type: 'file', name: file.display_name })}
+ >
+
+
{file.display_name}
+ {file.has_active_share && (
+
+ )}
+
+
+ );
+ })}
+
+ )}
+
+
+
+
+ {/* Context menu */}
+ {contextMenu && (
+
e.stopPropagation()}
+ >
+ {contextMenuActions(contextMenu.item).map((action, i) => (
+
+ ))}
+
+ )}
+
+ {/* Share panel (side panel) */}
+ {shareTarget && (
+
+
setShareTarget(null)} />
+
+ setShareTarget(null)} />
+
+
+ )}
+
+ {/* New folder modal */}
+ {showNewFolder && (
+
{ setShowNewFolder(false); setNewFolderName(''); }}
+ title="Nouveau dossier"
+ size="sm"
+ footer={
+
+
+
+
+ }
+ >
+ setNewFolderName(v)}
+ placeholder="Nom du dossier"
+ onKeyDown={e => e.key === 'Enter' && handleCreateFolder()}
+ />
+
+ )}
+
+ {/* Rename modal */}
+ {renameTarget && (
+
{ setRenameTarget(null); setRenameName(''); }}
+ title="Renommer"
+ size="sm"
+ footer={
+
+
+
+
+ }
+ >
+ setRenameName(v)}
+ onKeyDown={e => e.key === 'Enter' && handleRenameConfirm()}
+ />
+
+ )}
+
+ {/* Delete confirmation modal */}
+ {deleteTarget && (
+
setDeleteTarget(null)}
+ title="Confirmer la suppression"
+ size="sm"
+ footer={
+
+
+
+
+ }
+ >
+
+
+
+ Voulez-vous vraiment supprimer{' '}
+ « {deleteTarget.name} »
+ {deleteTarget.type === 'folder' && ' et tout son contenu'} ? Cette action est irréversible.
+
+
+
+ )}
+
+ {/* File viewer modal */}
+ {viewerFile && (
+
+ )}
+
+ {/* Move modal */}
+ {moveTarget && (
+
setMoveTarget(null)}
+ title="Déplacer vers…"
+ size="sm"
+ footer={
+
+ }
+ >
+
+
+ {allFolders
+ .filter(f => f.id !== moveTarget.id)
+ .map(f => (
+
+ ))}
+
+
+ )}
+
+ );
+};
+
+export default ExplorerPage;
diff --git a/src/modules/nuage/admin/SharesPage.js b/src/modules/nuage/admin/SharesPage.js
new file mode 100644
index 0000000..38ea724
--- /dev/null
+++ b/src/modules/nuage/admin/SharesPage.js
@@ -0,0 +1,254 @@
+'use client';
+
+import React, { useState, useEffect } from 'react';
+import { useRouter } from 'next/navigation';
+import {
+ Link02Icon,
+ Delete02Icon,
+ UserCircle02Icon,
+ Folder01Icon,
+ FileSecurityIcon,
+ Copy01Icon,
+} from '../../../shared/Icons.js';
+import { Card, Badge, Button, Table, FilterTabs } from '../../../shared/components';
+import { useToast } from '@hykocx/zen/toast';
+
+function formatDate(dateStr) {
+ if (!dateStr) return '—';
+ return new Date(dateStr).toLocaleDateString('fr-CA', {
+ year: 'numeric', month: 'short', day: 'numeric',
+ });
+}
+
+function isExpired(share) {
+ if (!share.expires_at) return false;
+ return new Date(share.expires_at) < new Date();
+}
+
+const SharesPage = ({ user }) => {
+ const router = useRouter();
+ const toast = useToast();
+ const [shares, setShares] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [filter, setFilter] = useState('all'); // 'all' | 'user' | 'anonymous'
+ const [revoking, setRevoking] = useState(null);
+
+ useEffect(() => {
+ loadShares();
+ }, []);
+
+ const loadShares = async () => {
+ setLoading(true);
+ try {
+ const res = await fetch('/zen/api/admin/nuage/shares', { credentials: 'include' });
+ const data = await res.json();
+ if (data.success) {
+ setShares(data.shares);
+ } else {
+ toast.error(data.error || 'Erreur lors du chargement');
+ }
+ } catch (e) {
+ console.error(e);
+ toast.error('Erreur réseau');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleRevoke = async (id) => {
+ setRevoking(id);
+ try {
+ const res = await fetch(`/zen/api/admin/nuage/shares?id=${id}`, {
+ method: 'DELETE',
+ credentials: 'include',
+ });
+ const data = await res.json();
+ if (data.success) {
+ toast.success('Partage révoqué');
+ loadShares();
+ } else {
+ toast.error(data.error || 'Erreur');
+ }
+ } catch (e) {
+ toast.error('Erreur réseau');
+ } finally {
+ setRevoking(null);
+ }
+ };
+
+ const copyLink = (token) => {
+ const url = `${window.location.origin}/zen/nuage/partage/${token}`;
+ navigator.clipboard.writeText(url).then(() => toast.success('Lien copié !'));
+ };
+
+ const filtered = shares.filter(s => {
+ if (filter === 'all') return true;
+ return s.share_type === filter;
+ });
+
+ const getShareStatus = (share) => {
+ if (!share.is_active) return { label: 'Révoqué', variant: 'default' };
+ if (isExpired(share)) return { label: 'Expiré', variant: 'danger' };
+ return { label: 'Actif', variant: 'success' };
+ };
+
+ const columns = [
+ {
+ key: 'type',
+ label: 'Type',
+ render: (share) => (
+
+ {share.share_type === 'user' ? (
+
+ ) : (
+
+ )}
+
+ {share.share_type === 'user' ? 'Utilisateur' : 'Lien anonyme'}
+
+
+ ),
+ skeleton: { height: 'h-4', width: '60%' },
+ },
+ {
+ key: 'recipient',
+ label: 'Destinataire',
+ render: (share) => (
+
+ {share.share_type === 'user' && share.name ? (
+ <>
+
+ {share.name}
+
+
{share.email}
+ >
+ ) : (
+
—
+ )}
+
+ ),
+ skeleton: { height: 'h-4', width: '70%' },
+ },
+ {
+ key: 'target',
+ label: 'Élément partagé',
+ render: (share) => (
+
+ {share.target_type === 'folder' ? (
+
+ ) : (
+
+ )}
+
+ {share.target_name || share.target_id}
+
+
+ ),
+ skeleton: { height: 'h-4', width: '50%' },
+ },
+ {
+ key: 'permission',
+ label: 'Permission',
+ render: (share) => (
+
+ {share.permission === 'reader' ? 'Lecteur' : 'Collaborateur'}
+
+ ),
+ skeleton: { height: 'h-4', width: '40%' },
+ },
+ {
+ key: 'expires_at',
+ label: 'Expiration',
+ render: (share) => (
+
+ {share.expires_at ? formatDate(share.expires_at) : 'Aucune'}
+
+ ),
+ skeleton: { height: 'h-4', width: '40%' },
+ },
+ {
+ key: 'status',
+ label: 'Statut',
+ render: (share) => {
+ const status = getShareStatus(share);
+ return
{status.label};
+ },
+ skeleton: { height: 'h-4', width: '30%' },
+ },
+ {
+ key: 'actions',
+ label: '',
+ render: (share) => (
+
+ {share.share_type === 'anonymous' && share.is_active && !isExpired(share) && (
+
+ ),
+ skeleton: { height: 'h-4', width: '20%' },
+ },
+ ];
+
+ const activeCount = shares.filter(s => s.is_active && !isExpired(s)).length;
+
+ return (
+
+ {/* Header */}
+
+
+
Partages
+
Gérez les liens et accès partagés
+
+
router.push('/admin/nuage/explorateur')}
+ icon={}
+ >
+ Explorateur
+
+
+
+ {/* Filter tabs + Table */}
+
+
+
+ {!loading && filtered.length === 0 ? (
+
+
+
Aucun partage trouvé
+
+ ) : (
+
+ )}
+
+
+
+ );
+};
+
+export default SharesPage;
diff --git a/src/modules/nuage/admin/index.js b/src/modules/nuage/admin/index.js
new file mode 100644
index 0000000..a7e0199
--- /dev/null
+++ b/src/modules/nuage/admin/index.js
@@ -0,0 +1,5 @@
+/**
+ * Nuage Admin Components
+ */
+export { default as ExplorerPage } from './ExplorerPage.js';
+export { default as SharesPage } from './SharesPage.js';
diff --git a/src/modules/nuage/api.js b/src/modules/nuage/api.js
new file mode 100644
index 0000000..29b68f8
--- /dev/null
+++ b/src/modules/nuage/api.js
@@ -0,0 +1,679 @@
+/**
+ * Nuage Module API Routes
+ * All API endpoints for the Nuage file manager
+ */
+
+import { validateSession } from '../../features/auth/lib/session.js';
+import { cookies } from 'next/headers';
+import { getSessionCookieName } from '../../shared/lib/appConfig.js';
+import {
+ getFolderContents,
+ getFolderBreadcrumb,
+ getFolderById,
+ createFolder,
+ renameFolder,
+ moveFolder,
+ deleteFolder,
+ getFolderItemCount,
+ getFileById,
+ uploadNuageFile,
+ renameFile,
+ moveFile,
+ deleteNuageFile,
+ proxyNuageFile,
+ getFolderUploadedSize,
+ getShareByToken,
+ getSharesForTarget,
+ getSharesForUser,
+ getAllActiveShares,
+ createShare,
+ updateShare,
+ revokeShare,
+ isShareValid,
+ searchUsers,
+ getSharedFolderContents,
+ getSharedBreadcrumb,
+ isFileInShare,
+ isFolderInShare,
+ getPasswordCookieName,
+ signPasswordToken,
+} from './crud.js';
+import { sendEmail } from '@hykocx/zen/email';
+import { render } from '@react-email/components';
+import { NuageShareEmail } from './email/index.js';
+
+const COOKIE_NAME = getSessionCookieName();
+
+const MAX_FILE_SIZE_BYTES = 500 * 1024 * 1024; // 500 MB hard cap
+
+const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+
+// ─── Auth helpers ────────────────────────────────────────────────────────────
+
+async function getAdminSession() {
+ const cookieStore = await cookies();
+ const sessionToken = cookieStore.get(COOKIE_NAME)?.value;
+ if (!sessionToken) return null;
+ const session = await validateSession(sessionToken);
+ if (!session?.user?.id || session.user.role !== 'admin') return null;
+ return session;
+}
+
+async function getUserSession() {
+ const cookieStore = await cookies();
+ const sessionToken = cookieStore.get(COOKIE_NAME)?.value;
+ if (!sessionToken) return null;
+ const session = await validateSession(sessionToken);
+ if (!session?.user?.id) return null;
+ return session;
+}
+
+// ─── Explorer: Folder Contents ───────────────────────────────────────────────
+
+async function handleGetContents(request) {
+ try {
+ const session = await getAdminSession();
+ if (!session) return { success: false, error: 'Unauthorized' };
+
+ const url = new URL(request.url);
+ const folderId = url.searchParams.get('folder') || null;
+
+ const contents = await getFolderContents(folderId);
+ const breadcrumb = await getFolderBreadcrumb(folderId);
+ const currentFolder = folderId ? await getFolderById(folderId) : null;
+
+ return { success: true, ...contents, breadcrumb, currentFolder };
+ } catch (error) {
+ console.error('handleGetContents error:', error);
+ return { success: false, error: error.message };
+ }
+}
+
+// ─── Folders ─────────────────────────────────────────────────────────────────
+
+async function handleCreateFolder(request) {
+ try {
+ const session = await getAdminSession();
+ if (!session) return { success: false, error: 'Unauthorized' };
+
+ const body = await request.json();
+ const { name, parentId } = body;
+ if (!name?.trim()) return { success: false, error: 'Le nom du dossier est requis' };
+
+ const folder = await createFolder(name, parentId || null);
+ return { success: true, folder };
+ } catch (error) {
+ console.error('handleCreateFolder error:', error);
+ return { success: false, error: error.message };
+ }
+}
+
+async function handleUpdateFolder(request) {
+ try {
+ const session = await getAdminSession();
+ if (!session) return { success: false, error: 'Unauthorized' };
+
+ const body = await request.json();
+ const { id, action, name, newParentId } = body;
+ if (!id) return { success: false, error: 'ID du dossier requis' };
+
+ let folder;
+ if (action === 'rename') {
+ if (!name?.trim()) return { success: false, error: 'Le nom est requis' };
+ folder = await renameFolder(id, name);
+ } else if (action === 'move') {
+ folder = await moveFolder(id, newParentId || null);
+ } else {
+ return { success: false, error: 'Action inconnue' };
+ }
+
+ return { success: true, folder };
+ } catch (error) {
+ console.error('handleUpdateFolder error:', error);
+ return { success: false, error: error.message };
+ }
+}
+
+async function handleDeleteFolder(request) {
+ try {
+ const session = await getAdminSession();
+ if (!session) return { success: false, error: 'Unauthorized' };
+
+ const url = new URL(request.url);
+ const id = url.searchParams.get('id');
+ if (!id) return { success: false, error: 'ID du dossier requis' };
+
+ const count = await getFolderItemCount(id);
+ const forceDelete = url.searchParams.get('force') === 'true';
+ if (count > 0 && !forceDelete) {
+ return { success: false, error: 'FOLDER_NOT_EMPTY', itemCount: count };
+ }
+
+ await deleteFolder(id);
+ return { success: true };
+ } catch (error) {
+ console.error('handleDeleteFolder error:', error);
+ return { success: false, error: error.message };
+ }
+}
+
+// ─── Files ───────────────────────────────────────────────────────────────────
+
+async function handleUploadFile(request) {
+ try {
+ const session = await getAdminSession();
+ if (!session) return { success: false, error: 'Unauthorized' };
+
+ const formData = await request.formData();
+ const file = formData.get('file');
+ const folderId = formData.get('folderId') || null;
+
+ if (!file || typeof file === 'string') {
+ return { success: false, error: 'Aucun fichier fourni' };
+ }
+
+ if (file.size > MAX_FILE_SIZE_BYTES) {
+ return { success: false, error: 'Fichier trop volumineux (max 500 Mo)' };
+ }
+
+ const buffer = Buffer.from(await file.arrayBuffer());
+ const nuageFile = await uploadNuageFile(
+ folderId,
+ buffer,
+ file.name,
+ file.type || 'application/octet-stream',
+ buffer.length
+ );
+
+ return { success: true, file: nuageFile };
+ } catch (error) {
+ console.error('handleUploadFile error:', error);
+ return { success: false, error: error.message };
+ }
+}
+
+async function handleUpdateFile(request) {
+ try {
+ const session = await getAdminSession();
+ if (!session) return { success: false, error: 'Unauthorized' };
+
+ const body = await request.json();
+ const { id, action, displayName, newFolderId } = body;
+ if (!id) return { success: false, error: 'ID du fichier requis' };
+
+ let file;
+ if (action === 'rename') {
+ if (!displayName?.trim()) return { success: false, error: 'Le nom est requis' };
+ file = await renameFile(id, displayName);
+ } else if (action === 'move') {
+ file = await moveFile(id, newFolderId || null);
+ } else {
+ return { success: false, error: 'Action inconnue' };
+ }
+
+ return { success: true, file };
+ } catch (error) {
+ console.error('handleUpdateFile error:', error);
+ return { success: false, error: error.message };
+ }
+}
+
+async function handleDeleteFile(request) {
+ try {
+ const session = await getAdminSession();
+ if (!session) return { success: false, error: 'Unauthorized' };
+
+ const url = new URL(request.url);
+ const id = url.searchParams.get('id');
+ if (!id) return { success: false, error: 'ID du fichier requis' };
+
+ await deleteNuageFile(id);
+ return { success: true };
+ } catch (error) {
+ console.error('handleDeleteFile error:', error);
+ return { success: false, error: error.message };
+ }
+}
+
+async function handleGetDownloadUrl(request) {
+ try {
+ const session = await getAdminSession();
+ if (!session) return { success: false, error: 'Unauthorized' };
+
+ const url = new URL(request.url);
+ const id = url.searchParams.get('id');
+ if (!id) return { success: false, error: 'ID du fichier requis' };
+
+ return await proxyNuageFile(id);
+ } catch (error) {
+ console.error('handleGetDownloadUrl error:', error);
+ return { success: false, error: error.message };
+ }
+}
+
+// ─── Shares ──────────────────────────────────────────────────────────────────
+
+async function handleGetShares(request) {
+ try {
+ const session = await getAdminSession();
+ if (!session) return { success: false, error: 'Unauthorized' };
+
+ const url = new URL(request.url);
+ const targetId = url.searchParams.get('targetId');
+ const targetType = url.searchParams.get('targetType');
+
+ if (targetId && targetType) {
+ const shares = await getSharesForTarget(targetId, targetType);
+ return { success: true, shares };
+ }
+
+ const shares = await getAllActiveShares();
+ return { success: true, shares };
+ } catch (error) {
+ console.error('handleGetShares error:', error);
+ return { success: false, error: error.message };
+ }
+}
+
+async function handleCreateShare(request) {
+ try {
+ const session = await getAdminSession();
+ if (!session) return { success: false, error: 'Unauthorized' };
+
+ const body = await request.json();
+ const {
+ targetId, targetType, shareType, userId, permission,
+ password, expiresAt, uploadLimitBytes,
+ } = body;
+
+ if (!targetId || !targetType || !shareType || !permission) {
+ return { success: false, error: 'Paramètres manquants' };
+ }
+
+ const share = await createShare({
+ targetId, targetType, shareType,
+ userId: userId || null,
+ permission,
+ password: password || null,
+ expiresAt: expiresAt || null,
+ uploadLimitBytes: uploadLimitBytes || null,
+ });
+
+ return { success: true, share };
+ } catch (error) {
+ console.error('handleCreateShare error:', error);
+ return { success: false, error: error.message };
+ }
+}
+
+async function handleUpdateShare(request) {
+ try {
+ const session = await getAdminSession();
+ if (!session) return { success: false, error: 'Unauthorized' };
+
+ const body = await request.json();
+ const {
+ id, permission, expiresAt, uploadLimitBytes,
+ password, clearPassword, userId, shareType, isActive,
+ } = body;
+
+ if (!id) return { success: false, error: 'ID du partage requis' };
+
+ const share = await updateShare(id, {
+ permission,
+ expiresAt,
+ uploadLimitBytes,
+ password: password || null,
+ clearPassword: clearPassword || false,
+ userId,
+ shareType,
+ isActive,
+ });
+
+ return { success: true, share };
+ } catch (error) {
+ console.error('handleUpdateShare error:', error);
+ return { success: false, error: error.message };
+ }
+}
+
+async function handleRevokeShare(request) {
+ try {
+ const session = await getAdminSession();
+ if (!session) return { success: false, error: 'Unauthorized' };
+
+ const url = new URL(request.url);
+ const id = url.searchParams.get('id');
+ if (!id) return { success: false, error: 'ID du partage requis' };
+
+ const share = await revokeShare(id);
+ return { success: true, share };
+ } catch (error) {
+ console.error('handleRevokeShare error:', error);
+ return { success: false, error: error.message };
+ }
+}
+
+async function handleSendShareEmail(request) {
+ try {
+ const session = await getAdminSession();
+ if (!session) return { success: false, error: 'Unauthorized' };
+
+ const body = await request.json();
+ const { shareId, toEmail, toName, customMessage, shareUrl, targetName, targetType, shareType, expiresAt } = body;
+
+ if (!toEmail) return { success: false, error: 'Adresse courriel requise' };
+ if (!EMAIL_REGEX.test(toEmail)) return { success: false, error: 'Adresse courriel invalide' };
+
+ const appName = process.env.ZEN_NAME || 'Nuage';
+ const html = await render(
+ NuageShareEmail({
+ recipientName: toName || toEmail,
+ shareUrl,
+ targetName,
+ targetType: targetType || 'folder',
+ shareType,
+ expiresAt: expiresAt || null,
+ customMessage: customMessage || null,
+ companyName: appName,
+ })
+ );
+
+ await sendEmail({
+ to: toEmail,
+ subject: `Vous avez reçu un nouveau partage !`,
+ html,
+ });
+
+ return { success: true };
+ } catch (error) {
+ console.error('handleSendShareEmail error:', error);
+ return { success: false, error: error.message };
+ }
+}
+
+// ─── User search ─────────────────────────────────────────────────────────────
+
+async function handleSearchUsers(request) {
+ try {
+ const session = await getAdminSession();
+ if (!session) return { success: false, error: 'Unauthorized' };
+
+ const url = new URL(request.url);
+ const q = url.searchParams.get('q') || '';
+ if (q.length < 2) return { success: true, users: [] };
+
+ const users = await searchUsers(q, 10);
+ return { success: true, users };
+ } catch (error) {
+ console.error('handleSearchUsers error:', error);
+ return { success: false, error: error.message };
+ }
+}
+
+// ─── Client-facing: My Shares ────────────────────────────────────────────────
+
+async function handleGetMyShares(request) {
+ try {
+ const session = await getUserSession();
+ if (!session) return { success: false, error: 'Unauthorized' };
+
+ const shares = await getSharesForUser(session.user.id);
+
+ // Enrich with target names
+ const enriched = await Promise.all(
+ shares.map(async (share) => {
+ let targetName = '';
+ if (share.target_type === 'file') {
+ const file = await getFileById(share.target_id);
+ targetName = file?.display_name || 'Fichier inconnu';
+ } else {
+ const folder = await getFolderById(share.target_id);
+ targetName = folder?.name || 'Dossier inconnu';
+ }
+ return { ...share, target_name: targetName };
+ })
+ );
+
+ return { success: true, shares: enriched };
+ } catch (error) {
+ console.error('handleGetMyShares error:', error);
+ return { success: false, error: error.message };
+ }
+}
+
+// ─── Client-facing: Shared folder contents ───────────────────────────────────
+
+async function handleGetSharedContents(request) {
+ try {
+ const session = await getUserSession();
+ if (!session) return { success: false, error: 'Unauthorized' };
+
+ const url = new URL(request.url);
+ const shareId = url.searchParams.get('shareId');
+ const folderId = url.searchParams.get('folder') || null;
+
+ if (!shareId) return { success: false, error: 'shareId requis' };
+
+ const share = await getShareByToken(shareId);
+ if (!share || !isShareValid(share) || share.user_id !== session.user.id) {
+ return { success: false, error: 'Accès refusé' };
+ }
+
+ const contents = await getSharedFolderContents(share, folderId);
+ const breadcrumb = await getSharedBreadcrumb(share, folderId);
+
+ return { success: true, ...contents, breadcrumb, permission: share.permission };
+ } catch (error) {
+ console.error('handleGetSharedContents error:', error);
+ return { success: false, error: error.message };
+ }
+}
+
+// ─── Public share: File proxy download ───────────────────────────────────────
+
+async function handleShareFileProxy(request) {
+ try {
+ const url = new URL(request.url);
+ const token = url.searchParams.get('token');
+ const fileId = url.searchParams.get('fileId');
+
+ if (!token || !fileId) return { error: 'Bad Request', message: 'Paramètres manquants' };
+
+ const share = await getShareByToken(token);
+ if (!share || !isShareValid(share)) {
+ return { error: 'Unauthorized', message: 'Lien invalide ou expiré' };
+ }
+
+ // Enforce password protection server-side
+ if (share.password_hash) {
+ const cookieStore = await cookies();
+ const cookieValue = cookieStore.get(getPasswordCookieName(token))?.value;
+ if (!cookieValue || cookieValue !== signPasswordToken(token)) {
+ return { error: 'Unauthorized', message: 'Mot de passe requis' };
+ }
+ }
+
+ const file = await getFileById(fileId);
+ if (!file) return { error: 'Not Found', message: 'Fichier introuvable' };
+
+ // Ensure the requested file belongs to this share (prevent IDOR)
+ const inScope = await isFileInShare(fileId, share);
+ if (!inScope) return { error: 'Forbidden', message: 'Accès refusé' };
+
+ const { getFile } = await import('@hykocx/zen/storage');
+ const result = await getFile(file.r2_key);
+ if (!result.success) return { error: 'Not Found', message: 'Fichier introuvable' };
+
+ return {
+ success: true,
+ file: {
+ body: result.data.body,
+ contentType: file.mime_type || result.data.contentType,
+ contentLength: result.data.contentLength,
+ filename: file.display_name,
+ }
+ };
+ } catch (error) {
+ console.error('handleShareFileProxy error:', error);
+ return { error: 'Internal Server Error', message: error.message };
+ }
+}
+
+// ─── Client-facing: Download ─────────────────────────────────────────────────
+
+async function handleClientDownload(request) {
+ try {
+ const session = await getUserSession();
+ if (!session) return { success: false, error: 'Unauthorized' };
+
+ const url = new URL(request.url);
+ const fileId = url.searchParams.get('fileId');
+ const shareId = url.searchParams.get('shareId');
+
+ if (!fileId || !shareId) return { success: false, error: 'Paramètres manquants' };
+
+ // Validate the share belongs to the user
+ const share = await getShareByToken(shareId);
+ if (!share || !isShareValid(share) || share.user_id !== session.user.id) {
+ return { success: false, error: 'Accès refusé' };
+ }
+
+ // Ensure the requested file belongs to this share (prevent IDOR)
+ const inScope = await isFileInShare(fileId, share);
+ if (!inScope) return { success: false, error: 'Accès refusé' };
+
+ const inline = url.searchParams.get('inline') === 'true';
+ return await proxyNuageFile(fileId, { inline });
+ } catch (error) {
+ console.error('handleClientDownload error:', error);
+ return { success: false, error: error.message };
+ }
+}
+
+// ─── Client-facing: Upload (collaborator) ────────────────────────────────────
+
+async function handleClientUpload(request) {
+ try {
+ const session = await getUserSession();
+ if (!session) return { success: false, error: 'Unauthorized' };
+
+ const formData = await request.formData();
+ const file = formData.get('file');
+ const shareId = formData.get('shareId');
+ const folderId = formData.get('folderId') || null;
+
+ if (!shareId) return { success: false, error: 'shareId requis' };
+
+ const share = await getShareByToken(shareId);
+ if (!share || !isShareValid(share) || share.user_id !== session.user.id) {
+ return { success: false, error: 'Accès refusé' };
+ }
+ if (share.permission !== 'collaborator') {
+ return { success: false, error: 'Permission insuffisante' };
+ }
+ if (share.target_type !== 'folder') {
+ return { success: false, error: 'L\'upload n\'est possible que sur un dossier partagé' };
+ }
+ if (!file || typeof file === 'string') {
+ return { success: false, error: 'Aucun fichier fourni' };
+ }
+
+ // Ensure the target folder is within the share scope (prevent IDOR)
+ if (folderId && folderId !== share.target_id) {
+ const inScope = await isFolderInShare(folderId, share);
+ if (!inScope) return { success: false, error: 'Accès refusé' };
+ }
+
+ if (file.size > MAX_FILE_SIZE_BYTES) {
+ return { success: false, error: 'Fichier trop volumineux (max 500 Mo)' };
+ }
+
+ // Check upload limit before buffering to avoid loading oversized files into memory
+ if (share.upload_limit_bytes) {
+ const fileSize = file.size;
+ const targetFolder = folderId || share.target_id;
+ const currentSize = await getFolderUploadedSize(targetFolder);
+ if (currentSize + fileSize > share.upload_limit_bytes) {
+ return { success: false, error: 'Limite de stockage du dossier partagé atteinte' };
+ }
+ }
+
+ const buffer = Buffer.from(await file.arrayBuffer());
+
+ const targetFolderId = folderId || share.target_id;
+ const nuageFile = await uploadNuageFile(
+ targetFolderId,
+ buffer,
+ file.name,
+ file.type || 'application/octet-stream',
+ buffer.length
+ );
+
+ return { success: true, file: nuageFile };
+ } catch (error) {
+ console.error('handleClientUpload error:', error);
+ return { success: false, error: error.message };
+ }
+}
+
+// ─── Route Definitions ───────────────────────────────────────────────────────
+
+export default {
+ routes: [
+ // Admin: Explorer
+ { path: '/admin/nuage', method: 'GET', handler: handleGetContents, auth: 'admin' },
+
+ // Admin: Folders
+ { path: '/admin/nuage/folders', method: 'POST', handler: handleCreateFolder, auth: 'admin' },
+ { path: '/admin/nuage/folders', method: 'PUT', handler: handleUpdateFolder, auth: 'admin' },
+ { path: '/admin/nuage/folders', method: 'DELETE', handler: handleDeleteFolder, auth: 'admin' },
+
+ // Admin: Files
+ { path: '/admin/nuage/files', method: 'POST', handler: handleUploadFile, auth: 'admin' },
+ { path: '/admin/nuage/files', method: 'PUT', handler: handleUpdateFile, auth: 'admin' },
+ { path: '/admin/nuage/files', method: 'DELETE', handler: handleDeleteFile, auth: 'admin' },
+ { path: '/admin/nuage/files/download', method: 'GET', handler: handleGetDownloadUrl, auth: 'admin' },
+
+ // Admin: Shares
+ { path: '/admin/nuage/shares', method: 'GET', handler: handleGetShares, auth: 'admin' },
+ { path: '/admin/nuage/shares', method: 'POST', handler: handleCreateShare, auth: 'admin' },
+ { path: '/admin/nuage/shares', method: 'PATCH', handler: handleUpdateShare, auth: 'admin' },
+ { path: '/admin/nuage/shares', method: 'DELETE', handler: handleRevokeShare, auth: 'admin' },
+ { path: '/admin/nuage/shares/email', method: 'POST', handler: handleSendShareEmail, auth: 'admin' },
+
+ // Admin: User search
+ { path: '/admin/nuage/users', method: 'GET', handler: handleSearchUsers, auth: 'admin' },
+
+ // Public share proxy download (no auth required)
+ { path: '/nuage/share/download', method: 'GET', handler: handleShareFileProxy, auth: 'none' },
+
+ // Client-facing (authenticated user)
+ { path: '/nuage/me', method: 'GET', handler: handleGetMyShares, auth: 'user' },
+ { path: '/nuage/shared', method: 'GET', handler: handleGetSharedContents, auth: 'user' },
+ { path: '/nuage/download', method: 'GET', handler: handleClientDownload, auth: 'user' },
+ { path: '/nuage/upload', method: 'POST', handler: handleClientUpload, auth: 'user' },
+ ],
+};
+
+export {
+ handleGetContents,
+ handleCreateFolder,
+ handleUpdateFolder,
+ handleDeleteFolder,
+ handleUploadFile,
+ handleUpdateFile,
+ handleDeleteFile,
+ handleGetDownloadUrl,
+ handleGetShares,
+ handleCreateShare,
+ handleUpdateShare,
+ handleRevokeShare,
+ handleSendShareEmail,
+ handleSearchUsers,
+ handleGetMyShares,
+ handleGetSharedContents,
+ handleClientDownload,
+ handleClientUpload,
+ handleShareFileProxy,
+};
diff --git a/src/modules/nuage/components/FileViewerModal.js b/src/modules/nuage/components/FileViewerModal.js
new file mode 100644
index 0000000..046c507
--- /dev/null
+++ b/src/modules/nuage/components/FileViewerModal.js
@@ -0,0 +1,85 @@
+'use client';
+
+import React, { useEffect } from 'react';
+import { Cancel01Icon } from '../../../shared/Icons.js';
+
+// ─── Viewable file helpers ────────────────────────────────────────────────────
+
+const VIEWABLE_TYPES = [
+ 'image/',
+ 'application/pdf',
+ 'video/',
+ 'audio/',
+ 'text/plain',
+];
+
+export function isViewable(mimeType) {
+ if (!mimeType) return false;
+ return VIEWABLE_TYPES.some(t => mimeType.startsWith(t));
+}
+
+function getViewerType(mimeType) {
+ if (!mimeType) return null;
+ if (mimeType.startsWith('image/')) return 'image';
+ if (mimeType === 'application/pdf') return 'pdf';
+ if (mimeType.startsWith('video/')) return 'video';
+ if (mimeType.startsWith('audio/')) return 'audio';
+ if (mimeType === 'text/plain') return 'text';
+ return null;
+}
+
+// ─── File viewer modal ────────────────────────────────────────────────────────
+
+export default function FileViewerModal({ file, url, onClose }) {
+ const viewerType = getViewerType(file.mime_type);
+ const fileName = file.display_name || file.name;
+
+ useEffect(() => {
+ const handler = (e) => { if (e.key === 'Escape') onClose(); };
+ window.addEventListener('keydown', handler);
+ return () => window.removeEventListener('keydown', handler);
+ }, [onClose]);
+
+ return (
+
+ {/* Header */}
+
+
+ {/* Content */}
+
+ {viewerType === 'image' && (
+

+ )}
+ {(viewerType === 'pdf' || viewerType === 'text') && (
+
+ )}
+ {viewerType === 'video' && (
+
+ )}
+ {viewerType === 'audio' && (
+
+ )}
+
+
+ );
+}
diff --git a/src/modules/nuage/components/NuageFileTable.js b/src/modules/nuage/components/NuageFileTable.js
new file mode 100644
index 0000000..6e64fcc
--- /dev/null
+++ b/src/modules/nuage/components/NuageFileTable.js
@@ -0,0 +1,219 @@
+'use client';
+
+import React from 'react';
+import {
+ Folder01Icon,
+ File02Icon,
+ Image01Icon,
+ PlaySquareIcon,
+ Pdf02Icon,
+ Mp302Icon,
+ Ppt02Icon,
+ Gif02Icon,
+ Rar02Icon,
+ Raw02Icon,
+ Svg02Icon,
+ Tiff02Icon,
+ Txt02Icon,
+ Typescript03Icon,
+ Wav02Icon,
+ Xls02Icon,
+ Xml02Icon,
+ Zip02Icon,
+ Csv02Icon,
+ Doc02Icon,
+} from '../../../shared/Icons.js';
+import { Badge, Table } from '../../../shared/components';
+
+// ─── File type helpers ────────────────────────────────────────────────────────
+
+export function getFileIcon(mimeType) {
+ if (!mimeType) return File02Icon;
+ if (mimeType === 'image/gif') return Gif02Icon;
+ if (mimeType === 'image/svg+xml') return Svg02Icon;
+ if (mimeType === 'image/tiff') return Tiff02Icon;
+ if (mimeType.startsWith('image/')) return Image01Icon;
+ if (mimeType.startsWith('video/')) return PlaySquareIcon;
+ if (mimeType === 'application/pdf') return Pdf02Icon;
+ if (mimeType === 'audio/mpeg' || mimeType === 'audio/mp3') return Mp302Icon;
+ if (mimeType === 'audio/wav' || mimeType === 'audio/wave') return Wav02Icon;
+ if (mimeType === 'application/vnd.ms-powerpoint' || mimeType === 'application/vnd.openxmlformats-officedocument.presentationml.presentation') return Ppt02Icon;
+ if (mimeType === 'application/vnd.rar' || mimeType === 'application/x-rar-compressed') return Rar02Icon;
+ if (mimeType === 'image/x-raw' || mimeType === 'image/x-canon-cr2' || mimeType === 'image/x-nikon-nef') return Raw02Icon;
+ if (mimeType === 'text/plain') return Txt02Icon;
+ if (mimeType === 'application/typescript' || mimeType === 'text/typescript') return Typescript03Icon;
+ if (mimeType === 'application/vnd.ms-excel' || mimeType === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') return Xls02Icon;
+ if (mimeType === 'application/xml' || mimeType === 'text/xml') return Xml02Icon;
+ if (mimeType === 'application/zip' || mimeType === 'application/x-zip-compressed') return Zip02Icon;
+ if (mimeType === 'text/csv') return Csv02Icon;
+ if (mimeType === 'application/msword' || mimeType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') return Doc02Icon;
+ return File02Icon;
+}
+
+export function getFileColor(mimeType) {
+ if (!mimeType) return 'text-neutral-500';
+ if (mimeType.startsWith('image/')) return 'text-teal-700';
+ if (mimeType.startsWith('video/')) return 'text-red-800';
+ if (mimeType === 'application/pdf') return 'text-red-800';
+ if (mimeType === 'audio/mpeg' || mimeType === 'audio/mp3' || mimeType === 'audio/wav' || mimeType === 'audio/wave') return 'text-indigo-700';
+ if (mimeType === 'application/vnd.ms-powerpoint' || mimeType === 'application/vnd.openxmlformats-officedocument.presentationml.presentation') return 'text-orange-700';
+ if (mimeType === 'application/vnd.rar' || mimeType === 'application/x-rar-compressed') return 'text-amber-800';
+ if (mimeType === 'text/plain') return 'text-neutral-500';
+ if (mimeType === 'application/typescript' || mimeType === 'text/typescript') return 'text-blue-700';
+ if (mimeType === 'application/vnd.ms-excel' || mimeType === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') return 'text-green-700';
+ if (mimeType === 'application/xml' || mimeType === 'text/xml') return 'text-orange-800';
+ if (mimeType === 'application/zip' || mimeType === 'application/x-zip-compressed') return 'text-amber-700';
+ if (mimeType === 'text/csv') return 'text-emerald-700';
+ if (mimeType === 'application/msword' || mimeType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') return 'text-blue-800';
+ return 'text-neutral-500';
+}
+
+export function formatBytes(bytes) {
+ if (!bytes || bytes === 0) return '0 o';
+ const units = ['o', 'Ko', 'Mo', 'Go'];
+ const i = Math.floor(Math.log(bytes) / Math.log(1024));
+ return `${(bytes / Math.pow(1024, i)).toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
+}
+
+export function formatDate(dateStr) {
+ if (!dateStr) return '';
+ return new Date(dateStr).toLocaleDateString('fr-CA', {
+ year: 'numeric', month: 'short', day: 'numeric',
+ });
+}
+
+// ─── NuageFileTable ───────────────────────────────────────────────────────────
+
+/**
+ * Unified file listing table for the Nuage module.
+ *
+ * Each item must have a `_type` field: 'folder' | 'file' | 'share'.
+ *
+ * Props:
+ * items — array of rows (folders, files, or shares with `_type`)
+ * loading — show skeleton rows
+ * sortBy — active sort key
+ * sortOrder — 'asc' | 'desc'
+ * onSort — (key) => void — called when a sortable header is clicked
+ * onRowClick — (item) => void
+ * renderActions — (item) => ReactNode — content for the actions column
+ * showShareBadge — show a "Partagé" badge on items with has_active_share
+ * emptyMessage — primary empty state text
+ * emptyDescription — secondary empty state text
+ * size — Table size prop (default 'sm')
+ * className — extra class on the Table
+ * getRowProps — (item) => object — extra props per row
+ */
+export default function NuageFileTable({
+ items = [],
+ loading = false,
+ sortBy,
+ sortOrder,
+ onSort,
+ onRowClick,
+ renderActions,
+ showShareBadge = false,
+ emptyMessage = 'Ce dossier est vide',
+ emptyDescription,
+ size = 'sm',
+ className,
+ getRowProps,
+}) {
+ const sortable = !!onSort;
+
+ const columns = [
+ {
+ key: 'name',
+ label: 'Nom',
+ sortable,
+ render: (item) => {
+ if (item._type === 'share') {
+ const isFolder = item.target_type === 'folder';
+ const isExpired = item.expires_at && new Date(item.expires_at) < new Date();
+ const Icon = isFolder ? Folder01Icon : File02Icon;
+ return (
+
+
+
+ {item.target_name || (isFolder ? 'Dossier' : 'Fichier')}
+
+
+ );
+ }
+
+ if (item._type === 'folder') {
+ return (
+
+
+ {item.name}
+ {showShareBadge && item.has_active_share && (
+ Partagé
+ )}
+
+ );
+ }
+
+ // file
+ const FileIcon = getFileIcon(item.mime_type);
+ const fileColor = getFileColor(item.mime_type);
+ return (
+
+
+ {item.display_name}
+ {showShareBadge && item.has_active_share && (
+ Partagé
+ )}
+
+ );
+ },
+ skeleton: { height: 'h-4', width: '60%' },
+ },
+ {
+ key: 'date',
+ label: 'Date',
+ sortable,
+ render: (item) => {
+ if (item._type === 'share') {
+ return item.expires_at ? (
+
Expire {formatDate(item.expires_at)}
+ ) : null;
+ }
+ return
{formatDate(item.created_at)};
+ },
+ skeleton: { height: 'h-4', width: '40%' },
+ },
+ {
+ key: 'size',
+ label: 'Taille',
+ sortable,
+ render: (item) => {
+ if (item._type !== 'file') return
—;
+ return
{formatBytes(item.size)};
+ },
+ skeleton: { height: 'h-4', width: '30%' },
+ },
+ ...(renderActions ? [{
+ key: 'actions',
+ label: '',
+ render: (item) => renderActions(item),
+ skeleton: { height: 'h-8', width: '40px' },
+ }] : []),
+ ];
+
+ return (
+
+ );
+}
diff --git a/src/modules/nuage/components/SharePanel.js b/src/modules/nuage/components/SharePanel.js
new file mode 100644
index 0000000..074eaa8
--- /dev/null
+++ b/src/modules/nuage/components/SharePanel.js
@@ -0,0 +1,776 @@
+'use client';
+
+import React, { useState, useEffect, useRef } from 'react';
+import {
+ Cancel01Icon,
+ Link02Icon,
+ Copy01Icon,
+ Delete02Icon,
+ UserCircle02Icon,
+} from '../../../shared/Icons.js';
+import { Button, Badge, Loading, Input, Modal, Textarea } from '../../../shared/components';
+import { formatBytes, formatDate } from './NuageFileTable.js';
+import { useToast } from '@hykocx/zen/toast';
+
+// ─── User search input ────────────────────────────────────────────────────────
+
+function UserSearchInput({ selectedUser, onSelect, onClear, query, onQueryChange, results, searching }) {
+ return (
+
+
+ {selectedUser ? (
+
+
+
{selectedUser.name}
+
{selectedUser.email}
+
+
+
+
+
+ ) : (
+ <>
+
+ {results.length > 0 && (
+
+ {results.map(u => (
+
onSelect(u)}
+ className="w-full text-left px-3 py-2 hover:bg-neutral-50 dark:hover:bg-neutral-800 transition-colors first:rounded-t-xl last:rounded-b-xl"
+ >
+ {u.name}
+ {u.email}
+
+ ))}
+
+ )}
+ {searching &&
Recherche…
}
+ >
+ )}
+
+ );
+}
+
+// ─── Share panel ──────────────────────────────────────────────────────────────
+
+export default function SharePanel({ target, onClose }) {
+ const toast = useToast();
+ const [shares, setShares] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [tab, setTab] = useState('list'); // 'list' | 'create'
+
+ // Create share form
+ const [shareType, setShareType] = useState('anonymous'); // 'user' | 'anonymous'
+ const [permission, setPermission] = useState('reader');
+ const [password, setPassword] = useState('');
+ const [expiresPreset, setExpiresPreset] = useState('');
+ const [uploadLimit, setUploadLimit] = useState('');
+ const [creating, setCreating] = useState(false);
+ const [createdShare, setCreatedShare] = useState(null);
+
+ // User search (create)
+ const [userQuery, setUserQuery] = useState('');
+ const [userResults, setUserResults] = useState([]);
+ const [selectedUser, setSelectedUser] = useState(null);
+ const [searchingUsers, setSearchingUsers] = useState(false);
+ const searchTimeout = useRef(null);
+
+ // Email modal
+ const [showEmailModal, setShowEmailModal] = useState(false);
+ const [emailAddress, setEmailAddress] = useState('');
+ const [emailMessage, setEmailMessage] = useState('');
+ const [sendingEmail, setSendingEmail] = useState(false);
+ const [emailShareTarget, setEmailShareTarget] = useState(null);
+
+ // Edit modal
+ const [editingShare, setEditingShare] = useState(null);
+ const [editShareType, setEditShareType] = useState('anonymous');
+ const [editPermission, setEditPermission] = useState('reader');
+ const [editExpiresPreset, setEditExpiresPreset] = useState('keep');
+ const [editUploadLimit, setEditUploadLimit] = useState('');
+ const [editPassword, setEditPassword] = useState('');
+ const [editClearPassword, setEditClearPassword] = useState(false);
+ const [editSelectedUser, setEditSelectedUser] = useState(null);
+ const [editUserQuery, setEditUserQuery] = useState('');
+ const [editUserResults, setEditUserResults] = useState([]);
+ const [editSearchingUsers, setEditSearchingUsers] = useState(false);
+ const [updating, setUpdating] = useState(false);
+ const editSearchTimeout = useRef(null);
+
+ useEffect(() => {
+ loadShares();
+ }, [target]);
+
+ const loadShares = async () => {
+ setLoading(true);
+ try {
+ const res = await fetch(
+ `/zen/api/admin/nuage/shares?targetId=${target.id}&targetType=${target.type}`,
+ { credentials: 'include' }
+ );
+ const data = await res.json();
+ if (data.success) setShares(data.shares);
+ } catch (e) {
+ console.error(e);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const makeUserSearchHandler = (setQuery, setSelected, setResults, setSearching, timeoutRef) => (q) => {
+ setQuery(q);
+ setSelected(null);
+ clearTimeout(timeoutRef.current);
+ if (q.length < 2) { setResults([]); return; }
+ timeoutRef.current = setTimeout(async () => {
+ setSearching(true);
+ try {
+ const res = await fetch(`/zen/api/admin/nuage/users?q=${encodeURIComponent(q)}`, { credentials: 'include' });
+ const data = await res.json();
+ if (data.success) setResults(data.users);
+ } catch (e) {
+ console.error(e);
+ } finally {
+ setSearching(false);
+ }
+ }, 300);
+ };
+
+ const handleUserSearch = makeUserSearchHandler(setUserQuery, setSelectedUser, setUserResults, setSearchingUsers, searchTimeout);
+ const handleEditUserSearch = makeUserSearchHandler(setEditUserQuery, setEditSelectedUser, setEditUserResults, setEditSearchingUsers, editSearchTimeout);
+
+ const getExpiresAt = (preset) => {
+ if (!preset || preset === 'keep') return preset === 'keep' ? undefined : null;
+ const days = { '24h': 1, '7d': 7, '30d': 30, '90d': 90 }[preset];
+ if (!days) return null;
+ const d = new Date();
+ d.setDate(d.getDate() + days);
+ return d.toISOString();
+ };
+
+ const handleCreate = async () => {
+ if (shareType === 'user' && !selectedUser) {
+ toast.error('Veuillez sélectionner un utilisateur');
+ return;
+ }
+ setCreating(true);
+ try {
+ const res = await fetch('/zen/api/admin/nuage/shares', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify({
+ targetId: target.id,
+ targetType: target.type,
+ shareType,
+ userId: shareType === 'user' ? selectedUser?.id : null,
+ permission,
+ password: shareType === 'anonymous' && password ? password : null,
+ expiresAt: getExpiresAt(expiresPreset),
+ uploadLimitBytes: permission === 'collaborator' && target.type === 'folder' && uploadLimit
+ ? parseInt(uploadLimit) * 1024 * 1024
+ : null,
+ }),
+ });
+ const data = await res.json();
+ if (data.success) {
+ toast.success('Partage créé');
+ setCreatedShare(data.share);
+ loadShares();
+ setTab('list');
+ setPassword('');
+ setExpiresPreset('');
+ setUploadLimit('');
+ setSelectedUser(null);
+ setUserQuery('');
+ } else {
+ toast.error(data.error || 'Erreur lors de la création du partage');
+ }
+ } catch (e) {
+ toast.error('Erreur réseau');
+ } finally {
+ setCreating(false);
+ }
+ };
+
+ const openEditModal = (share) => {
+ setEditingShare(share);
+ setEditShareType(share.share_type);
+ setEditPermission(share.permission);
+ setEditExpiresPreset('keep');
+ setEditUploadLimit(share.upload_limit_bytes ? String(Math.round(share.upload_limit_bytes / (1024 * 1024))) : '');
+ setEditPassword('');
+ setEditClearPassword(false);
+ setEditSelectedUser(share.share_type === 'user' ? { id: share.user_id, name: share.name, email: share.email } : null);
+ setEditUserQuery('');
+ setEditUserResults([]);
+ };
+
+ const handleUpdate = async () => {
+ if (editShareType === 'user' && !editSelectedUser) {
+ toast.error('Veuillez sélectionner un utilisateur');
+ return;
+ }
+ setUpdating(true);
+ try {
+ const body = { id: editingShare.id };
+ body.permission = editPermission;
+ body.shareType = editShareType;
+ body.userId = editShareType === 'user' ? (editSelectedUser?.id || null) : null;
+
+ const expiresAt = getExpiresAt(editExpiresPreset);
+ if (expiresAt !== undefined) body.expiresAt = expiresAt;
+
+ if (editPermission === 'collaborator' && target.type === 'folder') {
+ body.uploadLimitBytes = editUploadLimit ? parseInt(editUploadLimit) * 1024 * 1024 : null;
+ } else {
+ body.uploadLimitBytes = null;
+ }
+
+ if (editShareType === 'anonymous') {
+ if (editClearPassword) {
+ body.clearPassword = true;
+ } else if (editPassword) {
+ body.password = editPassword;
+ }
+ } else {
+ body.clearPassword = true;
+ }
+
+ const res = await fetch('/zen/api/admin/nuage/shares', {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify(body),
+ });
+ const data = await res.json();
+ if (data.success) {
+ toast.success('Partage modifié');
+ setEditingShare(null);
+ loadShares();
+ } else {
+ toast.error(data.error || 'Erreur lors de la modification');
+ }
+ } catch (e) {
+ toast.error('Erreur réseau');
+ } finally {
+ setUpdating(false);
+ }
+ };
+
+ const handleRevoke = async (shareId) => {
+ try {
+ const res = await fetch(`/zen/api/admin/nuage/shares?id=${shareId}`, {
+ method: 'DELETE',
+ credentials: 'include',
+ });
+ const data = await res.json();
+ if (data.success) {
+ toast.success('Partage révoqué');
+ loadShares();
+ } else {
+ toast.error(data.error || 'Erreur');
+ }
+ } catch (e) {
+ toast.error('Erreur réseau');
+ }
+ };
+
+ const getSharePublicUrl = (token) => {
+ const base = typeof window !== 'undefined' ? window.location.origin : '';
+ return `${base}/zen/nuage/partage/${token}`;
+ };
+
+ const copyToClipboard = (text) => {
+ navigator.clipboard.writeText(text).then(() => toast.success('Lien copié !'));
+ };
+
+ const openEmailModal = (share) => {
+ setEmailShareTarget(share);
+ setEmailAddress(share.share_type === 'user' ? (share.email || '') : '');
+ setEmailMessage('');
+ setShowEmailModal(true);
+ };
+
+ const handleSendEmail = async () => {
+ if (!emailAddress) { toast.error('Adresse courriel requise'); return; }
+ setSendingEmail(true);
+ try {
+ const shareUrl = getSharePublicUrl(emailShareTarget.token);
+ const res = await fetch('/zen/api/admin/nuage/shares/email', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify({
+ shareId: emailShareTarget.id,
+ toEmail: emailAddress,
+ toName: emailShareTarget.share_type === 'user' ? (emailShareTarget.name || '') : '',
+ customMessage: emailMessage || null,
+ shareUrl,
+ targetName: target.name,
+ targetType: target.type,
+ shareType: emailShareTarget.share_type,
+ expiresAt: emailShareTarget.expires_at || null,
+ }),
+ });
+ const data = await res.json();
+ if (data.success) {
+ toast.success('Courriel envoyé');
+ setShowEmailModal(false);
+ } else {
+ toast.error(data.error || 'Erreur lors de l\'envoi');
+ }
+ } catch (e) {
+ toast.error('Erreur réseau');
+ } finally {
+ setSendingEmail(false);
+ }
+ };
+
+ const isShareExpired = (share) => {
+ if (!share.expires_at) return false;
+ return new Date(share.expires_at) < new Date();
+ };
+
+ const activeTabClass = 'border-b-2 border-neutral-900 dark:border-white text-neutral-900 dark:text-white font-medium';
+ const inactiveTabClass = 'text-neutral-400 dark:text-neutral-500 hover:text-neutral-600 dark:hover:text-neutral-300';
+ const activeChipClass = 'bg-neutral-900 dark:bg-white text-white dark:text-neutral-900 border-neutral-900 dark:border-white';
+ const inactiveChipClass = 'bg-transparent border-neutral-200 dark:border-neutral-700 text-neutral-500 dark:text-neutral-400 hover:border-neutral-400 dark:hover:border-neutral-500';
+
+ return (
+
+ {/* Header */}
+
+
+
Partager
+
{target.name}
+
+
+
+
+
+
+ {/* Tabs */}
+
+ setTab('list')}
+ className={`py-2.5 text-xs mr-4 transition-colors ${tab === 'list' ? activeTabClass : inactiveTabClass}`}
+ >
+ Actifs {shares.length > 0 && ({shares.length})}
+
+ setTab('create')}
+ className={`py-2.5 text-xs transition-colors ${tab === 'create' ? activeTabClass : inactiveTabClass}`}
+ >
+ + Nouveau
+
+
+
+
+ {/* ── List tab ── */}
+ {tab === 'list' && (
+
+ {loading ? (
+
+ ) : shares.length === 0 ? (
+
+
+
Aucun partage actif
+
+ ) : (
+ shares.map(share => {
+ const expired = isShareExpired(share);
+ const url = getSharePublicUrl(share.token);
+ return (
+
+
+
+
+ {share.share_type === 'user' ? (
+
+ ) : (
+
+ )}
+
+
+
+ {share.share_type === 'user'
+ ? share.name || share.email || 'Utilisateur'
+ : 'Lien anonyme'}
+
+
+ {share.permission === 'reader' ? 'Lecteur' : 'Collaborateur'}
+ {share.expires_at && ` · ${formatDate(share.expires_at)}`}
+ {share.upload_limit_bytes && ` · ${formatBytes(share.upload_limit_bytes)}`}
+
+
+
+
+ {!share.is_active ? 'Révoqué' : expired ? 'Expiré' : 'Actif'}
+
+
+
+ {share.is_active && !expired && (
+ <>
+ copyToClipboard(url)}
+ className="flex items-center gap-1 px-2 py-1 text-[11px] font-medium text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-lg transition-colors"
+ >
+ Copier
+
+ openEmailModal(share)}
+ className="flex items-center gap-1 px-2 py-1 text-[11px] font-medium text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-lg transition-colors"
+ >
+ Envoyer
+
+ >
+ )}
+ openEditModal(share)}
+ className="flex items-center gap-1 px-2 py-1 text-[11px] font-medium text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-lg transition-colors"
+ >
+ Modifier
+
+ {share.is_active && !expired && (
+ handleRevoke(share.id)}
+ className="flex items-center gap-1 px-2 py-1 text-[11px] font-medium text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-500/10 rounded-lg transition-colors ml-auto"
+ >
+ Révoquer
+
+ )}
+
+
+ );
+ })
+ )}
+
+ )}
+
+ {/* ── Create tab ── */}
+ {tab === 'create' && (
+
+ {/* Share type */}
+
+
+
+ setShareType('anonymous')}
+ className={`flex-1 py-1.5 text-xs rounded-xl border font-medium transition-all ${shareType === 'anonymous' ? activeChipClass : inactiveChipClass}`}
+ >
+ Lien
+
+ setShareType('user')}
+ className={`flex-1 py-1.5 text-xs rounded-xl border font-medium transition-all ${shareType === 'user' ? activeChipClass : inactiveChipClass}`}
+ >
+ Utilisateur
+
+
+
+
+ {/* User search */}
+ {shareType === 'user' && (
+
{ setSelectedUser(u); setUserQuery(''); setUserResults([]); }}
+ onClear={() => { setSelectedUser(null); setUserQuery(''); }}
+ query={userQuery}
+ onQueryChange={handleUserSearch}
+ results={userResults}
+ searching={searchingUsers}
+ />
+ )}
+
+ {/* Permission */}
+
+
+
+ setPermission('reader')}
+ className={`flex-1 py-1.5 text-xs rounded-xl border font-medium transition-all ${permission === 'reader' ? activeChipClass : inactiveChipClass}`}
+ >
+ Lecteur
+
+ setPermission('collaborator')}
+ className={`flex-1 py-1.5 text-xs rounded-xl border font-medium transition-all ${permission === 'collaborator' ? activeChipClass : inactiveChipClass}`}
+ >
+ Collaborateur
+
+
+
+
+ {/* Upload limit (collaborator + folder only) */}
+ {permission === 'collaborator' && target.type === 'folder' && (
+
+ setUploadLimit(v)}
+ placeholder="Illimité"
+ description="Quota de stockage total pour ce partage"
+ />
+
+ )}
+
+ {/* Password (anonymous only) */}
+ {shareType === 'anonymous' && (
+
+ setPassword(v)}
+ placeholder="Aucun"
+ />
+
+ )}
+
+ {/* Expiration */}
+
+
+
+ {['', '24h', '7d', '30d', '90d'].map(preset => (
+ setExpiresPreset(preset)}
+ className={`px-2.5 py-1 text-xs rounded-xl border font-medium transition-all ${expiresPreset === preset ? activeChipClass : inactiveChipClass}`}
+ >
+ {preset === '' ? 'Aucune' : preset === '24h' ? '24 h' : preset === '7d' ? '7 j' : preset === '30d' ? '30 j' : '90 j'}
+
+ ))}
+
+
+
+
+ {shareType === 'anonymous' ? 'Générer le lien' : 'Créer le partage'}
+
+
+ )}
+
+
+ {/* Email modal */}
+ {showEmailModal && (
+
setShowEmailModal(false)}
+ title="Envoyer par courriel"
+ size="sm"
+ footer={
+
+ setShowEmailModal(false)}>Annuler
+ Envoyer
+
+ }
+ >
+
+ setEmailAddress(v)}
+ placeholder="courriel@exemple.com"
+ />
+
+
+ )}
+
+ {/* Edit share modal */}
+ {editingShare && (
+
setEditingShare(null)}
+ title="Modifier le partage"
+ size="sm"
+ footer={
+
+ setEditingShare(null)}>Annuler
+ Enregistrer
+
+ }
+ >
+
+ {/* Type */}
+
+
+
+ setEditShareType('anonymous')}
+ className={`flex-1 py-1.5 text-xs rounded-xl border font-medium transition-all ${editShareType === 'anonymous' ? activeChipClass : inactiveChipClass}`}
+ >
+ Lien
+
+ setEditShareType('user')}
+ className={`flex-1 py-1.5 text-xs rounded-xl border font-medium transition-all ${editShareType === 'user' ? activeChipClass : inactiveChipClass}`}
+ >
+ Utilisateur
+
+
+
+
+ {/* User search */}
+ {editShareType === 'user' && (
+
{ setEditSelectedUser(u); setEditUserQuery(''); setEditUserResults([]); }}
+ onClear={() => { setEditSelectedUser(null); setEditUserQuery(''); }}
+ query={editUserQuery}
+ onQueryChange={handleEditUserSearch}
+ results={editUserResults}
+ searching={editSearchingUsers}
+ />
+ )}
+
+ {/* Permission */}
+
+
+
+ setEditPermission('reader')}
+ className={`flex-1 py-1.5 text-xs rounded-xl border font-medium transition-all ${editPermission === 'reader' ? activeChipClass : inactiveChipClass}`}
+ >
+ Lecteur
+
+ setEditPermission('collaborator')}
+ className={`flex-1 py-1.5 text-xs rounded-xl border font-medium transition-all ${editPermission === 'collaborator' ? activeChipClass : inactiveChipClass}`}
+ >
+ Collaborateur
+
+
+
+
+ {/* Upload limit (collaborator + folder only) */}
+ {editPermission === 'collaborator' && target.type === 'folder' && (
+ setEditUploadLimit(v)}
+ placeholder="Illimité"
+ description="Quota de stockage total pour ce partage"
+ />
+ )}
+
+ {/* Password (anonymous only) */}
+ {editShareType === 'anonymous' && (
+
+
+ {editingShare.password_hash && !editClearPassword && (
+
+
Mot de passe défini
+
setEditClearPassword(true)}
+ className="text-[11px] font-medium text-red-500 hover:text-red-600 transition-colors"
+ >
+ Supprimer
+
+
+ )}
+ {editClearPassword && (
+
+
Mot de passe supprimé
+
setEditClearPassword(false)}
+ className="text-[11px] font-medium text-neutral-500 hover:text-neutral-700 transition-colors"
+ >
+ Annuler
+
+
+ )}
+ {!editClearPassword && (
+
setEditPassword(v)}
+ placeholder={editingShare.password_hash ? 'Nouveau mot de passe…' : 'Ajouter un mot de passe…'}
+ />
+ )}
+
+ )}
+
+ {/* Expiration */}
+
+
+
+ {['keep', '', '24h', '7d', '30d', '90d'].map(preset => (
+ setEditExpiresPreset(preset)}
+ className={`px-2.5 py-1 text-xs rounded-xl border font-medium transition-all ${editExpiresPreset === preset ? activeChipClass : inactiveChipClass}`}
+ >
+ {preset === 'keep' ? 'Conserver' : preset === '' ? 'Aucune' : preset === '24h' ? '24 h' : preset === '7d' ? '7 j' : preset === '30d' ? '30 j' : '90 j'}
+
+ ))}
+
+
+
+ {/* Reactivate revoked share */}
+ {!editingShare.is_active && (
+
+
+
Réactiver le partage
+
Ce partage est actuellement révoqué
+
+
{
+ setUpdating(true);
+ try {
+ const res = await fetch('/zen/api/admin/nuage/shares', {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify({ id: editingShare.id, isActive: true }),
+ });
+ const data = await res.json();
+ if (data.success) {
+ toast.success('Partage réactivé');
+ setEditingShare(null);
+ loadShares();
+ } else {
+ toast.error(data.error || 'Erreur');
+ }
+ } catch (e) {
+ toast.error('Erreur réseau');
+ } finally {
+ setUpdating(false);
+ }
+ }}>
+ Réactiver
+
+
+ )}
+
+
+ )}
+
+ );
+}
diff --git a/src/modules/nuage/crud.js b/src/modules/nuage/crud.js
new file mode 100644
index 0000000..ca418b2
--- /dev/null
+++ b/src/modules/nuage/crud.js
@@ -0,0 +1,461 @@
+/**
+ * Nuage Module — CRUD
+ * Database operations for folders, files, and shares + R2 integration
+ */
+
+import { query } from '@hykocx/zen/database';
+import { uploadFile, deleteFile, proxyFile } from '@hykocx/zen/storage';
+import crypto from 'crypto';
+
+// ─── Helpers ────────────────────────────────────────────────────────────────
+
+const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+
+function sanitizeFilename(name) {
+ const sanitized = name
+ .normalize('NFD')
+ .replace(/[\u0300-\u036f]/g, '')
+ .replace(/[^a-zA-Z0-9._-]/g, '_')
+ .replace(/_+/g, '_')
+ .substring(0, 200);
+ return sanitized || 'fichier';
+}
+
+function generateNuageFilePath(fileId, filename) {
+ return `nuage/files/${fileId}/${sanitizeFilename(filename)}`;
+}
+
+// ─── Folders ────────────────────────────────────────────────────────────────
+
+export async function getFolderById(id) {
+ if (!id || !UUID_REGEX.test(id)) return null;
+ const result = await query('SELECT * FROM zen_nuage_folders WHERE id = $1', [id]);
+ return result.rows[0] || null;
+}
+
+export async function getFolderContents(folderId = null) {
+ const param = folderId || null;
+
+ const folderResult = await query(
+ `SELECT f.*,
+ (SELECT COUNT(*) > 0 FROM zen_nuage_shares s WHERE s.target_id = f.id AND s.target_type = 'folder' AND s.is_active = true AND (s.expires_at IS NULL OR s.expires_at > NOW())) AS has_active_share
+ FROM zen_nuage_folders f
+ WHERE f.parent_id ${param ? '= $1' : 'IS NULL'}
+ ORDER BY f.name ASC`,
+ param ? [param] : []
+ );
+
+ const fileResult = await query(
+ `SELECT f.*,
+ (SELECT COUNT(*) > 0 FROM zen_nuage_shares s WHERE s.target_id = f.id AND s.target_type = 'file' AND s.is_active = true AND (s.expires_at IS NULL OR s.expires_at > NOW())) AS has_active_share
+ FROM zen_nuage_files f
+ WHERE f.folder_id ${param ? '= $1' : 'IS NULL'}
+ ORDER BY f.display_name ASC`,
+ param ? [param] : []
+ );
+
+ return {
+ folders: folderResult.rows,
+ files: fileResult.rows,
+ };
+}
+
+export async function getFolderBreadcrumb(folderId) {
+ if (!folderId) return [];
+ const breadcrumb = [];
+ let currentId = folderId;
+
+ while (currentId) {
+ const result = await query('SELECT * FROM zen_nuage_folders WHERE id = $1', [currentId]);
+ const folder = result.rows[0];
+ if (!folder) break;
+ breadcrumb.unshift(folder);
+ currentId = folder.parent_id;
+ }
+ return breadcrumb;
+}
+
+export async function createFolder(name, parentId = null) {
+ let depth = 0;
+ if (parentId) {
+ const parent = await getFolderById(parentId);
+ if (!parent) throw new Error('Dossier parent introuvable');
+ depth = parent.depth + 1;
+ if (depth > 10) throw new Error('Profondeur maximale de 10 niveaux atteinte');
+ }
+ const result = await query(
+ `INSERT INTO zen_nuage_folders (name, parent_id, depth) VALUES ($1, $2, $3) RETURNING *`,
+ [name.trim(), parentId, depth]
+ );
+ return result.rows[0];
+}
+
+export async function renameFolder(id, name) {
+ const result = await query(
+ `UPDATE zen_nuage_folders SET name = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2 RETURNING *`,
+ [name.trim(), id]
+ );
+ return result.rows[0] || null;
+}
+
+export async function moveFolder(id, newParentId) {
+ let depth = 0;
+ if (newParentId) {
+ const parent = await getFolderById(newParentId);
+ if (!parent) throw new Error('Dossier parent introuvable');
+ depth = parent.depth + 1;
+ if (depth > 10) throw new Error('Profondeur maximale de 10 niveaux atteinte');
+ }
+ const result = await query(
+ `UPDATE zen_nuage_folders SET parent_id = $1, depth = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $3 RETURNING *`,
+ [newParentId, depth, id]
+ );
+ return result.rows[0] || null;
+}
+
+export async function deleteFolder(id) {
+ // Collect all r2_keys recursively before deleting
+ const r2Keys = await collectFolderR2Keys(id);
+ for (const key of r2Keys) {
+ await deleteFile(key).catch(() => {});
+ }
+ await query('DELETE FROM zen_nuage_folders WHERE id = $1', [id]);
+ return { success: true };
+}
+
+async function collectFolderR2Keys(folderId) {
+ const keys = [];
+ const filesResult = await query('SELECT r2_key FROM zen_nuage_files WHERE folder_id = $1', [folderId]);
+ for (const row of filesResult.rows) keys.push(row.r2_key);
+
+ const subFolders = await query('SELECT id FROM zen_nuage_folders WHERE parent_id = $1', [folderId]);
+ for (const sub of subFolders.rows) {
+ const subKeys = await collectFolderR2Keys(sub.id);
+ keys.push(...subKeys);
+ }
+ return keys;
+}
+
+export async function getFolderItemCount(folderId) {
+ const folders = await query('SELECT COUNT(*) FROM zen_nuage_folders WHERE parent_id = $1', [folderId]);
+ const files = await query('SELECT COUNT(*) FROM zen_nuage_files WHERE folder_id = $1', [folderId]);
+ return parseInt(folders.rows[0].count) + parseInt(files.rows[0].count);
+}
+
+// ─── Files ──────────────────────────────────────────────────────────────────
+
+export async function getFileById(id) {
+ if (!id || !UUID_REGEX.test(id)) return null;
+ const result = await query('SELECT * FROM zen_nuage_files WHERE id = $1', [id]);
+ return result.rows[0] || null;
+}
+
+export async function uploadNuageFile(folderId, buffer, originalName, mimeType, size) {
+ const fileId = crypto.randomUUID();
+ const r2Key = generateNuageFilePath(fileId, originalName);
+
+ const uploadResult = await uploadFile({ key: r2Key, body: buffer, contentType: mimeType });
+ if (!uploadResult.success) throw new Error(uploadResult.error || 'Échec du téléversement vers R2');
+
+ const result = await query(
+ `INSERT INTO zen_nuage_files (id, folder_id, original_name, display_name, mime_type, size, r2_key)
+ VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`,
+ [fileId, folderId || null, originalName, originalName, mimeType, size, r2Key]
+ );
+ return result.rows[0];
+}
+
+export async function renameFile(id, displayName) {
+ const result = await query(
+ `UPDATE zen_nuage_files SET display_name = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2 RETURNING *`,
+ [displayName.trim(), id]
+ );
+ return result.rows[0] || null;
+}
+
+export async function moveFile(id, newFolderId) {
+ const result = await query(
+ `UPDATE zen_nuage_files SET folder_id = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2 RETURNING *`,
+ [newFolderId || null, id]
+ );
+ return result.rows[0] || null;
+}
+
+export async function deleteNuageFile(id) {
+ const file = await getFileById(id);
+ if (!file) throw new Error('Fichier introuvable');
+ await deleteFile(file.r2_key).catch(() => {});
+ await query('DELETE FROM zen_nuage_files WHERE id = $1', [id]);
+ return { success: true };
+}
+
+export async function proxyNuageFile(id, { inline = false } = {}) {
+ const file = await getFileById(id);
+ if (!file) throw new Error('Fichier introuvable');
+ return proxyFile(file.r2_key, { filename: inline ? undefined : file.display_name });
+}
+
+export async function getFolderUploadedSize(folderId) {
+ const result = await query(
+ `SELECT COALESCE(SUM(size), 0) AS total FROM zen_nuage_files WHERE folder_id = $1`,
+ [folderId]
+ );
+ return parseInt(result.rows[0].total);
+}
+
+// ─── Shares ─────────────────────────────────────────────────────────────────
+
+export async function getShareByToken(token) {
+ const result = await query('SELECT * FROM zen_nuage_shares WHERE token = $1', [token]);
+ return result.rows[0] || null;
+}
+
+export async function getShareInfoForMetadata(token) {
+ const result = await query(
+ `SELECT s.*,
+ CASE
+ WHEN s.target_type = 'file' THEN (SELECT display_name FROM zen_nuage_files WHERE id = s.target_id)
+ WHEN s.target_type = 'folder' THEN (SELECT name FROM zen_nuage_folders WHERE id = s.target_id)
+ END AS target_name
+ FROM zen_nuage_shares s
+ WHERE s.token = $1`,
+ [token]
+ );
+ return result.rows[0] || null;
+}
+
+export async function getSharesForTarget(targetId, targetType) {
+ const result = await query(
+ `SELECT s.*, u.name, u.email
+ FROM zen_nuage_shares s
+ LEFT JOIN zen_auth_users u ON u.id = s.user_id
+ WHERE s.target_id = $1 AND s.target_type = $2
+ ORDER BY s.created_at DESC`,
+ [targetId, targetType]
+ );
+ return result.rows;
+}
+
+export async function getSharesForUser(userId) {
+ const result = await query(
+ `SELECT s.*
+ FROM zen_nuage_shares s
+ WHERE s.user_id = $1
+ AND s.is_active = true
+ AND (s.expires_at IS NULL OR s.expires_at > CURRENT_TIMESTAMP)
+ ORDER BY s.created_at DESC`,
+ [userId]
+ );
+ return result.rows;
+}
+
+export async function getAllActiveShares() {
+ const result = await query(
+ `SELECT s.*, u.name, u.email,
+ CASE
+ WHEN s.target_type = 'file' THEN (SELECT display_name FROM zen_nuage_files WHERE id = s.target_id)
+ WHEN s.target_type = 'folder' THEN (SELECT name FROM zen_nuage_folders WHERE id = s.target_id)
+ END AS target_name
+ FROM zen_nuage_shares s
+ LEFT JOIN zen_auth_users u ON u.id = s.user_id
+ ORDER BY s.created_at DESC`
+ );
+ return result.rows;
+}
+
+export async function createShare({
+ targetId,
+ targetType,
+ shareType,
+ userId = null,
+ permission,
+ password = null,
+ expiresAt = null,
+ uploadLimitBytes = null,
+}) {
+ const token = crypto.randomBytes(32).toString('hex');
+ let passwordHash = null;
+
+ if (password) {
+ const salt = crypto.randomBytes(16).toString('hex');
+ passwordHash = await new Promise((resolve, reject) => {
+ crypto.scrypt(password, salt, 64, (err, key) => {
+ if (err) reject(err);
+ else resolve(`${salt}:${key.toString('hex')}`);
+ });
+ });
+ }
+
+ const result = await query(
+ `INSERT INTO zen_nuage_shares
+ (token, target_type, target_id, share_type, user_id, permission, password_hash, expires_at, upload_limit_bytes)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
+ RETURNING *`,
+ [token, targetType, targetId, shareType, userId, permission, passwordHash, expiresAt, uploadLimitBytes]
+ );
+ return result.rows[0];
+}
+
+export async function revokeShare(id) {
+ const result = await query(
+ `UPDATE zen_nuage_shares SET is_active = false WHERE id = $1 RETURNING *`,
+ [id]
+ );
+ return result.rows[0] || null;
+}
+
+export async function updateShare(id, {
+ permission,
+ expiresAt,
+ uploadLimitBytes,
+ password,
+ clearPassword,
+ userId,
+ shareType,
+ isActive,
+} = {}) {
+ let passwordHash = undefined;
+ if (clearPassword) {
+ passwordHash = null;
+ } else if (password) {
+ const salt = crypto.randomBytes(16).toString('hex');
+ passwordHash = await new Promise((resolve, reject) => {
+ crypto.scrypt(password, salt, 64, (err, key) => {
+ if (err) reject(err);
+ else resolve(`${salt}:${key.toString('hex')}`);
+ });
+ });
+ }
+
+ const sets = [];
+ const values = [];
+ let idx = 1;
+
+ if (permission !== undefined) { sets.push(`permission = $${idx++}`); values.push(permission); }
+ if (expiresAt !== undefined) { sets.push(`expires_at = $${idx++}`); values.push(expiresAt); }
+ if (uploadLimitBytes !== undefined){ sets.push(`upload_limit_bytes = $${idx++}`); values.push(uploadLimitBytes); }
+ if (passwordHash !== undefined) { sets.push(`password_hash = $${idx++}`); values.push(passwordHash); }
+ if (userId !== undefined) { sets.push(`user_id = $${idx++}`); values.push(userId); }
+ if (shareType !== undefined) { sets.push(`share_type = $${idx++}`); values.push(shareType); }
+ if (isActive !== undefined) { sets.push(`is_active = $${idx++}`); values.push(isActive); }
+
+ if (sets.length === 0) throw new Error('Aucun champ à modifier');
+
+ values.push(id);
+ const result = await query(
+ `UPDATE zen_nuage_shares SET ${sets.join(', ')} WHERE id = $${idx} RETURNING *`,
+ values
+ );
+ return result.rows[0] || null;
+}
+
+export function isShareValid(share) {
+ if (!share || !share.is_active) return false;
+ if (share.expires_at && new Date(share.expires_at) < new Date()) return false;
+ return true;
+}
+
+export async function verifySharePassword(share, password) {
+ if (!share.password_hash) return true;
+ const [salt, storedKey] = share.password_hash.split(':');
+ return new Promise((resolve, reject) => {
+ crypto.scrypt(password, salt, 64, (err, derivedKey) => {
+ if (err) reject(err);
+ else {
+ const storedBuf = Buffer.from(storedKey, 'hex');
+ resolve(
+ storedBuf.length === derivedKey.length &&
+ crypto.timingSafeEqual(storedBuf, derivedKey)
+ );
+ }
+ });
+ });
+}
+
+// ─── User search (for share panel) ──────────────────────────────────────────
+
+export async function searchUsers(q, limit = 10) {
+ const like = `%${q}%`;
+ const result = await query(
+ `SELECT id, name, email
+ FROM zen_auth_users
+ WHERE (name ILIKE $1 OR email ILIKE $1)
+ ORDER BY name
+ LIMIT $2`,
+ [like, limit]
+ );
+ return result.rows;
+}
+
+// ─── Shared content access (for public pages and client section) ─────────────
+
+export async function getSharedFolderContents(share, folderId = null) {
+ if (share.target_type === 'file') {
+ const file = await getFileById(share.target_id);
+ return { folders: [], files: file ? [file] : [] };
+ }
+
+ // For folder shares: start at the shared root or a sub-folder within it
+ const targetId = folderId || share.target_id;
+
+ // Verify the requested folder is within the share scope
+ if (folderId && folderId !== share.target_id) {
+ const breadcrumb = await getFolderBreadcrumb(folderId);
+ const inScope = breadcrumb.some(f => f.id === share.target_id);
+ if (!inScope) throw new Error('Dossier hors de la portée du partage');
+ }
+
+ return getFolderContents(targetId);
+}
+
+export async function getSharedBreadcrumb(share, folderId = null) {
+ if (!folderId || folderId === share.target_id) return [];
+ const full = await getFolderBreadcrumb(folderId);
+ const rootIndex = full.findIndex(f => f.id === share.target_id);
+ return rootIndex >= 0 ? full.slice(rootIndex + 1) : [];
+}
+
+// ─── Share scope validators ──────────────────────────────────────────────────
+
+/**
+ * Checks whether a file belongs to the scope of a share.
+ * For file shares: the file must be the shared file itself.
+ * For folder shares: the file must live inside the shared folder tree.
+ */
+export async function isFileInShare(fileId, share) {
+ const file = await getFileById(fileId);
+ if (!file) return false;
+
+ if (share.target_type === 'file') {
+ return file.id === share.target_id;
+ }
+
+ // Folder share: file must be in the shared folder or a descendant
+ if (!file.folder_id) return false;
+ if (file.folder_id === share.target_id) return true;
+ const breadcrumb = await getFolderBreadcrumb(file.folder_id);
+ return breadcrumb.some(f => f.id === share.target_id);
+}
+
+/**
+ * Checks whether a folder belongs to the scope of a share.
+ * Only meaningful for folder shares; the folder must be the root or a descendant.
+ */
+export async function isFolderInShare(folderId, share) {
+ if (share.target_type !== 'folder') return false;
+ if (folderId === share.target_id) return true;
+ const breadcrumb = await getFolderBreadcrumb(folderId);
+ return breadcrumb.some(f => f.id === share.target_id);
+}
+
+// ─── Password cookie helpers (server-side enforcement) ───────────────────────
+
+export function getPasswordCookieName(token) {
+ return `zen_nuage_pw_${token}`;
+}
+
+export function signPasswordToken(token) {
+ const secret = process.env.ZEN_SESSION_SECRET;
+ if (!secret) throw new Error('ZEN_SESSION_SECRET environment variable is not set');
+ return crypto.createHmac('sha256', secret).update(token).digest('hex');
+}
diff --git a/src/modules/nuage/dashboard/ClientNuageSection.js b/src/modules/nuage/dashboard/ClientNuageSection.js
new file mode 100644
index 0000000..0cfd8ca
--- /dev/null
+++ b/src/modules/nuage/dashboard/ClientNuageSection.js
@@ -0,0 +1,424 @@
+'use client';
+
+/**
+ * Client Nuage Section
+ * Unified file explorer for the client portal.
+ * Shares are displayed as folders/files at the root level.
+ * Clicking a folder share navigates into it like a real file explorer.
+ *
+ * Props:
+ * apiBasePath — base URL for the Zen API (default: '/zen/api')
+ * emptyMessage — message shown when there are no active shares
+ * newTab — open file download links in a new tab
+ */
+
+import React, { useState, useEffect, useRef } from 'react';
+import {
+ CloudUploadIcon,
+ Tick02Icon,
+} from '../../../shared/Icons.js';
+import { Loading, Button, Card, Breadcrumb } from '../../../shared/components';
+import NuageFileTable from '../components/NuageFileTable.js';
+
+// ─── Main explorer ─────────────────────────────────────────────────────────────
+
+export default function ClientNuageSection({
+ apiBasePath = '/zen/api',
+ emptyMessage = 'Aucun document partagé pour le moment.',
+ newTab = false,
+}) {
+ const fileInputRef = useRef(null);
+ const isInitialMount = useRef(true);
+ const skipHistoryPush = useRef(false);
+
+ // Navigation
+ const [activeShare, setActiveShare] = useState(null);
+ const [folderId, setFolderId] = useState(null);
+
+ // Data
+ const [shares, setShares] = useState([]);
+ const [contents, setContents] = useState({ folders: [], files: [] });
+ const [breadcrumb, setBreadcrumb] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ // Upload
+ const [uploading, setUploading] = useState(false);
+ const [uploadQueue, setUploadQueue] = useState([]);
+ const [uploadError, setUploadError] = useState(null);
+
+ // Sort (only used inside a share)
+ const [sortBy, setSortBy] = useState('name');
+ const [sortOrder, setSortOrder] = useState('asc');
+
+ // ─── Data fetching ──────────────────────────────────────────────────────────
+
+ useEffect(() => {
+ if (!activeShare) {
+ fetchShares();
+ } else {
+ fetchContents();
+ }
+ }, [activeShare, folderId]);
+
+ // Sync navigation state to URL history
+ useEffect(() => {
+ if (typeof window === 'undefined') return;
+ if (isInitialMount.current) {
+ isInitialMount.current = false;
+ return;
+ }
+ if (skipHistoryPush.current) {
+ skipHistoryPush.current = false;
+ return;
+ }
+ const url = new URL(window.location.href);
+ if (activeShare) {
+ url.searchParams.set('share', activeShare.token);
+ if (folderId) url.searchParams.set('folder', folderId);
+ else url.searchParams.delete('folder');
+ } else {
+ url.searchParams.delete('share');
+ url.searchParams.delete('folder');
+ }
+ window.history.pushState(
+ { shareToken: activeShare?.token ?? null, folderId: folderId ?? null },
+ '',
+ url.toString()
+ );
+ }, [activeShare, folderId]);
+
+ // Handle browser back/forward
+ useEffect(() => {
+ if (typeof window === 'undefined') return;
+ const onPop = (event) => {
+ skipHistoryPush.current = true;
+ const state = event.state;
+ if (!state?.shareToken) {
+ setActiveShare(null);
+ setFolderId(null);
+ setBreadcrumb([]);
+ setContents({ folders: [], files: [] });
+ } else {
+ const share = shares.find(s => s.token === state.shareToken);
+ setActiveShare(share || null);
+ setFolderId(state.folderId || null);
+ }
+ };
+ window.addEventListener('popstate', onPop);
+ return () => window.removeEventListener('popstate', onPop);
+ }, [shares]);
+
+ const fetchShares = async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ const base = typeof window !== 'undefined' ? window.location.origin : '';
+ const res = await fetch(`${base}${apiBasePath}/nuage/me`, { credentials: 'include' });
+ const data = await res.json();
+ if (!res.ok || !data.success) throw new Error(data.error || 'Erreur lors du chargement');
+ const allShares = data.shares || [];
+ setShares(allShares);
+ // Restore navigation from URL on initial load
+ const urlParams = new URLSearchParams(window.location.search);
+ const shareTokenFromUrl = urlParams.get('share');
+ if (shareTokenFromUrl) {
+ const share = allShares.find(s => s.token === shareTokenFromUrl);
+ if (share) {
+ skipHistoryPush.current = true;
+ setActiveShare(share);
+ setFolderId(urlParams.get('folder') || null);
+ }
+ }
+ } catch (e) {
+ setError(e.message);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const fetchContents = async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ const base = typeof window !== 'undefined' ? window.location.origin : '';
+ const params = new URLSearchParams({ shareId: activeShare.token });
+ if (folderId) params.set('folder', folderId);
+ const res = await fetch(`${base}${apiBasePath}/nuage/shared?${params}`, { credentials: 'include' });
+ const data = await res.json();
+ if (!res.ok || !data.success) throw new Error(data.error || 'Erreur');
+ setContents({ folders: data.folders || [], files: data.files || [] });
+ setBreadcrumb(data.breadcrumb || []);
+ } catch (e) {
+ setError(e.message);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ // ─── Navigation ─────────────────────────────────────────────────────────────
+
+ const goToRoot = () => {
+ setActiveShare(null);
+ setFolderId(null);
+ setBreadcrumb([]);
+ setContents({ folders: [], files: [] });
+ setSortBy('name');
+ setSortOrder('asc');
+ };
+
+ const goToShareRoot = () => {
+ setFolderId(null);
+ };
+
+ const openShare = (share) => {
+ const isExpired = share.expires_at && new Date(share.expires_at) < new Date();
+ if (isExpired) return;
+ setActiveShare(share);
+ setFolderId(null);
+ setBreadcrumb([]);
+ };
+
+ // ─── Sort helpers ────────────────────────────────────────────────────────────
+
+ const sorted = (items, isFolder) => {
+ return [...items].sort((a, b) => {
+ let valA, valB;
+ if (sortBy === 'name') {
+ valA = (isFolder ? a.name : a.display_name)?.toLowerCase();
+ valB = (isFolder ? b.name : b.display_name)?.toLowerCase();
+ } else if (sortBy === 'date') {
+ valA = a.created_at;
+ valB = b.created_at;
+ } else if (sortBy === 'size' && !isFolder) {
+ valA = a.size;
+ valB = b.size;
+ } else {
+ return 0;
+ }
+ if (valA < valB) return sortOrder === 'asc' ? -1 : 1;
+ if (valA > valB) return sortOrder === 'asc' ? 1 : -1;
+ return 0;
+ });
+ };
+
+ const toggleSort = (field) => {
+ if (sortBy === field) setSortOrder(o => o === 'asc' ? 'desc' : 'asc');
+ else { setSortBy(field); setSortOrder('asc'); }
+ };
+
+ // ─── Download ────────────────────────────────────────────────────────────────
+
+ const getProxyUrl = (fileId) => {
+ const params = new URLSearchParams({ fileId, shareId: activeShare.token });
+ const base = typeof window !== 'undefined' ? window.location.origin : '';
+ return `${base}${apiBasePath}/nuage/download?${params}`;
+ };
+
+ const handleDownload = (file) => {
+ const a = document.createElement('a');
+ a.href = getProxyUrl(file.id);
+ a.download = file.display_name;
+ if (newTab) a.target = '_blank';
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ };
+
+ // ─── Upload ──────────────────────────────────────────────────────────────────
+
+ const uploadFiles = async (files) => {
+ const fileList = Array.from(files);
+ setUploading(true);
+ setUploadError(null);
+ setUploadQueue(fileList.map(f => ({ name: f.name, done: false })));
+ const base = typeof window !== 'undefined' ? window.location.origin : '';
+
+ for (let i = 0; i < fileList.length; i++) {
+ const file = fileList[i];
+ const formData = new FormData();
+ formData.append('file', file);
+ formData.append('shareId', activeShare.token);
+ if (folderId) formData.append('folderId', folderId);
+ try {
+ const res = await fetch(`${base}${apiBasePath}/nuage/upload`, {
+ method: 'POST',
+ credentials: 'include',
+ body: formData,
+ });
+ const data = await res.json();
+ if (data.success) {
+ setUploadQueue(q => q.map((f, idx) => idx === i ? { ...f, done: true } : f));
+ } else {
+ setUploadError(data.error || 'Erreur lors du téléversement');
+ }
+ } catch (e) {
+ setUploadError('Erreur réseau');
+ }
+ }
+
+ setUploading(false);
+ setUploadQueue([]);
+ fetchContents();
+ };
+
+ // ─── Table data ──────────────────────────────────────────────────────────────
+
+ let tableData = [];
+ let tableSortBy;
+ let tableSortOrder;
+ let tableOnSort;
+
+ if (!activeShare) {
+ // Root: shares as rows (expired shares hidden)
+ tableData = shares
+ .filter(share => !share.expires_at || new Date(share.expires_at) >= new Date())
+ .map(share => ({ ...share, _type: 'share' }));
+ } else {
+ // Inside a share: folders + files
+ tableData = [
+ ...sorted(contents.folders, true).map(f => ({ ...f, _type: 'folder' })),
+ ...sorted(contents.files, false).map(f => ({ ...f, _type: 'file' })),
+ ];
+ tableSortBy = sortBy;
+ tableSortOrder = sortOrder;
+ tableOnSort = toggleSort;
+ }
+
+
+ // ─── Breadcrumb ──────────────────────────────────────────────────────────────
+
+ const breadcrumbItems = [
+ {
+ key: 'root',
+ label: 'Mes fichiers',
+ onClick: goToRoot,
+ active: !activeShare,
+ },
+ ...(activeShare
+ ? [
+ {
+ key: 'share',
+ label: activeShare.target_name || 'Partage',
+ onClick: goToShareRoot,
+ active: breadcrumb.length === 0,
+ },
+ ...(breadcrumb.length > 3
+ ? [
+ { key: 'ellipsis', label: '···', onClick: () => setFolderId(breadcrumb[0].id), active: false },
+ ...breadcrumb.slice(-2).map((f, i) => ({
+ key: f.id,
+ label: f.name,
+ onClick: () => setFolderId(f.id),
+ active: i === 1,
+ })),
+ ]
+ : breadcrumb.map((f, i) => ({
+ key: f.id,
+ label: f.name,
+ onClick: () => setFolderId(f.id),
+ active: i === breadcrumb.length - 1,
+ }))
+ ),
+ ]
+ : []),
+ ];
+
+ // ─── Render ───────────────────────────────────────────────────────────────────
+
+ const showUploadZone = activeShare &&
+ activeShare.permission === 'collaborator' &&
+ activeShare.target_type === 'folder';
+
+ return (
+
+
{ if (e.target.files?.length) uploadFiles(e.target.files); e.target.value = ''; }}
+ />
+
+ {/* Breadcrumb */}
+
+
+ {/* Upload zone (collaborator folder shares only) */}
+ {showUploadZone && (
+
e.preventDefault()}
+ onDrop={e => { e.preventDefault(); uploadFiles(e.dataTransfer.files); }}
+ >
+
+
+ {uploading ? 'Téléversement…' : 'Glissez des fichiers ici'}
+
+
fileInputRef.current?.click()}
+ loading={uploading}
+ icon={}
+ >
+ Choisir des fichiers
+
+ {uploadError &&
{uploadError}
}
+
+ )}
+
+ {/* Upload queue */}
+ {uploading && uploadQueue.length > 0 && (
+
+
Téléversement en cours…
+
+ {uploadQueue.map((f, i) => (
+
+ {f.done ? : }
+ {f.name}
+
+ ))}
+
+
+ )}
+
+ {/* Explorer table */}
+
+ {error ? (
+ {error}
+ ) : (
+ {
+ if (item._type === 'share') {
+ openShare(item);
+ } else if (item._type === 'folder') {
+ setFolderId(item.id);
+ }
+ }}
+ renderActions={(item) => {
+ if (item._type !== 'file') return null;
+ return (
+ { e.stopPropagation(); handleDownload(item); }}
+ className='-m-2'
+ >
+ Télécharger
+
+ );
+ }}
+ emptyMessage={!activeShare ? emptyMessage : 'Ce dossier est vide'}
+ size="sm"
+ className='min-h-64'
+ />
+ )}
+
+
+
+ );
+}
diff --git a/src/modules/nuage/dashboard/index.js b/src/modules/nuage/dashboard/index.js
new file mode 100644
index 0000000..a20ff45
--- /dev/null
+++ b/src/modules/nuage/dashboard/index.js
@@ -0,0 +1,10 @@
+/**
+ * Nuage Dashboard Module
+ * Exports client-facing section for the user portal
+ *
+ * Stats actions are kept separate to avoid 'use server' conflicts:
+ * import via modules.actions.js or directly from statsActions.js
+ */
+
+export { default as ClientNuageSection } from './ClientNuageSection.js';
+export { default } from './ClientNuageSection.js';
diff --git a/src/modules/nuage/db.js b/src/modules/nuage/db.js
new file mode 100644
index 0000000..1f53dd2
--- /dev/null
+++ b/src/modules/nuage/db.js
@@ -0,0 +1,109 @@
+/**
+ * Nuage Module — Database
+ * Creates tables for folders, files, and shares
+ */
+
+import { query } from '@hykocx/zen/database';
+
+async function tableExists(tableName) {
+ const result = await query(
+ `SELECT EXISTS (
+ SELECT FROM information_schema.tables
+ WHERE table_schema = 'public' AND table_name = $1
+ )`,
+ [tableName]
+ );
+ return result.rows[0].exists;
+}
+
+/**
+ * Create all Nuage tables
+ * @returns {Promise<{ created: string[], skipped: string[] }>}
+ */
+export async function createTables() {
+ const created = [];
+ const skipped = [];
+
+ // zen_nuage_folders
+ if (!(await tableExists('zen_nuage_folders'))) {
+ await query(`
+ CREATE TABLE zen_nuage_folders (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ name VARCHAR(255) NOT NULL,
+ parent_id UUID REFERENCES zen_nuage_folders(id) ON DELETE CASCADE,
+ depth INTEGER NOT NULL DEFAULT 0,
+ created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
+ )
+ `);
+ await query(`CREATE INDEX ON zen_nuage_folders(parent_id)`);
+ console.log(' ✓ Created zen_nuage_folders');
+ created.push('zen_nuage_folders');
+ } else {
+ console.log(' - Skipped zen_nuage_folders (already exists)');
+ skipped.push('zen_nuage_folders');
+ }
+
+ // zen_nuage_files
+ if (!(await tableExists('zen_nuage_files'))) {
+ await query(`
+ CREATE TABLE zen_nuage_files (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ folder_id UUID REFERENCES zen_nuage_folders(id) ON DELETE CASCADE,
+ original_name VARCHAR(255) NOT NULL,
+ display_name VARCHAR(255) NOT NULL,
+ mime_type VARCHAR(255) NOT NULL,
+ size BIGINT NOT NULL DEFAULT 0,
+ r2_key TEXT NOT NULL,
+ created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
+ )
+ `);
+ await query(`CREATE INDEX ON zen_nuage_files(folder_id)`);
+ console.log(' ✓ Created zen_nuage_files');
+ created.push('zen_nuage_files');
+ } else {
+ console.log(' - Skipped zen_nuage_files (already exists)');
+ skipped.push('zen_nuage_files');
+ }
+
+ // zen_nuage_shares
+ // References zen_auth_users (CMS auth users table)
+ if (!(await tableExists('zen_nuage_shares'))) {
+ await query(`
+ CREATE TABLE zen_nuage_shares (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ token VARCHAR(255) UNIQUE NOT NULL,
+ target_type VARCHAR(10) NOT NULL CHECK (target_type IN ('file', 'folder')),
+ target_id UUID NOT NULL,
+ share_type VARCHAR(10) NOT NULL CHECK (share_type IN ('user', 'anonymous')),
+ user_id TEXT REFERENCES zen_auth_users(id) ON DELETE CASCADE,
+ permission VARCHAR(15) NOT NULL CHECK (permission IN ('reader', 'collaborator')),
+ password_hash TEXT,
+ expires_at TIMESTAMPTZ,
+ upload_limit_bytes BIGINT,
+ is_active BOOLEAN NOT NULL DEFAULT true,
+ created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
+ )
+ `);
+ await query(`CREATE INDEX ON zen_nuage_shares(token)`);
+ await query(`CREATE INDEX ON zen_nuage_shares(target_id, target_type, is_active)`);
+ await query(`CREATE INDEX ON zen_nuage_shares(user_id, is_active)`);
+ console.log(' ✓ Created zen_nuage_shares');
+ created.push('zen_nuage_shares');
+ } else {
+ console.log(' - Skipped zen_nuage_shares (already exists)');
+ skipped.push('zen_nuage_shares');
+ }
+
+ return { created, skipped };
+}
+
+/**
+ * Drop all Nuage tables (in dependency order)
+ */
+export async function dropTables() {
+ await query('DROP TABLE IF EXISTS zen_nuage_shares CASCADE');
+ await query('DROP TABLE IF EXISTS zen_nuage_files CASCADE');
+ await query('DROP TABLE IF EXISTS zen_nuage_folders CASCADE');
+}
diff --git a/src/modules/nuage/email/NuageShareEmail.jsx b/src/modules/nuage/email/NuageShareEmail.jsx
new file mode 100644
index 0000000..9f91b32
--- /dev/null
+++ b/src/modules/nuage/email/NuageShareEmail.jsx
@@ -0,0 +1,105 @@
+/**
+ * Nuage Share Notification Email
+ * Sent when sharing a file or folder with a user or via anonymous link
+ */
+
+import { Button, Section, Text, Link } from '@react-email/components';
+import { BaseLayout } from '@hykocx/zen/email/templates';
+
+/**
+ * @param {object} props
+ * @param {string} props.recipientName — Name or email of the recipient
+ * @param {string} props.shareUrl — Full URL to access the share
+ * @param {string} props.targetName — Name of the shared file or folder
+ * @param {'file'|'folder'} props.targetType
+ * @param {'user'|'anonymous'} props.shareType
+ * @param {string|null} props.expiresAt — ISO date string of expiration, or null
+ * @param {string|null} props.customMessage — Optional message from the sender
+ * @param {string} props.companyName
+ */
+export const NuageShareEmail = ({
+ recipientName,
+ shareUrl,
+ targetName,
+ targetType = 'folder',
+ shareType = 'anonymous',
+ expiresAt = null,
+ customMessage = null,
+ companyName,
+}) => {
+ const appName = companyName || process.env.ZEN_NAME || 'Nuage';
+ const supportEmail = process.env.ZEN_SUPPORT_EMAIL || 'support@exemple.com';
+
+ const isFile = targetType === 'file';
+ const formattedExpiry = expiresAt
+ ? new Date(expiresAt).toLocaleDateString('fr-CA', { year: 'numeric', month: 'long', day: 'numeric' })
+ : null;
+ const preview = `Le ${isFile ? 'fichier' : 'dossier'} « ${targetName} » a été partagé avec vous.${formattedExpiry ? ` Ce partage reste accessible jusqu'au ${formattedExpiry}.` : ''}`;
+
+ return (
+
+
+ Bonjour {recipientName},{' '}
+ {isFile ? 'un fichier a été partagé avec vous.' : 'un dossier a été partagé avec vous.'}
+ {formattedExpiry && <>{' '}Ce partage expire le {formattedExpiry}.>}
+
+
+ {customMessage && (
+
+ )}
+
+
+
+ Détails du partage
+
+
+ Type :
+ {isFile ? 'Fichier' : 'Dossier'}
+
+
+ Nom :
+ {targetName}
+
+ {formattedExpiry && (
+
+ Expire le :
+ {formattedExpiry}
+
+ )}
+
+
+
+
+ {isFile ? 'Accéder au fichier' : 'Accéder au dossier'}
+
+
+
+
+ Lien :{' '}
+
+ {shareUrl}
+
+
+
+
+ {shareType === 'anonymous'
+ ? 'Ce lien est accessible à toute personne qui le possède.'
+ : 'Ce partage est destiné à votre compte uniquement.'}
+ {' '}Si vous n'attendiez pas ce message, ignorez-le.
+
+
+ );
+};
diff --git a/src/modules/nuage/email/index.js b/src/modules/nuage/email/index.js
new file mode 100644
index 0000000..b22a15c
--- /dev/null
+++ b/src/modules/nuage/email/index.js
@@ -0,0 +1,4 @@
+/**
+ * Nuage Email Templates
+ */
+export { NuageShareEmail } from './NuageShareEmail.jsx';
diff --git a/src/modules/nuage/metadata.js b/src/modules/nuage/metadata.js
new file mode 100644
index 0000000..258a88e
--- /dev/null
+++ b/src/modules/nuage/metadata.js
@@ -0,0 +1,69 @@
+/**
+ * Nuage Metadata Utilities
+ * Functions to generate dynamic metadata for nuage share pages
+ */
+
+import { generateMetadata, generateRobots } from '../../shared/lib/metadata/index.js';
+import { getShareInfoForMetadata } from './crud.js';
+import { getAppName } from '../../shared/lib/appConfig.js';
+
+/**
+ * Generate metadata for a public share page.
+ *
+ * @param {string} token - Share token
+ * @param {Object} options - Generation options
+ * @returns {Promise