From 839218a19c826a077c475f8a35ae7c6c80569b4e Mon Sep 17 00:00:00 2001 From: Saravana Achu Mac Date: Sun, 29 Mar 2026 20:38:33 -0700 Subject: [PATCH] feat: add auth pages, AuthGuard, Tiptap editor, workspace CRUD, toast notifications Phase 1 of the execution roadmap: - Build login, register, forgot-password pages using useAuth() from @bytelyst/react-auth - Add AuthGuard with kill-switch check wrapping (app) routes - Replace plain textarea NoteEditor with Tiptap rich text editor - Add workspace create/delete with CreateWorkspaceModal - Wire sonner toast notifications on all mutation handlers Made-with: Cursor --- docs/AGENT_TASK_ROADMAP.md | 8 ++ web/package.json | 4 + web/src/app/(app)/dashboard/page.tsx | 2 + web/src/app/(app)/layout.tsx | 5 +- web/src/app/(app)/notes/[noteId]/page.tsx | 25 +++-- web/src/app/(app)/reviews/page.tsx | 11 ++- web/src/app/(app)/workspaces/page.tsx | 81 ++++++++++++---- web/src/app/(auth)/forgot-password/page.tsx | 62 ++++++++++++ web/src/app/(auth)/layout.tsx | 15 +++ web/src/app/(auth)/login/page.tsx | 70 ++++++++++++++ web/src/app/(auth)/register/page.tsx | 84 ++++++++++++++++ web/src/components/AuthGuard.tsx | 49 ++++++++++ web/src/components/CreateWorkspaceModal.tsx | 71 ++++++++++++++ web/src/components/NoteEditor.tsx | 101 ++++++++++++++++---- web/src/lib/notes-client.ts | 30 ++++++ 15 files changed, 566 insertions(+), 52 deletions(-) create mode 100644 web/src/app/(auth)/forgot-password/page.tsx create mode 100644 web/src/app/(auth)/layout.tsx create mode 100644 web/src/app/(auth)/login/page.tsx create mode 100644 web/src/app/(auth)/register/page.tsx create mode 100644 web/src/components/AuthGuard.tsx create mode 100644 web/src/components/CreateWorkspaceModal.tsx diff --git a/docs/AGENT_TASK_ROADMAP.md b/docs/AGENT_TASK_ROADMAP.md index 89847f8..f0664b6 100644 --- a/docs/AGENT_TASK_ROADMAP.md +++ b/docs/AGENT_TASK_ROADMAP.md @@ -54,6 +54,14 @@ cd ../learning_ai_notes/backend && pnpm install && pnpm run typecheck --- +## Phase 0.5 — Fix Broken Imports + Adopt Dashboard Components + +- [x] **0.5.1** Replace broken `@bytelyst/ui` ToastProvider with `sonner` — [`7babee7`](https://github.com/saravanakumardb1/learning_ai_notes/commit/7babee7) +- [x] **0.5.2** Adopt `@bytelyst/dashboard-components` (ErrorPage, NotFoundPage, LoadingSpinner) — [`7babee7`](https://github.com/saravanakumardb1/learning_ai_notes/commit/7babee7) +- [x] **0.5.3** Replace raw `extraction-client.ts` with `@bytelyst/extraction` — [`7babee7`](https://github.com/saravanakumardb1/learning_ai_notes/commit/7babee7) + +--- + ## Phase 1 — Critical Web Gaps These block the web app from being usable by real users. diff --git a/web/package.json b/web/package.json index e0c6033..318e4de 100644 --- a/web/package.json +++ b/web/package.json @@ -23,6 +23,10 @@ "@bytelyst/platform-client": "^0.1.0", "@bytelyst/dashboard-components": "^0.1.0", "@bytelyst/extraction": "^0.1.0", + "@tiptap/extension-placeholder": "^2.11.0", + "@tiptap/pm": "^2.11.0", + "@tiptap/react": "^2.11.0", + "@tiptap/starter-kit": "^2.11.0", "@bytelyst/react-auth": "^0.1.0", "@bytelyst/telemetry-client": "^0.1.0", "lucide-react": "^0.575.0", diff --git a/web/src/app/(app)/dashboard/page.tsx b/web/src/app/(app)/dashboard/page.tsx index 62af04d..0af8ac5 100644 --- a/web/src/app/(app)/dashboard/page.tsx +++ b/web/src/app/(app)/dashboard/page.tsx @@ -6,6 +6,7 @@ import { AppShell } from "@/components/AppShell"; import { CreateNoteModal } from "@/components/CreateNoteModal"; import { listNoteSummaries, listWorkspaceSummaries } from "@/lib/notes-client"; import { listApprovalQueue } from "@/lib/review-client"; +import { toast } from "@/lib/toast"; import type { NoteSummary, WorkspaceSummary } from "@/lib/types"; export default function DashboardPage() { @@ -221,6 +222,7 @@ export default function DashboardPage() { workspaces={workspaces} onCreated={() => { setShowCreateNote(false); + toast.success("Note created"); window.location.reload(); }} onClose={() => setShowCreateNote(false)} diff --git a/web/src/app/(app)/layout.tsx b/web/src/app/(app)/layout.tsx index b5b3fd7..9106211 100644 --- a/web/src/app/(app)/layout.tsx +++ b/web/src/app/(app)/layout.tsx @@ -1,11 +1,12 @@ import type { ReactNode } from "react"; +import { AuthGuard } from "@/components/AuthGuard"; import { KeyboardShortcuts } from "@/components/KeyboardShortcuts"; export default function ProductLayout({ children }: { children: ReactNode }) { return ( - <> +
{children}
- +
); } diff --git a/web/src/app/(app)/notes/[noteId]/page.tsx b/web/src/app/(app)/notes/[noteId]/page.tsx index a4df35e..9626d50 100644 --- a/web/src/app/(app)/notes/[noteId]/page.tsx +++ b/web/src/app/(app)/notes/[noteId]/page.tsx @@ -12,6 +12,7 @@ import { ArtifactPanel } from "@/components/ArtifactPanel"; import { AgentTimeline } from "@/components/AgentTimeline"; import { LinkNoteModal } from "@/components/LinkNoteModal"; import { archiveNote, createNoteArtifact, createNoteTask, getNoteDetail, restoreNote, summarizeNote, updateNoteDetail } from "@/lib/notes-client"; +import { toast } from "@/lib/toast"; import type { NoteDetail } from "@/lib/types"; export default function NoteDetailPage() { @@ -46,8 +47,11 @@ export default function NoteDetailPage() { const refreshed = await getNoteDetail(note.id); setNote(refreshed); setError(null); + toast.success("Note saved"); } catch (err) { - setError(err instanceof Error ? err.message : "Unable to save note"); + const msg = err instanceof Error ? err.message : "Unable to save note"; + setError(msg); + toast.error(msg); } finally { setIsSaving(false); } @@ -78,8 +82,11 @@ export default function NoteDetailPage() { const refreshed = await getNoteDetail(note.id); setNote(refreshed); setError(null); + toast.success("Artifact created"); } catch (err) { - setError(err instanceof Error ? err.message : "Unable to create artifact"); + const msg = err instanceof Error ? err.message : "Unable to create artifact"; + setError(msg); + toast.error(msg); } finally { setIsCreatingArtifact(false); } @@ -104,8 +111,11 @@ export default function NoteDetailPage() { const refreshed = await getNoteDetail(note.id); setNote(refreshed); setError(null); + toast.success("Task created"); } catch (err) { - setError(err instanceof Error ? err.message : "Unable to create task"); + const msg = err instanceof Error ? err.message : "Unable to create task"; + setError(msg); + toast.error(msg); } finally { setIsCreatingTask(false); } @@ -116,8 +126,9 @@ export default function NoteDetailPage() { try { await summarizeNote(note.id, note.workspaceId); setNote(await getNoteDetail(note.id, note.workspaceId)); + toast.success("Note summarized"); } catch (err) { - setError(err instanceof Error ? err.message : "Unable to summarize note"); + toast.error(err instanceof Error ? err.message : "Unable to summarize note"); } } @@ -126,8 +137,9 @@ export default function NoteDetailPage() { try { await archiveNote(note.id, note.workspaceId); setNote(await getNoteDetail(note.id, note.workspaceId)); + toast.success("Note archived"); } catch (err) { - setError(err instanceof Error ? err.message : "Unable to archive note"); + toast.error(err instanceof Error ? err.message : "Unable to archive note"); } } @@ -136,8 +148,9 @@ export default function NoteDetailPage() { try { await restoreNote(note.id, note.workspaceId); setNote(await getNoteDetail(note.id, note.workspaceId)); + toast.success("Note restored"); } catch (err) { - setError(err instanceof Error ? err.message : "Unable to restore note"); + toast.error(err instanceof Error ? err.message : "Unable to restore note"); } } diff --git a/web/src/app/(app)/reviews/page.tsx b/web/src/app/(app)/reviews/page.tsx index fa9578a..36303bb 100644 --- a/web/src/app/(app)/reviews/page.tsx +++ b/web/src/app/(app)/reviews/page.tsx @@ -6,6 +6,7 @@ import { AppShell } from "@/components/AppShell"; import { AgentTimeline } from "@/components/AgentTimeline"; import { ProposalReviewCard } from "@/components/ProposalReviewCard"; import { approveReviewItem, batchReviewItems, listAgentTimeline, listApprovalQueue, rejectReviewItem } from "@/lib/review-client"; +import { toast } from "@/lib/toast"; import type { AgentTimelineItem, ApprovalQueueItem } from "@/lib/types"; export default function ReviewsPage() { @@ -122,8 +123,11 @@ export default function ReviewsPage() { ...current, ]); setReviewNote(""); + toast.success(`Proposal ${decision}`); } catch (err) { - setError(err instanceof Error ? err.message : "Unable to update review state"); + const msg = err instanceof Error ? err.message : "Unable to update review state"; + setError(msg); + toast.error(msg); } finally { setIsSubmitting(false); } @@ -157,8 +161,11 @@ export default function ReviewsPage() { ]); setSelectedBatchIds(new Set()); setReviewNote(""); + toast.success(`Batch ${decision}: ${batchItems.length} items`); } catch (err) { - setError(err instanceof Error ? err.message : "Batch review failed"); + const msg = err instanceof Error ? err.message : "Batch review failed"; + setError(msg); + toast.error(msg); } finally { setIsSubmitting(false); } diff --git a/web/src/app/(app)/workspaces/page.tsx b/web/src/app/(app)/workspaces/page.tsx index 922fe74..7687a82 100644 --- a/web/src/app/(app)/workspaces/page.tsx +++ b/web/src/app/(app)/workspaces/page.tsx @@ -4,7 +4,9 @@ import Link from "next/link"; import { useSearchParams } from "next/navigation"; import { Suspense, useEffect, useMemo, useState } from "react"; import { AppShell } from "@/components/AppShell"; -import { exportNotes, listNoteSummaries, listWorkspaceSummaries } from "@/lib/notes-client"; +import { CreateWorkspaceModal } from "@/components/CreateWorkspaceModal"; +import { exportNotes, listNoteSummaries, listWorkspaceSummaries, deleteWorkspace } from "@/lib/notes-client"; +import { toast } from "@/lib/toast"; import type { NoteSummary, WorkspaceSummary } from "@/lib/types"; export default function WorkspacesPage() { @@ -21,6 +23,8 @@ function WorkspacesPageInner() { const [workspaces, setWorkspaces] = useState([]); const [query, setQuery] = useState(() => searchParams?.get("q") ?? ""); const [error, setError] = useState(null); + const [showCreate, setShowCreate] = useState(false); + const [loadKey, setLoadKey] = useState(0); useEffect(() => { setQuery(searchParams?.get("q") ?? ""); @@ -39,7 +43,22 @@ function WorkspacesPageInner() { setError(err instanceof Error ? err.message : "Unable to load workspaces"); } })(); - }, []); + }, [loadKey]); + + function reload() { + setLoadKey((k) => k + 1); + } + + async function handleDelete(id: string, name: string) { + if (!confirm(`Delete workspace "${name}"? This cannot be undone.`)) return; + try { + await deleteWorkspace(id); + toast.success(`Workspace "${name}" deleted`); + reload(); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Delete failed"); + } + } const notesByWorkspace = useMemo( () => @@ -97,25 +116,33 @@ function WorkspacesPageInner() { title="Workspaces" description="Workspace-level organization, filters, and saved-view entry points for note collections." actions={ - +
+ + +
} >
@@ -177,6 +204,12 @@ function WorkspacesPageInner() { Owner: {workspace.owner} +
@@ -198,6 +231,12 @@ function WorkspacesPageInner() { ); })}
+ {showCreate && ( + { setShowCreate(false); toast.success("Workspace created"); reload(); }} + onClose={() => setShowCreate(false)} + /> + )} ); } diff --git a/web/src/app/(auth)/forgot-password/page.tsx b/web/src/app/(auth)/forgot-password/page.tsx new file mode 100644 index 0000000..00054c4 --- /dev/null +++ b/web/src/app/(auth)/forgot-password/page.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { useState, type FormEvent } from "react"; +import Link from "next/link"; +import { useAuth } from "@/lib/auth"; + +export default function ForgotPasswordPage() { + const { forgotPassword, isLoading, error, success, clearMessages } = useAuth(); + const [email, setEmail] = useState(""); + + async function handleSubmit(e: FormEvent) { + e.preventDefault(); + clearMessages(); + await forgotPassword(email); + } + + return ( +
+

Reset password

+

+ Enter your email and we'll send a reset link if the account exists. +

+ + {error && ( +
+ {error} +
+ )} + + {success && ( +
+ {success} +
+ )} + + + + + +

+ Back to sign in +

+
+ ); +} diff --git a/web/src/app/(auth)/layout.tsx b/web/src/app/(auth)/layout.tsx new file mode 100644 index 0000000..9beca8b --- /dev/null +++ b/web/src/app/(auth)/layout.tsx @@ -0,0 +1,15 @@ +import type { ReactNode } from "react"; +import Link from "next/link"; + +export default function AuthLayout({ children }: { children: ReactNode }) { + return ( +
+
+ + NoteLett + + {children} +
+
+ ); +} diff --git a/web/src/app/(auth)/login/page.tsx b/web/src/app/(auth)/login/page.tsx new file mode 100644 index 0000000..94df825 --- /dev/null +++ b/web/src/app/(auth)/login/page.tsx @@ -0,0 +1,70 @@ +"use client"; + +import { useState, type FormEvent } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useAuth } from "@/lib/auth"; + +export default function LoginPage() { + const { login, isLoading, error, clearMessages } = useAuth(); + const router = useRouter(); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + + async function handleSubmit(e: FormEvent) { + e.preventDefault(); + clearMessages(); + const ok = await login(email, password); + if (ok) router.push("/dashboard"); + } + + return ( +
+

Sign in

+ + {error && ( +
+ {error} +
+ )} + + + + + + + +
+ Forgot password? + Create account +
+
+ ); +} diff --git a/web/src/app/(auth)/register/page.tsx b/web/src/app/(auth)/register/page.tsx new file mode 100644 index 0000000..acf58de --- /dev/null +++ b/web/src/app/(auth)/register/page.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { useState, type FormEvent } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useAuth } from "@/lib/auth"; + +export default function RegisterPage() { + const { register, isLoading, error, clearMessages } = useAuth(); + const router = useRouter(); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [displayName, setDisplayName] = useState(""); + + async function handleSubmit(e: FormEvent) { + e.preventDefault(); + clearMessages(); + const ok = await register(email, password, displayName); + if (ok) router.push("/dashboard"); + } + + return ( +
+

Create account

+ + {error && ( +
+ {error} +
+ )} + + + + + + + + + +

+ Already have an account?{" "} + Sign in +

+
+ ); +} diff --git a/web/src/components/AuthGuard.tsx b/web/src/components/AuthGuard.tsx new file mode 100644 index 0000000..c831c16 --- /dev/null +++ b/web/src/components/AuthGuard.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { useEffect, useState, type ReactNode } from "react"; +import { useRouter } from "next/navigation"; +import { useAuth } from "@/lib/auth"; +import { checkKillSwitch } from "@/lib/kill-switch"; + +export function AuthGuard({ children }: { children: ReactNode }) { + const { isAuthenticated, isLoading: authLoading } = useAuth(); + const router = useRouter(); + const [killSwitchMsg, setKillSwitchMsg] = useState(null); + const [ready, setReady] = useState(false); + + useEffect(() => { + if (authLoading) return; + + if (!isAuthenticated) { + router.replace("/login"); + return; + } + + checkKillSwitch() + .then(({ disabled, message }) => { + if (disabled) { + setKillSwitchMsg(message || "This application is currently unavailable."); + } else { + setReady(true); + } + }) + .catch(() => { + setReady(true); + }); + }, [isAuthenticated, authLoading, router]); + + if (killSwitchMsg) { + return ( +
+
+

Service Unavailable

+

{killSwitchMsg}

+
+
+ ); + } + + if (!ready) return null; + + return <>{children}; +} diff --git a/web/src/components/CreateWorkspaceModal.tsx b/web/src/components/CreateWorkspaceModal.tsx new file mode 100644 index 0000000..ec5b65c --- /dev/null +++ b/web/src/components/CreateWorkspaceModal.tsx @@ -0,0 +1,71 @@ +"use client"; + +import { useState, type FormEvent } from "react"; +import { createWorkspace } from "@/lib/notes-client"; + +interface Props { + onCreated: () => void; + onClose: () => void; +} + +export function CreateWorkspaceModal({ onCreated, onClose }: Props) { + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + async function handleSubmit(e: FormEvent) { + e.preventDefault(); + if (!name.trim() || saving) return; + + setSaving(true); + setError(null); + + try { + await createWorkspace({ + id: crypto.randomUUID(), + name: name.trim(), + description: description.trim() || undefined, + }); + onCreated(); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to create workspace"); + } finally { + setSaving(false); + } + } + + return ( +
{ if (e.target === e.currentTarget) onClose(); }} + > +
+
Create Workspace
+ + {error &&
{error}
} + + + +