Add Tag Support With Backend Models And UI

This commit is contained in:
james fitzsimons 2025-12-15 21:33:00 +00:00
parent b596c9f34d
commit c01a1fc908
22 changed files with 374 additions and 39 deletions

1
.gitignore vendored
View file

@ -1,3 +1,4 @@
node_modules node_modules
frontend/src/assets/fontawesome/svg/* frontend/src/assets/fontawesome/svg/*
frontend/src/assets/fontawesome/svg/0.svg frontend/src/assets/fontawesome/svg/0.svg
*.db

View file

@ -2,7 +2,7 @@ import secrets
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Optional from typing import Optional
import bcrypt # Use bcrypt directly instead of passlib import bcrypt
from fastapi import Cookie, Depends, HTTPException, Request, status from fastapi import Cookie, Depends, HTTPException, Request, status
from sqlmodel import Session, select from sqlmodel import Session, select
@ -11,7 +11,6 @@ from app.models import Session as SessionModel
from app.models import User from app.models import User
# Password hashing with bcrypt directly
def hash_password(password: str) -> str: def hash_password(password: str) -> str:
password_bytes = password.encode("utf-8") password_bytes = password.encode("utf-8")
salt = bcrypt.gensalt() salt = bcrypt.gensalt()
@ -25,12 +24,11 @@ def verify_password(plain_password: str, hashed_password: str) -> bool:
return bcrypt.checkpw(password_bytes, hashed_bytes) return bcrypt.checkpw(password_bytes, hashed_bytes)
# Session management
def create_session( def create_session(
user_id: int, request: Request, db: Session, expires_in_days: int = 30 user_id: int, request: Request, db: Session, expires_in_days: int = 30
) -> str: ) -> str:
session_id = secrets.token_urlsafe(32) session_id = secrets.token_urlsafe(32)
expires_at = datetime.utcnow() + timedelta(days=expires_in_days) expires_at = datetime.now() + timedelta(days=expires_in_days)
db_session = SessionModel( db_session = SessionModel(
session_id=session_id, session_id=session_id,
@ -53,13 +51,12 @@ def get_session_user(session_id: Optional[str], db: Session) -> Optional[User]:
select(SessionModel).where(SessionModel.session_id == session_id) select(SessionModel).where(SessionModel.session_id == session_id)
).first() ).first()
if not session or session.expires_at < datetime.utcnow(): if not session or session.expires_at < datetime.now():
return None return None
return session.user return session.user
# Dependency for protected routes
async def require_auth( async def require_auth(
session_id: Optional[str] = Cookie(None), db: Session = Depends(get_session) session_id: Optional[str] = Cookie(None), db: Session = Depends(get_session)
) -> User: ) -> User:

View file

@ -2,7 +2,7 @@ from fastapi import FastAPI # type: ignore
from fastapi.middleware.cors import CORSMiddleware # type:ignore from fastapi.middleware.cors import CORSMiddleware # type:ignore
from app.database import create_db_and_tables from app.database import create_db_and_tables
from app.routes import auth, folders, notes from app.routes import auth, folders, notes, tags
app = FastAPI(title="Notes API") app = FastAPI(title="Notes API")
@ -24,6 +24,7 @@ def on_startup():
app.include_router(notes.router, prefix="/api") app.include_router(notes.router, prefix="/api")
app.include_router(folders.router, prefix="/api") app.include_router(folders.router, prefix="/api")
app.include_router(auth.router, prefix="/api") app.include_router(auth.router, prefix="/api")
app.include_router(tags.router, prefix="/api")
@app.get("/") @app.get("/")

View file

@ -4,26 +4,27 @@ from typing import List, Optional
from sqlmodel import Field, Relationship, SQLModel # type: ignore from sqlmodel import Field, Relationship, SQLModel # type: ignore
class User(SQLModel, table=True): class User(SQLModel, table=True): # type: ignore
id: Optional[int] = Field(default=None, primary_key=True) id: Optional[int] = Field(default=None, primary_key=True)
username: str = Field(unique=True, index=True) username: str = Field(unique=True, index=True)
email: str = Field(unique=True, index=True) email: str = Field(unique=True, index=True)
hashed_password: str hashed_password: str
salt: str salt: str
wrapped_master_key: str wrapped_master_key: str
created_at: datetime = Field(default_factory=datetime.utcnow) created_at: datetime = Field(default_factory=datetime.now)
# Add relationships to existing models # Add relationships to existing models
notes: List["Note"] = Relationship(back_populates="user") notes: List["Note"] = Relationship(back_populates="user")
folders: List["Folder"] = Relationship(back_populates="user") folders: List["Folder"] = Relationship(back_populates="user")
sessions: List["Session"] = Relationship(back_populates="user") sessions: List["Session"] = Relationship(back_populates="user")
tags: List["Tag"] = Relationship(back_populates="user")
class Session(SQLModel, table=True): class Session(SQLModel, table=True): # type: ignore
id: Optional[int] = Field(default=None, primary_key=True) id: Optional[int] = Field(default=None, primary_key=True)
session_id: str = Field(unique=True, index=True) session_id: str = Field(unique=True, index=True)
user_id: int = Field(foreign_key="user.id") user_id: int = Field(foreign_key="user.id")
created_at: datetime = Field(default_factory=datetime.utcnow) created_at: datetime = Field(default_factory=datetime.now)
expires_at: datetime expires_at: datetime
ip_address: Optional[str] = None ip_address: Optional[str] = None
user_agent: Optional[str] = None user_agent: Optional[str] = None
@ -35,7 +36,7 @@ class Folder(SQLModel, table=True): # type: ignore
id: Optional[int] = Field(default=None, primary_key=True) id: Optional[int] = Field(default=None, primary_key=True)
name: str = Field(max_length=255) name: str = Field(max_length=255)
parent_id: Optional[int] = Field(default=None, foreign_key="folder.id") parent_id: Optional[int] = Field(default=None, foreign_key="folder.id")
created_at: datetime = Field(default_factory=datetime.utcnow) created_at: datetime = Field(default_factory=datetime.now)
user_id: int = Field(foreign_key="user.id") user_id: int = Field(foreign_key="user.id")
# Relationships # Relationships
@ -47,20 +48,73 @@ class Folder(SQLModel, table=True): # type: ignore
user: User = Relationship(back_populates="folders") user: User = Relationship(back_populates="folders")
class NoteTag(SQLModel, table=True): #type: ignore
note_id: int = Field(foreign_key="note.id", primary_key=True)
tag_id: int = Field(foreign_key="tag.id", primary_key=True)
class Tag(SQLModel, table=True): # type: ignore
id: Optional[int] = Field(default=None, primary_key=True)
name: str = Field(max_length=255)
parent_id: Optional[int] = Field(default=None, foreign_key="tag.id")
user_id: int = Field(foreign_key="user.id")
created_at: datetime = Field(default_factory=datetime.now)
# Relationships
user: User = Relationship(back_populates="tags")
parent: Optional["Tag"] = Relationship(
back_populates="children",
sa_relationship_kwargs={"remote_side": "Tag.id"}
)
children: List["Tag"] = Relationship(back_populates="parent")
notes: List["Note"] = Relationship(back_populates="tags", link_model=NoteTag)
class Note(SQLModel, table=True): # type: ignore class Note(SQLModel, table=True): # type: ignore
id: Optional[int] = Field(default=None, primary_key=True) id: Optional[int] = Field(default=None, primary_key=True)
title: str = Field(max_length=255) title: str = Field(max_length=255)
content: str content: str
folder_id: Optional[int] = Field(default=None, foreign_key="folder.id") folder_id: Optional[int] = Field(default=None, foreign_key="folder.id")
created_at: datetime = Field(default_factory=datetime.utcnow) created_at: datetime = Field(default_factory=datetime.now)
updated_at: datetime = Field(default_factory=datetime.utcnow) updated_at: datetime = Field(default_factory=datetime.now)
user_id: int = Field(foreign_key="user.id") user_id: int = Field(foreign_key="user.id")
#Relationships
folder: Optional[Folder] = Relationship(back_populates="notes") folder: Optional[Folder] = Relationship(back_populates="notes")
user: User = Relationship(back_populates="notes") user: User = Relationship(back_populates="notes")
tags: List[Tag] = Relationship(back_populates="notes", link_model=NoteTag)
# API Response models
class TagRead(SQLModel):
id: int
name: str
parent_id: Optional[int] = None
created_at: datetime
class TagCreate(SQLModel):
name: str
parent_id: Optional[int] = None
class TagUpdate(SQLModel):
name: Optional[str] = None
parent_id: Optional[int] = None
class TagTreeNode(SQLModel):
id: int
name: str
children: List["TagTreeNode"] = []
class TagTreeResponse(SQLModel):
tags: List[TagTreeNode]
# API Response models (what gets sent to frontend)
class NoteRead(SQLModel): class NoteRead(SQLModel):
id: int id: int
title: str title: str
@ -68,6 +122,7 @@ class NoteRead(SQLModel):
folder_id: Optional[int] = None folder_id: Optional[int] = None
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
tags: List[TagRead] = []
class FolderTreeNode(SQLModel): class FolderTreeNode(SQLModel):

Binary file not shown.

View file

@ -1,8 +1,5 @@
from typing import List from typing import List
from fastapi import APIRouter, Depends, HTTPException # type: ignore
from sqlmodel import Session, select # type: ignore
from app.auth import require_auth from app.auth import require_auth
from app.database import get_session from app.database import get_session
from app.models import ( from app.models import (
@ -15,6 +12,9 @@ from app.models import (
NoteRead, NoteRead,
User, User,
) )
from fastapi import APIRouter, Depends, HTTPException # type: ignore
from sqlalchemy.orm import selectinload
from sqlmodel import Session, select # type: ignore
router = APIRouter(prefix="/folders", tags=["folders"]) router = APIRouter(prefix="/folders", tags=["folders"])
@ -35,21 +35,20 @@ def get_folder_tree(
): ):
"""Get complete folder tree with notes""" """Get complete folder tree with notes"""
# Get all top-level folders (parent_id is None) for current user
top_level_folders = session.exec( top_level_folders = session.exec(
select(Folder) select(Folder)
.options(selectinload(Folder.notes).selectinload(Note.tags))
.where(Folder.parent_id == None) .where(Folder.parent_id == None)
.where(Folder.user_id == current_user.id) .where(Folder.user_id == current_user.id)
).all() ).all()
# Get all orphaned notes (folder_id is None) for current user
orphaned_notes = session.exec( orphaned_notes = session.exec(
select(Note) select(Note)
.options(selectinload(Note.tags))
.where(Note.folder_id == None) .where(Note.folder_id == None)
.where(Note.user_id == current_user.id) .where(Note.user_id == current_user.id)
).all() ).all()
# Build tree recursively
tree = [build_folder_tree_node(folder) for folder in top_level_folders] tree = [build_folder_tree_node(folder) for folder in top_level_folders]
return FolderTreeResponse( return FolderTreeResponse(

View file

@ -1,11 +1,10 @@
from datetime import datetime from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session, select
from app.auth import require_auth from app.auth import require_auth
from app.database import get_session from app.database import get_session
from app.models import Note, NoteCreate, NoteUpdate, User from app.models import Note, NoteCreate, NoteUpdate, User
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session, select
router = APIRouter(prefix="/notes", tags=["notes"]) router = APIRouter(prefix="/notes", tags=["notes"])
@ -16,6 +15,7 @@ def list_notes(session: Session = Depends(get_session)):
return notes return notes
@router.post("/", response_model=Note) @router.post("/", response_model=Note)
def create_note( def create_note(
note: NoteCreate, note: NoteCreate,

View file

@ -0,0 +1,60 @@
from app.auth import require_auth
from app.database import get_session
from app.models import Note, NoteCreate, NoteTag, NoteUpdate, Tag, TagCreate, User
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session, select
router = APIRouter(prefix="/tags", tags=["tags"])
@router.get("/")
def list_tags(session: Session = Depends(get_session)):
tags = session.exec(select(Tag)).all()
return tags
@router.post('/', response_model=Tag)
def create_tag(
tag: TagCreate,
current_user: User = Depends(require_auth),
session: Session = Depends(get_session)
):
tag_data = tag.model_dump()
tag_data["user_id"] = current_user.id
db_tag = Tag.model_validate(tag_data)
session.add(db_tag)
session.commit()
session.refresh(db_tag)
return db_tag
@router.post("/note/{note_id}/tag/{tag_id}")
def add_tag_to_note(
note_id: int,
tag_id: int,
current_user: User = Depends(require_auth),
session: Session = Depends(get_session)
):
existing = session.exec(
select(NoteTag)
.where(NoteTag.note_id == note_id)
.where(NoteTag.tag_id == tag_id)
).first()
if existing:
return {"message": "Tag already added"}
note_tag = NoteTag(note_id=note_id, tag_id=tag_id)
session.add(note_tag)
session.commit()
return note_tag
@router.delete("/{tag_id}")
def delete_note(tag_id: int, session: Session = Depends(get_session)):
tag = session.get(Tag, tag_id)
if not tag:
raise HTTPException(status_code=404, detail="Tag not found")
session.delete(tag)
session.commit()
return {"message": "tag deleted"}

View file

@ -114,6 +114,12 @@ export async function decryptFolderTree(
...note, ...note,
title: await decryptString(note.title, encryptionKey), title: await decryptString(note.title, encryptionKey),
content: await decryptString(note.content, encryptionKey), content: await decryptString(note.content, encryptionKey),
tags: await Promise.all(
note.tags.map(async (tag) => ({
...tag,
name: await decryptString(tag.name, encryptionKey),
})),
),
})), })),
), ),
children: await Promise.all( children: await Promise.all(
@ -131,6 +137,12 @@ export async function decryptFolderTree(
...note, ...note,
title: await decryptString(note.title, encryptionKey), title: await decryptString(note.title, encryptionKey),
content: await decryptString(note.content, encryptionKey), content: await decryptString(note.content, encryptionKey),
tags: await Promise.all(
note.tags.map(async (tag) => ({
...tag,
name: await decryptString(tag.name, encryptionKey),
})),
),
})), })),
), ),
}; };

View file

@ -1,6 +1,7 @@
import axios from "axios"; import axios from "axios";
import { decryptFolderTree } from "./encryption"; import { decryptFolderTree } from "./encryption";
import { useAuthStore } from "../stores/authStore"; import { useAuthStore } from "../stores/authStore";
import { Tag } from "./tags";
axios.defaults.withCredentials = true; axios.defaults.withCredentials = true;
@ -22,6 +23,7 @@ export interface NoteRead {
folder_id: number | null; folder_id: number | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
tags: Tag[];
} }
export interface FolderTreeNode { export interface FolderTreeNode {

View file

@ -1,7 +1,7 @@
import axios from "axios"; import axios from "axios";
import { NoteRead } from "./folders";
import { encryptString, decryptString } from "./encryption"; import { encryptString, decryptString } from "./encryption";
import { useAuthStore } from "../stores/authStore"; import { useAuthStore } from "../stores/authStore";
import { Tag } from "./tags";
axios.defaults.withCredentials = true; axios.defaults.withCredentials = true;
const API_URL = (import.meta as any).env.PROD const API_URL = (import.meta as any).env.PROD
? "/api" ? "/api"
@ -14,6 +14,7 @@ export interface Note {
content: string; content: string;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
tags: Tag[];
} }
export interface NoteCreate { export interface NoteCreate {
@ -50,9 +51,12 @@ const fetchNotes = async () => {
...note, ...note,
title: await decryptString(note.title, encryptionKey), title: await decryptString(note.title, encryptionKey),
content: await decryptString(note.content, encryptionKey), content: await decryptString(note.content, encryptionKey),
tags: note.tags.map(async (tag) => ({
...tag,
name: await decryptString(tag.name, encryptionKey),
})),
})), })),
); );
return decryptedNotes; return decryptedNotes;
}; };

92
frontend/src/api/tags.tsx Normal file
View file

@ -0,0 +1,92 @@
import axios from "axios";
import { encryptString, decryptString } from "./encryption";
import { useAuthStore } from "../stores/authStore";
axios.defaults.withCredentials = true;
const API_URL = (import.meta as any).env.PROD
? "/api"
: "http://localhost:8000/api";
export interface Tag {
id: string;
name: string;
parent_id?: number;
created_at: string;
children: Tag[];
parent_path: string;
}
export interface TagCreate {
name: string;
parent_id?: number;
}
const buildTagTree = (
tags: Tag[],
parent_id: string | number | null = null,
parentPath = "",
): Tag[] => {
const result: Tag[] = [];
for (const tag of tags) {
if (tag.parent_id == parent_id) {
tag.parent_path = parentPath;
const currentPath = parentPath ? `${parentPath} ${tag.name}` : tag.name;
tag.children = buildTagTree(tags, tag.id, currentPath);
result.push(tag);
}
}
return result;
};
const fetchTags = async () => {
const encryptionKey = useAuthStore.getState().encryptionKey;
if (!encryptionKey) throw new Error("Not authenticated");
const { data } = await axios.get(`${API_URL}/tags/`);
const decryptedTags = await Promise.all(
data.map(async (tag: Tag) => ({
...tag,
name: await decryptString(tag.name, encryptionKey),
})),
);
const tags = buildTagTree(decryptedTags);
console.log(tags);
return tags;
};
const createTag = async (tag: TagCreate, noteId?: number) => {
const encryptionKey = useAuthStore.getState().encryptionKey;
if (!encryptionKey) throw new Error("Not authenticated");
const tagName = await encryptString(tag.name, encryptionKey);
const encryptedTag = {
name: tagName,
parent_id: tag.parent_id,
};
const r = await axios.post(`${API_URL}/tags/`, encryptedTag);
console.log(r);
if (noteId) {
return await addTagToNote(r.data.id, noteId);
}
};
const addTagToNote = async (tagId: number, noteId: number) => {
return axios.post(`${API_URL}/tags/note/${noteId}/tag/${tagId}`);
};
const deleteTag = async (tagId: number) => {
return axios.delete(`${API_URL}/tags/${tagId}`);
};
export const tagsApi = {
list: async () => await fetchTags(),
create: (tag: TagCreate, noteId?: number) => createTag(tag, noteId),
delete: (tagId: number) => deleteTag(tagId),
};

View file

@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import "../../main.css"; import "../../main.css";
import { motion } from "framer-motion"; import { AnimatePresence, motion } from "framer-motion";
import { useAuthStore } from "@/stores/authStore"; import { useAuthStore } from "@/stores/authStore";
import { useNoteStore } from "@/stores/notesStore"; import { useNoteStore } from "@/stores/notesStore";
import { useUIStore } from "@/stores/uiStore"; import { useUIStore } from "@/stores/uiStore";
@ -9,6 +9,9 @@ import { TiptapEditor } from "../TipTap";
import { Sidebar } from "./components/sidebar/SideBar"; import { Sidebar } from "./components/sidebar/SideBar";
import { StatusIndicator } from "./components/StatusIndicator"; import { StatusIndicator } from "./components/StatusIndicator";
import { Tag, tagsApi } from "@/api/tags";
import { useTagStore } from "@/stores/tagStore";
function Home() { function Home() {
const [newFolder] = useState(false); const [newFolder] = useState(false);
const [lastSavedNote, setLastSavedNote] = useState<{ const [lastSavedNote, setLastSavedNote] = useState<{
@ -92,12 +95,30 @@ function Home() {
} }
}; };
const { getTagTree, tagTree } = useTagStore();
const getTags = () => {
getTagTree();
};
return ( return (
<div className="flex bg-ctp-base h-screen text-ctp-text overflow-hidden"> <div className="flex bg-ctp-base h-screen text-ctp-text overflow-hidden">
{/* Sidebar */} {/* Sidebar */}
{showModal && <Modal />} {showModal && <Modal />}
<Sidebar /> <Sidebar />
{/*<div className="flex flex-col">
<input
type="text"
value={tagName}
onChange={(e) => setTagName(e.target.value)}
/>
<button onClick={createTag}>create</button>
{tags.map((tag) => (
<button onClick={() => deleteTag(tag.id)} key={tag.id}>
{tag.name}
</button>
))}
</div>*/}
<button onClick={() => getTags()}>Click</button>
{/* Main editor area */} {/* Main editor area */}
<div className="flex flex-col w-full h-screen overflow-hidden"> <div className="flex flex-col w-full h-screen overflow-hidden">
@ -109,6 +130,20 @@ function Home() {
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
className="w-full px-4 py-3 text-3xl font-semibold bg-transparentfocus:outline-none focus:border-ctp-mauve transition-colors placeholder:text-ctp-overlay0 text-ctp-text" className="w-full px-4 py-3 text-3xl font-semibold bg-transparentfocus:outline-none focus:border-ctp-mauve transition-colors placeholder:text-ctp-overlay0 text-ctp-text"
/> />
<div className="px-4 py-2 border-b border-ctp-surface2 flex items-center gap-2 flex-wrap">
{selectedNote?.tags &&
selectedNote.tags.map((tag) => (
<button
onClick={() => null}
key={tag.id}
className="bg-ctp-surface0 px-1.5 text-sm rounded-full"
>
{tag.parent_id && "..."}
{tag.name}
</button>
))}
</div>
<TiptapEditor <TiptapEditor
key={selectedNote?.id} key={selectedNote?.id}
content={selectedNote?.content || ""} content={selectedNote?.content || ""}
@ -136,8 +171,53 @@ const Modal = () => {
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
className="w-2/3 h-2/3 bg-ctp-base rounded-xl border-ctp-surface2 border p-5" className="w-2/3 h-2/3 bg-ctp-base rounded-xl border-ctp-surface2 border p-5"
> >
<Login /> {/*<Login />*/}
<TagSelector />
</div> </div>
</motion.div> </motion.div>
); );
}; };
const TagSelector = () => {
const { tagTree } = useTagStore();
const [value, setValue] = useState("");
return (
<div>
<input
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
/>
{tagTree && tagTree.map((tag) => <TagTree tag={tag} />)}
</div>
);
};
export const TagTree = ({ tag, depth = 0 }: { tag: Tag; depth?: number }) => {
const [collapse, setCollapse] = useState(false);
return (
<div key={tag.id} className="flex flex-col relative">
<div onClick={() => setCollapse(!collapse)}>{tag.name}</div>
<AnimatePresence>
{collapse && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2, ease: "easeInOut" }}
className="overflow-hidden flex flex-col"
>
{/* The line container */}
<div className="ml-2 pl-3 border-l border-ctp-surface2">
{/* Child tags */}
{tag.children.map((child) => (
<TagTree key={child.id} tag={child} depth={depth + 1} />
))}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
};

View file

@ -11,15 +11,15 @@ interface User {
id: number; id: number;
username: string; username: string;
email: string; email: string;
salt: string; // For key derivation salt: string;
} }
interface AuthState { interface AuthState {
user: User | null; user: User | null;
encryptionKey: CryptoKey | null; // Memory only! encryptionKey: CryptoKey | null;
isAuthenticated: boolean; isAuthenticated: boolean;
rememberMe: boolean; rememberMe: boolean;
setRememberMe: (boolean) => void; setRememberMe: (remember: boolean) => void;
login: (username: string, password: string) => Promise<void>; login: (username: string, password: string) => Promise<void>;
register: ( register: (
@ -45,7 +45,6 @@ export const useAuthStore = create<AuthState>()(
set({ rememberMe: bool }); set({ rememberMe: bool });
}, },
initEncryptionKey: async (password: string, salt: string) => { initEncryptionKey: async (password: string, salt: string) => {
// Use user-specific salt instead of hardcoded
const key = await deriveKey(password, salt); const key = await deriveKey(password, salt);
set({ encryptionKey: key }); set({ encryptionKey: key });
}, },
@ -76,7 +75,6 @@ export const useAuthStore = create<AuthState>()(
const data = await response.json(); const data = await response.json();
// Store the master key directly (not derived from password)
set({ set({
user: data.user, user: data.user,
isAuthenticated: true, isAuthenticated: true,
@ -99,11 +97,9 @@ export const useAuthStore = create<AuthState>()(
const { user } = await response.json(); const { user } = await response.json();
// Derive KEK and unwrap master key
const kek = await deriveKey(password, user.salt); const kek = await deriveKey(password, user.salt);
const masterKey = await unwrapMasterKey(user.wrapped_master_key, kek); const masterKey = await unwrapMasterKey(user.wrapped_master_key, kek);
// Store master key in memory
set({ encryptionKey: masterKey, user, isAuthenticated: true }); set({ encryptionKey: masterKey, user, isAuthenticated: true });
}, },
@ -115,7 +111,7 @@ export const useAuthStore = create<AuthState>()(
set({ set({
user: null, user: null,
encryptionKey: null, // Wipe from memory encryptionKey: null,
isAuthenticated: false, isAuthenticated: false,
}); });
}, },

View file

@ -36,7 +36,7 @@ const updateFolder = (
id: number, id: number,
folder: FolderTreeNode, folder: FolderTreeNode,
newFolder: FolderUpdate, newFolder: FolderUpdate,
) => { ): FolderTreeNode => {
if (folder.id === id) { if (folder.id === id) {
return { ...folder, ...newFolder }; return { ...folder, ...newFolder };
} }
@ -78,7 +78,7 @@ export const useNoteStore = create<NoteState>()(
(set, get) => ({ (set, get) => ({
loadFolderTree: async () => { loadFolderTree: async () => {
const data = await folderApi.tree(); const data = await folderApi.tree();
console.log("getting tree"); console.log(data);
set({ folderTree: data }); set({ folderTree: data });
}, },
folderTree: null, folderTree: null,

View file

@ -0,0 +1,36 @@
import { tagsApi } from "@/api/tags";
import { create } from "zustand";
import { persist } from "zustand/middleware";
interface Tag {
id: string;
name: string;
parent_id?: number;
created_at: string;
parent_path: string;
children: Tag[];
}
interface TagStore {
tagTree: Tag[] | null;
getTagTree: () => void;
}
export const useTagStore = create<TagStore>()(
persist(
(set, get) => ({
tagTree: null,
getTagTree: async () => {
const tags = await tagsApi.list();
set({ tagTree: tags });
},
}),
{
name: "tags-storage",
partialize: (state) => ({
tagTree: state.tagTree,
}),
},
),
);

View file

@ -19,7 +19,7 @@ export const useUIStore = create<UIState>()(
setUpdating: (update) => { setUpdating: (update) => {
set({ updating: update }); set({ updating: update });
}, },
showModal: false, showModal: true,
setShowModal: (show) => { setShowModal: (show) => {
set({ showModal: show }); set({ showModal: show });
}, },