SPB Git forge

spb/spb-cloud

Public
10commits 1branches 0releases
3.2 MBsize
maindefault branch
1 h agolast push
JavaScript 56.7% TypeScript 42.6%

feat: AI improvements, shared spaces, password manager & UX enhancements

## New Features

### Password Manager
- Full credential vault with AES-256-GCM encryption (lib/crypto.ts)
- Password generator (length, charset options) + strength checker
- Reveal/copy/edit/delete per entry with favicon icons
- API routes: GET/POST /api/passwords, GET/PATCH/DELETE /api/passwords/[id]
- Sidebar link added under "Mots de passe"

### Shared Spaces
- Users can now create shared folders (not just admin)
- CreateSharedFolderModal with user picker
- Admin management page at /admin/shared-spaces
- SharedSpaceManagement component + SharedBadge UI
- User-facing shared space view at /dashboard/shared-space/[id]
- Full API: list/create/update/delete spaces, members, storage quota

### Change Password
- ChangePasswordModal with current/new/confirm fields + strength indicator
- POST /api/auth/change-password with bcrypt verify & hash
- Button in sidebar footer

## AI Improvements
- Upgraded to claude-sonnet-4-6 with streaming SSE chat
- Multi-turn conversation history support
- react-markdown rendering for assistant messages
- File mention detection: auto-attach PDF/image/text content to chat
- New Analyser tab with file picker; Search results have Analyser/Download/Summarize actions
- Fixed: legacy files with userId=null now included in all AI queries

## Other Improvements
- Word document preview via mammoth.js (WordPreview plugin)
- Colored file type icons in FileCard
- Proper try/catch on all password API routes
- Better error handling in frontend (network errors, non-JSON responses)
- lib/prisma.ts cleanup for hot-reload stability

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
simon-pierre boucher committed 7 mo ago (Feb 21, 2026) parent 99af52c

41 changed files +3,608 −283

added app/admin/shared-spaces/page.tsx +18 −0
@@ -0,0 +1,18 @@
1 +"use client";
2 +
3 +import AppShell from "@/components/layout/AppShell";
4 +import SharedSpaceManagement from "@/components/admin/SharedSpaceManagement";
5 +
6 +export default function SharedSpacesPage() {
7 + return (
8 + <AppShell>
9 + <div className="p-4 lg:p-6 space-y-6">
10 + <div>
11 + <h1 className="text-2xl font-semibold text-gray-900 dark:text-white">Espaces partagés</h1>
12 + <p className="text-sm text-gray-500 mt-1">Créez des clouds partagés entre utilisateurs · 500 Go par espace</p>
13 + </div>
14 + <SharedSpaceManagement />
15 + </div>
16 + </AppShell>
17 + );
18 +}
modified app/api/ai/chat/route.ts +181 −7
@@ -1,20 +1,194 @@
1 1 import { NextRequest, NextResponse } from "next/server";
2 2 import { getIronSession } from "iron-session";
3 3 import { SessionData, sessionOptions } from "@/lib/session";
4 −import { aiChat } from "@/lib/ai";
4 +import { buildFileContext, ChatMessage } from "@/lib/ai";
5 +import { getFilePath } from "@/lib/storage";
6 +import { existsSync, readFileSync } from "fs";
7 +import { prisma } from "@/lib/prisma";
8 +import Anthropic from "@anthropic-ai/sdk";
9 +
10 +const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
11 +const MODEL = "claude-sonnet-4-6";
12 +
13 +// Find a file mentioned by name in the user message
14 +async function findMentionedFile(message: string, userId?: string) {
15 + const where = userId
16 + ? { OR: [{ userId }, { userId: null }] }
17 + : {};
18 +
19 + const files = await prisma.file.findMany({
20 + where,
21 + select: { id: true, name: true, mimeType: true, storagePath: true, ocrText: true },
22 + orderBy: { createdAt: "desc" },
23 + });
24 +
25 + const msgLower = message.toLowerCase();
26 + // Sort by name length desc so longer/more specific names match first
27 + const sorted = [...files].sort((a, b) => b.name.length - a.name.length);
28 + return sorted.find((f) => msgLower.includes(f.name.toLowerCase())) ?? null;
29 +}
30 +
31 +// Build content blocks for a file to send to Claude
32 +function buildFileBlocks(file: {
33 + name: string;
34 + mimeType: string;
35 + storagePath: string;
36 + ocrText: string | null;
37 +}): Anthropic.ContentBlockParam[] {
38 + const filePath = getFilePath(file.storagePath);
39 + if (!existsSync(filePath)) return [];
40 +
41 + try {
42 + // PDFs — send as native document block (Claude reads PDFs natively)
43 + if (file.mimeType === "application/pdf") {
44 + const base64 = readFileSync(filePath).toString("base64");
45 + return [
46 + {
47 + type: "document",
48 + source: { type: "base64", media_type: "application/pdf", data: base64 },
49 + } as Anthropic.ContentBlockParam,
50 + ];
51 + }
52 +
53 + // Images — send as image block
54 + if (file.mimeType.startsWith("image/")) {
55 + const supported = ["image/jpeg", "image/png", "image/gif", "image/webp"];
56 + if (supported.includes(file.mimeType)) {
57 + const base64 = readFileSync(filePath).toString("base64");
58 + return [
59 + {
60 + type: "image",
61 + source: {
62 + type: "base64",
63 + media_type: file.mimeType as "image/jpeg" | "image/png" | "image/gif" | "image/webp",
64 + data: base64,
65 + },
66 + },
67 + ];
68 + }
69 + }
70 +
71 + // Text / code / markdown / JSON — read directly
72 + if (
73 + file.mimeType.startsWith("text/") ||
74 + file.mimeType.includes("json") ||
75 + file.mimeType.includes("xml") ||
76 + file.mimeType.includes("markdown")
77 + ) {
78 + const content = readFileSync(filePath, "utf-8").substring(0, 50000);
79 + return [
80 + { type: "text", text: `Contenu du fichier "${file.name}":\n\`\`\`\n${content}\n\`\`\`` },
81 + ];
82 + }
83 +
84 + // Fallback: use OCR text if available
85 + if (file.ocrText) {
86 + return [
87 + { type: "text", text: `Texte extrait du fichier "${file.name}" (OCR):\n${file.ocrText}` },
88 + ];
89 + }
90 + } catch {}
91 + return [];
92 +}
5 93
6 94 export async function POST(request: NextRequest) {
7 − const response = NextResponse.next();
8 − const session = await getIronSession<SessionData>(request, response, sessionOptions);
95 + const tempResponse = new NextResponse();
96 + const session = await getIronSession<SessionData>(request, tempResponse, sessionOptions);
9 97 if (!session.isLoggedIn) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
10 98
11 − const { message, context } = await request.json();
99 + const { message, history = [], stream: wantStream = true } = await request.json();
12 100 if (!message) return NextResponse.json({ error: "message required" }, { status: 400 });
13 101
14 102 try {
15 − const reply = await aiChat(message, context);
16 − return NextResponse.json({ reply });
103 + const [fileContext, mentionedFile] = await Promise.all([
104 + buildFileContext(session.userId!),
105 + findMentionedFile(message, session.userId),
106 + ]);
107 +
108 + const systemPrompt = `Tu es l'assistant IA de SPB Cloud, un cloud personnel privé. Tu aides l'utilisateur avec ses fichiers et réponds toujours en français. Sois concis et précis. Tu peux utiliser le markdown (gras, listes, code) pour structurer tes réponses. Quand on te donne un fichier, lis-en le contenu et réponds directement en te basant dessus.
109 +
110 +${fileContext}`;
111 +
112 + // Build user message — attach file content if a file is mentioned
113 + let userContent: Anthropic.ContentBlockParam[];
114 + if (mentionedFile) {
115 + const fileBlocks = buildFileBlocks(mentionedFile);
116 + if (fileBlocks.length > 0) {
117 + userContent = [...fileBlocks, { type: "text", text: message }];
118 + } else {
119 + // File found but couldn't read it — mention ocrText if available
120 + const fallback = mentionedFile.ocrText
121 + ? `[Contenu OCR de "${mentionedFile.name}"]\n${mentionedFile.ocrText}\n\n`
122 + : "";
123 + userContent = [{ type: "text", text: fallback + message }];
124 + }
125 + } else {
126 + userContent = [{ type: "text", text: message }];
127 + }
128 +
129 + const messages: Anthropic.MessageParam[] = [
130 + ...(history as ChatMessage[]).map((m) => ({
131 + role: m.role as "user" | "assistant",
132 + content: m.content,
133 + })),
134 + { role: "user" as const, content: userContent },
135 + ];
136 +
137 + if (!wantStream) {
138 + const response = await anthropic.messages.create({
139 + model: MODEL,
140 + max_tokens: 4096,
141 + system: systemPrompt,
142 + messages,
143 + });
144 + const reply = response.content[0].type === "text" ? response.content[0].text : "";
145 + return NextResponse.json({ reply });
146 + }
147 +
148 + // Streaming
149 + const encoder = new TextEncoder();
150 + const readable = new ReadableStream({
151 + async start(controller) {
152 + try {
153 + const anthropicStream = anthropic.messages.stream({
154 + model: MODEL,
155 + max_tokens: 4096,
156 + system: systemPrompt,
157 + messages,
158 + });
159 +
160 + for await (const chunk of anthropicStream) {
161 + if (
162 + chunk.type === "content_block_delta" &&
163 + chunk.delta.type === "text_delta"
164 + ) {
165 + controller.enqueue(
166 + encoder.encode(`data: ${JSON.stringify({ token: chunk.delta.text })}\n\n`)
167 + );
168 + }
169 + }
170 + controller.enqueue(encoder.encode("data: [DONE]\n\n"));
171 + } catch (err) {
172 + controller.enqueue(
173 + encoder.encode(`data: ${JSON.stringify({ error: (err as Error).message })}\n\n`)
174 + );
175 + } finally {
176 + controller.close();
177 + }
178 + },
179 + });
180 +
181 + return new Response(readable, {
182 + headers: {
183 + "Content-Type": "text/event-stream",
184 + "Cache-Control": "no-cache",
185 + "Connection": "keep-alive",
186 + },
187 + });
17 188 } catch (error) {
18 − return NextResponse.json({ error: "Chat failed", details: (error as Error).message }, { status: 500 });
189 + return NextResponse.json(
190 + { error: "Chat failed", details: (error as Error).message },
191 + { status: 500 }
192 + );
19 193 }
20 194 }
added app/api/ai/files/route.ts +41 −0
@@ -0,0 +1,41 @@
1 +import { NextRequest, NextResponse } from "next/server";
2 +import { getIronSession } from "iron-session";
3 +import { SessionData, sessionOptions } from "@/lib/session";
4 +import { prisma } from "@/lib/prisma";
5 +
6 +export async function GET(request: NextRequest) {
7 + const response = NextResponse.next();
8 + const session = await getIronSession<SessionData>(request, response, sessionOptions);
9 + if (!session.isLoggedIn) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
10 +
11 + const { searchParams } = new URL(request.url);
12 + const q = searchParams.get("q") || "";
13 +
14 + // Include files owned by user + legacy files with no owner (userId=null)
15 + const ownerFilter = session.userId
16 + ? { OR: [{ userId: session.userId as string }, { userId: null as null }] }
17 + : {};
18 +
19 + const files = await prisma.file.findMany({
20 + where: {
21 + AND: [
22 + ownerFilter,
23 + ...(q ? [{ name: { contains: q } }] : []),
24 + ],
25 + },
26 + select: {
27 + id: true,
28 + name: true,
29 + mimeType: true,
30 + size: true,
31 + aiTags: true,
32 + aiDescription: true,
33 + ocrText: true,
34 + folder: { select: { name: true } },
35 + },
36 + take: 30,
37 + orderBy: { createdAt: "desc" },
38 + });
39 +
40 + return NextResponse.json(files);
41 +}
added app/api/auth/change-password/route.ts +43 −0
@@ -0,0 +1,43 @@
1 +import { NextRequest, NextResponse } from "next/server";
2 +import { getIronSession } from "iron-session";
3 +import { SessionData, sessionOptions } from "@/lib/session";
4 +import { prisma } from "@/lib/prisma";
5 +import bcrypt from "bcryptjs";
6 +
7 +export async function POST(request: NextRequest) {
8 + const response = NextResponse.next();
9 + const session = await getIronSession<SessionData>(request, response, sessionOptions);
10 + if (!session.isLoggedIn || !session.userId) {
11 + return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
12 + }
13 +
14 + const { currentPassword, newPassword } = await request.json();
15 + if (!currentPassword || !newPassword) {
16 + return NextResponse.json({ error: "Champs requis" }, { status: 400 });
17 + }
18 + if (newPassword.length < 6) {
19 + return NextResponse.json({ error: "Le nouveau mot de passe doit faire au moins 6 caractères" }, { status: 400 });
20 + }
21 +
22 + const user = await prisma.user.findUnique({
23 + where: { id: session.userId },
24 + select: { id: true, passwordHash: true },
25 + });
26 +
27 + if (!user) {
28 + return NextResponse.json({ error: "Utilisateur introuvable" }, { status: 404 });
29 + }
30 +
31 + const valid = await bcrypt.compare(currentPassword, user.passwordHash);
32 + if (!valid) {
33 + return NextResponse.json({ error: "Mot de passe actuel incorrect" }, { status: 400 });
34 + }
35 +
36 + const newHash = await bcrypt.hash(newPassword, 12);
37 + await prisma.user.update({
38 + where: { id: session.userId },
39 + data: { passwordHash: newHash },
40 + });
41 +
42 + return NextResponse.json({ success: true });
43 +}
modified app/api/auth/login/route.ts +2 −2
@@ -12,10 +12,10 @@ export async function POST(request: NextRequest) {
12 12 if (!allowed) return NextResponse.json({ error: "Trop de tentatives. Réessayez dans 1 minute." }, { status: 429 });
13 13
14 14 const body = await request.json();
15 − const { password, email } = body;
15 + const { password, username } = body;
16 16 if (!password) return NextResponse.json({ error: "Mot de passe requis" }, { status: 400 });
17 17
18 − const { valid, user } = await verifyPassword(password, email || undefined);
18 + const { valid, user } = await verifyPassword(password, username || undefined);
19 19 if (!valid || !user) return NextResponse.json({ error: "Identifiants incorrects" }, { status: 401 });
20 20
21 21 const response = NextResponse.json({ success: true, requires2FA: user.twoFactorEnabled });
modified app/api/files/route.ts +18 −0
@@ -88,6 +88,24 @@ export async function POST(request: NextRequest) {
88 88 );
89 89 }
90 90
91 + // Quota check for shared spaces
92 + if (folderId && folderId !== "root") {
93 + const folder = await prisma.folder.findUnique({ where: { id: folderId } });
94 + if (folder?.sharedSpaceId) {
95 + const space = await prisma.sharedSpace.findUnique({ where: { id: folder.sharedSpaceId } });
96 + if (space) {
97 + const agg = await prisma.file.aggregate({
98 + _sum: { size: true },
99 + where: { folder: { sharedSpaceId: folder.sharedSpaceId } },
100 + });
101 + const usedBytes = Number(agg._sum.size || 0);
102 + if (usedBytes + file.size > Number(space.quotaBytes)) {
103 + return NextResponse.json({ error: "Quota de l'espace partagé dépassé (500 Go max)" }, { status: 413 });
104 + }
105 + }
106 + }
107 + }
108 +
91 109 const buffer = Buffer.from(await file.arrayBuffer());
92 110 const mimeType = mime.lookup(file.name) || file.type || "application/octet-stream";
93 111 const { storagePath, size } = await saveFile(buffer, file.name);
modified app/api/folders/route.ts +10 −4
@@ -25,11 +25,16 @@ export async function GET(request: NextRequest) {
25 25 return NextResponse.json(folders);
26 26 }
27 27
28 + const sharedSpaceId = searchParams.get("sharedSpaceId");
28 29 const where: Record<string, unknown> = {};
29 − if (parentId === "root" || parentId === null) {
30 − where.parentId = null;
30 +
31 + if (sharedSpaceId) {
32 + where.sharedSpaceId = sharedSpaceId;
33 + if (parentId === "root" || parentId === null) where.parentId = null;
34 + else if (parentId) where.parentId = parentId;
31 35 } else {
32 − where.parentId = parentId;
36 + if (parentId === "root" || parentId === null) where.parentId = null;
37 + else where.parentId = parentId;
33 38 }
34 39
35 40 const folders = await prisma.folder.findMany({
@@ -45,7 +50,7 @@ export async function GET(request: NextRequest) {
45 50
46 51 export async function POST(request: NextRequest) {
47 52 const body = await request.json();
48 − const { name, parentId } = body;
53 + const { name, parentId, sharedSpaceId } = body;
49 54
50 55 if (!name || !name.trim()) {
51 56 return NextResponse.json({ error: "Folder name required" }, { status: 400 });
@@ -55,6 +60,7 @@ export async function POST(request: NextRequest) {
55 60 data: {
56 61 name: name.trim(),
57 62 parentId: parentId && parentId !== "root" ? parentId : null,
63 + sharedSpaceId: sharedSpaceId || null,
58 64 },
59 65 });
60 66
added app/api/passwords/[id]/route.ts +58 −0
@@ -0,0 +1,58 @@
1 +import { NextRequest, NextResponse } from "next/server";
2 +import { getIronSession } from "iron-session";
3 +import { SessionData, sessionOptions } from "@/lib/session";
4 +import { prisma } from "@/lib/prisma";
5 +import { encrypt, decrypt } from "@/lib/crypto";
6 +
7 +async function getAuthorized(request: NextRequest, id: string) {
8 + const response = NextResponse.next();
9 + const session = await getIronSession<SessionData>(request, response, sessionOptions);
10 + if (!session.isLoggedIn || !session.userId) return null;
11 + const entry = await prisma.passwordEntry.findFirst({ where: { id, userId: session.userId } });
12 + return entry;
13 +}
14 +
15 +// Reveal decrypted password for a single entry
16 +export async function GET(request: NextRequest, { params }: { params: { id: string } }) {
17 + try {
18 + const entry = await getAuthorized(request, params.id);
19 + if (!entry) return NextResponse.json({ error: "Not found" }, { status: 404 });
20 + return NextResponse.json({ ...entry, password: decrypt(entry.password) });
21 + } catch (err) {
22 + console.error("[GET /api/passwords/[id]]", err);
23 + return NextResponse.json({ error: "Decrypt failed" }, { status: 500 });
24 + }
25 +}
26 +
27 +export async function PATCH(request: NextRequest, { params }: { params: { id: string } }) {
28 + try {
29 + const entry = await getAuthorized(request, params.id);
30 + if (!entry) return NextResponse.json({ error: "Not found" }, { status: 404 });
31 +
32 + const { siteName, siteUrl, username, password, notes } = await request.json();
33 + const data: Record<string, unknown> = {};
34 + if (siteName !== undefined) data.siteName = siteName.trim();
35 + if (siteUrl !== undefined) data.siteUrl = siteUrl?.trim() || null;
36 + if (username !== undefined) data.username = username?.trim() || null;
37 + if (password !== undefined) data.password = encrypt(password);
38 + if (notes !== undefined) data.notes = notes?.trim() || null;
39 +
40 + const updated = await prisma.passwordEntry.update({ where: { id: params.id }, data });
41 + return NextResponse.json({ ...updated, password: "••••••••" });
42 + } catch (err) {
43 + console.error("[PATCH /api/passwords/[id]]", err);
44 + return NextResponse.json({ error: "Erreur serveur" }, { status: 500 });
45 + }
46 +}
47 +
48 +export async function DELETE(request: NextRequest, { params }: { params: { id: string } }) {
49 + try {
50 + const entry = await getAuthorized(request, params.id);
51 + if (!entry) return NextResponse.json({ error: "Not found" }, { status: 404 });
52 + await prisma.passwordEntry.delete({ where: { id: params.id } });
53 + return NextResponse.json({ success: true });
54 + } catch (err) {
55 + console.error("[DELETE /api/passwords/[id]]", err);
56 + return NextResponse.json({ error: "Erreur serveur" }, { status: 500 });
57 + }
58 +}
added app/api/passwords/route.ts +67 −0
@@ -0,0 +1,67 @@
1 +import { NextRequest, NextResponse } from "next/server";
2 +import { getIronSession } from "iron-session";
3 +import { SessionData, sessionOptions } from "@/lib/session";
4 +import { prisma } from "@/lib/prisma";
5 +import { encrypt, decrypt } from "@/lib/crypto";
6 +
7 +export async function GET(request: NextRequest) {
8 + try {
9 + const response = NextResponse.next();
10 + const session = await getIronSession<SessionData>(request, response, sessionOptions);
11 + if (!session.isLoggedIn || !session.userId) {
12 + return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
13 + }
14 +
15 + const entries = await prisma.passwordEntry.findMany({
16 + where: { userId: session.userId },
17 + orderBy: { siteName: "asc" },
18 + });
19 +
20 + const { searchParams } = new URL(request.url);
21 + const withPasswords = searchParams.get("reveal") === "1";
22 +
23 + return NextResponse.json(
24 + entries.map((e) => ({
25 + ...e,
26 + password: withPasswords ? (() => { try { return decrypt(e.password); } catch { return ""; } })() : "••••••••",
27 + }))
28 + );
29 + } catch (err) {
30 + console.error("[GET /api/passwords]", err);
31 + return NextResponse.json({ error: "Erreur serveur" }, { status: 500 });
32 + }
33 +}
34 +
35 +export async function POST(request: NextRequest) {
36 + try {
37 + const response = NextResponse.next();
38 + const session = await getIronSession<SessionData>(request, response, sessionOptions);
39 + if (!session.isLoggedIn || !session.userId) {
40 + return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
41 + }
42 +
43 + const body = await request.json();
44 + const { siteName, siteUrl, username, password, notes } = body;
45 + if (!siteName || !password) {
46 + return NextResponse.json({ error: "siteName et password requis" }, { status: 400 });
47 + }
48 +
49 + const encryptedPassword = encrypt(password);
50 +
51 + const entry = await prisma.passwordEntry.create({
52 + data: {
53 + userId: session.userId,
54 + siteName: siteName.trim(),
55 + siteUrl: siteUrl?.trim() || null,
56 + username: username?.trim() || null,
57 + password: encryptedPassword,
58 + notes: notes?.trim() || null,
59 + },
60 + });
61 +
62 + return NextResponse.json({ ...entry, password: "••••••••" }, { status: 201 });
63 + } catch (err) {
64 + console.error("[POST /api/passwords]", err);
65 + return NextResponse.json({ error: "Erreur serveur" }, { status: 500 });
66 + }
67 +}
added app/api/shared-spaces/[id]/members/route.ts +38 −0
@@ -0,0 +1,38 @@
1 +import { NextRequest, NextResponse } from "next/server";
2 +import { getIronSession } from "iron-session";
3 +import { SessionData, sessionOptions } from "@/lib/session";
4 +import { prisma } from "@/lib/prisma";
5 +import { requireAdmin } from "@/lib/auth";
6 +
7 +export async function POST(request: NextRequest, { params }: { params: { id: string } }) {
8 + const response = NextResponse.next();
9 + const session = await getIronSession<SessionData>(request, response, sessionOptions);
10 + if (!session.userId || !(await requireAdmin(session.userId))) {
11 + return NextResponse.json({ error: "Forbidden" }, { status: 403 });
12 + }
13 +
14 + const { userId } = await request.json();
15 + if (!userId) return NextResponse.json({ error: "userId requis" }, { status: 400 });
16 +
17 + const member = await prisma.sharedSpaceMember.create({
18 + data: { sharedSpaceId: params.id, userId },
19 + include: { user: { select: { id: true, name: true, username: true } } },
20 + });
21 +
22 + return NextResponse.json(member, { status: 201 });
23 +}
24 +
25 +export async function DELETE(request: NextRequest, { params }: { params: { id: string } }) {
26 + const response = NextResponse.next();
27 + const session = await getIronSession<SessionData>(request, response, sessionOptions);
28 + if (!session.userId || !(await requireAdmin(session.userId))) {
29 + return NextResponse.json({ error: "Forbidden" }, { status: 403 });
30 + }
31 +
32 + const { userId } = await request.json();
33 + await prisma.sharedSpaceMember.deleteMany({
34 + where: { sharedSpaceId: params.id, userId },
35 + });
36 +
37 + return NextResponse.json({ success: true });
38 +}
added app/api/shared-spaces/[id]/route.ts +59 −0
@@ -0,0 +1,59 @@
1 +import { NextRequest, NextResponse } from "next/server";
2 +import { getIronSession } from "iron-session";
3 +import { SessionData, sessionOptions } from "@/lib/session";
4 +import { prisma } from "@/lib/prisma";
5 +import { requireAdmin } from "@/lib/auth";
6 +
7 +export async function GET(request: NextRequest, { params }: { params: { id: string } }) {
8 + const response = NextResponse.next();
9 + const session = await getIronSession<SessionData>(request, response, sessionOptions);
10 + if (!session.userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
11 +
12 + const space = await prisma.sharedSpace.findUnique({
13 + where: { id: params.id },
14 + include: {
15 + members: { include: { user: { select: { id: true, name: true, username: true } } } },
16 + folders: { select: { id: true, name: true, parentId: true } },
17 + },
18 + });
19 +
20 + if (!space) return NextResponse.json({ error: "Not found" }, { status: 404 });
21 +
22 + const agg = await prisma.file.aggregate({
23 + _sum: { size: true },
24 + where: { folder: { sharedSpaceId: params.id } },
25 + });
26 +
27 + return NextResponse.json({
28 + ...space,
29 + quotaBytes: Number(space.quotaBytes),
30 + usedBytes: Number(agg._sum.size || 0),
31 + });
32 +}
33 +
34 +export async function PATCH(request: NextRequest, { params }: { params: { id: string } }) {
35 + const response = NextResponse.next();
36 + const session = await getIronSession<SessionData>(request, response, sessionOptions);
37 + if (!session.userId || !(await requireAdmin(session.userId))) {
38 + return NextResponse.json({ error: "Forbidden" }, { status: 403 });
39 + }
40 +
41 + const { name, quotaBytes } = await request.json();
42 + const data: Record<string, unknown> = {};
43 + if (name) data.name = name;
44 + if (quotaBytes) data.quotaBytes = BigInt(quotaBytes);
45 +
46 + const space = await prisma.sharedSpace.update({ where: { id: params.id }, data });
47 + return NextResponse.json({ ...space, quotaBytes: Number(space.quotaBytes) });
48 +}
49 +
50 +export async function DELETE(request: NextRequest, { params }: { params: { id: string } }) {
51 + const response = NextResponse.next();
52 + const session = await getIronSession<SessionData>(request, response, sessionOptions);
53 + if (!session.userId || !(await requireAdmin(session.userId))) {
54 + return NextResponse.json({ error: "Forbidden" }, { status: 403 });
55 + }
56 +
57 + await prisma.sharedSpace.delete({ where: { id: params.id } });
58 + return NextResponse.json({ success: true });
59 +}
added app/api/shared-spaces/[id]/storage/route.ts +24 −0
@@ -0,0 +1,24 @@
1 +import { NextRequest, NextResponse } from "next/server";
2 +import { getIronSession } from "iron-session";
3 +import { SessionData, sessionOptions } from "@/lib/session";
4 +import { prisma } from "@/lib/prisma";
5 +
6 +export async function GET(request: NextRequest, { params }: { params: { id: string } }) {
7 + const response = NextResponse.next();
8 + const session = await getIronSession<SessionData>(request, response, sessionOptions);
9 + if (!session.userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
10 +
11 + const space = await prisma.sharedSpace.findUnique({ where: { id: params.id } });
12 + if (!space) return NextResponse.json({ error: "Not found" }, { status: 404 });
13 +
14 + const agg = await prisma.file.aggregate({
15 + _sum: { size: true },
16 + where: { folder: { sharedSpaceId: params.id } },
17 + });
18 +
19 + const quotaBytes = Number(space.quotaBytes);
20 + const usedBytes = Number(agg._sum.size || 0);
21 + const percent = quotaBytes > 0 ? Math.round((usedBytes / quotaBytes) * 100) : 0;
22 +
23 + return NextResponse.json({ quotaBytes, usedBytes, percent });
24 +}
added app/api/shared-spaces/mine/route.ts +22 −0
@@ -0,0 +1,22 @@
1 +import { NextRequest, NextResponse } from "next/server";
2 +import { getIronSession } from "iron-session";
3 +import { SessionData, sessionOptions } from "@/lib/session";
4 +import { prisma } from "@/lib/prisma";
5 +
6 +export async function GET(request: NextRequest) {
7 + const response = NextResponse.next();
8 + const session = await getIronSession<SessionData>(request, response, sessionOptions);
9 + if (!session.userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
10 +
11 + const memberships = await prisma.sharedSpaceMember.findMany({
12 + where: { userId: session.userId },
13 + include: { sharedSpace: true },
14 + });
15 +
16 + return NextResponse.json(
17 + memberships.map((m) => ({
18 + ...m.sharedSpace,
19 + quotaBytes: Number(m.sharedSpace.quotaBytes),
20 + }))
21 + );
22 +}
added app/api/shared-spaces/route.ts +66 −0
@@ -0,0 +1,66 @@
1 +import { NextRequest, NextResponse } from "next/server";
2 +import { getIronSession } from "iron-session";
3 +import { SessionData, sessionOptions } from "@/lib/session";
4 +import { prisma } from "@/lib/prisma";
5 +import { requireAdmin } from "@/lib/auth";
6 +
7 +export async function GET(request: NextRequest) {
8 + const response = NextResponse.next();
9 + const session = await getIronSession<SessionData>(request, response, sessionOptions);
10 + if (!session.userId || !(await requireAdmin(session.userId))) {
11 + return NextResponse.json({ error: "Forbidden" }, { status: 403 });
12 + }
13 +
14 + const spaces = await prisma.sharedSpace.findMany({
15 + orderBy: { createdAt: "desc" },
16 + include: {
17 + _count: { select: { members: true, folders: true } },
18 + members: { include: { user: { select: { id: true, name: true, username: true } } } },
19 + },
20 + });
21 +
22 + const result = await Promise.all(
23 + spaces.map(async (space) => {
24 + const agg = await prisma.file.aggregate({
25 + _sum: { size: true },
26 + where: { folder: { sharedSpaceId: space.id } },
27 + });
28 + return {
29 + ...space,
30 + quotaBytes: Number(space.quotaBytes),
31 + usedBytes: Number(agg._sum.size || 0),
32 + };
33 + })
34 + );
35 +
36 + return NextResponse.json(result);
37 +}
38 +
39 +export async function POST(request: NextRequest) {
40 + const response = NextResponse.next();
41 + const session = await getIronSession<SessionData>(request, response, sessionOptions);
42 + // Any logged-in user can create a shared space
43 + if (!session.isLoggedIn || !session.userId) {
44 + return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
45 + }
46 +
47 + const { name, memberIds, quotaBytes } = await request.json();
48 + if (!name) return NextResponse.json({ error: "Nom requis" }, { status: 400 });
49 +
50 + // Always include the creator as a member
51 + const allMemberIds = Array.from(new Set([session.userId, ...(memberIds || [])]));
52 +
53 + const space = await prisma.sharedSpace.create({
54 + data: {
55 + name,
56 + quotaBytes: quotaBytes ? BigInt(quotaBytes) : BigInt(536870912000),
57 + members: { create: allMemberIds.map((userId: string) => ({ userId })) },
58 + },
59 + include: {
60 + _count: { select: { members: true, folders: true } },
61 + members: { include: { user: { select: { id: true, name: true, username: true } } } },
62 + },
63 + });
64 +
65 + return NextResponse.json({ ...space, quotaBytes: Number(space.quotaBytes), usedBytes: 0 }, { status: 201 });
66 +}
modified app/api/users/[id]/route.ts +2 −2
@@ -12,14 +12,14 @@ export async function PATCH(request: NextRequest, { params }: { params: { id: st
12 12 const body = await request.json();
13 13 const data: Record<string, unknown> = {};
14 14 if (body.name !== undefined) data.name = body.name;
15 − if (body.email !== undefined) data.email = body.email;
15 + if (body.username !== undefined) data.username = body.username;
16 16 if (body.role !== undefined) data.role = body.role;
17 17 if (body.password) data.passwordHash = await hashPassword(body.password);
18 18
19 19 const user = await prisma.user.update({
20 20 where: { id: params.id },
21 21 data,
22 − select: { id: true, email: true, name: true, role: true },
22 + select: { id: true, username: true, name: true, role: true },
23 23 });
24 24 return NextResponse.json(user);
25 25 }
added app/api/users/public/route.ts +22 −0
@@ -0,0 +1,22 @@
1 +import { NextRequest, NextResponse } from "next/server";
2 +import { getIronSession } from "iron-session";
3 +import { SessionData, sessionOptions } from "@/lib/session";
4 +import { prisma } from "@/lib/prisma";
5 +
6 +// Returns minimal user info (id, name, username) for any logged-in user
7 +// Used for the shared folder member picker
8 +export async function GET(request: NextRequest) {
9 + const response = NextResponse.next();
10 + const session = await getIronSession<SessionData>(request, response, sessionOptions);
11 + if (!session.isLoggedIn || !session.userId) {
12 + return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
13 + }
14 +
15 + const users = await prisma.user.findMany({
16 + where: { id: { not: session.userId } }, // exclude current user (they're added automatically)
17 + select: { id: true, name: true, username: true },
18 + orderBy: { username: "asc" },
19 + });
20 +
21 + return NextResponse.json(users);
22 +}
modified app/api/users/route.ts +7 −7
@@ -10,7 +10,7 @@ export async function GET(request: NextRequest) {
10 10 if (!session.userId || !(await requireAdmin(session.userId))) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
11 11
12 12 const users = await prisma.user.findMany({
13 − select: { id: true, email: true, name: true, role: true, twoFactorEnabled: true, createdAt: true, _count: { select: { files: true, folders: true } } },
13 + select: { id: true, username: true, email: true, name: true, role: true, twoFactorEnabled: true, createdAt: true, _count: { select: { files: true, folders: true } } },
14 14 orderBy: { createdAt: "asc" },
15 15 });
16 16 return NextResponse.json(users);
@@ -22,15 +22,15 @@ export async function POST(request: NextRequest) {
22 22 if (!session.userId || !(await requireAdmin(session.userId))) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
23 23
24 24 const body = await request.json();
25 − const { email, name, password, role } = body;
26 − if (!email || !password) return NextResponse.json({ error: "Email et mot de passe requis" }, { status: 400 });
25 + const { username, name, password, role } = body;
26 + if (!username || !password) return NextResponse.json({ error: "Nom d'utilisateur et mot de passe requis" }, { status: 400 });
27 27
28 − const existing = await prisma.user.findUnique({ where: { email } });
29 − if (existing) return NextResponse.json({ error: "Email d\u00e9j\u00e0 utilis\u00e9" }, { status: 409 });
28 + const existing = await prisma.user.findUnique({ where: { username } });
29 + if (existing) return NextResponse.json({ error: "Nom d'utilisateur déjà utilisé" }, { status: 409 });
30 30
31 31 const user = await prisma.user.create({
32 − data: { email, name: name || null, passwordHash: await hashPassword(password), role: role || "user" },
33 − select: { id: true, email: true, name: true, role: true, createdAt: true },
32 + data: { username, name: name || null, passwordHash: await hashPassword(password), role: role || "user" },
33 + select: { id: true, username: true, name: true, role: true, createdAt: true },
34 34 });
35 35 return NextResponse.json(user, { status: 201 });
36 36 }
modified app/api/v1/files/route.ts +39 −1
@@ -1,6 +1,8 @@
1 1 import { NextRequest, NextResponse } from "next/server";
2 2 import { prisma } from "@/lib/prisma";
3 +import { saveFile } from "@/lib/storage";
3 4 import { createHash } from "crypto";
5 +import mime from "mime-types";
4 6
5 7 async function validateApiKey(request: NextRequest) {
6 8 const auth = request.headers.get("authorization");
@@ -22,7 +24,7 @@ export async function GET(request: NextRequest) {
22 24 const folderId = searchParams.get("folderId");
23 25 const where: Record<string, unknown> = {};
24 26 if (folderId && folderId !== "root") where.folderId = folderId;
25 − else where.folderId = null;
27 + else if (!folderId) where.folderId = null;
26 28
27 29 const files = await prisma.file.findMany({
28 30 where,
@@ -33,3 +35,39 @@ export async function GET(request: NextRequest) {
33 35
34 36 return NextResponse.json({ files: files.map((f) => ({ ...f, size: Number(f.size) })) });
35 37 }
38 +
39 +export async function POST(request: NextRequest) {
40 + const apiKey = await validateApiKey(request);
41 + if (!apiKey) return NextResponse.json({ error: "Invalid API key" }, { status: 401 });
42 + if (!apiKey.permissions.includes("write") && !apiKey.permissions.includes("admin")) {
43 + return NextResponse.json({ error: "API key does not have write permission" }, { status: 403 });
44 + }
45 +
46 + const formData = await request.formData();
47 + const file = formData.get("file") as File | null;
48 + const folderId = formData.get("folderId") as string | null;
49 +
50 + if (!file) return NextResponse.json({ error: "No file provided" }, { status: 400 });
51 +
52 + const maxSize = parseInt(process.env.MAX_FILE_SIZE || "524288000");
53 + if (file.size > maxSize) {
54 + return NextResponse.json({ error: `File too large. Max ${Math.round(maxSize / 1024 / 1024)}MB` }, { status: 413 });
55 + }
56 +
57 + const buffer = Buffer.from(await file.arrayBuffer());
58 + const mimeType = mime.lookup(file.name) || file.type || "application/octet-stream";
59 + const { storagePath, size } = await saveFile(buffer, file.name);
60 +
61 + const dbFile = await prisma.file.create({
62 + data: {
63 + name: file.name,
64 + storagePath,
65 + size: BigInt(size),
66 + mimeType,
67 + userId: apiKey.userId,
68 + folderId: folderId && folderId !== "root" ? folderId : null,
69 + },
70 + });
71 +
72 + return NextResponse.json({ ...dbFile, size: Number(dbFile.size) }, { status: 201 });
73 +}
added app/api/v1/folders/route.ts +48 −0
@@ -0,0 +1,48 @@
1 +import { NextRequest, NextResponse } from "next/server";
2 +import { prisma } from "@/lib/prisma";
3 +import { createHash } from "crypto";
4 +
5 +async function validateApiKey(request: NextRequest) {
6 + const auth = request.headers.get("authorization");
7 + if (!auth?.startsWith("Bearer ")) return null;
8 + const rawKey = auth.substring(7);
9 + const keyHash = createHash("sha256").update(rawKey).digest("hex");
10 + const apiKey = await prisma.apiKey.findUnique({ where: { keyHash } });
11 + if (!apiKey) return null;
12 + if (apiKey.expiresAt && apiKey.expiresAt < new Date()) return null;
13 + await prisma.apiKey.update({ where: { id: apiKey.id }, data: { lastUsedAt: new Date() } });
14 + return apiKey;
15 +}
16 +
17 +export async function GET(request: NextRequest) {
18 + const apiKey = await validateApiKey(request);
19 + if (!apiKey) return NextResponse.json({ error: "Invalid API key" }, { status: 401 });
20 +
21 + const folders = await prisma.folder.findMany({
22 + select: { id: true, name: true, parentId: true, createdAt: true },
23 + orderBy: { name: "asc" },
24 + });
25 +
26 + return NextResponse.json({ folders });
27 +}
28 +
29 +export async function POST(request: NextRequest) {
30 + const apiKey = await validateApiKey(request);
31 + if (!apiKey) return NextResponse.json({ error: "Invalid API key" }, { status: 401 });
32 + if (!apiKey.permissions.includes("write") && !apiKey.permissions.includes("admin")) {
33 + return NextResponse.json({ error: "API key does not have write permission" }, { status: 403 });
34 + }
35 +
36 + const { name, parentId } = await request.json();
37 + if (!name) return NextResponse.json({ error: "Name required" }, { status: 400 });
38 +
39 + const folder = await prisma.folder.create({
40 + data: {
41 + name,
42 + parentId: parentId || null,
43 + userId: apiKey.userId,
44 + },
45 + });
46 +
47 + return NextResponse.json(folder, { status: 201 });
48 +}
added app/dashboard/passwords/page.tsx +662 −0
@@ -0,0 +1,662 @@
1 +"use client";
2 +
3 +import { useState, useEffect, useCallback } from "react";
4 +import AppShell from "@/components/layout/AppShell";
5 +import { motion, AnimatePresence } from "framer-motion";
6 +import {
7 + Lock, Plus, Eye, EyeOff, Copy, Trash2, Edit2, Search,
8 + RefreshCw, Shield, ShieldAlert, ShieldCheck, ShieldX,
9 + Zap, Key, Globe, User, FileText, Check, X, ChevronDown,
10 +} from "lucide-react";
11 +import toast from "react-hot-toast";
12 +import Modal from "@/components/ui/Modal";
13 +import Button from "@/components/ui/Button";
14 +
15 +// ─── Types ────────────────────────────────────────────────────────────────────
16 +interface PasswordEntry {
17 + id: string;
18 + siteName: string;
19 + siteUrl: string | null;
20 + username: string | null;
21 + password: string;
22 + notes: string | null;
23 + createdAt: string;
24 +}
25 +
26 +// ─── Password Strength Logic ──────────────────────────────────────────────────
27 +function analyzePassword(pwd: string) {
28 + if (!pwd) return { score: 0, label: "", color: "", checks: [] };
29 +
30 + const checks = [
31 + { label: "Au moins 8 caractères", ok: pwd.length >= 8 },
32 + { label: "Au moins 12 caractères", ok: pwd.length >= 12 },
33 + { label: "Majuscules (A-Z)", ok: /[A-Z]/.test(pwd) },
34 + { label: "Minuscules (a-z)", ok: /[a-z]/.test(pwd) },
35 + { label: "Chiffres (0-9)", ok: /[0-9]/.test(pwd) },
36 + { label: "Symboles (!@#$...)", ok: /[^A-Za-z0-9]/.test(pwd) },
37 + ];
38 +
39 + const score = checks.filter((c) => c.ok).length;
40 +
41 + const labels = ["", "Très faible", "Faible", "Moyen", "Bon", "Fort", "Excellent"];
42 + const colors = ["", "text-red-500", "text-orange-500", "text-yellow-500", "text-blue-500", "text-green-500", "text-emerald-500"];
43 + const bars = ["", "bg-red-500", "bg-orange-500", "bg-yellow-500", "bg-blue-500", "bg-green-500", "bg-emerald-500"];
44 +
45 + return { score, label: labels[score] || "", color: colors[score] || "", barColor: bars[score] || "", checks };
46 +}
47 +
48 +// ─── Password Generator ───────────────────────────────────────────────────────
49 +function generatePassword(opts: { length: number; upper: boolean; lower: boolean; numbers: boolean; symbols: boolean }) {
50 + const sets = [
51 + opts.upper ? "ABCDEFGHIJKLMNOPQRSTUVWXYZ" : "",
52 + opts.lower ? "abcdefghijklmnopqrstuvwxyz" : "",
53 + opts.numbers ? "0123456789" : "",
54 + opts.symbols ? "!@#$%^&*()-_=+[]{}|;:,.<>?" : "",
55 + ].join("");
56 + if (!sets) return "";
57 + return Array.from({ length: opts.length }, () => sets[Math.floor(Math.random() * sets.length)]).join("");
58 +}
59 +
60 +// ─── Site Icon ────────────────────────────────────────────────────────────────
61 +function SiteIcon({ name, url }: { name: string; url?: string | null }) {
62 + const colors = ["bg-blue-500", "bg-red-500", "bg-green-500", "bg-purple-500", "bg-orange-500", "bg-pink-500", "bg-teal-500", "bg-indigo-500"];
63 + const color = colors[name.charCodeAt(0) % colors.length];
64 + if (url) {
65 + try {
66 + const domain = new URL(url.startsWith("http") ? url : `https://${url}`).hostname;
67 + return (
68 + // eslint-disable-next-line @next/next/no-img-element
69 + <img
70 + src={`https://www.google.com/s2/favicons?domain=${domain}&sz=32`}
71 + alt={name}
72 + className="w-8 h-8 rounded-lg"
73 + onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
74 + />
75 + );
76 + } catch {}
77 + }
78 + return (
79 + <div className={`w-8 h-8 rounded-lg ${color} flex items-center justify-center text-white font-bold text-sm flex-shrink-0`}>
80 + {name[0]?.toUpperCase()}
81 + </div>
82 + );
83 +}
84 +
85 +const inputCls = "w-full bg-gray-100 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-lg px-3 py-2.5 text-sm text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:border-gray-400 dark:focus:border-white/20";
86 +
87 +// ─── Main Page ────────────────────────────────────────────────────────────────
88 +export default function PasswordsPage() {
89 + const [entries, setEntries] = useState<PasswordEntry[]>([]);
90 + const [loading, setLoading] = useState(true);
91 + const [search, setSearch] = useState("");
92 + const [tab, setTab] = useState<"vault" | "generator" | "checker">("vault");
93 +
94 + // Modal state
95 + const [showAdd, setShowAdd] = useState(false);
96 + const [editEntry, setEditEntry] = useState<PasswordEntry | null>(null);
97 + const [form, setForm] = useState({ siteName: "", siteUrl: "", username: "", password: "", notes: "" });
98 + const [formShowPw, setFormShowPw] = useState(false);
99 + const [saving, setSaving] = useState(false);
100 +
101 + // Revealed passwords per entry
102 + const [revealed, setRevealed] = useState<Record<string, string>>({});
103 + const [revealing, setRevealing] = useState<string | null>(null);
104 +
105 + // Generator state
106 + const [genOpts, setGenOpts] = useState({ length: 16, upper: true, lower: true, numbers: true, symbols: true });
107 + const [genResult, setGenResult] = useState("");
108 + const [genCopied, setGenCopied] = useState(false);
109 +
110 + // Checker state
111 + const [checkerPwd, setCheckerPwd] = useState("");
112 + const [checkerShow, setCheckerShow] = useState(false);
113 +
114 + // ── Fetch ──
115 + const fetchEntries = useCallback(async () => {
116 + setLoading(true);
117 + try {
118 + const res = await fetch("/api/passwords");
119 + if (res.ok) {
120 + setEntries(await res.json());
121 + } else if (res.status === 401) {
122 + toast.error("Session expirée, veuillez vous reconnecter");
123 + } else {
124 + toast.error("Erreur lors du chargement");
125 + }
126 + } catch {
127 + toast.error("Erreur réseau");
128 + }
129 + setLoading(false);
130 + }, []);
131 +
132 + useEffect(() => { fetchEntries(); }, [fetchEntries]);
133 + useEffect(() => { setGenResult(generatePassword(genOpts)); }, [genOpts]);
134 +
135 + // ── Reveal password ──
136 + const revealPassword = async (id: string) => {
137 + if (revealed[id]) {
138 + setRevealed((prev) => { const n = { ...prev }; delete n[id]; return n; });
139 + return;
140 + }
141 + setRevealing(id);
142 + const res = await fetch(`/api/passwords/${id}`);
143 + if (res.ok) {
144 + const data = await res.json();
145 + setRevealed((prev) => ({ ...prev, [id]: data.password }));
146 + }
147 + setRevealing(null);
148 + };
149 +
150 + // ── Copy ──
151 + const copyText = (text: string, label = "Copié !") => {
152 + navigator.clipboard.writeText(text).then(() => toast.success(label));
153 + };
154 +
155 + // ── Save (add or edit) ──
156 + const handleSave = async () => {
157 + if (!form.siteName.trim() || !form.password.trim()) return;
158 + setSaving(true);
159 + try {
160 + const method = editEntry ? "PATCH" : "POST";
161 + const url = editEntry ? `/api/passwords/${editEntry.id}` : "/api/passwords";
162 + const res = await fetch(url, {
163 + method,
164 + headers: { "Content-Type": "application/json" },
165 + body: JSON.stringify(form),
166 + });
167 + if (res.ok) {
168 + toast.success(editEntry ? "Modifié" : "Ajouté");
169 + setShowAdd(false);
170 + setEditEntry(null);
171 + setForm({ siteName: "", siteUrl: "", username: "", password: "", notes: "" });
172 + setFormShowPw(false);
173 + fetchEntries();
174 + setRevealed({});
175 + } else {
176 + let errMsg = "Erreur";
177 + try {
178 + const d = await res.json();
179 + errMsg = d.error || errMsg;
180 + } catch {}
181 + toast.error(errMsg);
182 + }
183 + } catch (e) {
184 + console.error("handleSave error:", e);
185 + toast.error("Erreur réseau");
186 + } finally {
187 + setSaving(false);
188 + }
189 + };
190 +
191 + const openEdit = async (entry: PasswordEntry) => {
192 + // Fetch decrypted password for editing
193 + const res = await fetch(`/api/passwords/${entry.id}`);
194 + const data = res.ok ? await res.json() : entry;
195 + setForm({ siteName: data.siteName, siteUrl: data.siteUrl || "", username: data.username || "", password: data.password, notes: data.notes || "" });
196 + setEditEntry(entry);
197 + setShowAdd(true);
198 + setFormShowPw(false);
199 + };
200 +
201 + const handleDelete = async (id: string) => {
202 + if (!confirm("Supprimer cet identifiant ?")) return;
203 + const res = await fetch(`/api/passwords/${id}`, { method: "DELETE" });
204 + if (res.ok) { toast.success("Supprimé"); fetchEntries(); }
205 + };
206 +
207 + // ── Generator ──
208 + const regenerate = () => setGenResult(generatePassword(genOpts));
209 + const copyGen = () => {
210 + copyText(genResult, "Mot de passe copié !");
211 + setGenCopied(true);
212 + setTimeout(() => setGenCopied(false), 2000);
213 + };
214 +
215 + // ── Filter ──
216 + const filtered = entries.filter((e) =>
217 + e.siteName.toLowerCase().includes(search.toLowerCase()) ||
218 + (e.username || "").toLowerCase().includes(search.toLowerCase())
219 + );
220 +
221 + const analysis = analyzePassword(checkerPwd);
222 +
223 + // ── Render ──
224 + return (
225 + <AppShell>
226 + <div className="flex flex-col h-full overflow-hidden">
227 + {/* Header */}
228 + <div className="px-4 lg:px-6 py-4 border-b border-gray-200 dark:border-white/10">
229 + <div className="flex items-center gap-3 mb-3">
230 + <div className="w-9 h-9 rounded-xl bg-gray-100 dark:bg-white/10 flex items-center justify-center">
231 + <Lock className="w-5 h-5 text-gray-700 dark:text-white" />
232 + </div>
233 + <div>
234 + <h1 className="text-lg font-semibold text-gray-900 dark:text-white">Gestionnaire de mots de passe</h1>
235 + <p className="text-xs text-gray-500">Chiffrement AES-256-GCM · Stockage local sécurisé</p>
236 + </div>
237 + </div>
238 +
239 + {/* Tabs */}
240 + <div className="flex gap-1 bg-gray-100 dark:bg-white/5 rounded-xl p-1">
241 + {([
242 + { id: "vault", label: "Coffre-fort", icon: Key },
243 + { id: "generator", label: "Générateur", icon: Zap },
244 + { id: "checker", label: "Validateur", icon: Shield },
245 + ] as const).map(({ id, label, icon: Icon }) => (
246 + <button
247 + key={id}
248 + onClick={() => setTab(id)}
249 + className={`flex-1 flex items-center justify-center gap-1.5 py-2 px-3 rounded-lg text-xs font-medium transition-colors ${
250 + tab === id
251 + ? "bg-white dark:bg-white/10 text-gray-900 dark:text-white shadow-sm"
252 + : "text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
253 + }`}
254 + >
255 + <Icon className="w-3.5 h-3.5" />
256 + <span className="hidden sm:inline">{label}</span>
257 + </button>
258 + ))}
259 + </div>
260 + </div>
261 +
262 + {/* ── VAULT TAB ── */}
263 + {tab === "vault" && (
264 + <div className="flex flex-col flex-1 min-h-0">
265 + {/* Toolbar */}
266 + <div className="px-4 lg:px-6 py-3 flex items-center gap-2 border-b border-gray-200 dark:border-white/10">
267 + <div className="relative flex-1">
268 + <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
269 + <input
270 + type="text"
271 + value={search}
272 + onChange={(e) => setSearch(e.target.value)}
273 + placeholder={`Rechercher parmi ${entries.length} identifiant(s)...`}
274 + className="w-full pl-9 pr-4 py-2.5 bg-gray-100 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-lg text-sm text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none"
275 + />
276 + </div>
277 + <Button size="sm" onClick={() => { setEditEntry(null); setForm({ siteName: "", siteUrl: "", username: "", password: "", notes: "" }); setShowAdd(true); }}>
278 + <Plus className="w-4 h-4" />
279 + <span className="hidden sm:inline">Ajouter</span>
280 + </Button>
281 + </div>
282 +
283 + {/* List */}
284 + <div className="flex-1 overflow-auto px-4 lg:px-6 py-4 space-y-2">
285 + {loading && (
286 + <div className="flex justify-center py-12">
287 + <div className="w-6 h-6 border-2 border-gray-300 dark:border-white/20 border-t-gray-700 dark:border-t-white rounded-full animate-spin" />
288 + </div>
289 + )}
290 +
291 + {!loading && filtered.length === 0 && (
292 + <div className="flex flex-col items-center justify-center py-16 text-center">
293 + <Lock className="w-12 h-12 text-gray-300 dark:text-gray-600 mb-4" />
294 + <p className="text-gray-500 dark:text-gray-400">
295 + {search ? "Aucun résultat" : "Aucun identifiant. Commencez par en ajouter un."}
296 + </p>
297 + </div>
298 + )}
299 +
300 + {!loading && filtered.map((entry) => {
301 + const isRevealed = !!revealed[entry.id];
302 + const displayPw = isRevealed ? revealed[entry.id] : "••••••••••••";
303 + return (
304 + <motion.div
305 + key={entry.id}
306 + initial={{ opacity: 0, y: 8 }}
307 + animate={{ opacity: 1, y: 0 }}
308 + className="flex items-center gap-3 p-3.5 bg-white dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl hover:border-gray-300 dark:hover:border-white/20 transition-colors group"
309 + >
310 + <SiteIcon name={entry.siteName} url={entry.siteUrl} />
311 +
312 + <div className="flex-1 min-w-0">
313 + <div className="flex items-center gap-2">
314 + <p className="text-sm font-medium text-gray-900 dark:text-white truncate">{entry.siteName}</p>
315 + {entry.siteUrl && (
316 + <a href={entry.siteUrl.startsWith("http") ? entry.siteUrl : `https://${entry.siteUrl}`} target="_blank" rel="noopener noreferrer" className="flex-shrink-0">
317 + <Globe className="w-3 h-3 text-gray-400 hover:text-blue-500 transition-colors" />
318 + </a>
319 + )}
320 + </div>
321 + {entry.username && (
322 + <button onClick={() => copyText(entry.username!, "Identifiant copié !")} className="flex items-center gap-1 text-xs text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition-colors mt-0.5">
323 + <User className="w-3 h-3" />
324 + {entry.username}
325 + </button>
326 + )}
327 + <div className="flex items-center gap-1.5 mt-0.5">
328 + <span className="text-xs font-mono text-gray-400 dark:text-gray-500 tracking-wider">
329 + {displayPw}
330 + </span>
331 + </div>
332 + </div>
333 +
334 + {/* Actions */}
335 + <div className="flex items-center gap-1 flex-shrink-0">
336 + <button
337 + onClick={() => revealPassword(entry.id)}
338 + disabled={revealing === entry.id}
339 + className="p-2 text-gray-400 hover:text-gray-700 dark:hover:text-white transition-colors"
340 + title={isRevealed ? "Masquer" : "Révéler"}
341 + >
342 + {revealing === entry.id
343 + ? <div className="w-4 h-4 border border-gray-300 border-t-gray-600 rounded-full animate-spin" />
344 + : isRevealed ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
345 + </button>
346 + {isRevealed && (
347 + <button onClick={() => copyText(revealed[entry.id], "Mot de passe copié !")} className="p-2 text-gray-400 hover:text-green-500 transition-colors" title="Copier le mot de passe">
348 + <Copy className="w-4 h-4" />
349 + </button>
350 + )}
351 + <button onClick={() => openEdit(entry)} className="p-2 text-gray-400 hover:text-gray-700 dark:hover:text-white transition-colors sm:opacity-0 sm:group-hover:opacity-100">
352 + <Edit2 className="w-4 h-4" />
353 + </button>
354 + <button onClick={() => handleDelete(entry.id)} className="p-2 text-gray-400 hover:text-red-500 transition-colors sm:opacity-0 sm:group-hover:opacity-100">
355 + <Trash2 className="w-4 h-4" />
356 + </button>
357 + </div>
358 + </motion.div>
359 + );
360 + })}
361 + </div>
362 + </div>
363 + )}
364 +
365 + {/* ── GENERATOR TAB ── */}
366 + {tab === "generator" && (
367 + <div className="flex-1 overflow-auto px-4 lg:px-6 py-6">
368 + <div className="max-w-lg mx-auto space-y-6">
369 + {/* Generated password display */}
370 + <div className="bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-2xl p-5">
371 + <p className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-3">Mot de passe généré</p>
372 + <div className="flex items-center gap-3">
373 + <p className="flex-1 text-lg font-mono font-semibold text-gray-900 dark:text-white break-all leading-tight">
374 + {genResult || <span className="text-gray-400 text-sm">Sélectionnez des options</span>}
375 + </p>
376 + </div>
377 + {genResult && (
378 + <div className="mt-3">
379 + {(() => { const a = analyzePassword(genResult); return (
380 + <div className="flex items-center gap-2">
381 + <div className="flex-1 bg-gray-200 dark:bg-white/10 rounded-full h-1.5">
382 + <div className={`h-1.5 rounded-full transition-all ${a.barColor}`} style={{ width: `${(a.score / 6) * 100}%` }} />
383 + </div>
384 + <span className={`text-xs font-medium ${a.color}`}>{a.label}</span>
385 + </div>
386 + ); })()}
387 + </div>
388 + )}
389 + <div className="flex gap-2 mt-4">
390 + <button
391 + onClick={regenerate}
392 + className="flex items-center gap-2 px-4 py-2 bg-gray-200 dark:bg-white/10 hover:bg-gray-300 dark:hover:bg-white/20 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 transition-colors"
393 + >
394 + <RefreshCw className="w-4 h-4" /> Régénérer
395 + </button>
396 + <button
397 + onClick={copyGen}
398 + disabled={!genResult}
399 + className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium text-white transition-colors flex-1 justify-center ${genCopied ? "bg-green-600" : "bg-gray-900 dark:bg-white dark:text-gray-900 hover:bg-gray-800 dark:hover:bg-gray-100"}`}
400 + >
401 + {genCopied ? <><Check className="w-4 h-4" /> Copié !</> : <><Copy className="w-4 h-4" /> Copier</>}
402 + </button>
403 + </div>
404 + </div>
405 +
406 + {/* Options */}
407 + <div className="space-y-4">
408 + {/* Length */}
409 + <div>
410 + <div className="flex justify-between mb-2">
411 + <label className="text-sm font-medium text-gray-700 dark:text-gray-300">Longueur</label>
412 + <span className="text-sm font-semibold text-gray-900 dark:text-white">{genOpts.length}</span>
413 + </div>
414 + <input
415 + type="range"
416 + min={6}
417 + max={64}
418 + value={genOpts.length}
419 + onChange={(e) => setGenOpts({ ...genOpts, length: Number(e.target.value) })}
420 + className="w-full accent-gray-900 dark:accent-white"
421 + />
422 + <div className="flex justify-between text-xs text-gray-400 mt-1">
423 + <span>6</span><span>64</span>
424 + </div>
425 + </div>
426 +
427 + {/* Character types */}
428 + <div className="space-y-2">
429 + <p className="text-sm font-medium text-gray-700 dark:text-gray-300">Types de caractères</p>
430 + {([
431 + { key: "upper", label: "Majuscules", example: "A-Z" },
432 + { key: "lower", label: "Minuscules", example: "a-z" },
433 + { key: "numbers", label: "Chiffres", example: "0-9" },
434 + { key: "symbols", label: "Symboles", example: "!@#$" },
435 + ] as const).map(({ key, label, example }) => (
436 + <label key={key} className="flex items-center justify-between p-3 bg-gray-50 dark:bg-white/5 rounded-lg cursor-pointer hover:bg-gray-100 dark:hover:bg-white/10 transition-colors">
437 + <div>
438 + <span className="text-sm text-gray-700 dark:text-gray-300">{label}</span>
439 + <span className="text-xs text-gray-400 ml-2">{example}</span>
440 + </div>
441 + <div
442 + onClick={() => setGenOpts({ ...genOpts, [key]: !genOpts[key] })}
443 + className={`w-10 h-6 rounded-full transition-colors cursor-pointer ${genOpts[key] ? "bg-gray-900 dark:bg-white" : "bg-gray-300 dark:bg-white/20"}`}
444 + >
445 + <div className={`w-5 h-5 bg-white dark:bg-gray-900 rounded-full shadow mt-0.5 transition-transform ${genOpts[key] ? "translate-x-4.5 ml-4.5" : "translate-x-0.5 ml-0.5"}`}
446 + style={{ transform: genOpts[key] ? "translateX(18px)" : "translateX(2px)" }}
447 + />
448 + </div>
449 + </label>
450 + ))}
451 + </div>
452 +
453 + {/* Use in vault shortcut */}
454 + <button
455 + onClick={() => {
456 + setForm((f) => ({ ...f, password: genResult }));
457 + setTab("vault");
458 + setShowAdd(true);
459 + setFormShowPw(true);
460 + }}
461 + disabled={!genResult}
462 + className="w-full flex items-center justify-center gap-2 py-2.5 bg-gray-100 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-lg text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-400 dark:hover:border-white/30 transition-colors"
463 + >
464 + <Key className="w-4 h-4" /> Utiliser ce mot de passe dans le coffre
465 + </button>
466 + </div>
467 + </div>
468 + </div>
469 + )}
470 +
471 + {/* ── CHECKER TAB ── */}
472 + {tab === "checker" && (
473 + <div className="flex-1 overflow-auto px-4 lg:px-6 py-6">
474 + <div className="max-w-lg mx-auto space-y-6">
475 + {/* Input */}
476 + <div>
477 + <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
478 + Entrez un mot de passe à analyser
479 + </label>
480 + <div className="relative">
481 + <Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
482 + <input
483 + type={checkerShow ? "text" : "password"}
484 + value={checkerPwd}
485 + onChange={(e) => setCheckerPwd(e.target.value)}
486 + placeholder="Votre mot de passe..."
487 + className={`${inputCls} pl-10 pr-10`}
488 + />
489 + <button onClick={() => setCheckerShow(!checkerShow)} className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
490 + {checkerShow ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
491 + </button>
492 + </div>
493 + </div>
494 +
495 + {checkerPwd && (
496 + <AnimatePresence>
497 + <motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} className="space-y-5">
498 + {/* Score bar */}
499 + <div className="bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-2xl p-5">
500 + <div className="flex items-center justify-between mb-3">
501 + <p className="text-sm font-medium text-gray-700 dark:text-gray-300">Niveau de sécurité</p>
502 + <span className={`text-lg font-bold ${analysis.color}`}>{analysis.label}</span>
503 + </div>
504 +
505 + {/* Score bars */}
506 + <div className="flex gap-1 mb-3">
507 + {Array.from({ length: 6 }).map((_, i) => (
508 + <div key={i} className={`flex-1 h-2 rounded-full transition-all ${i < analysis.score ? analysis.barColor : "bg-gray-200 dark:bg-white/10"}`} />
509 + ))}
510 + </div>
511 +
512 + {/* Stats */}
513 + <div className="grid grid-cols-2 gap-2 text-xs text-gray-500 dark:text-gray-400">
514 + <span>Longueur : <strong className="text-gray-900 dark:text-white">{checkerPwd.length} caractères</strong></span>
515 + <span>Score : <strong className="text-gray-900 dark:text-white">{analysis.score}/6</strong></span>
516 + </div>
517 + </div>
518 +
519 + {/* Checks */}
520 + <div className="bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-2xl p-5">
521 + <p className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">Critères de sécurité</p>
522 + <div className="space-y-2">
523 + {analysis.checks.map((c) => (
524 + <div key={c.label} className="flex items-center gap-2.5">
525 + <div className={`w-5 h-5 rounded-full flex items-center justify-center flex-shrink-0 ${c.ok ? "bg-green-100 dark:bg-green-500/20 text-green-600 dark:text-green-400" : "bg-red-100 dark:bg-red-500/20 text-red-500"}`}>
526 + {c.ok ? <Check className="w-3 h-3" /> : <X className="w-3 h-3" />}
527 + </div>
528 + <span className={`text-sm ${c.ok ? "text-gray-700 dark:text-gray-300" : "text-gray-400 dark:text-gray-500"}`}>{c.label}</span>
529 + </div>
530 + ))}
531 + </div>
532 + </div>
533 +
534 + {/* Recommendation */}
535 + <div className={`flex items-start gap-3 p-4 rounded-xl border ${
536 + analysis.score >= 5 ? "bg-green-50 dark:bg-green-500/10 border-green-200 dark:border-green-500/30 text-green-800 dark:text-green-300"
537 + : analysis.score >= 3 ? "bg-yellow-50 dark:bg-yellow-500/10 border-yellow-200 dark:border-yellow-500/30 text-yellow-800 dark:text-yellow-300"
538 + : "bg-red-50 dark:bg-red-500/10 border-red-200 dark:border-red-500/30 text-red-800 dark:text-red-300"
539 + }`}>
540 + {analysis.score >= 5 ? <ShieldCheck className="w-5 h-5 flex-shrink-0 mt-0.5" />
541 + : analysis.score >= 3 ? <Shield className="w-5 h-5 flex-shrink-0 mt-0.5" />
542 + : <ShieldX className="w-5 h-5 flex-shrink-0 mt-0.5" />}
543 + <p className="text-sm">
544 + {analysis.score >= 5
545 + ? "Excellent ! Ce mot de passe est très sécurisé."
546 + : analysis.score >= 4
547 + ? "Bon mot de passe. Ajoutez des symboles pour l'améliorer."
548 + : analysis.score >= 3
549 + ? "Mot de passe moyen. Augmentez la longueur et la diversité des caractères."
550 + : analysis.score >= 2
551 + ? "Mot de passe faible. Utilisez au moins 12 caractères avec majuscules, chiffres et symboles."
552 + : "Mot de passe très faible ! Changez-le immédiatement avec notre générateur."}
553 + </p>
554 + </div>
555 +
556 + {analysis.score < 5 && (
557 + <button
558 + onClick={() => setTab("generator")}
559 + className="w-full flex items-center justify-center gap-2 py-2.5 bg-gray-900 dark:bg-white text-white dark:text-gray-900 rounded-xl text-sm font-medium hover:bg-gray-800 dark:hover:bg-gray-100 transition-colors"
560 + >
561 + <Zap className="w-4 h-4" /> Générer un mot de passe sécurisé
562 + </button>
563 + )}
564 + </motion.div>
565 + </AnimatePresence>
566 + )}
567 +
568 + {!checkerPwd && (
569 + <div className="text-center py-12">
570 + <ShieldAlert className="w-12 h-12 mx-auto mb-4 text-gray-300 dark:text-gray-600" />
571 + <p className="text-gray-500 dark:text-gray-400 text-sm">Entrez un mot de passe pour l&apos;analyser</p>
572 + </div>
573 + )}
574 + </div>
575 + </div>
576 + )}
577 + </div>
578 +
579 + {/* ── Add / Edit Modal ── */}
580 + <Modal
581 + isOpen={showAdd}
582 + onClose={() => { setShowAdd(false); setEditEntry(null); setFormShowPw(false); }}
583 + title={editEntry ? "Modifier l'identifiant" : "Nouvel identifiant"}
584 + >
585 + <div className="space-y-3">
586 + <div>
587 + <label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Site / Service *</label>
588 + <div className="relative">
589 + <Globe className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
590 + <input type="text" value={form.siteName} onChange={(e) => setForm({ ...form, siteName: e.target.value })} placeholder="Facebook, Gmail, Netflix..." className={`${inputCls} pl-9`} autoFocus />
591 + </div>
592 + </div>
593 + <div>
594 + <label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">URL (optionnel)</label>
595 + <div className="relative">
596 + <Globe className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
597 + <input type="url" value={form.siteUrl} onChange={(e) => setForm({ ...form, siteUrl: e.target.value })} placeholder="https://facebook.com" className={`${inputCls} pl-9`} />
598 + </div>
599 + </div>
600 + <div>
601 + <label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Identifiant / Email</label>
602 + <div className="relative">
603 + <User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
604 + <input type="text" value={form.username} onChange={(e) => setForm({ ...form, username: e.target.value })} placeholder="user@example.com" className={`${inputCls} pl-9`} autoComplete="off" />
605 + </div>
606 + </div>
607 + <div>
608 + <div className="flex items-center justify-between mb-1">
609 + <label className="text-xs font-medium text-gray-500 dark:text-gray-400">Mot de passe *</label>
610 + <button
611 + type="button"
612 + onClick={() => { const p = generatePassword(genOpts); setForm({ ...form, password: p }); setFormShowPw(true); }}
613 + className="text-xs text-purple-600 dark:text-purple-400 hover:underline flex items-center gap-1"
614 + >
615 + <Zap className="w-3 h-3" /> Générer
616 + </button>
617 + </div>
618 + <div className="relative">
619 + <Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
620 + <input
621 + type={formShowPw ? "text" : "password"}
622 + value={form.password}
623 + onChange={(e) => setForm({ ...form, password: e.target.value })}
624 + placeholder="••••••••"
625 + className={`${inputCls} pl-9 pr-9`}
626 + autoComplete="new-password"
627 + />
628 + <button type="button" onClick={() => setFormShowPw(!formShowPw)} className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600">
629 + {formShowPw ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
630 + </button>
631 + </div>
632 + {/* Inline strength bar in modal */}
633 + {form.password && (() => { const a = analyzePassword(form.password); return (
634 + <div className="mt-1.5 flex items-center gap-2">
635 + <div className="flex gap-1 flex-1">
636 + {Array.from({ length: 6 }).map((_, i) => (
637 + <div key={i} className={`flex-1 h-1 rounded-full ${i < a.score ? a.barColor : "bg-gray-200 dark:bg-white/10"}`} />
638 + ))}
639 + </div>
640 + <span className={`text-xs ${a.color}`}>{a.label}</span>
641 + </div>
642 + ); })()}
643 + </div>
644 + <div>
645 + <label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Notes (optionnel)</label>
646 + <div className="relative">
647 + <FileText className="absolute left-3 top-3 w-4 h-4 text-gray-400" />
648 + <textarea value={form.notes} onChange={(e) => setForm({ ...form, notes: e.target.value })} placeholder="Compte personnel, code de récupération..." rows={2} className={`${inputCls} pl-9 resize-none`} />
649 + </div>
650 + </div>
651 + </div>
652 +
653 + <div className="flex gap-2 justify-end mt-4">
654 + <Button variant="ghost" onClick={() => { setShowAdd(false); setEditEntry(null); }}>Annuler</Button>
655 + <Button onClick={handleSave} loading={saving} disabled={!form.siteName.trim() || !form.password.trim()}>
656 + {editEntry ? "Enregistrer" : "Ajouter"}
657 + </Button>
658 + </div>
659 + </Modal>
660 + </AppShell>
661 + );
662 +}
added app/dashboard/shared-space/[id]/page.tsx +237 −0
@@ -0,0 +1,237 @@
1 +"use client";
2 +
3 +import { useState, useEffect, useCallback } from "react";
4 +import { useParams } from "next/navigation";
5 +import AppShell from "@/components/layout/AppShell";
6 +import FolderCard from "@/components/folders/FolderCard";
7 +import FileCard from "@/components/files/FileCard";
8 +import FileUpload from "@/components/files/FileUpload";
9 +import PreviewEngine from "@/components/preview/PreviewEngine";
10 +import Button from "@/components/ui/Button";
11 +import SharedBadge from "@/components/ui/SharedBadge";
12 +import Modal from "@/components/ui/Modal";
13 +import { formatFileSize } from "@/lib/utils";
14 +import { Grid, List, Upload, Plus, Users } from "lucide-react";
15 +import { motion, AnimatePresence } from "framer-motion";
16 +import toast from "react-hot-toast";
17 +
18 +interface SpaceData {
19 + id: string;
20 + name: string;
21 + quotaBytes: number;
22 + usedBytes: number;
23 + members: { id: string; user: { id: string; name: string | null; username: string | null } }[];
24 + folders: { id: string; name: string; parentId: string | null }[];
25 +}
26 +
27 +interface FileData {
28 + id: string;
29 + name: string;
30 + size: number;
31 + mimeType: string;
32 + createdAt: string;
33 + folderId?: string | null;
34 +}
35 +
36 +interface FolderData {
37 + id: string;
38 + name: string;
39 + parentId: string | null;
40 + sharedSpaceId?: string | null;
41 + _count?: { files: number; children: number };
42 + createdAt: string;
43 +}
44 +
45 +export default function SharedSpacePage() {
46 + const params = useParams();
47 + const id = params.id as string;
48 +
49 + const [space, setSpace] = useState<SpaceData | null>(null);
50 + const [folders, setFolders] = useState<FolderData[]>([]);
51 + const [files, setFiles] = useState<FileData[]>([]);
52 + const [currentFolderId, setCurrentFolderId] = useState<string | null>(null);
53 + const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
54 + const [showUpload, setShowUpload] = useState(false);
55 + const [showCreateFolder, setShowCreateFolder] = useState(false);
56 + const [newFolderName, setNewFolderName] = useState("");
57 + const [previewFile, setPreviewFile] = useState<FileData | null>(null);
58 + const [loading, setLoading] = useState(true);
59 +
60 + const fetchData = useCallback(async () => {
61 + setLoading(true);
62 + try {
63 + const [spaceRes, foldersRes, filesRes] = await Promise.all([
64 + fetch(`/api/shared-spaces/${id}`),
65 + fetch(`/api/folders?sharedSpaceId=${id}&parentId=${currentFolderId || "root"}`),
66 + currentFolderId ? fetch(`/api/files?folderId=${currentFolderId}`) : Promise.resolve(null),
67 + ]);
68 + if (spaceRes.ok) setSpace(await spaceRes.json());
69 + if (foldersRes.ok) setFolders(await foldersRes.json());
70 + if (filesRes?.ok) setFiles(await filesRes.json());
71 + else setFiles([]);
72 + } catch {
73 + // ignore
74 + } finally {
75 + setLoading(false);
76 + }
77 + }, [id, currentFolderId]);
78 +
79 + useEffect(() => { fetchData(); }, [fetchData]);
80 +
81 + const handleCreateFolder = async () => {
82 + if (!newFolderName.trim()) return;
83 + const res = await fetch("/api/folders", {
84 + method: "POST",
85 + headers: { "Content-Type": "application/json" },
86 + body: JSON.stringify({ name: newFolderName.trim(), sharedSpaceId: id, parentId: currentFolderId || null }),
87 + });
88 + if (res.ok) {
89 + toast.success("Dossier créé");
90 + setShowCreateFolder(false);
91 + setNewFolderName("");
92 + fetchData();
93 + } else {
94 + toast.error("Erreur");
95 + }
96 + };
97 +
98 + const percent = space ? Math.min(Math.round((space.usedBytes / space.quotaBytes) * 100), 100) : 0;
99 +
100 + const inputClasses = "w-full bg-gray-100 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-lg px-4 py-3 text-gray-900 dark:text-white text-sm focus:outline-none mb-4";
101 +
102 + return (
103 + <AppShell>
104 + <div className="flex flex-col h-full overflow-x-hidden">
105 + {/* Space header */}
106 + <div className="px-4 lg:px-6 py-4 border-b border-gray-200 dark:border-white/10">
107 + <div className="flex items-center gap-3 mb-3">
108 + <div className="w-8 h-8 rounded-lg bg-purple-100 dark:bg-purple-500/20 flex items-center justify-center flex-shrink-0">
109 + <Users className="w-4 h-4 text-purple-600 dark:text-purple-400" />
110 + </div>
111 + <div className="flex items-center gap-2 min-w-0">
112 + <h1 className="text-lg font-semibold text-gray-900 dark:text-white truncate">
113 + {space?.name || "Espace partagé"}
114 + </h1>
115 + <SharedBadge />
116 + </div>
117 + </div>
118 +
119 + {space && (
120 + <div>
121 + <div className="flex justify-between text-xs text-gray-500 mb-1">
122 + <span>{space.members.length} membre(s)</span>
123 + <span>{formatFileSize(space.usedBytes)} / {formatFileSize(space.quotaBytes)}</span>
124 + </div>
125 + <div className="w-full bg-gray-100 dark:bg-white/10 rounded-full h-1.5">
126 + <div
127 + className={`h-1.5 rounded-full transition-all ${percent > 90 ? "bg-red-500" : percent > 70 ? "bg-yellow-500" : "bg-purple-500"}`}
128 + style={{ width: `${percent}%` }}
129 + />
130 + </div>
131 + </div>
132 + )}
133 + </div>
134 +
135 + {/* Toolbar */}
136 + <div className="px-4 lg:px-6 py-3 border-b border-gray-200 dark:border-white/10 flex items-center gap-2 justify-end">
137 + <div className="flex items-center gap-1 bg-gray-100 dark:bg-white/5 rounded-lg p-1">
138 + <button onClick={() => setViewMode("grid")} className={`min-w-[36px] min-h-[36px] flex items-center justify-center rounded transition-colors ${viewMode === "grid" ? "bg-white dark:bg-white/10 text-gray-900 dark:text-white shadow-sm" : "text-gray-400"}`}>
139 + <Grid className="w-4 h-4" />
140 + </button>
141 + <button onClick={() => setViewMode("list")} className={`min-w-[36px] min-h-[36px] flex items-center justify-center rounded transition-colors ${viewMode === "list" ? "bg-white dark:bg-white/10 text-gray-900 dark:text-white shadow-sm" : "text-gray-400"}`}>
142 + <List className="w-4 h-4" />
143 + </button>
144 + </div>
145 + <Button variant="ghost" size="sm" onClick={() => setShowCreateFolder(true)}>
146 + <Plus className="w-4 h-4" /><span className="hidden sm:inline">Dossier</span>
147 + </Button>
148 + <Button size="sm" onClick={() => setShowUpload(!showUpload)}>
149 + <Upload className="w-4 h-4" /><span className="hidden sm:inline">Upload</span>
150 + </Button>
151 + </div>
152 +
153 + {/* Upload */}
154 + <AnimatePresence>
155 + {showUpload && currentFolderId && (
156 + <motion.div initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: "auto" }} exit={{ opacity: 0, height: 0 }} className="px-4 lg:px-6 pt-4">
157 + <FileUpload folderId={currentFolderId} onUploadComplete={fetchData} />
158 + </motion.div>
159 + )}
160 + {showUpload && !currentFolderId && (
161 + <motion.div initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: "auto" }} exit={{ opacity: 0, height: 0 }} className="px-4 lg:px-6 pt-4">
162 + <p className="text-sm text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-500/10 rounded-lg px-4 py-3">
163 + Ouvre un dossier pour uploader des fichiers dans cet espace.
164 + </p>
165 + </motion.div>
166 + )}
167 + </AnimatePresence>
168 +
169 + {/* Content */}
170 + <div className="flex-1 overflow-auto px-4 lg:px-6 py-4">
171 + {currentFolderId && (
172 + <button onClick={() => setCurrentFolderId(null)} className="flex items-center gap-1 text-sm text-gray-500 hover:text-gray-900 dark:hover:text-white mb-4 transition-colors">
173 + ← Retour à l&apos;espace
174 + </button>
175 + )}
176 +
177 + {loading ? (
178 + <div className="flex items-center justify-center h-32">
179 + <div className="w-6 h-6 border-2 border-gray-300 dark:border-white/20 border-t-gray-600 dark:border-t-white rounded-full animate-spin" />
180 + </div>
181 + ) : (
182 + <>
183 + {folders.length > 0 && (
184 + <div className="mb-6">
185 + <h3 className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-3">Dossiers</h3>
186 + <div className={viewMode === "grid" ? "grid grid-cols-2 gap-2 sm:grid-cols-3 sm:gap-3 lg:grid-cols-4 xl:grid-cols-5" : "space-y-1"}>
187 + {folders.map((folder) => (
188 + <FolderCard key={folder.id} folder={folder} onRefresh={fetchData} viewMode={viewMode} />
189 + ))}
190 + </div>
191 + </div>
192 + )}
193 + {files.length > 0 && (
194 + <div>
195 + <h3 className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-3">Fichiers ({files.length})</h3>
196 + <div className={viewMode === "grid" ? "grid grid-cols-2 gap-2 sm:grid-cols-3 sm:gap-3 lg:grid-cols-4 xl:grid-cols-5" : "space-y-1"}>
197 + {files.map((file) => (
198 + <FileCard key={file.id} file={file} onPreview={setPreviewFile} onRefresh={fetchData} viewMode={viewMode} />
199 + ))}
200 + </div>
201 + </div>
202 + )}
203 + {folders.length === 0 && files.length === 0 && (
204 + <div className="flex flex-col items-center justify-center h-40 text-center">
205 + <Users className="w-10 h-10 text-gray-300 dark:text-gray-600 mb-3" />
206 + <p className="text-gray-500 dark:text-gray-400 text-sm">Espace vide. Créez un dossier pour commencer.</p>
207 + </div>
208 + )}
209 + </>
210 + )}
211 + </div>
212 + </div>
213 +
214 + {/* Create folder modal */}
215 + <Modal isOpen={showCreateFolder} onClose={() => { setShowCreateFolder(false); setNewFolderName(""); }} title="Nouveau dossier">
216 + <input
217 + type="text"
218 + placeholder="Nom du dossier"
219 + value={newFolderName}
220 + onChange={(e) => setNewFolderName(e.target.value)}
221 + onKeyDown={(e) => e.key === "Enter" && handleCreateFolder()}
222 + className={inputClasses}
223 + autoFocus
224 + />
225 + <div className="flex gap-2 justify-end">
226 + <Button variant="ghost" onClick={() => { setShowCreateFolder(false); setNewFolderName(""); }}>Annuler</Button>
227 + <Button onClick={handleCreateFolder} disabled={!newFolderName.trim()}>Créer</Button>
228 + </div>
229 + </Modal>
230 +
231 + {/* Preview */}
232 + {previewFile && (
233 + <PreviewEngine file={previewFile} onClose={() => setPreviewFile(null)} />
234 + )}
235 + </AppShell>
236 + );
237 +}
modified app/login/page.tsx +10 −9
@@ -3,11 +3,11 @@
3 3 import { useState } from "react";
4 4 import { useRouter } from "next/navigation";
5 5 import { motion } from "framer-motion";
6 −import { Cloud, Lock, Eye, EyeOff, Loader2, Mail, Shield } from "lucide-react";
6 +import { Cloud, Lock, Eye, EyeOff, Loader2, User, Shield } from "lucide-react";
7 7 import ThemeToggle from "@/components/theme/ThemeToggle";
8 8
9 9 export default function LoginPage() {
10 − const [email, setEmail] = useState("");
10 + const [username, setUsername] = useState("");
11 11 const [password, setPassword] = useState("");
12 12 const [totpCode, setTotpCode] = useState("");
13 13 const [error, setError] = useState("");
@@ -27,7 +27,7 @@ export default function LoginPage() {
27 27 const res = await fetch("/api/auth/login", {
28 28 method: "POST",
29 29 headers: { "Content-Type": "application/json" },
30 − body: JSON.stringify({ password, email: email || undefined }),
30 + body: JSON.stringify({ password, username: username || undefined }),
31 31 });
32 32
33 33 const data = await res.json();
@@ -103,13 +103,14 @@ export default function LoginPage() {
103 103 {!needs2FA ? (
104 104 <form onSubmit={handleSubmit} className="space-y-4">
105 105 <div className="relative">
106 − <Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 dark:text-gray-500" />
106 + <User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 dark:text-gray-500" />
107 107 <input
108 − type="email"
109 − value={email}
110 − onChange={(e) => setEmail(e.target.value)}
111 − placeholder="Email (optionnel)"
108 + type="text"
109 + value={username}
110 + onChange={(e) => setUsername(e.target.value)}
111 + placeholder="Nom d'utilisateur"
112 112 className={inputClasses}
113 + autoComplete="username"
113 114 />
114 115 </div>
115 116
@@ -121,7 +122,7 @@ export default function LoginPage() {
121 122 onChange={(e) => setPassword(e.target.value)}
122 123 placeholder="Mot de passe"
123 124 className={inputClasses}
124 − autoFocus
125 + autoComplete="current-password"
125 126 />
126 127 <button
127 128 type="button"
added components/admin/SharedSpaceManagement.tsx +288 −0
@@ -0,0 +1,288 @@
1 +"use client";
2 +
3 +import { useState, useEffect } from "react";
4 +import { motion } from "framer-motion";
5 +import { Plus, Trash2, Edit2, Users, UserPlus, UserMinus } from "lucide-react";
6 +import Button from "../ui/Button";
7 +import Modal from "../ui/Modal";
8 +import toast from "react-hot-toast";
9 +import { formatFileSize } from "@/lib/utils";
10 +
11 +interface MemberData {
12 + id: string;
13 + user: { id: string; name: string | null; username: string | null };
14 +}
15 +
16 +interface SpaceData {
17 + id: string;
18 + name: string;
19 + quotaBytes: number;
20 + usedBytes: number;
21 + createdAt: string;
22 + _count: { members: number; folders: number };
23 + members: MemberData[];
24 +}
25 +
26 +interface UserOption {
27 + id: string;
28 + name: string | null;
29 + username: string | null;
30 +}
31 +
32 +const inputClasses = "w-full bg-gray-100 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-lg px-4 py-3 text-gray-900 dark:text-white text-sm focus:outline-none mb-4";
33 +
34 +export default function SharedSpaceManagement() {
35 + const [spaces, setSpaces] = useState<SpaceData[]>([]);
36 + const [users, setUsers] = useState<UserOption[]>([]);
37 + const [loading, setLoading] = useState(true);
38 + const [showCreate, setShowCreate] = useState(false);
39 + const [editSpace, setEditSpace] = useState<SpaceData | null>(null);
40 + const [form, setForm] = useState({ name: "", memberIds: [] as string[] });
41 +
42 + const fetchData = async () => {
43 + const [spacesRes, usersRes] = await Promise.all([
44 + fetch("/api/shared-spaces"),
45 + fetch("/api/users"),
46 + ]);
47 + if (spacesRes.ok) setSpaces(await spacesRes.json());
48 + if (usersRes.ok) setUsers(await usersRes.json());
49 + setLoading(false);
50 + };
51 +
52 + useEffect(() => { fetchData(); }, []);
53 +
54 + const handleCreate = async () => {
55 + const res = await fetch("/api/shared-spaces", {
56 + method: "POST",
57 + headers: { "Content-Type": "application/json" },
58 + body: JSON.stringify(form),
59 + });
60 + if (res.ok) {
61 + toast.success("Espace créé");
62 + setShowCreate(false);
63 + setForm({ name: "", memberIds: [] });
64 + fetchData();
65 + } else {
66 + toast.error("Erreur lors de la création");
67 + }
68 + };
69 +
70 + const handleDelete = async (id: string) => {
71 + if (!confirm("Supprimer cet espace partagé ? Les dossiers seront conservés mais retirés de l'espace.")) return;
72 + const res = await fetch(`/api/shared-spaces/${id}`, { method: "DELETE" });
73 + if (res.ok) { toast.success("Espace supprimé"); fetchData(); }
74 + else toast.error("Erreur");
75 + };
76 +
77 + const handleRename = async () => {
78 + if (!editSpace) return;
79 + const res = await fetch(`/api/shared-spaces/${editSpace.id}`, {
80 + method: "PATCH",
81 + headers: { "Content-Type": "application/json" },
82 + body: JSON.stringify({ name: editSpace.name }),
83 + });
84 + if (res.ok) { toast.success("Renommé"); setEditSpace(null); fetchData(); }
85 + else toast.error("Erreur");
86 + };
87 +
88 + const handleAddMember = async (spaceId: string, userId: string) => {
89 + const res = await fetch(`/api/shared-spaces/${spaceId}/members`, {
90 + method: "POST",
91 + headers: { "Content-Type": "application/json" },
92 + body: JSON.stringify({ userId }),
93 + });
94 + if (res.ok) { toast.success("Membre ajouté"); fetchData(); }
95 + else toast.error("Déjà membre ou erreur");
96 + };
97 +
98 + const handleRemoveMember = async (spaceId: string, userId: string) => {
99 + const res = await fetch(`/api/shared-spaces/${spaceId}/members`, {
100 + method: "DELETE",
101 + headers: { "Content-Type": "application/json" },
102 + body: JSON.stringify({ userId }),
103 + });
104 + if (res.ok) { toast.success("Membre retiré"); fetchData(); }
105 + else toast.error("Erreur");
106 + };
107 +
108 + const toggleMember = (userId: string) => {
109 + setForm((f) => ({
110 + ...f,
111 + memberIds: f.memberIds.includes(userId)
112 + ? f.memberIds.filter((id) => id !== userId)
113 + : [...f.memberIds, userId],
114 + }));
115 + };
116 +
117 + if (loading) return <div className="text-gray-500 text-sm p-4">Chargement...</div>;
118 +
119 + return (
120 + <div className="space-y-4">
121 + <div className="flex justify-end">
122 + <Button onClick={() => setShowCreate(true)}>
123 + <Plus className="w-4 h-4 mr-1" /> Nouvel espace
124 + </Button>
125 + </div>
126 +
127 + {spaces.length === 0 && (
128 + <div className="text-center py-12 text-gray-500 dark:text-gray-400">
129 + <Users className="w-10 h-10 mx-auto mb-3 opacity-40" />
130 + <p className="text-sm">Aucun espace partagé. Créez-en un pour commencer.</p>
131 + </div>
132 + )}
133 +
134 + <div className="space-y-4">
135 + {spaces.map((space) => {
136 + const percent = space.quotaBytes > 0 ? Math.min(Math.round((space.usedBytes / space.quotaBytes) * 100), 100) : 0;
137 + const memberUserIds = space.members.map((m) => m.user.id);
138 + const nonMembers = users.filter((u) => !memberUserIds.includes(u.id));
139 +
140 + return (
141 + <motion.div
142 + key={space.id}
143 + initial={{ opacity: 0, y: 10 }}
144 + animate={{ opacity: 1, y: 0 }}
145 + className="bg-white dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl p-5"
146 + >
147 + {/* Header */}
148 + <div className="flex items-start justify-between gap-3 mb-4">
149 + <div className="flex items-center gap-2 min-w-0">
150 + <div className="w-8 h-8 rounded-lg bg-purple-100 dark:bg-purple-500/20 flex items-center justify-center flex-shrink-0">
151 + <Users className="w-4 h-4 text-purple-600 dark:text-purple-400" />
152 + </div>
153 + <div className="min-w-0">
154 + <p className="font-medium text-gray-900 dark:text-white truncate">{space.name}</p>
155 + <p className="text-xs text-gray-500">{space._count.members} membre(s) · {space._count.folders} dossier(s)</p>
156 + </div>
157 + </div>
158 + <div className="flex items-center gap-1 flex-shrink-0">
159 + <button onClick={() => setEditSpace(space)} className="p-2 text-gray-400 hover:text-gray-700 dark:hover:text-white transition-colors">
160 + <Edit2 className="w-4 h-4" />
161 + </button>
162 + <button onClick={() => handleDelete(space.id)} className="p-2 text-gray-400 hover:text-red-500 transition-colors">
163 + <Trash2 className="w-4 h-4" />
164 + </button>
165 + </div>
166 + </div>
167 +
168 + {/* Storage quota */}
169 + <div className="mb-4">
170 + <div className="flex justify-between text-xs text-gray-500 mb-1">
171 + <span>Stockage utilisé</span>
172 + <span>{formatFileSize(space.usedBytes)} / {formatFileSize(space.quotaBytes)}</span>
173 + </div>
174 + <div className="w-full bg-gray-100 dark:bg-white/10 rounded-full h-1.5">
175 + <div
176 + className={`h-1.5 rounded-full transition-all ${percent > 90 ? "bg-red-500" : percent > 70 ? "bg-yellow-500" : "bg-purple-500"}`}
177 + style={{ width: `${percent}%` }}
178 + />
179 + </div>
180 + <p className="text-xs text-gray-400 mt-1">{percent}% utilisé</p>
181 + </div>
182 +
183 + {/* Members */}
184 + <div>
185 + <p className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-2">Membres</p>
186 + <div className="space-y-1 mb-3">
187 + {space.members.map((m) => (
188 + <div key={m.id} className="flex items-center justify-between py-1.5 px-2 rounded-lg hover:bg-gray-50 dark:hover:bg-white/5">
189 + <div className="flex items-center gap-2">
190 + <div className="w-6 h-6 rounded-full bg-gray-200 dark:bg-white/10 flex items-center justify-center">
191 + <span className="text-[10px] font-medium text-gray-600 dark:text-gray-300">
192 + {(m.user.name || m.user.username || "?")[0].toUpperCase()}
193 + </span>
194 + </div>
195 + <span className="text-sm text-gray-700 dark:text-gray-300">{m.user.name || m.user.username}</span>
196 + </div>
197 + <button onClick={() => handleRemoveMember(space.id, m.user.id)} className="p-1 text-gray-400 hover:text-red-500 transition-colors">
198 + <UserMinus className="w-3.5 h-3.5" />
199 + </button>
200 + </div>
201 + ))}
202 + {space.members.length === 0 && (
203 + <p className="text-xs text-gray-400 px-2">Aucun membre pour l&apos;instant</p>
204 + )}
205 + </div>
206 +
207 + {/* Add member */}
208 + {nonMembers.length > 0 && (
209 + <div className="flex gap-2">
210 + <select
211 + className="flex-1 bg-gray-100 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-lg px-3 py-2 text-sm text-gray-700 dark:text-gray-300 focus:outline-none"
212 + defaultValue=""
213 + onChange={(e) => {
214 + if (e.target.value) { handleAddMember(space.id, e.target.value); e.target.value = ""; }
215 + }}
216 + >
217 + <option value="" disabled>Ajouter un membre...</option>
218 + {nonMembers.map((u) => (
219 + <option key={u.id} value={u.id}>{u.name || u.username}</option>
220 + ))}
221 + </select>
222 + <button className="p-2 text-purple-500 hover:text-purple-700 transition-colors">
223 + <UserPlus className="w-5 h-5" />
224 + </button>
225 + </div>
226 + )}
227 + </div>
228 + </motion.div>
229 + );
230 + })}
231 + </div>
232 +
233 + {/* Create modal */}
234 + <Modal isOpen={showCreate} onClose={() => { setShowCreate(false); setForm({ name: "", memberIds: [] }); }} title="Nouvel espace partagé">
235 + <input
236 + type="text"
237 + placeholder="Nom de l'espace"
238 + value={form.name}
239 + onChange={(e) => setForm({ ...form, name: e.target.value })}
240 + className={inputClasses}
241 + autoFocus
242 + />
243 + {users.length > 0 && (
244 + <div className="mb-4">
245 + <p className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-2">Membres</p>
246 + <div className="space-y-1 max-h-48 overflow-auto">
247 + {users.map((u) => (
248 + <label key={u.id} className="flex items-center gap-3 px-2 py-2 rounded-lg hover:bg-gray-50 dark:hover:bg-white/5 cursor-pointer">
249 + <input
250 + type="checkbox"
251 + checked={form.memberIds.includes(u.id)}
252 + onChange={() => toggleMember(u.id)}
253 + className="rounded"
254 + />
255 + <span className="text-sm text-gray-700 dark:text-gray-300">{u.name || u.username}</span>
256 + </label>
257 + ))}
258 + </div>
259 + </div>
260 + )}
261 + <p className="text-xs text-gray-400 mb-4">Quota : 500 Go par défaut</p>
262 + <div className="flex gap-2 justify-end">
263 + <Button variant="ghost" onClick={() => { setShowCreate(false); setForm({ name: "", memberIds: [] }); }}>Annuler</Button>
264 + <Button onClick={handleCreate} disabled={!form.name.trim()}>Créer</Button>
265 + </div>
266 + </Modal>
267 +
268 + {/* Edit modal */}
269 + <Modal isOpen={!!editSpace} onClose={() => setEditSpace(null)} title="Modifier l'espace">
270 + {editSpace && (
271 + <>
272 + <input
273 + type="text"
274 + value={editSpace.name}
275 + onChange={(e) => setEditSpace({ ...editSpace, name: e.target.value })}
276 + className={inputClasses}
277 + autoFocus
278 + />
279 + <div className="flex gap-2 justify-end">
280 + <Button variant="ghost" onClick={() => setEditSpace(null)}>Annuler</Button>
281 + <Button onClick={handleRename}>Enregistrer</Button>
282 + </div>
283 + </>
284 + )}
285 + </Modal>
286 + </div>
287 + );
288 +}
modified components/admin/UserManagement.tsx +49 −25
@@ -9,6 +9,7 @@ import toast from "react-hot-toast";
9 9
10 10 interface UserData {
11 11 id: string;
12 + username: string | null;
12 13 email: string | null;
13 14 name: string | null;
14 15 role: string;
@@ -22,7 +23,7 @@ export default function UserManagement() {
22 23 const [loading, setLoading] = useState(true);
23 24 const [showCreate, setShowCreate] = useState(false);
24 25 const [editUser, setEditUser] = useState<UserData | null>(null);
25 − const [form, setForm] = useState({ email: "", name: "", password: "", role: "user" });
26 + const [form, setForm] = useState({ username: "", name: "", password: "", role: "user" });
26 27
27 28 const fetchUsers = async () => {
28 29 const res = await fetch("/api/users");
@@ -41,7 +42,7 @@ export default function UserManagement() {
41 42 if (res.ok) {
42 43 toast.success("Utilisateur créé");
43 44 setShowCreate(false);
44 − setForm({ email: "", name: "", password: "", role: "user" });
45 + setForm({ username: "", name: "", password: "", role: "user" });
45 46 fetchUsers();
46 47 } else {
47 48 const data = await res.json();
@@ -53,7 +54,7 @@ export default function UserManagement() {
53 54 if (!editUser) return;
54 55 const data: Record<string, string> = {};
55 56 if (form.name) data.name = form.name;
56 − if (form.email) data.email = form.email;
57 + if (form.username) data.username = form.username;
57 58 if (form.role) data.role = form.role;
58 59 if (form.password) data.password = form.password;
59 60
@@ -117,13 +118,13 @@ export default function UserManagement() {
117 118 {/* First line: avatar + name/email */}
118 119 <div className="flex items-center gap-3 flex-1 min-w-0">
119 120 <div className="w-10 h-10 rounded-full bg-gray-200 dark:bg-white/10 flex items-center justify-center text-sm font-medium text-gray-600 dark:text-gray-300 flex-shrink-0">
120 − {(user.name || user.email || "U")[0].toUpperCase()}
121 + {(user.name || user.username || "U")[0].toUpperCase()}
121 122 </div>
122 123 <div className="flex-1 min-w-0">
123 124 <p className="text-sm font-medium text-gray-900 dark:text-white truncate">
124 − {user.name || user.email || user.id}
125 + {user.name || user.username || user.id}
125 126 </p>
126 − <p className="text-xs text-gray-500 truncate">{user.email}</p>
127 + <p className="text-xs text-gray-500 truncate">@{user.username}</p>
127 128 </div>
128 129 </div>
129 130 {/* Second line on mobile: role badge + file count + actions */}
@@ -139,7 +140,7 @@ export default function UserManagement() {
139 140 </span>
140 141 <div className="flex gap-1 ml-auto">
141 142 <button
142 − onClick={() => { setEditUser(user); setForm({ email: user.email || "", name: user.name || "", password: "", role: user.role }); }}
143 + onClick={() => { setEditUser(user); setForm({ username: user.username || "", name: user.name || "", password: "", role: user.role }); }}
143 144 className="min-w-[44px] min-h-[44px] flex items-center justify-center p-2.5 text-gray-400 hover:text-gray-700 dark:hover:text-white rounded-lg transition-colors"
144 145 >
145 146 <Edit2 className="w-4 h-4" />
@@ -160,32 +161,55 @@ export default function UserManagement() {
160 161 {/* Create Modal */}
161 162 <Modal isOpen={showCreate} onClose={() => setShowCreate(false)} title="Nouvel utilisateur">
162 163 <div className="space-y-3">
163 − <input type="email" placeholder="Email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} className={inputClasses} />
164 − <input type="text" placeholder="Nom" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} className={inputClasses} />
165 − <input type="password" placeholder="Mot de passe" value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} className={inputClasses} />
166 − <select value={form.role} onChange={(e) => setForm({ ...form, role: e.target.value })} className={inputClasses}>
167 − <option value="user">Utilisateur</option>
168 − <option value="viewer">Lecteur</option>
169 − <option value="admin">Admin</option>
170 − </select>
164 + <div>
165 + <label className="block text-xs text-gray-500 mb-1 ml-1">Nom d&apos;utilisateur *</label>
166 + <input type="text" placeholder="ex: simon, marie42..." value={form.username} onChange={(e) => setForm({ ...form, username: e.target.value.toLowerCase().replace(/\s/g, "") })} className={inputClasses} autoFocus autoComplete="off" />
167 + </div>
168 + <div>
169 + <label className="block text-xs text-gray-500 mb-1 ml-1">Nom affiché</label>
170 + <input type="text" placeholder="ex: Simon Boucher" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} className={inputClasses} />
171 + </div>
172 + <div>
173 + <label className="block text-xs text-gray-500 mb-1 ml-1">Mot de passe *</label>
174 + <input type="password" placeholder="Mot de passe" value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} className={inputClasses} />
175 + </div>
176 + <div>
177 + <label className="block text-xs text-gray-500 mb-1 ml-1">Rôle</label>
178 + <select value={form.role} onChange={(e) => setForm({ ...form, role: e.target.value })} className={inputClasses}>
179 + <option value="user">Utilisateur</option>
180 + <option value="viewer">Lecteur</option>
181 + <option value="admin">Admin</option>
182 + </select>
183 + </div>
171 184 <div className="flex gap-2 justify-end pt-2">
172 185 <Button variant="ghost" onClick={() => setShowCreate(false)}>Annuler</Button>
173 − <Button onClick={handleCreate}>Créer</Button>
186 + <Button onClick={handleCreate} disabled={!form.username || !form.password}>Créer</Button>
174 187 </div>
175 188 </div>
176 189 </Modal>
177 190
178 − {/* Edit Modal */}
179 191 <Modal isOpen={!!editUser} onClose={() => setEditUser(null)} title="Modifier utilisateur">
180 192 <div className="space-y-3">
181 − <input type="email" placeholder="Email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} className={inputClasses} />
182 − <input type="text" placeholder="Nom" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} className={inputClasses} />
183 − <input type="password" placeholder="Nouveau mot de passe (vide = inchangé)" value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} className={inputClasses} />
184 − <select value={form.role} onChange={(e) => setForm({ ...form, role: e.target.value })} className={inputClasses}>
185 − <option value="user">Utilisateur</option>
186 − <option value="viewer">Lecteur</option>
187 − <option value="admin">Admin</option>
188 − </select>
193 + <div>
194 + <label className="block text-xs text-gray-500 mb-1 ml-1">Nom d&apos;utilisateur</label>
195 + <input type="text" placeholder="Nom d'utilisateur" value={form.username} onChange={(e) => setForm({ ...form, username: e.target.value.toLowerCase().replace(/\s/g, "") })} className={inputClasses} />
196 + </div>
197 + <div>
198 + <label className="block text-xs text-gray-500 mb-1 ml-1">Nom affiché</label>
199 + <input type="text" placeholder="ex: Simon Boucher" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} className={inputClasses} />
200 + </div>
201 + <div>
202 + <label className="block text-xs text-gray-500 mb-1 ml-1">Nouveau mot de passe</label>
203 + <input type="password" placeholder="Laisser vide pour ne pas changer" value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} className={inputClasses} />
204 + </div>
205 + <div>
206 + <label className="block text-xs text-gray-500 mb-1 ml-1">Rôle</label>
207 + <select value={form.role} onChange={(e) => setForm({ ...form, role: e.target.value })} className={inputClasses}>
208 + <option value="user">Utilisateur</option>
209 + <option value="viewer">Lecteur</option>
210 + <option value="admin">Admin</option>
211 + </select>
212 + </div>
189 213 <div className="flex gap-2 justify-end pt-2">
190 214 <Button variant="ghost" onClick={() => setEditUser(null)}>Annuler</Button>
191 215 <Button onClick={handleUpdate}>Sauvegarder</Button>
modified components/ai/AiPanel.tsx +633 −116
@@ -1,30 +1,148 @@
1 1 "use client";
2 2
3 −import { useState } from "react";
3 +import { useState, useEffect, useRef, useCallback } from "react";
4 4 import { motion, AnimatePresence } from "framer-motion";
5 −import { Brain, Search, Tag, FileText, MessageSquare, Sparkles, X, Loader2 } from "lucide-react";
6 −import Button from "../ui/Button";
5 +import {
6 + Brain,
7 + Search,
8 + FileText,
9 + MessageSquare,
10 + Sparkles,
11 + X,
12 + Loader2,
13 + Send,
14 + Trash2,
15 + ScanText,
16 + Tag,
17 + Info,
18 + ChevronDown,
19 + Zap,
20 + File,
21 + Download,
22 + FlaskConical,
23 + AlertCircle,
24 +} from "lucide-react";
25 +import ReactMarkdown from "react-markdown";
7 26
8 27 interface AiResult {
9 28 fileId: string;
10 29 name: string;
11 30 relevance: string;
12 31 snippet: string;
32 + folder?: string;
33 + mimeType?: string;
13 34 }
14 35
36 +interface ChatMessage {
37 + role: "user" | "assistant";
38 + content: string;
39 +}
40 +
41 +interface AnalyserFile {
42 + id: string;
43 + name: string;
44 + mimeType: string;
45 + size: number;
46 + aiTags?: string | null;
47 + aiDescription?: string | null;
48 + ocrText?: string | null;
49 + folder?: { name: string } | null;
50 +}
51 +
52 +type Tab = "search" | "chat" | "analyse";
53 +
54 +const relevanceColors: Record<string, string> = {
55 + high: "bg-green-100 text-green-700 dark:bg-green-500/20 dark:text-green-400",
56 + medium: "bg-yellow-100 text-yellow-700 dark:bg-yellow-500/20 dark:text-yellow-400",
57 + low: "bg-gray-100 text-gray-600 dark:bg-gray-500/20 dark:text-gray-400",
58 +};
59 +
60 +const mimeIcon = (mime: string) => {
61 + if (!mime) return "📁";
62 + if (mime.startsWith("image/")) return "🖼️";
63 + if (mime === "application/pdf") return "📄";
64 + if (mime.startsWith("video/")) return "🎬";
65 + if (mime.startsWith("audio/")) return "🎵";
66 + if (mime.includes("word") || mime.includes("document")) return "📝";
67 + if (mime.includes("spreadsheet") || mime === "text/csv") return "📊";
68 + if (mime.startsWith("text/")) return "📃";
69 + return "📁";
70 +};
71 +
72 +const inputClasses =
73 + "w-full bg-gray-100 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-lg px-4 py-3 text-gray-900 dark:text-white text-sm focus:outline-none focus:border-purple-400 dark:focus:border-purple-500 placeholder-gray-400 dark:placeholder-gray-500";
74 +
15 75 export default function AiPanel() {
16 76 const [open, setOpen] = useState(false);
17 − const [tab, setTab] = useState<"search" | "chat">("search");
77 + const [tab, setTab] = useState<Tab>("chat");
78 +
79 + // Search state
18 80 const [query, setQuery] = useState("");
19 − const [chatMessage, setChatMessage] = useState("");
20 81 const [searching, setSearching] = useState(false);
21 − const [chatting, setChatting] = useState(false);
22 82 const [results, setResults] = useState<AiResult[]>([]);
23 83 const [answer, setAnswer] = useState("");
24 − const [chatHistory, setChatHistory] = useState<Array<{ role: string; content: string }>>([]);
25 84
26 − const handleSearch = async () => {
27 − if (!query.trim()) return;
85 + // Chat state
86 + const [chatInput, setChatInput] = useState("");
87 + const [chatHistory, setChatHistory] = useState<ChatMessage[]>([]);
88 + const [isStreaming, setIsStreaming] = useState(false);
89 + const [streamingContent, setStreamingContent] = useState("");
90 + const chatEndRef = useRef<HTMLDivElement>(null);
91 +
92 + // Analyser state
93 + const [analyserFiles, setAnalyserFiles] = useState<AnalyserFile[]>([]);
94 + const [analyserLoading, setAnalyserLoading] = useState(false);
95 + const [analyserError, setAnalyserError] = useState(false);
96 + const [analyserQuery, setAnalyserQuery] = useState("");
97 + const [selectedFile, setSelectedFile] = useState<AnalyserFile | null>(null);
98 + const [analysing, setAnalysing] = useState(false);
99 + const [analyserResult, setAnalyserResult] = useState<{ type: string; content: string } | null>(null);
100 + const [showFilePicker, setShowFilePicker] = useState(false);
101 +
102 + // Auto-scroll chat
103 + useEffect(() => {
104 + chatEndRef.current?.scrollIntoView({ behavior: "smooth" });
105 + }, [chatHistory, streamingContent]);
106 +
107 + // Fetch files for analyser
108 + const fetchAnalyserFiles = useCallback(async (q = "") => {
109 + setAnalyserLoading(true);
110 + setAnalyserError(false);
111 + try {
112 + const res = await fetch(`/api/ai/files?q=${encodeURIComponent(q)}`);
113 + if (res.ok) {
114 + const data = await res.json();
115 + setAnalyserFiles(Array.isArray(data) ? data : []);
116 + } else {
117 + setAnalyserError(true);
118 + }
119 + } catch {
120 + setAnalyserError(true);
121 + } finally {
122 + setAnalyserLoading(false);
123 + }
124 + }, []);
125 +
126 + // Load files when switching to analyse tab, auto-open picker
127 + useEffect(() => {
128 + if (tab === "analyse") {
129 + fetchAnalyserFiles();
130 + setShowFilePicker(true);
131 + }
132 + }, [tab, fetchAnalyserFiles]);
133 +
134 + // ─── Jump to Analyser with a specific file ─────────────────
135 + const openInAnalyser = useCallback((file: AnalyserFile) => {
136 + setSelectedFile(file);
137 + setAnalyserResult(null);
138 + setShowFilePicker(false);
139 + setTab("analyse");
140 + }, []);
141 +
142 + // ─── Search ────────────────────────────────────────────────
143 + const handleSearch = useCallback(async (q?: string) => {
144 + const searchQuery = q ?? query;
145 + if (!searchQuery.trim()) return;
28 146 setSearching(true);
29 147 setResults([]);
30 148 setAnswer("");
@@ -32,7 +150,7 @@ export default function AiPanel() {
32 150 const res = await fetch("/api/ai/search", {
33 151 method: "POST",
34 152 headers: { "Content-Type": "application/json" },
35 − body: JSON.stringify({ query }),
153 + body: JSON.stringify({ query: searchQuery }),
36 154 });
37 155 if (res.ok) {
38 156 const data = await res.json();
@@ -41,39 +159,127 @@ export default function AiPanel() {
41 159 }
42 160 } catch {}
43 161 setSearching(false);
162 + }, [query]);
163 +
164 + const quickSearch = (q: string) => {
165 + setQuery(q);
166 + handleSearch(q);
44 167 };
45 168
169 + // ─── Chat (streaming) ──────────────────────────────────────
46 170 const handleChat = async () => {
47 − if (!chatMessage.trim()) return;
48 − const msg = chatMessage;
49 − setChatMessage("");
171 + const msg = chatInput.trim();
172 + if (!msg || isStreaming) return;
173 + setChatInput("");
174 +
175 + const historyToSend = [...chatHistory];
50 176 setChatHistory((prev) => [...prev, { role: "user", content: msg }]);
51 − setChatting(true);
177 + setIsStreaming(true);
178 + setStreamingContent("");
179 +
52 180 try {
53 181 const res = await fetch("/api/ai/chat", {
54 182 method: "POST",
55 183 headers: { "Content-Type": "application/json" },
56 − body: JSON.stringify({ message: msg }),
184 + body: JSON.stringify({ message: msg, history: historyToSend, stream: true }),
185 + });
186 +
187 + if (!res.ok || !res.body) throw new Error("Stream error");
188 +
189 + const reader = res.body.getReader();
190 + const decoder = new TextDecoder();
191 + let accumulated = "";
192 + let buffer = "";
193 +
194 + while (true) {
195 + const { done, value } = await reader.read();
196 + if (done) break;
197 + buffer += decoder.decode(value, { stream: true });
198 + const lines = buffer.split("\n\n");
199 + buffer = lines.pop() ?? "";
200 + for (const line of lines) {
201 + if (!line.startsWith("data: ")) continue;
202 + const data = line.slice(6).trim();
203 + if (data === "[DONE]") break;
204 + try {
205 + const parsed = JSON.parse(data);
206 + if (parsed.token) {
207 + accumulated += parsed.token;
208 + setStreamingContent(accumulated);
209 + }
210 + } catch {}
211 + }
212 + }
213 +
214 + setChatHistory((prev) => [
215 + ...prev,
216 + { role: "assistant", content: accumulated || "Désolé, aucune réponse." },
217 + ]);
218 + } catch {
219 + setChatHistory((prev) => [
220 + ...prev,
221 + { role: "assistant", content: "Erreur de connexion. Réessayez." },
222 + ]);
223 + } finally {
224 + setIsStreaming(false);
225 + setStreamingContent("");
226 + }
227 + };
228 +
229 + // ─── Analyser ──────────────────────────────────────────────
230 + const handleAnalyse = async (action: "ocr" | "describe" | "tag") => {
231 + if (!selectedFile) return;
232 + setAnalysing(true);
233 + setAnalyserResult(null);
234 + const endpoint =
235 + action === "ocr" ? "/api/ai/ocr" : action === "describe" ? "/api/ai/describe" : "/api/ai/tag";
236 + try {
237 + const res = await fetch(endpoint, {
238 + method: "POST",
239 + headers: { "Content-Type": "application/json" },
240 + body: JSON.stringify({ fileId: selectedFile.id }),
57 241 });
58 242 if (res.ok) {
59 243 const data = await res.json();
60 − setChatHistory((prev) => [...prev, { role: "assistant", content: data.reply }]);
244 + if (action === "ocr")
245 + setAnalyserResult({ type: "Texte extrait (OCR)", content: data.text || "Aucun texte trouvé." });
246 + else if (action === "describe")
247 + setAnalyserResult({ type: "Description", content: data.description || "Impossible de décrire." });
248 + else {
249 + const tags = Array.isArray(data.tags) ? data.tags : [];
250 + setAnalyserResult({ type: "Tags générés", content: tags.join(", ") || "Aucun tag." });
251 + }
61 252 }
62 − } catch {}
63 − setChatting(false);
253 + } catch {
254 + setAnalyserResult({ type: "Erreur", content: "Impossible d'analyser ce fichier." });
255 + }
256 + setAnalysing(false);
64 257 };
65 258
66 − const relevanceColors: Record<string, string> = {
67 − high: "bg-green-100 text-green-700 dark:bg-green-500/20 dark:text-green-400",
68 − medium: "bg-yellow-100 text-yellow-700 dark:bg-yellow-500/20 dark:text-yellow-400",
69 − low: "bg-gray-100 text-gray-600 dark:bg-gray-500/20 dark:text-gray-400",
259 + const handleAnalyseAll = async () => {
260 + if (!selectedFile) return;
261 + setAnalysing(true);
262 + setAnalyserResult(null);
263 + try {
264 + const [descRes, tagRes] = await Promise.all([
265 + fetch("/api/ai/describe", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ fileId: selectedFile.id }) }),
266 + fetch("/api/ai/tag", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ fileId: selectedFile.id }) }),
267 + ]);
268 + const desc = descRes.ok ? (await descRes.json()).description : "";
269 + const tags = tagRes.ok ? (await tagRes.json()).tags : [];
270 + setAnalyserResult({
271 + type: "Analyse complète",
272 + content: `**Description :** ${desc}\n\n**Tags :** ${Array.isArray(tags) ? tags.join(", ") : "—"}`,
273 + });
274 + } catch {
275 + setAnalyserResult({ type: "Erreur", content: "Erreur lors de l'analyse." });
276 + }
277 + setAnalysing(false);
70 278 };
71 279
72 − const inputClasses = "w-full bg-gray-100 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-lg px-4 py-3 text-gray-900 dark:text-white text-sm focus:outline-none focus:border-gray-400 dark:focus:border-white/20 placeholder-gray-400 dark:placeholder-gray-500";
73 −
74 280 return (
75 281 <>
76 − {/* AI Toggle Button - positioned above mobile safe area */}
282 + {/* Toggle Button */}
77 283 <button
78 284 onClick={() => setOpen(true)}
79 285 className="fixed bottom-20 right-4 sm:bottom-6 sm:right-6 z-40 w-14 h-14 rounded-full bg-purple-600 hover:bg-purple-700 text-white shadow-lg flex items-center justify-center transition-colors"
@@ -82,7 +288,7 @@ export default function AiPanel() {
82 288 <Brain className="w-6 h-6" />
83 289 </button>
84 290
85 − {/* AI Panel */}
291 + {/* Panel */}
86 292 <AnimatePresence>
87 293 {open && (
88 294 <>
@@ -94,11 +300,11 @@ export default function AiPanel() {
94 300 onClick={() => setOpen(false)}
95 301 />
96 302 <motion.div
97 − initial={{ x: 400, opacity: 0 }}
303 + initial={{ x: 420, opacity: 0 }}
98 304 animate={{ x: 0, opacity: 1 }}
99 − exit={{ x: 400, opacity: 0 }}
305 + exit={{ x: 420, opacity: 0 }}
100 306 transition={{ type: "spring", damping: 25, stiffness: 300 }}
101 − className="fixed right-0 top-0 bottom-0 w-full sm:w-96 z-50 bg-white dark:bg-gray-900 border-l border-gray-200 dark:border-white/10 flex flex-col shadow-2xl pb-[env(safe-area-inset-bottom)]"
307 + className="fixed right-0 top-0 bottom-0 w-full sm:w-[420px] z-50 bg-white dark:bg-gray-900 border-l border-gray-200 dark:border-white/10 flex flex-col shadow-2xl pb-[env(safe-area-inset-bottom)]"
102 308 >
103 309 {/* Header */}
104 310 <div className="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-white/10 pt-[max(0.75rem,env(safe-area-inset-top))]">
@@ -115,120 +321,431 @@ export default function AiPanel() {
115 321
116 322 {/* Tabs */}
117 323 <div className="flex border-b border-gray-200 dark:border-white/10">
118 − <button
119 − onClick={() => setTab("search")}
120 − className={`flex-1 px-4 py-3 text-sm font-medium transition-colors flex items-center justify-center gap-1.5 ${tab === "search" ? "text-purple-600 dark:text-purple-400 border-b-2 border-purple-500" : "text-gray-500"}`}
121 − >
122 − <Search className="w-4 h-4" /> Recherche IA
123 − </button>
124 − <button
125 − onClick={() => setTab("chat")}
126 − className={`flex-1 px-4 py-3 text-sm font-medium transition-colors flex items-center justify-center gap-1.5 ${tab === "chat" ? "text-purple-600 dark:text-purple-400 border-b-2 border-purple-500" : "text-gray-500"}`}
127 − >
128 − <MessageSquare className="w-4 h-4" /> Chat
129 − </button>
324 + {(["chat", "search", "analyse"] as Tab[]).map((t) => {
325 + const icons = { chat: MessageSquare, search: Search, analyse: ScanText };
326 + const labels = { chat: "Chat", search: "Recherche", analyse: "Analyser" };
327 + const Icon = icons[t];
328 + return (
329 + <button
330 + key={t}
331 + onClick={() => setTab(t)}
332 + className={`flex-1 px-2 py-3 text-xs font-medium transition-colors flex items-center justify-center gap-1.5 ${
333 + tab === t
334 + ? "text-purple-600 dark:text-purple-400 border-b-2 border-purple-500"
335 + : "text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
336 + }`}
337 + >
338 + <Icon className="w-3.5 h-3.5" />
339 + {labels[t]}
340 + </button>
341 + );
342 + })}
130 343 </div>
131 344
132 − {/* Content */}
133 − <div className="flex-1 overflow-auto p-4">
134 − {tab === "search" ? (
135 − <div className="space-y-4">
345 + {/* ── CHAT TAB ── */}
346 + {tab === "chat" && (
347 + <div className="flex flex-col flex-1 min-h-0">
348 + <div className="flex-1 overflow-auto p-4 space-y-3">
349 + {chatHistory.length === 0 && !isStreaming && (
350 + <div className="text-center py-6">
351 + <Brain className="w-8 h-8 mx-auto mb-3 text-gray-300 dark:text-gray-600" />
352 + <p className="text-sm text-gray-500 mb-4">Posez une question sur vos fichiers</p>
353 + <div className="space-y-2 text-left">
354 + {[
355 + "Résume mes fichiers récents",
356 + "Quels sont mes documents importants ?",
357 + "Aide-moi à organiser mes fichiers",
358 + ].map((prompt) => (
359 + <button
360 + key={prompt}
361 + onClick={() => setChatInput(prompt)}
362 + className="w-full text-left px-3 py-2 rounded-lg bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 text-xs text-gray-600 dark:text-gray-400 hover:bg-purple-50 dark:hover:bg-purple-500/10 hover:border-purple-200 dark:hover:border-purple-500/30 transition-colors"
363 + >
364 + <Zap className="w-3 h-3 inline mr-1.5 text-purple-400" />
365 + {prompt}
366 + </button>
367 + ))}
368 + </div>
369 + </div>
370 + )}
371 +
372 + {chatHistory.map((msg, i) => (
373 + <div key={i} className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}>
374 + {msg.role === "assistant" && (
375 + <div className="w-6 h-6 rounded-full bg-purple-100 dark:bg-purple-500/20 flex items-center justify-center flex-shrink-0 mr-2 mt-0.5">
376 + <Brain className="w-3 h-3 text-purple-600 dark:text-purple-400" />
377 + </div>
378 + )}
379 + <div
380 + className={`max-w-[82%] px-3 py-2.5 rounded-2xl text-sm leading-relaxed ${
381 + msg.role === "user"
382 + ? "bg-purple-600 text-white rounded-tr-sm"
383 + : "bg-gray-100 dark:bg-white/10 text-gray-900 dark:text-gray-100 rounded-tl-sm"
384 + }`}
385 + >
386 + {msg.role === "assistant" ? (
387 + <div className="prose prose-sm dark:prose-invert max-w-none prose-p:my-1 prose-ul:my-1 prose-li:my-0.5 prose-headings:my-1">
388 + <ReactMarkdown>{msg.content}</ReactMarkdown>
389 + </div>
390 + ) : (
391 + msg.content
392 + )}
393 + </div>
394 + </div>
395 + ))}
396 +
397 + {isStreaming && (
398 + <div className="flex justify-start">
399 + <div className="w-6 h-6 rounded-full bg-purple-100 dark:bg-purple-500/20 flex items-center justify-center flex-shrink-0 mr-2 mt-0.5">
400 + <Brain className="w-3 h-3 text-purple-600 dark:text-purple-400" />
401 + </div>
402 + <div className="max-w-[82%] px-3 py-2.5 rounded-2xl rounded-tl-sm bg-gray-100 dark:bg-white/10 text-sm text-gray-900 dark:text-gray-100 leading-relaxed">
403 + {streamingContent ? (
404 + <div className="prose prose-sm dark:prose-invert max-w-none prose-p:my-1">
405 + <ReactMarkdown>{streamingContent}</ReactMarkdown>
406 + </div>
407 + ) : (
408 + <span className="flex items-center gap-1">
409 + {[0, 150, 300].map((d) => (
410 + <span key={d} className="w-1.5 h-1.5 bg-purple-500 rounded-full animate-bounce" style={{ animationDelay: `${d}ms` }} />
411 + ))}
412 + </span>
413 + )}
414 + </div>
415 + </div>
416 + )}
417 + <div ref={chatEndRef} />
418 + </div>
419 +
420 + <div className="p-4 border-t border-gray-200 dark:border-white/10">
421 + {chatHistory.length > 0 && (
422 + <button
423 + onClick={() => setChatHistory([])}
424 + className="flex items-center gap-1 text-xs text-gray-400 hover:text-red-500 transition-colors mb-2"
425 + >
426 + <Trash2 className="w-3 h-3" /> Effacer la conversation
427 + </button>
428 + )}
136 429 <div className="flex gap-2">
137 430 <input
138 431 type="text"
139 − value={query}
140 − onChange={(e) => setQuery(e.target.value)}
141 − onKeyDown={(e) => e.key === "Enter" && handleSearch()}
142 − placeholder="Rechercher avec l'IA..."
432 + value={chatInput}
433 + onChange={(e) => setChatInput(e.target.value)}
434 + onKeyDown={(e) => e.key === "Enter" && !e.shiftKey && handleChat()}
435 + placeholder="Votre message..."
143 436 className={`${inputClasses} flex-1`}
437 + disabled={isStreaming}
144 438 />
145 − <Button size="sm" onClick={handleSearch} loading={searching}>
146 − <Search className="w-4 h-4" />
147 − </Button>
439 + <button
440 + onClick={handleChat}
441 + disabled={isStreaming || !chatInput.trim()}
442 + className="min-w-[44px] min-h-[44px] flex items-center justify-center rounded-lg bg-purple-600 hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed text-white transition-colors"
443 + >
444 + {isStreaming ? <Loader2 className="w-4 h-4 animate-spin" /> : <Send className="w-4 h-4" />}
445 + </button>
446 + </div>
447 + </div>
448 + </div>
449 + )}
450 +
451 + {/* ── SEARCH TAB ── */}
452 + {tab === "search" && (
453 + <div className="flex-1 overflow-auto p-4 space-y-4">
454 + <div className="flex gap-2">
455 + <input
456 + type="text"
457 + value={query}
458 + onChange={(e) => setQuery(e.target.value)}
459 + onKeyDown={(e) => e.key === "Enter" && handleSearch()}
460 + placeholder="Ex: rapports financiers 2024..."
461 + className={`${inputClasses} flex-1`}
462 + />
463 + <button
464 + onClick={() => handleSearch()}
465 + disabled={searching || !query.trim()}
466 + className="min-w-[44px] min-h-[44px] flex items-center justify-center rounded-lg bg-purple-600 hover:bg-purple-700 disabled:opacity-50 text-white transition-colors"
467 + >
468 + {searching ? <Loader2 className="w-4 h-4 animate-spin" /> : <Search className="w-4 h-4" />}
469 + </button>
470 + </div>
471 +
472 + {/* Quick suggestions */}
473 + {!answer && results.length === 0 && !searching && (
474 + <div className="space-y-2">
475 + <p className="text-xs font-medium text-gray-500 uppercase tracking-wider">Suggestions</p>
476 + <div className="grid grid-cols-2 gap-2">
477 + {[
478 + { label: "Documents récents", query: "documents récents" },
479 + { label: "Images & Photos", query: "images et photos" },
480 + { label: "PDFs", query: "fichiers PDF" },
481 + { label: "Fichiers texte", query: "fichiers texte markdown" },
482 + ].map((s) => (
483 + <button
484 + key={s.query}
485 + onClick={() => quickSearch(s.query)}
486 + className="text-left p-3 text-xs text-gray-600 dark:text-gray-400 bg-gray-50 dark:bg-white/5 rounded-lg hover:bg-purple-50 dark:hover:bg-purple-500/10 border border-transparent hover:border-purple-200 dark:hover:border-purple-500/30 transition-colors"
487 + >
488 + <Search className="w-3 h-3 mb-1 text-purple-400" />
489 + {s.label}
490 + </button>
491 + ))}
492 + </div>
493 + </div>
494 + )}
495 +
496 + {answer && (
497 + <div className="bg-purple-50 dark:bg-purple-500/10 border border-purple-200 dark:border-purple-500/20 rounded-lg p-3.5">
498 + <p className="text-xs font-medium text-purple-600 dark:text-purple-400 mb-1 flex items-center gap-1">
499 + <Brain className="w-3 h-3" /> Synthèse IA
500 + </p>
501 + <p className="text-sm text-purple-800 dark:text-purple-300">{answer}</p>
502 + </div>
503 + )}
504 +
505 + {results.length > 0 && (
506 + <div className="space-y-2">
507 + <p className="text-xs font-medium text-gray-500">{results.length} résultat(s) trouvé(s)</p>
508 + {results.map((r) => (
509 + <div
510 + key={r.fileId}
511 + className="p-3.5 bg-gray-50 dark:bg-white/5 rounded-lg border border-gray-200 dark:border-white/10"
512 + >
513 + <div className="flex items-start gap-3">
514 + <span className="text-base flex-shrink-0 mt-0.5">{mimeIcon(r.mimeType || "")}</span>
515 + <div className="flex-1 min-w-0">
516 + <div className="flex items-center gap-2 mb-0.5 flex-wrap">
517 + <p className="text-sm font-medium text-gray-900 dark:text-white truncate">{r.name}</p>
518 + <span className={`px-1.5 py-0.5 rounded text-[10px] font-medium flex-shrink-0 ${relevanceColors[r.relevance] || relevanceColors.low}`}>
519 + {r.relevance}
520 + </span>
521 + </div>
522 + {r.folder && <p className="text-[10px] text-gray-400 mb-0.5">📁 {r.folder}</p>}
523 + <p className="text-xs text-gray-500">{r.snippet}</p>
524 + </div>
525 + </div>
526 + {/* Actions on search results */}
527 + <div className="flex gap-2 mt-2.5 pt-2.5 border-t border-gray-100 dark:border-white/10">
528 + <button
529 + onClick={() => openInAnalyser({
530 + id: r.fileId,
531 + name: r.name,
532 + mimeType: r.mimeType || "",
533 + size: 0,
534 + })}
535 + className="flex items-center gap-1.5 px-2.5 py-1.5 text-[11px] font-medium text-purple-600 dark:text-purple-400 bg-purple-50 dark:bg-purple-500/10 rounded-lg hover:bg-purple-100 dark:hover:bg-purple-500/20 transition-colors"
536 + >
537 + <FlaskConical className="w-3 h-3" /> Analyser
538 + </button>
539 + <button
540 + onClick={() => window.open(`/api/files/${r.fileId}/download`, "_blank")}
541 + className="flex items-center gap-1.5 px-2.5 py-1.5 text-[11px] font-medium text-gray-500 dark:text-gray-400 bg-gray-100 dark:bg-white/10 rounded-lg hover:bg-gray-200 dark:hover:bg-white/20 transition-colors"
542 + >
543 + <Download className="w-3 h-3" /> Télécharger
544 + </button>
545 + <button
546 + onClick={() => {
547 + setChatInput(`Résume le fichier ${r.name}`);
548 + setTab("chat");
549 + }}
550 + className="flex items-center gap-1.5 px-2.5 py-1.5 text-[11px] font-medium text-gray-500 dark:text-gray-400 bg-gray-100 dark:bg-white/10 rounded-lg hover:bg-gray-200 dark:hover:bg-white/20 transition-colors"
551 + >
552 + <MessageSquare className="w-3 h-3" /> Résumer
553 + </button>
554 + </div>
555 + </div>
556 + ))}
148 557 </div>
558 + )}
559 + </div>
560 + )}
149 561
150 − {answer && (
151 − <div className="bg-purple-50 dark:bg-purple-500/10 border border-purple-200 dark:border-purple-500/20 rounded-lg p-3.5">
152 − <p className="text-sm text-purple-800 dark:text-purple-300">{answer}</p>
562 + {/* ── ANALYSE TAB ── */}
563 + {tab === "analyse" && (
564 + <div className="flex-1 overflow-auto p-4 space-y-4">
565 + {/* File picker */}
566 + <div>
567 + <p className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-2">Fichier à analyser</p>
568 + <button
569 + onClick={() => setShowFilePicker(!showFilePicker)}
570 + className="w-full flex items-center justify-between px-3 py-2.5 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-lg text-sm text-left hover:border-purple-300 dark:hover:border-purple-500/40 transition-colors"
571 + >
572 + <span className={selectedFile ? "text-gray-900 dark:text-white" : "text-gray-400"}>
573 + {selectedFile ? (
574 + <span className="flex items-center gap-2">
575 + <span>{mimeIcon(selectedFile.mimeType)}</span>
576 + <span className="truncate max-w-[240px]">{selectedFile.name}</span>
577 + </span>
578 + ) : "Choisir un fichier..."}
579 + </span>
580 + <ChevronDown className={`w-4 h-4 text-gray-400 transition-transform flex-shrink-0 ${showFilePicker ? "rotate-180" : ""}`} />
581 + </button>
582 +
583 + {showFilePicker && (
584 + <div className="mt-1 border border-gray-200 dark:border-white/10 rounded-lg overflow-hidden bg-white dark:bg-gray-800">
585 + <div className="p-2 border-b border-gray-100 dark:border-white/10">
586 + <input
587 + type="text"
588 + value={analyserQuery}
589 + onChange={(e) => {
590 + setAnalyserQuery(e.target.value);
591 + fetchAnalyserFiles(e.target.value);
592 + }}
593 + placeholder="Filtrer par nom..."
594 + className="w-full bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded px-2 py-1.5 text-xs text-gray-900 dark:text-white focus:outline-none"
595 + autoFocus
596 + />
597 + </div>
598 + <div className="max-h-56 overflow-auto">
599 + {analyserLoading && (
600 + <div className="flex items-center justify-center p-4 gap-2 text-xs text-gray-400">
601 + <Loader2 className="w-4 h-4 animate-spin" /> Chargement...
602 + </div>
603 + )}
604 + {analyserError && (
605 + <div className="flex items-center gap-2 p-3 text-xs text-red-500">
606 + <AlertCircle className="w-4 h-4" /> Impossible de charger les fichiers.
607 + </div>
608 + )}
609 + {!analyserLoading && !analyserError && analyserFiles.length === 0 && (
610 + <p className="p-3 text-xs text-gray-400 text-center">Aucun fichier trouvé</p>
611 + )}
612 + {!analyserLoading && analyserFiles.map((f) => (
613 + <button
614 + key={f.id}
615 + onClick={() => {
616 + setSelectedFile(f);
617 + setShowFilePicker(false);
618 + setAnalyserResult(null);
619 + setAnalyserQuery("");
620 + }}
621 + className={`w-full flex items-center gap-2 px-3 py-2.5 text-sm text-left transition-colors border-b border-gray-50 dark:border-white/5 last:border-0 ${
622 + selectedFile?.id === f.id
623 + ? "bg-purple-50 dark:bg-purple-500/10"
624 + : "hover:bg-gray-50 dark:hover:bg-white/5"
625 + }`}
626 + >
627 + <span className="text-base flex-shrink-0">{mimeIcon(f.mimeType)}</span>
628 + <div className="min-w-0 flex-1">
629 + <p className="text-xs font-medium text-gray-900 dark:text-white truncate">{f.name}</p>
630 + {f.folder && <p className="text-[10px] text-gray-400">📁 {f.folder.name}</p>}
631 + </div>
632 + {f.aiDescription && (
633 + <span className="w-2 h-2 rounded-full bg-purple-400 flex-shrink-0" title="Déjà analysé" />
634 + )}
635 + </button>
636 + ))}
637 + </div>
153 638 </div>
154 639 )}
640 + </div>
155 641
156 − {results.map((r) => (
157 − <div key={r.fileId} className="flex items-start gap-3 p-3.5 bg-gray-50 dark:bg-white/5 rounded-lg">
158 − <FileText className="w-4 h-4 text-gray-400 mt-0.5 flex-shrink-0" />
159 − <div className="flex-1 min-w-0">
160 − <div className="flex items-center gap-2">
161 − <p className="text-sm font-medium text-gray-900 dark:text-white truncate">{r.name}</p>
162 − <span className={`px-1.5 py-0.5 rounded text-[10px] font-medium flex-shrink-0 ${relevanceColors[r.relevance] || relevanceColors.low}`}>
163 − {r.relevance}
164 − </span>
642 + {/* Analysis actions */}
643 + {selectedFile && (
644 + <div className="space-y-3">
645 + <div className="p-3 bg-purple-50 dark:bg-purple-500/10 border border-purple-200 dark:border-purple-500/20 rounded-lg">
646 + <div className="flex items-start justify-between gap-2">
647 + <div className="min-w-0">
648 + <p className="text-xs text-purple-600 dark:text-purple-400 font-medium mb-0.5">Fichier sélectionné</p>
649 + <p className="text-sm font-medium text-purple-900 dark:text-purple-200 truncate">{selectedFile.name}</p>
650 + {selectedFile.folder && <p className="text-xs text-purple-400 mt-0.5">📁 {selectedFile.folder.name}</p>}
165 651 </div>
166 − <p className="text-xs text-gray-500 mt-0.5">{r.snippet}</p>
652 + <button
653 + onClick={() => window.open(`/api/files/${selectedFile.id}/download`, "_blank")}
654 + className="flex-shrink-0 p-1.5 rounded-lg text-purple-400 hover:text-purple-600 hover:bg-purple-100 dark:hover:bg-purple-500/20 transition-colors"
655 + title="Télécharger"
656 + >
657 + <Download className="w-3.5 h-3.5" />
658 + </button>
167 659 </div>
660 + {selectedFile.aiDescription && (
661 + <p className="text-xs text-purple-600 dark:text-purple-400 mt-2 border-t border-purple-200 dark:border-purple-500/30 pt-2">{selectedFile.aiDescription}</p>
662 + )}
663 + {selectedFile.aiTags && (() => {
664 + try {
665 + const tags = JSON.parse(selectedFile.aiTags as string) as string[];
666 + return (
667 + <div className="flex flex-wrap gap-1 mt-1.5">
668 + {tags.map((t) => (
669 + <span key={t} className="px-1.5 py-0.5 bg-purple-200 dark:bg-purple-500/30 text-purple-700 dark:text-purple-300 rounded text-[10px]">{t}</span>
670 + ))}
671 + </div>
672 + );
673 + } catch { return null; }
674 + })()}
168 675 </div>
169 − ))}
170 676
171 − {/* Quick actions */}
172 − <div className="pt-2 space-y-2">
173 − <p className="text-xs text-gray-500 font-medium uppercase tracking-wider">Actions rapides</p>
174 677 <div className="grid grid-cols-2 gap-2">
175 − <button onClick={() => { setQuery("documents récents"); handleSearch(); }} className="text-left p-3 text-xs text-gray-600 dark:text-gray-400 bg-gray-50 dark:bg-white/5 rounded-lg hover:bg-gray-100 dark:hover:bg-white/10">
176 − <FileText className="w-3 h-3 mb-1" /> Documents récents
678 + {(selectedFile.mimeType.startsWith("image/") || selectedFile.mimeType === "application/pdf") && (
679 + <button
680 + onClick={() => handleAnalyse("ocr")}
681 + disabled={analysing}
682 + className="flex items-center justify-center gap-2 px-3 py-2.5 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-lg text-xs font-medium text-gray-700 dark:text-gray-300 hover:border-purple-300 dark:hover:border-purple-500/40 disabled:opacity-50 transition-colors"
683 + >
684 + <ScanText className="w-3.5 h-3.5 text-purple-500" /> OCR
685 + </button>
686 + )}
687 + <button
688 + onClick={() => handleAnalyse("describe")}
689 + disabled={analysing}
690 + className="flex items-center justify-center gap-2 px-3 py-2.5 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-lg text-xs font-medium text-gray-700 dark:text-gray-300 hover:border-purple-300 dark:hover:border-purple-500/40 disabled:opacity-50 transition-colors"
691 + >
692 + <Info className="w-3.5 h-3.5 text-purple-500" /> Décrire
693 + </button>
694 + <button
695 + onClick={() => handleAnalyse("tag")}
696 + disabled={analysing}
697 + className="flex items-center justify-center gap-2 px-3 py-2.5 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-lg text-xs font-medium text-gray-700 dark:text-gray-300 hover:border-purple-300 dark:hover:border-purple-500/40 disabled:opacity-50 transition-colors"
698 + >
699 + <Tag className="w-3.5 h-3.5 text-purple-500" /> Auto-tagger
177 700 </button>
178 − <button onClick={() => { setQuery("images et photos"); handleSearch(); }} className="text-left p-3 text-xs text-gray-600 dark:text-gray-400 bg-gray-50 dark:bg-white/5 rounded-lg hover:bg-gray-100 dark:hover:bg-white/10">
179 − <Tag className="w-3 h-3 mb-1" /> Images & Photos
701 + <button
702 + onClick={handleAnalyseAll}
703 + disabled={analysing}
704 + className="flex items-center justify-center gap-2 px-3 py-2.5 bg-purple-600 hover:bg-purple-700 border border-transparent rounded-lg text-xs font-medium text-white disabled:opacity-50 transition-colors col-span-full"
705 + >
706 + {analysing ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Zap className="w-3.5 h-3.5" />}
707 + Analyse complète
180 708 </button>
181 709 </div>
182 − </div>
183 − </div>
184 − ) : (
185 − <div className="flex flex-col h-full">
186 − <div className="flex-1 space-y-3 mb-4">
187 − {chatHistory.length === 0 && (
188 − <div className="text-center text-gray-500 py-8">
189 − <Brain className="w-8 h-8 mx-auto mb-2 text-gray-300 dark:text-gray-600" />
190 − <p className="text-sm">Posez une question sur vos fichiers</p>
710 +
711 + {/* Also: ask Claude about this file */}
712 + <button
713 + onClick={() => {
714 + setChatInput(`Résume le fichier ${selectedFile.name}`);
715 + setTab("chat");
716 + }}
717 + className="w-full flex items-center justify-center gap-2 px-3 py-2 text-xs text-purple-600 dark:text-purple-400 border border-purple-200 dark:border-purple-500/30 rounded-lg hover:bg-purple-50 dark:hover:bg-purple-500/10 transition-colors"
718 + >
719 + <MessageSquare className="w-3.5 h-3.5" />
720 + Demander à Claude de résumer ce fichier
721 + </button>
722 +
723 + {analysing && !analyserResult && (
724 + <div className="flex items-center justify-center py-6 gap-2 text-sm text-gray-500">
725 + <Loader2 className="w-4 h-4 animate-spin text-purple-500" /> Analyse en cours...
191 726 </div>
192 727 )}
193 − {chatHistory.map((msg, i) => (
194 − <div key={i} className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}>
195 − <div className={`max-w-[80%] px-3 py-2 rounded-lg text-sm ${
196 − msg.role === "user"
197 − ? "bg-purple-600 text-white"
198 − : "bg-gray-100 dark:bg-white/10 text-gray-900 dark:text-gray-200"
199 − }`}>
200 − {msg.content}
201 − </div>
202 − </div>
203 − ))}
204 − {chatting && (
205 − <div className="flex justify-start">
206 − <div className="bg-gray-100 dark:bg-white/10 px-3 py-2 rounded-lg">
207 − <Loader2 className="w-4 h-4 animate-spin text-purple-500" />
728 +
729 + {analyserResult && (
730 + <div className="bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-lg p-3.5">
731 + <p className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-2">{analyserResult.type}</p>
732 + <div className="prose prose-sm dark:prose-invert max-w-none prose-p:my-1">
733 + <ReactMarkdown>{analyserResult.content}</ReactMarkdown>
208 734 </div>
209 735 </div>
210 736 )}
211 737 </div>
212 − </div>
213 − )}
214 − </div>
738 + )}
215 739
216 − {/* Chat input */}
217 − {tab === "chat" && (
218 − <div className="p-4 border-t border-gray-200 dark:border-white/10">
219 − <div className="flex gap-2">
220 − <input
221 − type="text"
222 − value={chatMessage}
223 − onChange={(e) => setChatMessage(e.target.value)}
224 − onKeyDown={(e) => e.key === "Enter" && handleChat()}
225 − placeholder="Votre message..."
226 − className={`${inputClasses} flex-1`}
227 − />
228 − <Button size="sm" onClick={handleChat} loading={chatting}>
229 − <MessageSquare className="w-4 h-4" />
230 − </Button>
231 − </div>
740 + {!selectedFile && !showFilePicker && (
741 + <div className="text-center py-8">
742 + <File className="w-10 h-10 mx-auto mb-3 text-gray-300 dark:text-gray-600" />
743 + <p className="text-sm text-gray-500 dark:text-gray-400">
744 + Sélectionnez un fichier ci-dessus pour l&apos;analyser
745 + </p>
746 + <p className="text-xs text-gray-400 mt-1">OCR, description, tags automatiques</p>
747 + </div>
748 + )}
232 749 </div>
233 750 )}
234 751 </motion.div>
added components/auth/ChangePasswordModal.tsx +149 −0
@@ -0,0 +1,149 @@
1 +"use client";
2 +
3 +import { useState } from "react";
4 +import { Eye, EyeOff, Lock, Check } from "lucide-react";
5 +import Modal from "../ui/Modal";
6 +import Button from "../ui/Button";
7 +import toast from "react-hot-toast";
8 +
9 +interface ChangePasswordModalProps {
10 + isOpen: boolean;
11 + onClose: () => void;
12 +}
13 +
14 +export default function ChangePasswordModal({ isOpen, onClose }: ChangePasswordModalProps) {
15 + const [currentPassword, setCurrentPassword] = useState("");
16 + const [newPassword, setNewPassword] = useState("");
17 + const [confirmPassword, setConfirmPassword] = useState("");
18 + const [showCurrent, setShowCurrent] = useState(false);
19 + const [showNew, setShowNew] = useState(false);
20 + const [loading, setLoading] = useState(false);
21 +
22 + const handleClose = () => {
23 + setCurrentPassword("");
24 + setNewPassword("");
25 + setConfirmPassword("");
26 + setShowCurrent(false);
27 + setShowNew(false);
28 + onClose();
29 + };
30 +
31 + const passwordsMatch = newPassword && confirmPassword && newPassword === confirmPassword;
32 + const newPasswordValid = newPassword.length >= 6;
33 + const canSubmit = currentPassword && newPasswordValid && passwordsMatch;
34 +
35 + const handleSubmit = async () => {
36 + if (!canSubmit) return;
37 + setLoading(true);
38 + try {
39 + const res = await fetch("/api/auth/change-password", {
40 + method: "POST",
41 + headers: { "Content-Type": "application/json" },
42 + body: JSON.stringify({ currentPassword, newPassword }),
43 + });
44 + const data = await res.json();
45 + if (res.ok) {
46 + toast.success("Mot de passe modifié avec succès");
47 + handleClose();
48 + } else {
49 + toast.error(data.error || "Erreur");
50 + }
51 + } catch {
52 + toast.error("Erreur réseau");
53 + } finally {
54 + setLoading(false);
55 + }
56 + };
57 +
58 + const inputBase =
59 + "w-full bg-gray-100 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-lg pl-10 pr-10 py-2.5 text-gray-900 dark:text-white text-sm placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:border-gray-400 dark:focus:border-white/20";
60 +
61 + return (
62 + <Modal isOpen={isOpen} onClose={handleClose} title="Changer le mot de passe">
63 + {/* Current password */}
64 + <div className="mb-3">
65 + <label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1.5">
66 + Mot de passe actuel
67 + </label>
68 + <div className="relative">
69 + <Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
70 + <input
71 + type={showCurrent ? "text" : "password"}
72 + value={currentPassword}
73 + onChange={(e) => setCurrentPassword(e.target.value)}
74 + placeholder="••••••••"
75 + className={inputBase}
76 + autoFocus
77 + />
78 + <button
79 + type="button"
80 + onClick={() => setShowCurrent(!showCurrent)}
81 + className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
82 + >
83 + {showCurrent ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
84 + </button>
85 + </div>
86 + </div>
87 +
88 + {/* New password */}
89 + <div className="mb-3">
90 + <label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1.5">
91 + Nouveau mot de passe
92 + </label>
93 + <div className="relative">
94 + <Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
95 + <input
96 + type={showNew ? "text" : "password"}
97 + value={newPassword}
98 + onChange={(e) => setNewPassword(e.target.value)}
99 + placeholder="Min. 6 caractères"
100 + className={`${inputBase} ${newPassword && !newPasswordValid ? "border-red-400 dark:border-red-500" : ""}`}
101 + />
102 + <button
103 + type="button"
104 + onClick={() => setShowNew(!showNew)}
105 + className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
106 + >
107 + {showNew ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
108 + </button>
109 + </div>
110 + {newPassword && !newPasswordValid && (
111 + <p className="text-xs text-red-500 mt-1">Au moins 6 caractères requis</p>
112 + )}
113 + </div>
114 +
115 + {/* Confirm new password */}
116 + <div className="mb-5">
117 + <label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1.5">
118 + Confirmer le nouveau mot de passe
119 + </label>
120 + <div className="relative">
121 + <Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
122 + <input
123 + type="password"
124 + value={confirmPassword}
125 + onChange={(e) => setConfirmPassword(e.target.value)}
126 + onKeyDown={(e) => e.key === "Enter" && handleSubmit()}
127 + placeholder="••••••••"
128 + className={`${inputBase} ${confirmPassword && !passwordsMatch ? "border-red-400 dark:border-red-500" : confirmPassword && passwordsMatch ? "border-green-400 dark:border-green-500" : ""}`}
129 + />
130 + {confirmPassword && passwordsMatch && (
131 + <Check className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-green-500" />
132 + )}
133 + </div>
134 + {confirmPassword && !passwordsMatch && (
135 + <p className="text-xs text-red-500 mt-1">Les mots de passe ne correspondent pas</p>
136 + )}
137 + </div>
138 +
139 + <div className="flex gap-2 justify-end">
140 + <Button variant="ghost" onClick={handleClose}>
141 + Annuler
142 + </Button>
143 + <Button onClick={handleSubmit} loading={loading} disabled={!canSubmit}>
144 + Changer le mot de passe
145 + </Button>
146 + </div>
147 + </Modal>
148 + );
149 +}
modified components/files/FileCard.tsx +38 −24
@@ -45,12 +45,26 @@ const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
45 45 presentation: FileText,
46 46 };
47 47
48 +const iconColorMap: Record<string, string> = {
49 + image: "text-emerald-400 dark:text-emerald-500",
50 + video: "text-purple-400 dark:text-purple-500",
51 + audio: "text-pink-400 dark:text-pink-500",
52 + pdf: "text-red-400 dark:text-red-500",
53 + code: "text-amber-400 dark:text-amber-500",
54 + spreadsheet: "text-green-500 dark:text-green-400",
55 + archive: "text-orange-400 dark:text-orange-500",
56 + document: "text-blue-500 dark:text-blue-400",
57 + presentation: "text-orange-500 dark:text-orange-400",
58 + file: "text-gray-400 dark:text-gray-500",
59 +};
60 +
48 61 export default function FileCard({ file, onPreview, onRefresh, viewMode }: FileCardProps) {
49 62 const [showActions, setShowActions] = useState(false);
50 63 const menuRef = useRef<HTMLDivElement>(null);
51 64
52 65 const iconType = getFileIcon(file.mimeType);
53 66 const Icon = iconMap[iconType] || File;
67 + const iconColor = iconColorMap[iconType] || "text-gray-400 dark:text-gray-500";
54 68
55 69 useEffect(() => {
56 70 function handleClick(e: MouseEvent) {
@@ -72,7 +86,7 @@ export default function FileCard({ file, onPreview, onRefresh, viewMode }: FileC
72 86 className="flex items-center gap-3 px-3 py-3.5 hover:bg-gray-100 dark:hover:bg-white/5 rounded-lg cursor-pointer group transition-colors"
73 87 onClick={() => onPreview(file)}
74 88 >
75 − <Icon className="w-5 h-5 text-gray-400 flex-shrink-0" />
89 + <Icon className={`w-5 h-5 flex-shrink-0 ${iconColor}`} />
76 90 <span className="text-sm text-gray-700 dark:text-gray-200 flex-1 truncate">{file.name}</span>
77 91 <span className="text-sm text-gray-500 hidden sm:block">{formatFileSize(file.size)}</span>
78 92 <span className="text-xs text-gray-500 hidden md:block">{formatDate(file.createdAt)}</span>
@@ -102,11 +116,11 @@ export default function FileCard({ file, onPreview, onRefresh, viewMode }: FileC
102 116 <motion.div
103 117 initial={{ opacity: 0, scale: 0.95 }}
104 118 animate={{ opacity: 1, scale: 1 }}
105 − className="group relative bg-white dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl hover:border-gray-300 dark:hover:border-white/20 transition-colors cursor-pointer shadow-sm dark:shadow-none"
119 + className={`group relative bg-white dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl hover:border-gray-300 dark:hover:border-white/20 transition-colors cursor-pointer shadow-sm dark:shadow-none ${showActions ? "z-20" : ""}`}
106 120 onClick={() => onPreview(file)}
107 121 >
108 − {/* Preview thumbnail */}
109 − <div className="aspect-square flex items-center justify-center bg-gray-50 dark:bg-white/[0.02] relative rounded-t-xl overflow-hidden">
122 + {/* Preview thumbnail — overflow-hidden stays here for image crop only */}
123 + <div className="aspect-square flex items-center justify-center bg-gray-50 dark:bg-white/[0.02] rounded-t-xl overflow-hidden">
110 124 {isImage ? (
111 125 <img
112 126 src={`/api/files/${file.id}/preview`}
@@ -115,28 +129,28 @@ export default function FileCard({ file, onPreview, onRefresh, viewMode }: FileC
115 129 loading="lazy"
116 130 />
117 131 ) : (
118 − <Icon className="w-12 h-12 text-gray-300 dark:text-gray-600" />
132 + <Icon className={`w-12 h-12 ${iconColor}`} />
119 133 )}
134 + </div>
120 135
121 − {/* Actions button */}
122 − <div className="absolute top-2 right-2" ref={menuRef}>
123 − <button
124 − onClick={(e) => {
125 − e.stopPropagation();
126 − setShowActions(!showActions);
127 − }}
128 − className="p-2 bg-white/80 dark:bg-black/50 rounded-lg text-gray-500 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white sm:opacity-0 sm:group-hover:opacity-100 transition-opacity backdrop-blur-sm min-w-[40px] min-h-[40px] flex items-center justify-center"
129 − >
130 − <MoreVertical className="w-4 h-4" />
131 − </button>
132 − {showActions && (
133 − <FileActions
134 − file={file}
135 − onClose={() => setShowActions(false)}
136 − onRefresh={onRefresh}
137 − />
138 − )}
139 − </div>
136 + {/* Actions button — outside overflow-hidden so dropdown is never clipped */}
137 + <div className="absolute top-2 right-2 z-20" ref={menuRef}>
138 + <button
139 + onClick={(e) => {
140 + e.stopPropagation();
141 + setShowActions(!showActions);
142 + }}
143 + className="p-2 bg-white/80 dark:bg-black/50 rounded-lg text-gray-500 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white sm:opacity-0 sm:group-hover:opacity-100 transition-opacity backdrop-blur-sm min-w-[40px] min-h-[40px] flex items-center justify-center"
144 + >
145 + <MoreVertical className="w-4 h-4" />
146 + </button>
147 + {showActions && (
148 + <FileActions
149 + file={file}
150 + onClose={() => setShowActions(false)}
151 + onRefresh={onRefresh}
152 + />
153 + )}
140 154 </div>
141 155
142 156 {/* Info */}
modified components/files/FileExplorer.tsx +9 −1
@@ -1,12 +1,13 @@
1 1 "use client";
2 2
3 3 import { useState, useEffect, useCallback } from "react";
4 −import { Grid, List, Plus, Upload, CheckSquare, Square } from "lucide-react";
4 +import { Grid, List, Plus, Upload, CheckSquare, Square, Users } from "lucide-react";
5 5 import FileCard from "./FileCard";
6 6 import FileUpload from "./FileUpload";
7 7 import PreviewEngine from "../preview/PreviewEngine";
8 8 import FolderCard from "../folders/FolderCard";
9 9 import CreateFolderModal from "../folders/CreateFolderModal";
10 +import CreateSharedFolderModal from "../folders/CreateSharedFolderModal";
10 11 import SearchBar from "../ui/SearchBar";
11 12 import Button from "../ui/Button";
12 13 import BulkActions from "./BulkActions";
@@ -25,6 +26,7 @@ interface FolderData {
25 26 id: string;
26 27 name: string;
27 28 parentId: string | null;
29 + sharedSpaceId?: string | null;
28 30 createdAt: string;
29 31 _count?: { files: number; children: number };
30 32 }
@@ -39,6 +41,7 @@ export default function FileExplorer({ folderId }: FileExplorerProps) {
39 41 const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
40 42 const [showUpload, setShowUpload] = useState(false);
41 43 const [showCreateFolder, setShowCreateFolder] = useState(false);
44 + const [showCreateSharedFolder, setShowCreateSharedFolder] = useState(false);
42 45 const [previewFile, setPreviewFile] = useState<FileData | null>(null);
43 46 const [searchQuery, setSearchQuery] = useState("");
44 47 const [loading, setLoading] = useState(true);
@@ -124,6 +127,10 @@ export default function FileExplorer({ folderId }: FileExplorerProps) {
124 127 <Plus className="w-4 h-4" />
125 128 <span className="hidden sm:inline">Dossier</span>
126 129 </Button>
130 + <Button variant="ghost" size="sm" onClick={() => setShowCreateSharedFolder(true)} className="min-w-[40px] min-h-[40px] text-purple-600 dark:text-purple-400 hover:text-purple-700 dark:hover:text-purple-300">
131 + <Users className="w-4 h-4" />
132 + <span className="hidden sm:inline">Partagé</span>
133 + </Button>
127 134 <Button size="sm" onClick={() => setShowUpload(!showUpload)} className="min-w-[40px] min-h-[40px]">
128 135 <Upload className="w-4 h-4" />
129 136 <span className="hidden sm:inline">Upload</span>
@@ -228,6 +235,7 @@ export default function FileExplorer({ folderId }: FileExplorerProps) {
228 235 )}
229 236
230 237 <CreateFolderModal isOpen={showCreateFolder} onClose={() => setShowCreateFolder(false)} parentId={folderId || null} onCreated={fetchData} />
238 + <CreateSharedFolderModal isOpen={showCreateSharedFolder} onClose={() => setShowCreateSharedFolder(false)} onCreated={fetchData} />
231 239 </div>
232 240 );
233 241 }
added components/folders/CreateSharedFolderModal.tsx +222 −0
@@ -0,0 +1,222 @@
1 +"use client";
2 +
3 +import { useState, useEffect } from "react";
4 +import { Users, Check, Search } from "lucide-react";
5 +import Modal from "../ui/Modal";
6 +import Button from "../ui/Button";
7 +import toast from "react-hot-toast";
8 +import { useRouter } from "next/navigation";
9 +
10 +interface UserOption {
11 + id: string;
12 + name: string | null;
13 + username: string | null;
14 +}
15 +
16 +interface CreateSharedFolderModalProps {
17 + isOpen: boolean;
18 + onClose: () => void;
19 + onCreated: () => void;
20 +}
21 +
22 +const inputClasses =
23 + "w-full bg-gray-100 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-lg px-4 py-2.5 text-gray-900 dark:text-white text-sm placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:border-purple-400 dark:focus:border-purple-500";
24 +
25 +export default function CreateSharedFolderModal({
26 + isOpen,
27 + onClose,
28 + onCreated,
29 +}: CreateSharedFolderModalProps) {
30 + const router = useRouter();
31 + const [name, setName] = useState("");
32 + const [users, setUsers] = useState<UserOption[]>([]);
33 + const [selectedIds, setSelectedIds] = useState<string[]>([]);
34 + const [userSearch, setUserSearch] = useState("");
35 + const [loading, setLoading] = useState(false);
36 + const [loadingUsers, setLoadingUsers] = useState(false);
37 +
38 + useEffect(() => {
39 + if (!isOpen) return;
40 + setLoadingUsers(true);
41 + fetch("/api/users/public")
42 + .then((r) => r.ok ? r.json() : [])
43 + .then((data) => setUsers(Array.isArray(data) ? data : []))
44 + .finally(() => setLoadingUsers(false));
45 + }, [isOpen]);
46 +
47 + const handleClose = () => {
48 + setName("");
49 + setSelectedIds([]);
50 + setUserSearch("");
51 + onClose();
52 + };
53 +
54 + const toggleUser = (id: string) => {
55 + setSelectedIds((prev) =>
56 + prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
57 + );
58 + };
59 +
60 + const handleCreate = async () => {
61 + if (!name.trim()) return;
62 + setLoading(true);
63 + try {
64 + const res = await fetch("/api/shared-spaces", {
65 + method: "POST",
66 + headers: { "Content-Type": "application/json" },
67 + body: JSON.stringify({ name: name.trim(), memberIds: selectedIds }),
68 + });
69 +
70 + if (res.ok) {
71 + const space = await res.json();
72 + toast.success(`Dossier partagé "${name}" créé`);
73 + handleClose();
74 + onCreated();
75 + // Navigate to the new shared space
76 + router.push(`/dashboard/shared-space/${space.id}`);
77 + } else {
78 + const data = await res.json();
79 + toast.error(data.error || "Erreur lors de la création");
80 + }
81 + } catch {
82 + toast.error("Erreur réseau");
83 + } finally {
84 + setLoading(false);
85 + }
86 + };
87 +
88 + const filteredUsers = users.filter((u) => {
89 + const q = userSearch.toLowerCase();
90 + return (
91 + u.username?.toLowerCase().includes(q) ||
92 + u.name?.toLowerCase().includes(q)
93 + );
94 + });
95 +
96 + return (
97 + <Modal isOpen={isOpen} onClose={handleClose} title="Nouveau dossier partagé">
98 + {/* Name */}
99 + <div className="mb-4">
100 + <label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1.5">
101 + Nom du dossier
102 + </label>
103 + <input
104 + type="text"
105 + value={name}
106 + onChange={(e) => setName(e.target.value)}
107 + onKeyDown={(e) => e.key === "Enter" && handleCreate()}
108 + placeholder="Ex: Projet Alpha, Famille..."
109 + className={inputClasses}
110 + autoFocus
111 + />
112 + </div>
113 +
114 + {/* User picker */}
115 + <div className="mb-4">
116 + <label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1.5 flex items-center gap-1.5">
117 + <Users className="w-3.5 h-3.5" />
118 + Partager avec
119 + {selectedIds.length > 0 && (
120 + <span className="ml-auto text-purple-600 dark:text-purple-400 font-semibold">
121 + {selectedIds.length} sélectionné(s)
122 + </span>
123 + )}
124 + </label>
125 +
126 + {loadingUsers ? (
127 + <div className="flex items-center justify-center py-6 text-sm text-gray-400">
128 + <div className="w-4 h-4 border-2 border-gray-300 dark:border-white/20 border-t-purple-500 rounded-full animate-spin mr-2" />
129 + Chargement des utilisateurs...
130 + </div>
131 + ) : users.length === 0 ? (
132 + <p className="text-xs text-gray-400 text-center py-4">
133 + Aucun autre utilisateur disponible.
134 + </p>
135 + ) : (
136 + <div className="border border-gray-200 dark:border-white/10 rounded-lg overflow-hidden">
137 + {/* Search within users */}
138 + {users.length > 4 && (
139 + <div className="p-2 border-b border-gray-100 dark:border-white/10 relative">
140 + <Search className="w-3.5 h-3.5 absolute left-4 top-1/2 -translate-y-1/2 text-gray-400" />
141 + <input
142 + type="text"
143 + value={userSearch}
144 + onChange={(e) => setUserSearch(e.target.value)}
145 + placeholder="Filtrer les utilisateurs..."
146 + className="w-full pl-7 pr-3 py-1.5 text-xs bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded focus:outline-none text-gray-900 dark:text-white"
147 + />
148 + </div>
149 + )}
150 +
151 + <div className="max-h-44 overflow-auto">
152 + {filteredUsers.length === 0 && (
153 + <p className="text-xs text-gray-400 text-center py-3">Aucun résultat</p>
154 + )}
155 + {filteredUsers.map((u) => {
156 + const isSelected = selectedIds.includes(u.id);
157 + const displayName = u.name || u.username || u.id;
158 + const initial = displayName[0].toUpperCase();
159 + return (
160 + <button
161 + key={u.id}
162 + onClick={() => toggleUser(u.id)}
163 + className={`w-full flex items-center gap-3 px-3 py-2.5 text-left transition-colors border-b border-gray-50 dark:border-white/5 last:border-0 ${
164 + isSelected
165 + ? "bg-purple-50 dark:bg-purple-500/10"
166 + : "hover:bg-gray-50 dark:hover:bg-white/5"
167 + }`}
168 + >
169 + {/* Avatar */}
170 + <div className={`w-7 h-7 rounded-full flex items-center justify-center flex-shrink-0 text-xs font-semibold ${
171 + isSelected
172 + ? "bg-purple-500 text-white"
173 + : "bg-gray-200 dark:bg-white/10 text-gray-600 dark:text-gray-300"
174 + }`}>
175 + {isSelected ? <Check className="w-3.5 h-3.5" /> : initial}
176 + </div>
177 +
178 + <div className="flex-1 min-w-0">
179 + {u.name && <p className="text-sm font-medium text-gray-900 dark:text-white truncate">{u.name}</p>}
180 + <p className={`text-xs truncate ${u.name ? "text-gray-400" : "text-sm font-medium text-gray-900 dark:text-white"}`}>
181 + @{u.username}
182 + </p>
183 + </div>
184 +
185 + {/* Checkbox indicator */}
186 + <div className={`w-4 h-4 rounded border flex-shrink-0 flex items-center justify-center transition-colors ${
187 + isSelected
188 + ? "bg-purple-500 border-purple-500"
189 + : "border-gray-300 dark:border-white/20"
190 + }`}>
191 + {isSelected && <Check className="w-3 h-3 text-white" />}
192 + </div>
193 + </button>
194 + );
195 + })}
196 + </div>
197 + </div>
198 + )}
199 + </div>
200 +
201 + {/* Info note */}
202 + <p className="text-xs text-gray-400 dark:text-gray-500 mb-4 flex items-start gap-1.5">
203 + <span className="text-purple-400 mt-0.5">ℹ</span>
204 + Vous serez automatiquement membre. Le dossier apparaîtra dans la sidebar de chaque membre.
205 + </p>
206 +
207 + <div className="flex gap-2 justify-end">
208 + <Button variant="ghost" onClick={handleClose}>
209 + Annuler
210 + </Button>
211 + <Button
212 + onClick={handleCreate}
213 + loading={loading}
214 + disabled={!name.trim()}
215 + >
216 + <Users className="w-4 h-4" />
217 + Créer le dossier partagé
218 + </Button>
219 + </div>
220 + </Modal>
221 + );
222 +}
modified components/folders/FolderCard.tsx +8 −2
@@ -7,10 +7,12 @@ import { useState, useRef, useEffect } from "react";
7 7 import toast from "react-hot-toast";
8 8 import Modal from "../ui/Modal";
9 9 import Button from "../ui/Button";
10 +import SharedBadge from "../ui/SharedBadge";
10 11
11 12 interface FolderData {
12 13 id: string;
13 14 name: string;
15 + sharedSpaceId?: string | null;
14 16 _count?: { files: number; children: number };
15 17 }
16 18
@@ -90,6 +92,7 @@ export default function FolderCard({ folder, onRefresh, viewMode }: FolderCardPr
90 92 >
91 93 <Folder className="w-5 h-5 text-blue-400 flex-shrink-0" />
92 94 <span className="text-sm text-gray-700 dark:text-gray-200 flex-1 truncate">{folder.name}</span>
95 + {folder.sharedSpaceId && <SharedBadge />}
93 96 {folder._count && (
94 97 <span className="text-xs text-gray-500">
95 98 {folder._count.files} fichiers
@@ -158,9 +161,12 @@ export default function FolderCard({ folder, onRefresh, viewMode }: FolderCardPr
158 161 onClick={() => router.push(`/dashboard/folder/${folder.id}`)}
159 162 >
160 163 <div className="flex items-center gap-3">
161 − <Folder className="w-8 h-8 text-blue-400" />
164 + <Folder className="w-8 h-8 text-blue-400 flex-shrink-0" />
162 165 <div className="flex-1 min-w-0">
163 − <p className="text-sm text-gray-700 dark:text-gray-200 truncate">{folder.name}</p>
166 + <div className="flex items-center gap-1.5 flex-wrap">
167 + <p className="text-sm text-gray-700 dark:text-gray-200 truncate">{folder.name}</p>
168 + {folder.sharedSpaceId && <SharedBadge />}
169 + </div>
164 170 {folder._count && (
165 171 <p className="text-xs text-gray-500">
166 172 {folder._count.files} fichiers, {folder._count.children} dossiers
modified components/layout/Sidebar.tsx +114 −37
@@ -18,13 +18,21 @@ import {
18 18 HardDrive,
19 19 Key,
20 20 Settings,
21 + KeyRound,
22 + Lock,
21 23 } from "lucide-react";
22 24 import ThemeToggle from "../theme/ThemeToggle";
23 25 import NotificationBell from "./NotificationBell";
26 +import SharedBadge from "../ui/SharedBadge";
27 +import ChangePasswordModal from "../auth/ChangePasswordModal";
24 28
25 −const navItems = [
29 +const userNavItems = [
26 30 { href: "/dashboard", label: "Fichiers", icon: FolderOpen },
27 31 { href: "/dashboard/favorites", label: "Favoris", icon: Star },
32 + { href: "/dashboard/passwords", label: "Mots de passe", icon: Lock },
33 +];
34 +
35 +const adminNavItems = [
28 36 { href: "/admin", label: "Dashboard", icon: LayoutDashboard },
29 37 { href: "/admin/shares", label: "Liens partagés", icon: Share2 },
30 38 { href: "/admin/activity", label: "Activité", icon: Activity },
@@ -32,6 +40,7 @@ const navItems = [
32 40
33 41 const adminItems = [
34 42 { href: "/admin/users", label: "Utilisateurs", icon: Users },
43 + { href: "/admin/shared-spaces", label: "Espaces partagés", icon: Users },
35 44 { href: "/admin/backup", label: "Backups", icon: HardDrive },
36 45 { href: "/admin/api-keys", label: "Clés API", icon: Key },
37 46 { href: "/admin/settings", label: "Paramètres", icon: Settings },
@@ -44,6 +53,12 @@ interface TagData {
44 53 _count?: { files: number };
45 54 }
46 55
56 +interface SharedSpaceData {
57 + id: string;
58 + name: string;
59 + quotaBytes: number;
60 +}
61 +
47 62 interface SidebarProps {
48 63 onNavigate?: () => void;
49 64 }
@@ -52,14 +67,30 @@ export default function Sidebar({ onNavigate }: SidebarProps) {
52 67 const pathname = usePathname();
53 68 const router = useRouter();
54 69 const [tags, setTags] = useState<TagData[]>([]);
70 + const [sharedSpaces, setSharedSpaces] = useState<SharedSpaceData[]>([]);
55 71 const [showTags, setShowTags] = useState(false);
56 72 const [showAdmin, setShowAdmin] = useState(false);
73 + const [showSpaces, setShowSpaces] = useState(true);
74 + const [isAdmin, setIsAdmin] = useState(false);
75 + const [showChangePassword, setShowChangePassword] = useState(false);
57 76
58 77 useEffect(() => {
78 + fetch("/api/auth/session")
79 + .then((res) => res.json())
80 + .then((data) => {
81 + if (data.role === "admin") setIsAdmin(true);
82 + })
83 + .catch(() => {});
84 +
59 85 fetch("/api/tags")
60 86 .then((res) => res.json())
61 87 .then(setTags)
62 88 .catch(() => {});
89 +
90 + fetch("/api/shared-spaces/mine")
91 + .then((res) => res.json())
92 + .then((data) => { if (Array.isArray(data)) setSharedSpaces(data); })
93 + .catch(() => {});
63 94 }, []);
64 95
65 96 const handleLogout = async () => {
@@ -73,6 +104,7 @@ export default function Sidebar({ onNavigate }: SidebarProps) {
73 104 };
74 105
75 106 const isAdminActive = adminItems.some((i) => pathname === i.href);
107 + const navItems = isAdmin ? [...userNavItems, ...adminNavItems] : userNavItems;
76 108
77 109 return (
78 110 <div className="h-screen flex flex-col bg-white dark:bg-[#0a0a0a] border-r border-gray-200 dark:border-white/10 transition-colors">
@@ -86,12 +118,12 @@ export default function Sidebar({ onNavigate }: SidebarProps) {
86 118 <ThemeToggle />
87 119 </div>
88 120
89 − {/* Navigation - touch optimized */}
121 + {/* Navigation */}
90 122 <nav className="flex-1 px-2 py-1 space-y-0.5 overflow-auto overscroll-contain">
91 123 {navItems.map((item) => {
92 124 const isActive =
93 125 pathname === item.href ||
94 − (item.href === "/dashboard" && pathname.startsWith("/dashboard") && !pathname.includes("favorites"));
126 + (item.href === "/dashboard" && pathname.startsWith("/dashboard") && !pathname.includes("favorites") && !pathname.includes("shared-space") && !pathname.includes("passwords"));
95 127 return (
96 128 <Link
97 129 key={item.href}
@@ -109,40 +141,76 @@ export default function Sidebar({ onNavigate }: SidebarProps) {
109 141 );
110 142 })}
111 143
112 − {/* Admin Section - toggle does NOT close sidebar */}
113 − <div className="pt-3">
114 − <button
115 − onClick={() => setShowAdmin(!showAdmin)}
116 − className={`flex items-center gap-2 px-3 py-2.5 w-full text-xs transition-colors min-h-[40px] rounded-lg active:bg-gray-50 dark:active:bg-white/5 ${
117 − isAdminActive ? "text-gray-700 dark:text-gray-300" : "text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300"
118 − }`}
119 − >
120 − {showAdmin ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />}
121 − <Settings className="w-3.5 h-3.5" />
122 − <span className="uppercase tracking-wider font-medium">Administration</span>
123 − </button>
124 − {(showAdmin || isAdminActive) && (
125 − <div className="space-y-0.5 ml-1">
126 − {adminItems.map((item) => (
127 − <Link
128 − key={item.href}
129 − href={item.href}
130 − onClick={handleLinkClick}
131 − className={`flex items-center gap-2.5 px-3 py-2.5 text-sm rounded-xl transition-colors min-h-[40px] ${
132 − pathname === item.href
133 − ? "bg-gray-100 dark:bg-white/10 text-gray-900 dark:text-white font-medium"
134 − : "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-50 dark:hover:bg-white/5 active:bg-gray-100 dark:active:bg-white/10"
135 − }`}
136 − >
137 − <item.icon className="w-4 h-4 flex-shrink-0" />
138 − {item.label}
139 − </Link>
140 − ))}
141 − </div>
142 − )}
143 − </div>
144 + {/* Shared Spaces Section */}
145 + {sharedSpaces.length > 0 && (
146 + <div className="pt-3">
147 + <button
148 + onClick={() => setShowSpaces(!showSpaces)}
149 + className="flex items-center gap-2 px-3 py-2.5 w-full text-xs text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition-colors min-h-[40px] rounded-lg active:bg-gray-50 dark:active:bg-white/5"
150 + >
151 + {showSpaces ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />}
152 + <Users className="w-3.5 h-3.5" />
153 + <span className="uppercase tracking-wider font-medium">Espaces partagés</span>
154 + </button>
155 + {showSpaces && (
156 + <div className="space-y-0.5 ml-1">
157 + {sharedSpaces.map((space) => (
158 + <Link
159 + key={space.id}
160 + href={`/dashboard/shared-space/${space.id}`}
161 + onClick={handleLinkClick}
162 + className={`flex items-center gap-2.5 px-3 py-2.5 text-sm rounded-xl transition-colors min-h-[40px] ${
163 + pathname === `/dashboard/shared-space/${space.id}`
164 + ? "bg-purple-50 dark:bg-purple-500/10 text-purple-700 dark:text-purple-300 font-medium"
165 + : "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-50 dark:hover:bg-white/5 active:bg-gray-100 dark:active:bg-white/10"
166 + }`}
167 + >
168 + <Users className="w-4 h-4 flex-shrink-0 text-purple-400" />
169 + <span className="truncate flex-1">{space.name}</span>
170 + <SharedBadge />
171 + </Link>
172 + ))}
173 + </div>
174 + )}
175 + </div>
176 + )}
177 +
178 + {/* Admin Section — admin only */}
179 + {isAdmin && (
180 + <div className="pt-3">
181 + <button
182 + onClick={() => setShowAdmin(!showAdmin)}
183 + className={`flex items-center gap-2 px-3 py-2.5 w-full text-xs transition-colors min-h-[40px] rounded-lg active:bg-gray-50 dark:active:bg-white/5 ${
184 + isAdminActive ? "text-gray-700 dark:text-gray-300" : "text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300"
185 + }`}
186 + >
187 + {showAdmin ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />}
188 + <Settings className="w-3.5 h-3.5" />
189 + <span className="uppercase tracking-wider font-medium">Administration</span>
190 + </button>
191 + {(showAdmin || isAdminActive) && (
192 + <div className="space-y-0.5 ml-1">
193 + {adminItems.map((item) => (
194 + <Link
195 + key={item.href}
196 + href={item.href}
197 + onClick={handleLinkClick}
198 + className={`flex items-center gap-2.5 px-3 py-2.5 text-sm rounded-xl transition-colors min-h-[40px] ${
199 + pathname === item.href
200 + ? "bg-gray-100 dark:bg-white/10 text-gray-900 dark:text-white font-medium"
201 + : "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-50 dark:hover:bg-white/5 active:bg-gray-100 dark:active:bg-white/10"
202 + }`}
203 + >
204 + <item.icon className="w-4 h-4 flex-shrink-0" />
205 + {item.label}
206 + </Link>
207 + ))}
208 + </div>
209 + )}
210 + </div>
211 + )}
144 212
145 − {/* Tags Section - toggle does NOT close sidebar */}
213 + {/* Tags Section */}
146 214 {tags.length > 0 && (
147 215 <div className="pt-3">
148 216 <button
@@ -174,7 +242,14 @@ export default function Sidebar({ onNavigate }: SidebarProps) {
174 242 </nav>
175 243
176 244 {/* Footer */}
177 − <div className="px-2 py-3 border-t border-gray-200 dark:border-white/10">
245 + <div className="px-2 py-3 border-t border-gray-200 dark:border-white/10 space-y-0.5">
246 + <button
247 + onClick={() => setShowChangePassword(true)}
248 + className="flex items-center gap-3 px-3 py-3 rounded-xl text-sm text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-50 dark:hover:bg-white/5 active:bg-gray-100 dark:active:bg-white/10 transition-colors w-full min-h-[44px]"
249 + >
250 + <KeyRound className="w-5 h-5" />
251 + Changer le mot de passe
252 + </button>
178 253 <button
179 254 onClick={handleLogout}
180 255 className="flex items-center gap-3 px-3 py-3 rounded-xl text-sm text-gray-500 dark:text-gray-400 hover:text-red-500 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-500/5 active:bg-red-100 dark:active:bg-red-500/10 transition-colors w-full min-h-[44px]"
@@ -183,6 +258,8 @@ export default function Sidebar({ onNavigate }: SidebarProps) {
183 258 Déconnexion
184 259 </button>
185 260 </div>
261 +
262 + <ChangePasswordModal isOpen={showChangePassword} onClose={() => setShowChangePassword(false)} />
186 263 </div>
187 264 );
188 265 }
added components/preview/plugins/WordPreview.tsx +128 −0
@@ -0,0 +1,128 @@
1 +"use client";
2 +
3 +import { useState, useEffect } from "react";
4 +import { Loader2, Download, FileText } from "lucide-react";
5 +import type { PreviewPluginProps } from "./types";
6 +
7 +export default function WordPreview({ file }: PreviewPluginProps) {
8 + const [html, setHtml] = useState<string | null>(null);
9 + const [error, setError] = useState(false);
10 + const [loading, setLoading] = useState(true);
11 +
12 + useEffect(() => {
13 + let cancelled = false;
14 +
15 + const convert = async () => {
16 + setLoading(true);
17 + setError(false);
18 + try {
19 + // Fetch the raw file bytes
20 + const res = await fetch(`/api/files/${file.id}/download`);
21 + if (!res.ok) throw new Error("Download failed");
22 + const arrayBuffer = await res.arrayBuffer();
23 +
24 + // mammoth converts docx → clean HTML
25 + const mammoth = await import("mammoth");
26 + const result = await mammoth.convertToHtml(
27 + { arrayBuffer },
28 + {
29 + styleMap: [
30 + "p[style-name='Heading 1'] => h1:fresh",
31 + "p[style-name='Heading 2'] => h2:fresh",
32 + "p[style-name='Heading 3'] => h3:fresh",
33 + "p[style-name='Titre 1'] => h1:fresh",
34 + "p[style-name='Titre 2'] => h2:fresh",
35 + ],
36 + }
37 + );
38 +
39 + if (!cancelled) {
40 + setHtml(result.value || "<p>Document vide.</p>");
41 + }
42 + } catch {
43 + if (!cancelled) setError(true);
44 + } finally {
45 + if (!cancelled) setLoading(false);
46 + }
47 + };
48 +
49 + convert();
50 + return () => { cancelled = true; };
51 + }, [file.id]);
52 +
53 + if (loading) {
54 + return (
55 + <div className="flex flex-col items-center justify-center h-full gap-3 text-gray-400">
56 + <Loader2 className="w-8 h-8 animate-spin text-blue-400" />
57 + <p className="text-sm">Chargement du document Word...</p>
58 + </div>
59 + );
60 + }
61 +
62 + if (error) {
63 + return (
64 + <div className="flex flex-col items-center justify-center h-full gap-4 text-gray-400 p-8">
65 + <FileText className="w-16 h-16 text-blue-400 opacity-50" />
66 + <p className="text-sm text-center">Impossible d&apos;afficher ce document.</p>
67 + <button
68 + onClick={() => window.open(`/api/files/${file.id}/download`, "_blank")}
69 + className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg text-sm text-white transition-colors"
70 + >
71 + <Download className="w-4 h-4" />
72 + Télécharger le fichier
73 + </button>
74 + </div>
75 + );
76 + }
77 +
78 + return (
79 + <div className="h-full overflow-auto bg-gray-50 dark:bg-[#1a1a1a]">
80 + {/* Page-like container */}
81 + <div className="max-w-3xl mx-auto my-6 px-4 sm:px-6">
82 + <div className="bg-white dark:bg-[#242424] shadow-md rounded-sm px-8 py-10 sm:px-14 sm:py-12 min-h-[500px]">
83 + {/* Download link */}
84 + <div className="flex items-center justify-between mb-6 pb-4 border-b border-gray-100 dark:border-white/10">
85 + <div className="flex items-center gap-2">
86 + <FileText className="w-4 h-4 text-blue-500" />
87 + <span className="text-sm font-medium text-gray-600 dark:text-gray-400 truncate max-w-xs">
88 + {file.name}
89 + </span>
90 + </div>
91 + <button
92 + onClick={() => window.open(`/api/files/${file.id}/download`, "_blank")}
93 + className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-gray-500 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 border border-gray-200 dark:border-white/10 rounded-lg transition-colors"
94 + >
95 + <Download className="w-3.5 h-3.5" />
96 + Télécharger
97 + </button>
98 + </div>
99 +
100 + {/* Rendered Word content */}
101 + <div
102 + className="word-content prose prose-sm sm:prose dark:prose-invert max-w-none"
103 + dangerouslySetInnerHTML={{ __html: html ?? "" }}
104 + />
105 + </div>
106 + </div>
107 +
108 + {/* Scoped styles for Word content */}
109 + <style jsx global>{`
110 + .word-content h1 { font-size: 1.6rem; font-weight: 700; margin-bottom: 0.5rem; margin-top: 1.5rem; }
111 + .word-content h2 { font-size: 1.3rem; font-weight: 600; margin-bottom: 0.4rem; margin-top: 1.2rem; }
112 + .word-content h3 { font-size: 1.1rem; font-weight: 600; margin-bottom: 0.3rem; margin-top: 1rem; }
113 + .word-content p { margin-bottom: 0.6rem; line-height: 1.7; }
114 + .word-content ul, .word-content ol { padding-left: 1.5rem; margin-bottom: 0.6rem; }
115 + .word-content li { margin-bottom: 0.2rem; }
116 + .word-content table { border-collapse: collapse; width: 100%; margin-bottom: 1rem; }
117 + .word-content td, .word-content th { border: 1px solid #d1d5db; padding: 0.4rem 0.6rem; text-align: left; }
118 + .word-content th { background: #f3f4f6; font-weight: 600; }
119 + .dark .word-content td, .dark .word-content th { border-color: rgba(255,255,255,0.15); }
120 + .dark .word-content th { background: rgba(255,255,255,0.08); }
121 + .word-content strong { font-weight: 700; }
122 + .word-content em { font-style: italic; }
123 + .word-content a { color: #3b82f6; text-decoration: underline; }
124 + .word-content img { max-width: 100%; height: auto; margin: 0.5rem 0; }
125 + `}</style>
126 + </div>
127 + );
128 +}
modified components/preview/plugins/index.ts +14 −0
@@ -11,8 +11,15 @@ const CodePreview = dynamic(() => import("./CodePreview"), { ssr: false });
11 11 const CsvJsonPreview = dynamic(() => import("./CsvJsonPreview"), { ssr: false });
12 12 const MarkdownPreview = dynamic(() => import("./MarkdownPreview"), { ssr: false });
13 13 const ArchivePreview = dynamic(() => import("./ArchivePreview"), { ssr: false });
14 +const WordPreview = dynamic(() => import("./WordPreview"), { ssr: false });
14 15 const FallbackPreview = dynamic(() => import("./FallbackPreview"), { ssr: false });
15 16
17 +const WORD_MIMES = [
18 + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", // .docx
19 + "application/msword", // .doc
20 + "application/vnd.oasis.opendocument.text", // .odt
21 +];
22 +
16 23 const plugins: PreviewPlugin[] = [
17 24 {
18 25 id: "image",
@@ -72,6 +79,13 @@ const plugins: PreviewPlugin[] = [
72 79 component: CodePreview as any,
73 80 supportsEdit: true,
74 81 },
82 + {
83 + id: "word",
84 + name: "Word",
85 + canHandle: (mime) => WORD_MIMES.includes(mime),
86 + priority: 15,
87 + component: WordPreview as any,
88 + },
75 89 {
76 90 id: "archive",
77 91 name: "Archive",
added components/ui/SharedBadge.tsx +7 −0
@@ -0,0 +1,7 @@
1 +export default function SharedBadge() {
2 + return (
3 + <span className="px-1.5 py-0.5 text-[10px] font-semibold rounded bg-purple-100 text-purple-700 dark:bg-purple-500/20 dark:text-purple-400 leading-none whitespace-nowrap">
4 + Partagé
5 + </span>
6 + );
7 +}
modified lib/ai.ts +69 −23
@@ -7,12 +7,16 @@ const anthropic = new Anthropic({
7 7 apiKey: process.env.ANTHROPIC_API_KEY,
8 8 });
9 9
10 +const MODEL = "claude-sonnet-4-6";
11 +
10 12 export async function aiSearch(query: string, userId?: string): Promise<{
11 − results: Array<{ fileId: string; name: string; relevance: string; snippet: string }>;
13 + results: Array<{ fileId: string; name: string; relevance: string; snippet: string; folder?: string; mimeType?: string }>;
12 14 answer: string;
13 15 }> {
14 − const where: Record<string, unknown> = {};
15 − if (userId) where.userId = userId;
16 + // Include files owned by user + legacy files with no owner (userId=null)
17 + const where: Record<string, unknown> = userId
18 + ? { OR: [{ userId }, { userId: null }] }
19 + : {};
16 20
17 21 const files = await prisma.file.findMany({
18 22 where,
@@ -31,9 +35,9 @@ export async function aiSearch(query: string, userId?: string): Promise<{
31 35 }).join("\n");
32 36
33 37 const response = await anthropic.messages.create({
34 − model: "claude-sonnet-4-5-20250929",
38 + model: MODEL,
35 39 max_tokens: 1024,
36 − messages: [{ role: "user", content: `Tu es un assistant de recherche de fichiers. Voici la liste des fichiers:\n\n${fileList}\n\nRecherche: "${query}"\n\nRéponds en JSON:\n{"results": [{"fileId": "...", "name": "...", "relevance": "high|medium|low", "snippet": "courte explication"}], "answer": "résumé"}` }],
40 + messages: [{ role: "user", content: `Tu es un assistant de recherche de fichiers. Voici la liste des fichiers:\n\n${fileList}\n\nRecherche: "${query}"\n\nRéponds en JSON:\n{"results": [{"fileId": "...", "name": "...", "relevance": "high|medium|low", "snippet": "courte explication", "folder": "nom du dossier ou null", "mimeType": "..."}], "answer": "résumé"}` }],
37 41 });
38 42
39 43 try {
@@ -59,7 +63,7 @@ export async function aiAutoTag(fileId: string): Promise<string[]> {
59 63 }
60 64
61 65 const response = await anthropic.messages.create({
62 − model: "claude-sonnet-4-5-20250929",
66 + model: MODEL,
63 67 max_tokens: 256,
64 68 messages: [{ role: "user", content: `Analyse ce fichier et suggère 3-5 tags pertinents en français.\n\n${contentHint}\n\nRéponds UNIQUEMENT avec un JSON array de strings. Ex: ["finance", "rapport", "2024"]` }],
65 69 });
@@ -87,9 +91,9 @@ export async function aiDescribe(fileId: string): Promise<string> {
87 91 const base64 = readFileSync(filePath).toString("base64");
88 92 const mediaType = file.mimeType as "image/jpeg" | "image/png" | "image/gif" | "image/webp";
89 93 const response = await anthropic.messages.create({
90 − model: "claude-sonnet-4-5-20250929",
91 − max_tokens: 256,
92 − messages: [{ role: "user", content: [{ type: "image", source: { type: "base64", media_type: mediaType, data: base64 } }, { type: "text", text: "Décris cette image en une phrase concise en français." }] }],
94 + model: MODEL,
95 + max_tokens: 512,
96 + messages: [{ role: "user", content: [{ type: "image", source: { type: "base64", media_type: mediaType, data: base64 } }, { type: "text", text: "Décris cette image en détail en français. Mentionne le sujet principal, les couleurs, le contexte et tout élément notable." }] }],
93 97 });
94 98 const desc = response.content[0].type === "text" ? response.content[0].text : "";
95 99 await prisma.file.update({ where: { id: fileId }, data: { aiDescription: desc } });
@@ -108,9 +112,9 @@ export async function aiDescribe(fileId: string): Promise<string> {
108 112 if (file.ocrText) hint += `\nOCR: ${file.ocrText.substring(0, 500)}`;
109 113
110 114 const response = await anthropic.messages.create({
111 − model: "claude-sonnet-4-5-20250929",
112 − max_tokens: 128,
113 − messages: [{ role: "user", content: `Décris ce fichier en une phrase concise en français:\n\n${hint}` }],
115 + model: MODEL,
116 + max_tokens: 256,
117 + messages: [{ role: "user", content: `Décris ce fichier de manière utile en français (2-3 phrases):\n\n${hint}` }],
114 118 });
115 119 const desc = response.content[0].type === "text" ? response.content[0].text : "";
116 120 await prisma.file.update({ where: { id: fileId }, data: { aiDescription: desc } });
@@ -128,9 +132,9 @@ export async function aiOcr(fileId: string): Promise<string> {
128 132 const base64 = readFileSync(filePath).toString("base64");
129 133 const mediaType = file.mimeType as "image/jpeg" | "image/png" | "image/gif" | "image/webp";
130 134 const response = await anthropic.messages.create({
131 − model: "claude-sonnet-4-5-20250929",
132 − max_tokens: 2048,
133 − messages: [{ role: "user", content: [{ type: "image", source: { type: "base64", media_type: mediaType, data: base64 } }, { type: "text", text: "Extrais tout le texte visible dans cette image. Retourne uniquement le texte extrait, sans commentaire." }] }],
135 + model: MODEL,
136 + max_tokens: 4096,
137 + messages: [{ role: "user", content: [{ type: "image", source: { type: "base64", media_type: mediaType, data: base64 } }, { type: "text", text: "Extrais tout le texte visible dans cette image de manière précise. Préserve la structure (titres, listes, tableaux). Retourne uniquement le texte extrait, sans commentaire." }] }],
134 138 });
135 139 const text = response.content[0].type === "text" ? response.content[0].text : "";
136 140 await prisma.file.update({ where: { id: fileId }, data: { ocrText: text } });
@@ -144,9 +148,9 @@ export async function aiOcr(fileId: string): Promise<string> {
144 148 if (!existsSync(filePath)) return "";
145 149 const base64 = readFileSync(filePath).toString("base64");
146 150 const response = await anthropic.messages.create({
147 − model: "claude-sonnet-4-5-20250929",
148 − max_tokens: 4096,
149 − messages: [{ role: "user", content: [{ type: "document", source: { type: "base64", media_type: "application/pdf", data: base64 } }, { type: "text", text: "Extrais tout le texte de ce document PDF. Retourne uniquement le texte extrait." }] as Anthropic.ContentBlockParam[] }],
151 + model: MODEL,
152 + max_tokens: 8192,
153 + messages: [{ role: "user", content: [{ type: "document", source: { type: "base64", media_type: "application/pdf", data: base64 } }, { type: "text", text: "Extrais tout le texte de ce document PDF en préservant la structure. Retourne uniquement le texte extrait." }] as Anthropic.ContentBlockParam[] }],
150 154 });
151 155 const text = response.content[0].type === "text" ? response.content[0].text : "";
152 156 await prisma.file.update({ where: { id: fileId }, data: { ocrText: text } });
@@ -156,12 +160,54 @@ export async function aiOcr(fileId: string): Promise<string> {
156 160 return "";
157 161 }
158 162
159 −export async function aiChat(message: string, context?: string): Promise<string> {
163 +export type ChatMessage = { role: "user" | "assistant"; content: string };
164 +
165 +export async function aiChat(message: string, history: ChatMessage[] = [], userId?: string): Promise<string> {
166 + let fileContext = "";
167 + if (userId) {
168 + const where = { OR: [{ userId }, { userId: null }] };
169 + const recentFiles = await prisma.file.findMany({
170 + where,
171 + select: { name: true, mimeType: true, aiTags: true, aiDescription: true, folder: { select: { name: true } } },
172 + take: 30,
173 + orderBy: { createdAt: "desc" },
174 + });
175 + const totalCount = await prisma.file.count({ where });
176 + fileContext = `\nL'utilisateur possède ${totalCount} fichier(s) au total. Voici les 30 plus récents:\n` +
177 + recentFiles.map(f =>
178 + `- ${f.name} (${f.mimeType})${f.folder ? ` [dossier: ${f.folder.name}]` : ""}${f.aiDescription ? ` — ${f.aiDescription}` : ""}${f.aiTags ? ` [tags: ${f.aiTags}]` : ""}`
179 + ).join("\n");
180 + }
181 +
160 182 const response = await anthropic.messages.create({
161 − model: "claude-sonnet-4-5-20250929",
162 − max_tokens: 1024,
163 − system: "Tu es l'assistant IA de SPB Cloud, un cloud personnel privé. Aide l'utilisateur avec ses fichiers et réponds en français.",
164 − messages: [{ role: "user", content: context ? `Contexte:\n${context}\n\nQuestion: ${message}` : message }],
183 + model: MODEL,
184 + max_tokens: 2048,
185 + system: `Tu es l'assistant IA de SPB Cloud, un cloud personnel privé. Tu aides l'utilisateur avec ses fichiers, réponds en français, et tu es concis et utile.${fileContext}`,
186 + messages: [
187 + ...history,
188 + { role: "user", content: message },
189 + ],
165 190 });
166 191 return response.content[0].type === "text" ? response.content[0].text : "";
167 192 }
193 +
194 +export async function buildFileContext(userId: string): Promise<string> {
195 + // Include files owned by user + legacy files with no owner (userId=null)
196 + const where = { OR: [{ userId }, { userId: null }] };
197 + const recentFiles = await prisma.file.findMany({
198 + where,
199 + select: { name: true, mimeType: true, aiTags: true, aiDescription: true, ocrText: true, folder: { select: { name: true } } },
200 + take: 30,
201 + orderBy: { createdAt: "desc" },
202 + });
203 + const totalCount = await prisma.file.count({ where });
204 + return `L'utilisateur possède ${totalCount} fichier(s) au total. Voici les 30 plus récents:\n` +
205 + recentFiles.map(f => {
206 + const parts = [`- ${f.name} (${f.mimeType})`];
207 + if (f.folder) parts.push(`[dossier: ${f.folder.name}]`);
208 + if (f.aiDescription) parts.push(`— ${f.aiDescription}`);
209 + if (f.aiTags) parts.push(`[tags: ${f.aiTags}]`);
210 + if (f.ocrText) parts.push(`[OCR dispo: ${f.ocrText.substring(0, 150)}...]`);
211 + return parts.join(" ");
212 + }).join("\n");
213 +}
modified lib/auth.ts +6 −4
@@ -1,16 +1,18 @@
1 1 import bcrypt from "bcryptjs";
2 2 import { prisma } from "./prisma";
3 3
4 −export async function verifyPassword(password: string, email?: string): Promise<{ valid: boolean; user: { id: string; role: string; twoFactorEnabled: boolean } | null }> {
4 +export async function verifyPassword(password: string, username?: string): Promise<{ valid: boolean; user: { id: string; role: string; twoFactorEnabled: boolean } | null }> {
5 5 let user;
6 − if (email) {
7 − user = await prisma.user.findUnique({ where: { email } });
6 + if (username) {
7 + user = await prisma.user.findFirst({
8 + where: { OR: [{ username }, { email: username }] },
9 + });
8 10 if (!user) return { valid: false, user: null };
9 11 const valid = await bcrypt.compare(password, user.passwordHash);
10 12 return { valid, user: valid ? { id: user.id, role: user.role, twoFactorEnabled: user.twoFactorEnabled } : null };
11 13 }
12 14
13 − // Legacy: try all users (for simple password-only login)
15 + // Fallback: single user / password-only login
14 16 const users = await prisma.user.findMany();
15 17 for (const u of users) {
16 18 const match = await bcrypt.compare(password, u.passwordHash);
added lib/crypto.ts +26 −0
@@ -0,0 +1,26 @@
1 +import { createCipheriv, createDecipheriv, randomBytes } from "crypto";
2 +
3 +const ALGORITHM = "aes-256-gcm";
4 +const KEY = Buffer.from(
5 + (process.env.ENCRYPTION_KEY || "spb-private-cloud-encrypt-key32!!").padEnd(32, "0").slice(0, 32)
6 +);
7 +
8 +export function encrypt(text: string): string {
9 + const iv = randomBytes(12);
10 + const cipher = createCipheriv(ALGORITHM, KEY, iv);
11 + const encrypted = Buffer.concat([cipher.update(text, "utf8"), cipher.final()]);
12 + const tag = cipher.getAuthTag();
13 + return `${iv.toString("hex")}:${tag.toString("hex")}:${encrypted.toString("hex")}`;
14 +}
15 +
16 +export function decrypt(data: string): string {
17 + const parts = data.split(":");
18 + if (parts.length !== 3) throw new Error("Invalid encrypted data");
19 + const [ivHex, tagHex, encryptedHex] = parts;
20 + const iv = Buffer.from(ivHex, "hex");
21 + const tag = Buffer.from(tagHex, "hex");
22 + const encrypted = Buffer.from(encryptedHex, "hex");
23 + const decipher = createDecipheriv(ALGORITHM, KEY, iv);
24 + decipher.setAuthTag(tag);
25 + return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString("utf8");
26 +}
modified package-lock.json +102 −1
@@ -38,6 +38,7 @@
38 38 "iron-session": "^8.0.4",
39 39 "jszip": "^3.10.1",
40 40 "lucide-react": "^0.564.0",
41 + "mammoth": "^1.11.0",
41 42 "mime-types": "^3.0.2",
42 43 "next": "14.2.35",
43 44 "otplib": "^13.3.0",
@@ -3080,6 +3081,15 @@
3080 3081 "win32"
3081 3082 ]
3082 3083 },
3084 + "node_modules/@xmldom/xmldom": {
3085 + "version": "0.8.11",
3086 + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz",
3087 + "integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==",
3088 + "license": "MIT",
3089 + "engines": {
3090 + "node": ">=10.0.0"
3091 + }
3092 + },
3083 3093 "node_modules/abort-controller": {
3084 3094 "version": "3.0.0",
3085 3095 "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
@@ -3627,6 +3637,12 @@
3627 3637 "url": "https://github.com/sponsors/sindresorhus"
3628 3638 }
3629 3639 },
3640 + "node_modules/bluebird": {
3641 + "version": "3.4.7",
3642 + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz",
3643 + "integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==",
3644 + "license": "MIT"
3645 + },
3630 3646 "node_modules/brace-expansion": {
3631 3647 "version": "1.1.12",
3632 3648 "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
@@ -4376,6 +4392,12 @@
4376 4392 "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
4377 4393 "license": "MIT"
4378 4394 },
4395 + "node_modules/dingbat-to-unicode": {
4396 + "version": "1.0.1",
4397 + "resolved": "https://registry.npmjs.org/dingbat-to-unicode/-/dingbat-to-unicode-1.0.1.tgz",
4398 + "integrity": "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==",
4399 + "license": "BSD-2-Clause"
4400 + },
4379 4401 "node_modules/dlv": {
4380 4402 "version": "1.1.3",
4381 4403 "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
@@ -4396,6 +4418,15 @@
4396 4418 "node": ">=6.0.0"
4397 4419 }
4398 4420 },
4421 + "node_modules/duck": {
4422 + "version": "0.1.12",
4423 + "resolved": "https://registry.npmjs.org/duck/-/duck-0.1.12.tgz",
4424 + "integrity": "sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg==",
4425 + "license": "BSD",
4426 + "dependencies": {
4427 + "underscore": "^1.13.1"
4428 + }
4429 + },
4399 4430 "node_modules/dunder-proto": {
4400 4431 "version": "1.0.1",
4401 4432 "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -6755,6 +6786,17 @@
6755 6786 "loose-envify": "cli.js"
6756 6787 }
6757 6788 },
6789 + "node_modules/lop": {
6790 + "version": "0.4.2",
6791 + "resolved": "https://registry.npmjs.org/lop/-/lop-0.4.2.tgz",
6792 + "integrity": "sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw==",
6793 + "license": "BSD-2-Clause",
6794 + "dependencies": {
6795 + "duck": "^0.1.12",
6796 + "option": "~0.2.1",
6797 + "underscore": "^1.13.1"
6798 + }
6799 + },
6758 6800 "node_modules/lowlight": {
6759 6801 "version": "1.20.0",
6760 6802 "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-1.20.0.tgz",
@@ -6784,6 +6826,39 @@
6784 6826 "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
6785 6827 }
6786 6828 },
6829 + "node_modules/mammoth": {
6830 + "version": "1.11.0",
6831 + "resolved": "https://registry.npmjs.org/mammoth/-/mammoth-1.11.0.tgz",
6832 + "integrity": "sha512-BcEqqY/BOwIcI1iR5tqyVlqc3KIaMRa4egSoK83YAVrBf6+yqdAAbtUcFDCWX8Zef8/fgNZ6rl4VUv+vVX8ddQ==",
6833 + "license": "BSD-2-Clause",
6834 + "dependencies": {
6835 + "@xmldom/xmldom": "^0.8.6",
6836 + "argparse": "~1.0.3",
6837 + "base64-js": "^1.5.1",
6838 + "bluebird": "~3.4.0",
6839 + "dingbat-to-unicode": "^1.0.1",
6840 + "jszip": "^3.7.1",
6841 + "lop": "^0.4.2",
6842 + "path-is-absolute": "^1.0.0",
6843 + "underscore": "^1.13.1",
6844 + "xmlbuilder": "^10.0.0"
6845 + },
6846 + "bin": {
6847 + "mammoth": "bin/mammoth"
6848 + },
6849 + "engines": {
6850 + "node": ">=12.0.0"
6851 + }
6852 + },
6853 + "node_modules/mammoth/node_modules/argparse": {
6854 + "version": "1.0.10",
6855 + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
6856 + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
6857 + "license": "MIT",
6858 + "dependencies": {
6859 + "sprintf-js": "~1.0.2"
6860 + }
6861 + },
6787 6862 "node_modules/math-intrinsics": {
6788 6863 "version": "1.1.0",
6789 6864 "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -7780,6 +7855,12 @@
7780 7855 "wrappy": "1"
7781 7856 }
7782 7857 },
7858 + "node_modules/option": {
7859 + "version": "0.2.4",
7860 + "resolved": "https://registry.npmjs.org/option/-/option-0.2.4.tgz",
7861 + "integrity": "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==",
7862 + "license": "BSD-2-Clause"
7863 + },
7783 7864 "node_modules/optionator": {
7784 7865 "version": "0.9.4",
7785 7866 "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -7934,7 +8015,6 @@
7934 8015 "version": "1.0.1",
7935 8016 "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
7936 8017 "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
7937 − "dev": true,
7938 8018 "license": "MIT",
7939 8019 "engines": {
7940 8020 "node": ">=0.10.0"
@@ -9076,6 +9156,12 @@
9076 9156 "url": "https://github.com/sponsors/wooorm"
9077 9157 }
9078 9158 },
9159 + "node_modules/sprintf-js": {
9160 + "version": "1.0.3",
9161 + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
9162 + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
9163 + "license": "BSD-3-Clause"
9164 + },
9079 9165 "node_modules/stable-hash": {
9080 9166 "version": "0.0.5",
9081 9167 "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
@@ -9866,6 +9952,12 @@
9866 9952 "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==",
9867 9953 "license": "MIT"
9868 9954 },
9955 + "node_modules/underscore": {
9956 + "version": "1.13.8",
9957 + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz",
9958 + "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==",
9959 + "license": "MIT"
9960 + },
9869 9961 "node_modules/undici-types": {
9870 9962 "version": "6.21.0",
9871 9963 "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
@@ -10285,6 +10377,15 @@
10285 10377 "dev": true,
10286 10378 "license": "ISC"
10287 10379 },
10380 + "node_modules/xmlbuilder": {
10381 + "version": "10.1.1",
10382 + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-10.1.1.tgz",
10383 + "integrity": "sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==",
10384 + "license": "MIT",
10385 + "engines": {
10386 + "node": ">=4.0"
10387 + }
10388 + },
10288 10389 "node_modules/y18n": {
10289 10390 "version": "4.0.3",
10290 10391 "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
modified package.json +1 −0
@@ -47,6 +47,7 @@
47 47 "iron-session": "^8.0.4",
48 48 "jszip": "^3.10.1",
49 49 "lucide-react": "^0.564.0",
50 + "mammoth": "^1.11.0",
50 51 "mime-types": "^3.0.2",
51 52 "next": "14.2.35",
52 53 "otplib": "^13.3.0",
modified prisma/schema.prisma +69 −16
@@ -11,6 +11,7 @@ datasource db {
11 11
12 12 model User {
13 13 id String @id @default(cuid())
14 + username String? @unique
14 15 email String? @unique
15 16 name String?
16 17 passwordHash String
@@ -20,12 +21,14 @@ model User {
20 21 createdAt DateTime @default(now())
21 22 updatedAt DateTime @default(now())
22 23
23 − files File[]
24 − folders Folder[]
25 − permissions Permission[]
26 − notifications Notification[]
27 − apiKeys ApiKey[]
28 − activityLogs ActivityLog[]
24 + files File[]
25 + folders Folder[]
26 + permissions Permission[]
27 + notifications Notification[]
28 + apiKeys ApiKey[]
29 + activityLogs ActivityLog[]
30 + sharedSpaces SharedSpaceMember[]
31 + passwordEntries PasswordEntry[]
29 32
30 33 @@index([email])
31 34 @@index([role])
@@ -66,19 +69,22 @@ model File {
66 69 }
67 70
68 71 model Folder {
69 − id String @id @default(cuid())
70 − name String
71 − parentId String?
72 − userId String?
73 − parent Folder? @relation("FolderTree", fields: [parentId], references: [id], onDelete: Cascade)
74 − children Folder[] @relation("FolderTree")
75 − user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
76 − files File[]
77 − permissions Permission[]
78 − createdAt DateTime @default(now())
72 + id String @id @default(cuid())
73 + name String
74 + parentId String?
75 + userId String?
76 + sharedSpaceId String?
77 + parent Folder? @relation("FolderTree", fields: [parentId], references: [id], onDelete: Cascade)
78 + children Folder[] @relation("FolderTree")
79 + user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
80 + sharedSpace SharedSpace? @relation(fields: [sharedSpaceId], references: [id], onDelete: SetNull)
81 + files File[]
82 + permissions Permission[]
83 + createdAt DateTime @default(now())
79 84
80 85 @@index([parentId])
81 86 @@index([userId])
87 + @@index([sharedSpaceId])
82 88 }
83 89
84 90 // ==================== SHARING ====================
@@ -228,3 +234,50 @@ model BackupLog {
228 234 startedAt DateTime @default(now())
229 235 completedAt DateTime?
230 236 }
237 +
238 +// ==================== SHARED SPACES ====================
239 +
240 +// ==================== PASSWORD MANAGER ====================
241 +
242 +model PasswordEntry {
243 + id String @id @default(cuid())
244 + userId String
245 + user User @relation(fields: [userId], references: [id], onDelete: Cascade)
246 + siteName String
247 + siteUrl String?
248 + username String?
249 + password String // AES-256-GCM encrypted
250 + notes String?
251 + createdAt DateTime @default(now())
252 + updatedAt DateTime @updatedAt
253 +
254 + @@index([userId])
255 + @@index([siteName])
256 +}
257 +
258 +// ==================== SHARED SPACES ====================
259 +
260 +model SharedSpace {
261 + id String @id @default(cuid())
262 + name String
263 + quotaBytes BigInt @default(536870912000)
264 + createdAt DateTime @default(now())
265 + updatedAt DateTime @updatedAt
266 + members SharedSpaceMember[]
267 + folders Folder[]
268 +
269 + @@index([name])
270 +}
271 +
272 +model SharedSpaceMember {
273 + id String @id @default(cuid())
274 + sharedSpaceId String
275 + userId String
276 + sharedSpace SharedSpace @relation(fields: [sharedSpaceId], references: [id], onDelete: Cascade)
277 + user User @relation(fields: [userId], references: [id], onDelete: Cascade)
278 + joinedAt DateTime @default(now())
279 +
280 + @@unique([sharedSpaceId, userId])
281 + @@index([sharedSpaceId])
282 + @@index([userId])
283 +}
modified prisma/seed.ts +2 −2
@@ -8,13 +8,13 @@ async function main() {
8 8
9 9 await prisma.user.upsert({
10 10 where: { id: "admin" },
11 − update: { passwordHash, role: "admin", name: "Admin", email: "admin@spbcloud.local" },
11 + update: { passwordHash, role: "admin", name: "Admin", username: "admin" },
12 12 create: {
13 13 id: "admin",
14 14 passwordHash,
15 15 role: "admin",
16 16 name: "Admin",
17 − email: "admin@spbcloud.local",
17 + username: "admin",
18 18 },
19 19 });
20 20
21 21