diff --git a/README.md b/README.md index 9ea0661..14c921c 100644 --- a/README.md +++ b/README.md @@ -12,11 +12,13 @@ Task and sprint board built with Next.js + Zustand and SQLite-backed API persist - Added unlimited nested replies (reply to comment, reply to reply, no depth limit). - Updated UI wording from "notes" to "comments". - Improved attachment opening/rendering by coercing MIME types (including `.md` as text) and using blob URLs for reliable in-browser viewing. +- Added lightweight collaborator identity tracking for task/comment authorship. ### Data model and status rules - Tasks use labels (`tags: string[]`) and can have multiple labels. - Tasks support attachments (`attachments: TaskAttachment[]`). +- Tasks now track `createdById`, `createdByName`, `updatedById`, and `updatedByName`. - There is no active `backlog` status in workflow logic. - A task is considered in Backlog when `sprintId` is empty. - Current status values: @@ -31,6 +33,25 @@ Task and sprint board built with Next.js + Zustand and SQLite-backed API persist - `done` - New tasks default to `status: open`. +### Authentication and sessions + +- Added account-based authentication with: + - `POST /api/auth/register` + - `POST /api/auth/login` + - `POST /api/auth/logout` + - `GET /api/auth/session` +- Added a dedicated login/register screen at `/login`. +- Added account settings at `/settings` for authenticated users. +- Main board (`/`) and task detail pages (`/tasks/{taskId}`) require an authenticated session. +- Main board and task detail headers include quick access to Settings and Logout. +- API routes now enforce auth on task read/write/delete (`/api/tasks` returns `401` when unauthenticated). +- Added `PATCH /api/auth/account` to update name/email/password for the current user. +- Session cookie: `gantt_session` (HTTP-only, same-site lax, secure in production). +- Added `Remember me` in auth forms: + - Checked: persistent 30-day cookie/session. + - Unchecked: browser-session cookie (clears on browser close), with server-side short-session expiry. +- Task/comment authorship now uses authenticated user identity. + ### Labels - Project selection UI for tasks was removed in favor of labels. diff --git a/src/app/api/auth/account/route.ts b/src/app/api/auth/account/route.ts new file mode 100644 index 0000000..d714d07 --- /dev/null +++ b/src/app/api/auth/account/route.ts @@ -0,0 +1,54 @@ +import { NextResponse } from "next/server"; +import { getAuthenticatedUser, updateUserAccount } from "@/lib/server/auth"; + +export const runtime = "nodejs"; + +export async function PATCH(request: Request) { + try { + const sessionUser = await getAuthenticatedUser(); + if (!sessionUser) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = (await request.json()) as { + name?: string; + email?: string; + currentPassword?: string; + newPassword?: string; + }; + + const nextName = typeof body.name === "string" ? body.name : undefined; + const nextEmail = typeof body.email === "string" ? body.email : undefined; + const currentPassword = typeof body.currentPassword === "string" ? body.currentPassword : undefined; + const newPassword = typeof body.newPassword === "string" ? body.newPassword : undefined; + + const user = updateUserAccount({ + userId: sessionUser.id, + name: nextName, + email: nextEmail, + currentPassword, + newPassword, + }); + + return NextResponse.json({ success: true, user }); + } catch (error) { + const message = error instanceof Error ? error.message : "Account update failed"; + if (message === "User not found") { + return NextResponse.json({ error: message }, { status: 404 }); + } + if (message === "Current password is incorrect") { + return NextResponse.json({ error: message }, { status: 401 }); + } + if (message.includes("exists")) { + return NextResponse.json({ error: message }, { status: 409 }); + } + if ( + message.includes("Current password is required") + || message.includes("at least") + || message.includes("Invalid email") + ) { + return NextResponse.json({ error: message }, { status: 400 }); + } + return NextResponse.json({ error: "Account update failed" }, { status: 500 }); + } +} diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts new file mode 100644 index 0000000..b06d760 --- /dev/null +++ b/src/app/api/auth/login/route.ts @@ -0,0 +1,38 @@ +import { NextResponse } from "next/server"; +import { authenticateUser, createUserSession, setSessionCookie } from "@/lib/server/auth"; + +export const runtime = "nodejs"; + +export async function POST(request: Request) { + try { + const body = (await request.json()) as { + email?: string; + password?: string; + rememberMe?: boolean; + }; + + const email = (body.email || "").trim(); + const password = body.password || ""; + const rememberMe = Boolean(body.rememberMe); + + if (!email || !password) { + return NextResponse.json({ error: "Email and password are required" }, { status: 400 }); + } + + const user = authenticateUser({ email, password }); + if (!user) { + return NextResponse.json({ error: "Invalid credentials" }, { status: 401 }); + } + + const session = createUserSession(user.id, rememberMe); + await setSessionCookie(session.token, rememberMe); + + return NextResponse.json({ + success: true, + user, + session: { expiresAt: session.expiresAt, rememberMe }, + }); + } catch { + return NextResponse.json({ error: "Login failed" }, { status: 500 }); + } +} diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts new file mode 100644 index 0000000..96038cd --- /dev/null +++ b/src/app/api/auth/logout/route.ts @@ -0,0 +1,15 @@ +import { NextResponse } from "next/server"; +import { clearSessionCookie, getSessionTokenFromCookies, revokeSession } from "@/lib/server/auth"; + +export const runtime = "nodejs"; + +export async function POST() { + try { + const token = await getSessionTokenFromCookies(); + if (token) revokeSession(token); + await clearSessionCookie(); + return NextResponse.json({ success: true }); + } catch { + return NextResponse.json({ error: "Logout failed" }, { status: 500 }); + } +} diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts new file mode 100644 index 0000000..938d88e --- /dev/null +++ b/src/app/api/auth/register/route.ts @@ -0,0 +1,38 @@ +import { NextResponse } from "next/server"; +import { createUserSession, registerUser, setSessionCookie } from "@/lib/server/auth"; + +export const runtime = "nodejs"; + +export async function POST(request: Request) { + try { + const body = (await request.json()) as { + name?: string; + email?: string; + password?: string; + rememberMe?: boolean; + }; + + const name = (body.name || "").trim(); + const email = (body.email || "").trim(); + const password = body.password || ""; + const rememberMe = Boolean(body.rememberMe); + + if (!name || !email || !password) { + return NextResponse.json({ error: "Name, email, and password are required" }, { status: 400 }); + } + + const user = registerUser({ name, email, password }); + const session = createUserSession(user.id, rememberMe); + await setSessionCookie(session.token, rememberMe); + + return NextResponse.json({ + success: true, + user, + session: { expiresAt: session.expiresAt, rememberMe }, + }); + } catch (error) { + const message = error instanceof Error ? error.message : "Registration failed"; + const status = message.includes("exists") ? 409 : 400; + return NextResponse.json({ error: message }, { status }); + } +} diff --git a/src/app/api/auth/session/route.ts b/src/app/api/auth/session/route.ts new file mode 100644 index 0000000..0f8edc8 --- /dev/null +++ b/src/app/api/auth/session/route.ts @@ -0,0 +1,16 @@ +import { NextResponse } from "next/server"; +import { getAuthenticatedUser } from "@/lib/server/auth"; + +export const runtime = "nodejs"; + +export async function GET() { + try { + const user = await getAuthenticatedUser(); + if (!user) { + return NextResponse.json({ authenticated: false }, { status: 401 }); + } + return NextResponse.json({ authenticated: true, user }); + } catch { + return NextResponse.json({ error: "Session check failed" }, { status: 500 }); + } +} diff --git a/src/app/api/tasks/route.ts b/src/app/api/tasks/route.ts index e266e6b..47ee38d 100644 --- a/src/app/api/tasks/route.ts +++ b/src/app/api/tasks/route.ts @@ -1,11 +1,16 @@ import { NextResponse } from "next/server"; import { getData, saveData, type DataStore, type Task } from "@/lib/server/taskDb"; +import { getAuthenticatedUser } from "@/lib/server/auth"; export const runtime = "nodejs"; // GET - fetch all tasks, projects, and sprints export async function GET() { try { + const user = await getAuthenticatedUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } const data = getData(); return NextResponse.json(data); } catch (error) { @@ -17,6 +22,11 @@ export async function GET() { // POST - create or update tasks, projects, or sprints export async function POST(request: Request) { try { + const user = await getAuthenticatedUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + const body = await request.json(); const { task, tasks, projects, sprints } = body as { task?: Task; @@ -33,19 +43,34 @@ export async function POST(request: Request) { if (task) { const existingIndex = data.tasks.findIndex((t) => t.id === task.id); if (existingIndex >= 0) { - data.tasks[existingIndex] = { ...task, updatedAt: new Date().toISOString() }; + data.tasks[existingIndex] = { + ...task, + updatedAt: new Date().toISOString(), + updatedById: user.id, + updatedByName: user.name, + }; } else { data.tasks.push({ ...task, id: task.id || Date.now().toString(), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), + createdById: task.createdById || user.id, + createdByName: task.createdByName || user.name, + updatedById: user.id, + updatedByName: user.name, }); } } if (tasks && Array.isArray(tasks)) { - data.tasks = tasks; + data.tasks = tasks.map((entry) => ({ + ...entry, + createdById: entry.createdById || user.id, + createdByName: entry.createdByName || user.name, + updatedById: entry.updatedById || user.id, + updatedByName: entry.updatedByName || user.name, + })); } const saved = saveData(data); @@ -59,6 +84,11 @@ export async function POST(request: Request) { // DELETE - remove a task export async function DELETE(request: Request) { try { + const user = await getAuthenticatedUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + const { id } = (await request.json()) as { id: string }; const data = getData(); data.tasks = data.tasks.filter((t) => t.id !== id); @@ -69,4 +99,3 @@ export async function DELETE(request: Request) { return NextResponse.json({ error: "Failed to delete" }, { status: 500 }); } } - diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx new file mode 100644 index 0000000..ec26344 --- /dev/null +++ b/src/app/login/page.tsx @@ -0,0 +1,148 @@ +"use client" + +import { useEffect, useState } from "react" +import { useRouter } from "next/navigation" +import { Button } from "@/components/ui/button" + +export default function LoginPage() { + const router = useRouter() + const [mode, setMode] = useState<"login" | "register">("login") + const [name, setName] = useState("") + const [email, setEmail] = useState("") + const [password, setPassword] = useState("") + const [rememberMe, setRememberMe] = useState(true) + const [isSubmitting, setIsSubmitting] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + let isMounted = true + const check = async () => { + try { + const res = await fetch("/api/auth/session", { cache: "no-store" }) + if (res.ok && isMounted) { + router.replace("/") + } + } catch { + // ignore and stay on login + } + } + check() + return () => { + isMounted = false + } + }, [router]) + + const submit = async () => { + setError(null) + + if (!email.trim() || !password) { + setError("Email and password are required") + return + } + + if (mode === "register" && !name.trim()) { + setError("Name is required") + return + } + + setIsSubmitting(true) + try { + const endpoint = mode === "login" ? "/api/auth/login" : "/api/auth/register" + const res = await fetch(endpoint, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name, email, password, rememberMe }), + }) + + const data = await res.json() + if (!res.ok) { + setError(data.error || "Authentication failed") + return + } + + router.replace("/") + } catch { + setError("Authentication failed") + } finally { + setIsSubmitting(false) + } + } + + return ( +
+
+

OpenClaw Task Hub

+

Sign in to continue.

+ +
+ + +
+ +
+ {mode === "register" && ( +
+ + setName(event.target.value)} + className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white" + placeholder="Your display name" + /> +
+ )} + +
+ + setEmail(event.target.value)} + className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white" + placeholder="you@example.com" + /> +
+ +
+ + setPassword(event.target.value)} + className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white" + placeholder="At least 8 characters" + /> +
+ + + + {error &&

{error}

} + + +
+
+
+ ) +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 3ab6549..46c75bc 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -31,7 +31,7 @@ import { markdownPreviewObjectUrl, textPreviewObjectUrl, } from "@/lib/attachments" -import { useTaskStore, Task, TaskType, TaskStatus, Priority, TaskAttachment } from "@/stores/useTaskStore" +import { useTaskStore, Task, TaskType, TaskStatus, Priority, TaskAttachment, type CommentAuthor } from "@/stores/useTaskStore" import { BacklogView } from "@/components/BacklogView" import { Plus, MessageSquare, Calendar, Trash2, X, LayoutGrid, ListTodo, GripVertical, Paperclip, Download } from "lucide-react" @@ -304,6 +304,7 @@ export default function Home() { projects, tasks, sprints, + currentUser, selectedProjectId, selectedTaskId, addTask, @@ -312,6 +313,7 @@ export default function Home() { selectTask, addComment, deleteComment, + setCurrentUser, syncFromServer, isLoading, } = useTaskStore() @@ -332,6 +334,7 @@ export default function Home() { const [editedTaskLabelInput, setEditedTaskLabelInput] = useState("") const [activeKanbanTaskId, setActiveKanbanTaskId] = useState(null) const [dragOverKanbanColumnKey, setDragOverKanbanColumnKey] = useState(null) + const [authReady, setAuthReady] = useState(false) const getTags = (taskLike: { tags?: unknown }) => { if (!Array.isArray(taskLike.tags)) return [] as string[] @@ -352,6 +355,27 @@ export default function Home() { }) } + const getCommentAuthor = (value: unknown): CommentAuthor => { + if (value === "assistant") { + return { id: "assistant", name: "Assistant", type: "assistant" } + } + if (value === "user") { + return { id: "legacy-user", name: "User", type: "human" } + } + if (!value || typeof value !== "object") { + return { id: "legacy-user", name: "User", type: "human" } + } + + const candidate = value as Partial + const type = candidate.type === "assistant" || candidate.id === "assistant" ? "assistant" : "human" + return { + id: typeof candidate.id === "string" && candidate.id ? candidate.id : type === "assistant" ? "assistant" : "legacy-user", + name: typeof candidate.name === "string" && candidate.name ? candidate.name : type === "assistant" ? "Assistant" : "User", + email: typeof candidate.email === "string" && candidate.email ? candidate.email : undefined, + type, + } + } + const formatBytes = (bytes: number) => { if (!Number.isFinite(bytes) || bytes <= 0) return "0 B" const units = ["B", "KB", "MB", "GB"] @@ -380,13 +404,39 @@ export default function Home() { const allLabels = useMemo(() => labelUsage.map(([label]) => label), [labelUsage]) - // Sync from server on mount useEffect(() => { - console.log('>>> PAGE: useEffect for syncFromServer running') - syncFromServer().then(() => { - console.log('>>> PAGE: syncFromServer completed') - }) - }, [syncFromServer]) + let isMounted = true + const loadSession = async () => { + try { + const res = await fetch("/api/auth/session", { cache: "no-store" }) + if (!res.ok) { + if (isMounted) router.replace("/login") + return + } + + const data = await res.json() + if (!isMounted) return + setCurrentUser({ + id: data.user.id, + name: data.user.name, + email: data.user.email, + }) + setAuthReady(true) + } catch { + if (isMounted) router.replace("/login") + } + } + + loadSession() + return () => { + isMounted = false + } + }, [router, setCurrentUser]) + + useEffect(() => { + if (!authReady) return + syncFromServer() + }, [authReady, syncFromServer]) useEffect(() => { if (selectedTaskId) { @@ -549,11 +599,20 @@ export default function Home() { const handleAddComment = () => { if (newComment.trim() && selectedTaskId) { - addComment(selectedTaskId, newComment.trim(), "user") + addComment(selectedTaskId, newComment.trim()) setNewComment("") } } + const handleLogout = async () => { + try { + await fetch("/api/auth/logout", { method: "POST" }) + } finally { + setAuthReady(false) + router.replace("/login") + } + } + const handleAttachmentUpload = async (event: ChangeEvent) => { const files = Array.from(event.target.files || []) if (files.length === 0) return @@ -622,6 +681,14 @@ export default function Home() { } } + if (!authReady) { + return ( +
+

Checking session...

+
+ ) + } + return (
{/* Header */} @@ -649,6 +716,23 @@ export default function Home() { {tasks.length} tasks · {allLabels.length} labels + + {currentUser.name} + + +
@@ -1208,26 +1292,30 @@ export default function Home() { {!editedTask.comments || editedTask.comments.length === 0 ? (

No comments yet. Add the first one.

) : ( - editedTask.comments.map((comment) => ( + editedTask.comments.map((comment) => { + const author = getCommentAuthor(comment.author) + const isAssistant = author.type === "assistant" + const displayName = author.id === currentUser.id ? "You" : author.name + return (
- {comment.author === "assistant" ? "AI" : "You"} + {isAssistant ? "AI" : displayName.slice(0, 2).toUpperCase()}
- {comment.author === "assistant" ? "Assistant" : "You"} + {isAssistant ? "Assistant" : displayName}
@@ -1244,7 +1332,8 @@ export default function Home() {

{comment.text}

- )) + ) + }) )}
diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx new file mode 100644 index 0000000..abdd9b2 --- /dev/null +++ b/src/app/settings/page.tsx @@ -0,0 +1,240 @@ +"use client" + +import { useEffect, useState } from "react" +import { useRouter } from "next/navigation" +import { ArrowLeft } from "lucide-react" +import { Button } from "@/components/ui/button" +import { useTaskStore } from "@/stores/useTaskStore" + +export default function SettingsPage() { + const router = useRouter() + const { setCurrentUser } = useTaskStore() + + const [authReady, setAuthReady] = useState(false) + const [isSaving, setIsSaving] = useState(false) + const [isLoggingOut, setIsLoggingOut] = useState(false) + const [error, setError] = useState(null) + const [success, setSuccess] = useState(null) + + const [name, setName] = useState("") + const [email, setEmail] = useState("") + const [currentPassword, setCurrentPassword] = useState("") + const [newPassword, setNewPassword] = useState("") + const [confirmPassword, setConfirmPassword] = useState("") + + useEffect(() => { + let isMounted = true + const loadSession = async () => { + try { + const res = await fetch("/api/auth/session", { cache: "no-store" }) + if (!res.ok) { + if (isMounted) router.replace("/login") + return + } + const data = await res.json() + if (!isMounted) return + + setName(data.user.name || "") + setEmail(data.user.email || "") + setCurrentUser({ + id: data.user.id, + name: data.user.name, + email: data.user.email, + }) + setAuthReady(true) + } catch { + if (isMounted) router.replace("/login") + } + } + + loadSession() + return () => { + isMounted = false + } + }, [router, setCurrentUser]) + + const handleSave = async () => { + setError(null) + setSuccess(null) + + const trimmedName = name.trim() + const trimmedEmail = email.trim() + + if (!trimmedName || !trimmedEmail) { + setError("Name and email are required") + return + } + + if (newPassword || confirmPassword || currentPassword) { + if (!currentPassword) { + setError("Current password is required to change password") + return + } + if (!newPassword) { + setError("New password is required") + return + } + if (newPassword !== confirmPassword) { + setError("New password and confirmation do not match") + return + } + } + + setIsSaving(true) + try { + const payload: Record = { + name: trimmedName, + email: trimmedEmail, + } + if (currentPassword) payload.currentPassword = currentPassword + if (newPassword) payload.newPassword = newPassword + + const res = await fetch("/api/auth/account", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }) + + const data = await res.json() + if (!res.ok) { + setError(data.error || "Failed to update account") + return + } + + setCurrentUser({ + id: data.user.id, + name: data.user.name, + email: data.user.email, + }) + setName(data.user.name) + setEmail(data.user.email) + setCurrentPassword("") + setNewPassword("") + setConfirmPassword("") + setSuccess("Account updated") + } catch { + setError("Failed to update account") + } finally { + setIsSaving(false) + } + } + + const handleLogout = async () => { + setIsLoggingOut(true) + try { + await fetch("/api/auth/logout", { method: "POST" }) + router.replace("/login") + } finally { + setIsLoggingOut(false) + } + } + + if (!authReady) { + return ( +
+

Checking session...

+
+ ) + } + + return ( +
+
+
+ + +
+ +
+
+

Account Settings

+

Update your profile and password.

+
+ +
+
+ + setName(event.target.value)} + className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white" + placeholder="Your display name" + /> +
+ +
+ + setEmail(event.target.value)} + className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white" + placeholder="you@example.com" + /> +
+
+ +
+
+

Change Password

+

Leave blank if you do not want to change it.

+
+ +
+ + setCurrentPassword(event.target.value)} + className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white" + placeholder="Current password" + /> +
+ +
+ + setNewPassword(event.target.value)} + className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white" + placeholder="At least 8 characters" + /> +
+ +
+ + setConfirmPassword(event.target.value)} + className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white" + placeholder="Confirm new password" + /> +
+
+ + {error &&

{error}

} + {success &&

{success}

} + +
+ +
+
+
+
+ ) +} diff --git a/src/app/tasks/[taskId]/page.tsx b/src/app/tasks/[taskId]/page.tsx index 6639957..de51963 100644 --- a/src/app/tasks/[taskId]/page.tsx +++ b/src/app/tasks/[taskId]/page.tsx @@ -19,11 +19,13 @@ import { import { useTaskStore, type Comment as TaskComment, + type CommentAuthor, type Priority, type Task, type TaskAttachment, type TaskStatus, type TaskType, + type UserProfile, } from "@/stores/useTaskStore" const typeColors: Record = { @@ -73,24 +75,53 @@ const getAttachments = (taskLike: { attachments?: unknown }) => { const getComments = (value: unknown): TaskComment[] => { if (!Array.isArray(value)) return [] - return value - .map((entry) => { - if (!entry || typeof entry !== "object") return null - const comment = entry as Partial - if (typeof comment.id !== "string" || typeof comment.text !== "string") return null + const normalized: TaskComment[] = [] + for (const entry of value) { + if (!entry || typeof entry !== "object") continue + const comment = entry as Partial + if (typeof comment.id !== "string" || typeof comment.text !== "string") continue - return { - id: comment.id, - text: comment.text, - createdAt: typeof comment.createdAt === "string" ? comment.createdAt : new Date().toISOString(), - author: comment.author === "assistant" ? "assistant" : "user", - replies: getComments(comment.replies), - } + normalized.push({ + id: comment.id, + text: comment.text, + createdAt: typeof comment.createdAt === "string" ? comment.createdAt : new Date().toISOString(), + author: getCommentAuthor(comment.author), + replies: getComments(comment.replies), }) - .filter((comment): comment is TaskComment => comment !== null) + } + + return normalized } -const buildComment = (text: string, author: "user" | "assistant" = "user"): TaskComment => ({ +const getCommentAuthor = (value: unknown): CommentAuthor => { + if (value === "assistant") { + return { id: "assistant", name: "Assistant", type: "assistant" } + } + if (value === "user") { + return { id: "legacy-user", name: "User", type: "human" } + } + if (!value || typeof value !== "object") { + return { id: "legacy-user", name: "User", type: "human" } + } + + const candidate = value as Partial + const type = candidate.type === "assistant" || candidate.id === "assistant" ? "assistant" : "human" + return { + id: typeof candidate.id === "string" && candidate.id ? candidate.id : type === "assistant" ? "assistant" : "legacy-user", + name: typeof candidate.name === "string" && candidate.name ? candidate.name : type === "assistant" ? "Assistant" : "User", + email: typeof candidate.email === "string" && candidate.email ? candidate.email : undefined, + type, + } +} + +const profileToAuthor = (profile: UserProfile): CommentAuthor => ({ + id: profile.id, + name: profile.name, + email: profile.email, + type: "human", +}) + +const buildComment = (text: string, author: CommentAuthor): TaskComment => ({ id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, text, createdAt: new Date().toISOString(), @@ -161,8 +192,10 @@ export default function TaskDetailPage() { const { tasks, sprints, + currentUser, updateTask, deleteTask, + setCurrentUser, syncFromServer, isLoading, } = useTaskStore() @@ -174,10 +207,40 @@ export default function TaskDetailPage() { const [isSaving, setIsSaving] = useState(false) const [replyDrafts, setReplyDrafts] = useState>({}) const [openReplyEditors, setOpenReplyEditors] = useState>({}) + const [authReady, setAuthReady] = useState(false) useEffect(() => { + let isMounted = true + const loadSession = async () => { + try { + const res = await fetch("/api/auth/session", { cache: "no-store" }) + if (!res.ok) { + if (isMounted) router.replace("/login") + return + } + const data = await res.json() + if (!isMounted) return + setCurrentUser({ + id: data.user.id, + name: data.user.name, + email: data.user.email, + }) + setAuthReady(true) + } catch { + if (isMounted) router.replace("/login") + } + } + + loadSession() + return () => { + isMounted = false + } + }, [router, setCurrentUser]) + + useEffect(() => { + if (!authReady) return syncFromServer() - }, [syncFromServer]) + }, [authReady, syncFromServer]) useEffect(() => { if (selectedTask) { @@ -244,9 +307,10 @@ export default function TaskDetailPage() { const handleAddComment = () => { if (!editedTask || !newComment.trim()) return + const actor = profileToAuthor(currentUser) setEditedTask({ ...editedTask, - comments: [...getComments(editedTask.comments), buildComment(newComment.trim(), "user")], + comments: [...getComments(editedTask.comments), buildComment(newComment.trim(), actor)], }) setNewComment("") } @@ -256,9 +320,10 @@ export default function TaskDetailPage() { const text = replyDrafts[parentId]?.trim() if (!text) return + const actor = profileToAuthor(currentUser) setEditedTask({ ...editedTask, - comments: addReplyToThread(getComments(editedTask.comments), parentId, buildComment(text, "user")), + comments: addReplyToThread(getComments(editedTask.comments), parentId, buildComment(text, actor)), }) setReplyDrafts((prev) => ({ ...prev, [parentId]: "" })) @@ -323,21 +388,33 @@ export default function TaskDetailPage() { router.push("/") } + const handleLogout = async () => { + try { + await fetch("/api/auth/logout", { method: "POST" }) + } finally { + setAuthReady(false) + router.replace("/login") + } + } + const renderThread = (comments: TaskComment[], depth = 0): JSX.Element[] => comments.map((comment) => { const replies = getComments(comment.replies) const isReplying = !!openReplyEditors[comment.id] const replyDraft = replyDrafts[comment.id] || "" + const author = getCommentAuthor(comment.author) + const isAssistant = author.type === "assistant" + const displayName = isAssistant ? "Assistant" : author.id === currentUser.id ? "You" : author.name return (
-
+
- - {comment.author === "assistant" ? "AI" : "You"} + + {isAssistant ? "AI" : displayName.slice(0, 2).toUpperCase()} - {comment.author === "assistant" ? "Assistant" : "You"} + {displayName} {new Date(comment.createdAt).toLocaleString()}
@@ -396,6 +473,14 @@ export default function TaskDetailPage() { ) } + if (!authReady) { + return ( +
+

Checking session...

+
+ ) + } + if (!selectedTask && !isLoading) { return (
@@ -422,7 +507,23 @@ export default function TaskDetailPage() { Back to Board - Task URL: /tasks/{editedTask.id} +
+ Task URL: /tasks/{editedTask.id} + + +
@@ -441,6 +542,9 @@ export default function TaskDetailPage() { onChange={(event) => setEditedTask({ ...editedTask, title: event.target.value })} className="w-full text-xl font-semibold bg-slate-800 border border-slate-700 rounded px-3 py-2 text-white" /> +

+ Created by {editedTask.createdByName || "Unknown"}{editedTask.updatedByName ? ` · Last updated by ${editedTask.updatedByName}` : ""} +

diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts new file mode 100644 index 0000000..c29de6d --- /dev/null +++ b/src/lib/server/auth.ts @@ -0,0 +1,295 @@ +import Database from "better-sqlite3"; +import { randomBytes, scryptSync, timingSafeEqual, createHash } from "crypto"; +import { mkdirSync } from "fs"; +import { join } from "path"; +import { cookies } from "next/headers"; + +const DATA_DIR = join(process.cwd(), "data"); +const DB_FILE = join(DATA_DIR, "tasks.db"); + +const SESSION_COOKIE_NAME = "gantt_session"; +const SESSION_HOURS_SHORT = 12; +const SESSION_DAYS_REMEMBER = 30; + +type SqliteDb = InstanceType; +let db: SqliteDb | null = null; + +export interface AuthUser { + id: string; + name: string; + email: string; + createdAt: string; +} + +interface UserRow extends AuthUser { + passwordHash: string; +} + +function normalizeEmail(email: string): string { + return email.trim().toLowerCase(); +} + +function getDb(): SqliteDb { + if (db) return db; + + mkdirSync(DATA_DIR, { recursive: true }); + const database = new Database(DB_FILE); + database.pragma("journal_mode = WAL"); + database.exec(` + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + email TEXT NOT NULL UNIQUE, + passwordHash TEXT NOT NULL, + createdAt TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + userId TEXT NOT NULL, + tokenHash TEXT NOT NULL UNIQUE, + createdAt TEXT NOT NULL, + expiresAt TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_sessions_token_hash ON sessions(tokenHash); + CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(userId); + CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expiresAt); + `); + + db = database; + return database; +} + +function hashPassword(password: string, salt?: string): string { + const safeSalt = salt || randomBytes(16).toString("hex"); + const derived = scryptSync(password, safeSalt, 64).toString("hex"); + return `scrypt$${safeSalt}$${derived}`; +} + +function verifyPassword(password: string, stored: string): boolean { + const parts = stored.split("$"); + if (parts.length !== 3 || parts[0] !== "scrypt") return false; + const [, salt, digest] = parts; + const candidate = hashPassword(password, salt); + const candidateDigest = candidate.split("$")[2]; + const a = Buffer.from(digest, "hex"); + const b = Buffer.from(candidateDigest, "hex"); + if (a.length !== b.length) return false; + return timingSafeEqual(a, b); +} + +function hashSessionToken(token: string): string { + return createHash("sha256").update(token).digest("hex"); +} + +function deleteExpiredSessions(database: SqliteDb) { + const now = new Date().toISOString(); + database.prepare("DELETE FROM sessions WHERE expiresAt <= ?").run(now); +} + +export function registerUser(params: { + name: string; + email: string; + password: string; +}): AuthUser { + const database = getDb(); + deleteExpiredSessions(database); + + const name = params.name.trim(); + const email = normalizeEmail(params.email); + const password = params.password; + + if (name.length < 2) throw new Error("Name must be at least 2 characters"); + if (!email.includes("@")) throw new Error("Invalid email"); + if (password.length < 8) throw new Error("Password must be at least 8 characters"); + + const existing = database + .prepare("SELECT id FROM users WHERE email = ? LIMIT 1") + .get(email) as { id: string } | undefined; + if (existing) { + throw new Error("Email already exists"); + } + + const user: AuthUser = { + id: `user-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + name, + email, + createdAt: new Date().toISOString(), + }; + + database + .prepare("INSERT INTO users (id, name, email, passwordHash, createdAt) VALUES (?, ?, ?, ?, ?)") + .run(user.id, user.name, user.email, hashPassword(password), user.createdAt); + + return user; +} + +export function authenticateUser(params: { + email: string; + password: string; +}): AuthUser | null { + const database = getDb(); + deleteExpiredSessions(database); + const email = normalizeEmail(params.email); + const row = database + .prepare("SELECT id, name, email, passwordHash, createdAt FROM users WHERE email = ? LIMIT 1") + .get(email) as UserRow | undefined; + if (!row) return null; + if (!verifyPassword(params.password, row.passwordHash)) return null; + + return { + id: row.id, + name: row.name, + email: row.email, + createdAt: row.createdAt, + }; +} + +export function updateUserAccount(params: { + userId: string; + name?: string; + email?: string; + currentPassword?: string; + newPassword?: string; +}): AuthUser { + const database = getDb(); + deleteExpiredSessions(database); + + const row = database + .prepare("SELECT id, name, email, passwordHash, createdAt FROM users WHERE id = ? LIMIT 1") + .get(params.userId) as UserRow | undefined; + + if (!row) throw new Error("User not found"); + + const requestedName = typeof params.name === "string" ? params.name.trim() : row.name; + const requestedEmail = typeof params.email === "string" ? normalizeEmail(params.email) : row.email; + const currentPassword = params.currentPassword || ""; + const newPassword = params.newPassword || ""; + + if (requestedName.length < 2) throw new Error("Name must be at least 2 characters"); + if (!requestedEmail.includes("@")) throw new Error("Invalid email"); + if (newPassword && newPassword.length < 8) throw new Error("New password must be at least 8 characters"); + + const emailChanged = requestedEmail !== row.email; + const passwordChanged = newPassword.length > 0; + const needsPasswordCheck = emailChanged || passwordChanged; + + if (needsPasswordCheck) { + if (!currentPassword) throw new Error("Current password is required"); + if (!verifyPassword(currentPassword, row.passwordHash)) { + throw new Error("Current password is incorrect"); + } + } + + if (emailChanged) { + const existing = database + .prepare("SELECT id FROM users WHERE email = ? AND id != ? LIMIT 1") + .get(requestedEmail, row.id) as { id: string } | undefined; + if (existing) throw new Error("Email already exists"); + } + + const nextPasswordHash = passwordChanged ? hashPassword(newPassword) : row.passwordHash; + + database + .prepare("UPDATE users SET name = ?, email = ?, passwordHash = ? WHERE id = ?") + .run(requestedName, requestedEmail, nextPasswordHash, row.id); + + return { + id: row.id, + name: requestedName, + email: requestedEmail, + createdAt: row.createdAt, + }; +} + +export function createUserSession(userId: string, rememberMe: boolean): { + token: string; + expiresAt: string; +} { + const database = getDb(); + deleteExpiredSessions(database); + + const now = Date.now(); + const ttlMs = rememberMe + ? SESSION_DAYS_REMEMBER * 24 * 60 * 60 * 1000 + : SESSION_HOURS_SHORT * 60 * 60 * 1000; + + const createdAt = new Date(now).toISOString(); + const expiresAt = new Date(now + ttlMs).toISOString(); + const token = randomBytes(32).toString("hex"); + const tokenHash = hashSessionToken(token); + + database + .prepare("INSERT INTO sessions (id, userId, tokenHash, createdAt, expiresAt) VALUES (?, ?, ?, ?, ?)") + .run(`session-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, userId, tokenHash, createdAt, expiresAt); + + return { token, expiresAt }; +} + +export function revokeSession(token: string) { + const database = getDb(); + const tokenHash = hashSessionToken(token); + database.prepare("DELETE FROM sessions WHERE tokenHash = ?").run(tokenHash); +} + +export function getUserBySessionToken(token: string): AuthUser | null { + const database = getDb(); + deleteExpiredSessions(database); + const tokenHash = hashSessionToken(token); + const now = new Date().toISOString(); + const row = database + .prepare(` + SELECT u.id, u.name, u.email, u.createdAt + FROM sessions s + JOIN users u ON u.id = s.userId + WHERE s.tokenHash = ? AND s.expiresAt > ? + LIMIT 1 + `) + .get(tokenHash, now) as AuthUser | undefined; + + return row ?? null; +} + +export async function setSessionCookie(token: string, rememberMe: boolean) { + const cookieStore = await cookies(); + const baseOptions = { + httpOnly: true, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + path: "/", + } as const; + + if (rememberMe) { + cookieStore.set(SESSION_COOKIE_NAME, token, { + ...baseOptions, + maxAge: SESSION_DAYS_REMEMBER * 24 * 60 * 60, + }); + return; + } + + // Session cookie (clears on browser close) + cookieStore.set(SESSION_COOKIE_NAME, token, baseOptions); +} + +export async function clearSessionCookie() { + const cookieStore = await cookies(); + cookieStore.set(SESSION_COOKIE_NAME, "", { + httpOnly: true, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + path: "/", + maxAge: 0, + }); +} + +export async function getSessionTokenFromCookies(): Promise { + const cookieStore = await cookies(); + return cookieStore.get(SESSION_COOKIE_NAME)?.value ?? null; +} + +export async function getAuthenticatedUser(): Promise { + const token = await getSessionTokenFromCookies(); + if (!token) return null; + return getUserBySessionToken(token); +} diff --git a/src/lib/server/taskDb.ts b/src/lib/server/taskDb.ts index 804962a..f860882 100644 --- a/src/lib/server/taskDb.ts +++ b/src/lib/server/taskDb.ts @@ -15,10 +15,17 @@ export interface TaskComment { id: string; text: string; createdAt: string; - author: "user" | "assistant"; + author: TaskCommentAuthor | "user" | "assistant"; replies?: TaskComment[]; } +export interface TaskCommentAuthor { + id: string; + name: string; + email?: string; + type: "human" | "assistant"; +} + export interface Task { id: string; title: string; @@ -30,6 +37,10 @@ export interface Task { sprintId?: string; createdAt: string; updatedAt: string; + createdById?: string; + createdByName?: string; + updatedById?: string; + updatedByName?: string; dueDate?: string; comments: TaskComment[]; tags: string[]; @@ -126,13 +137,43 @@ function normalizeComments(comments: unknown): TaskComment[] { id: value.id, text: value.text, createdAt: typeof value.createdAt === "string" ? value.createdAt : new Date().toISOString(), - author: value.author === "assistant" ? "assistant" : "user", + author: normalizeCommentAuthor(value.author), replies: normalizeComments(value.replies), }; }) .filter((comment): comment is TaskComment => comment !== null); } +function normalizeCommentAuthor(author: unknown): TaskCommentAuthor { + if (author === "assistant") { + return { id: "assistant", name: "Assistant", type: "assistant" }; + } + if (author === "user") { + return { id: "legacy-user", name: "User", type: "human" }; + } + + if (!author || typeof author !== "object") { + return { id: "legacy-user", name: "User", type: "human" }; + } + + const value = author as Partial; + const type: TaskCommentAuthor["type"] = + value.type === "assistant" || value.id === "assistant" ? "assistant" : "human"; + const id = typeof value.id === "string" && value.id.trim().length > 0 + ? value.id + : type === "assistant" + ? "assistant" + : "legacy-user"; + const name = typeof value.name === "string" && value.name.trim().length > 0 + ? value.name.trim() + : type === "assistant" + ? "Assistant" + : "User"; + const email = typeof value.email === "string" && value.email.trim().length > 0 ? value.email.trim() : undefined; + + return { id, name, email, type }; +} + function normalizeTask(task: Partial): Task { return { id: String(task.id ?? Date.now()), @@ -145,6 +186,10 @@ function normalizeTask(task: Partial): Task { sprintId: task.sprintId || undefined, createdAt: task.createdAt || new Date().toISOString(), updatedAt: task.updatedAt || new Date().toISOString(), + createdById: typeof task.createdById === "string" && task.createdById.trim().length > 0 ? task.createdById : undefined, + createdByName: typeof task.createdByName === "string" && task.createdByName.trim().length > 0 ? task.createdByName : undefined, + updatedById: typeof task.updatedById === "string" && task.updatedById.trim().length > 0 ? task.updatedById : undefined, + updatedByName: typeof task.updatedByName === "string" && task.updatedByName.trim().length > 0 ? task.updatedByName : undefined, dueDate: task.dueDate || undefined, comments: normalizeComments(task.comments), tags: Array.isArray(task.tags) ? task.tags.filter((tag): tag is string => typeof tag === "string") : [], @@ -183,8 +228,8 @@ function replaceAllData(database: SqliteDb, data: DataStore) { VALUES (@id, @name, @goal, @startDate, @endDate, @status, @projectId, @createdAt) `); const insertTask = database.prepare(` - INSERT INTO tasks (id, title, description, type, status, priority, projectId, sprintId, createdAt, updatedAt, dueDate, comments, tags, attachments) - VALUES (@id, @title, @description, @type, @status, @priority, @projectId, @sprintId, @createdAt, @updatedAt, @dueDate, @comments, @tags, @attachments) + INSERT INTO tasks (id, title, description, type, status, priority, projectId, sprintId, createdAt, updatedAt, createdById, createdByName, updatedById, updatedByName, dueDate, comments, tags, attachments) + VALUES (@id, @title, @description, @type, @status, @priority, @projectId, @sprintId, @createdAt, @updatedAt, @createdById, @createdByName, @updatedById, @updatedByName, @dueDate, @comments, @tags, @attachments) `); for (const project of payload.projects) { @@ -214,6 +259,10 @@ function replaceAllData(database: SqliteDb, data: DataStore) { insertTask.run({ ...task, sprintId: task.sprintId ?? null, + createdById: task.createdById ?? null, + createdByName: task.createdByName ?? null, + updatedById: task.updatedById ?? null, + updatedByName: task.updatedByName ?? null, dueDate: task.dueDate ?? null, comments: JSON.stringify(task.comments ?? []), tags: JSON.stringify(task.tags ?? []), @@ -280,6 +329,10 @@ function getDb(): SqliteDb { sprintId TEXT, createdAt TEXT NOT NULL, updatedAt TEXT NOT NULL, + createdById TEXT, + createdByName TEXT, + updatedById TEXT, + updatedByName TEXT, dueDate TEXT, comments TEXT NOT NULL DEFAULT '[]', tags TEXT NOT NULL DEFAULT '[]', @@ -296,6 +349,18 @@ function getDb(): SqliteDb { if (!taskColumns.some((column) => column.name === "attachments")) { database.exec("ALTER TABLE tasks ADD COLUMN attachments TEXT NOT NULL DEFAULT '[]';"); } + if (!taskColumns.some((column) => column.name === "createdById")) { + database.exec("ALTER TABLE tasks ADD COLUMN createdById TEXT;"); + } + if (!taskColumns.some((column) => column.name === "createdByName")) { + database.exec("ALTER TABLE tasks ADD COLUMN createdByName TEXT;"); + } + if (!taskColumns.some((column) => column.name === "updatedById")) { + database.exec("ALTER TABLE tasks ADD COLUMN updatedById TEXT;"); + } + if (!taskColumns.some((column) => column.name === "updatedByName")) { + database.exec("ALTER TABLE tasks ADD COLUMN updatedByName TEXT;"); + } seedIfEmpty(database); db = database; @@ -335,6 +400,10 @@ export function getData(): DataStore { sprintId: string | null; createdAt: string; updatedAt: string; + createdById: string | null; + createdByName: string | null; + updatedById: string | null; + updatedByName: string | null; dueDate: string | null; comments: string | null; tags: string | null; @@ -370,6 +439,10 @@ export function getData(): DataStore { sprintId: task.sprintId ?? undefined, createdAt: task.createdAt, updatedAt: task.updatedAt, + createdById: task.createdById ?? undefined, + createdByName: task.createdByName ?? undefined, + updatedById: task.updatedById ?? undefined, + updatedByName: task.updatedByName ?? undefined, dueDate: task.dueDate ?? undefined, comments: normalizeComments(safeParseArray(task.comments, [])), tags: safeParseArray(task.tags, []), diff --git a/src/stores/useTaskStore.ts b/src/stores/useTaskStore.ts index dd9c219..ffc0ce7 100644 --- a/src/stores/useTaskStore.ts +++ b/src/stores/useTaskStore.ts @@ -21,10 +21,23 @@ export interface Comment { id: string text: string createdAt: string - author: 'user' | 'assistant' + author: CommentAuthor | 'user' | 'assistant' replies?: Comment[] } +export interface CommentAuthor { + id: string + name: string + email?: string + type: 'human' | 'assistant' +} + +export interface UserProfile { + id: string + name: string + email?: string +} + export interface TaskAttachment { id: string name: string @@ -45,6 +58,10 @@ export interface Task { sprintId?: string createdAt: string updatedAt: string + createdById?: string + createdByName?: string + updatedById?: string + updatedByName?: string dueDate?: string comments: Comment[] tags: string[] @@ -66,12 +83,14 @@ interface TaskStore { selectedProjectId: string | null selectedTaskId: string | null selectedSprintId: string | null + currentUser: UserProfile isLoading: boolean lastSynced: number | null // Sync actions syncFromServer: () => Promise syncToServer: () => Promise + setCurrentUser: (user: Partial) => void // Project actions addProject: (name: string, description?: string) => void @@ -93,7 +112,7 @@ interface TaskStore { getTasksBySprint: (sprintId: string) => Task[] // Comment actions - addComment: (taskId: string, text: string, author: 'user' | 'assistant') => void + addComment: (taskId: string, text: string, author?: CommentAuthor | 'user' | 'assistant') => void deleteComment: (taskId: string, commentId: string) => void // Filters @@ -386,6 +405,72 @@ const defaultTasks: Task[] = [ } ] +const createLocalUserProfile = (): UserProfile => ({ + id: `user-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + name: 'Local User', +}) + +const defaultCurrentUser = createLocalUserProfile() + +const normalizeUserProfile = (value: unknown, fallback: UserProfile = defaultCurrentUser): UserProfile => { + if (!value || typeof value !== 'object') return fallback + const candidate = value as Partial + const id = typeof candidate.id === 'string' && candidate.id.trim().length > 0 ? candidate.id : fallback.id + const name = typeof candidate.name === 'string' && candidate.name.trim().length > 0 ? candidate.name.trim() : fallback.name + const email = typeof candidate.email === 'string' && candidate.email.trim().length > 0 ? candidate.email.trim() : undefined + return { id, name, email } +} + +const profileToCommentAuthor = (profile: UserProfile): CommentAuthor => ({ + id: profile.id, + name: profile.name, + email: profile.email, + type: 'human', +}) + +const assistantAuthor: CommentAuthor = { + id: 'assistant', + name: 'Assistant', + type: 'assistant', +} + +const normalizeCommentAuthor = (value: unknown): CommentAuthor => { + if (value === 'assistant') return assistantAuthor + if (value === 'user') { + return { + id: 'legacy-user', + name: 'User', + type: 'human', + } + } + + if (!value || typeof value !== 'object') { + return { + id: 'legacy-user', + name: 'User', + type: 'human', + } + } + + const candidate = value as Partial + const type: CommentAuthor['type'] = + candidate.type === 'assistant' || candidate.id === 'assistant' ? 'assistant' : 'human' + + const id = typeof candidate.id === 'string' && candidate.id.trim().length > 0 + ? candidate.id + : type === 'assistant' + ? 'assistant' + : 'legacy-user' + const name = typeof candidate.name === 'string' && candidate.name.trim().length > 0 + ? candidate.name.trim() + : type === 'assistant' + ? 'Assistant' + : 'User' + const email = typeof candidate.email === 'string' && candidate.email.trim().length > 0 ? candidate.email.trim() : undefined + + return { id, name, email, type } +} + const normalizeComments = (value: unknown): Comment[] => { if (!Array.isArray(value)) return [] @@ -395,12 +480,11 @@ const normalizeComments = (value: unknown): Comment[] => { const candidate = entry as Partial if (typeof candidate.id !== 'string' || typeof candidate.text !== 'string') return null - const author = candidate.author === 'assistant' ? 'assistant' : 'user' return { id: candidate.id, text: candidate.text, createdAt: typeof candidate.createdAt === 'string' ? candidate.createdAt : new Date().toISOString(), - author, + author: normalizeCommentAuthor(candidate.author), replies: normalizeComments(candidate.replies), } }) @@ -435,6 +519,10 @@ const normalizeTask = (task: Task): Task => ({ comments: normalizeComments(task.comments), tags: Array.isArray(task.tags) ? task.tags.filter((tag): tag is string => typeof tag === 'string' && tag.trim().length > 0) : [], attachments: normalizeAttachments(task.attachments), + createdById: typeof task.createdById === 'string' && task.createdById.trim().length > 0 ? task.createdById : undefined, + createdByName: typeof task.createdByName === 'string' && task.createdByName.trim().length > 0 ? task.createdByName : undefined, + updatedById: typeof task.updatedById === 'string' && task.updatedById.trim().length > 0 ? task.updatedById : undefined, + updatedByName: typeof task.updatedByName === 'string' && task.updatedByName.trim().length > 0 ? task.updatedByName : undefined, }) const removeCommentFromThread = (comments: Comment[], targetId: string): Comment[] => @@ -478,6 +566,7 @@ export const useTaskStore = create()( selectedProjectId: '1', selectedTaskId: null, selectedSprintId: null, + currentUser: defaultCurrentUser, isLoading: false, lastSynced: null, @@ -523,6 +612,10 @@ export const useTaskStore = create()( set({ lastSynced: Date.now() }) }, + setCurrentUser: (user) => { + set((state) => ({ currentUser: normalizeUserProfile(user, state.currentUser) })) + }, + addProject: (name, description) => { const colors = ['#8b5cf6', '#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#ec4899', '#06b6d4'] const newProject: Project = { @@ -566,11 +659,16 @@ export const useTaskStore = create()( selectProject: (id) => set({ selectedProjectId: id, selectedTaskId: null, selectedSprintId: null }), addTask: (task) => { + const actor = profileToCommentAuthor(get().currentUser) const newTask: Task = { ...task, id: Date.now().toString(), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), + createdById: actor.id, + createdByName: actor.name, + updatedById: actor.id, + updatedByName: actor.name, comments: normalizeComments([]), attachments: normalizeAttachments(task.attachments), } @@ -584,6 +682,7 @@ export const useTaskStore = create()( updateTask: (id, updates) => { console.log('updateTask called:', id, updates) set((state) => { + const actor = profileToCommentAuthor(state.currentUser) const newTasks = state.tasks.map((t) => t.id === id ? normalizeTask({ @@ -592,6 +691,8 @@ export const useTaskStore = create()( comments: updates.comments !== undefined ? normalizeComments(updates.comments) : t.comments, attachments: updates.attachments !== undefined ? normalizeAttachments(updates.attachments) : t.attachments, updatedAt: new Date().toISOString(), + updatedById: actor.id, + updatedByName: actor.name, } as Task) : t ) @@ -661,17 +762,25 @@ export const useTaskStore = create()( }, addComment: (taskId, text, author) => { + const actor = normalizeCommentAuthor(author ?? profileToCommentAuthor(get().currentUser)) const newComment: Comment = { id: Date.now().toString(), text, createdAt: new Date().toISOString(), - author, + author: actor, replies: [], } set((state) => { + const updater = profileToCommentAuthor(state.currentUser) const newTasks = state.tasks.map((t) => t.id === taskId - ? { ...t, comments: [...normalizeComments(t.comments), newComment], updatedAt: new Date().toISOString() } + ? { + ...t, + comments: [...normalizeComments(t.comments), newComment], + updatedAt: new Date().toISOString(), + updatedById: updater.id, + updatedByName: updater.name, + } : t ) syncToServer(state.projects, newTasks, state.sprints) @@ -681,9 +790,16 @@ export const useTaskStore = create()( deleteComment: (taskId, commentId) => { set((state) => { + const updater = profileToCommentAuthor(state.currentUser) const newTasks = state.tasks.map((t) => t.id === taskId - ? { ...t, comments: removeCommentFromThread(normalizeComments(t.comments), commentId), updatedAt: new Date().toISOString() } + ? { + ...t, + comments: removeCommentFromThread(normalizeComments(t.comments), commentId), + updatedAt: new Date().toISOString(), + updatedById: updater.id, + updatedByName: updater.name, + } : t ) syncToServer(state.projects, newTasks, state.sprints) @@ -706,7 +822,8 @@ export const useTaskStore = create()( { name: 'task-store', partialize: (state) => ({ - // Only persist UI state, not data + // Persist user identity and UI state, not task data + currentUser: state.currentUser, selectedProjectId: state.selectedProjectId, selectedTaskId: state.selectedTaskId, selectedSprintId: state.selectedSprintId, @@ -718,6 +835,7 @@ export const useTaskStore = create()( console.log('>>> PERSIST: Rehydration error:', error) } else { console.log('>>> PERSIST: Rehydrated state:', { + currentUser: state?.currentUser?.name, selectedProjectId: state?.selectedProjectId, selectedTaskId: state?.selectedTaskId, tasksCount: state?.tasks?.length,