MVP passwords
This commit is contained in:
+5
-2
@@ -8,7 +8,10 @@ COPY package*.json ./
|
|||||||
COPY tsconfig.json ./
|
COPY tsconfig.json ./
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
RUN npm ci
|
# Install build deps required for compiling native modules like better-sqlite3
|
||||||
|
RUN apk add --no-cache python3 make g++ build-base sqlite-dev && \
|
||||||
|
npm ci
|
||||||
|
ENV PYTHON=/usr/bin/python3
|
||||||
|
|
||||||
# Copy prisma schema
|
# Copy prisma schema
|
||||||
COPY prisma ./prisma/
|
COPY prisma ./prisma/
|
||||||
@@ -26,7 +29,7 @@ RUN npx tsc
|
|||||||
FROM node:20-alpine
|
FROM node:20-alpine
|
||||||
|
|
||||||
# Install OpenSSL for Prisma and su-exec, create non-root user
|
# Install OpenSSL for Prisma and su-exec, create non-root user
|
||||||
RUN apk add --no-cache openssl su-exec && \
|
RUN apk add --no-cache openssl su-exec sqlite-libs && \
|
||||||
addgroup -g 1001 -S nodejs && \
|
addgroup -g 1001 -S nodejs && \
|
||||||
adduser -S nodejs -u 1001
|
adduser -S nodejs -u 1001
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,34 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "PrivateVault" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY DEFAULT 'vault',
|
||||||
|
"passwordHash" TEXT NOT NULL,
|
||||||
|
"salt" TEXT NOT NULL,
|
||||||
|
"hint" TEXT,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- RedefineTables
|
||||||
|
PRAGMA defer_foreign_keys=ON;
|
||||||
|
PRAGMA foreign_keys=OFF;
|
||||||
|
CREATE TABLE "new_Drawing" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"elements" TEXT NOT NULL,
|
||||||
|
"appState" TEXT NOT NULL,
|
||||||
|
"files" TEXT NOT NULL DEFAULT '{}',
|
||||||
|
"preview" TEXT,
|
||||||
|
"version" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
"collectionId" TEXT,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL,
|
||||||
|
"isPrivate" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"encryptedData" TEXT,
|
||||||
|
"iv" TEXT,
|
||||||
|
CONSTRAINT "Drawing_collectionId_fkey" FOREIGN KEY ("collectionId") REFERENCES "Collection" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
INSERT INTO "new_Drawing" ("appState", "collectionId", "createdAt", "elements", "files", "id", "name", "preview", "updatedAt", "version") SELECT "appState", "collectionId", "createdAt", "elements", "files", "id", "name", "preview", "updatedAt", "version" FROM "Drawing";
|
||||||
|
DROP TABLE "Drawing";
|
||||||
|
ALTER TABLE "new_Drawing" RENAME TO "Drawing";
|
||||||
|
PRAGMA foreign_keys=ON;
|
||||||
|
PRAGMA defer_foreign_keys=OFF;
|
||||||
Binary file not shown.
Binary file not shown.
@@ -32,6 +32,21 @@ model Drawing {
|
|||||||
collection Collection? @relation(fields: [collectionId], references: [id])
|
collection Collection? @relation(fields: [collectionId], references: [id])
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
// Privacy/Encryption fields
|
||||||
|
isPrivate Boolean @default(false)
|
||||||
|
encryptedData String? // Encrypted blob containing elements, appState, files when isPrivate=true
|
||||||
|
iv String? // Initialization vector for AES-GCM decryption
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singleton model for storing vault password hash and settings
|
||||||
|
model PrivateVault {
|
||||||
|
id String @id @default("vault") // Singleton pattern
|
||||||
|
passwordHash String // bcrypt hash for password verification
|
||||||
|
salt String // Salt for client-side key derivation (hex encoded)
|
||||||
|
hint String? // Optional password hint
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
model Library {
|
model Library {
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -136,6 +136,18 @@ exports.Prisma.DrawingScalarFieldEnum = {
|
|||||||
version: 'version',
|
version: 'version',
|
||||||
collectionId: 'collectionId',
|
collectionId: 'collectionId',
|
||||||
createdAt: 'createdAt',
|
createdAt: 'createdAt',
|
||||||
|
updatedAt: 'updatedAt',
|
||||||
|
isPrivate: 'isPrivate',
|
||||||
|
encryptedData: 'encryptedData',
|
||||||
|
iv: 'iv'
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.Prisma.PrivateVaultScalarFieldEnum = {
|
||||||
|
id: 'id',
|
||||||
|
passwordHash: 'passwordHash',
|
||||||
|
salt: 'salt',
|
||||||
|
hint: 'hint',
|
||||||
|
createdAt: 'createdAt',
|
||||||
updatedAt: 'updatedAt'
|
updatedAt: 'updatedAt'
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -160,6 +172,7 @@ exports.Prisma.NullsOrder = {
|
|||||||
exports.Prisma.ModelName = {
|
exports.Prisma.ModelName = {
|
||||||
Collection: 'Collection',
|
Collection: 'Collection',
|
||||||
Drawing: 'Drawing',
|
Drawing: 'Drawing',
|
||||||
|
PrivateVault: 'PrivateVault',
|
||||||
Library: 'Library'
|
Library: 'Library'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+1281
-2
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "prisma-client-8eed3ee5004eaec649fc60571177778f25acb4a3cdc2c238bbb8e70dd820d0ff",
|
"name": "prisma-client-2894d2d911743eb6e6a7a673efae7513c10b6ce609edeb9caf76ed42f4728682",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"types": "index.d.ts",
|
"types": "index.d.ts",
|
||||||
"browser": "index-browser.js",
|
"browser": "index-browser.js",
|
||||||
|
|||||||
@@ -32,6 +32,21 @@ model Drawing {
|
|||||||
collection Collection? @relation(fields: [collectionId], references: [id])
|
collection Collection? @relation(fields: [collectionId], references: [id])
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
// Privacy/Encryption fields
|
||||||
|
isPrivate Boolean @default(false)
|
||||||
|
encryptedData String? // Encrypted blob containing elements, appState, files when isPrivate=true
|
||||||
|
iv String? // Initialization vector for AES-GCM decryption
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singleton model for storing vault password hash and settings
|
||||||
|
model PrivateVault {
|
||||||
|
id String @id @default("vault") // Singleton pattern
|
||||||
|
passwordHash String // bcrypt hash for password verification
|
||||||
|
salt String // Salt for client-side key derivation (hex encoded)
|
||||||
|
hint String? // Optional password hint
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
model Library {
|
model Library {
|
||||||
|
|||||||
@@ -136,6 +136,18 @@ exports.Prisma.DrawingScalarFieldEnum = {
|
|||||||
version: 'version',
|
version: 'version',
|
||||||
collectionId: 'collectionId',
|
collectionId: 'collectionId',
|
||||||
createdAt: 'createdAt',
|
createdAt: 'createdAt',
|
||||||
|
updatedAt: 'updatedAt',
|
||||||
|
isPrivate: 'isPrivate',
|
||||||
|
encryptedData: 'encryptedData',
|
||||||
|
iv: 'iv'
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.Prisma.PrivateVaultScalarFieldEnum = {
|
||||||
|
id: 'id',
|
||||||
|
passwordHash: 'passwordHash',
|
||||||
|
salt: 'salt',
|
||||||
|
hint: 'hint',
|
||||||
|
createdAt: 'createdAt',
|
||||||
updatedAt: 'updatedAt'
|
updatedAt: 'updatedAt'
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -160,6 +172,7 @@ exports.Prisma.NullsOrder = {
|
|||||||
exports.Prisma.ModelName = {
|
exports.Prisma.ModelName = {
|
||||||
Collection: 'Collection',
|
Collection: 'Collection',
|
||||||
Drawing: 'Drawing',
|
Drawing: 'Drawing',
|
||||||
|
PrivateVault: 'PrivateVault',
|
||||||
Library: 'Library'
|
Library: 'Library'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+351
-1
@@ -10,6 +10,7 @@ import { Worker } from "worker_threads";
|
|||||||
import multer from "multer";
|
import multer from "multer";
|
||||||
import archiver from "archiver";
|
import archiver from "archiver";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import * as crypto from "crypto";
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import { PrismaClient } from "./generated/client";
|
import { PrismaClient } from "./generated/client";
|
||||||
import {
|
import {
|
||||||
@@ -481,13 +482,350 @@ app.get("/health", (req, res) => {
|
|||||||
res.status(200).json({ status: "ok" });
|
res.status(200).json({ status: "ok" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Private Vault ---
|
||||||
|
|
||||||
|
// Hash password using scrypt (similar to bcrypt but built-in to Node.js)
|
||||||
|
const hashPasswordServer = (
|
||||||
|
password: string,
|
||||||
|
salt?: string
|
||||||
|
): Promise<{ hash: string; salt: string }> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const useSalt = salt || crypto.randomBytes(16).toString("hex");
|
||||||
|
crypto.scrypt(password, useSalt, 64, (err, derivedKey) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
resolve({ hash: derivedKey.toString("hex"), salt: useSalt });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const verifyPasswordServer = (
|
||||||
|
password: string,
|
||||||
|
hash: string,
|
||||||
|
salt: string
|
||||||
|
): Promise<boolean> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
crypto.scrypt(password, salt, 64, (err, derivedKey) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
resolve(derivedKey.toString("hex") === hash);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// GET /vault/status - Check if vault is set up
|
||||||
|
app.get("/vault/status", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const vault = await prisma.privateVault.findUnique({
|
||||||
|
where: { id: "vault" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const privateDrawingsCount = await prisma.drawing.count({
|
||||||
|
where: { isPrivate: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!vault) {
|
||||||
|
return res.json({
|
||||||
|
isSetup: false,
|
||||||
|
privateDrawingsCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
isSetup: true,
|
||||||
|
salt: vault.salt,
|
||||||
|
hint: vault.hint,
|
||||||
|
privateDrawingsCount,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to get vault status:", error);
|
||||||
|
res.status(500).json({ error: "Failed to get vault status" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /vault/setup - Create vault with password
|
||||||
|
app.post("/vault/setup", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { passwordHash, salt, hint } = req.body;
|
||||||
|
|
||||||
|
if (!passwordHash || !salt) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: "Password hash and salt are required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if vault already exists
|
||||||
|
const existing = await prisma.privateVault.findUnique({
|
||||||
|
where: { id: "vault" },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
return res.status(400).json({ error: "Vault already exists" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash the client-side hash again for extra security
|
||||||
|
const { hash: serverHash, salt: serverSalt } = await hashPasswordServer(
|
||||||
|
passwordHash
|
||||||
|
);
|
||||||
|
|
||||||
|
await prisma.privateVault.create({
|
||||||
|
data: {
|
||||||
|
id: "vault",
|
||||||
|
passwordHash: `${serverHash}:${serverSalt}`, // Store both server hash and server salt
|
||||||
|
salt, // Client-side salt for key derivation
|
||||||
|
hint: hint || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to setup vault:", error);
|
||||||
|
res.status(500).json({ error: "Failed to setup vault" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /vault/verify - Verify password and return salt for decryption
|
||||||
|
app.post("/vault/verify", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { password } = req.body;
|
||||||
|
|
||||||
|
if (!password) {
|
||||||
|
return res.status(400).json({ error: "Password is required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const vault = await prisma.privateVault.findUnique({
|
||||||
|
where: { id: "vault" },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!vault) {
|
||||||
|
return res.status(404).json({ error: "Vault not set up" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse stored hash format: "serverHash:serverSalt"
|
||||||
|
const [storedHash, serverSalt] = vault.passwordHash.split(":");
|
||||||
|
|
||||||
|
// Client sends SHA-256 hash of password, we verify against our scrypt hash
|
||||||
|
const isValid = await verifyPasswordServer(
|
||||||
|
password,
|
||||||
|
storedHash,
|
||||||
|
serverSalt
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
return res
|
||||||
|
.status(401)
|
||||||
|
.json({ success: false, error: "Invalid password" });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
salt: vault.salt, // Return client-side salt for key derivation
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to verify password:", error);
|
||||||
|
res.status(500).json({ error: "Failed to verify password" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /vault/password - Change vault password
|
||||||
|
app.put("/vault/password", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { passwordHash, salt } = req.body;
|
||||||
|
|
||||||
|
if (!passwordHash || !salt) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: "Password hash and salt are required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const vault = await prisma.privateVault.findUnique({
|
||||||
|
where: { id: "vault" },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!vault) {
|
||||||
|
return res.status(404).json({ error: "Vault not set up" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash the new client-side hash
|
||||||
|
const { hash: serverHash, salt: serverSalt } = await hashPasswordServer(
|
||||||
|
passwordHash
|
||||||
|
);
|
||||||
|
|
||||||
|
await prisma.privateVault.update({
|
||||||
|
where: { id: "vault" },
|
||||||
|
data: {
|
||||||
|
passwordHash: `${serverHash}:${serverSalt}`,
|
||||||
|
salt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to change password:", error);
|
||||||
|
res.status(500).json({ error: "Failed to change password" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /vault/hint - Get password hint
|
||||||
|
app.get("/vault/hint", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const vault = await prisma.privateVault.findUnique({
|
||||||
|
where: { id: "vault" },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!vault) {
|
||||||
|
return res.status(404).json({ error: "Vault not set up" });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ hint: vault.hint });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to get hint:", error);
|
||||||
|
res.status(500).json({ error: "Failed to get hint" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /vault/hint - Update password hint
|
||||||
|
app.put("/vault/hint", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { hint } = req.body;
|
||||||
|
|
||||||
|
const vault = await prisma.privateVault.findUnique({
|
||||||
|
where: { id: "vault" },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!vault) {
|
||||||
|
return res.status(404).json({ error: "Vault not set up" });
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.privateVault.update({
|
||||||
|
where: { id: "vault" },
|
||||||
|
data: { hint },
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to update hint:", error);
|
||||||
|
res.status(500).json({ error: "Failed to update hint" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /drawings/private - Get all private drawings
|
||||||
|
app.get("/drawings/private", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const drawings = await prisma.drawing.findMany({
|
||||||
|
where: { isPrivate: true },
|
||||||
|
orderBy: { updatedAt: "desc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(drawings);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to get private drawings:", error);
|
||||||
|
res.status(500).json({ error: "Failed to get private drawings" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /drawings/:id/lock - Move drawing to private vault
|
||||||
|
app.put("/drawings/:id/lock", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { encryptedData, iv } = req.body;
|
||||||
|
|
||||||
|
if (!encryptedData || !iv) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: "Encrypted data and IV are required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const drawing = await prisma.drawing.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!drawing) {
|
||||||
|
return res.status(404).json({ error: "Drawing not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a locked preview
|
||||||
|
const lockedPreview = `<svg xmlns="http://www.w3.org/2000/svg" width="200" height="150" viewBox="0 0 200 150">
|
||||||
|
<rect width="200" height="150" fill="#f1f5f9"/>
|
||||||
|
<rect x="75" y="45" width="50" height="40" rx="4" fill="#94a3b8"/>
|
||||||
|
<rect x="85" y="30" width="30" height="25" rx="15" fill="none" stroke="#94a3b8" stroke-width="6"/>
|
||||||
|
<circle cx="100" cy="65" r="4" fill="#f1f5f9"/>
|
||||||
|
<rect x="98" y="65" width="4" height="10" fill="#f1f5f9"/>
|
||||||
|
<text x="100" y="110" font-family="system-ui" font-size="12" fill="#64748b" text-anchor="middle">Private Drawing</text>
|
||||||
|
</svg>`;
|
||||||
|
|
||||||
|
await prisma.drawing.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
isPrivate: true,
|
||||||
|
encryptedData,
|
||||||
|
iv,
|
||||||
|
elements: "[]", // Clear plaintext data
|
||||||
|
appState: "{}",
|
||||||
|
files: "{}",
|
||||||
|
preview: lockedPreview,
|
||||||
|
collectionId: null, // Remove from any collection
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to lock drawing:", error);
|
||||||
|
res.status(500).json({ error: "Failed to lock drawing" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /drawings/:id/unlock - Remove drawing from private vault
|
||||||
|
app.put("/drawings/:id/unlock", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { elements, appState, files, preview } = req.body;
|
||||||
|
|
||||||
|
if (!elements || !appState) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: "Elements and appState are required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const drawing = await prisma.drawing.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!drawing) {
|
||||||
|
return res.status(404).json({ error: "Drawing not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!drawing.isPrivate) {
|
||||||
|
return res.status(400).json({ error: "Drawing is not private" });
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.drawing.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
isPrivate: false,
|
||||||
|
encryptedData: null,
|
||||||
|
iv: null,
|
||||||
|
elements: JSON.stringify(elements),
|
||||||
|
appState: JSON.stringify(appState),
|
||||||
|
files: JSON.stringify(files || {}),
|
||||||
|
preview: preview || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to unlock drawing:", error);
|
||||||
|
res.status(500).json({ error: "Failed to unlock drawing" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// --- Drawings ---
|
// --- Drawings ---
|
||||||
|
|
||||||
// GET /drawings
|
// GET /drawings
|
||||||
app.get("/drawings", async (req, res) => {
|
app.get("/drawings", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { search, collectionId } = req.query;
|
const { search, collectionId } = req.query;
|
||||||
const where: any = {};
|
const where: any = {
|
||||||
|
isPrivate: false, // Exclude private drawings from regular listings
|
||||||
|
};
|
||||||
|
|
||||||
if (search) {
|
if (search) {
|
||||||
where.name = { contains: String(search) };
|
where.name = { contains: String(search) };
|
||||||
@@ -534,6 +872,18 @@ app.get("/drawings/:id", async (req, res) => {
|
|||||||
return res.status(404).json({ error: "Drawing not found" });
|
return res.status(404).json({ error: "Drawing not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For private drawings, return encrypted data instead of parsed content
|
||||||
|
if (drawing.isPrivate) {
|
||||||
|
console.log("[API] Returning private drawing", { id });
|
||||||
|
return res.json({
|
||||||
|
...drawing,
|
||||||
|
elements: [], // Empty for private drawings
|
||||||
|
appState: {},
|
||||||
|
files: {},
|
||||||
|
// encryptedData, iv, and isPrivate are already included
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
console.log("[API] Returning drawing", {
|
console.log("[API] Returning drawing", {
|
||||||
id,
|
id,
|
||||||
elementCount: (() => {
|
elementCount: (() => {
|
||||||
|
|||||||
@@ -2,19 +2,24 @@ import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
|
|||||||
import { Dashboard } from './pages/Dashboard';
|
import { Dashboard } from './pages/Dashboard';
|
||||||
import { Editor } from './pages/Editor';
|
import { Editor } from './pages/Editor';
|
||||||
import { Settings } from './pages/Settings';
|
import { Settings } from './pages/Settings';
|
||||||
|
import { PrivateDrawings } from './pages/PrivateDrawings';
|
||||||
import { ThemeProvider } from './context/ThemeContext';
|
import { ThemeProvider } from './context/ThemeContext';
|
||||||
|
import { VaultProvider } from './context/VaultContext';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
|
<VaultProvider>
|
||||||
<Router>
|
<Router>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Dashboard />} />
|
<Route path="/" element={<Dashboard />} />
|
||||||
<Route path="/collections" element={<Dashboard />} />
|
<Route path="/collections" element={<Dashboard />} />
|
||||||
<Route path="/settings" element={<Settings />} />
|
<Route path="/settings" element={<Settings />} />
|
||||||
|
<Route path="/private" element={<PrivateDrawings />} />
|
||||||
<Route path="/editor/:id" element={<Editor />} />
|
<Route path="/editor/:id" element={<Editor />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Router>
|
</Router>
|
||||||
|
</VaultProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import type { Drawing, Collection } from "../types";
|
import type {
|
||||||
|
Drawing,
|
||||||
|
Collection,
|
||||||
|
VaultStatus,
|
||||||
|
VaultVerifyResult,
|
||||||
|
} from "../types";
|
||||||
|
|
||||||
export const API_URL = import.meta.env.VITE_API_URL || "/api";
|
export const API_URL = import.meta.env.VITE_API_URL || "/api";
|
||||||
|
|
||||||
@@ -96,3 +101,91 @@ export const updateLibrary = async (items: any[]) => {
|
|||||||
const response = await api.put<{ items: any[] }>("/library", { items });
|
const response = await api.put<{ items: any[] }>("/library", { items });
|
||||||
return response.data.items;
|
return response.data.items;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// --- Private Vault ---
|
||||||
|
|
||||||
|
export const getVaultStatus = async (): Promise<VaultStatus> => {
|
||||||
|
const response = await api.get<VaultStatus>("/vault/status");
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setupVault = async (
|
||||||
|
passwordHash: string,
|
||||||
|
salt: string,
|
||||||
|
hint?: string
|
||||||
|
): Promise<void> => {
|
||||||
|
await api.post("/vault/setup", { passwordHash, salt, hint });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const verifyVaultPassword = async (
|
||||||
|
password: string
|
||||||
|
): Promise<VaultVerifyResult> => {
|
||||||
|
const response = await api.post<VaultVerifyResult>("/vault/verify", {
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateVaultHint = async (hint: string): Promise<void> => {
|
||||||
|
await api.put("/vault/hint", { hint });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getVaultHint = async (): Promise<string | null> => {
|
||||||
|
const response = await api.get<{ hint: string | null }>("/vault/hint");
|
||||||
|
return response.data.hint;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const changeVaultPassword = async (
|
||||||
|
newPasswordHash: string,
|
||||||
|
newSalt: string,
|
||||||
|
_oldKey: CryptoKey,
|
||||||
|
_newKey: CryptoKey
|
||||||
|
): Promise<void> => {
|
||||||
|
// Note: The actual re-encryption of drawings happens client-side
|
||||||
|
// This endpoint just updates the password hash and salt
|
||||||
|
await api.put("/vault/password", {
|
||||||
|
passwordHash: newPasswordHash,
|
||||||
|
salt: newSalt,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Private Drawings ---
|
||||||
|
|
||||||
|
export const getPrivateDrawings = async (): Promise<Drawing[]> => {
|
||||||
|
const response = await api.get<Drawing[]>("/drawings/private");
|
||||||
|
return response.data.map(deserializeDrawing);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const lockDrawing = async (
|
||||||
|
id: string,
|
||||||
|
encryptedData: string,
|
||||||
|
iv: string
|
||||||
|
): Promise<void> => {
|
||||||
|
await api.put(`/drawings/${id}/lock`, { encryptedData, iv });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const lockDrawingWithPreview = async (
|
||||||
|
id: string,
|
||||||
|
encryptedData: string,
|
||||||
|
iv: string,
|
||||||
|
preview?: string
|
||||||
|
): Promise<void> => {
|
||||||
|
const body: any = { encryptedData, iv };
|
||||||
|
if (preview !== undefined) body.preview = preview;
|
||||||
|
await api.put(`/drawings/${id}/lock`, body);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const unlockDrawing = async (
|
||||||
|
id: string,
|
||||||
|
elements: any[],
|
||||||
|
appState: any,
|
||||||
|
files: any,
|
||||||
|
preview?: string
|
||||||
|
): Promise<void> => {
|
||||||
|
await api.put(`/drawings/${id}/unlock`, {
|
||||||
|
elements,
|
||||||
|
appState,
|
||||||
|
files,
|
||||||
|
preview,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { X, Key, Eye, EyeOff, AlertCircle } from 'lucide-react';
|
||||||
|
import { validatePasswordStrength } from '../utils/crypto';
|
||||||
|
|
||||||
|
interface ChangeVaultPasswordProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onChangePassword: (oldPassword: string, newPassword: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ChangeVaultPassword: React.FC<ChangeVaultPasswordProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onChangePassword,
|
||||||
|
}) => {
|
||||||
|
const [oldPassword, setOldPassword] = useState('');
|
||||||
|
const [newPassword, setNewPassword] = useState('');
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
const [showOld, setShowOld] = useState(false);
|
||||||
|
const [showNew, setShowNew] = useState(false);
|
||||||
|
const [showConfirm, setShowConfirm] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const strength = validatePasswordStrength(newPassword);
|
||||||
|
const passwordsMatch = confirmPassword === newPassword;
|
||||||
|
const canSubmit = oldPassword.length > 0 && newPassword.length > 0 && passwordsMatch && strength.isValid;
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setOldPassword('');
|
||||||
|
setNewPassword('');
|
||||||
|
setConfirmPassword('');
|
||||||
|
setShowOld(false);
|
||||||
|
setShowNew(false);
|
||||||
|
setShowConfirm(false);
|
||||||
|
setError(null);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!canSubmit) return;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await onChangePassword(oldPassword, newPassword);
|
||||||
|
handleClose();
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to change password');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStrengthColor = (score: number) => {
|
||||||
|
if (score <= 1) return 'bg-red-500';
|
||||||
|
if (score === 2) return 'bg-orange-500';
|
||||||
|
if (score === 3) return 'bg-yellow-500';
|
||||||
|
return 'bg-green-500';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStrengthText = (score: number) => {
|
||||||
|
if (score <= 1) return 'Weak';
|
||||||
|
if (score === 2) return 'Fair';
|
||||||
|
if (score === 3) return 'Good';
|
||||||
|
return 'Strong';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||||
|
<div className="bg-white dark:bg-neutral-900 rounded-2xl border-2 border-black dark:border-neutral-700 shadow-[8px_8px_0px_0px_rgba(0,0,0,1)] dark:shadow-[8px_8px_0px_0px_rgba(255,255,255,0.2)] w-full max-w-md mx-4">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between p-6 border-b-2 border-black dark:border-neutral-700">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 bg-amber-100 dark:bg-amber-900/30 rounded-xl flex items-center justify-center">
|
||||||
|
<Key size={20} className="text-amber-600 dark:text-amber-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-slate-900 dark:text-white">Change Vault Password</h2>
|
||||||
|
<p className="text-sm text-slate-500 dark:text-neutral-400">Update your vault password</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
className="p-2 hover:bg-slate-100 dark:hover:bg-neutral-800 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<X size={20} className="text-slate-500 dark:text-neutral-400" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-2 p-3 bg-red-50 dark:bg-red-900/20 border-2 border-red-200 dark:border-red-800 rounded-lg">
|
||||||
|
<AlertCircle size={16} className="text-red-600 dark:text-red-400" />
|
||||||
|
<span className="text-sm text-red-700 dark:text-red-300">{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Old Password */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-sm font-bold text-slate-700 dark:text-neutral-300">Current Password</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type={showOld ? 'text' : 'password'}
|
||||||
|
value={oldPassword}
|
||||||
|
onChange={(e) => setOldPassword(e.target.value)}
|
||||||
|
placeholder="Enter your current password"
|
||||||
|
autoFocus
|
||||||
|
className="w-full px-4 py-3 pr-12 bg-white dark:bg-neutral-800 border-2 border-black dark:border-neutral-700 rounded-lg text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-amber-500"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowOld(!showOld)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-slate-400 hover:text-slate-600 dark:text-neutral-500 dark:hover:text-neutral-300"
|
||||||
|
>
|
||||||
|
{showOld ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* New Password */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-sm font-bold text-slate-700 dark:text-neutral-300">New Password</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type={showNew ? 'text' : 'password'}
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
placeholder="Enter your new password"
|
||||||
|
className="w-full px-4 py-3 pr-12 bg-white dark:bg-neutral-800 border-2 border-black dark:border-neutral-700 rounded-lg text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-amber-500"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowNew(!showNew)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-slate-400 hover:text-slate-600 dark:text-neutral-500 dark:hover:text-neutral-300"
|
||||||
|
>
|
||||||
|
{showNew ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Strength Indicator */}
|
||||||
|
{newPassword.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{[1, 2, 3, 4].map((i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={`h-1.5 flex-1 rounded-full transition-colors ${
|
||||||
|
i <= strength.score ? getStrengthColor(strength.score) : 'bg-slate-200 dark:bg-neutral-700'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className={`text-xs font-medium ${
|
||||||
|
strength.score <= 1 ? 'text-red-600 dark:text-red-400' :
|
||||||
|
strength.score === 2 ? 'text-orange-600 dark:text-orange-400' :
|
||||||
|
strength.score === 3 ? 'text-yellow-600 dark:text-yellow-400' :
|
||||||
|
'text-green-600 dark:text-green-400'
|
||||||
|
}`}>
|
||||||
|
{getStrengthText(strength.score)}
|
||||||
|
</p>
|
||||||
|
{strength.feedback.length > 0 && (
|
||||||
|
<ul className="text-xs text-slate-500 dark:text-neutral-400 space-y-1">
|
||||||
|
{strength.feedback.map((fb, i) => (
|
||||||
|
<li key={i}>• {fb}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Confirm Password */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-sm font-bold text-slate-700 dark:text-neutral-300">Confirm New Password</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type={showConfirm ? 'text' : 'password'}
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
placeholder="Confirm your new password"
|
||||||
|
className={`w-full px-4 py-3 pr-12 bg-white dark:bg-neutral-800 border-2 rounded-lg text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-amber-500 ${
|
||||||
|
confirmPassword.length > 0 && !passwordsMatch ? 'border-red-500' : 'border-black dark:border-neutral-700'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowConfirm(!showConfirm)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-slate-400 hover:text-slate-600 dark:text-neutral-500 dark:hover:text-neutral-300"
|
||||||
|
>
|
||||||
|
{showConfirm ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClose}
|
||||||
|
className="flex-1 px-4 py-3 bg-slate-100 dark:bg-neutral-800 border-2 border-black dark:border-neutral-700 rounded-lg font-bold text-slate-700 dark:text-neutral-300 hover:bg-slate-200 dark:hover:bg-neutral-700 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!canSubmit || isLoading}
|
||||||
|
className="flex-1 px-4 py-3 bg-indigo-500 border-2 border-black dark:border-indigo-600 rounded-lg font-bold text-white hover:bg-indigo-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:shadow-[4px_4px_0px_0px_rgba(79,70,229,0.5)]"
|
||||||
|
>
|
||||||
|
{isLoading ? 'Changing...' : 'Change Password'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { PenTool, Trash2, FolderInput, ArrowRight, Check, Clock, Copy, Download } from 'lucide-react';
|
import { PenTool, Trash2, FolderInput, ArrowRight, Check, Clock, Copy, Download, Lock } from 'lucide-react';
|
||||||
import type { Drawing, Collection } from '../types';
|
import type { Drawing, Collection } from '../types';
|
||||||
import { formatDistanceToNow } from 'date-fns';
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
@@ -24,6 +24,8 @@ interface DrawingCardProps {
|
|||||||
onDragStart?: (e: React.DragEvent, id: string) => void;
|
onDragStart?: (e: React.DragEvent, id: string) => void;
|
||||||
onMouseDown?: (e: React.MouseEvent, id: string) => void;
|
onMouseDown?: (e: React.MouseEvent, id: string) => void;
|
||||||
onPreviewGenerated?: (id: string, preview: string) => void;
|
onPreviewGenerated?: (id: string, preview: string) => void;
|
||||||
|
onMoveToVault?: (id: string) => void;
|
||||||
|
isVaultSetup?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ContextMenuPortal: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
const ContextMenuPortal: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
@@ -44,6 +46,8 @@ export const DrawingCard: React.FC<DrawingCardProps> = ({
|
|||||||
onDragStart,
|
onDragStart,
|
||||||
onMouseDown,
|
onMouseDown,
|
||||||
onPreviewGenerated,
|
onPreviewGenerated,
|
||||||
|
onMoveToVault,
|
||||||
|
isVaultSetup = false,
|
||||||
}) => {
|
}) => {
|
||||||
const [isRenaming, setIsRenaming] = useState(false);
|
const [isRenaming, setIsRenaming] = useState(false);
|
||||||
const [showMoveSubmenu, setShowMoveSubmenu] = useState(false);
|
const [showMoveSubmenu, setShowMoveSubmenu] = useState(false);
|
||||||
@@ -336,6 +340,18 @@ export const DrawingCard: React.FC<DrawingCardProps> = ({
|
|||||||
<Download size={14} /> Export
|
<Download size={14} /> Export
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{isVaultSetup && onMoveToVault && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
onMoveToVault(drawing.id);
|
||||||
|
setContextMenu(null);
|
||||||
|
}}
|
||||||
|
className="w-full px-3 py-2 text-sm text-left text-amber-600 dark:text-amber-400 hover:bg-amber-50 dark:hover:bg-amber-900/30 flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Lock size={14} /> Move to Private Vault
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="border-t border-slate-50 dark:border-slate-700 my-1"></div>
|
<div className="border-t border-slate-50 dark:border-slate-700 my-1"></div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ interface LayoutProps {
|
|||||||
onEditCollection: (id: string, name: string) => void;
|
onEditCollection: (id: string, name: string) => void;
|
||||||
onDeleteCollection: (id: string) => void;
|
onDeleteCollection: (id: string) => void;
|
||||||
onDrop?: (e: React.DragEvent, collectionId: string | null) => void;
|
onDrop?: (e: React.DragEvent, collectionId: string | null) => void;
|
||||||
|
onDropToVault?: (e: React.DragEvent) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Layout: React.FC<LayoutProps> = ({
|
export const Layout: React.FC<LayoutProps> = ({
|
||||||
@@ -21,7 +22,8 @@ export const Layout: React.FC<LayoutProps> = ({
|
|||||||
onCreateCollection,
|
onCreateCollection,
|
||||||
onEditCollection,
|
onEditCollection,
|
||||||
onDeleteCollection,
|
onDeleteCollection,
|
||||||
onDrop
|
onDrop,
|
||||||
|
onDropToVault
|
||||||
}) => {
|
}) => {
|
||||||
const [sidebarWidth, setSidebarWidth] = useState(260);
|
const [sidebarWidth, setSidebarWidth] = useState(260);
|
||||||
const [isResizing, setIsResizing] = useState(false);
|
const [isResizing, setIsResizing] = useState(false);
|
||||||
@@ -76,6 +78,7 @@ export const Layout: React.FC<LayoutProps> = ({
|
|||||||
onEditCollection={onEditCollection}
|
onEditCollection={onEditCollection}
|
||||||
onDeleteCollection={onDeleteCollection}
|
onDeleteCollection={onDeleteCollection}
|
||||||
onDrop={onDrop}
|
onDrop={onDrop}
|
||||||
|
onDropToVault={onDropToVault}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Resize Handle */}
|
{/* Resize Handle */}
|
||||||
|
|||||||
@@ -0,0 +1,239 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { X, Lock, Eye, EyeOff, AlertCircle, CheckCircle2 } from 'lucide-react';
|
||||||
|
import { validatePasswordStrength } from '../utils/crypto';
|
||||||
|
|
||||||
|
interface PrivateVaultSetupProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSetup: (password: string, hint?: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PrivateVaultSetup: React.FC<PrivateVaultSetupProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onSetup,
|
||||||
|
}) => {
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
const [hint, setHint] = useState('');
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const passwordStrength = validatePasswordStrength(password);
|
||||||
|
const passwordsMatch = password === confirmPassword;
|
||||||
|
const canSubmit = passwordStrength.isValid && passwordsMatch && password.length > 0;
|
||||||
|
|
||||||
|
const getStrengthColor = (score: number) => {
|
||||||
|
if (score <= 1) return 'bg-red-500';
|
||||||
|
if (score === 2) return 'bg-orange-500';
|
||||||
|
if (score === 3) return 'bg-yellow-500';
|
||||||
|
return 'bg-green-500';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStrengthText = (score: number) => {
|
||||||
|
if (score <= 1) return 'Weak';
|
||||||
|
if (score === 2) return 'Fair';
|
||||||
|
if (score === 3) return 'Good';
|
||||||
|
return 'Strong';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!canSubmit) return;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await onSetup(password, hint || undefined);
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to set up vault');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setPassword('');
|
||||||
|
setConfirmPassword('');
|
||||||
|
setHint('');
|
||||||
|
setError(null);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||||
|
<div className="bg-white dark:bg-neutral-900 rounded-2xl border-2 border-black dark:border-neutral-700 shadow-[8px_8px_0px_0px_rgba(0,0,0,1)] dark:shadow-[8px_8px_0px_0px_rgba(255,255,255,0.2)] w-full max-w-md mx-4">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between p-6 border-b-2 border-black dark:border-neutral-700">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 bg-indigo-100 dark:bg-indigo-900/30 rounded-xl flex items-center justify-center">
|
||||||
|
<Lock size={20} className="text-indigo-600 dark:text-indigo-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-slate-900 dark:text-white">Set Up Private Vault</h2>
|
||||||
|
<p className="text-sm text-slate-500 dark:text-neutral-400">Protect your drawings with a password</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
className="p-2 hover:bg-slate-100 dark:hover:bg-neutral-800 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<X size={20} className="text-slate-500 dark:text-neutral-400" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-2 p-3 bg-red-50 dark:bg-red-900/20 border-2 border-red-200 dark:border-red-800 rounded-lg">
|
||||||
|
<AlertCircle size={16} className="text-red-600 dark:text-red-400" />
|
||||||
|
<span className="text-sm text-red-700 dark:text-red-300">{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Password */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-sm font-bold text-slate-700 dark:text-neutral-300">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="Enter your password"
|
||||||
|
className="w-full px-4 py-3 pr-12 bg-white dark:bg-neutral-800 border-2 border-black dark:border-neutral-700 rounded-lg text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-slate-400 hover:text-slate-600 dark:text-neutral-500 dark:hover:text-neutral-300"
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Strength Indicator */}
|
||||||
|
{password.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{[1, 2, 3, 4].map((i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={`h-1.5 flex-1 rounded-full transition-colors ${
|
||||||
|
i <= passwordStrength.score ? getStrengthColor(passwordStrength.score) : 'bg-slate-200 dark:bg-neutral-700'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className={`text-xs font-medium ${
|
||||||
|
passwordStrength.score <= 1 ? 'text-red-600 dark:text-red-400' :
|
||||||
|
passwordStrength.score === 2 ? 'text-orange-600 dark:text-orange-400' :
|
||||||
|
passwordStrength.score === 3 ? 'text-yellow-600 dark:text-yellow-400' :
|
||||||
|
'text-green-600 dark:text-green-400'
|
||||||
|
}`}>
|
||||||
|
{getStrengthText(passwordStrength.score)}
|
||||||
|
</p>
|
||||||
|
{passwordStrength.feedback.length > 0 && (
|
||||||
|
<ul className="text-xs text-slate-500 dark:text-neutral-400 space-y-1">
|
||||||
|
{passwordStrength.feedback.map((feedback, i) => (
|
||||||
|
<li key={i}>• {feedback}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Confirm Password */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-sm font-bold text-slate-700 dark:text-neutral-300">
|
||||||
|
Confirm Password
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type={showConfirmPassword ? 'text' : 'password'}
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
placeholder="Confirm your password"
|
||||||
|
className={`w-full px-4 py-3 pr-12 bg-white dark:bg-neutral-800 border-2 rounded-lg text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 ${
|
||||||
|
confirmPassword.length > 0 && !passwordsMatch
|
||||||
|
? 'border-red-500'
|
||||||
|
: 'border-black dark:border-neutral-700'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-slate-400 hover:text-slate-600 dark:text-neutral-500 dark:hover:text-neutral-300"
|
||||||
|
>
|
||||||
|
{showConfirmPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{confirmPassword.length > 0 && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{passwordsMatch ? (
|
||||||
|
<>
|
||||||
|
<CheckCircle2 size={14} className="text-green-600 dark:text-green-400" />
|
||||||
|
<span className="text-xs text-green-600 dark:text-green-400">Passwords match</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<AlertCircle size={14} className="text-red-600 dark:text-red-400" />
|
||||||
|
<span className="text-xs text-red-600 dark:text-red-400">Passwords do not match</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Password Hint (Optional) */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-sm font-bold text-slate-700 dark:text-neutral-300">
|
||||||
|
Password Hint <span className="font-normal text-slate-400 dark:text-neutral-500">(optional)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={hint}
|
||||||
|
onChange={(e) => setHint(e.target.value)}
|
||||||
|
placeholder="A hint to help you remember"
|
||||||
|
className="w-full px-4 py-3 bg-white dark:bg-neutral-800 border-2 border-black dark:border-neutral-700 rounded-lg text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Warning */}
|
||||||
|
<div className="p-3 bg-amber-50 dark:bg-amber-900/20 border-2 border-amber-200 dark:border-amber-800 rounded-lg">
|
||||||
|
<p className="text-xs text-amber-700 dark:text-amber-300">
|
||||||
|
<strong>Important:</strong> There is no way to recover your password. If you forget it,
|
||||||
|
all private drawings will be permanently inaccessible.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClose}
|
||||||
|
className="flex-1 px-4 py-3 bg-slate-100 dark:bg-neutral-800 border-2 border-black dark:border-neutral-700 rounded-lg font-bold text-slate-700 dark:text-neutral-300 hover:bg-slate-200 dark:hover:bg-neutral-700 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!canSubmit || isLoading}
|
||||||
|
className="flex-1 px-4 py-3 bg-indigo-500 border-2 border-black dark:border-indigo-600 rounded-lg font-bold text-white hover:bg-indigo-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:shadow-[4px_4px_0px_0px_rgba(79,70,229,0.5)]"
|
||||||
|
>
|
||||||
|
{isLoading ? 'Setting up...' : 'Set Up Vault'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { LayoutGrid, Folder, Plus, Trash2, Edit2, Archive, FolderOpen, Settings as SettingsIcon } from 'lucide-react';
|
import { LayoutGrid, Folder, Plus, Trash2, Edit2, Archive, FolderOpen, Settings as SettingsIcon, Lock, Unlock } from 'lucide-react';
|
||||||
import type { Collection } from '../types';
|
import type { Collection } from '../types';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { ConfirmModal } from './ConfirmModal';
|
import { ConfirmModal } from './ConfirmModal';
|
||||||
import { Logo } from './Logo';
|
import { Logo } from './Logo';
|
||||||
|
import { useVault } from '../context/VaultContext';
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
collections: Collection[];
|
collections: Collection[];
|
||||||
@@ -14,6 +15,7 @@ interface SidebarProps {
|
|||||||
onEditCollection: (id: string, name: string) => void;
|
onEditCollection: (id: string, name: string) => void;
|
||||||
onDeleteCollection: (id: string) => void;
|
onDeleteCollection: (id: string) => void;
|
||||||
onDrop?: (e: React.DragEvent, collectionId: string | null) => void;
|
onDrop?: (e: React.DragEvent, collectionId: string | null) => void;
|
||||||
|
onDropToVault?: (e: React.DragEvent) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SidebarItemProps {
|
interface SidebarItemProps {
|
||||||
@@ -109,6 +111,98 @@ const SidebarItem: React.FC<SidebarItemProps> = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Private Folder Item Component
|
||||||
|
const PrivateFolderItem: React.FC<{
|
||||||
|
isActive: boolean;
|
||||||
|
onSelectCollection: (id: string | null | undefined) => void;
|
||||||
|
onDropToVault?: (e: React.DragEvent) => void;
|
||||||
|
}> = ({ isActive, onSelectCollection, onDropToVault }) => {
|
||||||
|
const vault = useVault();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
|
|
||||||
|
// Don't show if still loading
|
||||||
|
if (vault.isLoading) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
// Always navigate to /private - that page handles setup/unlock flow
|
||||||
|
onSelectCollection('private');
|
||||||
|
navigate('/private');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
// Show drag over state for any drag (the actual check happens on drop)
|
||||||
|
setIsDragOver(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragLeave = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsDragOver(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsDragOver(false);
|
||||||
|
onDropToVault?.(e);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Show different badge based on vault state
|
||||||
|
const getBadge = () => {
|
||||||
|
if (isDragOver) {
|
||||||
|
return (
|
||||||
|
<span className="text-xs bg-amber-200 dark:bg-amber-800 text-amber-800 dark:text-amber-200 px-1.5 py-0.5 rounded-md font-bold animate-pulse">
|
||||||
|
Drop
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!vault.isSetup) {
|
||||||
|
return (
|
||||||
|
<span className="text-xs bg-indigo-100 dark:bg-indigo-900/50 text-indigo-600 dark:text-indigo-400 px-1.5 py-0.5 rounded-md font-bold">
|
||||||
|
Setup
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (vault.privateDrawingsCount > 0) {
|
||||||
|
return (
|
||||||
|
<span className="text-xs bg-amber-100 dark:bg-amber-900/50 text-amber-700 dark:text-amber-300 px-1.5 py-0.5 rounded-md font-bold">
|
||||||
|
{vault.privateDrawingsCount}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pl-3 pr-2">
|
||||||
|
<button
|
||||||
|
onClick={handleClick}
|
||||||
|
onDragOver={vault.isSetup ? handleDragOver : undefined}
|
||||||
|
onDragLeave={vault.isSetup ? handleDragLeave : undefined}
|
||||||
|
onDrop={vault.isSetup ? handleDrop : undefined}
|
||||||
|
className={clsx(
|
||||||
|
"w-full flex items-center gap-3 px-3 py-2.5 text-sm font-bold rounded-lg transition-all duration-200 border-2",
|
||||||
|
isActive || isDragOver
|
||||||
|
? "bg-amber-50 dark:bg-amber-900/30 text-amber-900 dark:text-amber-300 border-black dark:border-neutral-700 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] -translate-y-0.5"
|
||||||
|
: "text-slate-600 dark:text-neutral-400 border-transparent hover:bg-amber-50 dark:hover:bg-amber-900/30 hover:text-amber-900 dark:hover:text-amber-300 hover:border-black dark:hover:border-neutral-700 hover:shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-0.5"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{vault.isSetup && vault.isUnlocked ? (
|
||||||
|
<Unlock size={18} className={clsx(isActive || isDragOver ? "text-amber-900 dark:text-amber-300" : "text-amber-500 dark:text-amber-400")} />
|
||||||
|
) : (
|
||||||
|
<Lock size={18} className={clsx(isActive || isDragOver ? "text-amber-900 dark:text-amber-300" : "text-amber-500 dark:text-amber-400")} />
|
||||||
|
)}
|
||||||
|
<span className="min-w-0 flex-1 text-left">Private</span>
|
||||||
|
{getBadge()}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
export const Sidebar: React.FC<SidebarProps> = ({
|
export const Sidebar: React.FC<SidebarProps> = ({
|
||||||
@@ -118,7 +212,8 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
|||||||
onCreateCollection,
|
onCreateCollection,
|
||||||
onEditCollection,
|
onEditCollection,
|
||||||
onDeleteCollection,
|
onDeleteCollection,
|
||||||
onDrop
|
onDrop,
|
||||||
|
onDropToVault
|
||||||
}) => {
|
}) => {
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
const [newCollectionName, setNewCollectionName] = useState('');
|
const [newCollectionName, setNewCollectionName] = useState('');
|
||||||
@@ -206,6 +301,12 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
|||||||
onClick={() => onSelectCollection(null)}
|
onClick={() => onSelectCollection(null)}
|
||||||
onDrop={onDrop}
|
onDrop={onDrop}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<PrivateFolderItem
|
||||||
|
isActive={selectedCollectionId === 'private'}
|
||||||
|
onSelectCollection={onSelectCollection}
|
||||||
|
onDropToVault={onDropToVault}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { X, Lock, Eye, EyeOff, AlertCircle, HelpCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
interface UnlockVaultModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onUnlock: (password: string) => Promise<boolean>;
|
||||||
|
passwordHint?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UnlockVaultModal: React.FC<UnlockVaultModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onUnlock,
|
||||||
|
passwordHint,
|
||||||
|
}) => {
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [showHint, setShowHint] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!password) return;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const success = await onUnlock(password);
|
||||||
|
if (success) {
|
||||||
|
setPassword('');
|
||||||
|
onClose();
|
||||||
|
} else {
|
||||||
|
setError('Incorrect password');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to unlock vault');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setPassword('');
|
||||||
|
setError(null);
|
||||||
|
setShowHint(false);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||||
|
<div className="bg-white dark:bg-neutral-900 rounded-2xl border-2 border-black dark:border-neutral-700 shadow-[8px_8px_0px_0px_rgba(0,0,0,1)] dark:shadow-[8px_8px_0px_0px_rgba(255,255,255,0.2)] w-full max-w-sm mx-4">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between p-6 border-b-2 border-black dark:border-neutral-700">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 bg-amber-100 dark:bg-amber-900/30 rounded-xl flex items-center justify-center">
|
||||||
|
<Lock size={20} className="text-amber-600 dark:text-amber-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-slate-900 dark:text-white">Unlock Vault</h2>
|
||||||
|
<p className="text-sm text-slate-500 dark:text-neutral-400">Enter your password</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
className="p-2 hover:bg-slate-100 dark:hover:bg-neutral-800 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<X size={20} className="text-slate-500 dark:text-neutral-400" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-2 p-3 bg-red-50 dark:bg-red-900/20 border-2 border-red-200 dark:border-red-800 rounded-lg">
|
||||||
|
<AlertCircle size={16} className="text-red-600 dark:text-red-400" />
|
||||||
|
<span className="text-sm text-red-700 dark:text-red-300">{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Password */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-sm font-bold text-slate-700 dark:text-neutral-300">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="Enter your password"
|
||||||
|
autoFocus
|
||||||
|
className="w-full px-4 py-3 pr-12 bg-white dark:bg-neutral-800 border-2 border-black dark:border-neutral-700 rounded-lg text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-amber-500"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-slate-400 hover:text-slate-600 dark:text-neutral-500 dark:hover:text-neutral-300"
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Password Hint */}
|
||||||
|
{passwordHint && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowHint(!showHint)}
|
||||||
|
className="flex items-center gap-1.5 text-sm text-slate-500 dark:text-neutral-400 hover:text-slate-700 dark:hover:text-neutral-300 transition-colors"
|
||||||
|
>
|
||||||
|
<HelpCircle size={14} />
|
||||||
|
<span>{showHint ? 'Hide hint' : 'Show password hint'}</span>
|
||||||
|
</button>
|
||||||
|
{showHint && (
|
||||||
|
<div className="p-3 bg-slate-100 dark:bg-neutral-800 border-2 border-slate-200 dark:border-neutral-700 rounded-lg">
|
||||||
|
<p className="text-sm text-slate-600 dark:text-neutral-300">{passwordHint}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClose}
|
||||||
|
className="flex-1 px-4 py-3 bg-slate-100 dark:bg-neutral-800 border-2 border-black dark:border-neutral-700 rounded-lg font-bold text-slate-700 dark:text-neutral-300 hover:bg-slate-200 dark:hover:bg-neutral-700 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!password || isLoading}
|
||||||
|
className="flex-1 px-4 py-3 bg-amber-500 border-2 border-black dark:border-amber-600 rounded-lg font-bold text-white hover:bg-amber-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:shadow-[4px_4px_0px_0px_rgba(245,158,11,0.5)]"
|
||||||
|
>
|
||||||
|
{isLoading ? 'Unlocking...' : 'Unlock'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
import React, { createContext, useContext, useState, useCallback, useEffect, useRef } from 'react';
|
||||||
|
import { deriveKey, hexToBytes, generateSalt, bytesToHex, hashPassword } from '../utils/crypto';
|
||||||
|
import * as api from '../api';
|
||||||
|
|
||||||
|
interface VaultState {
|
||||||
|
isSetup: boolean;
|
||||||
|
isUnlocked: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
passwordHint: string | null;
|
||||||
|
privateDrawingsCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VaultContextType extends VaultState {
|
||||||
|
sessionKey: CryptoKey | null;
|
||||||
|
salt: Uint8Array | null;
|
||||||
|
checkVaultStatus: () => Promise<void>;
|
||||||
|
unlock: (password: string) => Promise<boolean>;
|
||||||
|
lock: () => void;
|
||||||
|
setupVault: (password: string, hint?: string) => Promise<void>;
|
||||||
|
changePassword: (oldPassword: string, newPassword: string) => Promise<void>;
|
||||||
|
updateHint: (hint: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VaultContext = createContext<VaultContextType | null>(null);
|
||||||
|
|
||||||
|
// Auto-lock timeout in milliseconds (15 minutes)
|
||||||
|
const AUTO_LOCK_TIMEOUT = 15 * 60 * 1000;
|
||||||
|
|
||||||
|
export const VaultProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
const [state, setState] = useState<VaultState>({
|
||||||
|
isSetup: false,
|
||||||
|
isUnlocked: false,
|
||||||
|
isLoading: true,
|
||||||
|
passwordHint: null,
|
||||||
|
privateDrawingsCount: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [sessionKey, setSessionKey] = useState<CryptoKey | null>(null);
|
||||||
|
const [salt, setSalt] = useState<Uint8Array | null>(null);
|
||||||
|
const autoLockTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
// Reset auto-lock timer on activity
|
||||||
|
const resetAutoLockTimer = useCallback(() => {
|
||||||
|
if (autoLockTimer.current) {
|
||||||
|
clearTimeout(autoLockTimer.current);
|
||||||
|
}
|
||||||
|
if (state.isUnlocked) {
|
||||||
|
autoLockTimer.current = setTimeout(() => {
|
||||||
|
lock();
|
||||||
|
}, AUTO_LOCK_TIMEOUT);
|
||||||
|
}
|
||||||
|
}, [state.isUnlocked]);
|
||||||
|
|
||||||
|
// Set up activity listeners for auto-lock
|
||||||
|
useEffect(() => {
|
||||||
|
if (state.isUnlocked) {
|
||||||
|
const handleActivity = () => resetAutoLockTimer();
|
||||||
|
window.addEventListener('mousemove', handleActivity);
|
||||||
|
window.addEventListener('keydown', handleActivity);
|
||||||
|
window.addEventListener('click', handleActivity);
|
||||||
|
resetAutoLockTimer();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('mousemove', handleActivity);
|
||||||
|
window.removeEventListener('keydown', handleActivity);
|
||||||
|
window.removeEventListener('click', handleActivity);
|
||||||
|
if (autoLockTimer.current) {
|
||||||
|
clearTimeout(autoLockTimer.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [state.isUnlocked, resetAutoLockTimer]);
|
||||||
|
|
||||||
|
// Check vault status on mount
|
||||||
|
const checkVaultStatus = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setState(prev => ({ ...prev, isLoading: true }));
|
||||||
|
const status = await api.getVaultStatus();
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
isSetup: status.isSetup,
|
||||||
|
passwordHint: status.hint || null,
|
||||||
|
privateDrawingsCount: status.privateDrawingsCount || 0,
|
||||||
|
isLoading: false,
|
||||||
|
}));
|
||||||
|
if (status.salt) {
|
||||||
|
setSalt(hexToBytes(status.salt));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to check vault status:', error);
|
||||||
|
setState(prev => ({ ...prev, isLoading: false }));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
checkVaultStatus();
|
||||||
|
}, [checkVaultStatus]);
|
||||||
|
|
||||||
|
// Lock the vault
|
||||||
|
const lock = useCallback(() => {
|
||||||
|
setSessionKey(null);
|
||||||
|
setState(prev => ({ ...prev, isUnlocked: false }));
|
||||||
|
if (autoLockTimer.current) {
|
||||||
|
clearTimeout(autoLockTimer.current);
|
||||||
|
autoLockTimer.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Unlock the vault with password
|
||||||
|
const unlock = useCallback(async (password: string): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
// Hash the password the same way we did during setup
|
||||||
|
const passwordHash = await hashPassword(password);
|
||||||
|
|
||||||
|
// First verify the password with the server
|
||||||
|
const result = await api.verifyVaultPassword(passwordHash);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive the encryption key client-side
|
||||||
|
const saltBytes = hexToBytes(result.salt);
|
||||||
|
const key = await deriveKey(password, saltBytes);
|
||||||
|
|
||||||
|
setSalt(saltBytes);
|
||||||
|
setSessionKey(key);
|
||||||
|
setState(prev => ({ ...prev, isUnlocked: true }));
|
||||||
|
resetAutoLockTimer();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to unlock vault:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, [resetAutoLockTimer]);
|
||||||
|
|
||||||
|
// Setup the vault with initial password
|
||||||
|
const setupVault = useCallback(async (password: string, hint?: string): Promise<void> => {
|
||||||
|
try {
|
||||||
|
// Generate a new salt
|
||||||
|
const newSalt = generateSalt();
|
||||||
|
const saltHex = bytesToHex(newSalt);
|
||||||
|
|
||||||
|
// Hash the password for server storage
|
||||||
|
const passwordHash = await hashPassword(password);
|
||||||
|
|
||||||
|
// Create vault on server
|
||||||
|
await api.setupVault(passwordHash, saltHex, hint);
|
||||||
|
|
||||||
|
// Derive the encryption key
|
||||||
|
const key = await deriveKey(password, newSalt);
|
||||||
|
|
||||||
|
setSalt(newSalt);
|
||||||
|
setSessionKey(key);
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
isSetup: true,
|
||||||
|
isUnlocked: true,
|
||||||
|
passwordHint: hint || null,
|
||||||
|
}));
|
||||||
|
resetAutoLockTimer();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to setup vault:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}, [resetAutoLockTimer]);
|
||||||
|
|
||||||
|
// Change vault password (requires re-encrypting all private drawings)
|
||||||
|
const changePassword = useCallback(async (oldPassword: string, newPassword: string): Promise<void> => {
|
||||||
|
try {
|
||||||
|
// Hash the old password the same way we did during setup
|
||||||
|
const oldPasswordHash = await hashPassword(oldPassword);
|
||||||
|
|
||||||
|
// Verify old password first
|
||||||
|
const verifyResult = await api.verifyVaultPassword(oldPasswordHash);
|
||||||
|
if (!verifyResult.success) {
|
||||||
|
throw new Error('Invalid current password');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive old key for decryption
|
||||||
|
const oldSalt = hexToBytes(verifyResult.salt);
|
||||||
|
const oldKey = await deriveKey(oldPassword, oldSalt);
|
||||||
|
|
||||||
|
// Generate new salt and derive new key
|
||||||
|
const newSalt = generateSalt();
|
||||||
|
const newSaltHex = bytesToHex(newSalt);
|
||||||
|
const newKey = await deriveKey(newPassword, newSalt);
|
||||||
|
const newPasswordHash = await hashPassword(newPassword);
|
||||||
|
|
||||||
|
// Re-encrypt all private drawings
|
||||||
|
await api.changeVaultPassword(newPasswordHash, newSaltHex, oldKey, newKey);
|
||||||
|
|
||||||
|
// Update local state
|
||||||
|
setSalt(newSalt);
|
||||||
|
setSessionKey(newKey);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to change password:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Update password hint
|
||||||
|
const updateHint = useCallback(async (hint: string): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await api.updateVaultHint(hint);
|
||||||
|
setState(prev => ({ ...prev, passwordHint: hint }));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update hint:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VaultContext.Provider
|
||||||
|
value={{
|
||||||
|
...state,
|
||||||
|
sessionKey,
|
||||||
|
salt,
|
||||||
|
checkVaultStatus,
|
||||||
|
unlock,
|
||||||
|
lock,
|
||||||
|
setupVault,
|
||||||
|
changePassword,
|
||||||
|
updateHint,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</VaultContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useVault = (): VaultContextType => {
|
||||||
|
const context = useContext(VaultContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useVault must be used within a VaultProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
@@ -10,6 +10,9 @@ import { useDebounce } from '../hooks/useDebounce';
|
|||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { ConfirmModal } from '../components/ConfirmModal';
|
import { ConfirmModal } from '../components/ConfirmModal';
|
||||||
import { importDrawings } from '../utils/importUtils';
|
import { importDrawings } from '../utils/importUtils';
|
||||||
|
import { useVault } from '../context/VaultContext';
|
||||||
|
import { encryptDrawing, generateLockedPreview } from '../utils/crypto';
|
||||||
|
import { UnlockVaultModal } from '../components/UnlockVaultModal';
|
||||||
|
|
||||||
type Point = { x: number; y: number };
|
type Point = { x: number; y: number };
|
||||||
|
|
||||||
@@ -102,6 +105,11 @@ export const Dashboard: React.FC = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
// Vault state
|
||||||
|
const vault = useVault();
|
||||||
|
const [drawingToMoveToVault, setDrawingToMoveToVault] = useState<string | null>(null);
|
||||||
|
const [showVaultUnlockModal, setShowVaultUnlockModal] = useState(false);
|
||||||
// navigate is already declared at the top
|
// navigate is already declared at the top
|
||||||
|
|
||||||
const refreshData = useCallback(async () => {
|
const refreshData = useCallback(async () => {
|
||||||
@@ -472,6 +480,106 @@ export const Dashboard: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Move drawing to private vault
|
||||||
|
const handleMoveToVault = useCallback(async (id: string) => {
|
||||||
|
// If vault isn't set up, redirect to settings
|
||||||
|
if (!vault.isSetup) {
|
||||||
|
navigate('/settings');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If vault isn't unlocked, show unlock modal
|
||||||
|
if (!vault.isUnlocked || !vault.sessionKey) {
|
||||||
|
setDrawingToMoveToVault(id);
|
||||||
|
setShowVaultUnlockModal(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Proceed with encryption
|
||||||
|
await encryptAndMoveToVault(id);
|
||||||
|
}, [vault.isSetup, vault.isUnlocked, vault.sessionKey, navigate]);
|
||||||
|
|
||||||
|
const encryptAndMoveToVault = useCallback(async (id: string) => {
|
||||||
|
if (!vault.sessionKey) return;
|
||||||
|
|
||||||
|
const drawing = drawings.find(d => d.id === id);
|
||||||
|
if (!drawing) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Encrypt the drawing data
|
||||||
|
const dataToEncrypt = {
|
||||||
|
elements: drawing.elements || [],
|
||||||
|
appState: drawing.appState || {},
|
||||||
|
files: drawing.files || {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const { encryptedData, iv } = await encryptDrawing(dataToEncrypt, vault.sessionKey);
|
||||||
|
const lockedPreview = generateLockedPreview();
|
||||||
|
|
||||||
|
// Update drawing to be private with encrypted data (include locked preview)
|
||||||
|
await api.lockDrawingWithPreview(id, encryptedData, iv, lockedPreview);
|
||||||
|
|
||||||
|
// Remove from current view
|
||||||
|
setDrawings(prev => prev.filter(d => d.id !== id));
|
||||||
|
vault.checkVaultStatus(); // Refresh vault status to update count
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to move to vault:", err);
|
||||||
|
}
|
||||||
|
}, [vault.sessionKey, drawings, vault]);
|
||||||
|
|
||||||
|
const handleVaultUnlockForMove = useCallback(async (password: string) => {
|
||||||
|
const success = await vault.unlock(password);
|
||||||
|
if (success && drawingToMoveToVault) {
|
||||||
|
setShowVaultUnlockModal(false);
|
||||||
|
await encryptAndMoveToVault(drawingToMoveToVault);
|
||||||
|
setDrawingToMoveToVault(null);
|
||||||
|
}
|
||||||
|
return success;
|
||||||
|
}, [vault, drawingToMoveToVault, encryptAndMoveToVault]);
|
||||||
|
|
||||||
|
// Handle dropping drawings to the vault
|
||||||
|
const handleDropToVault = useCallback(async (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
const draggedDrawingId = e.dataTransfer.getData('drawingId');
|
||||||
|
if (!draggedDrawingId) return;
|
||||||
|
|
||||||
|
// Collect IDs to move - if dragged item is selected, move all selected
|
||||||
|
let idsToMove: string[] = [];
|
||||||
|
if (selectedIds.has(draggedDrawingId)) {
|
||||||
|
idsToMove = Array.from(selectedIds);
|
||||||
|
} else {
|
||||||
|
idsToMove = [draggedDrawingId];
|
||||||
|
}
|
||||||
|
|
||||||
|
// If vault isn't set up, redirect to settings
|
||||||
|
if (!vault.isSetup) {
|
||||||
|
navigate('/settings');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If vault isn't unlocked, show unlock modal for the first drawing
|
||||||
|
if (!vault.isUnlocked || !vault.sessionKey) {
|
||||||
|
// Store first ID and show unlock modal
|
||||||
|
setDrawingToMoveToVault(idsToMove[0]);
|
||||||
|
setShowVaultUnlockModal(true);
|
||||||
|
// Note: For bulk moves when vault is locked, we only move the first one after unlock
|
||||||
|
// A more sophisticated approach would store all IDs, but this keeps it simple
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move all drawings to vault
|
||||||
|
for (const id of idsToMove) {
|
||||||
|
await encryptAndMoveToVault(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear selection if we moved selected items
|
||||||
|
if (selectedIds.has(draggedDrawingId)) {
|
||||||
|
setSelectedIds(new Set());
|
||||||
|
}
|
||||||
|
}, [selectedIds, vault.isSetup, vault.isUnlocked, vault.sessionKey, navigate, encryptAndMoveToVault]);
|
||||||
|
|
||||||
const handleBulkDuplicate = async () => {
|
const handleBulkDuplicate = async () => {
|
||||||
if (selectedIds.size === 0) return;
|
if (selectedIds.size === 0) return;
|
||||||
|
|
||||||
@@ -635,6 +743,7 @@ export const Dashboard: React.FC = () => {
|
|||||||
onEditCollection={handleEditCollection}
|
onEditCollection={handleEditCollection}
|
||||||
onDeleteCollection={handleDeleteCollection}
|
onDeleteCollection={handleDeleteCollection}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
|
onDropToVault={handleDropToVault}
|
||||||
>
|
>
|
||||||
{/* Drag Preview */}
|
{/* Drag Preview */}
|
||||||
<div
|
<div
|
||||||
@@ -912,6 +1021,7 @@ export const Dashboard: React.FC = () => {
|
|||||||
drawing={drawing}
|
drawing={drawing}
|
||||||
collections={collections}
|
collections={collections}
|
||||||
isSelected={selectedIds.has(drawing.id)}
|
isSelected={selectedIds.has(drawing.id)}
|
||||||
|
isTrash={isTrashView}
|
||||||
onToggleSelection={(e) => handleToggleSelection(drawing.id, e)}
|
onToggleSelection={(e) => handleToggleSelection(drawing.id, e)}
|
||||||
onRename={handleRenameDrawing}
|
onRename={handleRenameDrawing}
|
||||||
onDelete={handleDeleteDrawing}
|
onDelete={handleDeleteDrawing}
|
||||||
@@ -927,6 +1037,8 @@ export const Dashboard: React.FC = () => {
|
|||||||
onMouseDown={handleCardMouseDown}
|
onMouseDown={handleCardMouseDown}
|
||||||
onDragStart={handleCardDragStart}
|
onDragStart={handleCardDragStart}
|
||||||
onPreviewGenerated={handlePreviewGenerated}
|
onPreviewGenerated={handlePreviewGenerated}
|
||||||
|
onMoveToVault={!isTrashView ? handleMoveToVault : undefined}
|
||||||
|
isVaultSetup={vault.isSetup}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
@@ -975,6 +1087,17 @@ export const Dashboard: React.FC = () => {
|
|||||||
onConfirm={() => setShowImportSuccess(false)}
|
onConfirm={() => setShowImportSuccess(false)}
|
||||||
onCancel={() => setShowImportSuccess(false)}
|
onCancel={() => setShowImportSuccess(false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Vault Unlock Modal for Move to Vault */}
|
||||||
|
<UnlockVaultModal
|
||||||
|
isOpen={showVaultUnlockModal}
|
||||||
|
onClose={() => {
|
||||||
|
setShowVaultUnlockModal(false);
|
||||||
|
setDrawingToMoveToVault(null);
|
||||||
|
}}
|
||||||
|
onUnlock={handleVaultUnlockForMove}
|
||||||
|
passwordHint={vault.passwordHint}
|
||||||
|
/>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user