first commit
This commit is contained in:
commit
d416fe4cce
69 changed files with 8269 additions and 0 deletions
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
# Python
|
||||||
|
backend/.venv/
|
||||||
|
backend/__pycache__/
|
||||||
|
backend/**/*.pyc
|
||||||
|
backend/.pytest_cache/
|
||||||
|
backend/.mypy_cache/
|
||||||
|
backend/.ruff_cache/
|
||||||
|
backend/.coverage
|
||||||
|
backend/htmlcov/
|
||||||
|
|
||||||
|
# Node
|
||||||
|
frontend/node_modules/
|
||||||
|
frontend/dist/
|
||||||
|
frontend/.vite/
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
backend/.env
|
||||||
|
frontend/.env
|
||||||
|
.env
|
||||||
|
|
||||||
|
# OS / editor
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
.vscode/
|
||||||
46
README.md
Normal file
46
README.md
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
# Zero Knowledge Platform
|
||||||
|
|
||||||
|
A zero-knowledge encrypted web application scaffold with a FastAPI backend, React + Vite frontend, and local MongoDB via Docker Compose.
|
||||||
|
|
||||||
|
## Project Layout
|
||||||
|
|
||||||
|
- `backend` - FastAPI app with async MongoDB access via Motor
|
||||||
|
- `frontend` - React application built with Vite
|
||||||
|
- `docker-compose.yml` - local MongoDB for development
|
||||||
|
|
||||||
|
## Local Development
|
||||||
|
|
||||||
|
### 1. Start MongoDB
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d mongodb
|
||||||
|
```
|
||||||
|
|
||||||
|
The compose file runs MongoDB as a single-node replica set so the parent-delete transaction can work locally.
|
||||||
|
|
||||||
|
### 2. Backend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
python -m venv .venv
|
||||||
|
.venv\Scripts\activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
copy .env.example .env
|
||||||
|
uvicorn app.main:app --reload --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Frontend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
copy .env.example .env
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
If you open the frontend in Chrome using `127.0.0.1` instead of `localhost`, keep the backend `CORS_ORIGINS` list aligned with that origin.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Secrets are never committed.
|
||||||
|
- The scaffold is intentionally minimal so later phases can add the auth, crypto, and data flow layers without changing the base layout.
|
||||||
36
SECURITY_NOTES.md
Normal file
36
SECURITY_NOTES.md
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
# Security Notes
|
||||||
|
|
||||||
|
## Zero-Knowledge Guarantees
|
||||||
|
|
||||||
|
- [x] Encryption and decryption happen only in the browser with the Web Crypto API.
|
||||||
|
- [x] The backend only stores opaque `iv` + `ciphertext` blobs plus metadata.
|
||||||
|
- [x] The backend never receives the plaintext data content for parent or child records.
|
||||||
|
- [x] The backend never receives the encryption key.
|
||||||
|
- [x] Password-derived auth material is separate from password-derived encryption material.
|
||||||
|
- [x] The auth branch uses `saltAuth` and the encryption branch uses `saltEnc`.
|
||||||
|
- [x] Parent deletion cascades to children using a backend transaction that still only handles encrypted blobs.
|
||||||
|
- [x] No password reset or account recovery flow exists.
|
||||||
|
|
||||||
|
## Backend Safety Checks
|
||||||
|
|
||||||
|
- [x] No application logger or `print()` statements emit plaintext, password material, or derived keys.
|
||||||
|
- [x] API responses avoid plaintext content and do not echo secrets.
|
||||||
|
- [x] JWT expiry is enforced with `ACCESS_TOKEN_EXPIRE_MINUTES`.
|
||||||
|
- [x] There is no refresh-token flow, so refresh handling cannot expose content.
|
||||||
|
- [x] Ownership is derived from the JWT `sub` claim, not from client-supplied owner IDs.
|
||||||
|
- [x] Content queries are scoped by `ownerId` and `parentId` only.
|
||||||
|
|
||||||
|
## Operational Limits
|
||||||
|
|
||||||
|
- [ ] Full zero-knowledge security depends on the browser/client device being trusted.
|
||||||
|
- [ ] Server-side search over decrypted content is intentionally unsupported.
|
||||||
|
- [ ] Server-side content indexing is intentionally unsupported.
|
||||||
|
- [ ] If a user forgets their password, encrypted data is permanently unrecoverable.
|
||||||
|
- [ ] Production MongoDB should use a least-privilege app user, not an admin account.
|
||||||
|
- [ ] Production deployments should keep secrets out of source control and rotate JWT secrets on a policy schedule.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- The current development setup uses a local MongoDB service for convenience.
|
||||||
|
- The frontend keeps the encryption key only in React memory for the active session.
|
||||||
|
- Any future feature should be checked against the rule: does the server need plaintext? If yes, redesign it to run client-side.
|
||||||
6
backend/.env.example
Normal file
6
backend/.env.example
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
MONGODB_URI=mongodb://localhost:27017
|
||||||
|
MONGODB_DB_NAME=zero_knowledge_platform
|
||||||
|
JWT_SECRET=replace-with-a-long-random-secret
|
||||||
|
JWT_ALGORITHM=HS256
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES=60
|
||||||
|
CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
|
||||||
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
43
backend/app/api/auth.py
Normal file
43
backend/app/api/auth.py
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from motor.motor_asyncio import AsyncIOMotorDatabase
|
||||||
|
|
||||||
|
from app.core.security import create_access_token
|
||||||
|
from app.db.mongo import get_database
|
||||||
|
from app.schemas.auth import (
|
||||||
|
AuthBootstrapResponse,
|
||||||
|
AuthLoginBootstrapRequest,
|
||||||
|
AuthLoginRequest,
|
||||||
|
AuthSignupRequest,
|
||||||
|
AuthTokenResponse,
|
||||||
|
)
|
||||||
|
from app.services.auth import create_account, get_account_by_identifier, verify_account_login
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/signup", response_model=AuthTokenResponse)
|
||||||
|
async def signup(payload: AuthSignupRequest, database: AsyncIOMotorDatabase = Depends(get_database)) -> AuthTokenResponse:
|
||||||
|
owner_id = await create_account(database, payload)
|
||||||
|
access_token = create_access_token(owner_id)
|
||||||
|
return AuthTokenResponse(accessToken=access_token, ownerId=owner_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/bootstrap", response_model=AuthBootstrapResponse)
|
||||||
|
async def bootstrap(
|
||||||
|
payload: AuthLoginBootstrapRequest,
|
||||||
|
database: AsyncIOMotorDatabase = Depends(get_database),
|
||||||
|
) -> AuthBootstrapResponse:
|
||||||
|
account = await get_account_by_identifier(database, payload.identifierType.value, payload.identifier)
|
||||||
|
return AuthBootstrapResponse(
|
||||||
|
identifierType=payload.identifierType,
|
||||||
|
identifier=account["identifier"],
|
||||||
|
saltAuth=account["saltAuth"],
|
||||||
|
saltEnc=account["saltEnc"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login", response_model=AuthTokenResponse)
|
||||||
|
async def login(payload: AuthLoginRequest, database: AsyncIOMotorDatabase = Depends(get_database)) -> AuthTokenResponse:
|
||||||
|
owner_id = await verify_account_login(database, payload)
|
||||||
|
access_token = create_access_token(owner_id)
|
||||||
|
return AuthTokenResponse(accessToken=access_token, ownerId=owner_id)
|
||||||
61
backend/app/api/children.py
Normal file
61
backend/app/api/children.py
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from motor.motor_asyncio import AsyncIOMotorDatabase
|
||||||
|
|
||||||
|
from app.dependencies.auth import get_current_owner_id
|
||||||
|
from app.db.mongo import get_database
|
||||||
|
from app.schemas.records import ChildCreateRequest, ChildRecordResponse, ChildUpdateRequest
|
||||||
|
from app.services.records import create_child, delete_child, get_child, list_children, update_child
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/parents/{parent_id}/children", tags=["children"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=ChildRecordResponse)
|
||||||
|
async def create_child_record(
|
||||||
|
parent_id: str,
|
||||||
|
payload: ChildCreateRequest,
|
||||||
|
owner_id: str = Depends(get_current_owner_id),
|
||||||
|
database: AsyncIOMotorDatabase = Depends(get_database),
|
||||||
|
) -> ChildRecordResponse:
|
||||||
|
if payload.parentId != parent_id:
|
||||||
|
payload = ChildCreateRequest(parentId=parent_id, iv=payload.iv, ciphertext=payload.ciphertext)
|
||||||
|
return await create_child(database, owner_id, payload)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[ChildRecordResponse])
|
||||||
|
async def list_child_records(
|
||||||
|
parent_id: str,
|
||||||
|
owner_id: str = Depends(get_current_owner_id),
|
||||||
|
database: AsyncIOMotorDatabase = Depends(get_database),
|
||||||
|
) -> list[ChildRecordResponse]:
|
||||||
|
return await list_children(database, owner_id, parent_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{child_id}", response_model=ChildRecordResponse)
|
||||||
|
async def get_child_record(
|
||||||
|
parent_id: str,
|
||||||
|
child_id: str,
|
||||||
|
owner_id: str = Depends(get_current_owner_id),
|
||||||
|
database: AsyncIOMotorDatabase = Depends(get_database),
|
||||||
|
) -> ChildRecordResponse:
|
||||||
|
return await get_child(database, owner_id, parent_id, child_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{child_id}", response_model=ChildRecordResponse)
|
||||||
|
async def update_child_record(
|
||||||
|
parent_id: str,
|
||||||
|
child_id: str,
|
||||||
|
payload: ChildUpdateRequest,
|
||||||
|
owner_id: str = Depends(get_current_owner_id),
|
||||||
|
database: AsyncIOMotorDatabase = Depends(get_database),
|
||||||
|
) -> ChildRecordResponse:
|
||||||
|
return await update_child(database, owner_id, parent_id, child_id, payload)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{child_id}", status_code=204)
|
||||||
|
async def delete_child_record(
|
||||||
|
parent_id: str,
|
||||||
|
child_id: str,
|
||||||
|
owner_id: str = Depends(get_current_owner_id),
|
||||||
|
database: AsyncIOMotorDatabase = Depends(get_database),
|
||||||
|
) -> None:
|
||||||
|
await delete_child(database, owner_id, parent_id, child_id)
|
||||||
8
backend/app/api/health.py
Normal file
8
backend/app/api/health.py
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/health", tags=["health"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def health_check() -> dict[str, str]:
|
||||||
|
return {"status": "ok"}
|
||||||
54
backend/app/api/parents.py
Normal file
54
backend/app/api/parents.py
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from motor.motor_asyncio import AsyncIOMotorDatabase
|
||||||
|
|
||||||
|
from app.dependencies.auth import get_current_owner_id
|
||||||
|
from app.db.mongo import get_database
|
||||||
|
from app.schemas.records import ParentCreateRequest, ParentRecordResponse, ParentUpdateRequest
|
||||||
|
from app.services.records import create_parent, delete_parent, get_parent, list_parents, update_parent
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/parents", tags=["parents"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=ParentRecordResponse)
|
||||||
|
async def create_parent_record(
|
||||||
|
payload: ParentCreateRequest,
|
||||||
|
owner_id: str = Depends(get_current_owner_id),
|
||||||
|
database: AsyncIOMotorDatabase = Depends(get_database),
|
||||||
|
) -> ParentRecordResponse:
|
||||||
|
return await create_parent(database, owner_id, payload)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[ParentRecordResponse])
|
||||||
|
async def list_parent_records(
|
||||||
|
owner_id: str = Depends(get_current_owner_id),
|
||||||
|
database: AsyncIOMotorDatabase = Depends(get_database),
|
||||||
|
) -> list[ParentRecordResponse]:
|
||||||
|
return await list_parents(database, owner_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{parent_id}", response_model=ParentRecordResponse)
|
||||||
|
async def get_parent_record(
|
||||||
|
parent_id: str,
|
||||||
|
owner_id: str = Depends(get_current_owner_id),
|
||||||
|
database: AsyncIOMotorDatabase = Depends(get_database),
|
||||||
|
) -> ParentRecordResponse:
|
||||||
|
return await get_parent(database, owner_id, parent_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{parent_id}", response_model=ParentRecordResponse)
|
||||||
|
async def update_parent_record(
|
||||||
|
parent_id: str,
|
||||||
|
payload: ParentUpdateRequest,
|
||||||
|
owner_id: str = Depends(get_current_owner_id),
|
||||||
|
database: AsyncIOMotorDatabase = Depends(get_database),
|
||||||
|
) -> ParentRecordResponse:
|
||||||
|
return await update_parent(database, owner_id, parent_id, payload)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{parent_id}", status_code=204)
|
||||||
|
async def delete_parent_record(
|
||||||
|
parent_id: str,
|
||||||
|
owner_id: str = Depends(get_current_owner_id),
|
||||||
|
database: AsyncIOMotorDatabase = Depends(get_database),
|
||||||
|
) -> None:
|
||||||
|
await delete_parent(database, owner_id, parent_id)
|
||||||
0
backend/app/core/__init__.py
Normal file
0
backend/app/core/__init__.py
Normal file
31
backend/app/core/config.py
Normal file
31
backend/app/core/config.py
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
backend_env_path = Path(__file__).resolve().parents[2] / ".env"
|
||||||
|
load_dotenv(dotenv_path=backend_env_path)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Settings:
|
||||||
|
app_name: str = os.getenv("APP_NAME", "Zero Knowledge Platform API")
|
||||||
|
mongo_uri: str = os.getenv("MONGODB_URI", "mongodb://localhost:27017")
|
||||||
|
mongo_db_name: str = os.getenv("MONGODB_DB_NAME", "zero_knowledge_platform")
|
||||||
|
jwt_secret: str = os.getenv("JWT_SECRET", "replace-with-a-long-random-secret")
|
||||||
|
jwt_algorithm: str = os.getenv("JWT_ALGORITHM", "HS256")
|
||||||
|
access_token_expire_minutes: int = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "60"))
|
||||||
|
cors_origins: list[str] = field(
|
||||||
|
default_factory=lambda: [
|
||||||
|
origin.strip()
|
||||||
|
for origin in os.getenv(
|
||||||
|
"CORS_ORIGINS",
|
||||||
|
"http://localhost:5173,http://127.0.0.1:5173",
|
||||||
|
).split(",")
|
||||||
|
if origin.strip()
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
37
backend/app/core/security.py
Normal file
37
backend/app/core/security.py
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from jose import jwt
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_identifier(identifier_type: str, identifier: str) -> str:
|
||||||
|
value = identifier.strip()
|
||||||
|
if identifier_type == "email":
|
||||||
|
return value.lower()
|
||||||
|
if identifier_type == "username":
|
||||||
|
return value
|
||||||
|
if identifier_type == "mobile":
|
||||||
|
return re.sub(r"[\s\-().]", "", value)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def hash_auth_key(auth_key_material: str) -> str:
|
||||||
|
return pwd_context.hash(auth_key_material)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_auth_key(auth_key_material: str, hashed_auth_key: str) -> bool:
|
||||||
|
return pwd_context.verify(auth_key_material, hashed_auth_key)
|
||||||
|
|
||||||
|
|
||||||
|
def create_access_token(subject: str, extra_claims: dict[str, Any] | None = None) -> str:
|
||||||
|
expires_at = datetime.now(timezone.utc) + timedelta(minutes=settings.access_token_expire_minutes)
|
||||||
|
payload: dict[str, Any] = {"sub": subject, "exp": expires_at, "iat": datetime.now(timezone.utc)}
|
||||||
|
if extra_claims:
|
||||||
|
payload.update(extra_claims)
|
||||||
|
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
|
||||||
0
backend/app/db/__init__.py
Normal file
0
backend/app/db/__init__.py
Normal file
29
backend/app/db/mongo.py
Normal file
29
backend/app/db/mongo.py
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
client: AsyncIOMotorClient | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_database() -> AsyncIOMotorDatabase:
|
||||||
|
if client is None:
|
||||||
|
raise RuntimeError("MongoDB client has not been initialized")
|
||||||
|
return client[settings.mongo_db_name]
|
||||||
|
|
||||||
|
|
||||||
|
def get_client() -> AsyncIOMotorClient:
|
||||||
|
if client is None:
|
||||||
|
raise RuntimeError("MongoDB client has not been initialized")
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
async def connect_to_mongo() -> None:
|
||||||
|
global client
|
||||||
|
client = AsyncIOMotorClient(settings.mongo_uri)
|
||||||
|
|
||||||
|
|
||||||
|
async def close_mongo_connection() -> None:
|
||||||
|
global client
|
||||||
|
if client is not None:
|
||||||
|
client.close()
|
||||||
|
client = None
|
||||||
0
backend/app/dependencies/__init__.py
Normal file
0
backend/app/dependencies/__init__.py
Normal file
33
backend/app/dependencies/auth.py
Normal file
33
backend/app/dependencies/auth.py
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
from fastapi import Depends, HTTPException, status
|
||||||
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
|
from jose import JWTError, jwt
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
bearer_scheme = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_owner_id(
|
||||||
|
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
|
||||||
|
) -> str:
|
||||||
|
if credentials is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Not authenticated.",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(credentials.credentials, settings.jwt_secret, algorithms=[settings.jwt_algorithm])
|
||||||
|
except JWTError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid or expired token.",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
owner_id = payload.get("sub")
|
||||||
|
if not isinstance(owner_id, str) or not owner_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid token payload.",
|
||||||
|
)
|
||||||
|
return owner_id
|
||||||
51
backend/app/main.py
Normal file
51
backend/app/main.py
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi import Request
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
from app.api.auth import router as auth_router
|
||||||
|
from app.api.children import router as children_router
|
||||||
|
from app.api.parents import router as parents_router
|
||||||
|
from app.api.health import router as health_router
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.db.mongo import close_mongo_connection, connect_to_mongo, get_database
|
||||||
|
from app.services.records import ensure_record_indexes
|
||||||
|
from app.services.auth import ensure_auth_indexes
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
await connect_to_mongo()
|
||||||
|
await ensure_auth_indexes(get_database())
|
||||||
|
await ensure_record_indexes(get_database())
|
||||||
|
yield
|
||||||
|
await close_mongo_connection()
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title="Zero Knowledge Platform API", lifespan=lifespan)
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=settings.cors_origins,
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
app.include_router(health_router)
|
||||||
|
app.include_router(auth_router)
|
||||||
|
app.include_router(parents_router)
|
||||||
|
app.include_router(children_router)
|
||||||
|
|
||||||
|
|
||||||
|
@app.exception_handler(Exception)
|
||||||
|
async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse:
|
||||||
|
return JSONResponse(status_code=500, content={"detail": "Internal server error."})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
async def root() -> dict[str, str]:
|
||||||
|
return {"message": "Zero Knowledge Platform API"}
|
||||||
0
backend/app/schemas/__init__.py
Normal file
0
backend/app/schemas/__init__.py
Normal file
49
backend/app/schemas/auth.py
Normal file
49
backend/app/schemas/auth.py
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
|
||||||
|
|
||||||
|
class IdentifierType(str, Enum):
|
||||||
|
email = "email"
|
||||||
|
username = "username"
|
||||||
|
mobile = "mobile"
|
||||||
|
|
||||||
|
|
||||||
|
class AuthIdentifierBase(BaseModel):
|
||||||
|
identifierType: IdentifierType
|
||||||
|
identifier: str = Field(min_length=1, max_length=255)
|
||||||
|
|
||||||
|
@field_validator("identifier")
|
||||||
|
@classmethod
|
||||||
|
def strip_identifier(cls, value: str) -> str:
|
||||||
|
normalized = value.strip()
|
||||||
|
if not normalized:
|
||||||
|
raise ValueError("identifier cannot be empty")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
class AuthSignupRequest(AuthIdentifierBase):
|
||||||
|
saltAuth: str = Field(min_length=8)
|
||||||
|
saltEnc: str = Field(min_length=8)
|
||||||
|
authKeyMaterial: str = Field(min_length=8)
|
||||||
|
|
||||||
|
|
||||||
|
class AuthLoginBootstrapRequest(AuthIdentifierBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class AuthLoginRequest(AuthIdentifierBase):
|
||||||
|
authKeyMaterial: str = Field(min_length=8)
|
||||||
|
|
||||||
|
|
||||||
|
class AuthBootstrapResponse(BaseModel):
|
||||||
|
identifierType: IdentifierType
|
||||||
|
identifier: str
|
||||||
|
saltAuth: str
|
||||||
|
saltEnc: str
|
||||||
|
|
||||||
|
|
||||||
|
class AuthTokenResponse(BaseModel):
|
||||||
|
accessToken: str
|
||||||
|
tokenType: str = "bearer"
|
||||||
|
ownerId: str
|
||||||
47
backend/app/schemas/records.py
Normal file
47
backend/app/schemas/records.py
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
|
||||||
|
|
||||||
|
class ParentBlobBase(BaseModel):
|
||||||
|
iv: str = Field(min_length=1)
|
||||||
|
ciphertext: str = Field(min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
class ParentCreateRequest(ParentBlobBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ParentUpdateRequest(ParentBlobBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ChildCreateRequest(ParentBlobBase):
|
||||||
|
parentId: str = Field(min_length=1)
|
||||||
|
|
||||||
|
@field_validator("parentId")
|
||||||
|
@classmethod
|
||||||
|
def strip_parent_id(cls, value: str) -> str:
|
||||||
|
normalized = value.strip()
|
||||||
|
if not normalized:
|
||||||
|
raise ValueError("parentId cannot be empty")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
class ChildUpdateRequest(ParentBlobBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ParentRecordResponse(ParentBlobBase):
|
||||||
|
id: str
|
||||||
|
ownerId: str
|
||||||
|
createdAt: datetime
|
||||||
|
updatedAt: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ChildRecordResponse(ParentBlobBase):
|
||||||
|
id: str
|
||||||
|
parentId: str
|
||||||
|
ownerId: str
|
||||||
|
createdAt: datetime
|
||||||
|
updatedAt: datetime
|
||||||
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
68
backend/app/services/auth.py
Normal file
68
backend/app/services/auth.py
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
from motor.motor_asyncio import AsyncIOMotorDatabase
|
||||||
|
from pymongo.errors import DuplicateKeyError
|
||||||
|
|
||||||
|
from app.core.security import hash_auth_key, normalize_identifier, verify_auth_key
|
||||||
|
from app.schemas.auth import AuthLoginRequest, AuthSignupRequest
|
||||||
|
|
||||||
|
ACCOUNTS_COLLECTION = "accounts"
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_auth_indexes(database: AsyncIOMotorDatabase) -> None:
|
||||||
|
await database[ACCOUNTS_COLLECTION].create_index(
|
||||||
|
[("identifierType", 1), ("identifier", 1)],
|
||||||
|
unique=True,
|
||||||
|
name="uq_accounts_identifier",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _account_filter(identifier_type: str, identifier: str) -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
"identifierType": identifier_type,
|
||||||
|
"identifier": normalize_identifier(identifier_type, identifier),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def create_account(database: AsyncIOMotorDatabase, payload: AuthSignupRequest) -> str:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
account_document = {
|
||||||
|
"identifierType": payload.identifierType.value,
|
||||||
|
"identifier": normalize_identifier(payload.identifierType.value, payload.identifier),
|
||||||
|
"hashedAuthKey": hash_auth_key(payload.authKeyMaterial),
|
||||||
|
"saltAuth": payload.saltAuth,
|
||||||
|
"saltEnc": payload.saltEnc,
|
||||||
|
"createdAt": now,
|
||||||
|
"updatedAt": now,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await database[ACCOUNTS_COLLECTION].insert_one(account_document)
|
||||||
|
except DuplicateKeyError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="An account with that identifier already exists.",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
return str(result.inserted_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_account_by_identifier(database: AsyncIOMotorDatabase, identifier_type: str, identifier: str) -> dict[str, str]:
|
||||||
|
account = await database[ACCOUNTS_COLLECTION].find_one(_account_filter(identifier_type, identifier))
|
||||||
|
if account is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Account not found.",
|
||||||
|
)
|
||||||
|
return account
|
||||||
|
|
||||||
|
|
||||||
|
async def verify_account_login(database: AsyncIOMotorDatabase, payload: AuthLoginRequest) -> str:
|
||||||
|
account = await get_account_by_identifier(database, payload.identifierType.value, payload.identifier)
|
||||||
|
if not verify_auth_key(payload.authKeyMaterial, account["hashedAuthKey"]):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid credentials.",
|
||||||
|
)
|
||||||
|
return str(account["_id"])
|
||||||
235
backend/app/services/records.py
Normal file
235
backend/app/services/records.py
Normal file
|
|
@ -0,0 +1,235 @@
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from bson import ObjectId
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
from motor.motor_asyncio import AsyncIOMotorDatabase
|
||||||
|
from pymongo import ReturnDocument
|
||||||
|
from pymongo.errors import DuplicateKeyError
|
||||||
|
|
||||||
|
from app.schemas.records import (
|
||||||
|
ChildCreateRequest,
|
||||||
|
ChildRecordResponse,
|
||||||
|
ChildUpdateRequest,
|
||||||
|
ParentCreateRequest,
|
||||||
|
ParentRecordResponse,
|
||||||
|
ParentUpdateRequest,
|
||||||
|
)
|
||||||
|
|
||||||
|
PARENTS_COLLECTION = "parents"
|
||||||
|
CHILDREN_COLLECTION = "children"
|
||||||
|
|
||||||
|
|
||||||
|
class RecordNotFoundError(HTTPException):
|
||||||
|
def __init__(self, detail: str = "Record not found.") -> None:
|
||||||
|
super().__init__(status_code=status.HTTP_404_NOT_FOUND, detail=detail)
|
||||||
|
|
||||||
|
|
||||||
|
class BadRequestError(HTTPException):
|
||||||
|
def __init__(self, detail: str = "Invalid request.") -> None:
|
||||||
|
super().__init__(status_code=status.HTTP_400_BAD_REQUEST, detail=detail)
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _to_object_id(value: str, field_name: str) -> ObjectId:
|
||||||
|
if not ObjectId.is_valid(value):
|
||||||
|
raise BadRequestError(f"Invalid {field_name}.")
|
||||||
|
return ObjectId(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_parent(document: dict) -> ParentRecordResponse:
|
||||||
|
return ParentRecordResponse(
|
||||||
|
id=str(document["_id"]),
|
||||||
|
ownerId=document["ownerId"],
|
||||||
|
iv=document["iv"],
|
||||||
|
ciphertext=document["ciphertext"],
|
||||||
|
createdAt=document["createdAt"],
|
||||||
|
updatedAt=document["updatedAt"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_child(document: dict) -> ChildRecordResponse:
|
||||||
|
return ChildRecordResponse(
|
||||||
|
id=str(document["_id"]),
|
||||||
|
parentId=str(document["parentId"]),
|
||||||
|
ownerId=document["ownerId"],
|
||||||
|
iv=document["iv"],
|
||||||
|
ciphertext=document["ciphertext"],
|
||||||
|
createdAt=document["createdAt"],
|
||||||
|
updatedAt=document["updatedAt"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_record_indexes(database: AsyncIOMotorDatabase) -> None:
|
||||||
|
await database[PARENTS_COLLECTION].create_index([("ownerId", 1)], name="ix_parents_ownerId")
|
||||||
|
await database[CHILDREN_COLLECTION].create_index([("ownerId", 1)], name="ix_children_ownerId")
|
||||||
|
await database[CHILDREN_COLLECTION].create_index([("parentId", 1)], name="ix_children_parentId")
|
||||||
|
await database[CHILDREN_COLLECTION].create_index(
|
||||||
|
[("ownerId", 1), ("parentId", 1)],
|
||||||
|
name="ix_children_ownerId_parentId",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def create_parent(database: AsyncIOMotorDatabase, owner_id: str, payload: ParentCreateRequest) -> ParentRecordResponse:
|
||||||
|
now = _now()
|
||||||
|
document = {
|
||||||
|
"ownerId": owner_id,
|
||||||
|
"iv": payload.iv,
|
||||||
|
"ciphertext": payload.ciphertext,
|
||||||
|
"createdAt": now,
|
||||||
|
"updatedAt": now,
|
||||||
|
}
|
||||||
|
result = await database[PARENTS_COLLECTION].insert_one(document)
|
||||||
|
document["_id"] = result.inserted_id
|
||||||
|
return _serialize_parent(document)
|
||||||
|
|
||||||
|
|
||||||
|
async def list_parents(database: AsyncIOMotorDatabase, owner_id: str) -> list[ParentRecordResponse]:
|
||||||
|
parents: list[ParentRecordResponse] = []
|
||||||
|
cursor = database[PARENTS_COLLECTION].find({"ownerId": owner_id}).sort("createdAt", -1)
|
||||||
|
async for document in cursor:
|
||||||
|
parents.append(_serialize_parent(document))
|
||||||
|
return parents
|
||||||
|
|
||||||
|
|
||||||
|
async def get_parent(database: AsyncIOMotorDatabase, owner_id: str, parent_id: str) -> ParentRecordResponse:
|
||||||
|
document = await database[PARENTS_COLLECTION].find_one(
|
||||||
|
{"_id": _to_object_id(parent_id, "parentId"), "ownerId": owner_id}
|
||||||
|
)
|
||||||
|
if document is None:
|
||||||
|
raise RecordNotFoundError("Parent record not found.")
|
||||||
|
return _serialize_parent(document)
|
||||||
|
|
||||||
|
|
||||||
|
async def update_parent(
|
||||||
|
database: AsyncIOMotorDatabase,
|
||||||
|
owner_id: str,
|
||||||
|
parent_id: str,
|
||||||
|
payload: ParentUpdateRequest,
|
||||||
|
) -> ParentRecordResponse:
|
||||||
|
document = await database[PARENTS_COLLECTION].find_one_and_update(
|
||||||
|
{"_id": _to_object_id(parent_id, "parentId"), "ownerId": owner_id},
|
||||||
|
{
|
||||||
|
"$set": {
|
||||||
|
"iv": payload.iv,
|
||||||
|
"ciphertext": payload.ciphertext,
|
||||||
|
"updatedAt": _now(),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
return_document=ReturnDocument.AFTER,
|
||||||
|
)
|
||||||
|
if document is None:
|
||||||
|
raise RecordNotFoundError("Parent record not found.")
|
||||||
|
return _serialize_parent(document)
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_parent(database: AsyncIOMotorDatabase, owner_id: str, parent_id: str) -> None:
|
||||||
|
client = database.client
|
||||||
|
if client is None:
|
||||||
|
raise RuntimeError("MongoDB client has not been initialized")
|
||||||
|
|
||||||
|
parent_object_id = _to_object_id(parent_id, "parentId")
|
||||||
|
async with await client.start_session() as session:
|
||||||
|
async with session.start_transaction():
|
||||||
|
parent = await database[PARENTS_COLLECTION].find_one(
|
||||||
|
{"_id": parent_object_id, "ownerId": owner_id},
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
if parent is None:
|
||||||
|
raise RecordNotFoundError("Parent record not found.")
|
||||||
|
|
||||||
|
await database[CHILDREN_COLLECTION].delete_many(
|
||||||
|
{"parentId": parent_object_id, "ownerId": owner_id},
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
await database[PARENTS_COLLECTION].delete_one(
|
||||||
|
{"_id": parent_object_id, "ownerId": owner_id},
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def create_child(
|
||||||
|
database: AsyncIOMotorDatabase,
|
||||||
|
owner_id: str,
|
||||||
|
payload: ChildCreateRequest,
|
||||||
|
) -> ChildRecordResponse:
|
||||||
|
parent_object_id = _to_object_id(payload.parentId, "parentId")
|
||||||
|
parent = await database[PARENTS_COLLECTION].find_one({"_id": parent_object_id, "ownerId": owner_id})
|
||||||
|
if parent is None:
|
||||||
|
raise RecordNotFoundError("Parent record not found.")
|
||||||
|
|
||||||
|
now = _now()
|
||||||
|
document = {
|
||||||
|
"ownerId": owner_id,
|
||||||
|
"parentId": parent_object_id,
|
||||||
|
"iv": payload.iv,
|
||||||
|
"ciphertext": payload.ciphertext,
|
||||||
|
"createdAt": now,
|
||||||
|
"updatedAt": now,
|
||||||
|
}
|
||||||
|
result = await database[CHILDREN_COLLECTION].insert_one(document)
|
||||||
|
document["_id"] = result.inserted_id
|
||||||
|
return _serialize_child(document)
|
||||||
|
|
||||||
|
|
||||||
|
async def list_children(database: AsyncIOMotorDatabase, owner_id: str, parent_id: str) -> list[ChildRecordResponse]:
|
||||||
|
parent_object_id = _to_object_id(parent_id, "parentId")
|
||||||
|
children: list[ChildRecordResponse] = []
|
||||||
|
cursor = database[CHILDREN_COLLECTION].find({"ownerId": owner_id, "parentId": parent_object_id}).sort("createdAt", -1)
|
||||||
|
async for document in cursor:
|
||||||
|
children.append(_serialize_child(document))
|
||||||
|
return children
|
||||||
|
|
||||||
|
|
||||||
|
async def get_child(database: AsyncIOMotorDatabase, owner_id: str, parent_id: str, child_id: str) -> ChildRecordResponse:
|
||||||
|
document = await database[CHILDREN_COLLECTION].find_one(
|
||||||
|
{
|
||||||
|
"_id": _to_object_id(child_id, "childId"),
|
||||||
|
"parentId": _to_object_id(parent_id, "parentId"),
|
||||||
|
"ownerId": owner_id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if document is None:
|
||||||
|
raise RecordNotFoundError("Child record not found.")
|
||||||
|
return _serialize_child(document)
|
||||||
|
|
||||||
|
|
||||||
|
async def update_child(
|
||||||
|
database: AsyncIOMotorDatabase,
|
||||||
|
owner_id: str,
|
||||||
|
parent_id: str,
|
||||||
|
child_id: str,
|
||||||
|
payload: ChildUpdateRequest,
|
||||||
|
) -> ChildRecordResponse:
|
||||||
|
document = await database[CHILDREN_COLLECTION].find_one_and_update(
|
||||||
|
{
|
||||||
|
"_id": _to_object_id(child_id, "childId"),
|
||||||
|
"parentId": _to_object_id(parent_id, "parentId"),
|
||||||
|
"ownerId": owner_id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$set": {
|
||||||
|
"iv": payload.iv,
|
||||||
|
"ciphertext": payload.ciphertext,
|
||||||
|
"updatedAt": _now(),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
return_document=ReturnDocument.AFTER,
|
||||||
|
)
|
||||||
|
if document is None:
|
||||||
|
raise RecordNotFoundError("Child record not found.")
|
||||||
|
return _serialize_child(document)
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_child(database: AsyncIOMotorDatabase, owner_id: str, parent_id: str, child_id: str) -> None:
|
||||||
|
result = await database[CHILDREN_COLLECTION].delete_one(
|
||||||
|
{
|
||||||
|
"_id": _to_object_id(child_id, "childId"),
|
||||||
|
"parentId": _to_object_id(parent_id, "parentId"),
|
||||||
|
"ownerId": owner_id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if result.deleted_count == 0:
|
||||||
|
raise RecordNotFoundError("Child record not found.")
|
||||||
7
backend/requirements.txt
Normal file
7
backend/requirements.txt
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
fastapi>=0.115,<1
|
||||||
|
uvicorn[standard]>=0.30,<1
|
||||||
|
motor>=3.6,<4
|
||||||
|
pydantic>=2.8,<3
|
||||||
|
python-jose[cryptography]>=3.3,<4
|
||||||
|
passlib[bcrypt]>=1.7,<2
|
||||||
|
python-dotenv>=1.0,<2
|
||||||
30
docker-compose.yml
Normal file
30
docker-compose.yml
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
services:
|
||||||
|
mongodb:
|
||||||
|
image: mongo:7
|
||||||
|
container_name: zero-knowledge-mongodb
|
||||||
|
ports:
|
||||||
|
- "27017:27017"
|
||||||
|
command: ["mongod", "--replSet", "rs0", "--bind_ip_all"]
|
||||||
|
environment:
|
||||||
|
MONGO_INITDB_DATABASE: zero_knowledge_platform
|
||||||
|
volumes:
|
||||||
|
- mongodb_data:/data/db
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mongosh", "--quiet", "--eval", "db.runCommand({ ping: 1 }).ok"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
mongodb-init:
|
||||||
|
image: mongo:7
|
||||||
|
depends_on:
|
||||||
|
mongodb:
|
||||||
|
condition: service_healthy
|
||||||
|
entrypoint: ["bash", "-lc"]
|
||||||
|
command: >-
|
||||||
|
mongosh --host mongodb:27017 --quiet --eval
|
||||||
|
'try { rs.status() } catch (e) { rs.initiate({_id:"rs0", members:[{_id:0, host:"mongodb:27017"}]}) }'
|
||||||
|
restart: "no"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
mongodb_data:
|
||||||
1
frontend/.env.example
Normal file
1
frontend/.env.example
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
VITE_API_BASE_URL=http://localhost:8000
|
||||||
21
frontend/components.json
Normal file
21
frontend/components.json
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "tailwind.config.js",
|
||||||
|
"css": "src/styles.css",
|
||||||
|
"baseColor": "zinc",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide"
|
||||||
|
}
|
||||||
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Zero Knowledge Platform</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
4036
frontend/package-lock.json
generated
Normal file
4036
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
41
frontend/package.json
Normal file
41
frontend/package.json
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
{
|
||||||
|
"name": "zero-knowledge-platform-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"test": "vitest run"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-avatar": "^1.2.6",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.23",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.24",
|
||||||
|
"@radix-ui/react-label": "^2.1.15",
|
||||||
|
"@radix-ui/react-separator": "^1.1.15",
|
||||||
|
"@radix-ui/react-slot": "^1.3.3",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.16",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^1.33.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^7.18.2",
|
||||||
|
"tailwind-merge": "^3.6.0",
|
||||||
|
"tailwindcss-animate": "^1.0.7"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^26.2.0",
|
||||||
|
"@types/react": "^18.3.7",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
"autoprefixer": "^10.5.4",
|
||||||
|
"postcss": "^8.5.26",
|
||||||
|
"tailwindcss": "^3.4.19",
|
||||||
|
"typescript": "^5.5.4",
|
||||||
|
"vite": "^5.4.2",
|
||||||
|
"vitest": "^2.1.9"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
31
frontend/src/App.tsx
Normal file
31
frontend/src/App.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { BrowserRouter, Routes, Route } from 'react-router-dom'
|
||||||
|
import { AuthProvider } from './context/AuthContext'
|
||||||
|
import { WorkspaceProvider } from './context/WorkspaceContext'
|
||||||
|
import { AppLayout } from './components/layout/AppLayout'
|
||||||
|
import { LandingPage } from './pages/LandingPage'
|
||||||
|
import { AboutPage } from './pages/AboutPage'
|
||||||
|
import { SecurityPage } from './pages/SecurityPage'
|
||||||
|
import { LoginPage } from './pages/LoginPage'
|
||||||
|
import { SignupPage } from './pages/SignupPage'
|
||||||
|
import { WorkspacePage } from './pages/WorkspacePage'
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<BrowserRouter>
|
||||||
|
<AuthProvider>
|
||||||
|
<WorkspaceProvider>
|
||||||
|
<Routes>
|
||||||
|
<Route element={<AppLayout />}>
|
||||||
|
<Route path="/" element={<LandingPage />} />
|
||||||
|
<Route path="/about" element={<AboutPage />} />
|
||||||
|
<Route path="/security" element={<SecurityPage />} />
|
||||||
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route path="/signup" element={<SignupPage />} />
|
||||||
|
<Route path="/workspace" element={<WorkspacePage />} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
</WorkspaceProvider>
|
||||||
|
</AuthProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
)
|
||||||
|
}
|
||||||
120
frontend/src/components/AuthPanel.tsx
Normal file
120
frontend/src/components/AuthPanel.tsx
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
import type { IdentifierType } from '../lib/crypto'
|
||||||
|
|
||||||
|
const identifierOptions: Array<{ label: string; value: IdentifierType }> = [
|
||||||
|
{ label: 'Email', value: 'email' },
|
||||||
|
{ label: 'Username', value: 'username' },
|
||||||
|
{ label: 'Mobile number', value: 'mobile' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function AuthPanel() {
|
||||||
|
const { session, isAuthenticated, signIn, signUp, signOut } = useAuth()
|
||||||
|
const [mode, setMode] = useState<'login' | 'signup'>('signup')
|
||||||
|
const [identifierType, setIdentifierType] = useState<IdentifierType>('email')
|
||||||
|
const [identifier, setIdentifier] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [status, setStatus] = useState<string>('')
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
|
||||||
|
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault()
|
||||||
|
setIsSubmitting(true)
|
||||||
|
setStatus('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (mode === 'signup') {
|
||||||
|
await signUp({ identifierType, identifier, password })
|
||||||
|
setStatus('Account created and session initialized in memory.')
|
||||||
|
} else {
|
||||||
|
await signIn({ identifierType, identifier, password })
|
||||||
|
setStatus('Signed in and encryption key restored in memory.')
|
||||||
|
}
|
||||||
|
setPassword('')
|
||||||
|
} catch (error) {
|
||||||
|
setStatus(error instanceof Error ? error.message : 'Authentication failed.')
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="auth-shell">
|
||||||
|
<div className="auth-card">
|
||||||
|
<div className="auth-card__header">
|
||||||
|
<p className="eyebrow">Phase 2 Auth</p>
|
||||||
|
<h2>{mode === 'signup' ? 'Create an account' : 'Sign in'}</h2>
|
||||||
|
<p className="lede compact">
|
||||||
|
Use email, username, or mobile number with a password. The encryption key stays in memory only.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="auth-tabs" role="tablist" aria-label="Authentication mode">
|
||||||
|
<button type="button" className={mode === 'signup' ? 'tab active' : 'tab'} onClick={() => setMode('signup')}>
|
||||||
|
Sign up
|
||||||
|
</button>
|
||||||
|
<button type="button" className={mode === 'login' ? 'tab active' : 'tab'} onClick={() => setMode('login')}>
|
||||||
|
Log in
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form className="auth-form" onSubmit={handleSubmit}>
|
||||||
|
<label>
|
||||||
|
Identifier type
|
||||||
|
<select value={identifierType} onChange={(event) => setIdentifierType(event.target.value as IdentifierType)}>
|
||||||
|
{identifierOptions.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
{identifierType === 'email' ? 'Email' : identifierType === 'username' ? 'Username' : 'Mobile number'}
|
||||||
|
<input
|
||||||
|
value={identifier}
|
||||||
|
onChange={(event) => setIdentifier(event.target.value)}
|
||||||
|
placeholder={identifierType === 'mobile' ? '+15555550123' : 'Enter your identifier'}
|
||||||
|
autoComplete={identifierType === 'email' ? 'email' : 'off'}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Password
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
placeholder="Enter your password"
|
||||||
|
autoComplete={mode === 'signup' ? 'new-password' : 'current-password'}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button type="submit" disabled={isSubmitting}>
|
||||||
|
{isSubmitting ? 'Working…' : mode === 'signup' ? 'Create account' : 'Sign in'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{status ? <p className="status">{status}</p> : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside className="session-card">
|
||||||
|
<h3>Active session</h3>
|
||||||
|
{isAuthenticated && session ? (
|
||||||
|
<div className="session-details">
|
||||||
|
<p>Owner ID: {session.ownerId}</p>
|
||||||
|
<p>Identifier: {session.identifierType} / {session.identifier}</p>
|
||||||
|
<p>Access token is held in memory.</p>
|
||||||
|
<button type="button" className="secondary" onClick={signOut}>
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p>No active session.</p>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
354
frontend/src/components/DataWorkspace.tsx
Normal file
354
frontend/src/components/DataWorkspace.tsx
Normal file
|
|
@ -0,0 +1,354 @@
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Clock, FileText, KeyRound, Lock, Pencil, Plus, Trash2 } from 'lucide-react'
|
||||||
|
|
||||||
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
import { useWorkspace } from '../context/WorkspaceContext'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
|
||||||
|
type DraftState = {
|
||||||
|
title: string
|
||||||
|
notes: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyDraft: DraftState = { title: '', notes: '' }
|
||||||
|
|
||||||
|
function formatDate(value: string) {
|
||||||
|
return new Date(value).toLocaleString(undefined, {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
timeStyle: 'short',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataWorkspace() {
|
||||||
|
const { session } = useAuth()
|
||||||
|
const {
|
||||||
|
childrenByParent,
|
||||||
|
selection,
|
||||||
|
parentOfSelection,
|
||||||
|
status,
|
||||||
|
createIntent,
|
||||||
|
requestCreate,
|
||||||
|
createParentPage,
|
||||||
|
createChildPage,
|
||||||
|
updateSelection,
|
||||||
|
deleteSelection,
|
||||||
|
} = useWorkspace()
|
||||||
|
|
||||||
|
const [creating, setCreating] = useState<'parent' | 'child' | null>(null)
|
||||||
|
const [editing, setEditing] = useState(false)
|
||||||
|
const [draft, setDraft] = useState<DraftState>(emptyDraft)
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (createIntent !== null) {
|
||||||
|
setEditing(false)
|
||||||
|
setDraft(emptyDraft)
|
||||||
|
setCreating(createIntent)
|
||||||
|
requestCreate(null)
|
||||||
|
}
|
||||||
|
}, [createIntent, requestCreate])
|
||||||
|
|
||||||
|
if (session === null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function startCreateParent() {
|
||||||
|
setEditing(false)
|
||||||
|
setDraft(emptyDraft)
|
||||||
|
setCreating('parent')
|
||||||
|
}
|
||||||
|
|
||||||
|
function startCreateChild() {
|
||||||
|
setEditing(false)
|
||||||
|
setDraft(emptyDraft)
|
||||||
|
setCreating('child')
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEdit() {
|
||||||
|
if (selection === null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setCreating(null)
|
||||||
|
setDraft(
|
||||||
|
selection.kind === 'parent'
|
||||||
|
? { title: selection.parent.title, notes: selection.parent.notes }
|
||||||
|
: { title: selection.child.title, notes: selection.child.notes },
|
||||||
|
)
|
||||||
|
setEditing(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreateSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault()
|
||||||
|
if (creating === null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true)
|
||||||
|
try {
|
||||||
|
if (creating === 'parent') {
|
||||||
|
await createParentPage(draft.title, draft.notes)
|
||||||
|
} else {
|
||||||
|
const parent = parentOfSelection
|
||||||
|
if (parent === null) {
|
||||||
|
setCreating(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await createChildPage(parent.id, draft.title, draft.notes)
|
||||||
|
}
|
||||||
|
setCreating(null)
|
||||||
|
setDraft(emptyDraft)
|
||||||
|
} catch {
|
||||||
|
// error already surfaced through workspace status
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleEditSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault()
|
||||||
|
if (selection === null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true)
|
||||||
|
try {
|
||||||
|
await updateSelection(draft.title, draft.notes)
|
||||||
|
setEditing(false)
|
||||||
|
setDraft(emptyDraft)
|
||||||
|
} catch {
|
||||||
|
// error already surfaced through workspace status
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
if (selection === null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true)
|
||||||
|
try {
|
||||||
|
await deleteSelection()
|
||||||
|
setEditing(false)
|
||||||
|
setCreating(null)
|
||||||
|
setDraft(emptyDraft)
|
||||||
|
} catch {
|
||||||
|
// error already surfaced through workspace status
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedTitle =
|
||||||
|
selection === null
|
||||||
|
? ''
|
||||||
|
: selection.kind === 'parent'
|
||||||
|
? selection.parent.title
|
||||||
|
: selection.child.title
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-4xl space-y-8 p-6 sm:p-10 lg:p-12">
|
||||||
|
<section className="flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
|
||||||
|
<div className="max-w-2xl space-y-3">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-primary">Workspace</p>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight sm:text-4xl">
|
||||||
|
Welcome back,{' '}
|
||||||
|
<span className="text-primary drop-shadow-[0_0_14px_rgba(34,211,238,0.6)]">
|
||||||
|
{session.identifier}
|
||||||
|
</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||||
|
Browse your page tree in the sidebar, then read and edit pages here. Each page is sealed with
|
||||||
|
AES-256-GCM in this browser — the server never sees its content.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Badge variant="secondary" className="gap-1.5 border-primary/30 bg-primary/10 py-1 text-primary">
|
||||||
|
<Lock className="h-3 w-3" />
|
||||||
|
Session encrypted
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline" className="py-1 font-mono text-[11px]">
|
||||||
|
{session.ownerId.slice(0, 12)}…
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="min-w-0 rounded-2xl border border-border bg-card/60 p-6 shadow-[0_0_44px_-22px_rgba(34,211,238,0.35)] sm:p-8">
|
||||||
|
{creating !== null || editing ? (
|
||||||
|
<form
|
||||||
|
onSubmit={creating !== null ? handleCreateSubmit : handleEditSubmit}
|
||||||
|
className="space-y-4"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-primary">
|
||||||
|
{creating === 'parent'
|
||||||
|
? 'New top-level page'
|
||||||
|
: creating === 'child'
|
||||||
|
? `New child page ${parentOfSelection !== null ? `under "${parentOfSelection.title || 'untitled page'}"` : ''}`
|
||||||
|
: `Editing "${selectedTitle || 'untitled page'}"`}
|
||||||
|
</p>
|
||||||
|
<h2 className="mt-1 text-2xl font-bold tracking-tight">
|
||||||
|
{creating === 'parent'
|
||||||
|
? 'Create a page'
|
||||||
|
: creating === 'child'
|
||||||
|
? 'Create a child page'
|
||||||
|
: 'Edit page'}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="draft-title" className="text-xs text-muted-foreground">
|
||||||
|
Title
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="draft-title"
|
||||||
|
value={draft.title}
|
||||||
|
onChange={(event) => setDraft((current) => ({ ...current, title: event.target.value }))}
|
||||||
|
placeholder="e.g. Project notes"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="draft-notes" className="text-xs text-muted-foreground">
|
||||||
|
Notes
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
id="draft-notes"
|
||||||
|
value={draft.notes}
|
||||||
|
onChange={(event) => setDraft((current) => ({ ...current, notes: event.target.value }))}
|
||||||
|
placeholder="Anything you want to keep private..."
|
||||||
|
rows={8}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Button type="submit" disabled={isSubmitting} className="gap-2">
|
||||||
|
{creating !== null ? (
|
||||||
|
<>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
{creating === 'parent' ? 'Create page' : 'Create child page'}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
Save changes
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setCreating(null)
|
||||||
|
setEditing(false)
|
||||||
|
setDraft(emptyDraft)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
) : selection === null ? (
|
||||||
|
<div className="flex flex-col items-center gap-3 py-16 text-center">
|
||||||
|
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-secondary text-primary/70">
|
||||||
|
<FileText className="h-6 w-6" />
|
||||||
|
</span>
|
||||||
|
<h2 className="text-lg font-semibold">Select a page from the sidebar</h2>
|
||||||
|
<p className="max-w-sm text-sm leading-relaxed text-muted-foreground">
|
||||||
|
Your pages live in the My Pages section of the sidebar. Click any page to view it here, or use
|
||||||
|
the + icon there to create a top-level page.
|
||||||
|
</p>
|
||||||
|
<Button size="sm" className="mt-1 gap-2" onClick={startCreateParent}>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Create a top-level page
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-primary">
|
||||||
|
{selection.kind === 'parent' ? 'Parent page' : 'Child page'}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Button variant="outline" size="sm" className="gap-2" onClick={startCreateChild}>
|
||||||
|
<Plus className="h-3.5 w-3.5" />
|
||||||
|
Create child
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" className="gap-2" onClick={startEdit}>
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className={cn(
|
||||||
|
'gap-2 text-destructive hover:bg-destructive/10 hover:text-destructive',
|
||||||
|
isSubmitting && 'opacity-60'
|
||||||
|
)}
|
||||||
|
onClick={handleDelete}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="mt-3 text-3xl font-bold tracking-tight sm:text-4xl">
|
||||||
|
{selectedTitle || '(untitled page)'}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<Clock className="h-3.5 w-3.5" />
|
||||||
|
Created{' '}
|
||||||
|
{formatDate(
|
||||||
|
selection.kind === 'parent' ? selection.parent.createdAt : selection.child.createdAt,
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{selection.kind === 'parent' ? (
|
||||||
|
<Badge variant="secondary" className="text-[10px]">
|
||||||
|
{childrenByParent[selection.parent.id]?.length ?? 0} child pages
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 border-t border-border/60 pt-6">
|
||||||
|
{selection.kind === 'parent' ? (
|
||||||
|
selection.parent.notes ? (
|
||||||
|
<p className="whitespace-pre-wrap text-[15px] leading-relaxed text-foreground/90">
|
||||||
|
{selection.parent.notes}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm italic text-muted-foreground">
|
||||||
|
This page has no notes yet. Hit Edit to add some.
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
) : selection.child.notes ? (
|
||||||
|
<p className="whitespace-pre-wrap text-[15px] leading-relaxed text-foreground/90">
|
||||||
|
{selection.child.notes}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm italic text-muted-foreground">
|
||||||
|
This page has no notes yet. Hit Edit to add some.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{status ? (
|
||||||
|
<div className="flex items-center gap-2 rounded-xl border border-[rgba(34,211,238,0.35)] bg-[rgba(34,211,238,0.08)] px-4 py-3 text-sm text-primary">
|
||||||
|
<KeyRound className="h-4 w-4 shrink-0" />
|
||||||
|
{status}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
29
frontend/src/components/layout/AppLayout.tsx
Normal file
29
frontend/src/components/layout/AppLayout.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Outlet } from 'react-router-dom'
|
||||||
|
import { Header } from './Header'
|
||||||
|
import { Sidebar } from './Sidebar'
|
||||||
|
import { Sheet, SheetContent } from '@/components/ui/sheet'
|
||||||
|
|
||||||
|
export function AppLayout() {
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen flex-col bg-background">
|
||||||
|
<Header onMenuToggle={() => setSidebarOpen((open) => !open)} />
|
||||||
|
|
||||||
|
<div className="flex flex-1 overflow-hidden">
|
||||||
|
<Sidebar className="hidden md:flex" />
|
||||||
|
|
||||||
|
<Sheet open={sidebarOpen} onOpenChange={setSidebarOpen}>
|
||||||
|
<SheetContent side="left" className="w-[250px] border-sidebar-border bg-sidebar p-0">
|
||||||
|
<Sidebar />
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
|
||||||
|
<main className="flex-1 overflow-y-auto">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
218
frontend/src/components/layout/Header.tsx
Normal file
218
frontend/src/components/layout/Header.tsx
Normal file
|
|
@ -0,0 +1,218 @@
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
Bell,
|
||||||
|
CheckCircle2,
|
||||||
|
Fingerprint,
|
||||||
|
FolderOpen,
|
||||||
|
LogIn,
|
||||||
|
LogOut,
|
||||||
|
Menu,
|
||||||
|
Search,
|
||||||
|
ShieldCheck,
|
||||||
|
UserPlus,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu'
|
||||||
|
import { useAuth } from '@/context/AuthContext'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
interface HeaderProps {
|
||||||
|
onMenuToggle: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchPlaceholders = [
|
||||||
|
'Search in platform...',
|
||||||
|
'Search about functions used...',
|
||||||
|
'Search about securities provided...',
|
||||||
|
'Search for records and notes...',
|
||||||
|
]
|
||||||
|
|
||||||
|
const identifierLabel = (type: string) =>
|
||||||
|
type === 'email' ? 'Email' : type === 'mobile' ? 'Mobile' : 'Username'
|
||||||
|
|
||||||
|
export function Header({ onMenuToggle }: HeaderProps) {
|
||||||
|
const { session, isAuthenticated, signOut } = useAuth()
|
||||||
|
const [placeholderIndex, setPlaceholderIndex] = useState(0)
|
||||||
|
const [hideBell, setHideBell] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAuthenticated) {
|
||||||
|
const id = setTimeout(() => setHideBell(true), 800)
|
||||||
|
return () => clearTimeout(id)
|
||||||
|
}
|
||||||
|
setHideBell(false)
|
||||||
|
}, [isAuthenticated])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const id = setInterval(() => {
|
||||||
|
setPlaceholderIndex((i) => (i + 1) % searchPlaceholders.length)
|
||||||
|
}, 3000)
|
||||||
|
return () => clearInterval(id)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const notificationMessage =
|
||||||
|
isAuthenticated && session
|
||||||
|
? `Hey ${session.identifier}, you're one step away from encrypting your data. Head to your workspace to keep your records and passwords safe.`
|
||||||
|
: 'Hey there! You are one step away from encrypting your data. Create an account so your passwords and records stay private.'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="sticky top-0 z-40 grid h-16 grid-cols-[auto_1fr] items-center border-b border-sidebar-border bg-card/95 backdrop-blur supports-[backdrop-filter]:bg-card/80">
|
||||||
|
<div className="flex h-full w-[170px] items-center border-r border-sidebar-border bg-[#04101d] pl-4 md:w-[250px]">
|
||||||
|
<Link to="/" className="group flex items-center gap-2.5">
|
||||||
|
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-cyan-300 to-sky-600 text-[#03222e] shadow-[0_0_24px_-4px_rgba(34,211,238,0.8)] transition-all group-hover:shadow-[0_0_36px_-4px_rgba(34,211,238,1)]">
|
||||||
|
<Fingerprint className="h-5 w-5" />
|
||||||
|
</span>
|
||||||
|
<span className="leading-tight">
|
||||||
|
<span className="block text-[15px] font-semibold tracking-tight text-cyan-50">Zero Knowledge</span>
|
||||||
|
<span className="hidden text-[11px] font-normal text-cyan-400/60 md:block">Secure Data Platform</span>
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 px-3 sm:px-5">
|
||||||
|
<Button variant="ghost" size="icon" className="shrink-0 md:hidden" onClick={onMenuToggle}>
|
||||||
|
<Menu className="h-5 w-5" />
|
||||||
|
<span className="sr-only">Toggle sidebar</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="relative w-full max-w-xl">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
type="search"
|
||||||
|
placeholder={searchPlaceholders[placeholderIndex]}
|
||||||
|
className="h-9 bg-muted/60 pl-9 pr-4"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="ml-2 hidden items-center gap-2 text-[11px] font-medium uppercase tracking-[0.18em] text-cyan-400/60 xl:flex">
|
||||||
|
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-cyan-400 shadow-[0_0_10px_rgba(34,211,238,1)]" />
|
||||||
|
Zero-knowledge encryption active
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className="ml-auto flex items-center gap-1.5 sm:gap-2">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'transition-opacity duration-700 ease-out',
|
||||||
|
isAuthenticated && 'pointer-events-none opacity-0',
|
||||||
|
hideBell && 'hidden',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="relative">
|
||||||
|
<Bell className="h-4 w-4" />
|
||||||
|
<span className="absolute right-1.5 top-1.5 h-2 w-2 rounded-full bg-primary" />
|
||||||
|
<span className="sr-only">Notifications</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent className="w-80" align="end" forceMount>
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-secondary text-primary">
|
||||||
|
<ShieldCheck className="h-5 w-5" />
|
||||||
|
</span>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<p className="text-sm font-semibold leading-none">One step away from encryption</p>
|
||||||
|
<p className="text-xs leading-relaxed text-muted-foreground">{notificationMessage}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3">
|
||||||
|
{isAuthenticated ? (
|
||||||
|
<Button size="sm" className="w-full" asChild>
|
||||||
|
<Link to="/workspace">
|
||||||
|
<FolderOpen className="h-4 w-4" />
|
||||||
|
Go to workspace
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button size="sm" className="w-full" asChild>
|
||||||
|
<Link to="/signup">Create account</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isAuthenticated && session ? (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<button className="flex items-center gap-2 rounded-full border border-border bg-background py-1 pl-1 pr-3 transition-colors hover:bg-accent">
|
||||||
|
<Avatar className="h-7 w-7">
|
||||||
|
<AvatarFallback className="bg-primary text-[10px] text-primary-foreground">
|
||||||
|
{session.identifier.slice(0, 2).toUpperCase()}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<span className="hidden max-w-[140px] truncate text-xs font-medium text-foreground sm:block">
|
||||||
|
{session.identifier}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent className="w-64" align="end" forceMount>
|
||||||
|
<DropdownMenuLabel className="font-normal">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<p className="text-sm font-medium leading-none">{session.identifier}</p>
|
||||||
|
<p className="text-xs leading-none text-muted-foreground">
|
||||||
|
{identifierLabel(session.identifierType)}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-1.5 pt-1">
|
||||||
|
<Badge variant="secondary" className="gap-1 text-[10px]">
|
||||||
|
<CheckCircle2 className="h-3 w-3 text-emerald-600" />
|
||||||
|
Signed in
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline" className="font-mono text-[10px]">
|
||||||
|
{session.ownerId.slice(0, 10)}…
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link to="/workspace" className="cursor-pointer">
|
||||||
|
<FolderOpen className="h-4 w-4" />
|
||||||
|
My Workspace
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={signOut}
|
||||||
|
className="cursor-pointer text-destructive focus:text-destructive"
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4" />
|
||||||
|
Sign out
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Button variant="ghost" size="sm" asChild>
|
||||||
|
<Link to="/login">
|
||||||
|
<LogIn className="h-4 w-4" />
|
||||||
|
<span className="hidden sm:inline">Log in</span>
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" asChild>
|
||||||
|
<Link to="/signup">
|
||||||
|
<UserPlus className="h-4 w-4" />
|
||||||
|
<span className="hidden sm:inline">Sign up</span>
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
245
frontend/src/components/layout/Sidebar.tsx
Normal file
245
frontend/src/components/layout/Sidebar.tsx
Normal file
|
|
@ -0,0 +1,245 @@
|
||||||
|
import { NavLink, Link, useNavigate } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
FileText,
|
||||||
|
FolderOpen,
|
||||||
|
Home,
|
||||||
|
Info,
|
||||||
|
LogOut,
|
||||||
|
Plus,
|
||||||
|
ShieldCheck,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Separator } from '@/components/ui/separator'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { useAuth } from '@/context/AuthContext'
|
||||||
|
import { useWorkspace } from '@/context/WorkspaceContext'
|
||||||
|
|
||||||
|
interface SidebarProps {
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const mainNav = [
|
||||||
|
{ to: '/', label: 'Home', icon: Home },
|
||||||
|
{ to: '/about', label: 'About', icon: Info },
|
||||||
|
{ to: '/security', label: 'Security', icon: ShieldCheck },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function Sidebar({ className }: SidebarProps) {
|
||||||
|
const { isAuthenticated, signOut } = useAuth()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const {
|
||||||
|
parents,
|
||||||
|
childrenByParent,
|
||||||
|
expandedParents,
|
||||||
|
selection,
|
||||||
|
parentOfSelection,
|
||||||
|
toggleExpand,
|
||||||
|
selectParent,
|
||||||
|
selectChild,
|
||||||
|
requestCreate,
|
||||||
|
} = useWorkspace()
|
||||||
|
|
||||||
|
const createHint =
|
||||||
|
parentOfSelection === null
|
||||||
|
? 'Creates a new top-level page'
|
||||||
|
: `Creates a child under "${parentOfSelection.title || 'untitled page'}"`
|
||||||
|
|
||||||
|
function handleCreateParent() {
|
||||||
|
requestCreate('parent')
|
||||||
|
navigate('/workspace')
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCreateChild() {
|
||||||
|
requestCreate('child')
|
||||||
|
navigate('/workspace')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
className={cn(
|
||||||
|
'flex h-full w-[250px] flex-col border-r border-sidebar-border bg-sidebar text-sidebar-foreground',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<nav className="flex-1 overflow-y-auto px-2 py-4">
|
||||||
|
<p className="px-3 pb-2 text-[11px] font-semibold uppercase tracking-wider text-primary/50">
|
||||||
|
Navigation
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{mainNav.map((item) => (
|
||||||
|
<NavLink
|
||||||
|
key={item.to}
|
||||||
|
to={item.to}
|
||||||
|
end={item.to === '/'}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
cn(
|
||||||
|
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
|
||||||
|
isActive
|
||||||
|
? 'bg-sidebar-accent text-sidebar-accent-foreground shadow-[inset_0_0_0_1px_rgba(34,211,238,0.35)]'
|
||||||
|
: 'text-sidebar-foreground/70'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<item.icon className="h-4 w-4" />
|
||||||
|
{item.label}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isAuthenticated ? (
|
||||||
|
<>
|
||||||
|
<p className="px-3 pb-2 pt-5 text-[11px] font-semibold uppercase tracking-wider text-primary/50">
|
||||||
|
Account
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<NavLink
|
||||||
|
to="/workspace"
|
||||||
|
className={({ isActive }) =>
|
||||||
|
cn(
|
||||||
|
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
|
||||||
|
isActive
|
||||||
|
? 'bg-sidebar-accent text-sidebar-accent-foreground shadow-[inset_0_0_0_1px_rgba(34,211,238,0.35)]'
|
||||||
|
: 'text-sidebar-foreground/70'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<FolderOpen className="h-4 w-4" />
|
||||||
|
My Workspace
|
||||||
|
</NavLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 space-y-1">
|
||||||
|
<div className="flex items-center justify-between px-3 pb-1">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-wider text-primary/50">My Pages</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCreateParent}
|
||||||
|
title="Create a new top-level page"
|
||||||
|
aria-label="Create a new top-level page"
|
||||||
|
className="flex h-6 w-6 items-center justify-center rounded-md text-primary/70 transition-colors hover:bg-sidebar-accent hover:text-primary"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button size="sm" variant="secondary" className="w-full justify-start gap-2" onClick={handleCreateChild}>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
<p className="px-1 text-[10px] leading-snug text-muted-foreground">{createHint}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 max-h-[38vh] space-y-0.5 overflow-y-auto pr-1">
|
||||||
|
{parents.length === 0 ? (
|
||||||
|
<div className="rounded-lg border border-dashed border-border px-3 py-6 text-center">
|
||||||
|
<p className="text-xs text-muted-foreground">No pages yet. Click + to create one.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
parents.map((parent) => {
|
||||||
|
const isExpanded = expandedParents.has(parent.id)
|
||||||
|
const isParentActive =
|
||||||
|
selection !== null && selection.kind === 'parent' && selection.parent.id === parent.id
|
||||||
|
const children = childrenByParent[parent.id] ?? []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={parent.id}>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'group flex items-center gap-1 rounded-md py-1 pl-1 pr-2 transition-colors',
|
||||||
|
isParentActive
|
||||||
|
? 'bg-sidebar-accent text-sidebar-accent-foreground shadow-[inset_0_0_0_1px_rgba(34,211,238,0.35)]'
|
||||||
|
: 'hover:bg-accent/60'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleExpand(parent.id)}
|
||||||
|
className="flex h-5 w-5 shrink-0 items-center justify-center rounded text-muted-foreground hover:text-foreground"
|
||||||
|
aria-label={isExpanded ? 'Collapse page' : 'Expand page'}
|
||||||
|
>
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronDown className="h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => selectParent(parent)}
|
||||||
|
className={cn(
|
||||||
|
'flex min-w-0 flex-1 items-center gap-2 py-0.5 text-left text-sm',
|
||||||
|
isParentActive ? 'font-medium' : 'font-normal'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<FileText className="h-3.5 w-3.5 shrink-0 text-primary/60" />
|
||||||
|
<span className="truncate">{parent.title || '(untitled page)'}</span>
|
||||||
|
</button>
|
||||||
|
<span className="text-[10px] text-muted-foreground">{children.length}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isExpanded ? (
|
||||||
|
<div className="ml-[13px] border-l border-border pl-2.5">
|
||||||
|
{children.length === 0 ? (
|
||||||
|
<p className="py-1.5 pl-2 text-[11px] italic text-muted-foreground">
|
||||||
|
No child pages yet
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
children.map((child) => {
|
||||||
|
const isChildActive =
|
||||||
|
selection !== null &&
|
||||||
|
selection.kind === 'child' &&
|
||||||
|
selection.child.id === child.id
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={child.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => selectChild(child)}
|
||||||
|
className={cn(
|
||||||
|
'flex w-full items-center gap-2 rounded-md py-1.5 pl-2 pr-2 text-left text-sm transition-colors',
|
||||||
|
isChildActive
|
||||||
|
? 'bg-sidebar-accent text-sidebar-accent-foreground shadow-[inset_0_0_0_1px_rgba(34,211,238,0.35)]'
|
||||||
|
: 'text-sidebar-foreground/70 hover:bg-accent/60'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<FileText className="h-3.5 w-3.5 shrink-0 text-primary/40" />
|
||||||
|
<span className="truncate">{child.title || '(untitled child)'}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<Separator className="bg-sidebar-border" />
|
||||||
|
|
||||||
|
<div className="px-3 py-3">
|
||||||
|
{isAuthenticated ? (
|
||||||
|
<Button variant="outline" size="sm" className="w-full justify-start gap-2" onClick={signOut}>
|
||||||
|
<LogOut className="h-4 w-4" />
|
||||||
|
Sign out
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Button size="sm" asChild>
|
||||||
|
<Link to="/signup">Sign up</Link>
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" asChild>
|
||||||
|
<Link to="/login">Log in</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
48
frontend/src/components/ui/avatar.tsx
Normal file
48
frontend/src/components/ui/avatar.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Avatar = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AvatarPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AvatarPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Avatar.displayName = AvatarPrimitive.Root.displayName
|
||||||
|
|
||||||
|
const AvatarImage = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AvatarPrimitive.Image
|
||||||
|
ref={ref}
|
||||||
|
className={cn("aspect-square h-full w-full", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AvatarImage.displayName = AvatarPrimitive.Image.displayName
|
||||||
|
|
||||||
|
const AvatarFallback = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AvatarPrimitive.Fallback
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-full w-full items-center justify-center rounded-full bg-muted",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
|
||||||
|
|
||||||
|
export { Avatar, AvatarImage, AvatarFallback }
|
||||||
36
frontend/src/components/ui/badge.tsx
Normal file
36
frontend/src/components/ui/badge.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
|
||||||
|
secondary:
|
||||||
|
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
destructive:
|
||||||
|
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
|
||||||
|
outline: "text-foreground",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface BadgeProps
|
||||||
|
extends React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
VariantProps<typeof badgeVariants> {}
|
||||||
|
|
||||||
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants }
|
||||||
57
frontend/src/components/ui/button.tsx
Normal file
57
frontend/src/components/ui/button.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||||
|
outline:
|
||||||
|
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||||
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 px-4 py-2",
|
||||||
|
sm: "h-8 rounded-md px-3 text-xs",
|
||||||
|
lg: "h-10 rounded-md px-8",
|
||||||
|
icon: "h-9 w-9",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : "button"
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Button.displayName = "Button"
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
76
frontend/src/components/ui/card.tsx
Normal file
76
frontend/src/components/ui/card.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Card = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"rounded-xl border bg-card text-card-foreground shadow",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Card.displayName = "Card"
|
||||||
|
|
||||||
|
const CardHeader = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardHeader.displayName = "CardHeader"
|
||||||
|
|
||||||
|
const CardTitle = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("font-semibold leading-none tracking-tight", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardTitle.displayName = "CardTitle"
|
||||||
|
|
||||||
|
const CardDescription = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardDescription.displayName = "CardDescription"
|
||||||
|
|
||||||
|
const CardContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||||
|
))
|
||||||
|
CardContent.displayName = "CardContent"
|
||||||
|
|
||||||
|
const CardFooter = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex items-center p-6 pt-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardFooter.displayName = "CardFooter"
|
||||||
|
|
||||||
|
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||||
199
frontend/src/components/ui/dropdown-menu.tsx
Normal file
199
frontend/src/components/ui/dropdown-menu.tsx
Normal file
|
|
@ -0,0 +1,199 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||||
|
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||||
|
|
||||||
|
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||||
|
|
||||||
|
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||||
|
|
||||||
|
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||||
|
|
||||||
|
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||||
|
|
||||||
|
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||||
|
|
||||||
|
const DropdownMenuSubTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, children, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRight className="ml-auto" />
|
||||||
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
|
))
|
||||||
|
DropdownMenuSubTrigger.displayName =
|
||||||
|
DropdownMenuPrimitive.SubTrigger.displayName
|
||||||
|
|
||||||
|
const DropdownMenuSubContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.SubContent
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuSubContent.displayName =
|
||||||
|
DropdownMenuPrimitive.SubContent.displayName
|
||||||
|
|
||||||
|
const DropdownMenuContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||||
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Portal>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.Portal>
|
||||||
|
))
|
||||||
|
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const DropdownMenuItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||||
|
|
||||||
|
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
>(({ className, children, checked, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
))
|
||||||
|
DropdownMenuCheckboxItem.displayName =
|
||||||
|
DropdownMenuPrimitive.CheckboxItem.displayName
|
||||||
|
|
||||||
|
const DropdownMenuRadioItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.RadioItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<Circle className="h-2 w-2 fill-current" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
|
))
|
||||||
|
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||||
|
|
||||||
|
const DropdownMenuLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1.5 text-sm font-semibold",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||||
|
|
||||||
|
const DropdownMenuSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||||
|
|
||||||
|
const DropdownMenuShortcut = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
}
|
||||||
22
frontend/src/components/ui/input.tsx
Normal file
22
frontend/src/components/ui/input.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||||
|
({ className, type, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Input.displayName = "Input"
|
||||||
|
|
||||||
|
export { Input }
|
||||||
24
frontend/src/components/ui/label.tsx
Normal file
24
frontend/src/components/ui/label.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const labelVariants = cva(
|
||||||
|
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
|
)
|
||||||
|
|
||||||
|
const Label = React.forwardRef<
|
||||||
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||||
|
VariantProps<typeof labelVariants>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(labelVariants(), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Label.displayName = LabelPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Label }
|
||||||
31
frontend/src/components/ui/separator.tsx
Normal file
31
frontend/src/components/ui/separator.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Separator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||||
|
>(
|
||||||
|
(
|
||||||
|
{ className, orientation = "horizontal", decorative = true, ...props },
|
||||||
|
ref
|
||||||
|
) => (
|
||||||
|
<SeparatorPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
decorative={decorative}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 bg-border",
|
||||||
|
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Separator.displayName = SeparatorPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Separator }
|
||||||
138
frontend/src/components/ui/sheet.tsx
Normal file
138
frontend/src/components/ui/sheet.tsx
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import * as SheetPrimitive from "@radix-ui/react-dialog"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { X } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Sheet = SheetPrimitive.Root
|
||||||
|
|
||||||
|
const SheetTrigger = SheetPrimitive.Trigger
|
||||||
|
|
||||||
|
const SheetClose = SheetPrimitive.Close
|
||||||
|
|
||||||
|
const SheetPortal = SheetPrimitive.Portal
|
||||||
|
|
||||||
|
const SheetOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SheetPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SheetPrimitive.Overlay
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
|
||||||
|
|
||||||
|
const sheetVariants = cva(
|
||||||
|
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
side: {
|
||||||
|
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
|
||||||
|
bottom:
|
||||||
|
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
||||||
|
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
|
||||||
|
right:
|
||||||
|
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
side: "right",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
interface SheetContentProps
|
||||||
|
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
||||||
|
VariantProps<typeof sheetVariants> {}
|
||||||
|
|
||||||
|
const SheetContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SheetPrimitive.Content>,
|
||||||
|
SheetContentProps
|
||||||
|
>(({ side = "right", className, children, ...props }, ref) => (
|
||||||
|
<SheetPortal>
|
||||||
|
<SheetOverlay />
|
||||||
|
<SheetPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(sheetVariants({ side }), className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</SheetPrimitive.Close>
|
||||||
|
{children}
|
||||||
|
</SheetPrimitive.Content>
|
||||||
|
</SheetPortal>
|
||||||
|
))
|
||||||
|
SheetContent.displayName = SheetPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const SheetHeader = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col space-y-2 text-center sm:text-left",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
SheetHeader.displayName = "SheetHeader"
|
||||||
|
|
||||||
|
const SheetFooter = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
SheetFooter.displayName = "SheetFooter"
|
||||||
|
|
||||||
|
const SheetTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SheetPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SheetPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-lg font-semibold text-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SheetTitle.displayName = SheetPrimitive.Title.displayName
|
||||||
|
|
||||||
|
const SheetDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SheetPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SheetPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SheetDescription.displayName = SheetPrimitive.Description.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
Sheet,
|
||||||
|
SheetPortal,
|
||||||
|
SheetOverlay,
|
||||||
|
SheetTrigger,
|
||||||
|
SheetClose,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetFooter,
|
||||||
|
SheetTitle,
|
||||||
|
SheetDescription,
|
||||||
|
}
|
||||||
22
frontend/src/components/ui/textarea.tsx
Normal file
22
frontend/src/components/ui/textarea.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Textarea = React.forwardRef<
|
||||||
|
HTMLTextAreaElement,
|
||||||
|
React.ComponentProps<"textarea">
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
Textarea.displayName = "Textarea"
|
||||||
|
|
||||||
|
export { Textarea }
|
||||||
32
frontend/src/components/ui/tooltip.tsx
Normal file
32
frontend/src/components/ui/tooltip.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const TooltipProvider = TooltipPrimitive.Provider
|
||||||
|
|
||||||
|
const Tooltip = TooltipPrimitive.Root
|
||||||
|
|
||||||
|
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||||
|
|
||||||
|
const TooltipContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||||
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
|
<TooltipPrimitive.Portal>
|
||||||
|
<TooltipPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-tooltip-content-transform-origin]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</TooltipPrimitive.Portal>
|
||||||
|
))
|
||||||
|
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||||
53
frontend/src/context/AuthContext.tsx
Normal file
53
frontend/src/context/AuthContext.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
import { createContext, useContext, useMemo, useState, type ReactNode } from 'react'
|
||||||
|
|
||||||
|
import { login, signup, type ClientAuthSession } from '../lib/auth-api'
|
||||||
|
import type { IdentifierType } from '../lib/crypto'
|
||||||
|
|
||||||
|
type AuthActionInput = {
|
||||||
|
identifierType: IdentifierType
|
||||||
|
identifier: string
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthContextValue = {
|
||||||
|
session: ClientAuthSession | null
|
||||||
|
isAuthenticated: boolean
|
||||||
|
signUp: (input: AuthActionInput) => Promise<void>
|
||||||
|
signIn: (input: AuthActionInput) => Promise<void>
|
||||||
|
signOut: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextValue | null>(null)
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [session, setSession] = useState<ClientAuthSession | null>(null)
|
||||||
|
|
||||||
|
const value = useMemo<AuthContextValue>(
|
||||||
|
() => ({
|
||||||
|
session,
|
||||||
|
isAuthenticated: session !== null,
|
||||||
|
signUp: async (input: AuthActionInput) => {
|
||||||
|
const nextSession = await signup(input.identifierType, input.identifier, input.password)
|
||||||
|
setSession(nextSession)
|
||||||
|
},
|
||||||
|
signIn: async (input: AuthActionInput) => {
|
||||||
|
const nextSession = await login(input.identifierType, input.identifier, input.password)
|
||||||
|
setSession(nextSession)
|
||||||
|
},
|
||||||
|
signOut: () => {
|
||||||
|
setSession(null)
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[session],
|
||||||
|
)
|
||||||
|
|
||||||
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const context = useContext(AuthContext)
|
||||||
|
if (context === null) {
|
||||||
|
throw new Error('useAuth must be used within an AuthProvider')
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
288
frontend/src/context/WorkspaceContext.tsx
Normal file
288
frontend/src/context/WorkspaceContext.tsx
Normal file
|
|
@ -0,0 +1,288 @@
|
||||||
|
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
|
||||||
|
|
||||||
|
import { useAuth } from './AuthContext'
|
||||||
|
import {
|
||||||
|
createChild,
|
||||||
|
createParent,
|
||||||
|
deleteChild,
|
||||||
|
deleteParent,
|
||||||
|
listChildren,
|
||||||
|
listParents,
|
||||||
|
updateChild,
|
||||||
|
updateParent,
|
||||||
|
type ChildView,
|
||||||
|
type ParentView,
|
||||||
|
} from '../lib/records-api'
|
||||||
|
|
||||||
|
export type WorkspaceSelection =
|
||||||
|
| { kind: 'parent'; parent: ParentView }
|
||||||
|
| { kind: 'child'; child: ChildView }
|
||||||
|
|
||||||
|
type WorkspaceContextValue = {
|
||||||
|
parents: ParentView[]
|
||||||
|
childrenByParent: Record<string, ChildView[]>
|
||||||
|
expandedParents: Set<string>
|
||||||
|
selection: WorkspaceSelection | null
|
||||||
|
parentOfSelection: ParentView | null
|
||||||
|
status: string
|
||||||
|
isLoading: boolean
|
||||||
|
createIntent: 'parent' | 'child' | null
|
||||||
|
requestCreate: (mode: 'parent' | 'child' | null) => void
|
||||||
|
toggleExpand: (parentId: string) => Promise<void>
|
||||||
|
selectParent: (parent: ParentView) => void
|
||||||
|
selectChild: (child: ChildView) => void
|
||||||
|
createParentPage: (title: string, notes: string) => Promise<ParentView>
|
||||||
|
createChildPage: (parentId: string, title: string, notes: string) => Promise<ChildView>
|
||||||
|
updateSelection: (title: string, notes: string) => Promise<void>
|
||||||
|
deleteSelection: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
const WorkspaceContext = createContext<WorkspaceContextValue | null>(null)
|
||||||
|
|
||||||
|
export function WorkspaceProvider({ children }: { children: ReactNode }) {
|
||||||
|
const { session } = useAuth()
|
||||||
|
const [parents, setParents] = useState<ParentView[]>([])
|
||||||
|
const [childrenByParent, setChildrenByParent] = useState<Record<string, ChildView[]>>({})
|
||||||
|
const [expandedParents, setExpandedParents] = useState<Set<string>>(new Set())
|
||||||
|
const [selection, setSelection] = useState<WorkspaceSelection | null>(null)
|
||||||
|
const [status, setStatus] = useState('')
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const [createIntent, setCreateIntent] = useState<'parent' | 'child' | null>(null)
|
||||||
|
|
||||||
|
const parentOfSelection = (() => {
|
||||||
|
if (selection === null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (selection.kind === 'parent') {
|
||||||
|
return selection.parent
|
||||||
|
}
|
||||||
|
return parents.find((parent) => parent.id === selection.child.parentId) ?? null
|
||||||
|
})()
|
||||||
|
|
||||||
|
async function refreshChildren(parentId: string) {
|
||||||
|
if (session === null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const childList = await listChildren(session, parentId)
|
||||||
|
setChildrenByParent((current) => ({
|
||||||
|
...current,
|
||||||
|
[parentId]: childList,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshParents(nextSelectedParentId?: string | null) {
|
||||||
|
if (session === null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentList = await listParents(session)
|
||||||
|
setParents(parentList)
|
||||||
|
|
||||||
|
const selectedId = nextSelectedParentId ?? parentList[0]?.id ?? null
|
||||||
|
if (selectedId === null) {
|
||||||
|
setSelection(null)
|
||||||
|
setChildrenByParent({})
|
||||||
|
setExpandedParents(new Set())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = parentList.find((parent) => parent.id === selectedId) ?? parentList[0] ?? null
|
||||||
|
if (target === null) {
|
||||||
|
setSelection(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const childList = await listChildren(session, target.id)
|
||||||
|
setChildrenByParent((current) => ({
|
||||||
|
...current,
|
||||||
|
[target.id]: childList,
|
||||||
|
}))
|
||||||
|
setExpandedParents((current) => new Set(current).add(target.id))
|
||||||
|
setSelection({ kind: 'parent', parent: target })
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (session === null) {
|
||||||
|
setParents([])
|
||||||
|
setChildrenByParent({})
|
||||||
|
setExpandedParents(new Set())
|
||||||
|
setSelection(null)
|
||||||
|
setStatus('')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false
|
||||||
|
setIsLoading(true)
|
||||||
|
setStatus('Decrypting your workspace...')
|
||||||
|
|
||||||
|
refreshParents()
|
||||||
|
.then(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setStatus('Workspace unlocked. All records decrypted locally.')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setStatus(error instanceof Error ? error.message : 'Failed to load workspace.')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [session])
|
||||||
|
|
||||||
|
const value: WorkspaceContextValue = {
|
||||||
|
parents,
|
||||||
|
childrenByParent,
|
||||||
|
expandedParents,
|
||||||
|
selection,
|
||||||
|
parentOfSelection,
|
||||||
|
status,
|
||||||
|
isLoading,
|
||||||
|
createIntent,
|
||||||
|
requestCreate: (mode) => setCreateIntent(mode),
|
||||||
|
toggleExpand: async (parentId: string) => {
|
||||||
|
if (expandedParents.has(parentId)) {
|
||||||
|
setExpandedParents((current) => {
|
||||||
|
const next = new Set(current)
|
||||||
|
next.delete(parentId)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setExpandedParents((current) => new Set(current).add(parentId))
|
||||||
|
if (childrenByParent[parentId] === undefined) {
|
||||||
|
try {
|
||||||
|
await refreshChildren(parentId)
|
||||||
|
} catch (error) {
|
||||||
|
setStatus(error instanceof Error ? error.message : 'Failed to load child records.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
selectParent: (parent) => {
|
||||||
|
setSelection({ kind: 'parent', parent })
|
||||||
|
setExpandedParents((current) => new Set(current).add(parent.id))
|
||||||
|
if (childrenByParent[parent.id] === undefined) {
|
||||||
|
refreshChildren(parent.id).catch((error) => {
|
||||||
|
setStatus(error instanceof Error ? error.message : 'Failed to load child records.')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
selectChild: (child) => setSelection({ kind: 'child', child }),
|
||||||
|
createParentPage: async (title, notes) => {
|
||||||
|
if (session === null) {
|
||||||
|
throw new Error('Not authenticated.')
|
||||||
|
}
|
||||||
|
setStatus('Encrypting new page locally...')
|
||||||
|
try {
|
||||||
|
const next = await createParent(session, title, notes)
|
||||||
|
await refreshParents(next.id)
|
||||||
|
setStatus('New page stored as an encrypted blob.')
|
||||||
|
return next
|
||||||
|
} catch (error) {
|
||||||
|
setStatus(error instanceof Error ? error.message : 'Failed to create page.')
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
},
|
||||||
|
createChildPage: async (parentId, title, notes) => {
|
||||||
|
if (session === null) {
|
||||||
|
throw new Error('Not authenticated.')
|
||||||
|
}
|
||||||
|
setStatus('Encrypting child page locally...')
|
||||||
|
try {
|
||||||
|
const child = await createChild(session, parentId, title, notes)
|
||||||
|
setChildrenByParent((current) => ({
|
||||||
|
...current,
|
||||||
|
[parentId]: [child, ...(current[parentId] ?? [])],
|
||||||
|
}))
|
||||||
|
setExpandedParents((current) => new Set(current).add(parentId))
|
||||||
|
setSelection({ kind: 'child', child })
|
||||||
|
setStatus('Child page stored as an encrypted blob.')
|
||||||
|
return child
|
||||||
|
} catch (error) {
|
||||||
|
setStatus(error instanceof Error ? error.message : 'Failed to create child page.')
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
},
|
||||||
|
updateSelection: async (title, notes) => {
|
||||||
|
if (session === null || selection === null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setStatus('Encrypting changes locally...')
|
||||||
|
try {
|
||||||
|
if (selection.kind === 'parent') {
|
||||||
|
const updated = await updateParent(session, selection.parent.id, title, notes)
|
||||||
|
setParents((current) => current.map((parent) => (parent.id === updated.id ? updated : parent)))
|
||||||
|
setSelection({ kind: 'parent', parent: updated })
|
||||||
|
} else {
|
||||||
|
const updated = await updateChild(session, selection.child.parentId, selection.child.id, title, notes)
|
||||||
|
setChildrenByParent((current) => ({
|
||||||
|
...current,
|
||||||
|
[selection.child.parentId]: (current[selection.child.parentId] ?? []).map((child) =>
|
||||||
|
child.id === updated.id ? updated : child,
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
setSelection({ kind: 'child', child: updated })
|
||||||
|
}
|
||||||
|
setStatus('Page updated through an encrypted payload.')
|
||||||
|
} catch (error) {
|
||||||
|
setStatus(error instanceof Error ? error.message : 'Failed to update page.')
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
},
|
||||||
|
deleteSelection: async () => {
|
||||||
|
if (session === null || selection === null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (selection.kind === 'parent') {
|
||||||
|
await deleteParent(session, selection.parent.id)
|
||||||
|
setParents((current) => current.filter((parent) => parent.id !== selection.parent.id))
|
||||||
|
setChildrenByParent((current) => {
|
||||||
|
const next = { ...current }
|
||||||
|
delete next[selection.parent.id]
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
setExpandedParents((current) => {
|
||||||
|
const next = new Set(current)
|
||||||
|
next.delete(selection.parent.id)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
setSelection(null)
|
||||||
|
setStatus('Page and its encrypted children were removed.')
|
||||||
|
} else {
|
||||||
|
await deleteChild(session, selection.child.parentId, selection.child.id)
|
||||||
|
setChildrenByParent((current) => ({
|
||||||
|
...current,
|
||||||
|
[selection.child.parentId]: (current[selection.child.parentId] ?? []).filter(
|
||||||
|
(child) => child.id !== selection.child.id,
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
const parent = parents.find((item) => item.id === selection.child.parentId) ?? null
|
||||||
|
setSelection(parent === null ? null : { kind: 'parent', parent })
|
||||||
|
setStatus('Child page deleted.')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setStatus(error instanceof Error ? error.message : 'Failed to delete page.')
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return <WorkspaceContext.Provider value={value}>{children}</WorkspaceContext.Provider>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWorkspace() {
|
||||||
|
const context = useContext(WorkspaceContext)
|
||||||
|
if (context === null) {
|
||||||
|
throw new Error('useWorkspace must be used within a WorkspaceProvider')
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
107
frontend/src/lib/auth-api.ts
Normal file
107
frontend/src/lib/auth-api.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
import {
|
||||||
|
base64ToBytes,
|
||||||
|
bytesToBase64,
|
||||||
|
deriveAuthKeyMaterial,
|
||||||
|
deriveEncryptionKey,
|
||||||
|
generateSalt,
|
||||||
|
type IdentifierType,
|
||||||
|
normalizeIdentifier,
|
||||||
|
} from './crypto'
|
||||||
|
|
||||||
|
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8000'
|
||||||
|
|
||||||
|
type AuthSessionPayload = {
|
||||||
|
accessToken: string
|
||||||
|
tokenType: string
|
||||||
|
ownerId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ClientAuthSession = {
|
||||||
|
accessToken: string
|
||||||
|
ownerId: string
|
||||||
|
identifierType: IdentifierType
|
||||||
|
identifier: string
|
||||||
|
encKey: CryptoKey
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestJson<T>(path: string, body: unknown): Promise<T> {
|
||||||
|
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
|
||||||
|
const responseText = await response.text()
|
||||||
|
const data = responseText ? JSON.parse(responseText) : null
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const message = data?.detail ?? data?.message ?? 'Request failed.'
|
||||||
|
throw new Error(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
return data as T
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function signup(
|
||||||
|
identifierType: IdentifierType,
|
||||||
|
identifier: string,
|
||||||
|
password: string,
|
||||||
|
): Promise<ClientAuthSession> {
|
||||||
|
const saltAuth = generateSalt()
|
||||||
|
const saltEnc = generateSalt()
|
||||||
|
const normalizedIdentifier = normalizeIdentifier(identifierType, identifier)
|
||||||
|
const authKeyMaterial = await deriveAuthKeyMaterial(password, saltAuth)
|
||||||
|
const encKey = await deriveEncryptionKey(password, saltEnc)
|
||||||
|
|
||||||
|
const response = await requestJson<AuthSessionPayload>('/auth/signup', {
|
||||||
|
identifierType,
|
||||||
|
identifier: normalizedIdentifier,
|
||||||
|
saltAuth: bytesToBase64(saltAuth),
|
||||||
|
saltEnc: bytesToBase64(saltEnc),
|
||||||
|
authKeyMaterial,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
accessToken: response.accessToken,
|
||||||
|
ownerId: response.ownerId,
|
||||||
|
identifierType,
|
||||||
|
identifier: normalizedIdentifier,
|
||||||
|
encKey,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function login(
|
||||||
|
identifierType: IdentifierType,
|
||||||
|
identifier: string,
|
||||||
|
password: string,
|
||||||
|
): Promise<ClientAuthSession> {
|
||||||
|
const normalizedIdentifier = normalizeIdentifier(identifierType, identifier)
|
||||||
|
const bootstrap = await requestJson<{
|
||||||
|
identifierType: IdentifierType
|
||||||
|
identifier: string
|
||||||
|
saltAuth: string
|
||||||
|
saltEnc: string
|
||||||
|
}>('/auth/bootstrap', {
|
||||||
|
identifierType,
|
||||||
|
identifier: normalizedIdentifier,
|
||||||
|
})
|
||||||
|
|
||||||
|
const authKeyMaterial = await deriveAuthKeyMaterial(password, base64ToBytes(bootstrap.saltAuth))
|
||||||
|
const encKey = await deriveEncryptionKey(password, base64ToBytes(bootstrap.saltEnc))
|
||||||
|
|
||||||
|
const response = await requestJson<AuthSessionPayload>('/auth/login', {
|
||||||
|
identifierType,
|
||||||
|
identifier: normalizedIdentifier,
|
||||||
|
authKeyMaterial,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
accessToken: response.accessToken,
|
||||||
|
ownerId: response.ownerId,
|
||||||
|
identifierType,
|
||||||
|
identifier: normalizedIdentifier,
|
||||||
|
encKey,
|
||||||
|
}
|
||||||
|
}
|
||||||
31
frontend/src/lib/crypto.test.ts
Normal file
31
frontend/src/lib/crypto.test.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import {
|
||||||
|
decryptText,
|
||||||
|
deriveEncryptionKey,
|
||||||
|
encryptText,
|
||||||
|
generateSalt,
|
||||||
|
} from './crypto'
|
||||||
|
|
||||||
|
describe('crypto helpers', () => {
|
||||||
|
it('round-trips plaintext with AES-GCM', async () => {
|
||||||
|
const salt = generateSalt()
|
||||||
|
const key = await deriveEncryptionKey('correct horse battery staple', salt)
|
||||||
|
|
||||||
|
const encrypted = await encryptText('hello zero knowledge', key)
|
||||||
|
const decrypted = await decryptText(encrypted, key)
|
||||||
|
|
||||||
|
expect(decrypted).toBe('hello zero knowledge')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('produces different ciphertext for the same plaintext on each call', async () => {
|
||||||
|
const salt = generateSalt()
|
||||||
|
const key = await deriveEncryptionKey('correct horse battery staple', salt)
|
||||||
|
|
||||||
|
const first = await encryptText('repeatable text', key)
|
||||||
|
const second = await encryptText('repeatable text', key)
|
||||||
|
|
||||||
|
expect(first.iv).not.toBe(second.iv)
|
||||||
|
expect(first.ciphertext).not.toBe(second.ciphertext)
|
||||||
|
})
|
||||||
|
})
|
||||||
109
frontend/src/lib/crypto.ts
Normal file
109
frontend/src/lib/crypto.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
const PBKDF2_ITERATIONS = 310_000
|
||||||
|
const PBKDF2_HASH = 'SHA-256'
|
||||||
|
const DERIVED_KEY_LENGTH = 256
|
||||||
|
const AES_GCM_IV_LENGTH = 12
|
||||||
|
|
||||||
|
export type IdentifierType = 'email' | 'username' | 'mobile'
|
||||||
|
|
||||||
|
export type EncryptedPayload = {
|
||||||
|
iv: string
|
||||||
|
ciphertext: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateSalt(length = 16): Uint8Array {
|
||||||
|
const salt = new Uint8Array(length)
|
||||||
|
crypto.getRandomValues(salt)
|
||||||
|
return salt
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bytesToBase64(bytes: ArrayBuffer | Uint8Array): string {
|
||||||
|
const array = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes)
|
||||||
|
let binary = ''
|
||||||
|
for (let index = 0; index < array.length; index += 1) {
|
||||||
|
binary += String.fromCharCode(array[index])
|
||||||
|
}
|
||||||
|
return btoa(binary)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function base64ToBytes(value: string): Uint8Array {
|
||||||
|
const binary = atob(value)
|
||||||
|
const bytes = new Uint8Array(binary.length)
|
||||||
|
for (let index = 0; index < binary.length; index += 1) {
|
||||||
|
bytes[index] = binary.charCodeAt(index)
|
||||||
|
}
|
||||||
|
return bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeIdentifier(identifierType: IdentifierType, identifier: string): string {
|
||||||
|
const trimmed = identifier.trim()
|
||||||
|
if (identifierType === 'email') {
|
||||||
|
return trimmed.toLowerCase()
|
||||||
|
}
|
||||||
|
if (identifierType === 'mobile') {
|
||||||
|
return trimmed.replace(/[\s\-().]/g, '')
|
||||||
|
}
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deriveRawBits(password: string, salt: Uint8Array): Promise<ArrayBuffer> {
|
||||||
|
const passwordKey = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
new TextEncoder().encode(password),
|
||||||
|
'PBKDF2',
|
||||||
|
false,
|
||||||
|
['deriveBits'],
|
||||||
|
)
|
||||||
|
|
||||||
|
return crypto.subtle.deriveBits(
|
||||||
|
{
|
||||||
|
name: 'PBKDF2',
|
||||||
|
hash: PBKDF2_HASH,
|
||||||
|
salt,
|
||||||
|
iterations: PBKDF2_ITERATIONS,
|
||||||
|
},
|
||||||
|
passwordKey,
|
||||||
|
DERIVED_KEY_LENGTH,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deriveAuthKeyMaterial(password: string, salt: Uint8Array): Promise<string> {
|
||||||
|
const rawBits = await deriveRawBits(password, salt)
|
||||||
|
return bytesToBase64(rawBits)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deriveEncryptionKey(password: string, salt: Uint8Array): Promise<CryptoKey> {
|
||||||
|
const rawBits = await deriveRawBits(password, salt)
|
||||||
|
return crypto.subtle.importKey('raw', rawBits, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt'])
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function encryptText(plaintext: string, key: CryptoKey): Promise<EncryptedPayload> {
|
||||||
|
const iv = new Uint8Array(AES_GCM_IV_LENGTH)
|
||||||
|
crypto.getRandomValues(iv)
|
||||||
|
|
||||||
|
const ciphertext = await crypto.subtle.encrypt(
|
||||||
|
{
|
||||||
|
name: 'AES-GCM',
|
||||||
|
iv,
|
||||||
|
},
|
||||||
|
key,
|
||||||
|
new TextEncoder().encode(plaintext),
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
iv: bytesToBase64(iv),
|
||||||
|
ciphertext: bytesToBase64(ciphertext),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function decryptText(payload: EncryptedPayload, key: CryptoKey): Promise<string> {
|
||||||
|
const plaintext = await crypto.subtle.decrypt(
|
||||||
|
{
|
||||||
|
name: 'AES-GCM',
|
||||||
|
iv: base64ToBytes(payload.iv),
|
||||||
|
},
|
||||||
|
key,
|
||||||
|
base64ToBytes(payload.ciphertext),
|
||||||
|
)
|
||||||
|
|
||||||
|
return new TextDecoder().decode(plaintext)
|
||||||
|
}
|
||||||
152
frontend/src/lib/records-api.ts
Normal file
152
frontend/src/lib/records-api.ts
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
import type { ClientAuthSession } from './auth-api'
|
||||||
|
import { decryptText, encryptText, type EncryptedPayload } from './crypto'
|
||||||
|
|
||||||
|
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8000'
|
||||||
|
|
||||||
|
export type ParentRecord = EncryptedPayload & {
|
||||||
|
id: string
|
||||||
|
ownerId: string
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ChildRecord = EncryptedPayload & {
|
||||||
|
id: string
|
||||||
|
parentId: string
|
||||||
|
ownerId: string
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ParentView = {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
notes: string
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ChildView = {
|
||||||
|
id: string
|
||||||
|
parentId: string
|
||||||
|
title: string
|
||||||
|
notes: string
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type RequestMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'
|
||||||
|
|
||||||
|
async function requestJson<T>(path: string, method: RequestMethod, token: string, body?: unknown): Promise<T> {
|
||||||
|
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
})
|
||||||
|
|
||||||
|
const responseText = await response.text()
|
||||||
|
const data = responseText ? JSON.parse(responseText) : null
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const message = data?.detail ?? data?.message ?? 'Request failed.'
|
||||||
|
throw new Error(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
return data as T
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeView(title: string, notes: string) {
|
||||||
|
return JSON.stringify({ title, notes })
|
||||||
|
}
|
||||||
|
|
||||||
|
function deserializeView(value: string): { title: string; notes: string } {
|
||||||
|
const parsed = JSON.parse(value) as { title?: string; notes?: string }
|
||||||
|
return {
|
||||||
|
title: typeof parsed.title === 'string' ? parsed.title : '',
|
||||||
|
notes: typeof parsed.notes === 'string' ? parsed.notes : '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function decryptParentRecord(record: ParentRecord, session: ClientAuthSession): Promise<ParentView> {
|
||||||
|
const plaintext = await decryptText({ iv: record.iv, ciphertext: record.ciphertext }, session.encKey)
|
||||||
|
const view = deserializeView(plaintext)
|
||||||
|
return {
|
||||||
|
id: record.id,
|
||||||
|
title: view.title,
|
||||||
|
notes: view.notes,
|
||||||
|
createdAt: record.createdAt,
|
||||||
|
updatedAt: record.updatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function decryptChildRecord(record: ChildRecord, session: ClientAuthSession): Promise<ChildView> {
|
||||||
|
const plaintext = await decryptText({ iv: record.iv, ciphertext: record.ciphertext }, session.encKey)
|
||||||
|
const view = deserializeView(plaintext)
|
||||||
|
return {
|
||||||
|
id: record.id,
|
||||||
|
parentId: record.parentId,
|
||||||
|
title: view.title,
|
||||||
|
notes: view.notes,
|
||||||
|
createdAt: record.createdAt,
|
||||||
|
updatedAt: record.updatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listParents(session: ClientAuthSession): Promise<ParentView[]> {
|
||||||
|
const records = await requestJson<ParentRecord[]>('/parents', 'GET', session.accessToken)
|
||||||
|
return Promise.all(records.map((record) => decryptParentRecord(record, session)))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createParent(session: ClientAuthSession, title: string, notes: string): Promise<ParentView> {
|
||||||
|
const encrypted = await encryptText(serializeView(title, notes), session.encKey)
|
||||||
|
const record = await requestJson<ParentRecord>('/parents', 'POST', session.accessToken, encrypted)
|
||||||
|
return decryptParentRecord(record, session)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateParent(session: ClientAuthSession, parentId: string, title: string, notes: string): Promise<ParentView> {
|
||||||
|
const encrypted = await encryptText(serializeView(title, notes), session.encKey)
|
||||||
|
const record = await requestJson<ParentRecord>(`/parents/${parentId}`, 'PUT', session.accessToken, encrypted)
|
||||||
|
return decryptParentRecord(record, session)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteParent(session: ClientAuthSession, parentId: string): Promise<void> {
|
||||||
|
await requestJson<void>(`/parents/${parentId}`, 'DELETE', session.accessToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listChildren(session: ClientAuthSession, parentId: string): Promise<ChildView[]> {
|
||||||
|
const records = await requestJson<ChildRecord[]>(`/parents/${parentId}/children`, 'GET', session.accessToken)
|
||||||
|
return Promise.all(records.map((record) => decryptChildRecord(record, session)))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createChild(
|
||||||
|
session: ClientAuthSession,
|
||||||
|
parentId: string,
|
||||||
|
title: string,
|
||||||
|
notes: string,
|
||||||
|
): Promise<ChildView> {
|
||||||
|
const encrypted = await encryptText(serializeView(title, notes), session.encKey)
|
||||||
|
const record = await requestJson<ChildRecord>(`/parents/${parentId}/children`, 'POST', session.accessToken, {
|
||||||
|
parentId,
|
||||||
|
...encrypted,
|
||||||
|
})
|
||||||
|
return decryptChildRecord(record, session)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateChild(
|
||||||
|
session: ClientAuthSession,
|
||||||
|
parentId: string,
|
||||||
|
childId: string,
|
||||||
|
title: string,
|
||||||
|
notes: string,
|
||||||
|
): Promise<ChildView> {
|
||||||
|
const encrypted = await encryptText(serializeView(title, notes), session.encKey)
|
||||||
|
const record = await requestJson<ChildRecord>(`/parents/${parentId}/children/${childId}`, 'PUT', session.accessToken, encrypted)
|
||||||
|
return decryptChildRecord(record, session)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteChild(session: ClientAuthSession, parentId: string, childId: string): Promise<void> {
|
||||||
|
await requestJson<void>(`/parents/${parentId}/children/${childId}`, 'DELETE', session.accessToken)
|
||||||
|
}
|
||||||
6
frontend/src/lib/utils.ts
Normal file
6
frontend/src/lib/utils.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { clsx, type ClassValue } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
11
frontend/src/main.tsx
Normal file
11
frontend/src/main.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
|
||||||
|
import App from './App'
|
||||||
|
import './styles.css'
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
)
|
||||||
98
frontend/src/pages/AboutPage.tsx
Normal file
98
frontend/src/pages/AboutPage.tsx
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { Eye, KeyRound, ServerCog, ShieldCheck, Sparkles, ArrowRight } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
|
||||||
|
const pillars = [
|
||||||
|
{
|
||||||
|
icon: Eye,
|
||||||
|
title: 'Privacy by design',
|
||||||
|
description:
|
||||||
|
'Your plaintext never leaves the browser. The server only ever stores ciphertext, salts, and hashed authentication material.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: KeyRound,
|
||||||
|
title: 'Keys separated from auth',
|
||||||
|
description:
|
||||||
|
'Your password derives two independent branches: an auth key for signing in and an encryption key for your data. Even the server cannot decrypt your records.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: ServerCog,
|
||||||
|
title: 'A minimal, auditable server',
|
||||||
|
description:
|
||||||
|
'The FastAPI backend handles accounts and storage only. With no access to encryption keys, a compromised server yields nothing but encrypted blobs.',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export function AboutPage() {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-4xl p-6 sm:p-10 lg:p-14">
|
||||||
|
<p className="text-sm font-semibold uppercase tracking-widest text-primary">About</p>
|
||||||
|
<h1 className="mt-2 text-3xl font-bold tracking-tight sm:text-4xl">
|
||||||
|
A data platform that cannot see your data.
|
||||||
|
</h1>
|
||||||
|
<p className="mt-4 text-lg leading-relaxed text-muted-foreground">
|
||||||
|
Zero Knowledge is a service for storing structured notes and records with a guarantee most platforms
|
||||||
|
cannot make: we never see what you write. Encryption happens on your device, before anything is sent
|
||||||
|
to our servers.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-8 grid gap-4 sm:grid-cols-3">
|
||||||
|
{pillars.map((pillar) => (
|
||||||
|
<Card key={pillar.title} className="border-border/70 bg-card">
|
||||||
|
<CardHeader>
|
||||||
|
<pillar.icon className="mb-1 h-7 w-7 text-primary" />
|
||||||
|
<CardTitle className="text-base">{pillar.title}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<CardDescription className="leading-relaxed">{pillar.description}</CardDescription>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="mt-8 border-border/70 bg-gradient-to-br from-secondary/60 to-background">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Sparkles className="h-5 w-5 text-primary" />
|
||||||
|
<CardTitle className="text-lg">How your data stays yours</CardTitle>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ol className="space-y-3 text-sm leading-relaxed text-muted-foreground">
|
||||||
|
<li>
|
||||||
|
<span className="font-semibold text-foreground">1. You sign up with an identifier and password.</span>{' '}
|
||||||
|
Your browser derives unique random salts for authentication and encryption.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span className="font-semibold text-foreground">2. Keys are derived locally.</span>{' '}
|
||||||
|
PBKDF2 stretches your password into an auth key (sent hashed) and an AES-256 key that never
|
||||||
|
leaves memory.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span className="font-semibold text-foreground">3. Records are encrypted before upload.</span>{' '}
|
||||||
|
Each note is sealed with AES-GCM in your browser. The server stores only iv + ciphertext.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span className="font-semibold text-foreground">4. You decrypt on demand.</span>{' '}
|
||||||
|
When you sign back in, the key is re-derived from your password and records are unlocked in
|
||||||
|
your browser alone.
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="mt-8 flex items-center gap-4">
|
||||||
|
<Button asChild>
|
||||||
|
<Link to="/security">
|
||||||
|
See the security details
|
||||||
|
<ArrowRight className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<Link to="/signup">Create your free account</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
165
frontend/src/pages/LandingPage.tsx
Normal file
165
frontend/src/pages/LandingPage.tsx
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
ArrowRight,
|
||||||
|
CheckCircle2,
|
||||||
|
FileLock2,
|
||||||
|
FolderOpen,
|
||||||
|
KeyRound,
|
||||||
|
Lock,
|
||||||
|
ShieldCheck,
|
||||||
|
Smartphone,
|
||||||
|
UserPlus,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { useAuth } from '@/context/AuthContext'
|
||||||
|
|
||||||
|
const features = [
|
||||||
|
{
|
||||||
|
icon: FileLock2,
|
||||||
|
title: 'Encrypted notes & records',
|
||||||
|
description: 'Store parent and child records that are sealed with AES-GCM in your browser before they ever reach a server.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: KeyRound,
|
||||||
|
title: 'Two derived keys, one password',
|
||||||
|
description: 'PBKDF2 splits your password into an auth branch and an encryption branch — the server only ever sees the auth side.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Smartphone,
|
||||||
|
title: 'Sign in your way',
|
||||||
|
description: 'Email, username, or mobile number. Normalized identifiers and per-account salts keep every account unique.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: ShieldCheck,
|
||||||
|
title: 'Zero-knowledge sessions',
|
||||||
|
description: 'Your encryption key lives only in browser memory for the active session. Sign out, and it is gone.',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const stats = [
|
||||||
|
{ value: '310k', label: 'PBKDF2 iterations' },
|
||||||
|
{ value: 'AES-256', label: 'GCM authenticated encryption' },
|
||||||
|
{ value: '100%', label: 'Client-side encryption' },
|
||||||
|
{ value: '0', label: 'Plaintext stored on servers' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function LandingPage() {
|
||||||
|
const { isAuthenticated } = useAuth()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-5xl p-6 sm:p-10 lg:p-14">
|
||||||
|
<section className="flex flex-col items-start gap-5 pb-10">
|
||||||
|
<Badge variant="secondary" className="gap-1.5 px-3 py-1 text-xs">
|
||||||
|
<ShieldCheck className="h-3.5 w-3.5 text-primary" />
|
||||||
|
Zero-knowledge by design — no plaintext ever leaves your browser
|
||||||
|
</Badge>
|
||||||
|
|
||||||
|
<h1 className="text-4xl font-bold leading-tight tracking-tight sm:text-5xl lg:text-6xl">
|
||||||
|
Your data, encrypted
|
||||||
|
<span className="block text-primary">before it exists</span>
|
||||||
|
<span className="block">anywhere but here.</span>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="max-w-2xl text-lg leading-relaxed text-muted-foreground">
|
||||||
|
Zero Knowledge is a secure data platform where notes and records are encrypted on your device with
|
||||||
|
keys only you hold. Sign in with email, username, or mobile — and keep control of every byte.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
{isAuthenticated ? (
|
||||||
|
<Button size="lg" asChild>
|
||||||
|
<Link to="/workspace">
|
||||||
|
<FolderOpen className="h-4 w-4" />
|
||||||
|
Open my workspace
|
||||||
|
<ArrowRight className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button size="lg" asChild>
|
||||||
|
<Link to="/signup">
|
||||||
|
<UserPlus className="h-4 w-4" />
|
||||||
|
Get started free
|
||||||
|
<ArrowRight className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button size="lg" variant="outline" asChild>
|
||||||
|
<Link to="/login">Log in to your account</Link>
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 text-sm text-muted-foreground">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-emerald-600" /> No credit card required
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-emerald-600" /> Server never sees your plaintext
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-emerald-600" /> Open standards only
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid grid-cols-2 gap-3 rounded-2xl border bg-card p-5 sm:grid-cols-4 sm:p-6">
|
||||||
|
{stats.map((stat) => (
|
||||||
|
<div key={stat.label} className="flex flex-col items-center gap-1 text-center">
|
||||||
|
<span className="text-2xl font-bold tracking-tight text-primary">{stat.value}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">{stat.label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="py-10">
|
||||||
|
<div className="mb-6 flex flex-col gap-1">
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight sm:text-3xl">Why people store here</h2>
|
||||||
|
<p className="text-muted-foreground">A platform built on the principle that the safest data is data you never send.</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
{features.map((feature) => (
|
||||||
|
<Card key={feature.title} className="border-border/70 bg-card">
|
||||||
|
<CardHeader>
|
||||||
|
<span className="mb-1 flex h-10 w-10 items-center justify-center rounded-lg bg-secondary text-primary">
|
||||||
|
<feature.icon className="h-5 w-5" />
|
||||||
|
</span>
|
||||||
|
<CardTitle className="text-base">{feature.title}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<CardDescription className="leading-relaxed">{feature.description}</CardDescription>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-2xl border border-primary/30 bg-gradient-to-br from-secondary/70 via-background to-background p-6 sm:p-8">
|
||||||
|
<div className="flex flex-col items-start justify-between gap-4 sm:flex-row sm:items-center">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-primary text-primary-foreground">
|
||||||
|
<Lock className="h-5 w-5" />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold tracking-tight">Own your data. Fully.</h2>
|
||||||
|
<p className="mt-1 max-w-xl text-sm leading-relaxed text-muted-foreground">
|
||||||
|
Create an account in under a minute. Your encryption key is derived locally and stays in
|
||||||
|
memory — it is never stored, never transmitted, and never recoverable without your password.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<Button asChild>
|
||||||
|
<Link to="/signup">Create account</Link>
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<Link to="/security">See security details</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
111
frontend/src/pages/LoginPage.tsx
Normal file
111
frontend/src/pages/LoginPage.tsx
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
|
import { useAuth } from '@/context/AuthContext'
|
||||||
|
import type { IdentifierType } from '@/lib/crypto'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
|
||||||
|
const identifierOptions: Array<{ label: string; value: IdentifierType }> = [
|
||||||
|
{ label: 'Email', value: 'email' },
|
||||||
|
{ label: 'Username', value: 'username' },
|
||||||
|
{ label: 'Mobile number', value: 'mobile' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function LoginPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { signIn } = useAuth()
|
||||||
|
const [identifierType, setIdentifierType] = useState<IdentifierType>('email')
|
||||||
|
const [identifier, setIdentifier] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
|
||||||
|
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault()
|
||||||
|
setIsSubmitting(true)
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
await signIn({ identifierType, identifier, password })
|
||||||
|
navigate('/workspace')
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Authentication failed.')
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center p-6 sm:p-10 lg:p-16">
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<CardTitle className="text-2xl">Welcome back</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Sign in to access your encrypted workspace.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit} className="grid gap-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="identifier-type">Identifier type</Label>
|
||||||
|
<select
|
||||||
|
id="identifier-type"
|
||||||
|
value={identifierType}
|
||||||
|
onChange={(e) => setIdentifierType(e.target.value as IdentifierType)}
|
||||||
|
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
{identifierOptions.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="identifier">
|
||||||
|
{identifierType === 'email' ? 'Email' : identifierType === 'username' ? 'Username' : 'Mobile number'}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="identifier"
|
||||||
|
value={identifier}
|
||||||
|
onChange={(e) => setIdentifier(e.target.value)}
|
||||||
|
placeholder={identifierType === 'mobile' ? '+15555550123' : 'Enter your identifier'}
|
||||||
|
autoComplete={identifierType === 'email' ? 'email' : 'off'}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="password">Password</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="Enter your password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
|
|
||||||
|
<Button type="submit" disabled={isSubmitting} className="w-full">
|
||||||
|
{isSubmitting ? 'Signing in...' : 'Sign in'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-4 text-center text-sm text-muted-foreground">
|
||||||
|
Don't have an account?{' '}
|
||||||
|
<Link to="/signup" className="text-primary underline-offset-4 hover:underline">
|
||||||
|
Sign up
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
123
frontend/src/pages/SecurityPage.tsx
Normal file
123
frontend/src/pages/SecurityPage.tsx
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
ArrowRight,
|
||||||
|
Binary,
|
||||||
|
Fingerprint,
|
||||||
|
KeyRound,
|
||||||
|
Lock,
|
||||||
|
MemoryStick,
|
||||||
|
ShieldCheck,
|
||||||
|
Waves,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
|
||||||
|
const technologies = [
|
||||||
|
{
|
||||||
|
icon: KeyRound,
|
||||||
|
name: 'PBKDF2-SHA256',
|
||||||
|
tag: 'Key derivation',
|
||||||
|
description:
|
||||||
|
'310,000 iterations stretch your password into key material, making brute-force attempts computationally prohibitive.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Waves,
|
||||||
|
name: 'AES-GCM 256',
|
||||||
|
tag: 'Encryption',
|
||||||
|
description:
|
||||||
|
'Authenticated encryption with a fresh 96-bit IV per record. Tampered ciphertext is detected and rejected.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: MemoryStick,
|
||||||
|
name: 'Web Crypto API',
|
||||||
|
tag: 'Client side',
|
||||||
|
description:
|
||||||
|
'All derivation and encryption run inside your browser. Keys live in memory only and are never serialized to storage.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Binary,
|
||||||
|
name: 'Random salts',
|
||||||
|
tag: 'Per account',
|
||||||
|
description:
|
||||||
|
'Unique 128-bit salts per account keep identical passwords producing entirely different keys and ciphertext.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Fingerprint,
|
||||||
|
name: 'Hashed auth material',
|
||||||
|
tag: 'Server side',
|
||||||
|
description:
|
||||||
|
'The server stores only a derived auth key — never your password — and returns signed JWTs after verification.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: ShieldCheck,
|
||||||
|
name: 'Bearer tokens',
|
||||||
|
tag: 'Sessions',
|
||||||
|
description:
|
||||||
|
'Short-lived access tokens authorize requests without ever exposing your encryption key to the network.',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export function SecurityPage() {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-4xl p-6 sm:p-10 lg:p-14">
|
||||||
|
<p className="text-sm font-semibold uppercase tracking-widest text-primary">Security</p>
|
||||||
|
<h1 className="mt-2 text-3xl font-bold tracking-tight sm:text-4xl">
|
||||||
|
The technology protecting your data.
|
||||||
|
</h1>
|
||||||
|
<p className="mt-4 text-lg leading-relaxed text-muted-foreground">
|
||||||
|
Every layer of this platform is built around one idea: encryption keys live with you, never with the
|
||||||
|
service. Here is exactly what runs under the hood.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-8 grid gap-4 sm:grid-cols-2">
|
||||||
|
{technologies.map((tech) => (
|
||||||
|
<Card key={tech.name} className="border-border/70 bg-card">
|
||||||
|
<CardHeader className="flex-row items-center gap-3 space-y-0">
|
||||||
|
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-secondary text-primary">
|
||||||
|
<tech.icon className="h-5 w-5" />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-base">{tech.name}</CardTitle>
|
||||||
|
<Badge variant="secondary" className="mt-0.5 text-[10px]">
|
||||||
|
{tech.tag}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<CardDescription className="leading-relaxed">{tech.description}</CardDescription>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="mt-8 border-primary/30 bg-secondary/50">
|
||||||
|
<CardHeader className="flex-row items-start gap-3 space-y-0">
|
||||||
|
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground">
|
||||||
|
<Lock className="h-5 w-5" />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-base">The zero-knowledge guarantee</CardTitle>
|
||||||
|
<CardDescription className="mt-1 leading-relaxed">
|
||||||
|
If our servers were ever breached, an attacker would recover only ciphertext, salts, and hashed
|
||||||
|
auth material. Without your password and the in-memory encryption key, that data cannot be
|
||||||
|
decrypted. There is nothing to leak because the plaintext never existed outside your browser.
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="mt-8 flex items-center gap-4">
|
||||||
|
<Button asChild>
|
||||||
|
<Link to="/signup">
|
||||||
|
Start protecting your data
|
||||||
|
<ArrowRight className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<Link to="/about">Learn how it works</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
111
frontend/src/pages/SignupPage.tsx
Normal file
111
frontend/src/pages/SignupPage.tsx
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
|
import { useAuth } from '@/context/AuthContext'
|
||||||
|
import type { IdentifierType } from '@/lib/crypto'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
|
||||||
|
const identifierOptions: Array<{ label: string; value: IdentifierType }> = [
|
||||||
|
{ label: 'Email', value: 'email' },
|
||||||
|
{ label: 'Username', value: 'username' },
|
||||||
|
{ label: 'Mobile number', value: 'mobile' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function SignupPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { signUp } = useAuth()
|
||||||
|
const [identifierType, setIdentifierType] = useState<IdentifierType>('email')
|
||||||
|
const [identifier, setIdentifier] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
|
||||||
|
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault()
|
||||||
|
setIsSubmitting(true)
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
await signUp({ identifierType, identifier, password })
|
||||||
|
navigate('/workspace')
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Account creation failed.')
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center p-6 sm:p-10 lg:p-16">
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<CardTitle className="text-2xl">Create an account</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Use email, username, or mobile number with a password. The encryption key stays in memory only.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit} className="grid gap-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="identifier-type">Identifier type</Label>
|
||||||
|
<select
|
||||||
|
id="identifier-type"
|
||||||
|
value={identifierType}
|
||||||
|
onChange={(e) => setIdentifierType(e.target.value as IdentifierType)}
|
||||||
|
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
{identifierOptions.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="identifier">
|
||||||
|
{identifierType === 'email' ? 'Email' : identifierType === 'username' ? 'Username' : 'Mobile number'}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="identifier"
|
||||||
|
value={identifier}
|
||||||
|
onChange={(e) => setIdentifier(e.target.value)}
|
||||||
|
placeholder={identifierType === 'mobile' ? '+15555550123' : 'Enter your identifier'}
|
||||||
|
autoComplete={identifierType === 'email' ? 'email' : 'off'}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="password">Password</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="Enter your password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
|
|
||||||
|
<Button type="submit" disabled={isSubmitting} className="w-full">
|
||||||
|
{isSubmitting ? 'Creating account...' : 'Create account'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-4 text-center text-sm text-muted-foreground">
|
||||||
|
Already have an account?{' '}
|
||||||
|
<Link to="/login" className="text-primary underline-offset-4 hover:underline">
|
||||||
|
Log in
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
17
frontend/src/pages/WorkspacePage.tsx
Normal file
17
frontend/src/pages/WorkspacePage.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { Navigate } from 'react-router-dom'
|
||||||
|
import { useAuth } from '@/context/AuthContext'
|
||||||
|
import { DataWorkspace } from '@/components/DataWorkspace'
|
||||||
|
|
||||||
|
export function WorkspacePage() {
|
||||||
|
const { isAuthenticated } = useAuth()
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return <Navigate to="/login" replace />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 sm:p-10 lg:p-16">
|
||||||
|
<DataWorkspace />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
80
frontend/src/styles.css
Normal file
80
frontend/src/styles.css
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--background: 211 45% 5%;
|
||||||
|
--foreground: 190 50% 92%;
|
||||||
|
--card: 212 38% 9%;
|
||||||
|
--card-foreground: 190 50% 92%;
|
||||||
|
--popover: 212 38% 9%;
|
||||||
|
--popover-foreground: 190 50% 92%;
|
||||||
|
--primary: 187 100% 50%;
|
||||||
|
--primary-foreground: 211 60% 7%;
|
||||||
|
--secondary: 211 32% 15%;
|
||||||
|
--secondary-foreground: 190 50% 90%;
|
||||||
|
--muted: 211 30% 13%;
|
||||||
|
--muted-foreground: 194 22% 62%;
|
||||||
|
--accent: 189 42% 19%;
|
||||||
|
--accent-foreground: 187 100% 85%;
|
||||||
|
--destructive: 0 80% 56%;
|
||||||
|
--border: 195 32% 19%;
|
||||||
|
--input: 195 32% 19%;
|
||||||
|
--ring: 187 100% 50%;
|
||||||
|
--radius: 0.75rem;
|
||||||
|
--sidebar: 212 45% 6.5%;
|
||||||
|
--sidebar-foreground: 190 50% 88%;
|
||||||
|
--sidebar-primary: 187 100% 50%;
|
||||||
|
--sidebar-primary-foreground: 211 60% 7%;
|
||||||
|
--sidebar-accent: 189 42% 17%;
|
||||||
|
--sidebar-accent-foreground: 187 100% 85%;
|
||||||
|
--sidebar-border: 195 32% 17%;
|
||||||
|
--sidebar-ring: 187 100% 50%;
|
||||||
|
--chart-1: 187 100% 50%;
|
||||||
|
--chart-2: 199 92% 42%;
|
||||||
|
--chart-3: 164 90% 46%;
|
||||||
|
--chart-4: 213 80% 55%;
|
||||||
|
--chart-5: 192 100% 72%;
|
||||||
|
}
|
||||||
|
.dark {
|
||||||
|
--background: 211 45% 5%;
|
||||||
|
--foreground: 190 50% 92%;
|
||||||
|
--card: 212 38% 9%;
|
||||||
|
--card-foreground: 190 50% 92%;
|
||||||
|
--popover: 212 38% 9%;
|
||||||
|
--popover-foreground: 190 50% 92%;
|
||||||
|
--primary: 187 100% 50%;
|
||||||
|
--primary-foreground: 211 60% 7%;
|
||||||
|
--secondary: 211 32% 15%;
|
||||||
|
--secondary-foreground: 190 50% 90%;
|
||||||
|
--muted: 211 30% 13%;
|
||||||
|
--muted-foreground: 194 22% 62%;
|
||||||
|
--accent: 189 42% 19%;
|
||||||
|
--accent-foreground: 187 100% 85%;
|
||||||
|
--destructive: 0 80% 56%;
|
||||||
|
--border: 195 32% 19%;
|
||||||
|
--input: 195 32% 19%;
|
||||||
|
--ring: 187 100% 50%;
|
||||||
|
--sidebar: 212 45% 6.5%;
|
||||||
|
--sidebar-foreground: 190 50% 88%;
|
||||||
|
--sidebar-primary: 187 100% 50%;
|
||||||
|
--sidebar-primary-foreground: 211 60% 7%;
|
||||||
|
--sidebar-accent: 189 42% 17%;
|
||||||
|
--sidebar-accent-foreground: 187 100% 85%;
|
||||||
|
--sidebar-border: 195 32% 17%;
|
||||||
|
--sidebar-ring: 187 100% 50%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
background-image:
|
||||||
|
radial-gradient(ellipse 90% 55% at 15% -10%, rgba(34, 211, 238, 0.1), transparent),
|
||||||
|
radial-gradient(ellipse 70% 45% at 95% 110%, rgba(14, 116, 144, 0.14), transparent),
|
||||||
|
radial-gradient(ellipse 40% 30% at 75% 20%, rgba(6, 182, 212, 0.07), transparent);
|
||||||
|
background-attachment: fixed;
|
||||||
|
}
|
||||||
|
}
|
||||||
1
frontend/src/vite-env.d.ts
vendored
Normal file
1
frontend/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
/// <reference types="vite/client" />
|
||||||
68
frontend/tailwind.config.js
Normal file
68
frontend/tailwind.config.js
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
darkMode: ["class"],
|
||||||
|
content: [
|
||||||
|
"./index.html",
|
||||||
|
"./src/**/*.{js,ts,jsx,tsx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
borderRadius: {
|
||||||
|
lg: 'var(--radius)',
|
||||||
|
md: 'calc(var(--radius) - 2px)',
|
||||||
|
sm: 'calc(var(--radius) - 4px)'
|
||||||
|
},
|
||||||
|
colors: {
|
||||||
|
background: 'hsl(var(--background))',
|
||||||
|
foreground: 'hsl(var(--foreground))',
|
||||||
|
card: {
|
||||||
|
DEFAULT: 'hsl(var(--card))',
|
||||||
|
foreground: 'hsl(var(--card-foreground))'
|
||||||
|
},
|
||||||
|
popover: {
|
||||||
|
DEFAULT: 'hsl(var(--popover))',
|
||||||
|
foreground: 'hsl(var(--popover-foreground))'
|
||||||
|
},
|
||||||
|
primary: {
|
||||||
|
DEFAULT: 'hsl(var(--primary))',
|
||||||
|
foreground: 'hsl(var(--primary-foreground))'
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
DEFAULT: 'hsl(var(--secondary))',
|
||||||
|
foreground: 'hsl(var(--secondary-foreground))'
|
||||||
|
},
|
||||||
|
muted: {
|
||||||
|
DEFAULT: 'hsl(var(--muted))',
|
||||||
|
foreground: 'hsl(var(--muted-foreground))'
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: 'hsl(var(--accent))',
|
||||||
|
foreground: 'hsl(var(--accent-foreground))'
|
||||||
|
},
|
||||||
|
destructive: 'hsl(var(--destructive))',
|
||||||
|
border: 'hsl(var(--border))',
|
||||||
|
input: 'hsl(var(--input))',
|
||||||
|
ring: 'hsl(var(--ring))',
|
||||||
|
chart: {
|
||||||
|
'1': 'hsl(var(--chart-1))',
|
||||||
|
'2': 'hsl(var(--chart-2))',
|
||||||
|
'3': 'hsl(var(--chart-3))',
|
||||||
|
'4': 'hsl(var(--chart-4))',
|
||||||
|
'5': 'hsl(var(--chart-5))'
|
||||||
|
},
|
||||||
|
sidebar: {
|
||||||
|
DEFAULT: 'hsl(var(--sidebar))',
|
||||||
|
foreground: 'hsl(var(--sidebar-foreground))',
|
||||||
|
primary: 'hsl(var(--sidebar-primary))',
|
||||||
|
'primary-foreground': 'hsl(var(--sidebar-primary-foreground))',
|
||||||
|
accent: 'hsl(var(--sidebar-accent))',
|
||||||
|
'accent-foreground': 'hsl(var(--sidebar-accent-foreground))',
|
||||||
|
border: 'hsl(var(--sidebar-border))',
|
||||||
|
ring: 'hsl(var(--sidebar-ring))'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: [require("tailwindcss-animate")],
|
||||||
|
}
|
||||||
|
|
||||||
25
frontend/tsconfig.json
Normal file
25
frontend/tsconfig.json
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"allowJs": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": []
|
||||||
|
}
|
||||||
12
frontend/vite.config.ts
Normal file
12
frontend/vite.config.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": path.resolve(__dirname, "./src"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
7
frontend/vitest.config.ts
Normal file
7
frontend/vitest.config.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: 'node',
|
||||||
|
},
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue